From 7bb8b71c40cd07bf23506c2f6604c7d6738ab55e Mon Sep 17 00:00:00 2001 From: 8tp <8tp@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:55:50 -0500 Subject: [PATCH 1/2] ci: add test suite + CI gate; fix fresh-DB seed crash; clean lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quality hardening for the public repo: - tests/: 17-test suite via built-in node:test + tsx (no new deps, resolution matches production). Covers confidence rules, the weighted-consensus normalizer against a real throwaway SQLite DB, and seed determinism. - .github/workflows/ci.yml: gates typecheck + lint + test + build on PRs and pushes to main, across Node 20 & 22 (honors engines). - Fix: `npm run db:seed` crashed on a fresh DB with a FOREIGN KEY failure — sleep_sessions/workouts FK daily_summary(date), but children were upserted before normalizeAndUpsert created the parent rows. Insert a parent stub at the top of the day loop; the normalizer fills real values via ON CONFLICT. This restores the QUICKSTART "5-minute fresh start". - Lint: 1 error + 4 warnings -> 0 (prefer-const, unused import, inline import() type annotations). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 53 +++++++++++++++++ apps/api/src/routes/habits.ts | 2 +- apps/api/src/routes/ingest.ts | 3 +- apps/api/src/routes/settings.ts | 3 +- apps/api/src/routes/vitals.ts | 1 - package.json | 1 + packages/db/src/queries/habits.ts | 2 +- scripts/seed_demo_data.ts | 4 ++ tests/confidence.test.ts | 75 +++++++++++++++++++++++ tests/consensus.test.ts | 98 +++++++++++++++++++++++++++++++ tests/seed.test.ts | 65 ++++++++++++++++++++ 11 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/confidence.test.ts create mode 100644 tests/consensus.test.ts create mode 100644 tests/seed.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..744721f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +# Quality gate for every PR and push to main: typecheck, lint, tests, build. +# Keeps the OSS surface green for contributors. + +on: + pull_request: + push: + branches: [main] + +# Cancel superseded runs on the same ref to save minutes. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Honor the engines field (>=20) — guard against accidental newer-only APIs. + node: [20, 22] + name: verify (node ${{ matrix.node }}) + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node ${{ matrix.node }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + + # better-sqlite3 ships prebuilt binaries for current Node LTS lines, so this + # is normally download-only (no native compile needed). + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/apps/api/src/routes/habits.ts b/apps/api/src/routes/habits.ts index 95218f0..af20833 100644 --- a/apps/api/src/routes/habits.ts +++ b/apps/api/src/routes/habits.ts @@ -1,5 +1,5 @@ import type { FastifyPluginAsync } from 'fastify'; -import { z } from 'zod'; +import type { z } from 'zod'; import { queries } from '@vcc/db'; import { schemas } from '@vcc/shared'; import { parseRange, todayIso } from '../lib/range.js'; diff --git a/apps/api/src/routes/ingest.ts b/apps/api/src/routes/ingest.ts index 757655d..8eeea73 100644 --- a/apps/api/src/routes/ingest.ts +++ b/apps/api/src/routes/ingest.ts @@ -1,4 +1,5 @@ import type { FastifyPluginAsync } from 'fastify'; +import type { Database } from 'better-sqlite3'; import { queries } from '@vcc/db'; import { ok, fail } from '../lib/envelope.js'; import { normalizeAndUpsert } from '../services/normalizer.js'; @@ -83,6 +84,6 @@ export const registerIngestRoutes: FastifyPluginAsync = async (app) => { }); }; -function ensureDailyStub(db: import('better-sqlite3').Database, date: string): void { +function ensureDailyStub(db: Database, date: string): void { db.prepare(`INSERT OR IGNORE INTO daily_summary (date, devices_active) VALUES (?, 0)`).run(date); } diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 8cd694a..8bd11c3 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -1,4 +1,5 @@ import type { FastifyPluginAsync } from 'fastify'; +import type { Database } from 'better-sqlite3'; import { z } from 'zod'; import { queries } from '@vcc/db'; import { INTEGRATION_IDS, type IntegrationId } from '@vcc/shared'; @@ -21,7 +22,7 @@ const appPatch = z.object({ aiAutoSummary: z.boolean().optional(), }); -function buildSettings(db: import('better-sqlite3').Database) { +function buildSettings(db: Database) { return { app: { autoSyncEnabled: queries.settings.getAppSettingJson(db, 'autoSyncEnabled', true), diff --git a/apps/api/src/routes/vitals.ts b/apps/api/src/routes/vitals.ts index 890758b..418f0f5 100644 --- a/apps/api/src/routes/vitals.ts +++ b/apps/api/src/routes/vitals.ts @@ -1,6 +1,5 @@ import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; -import { queries } from '@vcc/db'; import { DEVICE_SOURCES, type DeviceSource } from '@vcc/shared'; import { parseRange } from '../lib/range.js'; import { ok } from '../lib/envelope.js'; diff --git a/package.json b/package.json index dec0058..7f70648 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dev:mcp": "npm run dev --workspace apps/mcp-server", "build": "npm run build --workspaces --if-present", "typecheck": "tsc -b", + "test": "node --import tsx --test tests/*.test.ts", "lint": "eslint \"{apps,packages,scripts}/**/*.{ts,tsx}\"", "format": "prettier --write \"{apps,packages,scripts}/**/*.{ts,tsx,css,json,md}\"", "db:migrate": "npm run db:migrate --workspace packages/db", diff --git a/packages/db/src/queries/habits.ts b/packages/db/src/queries/habits.ts index 2d9143c..d787076 100644 --- a/packages/db/src/queries/habits.ts +++ b/packages/db/src/queries/habits.ts @@ -121,7 +121,7 @@ export function streaks(db: Database): HabitStreak[] { const dates = new Set(rows.map((r) => r.date)); let current = 0; const today = new Date().toISOString().slice(0, 10); - let cursor = new Date(today); + const cursor = new Date(today); while (dates.has(cursor.toISOString().slice(0, 10))) { current += 1; cursor.setDate(cursor.getDate() - 1); diff --git a/scripts/seed_demo_data.ts b/scripts/seed_demo_data.ts index 2407331..ee6f0b8 100644 --- a/scripts/seed_demo_data.ts +++ b/scripts/seed_demo_data.ts @@ -77,6 +77,10 @@ function main() { for (let i = 0; i < days; i++) { const date = addDays(start, i); + // workouts + sleep_sessions FK to daily_summary(date), so the parent row must + // exist before we upsert children. normalizeAndUpsert (below) later fills in + // the real consensus values via ON CONFLICT(date) DO UPDATE. + db.prepare('INSERT OR IGNORE INTO daily_summary (date, devices_active) VALUES (?, 0)').run(date); baselineHrv += gauss(rng, 0.02, 0.15); baselineRhr += gauss(rng, -0.01, 0.1); diff --git a/tests/confidence.test.ts b/tests/confidence.test.ts new file mode 100644 index 0000000..8aadf2b --- /dev/null +++ b/tests/confidence.test.ts @@ -0,0 +1,75 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + confidenceFromSources, + confidenceFromSpread, + accuracyWeight, +} from '@vcc/shared'; + +// Confidence is the load-bearing trust signal shown on every metric. These lock +// the SPEC rules so a refactor of the scoring can't silently shift them. + +describe('confidenceFromSources', () => { + it('returns NONE when no device contributed', () => { + assert.equal(confidenceFromSources([]), 'NONE'); + }); + + it('returns MEDIUM for a single source', () => { + assert.equal(confidenceFromSources(['fitbit']), 'MEDIUM'); + }); + + it('returns HIGH once two or more distinct sources agree', () => { + assert.equal(confidenceFromSources(['fitbit', 'oura']), 'HIGH'); + assert.equal(confidenceFromSources(['fitbit', 'oura', 'whoop']), 'HIGH'); + }); + + it('counts distinct devices, not raw entries (duplicates do not promote)', () => { + assert.equal(confidenceFromSources(['fitbit', 'fitbit']), 'MEDIUM'); + }); +}); + +describe('confidenceFromSpread', () => { + it('returns NONE with no finite readings', () => { + assert.equal(confidenceFromSpread([]), 'NONE'); + assert.equal(confidenceFromSpread([Number.NaN, Number.POSITIVE_INFINITY]), 'NONE'); + }); + + it('returns MEDIUM with a single finite reading', () => { + assert.equal(confidenceFromSpread([50]), 'MEDIUM'); + assert.equal(confidenceFromSpread([50, Number.NaN]), 'MEDIUM'); + }); + + it('returns HIGH when sources agree within the absolute tolerance', () => { + assert.equal(confidenceFromSpread([50, 52], { toleranceAbs: 5 }), 'HIGH'); + }); + + it('downgrades to LOW when sources diverge beyond tolerance', () => { + assert.equal(confidenceFromSpread([50, 60], { toleranceAbs: 5 }), 'LOW'); + }); + + it('honors a relative-percent tolerance', () => { + // spread 4 over mean 52 ≈ 7.7% — outside a 5% band → LOW + assert.equal(confidenceFromSpread([50, 54], { toleranceRelPct: 5 }), 'LOW'); + assert.equal(confidenceFromSpread([50, 51], { toleranceRelPct: 5 }), 'HIGH'); + }); +}); + +describe('accuracyWeight', () => { + it('ranks the primary source highest and decays down the list', () => { + assert.equal(accuracyWeight('hrv', 'fitbit'), 1.0); + assert.equal(accuracyWeight('hrv', 'oura'), 0.7); + assert.equal(accuracyWeight('hrv', 'whoop'), 0.5); + assert.equal(accuracyWeight('hrv', 'apple'), 0.3); + }); + + it('returns 0 for a device absent from a metric ranking', () => { + // strain is WHOOP-only — fitbit must not vote in it. + assert.equal(accuracyWeight('strain', 'fitbit'), 0); + }); + + it('falls back to the default source order for an unknown metric', () => { + // Unknown metric → DEVICE_SOURCES order [fitbit, whoop, oura, apple]. + assert.equal(accuracyWeight('made_up_metric', 'fitbit'), 1.0); + assert.equal(accuracyWeight('made_up_metric', 'whoop'), 0.7); + }); +}); diff --git a/tests/consensus.test.ts b/tests/consensus.test.ts new file mode 100644 index 0000000..10d2634 --- /dev/null +++ b/tests/consensus.test.ts @@ -0,0 +1,98 @@ +import { describe, it, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { rmSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import type { Database } from 'better-sqlite3'; +import { openDb, queries } from '@vcc/db'; +import { normalizeAndUpsert } from '../apps/api/src/services/normalizer.js'; + +// Spins a throwaway on-disk SQLite DB (migrations applied) and folds per-device +// rows through the SAME normalizer production sync uses, then reads the +// consensus back out — the true integration test for the weighted-average + +// confidence machinery. + +const dbs: { db: Database; path: string }[] = []; + +function freshDb(): Database { + const path = join(tmpdir(), `vitals-test-${randomUUID()}.db`); + const db = openDb({ path, migrate: true }); + dbs.push({ db, path }); + return db; +} + +after(() => { + for (const { db, path } of dbs) { + db.close(); + for (const suffix of ['', '-wal', '-shm']) { + try { + rmSync(path + suffix); + } catch { + /* best effort */ + } + } + } +}); + +const approx = (actual: number | null, expected: number, eps = 1e-6) => { + assert.ok(actual !== null, 'expected a numeric consensus, got null'); + assert.ok(Math.abs((actual as number) - expected) < eps, `${actual} ≉ ${expected}`); +}; + +describe('normalizeAndUpsert consensus', () => { + it('a single source yields MEDIUM confidence and mirrors its readings', () => { + const db = freshDb(); + normalizeAndUpsert(db, { + fitbit: [{ date: '2026-01-01', hrv: 50, rhr: 55, sleepHours: 7.5 }] as never, + }); + const day = queries.dailySummary.get(db, '2026-01-01'); + assert.ok(day); + assert.equal(day!.devices.active, 1); + assert.equal(day!.devices.fitbit, true); + assert.equal(day!.consensus.level, 'MEDIUM'); + approx(day!.consensus.hrv, 50); + approx(day!.consensus.rhr, 55); + approx(day!.consensus.sleepHours, 7.5); + }); + + it('two sources produce HIGH confidence and an accuracy-weighted mean', () => { + const db = freshDb(); + normalizeAndUpsert(db, { + fitbit: [{ date: '2026-02-01', hrv: 40, rhr: 50, sleepHours: 8 }] as never, + oura: [{ date: '2026-02-01', hrv: 60, rhr: 60, sleepHours: 7 }] as never, + }); + const day = queries.dailySummary.get(db, '2026-02-01'); + assert.ok(day); + assert.equal(day!.devices.active, 2); + assert.equal(day!.consensus.level, 'HIGH'); + // weights: fitbit 1.0, oura 0.7 → (v_fb*1.0 + v_oura*0.7) / 1.7 + approx(day!.consensus.hrv, (40 * 1.0 + 60 * 0.7) / 1.7); + approx(day!.consensus.rhr, (50 * 1.0 + 60 * 0.7) / 1.7); + approx(day!.consensus.sleepHours, (8 * 1.0 + 7 * 0.7) / 1.7); + }); + + it('treats a 0h sleep value as "no data" and excludes it from consensus', () => { + const db = freshDb(); + normalizeAndUpsert(db, { + fitbit: [{ date: '2026-03-01', hrv: 48, rhr: 52, sleepHours: 0 }] as never, + }); + const day = queries.dailySummary.get(db, '2026-03-01'); + assert.ok(day); + assert.equal(day!.consensus.sleepHours, null); // 0 dropped, not averaged in + approx(day!.consensus.hrv, 48); // other metrics still resolve + }); + + it('a missing device is not an anomaly — partial coverage still resolves', () => { + const db = freshDb(); + normalizeAndUpsert(db, { + fitbit: [{ date: '2026-04-01', hrv: 55, rhr: 58, sleepHours: 6.5 }] as never, + // oura/whoop/apple absent this day on purpose + }); + const day = queries.dailySummary.get(db, '2026-04-01'); + assert.ok(day); + assert.notEqual(day!.consensus.level, 'NONE'); + assert.equal(day!.devices.oura, false); + assert.equal(day!.devices.active, 1); + }); +}); diff --git a/tests/seed.test.ts b/tests/seed.test.ts new file mode 100644 index 0000000..cdf5846 --- /dev/null +++ b/tests/seed.test.ts @@ -0,0 +1,65 @@ +import { describe, it, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { rmSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import Database from 'better-sqlite3'; + +// The README promises "deterministic seed → reviewable diffs". This proves it: +// seeding twice into fresh DBs must produce byte-identical consensus rows +// (everything except the per-run synced_at timestamp). + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const seedScript = join(repoRoot, 'scripts', 'seed_demo_data.ts'); +const created: string[] = []; + +function seedInto(): string { + const dbPath = join(tmpdir(), `vitals-seed-${randomUUID()}.db`); + created.push(dbPath); + execFileSync(process.execPath, ['--import', 'tsx', seedScript, '10'], { + cwd: repoRoot, + // Explicit DB_PATH wins: dotenv.config() never overrides an existing env var. + env: { ...process.env, DB_PATH: dbPath }, + stdio: 'ignore', + }); + return dbPath; +} + +function dailyRows(dbPath: string): Record[] { + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + try { + const rows = db.prepare('SELECT * FROM daily_summary ORDER BY date').all() as Record< + string, + unknown + >[]; + // synced_at is stamped at write time and is expected to differ between runs. + for (const r of rows) delete r.synced_at; + return rows; + } finally { + db.close(); + } +} + +after(() => { + for (const p of created) { + for (const suffix of ['', '-wal', '-shm']) { + try { + rmSync(p + suffix); + } catch { + /* best effort */ + } + } + } +}); + +describe('demo seed determinism', () => { + it('produces identical consensus rows across two independent runs', () => { + const a = dailyRows(seedInto()); + const b = dailyRows(seedInto()); + assert.ok(a.length > 0, 'seed produced no daily rows'); + assert.deepEqual(a, b); + }); +}); From 3f47ef59bc82e6cec09c36a6357ad7c2a94875ac Mon Sep 17 00:00:00 2001 From: 8tp <8tp@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:02:16 -0500 Subject: [PATCH 2/2] feat(trends): Trends page + deterministic weekly digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a sixth nav surface and the weekly digest the weekly-report stub promised. Backend: - services/weekly.ts: computeWeeklySummary — trailing-7-day averages of each consensus metric (HRV, RHR, sleep) vs the prior 7 days, with direction + good/bad tone and best/shortest sleep night. Pure read + arithmetic, no AI provider needed. - GET /api/insights/weekly: computes on demand so the page renders even before the weekly cron runs. - weekly-report.ts: replace the placeholder stub with the real computation, stored as a markdown digest + structured snapshot. - shared: WeeklyMetric / WeeklySummary types. Frontend: - pages/TrendsPage.tsx at /trends: "this week vs last" digest cards (TrendIndicator) + per-metric 30-day consensus sparklines off the existing /api/vitals endpoint. Built from existing PageHeader/Sparkline/Metric so it matches DESIGN.md. - IconTrends + nav entry + route. Tests: tests/weekly.test.ts (3) — week-over-week deltas, empty-week nulls, best/shortest night. Suite now 20 tests. Verified end-to-end over HTTP. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/jobs/weekly-report.ts | 50 ++++- apps/api/src/routes/insights.ts | 8 + apps/api/src/services/weekly.ts | 87 +++++++++ apps/web/src/App.tsx | 2 + apps/web/src/components/layout/nav.ts | 1 + apps/web/src/components/shared/icons.tsx | 11 ++ apps/web/src/pages/TrendsPage.tsx | 231 +++++++++++++++++++++++ packages/shared/src/types/briefings.ts | 29 +++ tests/weekly.test.ts | 95 ++++++++++ 9 files changed, 507 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/services/weekly.ts create mode 100644 apps/web/src/pages/TrendsPage.tsx create mode 100644 tests/weekly.test.ts diff --git a/apps/api/src/jobs/weekly-report.ts b/apps/api/src/jobs/weekly-report.ts index be28a27..17a62aa 100644 --- a/apps/api/src/jobs/weekly-report.ts +++ b/apps/api/src/jobs/weekly-report.ts @@ -1,18 +1,54 @@ import type { Database } from 'better-sqlite3'; import type { FastifyBaseLogger } from 'fastify'; +import type { WeeklyMetric, WeeklySummary } from '@vcc/shared'; import { queries } from '@vcc/db'; -import { addDaysIso, todayIso } from '../lib/range.js'; +import { todayIso } from '../lib/range.js'; +import { computeWeeklySummary } from '../services/weekly.js'; export async function runWeeklyReport(db: Database, log: FastifyBaseLogger): Promise { const end = todayIso(); - const start = addDaysIso(end, -6); - const week = queries.dailySummary.list(db, start, end); - log.info({ start, end, rows: week.length }, 'weekly report: assembling'); - // Phase 3 fills this in with trend + correlation output. For now stash a placeholder. + const summary = computeWeeklySummary(db, end); + log.info( + { start: summary.start, end: summary.end, days: summary.daysWithData }, + 'weekly report: assembled', + ); queries.briefings.store(db, { date: end, type: 'weekly', - content: `Weekly report stub ${start}..${end} (${week.length} days).`, - metricsSnapshot: { start, end, week }, + content: renderWeeklyMarkdown(summary), + metricsSnapshot: summary, }); } + +/** A terse markdown digest from the computed summary — what the brief surfaces. */ +function renderWeeklyMarkdown(s: WeeklySummary): string { + const lines: string[] = [`## Weekly digest · ${s.start} → ${s.end}`, '']; + if (s.daysWithData === 0) { + lines.push('_No device data this week._'); + return lines.join('\n'); + } + lines.push(`Coverage: ${s.daysWithData}/7 days with data.`, ''); + for (const m of s.metrics) { + lines.push(`- ${metricLine(m)}`); + } + if (s.bestSleep || s.worstSleep) { + lines.push(''); + if (s.bestSleep) lines.push(`- Best night: ${s.bestSleep.hours}h (${s.bestSleep.date})`); + if (s.worstSleep) lines.push(`- Shortest night: ${s.worstSleep.hours}h (${s.worstSleep.date})`); + } + return lines.join('\n'); +} + +function metricLine(m: WeeklyMetric): string { + if (m.avg == null) return `${m.label}: no data`; + const head = `${m.label}: ${m.avg}${m.unit}`; + if (m.deltaPct == null) return `${head} (no prior week)`; + const arrow = m.direction === 'up' ? '▲' : m.direction === 'down' ? '▼' : '–'; + const better = + m.direction === 'flat' + ? 'steady' + : (m.direction === 'up') === (m.betterWhen === 'higher') + ? 'improving' + : 'declining'; + return `${head} ${arrow} ${Math.abs(m.deltaPct)}% vs last week (${better})`; +} diff --git a/apps/api/src/routes/insights.ts b/apps/api/src/routes/insights.ts index 727bab3..557371f 100644 --- a/apps/api/src/routes/insights.ts +++ b/apps/api/src/routes/insights.ts @@ -4,6 +4,7 @@ import { ok } from '../lib/envelope.js'; import { todayIso } from '../lib/range.js'; import { buildInsightsForDate } from '../services/insights.js'; import { generateLocalBrief } from '../services/localBrief.js'; +import { computeWeeklySummary } from '../services/weekly.js'; export const registerInsightsRoutes: FastifyPluginAsync = async (app) => { app.get('/insights/today', async (req) => { @@ -14,6 +15,13 @@ export const registerInsightsRoutes: FastifyPluginAsync = async (app) => { return ok({ date, summary, briefing, insights }); }); + // Deterministic weekly digest, computed on demand (no AI provider needed) so + // the Trends page renders even if the weekly cron hasn't run yet. + app.get('/insights/weekly', async (req) => { + const { end } = req.query as { end?: string }; + return ok(computeWeeklySummary(req.server.db, end || todayIso())); + }); + app.get('/insights/briefing/:date', async (req, reply) => { const { date } = req.params as { date: string }; const briefing = queries.briefings.latestOfType(req.server.db, 'daily', date); diff --git a/apps/api/src/services/weekly.ts b/apps/api/src/services/weekly.ts new file mode 100644 index 0000000..53e54ce --- /dev/null +++ b/apps/api/src/services/weekly.ts @@ -0,0 +1,87 @@ +import type { Database } from 'better-sqlite3'; +import type { NormalizedDailySummary, WeeklyMetric, WeeklySummary } from '@vcc/shared'; +import { queries } from '@vcc/db'; +import { addDaysIso, todayIso } from '../lib/range.js'; + +// Deterministic, AI-free weekly digest: average each consensus metric over the +// trailing 7 days and compare to the 7 days before that. Pure read + arithmetic +// so it always works (no provider needed) and is easy to test. + +interface MetricSpec { + key: string; + label: string; + unit: string; + betterWhen: 'higher' | 'lower'; + pick: (d: NormalizedDailySummary) => number | null; +} + +const METRICS: MetricSpec[] = [ + { key: 'hrv', label: 'HRV', unit: 'ms', betterWhen: 'higher', pick: (d) => d.consensus.hrv }, + { key: 'rhr', label: 'Resting HR', unit: 'bpm', betterWhen: 'lower', pick: (d) => d.consensus.rhr }, + { + key: 'sleep', + label: 'Sleep', + unit: 'h', + betterWhen: 'higher', + pick: (d) => d.consensus.sleepHours, + }, +]; + +function mean(values: number[]): number | null { + return values.length ? values.reduce((a, b) => a + b, 0) / values.length : null; +} + +function round(v: number | null, dp = 1): number | null { + if (v == null) return null; + const f = 10 ** dp; + return Math.round(v * f) / f; +} + +function buildMetric(spec: MetricSpec, week: NormalizedDailySummary[], prev: NormalizedDailySummary[]): WeeklyMetric { + const cur = week.map(spec.pick).filter((v): v is number => v != null && Number.isFinite(v)); + const before = prev.map(spec.pick).filter((v): v is number => v != null && Number.isFinite(v)); + const avg = mean(cur); + const prevAvg = mean(before); + const deltaPct = avg != null && prevAvg != null && prevAvg !== 0 ? ((avg - prevAvg) / Math.abs(prevAvg)) * 100 : null; + const direction = deltaPct == null ? 'flat' : deltaPct > 1 ? 'up' : deltaPct < -1 ? 'down' : 'flat'; + return { + key: spec.key, + label: spec.label, + unit: spec.unit, + avg: round(avg, spec.key === 'sleep' ? 1 : 0), + prevAvg: round(prevAvg, spec.key === 'sleep' ? 1 : 0), + deltaPct: round(deltaPct, 1), + direction, + betterWhen: spec.betterWhen, + samples: cur.length, + }; +} + +/** + * Compute the trailing-7-day digest ending at `end` (default today), comparing + * each metric to the prior 7-day window. + */ +export function computeWeeklySummary(db: Database, end: string = todayIso()): WeeklySummary { + const start = addDaysIso(end, -6); + const prevStart = addDaysIso(end, -13); + const prevEnd = addDaysIso(end, -7); + + const week = queries.dailySummary.list(db, start, end); + const prev = queries.dailySummary.list(db, prevStart, prevEnd); + + const daysWithData = week.filter((d) => d.devices.active > 0).length; + const metrics = METRICS.map((m) => buildMetric(m, week, prev)); + + // Sleep highs/lows from this week's consensus hours. + const nights = week + .map((d) => ({ date: d.date, hours: d.consensus.sleepHours })) + .filter((n): n is { date: string; hours: number } => n.hours != null && n.hours > 0); + let bestSleep: WeeklySummary['bestSleep'] = null; + let worstSleep: WeeklySummary['worstSleep'] = null; + for (const n of nights) { + if (!bestSleep || n.hours > bestSleep.hours) bestSleep = { date: n.date, hours: round(n.hours, 1)! }; + if (!worstSleep || n.hours < worstSleep.hours) worstSleep = { date: n.date, hours: round(n.hours, 1)! }; + } + + return { start, end, daysWithData, metrics, bestSleep, worstSleep }; +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 86f0e2b..f16070b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { Layout } from './components/layout/Layout.js'; 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'; @@ -22,6 +23,7 @@ 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/layout/nav.ts b/apps/web/src/components/layout/nav.ts index 8887f98..bdca6f6 100644 --- a/apps/web/src/components/layout/nav.ts +++ b/apps/web/src/components/layout/nav.ts @@ -5,6 +5,7 @@ export const NAV = [ { to: '/', label: 'Dashboard', short: 'Home', icon: 'home' }, { 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<{ diff --git a/apps/web/src/components/shared/icons.tsx b/apps/web/src/components/shared/icons.tsx index ead7a00..bc13963 100644 --- a/apps/web/src/components/shared/icons.tsx +++ b/apps/web/src/components/shared/icons.tsx @@ -80,6 +80,16 @@ export function IconHabits(props: IconProps) { ); } +/** Trends — rising trend line with an arrow head. */ +export function IconTrends(props: IconProps) { + return ( + + + + + ); +} + /** Ask Claude — friendly sparkle. */ export function IconSparkle(props: IconProps) { return ( @@ -345,6 +355,7 @@ export const NAV_ICONS = { sleep: IconSleep, activity: IconActivity, habits: IconHabits, + trends: IconTrends, sparkle: IconSparkle, ask: IconAskAI, } as const; diff --git a/apps/web/src/pages/TrendsPage.tsx b/apps/web/src/pages/TrendsPage.tsx new file mode 100644 index 0000000..45e8192 --- /dev/null +++ b/apps/web/src/pages/TrendsPage.tsx @@ -0,0 +1,231 @@ +import { useEffect, useState } from 'react'; +import type { WeeklySummary } from '@vcc/shared'; +import { apiGet } from '../lib/api.js'; +import { PageHeader, HeaderDate } from '../components/layout/PageHeader.js'; +import { Sparkline } from '../components/shared/Sparkline.js'; +import { TrendIndicator } from '../components/shared/TrendIndicator.js'; +import { fmtDate, fmtNum } from '../lib/formatters.js'; + +const SECTION = 'px-6 md:px-10 py-7 border-b border-hairline animate-fade-rise'; + +/** Trend charts read a longer window than the dashboard's daily view. */ +const RANGE = '30d'; + +interface VitalsResponse { + metric: string; + range: { start: string; end: string; days: number }; + points: Array<{ date: string; value: number | null; source: string }>; + movingAverage7d: Array<{ date: string; value: number | null }>; + delta: { pct: number | null; direction: 'up' | 'down' | 'flat' }; +} + +interface ChartSpec { + metric: string; + label: string; + unit: string; + upIsGood: boolean; + dp: number; +} + +const CHARTS: ChartSpec[] = [ + { metric: 'hrv', label: 'HRV', unit: 'ms', upIsGood: true, dp: 0 }, + { metric: 'rhr', label: 'Resting HR', unit: 'bpm', upIsGood: false, dp: 0 }, + { metric: 'sleep_hours', label: 'Sleep', unit: 'h', upIsGood: true, dp: 1 }, +]; + +/** Pull the consensus series (oldest→newest) out of a /api/vitals response. */ +function consensusSeries(resp: VitalsResponse): { dates: string[]; values: (number | null)[] } { + const consensus = resp.points.filter((p) => p.source === 'consensus'); + return { dates: consensus.map((p) => p.date), values: consensus.map((p) => p.value) }; +} + +export default function TrendsPage() { + const [weekly, setWeekly] = useState(null); + const [charts, setCharts] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + Promise.all([ + apiGet('/api/insights/weekly'), + ...CHARTS.map((c) => + apiGet(`/api/vitals?metric=${c.metric}&range=${RANGE}`), + ), + ]) + .then(([week, ...series]) => { + if (cancelled) return; + setWeekly(week); + const map: Record = {}; + series.forEach((s, i) => { + map[CHARTS[i]!.metric] = s; + }); + setCharts(map); + setError(null); + }) + .catch((e: unknown) => { + if (!cancelled) setError(e instanceof Error ? e.message : 'failed to load trends'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + if (error) { + return ( +
+
+ We couldn’t reach your trend data ({error}). Start the backend with{' '} + npm run dev:api. +
+
+ ); + } + + if (loading && !weekly) { + return ( +
+
+
+
+ {[0, 1, 2].map((i) => ( +
+
+
+ ))} +
+ ); + } + + const coverage = weekly ? `${weekly.daysWithData}/7 days` : '—'; + + return ( +
+ } + /> + + {/* Weekly digest — deterministic week-over-week movement */} +
+
+

