diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f16070b..d24ba07 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,7 +5,6 @@ import DashboardPage from './pages/DashboardPage.js'; import SleepPage from './pages/SleepPage.js'; import WorkoutsPage from './pages/WorkoutsPage.js'; import TrendsPage from './pages/TrendsPage.js'; -import HabitsPage from './pages/HabitsPage.js'; import AskPage from './pages/AskPage.js'; import { useSettingsStore, selectAiEnabled } from './stores/settingsStore.js'; @@ -24,7 +23,6 @@ export default function App() { } /> } /> } /> - } /> {/* Ask is an AI surface — when AI is disabled the route falls through to home. */} : } /> } /> diff --git a/apps/web/src/components/ask/AskClaude.tsx b/apps/web/src/components/ask/AskClaude.tsx index fa22148..ea71100 100644 --- a/apps/web/src/components/ask/AskClaude.tsx +++ b/apps/web/src/components/ask/AskClaude.tsx @@ -29,7 +29,7 @@ export function AskClaude() { const { daily } = useHealthData(); const readiness = deriveReadiness(daily); - const { ask, answer, pending, error, stop } = useAsk(); + const { ask, answer, typing, pending, error, stop } = useAsk(); const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(''); // The assistant message currently receiving streamed tokens. @@ -46,10 +46,11 @@ export function AskClaude() { setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, text: answer } : m))); }, [answer]); - // When the request settles, surface any error in the bubble and release the - // streaming slot so the next question starts a fresh assistant message. + // When the request settles AND the typewriter has caught up, surface any error + // in the bubble and release the streaming slot so the next question starts a + // fresh assistant message. Waiting on `typing` keeps the reveal mirroring. useEffect(() => { - if (pending) return; + if (pending || typing) return; const id = streamingId.current; if (!id) return; streamingId.current = null; @@ -60,7 +61,7 @@ export function AskClaude() { : m, ), ); - }, [pending, error]); + }, [pending, typing, error]); // Keep the newest message in view as it streams in. useLayoutEffect(() => { @@ -109,7 +110,11 @@ export function AskClaude() { {/* Conversation thread */}
{messages.map((m, i) => ( - + ))}
diff --git a/apps/web/src/components/dashboard/InsightsPanel.tsx b/apps/web/src/components/dashboard/InsightsPanel.tsx index 17bfd43..ce21778 100644 --- a/apps/web/src/components/dashboard/InsightsPanel.tsx +++ b/apps/web/src/components/dashboard/InsightsPanel.tsx @@ -115,11 +115,16 @@ export function InsightsPanel({ }, [autoSummary, data, freshnessKey, generating, generate]); const flags = data?.insights ?? []; - const briefDate = data?.briefing - ? fmtDate(data.briefing.createdAt.slice(0, 10), 'EEEE, MMM d') - : fmtDate(data?.date ?? new Date().toISOString().slice(0, 10), 'EEEE, MMM d'); - const briefTime = data?.briefing - ? new Date(data.briefing.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + // Use the briefing's logical civil date for the header (the day it's about), + // and the UTC-normalized createdAt for the time — sqlite stores a tz-less + // 'YYYY-MM-DD HH:MM:SS' in UTC, which new Date() would misread as local. + const briefDate = fmtDate( + data?.briefing?.date ?? data?.date ?? new Date().toISOString().slice(0, 10), + 'EEEE, MMM d', + ); + const briefEpoch = data?.briefing ? toEpoch(data.briefing.createdAt) : NaN; + const briefTime = Number.isFinite(briefEpoch) + ? new Date(briefEpoch).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : null; return ( diff --git a/apps/web/src/components/layout/nav.ts b/apps/web/src/components/layout/nav.ts index bdca6f6..8d91dc5 100644 --- a/apps/web/src/components/layout/nav.ts +++ b/apps/web/src/components/layout/nav.ts @@ -6,7 +6,6 @@ export const NAV = [ { to: '/sleep', label: 'Sleep', short: 'Sleep', icon: 'sleep' }, { to: '/workouts', label: 'Activity', short: 'Activity', icon: 'activity' }, { to: '/trends', label: 'Trends', short: 'Trends', icon: 'trends' }, - { to: '/habits', label: 'Habits', short: 'Habits', icon: 'habits' }, { to: '/ask', label: 'Ask AI', short: 'Ask AI', icon: 'ask', ai: true }, ] as const satisfies ReadonlyArray<{ to: string; diff --git a/apps/web/src/components/shared/Sparkline.tsx b/apps/web/src/components/shared/Sparkline.tsx index 81c2f51..9cf63a5 100644 --- a/apps/web/src/components/shared/Sparkline.tsx +++ b/apps/web/src/components/shared/Sparkline.tsx @@ -1,8 +1,10 @@ -import { useId } from 'react'; +import { useId, useLayoutEffect, useRef, useState } from 'react'; interface SparklineProps { - /** Oldest → newest values. nulls create gaps but are interpolated for the line. */ + /** Oldest → newest values. nulls create gaps but the line bridges them. */ values: (number | null)[]; + /** Per-point labels (e.g. ISO dates), aligned with `values`. Enables hover. */ + labels?: string[]; /** Optional dashed reference line (e.g. baseline / goal). */ baseline?: number | null; /** Pixel height of the rendered SVG. Width is fluid. Default 120. */ @@ -11,69 +13,141 @@ interface SparklineProps { color?: string; /** Show the filled area under the line. Default true. */ fill?: boolean; - /** Mark the latest point. Default true. */ + /** Mark the latest point when not hovering. Default true. */ marker?: boolean; + /** Format a value for the hover tooltip. */ + format?: (v: number) => string; + /** Format a label for the hover tooltip. */ + formatLabel?: (s: string) => string; className?: string; } -const W = 360; - /** - * A calm single-line area sparkline on a near-invisible grid — the Instrument - * chart vocabulary. Fluid width, fixed height; values map oldest→newest L→R. + * A calm single-line area chart. Rendered at its real measured pixel width (so + * strokes and the marker stay round — no preserveAspectRatio stretching), with + * an optional per-point hover tooltip + guide line for reading individual days. */ export function Sparkline({ values, + labels, baseline = null, height = 120, color = 'var(--accent)', fill = true, marker = true, + format, + formatLabel, className, }: SparklineProps) { const id = useId().replace(/:/g, ''); + const wrapRef = useRef(null); + const [w, setW] = useState(0); + const [active, setActive] = useState(null); + + // Track the real rendered width so the SVG coordinate system is 1:1 with + // pixels — no horizontal stretching of the stroke or marker. + useLayoutEffect(() => { + const el = wrapRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + const cw = entries[0]?.contentRect.width ?? 0; + if (cw > 0) setW(Math.round(cw)); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + const finite = values.filter((v): v is number => v != null && Number.isFinite(v)); - if (finite.length < 2) { - return
; + // Until measured (w===0) render an empty box of the right height to avoid a flash. + if (finite.length < 2 || w === 0) { + return
; } - const pad = 10; + const padX = 5; + const padY = 10; const lo = Math.min(...finite, baseline ?? Infinity); const hi = Math.max(...finite, baseline ?? -Infinity); const span = hi - lo || 1; - const yOf = (v: number) => pad + (1 - (v - lo) / span) * (height - pad * 2); - const xOf = (i: number) => (i / (values.length - 1)) * W; + const yOf = (v: number) => padY + (1 - (v - lo) / span) * (height - padY * 2); + const xOf = (i: number) => padX + (i / Math.max(1, values.length - 1)) * (w - padX * 2); - // Build the line through finite points (skip nulls, keep index spacing). - const pts: { x: number; y: number }[] = []; + const pts: { x: number; y: number; i: number; v: number }[] = []; values.forEach((v, i) => { - if (v != null && Number.isFinite(v)) pts.push({ x: xOf(i), y: yOf(v) }); + if (v != null && Number.isFinite(v)) pts.push({ x: xOf(i), y: yOf(v), i, v }); }); - const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '); + const line = pts.map((p, k) => `${k === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '); const area = `${line} L${pts[pts.length - 1]!.x.toFixed(1)},${height} L${pts[0]!.x.toFixed(1)},${height} Z`; const last = pts[pts.length - 1]!; + const onMove = (e: React.PointerEvent) => { + const rect = wrapRef.current?.getBoundingClientRect(); + if (!rect) return; + const x = e.clientX - rect.left; + let best = pts[0]!; + let bestD = Infinity; + for (const p of pts) { + const d = Math.abs(p.x - x); + if (d < bestD) { + bestD = d; + best = p; + } + } + setActive(best.i); + }; + + const activePt = active != null ? pts.find((p) => p.i === active) ?? null : null; + const tipLeft = activePt ? Math.min(Math.max(activePt.x, 38), w - 38) : 0; + return ( - - - - - - - - {baseline != null && Number.isFinite(baseline) && ( - +
+ setActive(null)} + > + + + + + + + {baseline != null && Number.isFinite(baseline) && ( + + )} + {fill && } + + {activePt ? ( + <> + + + + ) : ( + marker && + )} + + {activePt && ( +
+ {labels?.[activePt.i] && ( +
+ {formatLabel ? formatLabel(labels[activePt.i]!) : labels[activePt.i]} +
+ )} +
+ {format ? format(activePt.v) : activePt.v} +
+
)} - {fill && } - - {marker && } - +
); } diff --git a/apps/web/src/hooks/useAsk.ts b/apps/web/src/hooks/useAsk.ts index c5b6a67..455d971 100644 --- a/apps/web/src/hooks/useAsk.ts +++ b/apps/web/src/hooks/useAsk.ts @@ -2,9 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { askStream } from '../lib/api.js'; export function useAsk() { - const [answer, setAnswer] = useState(''); - // The backend returns the whole answer in one shot after a long wait, so - // this is an honest "request in flight" flag, not token streaming. + // `full` is everything received from the backend (which today returns the + // answer in one shot); `displayed` is the typewriter-revealed slice so the + // assistant appears to type its reply back rather than snapping in whole. + const [full, setFull] = useState(''); + const [displayed, setDisplayed] = useState(''); const [pending, setPending] = useState(false); const [elapsed, setElapsed] = useState(0); const [error, setError] = useState(null); @@ -20,22 +22,41 @@ export function useAsk() { return () => clearInterval(id); }, [pending]); + // Typewriter: advance `displayed` toward `full` at a roughly constant pace + // (~1.5s total regardless of length) so long and short replies feel similar. + useEffect(() => { + if (displayed.length >= full.length) return; + const id = setTimeout(() => { + const inc = Math.max(2, Math.ceil(full.length / 90)); + setDisplayed(full.slice(0, Math.min(full.length, displayed.length + inc))); + }, 16); + return () => clearTimeout(id); + }, [full, displayed]); + const ask = useCallback((question: string) => { abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; - setAnswer(''); + setFull(''); + setDisplayed(''); setError(null); setElapsed(0); startRef.current = Date.now(); setPending(true); - askStream({ question }, (tok) => setAnswer((a) => a + tok), ctrl.signal) + askStream({ question }, (tok) => setFull((a) => a + tok), ctrl.signal) .catch((err) => { if ((err as Error).name !== 'AbortError') setError((err as Error).message); }) .finally(() => setPending(false)); }, []); - const stop = useCallback(() => abortRef.current?.abort(), []); - return { answer, pending, elapsed, error, ask, stop }; + const stop = useCallback(() => { + abortRef.current?.abort(); + // Snap the reveal to whatever arrived so a stopped answer doesn't hang mid-type. + setDisplayed(full); + }, [full]); + + // True while there's still buffered text to reveal. + const typing = displayed.length < full.length; + return { answer: displayed, typing, pending, elapsed, error, ask, stop }; } diff --git a/apps/web/src/hooks/useConfigStatus.ts b/apps/web/src/hooks/useConfigStatus.ts index 4fbf3a9..79a9949 100644 --- a/apps/web/src/hooks/useConfigStatus.ts +++ b/apps/web/src/hooks/useConfigStatus.ts @@ -26,8 +26,13 @@ export interface ConfigStatus { }; } +/** + * `undefined` = still loading (first fetch in flight), `null` = failed to load, + * object = resolved. Callers reading fields can keep using `config?.x`; those + * that must avoid a loading flash (the Ask page) check for `undefined`. + */ export function useConfigStatus() { - const [status, setStatus] = useState(null); + const [status, setStatus] = useState(undefined); useEffect(() => { apiGet('/api/config/status') .then(setStatus) diff --git a/apps/web/src/pages/AskPage.tsx b/apps/web/src/pages/AskPage.tsx index 964661a..facb96c 100644 --- a/apps/web/src/pages/AskPage.tsx +++ b/apps/web/src/pages/AskPage.tsx @@ -13,7 +13,15 @@ export default function AskPage() { return (
- {config?.claudeApiConfigured ? : } + {config === undefined ? ( + // Hold a stable, empty frame until config resolves — avoids flashing the + // setup empty-state for a beat before the chat mounts. +
+ ) : config?.claudeApiConfigured ? ( + + ) : ( + + )}
); } diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index cbc93e8..8343b1b 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -108,6 +108,7 @@ export default function DashboardPage() { // hrv series (oldest → newest) const hrvSeries = [...daily].reverse().map((d) => d.fitbit?.hrv ?? d.consensus.hrv ?? null); + const hrvDates = [...daily].reverse().map((d) => d.date); // sleep stages (latest night) const sd = daily.find((d) => d.fitbit?.sleepHours != null)?.fitbit; @@ -225,7 +226,14 @@ export default function DashboardPage() {

14-day HRV

{fmtNum(hrv.value, 0)} ms · baseline {fmtNum(hrv.baseline, 0)}
- + `${fmtNum(v, 0)} ms`} + formatLabel={(d) => fmtDate(d, 'EEE, MMM d')} + />
{fmtDate(daily[daily.length - 1]?.date ?? today, 'MMM d')} {fmtDate(today, 'MMM d')} diff --git a/apps/web/src/pages/SleepPage.tsx b/apps/web/src/pages/SleepPage.tsx index b1a174b..1b79eb9 100644 --- a/apps/web/src/pages/SleepPage.tsx +++ b/apps/web/src/pages/SleepPage.tsx @@ -234,7 +234,14 @@ export default function SleepPage() {
{nights.length >= 2 ? ( <> - + n.date)} + baseline={SLEEP_TARGET_HOURS} + height={120} + format={(v) => `${v.toFixed(1)}h`} + formatLabel={(d) => fmtDate(d, 'EEE, MMM d')} + />
{fmtDate(firstDate, 'MMM d')} {fmtDate(today, 'MMM d')} diff --git a/apps/web/src/pages/TrendsPage.tsx b/apps/web/src/pages/TrendsPage.tsx index 45e8192..c1426f8 100644 --- a/apps/web/src/pages/TrendsPage.tsx +++ b/apps/web/src/pages/TrendsPage.tsx @@ -205,9 +205,12 @@ export default function TrendsPage() { <> `${fmtNum(v, c.dp)}${c.unit}`} + formatLabel={(d) => fmtDate(d, 'EEE, MMM d')} />
{series.dates[0] ? fmtDate(series.dates[0], 'MMM d') : ''}