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);