This week vs last

+ {weekly && ( + + {fmtDate(weekly.start, 'MMM d')} – {fmtDate(weekly.end, 'MMM d')} + + )} +
+ + {weekly && weekly.daysWithData > 0 ? ( + <> +
+ {weekly.metrics.map((m) => ( +
+
{m.label}
+
+ + {m.avg != null ? fmtNum(m.avg, m.key === 'sleep' ? 1 : 0) : '—'} + + {m.avg != null && {m.unit}} +
+
+ + + {m.prevAvg != null ? `from ${fmtNum(m.prevAvg, m.key === 'sleep' ? 1 : 0)}${m.unit}` : 'no prior week'} + +
+
+ ))} +
+ + {(weekly.bestSleep || weekly.worstSleep) && ( +
+ {weekly.bestSleep && ( + + Best night {weekly.bestSleep.hours}h ·{' '} + {fmtDate(weekly.bestSleep.date, 'EEE MMM d')} + + )} + {weekly.worstSleep && ( + + Shortest {weekly.worstSleep.hours}h ·{' '} + {fmtDate(weekly.worstSleep.date, 'EEE MMM d')} + + )} +
+ )} + + ) : ( +

+ No device data in the last week yet — sync a source to see week-over-week movement. +

+ )} +
+ + {/* Per-metric trend charts over the longer window */} + {CHARTS.map((c, idx) => { + const resp = charts[c.metric]; + const series = resp ? consensusSeries(resp) : { dates: [], values: [] }; + const finite = series.values.filter((v): v is number => v != null && Number.isFinite(v)); + const latest = [...series.values].reverse().find((v) => v != null) ?? null; + const avg = finite.length ? finite.reduce((a, b) => a + b, 0) / finite.length : null; + const last = idx === CHARTS.length - 1; + return ( +
+
+
+

{c.label}

+ {resp && ( + + )} +
+ + {latest != null ? `${fmtNum(latest, c.dp)}${c.unit} now` : '—'} + {avg != null ? ` · ${fmtNum(avg, c.dp)}${c.unit} avg` : ''} + +
+ {finite.length >= 2 ? ( + <> + +
+ {series.dates[0] ? fmtDate(series.dates[0], 'MMM d') : ''} + + {series.dates[series.dates.length - 1] + ? fmtDate(series.dates[series.dates.length - 1]!, 'MMM d') + : ''} + +
+ + ) : ( +

+ Two or more days of data will draw your {c.label.toLowerCase()} trend here. +

+ )} +
+ ); + })} +
+ ); +} diff --git a/packages/shared/src/types/briefings.ts b/packages/shared/src/types/briefings.ts index 60694f1..9e9d525 100644 --- a/packages/shared/src/types/briefings.ts +++ b/packages/shared/src/types/briefings.ts @@ -9,6 +9,35 @@ export interface BriefingRecord { createdAt: string; } +/** One metric's week-over-week movement in the weekly digest. */ +export interface WeeklyMetric { + key: string; + label: string; + unit: string; + /** Mean across days with data this week, or null if none. */ + avg: number | null; + /** Mean across the prior 7-day window, or null. */ + prevAvg: number | null; + /** Percent change vs prior week, or null when not computable. */ + deltaPct: number | null; + direction: 'up' | 'down' | 'flat'; + /** Which direction is healthier — drives the tone (green/amber) in the UI. */ + betterWhen: 'higher' | 'lower'; + /** Days with a reading this week (out of 7). */ + samples: number; +} + +/** Deterministic 7-day digest: per-metric movement vs the prior week + sleep highs/lows. */ +export interface WeeklySummary { + start: string; + end: string; + /** Days in the window with any device data. */ + daysWithData: number; + metrics: WeeklyMetric[]; + bestSleep: { date: string; hours: number } | null; + worstSleep: { date: string; hours: number } | null; +} + export interface InsightItem { id: string; severity: 'green' | 'amber' | 'red' | 'blue'; diff --git a/tests/weekly.test.ts b/tests/weekly.test.ts new file mode 100644 index 0000000..981a51e --- /dev/null +++ b/tests/weekly.test.ts @@ -0,0 +1,95 @@ +import { describe, it, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { rmSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import type { Database } from 'better-sqlite3'; +import { openDb } from '@vcc/db'; +import { normalizeAndUpsert } from '../apps/api/src/services/normalizer.js'; +import { computeWeeklySummary } from '../apps/api/src/services/weekly.js'; + +// Deterministic week-over-week digest: this week vs the prior 7 days. + +const dbs: { db: Database; path: string }[] = []; + +function freshDb(): Database { + const path = join(tmpdir(), `vitals-weekly-${randomUUID()}.db`); + const db = openDb({ path, migrate: true }); + dbs.push({ db, path }); + return db; +} + +function addDays(date: string, n: number): string { + const d = new Date(`${date}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + n); + return d.toISOString().slice(0, 10); +} + +after(() => { + for (const { db, path } of dbs) { + db.close(); + for (const suffix of ['', '-wal', '-shm']) { + try { + rmSync(path + suffix); + } catch { + /* best effort */ + } + } + } +}); + +describe('computeWeeklySummary', () => { + const END = '2026-06-22'; + + it('averages each metric and compares to the prior week', () => { + const db = freshDb(); + // This week (END-6..END): hrv 60, rhr 50, sleep 8. Prior week: hrv 50, rhr 55, sleep 7. + const fitbit: unknown[] = []; + for (let i = 0; i <= 6; i++) fitbit.push({ date: addDays(END, -i), hrv: 60, rhr: 50, sleepHours: 8 }); + for (let i = 7; i <= 13; i++) fitbit.push({ date: addDays(END, -i), hrv: 50, rhr: 55, sleepHours: 7 }); + normalizeAndUpsert(db, { fitbit: fitbit as never }); + + const summary = computeWeeklySummary(db, END); + assert.equal(summary.daysWithData, 7); + + const hrv = summary.metrics.find((m) => m.key === 'hrv')!; + assert.equal(hrv.avg, 60); + assert.equal(hrv.prevAvg, 50); + assert.equal(hrv.deltaPct, 20); // (60-50)/50 + assert.equal(hrv.direction, 'up'); + assert.equal(hrv.betterWhen, 'higher'); // up = improving + + const rhr = summary.metrics.find((m) => m.key === 'rhr')!; + assert.equal(rhr.direction, 'down'); // 55 → 50 + assert.equal(rhr.betterWhen, 'lower'); // down = improving + }); + + it('reports null deltas and zero coverage on an empty week', () => { + const db = freshDb(); + const summary = computeWeeklySummary(db, END); + assert.equal(summary.daysWithData, 0); + for (const m of summary.metrics) { + assert.equal(m.avg, null); + assert.equal(m.deltaPct, null); + assert.equal(m.direction, 'flat'); + } + assert.equal(summary.bestSleep, null); + }); + + it('picks the best and shortest sleep night of the week', () => { + const db = freshDb(); + normalizeAndUpsert(db, { + fitbit: [ + { date: addDays(END, -1), hrv: 55, rhr: 52, sleepHours: 8.5 }, + { date: addDays(END, -2), hrv: 55, rhr: 52, sleepHours: 5.2 }, + { date: addDays(END, -3), hrv: 55, rhr: 52, sleepHours: 7.1 }, + ] as never, + }); + const summary = computeWeeklySummary(db, END); + assert.equal(summary.bestSleep?.hours, 8.5); + assert.equal(summary.bestSleep?.date, addDays(END, -1)); + assert.equal(summary.worstSleep?.hours, 5.2); + assert.equal(summary.worstSleep?.date, addDays(END, -2)); + }); +});