#1189·solidtime

Updating a time entry with only `end` in the payload bypasses `after_or_equal:start` — negative-duration entries get stored (UI renders `-1:00:-5`)

Author: denysshnurenkoCreated Aug 2, 2026Updated Aug 6, 2026

Summary

PUT /api/v1/organizations/{organization}/time-entries/{timeEntry} accepts an end earlier than the entry's stored start when the payload contains end but no start. The after_or_equal:start rule on end (app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php) compares against the request's own start field, so a partial update bypasses it. The entry is stored with a negative duration, and the web UI renders it as e.g. -1:00:-5.

Reproduction (verified live on a self-hosted instance, 2026-08)

POST /time-entries
     {"member_id": "…", "start": "2026-08-01T03:00:00Z", "end": "2026-08-01T03:01:00Z",
      "billable": false, "description": "repro"}
→ 200, duration: 60

PUT  /time-entries/{id}
     {"end": "2026-08-01T02:59:55Z"}                  ← 5s BEFORE start, no "start" in payload
→ 200 — stored: start 2026-08-01T03:00:00Z, end 2026-08-01T02:59:55Z, duration: -5

PUT  /time-entries/{id}                                ← control: same inverted times, start present
     {"start": "2026-08-01T03:00:00Z", "end": "2026-08-01T02:59:55Z"}
→ 422 {"message": "The end field must be a date after or equal to start."}

Expected

The partial update should fail with 422 exactly like the control request — the persisted start should participate in the validation when the payload doesn't carry one.

Actual

The entry is stored with duration: -5. In the time-entries list the UI then shows -1:00:-5 (for −5s) or -1:-12:-6 (for −12m6s): formatDuration() in resources/js/packages/ui/src/utils/time.ts uses Math.floor(dayJsDuration.asHours()), which yields -1 for any negative duration, and the negative minutes/seconds are padded as-is.

Context: we hit this via production automation — an idle-trim script issuing partial PUT {"end": …} after an idle prompt. Eight negative entries accumulated over a month before the odd -1:… durations in the UI gave it away.

Suggested fix

  • Validate end against the resulting start. TimeEntryUpdateRequest::rules() already resolves $timeEntry from the route (for the break rules), so a closure rule comparing against $this->input('start', $timeEntry->start) — or merging the persisted start into the validation data when the payload lacks it — would close the gap.
  • Optionally, make the duration formatters clamp or flag negative durations, so any legacy bad rows are visible as such instead of rendering -1:00:-5.

Related (from code reading, not runtime-verified)

The desktop app can produce the same inversion cross-device: with a timer started on another device while the desktop is already idle, the idle dialog's discard path calls stopTimer(idleStartTime) (src/renderer/src/App.vue) without clamping against the entry's start. That PUT includes start, so the server correctly rejects it — but the discard then fails silently and the timer "comes back". Clamping there (like idleMonitor.ts already does for local activity periods) would make the discard path robust as well.