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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 00:35:26 +02:00
1 parent 2cd8b28a22
commit a14ae57283
9 files changed
+28 -2

No files matched your search

+1
View File
@@ -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."
}
+1
View File
@@ -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."
}
+1
View File
@@ -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."
}
+1
View File
@@ -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."
}
+1
View File
@@ -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."
}
+1
View File
@@ -3465,6 +3465,7 @@
"durationRequired": "운동한 시간을 입력하세요.",
"durationInvalid": "시간은 정수로, 분은 0에서 59 사이로 입력하세요.",
"durationTooLong": "운동은 최대 24시간까지 입력할 수 있습니다.",
"endInFuture": "종료 시간이 미래가 됩니다. 시작 시간을 운동을 시작한 시각으로 설정하세요.",
"distanceInvalid": "거리를 현실적인 범위의 숫자로 입력하세요.",
"energyInvalid": "활동 에너지는 20,000kcal 이하의 숫자로 입력하세요."
}
+1
View File
@@ -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."
}
@@ -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",
+12
View File
@@ -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`,
});
}
}