Skip to content

fix broken timer#61

Merged
grmbyrn merged 2 commits into
mainfrom
fix-timer
Apr 7, 2026
Merged

fix broken timer#61
grmbyrn merged 2 commits into
mainfrom
fix-timer

Conversation

@grmbyrn
Copy link
Copy Markdown
Owner

@grmbyrn grmbyrn commented Apr 3, 2026

Summary by CodeRabbit

  • New Features

    • Timer state now syncs with server in real-time, refreshing every second for accurate tracking across sessions.
    • UI now derives running state and elapsed time from server data so timers stay consistent across devices.
  • Bug Fixes

    • Starting a timer when one is already running returns the existing running timer instead of an error.
  • Refactor

    • Client timer logic moved to server-driven synchronization for simpler, more reliable state handling.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 3, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worklog Ready Ready Preview, Comment Apr 3, 2026 3:53pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 3, 2026

📝 Walkthrough

Walkthrough

POST /api/timer now returns the existing running TimeEntry object when one exists instead of a 409 error; GET /api/timer uses findUnique and returns { runningEntry: null } if none. Client timer moved from local-only to server-driven polling with a new local hook for precise ticks and resume validation.

Changes

Cohort / File(s) Summary
API: timer route & POST tests
app/api/timer/route.ts, __tests__/api/timer.post.test.ts
POST now returns { runningEntry } for an existing RUNNING entry instead of 409/{ error }. GET changed from upsert to findUnique, returning { runningEntry: null } when absent. Tests updated to assert the new response shape.
GET timer tests
__tests__/api/timer.get.test.ts
Prisma mock switched from user.upsert to user.findUnique in test stubs for both existing and non-existing running entry cases.
Timer page (client)
app/timer/page.tsx
Replaced local mount/resume interval logic with React Query useQuery(['timerRunning']) polling (1s when running). Derived isRunning, seconds, activeEntryId from server runningEntry; start/stop call endpoints and invalidate cache instead of directly mutating local timer.
New hook: local timer logic
app/timer/useTimer.ts
Added useTimer hook managing isRunning, seconds, startTime, activeEntryId with interval tick logic, startLocal/stopLocal/resetLocal, and resumeFromEntry that enforces a 24-hour resume window. Export added.

Sequence Diagram(s)

sequenceDiagram
    participant Client as TimerPage (Client)
    participant API as /api/timer (Server)
    participant DB as Prisma (Database)

    Client->>API: POST /api/timer (start)
    API->>DB: find existing RUNNING entry for user
    DB-->>API: existing running entry OR null
    alt existing running entry
        API-->>Client: 200 { runningEntry }
    else no running entry
        API->>DB: create new TimeEntry (RUNNING)
        DB-->>API: created entry
        API-->>Client: 201 { runningEntry }
    end

    Client->>API: GET /api/timer (polled every 1s when running)
    API->>DB: findUnique user -> include running TimeEntry
    DB-->>API: runningEntry or null
    API-->>Client: 200 { runningEntry }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped from local ticks to server streams,

where running entries glow in query dreams.
I guard the 24‑hour hop and keep the beat—
invalidate, refetch, and tap my feet!

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'fix broken timer' is vague and does not convey the specific nature of the changes, which involve transitioning from local timer state to server-driven state using React Query and updating API response behavior. Consider a more descriptive title such as 'Refactor timer to use server-driven state with React Query' or 'Switch timer from local to server-driven state management' to better communicate the primary architectural change.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-timer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
__tests__/api/timer.post.test.ts (1)

58-74: ⚠️ Potential issue | 🟡 Minor

Pin the new success contract in this test.

The test name still says returns 409, and it never asserts the HTTP status or that mockPrisma.timeEntry.create was skipped. A 409 response carrying { runningEntry } would still pass, so the behavior this PR changes is not fully locked in.

