mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
The day view started about twenty-five section reads at once beside the readings, scores and movement, more than the pool holds, so a neighbouring request waited for a connection. The sections now run six at a time and come back in the same order.
204 lines
6.7 KiB
TypeScript
204 lines
6.7 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 pLimit from "p-limit";
|
|
|
|
import type { MeasurementType } from "@/generated/prisma/enums";
|
|
|
|
import { readDayMovement } from "@/lib/dawarich/movement";
|
|
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";
|
|
|
|
/** Record sections read at once for one day (see `loadDay`). */
|
|
const DAY_SECTION_CONCURRENCY = 6;
|
|
|
|
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 sectionLimit = pLimit(DAY_SECTION_CONCURRENCY);
|
|
const recordSections = [...access.readable].filter(
|
|
(section) => RECORD_SECTION_READERS[section] !== undefined,
|
|
);
|
|
// The movement context rides the environment section: owner-only, and
|
|
// gone with the module.
|
|
const [readings, parts, scores, movement] = await Promise.all([
|
|
valuesReadable || sleepReadable
|
|
? resolveDayReadings(
|
|
recordId,
|
|
day,
|
|
tz,
|
|
{ dayStart, dayEnd },
|
|
typeVisible,
|
|
priority,
|
|
)
|
|
: Promise.resolve(NO_READINGS),
|
|
// About twenty-five section readers, each a query or two, beside the
|
|
// readings, scores and movement: unbounded they took more connections
|
|
// than the pool holds, and a neighbouring request waited for a slot.
|
|
// Six at a time keeps the day under a third of the pool; the parts come
|
|
// back in section order as before.
|
|
Promise.all(
|
|
recordSections.map((section) =>
|
|
sectionLimit(() =>
|
|
(
|
|
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([]),
|
|
access.readable.has("environment")
|
|
? readDayMovement(recordId, day)
|
|
: Promise.resolve(undefined),
|
|
]);
|
|
|
|
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, tz);
|
|
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,
|
|
...(movement === undefined ? {} : { movement }),
|
|
};
|
|
}
|