mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'main' into feat/mood-level-b
This commit is contained in:
commit
b4051e6db3
12 files changed
+73
-57
No files matched your search
@@ -44,6 +44,15 @@
|
||||
meaning: an app that posts only a five-point label still writes a complete
|
||||
entry, because the server derives the pleasantness value from that label.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Marking an older missed dose as taken from the intake history now records
|
||||
the dose at the slot's own date and time. Marking Tuesday 09:00 from a
|
||||
Thursday evening used to land on the right slot but stamp the intake with
|
||||
the current moment; the recorded time now matches the claim being made. The
|
||||
optimistic paint had promised this all along, and the skipped-to-taken flip
|
||||
in the same view already behaved this way.
|
||||
|
||||
## [1.37.1] — 2026-08-08
|
||||
|
||||
### Added
|
||||
|
||||
+3
-5
@@ -891,7 +891,7 @@
|
||||
"quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt",
|
||||
"quickTagsExpand": "Weitere",
|
||||
"loadError": "Stimmungseinträge konnten nicht geladen werden.",
|
||||
"sectionLevelA": "Fünf Skalen",
|
||||
"sectionLevelA": "Gefühlslage",
|
||||
"sectionLevelAHelp": "Optional. Was du nicht angibst, bleibt leer.",
|
||||
"dimensionNotAnswered": "nicht angegeben",
|
||||
"dimensionClear": "leeren",
|
||||
@@ -3246,8 +3246,6 @@
|
||||
"navRecovery": "Erholung",
|
||||
"workouts": {
|
||||
"title": "Workouts",
|
||||
"askWhy": "Warum?",
|
||||
"askWhyPrompt": "Warum ist diese Einheit so ausgefallen? Erkläre mir meine eigenen Werte dazu.",
|
||||
"description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten — quellenübergreifend entdoppelt zwischen Apple Watch und Withings.",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Workouts",
|
||||
@@ -3772,8 +3770,8 @@
|
||||
},
|
||||
"inTargetTitle": "Im Zielbereich",
|
||||
"dimensions": {
|
||||
"title": "Fünf Skalen",
|
||||
"none": "Für diese fünf Skalen ist noch nichts erfasst.",
|
||||
"title": "Gefühlslage",
|
||||
"none": "Zur Gefühlslage ist noch nichts erfasst.",
|
||||
"window": "{days} Tage",
|
||||
"noneInWindow": "nichts im Zeitraum",
|
||||
"higherIsMore": "höher = {anchor}",
|
||||
|
||||
+3
-5
@@ -891,7 +891,7 @@
|
||||
"quickTagsHelp": "Your frequent tags — one tap",
|
||||
"quickTagsExpand": "More",
|
||||
"loadError": "Could not load your mood entries.",
|
||||
"sectionLevelA": "Five scales",
|
||||
"sectionLevelA": "State of mind",
|
||||
"sectionLevelAHelp": "Optional. Anything you leave alone stays empty.",
|
||||
"dimensionNotAnswered": "not recorded",
|
||||
"dimensionClear": "clear",
|
||||
@@ -3246,8 +3246,6 @@
|
||||
"navRecovery": "Recovery",
|
||||
"workouts": {
|
||||
"title": "Workouts",
|
||||
"askWhy": "Ask why",
|
||||
"askWhyPrompt": "Why did this session read the way it did? Walk me through my own numbers from it.",
|
||||
"description": "Recent runs, rides, walks and strength sessions, deduped across Apple Watch and Withings sources.",
|
||||
"emptyState": {
|
||||
"title": "No workouts yet",
|
||||
@@ -3772,8 +3770,8 @@
|
||||
},
|
||||
"inTargetTitle": "In your range",
|
||||
"dimensions": {
|
||||
"title": "Five scales",
|
||||
"none": "Nothing recorded for these five scales yet.",
|
||||
"title": "State of mind",
|
||||
"none": "Nothing recorded on your state of mind yet.",
|
||||
"window": "{days} days",
|
||||
"noneInWindow": "nothing in this range",
|
||||
"higherIsMore": "higher = {anchor}",
|
||||
|
||||
+3
-5
@@ -891,7 +891,7 @@
|
||||
"quickTagsHelp": "Tus etiquetas frecuentes: un toque",
|
||||
"quickTagsExpand": "Más",
|
||||
"loadError": "No se pudieron cargar tus registros de estado de ánimo.",
|
||||
"sectionLevelA": "Cinco escalas",
|
||||
"sectionLevelA": "Estado emocional",
|
||||
"sectionLevelAHelp": "Opcional. Lo que no toques queda vacío.",
|
||||
"dimensionNotAnswered": "sin indicar",
|
||||
"dimensionClear": "borrar",
|
||||
@@ -3246,8 +3246,6 @@
|
||||
"navRecovery": "Recuperación",
|
||||
"workouts": {
|
||||
"title": "Entrenamientos",
|
||||
"askWhy": "Por qué",
|
||||
"askWhyPrompt": "¿Por qué salió así esta sesión? Explícame mis propios datos de ella.",
|
||||
"description": "Carreras, salidas en bici, paseos y sesiones de fuerza recientes, deduplicados entre Apple Watch y Withings.",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay entrenamientos",
|
||||
@@ -3772,8 +3770,8 @@
|
||||
},
|
||||
"inTargetTitle": "En tu rango",
|
||||
"dimensions": {
|
||||
"title": "Cinco escalas",
|
||||
"none": "Todavía no hay nada registrado en estas cinco escalas.",
|
||||
"title": "Estado emocional",
|
||||
"none": "Todavía no hay nada registrado sobre tu estado emocional.",
|
||||
"window": "{days} días",
|
||||
"noneInWindow": "nada en este periodo",
|
||||
"higherIsMore": "más alto = {anchor}",
|
||||
|
||||
+3
-5
@@ -891,7 +891,7 @@
|
||||
"quickTagsHelp": "Vos tags fréquents — en un geste",
|
||||
"quickTagsExpand": "Plus",
|
||||
"loadError": "Impossible de charger vos entrées d'humeur.",
|
||||
"sectionLevelA": "Cinq échelles",
|
||||
"sectionLevelA": "État d'esprit",
|
||||
"sectionLevelAHelp": "Facultatif. Ce que vous ne touchez pas reste vide.",
|
||||
"dimensionNotAnswered": "non renseigné",
|
||||
"dimensionClear": "effacer",
|
||||
@@ -3246,8 +3246,6 @@
|
||||
"navRecovery": "Récupération",
|
||||
"workouts": {
|
||||
"title": "Entraînements",
|
||||
"askWhy": "Pourquoi",
|
||||
"askWhyPrompt": "Pourquoi cette séance s'est-elle passée ainsi ? Explique-moi mes propres chiffres.",
|
||||
"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",
|
||||
@@ -3772,8 +3770,8 @@
|
||||
},
|
||||
"inTargetTitle": "Dans votre plage",
|
||||
"dimensions": {
|
||||
"title": "Cinq échelles",
|
||||
"none": "Rien n'est encore enregistré pour ces cinq échelles.",
|
||||
"title": "État d'esprit",
|
||||
"none": "Rien n'est encore enregistré pour l'état d'esprit.",
|
||||
"window": "{days} jours",
|
||||
"noneInWindow": "rien sur cette période",
|
||||
"higherIsMore": "plus haut = {anchor}",
|
||||
|
||||
+3
-5
@@ -891,7 +891,7 @@
|
||||
"quickTagsHelp": "I tuoi tag frequenti: un tocco",
|
||||
"quickTagsExpand": "Altro",
|
||||
"loadError": "Impossibile caricare le voci dell'umore.",
|
||||
"sectionLevelA": "Cinque scale",
|
||||
"sectionLevelA": "Stato d'animo",
|
||||
"sectionLevelAHelp": "Facoltativo. Quello che non tocchi resta vuoto.",
|
||||
"dimensionNotAnswered": "non indicato",
|
||||
"dimensionClear": "cancella",
|
||||
@@ -3246,8 +3246,6 @@
|
||||
"navRecovery": "Recupero",
|
||||
"workouts": {
|
||||
"title": "Allenamenti",
|
||||
"askWhy": "Perché",
|
||||
"askWhyPrompt": "Perché questa sessione è andata così? Spiegami i miei dati di questa sessione.",
|
||||
"description": "Corse, uscite in bici, camminate e sessioni di forza recenti, deduplicati tra Apple Watch e Withings.",
|
||||
"emptyState": {
|
||||
"title": "Nessun allenamento",
|
||||
@@ -3772,8 +3770,8 @@
|
||||
},
|
||||
"inTargetTitle": "Nel tuo intervallo",
|
||||
"dimensions": {
|
||||
"title": "Cinque scale",
|
||||
"none": "Per queste cinque scale non c'è ancora nulla.",
|
||||
"title": "Stato d'animo",
|
||||
"none": "Non c'è ancora nulla sullo stato d'animo.",
|
||||
"window": "{days} giorni",
|
||||
"noneInWindow": "nulla in questo periodo",
|
||||
"higherIsMore": "più alto = {anchor}",
|
||||
|
||||
+3
-5
@@ -891,7 +891,7 @@
|
||||
"quickTagsHelp": "Twoje częste tagi — jedno dotknięcie",
|
||||
"quickTagsExpand": "Więcej",
|
||||
"loadError": "Nie udało się załadować wpisów nastroju.",
|
||||
"sectionLevelA": "Pięć skal",
|
||||
"sectionLevelA": "Samopoczucie",
|
||||
"sectionLevelAHelp": "Opcjonalne. To, czego nie ruszysz, zostaje puste.",
|
||||
"dimensionNotAnswered": "nie podano",
|
||||
"dimensionClear": "wyczyść",
|
||||
@@ -3246,8 +3246,6 @@
|
||||
"navRecovery": "Regeneracja",
|
||||
"workouts": {
|
||||
"title": "Treningi",
|
||||
"askWhy": "Dlaczego",
|
||||
"askWhyPrompt": "Dlaczego ta sesja wypadła w ten sposób? Wyjaśnij mi moje własne wyniki.",
|
||||
"description": "Ostatnie biegi, jazdy, marsze i treningi siłowe — zdeduplikowane między Apple Watch a Withings.",
|
||||
"emptyState": {
|
||||
"title": "Brak treningów",
|
||||
@@ -3772,8 +3770,8 @@
|
||||
},
|
||||
"inTargetTitle": "W Twoim zakresie",
|
||||
"dimensions": {
|
||||
"title": "Pięć skal",
|
||||
"none": "Dla tych pięciu skal nie ma jeszcze żadnych danych.",
|
||||
"title": "Samopoczucie",
|
||||
"none": "Nie zapisano jeszcze żadnych danych o samopoczuciu.",
|
||||
"window": "{days} dni",
|
||||
"noneInWindow": "brak w tym okresie",
|
||||
"higherIsMore": "wyżej = {anchor}",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.37.0";
|
||||
/* @sw-version-fallback */ "v1.37.1";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkoutDetail } from "@/hooks/use-workouts";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
@@ -31,7 +30,7 @@ import {
|
||||
* Layout, top to bottom (mobile-first single column):
|
||||
* hero header → reserved Activity-Insight seam (renders nothing today)
|
||||
* → stats grid + sport-average line → HR curve → effort zones → GPS
|
||||
* route → per-km splits → "that day" → coach launch.
|
||||
* route → per-km splits → "that day".
|
||||
*
|
||||
* "That day" answers the last question the page is for: what the rest of
|
||||
* the day looked like around the session. It renders the day's own pulse
|
||||
@@ -40,8 +39,8 @@ import {
|
||||
*
|
||||
* Every data-less section returns `null` (hide, don't render empty), so
|
||||
* an aggregates-only workout (a Strava ride with no wearable, a manual
|
||||
* entry) reads as hero + stats + "that day" + coach — compact but
|
||||
* honest, no empty shells.
|
||||
* entry) reads as hero + stats + "that day" — compact but honest, no
|
||||
* empty shells.
|
||||
*
|
||||
* Data flows from `GET /api/workouts/{id}?compact=1` (v1.4.32 + the #67
|
||||
* enrichment fields). The `canonicalId` pointer still resolves a
|
||||
@@ -120,18 +119,6 @@ export default function InsightsWorkoutDetailPage({
|
||||
<WorkoutDetailRoute workout={data} />
|
||||
<WorkoutDetailSplits workout={data} />
|
||||
<WorkoutDetailDayContext workout={data} />
|
||||
{/* 2026-07-17 UX-flows audit F6-1 — `workouts` narrows the
|
||||
snapshot the first coach turn reads. v1.31.0 — `workoutId` pins
|
||||
THIS session's own numbers as one additional snapshot section, so
|
||||
the answer is about this workout rather than about training in
|
||||
general, and the opener auto-sends so it lands directly. */}
|
||||
<CoachLaunchButton
|
||||
scope={{ metric: "workouts" }}
|
||||
workoutId={data.id}
|
||||
label={t("insights.workouts.askWhy")}
|
||||
prefill={t("insights.workouts.askWhyPrompt")}
|
||||
autoSend
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SubPageShell>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
applyOptimisticSlotMark,
|
||||
slotMarkRequestBody,
|
||||
complianceFromLedger,
|
||||
formatSlotDelta,
|
||||
groupLedgerByDay,
|
||||
@@ -195,3 +196,23 @@ describe("applyOptimisticSlotMark", () => {
|
||||
expect(next.rows[0].status).toBe("taken_late");
|
||||
});
|
||||
});
|
||||
|
||||
describe("slotMarkRequestBody", () => {
|
||||
const row = { at: "2026-08-04T07:00:00.000Z" };
|
||||
|
||||
it("anchors takenAt on the slot instant, not on now", () => {
|
||||
const body = slotMarkRequestBody(row, "taken");
|
||||
expect(body).toEqual({
|
||||
skipped: false,
|
||||
scheduledFor: "2026-08-04T07:00:00.000Z",
|
||||
takenAt: "2026-08-04T07:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the skipped arm free of takenAt", () => {
|
||||
expect(slotMarkRequestBody(row, "skipped")).toEqual({
|
||||
skipped: true,
|
||||
scheduledFor: "2026-08-04T07:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -204,3 +204,18 @@ export function applyOptimisticSlotMark(
|
||||
});
|
||||
return { ...payload, rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for marking a ledger slot from the Verlauf. The taken arm
|
||||
* anchors `takenAt` on the slot instant: marking Tuesday 09:00 from a
|
||||
* Thursday evening records a dose taken Tuesday 09:00, which is the claim
|
||||
* the person is making. The optimistic paint above already promised this.
|
||||
*/
|
||||
export function slotMarkRequestBody(
|
||||
row: Pick<LedgerRow, "at">,
|
||||
action: "taken" | "skipped",
|
||||
): Record<string, unknown> {
|
||||
return action === "skipped"
|
||||
? { skipped: true, scheduledFor: row.at }
|
||||
: { skipped: false, scheduledFor: row.at, takenAt: row.at };
|
||||
}
|
||||
@@ -106,6 +106,7 @@ import {
|
||||
formatSlotDelta,
|
||||
groupLedgerByDay,
|
||||
isSlotActionable,
|
||||
slotMarkRequestBody,
|
||||
type LedgerPayload,
|
||||
type LedgerRow,
|
||||
} from "@/components/medications/dose-history-ledger-compute";
|
||||
@@ -273,18 +274,13 @@ export function DoseHistoryLedger({
|
||||
|
||||
try {
|
||||
// Pin the displayed slot so the server marks THAT dose (the canonical
|
||||
// slot upsert), not a now-snap to the nearest one. The POST returns
|
||||
// the created event; its id drives the Undo affordance (the same
|
||||
// soft-delete route the cards use).
|
||||
// slot upsert), not a now-snap to the nearest one — and anchor
|
||||
// `takenAt` on the same instant, matching the optimistic paint. The
|
||||
// POST returns the created event; its id drives the Undo affordance
|
||||
// (the same soft-delete route the cards use).
|
||||
const created = await apiPost<{ id?: string } | undefined>(
|
||||
`/api/medications/${medicationId}/intake`,
|
||||
action === "skipped"
|
||||
? { skipped: true, scheduledFor: row.at }
|
||||
: {
|
||||
skipped: false,
|
||||
scheduledFor: row.at,
|
||||
takenAt: new Date().toISOString(),
|
||||
},
|
||||
slotMarkRequestBody(row, action),
|
||||
);
|
||||
const eventId: string | undefined = created?.id;
|
||||
toast.success(
|
||||
|
||||
Reference in new issue
Block a user