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
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
50 changes: 43 additions & 7 deletions apps/api/src/jobs/weekly-report.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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})`;
}
2 changes: 1 addition & 1 deletion apps/api/src/routes/habits.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/routes/ingest.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}
8 changes: 8 additions & 0 deletions apps/api/src/routes/insights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/routes/settings.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<boolean>(db, 'autoSyncEnabled', true),
Expand Down
1 change: 0 additions & 1 deletion apps/api/src/routes/vitals.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
87 changes: 87 additions & 0 deletions apps/api/src/services/weekly.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
2 changes: 2 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -22,6 +23,7 @@ export default function App() {
<Route index element={<DashboardPage />} />
<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 />} />
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/layout/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/shared/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ export function IconHabits(props: IconProps) {
);
}

/** Trends — rising trend line with an arrow head. */
export function IconTrends(props: IconProps) {
return (
<Svg {...props}>
<path d="M4 16.5l4.5-5 3.5 3 6.5-7.5" />
<path d="M14.5 6.5H19V11" />
</Svg>
);
}

/** Ask Claude — friendly sparkle. */
export function IconSparkle(props: IconProps) {
return (
Expand Down Expand Up @@ -345,6 +355,7 @@ export const NAV_ICONS = {
sleep: IconSleep,
activity: IconActivity,
habits: IconHabits,
trends: IconTrends,
sparkle: IconSparkle,
ask: IconAskAI,
} as const;
Expand Down
Loading
Loading