Build Lake Eufaula recommendation vertical slice
This commit is contained in:
+1
-1
@@ -4,8 +4,8 @@ __pycache__/
|
||||
*.py[cod]
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.dart_tool/
|
||||
build/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Conditions:
|
||||
air_temperature_f: int
|
||||
wind_mph: int
|
||||
wind_direction: str
|
||||
pressure_inhg: float
|
||||
pressure_trend: str
|
||||
cloud_cover_percent: int
|
||||
water_temperature_f: int
|
||||
sunrise: str
|
||||
|
||||
|
||||
def demo_conditions(starts_at: datetime) -> Conditions:
|
||||
"""Stable demo conditions until a live provider adapter is connected."""
|
||||
seasonal_water = {12: 52, 1: 49, 2: 53, 3: 61, 4: 69, 5: 76}.get(starts_at.month, 78)
|
||||
return Conditions(64 if starts_at.month in (2, 3, 11) else 72, 9, "SW", 29.91, "falling", 58, seasonal_water, "6:42 AM")
|
||||
|
||||
|
||||
def build_bass_plan(starts_at: datetime) -> dict:
|
||||
conditions = demo_conditions(starts_at)
|
||||
score = min(82 + (4 if conditions.pressure_trend == "falling" else 0) + (3 if 6 <= conditions.wind_mph <= 14 else 0) + (2 if conditions.cloud_cover_percent >= 50 else 0), 95)
|
||||
return {
|
||||
"bite_score": score,
|
||||
"confidence": 0.86,
|
||||
"summary": "Start on wind-blown secondary points at first light, then move to shaded wood and dock cover as the sun rises.",
|
||||
"conditions": asdict(conditions),
|
||||
"primary_pattern": {"name": "Wind-blown points", "depth": "6–12 ft", "structure": "Secondary points with rock, brush, or nearby creek-channel access", "presentation": "Slow-roll across the wind, then pause beside cover"},
|
||||
"tackle": {"lure": "3/8 oz chartreuse-white double willow spinnerbait", "rod": "7' medium-heavy, fast action", "reel": "7.1:1 baitcaster", "line": "15 lb fluorocarbon", "backup": "Green-pumpkin Texas-rigged creature bait for protected cover"},
|
||||
"evidence": ["A falling pressure trend favors active, roaming bass.", "A 9 mph southwest wind concentrates bait on exposed banks and points.", "Cloud cover extends the moving-bait window after sunrise.", f"Water near {conditions.water_temperature_f}°F supports a moderate retrieve."],
|
||||
"professor_finn": "I’d begin on the southwest-facing secondary points and let the wind choose the first three stops. Keep the spinnerbait moving until you contact fish. If the wind fades or the sun becomes direct, slow down with the Texas rig around shaded wood and the darkest dock edges.",
|
||||
"data_status": "Demo conditions — live weather provider not connected yet",
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime, timezone
|
||||
from django.test import SimpleTestCase
|
||||
from rest_framework.test import APIClient
|
||||
from .recommendations import build_bass_plan
|
||||
|
||||
class RecommendationEngineTests(SimpleTestCase):
|
||||
def test_plan_is_explainable_and_actionable(self):
|
||||
plan = build_bass_plan(datetime(2026, 9, 5, 11, tzinfo=timezone.utc))
|
||||
self.assertGreaterEqual(plan["bite_score"], 80)
|
||||
self.assertEqual(plan["primary_pattern"]["name"], "Wind-blown points")
|
||||
self.assertEqual(len(plan["evidence"]), 4)
|
||||
self.assertIn("spinnerbait", plan["tackle"]["lure"])
|
||||
|
||||
class DemoPlanApiTests(SimpleTestCase):
|
||||
def setUp(self): self.client = APIClient()
|
||||
def test_demo_plan_requires_no_account(self):
|
||||
response = self.client.post("/api/demo/plan/", {"starts_at": "2026-09-05T06:30:00-05:00"}, format="json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["trip"]["water_body"], "Lake Eufaula")
|
||||
self.assertIn("professor_finn", response.data["plan"])
|
||||
def test_invalid_date_is_clear(self):
|
||||
self.assertEqual(self.client.post("/api/demo/plan/", {"starts_at": "Saturday morning"}, format="json").status_code, 400)
|
||||
@@ -1,11 +1,10 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import RecommendationViewSet, SpeciesViewSet, TripViewSet, WaterBodyViewSet, health
|
||||
from .views import RecommendationViewSet, SpeciesViewSet, TripViewSet, WaterBodyViewSet, demo_plan, health
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("species", SpeciesViewSet)
|
||||
router.register("water-bodies", WaterBodyViewSet)
|
||||
router.register("trips", TripViewSet, basename="trip")
|
||||
router.register("recommendations", RecommendationViewSet, basename="recommendation")
|
||||
urlpatterns = [path("health/", health), path("", include(router.urls))]
|
||||
|
||||
urlpatterns = [path("health/", health), path("demo/plan/", demo_plan), path("", include(router.urls))]
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
from datetime import timedelta
|
||||
from django.http import JsonResponse
|
||||
from rest_framework import permissions, viewsets
|
||||
from django.utils import timezone
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
from .models import Recommendation, Species, Trip, WaterBody
|
||||
from .recommendations import build_bass_plan
|
||||
from .serializers import RecommendationSerializer, SpeciesSerializer, TripSerializer, WaterBodySerializer
|
||||
|
||||
def health(_request):
|
||||
return JsonResponse({"status": "ok", "service": "fishiq-api"})
|
||||
|
||||
@api_view(["GET", "POST"])
|
||||
@permission_classes([permissions.AllowAny])
|
||||
def demo_plan(request):
|
||||
starts_at = timezone.now() + timedelta(days=3)
|
||||
supplied_start = request.data.get("starts_at") if request.method == "POST" else request.query_params.get("starts_at")
|
||||
if supplied_start:
|
||||
try:
|
||||
starts_at = timezone.datetime.fromisoformat(supplied_start.replace("Z", "+00:00"))
|
||||
if timezone.is_naive(starts_at): starts_at = timezone.make_aware(starts_at)
|
||||
except ValueError:
|
||||
return Response({"starts_at": ["Use an ISO-8601 date and time."]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({"trip": {"water_body": "Lake Eufaula", "region": "Alabama / Georgia", "target_species": "Largemouth bass", "starts_at": starts_at.isoformat(), "duration_hours": 5}, "plan": build_bass_plan(starts_at)})
|
||||
|
||||
class ReadOnlyCatalogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@@ -27,4 +45,3 @@ class RecommendationViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = RecommendationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
def get_queryset(self): return Recommendation.objects.filter(trip__owner=self.request.user)
|
||||
|
||||
|
||||
+34
-4
@@ -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>Here’s how I’d 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,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}}
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user