The problem
My employer's policy is simple: 6 hours and 45 minutes after you clock in, you're eligible for lunch. Knowing when that is is the whole product. The catch is that "when you clocked in" lives inside a third-party HRMS page, in someone else's React tree, behind a session cookie, on a page that re-renders asynchronously.
That means the timer has to live somewhere outside the HRMS page. It also has to survive:
- The HRMS SPA re-rendering the DOM underneath the timer.
- Tab refreshes — the new render would otherwise wipe localStorage state.
- Cross-midnight — at 00:00 the "today" reference changes.
- Cross-TZ — the server's UTC clock silently disagrees with the user's local day for six hours.
- Friday/Saturday off-days (Sunday is a regular workday) and explicit leave days.
What it is
Two pieces, one source of truth:
- A Tampermonkey userscript that lives inside the Pathao HRMS page, reads the "Checked In" time from the DOM, renders an inline section at the bottom of the page with the live countdown, and silently POSTs the clock-in to the backend.
- A tiny Go HTTP service on the VPS that stores
one record per local day in a JSON file, computes a canonical
"state" payload, and serves that payload to:
- The userscript itself (so the countdown survives HRMS page refreshes),
- A standalone glassmorphism widget at
/api/lunch/widget, - A separate Chrome new-tab extension (LunchLine) that I run separately.
The state machine — five variants: Weekend,
OnLeave, NotClockedIn, ClockedIn,
Eligible — lives in computeState() on the Go side
and is mirrored 1:1 in the userscript so each surface can render
without round-tripping on every tick.
Architecture
/state every 60s
↘ widget page one-shot render at /widget
↘ LunchLine Chrome new-tab polls /stateSingle source of truth
computeState(snapshot, now) in main.go
is the one place the variant decision is made. The
/api/lunch/state endpoint, the widget HTML, and
the userscript's mirror all consume the same contract.
No external services
One binary, one JSON file, one systemd unit, one Nginx vhost. Total RAM usage on the VPS: ~10 MB. No database, no Docker, no message queue. Drop the binary, restart, done.
Atomic writes
Every clock-in is written to a tmp file, fsynced, then
rename(2)'d over the destination. A crash
mid-write cannot corrupt data.json.
Cross-TZ correctness
time.Local is pinned to
Asia/Dhaka at boot (override via
LUNCH_TZ). The variant decision and date
string both render in the user's local zone — not UTC.
Tech stack & why
| Layer | Choice | Why |
|---|---|---|
| Backend | Go · stdlib only | Six routes. A framework would be more code, not less. Zero deps means the cross-compiled binary just runs on the VPS. |
| Storage | Single JSON file | One user, at most five rows per week. A Postgres container is operational overkill.
cp is a complete backup. |
| Atomic write | tmp + fsync + rename(2) | Survives SIGKILL and power loss without half-written state. |
| TLS / proxy | Nginx | Already on the VPS. Reverse-proxy /api/lunch/* to the Go service; CORS and
shared-secret validation live here. |
| Supervision | systemd | Restart-on-failure with a 2-second back-off; logs go to journald. |
| Userscript | Vanilla JS + Tampermonkey APIs | No build step, no framework. GM_setValue survives SPA navigations; MutationObserver
watches the DOM. |
| Widget | Server-rendered HTML + embedded JS | The variant decision is server-side; the page embeds targetMs so the live tick is
pure JS arithmetic. |
| Timezone | Pin time.Local = Asia/Dhaka | A VPS runs UTC. Without this, every "today" string silently disagreed with the user's local day for 6 hours. |
What it does
Live countdown in the HRMS page
An inline section appended to <body> with a
HH:MM:SS tick down to the 6h 45m threshold. Survives SPA
re-renders, tab refreshes, and 00:00 rollovers. Toggles to a
green "Eligible — step away!" pill at the threshold.
Friday/Saturday off-day handling
The variant decision treats Friday and Saturday as
Weekend (no countdown, no leave button). Sunday
is a regular workday — verified by a regression test in
state_test.go that pins the policy.
Leave toggle
One button on the inline section flips today's
Leave flag. The flag survives HRMS SPA
navigations via GM_setValue and clears at local
midnight.
Clock-out & frozen verdict
Pressing "Clock out" captures the user's wall-clock time,
POSTs it to /api/lunch/clockout, and freezes the
section into a final ClockedOut verdict: either
"Eligible for lunch (Worked 7h 45m)" or "Not eligible
(Worked 4h 12m)" — held until midnight.
Phone-friendly standalone widget
/api/lunch/widget is a self-contained
dark-glassmorphism HTML page rendered server-side from the
same state machine. Open it from any device; the live tick
uses targetMs embedded in the HTML so it never
re-fetches.
Per-day history table
The widget renders every persisted record — date, clock-in,
clock-out, computed target — so the previous week's pattern
is auditable at a glance. Records roll over on the next
Sunday: data.json is bounded to one workweek
(≤ 5 rows).
Engineering challenges
Every project has bugs that cost an evening. The seven below are the ones that actually bit — each one is grounded in a commit, a test, or a code path you can read.
Sunday should be a workday, not a weekend
Symptom. Opening the widget on a Sunday
rendered Weekend — Off day — enjoy!, and the
userscript hid the countdown.
Why the obvious approach wasn't enough.
The first version of isPathaoWeekend treated
Fri/Sat/Sun as a weekend — that's the calendar weekend in
Bangladesh but not the company's policy.
Fix. isPathaoWeekend now returns
true only on Friday and Saturday. Sunday is a workday in both
the Go backend and the userscript mirror.
Guard. TestSundayNotWeekend and
TestFriSatWeekend in state_test.go
pin the policy across three separate days.
Widget stuck on yesterday's data
Symptom. Open the widget on Monday morning and it shows Sunday's clock-in, the Sunday status, and a 0-second countdown.
Cause. handleWidget originally
looked up "the most recently saved record" and rendered that.
After midnight on a weekend, that record was a weekday entry
the user didn't actually want to see today.
Fix. handleWidget now always
calls computeState(snapshot, time.Now()) so the
active variant is "today" in the server's local TZ. The
history table at the bottom still shows every record for
auditing.
Mach-O binary deployed to a Linux VPS
Symptom.
$ curl -s https://<your-domain>/api/lunch/state | jq .
jq: parse error: Invalid numeric literal at line 1, column 7Cause. I'd built lunch-tracker.linux
on macOS without GOOS=linux GOARCH=amd64, so the
artifact was a Mach-O arm64 binary. The Linux VPS couldn't
execve() it → systemd reported
status=203/EXEC → Nginx returned a 502 HTML page
→ jq tried to parse HTML as JSON. The MD5 of the binary
matched what I thought I shipped, which made the mismatch
harder to spot. file lunch-tracker.linux would
have caught it instantly.
Fix. The Makefile's linux
target now always cross-compiles with GOOS=linux
GOARCH=amd64 and runs file on the output
as a guard. make ship prints the
scp/ssh/restart/curl sequence.
Date showed Aug 8 when today is Aug 9
Symptom. It was Aug 9 (Sunday) in
Asia/Dhaka. The VPS clock was UTC, around 18:00 on Aug 8. The
widget and /api/lunch/state both returned
"date": "2026-08-08".
Cause.
time.Now().Format("2006-01-02") returned the
server's local date (UTC), not the user's. Around
18:00–00:00 UTC the date and day-of-week seen by the API
silently disagreed with what the user in BD was experiencing.
Fix. resolveLocalTZ() loads
Asia/Dhaka and assigns it to
time.Local at boot. The LUNCH_TZ
env var lets non-BD deployments override it.
TestDateRolloverAcrossTimezones and
TestProductionHotPath pin the behaviour.
Subtle point that bit us once: time.Time.Format
formats against the time's location, not
time.Local. So now.Format(...) is
fine once time.Local = Asia/Dhaka, but
t.Format(...) for a t built in UTC
will still format against UTC. The fix in
parseClockInAt is to construct the parsed time
in the anchor's location to begin with.
HRMS sidebar overshadowing the inline section
Symptom. When the Pathao left sidebar was expanded, it visually covered the inline section at the bottom of the page.
Cause. Pathao's sidebar uses
position:fixed/relative with a high
z-index when expanded. The original floating
widget sat at a fixed position that the sidebar would overlap.
Fix. The section is now appended to
<body> with
position:relative; z-index: 2147483000 so it
paints on top of any overlapping sidebar element. Earlier
attempts to mount the section inside an HRMS-supplied grid
column disappeared after the SPA re-rendered, so we reverted
to document.body.appendChild. The
document.body.appendChild approach is dumb but
robust — Pathao's re-render path doesn't reach our node.
Preflight from the Chrome new-tab page
Symptom. LunchLine's Chrome new-tab page
couldn't read /api/lunch/state from
https://www.google.com.
Cause. The default CORS handler only allowed
*.pathao.com and the production domain. A
cross-origin fetch from www.google.com needed
that origin on the allow-list.
Fix. Added https://www.google.com
(and chrome-extension://*) to
allowedOrigins. The preflight
(OPTIONS) is short-circuited with
204 No Content and a 24-hour
Access-Control-Max-Age.
Leave-clear silently failed
Symptom. Clicking "Clear leave" on the
widget or the userscript visibly fired the request, but the
UI kept rendering OnLeave on the next refresh.
Cause. handleLeave had a string
field for the body's leave flag and ignored it
— every POST stored Leave: true, regardless of
what the body said. The two near-identical render functions
(mark / clear) had drifted so far apart that the "clear"
path was, structurally, the "mark" path with a different
label.
Fix. The field is parsed as
*bool (pointer so we can distinguish "absent"
from "false"), defaults to true for backwards
compatibility with the widget's "Mark on leave" button, and
the value is written through to the store.
TestOnLeaveTransition is a regression test
against this specific bug.
API surface
Six routes. All return JSON except /api/lunch/widget
which renders HTML and /healthz which returns JSON.
CORS is allow-listed at the Nginx layer; the same list is mirrored
in allowedOrigins for browser-origin callers.
/api/lunch/clockinPersist (or replace) today's clock-in. Idempotent per date.
Request
{
"date": "2026-08-06",
"clockIn": "9:31:02 AM"
}Response
{
"ok": true,
"savedAt": 1754470860,
"record": {
"date": "2026-08-06",
"clockIn": "9:31:02 AM",
"savedAt": 1754470860,
"leave": false
}
}/api/lunch/clockoutRecord today's clock-out. Merges with any existing record so the prior clock-in and Leave flag survive.
Request
{
"date": "2026-08-06",
"clockOut": "5:15:00 PM"
}Response
{
"ok": true,
"savedAt": 1754499300,
"record": { "date": "2026-08-06", "clockIn": "9:31:02 AM", "clockOut": "5:15:00 PM", "leave": false }
}/api/lunch/leaveMark or clear the leave flag for a date.
Request
{ "date": "2026-08-09", "leave": true }Response
{ "ok": true, "date": "2026-08-09", "leave": true }/api/lunch/stateThe canonical state payload. Polled every 60s by the userscript and LunchLine.
Response
{
"status": "ClockedIn",
"date": "2026-08-09",
"clockIn": "9:31:02 AM",
"targetExit": "4:16:02 PM",
"targetMs": 1754735762000,
"savedAt": 1754729400,
"eligible": false,
"weekend": false,
"leave": false,
"message": "06:31:18 until lunch eligibility",
"updatedAt": 1754729400,
"nowMs": 1754732160000
}Fields worth knowing
status— canonical variant. The widget template and userscript both branch on this string.targetMs— unix-ms of the threshold; widget uses it for the live tick without re-fetching.nowMs— for client-side clock-drift detection.
/api/lunch/widgetSelf-contained HTML widget (dark glassmorphism). Server-rendered from the same state machine.
/healthzLiveness probe. access_log off in Nginx so uptime checks don't fill the log.
{ "ok": true, "records": 5, "time": "2026-08-15T20:15:28+06:00" }The state machine
computeState(snapshot, now) is the one place the
variant decision is made. Order matters — the function returns
on the first matching branch.
- Weekend
Today is Friday or Saturday. No leave button, no countdown. Even if a clock-in is stored for the day, the active card still reads "Off day — enjoy!" — the record appears only in the history table.
- OnLeave
The user pressed "Mark on leave" (or there's a stored record with
Leave: true). Card shows "Marked as on leave" with a "Clear leave" button. - NotClockedIn
Weekday, no record for today, no leave flag. Card shows "Not clocked in yet today" with a "Mark on leave" button.
- ClockedIn
Clock-in exists and
now < clockIn + 6h45m. Live HH:MM:SS countdown. Pill turns cyan when ≤ 5 minutes remain; switches to green at the threshold. - Eligible
now ≥ clockIn + 6h45m. Green pill "Eligible — step away!" with the elapsed-since-threshold time surfaced. - ClockedOut
User pressed "Clock out" or the day has ended. Worked-time is frozen at the clock-out instant; the verdict is "Eligible for lunch (Worked 7h 45m)" or "Not eligible (Worked 4h 12m)".
The userscript mirrors this decision tree in JavaScript so the inline section can re-render at 1 Hz without a network round-trip on every tick. The Go side is the source of truth for the widget; the JS is the source of truth for the HRMS page.
Data model & lifecycle
One record per local YYYY-MM-DD:
{
"2026-08-09": {
"date": "2026-08-09",
"clockIn": "9:31:02 AM",
"clockOut":"5:15:00 PM",
"savedAt": 1754729400,
"leave": false
},
"2026-08-10": { ... }
}The map is keyed by date so "give me today" is
O(1) and duplicates per date are impossible by
construction. On any write, every record that predates the
most-recent Sunday is dropped — bounding the file to one
Pathao workweek (≤ 5 rows). A fresh Sunday therefore wipes the
previous week automatically.
pruneOldRecordsLocked, called inside
Store.Set under the write lock. Every clock-in
write passes through it.Testing
The Go side ships 13 unit tests across two files;
the userscript has no automated test (a jsdom harness exists in
package.json but is currently dormant). Tests run
in well under 100 ms with go test ./....
State machine (state_test.go)
TestSundayNotWeekend·TestFriSatWeekend— pins the Friday/Saturday off-day policy.TestDateRolloverAcrossTimezones·TestProductionHotPath— pins the Asia/Dhaka rollover.TestOnLeaveTransition— regression for the leave-clear bug.TestClockedOutWorkedMath·TestClockedOutWorkedIsFrozen·TestClockedOutMalformedDoesNotCorruptRecord— clock-out math and parser resilience.TestComputeStateIgnoresFutureClockInForToday·TestComputeStateHonorsFutureClockInForPastDate— the "stale HRMS text on a new day" guard.TestHandleClockOutMergePreservesClockInAndLeave·TestHandleClockOutRejectsWithoutClockIn— HTTP-layer merge logic.
CORS (cors_test.go)
TestMatchOrigin— exact, wildcard-suffix, prefix, empty.TestOriginAllowed— full allow-list positive and negative cases.TestOriginOrStar— echo-origin vs*.
Storage (state_test.go):
TestStoreRetainsOnlyCurrentWorkweek,
TestStoreUnderCapNoPrune,
TestStoreSundayRolloverWipesPreviousWeek.
Deployment
From source to production in one command, with a binary guard that catches the most embarrassing bug:
make ship # prints:
# GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o lunch-tracker.linux main.go
# file lunch-tracker.linux # ← must say ELF, not Mach-O
# scp lunch-tracker.linux vps:/tmp/lunch-tracker.linux.new
# ssh vps 'sudo install -m 0755 /tmp/lunch-tracker.linux.new /usr/local/bin/lunch-tracker && \
# sudo systemctl restart lunch-tracker.service'
# curl -s https://<your-domain>/api/lunch/state | jq .Nginx
Single server block, TLS terminated at Nginx, reverse-proxy
/api/lunch/* to the local Go service.
proxy_read_timeout 15s matches the
Go WriteTimeout; the shared secret check is
performed at the Nginx layer so the Go backend never sees
unauthenticated traffic.
systemd
lunch-tracker.service restarts on failure with
a 2-second back-off. Logs flow to journald. The service
runs as www-data with the working directory
pinned to /srv/lunch-tracker.
Lessons learned
Picking a timezone once, at boot, is a force multiplier
Every other date / weekday / 00:00-rollover bug in this
codebase went away once time.Local was pinned
at boot. The fix is one line in main; the bug
class it eliminated took three evenings to find in the first
place.
Stdlib is enough until it isn't
Six routes, no middleware, no framework. The Go stdlib
mux + http.Server with explicit timeouts is
fewer lines than a router would be, and zero deps means the
cross-compiled binary just runs on the VPS. Resist the
framework reflex.
Two near-identical functions will diverge
The leave-clear bug came from two render functions that were 90% the same. The fix was a single function with a boolean parameter, plus a regression test. Single-source-of-truth for any UI flow that has symmetric "in / out" semantics.
Atomic file writes are not a luxury
os.CreateTemp + Sync +
os.Rename is ~10 lines of code and makes a
crash mid-write a non-event. For a single-file store, this
is the only correct pattern.
Mirror the source of truth at the edge
The Go side computes the variant once; the userscript mirrors the same logic in JS so the HRMS page can render at 1 Hz without round-tripping. The trade-off is two implementations to keep in sync, gated by tests on the Go side and shared comment blocks on both sides.
Deployment guards pay for themselves
The file lunch-tracker.linux guard is a single
shell line that prevents an entire class of "the
production server is up but returns garbage" debugging
sessions. make linux runs it automatically.
Source & live system
Code samples
Five functions straight out of main.go. The same
conventions show up across the codebase — small, locked,
explicit about failure.
Strict JSON decoding
Every POST handler routes its body through
decodeJSON. The DisallowUnknownFields()
call catches client typos before they silently end up as
zero-values on disk.
| |
Clock-in guard against stale UI text
The Pathao HRMS page sometimes serves a stale "Checked In"
timestamp from the previous day. The +15m guard
rejects any record noticeably in the future, so the persistent
store never silently accumulates ghost entries.
| |
Bounded workweek pruning
pruneOldRecordsLocked is invoked on every write.
The Pathao workweek runs Sunday→Thursday, so anything strictly
before last Sunday is dropped — the in-memory map is
effectively bounded to ~5 rows at all times.
| |
Atomic file writes
Every Set lands in data.json via
temp file → fsync → rename. A crash anywhere in the
sequence leaves the previous file intact — there is no
half-written JSON on disk, ever.
| |
CORS origin matching with wildcards
The widget runs on the production domain, the userscript on
Pathao's HRMS, and the LunchLine extension on a unique
chrome-extension://abcdefgh… origin. The matcher
handles both exact matches and the literal "*"
wildcard.
| |