From cfddf097c79e4661efe576dedf946dd3a99851fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 18:47:05 +0200 Subject: [PATCH] fix(documents): file an undated document under the reader's upload day The archive grouped a document without a stated date by the UTC day of its upload, so an upload late on the last evening of a month west of UTC opened next month's section (and early on the first morning east of UTC, last month's). The upload day is now read in the profile time zone for the month sections and the year filter. --- .../__tests__/document-timeline.test.tsx | 1 + .../documents/__tests__/vault-utils.test.ts | 29 ++++++++++++++++--- .../documents/document-timeline.tsx | 7 +++-- src/components/documents/documents-view.tsx | 7 +++-- src/components/documents/vault-utils.ts | 22 ++++++++++---- 5 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/components/documents/__tests__/document-timeline.test.tsx b/src/components/documents/__tests__/document-timeline.test.tsx index 3c080f3e6..ca520bbad 100644 --- a/src/components/documents/__tests__/document-timeline.test.tsx +++ b/src/components/documents/__tests__/document-timeline.test.tsx @@ -16,6 +16,7 @@ function render(uploadItems: UploadQueueItem[]) { return renderToStaticMarkup( {}} diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index ace2e807e..fff5fc7bf 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -133,7 +133,7 @@ describe("timeline items", () => { doc({ id: "c", documentDate: "2026-03-10" }), doc({ id: "d", documentDate: "2026-02-28" }), ]; - const items = buildTimelineItems(docs, 2); + const items = buildTimelineItems(docs, 2, "UTC"); expect(items.map((i) => i.type)).toEqual([ "month", "row", @@ -149,16 +149,37 @@ describe("timeline items", () => { it("files an undated document under its upload day", () => { const d = doc({ documentDate: null, createdAt: "2026-01-05T10:00:00Z" }); - expect(documentDateKey(d)).toBe("2026-01-05"); - const items = buildTimelineItems([d], 3); + expect(documentDateKey(d, "UTC")).toBe("2026-01-05"); + const items = buildTimelineItems([d], 3, "UTC"); expect(items[0]).toEqual({ type: "month", key: "2026-01" }); }); + it("files an undated upload under the reader's day, not the UTC day", () => { + // 22:30 on 31 January in Los Angeles is already 1 February in UTC. + const d = doc({ documentDate: null, createdAt: "2026-02-01T06:30:00Z" }); + expect(documentDateKey(d, "America/Los_Angeles")).toBe("2026-01-31"); + expect(buildTimelineItems([d], 3, "America/Los_Angeles")[0]).toEqual({ + type: "month", + key: "2026-01", + }); + // And 1 March at 00:30 in Kiritimati is still 28 February in UTC. + const e = doc({ documentDate: null, createdAt: "2026-02-28T10:30:00Z" }); + expect(documentDateKey(e, "Pacific/Kiritimati")).toBe("2026-03-01"); + }); + + it("keeps a stated document date whatever the zone", () => { + const d = doc({ + documentDate: "2026-01-31", + createdAt: "2026-02-01T06:30:00Z", + }); + expect(documentDateKey(d, "Pacific/Kiritimati")).toBe("2026-01-31"); + }); + it("keeps the DOM bounded: item count scales with rows, not documents", () => { const docs = Array.from({ length: 1000 }, (_, i) => doc({ id: `d${i}`, documentDate: "2026-03-01" }), ); - const items = buildTimelineItems(docs, 4); + const items = buildTimelineItems(docs, 4, "UTC"); // 1 month header + 250 rows — the virtualizer then windows over these. expect(items).toHaveLength(251); }); diff --git a/src/components/documents/document-timeline.tsx b/src/components/documents/document-timeline.tsx index 7afb9c70c..25faca739 100644 --- a/src/components/documents/document-timeline.tsx +++ b/src/components/documents/document-timeline.tsx @@ -55,7 +55,10 @@ export function DocumentTimeline({ onDelete, highlightId, onPrefetch, + timezone, }: { + /** The reader's profile zone; an undated document files under its upload day there. */ + timezone: string; documents: InboundDocumentDto[]; uploadItems: UploadQueueItem[]; onDismissUpload: (localId: string) => void; @@ -93,8 +96,8 @@ export function DocumentTimeline({ }, []); const items = useMemo( - () => buildTimelineItems(documents, columns), - [documents, columns], + () => buildTimelineItems(documents, columns, timezone), + [documents, columns, timezone], ); // The timeline does not start at the scrollport's top edge (page header, diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index c68bb8f5f..f35c9c7c3 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -39,6 +39,7 @@ import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; @@ -239,6 +240,7 @@ export function DocumentsView() { const { t } = useTranslations(); const format = useFormatters(); const { user, isLoading: authLoading, isAuthenticated } = useAuth(); + const timezone = user?.timezone || DEFAULT_TIMEZONE; // v1.36.x — uploading a document, filing it, sharing it and asking the AI // about it are none of them delegated verbs. Inside somebody else's record // the vault reads and nothing more: no upload path, no selection, no bulk @@ -506,11 +508,11 @@ export function DocumentsView() { const years = useMemo(() => { const set = new Set(); for (const doc of documents) { - set.add(Number(documentDateKey(doc).slice(0, 4))); + set.add(Number(documentDateKey(doc, timezone).slice(0, 4))); } if (filters.year !== undefined) set.add(filters.year); return [...set].sort((a, b) => b - a); - }, [documents, filters.year]); + }, [documents, filters.year, timezone]); // ── Selection + bulk actions ────────────────────────────────────────── const [selectedIds, setSelectedIds] = useState>( @@ -974,6 +976,7 @@ export function DocumentsView() { /> ) : ( (INBOUND_DOCUMENT_KINDS); @@ -114,15 +115,23 @@ export function buildVaultListApiSearch( } /** The date a document files under — its user filing date, else upload day. */ -export function documentDateKey(doc: InboundDocumentDto): string { +export function documentDateKey( + doc: InboundDocumentDto, + timezone: string, +): string { if (doc.documentDate) return doc.documentDate; - // createdAt is an ISO timestamp; the leading YYYY-MM-DD is the upload day. - return doc.createdAt.slice(0, 10); + // An undated document files under its upload day in the reader's zone: the + // leading YYYY-MM-DD of `createdAt` is the UTC day, which puts an upload + // late on the last evening of a month west of UTC into the next month. + return userDayKey(new Date(doc.createdAt), timezone); } /** The YYYY-MM month bucket a document files under. */ -export function documentMonthKey(doc: InboundDocumentDto): string { - return documentDateKey(doc).slice(0, 7); +export function documentMonthKey( + doc: InboundDocumentDto, + timezone: string, +): string { + return documentDateKey(doc, timezone).slice(0, 7); } /** @@ -186,6 +195,7 @@ export type TimelineItem = export function buildTimelineItems( documents: InboundDocumentDto[], columns: number, + timezone: string, ): TimelineItem[] { const cols = Math.max(1, columns); const items: TimelineItem[] = []; @@ -200,7 +210,7 @@ export function buildTimelineItems( }; for (const doc of documents) { - const key = documentMonthKey(doc); + const key = documentMonthKey(doc, timezone); if (key !== month) { flushRow(); month = key;