mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Merge work/score-settings-ui
This commit is contained in:
commit
cdfe4ec114
26 files changed
+2809
-30
No files matched your search
@@ -38,6 +38,15 @@ export const settingsKeys = {
|
||||
*/
|
||||
modulesPrefs: () => ["settings", "modules"] as const,
|
||||
|
||||
/**
|
||||
* v1.35.0 — which pillars count toward the Health Score, behind
|
||||
* `GET/PATCH /api/auth/me/health-score-config`. Its own cell rather than
|
||||
* a slice of `authMe()`: the payload carries the optimistic-concurrency
|
||||
* token the save echoes, and folding it into the account payload would
|
||||
* make every unrelated profile write advance the token.
|
||||
*/
|
||||
healthScoreConfig: () => ["settings", "health-score-config"] as const,
|
||||
|
||||
apiVersion: () => ["api", "version"] as const,
|
||||
publicVersion: () => ["public", "version"] as const,
|
||||
/** v1.15.12 H1 — admin overview "update available" check against the
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* What the "what counts toward your score" surface shows, and what it
|
||||
* refuses to show.
|
||||
*
|
||||
* Four decisions are pinned here because each of them is a sentence a
|
||||
* person reads about their own health, and each has a plausible wrong
|
||||
* version that would look fine in a screenshot:
|
||||
*
|
||||
* 1. A pillar every feeding module has switched off gets NO row. Not a
|
||||
* greyed row, not a "waiting for data" line. It is not waiting for
|
||||
* anything, and under a never-written recipe the word "selected"
|
||||
* would describe a default nobody picked.
|
||||
* 2. "Selected, waiting for data" belongs to a pillar the person
|
||||
* counts, whose module is on, and which the SERVER said has nothing
|
||||
* to score. Not to a pillar the person just switched off, and not to
|
||||
* one the server never mentioned.
|
||||
* 3. Wellbeing under crisis signposting is its own state and never a
|
||||
* configuration problem.
|
||||
* 4. A pillar this build can never score is never sent as part of a
|
||||
* selection, because the write-time breadth rule would otherwise
|
||||
* accept a recipe on its behalf that the scorer then refuses.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildScoreConfigRows,
|
||||
selectionToSave,
|
||||
showsWaitingForData,
|
||||
UNSCORABLE_PILLARS,
|
||||
type ScoreConfigRow,
|
||||
} from "@/lib/score-config/rows";
|
||||
import {
|
||||
SCORE_PILLAR_IDS,
|
||||
type ScorePillarId,
|
||||
} from "@/lib/analytics/score/types";
|
||||
|
||||
const ALL: ScorePillarId[] = [...SCORE_PILLAR_IDS];
|
||||
const ALL_MODULES_ON = {
|
||||
glucose: true,
|
||||
labs: true,
|
||||
sleep: true,
|
||||
mentalHealth: true,
|
||||
};
|
||||
|
||||
function rowsOf(result: ReturnType<typeof buildScoreConfigRows>) {
|
||||
return result.groups.flatMap((group) => group.rows);
|
||||
}
|
||||
|
||||
function row(
|
||||
result: ReturnType<typeof buildScoreConfigRows>,
|
||||
id: ScorePillarId,
|
||||
): ScoreConfigRow | undefined {
|
||||
return rowsOf(result).find((candidate) => candidate.id === id);
|
||||
}
|
||||
|
||||
describe("grouping", () => {
|
||||
it("puts blood pressure, glycaemia and lipids under one domain", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: ALL_MODULES_ON,
|
||||
});
|
||||
|
||||
const cardio = built.groups.find(
|
||||
(group) => group.domain === "cardiometabolic",
|
||||
);
|
||||
expect(cardio).toBeDefined();
|
||||
expect(cardio?.rows.map((r) => r.id)).toEqual([
|
||||
"BLOOD_PRESSURE",
|
||||
"GLYCAEMIA",
|
||||
"LIPIDS",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives every other pillar a domain of its own", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: ALL_MODULES_ON,
|
||||
});
|
||||
|
||||
for (const group of built.groups) {
|
||||
if (group.domain === "cardiometabolic") continue;
|
||||
expect(group.rows).toHaveLength(1);
|
||||
}
|
||||
// Six domains across eight pillars is the whole reason the surface
|
||||
// groups: equal weight per pillar is not equal weight per domain.
|
||||
expect(built.groups).toHaveLength(6);
|
||||
expect(rowsOf(built)).toHaveLength(SCORE_PILLAR_IDS.length);
|
||||
});
|
||||
|
||||
it("emits no group for a domain whose only pillar is hidden", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: { ...ALL_MODULES_ON, sleep: false },
|
||||
});
|
||||
|
||||
expect(built.groups.some((group) => group.domain === "sleep")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a module that is off", () => {
|
||||
it("removes the pillar entirely rather than showing it as waiting", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: { ...ALL_MODULES_ON, sleep: false },
|
||||
verdicts: { SLEEP: { status: "insufficient", reason: "stale" } },
|
||||
});
|
||||
|
||||
expect(row(built, "SLEEP")).toBeUndefined();
|
||||
expect(built.omitted).toContain("SLEEP");
|
||||
});
|
||||
|
||||
it("keeps glycaemia while either of its two modules is on", () => {
|
||||
const glucoseOnly = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: { ...ALL_MODULES_ON, labs: false },
|
||||
});
|
||||
const labsOnly = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: { ...ALL_MODULES_ON, glucose: false },
|
||||
});
|
||||
|
||||
expect(row(glucoseOnly, "GLYCAEMIA")).toBeDefined();
|
||||
expect(row(labsOnly, "GLYCAEMIA")).toBeDefined();
|
||||
// Lipids ride on labs alone, so they leave with it.
|
||||
expect(row(glucoseOnly, "LIPIDS")).toBeUndefined();
|
||||
expect(row(labsOnly, "LIPIDS")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides glycaemia only when both of its modules are off", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: { ...ALL_MODULES_ON, glucose: false, labs: false },
|
||||
});
|
||||
|
||||
expect(row(built, "GLYCAEMIA")).toBeUndefined();
|
||||
expect(built.omitted).toEqual(["GLYCAEMIA", "LIPIDS"]);
|
||||
});
|
||||
|
||||
it("reads a missing module key as on", () => {
|
||||
const built = buildScoreConfigRows({ selection: ALL, modules: {} });
|
||||
|
||||
expect(rowsOf(built)).toHaveLength(SCORE_PILLAR_IDS.length);
|
||||
expect(built.omitted).toEqual([]);
|
||||
});
|
||||
|
||||
it("never omits a pillar no module feeds", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: {
|
||||
glucose: false,
|
||||
labs: false,
|
||||
sleep: false,
|
||||
mentalHealth: false,
|
||||
},
|
||||
});
|
||||
|
||||
for (const id of [
|
||||
"BLOOD_PRESSURE",
|
||||
"ACTIVITY",
|
||||
"ADIPOSITY",
|
||||
"FITNESS",
|
||||
] as ScorePillarId[]) {
|
||||
expect(row(built, id)).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("selected, waiting for data", () => {
|
||||
it("is shown for a counted pillar the server had nothing to score", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: ALL_MODULES_ON,
|
||||
verdicts: { LIPIDS: { status: "insufficient", reason: "not_tracked" } },
|
||||
});
|
||||
|
||||
const lipids = row(built, "LIPIDS");
|
||||
expect(lipids?.eligibility).toBe("waiting");
|
||||
expect(showsWaitingForData(lipids!)).toBe(true);
|
||||
});
|
||||
|
||||
it("is not shown for a pillar the draft has switched off", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL.filter((id) => id !== "LIPIDS"),
|
||||
modules: ALL_MODULES_ON,
|
||||
verdicts: { LIPIDS: { status: "insufficient", reason: "not_tracked" } },
|
||||
});
|
||||
|
||||
const lipids = row(built, "LIPIDS");
|
||||
expect(lipids?.counts).toBe(false);
|
||||
expect(showsWaitingForData(lipids!)).toBe(false);
|
||||
});
|
||||
|
||||
it("is not shown while the server has said nothing", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: ALL_MODULES_ON,
|
||||
});
|
||||
|
||||
const lipids = row(built, "LIPIDS");
|
||||
expect(lipids?.eligibility).toBe("unknown");
|
||||
expect(showsWaitingForData(lipids!)).toBe(false);
|
||||
});
|
||||
|
||||
it("is not shown for a pillar that scored", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: ALL_MODULES_ON,
|
||||
verdicts: { SLEEP: { status: "ok" } },
|
||||
});
|
||||
|
||||
const sleep = row(built, "SLEEP");
|
||||
expect(sleep?.eligibility).toBe("counting");
|
||||
expect(showsWaitingForData(sleep!)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("states that are not absence", () => {
|
||||
it("keeps crisis signposting out of the waiting state", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: ALL_MODULES_ON,
|
||||
verdicts: {
|
||||
WELLBEING: {
|
||||
status: "insufficient",
|
||||
reason: "crisis_signposting",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wellbeing = row(built, "WELLBEING");
|
||||
expect(wellbeing?.eligibility).toBe("crisis");
|
||||
expect(showsWaitingForData(wellbeing!)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a failed read out of the waiting state", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: ALL_MODULES_ON,
|
||||
verdicts: {
|
||||
BLOOD_PRESSURE: { status: "insufficient", reason: "read_failed" },
|
||||
},
|
||||
});
|
||||
|
||||
const bp = row(built, "BLOOD_PRESSURE");
|
||||
expect(bp?.eligibility).toBe("read_failed");
|
||||
expect(showsWaitingForData(bp!)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pillar this build cannot score", () => {
|
||||
it("is present, honest and never selectable", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: ALL_MODULES_ON,
|
||||
// Even a verdict claiming it scored cannot make it selectable.
|
||||
verdicts: { FITNESS: { status: "ok" } },
|
||||
});
|
||||
|
||||
const fitness = row(built, "FITNESS");
|
||||
expect(fitness).toBeDefined();
|
||||
expect(fitness?.selectable).toBe(false);
|
||||
expect(fitness?.eligibility).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("leaves every other pillar selectable", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: ALL_MODULES_ON,
|
||||
});
|
||||
|
||||
for (const candidate of rowsOf(built)) {
|
||||
expect(candidate.selectable).toBe(!UNSCORABLE_PILLARS.has(candidate.id));
|
||||
}
|
||||
});
|
||||
|
||||
it("is never sent as part of a saved selection", () => {
|
||||
expect(selectionToSave(ALL)).not.toContain("FITNESS");
|
||||
expect(selectionToSave(["FITNESS"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("cannot carry a selection over the breadth floor on its own", () => {
|
||||
// The failure this guards: FITNESS is a domain of its own AND counts
|
||||
// as physiological, so echoing it back would let the write-time rule
|
||||
// accept `{ACTIVITY, WELLBEING, FITNESS}` — three domains, one of
|
||||
// them physiological — for a score the reader then refuses, because
|
||||
// FITNESS never reaches the composition.
|
||||
expect(selectionToSave(["ACTIVITY", "WELLBEING", "FITNESS"])).toEqual([
|
||||
"ACTIVITY",
|
||||
"WELLBEING",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps registry order and drops duplicates", () => {
|
||||
expect(selectionToSave(["LIPIDS", "ACTIVITY", "ACTIVITY"])).toEqual([
|
||||
"ACTIVITY",
|
||||
"LIPIDS",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* The localised names of the score's pillars and the domains they sit in.
|
||||
*
|
||||
* One map per concept, read by the hero panel and by the settings surface
|
||||
* that decides what counts. Two copies of a name map drift the moment one
|
||||
* surface renames a pillar, and the two surfaces would then be talking
|
||||
* about different things while showing the same list.
|
||||
*/
|
||||
import type { ScoreDomain, ScorePillarId } from "@/lib/analytics/score/types";
|
||||
|
||||
export const SCORE_PILLAR_LABEL_KEYS: Record<ScorePillarId, string> = {
|
||||
BLOOD_PRESSURE: "insights.healthScore.pillar.bloodPressure",
|
||||
GLYCAEMIA: "insights.healthScore.pillar.glycaemia",
|
||||
ACTIVITY: "insights.healthScore.pillar.activity",
|
||||
SLEEP: "insights.healthScore.pillar.sleep",
|
||||
ADIPOSITY: "insights.healthScore.pillar.adiposity",
|
||||
WELLBEING: "insights.healthScore.pillar.wellbeing",
|
||||
FITNESS: "insights.healthScore.pillar.fitness",
|
||||
LIPIDS: "insights.healthScore.pillar.lipids",
|
||||
};
|
||||
|
||||
/**
|
||||
* Domain headings. The cardiometabolic one carries three pillars, which
|
||||
* is the whole reason the surface groups at all: equal weight per pillar
|
||||
* is not equal weight per area, and nothing in the app said so before.
|
||||
*/
|
||||
export const SCORE_DOMAIN_LABEL_KEYS: Record<ScoreDomain, string> = {
|
||||
cardiometabolic: "settings.sections.score.domain.cardiometabolic",
|
||||
activity: "settings.sections.score.domain.activity",
|
||||
sleep: "settings.sections.score.domain.sleep",
|
||||
adiposity: "settings.sections.score.domain.adiposity",
|
||||
wellbeing: "settings.sections.score.domain.wellbeing",
|
||||
fitness: "settings.sections.score.domain.fitness",
|
||||
};
|
||||
|
||||
/**
|
||||
* Domains that need a sentence under the heading. Only the one that
|
||||
* carries more than one pillar does, and it is not decoration: without it
|
||||
* the triple count stays invisible and deselecting one of the three
|
||||
* reshapes the balance with no cue.
|
||||
*/
|
||||
export const SCORE_DOMAIN_NOTE_KEYS: Partial<Record<ScoreDomain, string>> = {
|
||||
cardiometabolic: "settings.sections.score.domainNote.cardiometabolic",
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* v1.35.0 — what the "what counts toward your score" surface shows, as a
|
||||
* pure function over three inputs the page already holds.
|
||||
*
|
||||
* Kept out of the component so the row states can be driven directly by a
|
||||
* test instead of through a rendered tree, and so the page has exactly one
|
||||
* place that decides what a row says.
|
||||
*
|
||||
* The three inputs, and why each is needed:
|
||||
*
|
||||
* - **the draft selection** — what the person currently has switched on
|
||||
* in front of them, which is not yet what the server holds;
|
||||
* - **the modules map** — whether the pillar's data is being recorded at
|
||||
* all. A pillar every feeding module has switched off is not shown,
|
||||
* full stop (see `omitted`, and `composition-source.test.ts`, which
|
||||
* pins the same decision one layer down);
|
||||
* - **the server's own pillar verdicts** — whether a pillar currently
|
||||
* scores, is waiting for data, is suppressed for safety, or could not
|
||||
* be read. These are never re-derived here. A pillar the server said
|
||||
* nothing about reads as `unknown`, and `unknown` renders no claim.
|
||||
*
|
||||
* The eligibility a row shows describes the recipe the SERVER scored, not
|
||||
* the draft in front of the person. That is deliberate: the draft has not
|
||||
* been computed against anything, so a line derived from it would be a
|
||||
* guess wearing the voice of a fact.
|
||||
*/
|
||||
import {
|
||||
SCORE_PILLAR_DOMAINS,
|
||||
SCORE_PILLAR_IDS,
|
||||
type ScoreDomain,
|
||||
type ScorePillarId,
|
||||
} from "@/lib/analytics/score/types";
|
||||
import {
|
||||
pillarsWithModuleData,
|
||||
type ScoreModuleKey,
|
||||
type ScoreReaderModules,
|
||||
} from "@/lib/analytics/score/modules";
|
||||
|
||||
/**
|
||||
* Pillars this build can never score, whatever anyone selects.
|
||||
*
|
||||
* FITNESS is the only entry and it is not a placeholder for a future
|
||||
* list. The schema records VO₂max but cannot distinguish a measured test
|
||||
* from a device estimate, so every FITNESS row reaches the scorer with
|
||||
* `measured: false` and is refused (`src/lib/analytics/score/reader.ts`,
|
||||
* the FITNESS input block). Offering it as a switch would be chrome over
|
||||
* a thing that recomputes nothing — the exact defect this release exists
|
||||
* to remove — so the row is present, honest, and inert.
|
||||
*/
|
||||
export const UNSCORABLE_PILLARS: ReadonlySet<ScorePillarId> = new Set([
|
||||
"FITNESS",
|
||||
]);
|
||||
|
||||
/**
|
||||
* What the server's last scoring run said about one pillar. Every value
|
||||
* comes from a `ScorePillarResult`; nothing here is computed from data.
|
||||
*/
|
||||
export type ScoreRowEligibility =
|
||||
/** It scored. */
|
||||
| "counting"
|
||||
/** Selected, its data path is open, and there is nothing to score yet. */
|
||||
| "waiting"
|
||||
/** Wellbeing under crisis signposting. Never a configuration problem. */
|
||||
| "crisis"
|
||||
/** The read broke. Absence and failure are different states. */
|
||||
| "read_failed"
|
||||
/** This build cannot score it at all. */
|
||||
| "unavailable"
|
||||
/** The server said nothing about it. No claim is made either way. */
|
||||
| "unknown";
|
||||
|
||||
export interface ScoreConfigRow {
|
||||
id: ScorePillarId;
|
||||
domain: ScoreDomain;
|
||||
/** True when the draft counts this pillar toward the score. */
|
||||
counts: boolean;
|
||||
/** False when the switch is inert — today only the unscorable pillars. */
|
||||
selectable: boolean;
|
||||
eligibility: ScoreRowEligibility;
|
||||
}
|
||||
|
||||
export interface ScoreConfigGroup {
|
||||
domain: ScoreDomain;
|
||||
rows: ScoreConfigRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One pillar's verdict as the page reads it off the report: the status,
|
||||
* plus the reason when the status is `insufficient`.
|
||||
*/
|
||||
export interface ScorePillarVerdict {
|
||||
status: "ok" | "insufficient";
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export interface BuildScoreConfigRowsInput {
|
||||
/** The draft selection the switches currently show. */
|
||||
selection: readonly ScorePillarId[];
|
||||
/** Resolved module map. A missing key reads as ON (fail open). */
|
||||
modules: Partial<Record<ScoreModuleKey, boolean>>;
|
||||
/**
|
||||
* Per-pillar verdicts from the last scoring run, keyed by pillar id.
|
||||
* A pillar the server did not report on is simply absent.
|
||||
*/
|
||||
verdicts?: Partial<Record<ScorePillarId, ScorePillarVerdict>>;
|
||||
}
|
||||
|
||||
/** Fail-open module resolution, matching every other gate in the app. */
|
||||
function resolveModules(
|
||||
modules: Partial<Record<ScoreModuleKey, boolean>>,
|
||||
): ScoreReaderModules {
|
||||
return {
|
||||
glucose: modules.glucose !== false,
|
||||
labs: modules.labs !== false,
|
||||
sleep: modules.sleep !== false,
|
||||
mentalHealth: modules.mentalHealth !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function eligibilityOf(
|
||||
id: ScorePillarId,
|
||||
verdict: ScorePillarVerdict | undefined,
|
||||
): ScoreRowEligibility {
|
||||
if (UNSCORABLE_PILLARS.has(id)) return "unavailable";
|
||||
if (!verdict) return "unknown";
|
||||
if (verdict.status === "ok") return "counting";
|
||||
if (verdict.reason === "crisis_signposting") return "crisis";
|
||||
if (verdict.reason === "read_failed") return "read_failed";
|
||||
return "waiting";
|
||||
}
|
||||
|
||||
/**
|
||||
* The pillars whose data path is closed, so the surface omits them.
|
||||
*
|
||||
* Returned beside the groups rather than silently dropped, because a
|
||||
* caller that wants to say where those pillars went (the modules screen)
|
||||
* needs to know there were any.
|
||||
*/
|
||||
export interface ScoreConfigRowsResult {
|
||||
groups: ScoreConfigGroup[];
|
||||
/** Registry-ordered pillars hidden because every feeding module is off. */
|
||||
omitted: ScorePillarId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the surface's rows, grouped by domain in registry order.
|
||||
*
|
||||
* A domain with no visible rows produces no group, so a heading can never
|
||||
* stand above an empty cluster.
|
||||
*/
|
||||
export function buildScoreConfigRows(
|
||||
input: BuildScoreConfigRowsInput,
|
||||
): ScoreConfigRowsResult {
|
||||
const recorded = new Set(
|
||||
pillarsWithModuleData(resolveModules(input.modules)),
|
||||
);
|
||||
const selected = new Set(input.selection);
|
||||
const groups: ScoreConfigGroup[] = [];
|
||||
const byDomain = new Map<ScoreDomain, ScoreConfigRow[]>();
|
||||
const omitted: ScorePillarId[] = [];
|
||||
|
||||
for (const id of SCORE_PILLAR_IDS) {
|
||||
if (!recorded.has(id)) {
|
||||
omitted.push(id);
|
||||
continue;
|
||||
}
|
||||
const domain = SCORE_PILLAR_DOMAINS[id];
|
||||
const row: ScoreConfigRow = {
|
||||
id,
|
||||
domain,
|
||||
counts: selected.has(id),
|
||||
selectable: !UNSCORABLE_PILLARS.has(id),
|
||||
eligibility: eligibilityOf(id, input.verdicts?.[id]),
|
||||
};
|
||||
const existing = byDomain.get(domain);
|
||||
if (existing) {
|
||||
existing.push(row);
|
||||
} else {
|
||||
const rows = [row];
|
||||
byDomain.set(domain, rows);
|
||||
groups.push({ domain, rows });
|
||||
}
|
||||
}
|
||||
|
||||
return { groups, omitted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a row earns the "selected, waiting for data" line.
|
||||
*
|
||||
* Narrow on purpose, and the narrowing is the decision: the line belongs
|
||||
* to a pillar the person counts, whose module is on (which is what makes
|
||||
* the row visible at all), and which the server reported as having
|
||||
* nothing to score yet. A pillar whose module is off never reaches here
|
||||
* because it has no row; a pillar the draft excludes is not waiting for
|
||||
* anything on the person's behalf; and an `unknown` verdict is not
|
||||
* evidence of absence.
|
||||
*/
|
||||
export function showsWaitingForData(row: ScoreConfigRow): boolean {
|
||||
return row.counts && row.eligibility === "waiting";
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection a save should send.
|
||||
*
|
||||
* Every pillar the person can actually choose, and nothing else. A pillar
|
||||
* this build cannot score is left out rather than echoed back: echoing it
|
||||
* would let it satisfy the write-time breadth rule (it is a domain of its
|
||||
* own AND a physiological pillar) on behalf of a score it can never
|
||||
* reach, so the write would be accepted and the score would still
|
||||
* disappear at read time. The refusal is only worth having if it judges
|
||||
* the recipe that will actually be scored.
|
||||
*/
|
||||
export function selectionToSave(
|
||||
selection: readonly ScorePillarId[],
|
||||
): ScorePillarId[] {
|
||||
const selected = new Set(selection);
|
||||
return SCORE_PILLAR_IDS.filter(
|
||||
(id) => selected.has(id) && !UNSCORABLE_PILLARS.has(id),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user