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/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..792eed9 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: { @@ -123,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/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..7580651 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; }, @@ -27,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/Navbar.tsx b/frontend/src/components/Navbar.tsx index a86fbda..1e7ae0d 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -48,7 +48,7 @@ export function Navbar() {
- + TimeTracker Logo TimeTracker diff --git a/frontend/src/components/TimerWidget.tsx b/frontend/src/components/TimerWidget.tsx index ab959c7..b4dee5f 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, Trash2 } 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, @@ -34,12 +49,19 @@ export function TimerWidget() { elapsedSeconds, startTimer, stopTimer, + cancelTimer, 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 { @@ -58,6 +80,15 @@ export function TimerWidget() { } }; + const handleCancelTimer = async () => { + setError(null); + try { + await cancelTimer(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to cancel timer"); + } + }; + const handleProjectChange = async (projectId: string) => { setError(null); try { @@ -78,6 +109,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,20 +162,68 @@ 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 */} - + {/* Stop + Cancel Buttons */} +
+ + +
{/* Project Selector — full width on mobile, auto on desktop */} diff --git a/frontend/src/contexts/TimerContext.tsx b/frontend/src/contexts/TimerContext.tsx index bf2d370..361b0ab 100644 --- a/frontend/src/contexts/TimerContext.tsx +++ b/frontend/src/contexts/TimerContext.tsx @@ -17,6 +17,8 @@ interface TimerContextType { elapsedSeconds: number; startTimer: (projectId?: string) => Promise; updateTimerProject: (projectId?: string | null) => Promise; + updateTimerStartTime: (startTime: string) => Promise; + cancelTimer: () => Promise; stopTimer: (projectId?: string) => Promise; } @@ -84,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, @@ -102,11 +112,22 @@ 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], + ); + + const cancelTimer = useCallback(async () => { + await cancelMutation.mutateAsync(); + }, [cancelMutation]); + const stopTimer = useCallback( async (projectId?: string): Promise => { try { @@ -127,6 +148,8 @@ export function TimerProvider({ children }: { children: ReactNode }) { elapsedSeconds, startTimer, updateTimerProject, + updateTimerStartTime, + cancelTimer, stopTimer, }} > diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 23d1172..a864a2a 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,4 +1,3 @@ -import { Clock } from 'lucide-react'; import { useAuth } from '@/contexts/AuthContext'; export function LoginPage() { @@ -8,8 +7,8 @@ export function LoginPage() {
-
- +
+ TimeTracker Logo

TimeTracker