diff --git a/src/library/read.ts b/src/library/read.ts index 3af8069..3c756b4 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -151,8 +151,8 @@ export interface PhotoFilter { } export interface TimelineGroup { - // The period's identity: `YYYY-MM-DD` for day and week (the Monday), and - // `YYYY-MM` for month. + // The period's identity: `YYYY-MM-DD` for day, `YYYY-Www` (ISO 8601 week, + // e.g. `2025-W32`) for week, and `YYYY-MM` for month. key: string; // Local-time milliseconds at the start of the period. startsAt: number; @@ -292,6 +292,31 @@ const pad = (n: number): string => String(n).padStart(2, "0"); const dateKey = (d: Date): string => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +// The ISO 8601 week key `YYYY-Www` for the week starting at the given Monday. +// The week-year is the year of that week's Thursday, so it can differ from the +// calendar year at the January/December boundary (e.g. 2024-12-30 is 2025-W01). +const isoWeekKey = (monday: Date): string => { + const thursday = new Date( + monday.getFullYear(), + monday.getMonth(), + monday.getDate() + 3, + ); + const isoYear = thursday.getFullYear(); + // Thursday of ISO week 1 is the Thursday of the week containing January 4. + const jan4 = new Date(isoYear, 0, 4); + const week1Thursday = new Date( + isoYear, + 0, + 4 + 3 - ((jan4.getDay() + 6) % 7), + ); + const week = + 1 + + Math.round((thursday.getTime() - week1Thursday.getTime()) / WEEK_MS); + return `${isoYear}-W${pad(week)}`; +}; + // The period a millisecond instant falls in, in local time. Weeks start on // Monday. `Date` normalizes out-of-range day arguments, so the week's Monday // is correct across month and year boundaries. @@ -312,7 +337,7 @@ const periodOf = ( // getDay(): 0=Sunday..6=Saturday; shift so Monday is the week start. const fromMonday = (d.getDay() + 6) % 7; const start = new Date(year, month, day - fromMonday); - return { key: dateKey(start), startsAt: start.getTime() }; + return { key: isoWeekKey(start), startsAt: start.getTime() }; } const start = new Date(year, month, day); return { key: dateKey(start), startsAt: start.getTime() }; diff --git a/test/library/read.test.ts b/test/library/read.test.ts index d8ad24f..102bdee 100644 --- a/test/library/read.test.ts +++ b/test/library/read.test.ts @@ -287,8 +287,9 @@ describe("lib.timeline grouping", () => { ], ); const groups = apis(records).timeline.groups({ groupBy: "week" }); - expect(groups.map((g) => g.key)).toEqual(["2024-01-22", "2024-01-15"]); - const first = groups.find((g) => g.key === "2024-01-15")!; + // ISO week keys: 2024-01-15 is in 2024-W03, the next Monday in 2024-W04. + expect(groups.map((g) => g.key)).toEqual(["2024-W04", "2024-W03"]); + const first = groups.find((g) => g.key === "2024-W03")!; expect(first.startsAt).toBe(Date.UTC(2024, 0, 15)); // The Sunday belongs to the Monday-started week, not the next one. expect(first.fileIDs.sort((a, b) => a - b)).toEqual([1, 2, 3]); @@ -301,7 +302,7 @@ describe("lib.timeline grouping", () => { [file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 14, 12)) })], ); const groups = apis(records).timeline.groups({ groupBy: "week" }); - expect(groups.map((g) => g.key)).toEqual(["2024-01-08"]); + expect(groups.map((g) => g.key)).toEqual(["2024-W02"]); expect(groups[0]!.startsAt).toBe(Date.UTC(2024, 0, 8)); });