mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
commit
aa899a9d54
86 files changed
+12949
-9
No files matched your search
@@ -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
|
||||
>;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
>;
|
||||
Reference in new issue
Block a user