mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Take the fitness pillar out of the health score
It could never score. The schema records VO2max but cannot tell a measured test apart from a device estimate, so every fitness row reached the scorer unproven and was refused - the pillar has never produced a value on any account. Listing it on the score settings page as a thing you can choose, with a line admitting it does nothing, was chrome over a switch that changed no number. So it leaves the catalogue: no pillar id, no domain, no scorer, no row, no wire field. A stored selection that still names it reads back fine - the resolver has always filtered stored ids against the catalogue and drops what it no longer knows, so nobody's saved recipe throws or is discarded over it. That also fixes the page claiming unsaved changes forever. The save stripped the fitness pillar out of the selection on its way to the server, so the answer that came back could never match the form that sent it, and 'All pillars' left the save button lit no matter how often you pressed it.
This commit is contained in:
1 parent
6c87c96fa6
commit
c60689affd
39 files changed
+347
-784
No files matched your search
@@ -134,7 +134,6 @@ const ALL_PILLARS = [
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -505,7 +505,7 @@ describe("<HealthScoreCard> pillars", () => {
|
||||
});
|
||||
|
||||
describe("<HealthScoreCard> one pillar, one home", () => {
|
||||
/** Four scored, one failed read, one crisis, two plain absences. */
|
||||
/** Four scored, one failed read, one crisis, one plain absence. */
|
||||
function mixedReport() {
|
||||
return scoredReport({
|
||||
pillars: [
|
||||
@@ -513,10 +513,9 @@ describe("<HealthScoreCard> one pillar, one home", () => {
|
||||
scoredAt("GLYCAEMIA", 74),
|
||||
scoredAt("ACTIVITY", 61),
|
||||
scoredAt("SLEEP", 45),
|
||||
gatedPillar("FITNESS", "read_failed"),
|
||||
gatedPillar("LIPIDS", "read_failed"),
|
||||
gatedPillar("WELLBEING", "crisis_signposting"),
|
||||
gatedPillar("ADIPOSITY", "missing_height"),
|
||||
gatedPillar("LIPIDS", "not_tracked"),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -538,14 +537,14 @@ describe("<HealthScoreCard> one pillar, one home", () => {
|
||||
"ACTIVITY",
|
||||
"SLEEP",
|
||||
]);
|
||||
expect(failedIds).toEqual(["FITNESS"]);
|
||||
expect(failedIds).toEqual(["LIPIDS"]);
|
||||
expect(crisisIds).toEqual(["WELLBEING"]);
|
||||
expect(counted).toBe(2);
|
||||
expect(counted).toBe(1);
|
||||
|
||||
// The four homes partition the eight pillars: disjoint, and complete.
|
||||
// The four homes partition the seven pillars: disjoint, and complete.
|
||||
const named = [...rowIds, ...failedIds, ...crisisIds];
|
||||
expect(new Set(named).size).toBe(named.length);
|
||||
expect(named.length + counted).toBe(8);
|
||||
expect(named.length + counted).toBe(7);
|
||||
});
|
||||
|
||||
it("never renders one pillar id twice anywhere in the panel", () => {
|
||||
@@ -556,14 +555,7 @@ describe("<HealthScoreCard> one pillar, one home", () => {
|
||||
const ids = pillarAttrs(html);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids.sort()).toEqual(
|
||||
[
|
||||
"ACTIVITY",
|
||||
"ADIPOSITY",
|
||||
"BLOOD_PRESSURE",
|
||||
"GLYCAEMIA",
|
||||
"LIPIDS",
|
||||
"SLEEP",
|
||||
].sort(),
|
||||
["ACTIVITY", "ADIPOSITY", "BLOOD_PRESSURE", "GLYCAEMIA", "SLEEP"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -581,7 +573,6 @@ describe("<HealthScoreCard> one pillar, one home", () => {
|
||||
"Sleep",
|
||||
"Adiposity",
|
||||
"Wellbeing",
|
||||
"Measured fitness",
|
||||
"Lipids",
|
||||
]) {
|
||||
const hits = listed.split(label).length - 1;
|
||||
|
||||
@@ -82,12 +82,6 @@ export function pillarObservedText(
|
||||
value: observed.value,
|
||||
});
|
||||
}
|
||||
if (pillar.id === "FITNESS") {
|
||||
return t("insights.healthScore.observedFitness", {
|
||||
value: observed.value,
|
||||
unit: observed.unit,
|
||||
});
|
||||
}
|
||||
return observed.label;
|
||||
}
|
||||
|
||||
@@ -129,18 +123,12 @@ function pillarReferenceText(
|
||||
? t("insights.healthScore.referenceAdiposity", {
|
||||
high: reference.high ?? "",
|
||||
})
|
||||
: pillar.id === "FITNESS"
|
||||
? t("insights.healthScore.referenceFitness", {
|
||||
: pillar.id === "GLYCAEMIA"
|
||||
? t("insights.healthScore.referenceHba1c", {
|
||||
low: reference.low ?? "",
|
||||
high: reference.high ?? "",
|
||||
unit: value.observed.unit,
|
||||
})
|
||||
: pillar.id === "GLYCAEMIA"
|
||||
? t("insights.healthScore.referenceHba1c", {
|
||||
low: reference.low ?? "",
|
||||
high: reference.high ?? "",
|
||||
})
|
||||
: reference.label;
|
||||
: reference.label;
|
||||
return formatReference(
|
||||
reference,
|
||||
label,
|
||||
|
||||
@@ -25,27 +25,28 @@ const base: ScoreConfigRow = {
|
||||
id: "SLEEP",
|
||||
domain: "sleep",
|
||||
counts: true,
|
||||
selectable: true,
|
||||
eligibility: "counting",
|
||||
};
|
||||
|
||||
describe("the three axes", () => {
|
||||
it("names recording and showing beside the score switch", () => {
|
||||
const html = render(base);
|
||||
|
||||
expect(html).toContain('data-slot="score-pillar-axis-recorded"');
|
||||
expect(html).toContain('data-slot="score-pillar-axis-shown"');
|
||||
expect(html).toContain('data-slot="score-pillar-switch"');
|
||||
expect(html).toContain("Being recorded");
|
||||
expect(html).toContain("Shown in the app");
|
||||
});
|
||||
|
||||
describe("the row", () => {
|
||||
it("offers exactly one switch, and it is the score one", () => {
|
||||
const html = render(base);
|
||||
|
||||
expect(html).toContain('data-slot="score-pillar-switch"');
|
||||
expect(html.match(/role="switch"/g) ?? []).toHaveLength(1);
|
||||
expect(html).toContain("Count Sleep toward the score");
|
||||
});
|
||||
|
||||
it("says nothing about recording or showing", () => {
|
||||
// Those two belong to the modules screen. Restating them under every
|
||||
// pillar put the same two words on every row of a page about a third
|
||||
// question, and the sentence above the rows already says so once.
|
||||
const html = render(base);
|
||||
|
||||
expect(html).not.toContain('data-slot="score-pillar-axes"');
|
||||
expect(html).not.toContain("Being recorded");
|
||||
expect(html).not.toContain("Shown in the app");
|
||||
});
|
||||
});
|
||||
|
||||
describe("row attributes", () => {
|
||||
@@ -106,38 +107,6 @@ describe("a failed read", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pillar this build cannot score", () => {
|
||||
it("is disabled, honest, and offers no switch at all", () => {
|
||||
const html = render({
|
||||
...base,
|
||||
id: "FITNESS",
|
||||
domain: "fitness",
|
||||
selectable: false,
|
||||
eligibility: "unavailable",
|
||||
});
|
||||
|
||||
expect(html).toContain('data-selectable="false"');
|
||||
expect(html).toContain('data-slot="score-pillar-unavailable"');
|
||||
expect(html).toContain('aria-disabled="true"');
|
||||
expect(html).toContain("Not available yet");
|
||||
// Not a switch that no-ops behind a disabled attribute: no switch.
|
||||
expect(html).not.toContain('data-slot="score-pillar-switch"');
|
||||
expect(html).not.toContain('role="switch"');
|
||||
});
|
||||
|
||||
it("says why, rather than leaving the row a dead end", () => {
|
||||
const html = render({
|
||||
...base,
|
||||
id: "FITNESS",
|
||||
domain: "fitness",
|
||||
selectable: false,
|
||||
eligibility: "unavailable",
|
||||
});
|
||||
|
||||
expect(html).toContain("has never produced a score");
|
||||
});
|
||||
});
|
||||
|
||||
describe("localisation", () => {
|
||||
// Asserting the absence of a key path would prove nothing: `t()` falls
|
||||
// back to the English bundle, so a missing locale key renders English
|
||||
|
||||
@@ -3,15 +3,11 @@
|
||||
/**
|
||||
* One pillar on the "what counts toward your Health Score" surface.
|
||||
*
|
||||
* The row's job is to make three things legible that nowhere else in the
|
||||
* app shows together: whether the pillar is being recorded, whether it is
|
||||
* shown, and whether it counts toward the score. Only the last is
|
||||
* writable here, and the card above the rows says so in words.
|
||||
*
|
||||
* The first two are not switches on this surface and are not dressed to
|
||||
* look like ones. They are read-outs, because the page that owns them is
|
||||
* the modules screen and a second switch for the same fact is how two
|
||||
* places end up disagreeing about one thing.
|
||||
* The row carries the pillar's name, one line about where it currently
|
||||
* stands with the server, and the switch that decides whether it counts.
|
||||
* Nothing else: recording and showing are the modules screen's business,
|
||||
* and repeating them on every row was noise on a page about a third
|
||||
* question.
|
||||
*
|
||||
* Presentation only. The parent owns the draft, the mutation and the
|
||||
* cache, so this stays a leaf a test can drive directly.
|
||||
@@ -35,7 +31,6 @@ const ELIGIBILITY_LINE_KEYS: Partial<
|
||||
counting: "settings.sections.score.state.counting",
|
||||
crisis: "settings.sections.score.state.crisis",
|
||||
read_failed: "settings.sections.score.state.readFailed",
|
||||
unavailable: "settings.sections.score.state.unavailableDetail",
|
||||
};
|
||||
|
||||
export interface ScorePillarRowProps {
|
||||
@@ -65,32 +60,17 @@ export function ScorePillarRow({
|
||||
data-domain={row.domain}
|
||||
data-counts={row.counts ? "true" : "false"}
|
||||
data-eligibility={row.eligibility}
|
||||
data-selectable={row.selectable ? "true" : "false"}
|
||||
className="flex items-start justify-between gap-4 py-3"
|
||||
className="flex min-h-11 items-center justify-between gap-4"
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<Label htmlFor={inputId} className="text-sm font-medium">
|
||||
{t(SCORE_PILLAR_LABEL_KEYS[row.id])}
|
||||
</Label>
|
||||
{/* The three axes, side by side. Recording and showing read as
|
||||
settled facts; the third is the switch to the right. */}
|
||||
<p
|
||||
data-slot="score-pillar-axes"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
<span data-slot="score-pillar-axis-recorded">
|
||||
{t("settings.sections.score.axis.recorded")}
|
||||
</span>
|
||||
{" · "}
|
||||
<span data-slot="score-pillar-axis-shown">
|
||||
{t("settings.sections.score.axis.shown")}
|
||||
</span>
|
||||
</p>
|
||||
{stateKey ? (
|
||||
<p
|
||||
data-slot="score-pillar-state"
|
||||
data-state={showsWaitingForData(row) ? "waiting" : row.eligibility}
|
||||
className="text-foreground text-xs"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t(stateKey)}
|
||||
</p>
|
||||
@@ -104,30 +84,16 @@ export function ScorePillarRow({
|
||||
className="text-muted-foreground size-3.5 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : null}
|
||||
{row.selectable ? (
|
||||
<Switch
|
||||
id={inputId}
|
||||
data-slot="score-pillar-switch"
|
||||
checked={row.counts}
|
||||
disabled={pending}
|
||||
onCheckedChange={(next) => onToggle(next)}
|
||||
aria-label={t("settings.sections.score.axis.countsFor", {
|
||||
pillar: t(SCORE_PILLAR_LABEL_KEYS[row.id]),
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
// Not a disabled switch pretending to be a choice: a pillar this
|
||||
// build can never score has no state to show, so it says so.
|
||||
// `aria-disabled` + the opacity container is the standard
|
||||
// disabled look (UI-STANDARDS §3).
|
||||
<span
|
||||
data-slot="score-pillar-unavailable"
|
||||
aria-disabled="true"
|
||||
className="text-muted-foreground text-xs opacity-50"
|
||||
>
|
||||
{t("settings.sections.score.state.unavailable")}
|
||||
</span>
|
||||
)}
|
||||
<Switch
|
||||
id={inputId}
|
||||
data-slot="score-pillar-switch"
|
||||
checked={row.counts}
|
||||
disabled={pending}
|
||||
onCheckedChange={(next) => onToggle(next)}
|
||||
aria-label={t("settings.sections.score.axis.countsFor", {
|
||||
pillar: t(SCORE_PILLAR_LABEL_KEYS[row.id]),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,13 +14,18 @@
|
||||
* - **One concept, one card.** The rows, the starting points and the
|
||||
* save all sit in the same card. A top half and a bottom half of the
|
||||
* same decision is the split the settings IA rules forbid.
|
||||
* - **Grouped by domain, and the grouping is visible.** Blood pressure,
|
||||
* glycaemia and lipids sit under one heading with a sentence saying
|
||||
* they are one area of health carrying three pillars. Equal weight per
|
||||
* pillar is not equal weight per area, and until this page nothing in
|
||||
* the app said so.
|
||||
* - **Only the score toggle is writable.** Recording and showing are
|
||||
* read-outs with a line saying this page leaves them alone.
|
||||
* - **The settings card anatomy, unmodified.** Header through
|
||||
* `SettingsCardHeader`, body at `mt-4 pl-7` so every line starts on
|
||||
* the title's reading edge, save bottom-right in its own row. The
|
||||
* v1.35.0 cut hung the save in the header's status slot and let the
|
||||
* body start at the card edge, which read as a different kind of card
|
||||
* from the ones above and below it.
|
||||
* - **Grouped by domain, and the grouping is spacing.** Blood pressure,
|
||||
* glycaemia and lipids sit under one heading; the gap between groups
|
||||
* does the separating. Rules between every row made a short list look
|
||||
* like a table of unrelated things.
|
||||
* - **Only the score toggle is writable.** Recording and showing belong
|
||||
* to the modules screen and are not restated per row.
|
||||
* - **A pillar whose module is off is not shown.** Turning a module off
|
||||
* closes the data path deliberately; a pillar behind a closed path is
|
||||
* not waiting for anything, and saying it waits nags a person in the
|
||||
@@ -66,16 +71,13 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import {
|
||||
SCORE_DOMAIN_LABEL_KEYS,
|
||||
SCORE_DOMAIN_NOTE_KEYS,
|
||||
} from "@/lib/score-config/labels";
|
||||
import { SCORE_DOMAIN_LABEL_KEYS } from "@/lib/score-config/labels";
|
||||
import {
|
||||
buildScoreConfigRows,
|
||||
selectionToSave,
|
||||
type ScorePillarVerdict,
|
||||
} from "@/lib/score-config/rows";
|
||||
import {
|
||||
orderedUniquePillars,
|
||||
SCORE_PILLAR_IDS,
|
||||
type ScorePillarId,
|
||||
} from "@/lib/analytics/score/types";
|
||||
@@ -191,7 +193,13 @@ export function ScoreSection() {
|
||||
apiPatch<HealthScoreConfigPayload>(
|
||||
"/api/auth/me/health-score-config",
|
||||
withBaseToken(
|
||||
{ pillars: selectionToSave(selection) },
|
||||
// Registry order, no duplicates. What the person sees is what
|
||||
// the server stores: every listed pillar is a real choice, so
|
||||
// nothing is filtered out of the selection on the way to the
|
||||
// write. A selection that came back different from the one that
|
||||
// was sent is what left the page permanently claiming unsaved
|
||||
// changes in v1.35.0.
|
||||
{ pillars: orderedUniquePillars(selection) },
|
||||
readUpdatedAtToken(queryClient, queryKeys.healthScoreConfig()),
|
||||
),
|
||||
),
|
||||
@@ -295,157 +303,167 @@ export function ScoreSection() {
|
||||
as="section"
|
||||
data-slot="score-config-card"
|
||||
aria-labelledby="score-config-title"
|
||||
className="space-y-4"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Gauge}
|
||||
titleId="score-config-title"
|
||||
title={t("settings.sections.score.card.title")}
|
||||
description={t("settings.sections.score.card.description")}
|
||||
status={
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => saveMutation.mutate(draft)}
|
||||
disabled={busy || !dirty || configQuery.isLoading}
|
||||
data-slot="score-config-save"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : null}
|
||||
{t("settings.sections.score.save")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* The sentence the whole feature exists for. Foreground, in the
|
||||
body, because it is content and not a meta description. */}
|
||||
<p data-slot="score-config-three-axes" className="text-sm">
|
||||
{t("settings.sections.score.threeAxes")}
|
||||
</p>
|
||||
{/* The body starts on the title's reading edge — icon box plus
|
||||
gap is 28 px, which is the `pl-7` every other settings card
|
||||
uses. */}
|
||||
<div className="mt-4 space-y-6 pl-7">
|
||||
{/* The sentence the whole feature exists for. Foreground, in
|
||||
the body, because it is content and not a meta
|
||||
description. */}
|
||||
<p data-slot="score-config-three-axes" className="text-sm">
|
||||
{t("settings.sections.score.threeAxes")}
|
||||
</p>
|
||||
|
||||
{/* The starting points. Both name data; neither names a person.
|
||||
A template that supplies a rationale for dropping a pillar is
|
||||
the one shape the evidence rules out, so there is none. */}
|
||||
<div
|
||||
data-slot="score-config-presets"
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.score.presets.label")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
data-slot="score-config-preset-all"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setRefusal(null);
|
||||
setDraft([...SCORE_PILLAR_IDS]);
|
||||
}}
|
||||
{/* The starting points. Both name data; neither names a
|
||||
person. A template that supplies a rationale for dropping a
|
||||
pillar is the one shape the evidence rules out, so there is
|
||||
none. */}
|
||||
<div
|
||||
data-slot="score-config-presets"
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
{t("settings.sections.score.presets.all")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy || !dirty}
|
||||
data-slot="score-config-preset-current"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setRefusal(null);
|
||||
setDraft([...serverSelection]);
|
||||
}}
|
||||
>
|
||||
{t("settings.sections.score.presets.current")}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.score.presets.label")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
data-slot="score-config-preset-all"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setRefusal(null);
|
||||
setDraft([...SCORE_PILLAR_IDS]);
|
||||
}}
|
||||
>
|
||||
{t("settings.sections.score.presets.all")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy || !dirty}
|
||||
data-slot="score-config-preset-current"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setRefusal(null);
|
||||
setDraft([...serverSelection]);
|
||||
}}
|
||||
>
|
||||
{t("settings.sections.score.presets.current")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{refusal ? (
|
||||
<p
|
||||
data-slot="score-config-refusal"
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{t(refusal)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{analyticsQuery.isError ? (
|
||||
<QueryErrorRow
|
||||
slot="score-config-eligibility-error"
|
||||
message={t("settings.sections.score.eligibilityFailed")}
|
||||
onRetry={() => void analyticsQuery.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{configQuery.isLoading ? (
|
||||
<div data-slot="score-config-loading" className="space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{groups.map((group) => (
|
||||
<div
|
||||
key={group.domain}
|
||||
data-slot="score-config-group"
|
||||
data-domain={group.domain}
|
||||
className="space-y-2"
|
||||
>
|
||||
{/* Names an area of health, so it sits in the content
|
||||
tier. The muted section-label treatment it shipped
|
||||
with read as switched-off beside the rows it
|
||||
heads. */}
|
||||
<h3
|
||||
data-slot="score-config-group-heading"
|
||||
className="text-foreground text-xs font-medium tracking-wide uppercase"
|
||||
>
|
||||
{t(SCORE_DOMAIN_LABEL_KEYS[group.domain])}
|
||||
</h3>
|
||||
{/* No rules between the rows: the gap between groups
|
||||
is the grouping. */}
|
||||
<div className="space-y-3">
|
||||
{group.rows.map((row) => (
|
||||
<ScorePillarRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pending={busy}
|
||||
onToggle={(next) => toggle(row.id, next)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{omitted.length > 0 ? (
|
||||
<p
|
||||
data-slot="score-config-omitted"
|
||||
data-count={omitted.length}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("settings.sections.score.omitted", {
|
||||
count: omitted.length,
|
||||
})}{" "}
|
||||
<Link
|
||||
href="/settings/modules"
|
||||
data-slot="score-config-omitted-link"
|
||||
className="text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("settings.sections.score.omittedLink")}
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{refusal ? (
|
||||
<p
|
||||
data-slot="score-config-refusal"
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
{/* Bottom-right, in its own row, like every other settings card
|
||||
that saves a form. */}
|
||||
<div className="mt-4 flex justify-end pl-7">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => saveMutation.mutate(draft)}
|
||||
disabled={busy || !dirty || configQuery.isLoading}
|
||||
data-slot="score-config-save"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t(refusal)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{analyticsQuery.isError ? (
|
||||
<QueryErrorRow
|
||||
slot="score-config-eligibility-error"
|
||||
message={t("settings.sections.score.eligibilityFailed")}
|
||||
onRetry={() => void analyticsQuery.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{configQuery.isLoading ? (
|
||||
<div data-slot="score-config-loading" className="space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{groups.map((group) => (
|
||||
<div
|
||||
key={group.domain}
|
||||
data-slot="score-config-group"
|
||||
data-domain={group.domain}
|
||||
>
|
||||
<h3
|
||||
data-slot="score-config-group-heading"
|
||||
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
|
||||
>
|
||||
{t(SCORE_DOMAIN_LABEL_KEYS[group.domain])}
|
||||
</h3>
|
||||
{SCORE_DOMAIN_NOTE_KEYS[group.domain] ? (
|
||||
<p
|
||||
data-slot="score-config-group-note"
|
||||
className="text-muted-foreground mt-1 text-xs"
|
||||
>
|
||||
{t(SCORE_DOMAIN_NOTE_KEYS[group.domain] as string)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="divide-border/60 mt-1 divide-y">
|
||||
{group.rows.map((row) => (
|
||||
<ScorePillarRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pending={busy}
|
||||
onToggle={(next) => toggle(row.id, next)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{omitted.length > 0 ? (
|
||||
<p
|
||||
data-slot="score-config-omitted"
|
||||
data-count={omitted.length}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("settings.sections.score.omitted", { count: omitted.length })}{" "}
|
||||
<Link
|
||||
href="/settings/modules"
|
||||
data-slot="score-config-omitted-link"
|
||||
className="text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("settings.sections.score.omittedLink")}
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
{busy ? (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : null}
|
||||
{t("settings.sections.score.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user