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;