Files
HealthLog/src/lib/report-selection/panel-state.ts
T
Marc-André Bombeck f12290532f fix(sharing): let the owner replace a link the upgrade closed, and count only what is shown
Migration 0273 revokes every live record share link minted before the selection
model, because their frozen scope was "everything except mood and cycle" — a
scope nobody chose. The owner has to see why the link stopped and be able to
replace it without retyping what they already decided, or a deliberate closure
reads as a fault. The list now names that state, says why in one sentence, and
offers a replacement that carries the label, the window and the lifetime
across, leaving exactly the decision that link never carried.

Writing the test for it found a defect in the picker. The group count and the
group checkbox read the catalogue's group rather than the leaves the surface
actually renders, so on the share-link form — where the insurance leaf is
hidden because the server refuses it — the identity row read "1/2" and drew
itself half-selected over a leaf the owner could neither see nor reach, and its
checkbox would have tried to add it. The three helpers take the rendered leaves
now, so a control cannot describe more than it offers.
2026-07-26 00:45:08 +02:00

129 lines
4.2 KiB
TypeScript

/**
* The pure state of the report scope picker.
*
* Kept out of the client component so the control shape is testable without a
* DOM, and so the structural guard can import a lib module rather than reach
* into a component. The panel owns layout and interaction; this owns "which
* controls exist, what each one means, and what the scope line says".
*
* Only leaves are ever stored. A group's state is DERIVED from its leaves, so
* a group and its leaves cannot disagree — there is no second source of truth
* to reconcile.
*/
import {
REPORT_GROUPS,
SENSITIVE_GROUP_ID,
leafLabelKey,
type ReportGroupId,
type ReportLeafId,
} from "./catalogue";
/** The panel's own state: the set of chosen leaf ids. */
export type PanelSelection = ReadonlySet<ReportLeafId>;
/** A group checkbox is on, off, or partly on. `mixed` is never a click target. */
export type GroupCheckState = "all" | "none" | "mixed";
/**
* `leaves` is the group's leaves AS THE SURFACE RENDERS THEM, which is not
* always the catalogue's: the share-link form hides the insurance leaf, and a
* count or a check state computed over the full group would then describe
* something the user cannot see or reach.
*/
export function groupCheckState(
leaves: readonly ReportLeafId[],
selected: PanelSelection,
): GroupCheckState {
if (leaves.length === 0) return "none";
const on = leaves.filter((leaf) => selected.has(leaf)).length;
if (on === 0) return "none";
if (on === leaves.length) return "all";
return "mixed";
}
/** How many of the rendered leaves are chosen, and how many there are. */
export function groupCount(
leaves: readonly ReportLeafId[],
selected: PanelSelection,
): { on: number; total: number } {
return {
on: leaves.filter((leaf) => selected.has(leaf)).length,
total: leaves.length,
};
}
/** Toggle one leaf. */
export function toggleLeaf(
selected: PanelSelection,
leaf: ReportLeafId,
): Set<ReportLeafId> {
const next = new Set(selected);
if (next.has(leaf)) next.delete(leaf);
else next.add(leaf);
return next;
}
/**
* Toggle a whole group.
*
* `mixed` and `none` both go to all-on; `all` goes to all-off. Turning a group
* off and back on within one session restores the previous leaf pattern via
* `remembered`, so the escape hatch does not cost the user their earlier
* refinements. Across sessions only leaves persist and the group state is
* re-derived.
*
* The fenced tier has no group control at all — `REPORT_GROUPS` still lists
* it, but the panel renders no checkbox for it, and this function refuses it
* so no future caller can route around that.
*/
export function toggleGroup(
selected: PanelSelection,
group: ReportGroupId,
leaves: readonly ReportLeafId[],
remembered?: PanelSelection,
): Set<ReportLeafId> {
if (group === SENSITIVE_GROUP_ID) return new Set(selected);
const next = new Set(selected);
if (groupCheckState(leaves, selected) === "all") {
for (const leaf of leaves) next.delete(leaf);
return next;
}
const restore = remembered
? leaves.filter((leaf) => remembered.has(leaf))
: [];
for (const leaf of restore.length > 0 ? restore : leaves) next.add(leaf);
return next;
}
/**
* The scope line under the Generate button, as the parts a component renders.
*
* Fenced inclusions are always restated BY NAME, never folded into the count.
* A number cannot be checked at a glance; "incl. Mood, PHQ-9" can, and the
* reading is the consent check.
*/
export interface ScopeSummary {
/** How many leaves are chosen in total. */
total: number;
/** How many groups contribute at least one leaf. */
groups: number;
/** i18n label keys for the chosen fenced leaves, in catalogue order. */
sensitiveLabelKeys: string[];
}
export function scopeSummary(selected: PanelSelection): ScopeSummary {
let total = 0;
let groups = 0;
const sensitiveLabelKeys: string[] = [];
for (const group of REPORT_GROUPS) {
const on = group.leaves.filter((leaf) => selected.has(leaf));
if (on.length === 0) continue;
groups += 1;
total += on.length;
if (group.id === SENSITIVE_GROUP_ID) {
sensitiveLabelKeys.push(...on.map(leafLabelKey));
}
}
return { total, groups, sensitiveLabelKeys };
}