mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
964a3d050c
commit
17b653b819
10 files changed
+53
-15
No files matched your search
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -3546,6 +3546,7 @@
|
||||
"sportLabel": "활동",
|
||||
"sportPlaceholder": "활동 선택",
|
||||
"startLabel": "시작",
|
||||
"startHint": "방금 끝났다면 비워 두세요. 지금부터 시간을 거꾸로 계산합니다.",
|
||||
"durationLabel": "시간",
|
||||
"distanceLabel": "거리({unit})",
|
||||
"energyLabel": "활동 에너지(kcal)",
|
||||
|
||||
@@ -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: "",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user