Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions __tests__/api/timer.get.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
},
Expand All @@ -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 };
};
Expand All @@ -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();
Expand All @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion __tests__/api/timer.post.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
28 changes: 11 additions & 17 deletions app/api/timer/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
157 changes: 73 additions & 84 deletions app/timer/page.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Date | null>(null);
const [activeEntryId, setActiveEntryId] = useState<string | null>(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<string, unknown> | null;
return dd && dd['runningEntry'] ? 1000 : false;
},
});
Comment thread
grmbyrn marked this conversation as resolved.

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,
Expand All @@ -31,103 +59,64 @@ 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) {
alert('Please select a client');
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'] });
};
Comment thread
grmbyrn marked this conversation as resolved.

const formatTime = (totalSeconds: number) => {
Expand Down
87 changes: 87 additions & 0 deletions app/timer/useTimer.ts
Original file line number Diff line number Diff line change
@@ -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<Date | null>(null);
const [activeEntryId, setActiveEntryId] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(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;
Loading