Build Lake Eufaula recommendation vertical slice

This commit is contained in:
reaper
2026-08-29 20:35:42 -05:00
parent 3eb459a307
commit ad4089bb77
8 changed files with 116 additions and 12 deletions
+34 -4
View File
@@ -1,11 +1,41 @@
import React from "react";
import React, { FormEvent, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import "./styles.css";
const features = ["Condition-aware plan", "Tackle you already own", "Explainable confidence", "Ask Professor Finn"];
type Plan = { trip: { water_body: string; target_species: string; starts_at: string; duration_hours: number }; plan: { bite_score: number; confidence: number; summary: string; data_status: string; conditions: Record<string, string | number>; primary_pattern: Record<string, string>; tackle: Record<string, string>; evidence: string[]; professor_finn: string } };
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000/api";
function nextSaturdayMorning() {
const date = new Date(); date.setDate(date.getDate() + ((6 - date.getDay() + 7) % 7 || 7)); date.setHours(6, 30, 0, 0);
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
}
function App() {
return <main><nav><strong>FishIQ</strong><span>Plan smarter. Fish better.</span></nav><section className="hero"><div><p className="eyebrow">YOUR FISHING INTELLIGENCE</p><h1>Know where to go.<br/>Know what to throw.</h1><p className="lede">Turn water, weather, season, and your own tackle into a practical fishing plan.</p><button>Plan a trip</button></div><aside><p className="eyebrow">SATURDAY · LAKE EUFAULA</p><h2>Largemouth bass</h2><div className="score">82 <small>Bite score</small></div><p>Start on wind-blown secondary points at first light. Shift to shaded cover as the sun rises.</p></aside></section><section className="features">{features.map((f, i) => <article key={f}><b>0{i+1}</b><h3>{f}</h3></article>)}</section></main>;
const [startsAt, setStartsAt] = useState(nextSaturdayMorning);
const [result, setResult] = useState<Plan | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function createPlan(event?: FormEvent) {
event?.preventDefault(); setLoading(true); setError("");
try {
const response = await fetch(`${API_URL}/demo/plan/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ starts_at: new Date(startsAt).toISOString() }) });
if (!response.ok) throw new Error("The planner could not create your trip.");
setResult(await response.json());
} catch (reason) { setError(reason instanceof Error ? reason.message : "The planner is unavailable."); }
finally { setLoading(false); }
}
useEffect(() => { void createPlan(); }, []);
const conditions = result?.plan.conditions;
return <main>
<nav><strong>FishIQ</strong><span>Plan smarter. Fish better.</span></nav>
<section className="intro"><div><p className="eyebrow">FIRST INTELLIGENT TRIP</p><h1>Lake Eufaula.<br/>Saturday morning.</h1><p className="lede">A practical largemouth bass plan built from conditions, seasonal behavior, and complete tackle recommendations.</p></div>
<form onSubmit={createPlan}><label>Water<select disabled><option>Lake Eufaula · AL/GA</option></select></label><label>Target<select disabled><option>Largemouth bass</option></select></label><label>Date and launch time<input type="datetime-local" value={startsAt} onChange={event => setStartsAt(event.target.value)} /></label><button disabled={loading}>{loading ? "Building your plan…" : "Build my plan"}</button>{error && <p className="error">{error} Check that the API is running.</p>}</form>
</section>
{result && <section className="plan" aria-live="polite"><header><div><p className="eyebrow">YOUR GAME PLAN</p><h2>{result.trip.target_species}</h2><p>{new Date(result.trip.starts_at).toLocaleString([], { weekday: "long", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })} · {result.trip.duration_hours} hours</p></div><div className="score">{result.plan.bite_score}<small>Bite score</small></div></header>
<p className="summary">{result.plan.summary}</p><div className="condition-strip"><span><b>{conditions?.air_temperature_f}°</b>Air</span><span><b>{conditions?.water_temperature_f}°</b>Water</span><span><b>{conditions?.wind_direction} {conditions?.wind_mph}</b>mph wind</span><span><b>{conditions?.pressure_inhg}</b>inHg</span><span><b>{conditions?.cloud_cover_percent}%</b>Clouds</span></div><p className="data-status">{result.plan.data_status}</p>
<div className="details"><article><p className="eyebrow">PRIMARY PATTERN</p><h3>{result.plan.primary_pattern.name}</h3><dl><dt>Depth</dt><dd>{result.plan.primary_pattern.depth}</dd><dt>Structure</dt><dd>{result.plan.primary_pattern.structure}</dd><dt>Retrieve</dt><dd>{result.plan.primary_pattern.presentation}</dd></dl></article><article><p className="eyebrow">COMPLETE SETUP</p><h3>{result.plan.tackle.lure}</h3><dl><dt>Rod</dt><dd>{result.plan.tackle.rod}</dd><dt>Reel</dt><dd>{result.plan.tackle.reel}</dd><dt>Line</dt><dd>{result.plan.tackle.line}</dd><dt>Backup</dt><dd>{result.plan.tackle.backup}</dd></dl></article></div>
<div className="finn"><div className="avatar">PF</div><div><p className="eyebrow">PROFESSOR FINN · {Math.round(result.plan.confidence * 100)}% CONFIDENCE</p><h3>Heres how Id fish it</h3><p>{result.plan.professor_finn}</p></div></div><details><summary>Why FishIQ recommends this</summary><ul>{result.plan.evidence.map(item => <li key={item}>{item}</li>)}</ul></details>
</section>}
</main>;
}
createRoot(document.getElementById("root")!).render(<React.StrictMode><App /></React.StrictMode>);
+1 -2
View File
@@ -1,3 +1,2 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=Manrope:wght@600;800&display=swap');
:root{font-family:'DM Sans',sans-serif;color:#eaf4ef;background:#071c1b}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% 10%,#174e46 0,transparent 34%),#071c1b}main{max-width:1180px;margin:auto;padding:28px}nav{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #ffffff24;padding:0 0 22px}nav strong{font:800 25px Manrope;color:#72e3ad}nav span{color:#a8bdb7}.hero{min-height:600px;display:grid;grid-template-columns:1.5fr 1fr;gap:64px;align-items:center}.eyebrow{color:#72e3ad;font-size:12px;font-weight:700;letter-spacing:.18em}h1{font:800 clamp(48px,7vw,84px)/.98 Manrope;margin:18px 0}.lede{font-size:20px;line-height:1.6;color:#b8c9c4;max-width:600px}button{background:#72e3ad;color:#06201b;border:0;border-radius:99px;padding:15px 25px;font-weight:700;font-size:16px}aside{background:#f2f0e8;color:#102b28;padding:34px;border-radius:24px;box-shadow:0 30px 70px #0005}aside .eyebrow{color:#237c62}.score{font:800 70px Manrope;color:#197457;border-block:1px solid #183a3125;padding:20px 0}.score small{font:600 14px 'DM Sans';color:#526763}.features{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.features article{border-top:1px solid #ffffff33;padding:20px 4px}.features b{color:#72e3ad;font-size:12px}.features h3{font:600 16px Manrope}@media(max-width:760px){.hero{grid-template-columns:1fr;padding:70px 0}.features{grid-template-columns:1fr 1fr}nav span{display:none}}
:root{font-family:'DM Sans',sans-serif;color:#eaf4ef;background:#071c1b;font-synthesis:none}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 78% 6%,#174e46 0,transparent 29%),#071c1b}button,input,select{font:inherit}main{max-width:1180px;margin:auto;padding:28px 28px 80px}nav{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #ffffff24;padding:0 0 22px}nav strong{font:800 25px Manrope;color:#72e3ad}nav span,.lede{color:#a8bdb7}.intro{min-height:560px;display:grid;grid-template-columns:1.35fr .8fr;gap:72px;align-items:center}.eyebrow{color:#72e3ad;font-size:11px;font-weight:700;letter-spacing:.18em;margin:0 0 12px}h1{font:800 clamp(46px,6.5vw,78px)/1 Manrope;margin:18px 0}.lede{font-size:19px;line-height:1.6;max-width:600px}form{background:#f2f0e8;color:#102b28;padding:30px;border-radius:22px;box-shadow:0 30px 70px #0005;display:grid;gap:18px}label{font-size:13px;font-weight:700;color:#3d514c;display:grid;gap:7px}input,select{width:100%;border:1px solid #cad3cf;background:#fff;border-radius:9px;padding:12px;color:#102b28}select:disabled{opacity:1}button{background:#72e3ad;color:#06201b;border:0;border-radius:99px;padding:14px 24px;font-weight:700;cursor:pointer}button:disabled{opacity:.65}.error{color:#a12828;font-size:13px;margin:0}.plan{background:#f2f0e8;color:#102b28;padding:clamp(24px,5vw,52px);border-radius:28px}.plan header{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:1px solid #1b3c3324;padding-bottom:26px}.plan h2{font:800 clamp(32px,5vw,54px) Manrope;margin:0}.plan header>div>p:last-child{color:#64756f}.score{font:800 68px/1 Manrope;color:#197457;text-align:right}.score small{display:block;font:600 12px 'DM Sans';color:#65756f;text-transform:uppercase;letter-spacing:.1em}.summary{font:600 clamp(20px,3vw,29px)/1.4 Manrope;max-width:850px}.condition-strip{display:grid;grid-template-columns:repeat(5,1fr);border-block:1px solid #1b3c3324;margin-top:28px}.condition-strip span{padding:19px 8px;color:#61716c;font-size:12px}.condition-strip b{display:block;color:#173d34;font-size:18px;margin-bottom:3px}.data-status{font-size:11px;color:#7d8b87;margin-top:8px}.details{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:32px 0}.details article{background:#fff;padding:28px;border-radius:16px}.details h3,.finn h3{font:700 20px Manrope;margin:7px 0 20px}.details .eyebrow{color:#237c62}dl{margin:0}dt{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:#74827e;margin-top:15px}dd{margin:3px 0;line-height:1.5}.finn{background:#103f36;color:#eef8f3;border-radius:18px;padding:28px;display:flex;gap:22px}.finn p:last-child{color:#c9dcd5;line-height:1.65;margin-bottom:0}.avatar{flex:0 0 50px;height:50px;border-radius:50%;display:grid;place-items:center;background:#72e3ad;color:#0b3129;font-weight:800}details{margin-top:18px;border:1px solid #1b3c3330;border-radius:14px;padding:18px 20px}summary{font-weight:700;cursor:pointer}li{margin:11px 0;line-height:1.5}@media(max-width:760px){.intro{grid-template-columns:1fr;gap:30px;padding:70px 0}.details{grid-template-columns:1fr}.condition-strip{grid-template-columns:repeat(2,1fr)}.plan header{gap:16px}.score{font-size:48px}nav span{display:none}}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />