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