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

+182
View File
@@ -0,0 +1,182 @@
/**
* Query of `GET /api/activity/calendar` (D1036).
*/
import { z } from "zod/v4";
export const activityCalendarQuerySchema = z.object({
period: z
.enum(["week", "month"])
.default("month")
.describe("Calendar span: one week (Monday to Sunday) or one month."),
date: z.iso
.date()
.optional()
.describe(
"Any day inside the period to show (`YYYY-MM-DD`, the person's local day). Defaults to today. A day after today, or more than ten years back, is refused.",
),
});
export type ActivityCalendarQuery = z.infer<typeof activityCalendarQuerySchema>;
const dateKey = z.iso.date();
const dayLevelEnum = z
.enum(["none", "unrated", "low", "below", "usual", "above", "high"])
.describe(
"Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with.",
);
const baselineStatsSchema = z
.object({
median: z.number(),
p10: z.number(),
p25: z.number(),
p75: z.number(),
p90: z.number(),
days: z
.number()
.int()
.describe("Days with a value the spread was computed from."),
})
.nullable()
.meta({ id: "ActivityBaselineStats" });
const periodComparisonSchema = z
.object({
value: z
.number()
.nullable()
.describe(
"The period so far: a total (steps, active minutes, workouts) or a mean (load).",
),
daysWithData: z.number().int(),
usual: z
.number()
.nullable()
.describe(
"The person's usual full period: the median of the twelve weeks (or, per day, of the six months) before it. Null with fewer than three earlier periods of data.",
),
usualToDate: z
.number()
.nullable()
.describe(
"The usual figure for the days elapsed so far; equals `usual` once the period is over.",
),
referencePeriods: z.number().int(),
})
.meta({ id: "ActivityPeriodComparison" });
const goalProgressSchema = z
.object({
key: z.enum([
"workoutsPerWeek",
"activeMinutesPerWeek",
"stepsPerWeek",
"stepsPerMonth",
]),
target: z.number().int(),
value: z.number(),
met: z.boolean(),
})
.meta({ id: "ActivityGoalProgress" });
const goalsSchema = z
.object({
workoutsPerWeek: z.number().int().nullable(),
activeMinutesPerWeek: z.number().int().nullable(),
stepsPerWeek: z.number().int().nullable(),
stepsPerMonth: z.number().int().nullable(),
})
.meta({
id: "ActivityGoals",
description:
"The person's own goals; `null` = not set. There is no default goal.",
});
export const activityGoalsResponseSchema = goalsSchema;
export const activityCalendarResponseSchema = z
.object({
period: z.enum(["week", "month"]),
start: dateKey,
end: dateKey,
gridStart: dateKey.describe(
"First day drawn (the Monday on or before `start`).",
),
gridEnd: dateKey.describe("Last day drawn (the Sunday on or after `end`)."),
today: dateKey,
timezone: z.string().describe("The IANA zone the days are cut in."),
loadType: z
.enum(["DAY_STRAIN", "CARDIO_LOAD", "STRAIN_SCORE"])
.nullable()
.describe(
"Which training-load score `load` carries; null when the account has none.",
),
days: z.array(
z.object({
date: dateKey,
inPeriod: z.boolean(),
future: z.boolean(),
steps: z.number().nullable(),
activeMinutes: z
.number()
.nullable()
.describe(
"The larger of the day's exercise minutes and workout minutes (never their sum).",
),
workoutCount: z.number().int(),
workoutsBySport: z.record(z.string(), z.number().int()),
load: z.number().nullable(),
level: z.object({
steps: dayLevelEnum,
activeMinutes: dayLevelEnum,
load: dayLevelEnum,
}),
}),
),
weeks: z.array(
z.object({
start: dateKey,
end: dateKey,
steps: z.number().nullable(),
activeMinutes: z.number().nullable(),
workouts: z.number().int(),
goals: z
.array(goalProgressSchema)
.describe("The weekly goals for a week that has begun."),
}),
),
baseline: z.object({
steps: baselineStatsSchema,
activeMinutes: baselineStatsSchema,
load: baselineStatsSchema,
}),
summary: z.object({
steps: periodComparisonSchema,
activeMinutes: periodComparisonSchema,
workouts: periodComparisonSchema,
load: periodComparisonSchema,
}),
sports: z.array(
z.object({ sportType: z.string(), count: z.number().int() }),
),
goals: goalsSchema,
goalProgress: z.array(goalProgressSchema),
available: z.object({
steps: z.boolean(),
activeMinutes: z.boolean(),
workouts: z.boolean(),
load: z.boolean(),
}),
previous: dateKey.describe("A day in the previous period."),
next: dateKey
.nullable()
.describe(
"A day in the next period, or null when that lies after today.",
),
})
.meta({ id: "ActivityCalendar" });
export type ActivityCalendarResponse = z.infer<
typeof activityCalendarResponseSchema
>;
+98
View File
@@ -0,0 +1,98 @@
/**
* Activity goals a person sets for themselves (D1036).
*
* Stored in `User.activityGoalsJson`. Every goal is optional and a `null`
* clears it; an account that never set one stores nothing and reads as "no
* goals". The calendar reports progress against the goals that are set and
* says nothing about the ones that are not: there is no default goal and no
* population norm here, only the person's own number.
*
* The keys and bounds live in `src/lib/activity/goal-bounds.ts` so the
* browser can read them without the schema.
*/
import { z } from "zod/v4";
import {
ACTIVITY_GOAL_BOUNDS,
ACTIVITY_GOAL_KEYS,
type ActivityGoalKey,
} from "@/lib/activity/goal-bounds";
export { ACTIVITY_GOAL_BOUNDS, ACTIVITY_GOAL_KEYS, type ActivityGoalKey };
function goalField(key: ActivityGoalKey) {
const { min, max } = ACTIVITY_GOAL_BOUNDS[key];
return z.number().int().min(min).max(max).nullable().optional();
}
/** The stored shape and the PATCH body: every key optional, `null` clears. */
export const activityGoalsSchema = z
.object({
workoutsPerWeek: goalField("workoutsPerWeek").describe(
"Workouts per week (Monday to Sunday). `null` clears the goal.",
),
activeMinutesPerWeek: goalField("activeMinutesPerWeek").describe(
"Active minutes per week: the larger of the day's exercise minutes and the day's workout minutes, summed over the week. `null` clears the goal.",
),
stepsPerWeek: goalField("stepsPerWeek").describe(
"Steps per week. `null` clears the goal.",
),
stepsPerMonth: goalField("stepsPerMonth").describe(
"Steps per calendar month. `null` clears the goal.",
),
})
.strict();
export type ActivityGoalsInput = z.infer<typeof activityGoalsSchema>;
/** The resolved goals: every key present, `null` = not set. */
export type ActivityGoals = Record<ActivityGoalKey, number | null>;
export const NO_ACTIVITY_GOALS: ActivityGoals = {
workoutsPerWeek: null,
activeMinutesPerWeek: null,
stepsPerWeek: null,
stepsPerMonth: null,
};
/**
* Read a stored value. Anything that does not parse (a hand-edited row, a
* shape from a future release) reads key by key: a valid goal survives, an
* invalid one reads as not set. Never throws.
*/
export function parseActivityGoals(raw: unknown): ActivityGoals {
const out: ActivityGoals = { ...NO_ACTIVITY_GOALS };
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return out;
}
const record = raw as Record<string, unknown>;
for (const key of ACTIVITY_GOAL_KEYS) {
const parsed = goalField(key).safeParse(record[key]);
if (parsed.success && typeof parsed.data === "number") {
out[key] = parsed.data;
}
}
return out;
}
/**
* Apply a validated PATCH to the stored goals: a key the body names replaces
* the stored value (`null` clears it), a key it leaves out stays as it was.
* Built key by key, never by spreading the body.
*/
export function mergeActivityGoals(
current: ActivityGoals,
patch: ActivityGoalsInput,
): ActivityGoals {
const next: ActivityGoals = { ...current };
for (const key of ACTIVITY_GOAL_KEYS) {
const value = patch[key];
if (value !== undefined) next[key] = value;
}
return next;
}
/** True when no goal is set, so the column can go back to null. */
export function hasNoActivityGoals(goals: ActivityGoals): boolean {
return ACTIVITY_GOAL_KEYS.every((key) => goals[key] === null);
}
+1
View File
@@ -2156,6 +2156,7 @@ const accountSettingsBackupSchema = z
documentsLayoutJson: z.unknown().optional(),
moduleListLayoutJson: z.unknown().optional(),
previewFeaturesJson: z.unknown().optional(),
activityGoalsJson: z.unknown().optional(),
reportSelectionJson: z.unknown().optional(),
globalExcludedInjectionSites: z.array(z.string()).optional(),
healthKitConfigJson: z.unknown().optional(),
+241
View File
@@ -0,0 +1,241 @@
/**
* Query and response schemas of the CSV record imports for lab results and
* medications (#1215).
*
* The body of both routes is the raw CSV file (`text/csv`), streamed. How to
* read it travels in the query: the column each field comes from (0-based
* index into the file's columns), the separator, the decimal mark, the date
* order and the zone a time without an offset is read in. The client works
* these out with the person on the mapping screen; the server never guesses
* a column.
*/
import { z } from "zod/v4";
import { MAX_COLUMNS } from "@/lib/import/csv-stream";
import { isValidTimezone } from "@/lib/tz/format";
const column = z.coerce
.number()
.int()
.min(0)
.max(MAX_COLUMNS - 1)
.describe("0-based index of the file column this field is read from.");
const formatFields = {
dryRun: z
.enum(["1", "0", "true", "false"])
.default("0")
.transform((v) => v === "1" || v === "true")
.describe(
"`1` validates every row and reports what would happen, writing nothing.",
),
header: z
.enum(["1", "0"])
.default("1")
.transform((v) => v === "1")
.describe("`1` when the first row holds column names and is not data."),
delimiter: z
.enum(["auto", "comma", "semicolon", "tab"])
.default("auto")
.describe("Column separator; `auto` detects it from the first line."),
decimal: z
.enum(["dot", "comma"])
.default("dot")
.describe(
"Decimal mark of the numbers. A number that reads differently in the two conventions (`1,234` with `dot`) is refused per row as ambiguous.",
),
dateOrder: z
.enum(["iso", "dmy", "mdy"])
.default("iso")
.describe(
"`iso` = YYYY-MM-DD[THH:mm[:ss][offset]], `dmy` = DD.MM.YYYY (also / or -), `mdy` = MM/DD/YYYY. Two-digit years are refused.",
),
timezone: z
.string()
.max(64)
.refine(isValidTimezone, { message: "Unknown time zone" })
.optional()
.describe(
"IANA zone a date-time without an offset is read in. Defaults to the account's zone. A date without a time is stored as that calendar date.",
),
};
function distinctColumns(
value: Record<string, unknown>,
keys: readonly string[],
): boolean {
const used = keys
.map((k) => value[k])
.filter((v): v is number => typeof v === "number");
return new Set(used).size === used.length;
}
const LAB_COLUMN_KEYS = [
"analyte",
"value",
"unit",
"date",
"time",
"referenceLow",
"referenceHigh",
"referenceRange",
"panel",
"note",
] as const;
export const labsCsvQuerySchema = z
.object({
...formatFields,
analyte: column.describe("Column of the marker name (required)."),
value: column.describe(
"Column of the result (required). A number is a numeric result; any other text (`negative`, `<0.5`) is kept as a qualitative result.",
),
unit: column
.optional()
.describe("Column of the unit. Required for a numeric result."),
date: column.describe("Column of the date, or date and time (required)."),
time: column.optional().describe("Column of the time, when separate."),
referenceLow: column.optional(),
referenceHigh: column.optional(),
referenceRange: column
.optional()
.describe("Column of a printed range such as `4.0-5.6` or `< 200`."),
panel: column.optional(),
note: column.optional().describe("Column of a note; stored encrypted."),
})
.refine((v) => distinctColumns(v, LAB_COLUMN_KEYS), {
message: "Each column can feed one field",
path: ["analyte"],
});
export type LabsCsvQuery = z.infer<typeof labsCsvQuerySchema>;
const MEDICATION_COLUMN_KEYS = [
"name",
"dose",
"times",
"asNeeded",
"startsOn",
"endsOn",
] as const;
export const medicationsCsvQuerySchema = z
.object({
...formatFields,
name: column.describe("Column of the medication name (required)."),
dose: column.describe(
"Column of the dose as written, e.g. `10 mg` (required).",
),
times: column
.optional()
.describe(
"Column of daily intake times (`08:00`, or several: `08:00 20:00`, separated by space, comma, semicolon or `|`). A medication with times is taken every day at them; any other rhythm is set up afterwards in the app. A row needs times unless it is marked as needed.",
),
asNeeded: column
.optional()
.describe("Column marking an as-needed medication (yes/no)."),
startsOn: column.optional().describe("Column of the start date."),
endsOn: column.optional().describe("Column of the end date."),
reminders: z
.enum(["1", "0"])
.default("0")
.transform((v) => v === "1")
.describe(
"`1` turns reminders on for imported medications that have times. Off by default, so an import never starts reminding on its own.",
),
})
.refine((v) => distinctColumns(v, MEDICATION_COLUMN_KEYS), {
message: "Each column can feed one field",
path: ["name"],
});
export type MedicationsCsvQuery = z.infer<typeof medicationsCsvQuerySchema>;
export const RECORD_IMPORT_ROW_REASONS = [
"missing_value",
"invalid_number",
"ambiguous_number",
"missing_unit",
"invalid_date",
"invalid_time",
"ambiguous_date",
"future_date",
"text_too_long",
"unit_mismatch",
"invalid_reference_range",
"invalid_times",
"missing_times",
"invalid_yes_no",
"as_needed_with_times",
"end_before_start",
"invalid_medication",
"oversized_row",
"row_limit",
"duplicate",
] as const;
export type RecordImportRowReason = (typeof RECORD_IMPORT_ROW_REASONS)[number];
export const recordImportResultSchema = z
.object({
kind: z.enum(["labs", "medications"]),
dryRun: z.boolean(),
rows: z.number().int().describe("Data rows read (the header excluded)."),
valid: z.number().int().describe("Rows that passed every check."),
inserted: z
.number()
.int()
.describe("Rows written; in a dry run, the rows that would be."),
duplicates: z
.number()
.int()
.describe(
"Rows skipped because the record already holds them, or an earlier row of the file does. A re-upload of the same file therefore writes nothing.",
),
failed: z
.number()
.int()
.describe("Rows refused; each is listed in `errors`."),
errors: z
.array(
z.object({
line: z.number().int().describe("1-based line of the file."),
reason: z.enum(RECORD_IMPORT_ROW_REASONS),
field: z.string().optional().describe("The mapped field at fault."),
}),
)
.describe("Refused and duplicate rows, in file order, at most 500."),
errorsTruncated: z.boolean(),
markers: z
.object({
matched: z
.array(
z.object({
name: z.string().describe("The name as the file writes it."),
marker: z.string().describe("The marker it resolves to."),
how: z.enum(["name", "alias", "catalog"]),
}),
)
.describe(
"File names that resolve to an existing marker, at most 200.",
),
created: z
.array(z.string())
.describe("Names that become new markers, at most 200."),
})
.optional()
.describe("Lab imports only: how the file's names meet the markers."),
preview: z
.array(
z.record(
z.string(),
z.union([z.string(), z.number(), z.boolean(), z.null()]),
),
)
.describe(
"The first 20 valid rows as they will be stored (dry run only).",
),
})
.meta({ id: "RecordImportResult" });
export type RecordImportResult = z.infer<typeof recordImportResultSchema>;
+132
View File
@@ -0,0 +1,132 @@
/**
* Wire schemas of the source-provenance reads (#1204, #1108, #576).
*/
import { z } from "zod/v4";
import {
measurementSourceEnum,
measurementTypeEnum,
} from "@/lib/validations/measurement";
export const PROVENANCE_MAX_DAYS = 366;
export const dayProvenanceQuerySchema = z.object({
type: measurementTypeEnum.describe("The metric."),
date: z.iso
.date()
.describe(
"The UTC day (`YYYY-MM-DD`). The source ladder picks one source per UTC day, so this is the day the pick is explained for.",
),
});
export const sourceComparisonQuerySchema = z
.object({
type: measurementTypeEnum.describe("The metric."),
from: z.iso.date().describe("First UTC day, inclusive."),
to: z.iso.date().describe("Last UTC day, inclusive."),
a: measurementSourceEnum
.optional()
.describe("First source. Defaults to the source covering the most days."),
b: measurementSourceEnum
.optional()
.describe("Second source. Defaults to the next one."),
})
.refine((q) => q.from <= q.to, {
message: "from must not be after to",
path: ["from"],
})
.refine(
(q) =>
(Date.parse(q.to) - Date.parse(q.from)) / 86_400_000 <
PROVENANCE_MAX_DAYS,
{ message: `At most ${PROVENANCE_MAX_DAYS} days`, path: ["to"] },
);
const pickReasonEnum = z
.enum(["only_source", "highest_on_ladder", "name_tiebreak", "not_ranked"])
.describe(
"`only_source`: one source that day. `highest_on_ladder`: it stands highest on the ladder among the day's sources. `name_tiebreak`: none of them is on the ladder, the source name decided. `not_ranked`: this metric has no ladder, every source counts.",
);
const passReasonEnum = z
.enum(["lower_on_ladder", "not_on_ladder", "name_tiebreak"])
.nullable()
.describe("Why this source was not the one picked; null for the picked one.");
const sourceRowSchema = z.object({
source: measurementSourceEnum,
rank: z
.number()
.int()
.describe("Position on the ladder, 0 = highest; 90 = not on the ladder."),
readings: z.number().int(),
value: z
.number()
.nullable()
.describe(
"The day's value from this source: the day total for a cumulative metric (sleep counts asleep stages only), the mean of the readings otherwise.",
),
min: z.number().nullable(),
max: z.number().nullable(),
firstAt: z.string().describe("ISO instant of the first reading that day."),
lastAt: z.string(),
unit: z.string().nullable(),
devices: z
.array(z.string())
.describe("Device kinds the readings carry; `unknown` when untagged."),
});
export const dayProvenanceResponseSchema = z
.object({
type: measurementTypeEnum,
date: z.iso.date(),
aggregation: z.enum(["sum", "mean"]),
ranked: z.boolean(),
metricKey: z.string().nullable(),
ladder: z.array(measurementSourceEnum),
picked: measurementSourceEnum.nullable(),
pickReason: pickReasonEnum.nullable(),
sources: z.array(sourceRowSchema.extend({ pass: passReasonEnum })),
})
.meta({ id: "DayProvenance" });
export const sourceComparisonResponseSchema = z
.object({
type: measurementTypeEnum,
from: z.iso.date(),
to: z.iso.date(),
aggregation: z.enum(["sum", "mean"]),
ranked: z.boolean(),
ladder: z.array(measurementSourceEnum),
unit: z.string().nullable(),
sources: z.array(
z.object({ source: measurementSourceEnum, days: z.number().int() }),
),
a: measurementSourceEnum.nullable(),
b: measurementSourceEnum.nullable(),
days: z.array(
z.object({
date: z.iso.date(),
a: z.number().nullable(),
b: z.number().nullable(),
picked: measurementSourceEnum.nullable(),
}),
),
summary: z.object({
daysA: z.number().int(),
daysB: z.number().int(),
daysBoth: z.number().int(),
meanA: z.number().nullable(),
meanB: z.number().nullable(),
meanDifference: z.number().nullable(),
meanAbsoluteDifference: z.number().nullable(),
pickedA: z.number().int(),
pickedB: z.number().int(),
}),
})
.meta({ id: "SourceComparison" });
export type DayProvenanceResponse = z.infer<typeof dayProvenanceResponseSchema>;
export type SourceComparisonResponse = z.infer<
typeof sourceComparisonResponseSchema
>;