Suggested test tightening
-  it('returns 409 when a running timer already exists', async () => {
+  it('returns 200 with the existing running entry when a timer already exists', async () => {
@@
     const res = await POST(req as Request);
     const json = await res.json();
 
+    expect(res.status).toBe(200);
     expect(json.runningEntry).toBeDefined();
     expect(json.runningEntry.id).toBe('existing');
+    expect(mockPrisma.timeEntry.create).not.toHaveBeenCalled();
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@__tests__/api/timer.post.test.ts` around lines 58 - 74, The test name and
assertions don't lock in the new success contract: update the test for POST
(calling POST(req)) to assert the HTTP status is 200 (or the new expected
success code) and that the response JSON contains runningEntry with id
'existing'; also assert that mockPrisma.timeEntry.create was NOT called to
ensure creation was skipped. Keep references to the POST handler invocation,
mockPrisma.timeEntry.findFirst returning the 'RUNNING' entry, and
mockPrisma.timeEntry.create to verify it wasn't invoked.
app/api/timer/route.ts (1)

44-62: ⚠️ Potential issue | 🔴 Critical

Make the start path atomic.

Lines 45-62 still do a read-then-create without any locking or uniqueness guarantee. Two near-simultaneous POST /api/timer calls can both miss existingRunning and insert duplicate RUNNING rows; after that, GET /api/timer returns whichever row findFirst picks, and the UI only stops that one. Please enforce the single-running-entry invariant at the DB layer and make this path atomic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/timer/route.ts` around lines 44 - 62, Add a DB-level uniqueness
guarantee for a single RUNNING entry and make the start path handle that
atomically: add a database partial unique index (e.g. UNIQUE(user_id) WHERE
status='RUNNING') via your Prisma migration (or raw SQL in a migration) so the
DB enforces one RUNNING per user, then change the start handler to attempt the
create directly (prisma.timeEntry.create) inside a try/catch and on
unique-constraint failure (Prisma P2002 / SQL unique-violation) query and return
the existing running entry (prisma.timeEntry.findFirst) instead of returning an
error; this ensures atomicity even for concurrent POST /api/timer calls and
references the existing code paths using prisma.timeEntry.create and
prisma.timeEntry.findFirst in the route handler.
🧹 Nitpick comments (1)
app/timer/useTimer.ts (1)

5-87: Remove this duplicate timer implementation or wire the page through it.

app/timer/page.tsx:16-42 now owns timer state directly and never imports useTimer, so this new hook is currently dead code. Keeping two timer implementations around will drift quickly, especially since this one already has different resume rules.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/timer/useTimer.ts` around lines 5 - 87, There are two competing timer
implementations: the exported hook useTimer (with symbols like useTimer,
startLocal, stopLocal, resetLocal, resumeFromEntry) is unused while the page
owns timer state separately; either remove this dead hook file or refactor the
page to import and use useTimer so there's a single source of truth; if you keep
the hook, update the page to replace its local state with useTimer (preserving
resume rules) and ensure only one resume policy (MAX_RESUME_AGE_SECONDS) is used
across the app.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/timer/page.tsx`:
- Around line 16-24: The GET /api/timer handler is performing writes
(prisma.user.upsert) which, combined with useQuery's refetchInterval in
app/timer/page.tsx, causes a DB write every second; fix this by making the GET
handler read-only: remove or relocate the prisma.user.upsert call out of the GET
handler in app/api/timer/route.ts (e.g., move it to a POST/PUT endpoint that
only runs on explicit client actions) and ensure the GET handler only performs
reads (findUnique/findFirst) and returns timer state; alternatively, if you
cannot change the API now, stop continuous polling by removing/refactoring
refetchInterval in useQuery (app/timer/page.tsx) and instead enable polling
conditionally (enabled: Boolean(timerRunning) or only poll when the timer is
active).
- Around line 106-109: handleReset is currently a no-op when activeEntryId is
null, making the Reset button unusable because the button is disabled while
running and when stopped activeEntryId is null; update handleReset to perform
the reset unconditionally (e.g., call handleStop() or a new clear/reset routine
regardless of activeEntryId) and/or adjust the button disable logic so the Reset
button is enabled when you expect it to clear UI state; reference the
handleReset function and activeEntryId variable (and the button disabling logic
around isRunning) and ensure the reset routine clears local UI state and/or
invokes the server stop/reset endpoint even if activeEntryId is null.

---

Outside diff comments:
In `@__tests__/api/timer.post.test.ts`:
- Around line 58-74: The test name and assertions don't lock in the new success
contract: update the test for POST (calling POST(req)) to assert the HTTP status
is 200 (or the new expected success code) and that the response JSON contains
runningEntry with id 'existing'; also assert that mockPrisma.timeEntry.create
was NOT called to ensure creation was skipped. Keep references to the POST
handler invocation, mockPrisma.timeEntry.findFirst returning the 'RUNNING'
entry, and mockPrisma.timeEntry.create to verify it wasn't invoked.

In `@app/api/timer/route.ts`:
- Around line 44-62: Add a DB-level uniqueness guarantee for a single RUNNING
entry and make the start path handle that atomically: add a database partial
unique index (e.g. UNIQUE(user_id) WHERE status='RUNNING') via your Prisma
migration (or raw SQL in a migration) so the DB enforces one RUNNING per user,
then change the start handler to attempt the create directly
(prisma.timeEntry.create) inside a try/catch and on unique-constraint failure
(Prisma P2002 / SQL unique-violation) query and return the existing running
entry (prisma.timeEntry.findFirst) instead of returning an error; this ensures
atomicity even for concurrent POST /api/timer calls and references the existing
code paths using prisma.timeEntry.create and prisma.timeEntry.findFirst in the
route handler.

---

Nitpick comments:
In `@app/timer/useTimer.ts`:
- Around line 5-87: There are two competing timer implementations: the exported
hook useTimer (with symbols like useTimer, startLocal, stopLocal, resetLocal,
resumeFromEntry) is unused while the page owns timer state separately; either
remove this dead hook file or refactor the page to import and use useTimer so
there's a single source of truth; if you keep the hook, update the page to
replace its local state with useTimer (preserving resume rules) and ensure only
one resume policy (MAX_RESUME_AGE_SECONDS) is used across the app.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b19c8b28-d157-421f-822b-0e82ba0d7a75

📥 Commits

Reviewing files that changed from the base of the PR and between 7f6e1cf and 39f2894.

📒 Files selected for processing (4)
  • __tests__/api/timer.post.test.ts
  • app/api/timer/route.ts
  • app/timer/page.tsx
  • app/timer/useTimer.ts

Comment thread app/timer/page.tsx
Comment thread app/timer/page.tsx
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
app/timer/page.tsx (1)

109-120: ⚠️ Potential issue | 🟡 Minor

Dead code: the handleStop() branch is unreachable.

The Reset button is disabled when isRunning is true (line 200). When isRunning is false, runningEntry is null, which means activeEntryId is also null. Therefore, the condition at line 111 will never be true when handleReset is called—lines 111-114 are dead code.

If the intent is to allow Reset while the timer is running, change the button's disabled condition. Otherwise, remove the dead branch.

🧹 Suggested cleanup: remove dead code
 const handleReset = () => {
-    // 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'] });
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/timer/page.tsx` around lines 109 - 120, handleReset contains an
unreachable branch that checks activeEntryId and calls handleStop; remove that
dead branch so handleReset always clears selection, updates tick, and
invalidates the timer query (i.e., delete the if (activeEntryId) { handleStop();
return; } block). Keep the rest of handleReset (setSelectedClientId(''),
setTick(Date.now()), queryClient.invalidateQueries(...)); if you actually want
Reset to work while running instead, instead update the Reset button's disabled
prop (which currently uses isRunning) to allow clicks when running and keep the
handleStop branch.
🧹 Nitpick comments (1)
__tests__/api/timer.get.test.ts (1)

26-48: Tests correctly verify the updated GET behavior.

Consider adding a test case for when user.findUnique returns null (authenticated session but user not yet in database). Per app/api/timer/route.ts:91-93, this should return { runningEntry: null }.

🧪 Suggested additional test case
it('returns null when user not found in database', async () => {
  (getServerSession as jest.Mock).mockResolvedValue({ user: { email: 'new@example.com' } });
  mockPrisma.user.findUnique.mockResolvedValue(null);

  const res = await GET();
  const json = await res.json();

  expect(json.runningEntry).toBeNull();
  expect(mockPrisma.timeEntry.findFirst).not.toHaveBeenCalled();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@__tests__/api/timer.get.test.ts` around lines 26 - 48, Add a test to cover
the case where an authenticated session exists but the user is not in the DB:
mock getServerSession to resolve { user: { email: 'new@example.com' } }, set
mockPrisma.user.findUnique to resolve null, call GET(), parse res.json(), and
assert that json.runningEntry is null and that mockPrisma.timeEntry.findFirst
was not called; this verifies the GET handler path (route GET) which checks
user.findUnique before querying timeEntry.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@app/timer/page.tsx`:
- Around line 109-120: handleReset contains an unreachable branch that checks
activeEntryId and calls handleStop; remove that dead branch so handleReset
always clears selection, updates tick, and invalidates the timer query (i.e.,
delete the if (activeEntryId) { handleStop(); return; } block). Keep the rest of
handleReset (setSelectedClientId(''), setTick(Date.now()),
queryClient.invalidateQueries(...)); if you actually want Reset to work while
running instead, instead update the Reset button's disabled prop (which
currently uses isRunning) to allow clicks when running and keep the handleStop
branch.

---

Nitpick comments:
In `@__tests__/api/timer.get.test.ts`:
- Around line 26-48: Add a test to cover the case where an authenticated session
exists but the user is not in the DB: mock getServerSession to resolve { user: {
email: 'new@example.com' } }, set mockPrisma.user.findUnique to resolve null,
call GET(), parse res.json(), and assert that json.runningEntry is null and that
mockPrisma.timeEntry.findFirst was not called; this verifies the GET handler
path (route GET) which checks user.findUnique before querying timeEntry.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 99a3e2da-0512-4ba8-b573-629ae5378a26

📥 Commits

Reviewing files that changed from the base of the PR and between 39f2894 and cb882c5.

📒 Files selected for processing (3)
  • __tests__/api/timer.get.test.ts
  • app/api/timer/route.ts
  • app/timer/page.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/api/timer/route.ts

@grmbyrn grmbyrn merged commit fe792a8 into main Apr 7, 2026
4 checks passed
@grmbyrn grmbyrn deleted the fix-timer branch April 7, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant