From 39f289452c90c66200926e5c82ce1f63c226ce68 Mon Sep 17 00:00:00 2001 From: Graeme Date: Fri, 3 Apr 2026 16:50:39 +0200 Subject: [PATCH 1/2] fix broken timer --- __tests__/api/timer.post.test.ts | 4 +- app/api/timer/route.ts | 14 ++- app/timer/page.tsx | 146 +++++++++++++------------------ app/timer/useTimer.ts | 87 ++++++++++++++++++ 4 files changed, 157 insertions(+), 94 deletions(-) create mode 100644 app/timer/useTimer.ts 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..1c4ce14 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 @@ -87,14 +87,10 @@ export async function GET() { }); 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..ec38569 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,35 @@ 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: 1000, + }); + + 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 +56,13 @@ export default function TimerPage() { }, }); - // On mount, check for any running entry and resume timer UI - 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; - }; - }, []); - + // Polling via react-query handles fetching running entry every second. + // When a running entry exists, ensure selected client matches it. 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 +70,42 @@ 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); + // No local timer state — to reset UI, stop server entry if present + if (activeEntryId) handleStop(); }; 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; From cb882c5a4753b4f0ab687606d4fd0ae311d8d3e7 Mon Sep 17 00:00:00 2001 From: Graeme Date: Fri, 3 Apr 2026 17:52:17 +0200 Subject: [PATCH 2/2] feat(timer): persist running timers and resume UI --- __tests__/api/timer.get.test.ts | 8 ++++---- app/api/timer/route.ts | 14 ++++++-------- app/timer/page.tsx | 17 ++++++++++++++--- 3 files changed, 24 insertions(+), 15 deletions(-) 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/app/api/timer/route.ts b/app/api/timer/route.ts index 1c4ce14..780cd77 100644 --- a/app/api/timer/route.ts +++ b/app/api/timer/route.ts @@ -77,14 +77,12 @@ 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: TimeEntryStatus.RUNNING }, diff --git a/app/timer/page.tsx b/app/timer/page.tsx index ec38569..c67cead 100644 --- a/app/timer/page.tsx +++ b/app/timer/page.tsx @@ -20,7 +20,10 @@ export default function TimerPage() { if (!res.ok) throw new Error('Failed to fetch timer'); return res.json(); }, - refetchInterval: 1000, + refetchInterval: (d: unknown) => { + const dd = d as Record | null; + return dd && dd['runningEntry'] ? 1000 : false; + }, }); const runningEntry = timerData?.runningEntry ?? null; @@ -104,8 +107,16 @@ export default function TimerPage() { }; const handleReset = () => { - // No local timer state — to reset UI, stop server entry if present - if (activeEntryId) handleStop(); + // 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) => {