Welcome to the Jose Madrid Salsa developer docs — explore features, APIs, and deployment guides.
Jose Madrid SalsaJMS Docs

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:

ColumnMeaning
clockInAt / clockOutAtPunch instants, both set by the server
clockInIp / clockOutIpIP address recorded at each punch
clockIn… / clockOut… Latitude, Longitude, AccuracyMetersDevice GPS fix at each punch, when available
notesNotes / 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:

  1. 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.
  2. 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.
  3. 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 with users:read and users: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 but DEVELOPER, 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 AuditLog row with action TIMECLOCK_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

PathRole
lib/timeclock.tsPure date, bucketing, and formatting logic
lib/timeclock-server.tsRange resolution and the page/API view model
app/api/account/timeclock/route.tsGET history, POST clock in
app/api/account/timeclock/clock-out/route.tsPOST clock out with notes
app/api/admin/timeclock/[id]/route.tsPATCH audited developer correction
app/admin/developer/timeclock/page.tsxDeveloper Console timeclock editor
lib/timeclock-developer.tsAll-employee punch view for that page
app/api/account/timeclock/[id]/route.tsPATCH developer-only clock-out edit
lib/timeclock-admin.tsCorrection validation
components/admin/timeclock/EditPunchDialog.tsxCorrection dialog
app/(public)/account/timeclock/page.tsxServer component, role gate
components/account/timeclock/LiveClock.tsxTicking clock, isolated
components/account/timeclock/getPunchLocation.tsOne-shot browser GPS fix, never rejects
components/account/timeclock/TimeClockPanel.tsxControls, 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?

Edit on GitHub

Last updated on

On this page