diff --git a/src/app/water/admin/page.tsx b/src/app/water/admin/page.tsx
index b9635d1..40118cb 100644
--- a/src/app/water/admin/page.tsx
+++ b/src/app/water/admin/page.tsx
@@ -1,7 +1,15 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
+import {
+ getWaterDisplaySummary,
+ getWaterHeadgatesAdmin,
+ getWaterIrrigators,
+ getWaterAlertLog,
+} from "@/actions/water-log/admin";
import WaterAdminClient from "@/components/water/WaterAdminClient";
+const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
+
export const dynamic = "force-dynamic";
export default async function WaterAdminPage() {
@@ -11,5 +19,22 @@ export default async function WaterAdminPage() {
redirect("/water/admin/login");
}
- return ;
-}
\ No newline at end of file
+ // Bootstrap the 4 datasets in parallel on the server so the first paint
+ // already has real data — no "Loading…" skeleton flash. The client's
+ // 30s polling keeps the display-summary card fresh from here.
+ const [summary, headgates, users, alertLog] = await Promise.all([
+ getWaterDisplaySummary(TUXEDO_BRAND_ID),
+ getWaterHeadgatesAdmin(TUXEDO_BRAND_ID),
+ getWaterIrrigators(TUXEDO_BRAND_ID),
+ getWaterAlertLog(TUXEDO_BRAND_ID, 50),
+ ]);
+
+ return (
+
+ );
+}
diff --git a/src/components/water/WaterAdminClient.tsx b/src/components/water/WaterAdminClient.tsx
index 8f0cc2a..0098959 100644
--- a/src/components/water/WaterAdminClient.tsx
+++ b/src/components/water/WaterAdminClient.tsx
@@ -1,71 +1,65 @@
"use client";
-import { useReducer, useEffect, useCallback } from "react";
+import { useReducer, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
-import { getWaterEntries, getWaterDisplaySummary, getWaterIrrigators, getWaterHeadgatesAdmin, createWaterIrrigator, createWaterHeadgate, getWaterAlertLog, type WaterDisplaySummary, type WaterDisplayHeadgate, type AlertLogEntry } from "@/actions/water-log/admin";
+import {
+ getWaterEntries,
+ getWaterDisplaySummary,
+ createWaterIrrigator,
+ createWaterHeadgate,
+ type AdminHeadgate as Headgate,
+ type AdminIrrigator as WaterUser,
+ type AdminEntry as WaterEntry,
+ type WaterDisplaySummary,
+ type WaterDisplayHeadgate,
+ type AlertLogEntry,
+} from "@/actions/water-log/admin";
import { logoutWaterAdmin } from "@/actions/water-log/field";
-type WaterEntry = {
- id: string;
- headgate_id: string;
- user_id: string;
- headgate_name: string;
- user_name: string;
- measurement: number;
- unit: string;
- notes: string | null;
- submitted_via: string;
- logged_at: string;
+/* ── Props ────────────────────────────────────────────────────────── */
+
+type Props = {
+ initialSummary: WaterDisplaySummary | null;
+ initialHeadgates: Headgate[];
+ initialUsers: WaterUser[];
+ initialAlertLog: AlertLogEntry[];
};
-type WaterUser = {
- id: string;
- name: string;
- role: "irrigator" | "water_admin";
- active: boolean;
- language_preference: string;
- last_used_at: string | null;
- created_at: string;
-};
-
-type Headgate = {
- id: string;
- name: string;
- active: boolean;
- unit: string;
- created_at: string;
-};
-
-const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
+/* ── i18n ─────────────────────────────────────────────────────────── */
const LABELS = {
en: {
title: "Water Log Admin",
- loggedInAs: "Logged in as",
- logout: "Salir",
- recentEntries: "Entradas Recientes",
+ loggedInAs: "Signed in as",
+ logout: "Sign out",
+ recentEntries: "Recent Entries",
noEntries: "No entries yet",
- when: "Fecha",
- user: "Usuario",
- headgate: "Compuerta",
- measurement: "Medición",
- via: "Vía",
- field: "Campo",
+ noMatches: "No matches",
+ when: "When",
+ user: "User",
+ headgate: "Headgate",
+ measurement: "Measurement",
+ via: "Via",
+ field: "Field",
admin: "Admin",
exportCSV: "Export CSV",
clearFilters: "Clear",
- displaySummary: "Resumen de Pantalla",
- todayCount: "Entradas de hoy",
- lastUpdate: "Última actualización",
+ save: "Save",
+ saveShort: "+",
+ loadMore: "Load more",
+ displaySummary: "Display Summary",
+ todayCount: "Entries today",
+ todayTotalSuffix: "total",
+ lastUpdate: "Last update",
refreshIn: "Refresh in",
- noHeadgates: "Sin compuertas activas",
- noLatest: "Sin lecturas",
- noEntriesToday: "Sin entradas hoy",
- recentEntriesLabel: "Entradas recientes",
+ noHeadgates: "No active headgates",
+ noLatest: "No readings",
+ noEntriesToday: "No entries today",
headgates: "Headgates",
users: "Users",
- addHeadgate: "Add Headgate",
- addUser: "Add User",
+ alerts: "Alert Log",
+ addHeadgate: "Add headgate",
+ addUser: "Add user",
edit: "Edit",
cancel: "Cancel",
name: "Name",
@@ -78,14 +72,28 @@ const LABELS = {
newPinFor: "New PIN for",
writeThisDown: "Write this down — it will not be shown again.",
dismiss: "Dismiss",
- saving: "Saving...",
+ saving: "Saving…",
+ filterAll: "All headgates",
+ filterAllVia: "All via",
+ noUsers: "No users yet",
+ noAlerts: "No alerts triggered yet.",
+ time: "Time",
+ threshold: "Threshold",
+ reading: "Reading",
+ alertHigh: "High ↑",
+ alertLow: "Low ↓",
+ minutesAgo: "m ago",
+ hoursAgo: "h ago",
+ justNow: "Just now",
+ never: "Never",
},
es: {
- title: "Admin Registro de Agua",
+ title: "Administración del Registro de Agua",
loggedInAs: "Conectado como",
logout: "Salir",
- recentEntries: "Entradas Recientes",
+ recentEntries: "Entradas recientes",
noEntries: "Sin entradas aún",
+ noMatches: "Sin resultados",
when: "Fecha",
user: "Usuario",
headgate: "Compuerta",
@@ -95,18 +103,22 @@ const LABELS = {
admin: "Admin",
exportCSV: "Exportar CSV",
clearFilters: "Limpiar",
- displaySummary: "Resumen de Pantalla",
- todayCount: "Entradas de hoy",
+ save: "Guardar",
+ saveShort: "+",
+ loadMore: "Cargar más",
+ displaySummary: "Resumen de pantalla",
+ todayCount: "Entradas hoy",
+ todayTotalSuffix: "total",
lastUpdate: "Última actualización",
- refreshIn: "Refresh in",
+ refreshIn: "Refresca en",
noHeadgates: "Sin compuertas activas",
noLatest: "Sin lecturas",
noEntriesToday: "Sin entradas hoy",
- recentEntriesLabel: "Entradas recientes",
headgates: "Compuertas",
users: "Usuarios",
- addHeadgate: "Agregar Compuerta",
- addUser: "Agregar Usuario",
+ alerts: "Registro de alertas",
+ addHeadgate: "Agregar compuerta",
+ addUser: "Agregar usuario",
edit: "Editar",
cancel: "Cancelar",
name: "Nombre",
@@ -117,18 +129,50 @@ const LABELS = {
language: "Idioma",
pin: "PIN",
newPinFor: "Nuevo PIN para",
- writeThisDown: "Write this down — it will not be shown again.",
- dismiss: "Dismiss",
- saving: "Saving...",
+ writeThisDown: "Anótalo — no se mostrará de nuevo.",
+ dismiss: "Cerrar",
+ saving: "Guardando…",
+ filterAll: "Todas las compuertas",
+ filterAllVia: "Todos los medios",
+ noUsers: "Sin usuarios aún",
+ noAlerts: "Sin alertas aún.",
+ time: "Hora",
+ threshold: "Umbral",
+ reading: "Lectura",
+ alertHigh: "Alta ↑",
+ alertLow: "Baja ↓",
+ minutesAgo: "m",
+ hoursAgo: "h",
+ justNow: "Ahora",
+ never: "Nunca",
},
-};
+} as const;
+
+type Labels = (typeof LABELS)["en" | "es"];
+
+function detectInitialLang(): "en" | "es" {
+ if (typeof document === "undefined") return "es"; // server snapshot fallback; client useEffect adjusts
+ const cookie = document.cookie.match(/wl_lang=(en|es)/)?.[1];
+ if (cookie === "en" || cookie === "es") return cookie;
+ if (typeof navigator !== "undefined") {
+ const lang = navigator.language?.toLowerCase() ?? "";
+ if (lang.startsWith("en")) return "en";
+ }
+ return "es"; // default for this predominantly-Spanish audience
+}
+
+const UNIT_OPTIONS = ["CFS", "GPM", "gal", "ac-in", "ac-ft"];
+
+/* ── State ────────────────────────────────────────────────────────── */
type State = {
lang: "en" | "es";
displaySummary: WaterDisplaySummary | null;
- displayLoading: boolean;
refreshCountdown: number;
- allEntries: WaterEntry[];
+ entries: WaterEntry[];
+ entriesPage: number; // 0 = first 50, 1 = next 50, ...
+ entriesHasMore: boolean;
+ entriesLoading: boolean;
users: WaterUser[];
headgates: Headgate[];
alertLog: AlertLogEntry[];
@@ -151,22 +195,36 @@ type State = {
newHgUnit: string;
savingHg: boolean;
newHgError: string | null;
- initialLoading: boolean;
};
type Action =
| { type: "SET_LANG"; lang: "en" | "es" }
| { type: "SET_DISPLAY_SUMMARY"; summary: WaterDisplaySummary | null }
- | { type: "SET_DISPLAY_LOADING"; value: boolean }
- | { type: "SET_REFRESH_COUNTDOWN"; value: number }
| { type: "DECREMENT_COUNTDOWN" }
- | { type: "SET_ENTRIES"; entries: WaterEntry[] }
+ | { type: "RESET_COUNTDOWN"; value: number }
+ | { type: "SET_ENTRIES"; entries: WaterEntry[]; append?: boolean }
+ | { type: "SET_ENTRIES_PAGE"; page: number }
+ | { type: "SET_ENTRIES_HAS_MORE"; value: boolean }
+ | { type: "SET_ENTRIES_LOADING"; value: boolean }
| { type: "SET_USERS"; users: WaterUser[] }
| { type: "SET_HEADGATES"; headgates: Headgate[] }
| { type: "SET_ALERT_LOG"; alertLog: AlertLogEntry[] }
- | { type: "SET_FILTER"; field: "filterDateFrom" | "filterDateTo" | "filterHeadgate" | "filterVia"; value: string }
+ | {
+ type: "SET_FILTER";
+ field: "filterDateFrom" | "filterDateTo" | "filterHeadgate" | "filterVia";
+ value: string;
+ }
| { type: "CLEAR_FILTERS" }
- | { type: "TOGGLE"; key: "showHeadgates" | "showUsers" | "showEntries" | "showAlerts" | "showAddHg" | "showAddUser" }
+ | {
+ type: "TOGGLE";
+ key:
+ | "showHeadgates"
+ | "showUsers"
+ | "showEntries"
+ | "showAlerts"
+ | "showAddHg"
+ | "showAddUser";
+ }
| { type: "SET_NEW_PIN"; value: { name: string; pin: string } | null }
| { type: "SET_NEW_USER_NAME"; value: string }
| { type: "SET_NEW_USER_LANG"; value: "en" | "es" }
@@ -175,39 +233,7 @@ type Action =
| { type: "SET_NEW_HG_NAME"; value: string }
| { type: "SET_NEW_HG_UNIT"; value: string }
| { type: "SET_SAVING_HG"; value: boolean }
- | { type: "SET_NEW_HG_ERROR"; value: string | null }
- | { type: "SET_INITIAL_LOADING"; value: boolean };
-
-const initialState: State = {
- lang: "en",
- displaySummary: null,
- displayLoading: true,
- refreshCountdown: 30,
- allEntries: [],
- users: [],
- headgates: [],
- alertLog: [],
- filterDateFrom: "",
- filterDateTo: "",
- filterHeadgate: "",
- filterVia: "",
- showHeadgates: true,
- showUsers: true,
- showEntries: true,
- showAlerts: true,
- showAddHg: false,
- showAddUser: false,
- newPin: null,
- newUserName: "",
- newUserLang: "en",
- savingUser: false,
- newUserError: null,
- newHgName: "",
- newHgUnit: "CFS",
- savingHg: false,
- newHgError: null,
- initialLoading: true,
-};
+ | { type: "SET_NEW_HG_ERROR"; value: string | null };
function reducer(state: State, action: Action): State {
switch (action.type) {
@@ -215,14 +241,21 @@ function reducer(state: State, action: Action): State {
return { ...state, lang: action.lang };
case "SET_DISPLAY_SUMMARY":
return { ...state, displaySummary: action.summary };
- case "SET_DISPLAY_LOADING":
- return { ...state, displayLoading: action.value };
- case "SET_REFRESH_COUNTDOWN":
- return { ...state, refreshCountdown: action.value };
case "DECREMENT_COUNTDOWN":
- return { ...state, refreshCountdown: state.refreshCountdown - 1 };
+ return { ...state, refreshCountdown: Math.max(state.refreshCountdown - 1, 0) };
+ case "RESET_COUNTDOWN":
+ return { ...state, refreshCountdown: action.value };
case "SET_ENTRIES":
- return { ...state, allEntries: action.entries };
+ return {
+ ...state,
+ entries: action.append ? [...state.entries, ...action.entries] : action.entries,
+ };
+ case "SET_ENTRIES_PAGE":
+ return { ...state, entriesPage: action.page };
+ case "SET_ENTRIES_HAS_MORE":
+ return { ...state, entriesHasMore: action.value };
+ case "SET_ENTRIES_LOADING":
+ return { ...state, entriesLoading: action.value };
case "SET_USERS":
return { ...state, users: action.users };
case "SET_HEADGATES":
@@ -232,7 +265,13 @@ function reducer(state: State, action: Action): State {
case "SET_FILTER":
return { ...state, [action.field]: action.value };
case "CLEAR_FILTERS":
- return { ...state, filterDateFrom: "", filterDateTo: "", filterHeadgate: "", filterVia: "" };
+ return {
+ ...state,
+ filterDateFrom: "",
+ filterDateTo: "",
+ filterHeadgate: "",
+ filterVia: "",
+ };
case "TOGGLE":
return { ...state, [action.key]: !state[action.key] };
case "SET_NEW_PIN":
@@ -253,65 +292,54 @@ function reducer(state: State, action: Action): State {
return { ...state, savingHg: action.value };
case "SET_NEW_HG_ERROR":
return { ...state, newHgError: action.value };
- case "SET_INITIAL_LOADING":
- return { ...state, initialLoading: action.value };
}
}
-function SummarySkeleton() {
- return (
-
-
-
- {[1, 2, 3].map(i => (
-
- ))}
-
-
- );
+const PAGE_SIZE = 50;
+const REFRESH_SECONDS = 30;
+
+function reducerInit(
+ initialSummary: WaterDisplaySummary | null,
+ initialHeadgates: Headgate[],
+ initialUsers: WaterUser[],
+ initialAlertLog: AlertLogEntry[],
+): State {
+ return {
+ lang: "es",
+ displaySummary: initialSummary,
+ refreshCountdown: REFRESH_SECONDS,
+ entries: [],
+ entriesPage: -1, // -1 means "haven't loaded yet"; first load treats this as page 0
+ entriesHasMore: true,
+ entriesLoading: false,
+ users: initialUsers,
+ headgates: initialHeadgates,
+ alertLog: initialAlertLog,
+ filterDateFrom: "",
+ filterDateTo: "",
+ filterHeadgate: "",
+ filterVia: "",
+ showHeadgates: true,
+ showUsers: true,
+ showEntries: true,
+ showAlerts: true,
+ showAddHg: false,
+ showAddUser: false,
+ newPin: null,
+ newUserName: "",
+ newUserLang: "es",
+ savingUser: false,
+ newUserError: null,
+ newHgName: "",
+ newHgUnit: "CFS",
+ savingHg: false,
+ newHgError: null,
+ };
}
-function TableSkeleton({ rows = 5 }: { rows?: number }) {
- return (
-
-
- {Array.from({ length: rows }).map((_, i) => (
-
- ))}
-
- );
-}
+/* ── Helpers ──────────────────────────────────────────────────────── */
-function CardsSkeleton({ count = 4 }: { count?: number }) {
- return (
-
-
-
- {Array.from({ length: count }).map((_, i) => (
-
- ))}
-
-
- );
-}
+const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleDateString("en-US", {
@@ -322,165 +350,221 @@ function formatDateTime(iso: string): string {
});
}
-const AGE_COLORS = {
- green: "bg-green-100 text-green-700",
- yellow: "bg-yellow-100 text-yellow-700",
- red: "bg-red-100 text-red-700",
-};
+function ageLabel(minutes_ago: number | null, t: Labels): string {
+ if (minutes_ago === null) return t.never;
+ if (minutes_ago < 2) return t.justNow;
+ if (minutes_ago < 60) return `${minutes_ago}${t.minutesAgo}`;
+ return `${Math.floor(minutes_ago / 60)}${t.hoursAgo}`;
+}
-const UNIT_OPTIONS = ["CFS", "GPM", "gal", "ac-in", "ac-ft"];
+function ageColor(minutes_ago: number | null): "ok" | "warn" | "down" {
+ if (minutes_ago === null) return "down";
+ if (minutes_ago > 60) return "warn";
+ return "ok";
+}
-function HeadgateCard({ hg }: { hg: WaterDisplayHeadgate }) {
- const ageColor = (["green", "yellow", "red"] as const)[
- hg.minutes_ago === null ? 2 : hg.minutes_ago > 60 ? 1 : 0
- ];
- const ageLabel =
- hg.minutes_ago === null
- ? "Never"
- : hg.minutes_ago < 2
- ? "Just now"
- : hg.minutes_ago < 60
- ? `${hg.minutes_ago}m ago`
- : `${Math.floor(hg.minutes_ago / 60)}h ago`;
+/* ── Subcomponents ────────────────────────────────────────────────── */
+
+function AdminHeader({
+ title,
+ subtitle,
+ lang,
+ onChangeLang,
+ onLogout,
+ t,
+}: {
+ title: string;
+ subtitle: string;
+ lang: "en" | "es";
+ onChangeLang: (l: "en" | "es") => void;
+ onLogout: () => void;
+ t: Labels;
+}) {
return (
-
-
{hg.name}
- {hg.latest_entry ? (
- <>
-
- {hg.latest_entry.measurement}{" "}
- {hg.unit}
-
-
{hg.latest_entry.user_name}
-
+
+
+
+ {title}
+
+
{subtitle}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function SectionLabel({ children, count }: { children: React.ReactNode; count?: number }) {
+ return (
+
+
+ {children}
+
+ {count !== undefined && (
+
+ {count}
+
)}
);
}
-function SectionHeader({
- title,
- count,
- onToggle,
- action,
+function ToggleButton({
+ open,
+ onClick,
}: {
- title: string;
- count?: number;
- onToggle: () => void;
- action?: React.ReactNode;
+ open: boolean;
+ onClick: () => void;
}) {
return (
-
-
- {action}
-
+
);
}
-async function handleLogout() {
- await logoutWaterAdmin();
- window.location.href = "/water";
-}
-
-/* ── Subcomponents ─────────────────────────────────────────────── */
-
-type Labels = typeof LABELS.en;
-
-function AdminHeader({ title, onLogout }: { title: string; onLogout: () => void }) {
+function HeadgateCard({ hg, t }: { hg: WaterDisplayHeadgate; t: Labels }) {
+ const tone = ageColor(hg.minutes_ago);
+ const pillClass =
+ tone === "ok"
+ ? "bg-green-900/30 text-green-400 ring-1 ring-inset ring-green-900/50"
+ : tone === "warn"
+ ? "bg-amber-900/30 text-amber-300 ring-1 ring-inset ring-amber-900/50"
+ : "bg-zinc-800 text-zinc-500 ring-1 ring-inset ring-zinc-700";
return (
-
-
-
-
{title}
-
Logged in as: Admin
-
-
-
+
+
{hg.name}
+ {hg.latest_entry ? (
+ <>
+
+ {hg.latest_entry.measurement}
+ {hg.unit}
+
+
{hg.latest_entry.user_name}
+
+ {ageLabel(hg.minutes_ago, t)}
+
+ >
+ ) : (
+
{t.noLatest}
+ )}
);
}
function DisplaySummarySection({
displaySummary,
- displayLoading,
refreshCountdown,
- initialLoading,
t,
}: {
displaySummary: WaterDisplaySummary | null;
- displayLoading: boolean;
refreshCountdown: number;
- initialLoading: boolean;
t: Labels;
}) {
return (
-
-
-
{t.displaySummary}
-
- {t.refreshIn}: {refreshCountdown}s
+
+
+ {t.displaySummary}
+
+ {t.refreshIn} {refreshCountdown}s
- {initialLoading ? (
-
- ) : displayLoading ? (
-
- ) : displaySummary ? (
-
-
- {displaySummary.headgates.length === 0 ? (
-
{t.noHeadgates}
- ) : (
-
- {displaySummary.headgates.map((hg) => (
-
- ))}
-
- )}
-
-
+
+ {displaySummary ? (
+
+ {displaySummary.headgates.length === 0 ? (
+
{t.noHeadgates}
+ ) : (
+
+ {displaySummary.headgates.map((hg) => (
+
+
+
+ ))}
+
+ )}
+
+
-
{t.todayCount}
-
+
+ {t.todayCount}
+
+
{displaySummary.today_count}
{displaySummary.today_count > 0 && (
-
- ({displaySummary.today_total.toFixed(1)} total)
+
+ {displaySummary.today_total.toFixed(1)} {t.todayTotalSuffix}
)}
- {displaySummary.recent_entries.length > 0 && (
-
- {t.lastUpdate}: {formatDateTime(displaySummary.recent_entries[0]?.logged_at)}
-
+ {displaySummary.recent_entries[0] && (
+
+ {t.lastUpdate}
+
+
+ {formatDateTime(displaySummary.recent_entries[0].logged_at)}
+
+
)}
) : (
-
{t.noEntriesToday}
+
)}
);
@@ -494,7 +578,6 @@ function HeadgatesSection({
newHgUnit,
savingHg,
newHgError,
- initialLoading,
t,
onToggleSection,
onToggleAdd,
@@ -511,7 +594,6 @@ function HeadgatesSection({
newHgUnit: string;
savingHg: boolean;
newHgError: string | null;
- initialLoading: boolean;
t: Labels;
onToggleSection: () => void;
onToggleAdd: () => void;
@@ -522,89 +604,108 @@ function HeadgatesSection({
onEdit: (id: string) => void;
}) {
return (
-