Files
HealthLog/src/lib/day/records.ts
T
Marc-André Bombeck 6c4c097517 fix(workouts): start the windowed workout read after the plain reads beside it
The windowed reader is async, so a Promise.all that listed it first held a rejected promise of it whenever a later element threw while the array was built, and nobody handled that rejection. It now comes last in each of those arrays.
2026-10-22 19:15:58 +02:00

1175 lines
35 KiB
TypeScript

/**
* The record side of a day: what ran through it and what happened on it,
* section by section (v1.42, #613).
*
* Every read is bounded to the day and indexed on `(userId, time field)`.
* A day is stored three ways in this schema, and each reader below uses the
* one its table speaks:
*
* - **an instant** (`takenAt`, `occurredAt`, `onsetAt`, `startedAt`): the
* half-open local window `[dayStart, dayEnd)` in the record's zone, 23,
* 24 or 25 hours long;
* - **a date string with its own zone** (mood, illness day logs, cycle):
* equality on the string, because the row already decided which day it
* belongs to and a second derivation could disagree with it; a
* screener's `takenAt` is cut in its own `tz` the same way;
* - **a calendar date** (`@db.Date` medication and course bounds, a
* document's stated date stored at noon UTC): compared as a date key,
* never through a zone.
*
* Free text is decrypted here for the caller that resolved the record and
* nowhere else. A value that cannot be decrypted is `null` with a warning on
* the request's event, never the ciphertext and never a blank that looks
* like "no note".
*/
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import type {
DayEvent,
DayRunningItem,
DaySectionKey,
} from "@/lib/day/contract";
import { prisma } from "@/lib/db";
import { formatRecordedNumber } from "@/lib/format/recorded-number";
import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health";
import { listTargetsBySource } from "@/lib/links/link-service";
import { getEvent } from "@/lib/logging/context";
import { lastDayCovered } from "@/lib/life-events/dates";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
import { phaseForDate } from "@/lib/cycle/engine-adapter";
import { LUTEAL_DEFAULT } from "@/lib/cycle/types";
import { resolveCycleDay } from "@/lib/cycle/verdict";
import { effectiveMoodTz } from "@/lib/mood/date-key";
import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
import { visitPath } from "@/lib/encounters/visit-links";
import {
canonicalWorkoutsInRange,
readWorkoutsAroundRange,
WORKOUT_PICK_SELECT,
} from "@/lib/workouts/canonical-read";
/** Everything a section reader needs about the day. */
export interface DayFrame {
userId: string;
day: string;
tz: string;
dayStart: Date;
dayEnd: Date;
/** Whether document names may be shown beside a visit or a dose. */
documentsReadable: boolean;
}
export interface SectionPart {
running: DayRunningItem[];
events: DayEvent[];
}
/** A window wide enough to hold the day in any zone a row may carry. */
function zoneSafeWindow(frame: DayFrame): { gte: Date; lt: Date } {
return {
gte: new Date(frame.dayStart.getTime() - 14 * 3_600_000),
lt: new Date(frame.dayEnd.getTime() + 14 * 3_600_000),
};
}
/** Decrypt a free-text column for the response, or null with a warning. */
export function openText(
buf: Uint8Array | null | undefined,
what: string,
): string | null {
if (!buf || buf.byteLength === 0) return null;
try {
return decryptFromBytes(buf);
} catch (err) {
getEvent()?.addWarning(
`day ${what} decrypt failed: ${err instanceof Error ? err.message : String(err)}`,
);
return null;
}
}
/** Day n of a period that began on `since`, counting `since` as day 1. */
export function dayIndexOf(since: string, day: string): number | null {
const n = daysBetweenDateKeys(since, day) + 1;
return n >= 1 ? n : null;
}
/**
* A running item with its day count. `since` is the record's own start date,
* never the day the entry was made: a record that holds no start (a
* medication filed without one) has no "day n" and no length to state, so it
* says neither rather than counting from when it was typed in.
*/
function running(
frame: DayFrame,
item: Omit<DayRunningItem, "dayIndex" | "dayCount">,
): DayRunningItem {
const { since, until } = item;
if (since === null) return { ...item, dayIndex: null, dayCount: null };
return {
...item,
dayIndex: dayIndexOf(since, frame.day),
// An end before the start (a hand-edited row) has no length to state.
dayCount:
until === null || until < since
? null
: daysBetweenDateKeys(since, until) + 1,
};
}
function event(
item: Omit<DayEvent, "docs" | "meta" | "note"> &
Partial<Pick<DayEvent, "docs" | "meta" | "note">>,
): DayEvent {
return { meta: null, note: null, docs: [], ...item };
}
/** A `@db.Date` column value as its calendar key. */
function dateKeyOrNull(value: Date | null): string | null {
return value ? dateOnlyKey(value) : null;
}
/* ─── medications ─────────────────────────────────────────────────────────── */
async function medicationsPart(frame: DayFrame): Promise<SectionPart> {
const { userId, day } = frame;
const dayDate = dayKeyAsUtcMidnight(day);
const [meds, courses, pauses, intakes, doseChanges, dosesInEffect] =
await Promise.all([
prisma.medication.findMany({
where: {
userId,
asNeeded: false,
oneShot: false,
// Without a start date, the day the medication was entered stands in.
OR: [
{ startsOn: { lte: dayDate } },
{ startsOn: null, createdAt: { lt: frame.dayEnd } },
],
AND: [{ OR: [{ endsOn: null }, { endsOn: { gte: dayDate } }] }],
},
select: {
id: true,
name: true,
dose: true,
active: true,
startsOn: true,
endsOn: true,
createdAt: true,
updatedAt: true,
},
}),
prisma.medicationCourse.findMany({
where: {
userId,
startsOn: { lte: dayDate },
OR: [{ endsOn: null }, { endsOn: { gte: dayDate } }],
},
select: {
id: true,
medicationId: true,
startsOn: true,
endsOn: true,
medication: { select: { name: true } },
},
}),
prisma.medicationPauseEra.findMany({
where: {
userId,
pausedAt: { lt: frame.dayEnd },
OR: [{ resumedAt: null }, { resumedAt: { gte: frame.dayStart } }],
},
select: {
id: true,
medicationId: true,
pausedAt: true,
resumedAt: true,
medication: { select: { name: true } },
},
}),
prisma.medicationIntakeEvent.findMany({
where: {
userId,
deletedAt: null,
skipped: false,
takenAt: { gte: frame.dayStart, lt: frame.dayEnd },
...TRACKED_INTAKE_EVENT_WHERE,
},
select: {
id: true,
medicationId: true,
takenAt: true,
doseTaken: true,
medication: { select: { name: true, dose: true } },
},
orderBy: { takenAt: "asc" },
}),
prisma.medicationDoseChange.findMany({
where: {
medication: { userId },
effectiveFrom: { gte: frame.dayStart, lt: frame.dayEnd },
},
select: {
id: true,
medicationId: true,
effectiveFrom: true,
doseValue: true,
doseUnit: true,
note: true,
noteEncrypted: true,
medication: { select: { name: true } },
},
}),
// The dose each medication was taken at that day: its last dose change
// on or before the day.
prisma.medicationDoseChange.findMany({
where: { medication: { userId }, effectiveFrom: { lt: frame.dayEnd } },
select: {
medicationId: true,
doseValue: true,
doseUnit: true,
},
orderBy: { effectiveFrom: "desc" },
distinct: ["medicationId"],
}),
]);
const out: SectionPart = { running: [], events: [] };
const doseThatDay = new Map(
dosesInEffect.map((d) => [d.medicationId, `${d.doseValue} ${d.doseUnit}`]),
);
// One running entry per medication. The medication, its course and its
// pause are three rows in three tables, and listed one by one they read
// as three medications ("Mounjaro 7.5 mg" above "Mounjaro"). The entry is
// the medication under its name and the dose of that day; a pause that
// day makes it the paused entry, a course lends it the course's days.
const runningByMedication = new Map<string, DayRunningItem>();
const takeRunning = (medicationId: string, item: DayRunningItem) => {
const held = runningByMedication.get(medicationId);
if (!held) {
runningByMedication.set(medicationId, item);
return;
}
const rank = (r: DayRunningItem) =>
r.kind === "medicationPause" ? 2 : r.kind === "medicationCourse" ? 1 : 0;
const [keep, other] = rank(item) > rank(held) ? [item, held] : [held, item];
runningByMedication.set(medicationId, {
...keep,
sub: keep.kind === "medicationPause" ? null : (keep.sub ?? other.sub),
});
};
for (const med of meds) {
const since = dateKeyOrNull(med.startsOn);
// Without a start date the entry's own date is the earliest the record
// knows the medication; it bounds which days show it, but it is not a
// start, so it never reaches the item.
const shownFrom = since ?? userDayKey(med.createdAt, frame.tz);
// An ended medication without an end date stopped when it was last
// changed; that is the only date the record holds for it.
const until =
dateKeyOrNull(med.endsOn) ??
(med.active ? null : userDayKey(med.updatedAt, frame.tz));
if (shownFrom > day || (until !== null && until < day)) continue;
const href = `/medications/${med.id}`;
takeRunning(
med.id,
running(frame, {
kind: "medication",
section: "medications",
id: med.id,
title: med.name,
sub: doseThatDay.get(med.id) ?? med.dose,
since,
until,
href,
}),
);
if (dateKeyOrNull(med.startsOn) === day) {
out.events.push(
event({
at: null,
kind: "medicationStart",
section: "medications",
id: med.id,
title: med.name,
meta: med.dose,
href,
}),
);
}
if (dateKeyOrNull(med.endsOn) === day) {
out.events.push(
event({
at: null,
kind: "medicationEnd",
section: "medications",
id: med.id,
title: med.name,
meta: med.dose,
href,
}),
);
}
}
for (const course of courses) {
const since = dateOnlyKey(course.startsOn);
const until = dateKeyOrNull(course.endsOn);
const href = `/medications/${course.medicationId}`;
takeRunning(
course.medicationId,
running(frame, {
kind: "medicationCourse",
section: "medications",
id: course.id,
title: course.medication.name,
sub: doseThatDay.get(course.medicationId) ?? null,
since,
until,
href,
}),
);
if (since === day) {
out.events.push(
event({
at: null,
kind: "courseStart",
section: "medications",
id: course.id,
title: course.medication.name,
href,
}),
);
}
if (until === day) {
out.events.push(
event({
at: null,
kind: "courseEnd",
section: "medications",
id: course.id,
title: course.medication.name,
href,
}),
);
}
}
for (const pause of pauses) {
const since = userDayKey(pause.pausedAt, frame.tz);
const until = pause.resumedAt
? userDayKey(pause.resumedAt, frame.tz)
: null;
const href = `/medications/${pause.medicationId}`;
takeRunning(
pause.medicationId,
running(frame, {
kind: "medicationPause",
section: "medications",
id: pause.id,
title: pause.medication.name,
sub: null,
since,
until,
href,
}),
);
if (pause.pausedAt >= frame.dayStart) {
out.events.push(
event({
at: pause.pausedAt.toISOString(),
kind: "pauseStart",
section: "medications",
id: pause.id,
title: pause.medication.name,
href,
}),
);
}
if (
pause.resumedAt &&
pause.resumedAt >= frame.dayStart &&
pause.resumedAt < frame.dayEnd
) {
out.events.push(
event({
at: pause.resumedAt.toISOString(),
kind: "pauseEnd",
section: "medications",
id: pause.id,
title: pause.medication.name,
href,
}),
);
}
}
for (const intake of intakes) {
out.events.push(
event({
at: intake.takenAt ? intake.takenAt.toISOString() : null,
kind: "intake",
section: "medications",
id: intake.id,
title: intake.medication.name,
meta: intake.doseTaken ?? intake.medication.dose,
href: `/medications/${intake.medicationId}/history`,
}),
);
}
for (const change of doseChanges) {
let note: string | null = null;
if (change.noteEncrypted && change.noteEncrypted.byteLength > 0) {
note = openText(change.noteEncrypted, "dose change note");
} else {
note = change.note;
}
out.events.push(
event({
at: change.effectiveFrom.toISOString(),
kind: "doseChange",
section: "medications",
id: change.id,
title: change.medication.name,
meta: `${change.doseValue} ${change.doseUnit}`,
note,
href: `/medications/${change.medicationId}/history`,
}),
);
}
out.running.push(...runningByMedication.values());
return out;
}
/* ─── illness and symptoms ────────────────────────────────────────────────── */
async function illnessPart(frame: DayFrame): Promise<SectionPart> {
const { userId, day } = frame;
const [episodes, dayLogs] = await Promise.all([
prisma.illnessEpisode.findMany({
where: {
userId,
deletedAt: null,
onsetAt: { lt: frame.dayEnd },
OR: [{ resolvedAt: null }, { resolvedAt: { gte: frame.dayStart } }],
},
select: {
id: true,
label: true,
lifecycle: true,
onsetAt: true,
resolvedAt: true,
},
orderBy: { onsetAt: "asc" },
}),
prisma.illnessDayLog.findMany({
where: { userId, date: day, deletedAt: null },
select: {
id: true,
episodeId: true,
functionalImpact: true,
noteEncrypted: true,
createdAt: true,
episode: { select: { label: true } },
},
}),
]);
const out: SectionPart = { running: [], events: [] };
for (const episode of episodes) {
const href = `/illness/${episode.id}`;
out.running.push(
running(frame, {
kind: "illness",
section: "illness",
id: episode.id,
title: episode.label,
sub: null,
since: userDayKey(episode.onsetAt, frame.tz),
until: episode.resolvedAt
? userDayKey(episode.resolvedAt, frame.tz)
: null,
href,
}),
);
if (episode.onsetAt >= frame.dayStart) {
out.events.push(
event({
at: episode.onsetAt.toISOString(),
kind: "illnessOnset",
section: "illness",
id: episode.id,
title: episode.label,
href,
}),
);
}
if (episode.resolvedAt && episode.resolvedAt < frame.dayEnd) {
out.events.push(
event({
at: episode.resolvedAt.toISOString(),
kind: "illnessResolved",
section: "illness",
id: episode.id,
title: episode.label,
href,
}),
);
}
}
for (const log of dayLogs) {
out.events.push(
event({
at: null,
kind: "illnessDayLog",
section: "illness",
id: log.id,
title: log.episode.label,
meta:
log.functionalImpact === null ? null : String(log.functionalImpact),
note: openText(log.noteEncrypted, "illness day log note"),
href: `/illness/${log.episodeId}`,
}),
);
}
return out;
}
async function symptomsPart(frame: DayFrame): Promise<SectionPart> {
const rows = await prisma.symptomEvent.findMany({
where: {
userId: frame.userId,
occurredAt: { gte: frame.dayStart, lt: frame.dayEnd },
},
select: {
id: true,
occurredAt: true,
intensity: true,
noteEncrypted: true,
episodeId: true,
// The link opens its episode only while that episode exists; a link to
// a deleted one opens the illness page like an unfiled occurrence.
episode: { select: { deletedAt: true } },
definition: { select: { labelEncrypted: true } },
},
orderBy: { occurredAt: "asc" },
});
return {
running: [],
events: rows.map((row) =>
event({
at: row.occurredAt.toISOString(),
kind: "symptom",
section: "symptoms",
id: row.id,
title: openText(row.definition.labelEncrypted, "symptom label") ?? "",
meta: String(row.intensity),
note: openText(row.noteEncrypted, "symptom note"),
href:
row.episodeId && row.episode && row.episode.deletedAt === null
? `/illness/${row.episodeId}`
: "/illness",
}),
),
};
}
/* ─── profile: allergies, visits, vaccinations ───────────────────────────── */
async function allergiesPart(frame: DayFrame): Promise<SectionPart> {
const rows = await prisma.allergy.findMany({
where: {
userId: frame.userId,
deletedAt: null,
onsetAt: { gte: frame.dayStart, lt: frame.dayEnd },
},
select: { id: true, substance: true, onsetAt: true, severity: true },
});
return {
running: [],
events: rows.map((row) =>
event({
at: row.onsetAt ? row.onsetAt.toISOString() : null,
kind: "allergyOnset",
section: "allergies",
id: row.id,
title: row.substance,
meta: row.severity,
href: "/profile",
}),
),
};
}
async function visitsPart(frame: DayFrame): Promise<SectionPart> {
const rows = await prisma.encounter.findMany({
where: {
userId: frame.userId,
deletedAt: null,
status: { in: ["DONE", "PLANNED"] },
occurredAt: { gte: frame.dayStart, lt: frame.dayEnd },
},
select: {
id: true,
occurredAt: true,
kind: true,
status: true,
reasonEncrypted: true,
outcomeEncrypted: true,
practitioner: { select: { name: true } },
},
orderBy: { occurredAt: "asc" },
});
const docs = await documentsFor(
frame,
"encounter",
rows.map((r) => r.id),
);
return {
running: [],
events: rows.map((row) =>
event({
at: row.occurredAt.toISOString(),
kind: row.kind === "PROCEDURE" ? "procedure" : "visit",
section: "visits",
id: row.id,
title:
openText(row.reasonEncrypted, "visit reason") ??
row.practitioner?.name ??
"",
meta: row.status === "PLANNED" ? "PLANNED" : row.kind,
note: openText(row.outcomeEncrypted, "visit outcome"),
docs: docs.get(row.id) ?? [],
href: visitPath(row.id),
}),
),
};
}
async function vaccinationsPart(frame: DayFrame): Promise<SectionPart> {
const rows = await prisma.vaccinationRecord.findMany({
where: {
userId: frame.userId,
deletedAt: null,
occurredAt: { gte: frame.dayStart, lt: frame.dayEnd },
},
select: {
id: true,
occurredAt: true,
vaccineName: true,
antigenSlug: true,
doseNumber: true,
noteEncrypted: true,
customVaccine: { select: { name: true } },
},
orderBy: { occurredAt: "asc" },
});
const docs = await documentsFor(
frame,
"vaccination",
rows.map((r) => r.id),
);
return {
running: [],
events: rows.map((row) =>
event({
at: row.occurredAt.toISOString(),
kind: "vaccination",
section: "vaccinations",
id: row.id,
title:
row.vaccineName ?? row.customVaccine?.name ?? row.antigenSlug ?? "",
meta: row.doseNumber === null ? null : String(row.doseNumber),
note: openText(row.noteEncrypted, "vaccination note"),
docs: docs.get(row.id) ?? [],
href: `/vaccinations?dose=${row.id}`,
}),
),
};
}
/* ─── labs, check-ups, documents ──────────────────────────────────────────── */
async function labsPart(frame: DayFrame): Promise<SectionPart> {
const rows = await prisma.labResult.findMany({
where: {
userId: frame.userId,
deletedAt: null,
takenAt: { gte: frame.dayStart, lt: frame.dayEnd },
},
select: {
id: true,
analyte: true,
value: true,
valueText: true,
unit: true,
sourceValue: true,
takenAt: true,
biomarkerId: true,
noteEncrypted: true,
},
orderBy: [{ takenAt: "asc" }, { analyte: "asc" }],
});
return {
running: [],
events: rows.map((row) =>
event({
at: row.takenAt.toISOString(),
kind: "labResult",
section: "labs",
id: row.id,
title: row.analyte,
meta:
row.value !== null
? // Every recorded digit, without the float noise a converted
// reading carries (the labs page prints the same figure).
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- the day contract's meta is wire text; the lab figure keeps the notation it was stored in
`${formatRecordedNumber(row.value, (v, digits) => v.toFixed(digits), row.sourceValue)} ${row.unit}`.trim()
: (row.valueText ?? null),
note: openText(row.noteEncrypted, "lab note"),
href: row.biomarkerId ? `/labs/${row.biomarkerId}` : "/labs",
}),
),
};
}
async function checkupsPart(frame: DayFrame): Promise<SectionPart> {
const rows = await prisma.measurementReminderEvent.findMany({
where: {
userId: frame.userId,
kind: "SATISFIED",
occurredAt: { gte: frame.dayStart, lt: frame.dayEnd },
reminder: { deletedAt: null, origin: "VORSORGE" },
},
select: {
id: true,
occurredAt: true,
reminder: { select: { id: true, label: true } },
},
orderBy: { occurredAt: "asc" },
});
return {
running: [],
events: rows.map((row) =>
event({
at: row.occurredAt.toISOString(),
kind: "checkup",
section: "checkups",
id: row.id,
title: row.reminder.label,
href: "/checkups",
}),
),
};
}
async function documentsPart(frame: DayFrame): Promise<SectionPart> {
// A document's date is a calendar date stored at noon UTC (older rows at
// UTC midnight): both anchors fall inside this UTC calendar day, and the
// date key decides. The stated report date wins over the filing date.
const start = dayKeyAsUtcMidnight(frame.day);
const end = dayKeyAsUtcMidnight(shiftDateKey(frame.day, 1));
const rows = await prisma.inboundDocument.findMany({
where: {
userId: frame.userId,
deletedAt: null,
OR: [
{ reportDate: { gte: start, lt: end } },
{ reportDate: null, documentDate: { gte: start, lt: end } },
],
},
select: {
id: true,
kind: true,
title: true,
filename: true,
reportDate: true,
documentDate: true,
},
});
return {
running: [],
events: rows
.filter(
(row) =>
dateKeyOrNull(row.reportDate ?? row.documentDate) === frame.day,
)
.map((row) =>
event({
at: null,
kind: "document",
section: "documents",
id: row.id,
title: row.title ?? row.filename ?? "",
meta: row.kind,
href: `/documents?doc=${row.id}`,
}),
),
};
}
/** Documents filed against visits or doses, when the grant reaches the vault. */
async function documentsFor(
frame: DayFrame,
sourceKind: "encounter" | "vaccination",
sourceIds: string[],
): Promise<Map<string, { id: string; name: string }[]>> {
const out = new Map<string, { id: string; name: string }[]>();
// The filename of a scanned page is itself the sensitive part, so a grant
// that does not reach the vault gets no names here.
if (!frame.documentsReadable || sourceIds.length === 0) return out;
const map = await listTargetsBySource(prisma, {
userId: frame.userId,
sourceKind,
sourceIds,
targetKind: "document",
});
for (const [id, targets] of map) {
out.set(
id,
targets.map((t) => ({ id: t.id, name: t.label })),
);
}
return out;
}
/* ─── mind: mood and screeners ────────────────────────────────────────────── */
async function moodPart(frame: DayFrame): Promise<SectionPart> {
const rows = await prisma.moodEntry.findMany({
where: { userId: frame.userId, date: frame.day, deletedAt: null },
select: {
id: true,
mood: true,
score: true,
moodLoggedAt: true,
note: true,
noteEncrypted: true,
},
orderBy: { moodLoggedAt: "asc" },
});
return {
running: [],
events: rows.map((row) =>
event({
at: row.moodLoggedAt.toISOString(),
kind: "mood",
section: "mood",
id: row.id,
title: row.mood,
meta: String(row.score),
note:
row.noteEncrypted && row.noteEncrypted.byteLength > 0
? openText(row.noteEncrypted, "mood note")
: row.note,
href: `/mood?from=${frame.day}&to=${frame.day}`,
}),
),
};
}
async function assessmentsPart(frame: DayFrame): Promise<SectionPart> {
const rows = await prisma.mentalHealthAssessment.findMany({
where: {
userId: frame.userId,
deletedAt: null,
// AUDIT-C (v1.43) lives in the life circumstances, not the day's screeners.
instrument: { in: [...SCREENER_INSTRUMENTS] },
takenAt: zoneSafeWindow(frame),
},
select: {
id: true,
instrument: true,
totalScore: true,
severityBand: true,
takenAt: true,
tz: true,
},
orderBy: { takenAt: "asc" },
});
return {
running: [],
events: rows
// A screener carries the zone it was taken in; its day is cut there.
.filter(
(row) =>
userDayKey(row.takenAt, row.tz ? effectiveMoodTz(row) : frame.tz) ===
frame.day,
)
.map((row) =>
event({
at: row.takenAt.toISOString(),
kind: "assessment",
section: "assessments",
id: row.id,
title: row.instrument,
meta: `${row.totalScore} ${row.severityBand}`,
href: "/mental-wellbeing",
}),
),
};
}
/* ─── workouts, cycle ─────────────────────────────────────────────────────── */
async function workoutsPart(frame: DayFrame): Promise<SectionPart> {
const [account, stored] = await Promise.all([
prisma.user.findUnique({
where: { id: frame.userId },
select: { sourcePriorityJson: true },
}),
// The windowed reader orders the rows itself.
readWorkoutsAroundRange(
{ from: frame.dayStart, to: frame.dayEnd },
(bounds) =>
prisma.workout.findMany({
where: { userId: frame.userId, startedAt: bounds },
select: WORKOUT_PICK_SELECT,
}),
),
]);
// One session synced by two services is one workout on the day, as in the
// workout list it links to, a twin across midnight included.
const rows = canonicalWorkoutsInRange(
stored,
account?.sourcePriorityJson ?? null,
);
return {
running: [],
events: rows.map((row) =>
event({
at: row.startedAt.toISOString(),
kind: "workout",
section: "workouts",
id: row.id,
title: row.sportType,
meta: `${Math.round(row.durationSec / 60)} min`,
href: `/insights/workouts/${row.id}`,
}),
),
};
}
/**
* The cycle a day falls in, with its cycle day and phase.
*
* Only logged cycles count; a forecast row says nothing about a day that
* has passed. The day belongs to the latest logged start at or before it,
* and the cycle closes on the next logged start. The count is
* `resolveCycleDay`'s, the one the cycle ring and the mood context use, so
* an open cycle stops counting past the typical length plus the grace
* window instead of reading "day 90" for an account that stopped logging.
* The phase is `phaseForDate`'s, the one the calendar and the Coach show,
* with the open cycle running to the cached forecast. `sub` carries the
* phase code; the client words it.
*/
async function cyclePart(frame: DayFrame): Promise<SectionPart> {
const logged = {
userId: frame.userId,
deletedAt: null,
isPredicted: false,
absorbedIntoId: null,
};
const cycleFields = {
id: true,
startDate: true,
endDate: true,
periodEndDate: true,
ovulationDate: true,
} as const;
const [current, next, logs, profile, prediction] = await Promise.all([
prisma.menstrualCycle.findFirst({
where: { ...logged, startDate: { lte: frame.day } },
select: cycleFields,
orderBy: { startDate: "desc" },
}),
prisma.menstrualCycle.findFirst({
where: { ...logged, startDate: { gt: frame.day } },
select: cycleFields,
orderBy: { startDate: "asc" },
}),
prisma.cycleDayLog.findMany({
where: { userId: frame.userId, date: frame.day, deletedAt: null },
select: { id: true, flow: true },
}),
prisma.cycleProfile.findUnique({
where: { userId: frame.userId },
select: {
typicalCycleLength: true,
typicalPeriodLength: true,
lutealPhaseLength: true,
},
}),
prisma.cyclePrediction.findUnique({
where: { userId: frame.userId },
select: { nextPeriodStart: true },
}),
]);
const running: DayRunningItem[] = [];
if (current) {
const today = userDayKey(new Date(), frame.tz);
const starts = next
? [current.startDate, next.startDate]
: [current.startDate];
const dayOfCycle = resolveCycleDay(
frame.day,
starts,
today,
profile ?? undefined,
);
if (dayOfCycle !== null) {
const { phase } = phaseForDate(
frame.day,
next ? [current, next] : [current],
next ? null : (prediction?.nextPeriodStart ?? null),
profile?.lutealPhaseLength ?? LUTEAL_DEFAULT,
today,
);
const until = next ? shiftDateKey(next.startDate, -1) : null;
running.push({
kind: "cyclePhase",
section: "cycle",
id: current.id,
title: "cycle",
sub: phase,
since: current.startDate,
until,
dayIndex: dayOfCycle,
dayCount:
until === null
? null
: daysBetweenDateKeys(current.startDate, until) + 1,
href: "/cycle",
});
}
}
return {
running,
events: logs.map((log) =>
event({
at: null,
kind: "cycleDayLog",
section: "cycle",
id: log.id,
title: "cycle",
meta: log.flow,
href: "/cycle",
}),
),
};
}
/* ─── environment, life events ────────────────────────────────────────────── */
async function environmentPart(frame: DayFrame): Promise<SectionPart> {
// The travel period only; the day's weather row is a value the client
// reads from `/api/environment`. No place name leaves here: the label is
// sealed with the coordinates, and a trip is "away" without it.
const trips = await prisma.environmentTravelLocation.findMany({
where: {
userId: frame.userId,
startDate: { lte: frame.day },
endDate: { gte: frame.day },
},
select: { id: true, startDate: true, endDate: true },
});
return {
running: trips.map((trip) =>
running(frame, {
kind: "travel",
section: "environment",
id: trip.id,
title: "travel",
sub: null,
since: trip.startDate,
until: trip.endDate,
href: null,
}),
),
events: [],
};
}
async function lifeEventsPart(frame: DayFrame): Promise<SectionPart> {
// Day-precise events on this day, and periods (any precision) spanning it.
const rows = await prisma.lifeEvent.findMany({
where: {
userId: frame.userId,
deletedAt: null,
startDate: { lte: frame.day },
OR: [
{ startDate: frame.day, precision: "DAY" },
// A coarse end reaches to the end of its month or year, so the row is
// a candidate from the first of the day's year; the exact rule runs
// below.
{ endDate: { gte: `${frame.day.slice(0, 4)}-01-01` } },
],
},
select: {
id: true,
startDate: true,
endDate: true,
precision: true,
category: true,
titleEncrypted: true,
noteEncrypted: true,
},
orderBy: { startDate: "asc" },
});
const out: SectionPart = { running: [], events: [] };
for (const row of rows) {
const title = openText(row.titleEncrypted, "life event title") ?? "";
const href = `/timeline?day=${frame.day}`;
const end =
row.endDate === null ? null : lastDayCovered(row.endDate, row.precision);
if (end !== null && end >= frame.day) {
out.running.push(
running(frame, {
kind: "lifeEvent",
section: "lifeEvents",
id: row.id,
title,
sub: row.category,
since: row.startDate,
until: end,
href,
}),
);
}
if (row.precision === "DAY" && row.startDate === frame.day) {
out.events.push(
event({
at: null,
kind: "lifeEvent",
section: "lifeEvents",
id: row.id,
title,
meta: row.category,
note: openText(row.noteEncrypted, "life event note"),
href,
}),
);
}
}
return out;
}
/** The record-side reader of each section; values and sleep are elsewhere. */
export const RECORD_SECTION_READERS: Readonly<
Partial<Record<DaySectionKey, (frame: DayFrame) => Promise<SectionPart>>>
> = Object.freeze({
medications: medicationsPart,
illness: illnessPart,
symptoms: symptomsPart,
allergies: allergiesPart,
labs: labsPart,
visits: visitsPart,
vaccinations: vaccinationsPart,
checkups: checkupsPart,
documents: documentsPart,
mood: moodPart,
assessments: assessmentsPart,
workouts: workoutsPart,
cycle: cyclePart,
environment: environmentPart,
lifeEvents: lifeEventsPart,
});