Timeclock
The staff timeclock in the account area — punch pairs, lunch breaks, IP and location logging, derived hours, pay-period selection, and audited admin corrections
Timeclock
Staff clock in and out at /account/timeclock. The page lives in the customer
account area, not the admin panel — it sits next to the Admin Panel link in
the account sidebar, so clocking in never requires entering the admin console.
Access is limited to STAFF, ADMIN, and DEVELOPER accounts (isStaff in
lib/rbac.ts). Everyone else sees a short notice instead of the clock. Each
user only ever sees and punches their own record.
The data model
Each clock-in/clock-out cycle is one TimeClockEntry row:
| Column | Meaning |
|---|---|
clockInAt / clockOutAt | Punch instants, both set by the server |
clockInIp / clockOutIp | IP address recorded at each punch |
clockIn… / clockOut… Latitude, Longitude, AccuracyMeters | Device GPS fix at each punch, when available |
notes | Notes / job duties, written once at clock-out |
clockOutAt is null while a shift is open. There is deliberately no stored
hours column — hours are always derived from the two timestamps, so a total
can never disagree with the record it came from.
Location
Clock In and Clock Out each ask the browser for one GPS fix
(getCurrentPosition, high accuracy, up to ten seconds for the fix and twenty
in all, so an ignored permission prompt cannot stall a punch) before sending
the punch, and the button reads Getting location… while it waits. The fix is
validated with Zod on the server (punchLocationSchema in
lib/timeclock-server.ts) and stored with the punch. The work history shows
each fix as coordinates with its accuracy radius, linked to Google Maps.
Location is best-effort, not required. If the browser has no geolocation,
the user refuses the prompt, or no fix arrives in time, the punch is still
recorded with the location columns left null, and the history shows a dash.
The IP address is logged either way. To make location mandatory instead, the
routes would reject a punch without location and the panel would need to
explain how to re-enable the permission.
Like the timestamp, the location comes from the person punching, but unlike the timestamp the server cannot produce it itself — a determined user can send coordinates of their choosing. Treat it as a strong signal, not proof.
The site-wide Permissions-Policy header disables geolocation; /account/timeclock
is one of two pages (with /waiver) granted geolocation=(self) in
packages/core/next-config.mjs, and tests/security-headers.test.ts pins that
list. The header is fixed per document, so a client-side <Link> from another
page would carry that page's denial with it; the account links to the
timeclock are therefore plain <a> tags that load the page fresh.
Lunch breaks
A break is not modelled specially. The user clocks out, clocks back in, and gets a second row on the same day. Day and period totals sum the pairs, so an 8:00–12:00 and a 13:00–17:00 punch add up to 8.00 hours on one day.
Why employees cannot alter entries
Three things together make the record tamper-evident for the person clocking in:
- Server-authoritative timestamps. The punch time comes from
new Date()inside the route handler. The browser never sends a time, so changing a laptop's clock changes nothing but the cosmetic display. - No employee mutation surface. The account API exposes a read, a clock-in, and a clock-out. There is no PATCH and no DELETE, and the clock-out only ever closes the caller's own single open row.
- Notes are write-once for the employee. They arrive as part of the clock-out mutation and the employee cannot edit them afterwards.
Developer corrections
A forgotten clock-out, or a punch made on the wrong day, is corrected by a
developer account (role DEVELOPER). Admins can see punch history but
cannot change it. There are two places to do it, both opening the same dialog
to set the clock-in, clock-out and job duties, with a required reason:
-
Developer Console → Timeclock (
/admin/developer/timeclock): every employee's punches for a date range (the same ±2 month window), filterable by employee, with each employee's hours for the range. -
The Time Clock card on a user's page in the admin console (
/admin/users/[id]). The card is shown to admins withusers:readandusers:write, but only developer accounts get the pencil. -
Times are entered as Zanesville wall time and converted on the server, so a developer in another time zone still sets the time the employee saw. A time the developer leaves untouched is not sent, so the stored instant, seconds included, is kept and a notes-only edit never changes paid hours. A wall time that names no single instant (the spring-forward gap or the repeated fall-back hour) is rejected rather than guessed.
-
PATCH /api/admin/timeclock/[id]returns 403 for every role butDEVELOPER, and rejects a clock-out before the clock-in or in the future, a shift over 24 hours, and times that overlap another punch for the same employee (overlaps would pay the same minutes twice). -
Every correction writes an
AuditLogrow with actionTIMECLOCK_CORRECTION: the developer, the employee, the reason, and the before and after values. The card shows who last corrected a punch, when, and why.
A correction always sets a clock-out, so it can never create a second open punch.
Developer clock-out edits
Developer accounts (role DEVELOPER) also see a pencil next to each clock-out
in their own timeclock window (/account/timeclock). It changes only that
punch's clock-out time, through PATCH /api/account/timeclock/[id], with the
same rules as the corrections above: a reason is required, the shift must end
after it starts, not in the future and within 24 hours, may not overlap another
punch, and the change is written to the audit log as TIMECLOCK_CORRECTION.
Every other role gets a 403, and the route only touches the caller's own punches.
One more consequence worth knowing:
Deleting the user deletes the history. The userId relation uses
onDelete: Cascade, matching every other user-owned record in this schema, so
removing an account through /api/admin/users/[id] takes its punches with it.
That path is therefore held to the owner accounts listed in
DATA_ERASURE_EMAILS (lib/developer/constants.ts) — holding users:write is
no longer enough to delete a user. Everyone else gets a 403.
The guarantees above cover the person clocking in; this guard covers deleting
through the admin console. If payroll records must survive even an owner deleting the account,
change the relation to onDelete: Restrict — note that this makes deleting any
user who has ever clocked in fail outright.
One open punch per user
A partial unique index enforces it at the database level, because an application-level check alone races on a double-click or a second tab:
CREATE UNIQUE INDEX "time_clock_entries_open_unique"
ON "time_clock_entries"("userId") WHERE "clockOutAt" IS NULL;Prisma's schema language cannot express a filtered index, so it exists only in the migration. The route still checks first, to return a readable error rather than a constraint violation.
The index is invisible to schema.prisma, so it was worth checking whether
Prisma would treat it as drift: prisma migrate diff after applying the
migration does not report it. Should a future reset ever drop it, re-add it
by hand — it is intentional, not leftover.
Hours and time zones
Punches are stored as UTC instants, but a work day is a local business day in
Zanesville, Ohio. lib/timeclock.ts buckets every instant through Intl
against a fixed America/New_York constant, so a daylight-saving transition
never shifts a shift onto the wrong day or drifts totals twice a year. A punch
is filed under the day it started, which keeps an overnight shift on one row.
Totals are shown both as decimal hours for payroll (8.25) and as readable
duration (8h 15m).
Pay periods
The user types a start and end date directly as MM/DD/YYYY — digits are masked
into slashes as they type, and Enter applies the range. There is no
calendar picker. Impossible dates such as 02/30/2026 are rejected rather than
rolled over into the next month.
The addressable window is two months either side of today. The server clamps whatever it receives to that window, so a hand-crafted query cannot page through unbounded history, and the UI re-displays the range the server actually used.
Files
| Path | Role |
|---|---|
lib/timeclock.ts | Pure date, bucketing, and formatting logic |
lib/timeclock-server.ts | Range resolution and the page/API view model |
app/api/account/timeclock/route.ts | GET history, POST clock in |
app/api/account/timeclock/clock-out/route.ts | POST clock out with notes |
app/api/admin/timeclock/[id]/route.ts | PATCH audited developer correction |
app/admin/developer/timeclock/page.tsx | Developer Console timeclock editor |
lib/timeclock-developer.ts | All-employee punch view for that page |
app/api/account/timeclock/[id]/route.ts | PATCH developer-only clock-out edit |
lib/timeclock-admin.ts | Correction validation |
components/admin/timeclock/EditPunchDialog.tsx | Correction dialog |
app/(public)/account/timeclock/page.tsx | Server component, role gate |
components/account/timeclock/LiveClock.tsx | Ticking clock, isolated |
components/account/timeclock/getPunchLocation.ts | One-shot browser GPS fix, never rejects |
components/account/timeclock/TimeClockPanel.tsx | Controls, period, history table |
The live clock repaints about 25 times a second and is kept in its own component so nothing else on the page re-renders with it. It is offset onto the server's time so the displayed clock matches what a punch will record.
How is this guide?
Last updated on