From 17b653b81927ebe10c9f562dd6e5f8826322bbfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 09:07:39 +0200 Subject: [PATCH] feat(workouts): read a blank start as now minus the duration Refs #1085. Logging a workout right after finishing it meant changing the start first, or the form refused it as ending in the future. The start field now opens blank; left blank, the workout is saved as starting the duration before now. A start the person enters is validated as before. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../workouts/manual-workout-form.tsx | 12 ++++++--- .../workouts/__tests__/manual-entry.test.ts | 27 ++++++++++++++++--- src/lib/workouts/manual-entry.ts | 22 +++++++++------ 10 files changed, 53 insertions(+), 15 deletions(-) diff --git a/messages/de.json b/messages/de.json index 010e91b92..0c7173452 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3546,6 +3546,7 @@ "sportLabel": "Aktivität", "sportPlaceholder": "Aktivität wählen", "startLabel": "Beginn", + "startHint": "Leer lassen, wenn du gerade fertig bist: Dann zählt es die Dauer von jetzt zurück.", "durationLabel": "Dauer", "distanceLabel": "Distanz ({unit})", "energyLabel": "Aktive Energie (kcal)", diff --git a/messages/en.json b/messages/en.json index b65774c65..3cd9aa9a4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3546,6 +3546,7 @@ "sportLabel": "Activity", "sportPlaceholder": "Choose an activity", "startLabel": "Start", + "startHint": "Leave empty if you just finished: it counts back from now by the duration.", "durationLabel": "Duration", "distanceLabel": "Distance ({unit})", "energyLabel": "Active energy (kcal)", diff --git a/messages/es.json b/messages/es.json index 1f9b678f2..d40131208 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3546,6 +3546,7 @@ "sportLabel": "Actividad", "sportPlaceholder": "Elige una actividad", "startLabel": "Inicio", + "startHint": "Déjalo vacío si acabas de terminar: se cuenta la duración hacia atrás desde ahora.", "durationLabel": "Duración", "distanceLabel": "Distancia ({unit})", "energyLabel": "Energía activa (kcal)", diff --git a/messages/fr.json b/messages/fr.json index 1c76f7992..a5d0afa08 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3546,6 +3546,7 @@ "sportLabel": "Activité", "sportPlaceholder": "Choisir une activité", "startLabel": "Début", + "startHint": "Laissez vide si vous venez de terminer : la durée est comptée à rebours depuis maintenant.", "durationLabel": "Durée", "distanceLabel": "Distance ({unit})", "energyLabel": "Énergie active (kcal)", diff --git a/messages/it.json b/messages/it.json index 61397873a..e0e489863 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3546,6 +3546,7 @@ "sportLabel": "Attività", "sportPlaceholder": "Scegli un’attività", "startLabel": "Inizio", + "startHint": "Lascia vuoto se hai appena finito: la durata viene contata a ritroso da adesso.", "durationLabel": "Durata", "distanceLabel": "Distanza ({unit})", "energyLabel": "Energia attiva (kcal)", diff --git a/messages/ko.json b/messages/ko.json index cdabb7b22..e51af7017 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3546,6 +3546,7 @@ "sportLabel": "활동", "sportPlaceholder": "활동 선택", "startLabel": "시작", + "startHint": "방금 끝났다면 비워 두세요. 지금부터 시간을 거꾸로 계산합니다.", "durationLabel": "시간", "distanceLabel": "거리({unit})", "energyLabel": "활동 에너지(kcal)", diff --git a/messages/pl.json b/messages/pl.json index 1f931f743..caa7f9245 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3546,6 +3546,7 @@ "sportLabel": "Aktywność", "sportPlaceholder": "Wybierz aktywność", "startLabel": "Początek", + "startHint": "Zostaw puste, jeśli właśnie skończyłeś: czas trwania liczy się wstecz od teraz.", "durationLabel": "Czas trwania", "distanceLabel": "Dystans ({unit})", "energyLabel": "Energia czynna (kcal)", diff --git a/src/components/workouts/manual-workout-form.tsx b/src/components/workouts/manual-workout-form.tsx index a254605e4..c849056e4 100644 --- a/src/components/workouts/manual-workout-form.tsx +++ b/src/components/workouts/manual-workout-form.tsx @@ -276,12 +276,18 @@ export function ManualWorkoutForm({ 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`)} + aria-describedby={[ + `${ids.start}-hint`, + describedBy("start", `${ids.start}-error`), + ] + .filter(Boolean) + .join(" ")} data-testid="manual-workout-start" /> +

