mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
The day view gains a scores section below the values: the health score as
recorded that day, the readiness the nightly job persisted for the night
that ended that morning, a device's own recovery, the sleep score of that
night and strain (computed, or the device's day strain on its 0 to 21
scale). Each tile carries the person's usual range over the 30 days before
and opens the score's own page; a score the day does not hold has no tile,
and a day without any has no section.
Every value comes from where its page reads its history: the stored health
score record, the persisted RECOVERY_SCORE and STRAIN_SCORE rows read on
the day they describe, the canonical recovery ladder, and the sleep score
computed over one bounded read of the 30 nights up to the day. The stored
score rows leave the values list of the day they are stamped on, so a
readiness no longer appears a day early among the readings.
Scores follow their own module (recovery, sleep; the health score is core)
and map to the whole record for sharing: a scoped grant sees them named as
not shared, a whole-record grant sees them. get_day sends them to the
Coach and the MCP endpoint by name and number, and the Coach exclusions
take out every score built on excluded data.
A running cycle now states its cycle day and phase ("Cycle day 12,
follicular phase"), computed the way the cycle calendar does. Only logged
cycles count, the next logged start closes a cycle, and an open cycle
stops counting past its typical length plus the grace window instead of
reading a day count nobody recorded.
184 lines
5.8 KiB
TypeScript
184 lines
5.8 KiB
TypeScript
/**
|
|
* `loadDay`: one local day across the record, for every reader (v1.42, #613).
|
|
*
|
|
* The web view, the native client, the Coach and the MCP endpoint all read a
|
|
* day through this function, so they cannot disagree about what a Tuesday
|
|
* held. The readings come live from `measurements` inside the local window
|
|
* (never from the rollups, which cut at UTC), the record sections from their
|
|
* own tables, each bounded to the day and indexed on its time field, and all
|
|
* of them in parallel.
|
|
*
|
|
* What the caller may see is decided before anything is read
|
|
* (`DayAccess`): a section of a switched-off module is not read and not
|
|
* mentioned, a section the grant does not cover is not read and is named
|
|
* once in `sections`. Nothing here writes, and nothing here talks to a model;
|
|
* `model-projection.ts` is the one way a day leaves for one.
|
|
*/
|
|
import type { MeasurementType } from "@/generated/prisma/enums";
|
|
|
|
import type { DayEvent, DayResponse, DayRunningItem } from "@/lib/day/contract";
|
|
import { loadNotableRange } from "@/lib/day/notable";
|
|
import {
|
|
RECORD_SECTION_READERS,
|
|
type DayFrame,
|
|
type SectionPart,
|
|
} from "@/lib/day/records";
|
|
import { DAY_SCORE_MEASUREMENT_TYPES, readDayScores } from "@/lib/day/scores";
|
|
import { measurementTypeVisible, type DayAccess } from "@/lib/day/sections";
|
|
import { readDayBands, shapeDayValues } from "@/lib/day/values";
|
|
import {
|
|
resolveDayReadings,
|
|
type DayReadings,
|
|
} from "@/lib/mood/linked-context";
|
|
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
|
import { localDayWindow } from "@/lib/tz/local-day";
|
|
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
|
|
|
const NO_READINGS: DayReadings = {
|
|
rowsByType: new Map(),
|
|
night: null,
|
|
nightSource: null,
|
|
};
|
|
|
|
/** Date-only entries first, then by time; ties keep the section order. */
|
|
function byTime(a: DayEvent, b: DayEvent): number {
|
|
if (a.at === b.at) return 0;
|
|
if (a.at === null) return -1;
|
|
if (b.at === null) return 1;
|
|
return a.at.localeCompare(b.at);
|
|
}
|
|
|
|
/** Oldest start first; an item without a start date follows the dated ones. */
|
|
function bySince(a: DayRunningItem, b: DayRunningItem): number {
|
|
if (a.since !== b.since) {
|
|
if (a.since === null) return 1;
|
|
if (b.since === null) return -1;
|
|
return a.since.localeCompare(b.since);
|
|
}
|
|
return a.title.localeCompare(b.title);
|
|
}
|
|
|
|
export interface LoadDayArgs {
|
|
/** The record's user id (the resolved user, never the actor). */
|
|
recordId: string;
|
|
day: string;
|
|
access: DayAccess;
|
|
/** The record's zone; resolved when absent. */
|
|
tz?: string;
|
|
/** The earliest instant a comparison may read (a lookback limit). */
|
|
floor?: Date | null;
|
|
}
|
|
|
|
export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
|
|
const { recordId, day, access } = args;
|
|
const tz = args.tz ?? (await resolveUserTimezone(recordId));
|
|
const { dayStart, dayEnd } = localDayWindow(day, tz);
|
|
const valuesReadable = access.readable.has("values");
|
|
const sleepReadable = access.readable.has("sleep");
|
|
const scoresReadable = access.readable.has("scores");
|
|
|
|
const typeVisible = (type: MeasurementType): boolean => {
|
|
if (!measurementTypeVisible(type, access.modules)) return false;
|
|
// A stored score is shown with the scores, on the day it describes.
|
|
if (scoresReadable && DAY_SCORE_MEASUREMENT_TYPES.has(type)) return false;
|
|
return type === "SLEEP_DURATION" ? sleepReadable : valuesReadable;
|
|
};
|
|
|
|
const frame: DayFrame = {
|
|
userId: recordId,
|
|
day,
|
|
tz,
|
|
dayStart,
|
|
dayEnd,
|
|
documentsReadable: access.readable.has("documents"),
|
|
};
|
|
|
|
const priority =
|
|
valuesReadable || sleepReadable || scoresReadable
|
|
? await loadUserSourcePriority(recordId)
|
|
: null;
|
|
|
|
const recordSections = [...access.readable].filter(
|
|
(section) => RECORD_SECTION_READERS[section] !== undefined,
|
|
);
|
|
const [readings, parts, scores] = await Promise.all([
|
|
valuesReadable || sleepReadable
|
|
? resolveDayReadings(
|
|
recordId,
|
|
day,
|
|
tz,
|
|
{ dayStart, dayEnd },
|
|
typeVisible,
|
|
priority,
|
|
)
|
|
: Promise.resolve(NO_READINGS),
|
|
Promise.all(
|
|
recordSections.map((section) =>
|
|
(
|
|
RECORD_SECTION_READERS[section] as (
|
|
frame: DayFrame,
|
|
) => Promise<SectionPart>
|
|
)(frame),
|
|
),
|
|
),
|
|
scoresReadable
|
|
? readDayScores({
|
|
userId: recordId,
|
|
day,
|
|
tz,
|
|
modules: access.modules,
|
|
priorityJson: priority,
|
|
floor: args.floor ?? null,
|
|
})
|
|
: Promise.resolve([]),
|
|
]);
|
|
|
|
const presentTypes = [...readings.rowsByType.keys()];
|
|
const [bands, notable] =
|
|
presentTypes.length === 0
|
|
? [new Map(), []]
|
|
: await Promise.all([
|
|
readDayBands({
|
|
userId: recordId,
|
|
day,
|
|
tz,
|
|
types: presentTypes,
|
|
priorityJson: priority,
|
|
floor: args.floor ?? null,
|
|
}),
|
|
loadNotableRange({
|
|
userId: recordId,
|
|
from: day,
|
|
to: day,
|
|
tz,
|
|
priorityJson: priority,
|
|
typeVisible,
|
|
gaps: false,
|
|
extremeTypes: presentTypes,
|
|
firstValueTypes: presentTypes,
|
|
floor: args.floor ?? null,
|
|
}),
|
|
]);
|
|
|
|
const values = shapeDayValues(readings, bands, sleepReadable);
|
|
const running = parts.flatMap((p) => p.running).sort(bySince);
|
|
const events = parts.flatMap((p) => p.events).sort(byTime);
|
|
|
|
const sections: DayResponse["sections"] = {};
|
|
for (const section of access.notShared) {
|
|
sections[section] = { available: false, reason: "not_shared" };
|
|
}
|
|
|
|
return {
|
|
date: day,
|
|
tz,
|
|
counts: { values: values.length, entries: events.length },
|
|
running,
|
|
values,
|
|
events,
|
|
notable: notable.map(({ kind, type, params }) => ({ kind, type, params })),
|
|
scores,
|
|
sections,
|
|
};
|
|
}
|