From 7358fa625640df8c7be6a01cce6161afac55f522 Mon Sep 17 00:00:00 2001 From: "simon.franken" Date: Mon, 23 Feb 2026 10:32:38 +0100 Subject: [PATCH 1/5] Add ability to manually adjust the running timer's start time Allows users to retroactively correct the start time of an ongoing timer without stopping it. A pencil icon in the timer widget opens an inline time input pre-filled with the current start time; confirming sends the new time to the backend which validates it is in the past before persisting. --- backend/src/schemas/index.ts | 1 + backend/src/services/timer.service.ts | 17 +++- backend/src/types/index.ts | 1 + frontend/src/api/timer.ts | 11 ++- frontend/src/components/TimerWidget.tsx | 102 +++++++++++++++++++++++- frontend/src/contexts/TimerContext.tsx | 11 ++- 6 files changed, 133 insertions(+), 10 deletions(-) diff --git a/backend/src/schemas/index.ts b/backend/src/schemas/index.ts index d7118b1..f0815eb 100644 --- a/backend/src/schemas/index.ts +++ b/backend/src/schemas/index.ts @@ -64,6 +64,7 @@ export const StartTimerSchema = z.object({ export const UpdateTimerSchema = z.object({ projectId: z.string().uuid().optional().nullable(), + startTime: z.string().datetime().optional(), }); export const StopTimerSchema = z.object({ diff --git a/backend/src/services/timer.service.ts b/backend/src/services/timer.service.ts index a35b8f5..4ad475a 100644 --- a/backend/src/services/timer.service.ts +++ b/backend/src/services/timer.service.ts @@ -102,9 +102,24 @@ export class TimerService { projectId = data.projectId; } + // Validate startTime if provided + let startTime: Date | undefined = undefined; + if (data.startTime) { + const parsed = new Date(data.startTime); + const now = new Date(); + if (parsed >= now) { + throw new BadRequestError("Start time must be in the past"); + } + startTime = parsed; + } + + const updateData: Record = {}; + if (projectId !== undefined) updateData.projectId = projectId; + if (startTime !== undefined) updateData.startTime = startTime; + return prisma.ongoingTimer.update({ where: { userId }, - data: projectId !== undefined ? { projectId } : {}, + data: updateData, include: { project: { select: { diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 9559edb..95fb778 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -71,6 +71,7 @@ export interface StartTimerInput { export interface UpdateTimerInput { projectId?: string | null; + startTime?: string; } export interface StopTimerInput { diff --git a/frontend/src/api/timer.ts b/frontend/src/api/timer.ts index 06e2601..c0c06ae 100644 --- a/frontend/src/api/timer.ts +++ b/frontend/src/api/timer.ts @@ -1,6 +1,11 @@ import apiClient from './client'; import type { OngoingTimer, TimeEntry } from '@/types'; +export interface UpdateTimerPayload { + projectId?: string | null; + startTime?: string; +} + export const timerApi = { getOngoing: async (): Promise => { const { data } = await apiClient.get('/timer'); @@ -14,10 +19,8 @@ export const timerApi = { return data; }, - update: async (projectId?: string | null): Promise => { - const { data } = await apiClient.put('/timer', { - projectId, - }); + update: async (payload: UpdateTimerPayload): Promise => { + const { data } = await apiClient.put('/timer', payload); return data; }, diff --git a/frontend/src/components/TimerWidget.tsx b/frontend/src/components/TimerWidget.tsx index ab959c7..3b07cb1 100644 --- a/frontend/src/components/TimerWidget.tsx +++ b/frontend/src/components/TimerWidget.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { Play, Square, ChevronDown } from "lucide-react"; +import { useState, useRef } from "react"; +import { Play, Square, ChevronDown, Pencil, Check, X } from "lucide-react"; import { useTimer } from "@/contexts/TimerContext"; import { useProjects } from "@/hooks/useProjects"; import { ProjectColorDot } from "@/components/ProjectColorDot"; @@ -27,6 +27,21 @@ function TimerDisplay({ totalSeconds }: { totalSeconds: number }) { ); } +/** Converts a HH:mm string to an ISO datetime, inferring the correct date. + * If the resulting time would be in the future, it is assumed to belong to the previous day. + */ +function timeInputToIso(timeValue: string): string { + const [hours, minutes] = timeValue.split(":").map(Number); + const now = new Date(); + const candidate = new Date(now); + candidate.setHours(hours, minutes, 0, 0); + // If the candidate is in the future, roll back one day + if (candidate > now) { + candidate.setDate(candidate.getDate() - 1); + } + return candidate.toISOString(); +} + export function TimerWidget() { const { ongoingTimer, @@ -35,11 +50,17 @@ export function TimerWidget() { startTimer, stopTimer, updateTimerProject, + updateTimerStartTime, } = useTimer(); const { projects } = useProjects(); const [showProjectSelect, setShowProjectSelect] = useState(false); const [error, setError] = useState(null); + // Start time editing state + const [editingStartTime, setEditingStartTime] = useState(false); + const [startTimeInput, setStartTimeInput] = useState(""); + const startTimeInputRef = useRef(null); + const handleStart = async () => { setError(null); try { @@ -78,6 +99,42 @@ export function TimerWidget() { } }; + const handleStartEditStartTime = () => { + if (!ongoingTimer) return; + const start = new Date(ongoingTimer.startTime); + const hh = start.getHours().toString().padStart(2, "0"); + const mm = start.getMinutes().toString().padStart(2, "0"); + setStartTimeInput(`${hh}:${mm}`); + setEditingStartTime(true); + // Focus the input on next render + setTimeout(() => startTimeInputRef.current?.focus(), 0); + }; + + const handleCancelEditStartTime = () => { + setEditingStartTime(false); + setError(null); + }; + + const handleConfirmStartTime = async () => { + if (!startTimeInput) return; + setError(null); + try { + const iso = timeInputToIso(startTimeInput); + await updateTimerStartTime(iso); + setEditingStartTime(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update start time"); + } + }; + + const handleStartTimeKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + void handleConfirmStartTime(); + } else if (e.key === "Escape") { + handleCancelEditStartTime(); + } + }; + if (isLoading) { return (
@@ -95,10 +152,47 @@ export function TimerWidget() { <> {/* Row 1 (mobile): timer + stop side by side. On sm+ dissolves into the parent flex row via contents. */}
- {/* Timer Display */} + {/* Timer Display + Start Time Editor */}
- + {editingStartTime ? ( +
+ Started at + setStartTimeInput(e.target.value)} + onKeyDown={handleStartTimeKeyDown} + className="font-mono text-lg font-bold text-gray-900 border border-primary-400 rounded px-2 py-0.5 focus:outline-none focus:ring-2 focus:ring-primary-500 w-28" + /> + + +
+ ) : ( +
+ + +
+ )}
{/* Stop Button */} diff --git a/frontend/src/contexts/TimerContext.tsx b/frontend/src/contexts/TimerContext.tsx index bf2d370..2a72604 100644 --- a/frontend/src/contexts/TimerContext.tsx +++ b/frontend/src/contexts/TimerContext.tsx @@ -17,6 +17,7 @@ interface TimerContextType { elapsedSeconds: number; startTimer: (projectId?: string) => Promise; updateTimerProject: (projectId?: string | null) => Promise; + updateTimerStartTime: (startTime: string) => Promise; stopTimer: (projectId?: string) => Promise; } @@ -102,7 +103,14 @@ export function TimerProvider({ children }: { children: ReactNode }) { const updateTimerProject = useCallback( async (projectId?: string | null) => { - await updateMutation.mutateAsync(projectId); + await updateMutation.mutateAsync({ projectId }); + }, + [updateMutation], + ); + + const updateTimerStartTime = useCallback( + async (startTime: string) => { + await updateMutation.mutateAsync({ startTime }); }, [updateMutation], ); @@ -127,6 +135,7 @@ export function TimerProvider({ children }: { children: ReactNode }) { elapsedSeconds, startTimer, updateTimerProject, + updateTimerStartTime, stopTimer, }} > -- 2.49.1 From 06596dcee9737f0813ff8894396cce8d1943a729 Mon Sep 17 00:00:00 2001 From: "simon.franken" Date: Mon, 23 Feb 2026 10:41:50 +0100 Subject: [PATCH 2/5] Add cancel (discard) timer feature Allows users to discard a running timer without creating a time entry. A trash icon in the timer widget reveals a confirmation step ('Discard / Keep') to prevent accidental data loss. Backend exposes a new DELETE /api/timer endpoint that simply deletes the ongoingTimer row. --- backend/src/routes/timer.routes.ts | 10 ++++ backend/src/services/timer.service.ts | 9 ++++ frontend/src/api/timer.ts | 4 ++ frontend/src/components/TimerWidget.tsx | 61 +++++++++++++++++++++---- frontend/src/contexts/TimerContext.tsx | 14 ++++++ 5 files changed, 89 insertions(+), 9 deletions(-) diff --git a/backend/src/routes/timer.routes.ts b/backend/src/routes/timer.routes.ts index 8b7a8a4..474b0c6 100644 --- a/backend/src/routes/timer.routes.ts +++ b/backend/src/routes/timer.routes.ts @@ -48,6 +48,16 @@ router.put( } ); +// DELETE /api/timer - Cancel (discard) the ongoing timer without creating a time entry +router.delete('/', requireAuth, async (req: AuthenticatedRequest, res, next) => { + try { + await timerService.cancel(req.user!.id); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + // POST /api/timer/stop - Stop timer router.post( '/stop', diff --git a/backend/src/services/timer.service.ts b/backend/src/services/timer.service.ts index 4ad475a..792eed9 100644 --- a/backend/src/services/timer.service.ts +++ b/backend/src/services/timer.service.ts @@ -138,6 +138,15 @@ export class TimerService { }); } + async cancel(userId: string) { + const timer = await this.getOngoingTimer(userId); + if (!timer) { + throw new NotFoundError("No timer is running"); + } + + await prisma.ongoingTimer.delete({ where: { userId } }); + } + async stop(userId: string, data?: StopTimerInput) { const timer = await this.getOngoingTimer(userId); if (!timer) { diff --git a/frontend/src/api/timer.ts b/frontend/src/api/timer.ts index c0c06ae..7580651 100644 --- a/frontend/src/api/timer.ts +++ b/frontend/src/api/timer.ts @@ -30,4 +30,8 @@ export const timerApi = { }); return data; }, + + cancel: async (): Promise => { + await apiClient.delete('/timer'); + }, }; \ No newline at end of file diff --git a/frontend/src/components/TimerWidget.tsx b/frontend/src/components/TimerWidget.tsx index 3b07cb1..f8f400e 100644 --- a/frontend/src/components/TimerWidget.tsx +++ b/frontend/src/components/TimerWidget.tsx @@ -1,5 +1,5 @@ import { useState, useRef } from "react"; -import { Play, Square, ChevronDown, Pencil, Check, X } from "lucide-react"; +import { Play, Square, ChevronDown, Pencil, Check, X, Trash2 } from "lucide-react"; import { useTimer } from "@/contexts/TimerContext"; import { useProjects } from "@/hooks/useProjects"; import { ProjectColorDot } from "@/components/ProjectColorDot"; @@ -49,12 +49,14 @@ export function TimerWidget() { elapsedSeconds, startTimer, stopTimer, + cancelTimer, updateTimerProject, updateTimerStartTime, } = useTimer(); const { projects } = useProjects(); const [showProjectSelect, setShowProjectSelect] = useState(false); const [error, setError] = useState(null); + const [confirmCancel, setConfirmCancel] = useState(false); // Start time editing state const [editingStartTime, setEditingStartTime] = useState(false); @@ -72,6 +74,7 @@ export function TimerWidget() { const handleStop = async () => { setError(null); + setConfirmCancel(false); try { await stopTimer(); } catch (err) { @@ -79,6 +82,16 @@ export function TimerWidget() { } }; + const handleCancelTimer = async () => { + setError(null); + try { + await cancelTimer(); + setConfirmCancel(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to cancel timer"); + } + }; + const handleProjectChange = async (projectId: string) => { setError(null); try { @@ -195,14 +208,44 @@ export function TimerWidget() { )}
- {/* Stop Button */} - + {/* Stop + Cancel Buttons */} +
+ {confirmCancel ? ( +
+ Discard timer? + + +
+ ) : ( + <> + + + + )} +
{/* Project Selector — full width on mobile, auto on desktop */} diff --git a/frontend/src/contexts/TimerContext.tsx b/frontend/src/contexts/TimerContext.tsx index 2a72604..361b0ab 100644 --- a/frontend/src/contexts/TimerContext.tsx +++ b/frontend/src/contexts/TimerContext.tsx @@ -18,6 +18,7 @@ interface TimerContextType { startTimer: (projectId?: string) => Promise; updateTimerProject: (projectId?: string | null) => Promise; updateTimerStartTime: (startTime: string) => Promise; + cancelTimer: () => Promise; stopTimer: (projectId?: string) => Promise; } @@ -85,6 +86,14 @@ export function TimerProvider({ children }: { children: ReactNode }) { }, }); + // Cancel timer mutation + const cancelMutation = useMutation({ + mutationFn: timerApi.cancel, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["ongoingTimer"] }); + }, + }); + // Stop timer mutation const stopMutation = useMutation({ mutationFn: timerApi.stop, @@ -115,6 +124,10 @@ export function TimerProvider({ children }: { children: ReactNode }) { [updateMutation], ); + const cancelTimer = useCallback(async () => { + await cancelMutation.mutateAsync(); + }, [cancelMutation]); + const stopTimer = useCallback( async (projectId?: string): Promise => { try { @@ -136,6 +149,7 @@ export function TimerProvider({ children }: { children: ReactNode }) { startTimer, updateTimerProject, updateTimerStartTime, + cancelTimer, stopTimer, }} > -- 2.49.1 From e01e5e59df8eba19e6fc690562fb7e36e67e504d Mon Sep 17 00:00:00 2001 From: "simon.franken" Date: Mon, 23 Feb 2026 10:44:34 +0100 Subject: [PATCH 3/5] =?UTF-8?q?Remove=20cancel=20confirmation=20=E2=80=94?= =?UTF-8?q?=20discard=20timer=20immediately=20on=20click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/TimerWidget.tsx | 52 +++++++------------------ 1 file changed, 14 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/TimerWidget.tsx b/frontend/src/components/TimerWidget.tsx index f8f400e..4915303 100644 --- a/frontend/src/components/TimerWidget.tsx +++ b/frontend/src/components/TimerWidget.tsx @@ -56,7 +56,6 @@ export function TimerWidget() { const { projects } = useProjects(); const [showProjectSelect, setShowProjectSelect] = useState(false); const [error, setError] = useState(null); - const [confirmCancel, setConfirmCancel] = useState(false); // Start time editing state const [editingStartTime, setEditingStartTime] = useState(false); @@ -74,7 +73,6 @@ export function TimerWidget() { const handleStop = async () => { setError(null); - setConfirmCancel(false); try { await stopTimer(); } catch (err) { @@ -86,7 +84,6 @@ export function TimerWidget() { setError(null); try { await cancelTimer(); - setConfirmCancel(false); } catch (err) { setError(err instanceof Error ? err.message : "Failed to cancel timer"); } @@ -210,41 +207,20 @@ export function TimerWidget() { {/* Stop + Cancel Buttons */}
- {confirmCancel ? ( -
- Discard timer? - - -
- ) : ( - <> - - - - )} + +
-- 2.49.1 From 3ab39643dd27dfd2fad9591ea6661e8ac3faff5f Mon Sep 17 00:00:00 2001 From: "simon.franken" Date: Mon, 23 Feb 2026 10:47:07 +0100 Subject: [PATCH 4/5] Disable Stop button when no project is selected --- frontend/src/components/TimerWidget.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/TimerWidget.tsx b/frontend/src/components/TimerWidget.tsx index 4915303..b4dee5f 100644 --- a/frontend/src/components/TimerWidget.tsx +++ b/frontend/src/components/TimerWidget.tsx @@ -216,7 +216,9 @@ export function TimerWidget() {