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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 09:07:39 +02:00
1 parent 964a3d050c
commit 17b653b819
10 files changed
+53 -15

No files matched your search

+1
View File
@@ -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)",
+1
View File
@@ -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)",
+1
View File
@@ -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)",
+1
View File
@@ -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)",
+1
View File
@@ -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)",
+1
View File
@@ -3546,6 +3546,7 @@
"sportLabel": "활동",
"sportPlaceholder": "활동 선택",
"startLabel": "시작",
"startHint": "방금 끝났다면 비워 두세요. 지금부터 시간을 거꾸로 계산합니다.",
"durationLabel": "시간",
"distanceLabel": "거리({unit})",
"energyLabel": "활동 에너지(kcal)",
+1
View File
@@ -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)",
@@ -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"
/>
<p id={`${ids.start}-hint`} className="text-muted-foreground text-xs">
{t("insights.workouts.manual.startHint")}
</p>
<FieldError id={`${ids.start}-error`} message={fieldError("start")} />
</div>
@@ -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: "",
});
+14 -8
View File
@@ -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);