9e6accb3df3cd0cfa034338bb9b60c8a6e949af1
359 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9e6accb3df |
feat(time-tracking): timesheet workflow, manual entry, GPS, offline, payroll export (cycle 12)
Phase 2 of the time-tracking buildout — turns the existing clock widget
into a full payroll-ready system for Drivers / Supervisors / Admins.
Roles
- field_workers CHECK extended with 'driver' and 'supervisor' alongside
the existing worker / time_admin / irrigator / water_admin values.
Schema (migration 0098)
- time_tracking_logs gains clock_in/out lat/lng/accuracy_m + gps_verified
+ entry_kind ('clock' | 'manual') + manual_reason + edit audit columns.
- new time_tracking_timesheets (draft → submitted → approved/locked →
rejected, with unlock path for admins).
- new time_tracking_audit_log (append-only).
- new time_tracking_supervisor_assignments (supervisor ↔ supervisee edges).
- new SECURITY DEFINER RPCs: recompute_timesheet_totals(),
get_or_create_timesheet(), plus a time_tracking_approval_queue view.
Workflow + locking (src/actions/time-tracking/timesheets.ts)
- resolveCaller() returns platform_admin / brand_admin / supervisor /
worker context; supervisor scope is enforced via the assignments table.
- submitTimesheet / approveTimesheet / rejectTimesheet / unlockTimesheet
all run inside withTx with FOR UPDATE row locks so the status + audit
row + recompute RPC commit atomically.
- Editing or deleting an entry on an approved timesheet is blocked at
the action layer; only unlockTimesheet (admin-only, mandatory audit
note) can re-open it.
Manual entry (src/actions/time-tracking/entries.ts)
- addManualTimeEntry / editTimeEntry / deleteTimeEntry with Zod validation,
audit-log writes in the same transaction, lock-aware.
GPS + offline (src/actions/time-tracking/field.ts,
src/lib/offline/time-tracking-queue.ts,
src/actions/time-tracking/offline-handlers.ts)
- captureGps() in the field client: best-effort geolocation, never blocks
clock-in/out (gps_verified = accuracy_m <= 100).
- tryOrQueueClock() wraps the action; on network failure the event lands
in IndexedDB and replays via replayOfflineClock() (PIN re-verified on
replay, submitted_via = 'offline_sync').
Export (src/actions/time-tracking/export.ts +
src/app/api/time-tracking/timesheets/[id]/export/route.ts)
- PDF (pdf-lib) + CSV (papaparse) payroll export with signature lines,
available for any approved timesheet.
UI
- /admin/time-tracking with tab nav (Overview / Timesheets / Approvals /
Audit).
- TimesheetsList, TimesheetDetail (status-driven workflow buttons,
manual entry form, audit trail), ManualEntryForm.
- FleetRouteSummary on the time-tracking overview shows the full crew's
water + time totals for the day.
- DailyRouteSummary on /admin/water-log/users/[id] shows the per-worker
cross-link badge (water gallons + time hours + timesheet status).
Verification
- npx tsc --noEmit → 0 errors.
- npm run build → all 10 time-tracking routes compile.
- npm run lint → 0 errors/warnings in new files.
- Playwright smoke → 4/5 pass; the failing case is a pre-existing
Tuxedo storefront 404 unrelated to this change.
|
||
|
|
eb0da05037 |
feat(field): cycle 11 — visual cohesion for time-tracking + water-log
Deploy to route.crispygoat.com / deploy (push) Successful in 4m0s
- Lift the PIN screen out of water-specific code into a shared src/components/field/FieldPinScreen.tsx used by both apps. Same Apple HIG surface (#F2F2F7), same gradient brand mark, same digit-box row, same forest-green unlock button. The only difference between callsites is the wordmark + the domain icon (Droplet for water, Clock for time). - TimeTrackingFieldClient rewritten to the same HIG visual system: frosted top nav, grouped-list cards, Forest primary buttons, Ghost secondary, ring-and-check success screen. Behavior (reducer/handlers/lang toggle) unchanged. - FieldPinScreen covers its viewport (min-h-[100dvh]) so the dark Tuxedo ambient gradient behind app/tuxedo/layout.tsx no longer shows through on /tuxedo/time-clock. - Shared field-* keyframes (blink/shake/draw/ring) replace the legacy water-blink/water-shake/water-draw/water-ring pair so both apps animate identically. SuccessScreen updated to use the new field-* names. - Time ClockedOutScreen now uses the same iOS system green (#34C759), the same soft glow, and the same staggered content reveal as the Water SuccessScreen. - No new lint errors or warnings (335 problems = baseline). tsc clean. |
||
|
|
16ad0955f2 |
feat(workers): unify water + time worker tables into field_workers (cycle 10)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
The two per-domain worker tables (water_irrigators, time_tracking_workers)
both carried a pin_hash, a brand, a role, and almost-identical columns.
Migrating a Tuxedo worker from one app to the other meant a duplicate
PIN. Field and admin staff complained; ops got inconsistent identity
records per domain.
Unify into a single field_workers table. Cycle 10 migration
0097_field_workers.sql:
- creates field_workers (id, brand_id, name, pin_hash, role with CHECK
IN ('worker','time_admin','irrigator','water_admin'),
language_preference, phone, notes, active, last_used_at, …)
- adds brand-scoped RLS, brand index, active index
- nullable field_worker_id on downstream tables, backfilled via
UPDATE … FROM, NULL count verified with RAISE EXCEPTION before
SET NOT NULL
- drops the two old tables and renames FK columns
(irrigator_id → field_worker_id, worker_id → field_worker_id)
- reissues the one-open-clock-in partial unique index against
field_worker_id
Application surface:
- All actions/services now read/write fieldWorkers. Drizzle's enum
literal infers tighter role types than the prior ad-hoc casts.
- The verify paths still filter to their respective domain roles so a
water-only worker can't be matched against a time-tracking PIN.
- API surface preserved: action function names didn't change.
- Seed file updated so QA reseeds work post-deploy.
Verified:
- npx tsc --noEmit clean
- npx vitest: 218 pass; 29 failures are pre-existing on main
- npm run lint: exit 0 (no new violations from cycle 10)
- npm run build: exit 0
- /water and /tuxedo/time-clock both 200 on dev
- DB verified via direct query: 20 field_workers, 200 water entries
with zero NULL field_worker_id, index rebuilt.
PR-reviewer: APPROVED.
|
||
|
|
ce652ff5de |
test(time-tracking): unit tests for cron route, drain summary, workspace token (cycle 9)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m17s
Closes the test-coverage gap flagged by pr-reviewers in cycles 7
and 8. Adds 26 new tests across three files and extracts one
helper module so the route is now a thin shell over pure logic.
- New: src/services/sync-drain-summary.ts — per-brand drain
translator with the discriminated-union narrowing
(`result.skipped === true`) the cycle-8 reviewer flagged as
fragile. Exports TTDrainCounters, TTDrainSkipped, TTDrainResult,
DrainRunner, drainOneBrand, summarizeDrains. import server-only.
- New: tests/unit/resolve-workspace-token.test.ts (6 tests) —
covers all 4 ResolvedWorkspaceToken branches (ok, no_workspace,
connection_disabled, decryption_failed with Error + non-Error)
plus brandId threading.
- New: tests/unit/sync-drain-summary.test.ts (7 tests) —
drainOneBrand happy/skipped/error branches plus summarizeDrains
multi-brand and empty. Skipped-branch regression test catches
`"skipped" in result` narrowing mistakes via the happy-branch
fixture.
- New: tests/unit/time-tracking-smartsheet-sync-route.test.ts
(13 tests) — POST + GET auth (Bearer header, ?secret, fallback,
missing in prod = 401, allow in dev), body parsing (no body,
brandId, malformed JSON), aggregation (success flag, totals),
GET delegation.
Refactor: extracted drainOneBrand from the cycle-8 route into the
new helper; route is now authenticate → parse → fan-out → agg.
Bug fix while in here: authenticate() now reads env per-request
(not at module load), and uses readCronSecret() that treats empty
strings as 'not set' so the CRON_SECRET fallback works even when
SMARTSHEET_CRON_SECRET is present-but-empty (the vi.stubEnv idiom
and some hosting dashboards).
vitest.config.ts: added specific aliases for @/db/schema/smartsheet-
workspace and @/db/schema/time-tracking (test runtime only; doesn't
affect Next.js builds).
Live tests:
- npx vitest run: 218 passed, 29 pre-existing failures in
tests/unit/{create-admin-user,reset-admin-password,send-password-
reset-email}.test.ts (unrelated to cycle 9; same baseline as cycle
8).
- npx tsc --noEmit: clean (no new errors).
- npm run lint: no new warnings on cycle 9 files.
- npm run build: EXIT 0 (41s); new route still registered.
- Smoke (port 4000): GET ?secret=qa-audit-cron-secret → 200;
GET (no auth) → 401; GET ?secret=wrong → 401.
PR-reviewer verdict: APPROVED. Three style notes fixed before
commit (test comment about narrowing, vi.stubEnv for NODE_ENV,
named TTDrainResult type).
Co-authored-by: cyc9-bot <bot@routecomm>
|
||
|
|
da488b2cb0 |
feat(time-tracking): add cron route for time-tracking smartsheet sync (cycle 8)
Mirrors the /api/water-log/smartsheet-sync route so time entries
eventually reach the customer's Smartsheet workbook even when the
worker loses connectivity between clock-out and the realtime push.
- New route: /api/time-tracking/smartsheet-sync — drains pending
sync queue rows for every brand with sync_enabled = true.
- Per-frequency gating lives inside runScheduledTTSync:
realtime = backstop only; every_15_min / hourly skip cleanly
when not yet due (literal `skipped === true` branch).
- Auth: Bearer $SMARTSHEET_CRON_SECRET (falls back to $CRON_SECRET).
- Optional body { brandId? } for 'Retry now' admin button.
- Schedule: */15 * * * * in vercel.json (same cadence as water-log).
Co-authored-by: cyc8-bot <bot@routecomm>
|
||
|
|
ad4d3c9976 |
feat(smartsheet): cycle 7 — workbook hub
Replaces two independent Smartsheet configs (water log + time tracking) with one brand-level workbook connection that owns the encrypted API token. Per-feature configs keep their sheet_id + column_mapping + frequency + sync_enabled. Schema (migration 0096): - NEW smartsheet_workspace: one row per brand. Holds encrypted_api_token + token_iv + token_auth_tag (AES-256-GCM, same key), default_sheet_id, connection_enabled (master switch), last_test_*, audit columns. RLS brand-scoped. - Backfill: water_smartsheet_config rows copy into workspace first; time_tracking_smartsheet_config fills in only brands with no workspace row yet. Idempotent (NOT EXISTS + ON CONFLICT DO NOTHING). - DROP encrypted_api_token + token_iv + token_auth_tag from both feature configs (destructive — bytes are copied to workspace first). - Drop unused bytea customType from both schemas (no longer needed). Actions: - NEW src/actions/smartsheet/workspace.ts: getSmartsheetWorkspace, saveSmartsheetWorkspace, testSmartsheetWorkspaceConnection, disableSmartsheetWorkspace. Permission: can_manage_settings + platform_admin. - NEW src/services/workspace-token.ts (server-only): resolveWorkspaceToken helper. Lives outside the 'use server' file so the plaintext token is not exposed as an RPC surface — only callable from server-side sync engines. - MODIFIED water-log + time-tracking smartsheet actions: refactored to read token via resolveWorkspaceToken; save* no longer accepts a token; test* falls through to the workspace token if no token given. - MODIFIED both sync services: load token from resolveWorkspaceToken instead of decrypting the per-feature config. 'connection_disabled' now skips cleanly (no retry, no failed log row) — pausing the workspace at the hub level no longer floods Recent Activity with 'disabled' failures. UI: - NEW src/components/admin/water-log/SmartsheetHub.tsx: top-level hub card. Owns the token + Test Connection + connection enabled toggle + default sheet ID + last-verified badge. Permission gated. - MODIFIED SmartsheetIntegrationCard.tsx (water): rewritten without token UI. Reads masked token from workspace. Sheet ID + column mapping + frequency + enabled + backfill + recent activity. - MODIFIED TimeTrackingSmartsheetCard.tsx: same treatment. - MODIFIED /admin/water-log/settings/page: collapses §05 + §06 into a single Smartsheet section with the hub on top + both feature cards as siblings underneath. Tuxedo-only throughout (TUXEDO_BRAND_ID hardcoded in the settings page; matches existing single-tenant /water convention). Security fixes from pr-reviewer: - resolveWorkspaceToken extracted from 'use server' file to a server-only service module — was reachable as an RPC, would have returned the plaintext token to any authenticated admin. - getSmartsheetWorkspace now actually checks the auth result instead of calling requireManageSettings() and discarding the return value. Diff: -1742 / +597 lines (net -1100 from the simpler per-feature card). |
||
|
|
089d77aa68 |
feat(water-log): cycle 6 — worker sub-PWA at /water scope
Same-origin sub-PWA for Tuxedo field workers, fully isolated from the main Route Commerce PWA. - public/manifest-field.json: Tuxedo-branded manifest, start_url=/water, scope=/water, themeColor=#14532d, no Route Commerce branding - public/sw-field.js: scoped SW with field-shell-v1 + field-data-v1 caches (never touches main app's rc-shell-v* / rc-data-v*). Skips any request outside /water scope. Two-cache strategy: shell cache-first + /api/water stale-while-revalidate. - src/app/water/layout.tsx: child layout overrides root metadata (manifest, themeColor, applicationName, appleWebApp.title) and mounts the field SW registration + install prompt. - src/components/pwa/FieldSWRegistration.tsx: registers /sw-field.js with scope=/water. Module-scoped registered flag prevents strict- mode double-registration. - src/components/pwa/FieldInstallPrompt.tsx: sibling of InstallPrompt, forest-green #1a4d2e theming, fires after 4s dwell. Captures display-mode:standalone AND navigator.standalone for iOS. No conflict with the main app PWA: PWAInstallPrompt is mounted only under /admin/layout.tsx, so /water routes only see FieldInstallPrompt. Tuxedo-only by design (matches existing /water single-tenant hardcode). |
||
|
|
b793cd6955 |
feat(time-tracking): cycle 5 — Smartsheet sync scaffold
Mirrors the water-log Smartsheet pattern (migration 0093 + src/services/smartsheet-sync.ts). End-to-end wiring is in place; the brand fills in sheet ID + token + column mapping whenever ready. DB: - 0095_time_tracking_smartsheet_sync.sql — config + sync_queue + sync_log tables with brand-scoped RLS. Unique constraint on (brand_id, log_id) keeps the queue idempotent against retries. Code: - db/schema/time-tracking.ts — adds TTSmartsheetFrequency, TT_SMARTSHEET_FREQUENCIES, TTSmartsheetColumnKey, TTSmartsheetColumnMapping, and the three pgTable defs. - src/services/time-tracking-smartsheet-sync.ts — syncLogToSmartsheet (dedup-by-log_id, token-echo sanitization, retry-with-backoff), drainTTSyncQueue, runScheduledTTSync. - src/actions/time-tracking/smartsheet.ts — full admin CRUD (getTTSmartsheetConfig, saveTTSmartsheetConfig, testTTSmartsheetConnection, getTTSmartsheetRecent, getTTSmartsheetQueueSummary, disableTTSmartsheet). - src/actions/time-tracking/field.ts — clockOutWorker calls the new enqueueClockOutForSync helper in its OWN transaction (so a queue- insert failure can never poison the clock-out commit; reviewer caught this in cycle 2's same-style bug pattern). - src/components/admin/time-tracking/TimeTrackingSmartsheetCard.tsx — slim, focused card: sheet ID + token + frequency + enable + Test Connection → column-mapping dropdowns + Recent Activity table. - src/app/admin/water-log/settings/page.tsx — §06 section renders the new card as a sibling of the existing water-log smartsheet card. Permission gate uses can_manage_settings + platform_admin (not can_manage_water_log) — feature is a brand-level integration setting, not water-log-specific. Out of scope / deferred: - /api/time-tracking/smartsheet-sync cron route. The trigger is optional: realtime mode syncs inline; 15-min/hourly modes need the cron. Add in a follow-up cycle when the cron infrastructure is revisited. - drag-and-drop column-mapping UX (current card uses plain dropdowns). - backfill flow (no equivalent of the water-log "backfill past N days" feature yet). - can_manage_time_tracking permission flag (uses can_manage_settings as the proxy; revisit once the admin_users schema gets a time-tracking-specific permission column). |
||
|
|
dfd6b63888 |
feat(water-log): cycle 4 — Tuxedo mobile worker Time tab + unified PIN
The mobile `/water` worker experience (Tuxedo-only, matching the
existing water flow) now exposes a "Time" tab alongside "Headgates"
so irrigators can clock in/out from the same phone they use for
water entries — no second PIN.
- MobileWaterApp: new `activeTab` state, sticky TabBar rendered only
after PIN, screen='time' case embeds <TimeTrackingFieldClient>
with hardcoded Tuxedo brand constants.
- handlePinSubmit best-effort calls `verifyTimeTrackingPin` after
the water PIN succeeds, so the Time tab never re-prompts a worker
who is also set up in `time_tracking_workers`.
- handleLogout clears BOTH `wl_session` and `time_tracking_session`
cookies in independent try/catch blocks.
- Tab switching to Time drops in-flight water flow state; switching
back to Headgates restores the list without reload.
- i18n: added `tab.{headgates,time,logout}` strings (en + es); lifted
Tuxedo brand display constants into @/lib/water-log/brand so the
standalone /tuxedo/time-clock page and the embedded Time tab share
one source of truth.
|
||
|
|
0599bdc331 |
feat(water-log): cycle 3 — embed Time Tracking tab in admin shell
The water-log admin panel at /admin/water-log becomes a one-stop hub for the customer: today's entries, the headgates, who can log them, AND how many hours those workers put in. - New TimeTrackingTab — thin wrapper that renders the existing TimeTrackingAdminPanel (which already owns its own Summary / Workers / Tasks / Logs / Settings sub-tabs). - Shell.tsx now exposes Today | Headgates | Users | Time Tracking; the new branch just passes brandId through. - TabValue union and TABS[] updated to "time-tracking". This is the customer-facing surface the user asked for. Cycle 4 adds the same tab to the mobile worker portal; Cycle 5 scaffolds the Smartsheet sync (config-deferred). |
||
|
|
93bcbc29a0 |
refactor(time-tracking): cycle 2 — re-implement field actions against Drizzle
Replaces the in-progress stub (RPC + brandId params) with Drizzle + the shared SECURITY DEFINER wrappers in db/client. - field.ts: cookie session, scrypt PIN, clock-in/out, getOpenClockIn, getWorkerPayPeriodHours. Cookie payload now 7 fields; parseSessionCookie re-resolves name/role/lang/brand_id from DB on every read so a stale or tampered cookie cannot impersonate a different brand. - index.ts: admin CRUD for workers / tasks / settings / logs / notifications + getWorkerPeriodTotals. All admin actions drop the brandId param; the helpers pull it from the admin session via getAdminUser(). - notifications.ts: overtime check inserts with status='pending' (not optimistic 'sent') since email/SMS dispatch is out of scope until the notification_targets columns land in a follow-up migration. - export/route: cast fix for the changed TimeLog shape. DB: - 0094 partial unique index on time_tracking_logs (worker_id) WHERE clock_out IS NULL — enforces one open clock-in per worker against concurrent / retried requests (READ COMMITTED cannot). |
||
|
|
c9b6729482 |
refactor(water-log): split 2117-line panel into tabbed shell + focused modules
The Water Log admin panel was the largest single file in the app (2117 lines,
up from the backlog's 1393 estimate) and mixed 12+ distinct UI concerns:
tab shell, reducer, three section components, a modal, ~12 sub-components,
and 8 inline icons.
Split into focused files under src/components/admin/water-log/:
Shell.tsx tabbed shell, reducer wiring, derived data
reducer.ts reducer + initState + selectors
types.ts shared types (State, Action, Toast, …)
tabs/TodayTab.tsx hero summary + recent entries + report settings
tabs/HeadgatesTab.tsx headgate cards + CRUD
tabs/UsersTab.tsx user list + CRUD
shared/Primitives.tsx SectionHeader, StatTile, StatusPill, PinBanner,
RoleLegend, EmptyState, Input, Select, FilterChip,
Avatar, MonthSelect, DayInput, Th, Td
shared/HeaderNav.tsx top-of-page quick links
shared/Toast.tsx toast notification
shared/ReportPreviewModal.tsx daily-report preview modal
shared/icons.tsx 9 inline icons (incl. new ClockIcon reserved
for the Time Tracking tab in Cycle 3)
Tabs wired in this commit: today (default), headgates, users.
Time Tracking lands in Cycle 3.
Bugs caught by pr-reviewer subagent before commit:
- Color drift: text-[#1d1d1f] was silently rewritten to text-[#1d1d1d] in
16 places during mechanical extraction. sed restored all 16.
- Subtitle template placeholder: monolith had literal '{n}' in the
Recent Entries subtitle. New copy: 'The latest readings…'.
- localStorage coupling: first pass put setItem('wl_season_settings:v1')
inside TodayTab.onSeasonChange. Lifted to Shell.persistSeason so the
storage contract is owned by the shell, not scattered through tabs.
Verified:
- npx tsc --noEmit clean
- npx eslint src/components/admin/water-log/ 0 errors, 0 warnings
- npm run lint full project: 388 → 378 warnings (-10 unused imports);
14 pre-existing errors unchanged
- npm run build succeeds
- curl localhost:4000/admin/water-log HTTP 307 to /login (admin guard
fires; route registered)
Refactor-progress tracked at /tmp/refactor-routecomm.md (Phase 2,
Cycle 1 logged with outcome + 3 fix notes). Next: Cycle 2 — bring the
stubbed time-tracking actions back to life against Drizzle.
|
||
|
|
bf68fa8431 |
revert: drop smartsheet backfill script from deploy
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
The script kept failing on deploy (ESM/CJS mismatch). User opted to drop the auto-fix. The 26 manual rows will remain in the sheet without Entry IDs; the cron drain will retry them, which will dedup against the existing rows once the manual cleanup is done if needed. |
||
|
|
b07be10cf9 |
fix(deploy): ship smartsheet-backfill-entry-ids.mjs to server
Deploy to route.crispygoat.com / deploy (push) Failing after 4m0s
|
||
|
|
c160a1d81b |
fix(smartsheet): one-shot backfill script to re-add Entry IDs to manually inserted rows
Deploy to route.crispygoat.com / deploy (push) Failing after 3m59s
26 water-log rows were POSTed directly to Smartsheet on 2026-07-03 because
the cron drain was failing. Those rows went in without Entry IDs, so the
next cron drain would have created duplicates (findRowByColumn returns
nothing when Entry ID is empty).
This script:
1. Matches each of the 26 hardcoded rows to a DB entry by
(headgate + irrigator + measurement + unit + logged_at).
2. Finds the corresponding Smartsheet row by the same criteria
(only the manually-inserted rows are missing Entry IDs).
3. PUTs the Entry ID back into the sheet row.
4. Marks the sync queue row as 'synced' with smartsheet_row_id set,
so the drain skips them.
5. Writes a sync log entry noting the manual backfill.
Idempotent: re-runs are no-ops. Wired into deploy.yml as a final step
so it runs on the next push; safe to leave in place across deploys.
Local invocation:
npm run smartsheet:backfill-entry-ids
|
||
|
|
81f3f2cc83 |
fix(smartsheet): send addRow body as top-level array, not { rows: [...] }
Deploy to route.crispygoat.com / deploy (push) Successful in 3m57s
Smartsheet's POST /sheets/{id}/rows expects the body to be a top-level
JSON array (or a single Row object), NOT wrapped in { rows: [...] }.
When we wrapped the body, Smartsheet returned:
- HTTP 200 OK
- resultCode: 0
- message: 'SUCCESS'
…and silently dropped every cell value, creating an empty row. This
is the worst kind of failure mode: success-shaped response, no error
to debug, just no data in the sheet. (PUT /sheets/{id}/rows worked
correctly because the docs make it more obvious that PUT takes an
array at the top level.)
Root cause confirmed by direct API probe:
- Body: { rows: [{ cells: [...] }] } → 200, no values persisted
- Body: [{ cells: [...] }] → 200, values persisted
Fix: send the array directly as the request body.
After deploy, retries against the 25 queued entries will:
1. findRowByColumn locates the existing empty rows by Entry ID
(the failed attempts from earlier today created empty rows in
the sheet, since the body wrapper caused cell values to be
dropped)
2. No duplicate insert; the existing empty row is reused
3. Queue marked as synced with smartsheetRowId
The user should also manually delete the empty orphan rows from
their sheet (rows 27-38 from the failed syncs + my probes).
Also reverts the smartsheet npm package install (88 packages added
by 'npm install smartsheet'). The REST fix is one line; the SDK's
deps weren't worth keeping.
|
||
|
|
5aab432e5a |
fix(smartsheet): handle POST /rows response as either object or array
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
Smartsheet's POST /sheets/{id}/rows response shape varies by request size:
- Single-row request → result: { id, rowNumber, ... } (object)
- Multi-row request → result: [{ id, rowNumber, ... }] (array)
We always post exactly one row, so the actual response is the object
form. The previous fix (
|
||
|
|
928779e06d |
fix(smartsheet): register sync cron and fix backfill transaction visibility
Deploy to route.crispygoat.com / deploy (push) Successful in 4m9s
Two bugs were silently breaking every Smartsheet sync since launch:
1. The /api/water-log/smartsheet-sync route was never registered in
vercel.json's crons array. The route existed and was wired up
correctly, but no scheduler ever called it. The UI told users
'they'll drain on the next cron tick (within 15 minutes)' —
but there was no tick. Queue rows accumulated indefinitely.
Fix: register the route with schedule '*/15 * * * *'.
2. enqueueSmartsheetBackfill ran inserts + inline drain inside the
same withBrand callback, which opens a Postgres transaction.
drainSyncQueue opens its OWN withBrand (and thus its own
transaction) to read candidates. Postgres MVCC means the drain's
snapshot was taken before the inserts committed — so it always
reported '0 synced / 0 skipped / 0 failed' even with 25+ fresh
rows in the queue. The 25 rows were correctly inserted, but the
inline drain could never see them.
Fix: split the backfill into three explicit phases, each in its
own transaction:
- Phase 1: validate + query + insert queue rows (commits)
- Phase 2: inline drain in a FRESH transaction (sees phase 1)
- Phase 3: audit log (logAuditEvent opens its own withBrand)
The phase 1 callback returns a discriminated union so config-
missing / sync-disabled / empty-result paths still short-circuit
cleanly without ever touching phase 2.
After deploy, the existing 25 queued entries for the Tuxedo brand
will drain on the first cron tick (~15 min). To drain immediately
without waiting:
curl -H 'Authorization: Bearer $SMARTSHEET_CRON_SECRET' \
-X POST http://crispygoat.com:3100/api/water-log/smartsheet-sync
|
||
|
|
b061cdd289 |
fix(smartsheet): read flat response shape for GET sheet, array result for POST row
Deploy to route.crispygoat.com / deploy (push) Successful in 4m11s
The Smartsheet API uses TWO different response shapes:
- GET /2.0/sheets/{id} → flat: { id, name, columns, ... }
- POST /2.0/sheets/{id}/rows → wrapped: { result: [{id, rowNumber}], resultCode, ... }
Our code assumed both were wrapped in a 'result' object, which meant:
- getSheetMeta read data.result.id → undefined on success.
The defensive guard in
|
||
|
|
71e1792ee3 |
fix(smartsheet): detect share-link slugs and guard malformed API responses
Deploy to route.crispygoat.com / deploy (push) Successful in 3m55s
When an admin pastes a Smartsheet share-link URL (or its bare slug) into the Water Log Smartsheet config, the REST API returns a 2xx with a body that lacks the 'result' envelope. getSheetMeta then crashed with: Cannot read properties of undefined (reading 'id') The error bubbled up through testSmartsheetConnection and surfaced in the Test Connection UI as a generic TypeError. Two-part fix in src/lib/smartsheet.ts: 1. extractSheetId now detects non-numeric IDs (Smartsheet share slugs are 30+ char base64-ish strings) and throws a clear, actionable error BEFORE hitting the API, telling the admin to paste the numeric sheet ID from the signed-in Smartsheet URL or File → Properties. 2. getSheetMeta has a defensive guard (if (!data?.result || data.result.id == null)) that throws a SmartsheetApiError(502, ...) with the same actionable message — belt-and-suspenders for any other unexpected response shape, plus safer defaults on individual column fields so a single malformed column can't kill the whole render. User's failing input was: https://app.smartsheet.com/sheets/V4XgwvVJqFjcxQmQMrgwm77WjCP7hpjqqW82RRR1 Their real numeric sheet ID is 782660409446276 (now accepted). |
||
|
|
a470b6fe9d |
feat(water-log/settings): sticky in-page section nav for Smartsheet card
Deploy to route.crispygoat.com / deploy (push) Successful in 4m16s
The Smartsheet card has six stacked sections (Enable, Connection, Sync frequency, Column mapping, Backfill, Recent activity) — at 1100px viewport that's a long scroll. Add a sticky 'On this page' pill nav at the top of the card that highlights the active section as the user scrolls, with anchor jump on click. - Each <Card> gets a stable id (smartsheet-enable, -connection, -frequency, -mapping, -backfill, -activity) and scroll-mt-20 so anchor jumps clear the sticky nav. - IntersectionObserver (rootMargin 0 0 -80% 0) tracks which section is in the top 20% of the viewport and updates activeSection state. - Pill click uses native scrollIntoView, which honors the global scroll-behavior: smooth on <html> (auto-flipped to instant under prefers-reduced-motion by globals.css — no JS matchMedia needed). - URL hash updates via history.replaceState so the destination is shareable without triggering a second jump. - Apple HIG polish: rounded-full pills, focus-visible ring, active:scale-[0.96], motion-safe: spring transitions, bg-[#fdfaf2]/92 with backdrop-blur for the sticky chrome. - All changes additive — no existing Card/ToggleRow behavior modified. Verified: npx tsc --noEmit clean, npm run build clean, npm run lint on this file clean. |
||
|
|
61d5e3c6fa |
polish(water-log/settings): Apple HIG refinements to the Smartsheet surface
Deploy to route.crispygoat.com / deploy (push) Successful in 4m0s
Layer HIG polish on top of the Field Almanac visual identity — no layout change, just quality. Per-element polish: - Inputs: focus-visible ring (4px at 15% opacity), subtle inner shadow, 44pt min tap target, motion-safe transition. - Selects: same focus ring + min height + transition. - Toggles: Apple spring curve (cubic-bezier 0.32,0.72,0,1), 26×46 track, 20px thumb, 200ms color, 200ms spring transform, focus ring, active:scale-0.97. - Buttons: focus-visible ring, active:scale-0.97 press state, 48pt primary CTA, 40pt secondary, refined hover with subtle lift shadow. - Status banner: inline SVG icon (filled circle + check or info-i), polite/assertive aria-live, slide-down entrance. - Test Connection result: HIG-style icon (filled green/red circle + stroke check/x), refined hierarchy. - Backfill result + error: same HIG pattern as status banner. - Modal: backdrop-blur-[6px], spring entrance (sheet-up 220ms), rounded-t-2xl on mobile (sheet-style) and rounded-2xl on sm+. - All animations: motion-safe: + a prefers-reduced-motion block in globals.css that disables them. Type system: kept the existing --font-display (Field Almanac serif identity) for headings; system-ui for body remains the fallback chain in the existing --font-sans token. New keyframes in globals.css: smartsheet-fade-in, smartsheet-sheet-up, smartsheet-slide-down. All short (180–220ms) to feel responsive. Also: button label 'Save Settings' → 'Save changes' (sentence case, matches HIG register) on the parent settings page and the Smartsheet card. |
||
|
|
7d3de121c6 |
feat(water-log): add manual backfill to push historical entries to Smartsheet
Deploy to route.crispygoat.com / deploy (push) Successful in 4m0s
Adds a backfill button to the Smartsheet Integration card so brand admins
can push existing water log entries (all time / last 30d / last 7d) to
their Smartsheet sheet — important for customers who configure
Smartsheet after months/years of recording entries.
- New enqueueSmartsheetBackfill(brandId, {since?}) server action:
- Counts candidates, enqueues in 500-row chunks with
onConflictDoNothing (UNIQUE on brand_id+entry_id absorbs dups).
- Runs one inline drain pass (batchSize=200) for immediate feedback.
- Cron picks up remaining queue rows across the next ticks.
- Requires active Smartsheet config and admin permission; logs an
audit event for the backfill.
- New "Backfill historical entries" card in SmartsheetIntegrationCard
with range selector, confirmation prompt, and inline result feedback
(counts of newly queued / already queued / drained synced / skipped /
failed / remaining).
- Sync engine dedup by Entry ID + UNIQUE constraint means re-runs are
safe: re-running never creates duplicate Smartsheet rows.
|
||
|
|
9910e588b1 |
fix(deploy): pm2 restart --update-env so PM2 picks up new env vars on deploy
Deploy to route.crispygoat.com / deploy (push) Successful in 4m15s
Without --update-env, PM2 caches the original env vars at first start and silently ignores newly added ones — including SMARTSHEET_TOKEN_ENC_KEY and SMARTSHEET_CRON_SECRET, which were getting written to .env but never reaching the running Next.js process. |
||
|
|
35313b16d7 |
feat(water-log): run Smartsheet sync via Gitea Actions cron + persist secrets on deploy
Deploy to route.crispygoat.com / deploy (push) Successful in 4m20s
- New .gitea/workflows/smartsheet-cron.yml: hits POST /api/water-log/smartsheet-sync every 15min with SMARTSHEET_CRON_SECRET bearer. Replaces the no-op vercel.json cron entry (ignored on self-hosted Gitea). - deploy.yml now writes SMARTSHEET_TOKEN_ENC_KEY and SMARTSHEET_CRON_SECRET to the runtime .env on every deploy so we don't silently lose them. - Dropped the unused cron entry from vercel.json. |
||
|
|
242c195265 |
feat(water-log): add Smartsheet setup instructions modal
Deploy to route.crispygoat.com / deploy (push) Successful in 3m54s
Adds a 'Setup instructions' button to the Connection card that opens a modal with full onboarding guidance: - Prerequisites (Smartsheet account, admin access) - Step 1: Generate an API token (with link to Smartsheet docs) - Step 2: Sheet structure — full schema table mapping our 7 fields to Smartsheet column types (TEXT_NUMBER / DATETIME / DATE), with required vs optional flags and example values - Collapsible 'starter header row' CSV snippet users can paste into row 1 of a new sheet - Step 3: Connect flow (paste URL + token, test, map columns, save) - Common issues troubleshooting (401, 404 slug URLs, DATE column rejecting ISO timestamps) Modal UX: - role='dialog', aria-modal, aria-labelledby - Closes on X button, 'Got it' footer button, click-outside backdrop, or Escape key - Sticky header + footer so scrollable content stays framed - Locked to existing cream + forest palette (Field Almanac style) Implementation: - Added 'setupModalOpen' to state, OPEN_SETUP_MODAL/CLOSE_SETUP_MODAL actions to reducer - Extended Card subcomponent with optional 'action' slot (used here for the trigger button, reusable for future per-card actions) - Added useEffect that registers a window keydown listener only while the modal is open; cleans up on close or unmount - FIELD_SCHEMA const declared alongside the modal so future edits to the schema live next to their documentation |
||
|
|
12557f947f |
docs: note Gitea API port 3013 quirk + SQLite location
Deploy to route.crispygoat.com / deploy (push) Successful in 3m52s
|
||
|
|
178b85a9da |
docs: correct MEMORY.md hosting reference (Gitea, not Vercel)
Deploy to route.crispygoat.com / deploy (push) Successful in 3m50s
|
||
|
|
fc70344dab |
feat(water-log): Smartsheet sync integration
Deploy to route.crispygoat.com / deploy (push) Successful in 5m34s
Per-brand Smartsheet integration that pushes new water_log_entries to a
configured sheet. Config UI lives at /admin/water-log/settings.
Database (0093_water_smartsheet_sync.sql):
- water_smartsheet_config: one row per brand; AES-256-GCM encrypted API
token (BYTEA), sheet id, column mapping JSONB, sync_frequency
(realtime | every_15_minutes | hourly), enable flag, last-sync
metadata. RLS via existing current_brand_id() pattern.
- water_smartsheet_sync_queue: one row per entry awaiting sync;
tracks status / attempts / exponential backoff. UNIQUE(brand_id,
entry_id) for idempotent enqueue.
- water_smartsheet_sync_log: append-only audit of every sync attempt
(success or failure); backs the Recent Activity panel in the UI.
Server:
- src/lib/crypto.ts: AES-256-GCM encrypt/decrypt + token mask helper.
Reads SMARTSHEET_TOKEN_ENC_KEY. Throws on missing/wrong-size key.
- src/lib/smartsheet.ts: typed REST wrapper (no SDK; the official
smartsheet npm has Node 22 compat issues). getSheetMeta, addRow,
findRowByColumn + typed SmartsheetApiError.
- src/services/smartsheet-sync.ts: syncEntryToSmartsheet (one entry),
drainSyncQueue (batch with backoff), runScheduledSync (cron entry).
Sequential processing stays well under Smartsheet's 300 req/min.
Max 5 attempts, then row stays 'failed' permanently.
- src/actions/water-log/smartsheet.ts: 6 server actions. Token never
returned in plaintext — UI gets maskedToken only. Permission check
matches the rest of the water-log module (can_manage_water_log).
- src/app/api/water-log/smartsheet-sync/route.ts: POST cron route,
bearer auth via SMARTSHEET_CRON_SECRET (falls back to CRON_SECRET).
Optional {brandId} body for manual retry.
- vercel.json: added */15 * * * * cron entry. Single entry covers
both 15-min and hourly frequencies (route filters per-brand).
UI:
- src/components/admin/water-log/SmartsheetIntegrationCard.tsx:
self-contained client component with useReducer. Sections: enable
toggle, sheet URL/ID + API token + Test Connection, sync frequency
radio group, column mapping dropdowns (populated after Test),
Recent Activity panel.
- src/app/admin/water-log/settings/page.tsx: mounted the card as new
'§ 05 — Integrations' section after the existing admin-PIN settings.
Card takes brandId as a prop (CLAUDE.md 'Brand ID Threading').
Hooks:
- src/actions/water-log/field.ts::submitWaterEntry: calls
triggerSyncForEntry(brandId, entryId) in a fire-and-forget void
after the entry insert. Sync failures NEVER bubble to the field
worker.
Env vars (.env.example):
- SMARTSHEET_TOKEN_ENC_KEY (REQUIRED, 32 random bytes base64)
- SMARTSHEET_CRON_SECRET (optional bearer)
- SMARTSHEET_SYNC_TIMEOUT_MS (optional, default 8000)
Deploy:
1. openssl rand -base64 32 → SMARTSHEET_TOKEN_ENC_KEY in .env.local
AND Vercel dashboard
2. npm run migrate:one 93 (pushes 0093_water_smartsheet_sync.sql)
3. Push triggers .gitea/workflows/deploy.yml
Rollback: remove cron entry, DROP the 3 tables, revert field.ts +
settings page hooks, git revert the merge commit. See MEMORY.md
for full rollback story + sharp edges (no key rotation, slug URLs).
|
||
|
|
3b0d0c07e3 |
fix(admin): show wholesale page header immediately, render skeleton inside content area
The WholesaleClient previously hid the entire page (header + tabs + stats) during the initial 1-2s server-action Promise.all load, showing only the WholesaleLoadingSkeleton. That meant users (and the audit) saw a blank main area with no h1, and the page felt slower than it actually was. Now the PageHeader and tabs render immediately on mount; the skeleton appears in the content area below while data is fetching. Once load_complete dispatches, skeleton is swapped for the tab content. This matches the pattern used by every modern admin app (Slack/Linear/Notion) — title is up before data lands. Affects: - /admin/wholesale h1 now visible at +0.5s (was +1.5s) - Audit visual_hierarchy_clear 68/76 → 76/76 |
||
|
|
6de112467a |
fix(admin): SQL GROUP BY in reports, hydration in settings, mobile tabs scroll
- reports.ts: wrap window-function arg in SUM() so it operates on the per-group aggregate instead of the raw c.id column (PostgreSQL rejects window funcs over non-grouped, non-aggregated columns). - SettingsClient.tsx: read URL hash in useEffect instead of a lazy useState initializer to avoid server/client first-render mismatch on tab content (was triggering full client re-render in /admin/settings). - layout.tsx: add pt-16 lg:pt-0 to page-content for mobile header clearance. - ReportsDashboard.tsx: memoize DateRange on primitive inputs to break useEffect → setState → re-render loop caused by buildRange returning a new object every call. - AdminFilterTabs.tsx: add overflow-x-auto so wide tab rows scroll on mobile instead of overflowing. - use-media-query.ts: always start as false for hydration safety; callers handle the mobile/unknown branch first. |
||
|
|
137b5e7ffe |
refactor(water-log): tone down report preview modal to production-appropriate GlassModal
Deploy to route.crispygoat.com / deploy (push) Successful in 3m55s
Drops the overdesigned 'Field Bulletin' treatment (perforated tear-strip, crop marks, rubber stamp, paper grain, side stats, signature) in favor of the shared GlassModal chrome with a clean data table, meta line, and a plain-text copy action. Matches the rest of the admin UI. |
||
|
|
060520b7be |
feat(water-log): replace alert() report preview with 'Field Bulletin' modal
Deploy to route.crispygoat.com / deploy (push) Successful in 4m4s
- New ReportPreviewModal styled as a USGS/SCS field bulletin printout: forest header strip, perforated tear-strip, crop marks, Fraunces masthead, rotated PREVIEW rubber stamp, hairline-ruled data table with stagger-row reveal, first/last entry + top-gate stats, and a plain-text 'Copy' action backed by formatDailyWaterReport(). - ESC and backdrop click both close; body scroll locked while open; prefers-reduced-motion disables all transitions. - Preview state lives in the parent reducer (OPEN/CLOSE_REPORT_PREVIEW); no extra fetch — reuses the already-filtered todayEntries. |
||
|
|
d34fc2434a |
feat(water-log): remember last unit per headgate (cross-device)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m11s
Field operators no longer have to re-pick their preferred unit on the same headgate every shift. Per-headgate 'last_unit' is computed by a correlated subquery on getWaterHeadgates: the unit from the most recent water_log_entries row for that gate, or null when no entries exist yet. The LogForm unit picker now prefers this over the headgate's static configured unit: 1. remembered last_unit (if in catalog) 2. headgate.unit (if in catalog) 3. units[0] (final fallback) Because the value is derived from water_log_entries (not stored in a new column), no migration is needed — the entry-table is already the source of truth for what was actually logged, and a new entry naturally propagates to the next visit. Cross-device by construction. Subquery is scoped by brand_id as a defensive measure and uses the existing headgate_id PK + (logged_at DESC) implicit ordering — for the Tuxedo fleet size (<200 active gates × few entries each) the planner hashes on the headgate PK and never touches disk. Refs /tmp/refactor-water-log.md (customer-feature followup) |
||
|
|
9ecb496a7c |
fix(water-log): address code-review findings from i18n PR
Deploy to route.crispygoat.com / deploy (push) Successful in 4m9s
- Session-expired bounce-to-PIN branch now matches the actual strings
returned by requireFieldSession (was 'Session expired or invalid'
which never matched). Added 'Session not found' and 'User is inactive'
to the intercept list. The user-visible 'Your session expired. Please
sign in again.' string is now in the dictionary (en + es).
- LangProvider moved up to WaterFieldClient. The orchestrator (which
was sitting OUTSIDE the provider in
|
||
|
|
7795263944 |
feat(water-log): add 'holes' unit + EN/ES language toggle for mobile
Deploy to route.crispygoat.com / deploy (push) Successful in 4m2s
- 'holes' / 'hoyos' added to MOBILE_UNITS alongside CFS/GPM/gal/ac-in/ac-ft with integer-only validation: submit button greys out and the keyboard raises the numeric keypad (no decimal) when the unit is selected. - Language toggle in the headgate list header (next to logout) flips every screen between English and Spanish. Choice is persisted to the existing 'wl_lang' cookie for 1y, so the user's last selection sticks across logout/relogin. - i18n strings lifted into shared src/lib/water-log/i18n.ts (single source of truth, en/es parity enforced by tests). All mobile screens (Pin, HeadgateList, LogForm, Success) plus the loadHeadgates error in the orchestrator consume the dictionary. Brand strip, aria-labels, segmented control labels, success recap, and submit-button labels all go through getT()/formatUnit()/displayUnit(). - 26 new unit tests (tests/unit/water-log-i18n.test.ts) cover: en/es dict parity, holes catalog + hoyos translation, all the detectInitialLang() branches (SSR/cookie/navigator), setLangCookie read/write, label interpolation helpers, and the unit catalog. Refs /tmp/refactor-water-log.md (Commit B plan) |
||
|
|
b29caa0f30 |
refactor(water-log): add i18n foundation for mobile EN/ES
Deploy to route.crispygoat.com / deploy (push) Successful in 4m16s
NEW infrastructure for upcoming mobile app localization:
- src/lib/water-log/i18n.ts
Shared EN/ES label dictionary for the mobile (/water) portal.
Includes units (CFS/GPM/gal/ac-in/ac-ft/holes), status labels,
screen-specific strings, and relative-time formatters. No React,
no next/headers — safe to import from client, server, and tests.
- src/lib/water-log/lang-context.tsx
<LangProvider> + useLang() hook. Provider manages lang state and
persists every change to the wl_lang cookie (1-year max-age).
- src/components/water/LanguageToggle.tsx
Reusable EN | ES pill component for the mobile nav bar.
Themed for the Tuxedo green palette.
The admin client (/water/admin) keeps its existing inline LABELS dict
and zinc-styled toggle — its surface is different enough (admin-
specific strings like "Export CSV", "Recent Entries") that moving it
to the shared dict is a separate, larger refactor.
No behavior change. Smoke test: nothing should look different in prod
until a follow-up commit wires the mobile screens to the new context.
|
||
|
|
c3da54af50 |
refactor(water-log): centralize brand + session primitives
Deploy to route.crispygoat.com / deploy (push) Successful in 4m19s
Phase 1 of the water-log auth refactor. No behavior change.
NEW:
- src/lib/water-log/brand.ts — single source of TUXEDO_BRAND_ID
- src/lib/water-log/session.ts — cookie name constants + shared types
(FieldSession, AdminSession, FieldSessionAuthed,
FieldSessionResult). Safe to import from client
components.
- src/lib/water-log/session-server.ts — server-only cookie I/O helpers
(setSessionCookie, clearSessionCookie,
readSessionCookie).
MODIFIED:
- src/lib/water-admin-pin-auth.ts — re-export TUXEDO_BRAND_ID, use helpers
- src/actions/water-log/auth.ts — use shared cookie constant + types
- src/actions/water-log/field.ts — drop local TUXEDO_BRAND_ID copy
- src/app/api/water-photo-upload/route.ts — use shared cookie names
- src/components/water/mobile/MobileWaterApp.tsx — drop local
TUXEDO_BRAND_ID copy;
use string ops over
regex for cookie peek.
FieldSession retains its union shape (back-compat) — FieldSessionAuthed
is the narrowed success branch.
|
||
|
|
69d8f829f1 |
fix(water-log): remove nested Promise.all in field PIN login
Deploy to route.crispygoat.com / deploy (push) Successful in 5m3s
Replace nested Promise.all([insert, update]) + outer Promise.all([withBrand, cookies()]) with sequential awaits in verifyWaterPin, mirroring the admin-portal pattern from commit |
||
|
|
ce62b17898 |
feat(water-log): surface irrigator name on log form + success screen
Deploy to route.crispygoat.com / deploy (push) Successful in 4m2s
Makes the currently-signed-in irrigator visible while data is being
entered and confirmed, so the operator can verify the reading is
attributed to them at a glance.
- LogForm nav bar: title + initials avatar + name as a subtitle,
sign-out button moves from the form footer into the nav bar
(more discoverable, frees vertical space for the Submit button)
- SuccessScreen recap: 'Logged by {name}' row with avatar below
the timestamp, matches the log form styling
- MobileWaterApp threads irrigatorName through to both screens
The headgate list still shows 'Signed in as {name}' as a small
caption — left untouched to avoid duplicate UI.
Validation:
- tsc --noEmit clean
- eslint clean on all modified files
|
||
|
|
ac88584b8b |
feat(water-log): redesign /water as Apple HIG mobile-first experience
Deploy to route.crispygoat.com / deploy (push) Successful in 4m13s
Replaces the legacy 1000+ line WaterFieldClient with a polished
three-screen state machine tuned for phones and tablets in the field:
PIN -> Headgates -> Log -> Success
Design (Apple Human Interface Guidelines):
- System font stack (-apple-system, BlinkMacSystemFont, 'SF Pro Display')
instead of the admin's Fraunces/Manrope for native iOS feel
- iOS systemGreen (#34C759) primary success, forest-900 brand accent
- iOS systemGroupedBackground (#F2F2F7) surface, white cards,
rgba(60,60,67,0.x) text greys
- 44pt minimum touch targets, 58pt primary buttons, 68pt PIN boxes
- env(safe-area-inset-top/bottom) respected on every screen
- 100dvh shell so iOS Safari chrome collapse doesn't reflow
Screens:
- PinScreen: 4 iOS-style digit boxes with blinking caret, big
'Unlock' button (disabled until 4 digits entered), branded
'TUXEDO WATER LOG' header, error shake on bad PIN
- HeadgateList: grouped list of large tappable cards showing
name + geocode (notes) + unit + last-logged relative time,
pull-to-refresh, empty state with refresh CTA
- LogForm: sticky headgate card + 44pt numeric input +
SegmentedControl for units (CFS / GPM / gal / ac-in / ac-ft)
+ live 'Logged now' timestamp + optional 500-char notes +
green Submit Log button with inline measurement/unit preview
- SuccessScreen: animated ring + checkmark draw-on, recap card,
'Log Another Entry' (green) and 'Back to Headgates' (secondary)
Reusable primitives (mobile/):
- SegmentedControl: iOS-style sliding pill with spring timing
- PullToRefresh: callback-based (no router.refresh) with resistance
- icons.tsx: inline SVG icon set (Lock, Droplet, Gauge, Refresh, etc.)
Backend:
- getWaterHeadgates extended to include notes (the 'geocode') and
last_used_at for richer card display
- No new routes, no DB migration - reuses verifyWaterPin,
submitWaterEntry, logoutWater server actions unchanged
Quality gates:
- tsc --noEmit clean
- eslint clean on all new files
- npm run build succeeds (/water rendered as static)
- prefers-reduced-motion respected (existing global guard)
|
||
|
|
ca1cf143d3 |
fix(water-admin): rebrand subtitle to "Tuxedo Field Operations"
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
The header subtitle was "Signed in as Admin" — appended with a literal "Admin" suffix. Customers misread it as "every user is an admin" because the page doesn't know *which* admin is signed in (the wl_admin_session cookie is an opaque bearer token with no user identity). Replace the label value with "Tuxedo Field Operations" / "Operaciones de Campo Tuxedo" so the page frames itself as a brand-internal operations tool, not a per-user role. Also drops the trailing " Admin" suffix from the rendered string. The /water (irrigator) page is unchanged — it still shows the actual irrigator name since wl_session is keyed to a real user. |
||
|
|
b966787daa |
refactor(water-log): replace PIN auth with self-contained cookie module
Deploy to route.crispygoat.com / deploy (push) Successful in 4m19s
The /water/admin PIN portal had a server-action implementation that
went through a pg transaction + cookies() interaction that 500'd for
users without a platform login. The fix attempts (move cookie set
outside the transaction, remove getAdminUser, add placeholder UUID)
made it brittle and hard to reason about.
This replaces the whole stack with a self-contained module:
src/lib/water-admin-pin-auth.ts (new, 126 lines, no Neon Auth)
- verifyAdminPin(pin) — scrypt verify, sets wl_admin_session
cookie to a random UUID
- getAdminSession() — read cookie, return session or null
- clearAdminSession() — delete cookie
- hashPin (re-exported) — same scrypt params as verifyPin
The /water/admin portal is Tuxedo-brand-specific (CLAUDE.md "Public
Storefront Architecture"), so the brand UUID is hardcoded. The cookie
is the session — no DB session table writes. Cookie presence =
authenticated. This means the /admin/water-log/* pages (entries,
headgates, users) can use the same `getAdminSession()` helper for
their "isWaterAdmin" branch — no more DB session lookup.
Deleted:
- verifyWaterAdminPin server action in settings.ts (no callers)
- requireWaterAdminSession / getWaterAdminSession from auth.ts
(replaced by cookie-only check)
- the cookie-outside-transaction dance (no longer needed)
Updated callers:
- /api/water-admin-auth — POST { pin } instead of { brandId, pin }
- /water/admin — uses new getAdminSession() helper
- /water/admin/login — no longer needs brandId prop
- /admin/water-log/entries|headgates|users/[id] — same helper
Tests: 19 passing in water-log-auth.test.ts (3 new for the new module).
|
||
|
|
ca79896d5f |
fix(water-log): set wl_admin_session cookie outside pg transaction
Deploy to route.crispygoat.com / deploy (push) Successful in 4m49s
verifyWaterAdminPin previously called cookies().set(...) from inside the withBrand(...) pg transaction. The pg transaction's AsyncLocalStorage context masks Next.js's request-scoped cookie store, so for users without a platform login the cookie set threw and the route handler returned 500. Move the cookie write to the outer function frame, after withBrand resolves. The DB session row is still inserted inside the transaction (so it's atomic with the PIN verification); only the cookie write is hoisted out. The prior fix removed getAdminUser() but left the cookie set inside the transaction, so valid PINs still 500'd. This is the missing half. Adds a static regression test that asserts const cookieStore is declared AFTER the matching close-paren of withBrand(...). |
||
|
|
98fc1d3bdd |
fix(water-log): make verifyWaterAdminPin fully PIN-only
Deploy to route.crispygoat.com / deploy (push) Successful in 4m3s
The previous PR wrapped the getAdminUser() call in try/catch as a
best-effort audit hook. In practice this still 500s users without a
platform login: getSession() inside getAdminUser() touches the
Next.js cookie store, and any failure leaves the store in a state
where the subsequent cookies().set('wl_admin_session', ...) throws.
The /water/admin/login page is the Tuxedo-brand-specific entry
point for brand staff in the field. It must work PIN-only, with no
Neon Auth dependency.
Fix:
- Drop the getAdminUser() call inside verifyWaterAdminPin entirely.
- adminUserId on the new water_admin_sessions row is always the
zero-UUID placeholder. Audit attribution is intentionally deferred.
- Document the Tuxedo-only/PIN-only contract on the page itself and
in the spec so future maintainers don't accidentally re-add the
Neon Auth dependency.
Guards (TDD, red → green):
- tests/unit/water-log-auth.test.ts: 3 new tests scoped to the
verifyWaterAdminPin function source — asserts it does not call
getAdminUser() or getSession() and that the zero-UUID placeholder
is present. These fail if a future refactor re-introduces the
dependency.
|
||
|
|
658f6a5b8b |
fix(water-log): remove platform-login dependency from PIN flows
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
Field users at /water were blocked because every server action called getSession() (Neon Auth) even though the field path is PIN-authenticated. A missing or expired platform session hung or rejected PIN submissions, so users without a platform login could never reach the water log entry screen. Centralize water-log auth in src/actions/water-log/auth.ts with three helpers — requireFieldSession, requireWaterAdminSession, and requireWaterAdminPermission — and migrate all server actions off the stray getSession() calls. Auth.ts deliberately does NOT import getSession; a static-source unit test enforces that. Changes: - src/actions/water-log/auth.ts (new): dedicated auth layer, no Neon Auth - src/actions/water-log/field.ts: -10 await getSession(), use helpers - src/actions/water-log/admin.ts: -18 await getSession(), use helpers - src/actions/water-log/settings.ts: -4 await getSession(), resilient to missing platform admin - 3x admin pages: update getWaterAdminSession import path - tests/unit/water-log-auth.test.ts (new): 17 tests incl. regression guard that auth.ts never imports getSession - tests/water-log.spec.ts: 3 E2E regression tests (no platform login) - vitest.config.ts: alias for deep @/db/schema/water-log path Rollback = revert this commit. No DB migration; no schema change. Existing rows/cookies unchanged. |
||
|
|
015eb33291 |
docs(spec): water log — remove platform-login dependency
Bug: every server action in src/actions/water-log/{field,admin,settings}.ts
calls await getSession() from @/lib/auth, which makes a network
round-trip to Neon Auth on every PIN-screen interaction. The water log
module is intentionally PIN-based and self-contained; the
getSession() calls are leftover copy-paste from site-admin patterns
and cause /water PIN submission to hang or fail for unauthenticated
users.
This spec removes the spurious getSession() calls, centralizes the
PIN-based auth helpers in a new src/actions/water-log/auth.ts, and
adds a regression-guard E2E test.
|
||
|
|
602adb4588 |
Polish /water/admin: dark zinc palette, Spanish-first, faster load
Deploy to route.crispygoat.com / deploy (push) Successful in 4m8s
Display & aesthetic - Switch stone-* palette to zinc-950/900/800 to match headgate edit page - Cards: rounded-2xl p-5 ring-1 ring-zinc-800 (was rounded-xl p-4 ring-stone-200) - Sticky header: bg-zinc-950/80 backdrop-blur with EN/ES toggle - Section labels: uppercase tracking-wider text-zinc-500 - Stat numbers: text-3xl font-bold tabular-nums - Status pills: subdued dark variants (bg-green-900/30 text-green-400, etc.) - Replace hardcoded "Salir" with localized t.logout - Default language to ES; auto-detect via navigator.language - Add missing labels (alerts, no_users, no_alerts, save, filter_all, …) Performance - Bootstrap 4 datasets in RSC (page.tsx) so first paint has data, not a spinner - Drop entries fetch from 500 to 50; add "Load more" paginated button - Display-summary polling keeps previous data visible during refresh - Use ref for countdown so the 30s interval isn't recreated each tick No DB or server-action changes. |
||
|
|
4d323af51b |
fix(water-log): add missing pin_hash column on water_admin_settings
Deploy to route.crispygoat.com / deploy (push) Successful in 4m3s
The Drizzle schema in db/schema/water-log.ts declares
`water_admin_settings.pin_hash TEXT` for the hashed admin PIN, but
the table was originally created in 0090_water_log_completion.sql
WITHOUT that column. The earlier 0004 migration was a no-op against
prod (table already existed from 0090) and so did not add it either.
Effect: every Drizzle `select()` / `insert()` against the table
throws 'column pin_hash does not exist', which:
- /api/water-admin-auth catches and returns 500 'Server error'
- /admin/water-log/settings never finishes loading (the action's
promise rejects inside withBrand, so LOAD_DONE is never
dispatched, and the page sits on the Loading… state)
Migration adds the column with IF NOT EXISTS so it's safe to re-run
and won't error on a brand whose row already has a hash.
|
||
|
|
06e536fb87 |
fix(water-log): add missing tables for admin PIN portal
Deploy to route.crispygoat.com / deploy (push) Successful in 3m46s
The water-log admin actions (saveWaterAdminSettings, verifyWaterAdminPin, regenerateAdminPin) and /api/water-admin-auth route were all wired up against the Drizzle schema in db/schema/water-log.ts, but the three underlying tables never had a migration applied: - water_admin_settings - water_admin_sessions - water_audit_log Hitting any of these surfaces threw 'relation does not exist' from pg, which /api/water-admin-auth surfaced as a 500 'Server error' to the field. Adding /admin/water-log/settings → save also failed silently with a server error. This migration creates the three tables with columns matching the Drizzle schema (PKs, defaults, FKs to brands + admin_users) and wires up RLS + tenant_isolation policies in the same shape as 0001_init.sql. The deploy workflow (scripts/migrate.js) will pick this file up automatically on the next push to main. |