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.
This commit is contained in:
simon.franken
2026-02-23 10:41:50 +01:00
parent 7358fa6256
commit 06596dcee9
5 changed files with 89 additions and 9 deletions

View File

@@ -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',

View File

@@ -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) {

View File

@@ -30,4 +30,8 @@ export const timerApi = {
});
return data;
},
cancel: async (): Promise<void> => {
await apiClient.delete('/timer');
},
};

View File

@@ -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<string | null>(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() {
)}
</div>
{/* Stop Button */}
{/* Stop + Cancel Buttons */}
<div className="flex items-center space-x-2 shrink-0 sm:order-last">
{confirmCancel ? (
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">Discard timer?</span>
<button
onClick={() => void handleCancelTimer()}
className="flex items-center space-x-1 px-3 py-2 bg-gray-700 text-white rounded-lg font-medium hover:bg-gray-800 transition-colors text-sm"
>
<Trash2 className="h-4 w-4" />
<span>Discard</span>
</button>
<button
onClick={() => setConfirmCancel(false)}
className="px-3 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors text-sm"
>
Keep
</button>
</div>
) : (
<>
<button
onClick={() => setConfirmCancel(true)}
title="Discard timer"
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<Trash2 className="h-5 w-5" />
</button>
<button
onClick={handleStop}
className="flex items-center space-x-2 px-6 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors shrink-0 sm:order-last"
className="flex items-center space-x-2 px-6 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
>
<Square className="h-5 w-5 fill-current" />
<span>Stop</span>
</button>
</>
)}
</div>
</div>
{/* Project Selector — full width on mobile, auto on desktop */}

View File

@@ -18,6 +18,7 @@ interface TimerContextType {
startTimer: (projectId?: string) => Promise<void>;
updateTimerProject: (projectId?: string | null) => Promise<void>;
updateTimerStartTime: (startTime: string) => Promise<void>;
cancelTimer: () => Promise<void>;
stopTimer: (projectId?: string) => Promise<TimeEntry | null>;
}
@@ -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<TimeEntry | null> => {
try {
@@ -136,6 +149,7 @@ export function TimerProvider({ children }: { children: ReactNode }) {
startTimer,
updateTimerProject,
updateTimerStartTime,
cancelTimer,
stopTimer,
}}
>