mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +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
@@ -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