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
2 changes: 0 additions & 2 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -24,7 +23,6 @@ export default function App() {
<Route path="sleep" element={<SleepPage />} />
<Route path="workouts" element={<WorkoutsPage />} />
<Route path="trends" element={<TrendsPage />} />
<Route path="habits" element={<HabitsPage />} />
{/* Ask is an AI surface — when AI is disabled the route falls through to home. */}
<Route path="ask" element={aiEnabled ? <AskPage /> : <Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
Expand Down
17 changes: 11 additions & 6 deletions apps/web/src/components/ask/AskClaude.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Message[]>([]);
const [draft, setDraft] = useState('');
// The assistant message currently receiving streamed tokens.
Expand All @@ -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;
Expand All @@ -60,7 +61,7 @@ export function AskClaude() {
: m,
),
);
}, [pending, error]);
}, [pending, typing, error]);

// Keep the newest message in view as it streams in.
useLayoutEffect(() => {
Expand Down Expand Up @@ -109,7 +110,11 @@ export function AskClaude() {
{/* Conversation thread */}
<div className="flex-1 flex flex-col gap-5 py-4 pb-7">
{messages.map((m, i) => (
<ChatMessage key={m.id} message={m} streaming={pending && i === messages.length - 1} />
<ChatMessage
key={m.id}
message={m}
streaming={(pending || typing) && i === messages.length - 1}
/>
))}
<div ref={scrollAnchor} aria-hidden className="h-px" />
</div>
Expand Down
15 changes: 10 additions & 5 deletions apps/web/src/components/dashboard/InsightsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/components/layout/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
146 changes: 110 additions & 36 deletions apps/web/src/components/shared/Sparkline.tsx
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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<HTMLDivElement>(null);
const [w, setW] = useState(0);
const [active, setActive] = useState<number | null>(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 <div className={className} style={{ height }} aria-hidden />;
// Until measured (w===0) render an empty box of the right height to avoid a flash.
if (finite.length < 2 || w === 0) {
return <div ref={wrapRef} className={className} style={{ height }} aria-hidden />;
}

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<SVGSVGElement>) => {
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 (
<svg
className={className}
width="100%"
height={height}
viewBox={`0 0 ${W} ${height}`}
preserveAspectRatio="none"
style={{ display: 'block', overflow: 'visible' }}
>
<defs>
<linearGradient id={`spark-${id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={color} stopOpacity="0.16" />
<stop offset="1" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
{baseline != null && Number.isFinite(baseline) && (
<line x1="0" y1={yOf(baseline)} x2={W} y2={yOf(baseline)} stroke="var(--chart-neutral)" strokeWidth="1" strokeDasharray="4 5" />
<div ref={wrapRef} className={className} style={{ position: 'relative' }}>
<svg
width="100%"
height={height}
viewBox={`0 0 ${w} ${height}`}
style={{ display: 'block', overflow: 'visible', touchAction: 'pan-y' }}
onPointerMove={onMove}
onPointerDown={onMove}
onPointerLeave={() => setActive(null)}
>
<defs>
<linearGradient id={`spark-${id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={color} stopOpacity="0.16" />
<stop offset="1" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
{baseline != null && Number.isFinite(baseline) && (
<line x1="0" y1={yOf(baseline)} x2={w} y2={yOf(baseline)} stroke="var(--chart-neutral)" strokeWidth="1" strokeDasharray="4 5" />
)}
{fill && <path d={area} fill={`url(#spark-${id})`} />}
<path d={line} fill="none" stroke={color} strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
{activePt ? (
<>
<line x1={activePt.x} y1="0" x2={activePt.x} y2={height} stroke="var(--chart-neutral)" strokeWidth="1" />
<circle cx={activePt.x} cy={activePt.y} r="4" fill="var(--surface)" stroke={color} strokeWidth="2.4" />
</>
) : (
marker && <circle cx={last.x} cy={last.y} r="3.6" fill="var(--surface)" stroke={color} strokeWidth="2.4" />
)}
</svg>
{activePt && (
<div
className="pointer-events-none absolute -top-1 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-lg px-2.5 py-1.5 text-center"
style={{
left: tipLeft,
background: 'var(--surface)',
boxShadow: 'var(--shadow-card), inset 0 0 0 1px var(--hairline)',
}}
>
{labels?.[activePt.i] && (
<div className="meta-mono leading-none mb-1">
{formatLabel ? formatLabel(labels[activePt.i]!) : labels[activePt.i]}
</div>
)}
<div className="num text-[13px] font-semibold leading-none text-ink">
{format ? format(activePt.v) : activePt.v}
</div>
</div>
)}
{fill && <path d={area} fill={`url(#spark-${id})`} />}
<path d={line} fill="none" stroke={color} strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
{marker && <circle cx={last.x} cy={last.y} r="3.6" fill="var(--surface)" stroke={color} strokeWidth="2.4" />}
</svg>
</div>
);
}
35 changes: 28 additions & 7 deletions apps/web/src/hooks/useAsk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
Expand All @@ -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 };
}
7 changes: 6 additions & 1 deletion apps/web/src/hooks/useConfigStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConfigStatus | null>(null);
const [status, setStatus] = useState<ConfigStatus | null | undefined>(undefined);
useEffect(() => {
apiGet<ConfigStatus>('/api/config/status')
.then(setStatus)
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/pages/AskPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@ export default function AskPage() {

return (
<div className="max-w-[780px] mx-auto px-4 sm:px-6">
{config?.claudeApiConfigured ? <AskClaude /> : <AskEmptyState />}
{config === undefined ? (
// Hold a stable, empty frame until config resolves — avoids flashing the
// setup empty-state for a beat before the chat mounts.
<div className="min-h-[60vh]" aria-busy="true" />
) : config?.claudeApiConfigured ? (
<AskClaude />
) : (
<AskEmptyState />
)}
</div>
);
}
10 changes: 9 additions & 1 deletion apps/web/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -225,7 +226,14 @@ export default function DashboardPage() {
<h3 className="section-heading text-[15px]">14-day HRV</h3>
<span className="meta-mono">{fmtNum(hrv.value, 0)} ms · baseline {fmtNum(hrv.baseline, 0)}</span>
</div>
<Sparkline values={hrvSeries} baseline={hrv.baseline} height={120} />
<Sparkline
values={hrvSeries}
labels={hrvDates}
baseline={hrv.baseline}
height={120}
format={(v) => `${fmtNum(v, 0)} ms`}
formatLabel={(d) => fmtDate(d, 'EEE, MMM d')}
/>
<div className="flex justify-between meta-mono mt-2">
<span>{fmtDate(daily[daily.length - 1]?.date ?? today, 'MMM d')}</span>
<span>{fmtDate(today, 'MMM d')}</span>
Expand Down
Loading
Loading