diff --git a/eslint-plugins/healthlog/api-fetch-required.js b/eslint-plugins/healthlog/api-fetch-required.js index 028bb33ce..67da72ca5 100644 --- a/eslint-plugins/healthlog/api-fetch-required.js +++ b/eslint-plugins/healthlog/api-fetch-required.js @@ -43,8 +43,16 @@ const EXEMPT_FILES = ["src/lib/api/api-fetch.ts"]; // server-side lib code never self-calls `/api/` routes, and the // safe-fetch rule already covers its outbound calls. `src/hooks/` is // client-only by construction (v1.16.4) — its `/api/` reads route -// through the wrapper like every component. -const ENFORCED_ROOTS = ["src/components/", "src/app/", "src/hooks/"]; +// through the wrapper like every component. `src/lib/queries/` is the +// one `src/lib/` exception: it holds client-only TanStack-Query hooks +// (every file is `"use client"`), so its `/api/` reads route through the +// wrapper too. +const ENFORCED_ROOTS = [ + "src/components/", + "src/app/", + "src/hooks/", + "src/lib/queries/", +]; function toPosix(filename) { return filename.replace(/\\/g, "/"); diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx index d42000c7f..2c389dbc1 100644 --- a/src/components/settings/sharing-section.tsx +++ b/src/components/settings/sharing-section.tsx @@ -23,7 +23,6 @@ */ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import QRCode from "qrcode"; import { Copy, KeyRound, Loader2, Share2, Trash2 } from "lucide-react"; import { @@ -136,7 +135,13 @@ function ShareLinksCard() { useEffect(() => { if (!qrUrl) return; let cancelled = false; - QRCode.toDataURL(qrUrl, { margin: 1, width: 220 }) + // Dynamic import so `qrcode` code-splits out of the settings chunk — the + // QR is only ever rendered after a link is created, so the library never + // needs to ship eagerly. + import("qrcode") + .then(({ default: QRCode }) => + QRCode.toDataURL(qrUrl, { margin: 1, width: 220 }), + ) .then((url) => { if (!cancelled) setQrDataUrl(url); }) diff --git a/src/lib/insights/derived/readiness.ts b/src/lib/insights/derived/readiness.ts index 80c22acd4..87e1c5603 100644 --- a/src/lib/insights/derived/readiness.ts +++ b/src/lib/insights/derived/readiness.ts @@ -40,6 +40,8 @@ import { type RollupCoverageMap, } from "@/lib/rollups/measurement-coverage"; import { computeMoodStability } from "@/lib/insights/mood-aggregates"; +import { MS_PER_DAY } from "@/lib/time-constants"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; import { buildInsufficient, buildOk, @@ -50,7 +52,6 @@ import { computeVitalsBaseline, type BaselineProfile } from "./baseline"; import { computeSleepScore } from "./sleep-score"; import type { Derived, DerivedProvenanceSource } from "./types"; -const MS_PER_DAY = 24 * 60 * 60 * 1000; const DEFAULT_WINDOW_DAYS = 30; /** * Row cap for the latest-day mean read. A dense intra-day day (high-frequency @@ -171,6 +172,7 @@ async function readLatestDayMean( type: MeasurementType, windowDays: number, now: Date, + tz: string, ): Promise { const since = new Date(now.getTime() - windowDays * MS_PER_DAY); const rows = await prisma.measurement.findMany({ @@ -181,13 +183,15 @@ async function readLatestDayMean( }); if (rows.length === 0) return null; // Derive the most-recent day defensively (do not assume the DB ordering). + // Day-keys are minted in the user's timezone so a 23:30-local reading lands + // on the right "today" bucket regardless of UTC offset. let latestDay = ""; for (const r of rows) { - const d = r.measuredAt.toISOString().slice(0, 10); + const d = userDayKey(r.measuredAt, tz); if (d > latestDay) latestDay = d; } const sameDay = rows.filter( - (r) => r.measuredAt.toISOString().slice(0, 10) === latestDay, + (r) => userDayKey(r.measuredAt, tz) === latestDay, ); return sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length; } @@ -216,6 +220,7 @@ async function scoreVitalDeviation( windowDays: number, now: Date, coverage: RollupCoverageMap, + tz: string, ): Promise<{ value: number | null; source: DerivedProvenanceSource; @@ -235,7 +240,7 @@ async function scoreVitalDeviation( historyDays: baseline.coverage.historyDays, }; } - const today = await readLatestDayMean(userId, type, windowDays, now); + const today = await readLatestDayMean(userId, type, windowDays, now, tz); // No recent reading, or a non-finite baseline center / today value: there // is no scorable deviation. Return null (hidden) rather than letting a NaN // fall through `scoreDeviation` → `clamp100`, which would surface a @@ -271,6 +276,13 @@ export interface ReadinessOpts { now?: Date; /** Pre-probed coverage (shared across metrics in one request). */ coverage?: RollupCoverageMap; + /** + * IANA timezone the "same day" grouping is keyed in, so a 23:30-local + * reading lands on the correct readiness day. Defaults to `Europe/Berlin` + * (the canonical tenant zone) when the caller has not resolved the user's + * zone. + */ + tz?: string; } /** @@ -284,6 +296,7 @@ export async function computeReadiness( ): Promise> { const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS; const now = opts.now ?? new Date(); + const tz = opts.tz ?? DEFAULT_TIMEZONE; const computedAt = nowProvenanceTimestamp(now); const coverage = opts.coverage ?? (await probeRollupCoverage(userId)); @@ -305,6 +318,7 @@ export async function computeReadiness( windowDays, now, coverage, + tz, ), scoreVitalDeviation( userId, @@ -314,6 +328,7 @@ export async function computeReadiness( windowDays, now, coverage, + tz, ), scoreVitalDeviation( userId, @@ -323,6 +338,7 @@ export async function computeReadiness( windowDays, now, coverage, + tz, ), ]); diff --git a/src/lib/insights/mood-narratives.ts b/src/lib/insights/mood-narratives.ts index 43ed97069..243d26e22 100644 --- a/src/lib/insights/mood-narratives.ts +++ b/src/lib/insights/mood-narratives.ts @@ -16,6 +16,8 @@ import { dayOffsetToBerlinDayKey } from "@/lib/insights/bucket-series"; import { round } from "@/lib/insights/status-shared"; +import { MS_PER_DAY } from "@/lib/time-constants"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; import type { StructuredTagRow, TagSummaryRow, @@ -80,6 +82,13 @@ export interface MoodNarrativeInput { /** Day keys (YYYY-MM-DD) the user logged a mood on. */ loggedDayKeys: string[]; now: Date; + /** + * IANA timezone the streak anchor + day-walk are keyed in, so the "today" + * key matches the tz-anchored `loggedDayKeys` for a logger east/west of + * UTC near midnight. Defaults to `Europe/Berlin` when the caller has not + * resolved the user's zone. + */ + tz?: string; } /** i18n label keys for each part-of-day bucket. */ @@ -100,8 +109,6 @@ const WEEKDAY_LABEL_KEYS = [ "charts.weekdaysFull.sun", ] as const; -const MS_PER_DAY = 24 * 60 * 60 * 1000; - function mean(values: number[]): number | null { if (values.length === 0) return null; return values.reduce((sum, v) => sum + v, 0) / values.length; @@ -349,22 +356,29 @@ function inTargetNarrative(input: MoodNarrativeInput): MoodNarrative | null { function streakNarrative(input: MoodNarrativeInput): MoodNarrative | null { if (input.loggedDayKeys.length === 0) return null; + const tz = input.tz ?? DEFAULT_TIMEZONE; const keys = new Set(input.loggedDayKeys); - const todayKey = input.now.toISOString().slice(0, 10); - const todayMs = Date.parse(todayKey + "T00:00:00Z"); + // Anchor "today" in the user's timezone so the key matches the tz-anchored + // `loggedDayKeys` for a late-evening logger east/west of UTC. + const todayKey = userDayKey(input.now, tz); + // Walk the cursor from a NOON-UTC anchor of the day key: ±12h covers every + // IANA offset, so `new Date(cursor)` always re-derives the same calendar day + // in the user's zone regardless of whether they are east or west of UTC. + const noonMs = (key: string) => Date.parse(key + "T12:00:00Z"); + const todayMs = noonMs(todayKey); // Anchor the streak at today if logged, else the most recent logged day. let anchorMs = todayMs; if (!keys.has(todayKey)) { const sorted = [...keys].sort().reverse(); - anchorMs = Date.parse(sorted[0] + "T00:00:00Z"); + anchorMs = noonMs(sorted[0]); // A streak that does not include today is stale — don't congratulate. if (anchorMs !== todayMs) return null; } let run = 0; let cursor = anchorMs; - while (keys.has(new Date(cursor).toISOString().slice(0, 10))) { + while (keys.has(userDayKey(new Date(cursor), tz))) { run += 1; cursor -= MS_PER_DAY; } diff --git a/src/lib/queries/use-achievements-query.ts b/src/lib/queries/use-achievements-query.ts index f1d42efec..88f7f7574 100644 --- a/src/lib/queries/use-achievements-query.ts +++ b/src/lib/queries/use-achievements-query.ts @@ -3,6 +3,7 @@ import { useQuery, type UseQueryResult } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; +import { apiGet } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import type { AchievementMetrics, @@ -65,13 +66,8 @@ export interface UseAchievementsQueryOptions { refetchInterval?: number | false; } -async function fetchAchievements(): Promise { - const res = await fetch("/api/gamification/achievements"); - if (!res.ok) { - throw new Error(`Failed to load achievements (${res.status})`); - } - const json = (await res.json()) as { data: AchievementsPayload }; - return json.data; +function fetchAchievements(): Promise { + return apiGet("/api/gamification/achievements"); } /** diff --git a/src/lib/queries/use-analytics-query.ts b/src/lib/queries/use-analytics-query.ts index 2fb964a2b..52282a445 100644 --- a/src/lib/queries/use-analytics-query.ts +++ b/src/lib/queries/use-analytics-query.ts @@ -3,6 +3,7 @@ import { useQuery, type UseQueryResult } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; +import { apiGet } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; import type { DataSummary } from "@/lib/analytics/trends"; @@ -114,18 +115,10 @@ export interface UseAnalyticsQueryOptions { enabled?: boolean; } -async function fetchAnalytics( - slice: AnalyticsSlice, -): Promise { - const res = - slice === "summaries" - ? await fetch("/api/analytics?slice=summaries") - : await fetch("/api/analytics"); - if (!res.ok) { - throw new Error(`Failed to load analytics (${res.status})`); - } - const json = (await res.json()) as { data: AnalyticsRawPayload }; - return json.data; +function fetchAnalytics(slice: AnalyticsSlice): Promise { + return apiGet( + slice === "summaries" ? "/api/analytics?slice=summaries" : "/api/analytics", + ); } /** diff --git a/src/lib/time-constants.ts b/src/lib/time-constants.ts new file mode 100644 index 000000000..e1f8c3b02 --- /dev/null +++ b/src/lib/time-constants.ts @@ -0,0 +1,17 @@ +/** + * Shared duration constants in milliseconds. + * + * Replaces the inline `24 * 60 * 60 * 1000` (and friends) scattered across + * the codebase. Centralising the literal keeps the intent readable at the + * call site and removes the off-by-a-zero risk of hand-typing the product. + * Pure module — no imports, safe to use anywhere (server or client). + */ + +/** One minute in milliseconds. */ +export const MS_PER_MINUTE = 60 * 1000; + +/** One hour in milliseconds. */ +export const MS_PER_HOUR = 60 * MS_PER_MINUTE; + +/** One day in milliseconds. */ +export const MS_PER_DAY = 24 * MS_PER_HOUR;