Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/(app)/inbox/inbox-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string | null>(null);
const clock = useClock();

const noteParam = searchParams.get("note");

Expand Down Expand Up @@ -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 },
]}
/>
<header>
Expand Down
20 changes: 12 additions & 8 deletions app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Providers session={session}>
<HydrationBoundary state={state}>
<AppShell>{children}</AppShell>
</HydrationBoundary>
</Providers>
<ClockProvider zone={clock.zone} now={clock.now}>
<Providers session={session}>
<HydrationBoundary state={state}>
<AppShell>{children}</AppShell>
</HydrationBoundary>
</Providers>
</ClockProvider>
);
}
11 changes: 10 additions & 1 deletion app/(public)/share/[token]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <SharedView payload={payload} />;
// 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 (
<ClockProvider zone={clock.zone} now={clock.now}>
<SharedView payload={payload} />
</ClockProvider>
);
}
5 changes: 4 additions & 1 deletion app/components/analytics-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
Expand All @@ -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 (
<main className="lk-page">
Expand Down
86 changes: 86 additions & 0 deletions app/components/clock.tsx
Original file line number Diff line number Diff line change
@@ -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<Clock | null>(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<typeof setInterval> | 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<Clock>(() => ({ zone, now, in: tz(zone) }), [zone, now]);
return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>;
}

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]);
}
3 changes: 2 additions & 1 deletion app/components/home/contents.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
Expand Down
4 changes: 3 additions & 1 deletion app/components/home/notebook-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
Expand All @@ -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 },
]}
/>

Expand Down
6 changes: 4 additions & 2 deletions app/components/home/today-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)";

Expand All @@ -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 (
<section id="today" aria-label="Due today" className="lk-hero scroll-mt-16 px-4 py-3">
<div className="mb-1.5 flex items-baseline gap-3">
Expand Down Expand Up @@ -58,7 +60,7 @@ export function TodayStrip() {
<span className="min-w-0 flex-1 truncate">{t.title}</span>
<span className="hidden text-sm text-muted-foreground sm:inline">{t.subject.title}</span>
<span className={`lk-print text-xs uppercase ${overdue ? "text-destructive" : "text-muted-foreground"}`}>
{overdue ? "Overdue" : due ? format(due, "HH:mm") : ""}
{overdue ? "Overdue" : due ? format(due, "HH:mm", { in: clock.in }) : ""}
</span>
</li>
);
Expand Down
3 changes: 2 additions & 1 deletion app/components/note/note-fullscreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -27,6 +27,7 @@ export function NoteFullscreen({
color: string | null;
onClose: () => void;
}) {
const ago = useAgo();
const draft = useNoteDraft(note.id);
const sheetRef = useRef<HTMLDivElement>(null);
// Whatever opened the sheet (its Full screen button) gets focus back. Read
Expand Down
3 changes: 2 additions & 1 deletion app/components/note/note-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -26,6 +26,7 @@ export function NoteList({
onCreated: (noteId: string) => void;
placeholder?: string;
}) {
const ago = useAgo();
const create = useCreateMilestone();
const [title, setTitle] = useState("");

Expand Down
3 changes: 2 additions & 1 deletion app/components/note/note-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 5 additions & 4 deletions app/components/pace/pace.tsx
Original file line number Diff line number Diff line change
@@ -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<PaceStatus, { label: string; color: string }> = {
Expand Down Expand Up @@ -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<Clock, "now" | "in">): 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}`;
}
12 changes: 7 additions & 5 deletions app/components/review/revision.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Clock, "now" | "in">): boolean {
return reviewDueAt !== null && new Date(reviewDueAt) <= endOfDay(clock.now, { in: clock.in });
}

export function RateButtons({ milestoneId }: { milestoneId: string }) {
Expand Down Expand Up @@ -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 <RateButtons milestoneId={milestone.id} />;
const days = differenceInCalendarDays(new Date(milestone.reviewDueAt), new Date());
if (isReviewDue(milestone.reviewDueAt, clock)) return <RateButtons milestoneId={milestone.id} />;
const days = differenceInCalendarDays(new Date(milestone.reviewDueAt), clock.now, { in: clock.in });
return (
<span
className="lk-print hidden flex-none text-2xs uppercase tracking-wide text-muted-foreground sm:inline"
Expand Down
4 changes: 3 additions & 1 deletion app/components/settings/agent-connections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useState } from "react";
import { useMounted } from "@/hooks/useMounted";
import { Check, Copy, KeyRound } from "lucide-react";
import { BRAND } from "@/lib/brand";
import { ago } from "@/lib/dates";
import { useAgo } from "@/app/components/clock";
import {
useConnectedApps,
useCreateToken,
Expand Down Expand Up @@ -72,6 +72,7 @@ function CopyButton({ value, label }: { value: string; label: string }) {
}

function TokenRow({ token }: { token: AgentToken }) {
const ago = useAgo();
const revoke = useRevokeToken();
const [confirming, setConfirming] = useState(false);
const revoked = !!token.revokedAt;
Expand Down Expand Up @@ -111,6 +112,7 @@ function TokenRow({ token }: { token: AgentToken }) {
}

function AppRow({ app }: { app: ConnectedApp }) {
const ago = useAgo();
const disconnect = useDisconnectApp();
const [confirming, setConfirming] = useState(false);
return (
Expand Down
Loading
Loading