Engineering Case Study

Lunch Tracker

A two-part system that keeps a personal lunch-eligibility countdown in sync between the Pathao HRMS web UI and a tiny Go backend on a VPS — so you always know when you're allowed to step away.

  • Role Sole author · backend, userscript, deploy
  • Stack Go (stdlib) · Vanilla JS · Tampermonkey · Nginx · systemd
  • Status Live since Aug 2026
  • Surface HRMS userscript · standalone widget · Chrome new-tab (LunchLine)
01

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.
02

What it is

Two pieces, one source of truth:

  1. 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.
  2. 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.

03

Architecture

👤 User on Pathao HRMS
📄 HRMS React page renders <h6>Checked In</h6>
🧩 Tampermonkey userscript MutationObserver + polling
↓ HTTPS POST
🌐 Nginx TLS · /api/lunch/* → 127.0.0.1:5000
⚙️ Go service :5000 stdlib only · 6 routes · < 10MB RAM
↓ atomic tmp + rename
💾 data.json one record per local YYYY-MM-DD
↘ userscript polls /state every 60s ↘ widget page one-shot render at /widget ↘ LunchLine Chrome new-tab polls /state

Single 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.

04

Tech stack & why

LayerChoiceWhy
BackendGo · stdlib onlySix routes. A framework would be more code, not less. Zero deps means the cross-compiled binary just runs on the VPS.
StorageSingle JSON fileOne user, at most five rows per week. A Postgres container is operational overkill. cp is a complete backup.
Atomic writetmp + fsync + rename(2)Survives SIGKILL and power loss without half-written state.
TLS / proxyNginxAlready on the VPS. Reverse-proxy /api/lunch/* to the Go service; CORS and shared-secret validation live here.
SupervisionsystemdRestart-on-failure with a 2-second back-off; logs go to journald.
UserscriptVanilla JS + Tampermonkey APIsNo build step, no framework. GM_setValue survives SPA navigations; MutationObserver watches the DOM.
WidgetServer-rendered HTML + embedded JSThe variant decision is server-side; the page embeds targetMs so the live tick is pure JS arithmetic.
TimezonePin time.Local = Asia/DhakaA VPS runs UTC. Without this, every "today" string silently disagreed with the user's local day for 6 hours.
05

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).

06

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.

Timezones

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.

Bug

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.

Deployment

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 7

Cause. 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.

Timezones

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.

UI

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.

CORS

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.

Reliability

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.

07

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.

POST /api/lunch/clockin

Persist (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
  }
}
POST /api/lunch/clockout

Record 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 }
}
POST /api/lunch/leave

Mark or clear the leave flag for a date.

Request

{ "date": "2026-08-09", "leave": true }

Response

{ "ok": true, "date": "2026-08-09", "leave": true }
GET /api/lunch/state

The 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.
GET /api/lunch/widget

Self-contained HTML widget (dark glassmorphism). Server-rendered from the same state machine.

GET /healthz

Liveness 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" }
08

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.

  1. 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.

  2. 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.

  3. NotClockedIn

    Weekday, no record for today, no leave flag. Card shows "Not clocked in yet today" with a "Mark on leave" button.

  4. 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.

  5. Eligible

    now ≥ clockIn + 6h45m. Green pill "Eligible — step away!" with the elapsed-since-threshold time surfaced.

  6. 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.

09

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.

The prune is a defensive predicate in pruneOldRecordsLocked, called inside Store.Set under the write lock. Every clock-in write passes through it.
10

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.

11

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.

12

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

12

Code samples

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// decodeJSON rejects non-JSON Content-Type, decodes the body into
    // dst, and rejects unknown fields. Returns true on success; on
    // failure it has already written the error response.
    func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
    if ct := r.Header.Get("Content-Type"); ct != "" &&
    !strings.HasPrefix(ct, "application/json") {
    http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
    return false
    }
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()
    if err := dec.Decode(dst); err != nil {
    writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
    return false
    }
    return true
    }

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// handleClockIn persists the latest clock-in for a date.
    func (s *server) handleClockIn(w http.ResponseWriter, r *http.Request) {
    defer r.Body.Close()

    var rec ClockIn
    if !decodeJSON(w, r, &rec) {
    return
    }

    date, err := time.ParseInLocation("2006-01-02", rec.Date, time.Local)
    if err != nil {
    writeJSON(w, http.StatusBadRequest, map[string]string{"error": "date must be YYYY-MM-DD"})
    return
    }
    ci, err := parseClockIn(rec.ClockIn)
    if err != nil {
    writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
    return
    }

    // Reject clock-ins that are noticeably in the future relative to
    // the server's wall-clock. The HRMS page sometimes shows a stale
    // "Checked In" text from the previous day; without this guard the
    // userscript would happily store a 1:15 PM record for an 11:00 AM
    // today.
    now := time.Now()
    if ci.After(now.Add(clockInFutureSlack)) {
    writeJSON(w, http.StatusBadRequest, map[string]string{
    "error": fmt.Sprintf("clock-in %s is in the future (now=%s); refusing to store a stale or fake record",
    rec.ClockIn, now.Format("3:04:05 PM")),
    })
    return
    }
    // ...date-in-future guard, persist, respond
    }

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Caller must hold s.mu (write).
    func pruneOldRecordsLocked(records map[string]ClockIn) {
    if len(records) == 0 {
    return
    }
    // Fast path: under the cap, and no stale weekday entries are
    // possible because a workweek holds at most maxRetainedDays rows.
    if len(records) <= maxRetainedDays {
    // Even under the cap, defensive prune: drop anything that
    // somehow pre-dates the current Sunday (e.g. legacy data.json
    // from before this rule shipped). The loop is cheap and
    // guarantees the invariant.
    boundary := currentWorkweekStart(time.Now())
    for d := range records {
    if d < boundary {
    delete(records, d)
    }
    }
    return
    }
    // ...fall-through: full prune + oldest-first truncation
    }

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// flushLocked writes the in-memory map atomically: encode to a
    // temp file in the same directory and rename over the destination.
    // Caller must hold writeMu.
    func (s *Store) flushLocked() error {
    s.mu.RLock()
    buf, err := json.MarshalIndent(s.records, "", " ")
    s.mu.RUnlock()
    if err != nil {
    return fmt.Errorf("encode %s: %w", s.path, err)
    }

    dir := filepath.Dir(s.path)
    tmp, err := os.CreateTemp(dir, "data-*.json.tmp")
    if err != nil {
    return fmt.Errorf("create temp: %w", err)
    }
    tmpName := tmp.Name()
    cleanup := func() { _ = os.Remove(tmpName) }

    if _, err := tmp.Write(buf); err != nil {
    _ = tmp.Close()
    cleanup()
    return fmt.Errorf("write temp: %w", err)
    }
    if err := tmp.Sync(); err != nil {
    _ = tmp.Close()
    cleanup()
    return fmt.Errorf("sync temp: %w", err)
    }
    if err := tmp.Close(); err != nil {
    cleanup()
    return fmt.Errorf("close temp: %w", err)
    }
    if err := os.Rename(tmpName, s.path); err != nil {
    cleanup()
    return fmt.Errorf("rename: %w", err)
    }
    return nil
    }

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// matchOrigin returns true if pattern matches origin. The single
    // wildcard "*" matches everything; otherwise the match is exact.
    func matchOrigin(pattern, origin string) bool {
    if pattern == "*" {
    return true
    }
    if !strings.HasPrefix(pattern, "https://") &&
    !strings.HasPrefix(pattern, "http://") {
    return false
    }
    return pattern == origin
    }