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, }} >