mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
commit
1706b67dcf
7 files changed
+82
-33
No files matched your search
@@ -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, "/");
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user