From a14ae572835507bb54493c1235fcbb1ef34dcab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 00:35:26 +0200 Subject: [PATCH] fix(workouts): refuse a manual workout that would end in the future The form's start defaults to the minute it opened, so a workout logged right after it finished was stored as ending up to a day ahead. The duration now fails validation when start + duration lies past now, with the same one-minute slack the start check uses. --- 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 + src/lib/workouts/__tests__/manual-entry.test.ts | 11 +++++++++-- src/lib/workouts/manual-entry.ts | 12 ++++++++++++ 9 files changed, 28 insertions(+), 2 deletions(-) diff --git a/messages/de.json b/messages/de.json index 822a5d825..64e5a0e0b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3465,6 +3465,7 @@ "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.", + "endInFuture": "Das Workout würde in der Zukunft enden. Stell den Beginn auf die Uhrzeit, zu der es angefangen hat.", "distanceInvalid": "Gib die Distanz als Zahl in einem realistischen Bereich ein.", "energyInvalid": "Gib die aktive Energie als Zahl bis 20.000 kcal ein." } diff --git a/messages/en.json b/messages/en.json index 41ffba7d9..2f5660ba4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3465,6 +3465,7 @@ "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.", + "endInFuture": "It would end in the future. Set the start to when the workout began.", "distanceInvalid": "Enter the distance as a number within a realistic range.", "energyInvalid": "Enter active energy as a number up to 20,000 kcal." } diff --git a/messages/es.json b/messages/es.json index e6c2d55df..8917fa569 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3465,6 +3465,7 @@ "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.", + "endInFuture": "Terminaría en el futuro. Pon como inicio la hora en que empezó el entrenamiento.", "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." } diff --git a/messages/fr.json b/messages/fr.json index a559cbce2..c7b83d06a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3465,6 +3465,7 @@ "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.", + "endInFuture": "Il se terminerait dans le futur. Indiquez comme début l’heure à laquelle l’entraînement a commencé.", "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." } diff --git a/messages/it.json b/messages/it.json index d4210d9be..dc823ccee 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3465,6 +3465,7 @@ "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.", + "endInFuture": "Finirebbe nel futuro. Imposta come inizio l’ora in cui è cominciato l’allenamento.", "distanceInvalid": "Inserisci la distanza come numero in un intervallo realistico.", "energyInvalid": "Inserisci l’energia attiva come numero fino a 20.000 kcal." } diff --git a/messages/ko.json b/messages/ko.json index 83eefb037..ad5942532 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3465,6 +3465,7 @@ "durationRequired": "운동한 시간을 입력하세요.", "durationInvalid": "시간은 정수로, 분은 0에서 59 사이로 입력하세요.", "durationTooLong": "운동은 최대 24시간까지 입력할 수 있습니다.", + "endInFuture": "종료 시간이 미래가 됩니다. 시작 시간을 운동을 시작한 시각으로 설정하세요.", "distanceInvalid": "거리를 현실적인 범위의 숫자로 입력하세요.", "energyInvalid": "활동 에너지는 20,000kcal 이하의 숫자로 입력하세요." } diff --git a/messages/pl.json b/messages/pl.json index 9b520a948..8fead53ee 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3465,6 +3465,7 @@ "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.", + "endInFuture": "Trening kończyłby się w przyszłości. Ustaw początek na godzinę, o której się zaczął.", "distanceInvalid": "Podaj dystans jako liczbę w realistycznym zakresie.", "energyInvalid": "Podaj energię czynną jako liczbę do 20 000 kcal." } diff --git a/src/lib/workouts/__tests__/manual-entry.test.ts b/src/lib/workouts/__tests__/manual-entry.test.ts index 7c3300475..db7e0f255 100644 --- a/src/lib/workouts/__tests__/manual-entry.test.ts +++ b/src/lib/workouts/__tests__/manual-entry.test.ts @@ -136,9 +136,9 @@ describe("buildManualWorkoutEntry — validation", () => { }); it("tolerates the minute the form was opened in", () => { - // 14:00:30 Berlin is 30 s past `now`: inside the slack. + // 13:15 Berlin + 45 min ends at 12:00Z, 30 s past `now`: inside the slack. const result = buildManualWorkoutEntry( - draft({ start: "2026-09-15T14:00" }), + draft({ start: "2026-09-15T13:15" }), { ...CTX, now: new Date("2026-09-15T11:59:30Z"), @@ -147,6 +147,13 @@ describe("buildManualWorkoutEntry — validation", () => { expect(result.ok).toBe(true); }); + it("refuses a workout that would end in the future", () => { + // Logged at 14:00 Berlin with the start left at 13:30: 45 min ends 14:15. + expect(errorsOf({ start: "2026-09-15T13:30" }).duration).toBe( + "insights.workouts.manual.errors.endInFuture", + ); + }); + it("asks for a start", () => { expect(errorsOf({ start: "" }).start).toBe( "insights.workouts.manual.errors.startRequired", diff --git a/src/lib/workouts/manual-entry.ts b/src/lib/workouts/manual-entry.ts index d356ecc47..07012deb9 100644 --- a/src/lib/workouts/manual-entry.ts +++ b/src/lib/workouts/manual-entry.ts @@ -235,6 +235,18 @@ function manualWorkoutSchema(ctx: ManualWorkoutContext) { path: ["duration"], 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. + startedAt !== null && + startedAt.getTime() + sec * 1000 > + ctx.now.getTime() + FUTURE_TOLERANCE_MS + ) { + issue.addIssue({ + code: "custom", + path: ["duration"], + message: `${ERR}.endInFuture`, + }); } }