Merge w10/codehygiene into release/v1.18.10

Enforce apiFetch in src/lib/queries and migrate two hand-rolled hooks; thread a
DST-safe day-key into readiness and mood-narratives; lazy-load qrcode; add a
shared day-millisecond constant.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 17:11:51 +02:00
commit 1706b67dcf
7 files changed
+82 -33

No files matched your search

+10 -2
View File
@@ -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, "/");
+7 -2
View File
@@ -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);
})
+20 -4
View File
@@ -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<number | null> {
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<Derived<ReadinessValue>> {
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,
),
]);
+20 -6
View File
@@ -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;
}
+3 -7
View File
@@ -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<AchievementsPayload> {
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<AchievementsPayload> {
return apiGet<AchievementsPayload>("/api/gamification/achievements");
}
/**
+5 -12
View File
@@ -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<AnalyticsRawPayload> {
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<AnalyticsRawPayload> {
return apiGet<AnalyticsRawPayload>(
slice === "summaries" ? "/api/analytics?slice=summaries" : "/api/analytics",
);
}
/**
+17
View File
@@ -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;