From 49c08328dcad2ea2349b6d4deb7c1b4e0bc40aac Mon Sep 17 00:00:00 2001 From: Sky-walkerX Date: Sat, 3 Oct 2026 01:26:30 +0530 Subject: [PATCH] fix: render dates in the user's time zone on the server too Since pages arrive with their data, the server renders dates and "today", and it runs in UTC. For anyone elsewhere the HTML disagreed with the browser ("2 Oct" against "3 Oct", no due count), and React threw the server's HTML away. The notebook layout now works out the user's zone (the lk-tz cookie, UTC without one) and the time once, and hands both to the browser through a clock (app/components/clock.tsx). Every component that renders a date or works out today reads that clock, through date-fns's in option with @date-fns/tz, so server and browser agree; the prefetch keys today the same way. If the browser is in a different zone than the cookie said, it saves its own and renders again. Relative times ("4 minutes ago") use the server's now until hydration for the same reason. Shared pages get the same clock. Sorting lists give dnd-kit a stable id, since its global counter differs between server and browser. --- app/(app)/inbox/inbox-view.tsx | 4 +- app/(app)/layout.tsx | 20 +++-- app/(public)/share/[token]/page.tsx | 11 ++- app/components/analytics-page.tsx | 5 +- app/components/clock.tsx | 86 +++++++++++++++++++ app/components/home/contents.tsx | 3 +- app/components/home/notebook-home.tsx | 4 +- app/components/home/today-strip.tsx | 6 +- app/components/note/note-fullscreen.tsx | 3 +- app/components/note/note-list.tsx | 3 +- app/components/note/note-view.tsx | 3 +- app/components/pace/pace.tsx | 9 +- app/components/review/revision.tsx | 12 +-- app/components/settings/agent-connections.tsx | 4 +- app/components/share/shared-node.tsx | 4 +- app/components/subject/sortable-list.tsx | 6 +- app/components/subject/subject-header.tsx | 21 +++-- app/components/subject/task-row.tsx | 10 ++- hooks/use-activity-heatmap.ts | 10 ++- hooks/use-analytics.ts | 20 +++-- hooks/useReviews.ts | 14 +-- hooks/useTasks.ts | 17 ++-- lib/dates.ts | 11 ++- lib/prefetch.ts | 28 +++--- lib/zone.ts | 5 ++ package.json | 1 + pnpm-lock.yaml | 8 ++ 27 files changed, 240 insertions(+), 88 deletions(-) create mode 100644 app/components/clock.tsx diff --git a/app/(app)/inbox/inbox-view.tsx b/app/(app)/inbox/inbox-view.tsx index 2ac4097..7ce25c1 100644 --- a/app/(app)/inbox/inbox-view.tsx +++ b/app/(app)/inbox/inbox-view.tsx @@ -6,6 +6,7 @@ import { useSession } from "next-auth/react"; import { format } from "date-fns"; import { useInbox } from "@/hooks/useInbox"; import { useSubject } from "@/hooks/useSubjects"; +import { useClock } from "@/app/components/clock"; import { RuledBoxes } from "@/app/components/notebook/ruled-boxes"; import { NoteList } from "@/app/components/note/note-list"; import { NoteView } from "@/app/components/note/note-view"; @@ -30,6 +31,7 @@ function Inbox() { const { data: inbox, isError: inboxError } = useInbox(status === "authenticated"); const { data: subject, isLoading } = useSubject(inbox?.id); const [justCreated, setJustCreated] = useState(null); + const clock = useClock(); const noteParam = searchParams.get("note"); @@ -77,7 +79,7 @@ function Inbox() { { label: "Tray", value: "Inbox", grow: 2 }, { label: "Unfiled", value: notes.length }, { label: "To witness", value: inbox.awaiting }, - { label: "Date", value: format(new Date(), "d MMM yyyy"), grow: 1.3 }, + { label: "Date", value: format(clock.now, "d MMM yyyy", { in: clock.in }), grow: 1.3 }, ]} />
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 8c274f9..b39d2b9 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,23 +1,27 @@ import { HydrationBoundary } from "@tanstack/react-query"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/authOptions"; -import { prefetch, spinePrefetches } from "@/lib/prefetch"; +import { prefetch, spinePrefetches, viewerClock } from "@/lib/prefetch"; import { Providers } from "@/app/components/QueryProviders"; +import { ClockProvider } from "@/app/components/clock"; import { AppShell } from "@/app/components/shell/app-shell"; // The notebook: the spine, Ask, the palette and quick add around every page. export default async function NotebookLayout({ children }: { children: React.ReactNode }) { // A JWT decode, no database. The client starts with the session settled, so // the frame draws once instead of waiting on /api/auth/session. - const session = await getServerSession(authOptions); + const [session, clock] = await Promise.all([getServerSession(authOptions), viewerClock()]); // The spine's data comes with the first response. Client navigations keep // this layout, so it's fetched once per load, not per page. - const state = session?.user?.id ? await prefetch(await spinePrefetches(session.user.id)) : undefined; + const userId = session?.user?.id; + const state = userId ? await prefetch(spinePrefetches(userId, clock.zone, clock.now)) : undefined; return ( - - - {children} - - + + + + {children} + + + ); } diff --git a/app/(public)/share/[token]/page.tsx b/app/(public)/share/[token]/page.tsx index 7651985..0530928 100644 --- a/app/(public)/share/[token]/page.tsx +++ b/app/(public)/share/[token]/page.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { loadShare } from "@/lib/share/load"; import { SharedView } from "@/app/components/share/shared-view"; +import { ClockProvider } from "@/app/components/clock"; +import { viewerClock } from "@/lib/prefetch"; // Rendered per request: a revoked link has to stop working the moment it is // revoked, which a statically cached page could not honour. @@ -38,5 +40,12 @@ export default async function SharePage({ params }: Props) { // read the status line, and `not-found.tsx` still renders the friendly page. if (!payload) notFound(); - return ; + // Due dates read in the viewer's zone when they've been here before (the + // lk-tz cookie), otherwise UTC until the browser sets it. + const clock = await viewerClock(); + return ( + + + + ); } diff --git a/app/components/analytics-page.tsx b/app/components/analytics-page.tsx index ff730d9..4780695 100644 --- a/app/components/analytics-page.tsx +++ b/app/components/analytics-page.tsx @@ -4,6 +4,8 @@ import { useSession } from "next-auth/react"; import Link from "next/link"; import { useAnalytics } from "@/hooks/use-analytics"; import { useSubjects } from "@/hooks/useSubjects"; +import { getDay } from "date-fns"; +import { useClock } from "@/app/components/clock"; import { RuledBoxes } from "./notebook/ruled-boxes"; import { ActivityHeatmap } from "./analytics/heatmap"; import { Skeleton } from "@/app/components/ui/skeleton"; @@ -24,6 +26,7 @@ export default function AnalyticsPage() { const { status } = useSession({ required: true }); const { data: stats, isLoading } = useAnalytics(); const { data: subjects = [] } = useSubjects(); + const clock = useClock(); if (status === "loading" || isLoading || !stats) { return ( @@ -36,7 +39,7 @@ export default function AnalyticsPage() { const weekMax = Math.max(...stats.weeklyProgress, 1); const weekTotal = stats.weeklyProgress.reduce((a, b) => a + b, 0); - const todayIdx = new Date().getDay(); + const todayIdx = getDay(clock.now, { in: clock.in }); return (
diff --git a/app/components/clock.tsx b/app/components/clock.tsx new file mode 100644 index 0000000..df698a8 --- /dev/null +++ b/app/components/clock.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useMemo, useSyncExternalStore } from "react"; +import { useRouter } from "next/navigation"; +import { tz, type TZDate } from "@date-fns/tz"; +import { ZONE_COOKIE } from "@/lib/zone"; +import { ago } from "@/lib/dates"; + +// The user's time zone and the current time, for anything that renders a date +// or works out "today". The server renders notebook pages in UTC, so local +// time there is wrong: components read both from here instead. The zone comes +// from the lk-tz cookie (lib/zone.ts) and `now` from the server's render, so +// the server's HTML and the browser's first render agree exactly; after +// hydration `now` follows the real clock. + +export type Clock = { + zone: string; + now: number; + /** For date-fns: format(date, "d MMM", { in: clock.in }) works in the user's zone. */ + in: (value: Date | number | string) => TZDate; +}; + +const browserZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + +const ClockContext = createContext(null); + +// One shared ticking value, read with useSyncExternalStore: during hydration +// React uses the server's `now`, then this one. +let current = Date.now(); +const listeners = new Set<() => void>(); +let timer: ReturnType | undefined; +function subscribe(onChange: () => void) { + listeners.add(onChange); + current = Date.now(); + timer ??= setInterval(() => { + current = Date.now(); + listeners.forEach((l) => l()); + }, 30_000); + return () => { + listeners.delete(onChange); + if (listeners.size === 0) { + clearInterval(timer); + timer = undefined; + } + }; +} + +export function ClockProvider({ zone, now: serverNow, children }: { zone: string; now: number; children: React.ReactNode }) { + const now = useSyncExternalStore(subscribe, () => current, () => serverNow); + const router = useRouter(); + + // The server only knows the zone the cookie names (UTC without one). If the + // browser is somewhere else, save its zone and render again with it. + useEffect(() => { + const actual = browserZone(); + if (actual === zone) return; + document.cookie = `${ZONE_COOKIE}=${encodeURIComponent(actual)}; path=/; max-age=31536000; samesite=lax`; + router.refresh(); + }, [zone, router]); + + const clock = useMemo(() => ({ zone, now, in: tz(zone) }), [zone, now]); + return {children}; +} + +const noSubscription = () => () => {}; + +/** + * The clock from the nearest ClockProvider. Outside one (panels that only + * render in the browser) it's the browser's own zone and the shared clock's + * last tick. + */ +export function useClock(): Clock { + const clock = useContext(ClockContext); + const last = useSyncExternalStore(noSubscription, () => current, () => current); + return useMemo(() => { + if (clock) return clock; + const zone = typeof window === "undefined" ? "UTC" : browserZone(); + return { zone, now: last, in: tz(zone) }; + }, [clock, last]); +} + +/** `ago` on the clock: "4 minutes ago" that the server and hydration agree on. */ +export function useAgo(): (date: Date | string) => string { + const { now } = useClock(); + return useCallback((date: Date | string) => ago(date, now), [now]); +} diff --git a/app/components/home/contents.tsx b/app/components/home/contents.tsx index b63b72e..7650c42 100644 --- a/app/components/home/contents.tsx +++ b/app/components/home/contents.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { ago } from "@/lib/dates"; +import { useAgo } from "@/app/components/clock"; import { useRecentNotes } from "@/hooks/useNotes"; import { Skeleton } from "@/app/components/ui/skeleton"; import { awaitingWitness, isOwnNote, sourceLabel } from "@/lib/notes/source"; @@ -10,6 +10,7 @@ import { CONTENTS_RECENT_NOTES } from "@/lib/query-keys"; // The notebook's contents page: notes across every section, most recently // written first. Each row opens the note where it lives. export function Contents() { + const ago = useAgo(); const { data: notes, isLoading, isError } = useRecentNotes(CONTENTS_RECENT_NOTES); return ( diff --git a/app/components/home/notebook-home.tsx b/app/components/home/notebook-home.tsx index a494461..fb5d664 100644 --- a/app/components/home/notebook-home.tsx +++ b/app/components/home/notebook-home.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useSession } from "next-auth/react"; import { format } from "date-fns"; import { useSubjects } from "@/hooks/useSubjects"; +import { useClock } from "@/app/components/clock"; import { RuledBoxes } from "../notebook/ruled-boxes"; import { Contents } from "./contents"; import { TodayStrip } from "./today-strip"; @@ -14,6 +15,7 @@ import { Skeleton } from "../ui/skeleton"; export function NotebookHome() { const { data: session, status } = useSession({ required: true }); const { data: subjects, isLoading } = useSubjects(); + const clock = useClock(); if (status === "loading" || isLoading) { return ( @@ -36,7 +38,7 @@ export function NotebookHome() { { label: "Owner", value: owner, grow: 2 }, { label: "Sections", value: list.length }, { label: "Notes", value: noteCount }, - { label: "Date", value: format(new Date(), "d MMM yyyy"), grow: 1.3 }, + { label: "Date", value: format(clock.now, "d MMM yyyy", { in: clock.in }), grow: 1.3 }, ]} /> diff --git a/app/components/home/today-strip.tsx b/app/components/home/today-strip.tsx index 68b516b..ba34f82 100644 --- a/app/components/home/today-strip.tsx +++ b/app/components/home/today-strip.tsx @@ -5,6 +5,7 @@ import { format, isBefore, startOfDay } from "date-fns"; import { useTodayTasks } from "@/hooks/useTasks"; import { useDueReviews } from "@/hooks/useReviews"; import { RateButtons } from "@/app/components/review/revision"; +import { useClock } from "@/app/components/clock"; const FALLBACK = "var(--muted-foreground)"; @@ -14,9 +15,10 @@ const FALLBACK = "var(--muted-foreground)"; export function TodayStrip() { const { data: tasks = [] } = useTodayTasks(); const { data: reviews = [] } = useDueReviews(); + const clock = useClock(); if (tasks.length === 0 && reviews.length === 0) return null; - const startToday = startOfDay(new Date()); + const startToday = startOfDay(clock.now, { in: clock.in }); return (
@@ -58,7 +60,7 @@ export function TodayStrip() { {t.title} {t.subject.title} - {overdue ? "Overdue" : due ? format(due, "HH:mm") : ""} + {overdue ? "Overdue" : due ? format(due, "HH:mm", { in: clock.in }) : ""} ); diff --git a/app/components/note/note-fullscreen.tsx b/app/components/note/note-fullscreen.tsx index a85bda7..7e2ee1b 100644 --- a/app/components/note/note-fullscreen.tsx +++ b/app/components/note/note-fullscreen.tsx @@ -7,7 +7,7 @@ import type { MilestoneWithTasks } from "@/hooks/useSubjects"; import { useNoteDraft } from "@/hooks/useNoteDraft"; import { Markdown } from "@/app/components/subject/markdown"; import { NotesEditor } from "@/app/components/subject/notes-editor-lazy"; -import { ago } from "@/lib/dates"; +import { useAgo } from "@/app/components/clock"; import { isOwnNote, sourceLabel } from "@/lib/notes/source"; import { pageLabel } from "@/lib/notes/page-label"; @@ -27,6 +27,7 @@ export function NoteFullscreen({ color: string | null; onClose: () => void; }) { + const ago = useAgo(); const draft = useNoteDraft(note.id); const sheetRef = useRef(null); // Whatever opened the sheet (its Full screen button) gets focus back. Read diff --git a/app/components/note/note-list.tsx b/app/components/note/note-list.tsx index fa9e40b..0e96625 100644 --- a/app/components/note/note-list.tsx +++ b/app/components/note/note-list.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useState } from "react"; import { Plus } from "lucide-react"; -import { ago } from "@/lib/dates"; +import { useAgo } from "@/app/components/clock"; import type { MilestoneWithTasks } from "@/hooks/useSubjects"; import { useCreateMilestone } from "@/hooks/useMilestones"; import { isTempId } from "@/lib/subject-cache"; @@ -26,6 +26,7 @@ export function NoteList({ onCreated: (noteId: string) => void; placeholder?: string; }) { + const ago = useAgo(); const create = useCreateMilestone(); const [title, setTitle] = useState(""); diff --git a/app/components/note/note-view.tsx b/app/components/note/note-view.tsx index c5047ef..619b144 100644 --- a/app/components/note/note-view.tsx +++ b/app/components/note/note-view.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; import { ArrowLeft, ListChecks, Maximize2, MessageSquareQuote, PenLine, Pencil, Trash2 } from "lucide-react"; -import { ago } from "@/lib/dates"; +import { useAgo } from "@/app/components/clock"; import type { MilestoneWithTasks } from "@/hooks/useSubjects"; import { useDeleteMilestone, useUpdateMilestone } from "@/hooks/useMilestones"; import { useNoteDraft } from "@/hooks/useNoteDraft"; @@ -41,6 +41,7 @@ export function NoteView({ onDeleted: () => void; onMoved: (subjectId: string) => void; }) { + const ago = useAgo(); const update = useUpdateMilestone(); const del = useDeleteMilestone(); const draft = useNoteDraft(note.id); diff --git a/app/components/pace/pace.tsx b/app/components/pace/pace.tsx index 585a509..66d58eb 100644 --- a/app/components/pace/pace.tsx +++ b/app/components/pace/pace.tsx @@ -1,6 +1,7 @@ "use client"; import { format } from "date-fns"; +import type { Clock } from "@/app/components/clock"; import type { PaceStatus } from "@/lib/pace/pace"; export const PACE_META: Record = { @@ -28,9 +29,9 @@ export function PaceBar({ coverage, elapsed, showMark }: { coverage: number; ela ); } -/** "12 Nov · 41d left", or "today" / "passed" at the edges. */ -export function countdown(target: Date | string, daysLeft: number | null): string { +/** "12 Nov · 41d left", or "today" / "passed" at the edges, in the user's zone. */ +export function countdown(target: Date | string, daysLeft: number | null, clock: Pick): string { const d = new Date(target); - const when = daysLeft === null || daysLeft === 0 ? (d < new Date() ? "passed" : "today") : `${daysLeft}d left`; - return `${format(d, "d MMM")} · ${when}`; + const when = daysLeft === null || daysLeft === 0 ? (d.getTime() < clock.now ? "passed" : "today") : `${daysLeft}d left`; + return `${format(d, "d MMM", { in: clock.in })} · ${when}`; } diff --git a/app/components/review/revision.tsx b/app/components/review/revision.tsx index 3f488a7..181cc8c 100644 --- a/app/components/review/revision.tsx +++ b/app/components/review/revision.tsx @@ -6,6 +6,7 @@ import type { MilestoneWithTasks } from "@/hooks/useSubjects"; import { useUpdateMilestone } from "@/hooks/useMilestones"; import { useReviewMilestone } from "@/hooks/useReviews"; import { firstReview, type ReviewRating } from "@/lib/review/schedule"; +import { useClock, type Clock } from "@/app/components/clock"; import { DropdownMenu, DropdownMenuContent, @@ -29,9 +30,9 @@ const RATINGS: { rating: ReviewRating; label: string; hint: string }[] = [ { rating: "SOLID", label: "Solid", hint: "Push it well out" }, ]; -/** A review is due once its date falls on or before the end of today, local time. */ -export function isReviewDue(reviewDueAt: Date | string | null): boolean { - return reviewDueAt !== null && new Date(reviewDueAt) <= endOfDay(new Date()); +/** A review is due once its date falls on or before the end of the user's today. */ +export function isReviewDue(reviewDueAt: Date | string | null, clock: Pick): boolean { + return reviewDueAt !== null && new Date(reviewDueAt) <= endOfDay(clock.now, { in: clock.in }); } export function RateButtons({ milestoneId }: { milestoneId: string }) { @@ -113,9 +114,10 @@ export function ConfidenceMenu({ milestone }: { milestone: MilestoneWithTasks }) /** Rating buttons when a review is due, otherwise when the next one comes up. */ export function ReviewStatus({ milestone }: { milestone: MilestoneWithTasks }) { + const clock = useClock(); if (!milestone.isCompleted || !milestone.reviewDueAt) return null; - if (isReviewDue(milestone.reviewDueAt)) return ; - const days = differenceInCalendarDays(new Date(milestone.reviewDueAt), new Date()); + if (isReviewDue(milestone.reviewDueAt, clock)) return ; + const days = differenceInCalendarDays(new Date(milestone.reviewDueAt), clock.now, { in: clock.in }); return ( = { HIGH: "var(--destructive)", @@ -22,6 +23,7 @@ const PRIORITY_COLOR: Record = { * markup: no checkbox a viewer can click, no edit affordance to hide. */ export function SharedNodeView({ node, depth }: { node: SharedNode; depth: number }) { + const clock = useClock(); const due = node.dueDate ? new Date(node.dueDate) : null; const isMilestone = node.kind === "milestone"; const done = node.isCompleted === true; @@ -69,7 +71,7 @@ export function SharedNodeView({ node, depth }: { node: SharedNode; depth: numbe {due && ( - {format(due, "dd MMM")} + {format(due, "dd MMM", { in: clock.in })} )}
diff --git a/app/components/subject/sortable-list.tsx b/app/components/subject/sortable-list.tsx index 7996eb9..c16047a 100644 --- a/app/components/subject/sortable-list.tsx +++ b/app/components/subject/sortable-list.tsx @@ -1,5 +1,6 @@ "use client"; +import { useId } from "react"; import { DndContext, closestCenter, @@ -29,6 +30,9 @@ export function SortableList({ onReorder: (ids: string[]) => void; children: React.ReactNode; }) { + // dnd-kit numbers its accessibility ids from a global counter, which the + // server render and the browser count differently; a stable id keeps them equal. + const id = useId(); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), @@ -43,7 +47,7 @@ export function SortableList({ }; return ( - + {children} diff --git a/app/components/subject/subject-header.tsx b/app/components/subject/subject-header.tsx index e3f6700..7851e45 100644 --- a/app/components/subject/subject-header.tsx +++ b/app/components/subject/subject-header.tsx @@ -16,10 +16,11 @@ import { PACE_META, PaceBar, countdown } from "@/app/components/pace/pace"; import { computeCoverage, subjectPace } from "@/lib/pace/pace"; import { endOfDay, format, startOfDay } from "date-fns"; import { RuledBoxes } from "@/app/components/notebook/ruled-boxes"; +import { useClock, type Clock } from "@/app/components/clock"; // speaks yyyy-MM-dd in local time. A target counts through // the end of its day; a start counts from its beginning. -const toInput = (d: Date | string | null) => (d ? format(new Date(d), "yyyy-MM-dd") : ""); +const toInput = (d: Date | string | null, clock: Pick) => (d ? format(new Date(d), "yyyy-MM-dd", { in: clock.in }) : ""); const fromInput = (v: string, edge: "start" | "end") => v ? (edge === "end" ? endOfDay(new Date(`${v}T00:00`)) : startOfDay(new Date(`${v}T00:00`))).toISOString() : null; @@ -30,12 +31,13 @@ export function SubjectHeader({ subject }: { subject: SubjectDetail }) { const update = useUpdateSubject(); const del = useDeleteSubject(); + const clock = useClock(); const [editOpen, setEditOpen] = useState(false); const [title, setTitle] = useState(subject.title); const [description, setDescription] = useState(subject.description ?? ""); const [color, setColor] = useState(subject.color ?? SUBJECT_PALETTE[0]); - const [targetDate, setTargetDate] = useState(toInput(subject.targetDate)); - const [startDate, setStartDate] = useState(toInput(subject.startDate)); + const [targetDate, setTargetDate] = useState(toInput(subject.targetDate, clock)); + const [startDate, setStartDate] = useState(toInput(subject.startDate, clock)); const [confirmOpen, setConfirmOpen] = useState(false); // Re-seed the draft fields every time the popover opens so an edit started @@ -45,8 +47,8 @@ export function SubjectHeader({ subject }: { subject: SubjectDetail }) { setTitle(subject.title); setDescription(subject.description ?? ""); setColor(subject.color ?? SUBJECT_PALETTE[0]); - setTargetDate(toInput(subject.targetDate)); - setStartDate(toInput(subject.startDate)); + setTargetDate(toInput(subject.targetDate, clock)); + setStartDate(toInput(subject.startDate, clock)); } setEditOpen(o); }; @@ -91,7 +93,7 @@ export function SubjectHeader({ subject }: { subject: SubjectDetail }) { }, { label: "Notes", value: subject.milestones.length }, { label: "Resources", value: subject.resources.length }, - { label: "Updated", value: format(new Date(subject.updatedAt), "d MMM yyyy"), grow: 1.3 }, + { label: "Updated", value: format(new Date(subject.updatedAt), "d MMM yyyy", { in: clock.in }), grow: 1.3 }, ]} />
@@ -203,6 +205,7 @@ export function SubjectHeader({ subject }: { subject: SubjectDetail }) { // Task progress, or weighted syllabus coverage against the exam date when the // subject has one. Part of the Plan layer, so it lives on the Plan tab. export function SubjectProgress({ subject }: { subject: SubjectDetail }) { + const clock = useClock(); const milestoneTasks = subject.milestones.flatMap((m) => m.tasks); const allTasks = [...milestoneTasks, ...subject.tasks]; const total = allTasks.length; @@ -210,7 +213,7 @@ export function SubjectProgress({ subject }: { subject: SubjectDetail }) { const pct = total === 0 ? 0 : Math.round((done / total) * 100); const complete = total > 0 && done === total; const weak = subject.milestones.filter((m) => m.confidence === "WEAK").length; - const toRevise = subject.milestones.filter((m) => m.isCompleted && isReviewDue(m.reviewDueAt)).length; + const toRevise = subject.milestones.filter((m) => m.isCompleted && isReviewDue(m.reviewDueAt, clock)).length; const coverage = computeCoverage( subject.milestones.map((m) => ({ @@ -221,7 +224,7 @@ export function SubjectProgress({ subject }: { subject: SubjectDetail }) { })), { done, total }, ); - const pace = subjectPace(subject, coverage); + const pace = subjectPace(subject, coverage, new Date(clock.now)); // With a target date the headline number is weighted syllabus coverage; // without one it stays plain task progress. const examMode = pace.status !== "none"; @@ -247,7 +250,7 @@ export function SubjectProgress({ subject }: { subject: SubjectDetail }) {
{examMode && subject.targetDate && pace.status !== "none" && (
- target {countdown(subject.targetDate, pace.daysLeft)} ·{" "} + target {countdown(subject.targetDate, pace.daysLeft, clock)} ·{" "} {PACE_META[pace.status].label} {pace.status !== "done" && pace.status !== "overdue" && ( <> diff --git a/app/components/subject/task-row.tsx b/app/components/subject/task-row.tsx index 40e8d9f..73dd3bc 100644 --- a/app/components/subject/task-row.tsx +++ b/app/components/subject/task-row.tsx @@ -27,6 +27,7 @@ import { SubtaskRow } from "./subtask-row"; import { AddSubtask } from "./add-subtask"; import { useReveal } from "./reveal"; import { ShareButton } from "@/app/components/share/share-button"; +import { useClock } from "@/app/components/clock"; const PRIORITY_COLOR: Record = { HIGH: "var(--destructive)", @@ -45,12 +46,13 @@ export const TaskRow = memo(function TaskRow({ task }: { task: TaskWithSubtasks id: task.id, }); + const clock = useClock(); const [open, setOpen] = useState(false); const reveal = useReveal(task.id, setOpen); const [editOpen, setEditOpen] = useState(false); const [title, setTitle] = useState(task.title); const [priority, setPriority] = useState(task.priority); - const [due, setDue] = useState(task.dueDate ? format(new Date(task.dueDate), "yyyy-MM-dd") : ""); + const [due, setDue] = useState(task.dueDate ? format(new Date(task.dueDate), "yyyy-MM-dd", { in: clock.in }) : ""); const [recurrence, setRecurrence] = useState<"NONE" | Recurrence>(task.recurrence ?? "NONE"); const [estimate, setEstimate] = useState(task.estimatedTime ? String(task.estimatedTime) : ""); const [editingNotes, setEditingNotes] = useState(false); @@ -59,7 +61,7 @@ export const TaskRow = memo(function TaskRow({ task }: { task: TaskWithSubtasks const [failedNotes, setFailedNotes] = useState(null); const dueDate = task.dueDate ? new Date(task.dueDate) : null; - const overdue = !task.isCompleted && dueDate ? isBefore(dueDate, startOfDay(new Date())) : false; + const overdue = !task.isCompleted && dueDate ? isBefore(dueDate, startOfDay(clock.now, { in: clock.in })) : false; const subtasks = task.subtasks; const subDone = subtasks.filter((s) => s.isCompleted).length; @@ -84,7 +86,7 @@ export const TaskRow = memo(function TaskRow({ task }: { task: TaskWithSubtasks if (o) { setTitle(task.title); setPriority(task.priority); - setDue(task.dueDate ? format(new Date(task.dueDate), "yyyy-MM-dd") : ""); + setDue(task.dueDate ? format(new Date(task.dueDate), "yyyy-MM-dd", { in: clock.in }) : ""); setRecurrence(task.recurrence ?? "NONE"); setEstimate(task.estimatedTime ? String(task.estimatedTime) : ""); } @@ -209,7 +211,7 @@ export const TaskRow = memo(function TaskRow({ task }: { task: TaskWithSubtasks {dueDate && ( {overdue ? "overdue · " : ""} - {format(dueDate, "dd MMM")} + {format(dueDate, "dd MMM", { in: clock.in })} )} diff --git a/hooks/use-activity-heatmap.ts b/hooks/use-activity-heatmap.ts index e51020e..44941a5 100644 --- a/hooks/use-activity-heatmap.ts +++ b/hooks/use-activity-heatmap.ts @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useTasks } from "./useTasks"; import { format, subDays } from "date-fns"; +import { useClock } from "@/app/components/clock"; export interface HeatmapDay { date: string; // yyyy-MM-dd @@ -10,23 +11,24 @@ export interface HeatmapDay { // GitHub-style activity: tasks completed per day over the last `days`. export function useActivityHeatmap(days = 365) { const { data: tasks = [], isLoading } = useTasks(); + const clock = useClock(); const data = useMemo(() => { const counts = new Map(); for (const t of tasks) { if (!t.isCompleted || !t.completedAt) continue; - const key = format(new Date(t.completedAt), "yyyy-MM-dd"); + const key = format(new Date(t.completedAt), "yyyy-MM-dd", { in: clock.in }); counts.set(key, (counts.get(key) ?? 0) + 1); } - const today = new Date(); + // The user's days, in their zone (the clock), not the server's. const out: HeatmapDay[] = []; for (let i = days - 1; i >= 0; i--) { - const key = format(subDays(today, i), "yyyy-MM-dd"); + const key = format(subDays(clock.now, i, { in: clock.in }), "yyyy-MM-dd", { in: clock.in }); out.push({ date: key, count: counts.get(key) ?? 0 }); } return out; - }, [tasks, days]); + }, [tasks, days, clock]); return { data, isLoading }; } diff --git a/hooks/use-analytics.ts b/hooks/use-analytics.ts index f0e5b25..7621f9b 100644 --- a/hooks/use-analytics.ts +++ b/hooks/use-analytics.ts @@ -1,7 +1,8 @@ import { useMemo } from "react"; import { useTasks } from "./useTasks"; -import { format, isToday, startOfWeek, addDays } from "date-fns"; +import { format, startOfWeek, addDays } from "date-fns"; import { computeStreaks } from "@/lib/analytics/streak"; +import { useClock } from "@/app/components/clock"; // Progress stats derived from the user's tasks (no gamification/XP). export interface ProgressStats { @@ -14,27 +15,28 @@ export interface ProgressStats { weeklyProgress: number[]; // completions per day, Sun…Sat of the current week } -const dayKey = (d: Date | string) => format(new Date(d), "yyyy-MM-dd"); - export function useAnalytics() { const { data: tasks = [], isLoading } = useTasks(); + const clock = useClock(); const data = useMemo(() => { + // Days are the user's, in their zone (the clock), not the server's. + const dayKey = (d: Date | string | number) => format(new Date(d), "yyyy-MM-dd", { in: clock.in }); + const todayKey = dayKey(clock.now); const completed = tasks.filter((t) => t.isCompleted && t.completedAt); const totalCompleted = completed.length; - const completedToday = completed.filter((t) => t.completedAt && isToday(new Date(t.completedAt))).length; + const completedToday = completed.filter((t) => t.completedAt && dayKey(t.completedAt) === todayKey).length; const activeTasks = tasks.filter((t) => !t.isCompleted).length; const totalFocusMinutes = tasks.reduce((sum, t) => sum + (t.timeSpent ?? 0), 0); - const today = new Date(); const { current: currentStreak, longest: longestStreak } = computeStreaks( completed.map((t) => dayKey(t.completedAt!)), - dayKey(today), + todayKey, ); - const weekStart = startOfWeek(today, { weekStartsOn: 0 }); + const weekStart = startOfWeek(clock.now, { weekStartsOn: 0, in: clock.in }); const weeklyProgress = Array.from({ length: 7 }, (_, i) => { - const key = dayKey(addDays(weekStart, i)); + const key = dayKey(addDays(weekStart, i, { in: clock.in })); return completed.filter((t) => t.completedAt && dayKey(t.completedAt) === key).length; }); @@ -47,7 +49,7 @@ export function useAnalytics() { activeTasks, weeklyProgress, }; - }, [tasks]); + }, [tasks, clock]); return { data, isLoading }; } diff --git a/hooks/useReviews.ts b/hooks/useReviews.ts index eeb8bb3..8c9727b 100644 --- a/hooks/useReviews.ts +++ b/hooks/useReviews.ts @@ -1,8 +1,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { endOfDay, format } from "date-fns"; import type { Milestone, Subject } from "@/app/generated/prisma/browser"; import { api } from "@/lib/fetcher"; import { queryKeys } from "@/lib/query-keys"; +import { todayIn } from "@/lib/zone"; +import { useClock } from "@/app/components/clock"; import type { SubjectDetail } from "@/hooks/useSubjects"; import { patchSubjectCaches, replaceMilestone, restoreSubjectCaches } from "@/lib/subject-cache"; import { nextReview, type ReviewRating } from "@/lib/review/schedule"; @@ -12,13 +13,14 @@ export type DueReview = Omit & { subject: Pick api.get(`/api/reviews?before=${encodeURIComponent(before)}`), }); } diff --git a/hooks/useTasks.ts b/hooks/useTasks.ts index 40b1b7d..99763f2 100644 --- a/hooks/useTasks.ts +++ b/hooks/useTasks.ts @@ -1,8 +1,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { endOfDay, format } from "date-fns"; import type { Task, Priority, Recurrence } from "@/app/generated/prisma/browser"; import { api } from "@/lib/fetcher"; import { queryKeys } from "@/lib/query-keys"; +import { todayIn } from "@/lib/zone"; +import { useClock } from "@/app/components/clock"; import type { SubjectDetail, TaskWithSubtasks } from "@/hooks/useSubjects"; import { patchSubjectCaches, @@ -45,15 +46,15 @@ export function useTasks(params?: { subjectId?: string; milestoneId?: string }) }); } -// Cross-subject "Today": incomplete tasks due today or overdue. The client's -// local end-of-day goes along as ?before= so the boundary follows the user's -// timezone (the server may run in UTC); the day in the key rolls it over at -// local midnight. +// Cross-subject "Today": incomplete tasks due today or overdue. The end of the +// user's day, in their zone (the clock), goes along as ?before= so the boundary +// isn't the server's UTC; the day in the key rolls it over at their midnight. export function useTodayTasks() { - const now = new Date(); - const before = endOfDay(now).toISOString(); + const { zone, now } = useClock(); + const today = todayIn(zone, new Date(now))!; + const before = today.endOfDay.toISOString(); return useQuery({ - queryKey: queryKeys.todayTasks(format(now, "yyyy-MM-dd")), + queryKey: queryKeys.todayTasks(today.day), queryFn: () => api.get(`/api/tasks?today=true&before=${encodeURIComponent(before)}`), }); diff --git a/lib/dates.ts b/lib/dates.ts index eb25441..f2f7ba7 100644 --- a/lib/dates.ts +++ b/lib/dates.ts @@ -1,10 +1,13 @@ -import { formatDistanceToNowStrict } from "date-fns"; +import { formatDistanceStrict } from "date-fns"; /** * "4 minutes ago", or "just now" inside the last minute, where a count of - * seconds reads as a timer rather than a date. + * seconds reads as a timer rather than a date. Components pass the clock's + * `now` (app/components/clock.tsx) so the server's render and the browser's + * first one say the same thing. */ -export function ago(date: Date | string): string { +export function ago(date: Date | string, now: number = Date.now()): string { const at = new Date(date); - return Date.now() - at.getTime() < 60_000 ? "just now" : formatDistanceToNowStrict(at, { addSuffix: true }); + if (now - at.getTime() < 60_000) return "just now"; + return formatDistanceStrict(at, now, { addSuffix: true }); } diff --git a/lib/prefetch.ts b/lib/prefetch.ts index 41b57f3..4c22aea 100644 --- a/lib/prefetch.ts +++ b/lib/prefetch.ts @@ -7,7 +7,7 @@ import { listSubjects } from "@/lib/subjects/list"; import { getInboxSummary } from "@/lib/subjects/inbox"; import { listTasksDue } from "@/lib/tasks/list"; import { listDueReviews } from "@/lib/review/due"; -import { ZONE_COOKIE, todayIn } from "@/lib/zone"; +import { ZONE_COOKIE, resolveZone, todayIn } from "@/lib/zone"; // Data the server puts in a page so it arrives with the page instead of // after it: each entry is a query key and the loader behind its API route. @@ -35,23 +35,23 @@ export async function viewerId(): Promise { return session?.user?.id ?? null; } +/** The user's zone from the lk-tz cookie (UTC without one) and the time now. */ +export async function viewerClock(): Promise<{ zone: string; now: number }> { + return { zone: resolveZone((await cookies()).get(ZONE_COOKIE)?.value), now: Date.now() }; +} + /** * What the spine shows on every page: subjects, the Inbox's counts, and - * what's due today. Today and due revisions are keyed by the user's local - * date, known from the lk-tz cookie; without it they're left to the browser. + * what's due today. Today is the user's date in their zone at `now`, the same + * values the notebook layout hands the browser's clock (app/components/clock.tsx), + * so these land under the keys the spine's hooks ask for. */ -export async function spinePrefetches(userId: string): Promise { - const zone = (await cookies()).get(ZONE_COOKIE)?.value; - const today = zone ? todayIn(zone) : null; - const entries: Prefetch[] = [ +export function spinePrefetches(userId: string, zone: string, now: number): Prefetch[] { + const today = todayIn(zone, new Date(now))!; + return [ [queryKeys.subjects, () => listSubjects(userId)], [queryKeys.inbox, () => getInboxSummary(userId)], + [queryKeys.todayTasks(today.day), () => listTasksDue(userId, today.endOfDay)], + [queryKeys.dueReviews(today.day), () => listDueReviews(userId, today.endOfDay)], ]; - if (today) { - entries.push( - [queryKeys.todayTasks(today.day), () => listTasksDue(userId, today.endOfDay)], - [queryKeys.dueReviews(today.day), () => listDueReviews(userId, today.endOfDay)], - ); - } - return entries; } diff --git a/lib/zone.ts b/lib/zone.ts index 54ad59f..4f04b1b 100644 --- a/lib/zone.ts +++ b/lib/zone.ts @@ -6,6 +6,11 @@ export const ZONE_COOKIE = "lk-tz"; +/** The zone named in the cookie if Intl knows it, otherwise UTC. */ +export function resolveZone(value: string | undefined): string { + return value && todayIn(value) ? value : "UTC"; +} + type Parts = { year: number; month: number; day: number; hour: number; minute: number; second: number }; function partsIn(instant: Date, timeZone: string): Parts { diff --git a/package.json b/package.json index 49339b0..65027cb 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@codemirror/language-data": "^6.5.2", "@codemirror/state": "^6.7.6", "@codemirror/view": "^6.43.13", + "@date-fns/tz": "^1.5.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 164de4d..3e8e74f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@codemirror/view': specifier: ^6.43.13 version: 6.43.13 + '@date-fns/tz': + specifier: ^1.5.0 + version: 1.5.0 '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -337,6 +340,9 @@ packages: '@codemirror/view@6.43.13': resolution: {integrity: sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==} + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -4628,6 +4634,8 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 + '@date-fns/tz@1.5.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.3.0)': dependencies: react: 19.3.0