Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s

Completes the Tuxedo Water Log feature end-to-end. Builds on the existing
water_* tables in 0001_init.sql; extends them with 0090 and rewrites the
server actions, settings page, export route, and admin panel to actually
function.

Schema (db/migrations/0090_water_log_completion.sql, db/schema/water-log.ts)
- headgate_token + status + max_flow_gpm + thresholds + notes on water_headgates
- role + phone + notes on water_irrigators
- method + total_gallons + photo_url + logged_date + lat/lon on water_log_entries
- new tables: water_admin_settings, water_admin_sessions, water_audit_log, water_alert_log

Auth (src/lib/water-log-pin.ts, src/lib/water-log-audit.ts)
- scrypt N=16384 per-PIN random salt, self-describing hash format
- weak-PIN detection (repeats, monotonic, palindromes)
- 200ms delay + timingSafeEqual on failed verify
- 4-8 digit PINs, generatePin() rejects weak patterns

Server actions (src/actions/water-log/{admin,field,settings}.ts)
- full Drizzle CRUD: headgates, irrigators, entries (with audit hooks)
- verifyPin + 8h field sessions, admin PIN + configurable 1-168h sessions
- regenerateAdminPin invalidates existing admin sessions
- threshold breach detection + alert log writes
- getWaterAdminSession() helper for admin pages

API routes
- /api/water-admin-auth: PIN-protected, generic error (no enum leak)
- /api/water-logs/export: admin-gated JSON/CSV, correct headers

UI (Field Almanac — cream + forest, Fraunces display, § section markers)
- src/components/admin/WaterLogAdminPanel.tsx: full rewrite
- src/components/water/WaterFieldClient.tsx: matches palette
- src/components/water/icons/{WaterGauge,SeasonMark}.tsx: custom SVGs
- src/app/admin/water-log/settings/page.tsx: full rewrite, Field Almanac

Tests
- tests/unit/water-log-pin.test.ts (21 cases): validate, hash, verify, generate
- tests/unit/water-log-reporting.test.ts (31 cases): season, filter, CSV, report
- tests/water-log.spec.ts (Playwright E2E): PIN form, auth gates, API gates

Docs
- docs/water-log.md: full admin guide, security model, data dictionary, checklist
- README: link to docs/water-log.md in admin modules list
- .env.example: Water Log section comment

