mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge remote-tracking branch 'origin/main' into fix/ai-timeout-everywhere
This commit is contained in:
commit
0f6669847c
41 files changed
+3055
-129
No files matched your search
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"routeBaselineKbGz": {
|
||||
"/page": 447,
|
||||
"/insights/page": 434,
|
||||
@@ -7,6 +7,6 @@
|
||||
"/insights/mood/page": 447
|
||||
},
|
||||
"routeDriftAllowanceKbGz": 20,
|
||||
"totalClientKbGz": 3724,
|
||||
"totalClientKbGz": 3749,
|
||||
"maxRechartsChunks": 1
|
||||
}
|
||||
+73
-9
@@ -4661,6 +4661,42 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
delete:
|
||||
tags:
|
||||
- Measurements
|
||||
summary: Delete a workout entered by hand
|
||||
description: "Removes a `MANUAL` workout outright, with its child rows, and any personal record that workout set; a
|
||||
silent detection pass then re-derives the previous best. A workout from any other source answers 409
|
||||
(`workout.delete.synced_source`), because the next sync would write it back. Own record only: a cookie session
|
||||
or a wildcard token. Another user's row answers 404."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: Deleted.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteWorkoutResponse"
|
||||
"401": *a1
|
||||
"404":
|
||||
description: Workout not found (or owned by another user).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"409":
|
||||
description: The workout was synced from a device or service, not entered by hand.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/workouts/batch:
|
||||
post:
|
||||
tags:
|
||||
@@ -25902,7 +25938,7 @@ components:
|
||||
- order
|
||||
tiles:
|
||||
minItems: 1
|
||||
maxItems: 62
|
||||
maxItems: 63
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
@@ -25919,6 +25955,7 @@ components:
|
||||
- pain
|
||||
- weight
|
||||
- bmi
|
||||
- body-fat
|
||||
- body-water
|
||||
- bone-mass
|
||||
- fat-free-mass
|
||||
@@ -31264,7 +31301,7 @@ components:
|
||||
additionalProperties: false
|
||||
tiles:
|
||||
minItems: 1
|
||||
maxItems: 62
|
||||
maxItems: 63
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
@@ -31281,6 +31318,7 @@ components:
|
||||
- pain
|
||||
- weight
|
||||
- bmi
|
||||
- body-fat
|
||||
- body-water
|
||||
- bone-mass
|
||||
- fat-free-mass
|
||||
@@ -31762,12 +31800,13 @@ components:
|
||||
vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid,
|
||||
vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout —
|
||||
workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted,
|
||||
workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict,
|
||||
dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict,
|
||||
invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict,
|
||||
missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured,
|
||||
notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable,
|
||||
rate_limited_self, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured"
|
||||
workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict,
|
||||
ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
|
||||
health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at,
|
||||
json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns,
|
||||
modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only,
|
||||
private_origin_not_approved, private_origin_not_grantable, rate_limited_self, too_many_rows,
|
||||
unreadable_json, upstream_invalid_json, vapid_not_configured"
|
||||
type: string
|
||||
capability:
|
||||
description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`,
|
||||
@@ -43051,6 +43090,30 @@ components:
|
||||
description: "One AI capability, resolved for one record from every layer that can say no. Render from it; never
|
||||
recompute it. Data never depends on it: measurements, scores, statistics and device records load whatever it
|
||||
says."
|
||||
DeleteWorkoutResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
deleted:
|
||||
type: boolean
|
||||
const: true
|
||||
required:
|
||||
- deleted
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
BatchWorkoutsResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -51976,7 +52039,7 @@ components:
|
||||
additionalProperties: false
|
||||
tiles:
|
||||
minItems: 1
|
||||
maxItems: 62
|
||||
maxItems: 63
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
@@ -51993,6 +52056,7 @@ components:
|
||||
- pain
|
||||
- weight
|
||||
- bmi
|
||||
- body-fat
|
||||
- body-water
|
||||
- bone-mass
|
||||
- fat-free-mass
|
||||
|
||||
@@ -428,14 +428,23 @@ test.describe("lab-scan review dialog uses the width it is given", () => {
|
||||
await expect(rows).toHaveCount(REVIEW_ROWS.length);
|
||||
|
||||
const label = `scan review @${vp.label}`;
|
||||
// The sheet widens from the picking stage to the review stage and the
|
||||
// row grid re-flows from the container width it is given. Measuring in
|
||||
// the middle of that sees a half-laid-out row, so wait until the
|
||||
// surface has kept the same width across two frames.
|
||||
// The sheet widens from the picking stage to the review stage, and on a
|
||||
// phone or small tablet it also slides up from the bottom. Measuring
|
||||
// while either runs compares fields from different frames (the width
|
||||
// settles before the slide ends, so waiting on width alone was not
|
||||
// enough). Wait for every running animation and transition to finish,
|
||||
// then for the surface to hold still across two frames.
|
||||
await page.evaluate(() =>
|
||||
Promise.all(
|
||||
document
|
||||
.getAnimations()
|
||||
.map((animation) => animation.finished.catch(() => undefined)),
|
||||
),
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const before = (await surface.boundingBox())!.width;
|
||||
const before = (await surface.boundingBox())!;
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
@@ -444,10 +453,13 @@ test.describe("lab-scan review dialog uses the width it is given", () => {
|
||||
),
|
||||
),
|
||||
);
|
||||
const after = (await surface.boundingBox())!.width;
|
||||
return Math.abs(after - before);
|
||||
const after = (await surface.boundingBox())!;
|
||||
return (
|
||||
Math.abs(after.width - before.width) +
|
||||
Math.abs(after.y - before.y)
|
||||
);
|
||||
},
|
||||
{ message: `${label}: dialog width settles` },
|
||||
{ message: `${label}: dialog settles` },
|
||||
)
|
||||
.toBeLessThan(0.5);
|
||||
await expectNoSidewaysScroll(page, label);
|
||||
@@ -470,20 +482,24 @@ test.describe("lab-scan review dialog uses the width it is given", () => {
|
||||
|
||||
// Value, unit and date share a line when the fields have room for three
|
||||
// columns, and the date wraps under them when they do not.
|
||||
// "Beside" means the same grid row: to the right of the value and
|
||||
// starting above the value's bottom edge. Comparing the tops alone was
|
||||
// too strict, because a field label that wraps onto a second line (it
|
||||
// depends on the runner's font metrics) moves that field's input down
|
||||
// by one line while it stays in the same row.
|
||||
const besideValue = (field: { x: number; y: number }, name: string) => {
|
||||
expect(field.x, `${label}: ${name} right of value`).toBeGreaterThan(
|
||||
value.x,
|
||||
);
|
||||
expect(field.y, `${label}: ${name} in the value's row`).toBeLessThan(
|
||||
value.y + value.height,
|
||||
);
|
||||
};
|
||||
if (vp.threeAcross) {
|
||||
expect(
|
||||
Math.abs(unit.y - value.y),
|
||||
`${label}: unit beside value`,
|
||||
).toBeLessThan(8);
|
||||
expect(
|
||||
Math.abs(date.y - value.y),
|
||||
`${label}: date beside value`,
|
||||
).toBeLessThan(8);
|
||||
besideValue(unit, "unit");
|
||||
besideValue(date, "date");
|
||||
} else {
|
||||
expect(
|
||||
Math.abs(unit.y - value.y),
|
||||
`${label}: unit beside value`,
|
||||
).toBeLessThan(8);
|
||||
besideValue(unit, "unit");
|
||||
expect(date.y, `${label}: date wraps`).toBeGreaterThan(
|
||||
value.y + value.height - 1,
|
||||
);
|
||||
|
||||
+46
-3
@@ -403,7 +403,9 @@
|
||||
"medication": "Medikament",
|
||||
"medicationDescription": "Eine Dosis als genommen markieren",
|
||||
"mood": "Stimmung",
|
||||
"moodDescription": "Stimmung und Tags erfassen"
|
||||
"moodDescription": "Stimmung und Tags erfassen",
|
||||
"workout": "Workout",
|
||||
"workoutDescription": "Eine Einheit, die kein Gerät aufgezeichnet hat"
|
||||
},
|
||||
"cycle": "Zyklus",
|
||||
"labs": "Laborwerte",
|
||||
@@ -520,6 +522,7 @@
|
||||
"quickAddMeasurement": "Messung erfassen",
|
||||
"quickAddMood": "Stimmung erfassen",
|
||||
"quickAddMedicationIntake": "Einnahme erfassen",
|
||||
"quickAddWorkout": "Workout erfassen",
|
||||
"customizeDashboard": "Dashboard anpassen",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Medikamenteneinnahme erfassen",
|
||||
@@ -3157,6 +3160,7 @@
|
||||
"boneMassBody": "Die Knochenmasse ist das geschätzte Gewicht der Mineralien in deinem Skelett. Waagengestützte Schätzungen sind ungefähr, doch ein gleichbleibender oder steigender Trend unterstützt die langfristige Knochenstärke.",
|
||||
"fatFreeMassBody": "Die fettfreie Masse ist alles in deinem Körper, das kein Fett ist — Muskeln, Knochen, Organe und Wasser. Sie zu halten oder aufzubauen, während das Gewicht sinkt, deutet meist auf eine gesündere Körperzusammensetzung hin.",
|
||||
"fatMassBody": "Die Fettmasse ist das Gesamtgewicht des Körperfetts. Etwas Fett ist für Hormone und Energiespeicher unverzichtbar, doch zu viel — besonders am Bauch — erhöht das langfristige Gesundheitsrisiko (WHO 2024).",
|
||||
"bodyFatBody": "Der Körperfettanteil ist der Teil deines Gewichts, der aus Fett besteht. Gesunde Bereiche hängen von Geschlecht und Alter ab, und Waagen schätzen den Wert per Bioimpedanz, deshalb sagt der Verlauf über mehrere Wochen mehr als eine einzelne Messung.",
|
||||
"muscleMassBody": "Die Muskelmasse ist das geschätzte Gewicht der Muskeln in deinem Körper. Mehr Muskeln unterstützen Kraft, Stoffwechsel und Beweglichkeit und müssen mit zunehmendem Alter aktiv erhalten werden.",
|
||||
"visceralFatBody": "Viszeralfett ist das Fett, das tief im Bauchraum um die Organe gespeichert wird. Anders als das Unterhautfett ist ein hoher Wert eng mit Herzkrankheiten und Typ-2-Diabetes verbunden, daher ist niedriger meist besser.",
|
||||
"leanBodyMassBody": "Die Magermasse ist dein Gesamtgewicht abzüglich des Körperfetts — Muskeln, Knochen, Organe und Wasser zusammen. Sie neben dem Gewicht zu verfolgen zeigt, ob Veränderungen aus Muskeln oder aus Fett stammen.",
|
||||
@@ -3358,7 +3362,7 @@
|
||||
"description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten — quellenübergreifend entdoppelt zwischen Apple Watch und Withings.",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Workouts",
|
||||
"description": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier synchronisiert werden. Withings-Aktivitätsimport pflegt Geh- und Radtouren mit ein."
|
||||
"description": "Workouts kommen per Apple Health vom iPhone und von Withings. Du kannst eins auch von Hand erfassen."
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Zurück zu Workouts",
|
||||
@@ -3436,7 +3440,35 @@
|
||||
"countFew": "{count} Workouts",
|
||||
"filterLabel": "Nach Sportart filtern",
|
||||
"filterAll": "Alle Sportarten",
|
||||
"filterEmpty": "Keine Einheiten dieser Sportart in deinem Verlauf."
|
||||
"filterEmpty": "Keine Einheiten dieser Sportart in deinem Verlauf.",
|
||||
"manual": {
|
||||
"logWorkout": "Workout erfassen",
|
||||
"sheetTitle": "Workout erfassen",
|
||||
"sheetDescription": "Trag eine Einheit von Hand ein: was, wann und wie lange.",
|
||||
"sportLabel": "Aktivität",
|
||||
"sportPlaceholder": "Aktivität wählen",
|
||||
"startLabel": "Beginn",
|
||||
"durationLabel": "Dauer",
|
||||
"distanceLabel": "Distanz ({unit})",
|
||||
"energyLabel": "Aktive Energie (kcal)",
|
||||
"alreadySaved": "Dieses Workout ist schon gespeichert",
|
||||
"saveError": "Das Workout konnte nicht gespeichert werden",
|
||||
"deleteLabel": "Workout löschen",
|
||||
"deleteConfirmTitle": "Dieses Workout löschen?",
|
||||
"deleteConfirmDescription": "Dieses von Hand eingetragene Workout wird endgültig entfernt.",
|
||||
"deleted": "Workout gelöscht",
|
||||
"deleteError": "Das Workout konnte nicht gelöscht werden",
|
||||
"errors": {
|
||||
"sportRequired": "Wähle eine Aktivität.",
|
||||
"startRequired": "Gib Datum und Uhrzeit ein.",
|
||||
"startInFuture": "Der Beginn darf nicht in der Zukunft liegen.",
|
||||
"durationRequired": "Gib an, wie lange das Workout gedauert hat.",
|
||||
"durationInvalid": "Gib ganze Stunden und Minuten von 0 bis 59 ein.",
|
||||
"durationTooLong": "Ein Workout dauert höchstens 24 Stunden.",
|
||||
"distanceInvalid": "Gib die Distanz als Zahl in einem realistischen Bereich ein.",
|
||||
"energyInvalid": "Gib die aktive Energie als Zahl bis 20.000 kcal ein."
|
||||
}
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Blutzucker",
|
||||
@@ -3575,7 +3607,18 @@
|
||||
"description": "Fettmassewerte erscheinen hier, sobald eine Körperanalysewaage synchronisiert."
|
||||
}
|
||||
},
|
||||
"bodyFat": {
|
||||
"title": "Körperfett",
|
||||
"description": "Dein Körperfettanteil im Zeitverlauf.",
|
||||
"chartTitle": "Körperfett",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Körperfettdaten",
|
||||
"description": "Körperfettwerte erscheinen hier, sobald eine Körperanalysewaage synchronisiert oder du einen Wert von Hand erfasst.",
|
||||
"cta": "Körperfett erfassen"
|
||||
}
|
||||
},
|
||||
"navFatMass": "Fettmasse",
|
||||
"navBodyFat": "Körperfett",
|
||||
"muscleMass": {
|
||||
"title": "Muskelmasse",
|
||||
"description": "Deine Muskelmasse im Zeitverlauf, in Kilogramm.",
|
||||
|
||||
+46
-3
@@ -403,7 +403,9 @@
|
||||
"medication": "Medication",
|
||||
"medicationDescription": "Mark a dose as taken",
|
||||
"mood": "Mood",
|
||||
"moodDescription": "Log your mood and tags"
|
||||
"moodDescription": "Log your mood and tags",
|
||||
"workout": "Workout",
|
||||
"workoutDescription": "A session your devices did not record"
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Labs",
|
||||
@@ -520,6 +522,7 @@
|
||||
"quickAddMeasurement": "Log measurement",
|
||||
"quickAddMood": "Log mood",
|
||||
"quickAddMedicationIntake": "Log medication intake",
|
||||
"quickAddWorkout": "Log workout",
|
||||
"customizeDashboard": "Customize dashboard",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Log medication intake",
|
||||
@@ -3157,6 +3160,7 @@
|
||||
"boneMassBody": "Bone mass is the estimated weight of the mineral in your skeleton. Scale-based estimates are approximate, but a steady or rising trend supports long-term bone strength.",
|
||||
"fatFreeMassBody": "Fat-free mass is everything in your body that is not fat — muscle, bone, organs, and water. Holding or building it while losing weight usually signals healthier body composition.",
|
||||
"fatMassBody": "Fat mass is the total weight of fat in your body. Some fat is essential for hormones and energy storage, but too much — especially around the abdomen — raises long-term health risk (WHO 2024).",
|
||||
"bodyFatBody": "Body fat percentage is the share of your weight that is fat. Healthy ranges depend on sex and age, and home scales estimate it from bioimpedance, so the trend over several weeks says more than a single reading.",
|
||||
"muscleMassBody": "Muscle mass is the estimated weight of the muscle in your body. More muscle supports strength, metabolism, and mobility, and it tends to need active effort to preserve as you age.",
|
||||
"visceralFatBody": "Visceral fat is the fat stored deep in the abdomen around your organs. Unlike fat under the skin, a high level is closely linked to heart disease and type 2 diabetes, so lower is generally better.",
|
||||
"leanBodyMassBody": "Lean body mass is your total weight minus body fat — muscle, bone, organs, and water combined. Tracking it alongside weight shows whether changes come from muscle or from fat.",
|
||||
@@ -3358,7 +3362,7 @@
|
||||
"description": "Recent runs, rides, walks and strength sessions, deduped across Apple Watch and Withings sources.",
|
||||
"emptyState": {
|
||||
"title": "No workouts yet",
|
||||
"description": "Open the Apple Health app on your iPhone to sync workouts here. Withings activity import keeps walking and cycling sessions flowing in too."
|
||||
"description": "Workouts sync from Apple Health on your iPhone and from Withings. You can also log one by hand."
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Back to workouts",
|
||||
@@ -3436,7 +3440,35 @@
|
||||
"countFew": "{count} workouts",
|
||||
"filterLabel": "Filter by sport",
|
||||
"filterAll": "All sports",
|
||||
"filterEmpty": "No sessions of this sport in your history."
|
||||
"filterEmpty": "No sessions of this sport in your history.",
|
||||
"manual": {
|
||||
"logWorkout": "Log workout",
|
||||
"sheetTitle": "Log workout",
|
||||
"sheetDescription": "Enter a session by hand: what you did, when, and for how long.",
|
||||
"sportLabel": "Activity",
|
||||
"sportPlaceholder": "Choose an activity",
|
||||
"startLabel": "Start",
|
||||
"durationLabel": "Duration",
|
||||
"distanceLabel": "Distance ({unit})",
|
||||
"energyLabel": "Active energy (kcal)",
|
||||
"alreadySaved": "This workout is already saved",
|
||||
"saveError": "The workout could not be saved",
|
||||
"deleteLabel": "Delete workout",
|
||||
"deleteConfirmTitle": "Delete this workout?",
|
||||
"deleteConfirmDescription": "This workout you entered by hand will be removed for good.",
|
||||
"deleted": "Workout deleted",
|
||||
"deleteError": "The workout could not be deleted",
|
||||
"errors": {
|
||||
"sportRequired": "Choose an activity.",
|
||||
"startRequired": "Enter a date and time.",
|
||||
"startInFuture": "The start cannot be in the future.",
|
||||
"durationRequired": "Enter how long the workout lasted.",
|
||||
"durationInvalid": "Enter whole hours, and minutes from 0 to 59.",
|
||||
"durationTooLong": "A workout can last at most 24 hours.",
|
||||
"distanceInvalid": "Enter the distance as a number within a realistic range.",
|
||||
"energyInvalid": "Enter active energy as a number up to 20,000 kcal."
|
||||
}
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Blood Glucose",
|
||||
@@ -3575,7 +3607,18 @@
|
||||
"description": "Fat-mass readings appear here once a connected body-composition scale syncs."
|
||||
}
|
||||
},
|
||||
"bodyFat": {
|
||||
"title": "Body Fat",
|
||||
"description": "Your body fat percentage over time.",
|
||||
"chartTitle": "Body fat",
|
||||
"emptyState": {
|
||||
"title": "No body-fat data yet",
|
||||
"description": "Body-fat readings appear here once a body-composition scale syncs, or when you log one by hand.",
|
||||
"cta": "Log body fat"
|
||||
}
|
||||
},
|
||||
"navFatMass": "Fat Mass",
|
||||
"navBodyFat": "Body Fat",
|
||||
"muscleMass": {
|
||||
"title": "Muscle Mass",
|
||||
"description": "Your muscle mass over time, in kilograms.",
|
||||
|
||||
+46
-3
@@ -403,7 +403,9 @@
|
||||
"medication": "Medicamento",
|
||||
"medicationDescription": "Marcar una dosis como tomada",
|
||||
"mood": "Estado de ánimo",
|
||||
"moodDescription": "Registra tu estado de ánimo y etiquetas"
|
||||
"moodDescription": "Registra tu estado de ánimo y etiquetas",
|
||||
"workout": "Entrenamiento",
|
||||
"workoutDescription": "Una sesión que tus dispositivos no registraron"
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Análisis",
|
||||
@@ -520,6 +522,7 @@
|
||||
"quickAddMeasurement": "Registrar medición",
|
||||
"quickAddMood": "Registrar estado de ánimo",
|
||||
"quickAddMedicationIntake": "Registrar toma",
|
||||
"quickAddWorkout": "Registrar entrenamiento",
|
||||
"customizeDashboard": "Personalizar panel",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Registrar toma de medicación",
|
||||
@@ -3157,6 +3160,7 @@
|
||||
"boneMassBody": "La masa ósea es el peso estimado del mineral de tu esqueleto. Las estimaciones de la báscula son aproximadas, pero una tendencia estable o al alza favorece la fortaleza ósea a largo plazo.",
|
||||
"fatFreeMassBody": "La masa libre de grasa es todo lo que en tu cuerpo no es grasa: músculo, hueso, órganos y agua. Mantenerla o aumentarla mientras pierdes peso suele indicar una composición corporal más sana.",
|
||||
"fatMassBody": "La masa grasa es el peso total de la grasa de tu cuerpo. Algo de grasa es esencial para las hormonas y la reserva de energía, pero un exceso —sobre todo en el abdomen— eleva el riesgo para la salud a largo plazo (WHO 2024).",
|
||||
"bodyFatBody": "El porcentaje de grasa corporal es la parte de tu peso que es grasa. Los rangos saludables dependen del sexo y la edad, y las básculas lo estiman por bioimpedancia, así que la tendencia de varias semanas dice más que una sola lectura.",
|
||||
"muscleMassBody": "La masa muscular es el peso estimado del músculo de tu cuerpo. Más músculo favorece la fuerza, el metabolismo y la movilidad, y suele requerir esfuerzo activo para conservarse con la edad.",
|
||||
"visceralFatBody": "La grasa visceral es la grasa almacenada en lo profundo del abdomen, alrededor de los órganos. A diferencia de la grasa bajo la piel, un nivel alto se asocia estrechamente con enfermedad cardíaca y diabetes tipo 2, por lo que cuanto más baja, mejor.",
|
||||
"leanBodyMassBody": "La masa magra corporal es tu peso total menos la grasa: músculo, hueso, órganos y agua en conjunto. Seguirla junto al peso muestra si los cambios vienen del músculo o de la grasa.",
|
||||
@@ -3358,7 +3362,7 @@
|
||||
"description": "Carreras, salidas en bici, paseos y sesiones de fuerza recientes, deduplicados entre Apple Watch y Withings.",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay entrenamientos",
|
||||
"description": "Abre la app Apple Salud en el iPhone para sincronizar tus entrenamientos. La importación de actividad de Withings también incorpora caminatas y rutas en bici."
|
||||
"description": "Los entrenamientos llegan desde Apple Salud en el iPhone y desde Withings. También puedes registrar uno a mano."
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Volver a entrenamientos",
|
||||
@@ -3436,7 +3440,35 @@
|
||||
"countFew": "{count} entrenamientos",
|
||||
"filterLabel": "Filtrar por deporte",
|
||||
"filterAll": "Todos los deportes",
|
||||
"filterEmpty": "No hay sesiones de este deporte en tu historial."
|
||||
"filterEmpty": "No hay sesiones de este deporte en tu historial.",
|
||||
"manual": {
|
||||
"logWorkout": "Registrar entrenamiento",
|
||||
"sheetTitle": "Registrar entrenamiento",
|
||||
"sheetDescription": "Introduce una sesión a mano: qué hiciste, cuándo y durante cuánto tiempo.",
|
||||
"sportLabel": "Actividad",
|
||||
"sportPlaceholder": "Elige una actividad",
|
||||
"startLabel": "Inicio",
|
||||
"durationLabel": "Duración",
|
||||
"distanceLabel": "Distancia ({unit})",
|
||||
"energyLabel": "Energía activa (kcal)",
|
||||
"alreadySaved": "Este entrenamiento ya está guardado",
|
||||
"saveError": "No se pudo guardar el entrenamiento",
|
||||
"deleteLabel": "Eliminar entrenamiento",
|
||||
"deleteConfirmTitle": "¿Eliminar este entrenamiento?",
|
||||
"deleteConfirmDescription": "Este entrenamiento que introdujiste a mano se eliminará de forma definitiva.",
|
||||
"deleted": "Entrenamiento eliminado",
|
||||
"deleteError": "No se pudo eliminar el entrenamiento",
|
||||
"errors": {
|
||||
"sportRequired": "Elige una actividad.",
|
||||
"startRequired": "Introduce una fecha y una hora.",
|
||||
"startInFuture": "El inicio no puede estar en el futuro.",
|
||||
"durationRequired": "Indica cuánto duró el entrenamiento.",
|
||||
"durationInvalid": "Introduce horas enteras y minutos de 0 a 59.",
|
||||
"durationTooLong": "Un entrenamiento puede durar como máximo 24 horas.",
|
||||
"distanceInvalid": "Introduce la distancia como un número dentro de un rango realista.",
|
||||
"energyInvalid": "Introduce la energía activa como un número de hasta 20.000 kcal."
|
||||
}
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Glucosa en sangre",
|
||||
@@ -3575,7 +3607,18 @@
|
||||
"description": "Las lecturas aparecen aquí cuando una báscula de composición se sincroniza."
|
||||
}
|
||||
},
|
||||
"bodyFat": {
|
||||
"title": "Grasa corporal",
|
||||
"description": "Tu porcentaje de grasa corporal en el tiempo.",
|
||||
"chartTitle": "Grasa corporal",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de grasa corporal",
|
||||
"description": "Las lecturas aparecen aquí cuando una báscula de composición se sincroniza o cuando registras una a mano.",
|
||||
"cta": "Registrar grasa corporal"
|
||||
}
|
||||
},
|
||||
"navFatMass": "Masa grasa",
|
||||
"navBodyFat": "Grasa corporal",
|
||||
"muscleMass": {
|
||||
"title": "Masa muscular",
|
||||
"description": "Tu masa muscular en el tiempo, en kilogramos.",
|
||||
|
||||
+46
-3
@@ -403,7 +403,9 @@
|
||||
"medication": "Médicament",
|
||||
"medicationDescription": "Marquer une dose comme prise",
|
||||
"mood": "Humeur",
|
||||
"moodDescription": "Enregistrez votre humeur et des étiquettes"
|
||||
"moodDescription": "Enregistrez votre humeur et des étiquettes",
|
||||
"workout": "Entraînement",
|
||||
"workoutDescription": "Une séance que vos appareils n’ont pas enregistrée"
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Analyses",
|
||||
@@ -520,6 +522,7 @@
|
||||
"quickAddMeasurement": "Saisir une mesure",
|
||||
"quickAddMood": "Saisir une humeur",
|
||||
"quickAddMedicationIntake": "Saisir une prise",
|
||||
"quickAddWorkout": "Saisir un entraînement",
|
||||
"customizeDashboard": "Personnaliser le tableau de bord",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Saisir une prise de médicament",
|
||||
@@ -3157,6 +3160,7 @@
|
||||
"boneMassBody": "La masse osseuse est le poids estimé des minéraux de votre squelette. Les estimations sur balance sont approximatives, mais une tendance stable ou en hausse soutient la solidité osseuse à long terme.",
|
||||
"fatFreeMassBody": "La masse non grasse est tout ce qui, dans votre corps, n'est pas de la graisse : muscle, os, organes et eau. La maintenir ou la développer tout en perdant du poids signale en général une composition corporelle plus saine.",
|
||||
"fatMassBody": "La masse grasse est le poids total de la graisse de votre corps. Une part de graisse est indispensable aux hormones et au stockage d'énergie, mais un excès — surtout au niveau de l'abdomen — augmente le risque pour la santé à long terme (WHO 2024).",
|
||||
"bodyFatBody": "Le taux de masse grasse est la part de votre poids constituée de graisse. Les plages saines dépendent du sexe et de l'âge, et les balances l'estiment par bio-impédance : la tendance sur plusieurs semaines en dit plus qu'une mesure isolée.",
|
||||
"muscleMassBody": "La masse musculaire est le poids estimé des muscles de votre corps. Plus de muscle soutient la force, le métabolisme et la mobilité, et demande généralement un effort actif pour être préservé avec l'âge.",
|
||||
"visceralFatBody": "La graisse viscérale est la graisse stockée au plus profond de l'abdomen, autour des organes. Contrairement à la graisse sous la peau, un niveau élevé est étroitement lié aux maladies cardiaques et au diabète de type 2 : plus elle est basse, mieux c'est.",
|
||||
"leanBodyMassBody": "La masse maigre est votre poids total moins la graisse corporelle : muscle, os, organes et eau réunis. La suivre en parallèle du poids montre si les variations viennent du muscle ou de la graisse.",
|
||||
@@ -3358,7 +3362,7 @@
|
||||
"description": "Courses, sorties à vélo, marches et séances de musculation récentes, dédupliquées entre Apple Watch et Withings.",
|
||||
"emptyState": {
|
||||
"title": "Aucun entraînement",
|
||||
"description": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser tes entraînements. L'import d'activité Withings alimente également la marche et le vélo."
|
||||
"description": "Tes entraînements arrivent d'Apple Santé sur l'iPhone et de Withings. Tu peux aussi en saisir un à la main."
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Retour aux entraînements",
|
||||
@@ -3436,7 +3440,35 @@
|
||||
"countFew": "{count} séances",
|
||||
"filterLabel": "Filtrer par sport",
|
||||
"filterAll": "Tous les sports",
|
||||
"filterEmpty": "Aucune séance de ce sport dans votre historique."
|
||||
"filterEmpty": "Aucune séance de ce sport dans votre historique.",
|
||||
"manual": {
|
||||
"logWorkout": "Saisir un entraînement",
|
||||
"sheetTitle": "Saisir un entraînement",
|
||||
"sheetDescription": "Saisissez une séance à la main : ce que vous avez fait, quand et combien de temps.",
|
||||
"sportLabel": "Activité",
|
||||
"sportPlaceholder": "Choisir une activité",
|
||||
"startLabel": "Début",
|
||||
"durationLabel": "Durée",
|
||||
"distanceLabel": "Distance ({unit})",
|
||||
"energyLabel": "Énergie active (kcal)",
|
||||
"alreadySaved": "Cet entraînement est déjà enregistré",
|
||||
"saveError": "L’entraînement n’a pas pu être enregistré",
|
||||
"deleteLabel": "Supprimer l’entraînement",
|
||||
"deleteConfirmTitle": "Supprimer cet entraînement ?",
|
||||
"deleteConfirmDescription": "Cet entraînement saisi à la main sera supprimé définitivement.",
|
||||
"deleted": "Entraînement supprimé",
|
||||
"deleteError": "L’entraînement n’a pas pu être supprimé",
|
||||
"errors": {
|
||||
"sportRequired": "Choisissez une activité.",
|
||||
"startRequired": "Saisissez une date et une heure.",
|
||||
"startInFuture": "Le début ne peut pas être dans le futur.",
|
||||
"durationRequired": "Indiquez la durée de l’entraînement.",
|
||||
"durationInvalid": "Saisissez des heures entières et des minutes de 0 à 59.",
|
||||
"durationTooLong": "Un entraînement dure au maximum 24 heures.",
|
||||
"distanceInvalid": "Saisissez la distance sous forme de nombre, dans une plage réaliste.",
|
||||
"energyInvalid": "Saisissez l’énergie active sous forme de nombre, jusqu’à 20 000 kcal."
|
||||
}
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Glycémie",
|
||||
@@ -3575,7 +3607,18 @@
|
||||
"description": "Les relevés apparaissent ici dès qu’une balance à impédance se synchronise."
|
||||
}
|
||||
},
|
||||
"bodyFat": {
|
||||
"title": "Taux de masse grasse",
|
||||
"description": "Votre taux de masse grasse au fil du temps.",
|
||||
"chartTitle": "Taux de masse grasse",
|
||||
"emptyState": {
|
||||
"title": "Aucune donnée de taux de masse grasse",
|
||||
"description": "Les relevés apparaissent ici dès qu'une balance à impédance se synchronise ou que vous en saisissez un à la main.",
|
||||
"cta": "Enregistrer le taux de masse grasse"
|
||||
}
|
||||
},
|
||||
"navFatMass": "Masse grasse",
|
||||
"navBodyFat": "Taux de masse grasse",
|
||||
"muscleMass": {
|
||||
"title": "Masse musculaire",
|
||||
"description": "Votre masse musculaire au fil du temps, en kilogrammes.",
|
||||
|
||||
+46
-3
@@ -403,7 +403,9 @@
|
||||
"medication": "Farmaco",
|
||||
"medicationDescription": "Segna una dose come assunta",
|
||||
"mood": "Umore",
|
||||
"moodDescription": "Registra umore e tag"
|
||||
"moodDescription": "Registra umore e tag",
|
||||
"workout": "Allenamento",
|
||||
"workoutDescription": "Una sessione che i tuoi dispositivi non hanno registrato"
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Esami",
|
||||
@@ -520,6 +522,7 @@
|
||||
"quickAddMeasurement": "Registra misurazione",
|
||||
"quickAddMood": "Registra umore",
|
||||
"quickAddMedicationIntake": "Registra assunzione",
|
||||
"quickAddWorkout": "Registra allenamento",
|
||||
"customizeDashboard": "Personalizza dashboard",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Registra assunzione del farmaco",
|
||||
@@ -3157,6 +3160,7 @@
|
||||
"boneMassBody": "La massa ossea è il peso stimato dei minerali del tuo scheletro. Le stime da bilancia sono approssimative, ma un andamento stabile o in aumento sostiene la robustezza ossea a lungo termine.",
|
||||
"fatFreeMassBody": "La massa magra è tutto ciò che nel corpo non è grasso: muscoli, ossa, organi e acqua. Mantenerla o aumentarla mentre si perde peso indica di solito una composizione corporea più sana.",
|
||||
"fatMassBody": "La massa grassa è il peso totale del grasso nel corpo. Un po' di grasso è essenziale per gli ormoni e le riserve di energia, ma un eccesso — soprattutto sull'addome — aumenta il rischio per la salute a lungo termine (WHO 2024).",
|
||||
"bodyFatBody": "La percentuale di grasso corporeo è la parte del tuo peso costituita da grasso. Gli intervalli sani dipendono da sesso ed età, e le bilance la stimano con la bioimpedenza, quindi l'andamento su più settimane dice più di una singola misura.",
|
||||
"muscleMassBody": "La massa muscolare è il peso stimato dei muscoli del corpo. Più muscolo sostiene forza, metabolismo e mobilità, e di solito richiede impegno attivo per essere mantenuto con l'età.",
|
||||
"visceralFatBody": "Il grasso viscerale è il grasso depositato in profondità nell'addome, intorno agli organi. A differenza del grasso sottocutaneo, un livello elevato è strettamente legato a malattie cardiache e diabete di tipo 2, quindi più basso è in genere meglio.",
|
||||
"leanBodyMassBody": "La massa magra corporea è il peso totale meno il grasso: muscoli, ossa, organi e acqua insieme. Seguirla accanto al peso mostra se i cambiamenti derivano dal muscolo o dal grasso.",
|
||||
@@ -3358,7 +3362,7 @@
|
||||
"description": "Corse, uscite in bici, camminate e sessioni di forza recenti, deduplicati tra Apple Watch e Withings.",
|
||||
"emptyState": {
|
||||
"title": "Nessun allenamento",
|
||||
"description": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti. L'import di attività di Withings porta dentro anche camminate e uscite in bici."
|
||||
"description": "Gli allenamenti arrivano da Salute di Apple sull'iPhone e da Withings. Puoi anche registrarne uno a mano."
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Torna agli allenamenti",
|
||||
@@ -3436,7 +3440,35 @@
|
||||
"countFew": "{count} allenamenti",
|
||||
"filterLabel": "Filtra per sport",
|
||||
"filterAll": "Tutti gli sport",
|
||||
"filterEmpty": "Nessuna sessione di questo sport nella tua cronologia."
|
||||
"filterEmpty": "Nessuna sessione di questo sport nella tua cronologia.",
|
||||
"manual": {
|
||||
"logWorkout": "Registra allenamento",
|
||||
"sheetTitle": "Registra allenamento",
|
||||
"sheetDescription": "Inserisci una sessione a mano: cosa hai fatto, quando e per quanto tempo.",
|
||||
"sportLabel": "Attività",
|
||||
"sportPlaceholder": "Scegli un’attività",
|
||||
"startLabel": "Inizio",
|
||||
"durationLabel": "Durata",
|
||||
"distanceLabel": "Distanza ({unit})",
|
||||
"energyLabel": "Energia attiva (kcal)",
|
||||
"alreadySaved": "Questo allenamento è già salvato",
|
||||
"saveError": "Non è stato possibile salvare l’allenamento",
|
||||
"deleteLabel": "Elimina allenamento",
|
||||
"deleteConfirmTitle": "Eliminare questo allenamento?",
|
||||
"deleteConfirmDescription": "Questo allenamento inserito a mano verrà rimosso definitivamente.",
|
||||
"deleted": "Allenamento eliminato",
|
||||
"deleteError": "Non è stato possibile eliminare l’allenamento",
|
||||
"errors": {
|
||||
"sportRequired": "Scegli un’attività.",
|
||||
"startRequired": "Inserisci data e ora.",
|
||||
"startInFuture": "L’inizio non può essere nel futuro.",
|
||||
"durationRequired": "Indica quanto è durato l’allenamento.",
|
||||
"durationInvalid": "Inserisci ore intere e minuti da 0 a 59.",
|
||||
"durationTooLong": "Un allenamento può durare al massimo 24 ore.",
|
||||
"distanceInvalid": "Inserisci la distanza come numero in un intervallo realistico.",
|
||||
"energyInvalid": "Inserisci l’energia attiva come numero fino a 20.000 kcal."
|
||||
}
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Glicemia",
|
||||
@@ -3575,7 +3607,18 @@
|
||||
"description": "Le letture appaiono qui quando una bilancia impedenziometrica si sincronizza."
|
||||
}
|
||||
},
|
||||
"bodyFat": {
|
||||
"title": "Grasso corporeo",
|
||||
"description": "La tua percentuale di grasso corporeo nel tempo.",
|
||||
"chartTitle": "Grasso corporeo",
|
||||
"emptyState": {
|
||||
"title": "Nessun dato di grasso corporeo",
|
||||
"description": "Le letture appaiono qui quando una bilancia impedenziometrica si sincronizza o quando ne registri una a mano.",
|
||||
"cta": "Registra il grasso corporeo"
|
||||
}
|
||||
},
|
||||
"navFatMass": "Massa grassa",
|
||||
"navBodyFat": "Grasso corporeo",
|
||||
"muscleMass": {
|
||||
"title": "Massa muscolare",
|
||||
"description": "La tua massa muscolare nel tempo, in chilogrammi.",
|
||||
|
||||
+46
-3
@@ -403,7 +403,9 @@
|
||||
"medication": "약",
|
||||
"medicationDescription": "복용으로 표시하기",
|
||||
"mood": "기분",
|
||||
"moodDescription": "기분과 태그 기록하기"
|
||||
"moodDescription": "기분과 태그 기록하기",
|
||||
"workout": "운동",
|
||||
"workoutDescription": "기기가 기록하지 못한 운동"
|
||||
},
|
||||
"cycle": "생리 주기",
|
||||
"labs": "검사",
|
||||
@@ -520,6 +522,7 @@
|
||||
"quickAddMeasurement": "측정 기록",
|
||||
"quickAddMood": "기분 기록",
|
||||
"quickAddMedicationIntake": "복용 기록",
|
||||
"quickAddWorkout": "운동 기록",
|
||||
"customizeDashboard": "대시보드 맞춤 설정",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "복용 기록",
|
||||
@@ -3157,6 +3160,7 @@
|
||||
"boneMassBody": "골량은 뼈에 들어 있는 무기질의 추정 무게예요. 체중계 기반 추정은 대략적이지만, 유지되거나 올라가는 추이는 장기적인 뼈 건강에 도움이 돼요.",
|
||||
"fatFreeMassBody": "제지방량은 몸에서 지방이 아닌 전부 — 근육·뼈·장기·수분이에요. 체중을 줄이면서도 이걸 유지하거나 늘리면 대체로 더 건강한 체성분을 뜻해요.",
|
||||
"fatMassBody": "체지방량은 몸속 지방의 총 무게예요. 지방은 호르몬과 에너지 저장에 어느 정도 꼭 필요하지만, 특히 복부에 너무 많으면 장기적인 건강 위험이 올라가요(WHO 2024).",
|
||||
"bodyFatBody": "체지방률은 체중 중 지방이 차지하는 비율이에요. 건강한 범위는 성별과 나이에 따라 다르고, 체중계는 생체 전기 임피던스로 추정하므로 한 번의 측정값보다 몇 주간의 추이가 더 많은 걸 알려줘요.",
|
||||
"muscleMassBody": "근육량은 몸속 근육의 추정 무게예요. 근육이 많을수록 근력·대사·활동성에 도움이 되고, 나이가 들수록 유지하려면 적극적인 노력이 필요해요.",
|
||||
"visceralFatBody": "내장지방은 복부 깊숙이 장기 주변에 쌓이는 지방이에요. 피부 밑 지방과 달리 수치가 높으면 심장병과 2형 당뇨병에 밀접하게 연결되니 대체로 낮을수록 좋아요.",
|
||||
"leanBodyMassBody": "제지방 체중은 전체 체중에서 체지방을 뺀 값 — 근육·뼈·장기·수분을 합친 거예요. 체중과 함께 보면 변화가 근육에서 왔는지 지방에서 왔는지 알 수 있어요.",
|
||||
@@ -3358,7 +3362,7 @@
|
||||
"description": "최근 달리기, 자전거, 걷기, 근력 운동 세션이에요. Apple Watch와 Withings 소스 사이 중복은 정리했어요.",
|
||||
"emptyState": {
|
||||
"title": "아직 운동 기록이 없어요",
|
||||
"description": "iPhone에서 Apple Health 앱을 열면 운동이 여기로 동기화돼요. Withings 활동 불러오기로 걷기와 자전거 세션도 계속 들어와요."
|
||||
"description": "운동은 iPhone의 Apple Health와 Withings에서 동기화돼요. 직접 기록할 수도 있어요."
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "운동으로 돌아가기",
|
||||
@@ -3436,7 +3440,35 @@
|
||||
"countFew": "운동 {count}개",
|
||||
"filterLabel": "종목별 필터",
|
||||
"filterAll": "전체 종목",
|
||||
"filterEmpty": "기록에 이 종목의 세션이 없어요."
|
||||
"filterEmpty": "기록에 이 종목의 세션이 없어요.",
|
||||
"manual": {
|
||||
"logWorkout": "운동 기록",
|
||||
"sheetTitle": "운동 기록",
|
||||
"sheetDescription": "무엇을, 언제, 얼마나 했는지 직접 입력하세요.",
|
||||
"sportLabel": "활동",
|
||||
"sportPlaceholder": "활동 선택",
|
||||
"startLabel": "시작",
|
||||
"durationLabel": "시간",
|
||||
"distanceLabel": "거리({unit})",
|
||||
"energyLabel": "활동 에너지(kcal)",
|
||||
"alreadySaved": "이미 저장된 운동입니다",
|
||||
"saveError": "운동을 저장하지 못했습니다",
|
||||
"deleteLabel": "운동 삭제",
|
||||
"deleteConfirmTitle": "이 운동을 삭제할까요?",
|
||||
"deleteConfirmDescription": "직접 입력한 이 운동이 영구적으로 삭제됩니다.",
|
||||
"deleted": "운동을 삭제했습니다",
|
||||
"deleteError": "운동을 삭제하지 못했습니다",
|
||||
"errors": {
|
||||
"sportRequired": "활동을 선택하세요.",
|
||||
"startRequired": "날짜와 시간을 입력하세요.",
|
||||
"startInFuture": "시작 시간은 미래일 수 없습니다.",
|
||||
"durationRequired": "운동한 시간을 입력하세요.",
|
||||
"durationInvalid": "시간은 정수로, 분은 0에서 59 사이로 입력하세요.",
|
||||
"durationTooLong": "운동은 최대 24시간까지 입력할 수 있습니다.",
|
||||
"distanceInvalid": "거리를 현실적인 범위의 숫자로 입력하세요.",
|
||||
"energyInvalid": "활동 에너지는 20,000kcal 이하의 숫자로 입력하세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "혈당",
|
||||
@@ -3575,7 +3607,18 @@
|
||||
"description": "연결된 체성분 체중계가 동기화되면 체지방량 측정값이 여기에 나타나요."
|
||||
}
|
||||
},
|
||||
"bodyFat": {
|
||||
"title": "체지방률",
|
||||
"description": "시간에 따른 체지방률이에요.",
|
||||
"chartTitle": "체지방률",
|
||||
"emptyState": {
|
||||
"title": "아직 체지방률 데이터가 없어요",
|
||||
"description": "체성분 체중계가 동기화되거나 직접 기록하면 체지방률 측정값이 여기에 나타나요.",
|
||||
"cta": "체지방률 기록"
|
||||
}
|
||||
},
|
||||
"navFatMass": "체지방량",
|
||||
"navBodyFat": "체지방률",
|
||||
"muscleMass": {
|
||||
"title": "근육량",
|
||||
"description": "시간에 따른 근육량이에요(kg).",
|
||||
|
||||
+46
-3
@@ -403,7 +403,9 @@
|
||||
"medication": "Lek",
|
||||
"medicationDescription": "Oznacz dawkę jako przyjętą",
|
||||
"mood": "Nastrój",
|
||||
"moodDescription": "Zapisz nastrój i tagi"
|
||||
"moodDescription": "Zapisz nastrój i tagi",
|
||||
"workout": "Trening",
|
||||
"workoutDescription": "Sesja, której nie zapisały twoje urządzenia"
|
||||
},
|
||||
"cycle": "Cykl",
|
||||
"labs": "Wyniki badań",
|
||||
@@ -520,6 +522,7 @@
|
||||
"quickAddMeasurement": "Zarejestruj pomiar",
|
||||
"quickAddMood": "Zarejestruj nastrój",
|
||||
"quickAddMedicationIntake": "Zarejestruj przyjęcie",
|
||||
"quickAddWorkout": "Zarejestruj trening",
|
||||
"customizeDashboard": "Dostosuj pulpit",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Zarejestruj przyjęcie leku",
|
||||
@@ -3157,6 +3160,7 @@
|
||||
"boneMassBody": "Masa kostna to szacowana waga minerałów w szkielecie. Pomiary z wagi są przybliżone, ale stabilny lub rosnący trend sprzyja długotrwałej wytrzymałości kości.",
|
||||
"fatFreeMassBody": "Masa beztłuszczowa to wszystko w ciele, co nie jest tłuszczem — mięśnie, kości, narządy i woda. Utrzymanie lub jej budowanie przy spadku wagi zwykle wskazuje na zdrowszy skład ciała.",
|
||||
"fatMassBody": "Masa tłuszczowa to łączna waga tłuszczu w ciele. Pewna ilość tłuszczu jest niezbędna dla hormonów i magazynowania energii, ale jego nadmiar — zwłaszcza w okolicy brzucha — zwiększa długoterminowe ryzyko zdrowotne (WHO 2024).",
|
||||
"bodyFatBody": "Procent tkanki tłuszczowej to część masy ciała, którą stanowi tłuszcz. Zdrowe zakresy zależą od płci i wieku, a wagi szacują go metodą bioimpedancji, dlatego trend z kilku tygodni mówi więcej niż pojedynczy pomiar.",
|
||||
"muscleMassBody": "Masa mięśniowa to szacowana waga mięśni w ciele. Więcej mięśni wspiera siłę, metabolizm i sprawność, a ich utrzymanie z wiekiem zwykle wymaga aktywnego wysiłku.",
|
||||
"visceralFatBody": "Tłuszcz trzewny to tłuszcz gromadzony głęboko w jamie brzusznej wokół narządów. W odróżnieniu od tłuszczu podskórnego jego wysoki poziom jest ściśle powiązany z chorobami serca i cukrzycą typu 2, więc im niżej, tym lepiej.",
|
||||
"leanBodyMassBody": "Beztłuszczowa masa ciała to całkowita waga pomniejszona o tłuszcz — mięśnie, kości, narządy i woda razem. Śledzenie jej obok wagi pokazuje, czy zmiany wynikają z mięśni, czy z tłuszczu.",
|
||||
@@ -3358,7 +3362,7 @@
|
||||
"description": "Ostatnie biegi, jazdy, marsze i treningi siłowe — zdeduplikowane między Apple Watch a Withings.",
|
||||
"emptyState": {
|
||||
"title": "Brak treningów",
|
||||
"description": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi. Import aktywności z Withings także dorzuca marsze i przejażdżki."
|
||||
"description": "Treningi trafiają tu z Apple Zdrowie na iPhonie i z Withings. Możesz też wpisać trening ręcznie."
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Powrót do treningów",
|
||||
@@ -3436,7 +3440,35 @@
|
||||
"countFew": "{count} treningi",
|
||||
"filterLabel": "Filtruj według sportu",
|
||||
"filterAll": "Wszystkie sporty",
|
||||
"filterEmpty": "Brak treningów tego sportu w Twojej historii."
|
||||
"filterEmpty": "Brak treningów tego sportu w Twojej historii.",
|
||||
"manual": {
|
||||
"logWorkout": "Zarejestruj trening",
|
||||
"sheetTitle": "Zarejestruj trening",
|
||||
"sheetDescription": "Wpisz sesję ręcznie: rodzaj aktywności, początek i czas trwania.",
|
||||
"sportLabel": "Aktywność",
|
||||
"sportPlaceholder": "Wybierz aktywność",
|
||||
"startLabel": "Początek",
|
||||
"durationLabel": "Czas trwania",
|
||||
"distanceLabel": "Dystans ({unit})",
|
||||
"energyLabel": "Energia czynna (kcal)",
|
||||
"alreadySaved": "Ten trening jest już zapisany",
|
||||
"saveError": "Nie udało się zapisać treningu",
|
||||
"deleteLabel": "Usuń trening",
|
||||
"deleteConfirmTitle": "Usunąć ten trening?",
|
||||
"deleteConfirmDescription": "Ten ręcznie wpisany trening zostanie trwale usunięty.",
|
||||
"deleted": "Trening usunięty",
|
||||
"deleteError": "Nie udało się usunąć treningu",
|
||||
"errors": {
|
||||
"sportRequired": "Wybierz aktywność.",
|
||||
"startRequired": "Podaj datę i godzinę.",
|
||||
"startInFuture": "Początek nie może być w przyszłości.",
|
||||
"durationRequired": "Podaj, jak długo trwał trening.",
|
||||
"durationInvalid": "Podaj pełne godziny i minuty od 0 do 59.",
|
||||
"durationTooLong": "Trening może trwać najwyżej 24 godziny.",
|
||||
"distanceInvalid": "Podaj dystans jako liczbę w realistycznym zakresie.",
|
||||
"energyInvalid": "Podaj energię czynną jako liczbę do 20 000 kcal."
|
||||
}
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Poziom glukozy",
|
||||
@@ -3575,7 +3607,18 @@
|
||||
"description": "Odczyty pojawią się tutaj po synchronizacji wagi analitycznej."
|
||||
}
|
||||
},
|
||||
"bodyFat": {
|
||||
"title": "Tkanka tłuszczowa",
|
||||
"description": "Twój procent tkanki tłuszczowej w czasie.",
|
||||
"chartTitle": "Tkanka tłuszczowa",
|
||||
"emptyState": {
|
||||
"title": "Brak danych o tkance tłuszczowej",
|
||||
"description": "Odczyty pojawią się tutaj po synchronizacji wagi analitycznej lub po ręcznym zapisaniu wartości.",
|
||||
"cta": "Zapisz tkankę tłuszczową"
|
||||
}
|
||||
},
|
||||
"navFatMass": "Masa tłuszczowa",
|
||||
"navBodyFat": "Tkanka tłuszczowa",
|
||||
"muscleMass": {
|
||||
"title": "Masa mięśniowa",
|
||||
"description": "Twoja masa mięśniowa w czasie, w kilogramach.",
|
||||
|
||||
@@ -380,6 +380,13 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/workouts/delete-workout-button.tsx",
|
||||
destroys:
|
||||
"one workout entered by hand, with the personal records it set; synced workouts are refused by the route",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
|
||||
// ── Visits and the address book ─────────────────────────────────────────
|
||||
{
|
||||
|
||||
@@ -21,13 +21,21 @@
|
||||
* route geometry); absent for manual entries.
|
||||
* - Ownership-gated: a row owned by another user resolves as 404 so
|
||||
* the existence channel never leaks.
|
||||
*
|
||||
* `DELETE /api/workouts/{id}` removes a workout entered by hand. Only a
|
||||
* `MANUAL` row: a synced workout would come back with the next sync, so
|
||||
* deleting it here would be a promise the record cannot keep. See the
|
||||
* handler below.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
@@ -357,3 +365,103 @@ export const GET = apiHandler(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* `DELETE /api/workouts/{id}` — remove a workout that was entered by hand.
|
||||
*
|
||||
* - Own record only: `requireAuth()` with no scope admits a cookie session or
|
||||
* a wildcard token and refuses an acting-account carrier, the same arm the
|
||||
* detail read above uses. A narrow token is refused.
|
||||
* - Ownership: another user's row answers 404, like the read.
|
||||
* - `MANUAL` only. Every other source is a sync, and the next sync would
|
||||
* write the row back, so it answers 409 instead of pretending.
|
||||
* - Not module-gated: deleting is cleanup of the person's own rows, which
|
||||
* the data layer allows whatever the module says
|
||||
* (`module-route-gate-inventory.test.ts`).
|
||||
*
|
||||
* The workout table carries no tombstone, so the row is removed outright;
|
||||
* route, samples, insight and claim rows cascade with it. A personal record
|
||||
* the workout set would otherwise outlive it (the detector only ever raises
|
||||
* a stored best), so the records keyed on this workout go in the same
|
||||
* transaction and a silent detection pass re-derives the honest best.
|
||||
*/
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const row = await prisma.workout.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
source: true,
|
||||
externalId: true,
|
||||
startedAt: true,
|
||||
sportType: true,
|
||||
},
|
||||
});
|
||||
if (!row || row.userId !== user.id) {
|
||||
return apiError("Workout not found", 404);
|
||||
}
|
||||
if (row.source !== "MANUAL") {
|
||||
annotate({
|
||||
action: {
|
||||
name: "workout.delete",
|
||||
entity_type: "workout",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { outcome: "synced_source", source: row.source },
|
||||
});
|
||||
return apiError(
|
||||
"Only a workout entered by hand can be deleted here",
|
||||
409,
|
||||
{ errorCode: "workout.delete.synced_source" },
|
||||
);
|
||||
}
|
||||
|
||||
const removedRecords = await prisma.$transaction(async (tx) => {
|
||||
const records = await tx.personalRecord.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
metricSlot: { not: null },
|
||||
source: "MANUAL",
|
||||
...(row.externalId !== null
|
||||
? { externalId: row.externalId }
|
||||
: { externalId: null, achievedAt: row.startedAt }),
|
||||
},
|
||||
});
|
||||
await tx.workout.delete({ where: { id: row.id } });
|
||||
return records.count;
|
||||
});
|
||||
|
||||
await auditLog("workout.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
workoutId: id,
|
||||
sportType: row.sportType,
|
||||
personalRecordsRemoved: removedRecords,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "workout.delete", entity_type: "workout", entity_id: id },
|
||||
meta: { outcome: "deleted", personalRecordsRemoved: removedRecords },
|
||||
});
|
||||
|
||||
invalidateUserMeasurements(user.id, { evict: true });
|
||||
|
||||
if (removedRecords > 0) {
|
||||
try {
|
||||
await enqueuePrDetection(user.id, { silent: true });
|
||||
} catch (err) {
|
||||
annotate({
|
||||
action: { name: "personal_records.detection_enqueue_failed" },
|
||||
meta: { error: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Unit suite for `DELETE /api/workouts/{id}`.
|
||||
*
|
||||
* Pins:
|
||||
* - a workout entered by hand is removed, with the personal records keyed
|
||||
* on it, and a silent detection pass re-derives the previous best;
|
||||
* - a synced workout answers 409 and nothing is touched, because the next
|
||||
* sync would write it back;
|
||||
* - another user's row and a missing row answer 404;
|
||||
* - no session answers 401.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const tx = {
|
||||
personalRecord: { deleteMany: vi.fn() },
|
||||
workout: { delete: vi.fn() },
|
||||
};
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
workout: { findUnique: vi.fn() },
|
||||
$transaction: vi.fn(async (fn: (client: typeof tx) => unknown) => fn(tx)),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/pr-detection", () => ({
|
||||
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { DELETE } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const MANUAL_ROW = {
|
||||
id: "w-1",
|
||||
userId: "user-1",
|
||||
source: "MANUAL" as const,
|
||||
externalId: "manual:5b0f3c1e-1111-4222-8333-444455556666",
|
||||
startedAt: new Date("2026-09-01T07:00:00Z"),
|
||||
sportType: "running",
|
||||
};
|
||||
|
||||
function del(id = "w-1") {
|
||||
return DELETE(
|
||||
new NextRequest(`http://localhost/api/workouts/${id}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
}
|
||||
|
||||
describe("DELETE /api/workouts/{id}", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
tx.personalRecord.deleteMany.mockResolvedValue({ count: 0 });
|
||||
tx.workout.delete.mockResolvedValue({ id: "w-1" });
|
||||
});
|
||||
|
||||
it("removes a workout entered by hand", async () => {
|
||||
vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never);
|
||||
|
||||
const res = await del();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ data: { deleted: true }, error: null });
|
||||
expect(tx.workout.delete).toHaveBeenCalledWith({ where: { id: "w-1" } });
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"workout.delete",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", {
|
||||
evict: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the personal records the workout set with it and re-derives the best", async () => {
|
||||
vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never);
|
||||
tx.personalRecord.deleteMany.mockResolvedValue({ count: 2 });
|
||||
|
||||
const res = await del();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: "user-1",
|
||||
metricSlot: { not: null },
|
||||
source: "MANUAL",
|
||||
externalId: MANUAL_ROW.externalId,
|
||||
},
|
||||
});
|
||||
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
|
||||
silent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keys a record without an external id on the session's start", async () => {
|
||||
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
|
||||
...MANUAL_ROW,
|
||||
externalId: null,
|
||||
} as never);
|
||||
|
||||
await del();
|
||||
|
||||
expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: "user-1",
|
||||
metricSlot: { not: null },
|
||||
source: "MANUAL",
|
||||
externalId: null,
|
||||
achievedAt: MANUAL_ROW.startedAt,
|
||||
},
|
||||
});
|
||||
// Nothing was removed, so nothing needs re-deriving.
|
||||
expect(enqueuePrDetection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["APPLE_HEALTH", "WITHINGS", "WHOOP", "EXTERNAL"])(
|
||||
"refuses a %s workout with 409 and touches nothing",
|
||||
async (source) => {
|
||||
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
|
||||
...MANUAL_ROW,
|
||||
source,
|
||||
} as never);
|
||||
|
||||
const res = await del();
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body.data).toBeNull();
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(tx.workout.delete).not.toHaveBeenCalled();
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("answers 404 for another user's workout", async () => {
|
||||
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
|
||||
...MANUAL_ROW,
|
||||
userId: "someone-else",
|
||||
} as never);
|
||||
|
||||
const res = await del();
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers 404 when there is no such workout", async () => {
|
||||
vi.mocked(prisma.workout.findUnique).mockResolvedValue(null);
|
||||
|
||||
const res = await del("missing");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("answers 401 without a session", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await del();
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.workout.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Percent } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
|
||||
/**
|
||||
* `/insights/body-fat`.
|
||||
*
|
||||
* BODY_FAT sub-page on the generic HealthKitMetricPage scaffold, like its
|
||||
* body-composition siblings. The dashboard tile and chart for body fat
|
||||
* predate the sub-pages; this is the page the tile opens. Readings arrive
|
||||
* from a body-composition scale or by hand, so the empty state offers
|
||||
* manual entry.
|
||||
*/
|
||||
export default function InsightsBodyFatPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="BODY_FAT"
|
||||
insightMetric="BODY_FAT"
|
||||
chartKey="bodyFat"
|
||||
i18nPrefix="insights.bodyFat"
|
||||
explainerMetric="bodyFat"
|
||||
color="var(--warning)"
|
||||
unit="%"
|
||||
statIcon={Percent}
|
||||
emptyStateIcon={<Percent className="size-6" />}
|
||||
emptyStateCtaType="BODY_FAT"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { canonicalWorkoutDetailHref } from "@/lib/workouts/canonical-detail-route";
|
||||
import { DeleteWorkoutButton } from "@/components/workouts/delete-workout-button";
|
||||
import {
|
||||
WorkoutDetailHeader,
|
||||
WorkoutDetailStats,
|
||||
@@ -64,6 +65,11 @@ export default function InsightsWorkoutDetailPage({
|
||||
// No description. The one this page used to carry belongs to the LIST
|
||||
// ("Recent runs, rides, walks…, deduped…") and describes a surface
|
||||
// this is not — a detail page must not claim to be the list.
|
||||
// A workout entered by hand can be deleted here; the control renders
|
||||
// nothing for a synced one.
|
||||
headerAction={
|
||||
data && !canonicalHref ? <DeleteWorkoutButton workout={data} /> : null
|
||||
}
|
||||
backLink={
|
||||
<BackLink
|
||||
href="/insights/workouts"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Activity } from "lucide-react";
|
||||
import { Activity, Plus } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -10,6 +10,11 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { WorkoutList } from "@/components/insights/workout-list";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ManualWorkoutSheet } from "@/components/workouts/manual-workout-sheet";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { canLogWorkout } from "@/lib/workouts/manual-entry";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
|
||||
/**
|
||||
@@ -42,6 +47,10 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
export default function InsightsWorkoutsPageClient() {
|
||||
const { t, tCount } = useTranslations();
|
||||
const [sportType, setSportType] = useState<string | undefined>(undefined);
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const capabilities = useRecordCapabilities();
|
||||
const showLog = canLogWorkout(capabilities, user?.modules);
|
||||
const {
|
||||
workouts,
|
||||
total,
|
||||
@@ -80,7 +89,31 @@ export default function InsightsWorkoutsPageClient() {
|
||||
description={t("insights.workouts.description")}
|
||||
explainerMetric="workouts"
|
||||
coachLaunch
|
||||
headerAction={
|
||||
showLog ? (
|
||||
// Same 40 px ghost icon and widened hit area as the header
|
||||
// cluster's other actions; it opens the form in place.
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="workout-log-manual"
|
||||
aria-label={t("insights.workouts.manual.logWorkout")}
|
||||
title={t("insights.workouts.manual.logWorkout")}
|
||||
onClick={() => setLogOpen(true)}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground relative size-10",
|
||||
"before:absolute before:-inset-1.5 before:content-['']",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{showLog ? (
|
||||
<ManualWorkoutSheet open={logOpen} onOpenChange={setLogOpen} />
|
||||
) : null}
|
||||
{/* No `<MetricRangeControls>` here: workouts are session records, not a
|
||||
MeasurementType series, so the period-over-period range read has
|
||||
nothing to aggregate. */}
|
||||
@@ -100,7 +133,13 @@ export default function InsightsWorkoutsPageClient() {
|
||||
icon={<Activity className="size-6" />}
|
||||
title={t("insights.workouts.emptyState.title")}
|
||||
description={t("insights.workouts.emptyState.description")}
|
||||
cta={null}
|
||||
cta={
|
||||
showLog ? (
|
||||
<Button size="sm" type="button" onClick={() => setLogOpen(true)}>
|
||||
{t("insights.workouts.manual.logWorkout")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?"
|
||||
/>
|
||||
) : (
|
||||
|
||||
+5
-39
@@ -174,6 +174,7 @@ export {
|
||||
resolveConfiguredTileCount,
|
||||
resolveChartRowPlaceholderCount,
|
||||
} from "@/components/dashboard/dashboard-gates";
|
||||
import { dashboardTileHref } from "@/components/dashboard/tile-destinations";
|
||||
import {
|
||||
resolveDashboardFirstPaintGate,
|
||||
resolveConfiguredTileCount,
|
||||
@@ -182,42 +183,6 @@ import {
|
||||
resolveGlucoseTiles,
|
||||
} from "@/components/dashboard/dashboard-gates";
|
||||
|
||||
/**
|
||||
* Dashboard metric tiles are no longer dead-ends: each tile links to its
|
||||
* Insights detail page (bands, correlations, history). Keyed by the stable
|
||||
* tile `id` used in the `trendCards` builder; glucose contexts share one
|
||||
* blood-glucose destination via the `glucose-` prefix. Tiles with no
|
||||
* dedicated sub-page (body fat %) stay un-linked rather than misroute.
|
||||
*/
|
||||
const TILE_INSIGHT_HREF: Record<string, string> = {
|
||||
weight: "/insights/weight",
|
||||
"bp-sys": "/insights/blood-pressure",
|
||||
"bp-dia": "/insights/blood-pressure",
|
||||
bpInTarget: "/insights/blood-pressure",
|
||||
pulse: "/insights/pulse",
|
||||
mood: "/insights/mood",
|
||||
sleep: "/insights/sleep",
|
||||
steps: "/insights/steps",
|
||||
vo2Max: "/insights/cardio-fitness",
|
||||
// v1.28.52 — vitals + body-composition strip tiles link to their
|
||||
// existing /insights detail pages (bands, history, correlations).
|
||||
hrv: "/insights/hrv",
|
||||
oxygenSaturation: "/insights/oxygen",
|
||||
respiratoryRate: "/insights/respiratory-rate",
|
||||
wristTemperature: "/insights/wrist-temperature",
|
||||
muscleMass: "/insights/muscle-mass",
|
||||
totalBodyWater: "/insights/body-water",
|
||||
boneMass: "/insights/bone-mass",
|
||||
// v1.29 — fluid intake links to the nutrients surface (hydration hero +
|
||||
// quick-add), not a Measurement-backed detail page.
|
||||
waterIntake: "/insights/nutrients",
|
||||
};
|
||||
|
||||
function tileInsightHref(id: string): string | null {
|
||||
if (id.startsWith("glucose-")) return "/insights/blood-glucose";
|
||||
return TILE_INSIGHT_HREF[id] ?? null;
|
||||
}
|
||||
|
||||
export default function DashboardPageClient({
|
||||
batchWindow: batchWindowProp,
|
||||
}: {
|
||||
@@ -2172,9 +2137,10 @@ export default function DashboardPageClient({
|
||||
</Suspense>
|
||||
);
|
||||
// A tile is no longer a dead-end: it links to its Insights
|
||||
// detail page. The link fills the grid cell and paints a
|
||||
// ring on hover / focus so the whole tile reads as tappable.
|
||||
const href = tileInsightHref(entry.id);
|
||||
// detail page (`tile-destinations.ts`, gated on the page's
|
||||
// module). The link fills the grid cell and paints a ring
|
||||
// on hover / focus so the whole tile reads as tappable.
|
||||
const href = dashboardTileHref(entry.id, user?.modules);
|
||||
return href ? (
|
||||
<Link
|
||||
key={entry.id}
|
||||
|
||||
@@ -126,7 +126,11 @@ function writeCaps(
|
||||
sections: ShareDomain[] | null,
|
||||
) {
|
||||
const writable = new Set(delegatedDomains(level, sections, "write"));
|
||||
return { canWriteDomain: (domain: ShareDomain) => writable.has(domain) };
|
||||
return {
|
||||
// A delegate is always inside somebody else's record.
|
||||
inSharedRecord: true,
|
||||
canWriteDomain: (domain: ShareDomain) => writable.has(domain),
|
||||
};
|
||||
}
|
||||
|
||||
const mockAccessRef: { value: AccountAccess } = { value: OWN_RECORD };
|
||||
@@ -499,7 +503,7 @@ describe("the capture picker's kinds", () => {
|
||||
|
||||
/** The picker asks one question per kind, of that kind's own section. */
|
||||
const caps = writeCaps;
|
||||
const OWNER_CAPS = { canWriteDomain: () => true };
|
||||
const OWNER_CAPS = { inSharedRecord: false, canWriteDomain: () => true };
|
||||
const WRITER_CAPS = caps("write", null);
|
||||
const READER_CAPS = caps("read", null);
|
||||
// A guardian: every section with a delegated route answers at MANAGE.
|
||||
@@ -558,6 +562,29 @@ describe("the capture picker's kinds", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("offers a workout in the caller's own record, gated by the workouts module", () => {
|
||||
expect(visibleCaptureKinds(OWNER_CAPS, ["workout"])).toEqual(["workout"]);
|
||||
expect(
|
||||
visibleCaptureKinds(OWNER_CAPS, ["workout"], { workouts: false }),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
visibleCaptureKinds(OWNER_CAPS, ["workout"], { mood: false }),
|
||||
).toEqual(["workout"]);
|
||||
});
|
||||
|
||||
it("never offers a delegate a workout, at any level or scope", () => {
|
||||
// The batch ingest resolves the caller (`requireAuth`), so no grant
|
||||
// reaches it: a measurements-scoped MANAGE grant is refused the same way.
|
||||
for (const delegate of [
|
||||
WRITER_CAPS,
|
||||
READER_CAPS,
|
||||
GUARDIAN_CAPS,
|
||||
caps("manage", ["measurements"]),
|
||||
]) {
|
||||
expect(visibleCaptureKinds(delegate, ["workout"])).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers a scoped MANAGE delegate the mood entry only with the mind section", () => {
|
||||
expect(visibleCaptureKinds(caps("manage", ["mind"]), [...ALL])).toEqual([
|
||||
"mood",
|
||||
@@ -659,6 +686,20 @@ describe("a form opened before the record answered", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("withdraws the workout sheet in any shared record, and keeps it in one's own", () => {
|
||||
for (const level of ["read", "write", "manage"] as const) {
|
||||
expect(admittedQuickEntry("workout", writeCaps(level, null)), level).toBe(
|
||||
null,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
admittedQuickEntry("workout", {
|
||||
inSharedRecord: false,
|
||||
canWriteDomain: () => true,
|
||||
}),
|
||||
).toBe("workout");
|
||||
});
|
||||
|
||||
it("keeps the mood sheet for a guardian, whose mind routes answer at MANAGE", () => {
|
||||
expect(admittedQuickEntry("mood", writeCaps("manage", null))).toBe("mood");
|
||||
});
|
||||
@@ -678,7 +719,7 @@ describe("a form opened before the record answered", () => {
|
||||
});
|
||||
|
||||
it("leaves the owner's own sheets alone", () => {
|
||||
const OWNER_CAPS = { canWriteDomain: () => true };
|
||||
const OWNER_CAPS = { inSharedRecord: false, canWriteDomain: () => true };
|
||||
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
|
||||
expect(admittedQuickEntry(sheet, OWNER_CAPS), sheet).toBe(sheet);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* post-hydration name personalisation);
|
||||
* 2. the SSR pass renders the name-less fallback (hydration-safe).
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
@@ -30,13 +30,24 @@ vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
<>{children}</>
|
||||
),
|
||||
}));
|
||||
const modulesRef: { value: Record<string, boolean> | undefined } = {
|
||||
value: undefined,
|
||||
};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { username: "tester", timezone: "Europe/Berlin" },
|
||||
user: {
|
||||
username: "tester",
|
||||
timezone: "Europe/Berlin",
|
||||
modules: modulesRef.value,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { DashboardHeader } from "../dashboard-header";
|
||||
import {
|
||||
CAPTURE_KIND_ORDER,
|
||||
visibleCaptureKinds,
|
||||
} from "@/components/layout/capture-picker";
|
||||
|
||||
function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") {
|
||||
return renderToStaticMarkup(
|
||||
@@ -85,3 +96,88 @@ describe("<DashboardHeader> — quick-add menu", () => {
|
||||
expect(html).not.toContain("Log water");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DashboardHeader> — Log workout", () => {
|
||||
beforeEach(() => {
|
||||
modulesRef.value = undefined;
|
||||
});
|
||||
|
||||
it("offers it beside the other capture entries", () => {
|
||||
modulesRef.value = { workouts: true };
|
||||
const html = renderSSR(
|
||||
<DashboardHeader onQuickEntry={() => undefined} />,
|
||||
"en",
|
||||
);
|
||||
expect(html).toContain("Log workout");
|
||||
});
|
||||
|
||||
it("withholds it when the workouts module is off, and keeps the rest", () => {
|
||||
modulesRef.value = { workouts: false };
|
||||
const html = renderSSR(
|
||||
<DashboardHeader onQuickEntry={() => undefined} />,
|
||||
"en",
|
||||
);
|
||||
expect(html).not.toContain("Log workout");
|
||||
expect(html).toContain("Log measurement");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DashboardHeader> — every entry follows its module, as the capture picker does", () => {
|
||||
beforeEach(() => {
|
||||
modulesRef.value = undefined;
|
||||
});
|
||||
|
||||
it("withholds Log mood when the mood module is off", () => {
|
||||
modulesRef.value = { mood: false };
|
||||
const html = renderSSR(
|
||||
<DashboardHeader onQuickEntry={() => undefined} />,
|
||||
"en",
|
||||
);
|
||||
expect(html).not.toContain("Log mood");
|
||||
expect(html).toContain("Log medication intake");
|
||||
});
|
||||
|
||||
it("withholds Log medication intake when the medications module is off", () => {
|
||||
modulesRef.value = { medications: false };
|
||||
const html = renderSSR(
|
||||
<DashboardHeader onQuickEntry={() => undefined} />,
|
||||
"en",
|
||||
);
|
||||
expect(html).not.toContain("Log medication intake");
|
||||
expect(html).toContain("Log mood");
|
||||
});
|
||||
|
||||
it("offers exactly what the capture picker offers for the same modules", () => {
|
||||
const cases: Array<Record<string, boolean> | undefined> = [
|
||||
undefined,
|
||||
{ mood: false },
|
||||
{ medications: false },
|
||||
{ workouts: false },
|
||||
{ mood: false, medications: false, workouts: false },
|
||||
];
|
||||
for (const modules of cases) {
|
||||
modulesRef.value = modules;
|
||||
const html = renderSSR(
|
||||
<DashboardHeader onQuickEntry={() => undefined} />,
|
||||
"en",
|
||||
);
|
||||
const picker = visibleCaptureKinds(
|
||||
{ inSharedRecord: false, canWriteDomain: () => true },
|
||||
CAPTURE_KIND_ORDER,
|
||||
modules,
|
||||
);
|
||||
const label = {
|
||||
measurement: "Log measurement",
|
||||
medication: "Log medication intake",
|
||||
mood: "Log mood",
|
||||
workout: "Log workout",
|
||||
} as const;
|
||||
for (const kind of CAPTURE_KIND_ORDER) {
|
||||
expect(
|
||||
html.includes(label[kind]),
|
||||
`${kind} ${JSON.stringify(modules)}`,
|
||||
).toBe(picker.includes(kind));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Every dashboard tile whose metric has an Insights sub-page opens it.
|
||||
*
|
||||
* The body fat tile was rendered without a link while its siblings each
|
||||
* opened their page, and nothing noticed, because the href map was a
|
||||
* hand-kept list beside the render code with no check tying the two
|
||||
* together. This guard derives the tile set from the registry
|
||||
* (`TILE_CAPABLE_WIDGET_IDS`) and from the dashboard's own render code, and
|
||||
* holds every tile against the sub-page registry.
|
||||
*/
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
TILE_CAPABLE_WIDGET_IDS,
|
||||
type TileCapableWidgetId,
|
||||
} from "@/components/dashboard/dashboard-gates";
|
||||
import {
|
||||
TILE_DESTINATION,
|
||||
dashboardTileHref,
|
||||
tileWidgetId,
|
||||
} from "@/components/dashboard/tile-destinations";
|
||||
import { subPageSlugForType } from "@/lib/insights/sub-page-metric";
|
||||
import { surfaceModule } from "@/lib/modules/surface";
|
||||
|
||||
const ROOT = path.resolve(__dirname, "../../../..");
|
||||
|
||||
/**
|
||||
* The series each tile paints. Kept here rather than in production code
|
||||
* because only this guard needs it; a tile id missing from this record
|
||||
* fails the totality test below, so a new tile cannot skip the question.
|
||||
*/
|
||||
const TILE_TYPES: Record<TileCapableWidgetId, readonly string[]> = {
|
||||
weight: ["WEIGHT"],
|
||||
bp: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
||||
pulse: ["PULSE"],
|
||||
bodyFat: ["BODY_FAT"],
|
||||
mood: ["MOOD"],
|
||||
sleep: ["SLEEP_DURATION"],
|
||||
steps: ["ACTIVITY_STEPS"],
|
||||
glucose: ["BLOOD_GLUCOSE"],
|
||||
bpInTarget: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
||||
vo2Max: ["VO2_MAX"],
|
||||
hrv: ["HEART_RATE_VARIABILITY", "HRV_RMSSD"],
|
||||
oxygenSaturation: ["OXYGEN_SATURATION"],
|
||||
respiratoryRate: ["RESPIRATORY_RATE"],
|
||||
wristTemperature: ["WRIST_TEMPERATURE"],
|
||||
muscleMass: ["MUSCLE_MASS"],
|
||||
totalBodyWater: ["TOTAL_BODY_WATER"],
|
||||
boneMass: ["BONE_MASS"],
|
||||
waterIntake: ["NUTRIENT_WATER"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Tiles whose series is not a `MeasurementType` with a sub-page, but which
|
||||
* still lead to a page that shows it. Each entry names its reason.
|
||||
*/
|
||||
const NON_MEASUREMENT_DESTINATION: Partial<
|
||||
Record<TileCapableWidgetId, { slug: string; reason: string }>
|
||||
> = {
|
||||
waterIntake: {
|
||||
slug: "nutrients",
|
||||
reason:
|
||||
"fluid intake is a NutrientIntakeDay total, charted on the nutrients page",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Tiles that deliberately lead nowhere, with the reason. Empty today: every
|
||||
* tile has a page. An entry here must be a tile whose destination is null.
|
||||
*/
|
||||
const NO_DESTINATION: Partial<Record<TileCapableWidgetId, string>> = {};
|
||||
|
||||
/** Tile ids the dashboard pushes into its strip, read from the render code. */
|
||||
function renderedTileIds(): string[] {
|
||||
const source = readFileSync(
|
||||
path.join(ROOT, "src/app/page-client.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const ids: string[] = [];
|
||||
const pattern =
|
||||
/trendCards\.push\(\{\s*id:\s*(?:"([^"]+)"|`([^`$]+)\$\{[^`]*`)/g;
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
ids.push(match[1] ?? `${match[2]}*`);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe("dashboard tile destinations", () => {
|
||||
it("reads a non-trivial tile set from the registry and the render code", () => {
|
||||
expect(TILE_CAPABLE_WIDGET_IDS.length).toBeGreaterThanOrEqual(18);
|
||||
expect(renderedTileIds().length).toBeGreaterThanOrEqual(19);
|
||||
});
|
||||
|
||||
it("knows every tile the dashboard renders, and renders every known tile", () => {
|
||||
const rendered = renderedTileIds();
|
||||
const widgets = new Set<string>();
|
||||
for (const id of rendered) {
|
||||
const sample = id.endsWith("*") ? `${id.slice(0, -1)}X` : id;
|
||||
const widget = tileWidgetId(sample);
|
||||
expect(widget, `rendered tile "${id}" has no widget`).not.toBeNull();
|
||||
widgets.add(widget as string);
|
||||
}
|
||||
expect([...widgets].sort()).toEqual([...TILE_CAPABLE_WIDGET_IDS].sort());
|
||||
});
|
||||
|
||||
it("routes the dashboard's tile links through this map and no other", () => {
|
||||
const source = readFileSync(
|
||||
path.join(ROOT, "src/app/page-client.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toMatch(/dashboardTileHref\(entry\.id,/);
|
||||
expect(source).not.toMatch(/"\/insights\/[a-z-]+"/);
|
||||
});
|
||||
|
||||
it("declares the series of every tile", () => {
|
||||
expect(Object.keys(TILE_TYPES).sort()).toEqual(
|
||||
[...TILE_CAPABLE_WIDGET_IDS].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([...TILE_CAPABLE_WIDGET_IDS])(
|
||||
"%s opens the page of the metric it shows",
|
||||
(widget) => {
|
||||
const destination = TILE_DESTINATION[widget];
|
||||
const pages = new Set(
|
||||
TILE_TYPES[widget]
|
||||
.map((type) => subPageSlugForType(type))
|
||||
.filter((slug): slug is NonNullable<typeof slug> => slug != null),
|
||||
);
|
||||
if (pages.size > 0) {
|
||||
expect(
|
||||
destination,
|
||||
`${widget} shows ${TILE_TYPES[widget].join(", ")}, which has a page, but links nowhere`,
|
||||
).not.toBeNull();
|
||||
expect(pages.has(destination!)).toBe(true);
|
||||
return;
|
||||
}
|
||||
const nonMeasurement = NON_MEASUREMENT_DESTINATION[widget];
|
||||
if (nonMeasurement) {
|
||||
expect(destination).toBe(nonMeasurement.slug);
|
||||
expect(nonMeasurement.reason.length).toBeGreaterThan(10);
|
||||
return;
|
||||
}
|
||||
expect(destination).toBeNull();
|
||||
expect(
|
||||
NO_DESTINATION[widget],
|
||||
`${widget} has no destination and no written reason`,
|
||||
).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
it("allowlists only tiles that really lead nowhere", () => {
|
||||
for (const widget of Object.keys(NO_DESTINATION) as TileCapableWidgetId[]) {
|
||||
expect(TILE_DESTINATION[widget]).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("links only to sub-pages that exist as routes", () => {
|
||||
const slugs = Object.values(TILE_DESTINATION).filter(
|
||||
(slug): slug is NonNullable<typeof slug> => slug !== null,
|
||||
);
|
||||
expect(slugs.length).toBeGreaterThanOrEqual(18);
|
||||
for (const slug of slugs) {
|
||||
expect(
|
||||
existsSync(path.join(ROOT, "src/app/insights", slug, "page.tsx")),
|
||||
`/insights/${slug} has no page`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("never links a tile to a page owned by a different module", () => {
|
||||
for (const widget of TILE_CAPABLE_WIDGET_IDS) {
|
||||
const slug = TILE_DESTINATION[widget];
|
||||
if (slug === null) continue;
|
||||
const pageOwner = surfaceModule(`insights-page:${slug}`);
|
||||
if (pageOwner === undefined) continue;
|
||||
expect(
|
||||
surfaceModule(`widget:${widget}`),
|
||||
`${widget} links to /insights/${slug}, owned by ${pageOwner}`,
|
||||
).toBe(pageOwner);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the rendered tile ids to their hrefs", () => {
|
||||
expect(dashboardTileHref("bodyFat", undefined)).toBe("/insights/body-fat");
|
||||
expect(dashboardTileHref("bp-sys", undefined)).toBe(
|
||||
"/insights/blood-pressure",
|
||||
);
|
||||
expect(dashboardTileHref("bp-dia", null)).toBe("/insights/blood-pressure");
|
||||
expect(dashboardTileHref("glucose-FASTING", {})).toBe(
|
||||
"/insights/blood-glucose",
|
||||
);
|
||||
expect(dashboardTileHref("not-a-tile", undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the link when the destination's module is switched off", () => {
|
||||
expect(dashboardTileHref("mood", { mood: false })).toBeNull();
|
||||
expect(dashboardTileHref("glucose-FASTING", { glucose: false })).toBeNull();
|
||||
expect(dashboardTileHref("waterIntake", { nutrients: false })).toBeNull();
|
||||
expect(dashboardTileHref("bodyFat", { mood: false })).toBe(
|
||||
"/insights/body-fat",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
GLUCOSE_CONTEXT_BUCKETS,
|
||||
type GlucoseContextBucket,
|
||||
} from "@/lib/glucose";
|
||||
import type { DashboardLayout } from "@/lib/dashboard-layout";
|
||||
import type {
|
||||
DashboardLayout,
|
||||
DashboardWidgetId,
|
||||
} from "@/lib/dashboard-layout";
|
||||
|
||||
/**
|
||||
* v1.7.0 — first-paint gate for the dashboard tile strip.
|
||||
@@ -49,7 +52,7 @@ export function resolveDashboardFirstPaintGate(input: {
|
||||
* path at all — counting any of them over-reserved the loading
|
||||
* silhouette and made the strip reshuffle when the data landed.
|
||||
*/
|
||||
const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
|
||||
export const TILE_CAPABLE_WIDGET_IDS = [
|
||||
"weight",
|
||||
"bp",
|
||||
"pulse",
|
||||
@@ -72,7 +75,13 @@ const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
|
||||
// v1.29 — fluid intake strip tile: paints one strip card and self-gates
|
||||
// on having a NUTRIENT_WATER sample.
|
||||
"waterIntake",
|
||||
]);
|
||||
] as const satisfies readonly DashboardWidgetId[];
|
||||
|
||||
export type TileCapableWidgetId = (typeof TILE_CAPABLE_WIDGET_IDS)[number];
|
||||
|
||||
const TILE_CAPABLE_WIDGET_ID_SET: ReadonlySet<string> = new Set(
|
||||
TILE_CAPABLE_WIDGET_IDS,
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.16.8 — silhouette count for the tile-strip skeleton: one card per
|
||||
@@ -85,7 +94,7 @@ const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
|
||||
export function resolveConfiguredTileCount(layout: DashboardLayout): number {
|
||||
let count = 0;
|
||||
for (const widget of layout.widgets) {
|
||||
if (!TILE_CAPABLE_WIDGET_IDS.has(widget.id)) continue;
|
||||
if (!TILE_CAPABLE_WIDGET_ID_SET.has(widget.id)) continue;
|
||||
if (!(widget.tileVisible ?? widget.visible)) continue;
|
||||
count += widget.id === "bp" ? 2 : 1;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { Activity, Pill, Plus, Waves, Wrench } from "lucide-react";
|
||||
import { Activity, Footprints, Pill, Plus, Waves, Wrench } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -26,6 +26,10 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { hourInTz } from "@/lib/tz/format";
|
||||
import {
|
||||
CAPTURE_KIND_ORDER,
|
||||
visibleCaptureKinds,
|
||||
} from "@/components/layout/capture-picker";
|
||||
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
|
||||
|
||||
export function DashboardHeader({
|
||||
@@ -36,19 +40,30 @@ export function DashboardHeader({
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const mounted = useMounted();
|
||||
// The quick-add offers three kinds, and each one is asked about its own
|
||||
// The quick-add offers four kinds, and each one is asked about its own
|
||||
// section: a reading under `measurements`, a dose under `medications`, a
|
||||
// mood entry under `mind`. The coarse `canAdd` this used to gate the whole
|
||||
// mood entry under `mind`, a workout in one's own record only. The coarse `canAdd` this used to gate the whole
|
||||
// menu on carries the grant's LEVEL and no scope, so it answered true for a
|
||||
// grant that opens neither of the two sections the menu never asked about.
|
||||
// The customize shortcut points at a settings page sharing does not cover at
|
||||
// all.
|
||||
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const canAddMeasurement = canWriteDomain("measurements");
|
||||
const canAddIntake = canWriteDomain("medications");
|
||||
// A mood entry is created at MANAGE under the mind section.
|
||||
const canAddMood = canWriteDomain("mind");
|
||||
const canAddAnything = canAddMeasurement || canAddIntake || canAddMood;
|
||||
const capabilities = useRecordCapabilities();
|
||||
const { inSharedRecord } = capabilities;
|
||||
// The same answer the bottom bar's capture picker renders from: per kind,
|
||||
// the record's section (a delegated grant) AND the kind's module
|
||||
// (`capture:<kind>`). Asking the picker's own function is what keeps the two
|
||||
// menus from disagreeing — this one used to check the section alone, so a
|
||||
// switched-off mood module still offered "Log mood" here.
|
||||
const offered = visibleCaptureKinds(
|
||||
capabilities,
|
||||
CAPTURE_KIND_ORDER,
|
||||
user?.modules,
|
||||
);
|
||||
const canAddMeasurement = offered.includes("measurement");
|
||||
const canAddIntake = offered.includes("medication");
|
||||
const canAddMood = offered.includes("mood");
|
||||
const canAddWorkout = offered.includes("workout");
|
||||
const canAddAnything = offered.length > 0;
|
||||
|
||||
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
|
||||
// from the auth query, which can resolve before this boundary
|
||||
@@ -183,6 +198,12 @@ export function DashboardHeader({
|
||||
{t("dashboard.quickAddMedicationIntake")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canAddWorkout && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("workout")}>
|
||||
<Footprints className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddWorkout")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
|
||||
import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useRecordCapabilities,
|
||||
@@ -32,15 +33,20 @@ import {
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
export type QuickEntryDialog =
|
||||
"measurement" | "mood" | "medicationIntake" | null;
|
||||
"measurement" | "mood" | "medicationIntake" | "workout" | null;
|
||||
|
||||
/** The section each sheet writes to; see the capture picker. */
|
||||
/**
|
||||
* The section each sheet writes to; see the capture picker. `null` marks a
|
||||
* sheet whose route resolves the caller rather than the record, which no
|
||||
* grant reaches: a workout goes through the batch ingest, own record only.
|
||||
*/
|
||||
const QUICK_ENTRY_DOMAIN: Readonly<
|
||||
Record<NonNullable<QuickEntryDialog>, ShareDomain>
|
||||
Record<NonNullable<QuickEntryDialog>, ShareDomain | null>
|
||||
> = {
|
||||
measurement: "measurements",
|
||||
medicationIntake: "medications",
|
||||
mood: "mind",
|
||||
workout: null,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -59,10 +65,13 @@ const QUICK_ENTRY_DOMAIN: Readonly<
|
||||
*/
|
||||
export function admittedQuickEntry(
|
||||
open: QuickEntryDialog,
|
||||
caps: Pick<RecordCapabilities, "canWriteDomain">,
|
||||
caps: Pick<RecordCapabilities, "canWriteDomain" | "inSharedRecord">,
|
||||
): QuickEntryDialog {
|
||||
if (open === null) return open;
|
||||
return caps.canWriteDomain(QUICK_ENTRY_DOMAIN[open]) ? open : null;
|
||||
const domain = QUICK_ENTRY_DOMAIN[open];
|
||||
const admitted =
|
||||
domain === null ? !caps.inSharedRecord : caps.canWriteDomain(domain);
|
||||
return admitted ? open : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,6 +160,9 @@ export function QuickEntrySheets({
|
||||
// mobile soft keyboard.
|
||||
const [medicationIntakeFooterEl, setMedicationIntakeFooterEl] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
const [workoutFooterEl, setWorkoutFooterEl] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
// v1.11.3 F3 — when an open quick-entry sheet is dismissed with
|
||||
// unsaved input, hold the close in this flag and surface a confirm
|
||||
// instead of nulling the dialog outright. Cleared once the user
|
||||
@@ -214,6 +226,24 @@ export function QuickEntrySheets({
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
|
||||
{/* Log a workout by hand. Mounted only while open, so every opening
|
||||
is a fresh form with its own external id. */}
|
||||
<ResponsiveSheet
|
||||
open={openSheet === "workout"}
|
||||
onOpenChange={handleQuickEntryOpenChange}
|
||||
title={t("insights.workouts.manual.sheetTitle")}
|
||||
description={t("insights.workouts.manual.sheetDescription")}
|
||||
footer={<div ref={setWorkoutFooterEl} className="flex w-full" />}
|
||||
>
|
||||
{openSheet === "workout" && (
|
||||
<ManualWorkoutForm
|
||||
onSuccess={onClose}
|
||||
onCancel={onClose}
|
||||
footerSlot={workoutFooterEl}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
|
||||
{/* v1.11.3 F3 — confirm before discarding a partly-filled
|
||||
quick-entry form when the sheet is dismissed by an overlay
|
||||
tap, Escape, or a mobile swipe-down. Keeps the sheet open
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Where each dashboard strip tile leads.
|
||||
*
|
||||
* A tile shows one metric, and every metric with an Insights sub-page should
|
||||
* open it on click. The map is keyed by the tile-capable widget id and typed
|
||||
* against the sub-page slug union, so a destination that does not exist is a
|
||||
* compile error and a tile added to `TILE_CAPABLE_WIDGET_IDS` without a
|
||||
* decision here is one too. `null` means the tile deliberately leads nowhere;
|
||||
* the guard in `__tests__/tile-destinations.test.ts` demands a written reason
|
||||
* for every such entry and fails when a tile's metric has a sub-page the tile
|
||||
* does not open. It exists because the body fat tile was the one tile that
|
||||
* led nowhere, documented as having "no dedicated sub-page" while every
|
||||
* body-composition sibling had one.
|
||||
*
|
||||
* A link never points at a page the account cannot see: the destination
|
||||
* passes the same `insights-page:<slug>` surface gate the tab strip uses.
|
||||
*/
|
||||
import {
|
||||
INSIGHTS_OVERVIEW_PATH,
|
||||
type SubPageSlug,
|
||||
} from "@/lib/insights/sub-page-metric";
|
||||
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
|
||||
|
||||
import {
|
||||
TILE_CAPABLE_WIDGET_IDS,
|
||||
type TileCapableWidgetId,
|
||||
} from "./dashboard-gates";
|
||||
|
||||
export const TILE_DESTINATION: Readonly<
|
||||
Record<TileCapableWidgetId, SubPageSlug | null>
|
||||
> = {
|
||||
weight: "weight",
|
||||
bp: "blood-pressure",
|
||||
pulse: "pulse",
|
||||
bodyFat: "body-fat",
|
||||
mood: "mood",
|
||||
sleep: "sleep",
|
||||
steps: "steps",
|
||||
glucose: "blood-glucose",
|
||||
bpInTarget: "blood-pressure",
|
||||
vo2Max: "cardio-fitness",
|
||||
hrv: "hrv",
|
||||
oxygenSaturation: "oxygen",
|
||||
respiratoryRate: "respiratory-rate",
|
||||
wristTemperature: "wrist-temperature",
|
||||
muscleMass: "muscle-mass",
|
||||
totalBodyWater: "body-water",
|
||||
boneMass: "bone-mass",
|
||||
// Fluid intake lives on the nutrients page (hydration hero + quick-add),
|
||||
// not on a Measurement-backed detail page.
|
||||
waterIntake: "nutrients",
|
||||
};
|
||||
|
||||
const TILE_WIDGET_IDS: ReadonlySet<string> = new Set(TILE_CAPABLE_WIDGET_IDS);
|
||||
|
||||
/**
|
||||
* The widget a rendered tile belongs to. Blood pressure paints two tiles
|
||||
* (`bp-sys`, `bp-dia`) and glucose one per logged context (`glucose-<ctx>`);
|
||||
* every other tile carries its widget id verbatim.
|
||||
*/
|
||||
export function tileWidgetId(tileId: string): TileCapableWidgetId | null {
|
||||
if (tileId === "bp-sys" || tileId === "bp-dia") return "bp";
|
||||
if (tileId.startsWith("glucose-")) return "glucose";
|
||||
return TILE_WIDGET_IDS.has(tileId) ? (tileId as TileCapableWidgetId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The href a rendered tile links to, or null when it has no destination or
|
||||
* the destination page is hidden by a switched-off module.
|
||||
*/
|
||||
export function dashboardTileHref(
|
||||
tileId: string,
|
||||
modules: SurfaceModuleMap | null | undefined,
|
||||
): string | null {
|
||||
const widget = tileWidgetId(tileId);
|
||||
if (widget === null) return null;
|
||||
const slug = TILE_DESTINATION[widget];
|
||||
if (slug === null) return null;
|
||||
if (!isSurfaceVisible(`insights-page:${slug}`, modules)) return null;
|
||||
return `${INSIGHTS_OVERVIEW_PATH}/${slug}`;
|
||||
}
|
||||
@@ -233,6 +233,7 @@ export const SUB_PAGE_TABS: Record<
|
||||
// ── body composition ──
|
||||
weight: { labelKey: "insights.navWeight", metric: "WEIGHT" },
|
||||
bmi: { labelKey: "insights.navBmi", metric: "BMI" },
|
||||
"body-fat": { labelKey: "insights.navBodyFat", metric: "BODY_FAT" },
|
||||
"body-water": {
|
||||
labelKey: "insights.navTotalBodyWater",
|
||||
metric: "TOTAL_BODY_WATER",
|
||||
|
||||
@@ -151,6 +151,13 @@ export interface SubPageShellProps {
|
||||
* at the page foot.
|
||||
*/
|
||||
showAllValuesType?: string;
|
||||
/**
|
||||
* A page-owned action that opens something in place rather than linking
|
||||
* away (the workouts page's "Log workout"). Leads the header cluster, in
|
||||
* the same 40 px ghost-icon shape as its siblings; the page owns the
|
||||
* control and whatever it opens.
|
||||
*/
|
||||
headerAction?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -171,6 +178,7 @@ export function SubPageShell({
|
||||
coachLaunch = false,
|
||||
captureType,
|
||||
showAllValuesType,
|
||||
headerAction,
|
||||
children,
|
||||
}: SubPageShellProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -309,6 +317,7 @@ export function SubPageShell({
|
||||
with a tighter gap the later sibling's invisible halo sat
|
||||
on top of its neighbour's clickable edge. */}
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{headerAction}
|
||||
{captureHref ? (
|
||||
<Button
|
||||
asChild
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Activity, Pill, Waves } from "lucide-react";
|
||||
import { Activity, Footprints, Pill, Waves } from "lucide-react";
|
||||
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
|
||||
import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -30,11 +31,12 @@ import {
|
||||
/**
|
||||
* The center "Log" capture action (iOS parity — the bottom bar's
|
||||
* middle slot is a capture CTA, not a destination). It opens a small
|
||||
* picker that routes to three existing quick-entry surfaces:
|
||||
* picker that routes to four existing quick-entry surfaces:
|
||||
*
|
||||
* - Measurement → `<MeasurementForm>` (the `/measurements` add form)
|
||||
* - Medication → `<MedicationIntakeQuickAdd>` (dashboard quick-add)
|
||||
* - Mood → `<MoodForm>` (the `/mood` add form, 5-face flow)
|
||||
* - Workout → `<ManualWorkoutForm>` (the workouts page's "Log workout")
|
||||
*
|
||||
* The picker reuses each existing capture component rather than
|
||||
* rebuilding parallel forms.
|
||||
@@ -50,7 +52,7 @@ import {
|
||||
* instead of closing unconditionally.
|
||||
*/
|
||||
|
||||
type CaptureKind = "measurement" | "medication" | "mood";
|
||||
type CaptureKind = "measurement" | "medication" | "mood" | "workout";
|
||||
|
||||
/**
|
||||
* The section each capture surface writes to.
|
||||
@@ -66,10 +68,13 @@ type CaptureKind = "measurement" | "medication" | "mood";
|
||||
* so asking it per section answers both cases and the mood entry's
|
||||
* MANAGE-only create as well.
|
||||
*/
|
||||
const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain>> = {
|
||||
const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain | null>> = {
|
||||
measurement: "measurements",
|
||||
medication: "medications",
|
||||
mood: "mind",
|
||||
// A workout goes through the batch ingest, which resolves the caller:
|
||||
// no grant reaches it, so it is offered in one's own record only.
|
||||
workout: null,
|
||||
};
|
||||
|
||||
/** The order the chooser lists them in. */
|
||||
@@ -77,6 +82,7 @@ export const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
|
||||
"measurement",
|
||||
"medication",
|
||||
"mood",
|
||||
"workout",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -118,15 +124,16 @@ export function admittedCaptureKind(
|
||||
* kind the record allows, the gate's default-on contract.
|
||||
*/
|
||||
export function visibleCaptureKinds(
|
||||
caps: Pick<RecordCapabilities, "canWriteDomain">,
|
||||
caps: Pick<RecordCapabilities, "canWriteDomain" | "inSharedRecord">,
|
||||
kinds: ReadonlyArray<CaptureKind>,
|
||||
modules?: SurfaceModuleMap | null,
|
||||
): CaptureKind[] {
|
||||
return kinds.filter(
|
||||
(kind) =>
|
||||
caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind]) &&
|
||||
isSurfaceVisible(`capture:${kind}`, modules),
|
||||
);
|
||||
return kinds.filter((kind) => {
|
||||
const domain = CAPTURE_KIND_DOMAIN[kind];
|
||||
const writable =
|
||||
domain === null ? !caps.inSharedRecord : caps.canWriteDomain(domain);
|
||||
return writable && isSurfaceVisible(`capture:${kind}`, modules);
|
||||
});
|
||||
}
|
||||
|
||||
interface CapturePickerProps {
|
||||
@@ -203,6 +210,12 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
description: t("nav.capture.moodDescription"),
|
||||
icon: Waves,
|
||||
},
|
||||
{
|
||||
kind: "workout",
|
||||
label: t("nav.capture.workout"),
|
||||
description: t("nav.capture.workoutDescription"),
|
||||
icon: Footprints,
|
||||
},
|
||||
];
|
||||
const options = allOptions.filter((opt) => offered.includes(opt.kind));
|
||||
|
||||
@@ -210,6 +223,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
measurement: t("measurements.addMeasurement"),
|
||||
medication: t("nav.capture.medication"),
|
||||
mood: t("mood.addEntry"),
|
||||
workout: t("insights.workouts.manual.sheetTitle"),
|
||||
};
|
||||
// `openKind === null` keeps the form sheet closed (the title is unread
|
||||
// then); the mood label is the harmless default, matching the prior
|
||||
@@ -272,6 +286,13 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
)}
|
||||
{openKind === "workout" && (
|
||||
<ManualWorkoutForm
|
||||
onSuccess={closeForm}
|
||||
onCancel={closeForm}
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
)}
|
||||
{openKind === "mood" && (
|
||||
<MoodForm
|
||||
onSuccess={closeForm}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* `<ManualWorkoutForm>` and its save path, and the detail page's delete.
|
||||
*
|
||||
* SSR-only, like the other capture-form suites (vitest runs in `node`, no
|
||||
* testing-library). The rules themselves live in `manual-entry.ts` and are
|
||||
* pinned in its own suite; this one pins the wire and the markup:
|
||||
*
|
||||
* 1. the save posts ONE entry to `POST /api/workouts/batch`, MANUAL, with
|
||||
* the form's external id, and refreshes the workout reads;
|
||||
* 2. a `duplicate` (a second tap) is a success, a `skipped` entry is not;
|
||||
* 3. the form renders its five fields, the distance in the reader's unit,
|
||||
* and a layout that stacks distance / energy on a phone;
|
||||
* 4. delete is offered for a hand-entered workout in one's own record only,
|
||||
* and calls `DELETE /api/workouts/{id}`.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const authRef: { unitPreference: "metric" | "imperial" } = {
|
||||
unitPreference: "metric",
|
||||
};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
username: "tester",
|
||||
timezone: "Europe/Berlin",
|
||||
unitPreference: authRef.unitPreference,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
ManualWorkoutForm,
|
||||
saveManualWorkout,
|
||||
sportOptions,
|
||||
} from "../manual-workout-form";
|
||||
import {
|
||||
canDeleteWorkout,
|
||||
deleteManualWorkout,
|
||||
} from "../delete-workout-button";
|
||||
import type { ManualWorkoutEntry } from "@/lib/workouts/manual-entry";
|
||||
|
||||
const ENTRY: ManualWorkoutEntry = {
|
||||
sportType: "cycling",
|
||||
startedAt: "2026-09-15T06:00:00.000Z",
|
||||
endedAt: "2026-09-15T07:30:00.000Z",
|
||||
source: "MANUAL",
|
||||
externalId: "manual:5b0f3c1e-1111-4222-8333-444455556666",
|
||||
totalDistanceM: 32000,
|
||||
};
|
||||
|
||||
function envelope(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify({ data, error: null }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function fakeClient() {
|
||||
return {
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
removeQueries: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function render(node: React.ReactElement) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
authRef.unitPreference = "metric";
|
||||
fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("saveManualWorkout — the wire", () => {
|
||||
it("posts one MANUAL entry with the form's external id to the batch route", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
envelope({ entries: [{ index: 0, status: "inserted" }] }),
|
||||
);
|
||||
const client = fakeClient();
|
||||
|
||||
const outcome = await saveManualWorkout(
|
||||
ENTRY,
|
||||
client as unknown as QueryClient,
|
||||
);
|
||||
|
||||
expect(outcome).toBe("inserted");
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(String(url)).toBe("/api/workouts/batch");
|
||||
expect((init as RequestInit).method).toBe("POST");
|
||||
const body = JSON.parse(String((init as RequestInit).body));
|
||||
expect(body).toEqual({ workouts: [ENTRY] });
|
||||
expect(body.workouts[0].source).toBe("MANUAL");
|
||||
expect(body.workouts[0].externalId).toBe(ENTRY.externalId);
|
||||
});
|
||||
|
||||
it("refreshes every workout read, and the daily reads", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
envelope({ entries: [{ index: 0, status: "inserted" }] }),
|
||||
);
|
||||
const client = fakeClient();
|
||||
|
||||
await saveManualWorkout(ENTRY, client as unknown as QueryClient);
|
||||
|
||||
const keys = client.invalidateQueries.mock.calls.map(([arg]) =>
|
||||
JSON.stringify((arg as { queryKey: unknown }).queryKey),
|
||||
);
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.workouts()));
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.dailyDigest()));
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.dashboardSnapshot()));
|
||||
});
|
||||
|
||||
it("treats a duplicate (the same form submitted twice) as saved", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
envelope({ entries: [{ index: 0, status: "duplicate" }] }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
saveManualWorkout(ENTRY, fakeClient() as unknown as QueryClient),
|
||||
).resolves.toBe("duplicate");
|
||||
});
|
||||
|
||||
it("throws when the route skipped the entry, and refreshes nothing", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
envelope({ entries: [{ index: 0, status: "skipped" }] }),
|
||||
);
|
||||
const client = fakeClient();
|
||||
|
||||
await expect(
|
||||
saveManualWorkout(ENTRY, client as unknown as QueryClient),
|
||||
).rejects.toThrow();
|
||||
expect(client.invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sportOptions", () => {
|
||||
it("lists every sport by label, with Other last", () => {
|
||||
const options = sportOptions((sport) => sport.toUpperCase());
|
||||
expect(options).toHaveLength(21);
|
||||
expect(options.at(-1)).toEqual({ value: "other", label: "OTHER" });
|
||||
const labels = options.slice(0, -1).map((o) => o.label);
|
||||
expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ManualWorkoutForm> — markup", () => {
|
||||
it("renders the five fields and nothing else", () => {
|
||||
const html = render(<ManualWorkoutForm />);
|
||||
expect(html).toContain('data-testid="manual-workout-sport"');
|
||||
expect(html).toContain('data-testid="manual-workout-start"');
|
||||
expect(html).toContain('data-testid="manual-workout-hours"');
|
||||
expect(html).toContain('data-testid="manual-workout-minutes"');
|
||||
expect(html).toContain('data-testid="manual-workout-distance"');
|
||||
expect(html).toContain('data-testid="manual-workout-energy"');
|
||||
expect(html).toContain("Activity");
|
||||
expect(html).toContain("Duration");
|
||||
expect(html).toContain("Active energy (kcal)");
|
||||
// No heart rate, no sets, no route: a record, not a training log.
|
||||
expect(html).not.toMatch(/heart|bpm|sets|reps/i);
|
||||
});
|
||||
|
||||
it("names the distance in kilometres for a metric reader", () => {
|
||||
expect(render(<ManualWorkoutForm />)).toContain("Distance (km)");
|
||||
});
|
||||
|
||||
it("names the distance in miles for an imperial reader", () => {
|
||||
authRef.unitPreference = "imperial";
|
||||
expect(render(<ManualWorkoutForm />)).toContain("Distance (mi)");
|
||||
});
|
||||
|
||||
it("keeps hours and minutes side by side and stacks distance and energy on a phone", () => {
|
||||
const html = render(<ManualWorkoutForm />);
|
||||
// Two short numeric fields fit a 390 px sheet side by side.
|
||||
expect(html).toMatch(/class="grid grid-cols-2 gap-4"/);
|
||||
// Distance and energy carry longer labels: one column below `sm`, two
|
||||
// from 640 px up.
|
||||
expect(html).toMatch(/class="grid gap-4 sm:grid-cols-2"/);
|
||||
// The action buttons keep the 44 px touch floor on a phone.
|
||||
expect(html).toContain("min-h-11 sm:min-h-9");
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete — a hand-entered workout in one's own record only", () => {
|
||||
it("is offered for a MANUAL workout in one's own record", () => {
|
||||
expect(
|
||||
canDeleteWorkout({ source: "MANUAL" }, { inSharedRecord: false }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"APPLE_HEALTH",
|
||||
"WITHINGS",
|
||||
"WHOOP",
|
||||
"FITBIT",
|
||||
"STRAVA",
|
||||
"EXTERNAL",
|
||||
])("is not offered for a %s workout", (source) => {
|
||||
expect(canDeleteWorkout({ source }, { inSharedRecord: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("is not offered inside somebody else's record", () => {
|
||||
expect(
|
||||
canDeleteWorkout({ source: "MANUAL" }, { inSharedRecord: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("calls DELETE on the workout and drops its detail from the cache", async () => {
|
||||
fetchSpy.mockResolvedValue(envelope({ deleted: true }));
|
||||
const client = fakeClient();
|
||||
|
||||
await deleteManualWorkout("w-1", client as unknown as QueryClient);
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(String(url)).toBe("/api/workouts/w-1");
|
||||
expect((init as RequestInit).method).toBe("DELETE");
|
||||
expect(client.removeQueries).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.workoutDetail("w-1"),
|
||||
});
|
||||
expect(client.invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.workouts(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { apiDelete } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Whether a workout can be deleted from its detail page: one entered by hand,
|
||||
* in one's own record. A synced workout would come back with the next sync,
|
||||
* and `DELETE /api/workouts/{id}` refuses it (409); the route also resolves
|
||||
* the caller, so a delegate is never offered it.
|
||||
*/
|
||||
export function canDeleteWorkout(
|
||||
workout: { source: string },
|
||||
caps: { inSharedRecord: boolean },
|
||||
): boolean {
|
||||
return workout.source === "MANUAL" && !caps.inSharedRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete, then drop the deleted detail from the cache and refresh the rest of
|
||||
* the workout reads (list, dashboard tile) and the two daily reads the route
|
||||
* evicted server-side. The detail is removed rather than invalidated, so
|
||||
* nothing refetches a row that no longer exists.
|
||||
*/
|
||||
export async function deleteManualWorkout(
|
||||
id: string,
|
||||
queryClient: QueryClient,
|
||||
): Promise<void> {
|
||||
await apiDelete(`/api/workouts/${encodeURIComponent(id)}`);
|
||||
queryClient.removeQueries({ queryKey: queryKeys.workoutDetail(id) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.workouts() });
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail page's delete control: a ghost icon in the page header cluster
|
||||
* that asks before it deletes. Renders nothing for a workout it may not
|
||||
* delete.
|
||||
*/
|
||||
export function DeleteWorkoutButton({
|
||||
workout,
|
||||
}: {
|
||||
workout: { id: string; source: string };
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const capabilities = useRecordCapabilities();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
if (!canDeleteWorkout(workout, capabilities)) return null;
|
||||
|
||||
async function handleDelete(e: React.MouseEvent) {
|
||||
// Keep the dialog open while the request runs; it closes on success.
|
||||
e.preventDefault();
|
||||
if (deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteManualWorkout(workout.id, queryClient);
|
||||
toastWrittenOutcome("success", t("insights.workouts.manual.deleted"));
|
||||
setOpen(false);
|
||||
router.replace("/insights/workouts");
|
||||
} catch {
|
||||
toastWrittenOutcome("failed", t("insights.workouts.manual.deleteError"));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="workout-delete"
|
||||
aria-label={t("insights.workouts.manual.deleteLabel")}
|
||||
title={t("insights.workouts.manual.deleteLabel")}
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-destructive relative size-10",
|
||||
"before:absolute before:-inset-1.5 before:content-['']",
|
||||
)}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("insights.workouts.manual.deleteConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("insights.workouts.manual.deleteConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
data-testid="workout-delete-confirm"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { FieldError } from "@/components/forms/field-error";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { getQuantityTransform } from "@/lib/measurements/display-transform";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import {
|
||||
buildManualWorkoutEntry,
|
||||
emptyManualWorkoutDraft,
|
||||
newManualWorkoutExternalId,
|
||||
wallClockNow,
|
||||
type ManualWorkoutDraft,
|
||||
type ManualWorkoutEntry,
|
||||
type ManualWorkoutField,
|
||||
} from "@/lib/workouts/manual-entry";
|
||||
import {
|
||||
workoutSportTypeEnum,
|
||||
type WorkoutSportType,
|
||||
} from "@/lib/validations/workout";
|
||||
|
||||
/**
|
||||
* Log a workout by hand: sport, start, duration, and optionally distance and
|
||||
* active energy. Nothing else on purpose; see `manual-entry.ts`.
|
||||
*
|
||||
* Same contract as the other capture forms (`MoodForm`,
|
||||
* `MedicationIntakeQuickAdd`): mounted inside a `ResponsiveSheet`, the action
|
||||
* row portalled into the sheet's sticky footer through `footerSlot`, and
|
||||
* `onSuccess` / `onCancel` close the sheet.
|
||||
*/
|
||||
|
||||
interface ManualWorkoutFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
footerSlot?: HTMLElement | null;
|
||||
}
|
||||
|
||||
interface BatchResult {
|
||||
entries?: Array<{ status: string }>;
|
||||
}
|
||||
|
||||
export type ManualWorkoutSaveOutcome = "inserted" | "duplicate";
|
||||
|
||||
/**
|
||||
* Post the entry and refresh what shows workouts. A `duplicate` is a success:
|
||||
* the same form already landed this session (a second tap, a retry), so the
|
||||
* row exists exactly once. A `skipped` entry was refused and throws.
|
||||
*/
|
||||
export async function saveManualWorkout(
|
||||
entry: ManualWorkoutEntry,
|
||||
queryClient: QueryClient,
|
||||
): Promise<ManualWorkoutSaveOutcome> {
|
||||
const result = await apiPost<BatchResult>("/api/workouts/batch", {
|
||||
workouts: [entry],
|
||||
});
|
||||
const status = result?.entries?.[0]?.status;
|
||||
if (status !== "inserted" && status !== "duplicate") {
|
||||
throw new Error(`workout entry not stored: ${status ?? "unknown"}`);
|
||||
}
|
||||
// The list, the dashboard tile and every detail read sit under this
|
||||
// prefix. The form also opens from the capture picker on any page, where
|
||||
// the Today hero (which names a workout that just landed) and the dashboard
|
||||
// snapshot are unmounted, so those two are refetched outright.
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.workouts() });
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
return status;
|
||||
}
|
||||
|
||||
/** The sports, by their label in the reader's language, "Other" last. */
|
||||
export function sportOptions(
|
||||
label: (sport: WorkoutSportType) => string,
|
||||
): Array<{ value: WorkoutSportType; label: string }> {
|
||||
const named = workoutSportTypeEnum.options
|
||||
.filter((sport) => sport !== "other")
|
||||
.map((value): { value: WorkoutSportType; label: string } => ({
|
||||
value,
|
||||
label: label(value),
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return [...named, { value: "other", label: label("other") }];
|
||||
}
|
||||
|
||||
export function ManualWorkoutForm({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
footerSlot,
|
||||
}: ManualWorkoutFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const { preference } = useUnitDisplay();
|
||||
const timezone = user?.timezone || DEFAULT_TIMEZONE;
|
||||
|
||||
// Minted once per opened form and sent with every submit, so a second tap
|
||||
// or a retried request lands as a duplicate rather than a second row.
|
||||
const [externalId] = useState(newManualWorkoutExternalId);
|
||||
const [draft, setDraft] = useState<ManualWorkoutDraft>(() =>
|
||||
emptyManualWorkoutDraft(new Date(), timezone),
|
||||
);
|
||||
const [errors, setErrors] = useState<
|
||||
Partial<Record<ManualWorkoutField, string>>
|
||||
>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const formId = useId();
|
||||
const ids = {
|
||||
sport: `${formId}-sport`,
|
||||
start: `${formId}-start`,
|
||||
hours: `${formId}-hours`,
|
||||
minutes: `${formId}-minutes`,
|
||||
distance: `${formId}-distance`,
|
||||
energy: `${formId}-energy`,
|
||||
durationError: `${formId}-duration-error`,
|
||||
saveError: `${formId}-save-error`,
|
||||
};
|
||||
|
||||
const distanceUnit = getQuantityTransform("distance", preference).displayUnit;
|
||||
const options = useMemo(
|
||||
() => sportOptions((sport) => t(`insights.workouts.sport.${sport}`)),
|
||||
[t],
|
||||
);
|
||||
|
||||
function update<K extends keyof ManualWorkoutDraft>(
|
||||
key: K,
|
||||
value: ManualWorkoutDraft[K],
|
||||
) {
|
||||
setDraft((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (saving) return;
|
||||
setSaveError(null);
|
||||
const built = buildManualWorkoutEntry(draft, {
|
||||
timezone,
|
||||
unitPreference: preference,
|
||||
externalId,
|
||||
now: new Date(),
|
||||
});
|
||||
if (!built.ok) {
|
||||
setErrors(built.errors);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
setSaving(true);
|
||||
try {
|
||||
const outcome = await saveManualWorkout(built.entry, queryClient);
|
||||
// A duplicate wrote nothing new: the same form already landed this
|
||||
// session. That is reported as such, not as a fresh save.
|
||||
if (outcome === "duplicate") {
|
||||
toastWrittenOutcome(
|
||||
"empty",
|
||||
t("insights.workouts.manual.alreadySaved"),
|
||||
);
|
||||
} else {
|
||||
toastWrittenOutcome("success", t("common.saved"));
|
||||
}
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setSaveError(
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("insights.workouts.manual.saveError"),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const fieldError = (field: ManualWorkoutField) =>
|
||||
errors[field] ? t(errors[field]) : undefined;
|
||||
const describedBy = (field: ManualWorkoutField, id: string) =>
|
||||
errors[field] ? id : undefined;
|
||||
|
||||
const footerNode = (
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={saving}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-testid="manual-workout-save"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Plus className="size-4" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
className="space-y-4"
|
||||
data-testid="manual-workout-form"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={ids.sport}>
|
||||
{t("insights.workouts.manual.sportLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.sportType}
|
||||
onValueChange={(value) =>
|
||||
update("sportType", value as WorkoutSportType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={ids.sport}
|
||||
className="w-full"
|
||||
aria-invalid={!!errors.sportType || undefined}
|
||||
aria-describedby={describedBy("sportType", `${ids.sport}-error`)}
|
||||
data-testid="manual-workout-sport"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t("insights.workouts.manual.sportPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError
|
||||
id={`${ids.sport}-error`}
|
||||
message={fieldError("sportType")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={ids.start}>
|
||||
{t("insights.workouts.manual.startLabel")}
|
||||
</Label>
|
||||
<DateTimeField
|
||||
id={ids.start}
|
||||
value={draft.start}
|
||||
onChange={(value) => update("start", value)}
|
||||
max={wallClockNow(new Date(), timezone)}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.start || undefined}
|
||||
aria-describedby={describedBy("start", `${ids.start}-error`)}
|
||||
data-testid="manual-workout-start"
|
||||
/>
|
||||
<FieldError id={`${ids.start}-error`} message={fieldError("start")} />
|
||||
</div>
|
||||
|
||||
<fieldset
|
||||
className="space-y-2"
|
||||
aria-describedby={describedBy("duration", ids.durationError)}
|
||||
>
|
||||
<legend className="mb-2 text-sm leading-none font-medium">
|
||||
{t("insights.workouts.manual.durationLabel")}
|
||||
</legend>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor={ids.hours}
|
||||
className="text-muted-foreground text-xs font-normal"
|
||||
>
|
||||
{t("common.hours")}
|
||||
</Label>
|
||||
<Input
|
||||
id={ids.hours}
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={draft.hours}
|
||||
onChange={(e) => update("hours", e.target.value)}
|
||||
aria-invalid={!!errors.duration || undefined}
|
||||
data-testid="manual-workout-hours"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor={ids.minutes}
|
||||
className="text-muted-foreground text-xs font-normal"
|
||||
>
|
||||
{t("common.minutes")}
|
||||
</Label>
|
||||
<Input
|
||||
id={ids.minutes}
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={draft.minutes}
|
||||
onChange={(e) => update("minutes", e.target.value)}
|
||||
aria-invalid={!!errors.duration || undefined}
|
||||
data-testid="manual-workout-minutes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FieldError id={ids.durationError} message={fieldError("duration")} />
|
||||
</fieldset>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={ids.distance}>
|
||||
{t("insights.workouts.manual.distanceLabel", {
|
||||
unit: distanceUnit,
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id={ids.distance}
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
placeholder={t("common.optional")}
|
||||
value={draft.distance}
|
||||
onChange={(e) => update("distance", e.target.value)}
|
||||
aria-invalid={!!errors.distance || undefined}
|
||||
aria-describedby={describedBy("distance", `${ids.distance}-error`)}
|
||||
data-testid="manual-workout-distance"
|
||||
/>
|
||||
<FieldError
|
||||
id={`${ids.distance}-error`}
|
||||
message={fieldError("distance")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={ids.energy}>
|
||||
{t("insights.workouts.manual.energyLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={ids.energy}
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
placeholder={t("common.optional")}
|
||||
value={draft.energyKcal}
|
||||
onChange={(e) => update("energyKcal", e.target.value)}
|
||||
aria-invalid={!!errors.energyKcal || undefined}
|
||||
aria-describedby={describedBy("energyKcal", `${ids.energy}-error`)}
|
||||
data-testid="manual-workout-energy"
|
||||
/>
|
||||
<FieldError
|
||||
id={`${ids.energy}-error`}
|
||||
message={fieldError("energyKcal")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div
|
||||
id={ids.saveError}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footerSlot ? createPortal(footerNode, footerSlot) : footerNode}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
|
||||
import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* The "Log workout" sheet for a surface that owns its own open state (the
|
||||
* workouts page header). The dashboard quick-add and the capture picker mount
|
||||
* `ManualWorkoutForm` inside their own sheets, which already carry the same
|
||||
* confirm-before-discard guard; this one carries it too, so a half-filled
|
||||
* form is never dropped by a stray swipe.
|
||||
*/
|
||||
export function ManualWorkoutSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (next) return onOpenChange(true);
|
||||
if (sheetBodyHasUnsavedInput()) {
|
||||
setConfirmDiscardOpen(true);
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title={t("insights.workouts.manual.sheetTitle")}
|
||||
description={t("insights.workouts.manual.sheetDescription")}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
{open && (
|
||||
<ManualWorkoutForm
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
<AlertDialog
|
||||
open={confirmDiscardOpen}
|
||||
onOpenChange={setConfirmDiscardOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("dashboard.quickEntryDiscard.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("dashboard.quickEntryDiscard.description")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("dashboard.quickEntryDiscard.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setConfirmDiscardOpen(false);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{t("dashboard.quickEntryDiscard.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -34,10 +34,12 @@ describe("type → insights sub-page slug", () => {
|
||||
expect(subPageSlugForType("ACTIVITY_STEPS")).toBe("steps");
|
||||
expect(subPageSlugForType("MOOD")).toBe("mood");
|
||||
expect(subPageSlugForType("BLOOD_GLUCOSE")).toBe("blood-glucose");
|
||||
// Body fat gained its own page so the dashboard tile has somewhere to go;
|
||||
// it used to fall back to the filtered measurements list.
|
||||
expect(subPageSlugForType("BODY_FAT")).toBe("body-fat");
|
||||
});
|
||||
|
||||
it("returns undefined for a type with no sub-page", () => {
|
||||
expect(subPageSlugForType("BODY_FAT")).toBeUndefined();
|
||||
expect(subPageSlugForType("RECOVERY_SCORE")).toBeUndefined();
|
||||
expect(subPageSlugForType("NOT_A_REAL_TYPE")).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -82,6 +82,9 @@ export type InsightMetric =
|
||||
| "BLOOD_GLUCOSE"
|
||||
| "TOTAL_BODY_WATER"
|
||||
| "BONE_MASS"
|
||||
// Body fat percentage, keyed on its MeasurementType like the rest of the
|
||||
// body-composition pages.
|
||||
| "BODY_FAT"
|
||||
| "FLIGHTS_CLIMBED"
|
||||
| "WALKING_RUNNING_DISTANCE"
|
||||
| "FAT_FREE_MASS"
|
||||
|
||||
@@ -66,6 +66,10 @@ const SUB_PAGE_METRIC = {
|
||||
weight: ["WEIGHT"],
|
||||
// BMI is derived from WEIGHT + profile height (no separate series).
|
||||
bmi: ["WEIGHT"],
|
||||
// Body fat percentage. The dashboard has tiled and charted it since the
|
||||
// first release, but it never had a page of its own, so its tile led
|
||||
// nowhere while every sibling below opened one.
|
||||
"body-fat": ["BODY_FAT"],
|
||||
// v1.7.0 — Withings / Apple body-composition tail.
|
||||
"body-water": ["TOTAL_BODY_WATER"],
|
||||
"bone-mass": ["BONE_MASS"],
|
||||
@@ -199,6 +203,7 @@ export const SUB_PAGE_GROUP: Partial<Record<SubPageSlug, SubPageGroup>> = {
|
||||
"respiratory-rate": "vitals",
|
||||
// body composition (v1.7.0)
|
||||
bmi: "body",
|
||||
"body-fat": "body",
|
||||
"body-water": "body",
|
||||
"bone-mass": "body",
|
||||
"fat-free-mass": "body",
|
||||
@@ -387,7 +392,7 @@ export const SUB_PAGE_MANAGER_GROUP_SLUGS: Record<
|
||||
* `WEIGHT`) the first in `SUB_PAGE_SLUGS` order wins (`weight`). A type that
|
||||
* only ever appears on a multi-metric page (the two blood-pressure
|
||||
* components, the two HRV flavours) falls back to that page. Types with no
|
||||
* sub-page at all (BODY_FAT, RECOVERY_SCORE, the `*_EVENT` series, most
|
||||
* sub-page at all (RECOVERY_SCORE, the `*_EVENT` series, most
|
||||
* device scores) are absent — the caller falls back to the filtered
|
||||
* measurements list.
|
||||
*/
|
||||
|
||||
@@ -255,6 +255,7 @@ describe("every surface that shows a module's data is owned by it", () => {
|
||||
["overview:cycle-ring", "cycle"],
|
||||
["capture:mood", "mood"],
|
||||
["capture:medication", "medications"],
|
||||
["capture:workout", "workouts"],
|
||||
["trend:mood", "mood"],
|
||||
["widget:medications", "medications"],
|
||||
["correlation:MEDICATION_COMPLIANCE", "medications"],
|
||||
|
||||
@@ -123,6 +123,7 @@ const STATIC_SURFACE_MODULE = {
|
||||
// ── Add menu ──
|
||||
"capture:mood": "mood",
|
||||
"capture:medication": "medications",
|
||||
"capture:workout": "workouts",
|
||||
|
||||
// ── Dashboard widgets ──
|
||||
// CORE widgets (weight, blood pressure, pulse, body fat, the vital-derived
|
||||
|
||||
@@ -357,6 +357,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"workout.batch.payload_too_large",
|
||||
"workout.batch.source_not_permitted",
|
||||
"workout.batch.too_large",
|
||||
"workout.delete.synced_source",
|
||||
],
|
||||
"(unprefixed)": [
|
||||
"about_me_conflict",
|
||||
|
||||
@@ -312,6 +312,36 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Measurements"],
|
||||
summary: "Delete a workout entered by hand",
|
||||
description:
|
||||
"Removes a `MANUAL` workout outright, with its child rows, and any personal record that workout set; a silent detection pass then re-derives the previous best. A workout from any other source answers 409 (`workout.delete.synced_source`), because the next sync would write it back. Own record only: a cookie session or a wildcard token. Another user's row answers 404.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Deleted.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.literal(true) }),
|
||||
"DeleteWorkoutResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Workout not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The workout was synced from a device or service, not entered by hand.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/workouts/batch": {
|
||||
post: {
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildManualWorkoutEntry,
|
||||
canLogWorkout,
|
||||
emptyManualWorkoutDraft,
|
||||
newManualWorkoutExternalId,
|
||||
wallClockNow,
|
||||
type ManualWorkoutContext,
|
||||
type ManualWorkoutDraft,
|
||||
} from "@/lib/workouts/manual-entry";
|
||||
import { createBatchWorkoutSchema } from "@/lib/validations/workout";
|
||||
|
||||
const NOW = new Date("2026-09-15T12:00:00Z");
|
||||
const ID = "manual:5b0f3c1e-1111-4222-8333-444455556666";
|
||||
|
||||
const CTX: ManualWorkoutContext = {
|
||||
timezone: "Europe/Berlin",
|
||||
unitPreference: "metric",
|
||||
externalId: ID,
|
||||
now: NOW,
|
||||
};
|
||||
|
||||
function draft(over: Partial<ManualWorkoutDraft> = {}): ManualWorkoutDraft {
|
||||
return {
|
||||
sportType: "running",
|
||||
// 08:00 in Berlin (UTC+2 in September) = 06:00Z.
|
||||
start: "2026-09-15T08:00",
|
||||
hours: "0",
|
||||
minutes: "45",
|
||||
distance: "",
|
||||
energyKcal: "",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function errorsOf(over: Partial<ManualWorkoutDraft>, ctx = CTX) {
|
||||
const result = buildManualWorkoutEntry(draft(over), ctx);
|
||||
if (result.ok) throw new Error("expected the draft to be refused");
|
||||
return result.errors;
|
||||
}
|
||||
|
||||
describe("buildManualWorkoutEntry — the payload", () => {
|
||||
it("builds exactly the batch entry: MANUAL, the form's external id, start + duration", () => {
|
||||
const result = buildManualWorkoutEntry(draft(), CTX);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
entry: {
|
||||
sportType: "running",
|
||||
startedAt: "2026-09-15T06:00:00.000Z",
|
||||
endedAt: "2026-09-15T06:45:00.000Z",
|
||||
source: "MANUAL",
|
||||
externalId: ID,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the same external id on every submit of one form", () => {
|
||||
const a = buildManualWorkoutEntry(draft(), CTX);
|
||||
const b = buildManualWorkoutEntry(draft(), { ...CTX, now: new Date() });
|
||||
expect(a.ok && b.ok).toBe(true);
|
||||
if (a.ok && b.ok) expect(a.entry.externalId).toBe(b.entry.externalId);
|
||||
});
|
||||
|
||||
it("is accepted by the batch route's own schema", () => {
|
||||
const result = buildManualWorkoutEntry(
|
||||
draft({ distance: "10,5", energyKcal: "640" }),
|
||||
CTX,
|
||||
);
|
||||
if (!result.ok) throw new Error("expected ok");
|
||||
const parsed = createBatchWorkoutSchema.safeParse({
|
||||
workouts: [result.entry],
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) expect(parsed.data.workouts[0]!.source).toBe("MANUAL");
|
||||
});
|
||||
|
||||
it("reads the start in the profile zone, not the device's", () => {
|
||||
const result = buildManualWorkoutEntry(
|
||||
draft({ start: "2026-09-15T05:00" }),
|
||||
{
|
||||
...CTX,
|
||||
timezone: "America/New_York",
|
||||
},
|
||||
);
|
||||
// 05:00 in New York (UTC-4 in September) = 09:00Z.
|
||||
expect(result.ok && result.entry.startedAt).toBe(
|
||||
"2026-09-15T09:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("stores a metric distance in metres and energy as typed", () => {
|
||||
const result = buildManualWorkoutEntry(
|
||||
draft({ distance: "5,25", energyKcal: "312.5" }),
|
||||
CTX,
|
||||
);
|
||||
expect(result.ok && result.entry.totalDistanceM).toBe(5250);
|
||||
expect(result.ok && result.entry.totalEnergyKcal).toBe(312.5);
|
||||
});
|
||||
|
||||
it("converts an imperial distance from miles to metres", () => {
|
||||
const result = buildManualWorkoutEntry(draft({ distance: "3.1" }), {
|
||||
...CTX,
|
||||
unitPreference: "imperial",
|
||||
});
|
||||
// 3.1 mi = 4988.97 m, stored to a tenth.
|
||||
expect(result.ok && result.entry.totalDistanceM).toBe(4989);
|
||||
});
|
||||
|
||||
it("leaves distance and energy out when they were not entered", () => {
|
||||
const result = buildManualWorkoutEntry(draft(), CTX);
|
||||
expect(result.ok && "totalDistanceM" in result.entry).toBe(false);
|
||||
expect(result.ok && "totalEnergyKcal" in result.entry).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a session of exactly 24 hours", () => {
|
||||
const result = buildManualWorkoutEntry(
|
||||
draft({ start: "2026-09-14T08:00", hours: "24", minutes: "" }),
|
||||
CTX,
|
||||
);
|
||||
expect(result.ok && result.entry.endedAt).toBe("2026-09-15T06:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildManualWorkoutEntry — validation", () => {
|
||||
it("asks for a sport", () => {
|
||||
expect(errorsOf({ sportType: "" }).sportType).toBe(
|
||||
"insights.workouts.manual.errors.sportRequired",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a start in the future", () => {
|
||||
expect(errorsOf({ start: "2026-09-15T15:00" }).start).toBe(
|
||||
"insights.workouts.manual.errors.startInFuture",
|
||||
);
|
||||
});
|
||||
|
||||
it("tolerates the minute the form was opened in", () => {
|
||||
// 14:00:30 Berlin is 30 s past `now`: inside the slack.
|
||||
const result = buildManualWorkoutEntry(
|
||||
draft({ start: "2026-09-15T14:00" }),
|
||||
{
|
||||
...CTX,
|
||||
now: new Date("2026-09-15T11:59:30Z"),
|
||||
},
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("asks for a start", () => {
|
||||
expect(errorsOf({ start: "" }).start).toBe(
|
||||
"insights.workouts.manual.errors.startRequired",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a zero duration", () => {
|
||||
expect(errorsOf({ hours: "", minutes: "" }).duration).toBe(
|
||||
"insights.workouts.manual.errors.durationRequired",
|
||||
);
|
||||
expect(errorsOf({ hours: "0", minutes: "0" }).duration).toBe(
|
||||
"insights.workouts.manual.errors.durationRequired",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses more than 24 hours", () => {
|
||||
expect(errorsOf({ hours: "24", minutes: "1" }).duration).toBe(
|
||||
"insights.workouts.manual.errors.durationTooLong",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses minutes past 59 and anything that is not a whole number", () => {
|
||||
for (const over of [
|
||||
{ minutes: "60" },
|
||||
{ minutes: "-5" },
|
||||
{ hours: "1.5" },
|
||||
{ hours: "abc" },
|
||||
]) {
|
||||
expect(errorsOf(over).duration, JSON.stringify(over)).toBe(
|
||||
"insights.workouts.manual.errors.durationInvalid",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a negative, garbled or impossible distance", () => {
|
||||
for (const distance of ["-1", "5km", "1000.1"]) {
|
||||
expect(errorsOf({ distance }).distance, distance).toBe(
|
||||
"insights.workouts.manual.errors.distanceInvalid",
|
||||
);
|
||||
}
|
||||
// The ceiling is in metres, so it binds in miles too.
|
||||
expect(
|
||||
errorsOf({ distance: "622" }, { ...CTX, unitPreference: "imperial" })
|
||||
.distance,
|
||||
).toBe("insights.workouts.manual.errors.distanceInvalid");
|
||||
});
|
||||
|
||||
it("accepts a zero distance", () => {
|
||||
const result = buildManualWorkoutEntry(draft({ distance: "0" }), CTX);
|
||||
expect(result.ok && result.entry.totalDistanceM).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses negative or implausible energy", () => {
|
||||
for (const energyKcal of ["-10", "20001", "lots"]) {
|
||||
expect(errorsOf({ energyKcal }).energyKcal, energyKcal).toBe(
|
||||
"insights.workouts.manual.errors.energyInvalid",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports every refused field at once", () => {
|
||||
const errors = errorsOf({ sportType: "", minutes: "", hours: "" });
|
||||
expect(Object.keys(errors).sort()).toEqual(["duration", "sportType"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the form's defaults", () => {
|
||||
it("opens on the current minute in the profile zone", () => {
|
||||
expect(wallClockNow(NOW, "Europe/Berlin")).toBe("2026-09-15T14:00");
|
||||
expect(emptyManualWorkoutDraft(NOW, "Asia/Seoul")).toMatchObject({
|
||||
sportType: "",
|
||||
start: "2026-09-15T21:00",
|
||||
hours: "",
|
||||
minutes: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("mints a distinct, stable-shaped external id per form", () => {
|
||||
const a = newManualWorkoutExternalId();
|
||||
const b = newManualWorkoutExternalId();
|
||||
expect(a).toMatch(/^manual:[0-9a-f-]{36}$/);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canLogWorkout — who is offered the entry", () => {
|
||||
const OWN = { inSharedRecord: false };
|
||||
const SHARED = { inSharedRecord: true };
|
||||
|
||||
it("offers it in one's own record with the workouts module on", () => {
|
||||
expect(canLogWorkout(OWN, { workouts: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("offers it while the module map is still loading", () => {
|
||||
expect(canLogWorkout(OWN, undefined)).toBe(true);
|
||||
expect(canLogWorkout(OWN, null)).toBe(true);
|
||||
});
|
||||
|
||||
it("withholds it when the workouts module is off", () => {
|
||||
expect(canLogWorkout(OWN, { workouts: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not follow any other module", () => {
|
||||
expect(canLogWorkout(OWN, { mood: false, recovery: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("withholds it inside somebody else's record", () => {
|
||||
expect(canLogWorkout(SHARED, { workouts: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* A workout entered by hand: the form's draft, its validation, and the one
|
||||
* batch entry it becomes.
|
||||
*
|
||||
* HealthLog records workouts; it does not plan them. So the hand-entered
|
||||
* shape is the session as a health record sees it and nothing more: what,
|
||||
* when, how long, and optionally how far and how much energy. No sets, no
|
||||
* exercises, no heart rate typed in from memory.
|
||||
*
|
||||
* The entry is written through `POST /api/workouts/batch`, the same route the
|
||||
* phone syncs through, as `source: "MANUAL"` with an `externalId` minted once
|
||||
* per opened form. That id is what makes a double submit (a second tap, a
|
||||
* retried request) a `duplicate` instead of a second row: the route's
|
||||
* `(userId, source, externalId)` key catches it.
|
||||
*
|
||||
* Pure, no React and no fetch, so the rules are pinned without a browser.
|
||||
* Error values are i18n keys; the form translates them.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
getQuantityTransform,
|
||||
invertDisplayTransform,
|
||||
type UnitPreference,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
import {
|
||||
workoutSportTypeEnum,
|
||||
type WorkoutSportType,
|
||||
} from "@/lib/validations/workout";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
|
||||
|
||||
/** One session is at most a day long. */
|
||||
export const MANUAL_WORKOUT_MAX_DURATION_SEC = 24 * 60 * 60;
|
||||
/** The batch schema's own ceiling: 1000 km. */
|
||||
export const MANUAL_WORKOUT_MAX_DISTANCE_M = 1_000_000;
|
||||
/** A day of hard endurance work stays well under this. */
|
||||
export const MANUAL_WORKOUT_MAX_ENERGY_KCAL = 20_000;
|
||||
/**
|
||||
* Slack on "not in the future". The form's default start is the minute the
|
||||
* sheet opened, and the clock keeps moving while the person types.
|
||||
*/
|
||||
const FUTURE_TOLERANCE_MS = 60_000;
|
||||
|
||||
export interface ManualWorkoutDraft {
|
||||
/** Empty until the person picks one. */
|
||||
sportType: WorkoutSportType | "";
|
||||
/** Wall clock in the profile timezone, `yyyy-MM-ddTHH:mm`. */
|
||||
start: string;
|
||||
hours: string;
|
||||
minutes: string;
|
||||
/** In the reader's unit (km or mi). Empty means not recorded. */
|
||||
distance: string;
|
||||
/** Kilocalories. Empty means not recorded. */
|
||||
energyKcal: string;
|
||||
}
|
||||
|
||||
export type ManualWorkoutField =
|
||||
"sportType" | "start" | "duration" | "distance" | "energyKcal";
|
||||
|
||||
/** The one entry of the `POST /api/workouts/batch` body this form sends. */
|
||||
export interface ManualWorkoutEntry {
|
||||
sportType: WorkoutSportType;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
source: "MANUAL";
|
||||
externalId: string;
|
||||
totalDistanceM?: number;
|
||||
totalEnergyKcal?: number;
|
||||
}
|
||||
|
||||
export interface ManualWorkoutContext {
|
||||
/** The profile timezone the start is read in. */
|
||||
timezone: string;
|
||||
unitPreference: UnitPreference;
|
||||
/** The id this form instance sends with every submit. */
|
||||
externalId: string;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export type ManualWorkoutResult =
|
||||
| { ok: true; entry: ManualWorkoutEntry }
|
||||
| { ok: false; errors: Partial<Record<ManualWorkoutField, string>> };
|
||||
|
||||
const ERR = "insights.workouts.manual.errors";
|
||||
|
||||
/**
|
||||
* Whether "Log workout" is offered: in one's own record, with the workouts
|
||||
* module on.
|
||||
*
|
||||
* Own record only because the route behind it is: the batch ingest resolves
|
||||
* the caller (`requireAuth`), and a delegate acting on somebody else's record
|
||||
* would be refused, so no section of a grant can admit it. The module half is
|
||||
* the `capture:workout` surface; an absent module map (the account still
|
||||
* loading) offers it, the gate's default-on contract.
|
||||
*
|
||||
* The workouts page asks this; the dashboard add menu and the capture picker
|
||||
* ask `visibleCaptureKinds`, which applies the same two terms to the
|
||||
* `workout` kind.
|
||||
*/
|
||||
export function canLogWorkout(
|
||||
caps: { inSharedRecord: boolean },
|
||||
modules?: SurfaceModuleMap | null,
|
||||
): boolean {
|
||||
return !caps.inSharedRecord && isSurfaceVisible("capture:workout", modules);
|
||||
}
|
||||
|
||||
/** A fresh id for one opened form. The prefix says where the row came from. */
|
||||
export function newManualWorkoutExternalId(): string {
|
||||
return `manual:${randomId()}`;
|
||||
}
|
||||
|
||||
/** `now` as the wall clock the start field shows, `yyyy-MM-ddTHH:mm`. */
|
||||
export function wallClockNow(now: Date, timezone: string): string {
|
||||
const p = wallClockInTz(now, timezone);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}`;
|
||||
}
|
||||
|
||||
export function emptyManualWorkoutDraft(
|
||||
now: Date,
|
||||
timezone: string,
|
||||
): ManualWorkoutDraft {
|
||||
return {
|
||||
sportType: "",
|
||||
start: wallClockNow(now, timezone),
|
||||
hours: "",
|
||||
minutes: "",
|
||||
distance: "",
|
||||
energyKcal: "",
|
||||
};
|
||||
}
|
||||
|
||||
const WALL_CLOCK = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/;
|
||||
|
||||
function startToUtc(start: string, timezone: string): Date | null {
|
||||
const m = WALL_CLOCK.exec(start);
|
||||
if (!m) return null;
|
||||
const [year, month, day, hour, minute] = m.slice(1).map(Number) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
|
||||
if (hour > 23 || minute > 59) return null;
|
||||
const at = zonedWallClockToUtc({ year, month, day, hour, minute }, timezone);
|
||||
return Number.isNaN(at.getTime()) ? null : at;
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed number, or `null` for an empty field, or `NaN` for garbage.
|
||||
* Accepts a decimal comma, which is what half the locales type.
|
||||
*/
|
||||
function parseDecimal(raw: string): number | null {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return null;
|
||||
if (!/^\d+([.,]\d+)?$/.test(trimmed)) return Number.NaN;
|
||||
return Number(trimmed.replace(",", "."));
|
||||
}
|
||||
|
||||
function parseWhole(raw: string): number | null {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return null;
|
||||
if (!/^\d+$/.test(trimmed)) return Number.NaN;
|
||||
return Number(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* The draft, checked. Built per call because "not in the future" and the
|
||||
* unit conversion depend on the context.
|
||||
*/
|
||||
function manualWorkoutSchema(ctx: ManualWorkoutContext) {
|
||||
return z
|
||||
.object({
|
||||
sportType: z.string(),
|
||||
start: z.string(),
|
||||
hours: z.string(),
|
||||
minutes: z.string(),
|
||||
distance: z.string(),
|
||||
energyKcal: z.string(),
|
||||
})
|
||||
.superRefine((d, issue) => {
|
||||
if (!workoutSportTypeEnum.safeParse(d.sportType).success) {
|
||||
issue.addIssue({
|
||||
code: "custom",
|
||||
path: ["sportType"],
|
||||
message: `${ERR}.sportRequired`,
|
||||
});
|
||||
}
|
||||
|
||||
const startedAt = startToUtc(d.start, ctx.timezone);
|
||||
if (startedAt === null) {
|
||||
issue.addIssue({
|
||||
code: "custom",
|
||||
path: ["start"],
|
||||
message: `${ERR}.startRequired`,
|
||||
});
|
||||
} else if (
|
||||
startedAt.getTime() >
|
||||
ctx.now.getTime() + FUTURE_TOLERANCE_MS
|
||||
) {
|
||||
issue.addIssue({
|
||||
code: "custom",
|
||||
path: ["start"],
|
||||
message: `${ERR}.startInFuture`,
|
||||
});
|
||||
}
|
||||
|
||||
const hours = parseWhole(d.hours);
|
||||
const minutes = parseWhole(d.minutes);
|
||||
if (
|
||||
Number.isNaN(hours) ||
|
||||
Number.isNaN(minutes) ||
|
||||
(minutes !== null && minutes > 59)
|
||||
) {
|
||||
issue.addIssue({
|
||||
code: "custom",
|
||||
path: ["duration"],
|
||||
message: `${ERR}.durationInvalid`,
|
||||
});
|
||||
} else {
|
||||
const sec = ((hours ?? 0) * 60 + (minutes ?? 0)) * 60;
|
||||
if (sec <= 0) {
|
||||
issue.addIssue({
|
||||
code: "custom",
|
||||
path: ["duration"],
|
||||
message: `${ERR}.durationRequired`,
|
||||
});
|
||||
} else if (sec > MANUAL_WORKOUT_MAX_DURATION_SEC) {
|
||||
issue.addIssue({
|
||||
code: "custom",
|
||||
path: ["duration"],
|
||||
message: `${ERR}.durationTooLong`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const distance = parseDecimal(d.distance);
|
||||
if (
|
||||
distance !== null &&
|
||||
(Number.isNaN(distance) ||
|
||||
toMetres(distance, ctx.unitPreference) >
|
||||
MANUAL_WORKOUT_MAX_DISTANCE_M)
|
||||
) {
|
||||
issue.addIssue({
|
||||
code: "custom",
|
||||
path: ["distance"],
|
||||
message: `${ERR}.distanceInvalid`,
|
||||
});
|
||||
}
|
||||
|
||||
const energy = parseDecimal(d.energyKcal);
|
||||
if (
|
||||
energy !== null &&
|
||||
(Number.isNaN(energy) || energy > MANUAL_WORKOUT_MAX_ENERGY_KCAL)
|
||||
) {
|
||||
issue.addIssue({
|
||||
code: "custom",
|
||||
path: ["energyKcal"],
|
||||
message: `${ERR}.energyInvalid`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** A distance in the reader's unit, in metres to a tenth. */
|
||||
function toMetres(display: number, preference: UnitPreference): number {
|
||||
const metres = invertDisplayTransform(
|
||||
display,
|
||||
getQuantityTransform("distance", preference),
|
||||
);
|
||||
return Math.round(metres * 10) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the draft and build the batch entry from it, field by field.
|
||||
* Returns the first error per field when it does not validate.
|
||||
*/
|
||||
export function buildManualWorkoutEntry(
|
||||
draft: ManualWorkoutDraft,
|
||||
ctx: ManualWorkoutContext,
|
||||
): ManualWorkoutResult {
|
||||
const parsed = manualWorkoutSchema(ctx).safeParse(draft);
|
||||
if (!parsed.success) {
|
||||
const errors: Partial<Record<ManualWorkoutField, string>> = {};
|
||||
for (const issue of parsed.error.issues) {
|
||||
const field = issue.path[0] as ManualWorkoutField;
|
||||
errors[field] ??= issue.message;
|
||||
}
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
// Every branch below was proven by the schema above.
|
||||
const startedAt = startToUtc(draft.start, ctx.timezone)!;
|
||||
const durationSec =
|
||||
((parseWhole(draft.hours) ?? 0) * 60 + (parseWhole(draft.minutes) ?? 0)) *
|
||||
60;
|
||||
const distance = parseDecimal(draft.distance);
|
||||
const energy = parseDecimal(draft.energyKcal);
|
||||
|
||||
const entry: ManualWorkoutEntry = {
|
||||
sportType: draft.sportType as WorkoutSportType,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: new Date(startedAt.getTime() + durationSec * 1000).toISOString(),
|
||||
source: "MANUAL",
|
||||
externalId: ctx.externalId,
|
||||
};
|
||||
if (distance !== null) {
|
||||
entry.totalDistanceM = toMetres(distance, ctx.unitPreference);
|
||||
}
|
||||
if (energy !== null) entry.totalEnergyKcal = energy;
|
||||
return { ok: true, entry };
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* A workout entered by hand, end to end against a real Postgres.
|
||||
*
|
||||
* The web form builds its entry with `buildManualWorkoutEntry` and posts it
|
||||
* to `POST /api/workouts/batch`; this suite drives that exact entry through
|
||||
* the real route and reads it back through `GET /api/workouts`, then deletes
|
||||
* it through `DELETE /api/workouts/{id}`. Pins:
|
||||
*
|
||||
* - the entry lands as `MANUAL`, with the start, duration and metres the
|
||||
* form computed, and the list serves it;
|
||||
* - a second submit of the same form is a `duplicate`, not a second row;
|
||||
* - delete removes a hand-entered workout and refuses a synced one.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const TEST_USER_ID = "user-workout-manual-entry";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { POST: postBatch } = await import("@/app/api/workouts/batch/route");
|
||||
const { GET: listWorkouts } = await import("@/app/api/workouts/route");
|
||||
const { DELETE: deleteWorkout } = await import("@/app/api/workouts/[id]/route");
|
||||
const { buildManualWorkoutEntry, newManualWorkoutExternalId } =
|
||||
await import("@/lib/workouts/manual-entry");
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "workout-manual",
|
||||
email: "workout-manual@example.test",
|
||||
},
|
||||
});
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
function post(body: unknown): Promise<Response> {
|
||||
return postBatch(
|
||||
new NextRequest("http://localhost/api/workouts/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function list(): Promise<Response> {
|
||||
return listWorkouts(new NextRequest("http://localhost/api/workouts"));
|
||||
}
|
||||
|
||||
function del(id: string): Promise<Response> {
|
||||
return deleteWorkout(
|
||||
new NextRequest(`http://localhost/api/workouts/${id}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
}
|
||||
|
||||
/** The entry the form sends for a 45-minute imperial run, yesterday 07:30 Berlin. */
|
||||
function formEntry(externalId: string) {
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const day = yesterday.toISOString().slice(0, 10);
|
||||
const built = buildManualWorkoutEntry(
|
||||
{
|
||||
sportType: "running",
|
||||
start: `${day}T07:30`,
|
||||
hours: "0",
|
||||
minutes: "45",
|
||||
distance: "5",
|
||||
energyKcal: "410",
|
||||
},
|
||||
{
|
||||
timezone: "Europe/Berlin",
|
||||
unitPreference: "imperial",
|
||||
externalId,
|
||||
now: new Date(),
|
||||
},
|
||||
);
|
||||
if (!built.ok) throw new Error(JSON.stringify(built.errors));
|
||||
return built.entry;
|
||||
}
|
||||
|
||||
describe("a workout entered by hand", () => {
|
||||
it("lands through the batch route as MANUAL and is served by the list", async () => {
|
||||
const entry = formEntry(newManualWorkoutExternalId());
|
||||
|
||||
const res = await post({ workouts: [entry] });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.entries).toEqual([{ index: 0, status: "inserted" }]);
|
||||
|
||||
const listRes = await list();
|
||||
expect(listRes.status).toBe(200);
|
||||
const { data } = await listRes.json();
|
||||
expect(data.workouts).toHaveLength(1);
|
||||
const row = data.workouts[0];
|
||||
expect(row).toMatchObject({
|
||||
sportType: "running",
|
||||
startedAt: entry.startedAt,
|
||||
endedAt: entry.endedAt,
|
||||
durationSec: 45 * 60,
|
||||
// 5 mi, stored in metres to a tenth.
|
||||
distanceM: 8046.7,
|
||||
activeEnergyKcal: 410,
|
||||
source: "MANUAL",
|
||||
externalId: entry.externalId,
|
||||
});
|
||||
});
|
||||
|
||||
it("stores a second submit of the same form once", async () => {
|
||||
const entry = formEntry(newManualWorkoutExternalId());
|
||||
|
||||
await post({ workouts: [entry] });
|
||||
const again = await post({ workouts: [entry] });
|
||||
|
||||
expect((await again.json()).data.entries).toEqual([
|
||||
{ index: 0, status: "duplicate" },
|
||||
]);
|
||||
expect(
|
||||
await getPrismaClient().workout.count({
|
||||
where: { userId: TEST_USER_ID },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("can be deleted, and is gone from the list", async () => {
|
||||
await post({ workouts: [formEntry(newManualWorkoutExternalId())] });
|
||||
const stored = await getPrismaClient().workout.findFirstOrThrow({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
|
||||
const res = await del(stored.id);
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data).toEqual({ deleted: true });
|
||||
|
||||
const { data } = await (await list()).json();
|
||||
expect(data.workouts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses to delete a synced workout and leaves it in place", async () => {
|
||||
const synced = await getPrismaClient().workout.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
sportType: "cycling",
|
||||
startedAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
|
||||
endedAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
durationSec: 3600,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "hk-sync-1",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await del(synced.id);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(
|
||||
await getPrismaClient().workout.count({ where: { id: synced.id } }),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user