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