From 32580a716bd653c5e42ddbd35f453c7f5d5030d7 Mon Sep 17 00:00:00 2001 From: Claas Date: Sun, 28 Jun 2026 14:04:19 +0000 Subject: [PATCH] Implement delete to allow edit of times from timer --- client/src/routes/_app/timer.tsx | 56 +++++++++++---- client/src/timer.ts | 13 +++- .../yealch/yealch/timer/TimerController.java | 69 ++++++++++++++----- 3 files changed, 106 insertions(+), 32 deletions(-) diff --git a/client/src/routes/_app/timer.tsx b/client/src/routes/_app/timer.tsx index 45a9d75..07f3f36 100644 --- a/client/src/routes/_app/timer.tsx +++ b/client/src/routes/_app/timer.tsx @@ -7,6 +7,7 @@ import Icon from "@/Icon"; import { isProject, query as projectQuery, type Id as ProjectId } from "@/project"; import { query as timesQuery } from "@/time"; import { + deleteTimerEntry, discardTimer, pauseTimer, query as timerQuery, @@ -14,6 +15,7 @@ import { stopTimer, type TimerData, type TimerEntry, + type TimerEntryId, } from "@/timer"; import { Title } from "@/Title"; import { idQuery } from "@/user"; @@ -51,19 +53,24 @@ function formatElapsed(ms: number): string { return [hours, minutes, seconds].map((n) => String(n).padStart(2, "0")).join(":"); } -function EntryRow(props: { entry: TimerEntry; tick: number }) { - const startedAt = () => Temporal.Instant.from(props.entry.startedAt); +function EntryRow(properties: { + entry: TimerEntry; + tick: number; + onDelete: () => void; + isDeleting: boolean; +}) { + const startedAt = () => Temporal.Instant.from(properties.entry.startedAt); const pausedAt = () => - props.entry.pausedAt ? Temporal.Instant.from(props.entry.pausedAt) : null; + properties.entry.pausedAt ? Temporal.Instant.from(properties.entry.pausedAt) : null; const duration = () => { - const end = pausedAt() ?? (void props.tick, Temporal.Now.instant()); + const end = pausedAt() ?? (void properties.tick, Temporal.Now.instant()); return end.since(startedAt()).round({ smallestUnit: "second", largestUnit: "hour" }); }; return ( -
  • +
  • ); } @@ -119,15 +134,15 @@ function TimerPage() { }); const elapsedMs = createMemo(() => { - const t = timer(); - if (!t) return 0; - if (t.status !== "RUNNING") return t.accumulatedMs; + const currentTimer = timer(); + if (!currentTimer) return 0; + if (currentTimer.status !== "RUNNING") return currentTimer.accumulatedMilliseconds; // RUNNING: subscribe to tick for live updates void tick(); return ( - t.accumulatedMs + + currentTimer.accumulatedMilliseconds + Temporal.Now.instant() - .since(Temporal.Instant.from(t.currentPeriodStart!)) + .since(Temporal.Instant.from(currentTimer.currentPeriodStart!)) .total("milliseconds") ); }); @@ -163,11 +178,17 @@ function TimerPage() { }, })); + const deleteEntryMutation = useMutation(() => ({ + mutationFn: (entryId: TimerEntryId) => deleteTimerEntry(userId, entryId), + onSuccess: setTimerCache, + })); + const isPending = () => startMutation.isPending || pauseMutation.isPending || saveMutation.isPending || - discardMutation.isPending; + discardMutation.isPending || + deleteEntryMutation.isPending; const entries = () => timer()?.entries; @@ -239,7 +260,14 @@ function TimerPage() { 0}>
      - {(entry) => } + {(entry) => ( + deleteEntryMutation.mutate(entry.id)} + isDeleting={deleteEntryMutation.isPending} + /> + )}
    diff --git a/client/src/timer.ts b/client/src/timer.ts index c57db7a..2b94741 100644 --- a/client/src/timer.ts +++ b/client/src/timer.ts @@ -5,7 +5,10 @@ import { QUERY_BASE, type UserId } from "./user"; export type TimerStatus = "RUNNING" | "PAUSED"; +export type TimerEntryId = string & { __brand: "TimerEntryId" }; + export type TimerEntry = { + id: TimerEntryId; startedAt: string; pausedAt: string | null; }; @@ -13,7 +16,7 @@ export type TimerEntry = { export type TimerData = { status: TimerStatus; currentPeriodStart: string | null; // ISO 8601, set only when RUNNING - accumulatedMs: number; // sum of all completed start-pause entry durations + accumulatedMilliseconds: number; // sum of all completed start-pause entry durations entries: TimerEntry[]; }; @@ -50,6 +53,14 @@ export async function stopTimer(userId: UserId, projectId: ProjectId): Promise { + const response = await fetch(`/api/users/${userId}/timer/entries/${entryId}`, { + method: "DELETE", + }); + if (!response.ok) throw new Error(`Error deleting timer entry: ${response.status}`); + return response.json() as Promise; +} + export async function discardTimer(userId: UserId): Promise { const response = await fetch(`/api/users/${userId}/timer`, { method: "DELETE" }); if (!response.ok) throw new Error(`Error discarding timer: ${response.status}`); diff --git a/server/src/main/java/com/yealch/yealch/timer/TimerController.java b/server/src/main/java/com/yealch/yealch/timer/TimerController.java index 8451bfc..5dcd7d4 100644 --- a/server/src/main/java/com/yealch/yealch/timer/TimerController.java +++ b/server/src/main/java/com/yealch/yealch/timer/TimerController.java @@ -40,17 +40,21 @@ public class TimerController { this.timeRepository = timeRepository; } - record ErrorResponse(String error) {} + record ErrorResponse(String error) { + } - record TimerStartPauseResponse(String startedAt, String pausedAt) {} + record TimerStartPauseResponse(String id, String startedAt, String pausedAt) { + } record TimerResponse( String status, String currentPeriodStart, - long accumulatedMs, - List entries) {} + long accumulatedMilliseconds, + List entries) { + } - record CreateTimerStopRequest(UUID projectId) {} + record CreateTimerStopRequest(UUID projectId) { + } private UUID authenticatedUserId(Authentication authentication) { return UsersController.getUserId(authentication).orElse(null); @@ -61,7 +65,7 @@ public class TimerController { } private TimerResponse toResponse(Timer timer) { - long accumulatedMs = timer.getStartPauseEntries().stream() + long accumulatedMilliseconds = timer.getStartPauseEntries().stream() .filter(e -> e.getPausedAt() != null) .mapToLong(e -> Duration.between(e.getStartedAt(), e.getPausedAt()).toMillis()) .sum(); @@ -76,12 +80,13 @@ public class TimerController { } List entries = timer.getStartPauseEntries().stream() - .map(e -> new TimerStartPauseResponse( - e.getStartedAt().toString(), - e.getPausedAt() != null ? e.getPausedAt().toString() : null)) + .map(entry -> new TimerStartPauseResponse( + entry.getId().toString(), + entry.getStartedAt().toString(), + entry.getPausedAt() != null ? entry.getPausedAt().toString() : null)) .toList(); - return new TimerResponse(status, currentPeriodStart, accumulatedMs, entries); + return new TimerResponse(status, currentPeriodStart, accumulatedMilliseconds, entries); } @GetMapping @@ -96,7 +101,9 @@ public class TimerController { .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).build()); } - /** Creates a start for the timer: starts a new timer or resumes a paused one. */ + /** + * Creates a start for the timer: starts a new timer or resumes a paused one. + */ @PostMapping("/start") public ResponseEntity createTimerStart(@PathVariable UUID userId, Authentication authentication) { UUID authId = authenticatedUserId(authentication); @@ -135,7 +142,10 @@ public class TimerController { return ResponseEntity.ok(toResponse(timer)); } - /** Creates a pause for the timer: sets the pause time on the current running entry. */ + /** + * Creates a pause for the timer: sets the pause time on the current running + * entry. + */ @PostMapping("/pause") public ResponseEntity createTimerPause(@PathVariable UUID userId, Authentication authentication) { UUID authId = authenticatedUserId(authentication); @@ -156,12 +166,15 @@ public class TimerController { return ResponseEntity.ok(toResponse(timer)); } - /** Stops the timer: pauses the running entry if needed, converts all entries to time records, deletes the timer. */ + /** + * Stops the timer: pauses the running entry if needed, converts all entries to + * time records, deletes the timer. + */ @PostMapping(value = "/stop", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity createTimerStop(@PathVariable UUID userId, @RequestBody CreateTimerStopRequest request, Authentication authentication) { - UUID authId = authenticatedUserId(authentication); - if (isUnauthorized(authId, userId)) { + UUID authenticatedUserId = authenticatedUserId(authentication); + if (isUnauthorized(authenticatedUserId, userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } @@ -198,10 +211,32 @@ public class TimerController { return ResponseEntity.status(HttpStatus.CREATED).build(); } + @DeleteMapping("/entries/{entryId}") + public ResponseEntity deleteTimerEntry(@PathVariable UUID userId, @PathVariable UUID entryId, + Authentication authentication) { + UUID authenticatedUserId = authenticatedUserId(authentication); + if (isUnauthorized(authenticatedUserId, userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + Timer timer = timerRepository.findByUser_Id(userId).orElse(null); + if (timer == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("no active timer")); + } + + boolean isRemoved = timer.getStartPauseEntries().removeIf(entry -> entry.getId().equals(entryId)); + if (!isRemoved) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("entry not found")); + } + + timerRepository.save(timer); + return ResponseEntity.ok(toResponse(timer)); + } + @DeleteMapping public ResponseEntity deleteTimer(@PathVariable UUID userId, Authentication authentication) { - UUID authId = authenticatedUserId(authentication); - if (isUnauthorized(authId, userId)) { + UUID authenticatedUserId = authenticatedUserId(authentication); + if (isUnauthorized(authenticatedUserId, userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } -- 2.51.2