Merge branch 'feat/v144-userneeds' into release/v1.44.0

# Conflicts:
#	src/__tests__/sharing-surface-guard.test.ts
#	src/__tests__/workout-canonical-identity-guard.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 22:24:01 +02:00
commit aa899a9d54
86 files changed
+12949 -9

No files matched your search

@@ -0,0 +1,168 @@
/**
* `/api/activity/calendar` and `/api/activity/goals` (D1036).
*
* The goals: a PATCH replaces only what it names, `null` clears, an unknown
* key or a goal out of bounds is a 422 and writes nothing, and clearing the
* last goal returns the column to NULL rather than storing an empty object.
* The calendar: the module gate answers before any read, a future or malformed
* day is refused, and the reader is asked for exactly the window the period
* needs and never past today.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({
findUnique: vi.fn(),
update: vi.fn(),
readActivityDays: vi.fn(),
requireModuleEnabled: vi.fn(),
auditLog: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (handler: unknown) => handler,
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
requireRecordAuth: vi.fn(async () => ({
user: { id: "user-1" },
actor: { id: "user-1" },
grantId: null,
})),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog }));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: mocks.findUnique, update: mocks.update } },
toJson: (v: unknown) => v,
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: mocks.requireModuleEnabled,
}));
vi.mock("@/lib/activity/calendar-read", () => ({
readActivityDays: mocks.readActivityDays,
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
}));
import { Prisma } from "@/generated/prisma/client";
import { GET as calendarGet } from "../calendar/route";
import { GET as goalsGet, PATCH as goalsPatch } from "../goals/route";
type Handler = (req: NextRequest) => Promise<Response>;
function patch(body: unknown) {
return (goalsPatch as unknown as Handler)(
new NextRequest("http://localhost/api/activity/goals", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
}
function calendar(query: string) {
return (calendarGet as unknown as Handler)(
new NextRequest(`http://localhost/api/activity/calendar${query}`),
);
}
beforeEach(() => {
vi.clearAllMocks();
mocks.findUnique.mockResolvedValue({
activityGoalsJson: { workoutsPerWeek: 3, stepsPerWeek: 50000 },
});
mocks.update.mockResolvedValue({});
mocks.requireModuleEnabled.mockResolvedValue({ enabled: true });
mocks.readActivityDays.mockResolvedValue({ days: [], loadType: null });
});
describe("activity goals", () => {
it("reads every goal, unset ones as null", async () => {
const res = await (goalsGet as unknown as () => Promise<Response>)();
const body = await res.json();
expect(body.data).toEqual({
workoutsPerWeek: 3,
activeMinutesPerWeek: null,
stepsPerWeek: 50000,
stepsPerMonth: null,
});
});
it("changes what the body names and keeps the rest", async () => {
const res = await patch({ stepsPerWeek: null, stepsPerMonth: 250000 });
expect(res.status).toBe(200);
expect(mocks.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
activityGoalsJson: {
workoutsPerWeek: 3,
activeMinutesPerWeek: null,
stepsPerWeek: null,
stepsPerMonth: 250000,
},
},
});
expect(mocks.auditLog).toHaveBeenCalledWith(
"activity.goals.update",
expect.objectContaining({ userId: "user-1" }),
);
});
it("stores NULL once the last goal is cleared", async () => {
await patch({ workoutsPerWeek: null, stepsPerWeek: null });
expect(mocks.update.mock.calls[0][0].data.activityGoalsJson).toBe(
Prisma.DbNull,
);
});
it.each([
[{ caloriesPerWeek: 3000 }],
[{ workoutsPerWeek: 0 }],
[{ stepsPerWeek: 2.5 }],
[{ stepsPerMonth: "200000" }],
])("refuses %j without writing", async (body) => {
const res = await patch(body);
expect(res.status).toBe(422);
expect(mocks.update).not.toHaveBeenCalled();
});
});
describe("activity calendar", () => {
it("answers the module gate before reading anything", async () => {
mocks.requireModuleEnabled.mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
});
const res = await calendar("?period=month");
expect(res.status).toBe(403);
expect(mocks.readActivityDays).not.toHaveBeenCalled();
});
it.each(["?period=year", "?date=2026-13-01", "?date=yesterday"])(
"refuses %s",
async (q) => {
const res = await calendar(q);
expect(res.status).toBe(422);
},
);
it("refuses a day after today", async () => {
const res = await calendar("?date=2999-01-01");
expect(res.status).toBe(422);
expect(mocks.readActivityDays).not.toHaveBeenCalled();
});
it("reads the baseline window before the period and nothing past the grid", async () => {
const res = await calendar("?period=week&date=2025-03-12");
expect(res.status).toBe(200);
const args = mocks.readActivityDays.mock.calls[0][0];
// Week of Monday 2025-03-10; 84 days before it.
expect(args.fromKey).toBe("2024-12-16");
expect(args.toKey).toBe("2025-03-16");
expect(args.tz).toBe("Europe/Berlin");
const body = await res.json();
expect(body.data.start).toBe("2025-03-10");
expect(body.data.goals.workoutsPerWeek).toBe(3);
expect(body.data.days).toHaveLength(7);
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* `GET /api/activity/calendar` — a week or a month of activity against the
* person's own normal, with their weekly and monthly goals (D1036).
*
* Computed server-side, without AI, from the stored readings: steps (the
* canonical-source day totals the steps chart draws), exercise minutes,
* workouts after the cross-source collapse, and a training-load score when a
* device or HealthLog provides one. What "usual" means is in
* `src/lib/activity/calendar-math.ts`; it is always the person's own history,
* never a population norm.
*
* Owned by the Workouts module, like the page that shows it: a switched-off
* module answers 403 here as `/api/workouts` does.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
buildActivityCalendar,
periodBounds,
} from "@/lib/activity/calendar-math";
import { readActivityDays } from "@/lib/activity/calendar-read";
import { daysBetweenDateKeys, userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { activityCalendarQuerySchema } from "@/lib/validations/activity-calendar";
import { parseActivityGoals } from "@/lib/validations/activity-goals";
export const dynamic = "force-dynamic";
/** How far back the calendar pages: ten years, the series route's ceiling. */
const MAX_DAYS_BACK = 3650 - 200;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
annotate({ action: { name: "activity.calendar.read" } });
const gate = await requireModuleEnabled(user.id, "workouts");
if (!gate.enabled) return gate.response;
const params = new URL(request.url).searchParams;
const parsed = activityCalendarQuerySchema.safeParse({
period: params.get("period") ?? undefined,
date: params.get("date") ?? undefined,
});
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const tz = await resolveUserTimezone(user.id);
const now = new Date();
const today = userDayKey(now, tz);
const anchor = parsed.data.date ?? today;
if (anchor > today) {
return apiError("The calendar cannot show a day after today", 422, {
errorCode: "activity.calendar.future_date",
});
}
if (daysBetweenDateKeys(anchor, today) > MAX_DAYS_BACK) {
return apiError("The calendar reaches back ten years", 422, {
errorCode: "activity.calendar.too_old",
});
}
const bounds = periodBounds(parsed.data.period, anchor);
const lastRead = bounds.gridEnd < today ? bounds.gridEnd : today;
const [{ days, loadType }, goalRow] = await Promise.all([
readActivityDays({
userId: user.id,
tz,
fromKey: bounds.readStart,
toKey: lastRead,
now,
includeWorkouts: true,
}),
prisma.user.findUnique({
where: { id: user.id },
select: { activityGoalsJson: true },
}),
]);
const calendar = buildActivityCalendar({
bounds,
today,
input: days,
goals: parseActivityGoals(goalRow?.activityGoalsJson ?? null),
});
annotate({
meta: {
activity_calendar_period: bounds.period,
activity_calendar_days_read: days.length,
activity_calendar_load_type: loadType ?? "none",
},
});
return apiSuccess({ ...calendar, timezone: tz, loadType });
});
+90
View File
@@ -0,0 +1,90 @@
/**
* GET / PATCH the caller's activity goals (D1036).
*
* Every goal is optional and set by the person; there is no default goal.
* PATCH replaces the goals the body names (`null` clears one) and keeps the
* rest; an unknown key or an out-of-bounds goal is refused with the
* multi-issue 422. The goals live on their own `User` column
* (`activity_goals_json`) and the activity calendar reads them.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { Prisma } from "@/generated/prisma/client";
import { prisma, toJson } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
ACTIVITY_GOAL_KEYS,
activityGoalsSchema,
hasNoActivityGoals,
mergeActivityGoals,
parseActivityGoals,
type ActivityGoals,
} from "@/lib/validations/activity-goals";
export const dynamic = "force-dynamic";
async function readGoals(userId: string): Promise<ActivityGoals> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { activityGoalsJson: true },
});
return parseActivityGoals(row?.activityGoalsJson ?? null);
}
function setKeys(goals: ActivityGoals): string[] {
return ACTIVITY_GOAL_KEYS.filter((key) => goals[key] !== null);
}
export const GET = apiHandler(async () => {
// Owner-only, like the write: a delegate sees the goals where they matter,
// inside the calendar, which carries them.
const { user } = await requireAuth();
const goals = await readGoals(user.id);
annotate({
action: { name: "activity.goals.read" },
meta: { set: setKeys(goals) },
});
return apiSuccess(goals);
});
export const PATCH = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = activityGoalsSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "activity.goals.validation-failed" } });
return returnAllZodIssues(parsed.error, 422);
}
const next = mergeActivityGoals(await readGoals(user.id), parsed.data);
await prisma.user.update({
where: { id: user.id },
data: {
activityGoalsJson: hasNoActivityGoals(next)
? Prisma.DbNull
: toJson(next),
},
});
await auditLog("activity.goals.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { set: setKeys(next) },
});
annotate({
action: { name: "activity.goals.update" },
meta: { set: setKeys(next) },
});
return apiSuccess(next);
});
@@ -0,0 +1,165 @@
/**
* The request side of `/api/import/labs-csv` and `/api/import/medications-csv`
* (#1215): module gate first, a separate bucket for dry runs, the content
* type, the byte cap while streaming, the mapping refused before anything is
* read, and an audit row only for a real import.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({
checkRateLimit: vi.fn(),
requireModuleEnabled: vi.fn(),
importLabsCsv: vi.fn(),
importMedicationsCsv: vi.fn(),
auditLog: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (handler: unknown) => handler,
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: mocks.checkRateLimit }));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: mocks.requireModuleEnabled,
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
}));
vi.mock("@/lib/import/labs-csv", () => ({
importLabsCsv: mocks.importLabsCsv,
}));
vi.mock("@/lib/import/medications-csv", () => ({
importMedicationsCsv: mocks.importMedicationsCsv,
}));
import { CsvTooLargeError } from "@/lib/import/csv-stream";
import { POST as labsPost } from "../labs-csv/route";
import { POST as medsPost } from "../medications-csv/route";
type Handler = (req: NextRequest) => Promise<Response>;
const RESULT = {
kind: "labs",
dryRun: false,
rows: 2,
valid: 2,
inserted: 2,
duplicates: 0,
failed: 0,
errors: [],
errorsTruncated: false,
preview: [],
};
function post(
handler: unknown,
query: string,
body = "a,b\n1,2\n",
type = "text/csv",
) {
return (handler as Handler)(
new NextRequest(`http://localhost/api/import/x?${query}`, {
method: "POST",
headers: { "content-type": type },
body,
}),
);
}
beforeEach(() => {
vi.clearAllMocks();
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
mocks.requireModuleEnabled.mockResolvedValue({ enabled: true });
mocks.importLabsCsv.mockResolvedValue(RESULT);
mocks.importMedicationsCsv.mockResolvedValue({
...RESULT,
kind: "medications",
});
});
const LABS = "analyte=0&value=1&unit=2&date=3";
const MEDS = "name=0&dose=1&times=2";
describe("record CSV import routes", () => {
it("answers the module gate before reading the file", async () => {
mocks.requireModuleEnabled.mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
});
expect((await post(labsPost, LABS)).status).toBe(403);
expect(mocks.requireModuleEnabled).toHaveBeenCalledWith("user-1", "labs");
expect(mocks.importLabsCsv).not.toHaveBeenCalled();
});
it("refuses a mapping without the required columns, or with a shared column", async () => {
expect((await post(labsPost, "analyte=0&value=1")).status).toBe(422);
expect((await post(labsPost, "analyte=0&value=0&date=1")).status).toBe(422);
expect((await post(medsPost, "name=0")).status).toBe(422);
expect(mocks.importLabsCsv).not.toHaveBeenCalled();
});
it("spends the preview bucket on a dry run and the import bucket otherwise", async () => {
await post(labsPost, `${LABS}&dryRun=1`);
expect(mocks.checkRateLimit).toHaveBeenLastCalledWith(
"import-preview:user-1",
30,
3_600_000,
);
expect(mocks.auditLog).not.toHaveBeenCalled();
await post(medsPost, MEDS);
expect(mocks.checkRateLimit).toHaveBeenLastCalledWith(
"import:user-1",
5,
3_600_000,
);
expect(mocks.auditLog).toHaveBeenCalledWith(
"import.medications.csv",
expect.objectContaining({ userId: "user-1" }),
);
});
it("answers 429 when the bucket is spent", async () => {
mocks.checkRateLimit.mockResolvedValue({ allowed: false });
expect((await post(labsPost, LABS)).status).toBe(429);
});
it("refuses a body that is not CSV", async () => {
expect((await post(labsPost, LABS, "{}", "application/json")).status).toBe(
415,
);
});
it("turns the streaming cap into a 413", async () => {
mocks.importLabsCsv.mockRejectedValue(new CsvTooLargeError(1));
expect((await post(labsPost, LABS)).status).toBe(413);
});
it("calls a file without data rows empty", async () => {
mocks.importLabsCsv.mockResolvedValue({ ...RESULT, rows: 0 });
expect((await post(labsPost, LABS)).status).toBe(422);
});
it("hands the parsed mapping and the account zone to the importer", async () => {
const res = await post(
labsPost,
`${LABS}&decimal=comma&dateOrder=dmy&delimiter=semicolon`,
);
expect(res.status).toBe(200);
const args = mocks.importLabsCsv.mock.calls[0][0];
expect(args.tz).toBe("Europe/Berlin");
expect(args.query).toMatchObject({
analyte: 0,
value: 1,
unit: 2,
date: 3,
decimal: "comma",
dateOrder: "dmy",
delimiter: "semicolon",
dryRun: false,
header: true,
});
expect(args.stream).toBeInstanceOf(ReadableStream);
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* `POST /api/import/labs-csv` — lab results from a spreadsheet (#1215).
*
* Body: the CSV file, streamed. Query: the column mapping and the file's
* conventions (`labsCsvQuerySchema`). `dryRun=1` checks every row and reports
* what would happen without writing; the real import writes in chunks, skips
* readings the record already holds and reports every refused row by line.
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { importLabsCsv } from "@/lib/import/labs-csv";
import { handleRecordImport } from "@/lib/import/record-import-route";
import { labsCsvQuerySchema } from "@/lib/validations/record-import";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) =>
handleRecordImport(request, {
kind: "labs",
module: "labs",
schema: labsCsvQuerySchema,
run: (args) => importLabsCsv(args),
}),
);
@@ -0,0 +1,25 @@
/**
* `POST /api/import/medications-csv` — medications from a spreadsheet (#1215).
*
* Body: the CSV file, streamed. Query: the column mapping and the file's
* conventions (`medicationsCsvQuerySchema`). Maps only name, dose, daily
* times, as-needed and a start and end date; reminders stay off unless the
* query turns them on. `dryRun=1` checks without writing.
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { importMedicationsCsv } from "@/lib/import/medications-csv";
import { handleRecordImport } from "@/lib/import/record-import-route";
import { medicationsCsvQuerySchema } from "@/lib/validations/record-import";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) =>
handleRecordImport(request, {
kind: "medications",
module: "medications",
schema: medicationsCsvQuerySchema,
run: (args) => importMedicationsCsv(args),
}),
);
@@ -0,0 +1,42 @@
/**
* `GET /api/measurements/provenance/compare` — two sources of one metric side
* by side over up to a year of UTC days, with the source the ladder picked
* each day (#1204). Read-only; one aggregate query, at most one row per day
* and source.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readSourceComparison } from "@/lib/provenance/source-provenance";
import { sourceComparisonQuerySchema } from "@/lib/validations/source-provenance";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
annotate({ action: { name: "measurement.provenance.compare" } });
const params = new URL(request.url).searchParams;
const parsed = sourceComparisonQuerySchema.safeParse({
type: params.get("type") ?? undefined,
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
a: params.get("a") ?? undefined,
b: params.get("b") ?? undefined,
});
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const result = await readSourceComparison({
userId: user.id,
...parsed.data,
});
annotate({
meta: {
provenance_type: result.type,
provenance_days: result.days.length,
provenance_sources: result.sources.length,
},
});
return apiSuccess(result);
});
@@ -0,0 +1,40 @@
/**
* `GET /api/measurements/provenance` — which sources delivered a metric on a
* day, which one the source-priority ladder picked, and why (#1204, #1108,
* #576). Read-only; the explanation comes from the same rank SQL the charts
* and summaries pick with (`src/lib/provenance/source-provenance.ts`).
*/
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readDayProvenance } from "@/lib/provenance/source-provenance";
import { dayProvenanceQuerySchema } from "@/lib/validations/source-provenance";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
annotate({ action: { name: "measurement.provenance.day" } });
const params = new URL(request.url).searchParams;
const parsed = dayProvenanceQuerySchema.safeParse({
type: params.get("type") ?? undefined,
date: params.get("date") ?? undefined,
});
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const result = await readDayProvenance({
userId: user.id,
type: parsed.data.type,
date: parsed.data.date,
});
annotate({
meta: {
provenance_type: result.type,
provenance_sources: result.sources.length,
provenance_pick_reason: result.pickReason ?? "none",
},
});
return apiSuccess(result);
});
+3
View File
@@ -1,5 +1,6 @@
"use client";
import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link";
import Link from "next/link";
import { ArrowDownRight, ArrowUpRight, HeartPulse } from "lucide-react";
@@ -226,6 +227,8 @@ export default function InsightsBlutdruckPage() {
slug="blood-pressure"
icon={<HeartPulse className="h-5 w-5" />}
/>
{/* v1.44 (#1204) — where these values come from. */}
<SourceProvenanceLink type="BLOOD_PRESSURE_SYS" />
</SubPageShell>
);
}
+3
View File
@@ -1,5 +1,6 @@
"use client";
import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link";
import Link from "next/link";
import { Gauge, Heart } from "lucide-react";
@@ -211,6 +212,8 @@ export default function InsightsPulsPage() {
cta={t("insights.vo2Max.cardioLinkCta")}
/>
) : null}
{/* v1.44 (#1204) — where these values come from. */}
<SourceProvenanceLink type="PULSE" />
</SubPageShell>
);
}
+3
View File
@@ -1,5 +1,6 @@
"use client";
import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link";
import Link from "next/link";
import { Moon } from "lucide-react";
@@ -153,6 +154,8 @@ export default function InsightsSchlafPage() {
<SleepQualitySection enabled={!isEmpty} />
<MetricTargetSummary slug="sleep" />
{/* v1.44 (#1204) — where these values come from. */}
<SourceProvenanceLink type="SLEEP_DURATION" />
</SubPageShell>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { SourceProvenanceView } from "@/components/provenance/source-provenance-view";
import { useTranslations } from "@/lib/i18n/context";
import { isProvenanceType } from "@/lib/provenance/ranked-types";
/**
* v1.44 — `/insights/sources` (#1204, #1108, #576).
*
* Where a metric's values come from: per day, every source that delivered
* one and the one the source priority picked, with the reason; and two
* sources side by side over a range. Reached from a metric's page and from
* Settings > Source priority; `?type=` and `?date=` open it on a metric and a
* day. Read-only: the order that decides the pick is edited in Settings.
*/
function SourcesPageBody() {
const params = useSearchParams();
const type = params.get("type");
const date = params.get("date");
return (
<SourceProvenanceView
initialType={type && isProvenanceType(type) ? type : "ACTIVITY_STEPS"}
initialDate={date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null}
/>
);
}
export default function InsightsSourcesPage() {
const { t } = useTranslations();
return (
<SubPageShell
title={t("sourceProvenance.title")}
description={t("sourceProvenance.description")}
>
<Suspense fallback={null}>
<SourcesPageBody />
</Suspense>
</SubPageShell>
);
}
+22 -1
View File
@@ -1,8 +1,11 @@
"use client";
import { Footprints } from "lucide-react";
import { CalendarDays, Footprints } from "lucide-react";
import { CrossLinkCard } from "@/components/insights/cross-link-card";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { useSurfaceVisible } from "@/hooks/use-surface-visible";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.12 — `/insights/steps`.
@@ -15,8 +18,14 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
* user having step data. Reuses the generic HealthKitMetricPage scaffold;
* empty-state carries no manual-entry CTA — the series arrives from Apple
* Health or Withings sync.
*
* v1.44 — links across to the activity calendar on the workouts page, which
* shows the same day totals as a week or month against the person's usual.
* The link follows the Workouts module: switched off, it is not offered.
*/
export default function InsightsStepsPage() {
const { t } = useTranslations();
const calendarVisible = useSurfaceVisible("insights-page:workouts");
return (
<HealthKitMetricPage
measurementType="ACTIVITY_STEPS"
@@ -32,6 +41,18 @@ export default function InsightsStepsPage() {
emptyStateCtaType={null}
captureType="ACTIVITY_STEPS"
coachPrefill="I haven't logged any steps yet. What's a reasonable daily step target, and how does walking more help my health?"
afterChart={
calendarVisible ? (
<CrossLinkCard
href="/insights/workouts#activity-calendar"
slot="steps-activity-calendar-link"
icon={CalendarDays}
title={t("activityCalendar.crossLink.title")}
body={t("activityCalendar.crossLink.body")}
cta={t("activityCalendar.crossLink.cta")}
/>
) : null
}
/>
);
}
+3
View File
@@ -1,5 +1,6 @@
"use client";
import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Scale } from "lucide-react";
@@ -222,6 +223,8 @@ export default function InsightsGewichtPage() {
slug="weight"
icon={<Scale className="h-5 w-5" />}
/>
{/* v1.44 (#1204) — where these values come from. */}
<SourceProvenanceLink type="WEIGHT" />
</SubPageShell>
);
}
@@ -21,6 +21,7 @@ import { canLogWorkout } from "@/lib/workouts/manual-entry";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { translateOrNull } from "@/lib/i18n/translate-code";
import { EmptyState } from "@/components/ui/empty-state";
import { ActivityCalendarCard } from "@/components/activity/activity-calendar-card";
/**
* v1.4.32 — `/insights/workouts`.
@@ -107,6 +108,11 @@ export default function InsightsWorkoutsPageClient() {
{showLog ? (
<ManualWorkoutSheet open={logOpen} onOpenChange={setLogOpen} />
) : null}
{/* The activity calendar (D1036): steps, active minutes and workouts
against the person's own usual. It stands above the list and draws
from steps alone too, so an account without workouts still sees
its routine. */}
<ActivityCalendarCard />
{/* No `<MetricRangeControls>` here: workouts are session records, not a
MeasurementType series, so the period-over-period range read has
nothing to aggregate. */}