diff --git a/__tests__/api/timer.get.test.ts b/__tests__/api/timer.get.test.ts index 7bd29a0..3989335 100644 --- a/__tests__/api/timer.get.test.ts +++ b/__tests__/api/timer.get.test.ts @@ -3,7 +3,7 @@ import { getServerSession } from 'next-auth'; jest.mock('@/lib/prisma', () => ({ prisma: { - user: { upsert: jest.fn() }, + user: { upsert: jest.fn(), findUnique: jest.fn() }, client: { findFirst: jest.fn() }, timeEntry: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), findUnique: jest.fn() }, }, @@ -12,7 +12,7 @@ jest.mock('next-auth', () => ({ getServerSession: jest.fn() })); const { prisma: mockPrisma } = jest.requireMock('@/lib/prisma') as { prisma: { - user: { upsert: jest.Mock }; + user: { upsert: jest.Mock; findUnique: jest.Mock }; client: { findFirst: jest.Mock }; timeEntry: { findFirst: jest.Mock; create: jest.Mock; update: jest.Mock; findUnique: jest.Mock }; }; @@ -25,7 +25,7 @@ describe('GET /api/timer', () => { it('returns runningEntry when present', async () => { (getServerSession as jest.Mock).mockResolvedValue({ user: { email: 'test@example.com' } }); - mockPrisma.user.upsert.mockResolvedValue({ id: 'user1', email: 'test@example.com' }); + mockPrisma.user.findUnique.mockResolvedValue({ id: 'user1', email: 'test@example.com' }); mockPrisma.timeEntry.findFirst.mockResolvedValue({ id: 'entry1', clientId: 'client1', startTime: new Date().toISOString(), status: 'RUNNING', client: { id: 'client1', name: 'C' } }); const res = await GET(); @@ -38,7 +38,7 @@ describe('GET /api/timer', () => { it('returns null when no running entry', async () => { (getServerSession as jest.Mock).mockResolvedValue({ user: { email: 'test@example.com' } }); - mockPrisma.user.upsert.mockResolvedValue({ id: 'user1', email: 'test@example.com' }); + mockPrisma.user.findUnique.mockResolvedValue({ id: 'user1', email: 'test@example.com' }); mockPrisma.timeEntry.findFirst.mockResolvedValue(null); const res = await GET(); diff --git a/__tests__/api/timer.post.test.ts b/__tests__/api/timer.post.test.ts index 66d6cbf..ba34ee0 100644 --- a/__tests__/api/timer.post.test.ts +++ b/__tests__/api/timer.post.test.ts @@ -69,6 +69,8 @@ describe('POST /api/timer', () => { const res = await POST(req as Request); const json = await res.json(); - expect(json.error).toBe('A timer is already running'); + // With the simplified approach the POST returns the existing running entry + expect(json.runningEntry).toBeDefined(); + expect(json.runningEntry.id).toBe('existing'); }); }); diff --git a/app/api/timer/route.ts b/app/api/timer/route.ts index cf580f3..780cd77 100644 --- a/app/api/timer/route.ts +++ b/app/api/timer/route.ts @@ -41,13 +41,13 @@ export async function POST(req: Request) { return Response.json({ error: 'Client not found' }, { status: 404 }); } - // Ensure there is no other running entry for this user + // If there is an existing running entry, return it (DB is the source of truth) const existingRunning = await prisma.timeEntry.findFirst({ where: { userId: user.id, status: TimeEntryStatus.RUNNING }, }); if (existingRunning) { - return Response.json({ error: 'A timer is already running' }, { status: 409 }); + return Response.json({ runningEntry: existingRunning }); } // Create time entry with status RUNNING @@ -77,24 +77,18 @@ export async function GET() { } try { - const user = await prisma.user.upsert({ - where: { email: session.user.email }, - update: {}, - create: { - email: session.user.email, - name: session.user.name || '', - }, - }); + // Find the user read-only; do not create a user during GET so polling is read-only. + const user = await prisma.user.findUnique({ where: { email: session.user.email } }); + + if (!user) { + return Response.json({ runningEntry: null }); + } const runningEntry = await prisma.timeEntry.findFirst({ - where: { - userId: user.id, - status: 'RUNNING', - }, - include: { - client: true, - }, + where: { userId: user.id, status: TimeEntryStatus.RUNNING }, + include: { client: true }, }); + return Response.json({ runningEntry }); } catch (error) { console.error('Error fetching in-progress time entries:', error); diff --git a/app/timer/page.tsx b/app/timer/page.tsx index cefcbd2..c67cead 100644 --- a/app/timer/page.tsx +++ b/app/timer/page.tsx @@ -1,7 +1,7 @@ -'use client'; + 'use client'; -import { useEffect, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useMemo, useState, useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; interface Client { id: string; @@ -11,10 +11,38 @@ interface Client { export default function TimerPage() { const [selectedClientId, setSelectedClientId] = useState(''); - const [isRunning, setIsRunning] = useState(false); - const [seconds, setSeconds] = useState(0); - const [startTime, setStartTime] = useState(null); - const [activeEntryId, setActiveEntryId] = useState(null); + const queryClient = useQueryClient(); + + const { data: timerData } = useQuery({ + queryKey: ['timerRunning'], + queryFn: async () => { + const res = await fetch('/api/timer'); + if (!res.ok) throw new Error('Failed to fetch timer'); + return res.json(); + }, + refetchInterval: (d: unknown) => { + const dd = d as Record | null; + return dd && dd['runningEntry'] ? 1000 : false; + }, + }); + + const runningEntry = timerData?.runningEntry ?? null; + const activeEntryId = runningEntry?.id ?? null; + const isRunning = !!runningEntry; + const [tick, setTick] = useState(() => Date.now()); + + // Update a lightweight tick every second while a timer is running to drive the display. + useEffect(() => { + if (!isRunning) return; + const id = setInterval(() => setTick(Date.now()), 1000); + return () => clearInterval(id); + }, [isRunning]); + + const seconds = useMemo(() => { + if (!runningEntry?.startTime) return 0; + const diff = Math.floor((tick - new Date(runningEntry.startTime).getTime()) / 1000); + return Math.max(0, diff); + }, [runningEntry, tick]); const { data: clientsData, @@ -31,44 +59,13 @@ export default function TimerPage() { }, }); - // On mount, check for any running entry and resume timer UI + // Polling via react-query handles fetching running entry every second. + // When a running entry exists, ensure selected client matches it. useEffect(() => { - let mounted = true; - (async () => { - try { - const res = await fetch('/api/timer'); - if (!res.ok) return; - const data = await res.json(); - const entry = data.runningEntry; - if (mounted && entry) { - setSelectedClientId(entry.clientId); - setActiveEntryId(entry.id); - const s = new Date(entry.startTime); - setStartTime(s); - const diff = Math.floor((Date.now() - s.getTime()) / 1000); - setSeconds(Math.max(0, diff)); - setIsRunning(true); - } - } catch { - // ignore - } - })(); - return () => { - mounted = false; - }; - }, []); - - useEffect(() => { - let interval: NodeJS.Timeout; - - if (isRunning) { - interval = setInterval(() => { - setSeconds((s) => s + 1); - }, 1000); + if (runningEntry && selectedClientId !== runningEntry.clientId) { + setSelectedClientId(runningEntry.clientId); } - - return () => clearInterval(interval); - }, [isRunning]); + }, [runningEntry, selectedClientId]); const handleStart = async () => { if (!selectedClientId) { @@ -76,58 +73,50 @@ export default function TimerPage() { return; } - const now = new Date(); - setStartTime(now); - setIsRunning(true); - - // Create a TimeEntry in the database (without endTime) - const response = await fetch('/api/timer', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - clientId: selectedClientId, - startTime: now.toISOString(), - }), - }); - - if (response.ok) { - const data = await response.json(); - setActiveEntryId(data.timeEntry.id); - } else { + try { + const now = new Date().toISOString(); + const res = await fetch('/api/timer', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId: selectedClientId, startTime: now }), + }); + if (!res.ok) throw new Error('Failed to start timer'); + // invalidate query so the new running entry is fetched on next tick + queryClient.invalidateQueries({ queryKey: ['timerRunning'] }); + } catch { alert('Error starting timer'); } }; const handleStop = async () => { - if (!startTime || !activeEntryId) return; - - setIsRunning(false); - - // Save to database - const endTime = new Date(); - const response = await fetch(`/api/timer/${activeEntryId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - endTime: endTime.toISOString(), - }), - }); - - if (response.ok) { - // Reset timer - setSeconds(0); - setStartTime(null); - setActiveEntryId(null); + if (!activeEntryId) return; + + try { + const endTime = new Date().toISOString(); + const res = await fetch(`/api/timer/${activeEntryId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endTime }), + }); + if (!res.ok) throw new Error('Failed to stop'); + queryClient.invalidateQueries({ queryKey: ['timerRunning'] }); alert('Time entry saved!'); - } else { + } catch { alert('Error saving time entry'); } }; const handleReset = () => { - setSeconds(0); - setIsRunning(false); - setStartTime(null); + // If there's an active server entry, stop it. + if (activeEntryId) { + handleStop(); + return; + } + + // Otherwise clear client selection and refresh timer query so UI shows 00:00:00 + setSelectedClientId(''); + setTick(Date.now()); + queryClient.invalidateQueries({ queryKey: ['timerRunning'] }); }; const formatTime = (totalSeconds: number) => { diff --git a/app/timer/useTimer.ts b/app/timer/useTimer.ts new file mode 100644 index 0000000..76dbadd --- /dev/null +++ b/app/timer/useTimer.ts @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from 'react'; + +type ResumeEntry = { id: string; startTime: string } | null; + +export function useTimer() { + const [isRunning, setIsRunning] = useState(false); + const [seconds, setSeconds] = useState(0); + const [startTime, setStartTime] = useState(null); + const [activeEntryId, setActiveEntryId] = useState(null); + const intervalRef = useRef(null); + + const MAX_RESUME_AGE_SECONDS = 24 * 60 * 60; + + useEffect(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + if (isRunning) { + intervalRef.current = setInterval(() => { + if (!startTime) return; + const diff = Math.floor((Date.now() - startTime.getTime()) / 1000); + setSeconds(Math.max(0, diff)); + }, 1000); + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [isRunning, startTime]); + + const startLocal = (when: Date, entryId?: string | null) => { + // ensure any existing interval is cleared before starting + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setStartTime(when); + setSeconds(0); + setActiveEntryId(entryId ?? null); + setIsRunning(true); + }; + + const stopLocal = () => { + // stop ticking immediately and clear interval + setIsRunning(false); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + + const resetLocal = () => { + setIsRunning(false); + setSeconds(0); + setStartTime(null); + setActiveEntryId(null); + }; + + const resumeFromEntry = (entry: ResumeEntry) => { + if (!entry) return; + const s = new Date(entry.startTime); + const diff = Math.floor((Date.now() - s.getTime()) / 1000); + if (diff > MAX_RESUME_AGE_SECONDS) return; + setStartTime(s); + setSeconds(Math.max(0, diff)); + setActiveEntryId(entry.id); + setIsRunning(true); + }; + + return { + seconds, + isRunning, + startTime, + activeEntryId, + startLocal, + stopLocal, + resetLocal, + resumeFromEntry, + } as const; +} + +export default useTimer;