Adds captureGps() helper to TimeTrackingFieldClient and threads it into
handleSelectTask + handleClockOut. Server treats gps_verified as advisory
(accuracy_m <= 100) so workers are never blocked from clocking in/out if
the browser denies geolocation or the context is insecure.
Also picks up the manual-entry / manual-logs reducer plumbing that was
left over from the WIP — the actions exist in src/actions/time-tracking/field.ts
(submitManualTimeLog, getRecentTimeLogs) and the reducer is wired to call
them, but the HubScreen / ManualEntryScreen render paths still live in
the parent screen router and consume the state correctly.
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.
- 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.
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).
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).
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).
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.
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).
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.
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.
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.
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.
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
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).
- 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.
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.
- 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.
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)
- 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 b29caa0) now calls useLang()
directly, so the loadHeadgates fallback error string is always
rendered in the user's chosen language — even if they toggled
after signing in.
- getClientLangSnapshot now memoizes the cookie value (React 19
enforces getSnapshot purity stricter than 18).
- LanguageToggle switched from aria-pressed (toggle pattern) to
role="radiogroup" + role="radio" + aria-checked (mutually-exclusive
selector pattern). Per-button aria-labels dropped — the visible
EN/ES text is read by SR as the radio name. Group label switched
to t.common.a11yLanguage for Spanish parity.
- Notes textarea now has aria-label={t.log.notes} (was unlabeled —
SR would announce 'Edit text' with no context).
- 1 new test: lock in that wl_lang=<script>...</script> falls through
to the navigator (injection guard on the cookie regex).
Refs /tmp/refactor-water-log.md (review followups to Commit B)
- '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)
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.
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.
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
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)
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.
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.
Per docs/qa/audit-2026-06-26/FINAL-REPORT.md. Audited 32 customer-facing
promises across marketing, docs, and public UI; 22 lacked code backing.
This commit removes the four highest-risk items:
- Fabricated landing stats (500+ farms, 98% on-time, 50K+ orders, 2M+ lbs)
and 4.9/12 JSON-LD rating on src/app/page.tsx + HeroSection.tsx.
- Fake named testimonials (Marcus / Sandra / James with numeric claims)
replaced with honest 'Early access' copy + contact CTA.
- Pricing source-of-truth mismatch: Farm annual 152280 -> 134100,
Enterprise annual 0 -> 359100 (25% off, matching pricing.ts).
- Security claims (SOC 2 Type II, 99.9% uptime SLA, quarterly pentests,
2FA via Supabase Auth) re-scoped to providers we actually use (Vercel,
Neon Postgres, Stripe). Provider cards updated.
- Plus LOW-risk cleanups: OG image refs (-> .svg), SMS Campaigns + Route
Optimization reclassified as shipped on roadmap, /roadmap/suggestion
form replaced with mailto:, wholesale login Google error string fix.
Note: src/app/wholesale/login/page.tsx and src/app/pricing/PricingClientPage.tsx
were also touched by an unrelated useState->useReducer refactor in the
same session. Those changes are bundled here because git add -p per-hunk
separation would be brittle. Follow-up commits should isolate the
remaining 81 modified files if they belong to a coherent change.
Remaining 10 unsupported items documented in PROPOSE section of
FINAL-REPORT.md need separate decisions (see that file).