From ad4089bb77111cc1f38b09d2ea902c3e89f7f63e Mon Sep 17 00:00:00 2001 From: reaper Date: Sat, 29 Aug 2026 20:35:42 -0500 Subject: [PATCH] Build Lake Eufaula recommendation vertical slice --- .gitignore | 2 +- apps/api/fishing/recommendations.py | 36 +++++++++++++++++++++++++++ apps/api/fishing/tests.py | 22 +++++++++++++++++ apps/api/fishing/urls.py | 5 ++-- apps/api/fishing/views.py | 21 ++++++++++++++-- apps/web/src/main.tsx | 38 ++++++++++++++++++++++++++--- apps/web/src/styles.css | 3 +-- apps/web/src/vite-env.d.ts | 1 + 8 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 apps/api/fishing/recommendations.py create mode 100644 apps/api/fishing/tests.py create mode 100644 apps/web/src/vite-env.d.ts diff --git a/.gitignore b/.gitignore index da5193c..7edfc90 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,8 @@ __pycache__/ *.py[cod] node_modules/ dist/ +*.tsbuildinfo .dart_tool/ build/ .idea/ .vscode/ - diff --git a/apps/api/fishing/recommendations.py b/apps/api/fishing/recommendations.py new file mode 100644 index 0000000..1a42bb2 --- /dev/null +++ b/apps/api/fishing/recommendations.py @@ -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", + } diff --git a/apps/api/fishing/tests.py b/apps/api/fishing/tests.py new file mode 100644 index 0000000..7ed5284 --- /dev/null +++ b/apps/api/fishing/tests.py @@ -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) diff --git a/apps/api/fishing/urls.py b/apps/api/fishing/urls.py index c3f891f..1c7528d 100644 --- a/apps/api/fishing/urls.py +++ b/apps/api/fishing/urls.py @@ -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))] diff --git a/apps/api/fishing/views.py b/apps/api/fishing/views.py index 43097b0..cb76a66 100644 --- a/apps/api/fishing/views.py +++ b/apps/api/fishing/views.py @@ -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) - diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index b3317c9..4839287 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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; primary_pattern: Record; tackle: Record; 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

YOUR FISHING INTELLIGENCE

Know where to go.
Know what to throw.

Turn water, weather, season, and your own tackle into a practical fishing plan.

{features.map((f, i) =>
0{i+1}

{f}

)}
; + const [startsAt, setStartsAt] = useState(nextSaturdayMorning); + const [result, setResult] = useState(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
+ +

FIRST INTELLIGENT TRIP

Lake Eufaula.
Saturday morning.

A practical largemouth bass plan built from conditions, seasonal behavior, and complete tackle recommendations.

+
{error &&

{error} Check that the API is running.

}
+
+ {result &&

YOUR GAME PLAN

{result.trip.target_species}

{new Date(result.trip.starts_at).toLocaleString([], { weekday: "long", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })} · {result.trip.duration_hours} hours

{result.plan.bite_score}Bite score
+

{result.plan.summary}

{conditions?.air_temperature_f}°Air{conditions?.water_temperature_f}°Water{conditions?.wind_direction} {conditions?.wind_mph}mph wind{conditions?.pressure_inhg}inHg{conditions?.cloud_cover_percent}%Clouds

{result.plan.data_status}

+

PRIMARY PATTERN

{result.plan.primary_pattern.name}

Depth
{result.plan.primary_pattern.depth}
Structure
{result.plan.primary_pattern.structure}
Retrieve
{result.plan.primary_pattern.presentation}

COMPLETE SETUP

{result.plan.tackle.lure}

Rod
{result.plan.tackle.rod}
Reel
{result.plan.tackle.reel}
Line
{result.plan.tackle.line}
Backup
{result.plan.tackle.backup}
+
PF

PROFESSOR FINN · {Math.round(result.plan.confidence * 100)}% CONFIDENCE

Here’s how I’d fish it

{result.plan.professor_finn}

Why FishIQ recommends this
    {result.plan.evidence.map(item =>
  • {item}
  • )}
+
} +
; } createRoot(document.getElementById("root")!).render(); - diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index aea4363..a3688c5 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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}} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1 @@ +///