+ {t("insights.workouts.manual.startHint")} +

diff --git a/src/lib/workouts/__tests__/manual-entry.test.ts b/src/lib/workouts/__tests__/manual-entry.test.ts index db7e0f255..0f728fb10 100644 --- a/src/lib/workouts/__tests__/manual-entry.test.ts +++ b/src/lib/workouts/__tests__/manual-entry.test.ts @@ -154,12 +154,31 @@ describe("buildManualWorkoutEntry — validation", () => { ); }); - it("asks for a start", () => { - expect(errorsOf({ start: "" }).start).toBe( + it("refuses a start it cannot read", () => { + expect(errorsOf({ start: "2026-09-15" }).start).toBe( "insights.workouts.manual.errors.startRequired", ); }); + it("reads a blank start as now minus the duration (#1085)", () => { + // NOW is 12:00Z; 45 minutes logged right after finishing. + const result = buildManualWorkoutEntry(draft({ start: "" }), CTX); + expect(result.ok).toBe(true); + expect(result.ok && result.entry.startedAt).toBe( + "2026-09-15T11:15:00.000Z", + ); + expect(result.ok && result.entry.endedAt).toBe("2026-09-15T12:00:00.000Z"); + }); + + it("never reports a blank start as ending in the future", () => { + const result = buildManualWorkoutEntry( + draft({ start: "", hours: "3", minutes: "0" }), + CTX, + ); + expect(result.ok).toBe(true); + expect(result.ok && result.entry.endedAt).toBe("2026-09-15T12:00:00.000Z"); + }); + it("refuses a zero duration", () => { expect(errorsOf({ hours: "", minutes: "" }).duration).toBe( "insights.workouts.manual.errors.durationRequired", @@ -221,11 +240,11 @@ describe("buildManualWorkoutEntry — validation", () => { }); describe("the form's defaults", () => { - it("opens on the current minute in the profile zone", () => { + it("opens with a blank start; the field caps at the current minute", () => { expect(wallClockNow(NOW, "Europe/Berlin")).toBe("2026-09-15T14:00"); expect(emptyManualWorkoutDraft(NOW, "Asia/Seoul")).toMatchObject({ sportType: "", - start: "2026-09-15T21:00", + start: "", hours: "", minutes: "", }); diff --git a/src/lib/workouts/manual-entry.ts b/src/lib/workouts/manual-entry.ts index 07012deb9..6be35a294 100644 --- a/src/lib/workouts/manual-entry.ts +++ b/src/lib/workouts/manual-entry.ts @@ -124,7 +124,9 @@ export function emptyManualWorkoutDraft( ): ManualWorkoutDraft { return { sportType: "", - start: wallClockNow(now, timezone), + // Blank on purpose (#1085): a blank start means "it just ended", so the + // workout is saved as starting `duration` before now. + start: "", hours: "", minutes: "", distance: "", @@ -191,16 +193,17 @@ function manualWorkoutSchema(ctx: ManualWorkoutContext) { }); } - const startedAt = startToUtc(d.start, ctx.timezone); - if (startedAt === null) { + const startBlank = d.start.trim() === ""; + const startedAt = startBlank ? null : startToUtc(d.start, ctx.timezone); + if (!startBlank && startedAt === null) { issue.addIssue({ code: "custom", path: ["start"], message: `${ERR}.startRequired`, }); } else if ( - startedAt.getTime() > - ctx.now.getTime() + FUTURE_TOLERANCE_MS + startedAt !== null && + startedAt.getTime() > ctx.now.getTime() + FUTURE_TOLERANCE_MS ) { issue.addIssue({ code: "custom", @@ -236,8 +239,8 @@ function manualWorkoutSchema(ctx: ManualWorkoutContext) { message: `${ERR}.durationTooLong`, }); } else if ( - // The default start is "now", so a workout logged right after it - // finished would otherwise be stored as ending in the future. + // Only a start the person entered can push the end past now; a + // blank start is read as "now minus the duration". startedAt !== null && startedAt.getTime() + sec * 1000 > ctx.now.getTime() + FUTURE_TOLERANCE_MS @@ -306,10 +309,13 @@ export function buildManualWorkoutEntry( } // 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 startedAt = + draft.start.trim() === "" + ? new Date(ctx.now.getTime() - durationSec * 1000) + : startToUtc(draft.start, ctx.timezone)!; const distance = parseDecimal(draft.distance); const energy = parseDecimal(draft.energyKcal);