fix(imports): keep date-only values on their calendar day

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Sonnet 5.5 committed 2026-10-05 13:51:49 -04:00
1 parent 1ccc138144
commit edd6eb541d
4 files changed
+158 -26

No files matched your search

+46 -24
View File
@@ -29,21 +29,53 @@ const log = createLogger("imports");
/** Two plays of the same item this close together are treated as the same play
* (e.g. a Plex webhook watch and the same play scrobbled to Trakt). */
const TIMESTAMP_DEDUPE_WINDOW_MS = 3 * 60 * 60 * 1000;
/** Date-only entries (Letterboxd diary) match any watch within ±36h of UTC
* midnight on that date, which covers every timezone. */
const DATE_ONLY_DEDUPE_WINDOW_MS = 36 * 60 * 60 * 1000;
/** Date-only values are stored at server-local noon so stats (which bucket in server-local
* time) put them on their calendar day. */
function dateOnlyToLocalNoon(day: string): Date {
return new Date(`${day}T12:00:00`);
}
interface ImportedWatchTime {
at: Date;
/** Inclusive [from, to] ranges; an existing watch in any of them is the same play. */
ranges: Array<[Date, Date]>;
}
function importedWatchTime(item: {
watchedAt?: string;
watchedOn?: string;
}): { at: Date; windowMs: number } | null {
const candidate = item.watchedAt
? { at: new Date(item.watchedAt), windowMs: TIMESTAMP_DEDUPE_WINDOW_MS }
: item.watchedOn
? { at: new Date(item.watchedOn), windowMs: DATE_ONLY_DEDUPE_WINDOW_MS }
: null;
if (!candidate || Number.isNaN(candidate.at.getTime())) return null;
return candidate;
}): ImportedWatchTime | null {
if (item.watchedAt) {
const at = new Date(item.watchedAt);
if (Number.isNaN(at.getTime())) return null;
return {
at,
ranges: [
[
new Date(at.getTime() - TIMESTAMP_DEDUPE_WINDOW_MS),
new Date(at.getTime() + TIMESTAMP_DEDUPE_WINDOW_MS),
],
],
};
}
if (item.watchedOn) {
const start = new Date(`${item.watchedOn}T00:00:00`); // server-local midnight
if (Number.isNaN(start.getTime())) return null;
const end = new Date(start);
end.setDate(end.getDate() + 1);
end.setMilliseconds(end.getMilliseconds() - 1);
// Imports before this fix stored date-only watches at UTC midnight; match those exactly.
const legacy = new Date(`${item.watchedOn}T00:00:00Z`);
return {
at: dateOnlyToLocalNoon(item.watchedOn),
ranges: [
[start, end],
[legacy, legacy],
],
};
}
return null;
}
function safeParseJsonArray(value: string | null): string[] {
@@ -107,12 +139,7 @@ async function processMovie(
const time = importedWatchTime(movie);
const isDuplicate = time
? hasMovieWatchBetween(
userId,
title.id,
new Date(time.at.getTime() - time.windowMs),
new Date(time.at.getTime() + time.windowMs),
)
? time.ranges.some(([from, to]) => hasMovieWatchBetween(userId, title.id, from, to))
: hasMovieWatch(userId, title.id); // undated: any existing watch counts
if (isDuplicate) {
result.skipped++;
@@ -174,12 +201,7 @@ async function processEpisode(
const time = importedWatchTime(ep);
const isDuplicate = time
? hasEpisodeWatchBetween(
userId,
episode.id,
new Date(time.at.getTime() - time.windowMs),
new Date(time.at.getTime() + time.windowMs),
)
? time.ranges.some(([from, to]) => hasEpisodeWatchBetween(userId, episode.id, from, to))
: hasEpisodeWatch(userId, episode.id); // undated: any existing watch counts
if (isDuplicate) {
result.skipped++;
@@ -290,7 +312,7 @@ async function processRating(
const ratedAt = item.ratedAt
? new Date(item.ratedAt)
: item.ratedOn
? new Date(item.ratedOn)
? dateOnlyToLocalNoon(item.ratedOn)
: undefined;
rateTitleStars(userId, title.id, item.rating, ratedAt);
result.imported++;
+2 -2
View File
@@ -91,7 +91,7 @@ export async function resolveMovieTmdbId(
const results = searchResult.results ?? [];
if (ids.year) {
const match = results.find((r) => {
const releaseYear = r.release_date ? new Date(r.release_date).getFullYear() : null;
const releaseYear = r.release_date ? Number.parseInt(r.release_date.slice(0, 4), 10) : null;
return releaseYear === ids.year;
});
if (match) {
@@ -162,7 +162,7 @@ export async function resolveShowTmdbId(
const results = searchResult.results ?? [];
if (ids.year) {
const match = results.find((r) => {
const airYear = r.first_air_date ? new Date(r.first_air_date).getFullYear() : null;
const airYear = r.first_air_date ? Number.parseInt(r.first_air_date.slice(0, 4), 10) : null;
return airYear === ids.year;
});
if (match) {
@@ -716,6 +716,84 @@ describe("processImportJob — rewatches and added dates", () => {
expect(movieWatches(userId)).toHaveLength(2);
});
describe("date-only watches in a west-of-UTC server time zone", () => {
async function withLosAngeles(fn: () => Promise<void>) {
const tz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
try {
await fn();
} finally {
if (tz === undefined) delete process.env.TZ;
else process.env.TZ = tz;
}
}
const diary = (...days: string[]): NormalizedImport => ({
...emptyPayload("letterboxd"),
movies: days.map((watchedOn) => ({ tmdbId: 550, title: "Fight Club", watchedOn })),
});
test("consecutive-day diary entries are both kept", () =>
withLosAngeles(async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
const jobId = createJob(userId, diary("2024-01-15", "2024-01-16"));
await processImportJob(jobId);
expect(readImportJob(jobId).importedCount).toBe(2);
expect(movieWatches(userId)).toHaveLength(2);
}));
test("stores the watch on its calendar day", () =>
withLosAngeles(async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
await processImportJob(createJob(userId, diary("2024-01-15")));
const [watch] = movieWatches(userId);
const at = watch?.watchedAt as Date;
expect(at.getFullYear()).toBe(2024);
expect(at.getMonth()).toBe(0);
expect(at.getDate()).toBe(15);
}));
test("re-importing the same diary is idempotent", () =>
withLosAngeles(async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
await processImportJob(createJob(userId, diary("2024-01-15")));
const secondId = createJob(userId, diary("2024-01-15"));
await processImportJob(secondId);
expect(movieWatches(userId)).toHaveLength(1);
expect(readImportJob(secondId).skippedCount).toBe(1);
}));
test("legacy UTC-midnight rows still dedupe", () =>
withLosAngeles(async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
insertMovieWatch(userId, "movie-1", new Date("2024-01-15T00:00:00Z"));
const jobId = createJob(userId, diary("2024-01-15"));
await processImportJob(jobId);
expect(readImportJob(jobId).skippedCount).toBe(1);
expect(movieWatches(userId)).toHaveLength(1);
}));
test("a same-day evening play dedupes the diary entry", () =>
withLosAngeles(async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
insertMovieWatch(userId, "movie-1", new Date("2024-01-15T21:30:00"));
const jobId = createJob(userId, diary("2024-01-15"));
await processImportJob(jobId);
expect(readImportJob(jobId).skippedCount).toBe(1);
expect(movieWatches(userId)).toHaveLength(1);
}));
});
test("library item backdates addedAt after a watch created the row", async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
+32
View File
@@ -106,6 +106,22 @@ describe("resolveMovieTmdbId", () => {
expect(result).toBe(200);
});
test("matches a Jan 1 release year west of UTC", async () => {
const tz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
try {
searchMoviesSpy = vi.spyOn(tmdbClient, "searchMovies").mockResolvedValue({
results: [{ id: 42, release_date: "2010-01-01" }],
} as never);
const result = await resolveMovieTmdbId({ title: "X", year: 2010 });
expect(result).toBe(42);
} finally {
if (tz === undefined) delete process.env.TZ;
else process.env.TZ = tz;
}
});
test("title search without year match returns null", async () => {
searchMoviesSpy = vi.spyOn(tmdbClient, "searchMovies").mockResolvedValue({
results: [
@@ -254,6 +270,22 @@ describe("resolveShowTmdbId", () => {
expect(result).toBe(850);
});
test("matches a Jan 1 first-air year west of UTC", async () => {
const tz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
try {
searchTvSpy = vi.spyOn(tmdbClient, "searchTv").mockResolvedValue({
results: [{ id: 42, first_air_date: "2010-01-01" }],
} as never);
const result = await resolveShowTmdbId({ title: "X", year: 2010 });
expect(result).toBe(42);
} finally {
if (tz === undefined) delete process.env.TZ;
else process.env.TZ = tz;
}
});
test("falls back to title search", async () => {
searchTvSpy = vi.spyOn(tmdbClient, "searchTv").mockResolvedValue({
results: [{ id: 900, name: "The Office" }],