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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 18:47:05 +02:00
1 parent 9ecdda6358
commit cfddf097c7
5 files changed
+52 -14

No files matched your search

@@ -16,6 +16,7 @@ function render(uploadItems: UploadQueueItem[]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentTimeline
timezone="UTC"
documents={[]}
uploadItems={uploadItems}
onDismissUpload={() => {}}
@@ -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);
});
@@ -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,
+5 -2
View File
@@ -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<number>();
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<ReadonlySet<string>>(
@@ -974,6 +976,7 @@ export function DocumentsView() {
/>
) : (
<DocumentTimeline
timezone={timezone}
documents={documents}
uploadItems={upload.items}
onDismissUpload={upload.dismiss}
+16 -6
View File
@@ -14,6 +14,7 @@ import {
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
import { userDayKey } from "@/lib/tz/format";
const KIND_SET = new Set<string>(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;