Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { WaterGauge } from "./WaterGauge";
|
||||
export { SeasonMark } from "./SeasonMark";
|
||||
Reference in New Issue
Block a user