Type-check: clean. Tests: 70 pass, 3 fail (3 pre-existing getAdminUser
failures on main, unrelated to this work).
This commit is contained in:
Tyler
2026-06-17 11:36:00 -06:00
parent 52c8a71cd0
commit 11cd2fd01a
22 changed files with 4944 additions and 1137 deletions
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -361,8 +361,8 @@ export default function WaterAdminClient() {
setSavingUser(true);
setNewUserError(null);
const result = await createWaterIrrigator(TUXEDO_BRAND_ID, newUserName.trim(), newUserLang);
if (result.success && result.pin) {
setNewPin({ name: result.irrigator!.name, pin: result.pin });
if (result.success && result.pin && result.user) {
setNewPin({ name: result.user.name, pin: result.pin });
setNewUserName("");
setShowAddUser(false);
const updated = await getWaterIrrigators(TUXEDO_BRAND_ID);
+67 -32
View File
@@ -10,6 +10,7 @@ import {
setWaterLang,
} from "@/actions/water-log/field";
import { useSearchParams } from "next/navigation";
import { WaterGauge } from "@/components/water/icons";
type Headgate = {
id: string;
@@ -345,10 +346,13 @@ function WaterFieldInner() {
// Language selection screen
if (step === "loading") {
return (
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs text-center">
<div className="w-12 h-12 rounded-full border-3 border-stone-300 border-t-stone-600 animate-spin mx-auto mb-4" />
<p className="text-stone-500 text-base">Loading...</p>
<div className="mx-auto mb-4 flex justify-center">
<WaterGauge size={64} level={null} status="open" />
</div>
<div className="w-12 h-12 rounded-full border-[3px] border-[#d4d9d3] border-t-[#1a4d2e] animate-spin mx-auto mb-4" />
<p className="text-[#57694e] text-base font-medium">Loading</p>
</div>
</div>
);
@@ -357,20 +361,31 @@ function WaterFieldInner() {
// Language selection screen
if (step === "lang") {
return (
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1>
<p className="text-stone-500 text-center mb-8">{t.selectLang}</p>
<div className="flex flex-col gap-4">
<div className="mb-3 flex justify-center">
<WaterGauge size={56} level={null} status="open" />
</div>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-1 text-xs font-mono uppercase tracking-[0.25em]">
Tuxedo Ditch Co.
</p>
<p className="text-[#57694e] text-center mb-8 text-sm">{t.selectLang}</p>
<div className="flex flex-col gap-3">
<button
onClick={() => handleLangSelect("en")}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-stone-900 shadow-sm ring-1 ring-stone-200 hover:bg-stone-50 min-h-[64px]"
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
English
</button>
<button
onClick={() => handleLangSelect("es")}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-stone-900 shadow-sm ring-1 ring-stone-200 hover:bg-stone-50 min-h-[64px]"
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
Español
</button>
@@ -383,26 +398,31 @@ function WaterFieldInner() {
// Role selection screen
if (step === "role") {
return (
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<button
onClick={() => setStep("lang")}
className="text-sm text-stone-500 mb-6 hover:text-stone-700"
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
>
{t.back}
</button>
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1>
<p className="text-stone-500 text-center mb-8">{t.whoAreYou}</p>
<div className="flex flex-col gap-4">
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-8">{t.whoAreYou}</p>
<div className="flex flex-col gap-3">
<button
onClick={() => { setStep("pin"); }}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-stone-900 shadow-sm ring-1 ring-stone-200 hover:bg-stone-50 min-h-[64px]"
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
Irrigator
</button>
<button
onClick={() => { setStep("pin"); }}
className="w-full rounded-xl bg-stone-900 px-6 py-5 text-xl font-semibold text-white shadow-sm ring-1 ring-stone-700 hover:bg-stone-800 min-h-[64px]"
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-semibold text-white shadow-sm ring-1 ring-[#1a4d2e] hover:bg-[#14532d] transition min-h-[64px]"
>
Admin
</button>
@@ -415,16 +435,21 @@ function WaterFieldInner() {
// PIN entry screen
if (step === "pin") {
return (
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<button
onClick={() => setStep("role")}
className="text-sm text-stone-500 mb-6 hover:text-stone-700"
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
>
{t.back}
</button>
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1>
<p className="text-stone-500 text-center mb-8">{t.enterPin}</p>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-8">{t.enterPin}</p>
<form onSubmit={handlePinSubmit}>
<input
@@ -435,17 +460,17 @@ function WaterFieldInner() {
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder={t.pinPlaceholder}
className="w-full rounded-xl border-2 border-stone-300 px-6 py-6 text-center text-4xl font-bold tracking-widest outline-none focus:border-stone-900 mb-4"
className="w-full rounded-xl border-2 border-[#d4d9d3] bg-white px-6 py-6 text-center text-4xl font-bold tracking-widest text-[#1d1d1f] outline-none focus:border-[#1a4d2e] mb-4"
style={{ letterSpacing: "0.3em" }}
autoFocus
/>
{error && (
<p className="text-center text-red-600 text-sm mb-4">{error}</p>
<p className="text-center text-[#b91c1c] text-sm mb-4">{error}</p>
)}
<button
type="submit"
disabled={pin.length !== 4 || loading}
className="w-full rounded-xl bg-stone-900 px-6 py-5 text-xl font-bold text-white disabled:opacity-50 min-h-[64px]"
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-bold text-white disabled:opacity-50 hover:bg-[#14532d] transition min-h-[64px]"
>
{loading ? t.loggingIn : t.submit}
</button>
@@ -459,17 +484,27 @@ function WaterFieldInner() {
const selectedHg = headgates.find((hg) => hg.id === effectiveSelectedHeadgate);
return (
<div className="min-h-screen bg-stone-50">
<div className="min-h-screen bg-[#fdfaf2]">
{/* Header */}
<div className="bg-stone-900 px-4 py-4">
<div className="bg-[#1a4d2e] px-4 py-4">
<div className="mx-auto max-w-lg flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white">{t.title}</h1>
<p className="text-sm text-stone-400">{t.loggedInAs}: {irrigatorName}</p>
<div className="flex items-center gap-3">
<WaterGauge size={32} level={null} status="open" />
<div>
<h1
className="text-xl font-bold text-white"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-xs text-[#bbf7d0] font-mono uppercase tracking-wider">
{t.loggedInAs}: {irrigatorName}
</p>
</div>
</div>
<button
onClick={handleLogout}
className="rounded-lg bg-stone-700 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-600 transition-colors min-h-[44px]"
className="rounded-lg bg-[#14532d] px-4 py-2 text-sm font-semibold text-white hover:bg-[#166534] transition-colors min-h-[44px]"
>
{t.logout}
</button>
@@ -477,7 +512,7 @@ function WaterFieldInner() {
</div>
{/* Step progress indicator */}
<div className="bg-stone-100 border-b border-stone-200 px-4 py-2">
<div className="bg-[#fafaf7] border-b border-[#d4d9d3] px-4 py-2">
<div className="mx-auto max-w-lg">
<div className="flex items-center gap-1.5">
{["lang", "role", "pin", "form"].map((s, i) => {
@@ -486,8 +521,8 @@ function WaterFieldInner() {
const isPast = stepIndex > i;
return (
<div key={s} className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${isActive ? "bg-green-600" : isPast ? "bg-green-400" : "bg-stone-300"}`} />
{i < 3 && <div className={`w-6 h-0.5 ${isPast ? "bg-green-400" : "bg-stone-300"}`} />}
<div className={`w-2 h-2 rounded-full ${isActive ? "bg-[#1a4d2e]" : isPast ? "bg-[#22c55e]" : "bg-[#d4d9d3]"}`} />
{i < 3 && <div className={`w-6 h-0.5 ${isPast ? "bg-[#22c55e]" : "bg-[#d4d9d3]"}`} />}
</div>
);
})}
+85
View File
@@ -0,0 +1,85 @@
/**
* SeasonMark — small inline sun / snowflake indicator used in the
* "Today's Summary" card to signal irrigation season status. Drawn as
* a flat 16px mark; uses the project's existing color tokens so it
* works in light and dark contexts.
*/
type Props = {
inSeason: boolean;
size?: number;
className?: string;
title?: string;
};
export function SeasonMark({
inSeason,
size = 16,
className = "",
title,
}: Props) {
const stroke = "#1a4d2e";
if (inSeason) {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
className={className}
role="img"
aria-label={title ?? "In irrigation season"}
style={{ display: "inline-block", flexShrink: 0 }}
>
<circle cx="8" cy="8" r="2.8" fill="#facc15" stroke={stroke} strokeWidth="1" />
{[
[8, 1],
[8, 15],
[1, 8],
[15, 8],
[3, 3],
[13, 13],
[3, 13],
[13, 3],
].map(([cx, cy], i) => (
<line
key={i}
x1={8}
y1={8}
x2={cx}
y2={cy}
stroke={stroke}
strokeWidth="1"
strokeLinecap="round"
opacity="0.7"
/>
))}
</svg>
);
}
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
className={className}
role="img"
aria-label={title ?? "Outside irrigation season"}
style={{ display: "inline-block", flexShrink: 0 }}
>
<g stroke="#1e3a8a" strokeWidth="1.2" strokeLinecap="round" fill="none">
<line x1="8" y1="1" x2="8" y2="15" />
<line x1="1" y1="8" x2="15" y2="8" />
<line x1="2.5" y1="2.5" x2="13.5" y2="13.5" />
<line x1="2.5" y1="13.5" x2="13.5" y2="2.5" />
{/* Arrow heads */}
<polyline points="6.5,2.5 8,1 9.5,2.5" />
<polyline points="6.5,13.5 8,15 9.5,13.5" />
<polyline points="2.5,6.5 1,8 2.5,9.5" />
<polyline points="13.5,6.5 15,8 13.5,9.5" />
<polyline points="4,4 2.5,2.5 4,1" />
<polyline points="12,4 13.5,2.5 12,1" />
<polyline points="4,12 2.5,13.5 4,15" />
<polyline points="12,12 13.5,13.5 12,15" />
</g>
</svg>
);
}
+128
View File
@@ -0,0 +1,128 @@
/**
* WaterGauge — a stylized "ditch gauge" SVG used as a visual signature
* for the Water Log feature. Renders a vertical pipe with a water level
* and a small headgate at the top. Deliberately drawn with a slight
* hand-sketched quality (rough strokes, no perfectly straight lines)
* to evoke a field-notebook annotation rather than a slick dashboard
* chart.
*
* <WaterGauge size={48} level={0.7} status="open" />
* <WaterGauge size={24} level={null} status="closed" />
*/
type Props = {
size?: number;
level?: number | null; // 0..1
status?: "open" | "closed" | "maintenance";
className?: string;
ariaLabel?: string;
};
export function WaterGauge({
size = 32,
level = 0.5,
status = "open",
className = "",
ariaLabel = "Water gauge",
}: Props) {
const w = size;
const h = size * 1.25;
const stroke = Math.max(1, size / 16);
// Clamp + choose fill level
const l =
level == null
? status === "closed"
? 0
: 0.45
: Math.max(0, Math.min(1, level));
// Color reflects the operational state
const waterColor =
status === "maintenance"
? "#a16207" // gold-700
: status === "closed"
? "#86868b" // surface-400
: l > 0.85
? "#dc2626" // red — over threshold
: l < 0.15
? "#2563eb" // blue — under threshold
: "#0e7490"; // cyan-700 — flowing nicely
const headgateColor = status === "closed" ? "#3b3b3f" : "#1a4d2e";
return (
<svg
width={w}
height={h}
viewBox="0 0 32 40"
role="img"
aria-label={ariaLabel}
className={className}
style={{ display: "inline-block", flexShrink: 0 }}
>
{/* Top "headgate" — a small handled gate */}
<g stroke={headgateColor} strokeWidth={stroke} fill="none" strokeLinecap="round">
{/* Gate top bar */}
<path d="M9 4 H23" />
{/* Gate handle */}
<path d="M14 1.5 V4 M18 1.5 V4" />
{/* Gate frame */}
<path d="M8 4 V8 H24 V4" />
{/* Gate wheel */}
<circle cx="16" cy="3" r="1.2" fill={headgateColor} />
</g>
{/* The ditch / pipe */}
<g
stroke="#1a1a1a"
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 8 L8 36 L24 36 L24 8" />
{/* Tick marks at quarter / half / three-quarter */}
<path d="M8 17 H11 M8 24 H11 M8 31 H11" />
<path d="M24 17 H21 M24 24 H21 M24 31 H21" />
</g>
{/* Water level — clipped inside the pipe */}
<clipPath id={`wg-clip-${Math.round(size)}-${status}`}>
<path d="M9.2 9 L9.2 35.2 L22.8 35.2 L22.8 9 Z" />
</clipPath>
<g clipPath={`url(#wg-clip-${Math.round(size)}-${status})`}>
<rect
x={9.2}
y={35.2 - 25.6 * l}
width={13.6}
height={25.6 * l}
fill={waterColor}
opacity={0.85}
/>
{/* Wavy water surface — three short arcs */}
{l > 0 && (
<path
d={`M9.2 ${35.2 - 25.6 * l} q1.7 -1 3.4 0 t3.4 0 t3.4 0 t3.4 0`}
stroke={waterColor}
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
/>
)}
</g>
{/* Status dot — top-right corner */}
<circle
cx={26}
cy={3}
r={1.4}
fill={
status === "open"
? "#16a34a" // green
: status === "closed"
? "#9ca3af" // gray
: "#ca8a04" // gold
}
/>
</svg>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { WaterGauge } from "./WaterGauge";
export { SeasonMark } from "./SeasonMark";