diff --git a/__tests__/TimerPage.resume.test.tsx b/__tests__/TimerPage.resume.test.tsx
new file mode 100644
index 0000000..5f02381
--- /dev/null
+++ b/__tests__/TimerPage.resume.test.tsx
@@ -0,0 +1,38 @@
+import { render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import TimerPage from '../app/timer/page';
+
+// Mock fetch for clients and /api/timer
+beforeEach(() => {
+ global.fetch = jest.fn((url) => {
+ if (typeof url === 'string' && url.endsWith('/api/timer')) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ runningEntry: { id: 'entry1', clientId: 'client1', startTime: new Date(Date.now() - 5000).toISOString(), client: { id: 'client1', name: 'Client A', hourlyRate: 50 } } }) });
+ }
+ if (typeof url === 'string' && url.endsWith('/api/clients')) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ clients: [{ id: 'client1', name: 'Client A', hourlyRate: 50 }] }) });
+ }
+ return Promise.resolve({ ok: false });
+ }) as jest.Mock;
+});
+
+afterEach(() => {
+ jest.resetAllMocks();
+});
+
+describe('TimerPage resume behavior', () => {
+ it('resumes UI when runningEntry exists', async () => {
+ const qc = new QueryClient();
+ render(
+
+
+ ,
+ );
+
+ // Wait for the timer display to show a non-zero time
+ const timerDisplay = await screen.findByText(/\d{2}:\d{2}:\d{2}/);
+ expect(timerDisplay.textContent).not.toBe('00:00:00');
+
+ // Ensure Stop button is present (implies running)
+ expect(await screen.findByText(/stop/i)).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/api/timer.get.test.ts b/__tests__/api/timer.get.test.ts
new file mode 100644
index 0000000..7bd29a0
--- /dev/null
+++ b/__tests__/api/timer.get.test.ts
@@ -0,0 +1,49 @@
+import { GET } from '../../app/api/timer/route';
+import { getServerSession } from 'next-auth';
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ user: { upsert: jest.fn() },
+ client: { findFirst: jest.fn() },
+ timeEntry: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), findUnique: jest.fn() },
+ },
+}));
+jest.mock('next-auth', () => ({ getServerSession: jest.fn() }));
+
+const { prisma: mockPrisma } = jest.requireMock('@/lib/prisma') as {
+ prisma: {
+ user: { upsert: jest.Mock };
+ client: { findFirst: jest.Mock };
+ timeEntry: { findFirst: jest.Mock; create: jest.Mock; update: jest.Mock; findUnique: jest.Mock };
+ };
+};
+
+describe('GET /api/timer', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ 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.timeEntry.findFirst.mockResolvedValue({ id: 'entry1', clientId: 'client1', startTime: new Date().toISOString(), status: 'RUNNING', client: { id: 'client1', name: 'C' } });
+
+ const res = await GET();
+ const json = await res.json();
+
+ expect(json.runningEntry).toBeDefined();
+ expect(json.runningEntry.status).toBe('RUNNING');
+ expect(json.runningEntry.client).toBeDefined();
+ });
+
+ 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.timeEntry.findFirst.mockResolvedValue(null);
+
+ const res = await GET();
+ const json = await res.json();
+
+ expect(json.runningEntry).toBeNull();
+ });
+});
diff --git a/__tests__/api/timer.post.test.ts b/__tests__/api/timer.post.test.ts
new file mode 100644
index 0000000..66d6cbf
--- /dev/null
+++ b/__tests__/api/timer.post.test.ts
@@ -0,0 +1,74 @@
+// Mock prisma, next-auth, auth and env before importing the route module
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ user: { upsert: jest.fn() },
+ client: { findFirst: jest.fn() },
+ timeEntry: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), findUnique: jest.fn() },
+ },
+}));
+jest.mock('next-auth', () => ({ getServerSession: jest.fn() }));
+jest.mock('@/auth', () => ({ authOptions: {} }));
+jest.mock('@/lib/env', () => ({ validateEnv: () => {} }));
+
+const { getServerSession } = jest.requireMock('next-auth') as { getServerSession: jest.Mock };
+let POST: (req: Request) => Promise;
+beforeAll(async () => {
+ const mod = await import('../../app/api/timer/route');
+ POST = mod.POST;
+});
+
+const { prisma: mockPrisma } = jest.requireMock('@/lib/prisma') as {
+ prisma: {
+ user: { upsert: jest.Mock };
+ client: { findFirst: jest.Mock };
+ timeEntry: { findFirst: jest.Mock; create: jest.Mock; update: jest.Mock; findUnique: jest.Mock };
+ };
+};
+
+describe('POST /api/timer', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('creates a running time entry when none exists', async () => {
+ // Mock session
+ (getServerSession as jest.Mock).mockResolvedValue({ user: { email: 'test@example.com', name: 'Test' } });
+
+ // Mock upsert user
+ mockPrisma.user.upsert.mockResolvedValue({ id: 'user1', email: 'test@example.com' });
+ // Mock client lookup
+ mockPrisma.client.findFirst.mockResolvedValue({ id: 'client1', userId: 'user1' });
+ // No existing running entry
+ mockPrisma.timeEntry.findFirst.mockResolvedValue(null);
+ // Create returns new entry
+ mockPrisma.timeEntry.create.mockResolvedValue({ id: 'entry1', startTime: new Date().toISOString(), clientId: 'client1', userId: 'user1', status: 'RUNNING' });
+
+ const req = new Request('http://localhost/api/timer', {
+ method: 'POST',
+ body: JSON.stringify({ clientId: 'client1', startTime: new Date().toISOString() }),
+ });
+
+ const res = await POST(req as Request);
+ const json = await res.json();
+
+ expect(json.timeEntry).toBeDefined();
+ expect(json.timeEntry.status).toBe('RUNNING');
+ });
+
+ it('returns 409 when a running timer already exists', async () => {
+ (getServerSession as jest.Mock).mockResolvedValue({ user: { email: 'test@example.com', name: 'Test' } });
+ mockPrisma.user.upsert.mockResolvedValue({ id: 'user1', email: 'test@example.com' });
+ mockPrisma.client.findFirst.mockResolvedValue({ id: 'client1', userId: 'user1' });
+ mockPrisma.timeEntry.findFirst.mockResolvedValue({ id: 'existing', userId: 'user1', status: 'RUNNING' });
+
+ const req = new Request('http://localhost/api/timer', {
+ method: 'POST',
+ body: JSON.stringify({ clientId: 'client1', startTime: new Date().toISOString() }),
+ });
+
+ const res = await POST(req as Request);
+ const json = await res.json();
+
+ expect(json.error).toBe('A timer is already running');
+ });
+});
diff --git a/__tests__/api/timer.put.test.ts b/__tests__/api/timer.put.test.ts
new file mode 100644
index 0000000..3bc9ac9
--- /dev/null
+++ b/__tests__/api/timer.put.test.ts
@@ -0,0 +1,59 @@
+import { PUT } from '../../app/api/timer/[id]/route';
+import { getServerSession } from 'next-auth';
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ user: { upsert: jest.fn() },
+ client: { findFirst: jest.fn() },
+ timeEntry: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), findUnique: jest.fn() },
+ },
+}));
+jest.mock('next-auth', () => ({ getServerSession: jest.fn() }));
+
+const { prisma: mockPrisma } = jest.requireMock('@/lib/prisma') as {
+ prisma: {
+ user: { upsert: jest.Mock };
+ client: { findFirst: jest.Mock };
+ timeEntry: { findFirst: jest.Mock; create: jest.Mock; update: jest.Mock; findUnique: jest.Mock };
+ };
+};
+
+describe('PUT /api/timer/:id', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('completes an existing running entry', async () => {
+ (getServerSession as jest.Mock).mockResolvedValue({ user: { email: 'test@example.com' } });
+ mockPrisma.user.upsert.mockResolvedValue({ id: 'user1', email: 'test@example.com' });
+ mockPrisma.timeEntry.findUnique.mockResolvedValue({ id: 'entry1', userId: 'user1' });
+ mockPrisma.timeEntry.update.mockResolvedValue({ id: 'entry1', endTime: new Date().toISOString(), status: 'COMPLETED' });
+
+ const req = new Request('http://localhost/api/timer/entry1', {
+ method: 'PUT',
+ body: JSON.stringify({ endTime: new Date().toISOString() }),
+ });
+
+ const res = await PUT(req as Request, { params: Promise.resolve({ id: 'entry1' }) } as { params: Promise<{ id: string }> });
+ const json = await res.json();
+
+ expect(json.timeEntry).toBeDefined();
+ expect(json.timeEntry.status).toBe('COMPLETED');
+ });
+
+ it('returns 404 for non-owner or missing entry', async () => {
+ (getServerSession as jest.Mock).mockResolvedValue({ user: { email: 'test@example.com' } });
+ mockPrisma.user.upsert.mockResolvedValue({ id: 'user1', email: 'test@example.com' });
+ mockPrisma.timeEntry.findUnique.mockResolvedValue(null);
+
+ const req = new Request('http://localhost/api/timer/doesnotexist', {
+ method: 'PUT',
+ body: JSON.stringify({ endTime: new Date().toISOString() }),
+ });
+
+ const res = await PUT(req as Request, { params: Promise.resolve({ id: 'doesnotexist' }) } as { params: Promise<{ id: string }> });
+ const json = await res.json();
+
+ expect(json.error).toBe('Time entry not found');
+ });
+});
diff --git a/app/api/timer/[id]/route.ts b/app/api/timer/[id]/route.ts
index c675129..9d99a3c 100644
--- a/app/api/timer/[id]/route.ts
+++ b/app/api/timer/[id]/route.ts
@@ -1,6 +1,7 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/auth';
import { prisma } from '@/lib/prisma';
+import { TimeEntryStatus } from '@prisma/client';
import { validateEnv } from '@/lib/env';
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
@@ -29,13 +30,16 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
},
});
+ const existing = await prisma.timeEntry.findUnique({ where: { id } });
+ if (!existing || existing.userId !== user.id) {
+ return Response.json({ error: 'Time entry not found' }, { status: 404 });
+ }
+
const updatedEntry = await prisma.timeEntry.update({
- where: {
- id,
- userId: user.id,
- },
+ where: { id },
data: {
endTime: new Date(endTime),
+ status: TimeEntryStatus.COMPLETED,
},
});
return Response.json({ timeEntry: updatedEntry });
diff --git a/app/api/timer/route.ts b/app/api/timer/route.ts
index 6dc2c37..cf580f3 100644
--- a/app/api/timer/route.ts
+++ b/app/api/timer/route.ts
@@ -1,6 +1,7 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/auth';
import { prisma } from '@/lib/prisma';
+import { TimeEntryStatus } from '@prisma/client';
import { validateEnv } from '@/lib/env';
export async function POST(req: Request) {
@@ -40,13 +41,23 @@ export async function POST(req: Request) {
return Response.json({ error: 'Client not found' }, { status: 404 });
}
- // Create time entry
+ // Ensure there is no other running entry for this user
+ 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 });
+ }
+
+ // Create time entry with status RUNNING
const timeEntry = await prisma.timeEntry.create({
data: {
startTime: new Date(startTime),
endTime: endTime ? new Date(endTime) : null,
clientId,
userId: user.id,
+ status: TimeEntryStatus.RUNNING,
},
});
@@ -75,16 +86,16 @@ export async function GET() {
},
});
- const inProgressEntries = await prisma.timeEntry.findMany({
+ const runningEntry = await prisma.timeEntry.findFirst({
where: {
userId: user.id,
- endTime: null,
+ status: 'RUNNING',
},
include: {
client: true,
},
});
- return Response.json({ inProgressEntries });
+ return Response.json({ runningEntry });
} catch (error) {
console.error('Error fetching in-progress time entries:', error);
return Response.json({ error: 'Internal server error' }, { status: 500 });
diff --git a/app/timer/page.tsx b/app/timer/page.tsx
index ce5f944..cefcbd2 100644
--- a/app/timer/page.tsx
+++ b/app/timer/page.tsx
@@ -31,6 +31,33 @@ 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;
+ };
+ }, []);
+
useEffect(() => {
let interval: NodeJS.Timeout;
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 0000000..82c51a5
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,27 @@
+/** @type {import('jest').Config} */
+const config = {
+ testEnvironment: 'jsdom',
+ testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'],
+ transform: {
+ '^.+\\.(ts|tsx)$': ['ts-jest', {
+ tsconfig: {
+ jsx: 'react-jsx',
+ paths: {} // disable tsconfig path mapping in ts-jest
+ }
+ }],
+ },
+ moduleNameMapper: {
+ '^@/auth$': '/auth.ts', // specific override first
+ '^@/(.*)$': '/app/$1', // matches tsconfig: @/* -> ./app/*
+ },
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
+ setupFilesAfterEnv: ['/jest.setup.ts'],
+ collectCoverageFrom: [
+ 'app/**/*.{ts,tsx}',
+ 'components/**/*.{ts,tsx}',
+ '!**/node_modules/**',
+ '!**/.next/**',
+ ],
+};
+
+module.exports = config;
\ No newline at end of file
diff --git a/jest.config.ts b/jest.config.ts
deleted file mode 100644
index a95f358..0000000
--- a/jest.config.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import type { Config } from 'jest';
-
-const config: Config = {
- testEnvironment: 'jsdom',
- testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'],
- transform: {
- '^.+\\.(ts|tsx)$': 'ts-jest',
- },
- moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
- setupFilesAfterEnv: ['/jest.setup.ts'],
- collectCoverageFrom: [
- 'app/**/*.{ts,tsx}',
- 'components/**/*.{ts,tsx}',
- '!**/node_modules/**',
- '!**/.next/**',
- ],
-};
-
-export default config;
diff --git a/jest.setup.ts b/jest.setup.ts
index 0ceaa48..cd6e75e 100644
--- a/jest.setup.ts
+++ b/jest.setup.ts
@@ -1,4 +1,64 @@
import '@testing-library/jest-dom';
import { TextEncoder, TextDecoder } from 'util';
-global.TextEncoder = TextEncoder;
-global.TextDecoder = TextDecoder;
+
+// Ensure TextEncoder/TextDecoder exist before any runtime shims
+(global as unknown as { TextEncoder: typeof TextEncoder }).TextEncoder = TextEncoder;
+(global as unknown as { TextDecoder: typeof TextDecoder }).TextDecoder = TextDecoder;
+
+// Minimal Request polyfill for Node/Jest environment used by API route tests
+// The real Request has more behavior; this provides `json()` and stores body/method.
+class TestRequest {
+ url: string;
+ method?: string;
+ headers?: Record;
+ private _body?: string | undefined;
+ constructor(url: string, init?: { method?: string; headers?: Record; body?: string | undefined }) {
+ this.url = url;
+ this.method = init?.method;
+ this.headers = init?.headers;
+ this._body = init?.body;
+ }
+ async json() {
+ if (this._body === undefined) return null;
+ return JSON.parse(this._body);
+ }
+ text() {
+ return Promise.resolve(this._body ?? '');
+ }
+}
+
+(global as unknown as { Request?: unknown }).Request = TestRequest;
+
+// Basic env required by validateEnv and next-auth during tests
+process.env.DATABASE_URL = process.env.DATABASE_URL || 'postgresql://test:test@localhost:5432/test';
+process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET || 'test-secret';
+process.env.NEXTAUTH_URL = process.env.NEXTAUTH_URL || 'http://localhost:3000';
+
+// Minimal Response.json helper used by Next.js app route handlers in tests
+// Provide a minimal Response-like object used by app route handlers in tests
+class TestResponse {
+ body: unknown;
+ status?: number;
+ ok: boolean;
+ constructor(body: unknown, init?: { status?: number }) {
+ this.body = body;
+ this.status = init?.status;
+ this.ok = this.status === undefined ? true : this.status >= 200 && this.status < 300;
+ }
+ async json() {
+ return this.body;
+ }
+}
+
+if ((global as unknown as { Response?: unknown }).Response === undefined) {
+ (global as unknown as { Response?: unknown }).Response = {
+ json(body: unknown, init?: { status?: number }) {
+ return new TestResponse(body, init);
+ },
+ };
+}
+
+// Only assign TestRequest if a global Request is not already present
+if ((global as unknown as { Request?: unknown }).Request === undefined) {
+ (global as unknown as { Request?: unknown }).Request = TestRequest;
+}
\ No newline at end of file
diff --git a/prisma/migrations/20260403085905_add_timeentry_status/migration.sql b/prisma/migrations/20260403085905_add_timeentry_status/migration.sql
new file mode 100644
index 0000000..1d690c4
--- /dev/null
+++ b/prisma/migrations/20260403085905_add_timeentry_status/migration.sql
@@ -0,0 +1,9 @@
+-- CreateEnum
+CREATE TYPE "TimeEntryStatus" AS ENUM ('RUNNING', 'COMPLETED');
+
+-- AlterTable
+ALTER TABLE "TimeEntry" ADD COLUMN "status" "TimeEntryStatus" NOT NULL DEFAULT 'RUNNING';
+-- Backfill existing entries: if an entry already has an endTime, mark it COMPLETED
+UPDATE "TimeEntry"
+SET "status" = 'COMPLETED'
+WHERE "endTime" IS NOT NULL;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 247b6ee..a2983bf 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -42,6 +42,7 @@ model TimeEntry {
id String @id @default(cuid())
startTime DateTime
endTime DateTime?
+ status TimeEntryStatus @default(RUNNING)
createdAt DateTime @default(now())
clientId String
@@ -51,6 +52,11 @@ model TimeEntry {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
+enum TimeEntryStatus {
+ RUNNING
+ COMPLETED
+}
+
model Invoice {
id String @id @default(cuid())
// Human-readable invoice number, e.g. INV-001 (scoped per user)