diff --git a/080_target_point_hazard_group.sql b/080_target_point_hazard_group.sql
new file mode 100644
index 0000000..9eb4410
--- /dev/null
+++ b/080_target_point_hazard_group.sql
@@ -0,0 +1,32 @@
+-- =====================================================================
+-- TeeCup — migrasjon 080
+-- hazard_group: eksplisitt paring av forkant/bakkant til SAMME hindring
+-- =====================================================================
+-- Konkret anledning (ADR-084): hull-diagrammet for rangefinderen
+-- (ADR-083) trengte en måte å vise ÉN hindring med både forkant- og
+-- bakkant-avstand (f.eks. en bunker sin fremre og bakre kant), i
+-- stedet for å vise hvert koordinatpunkt som sin egen markør. Første
+-- forsøk grupperte punkter på `poi_type`+`side_fairway` -- avvist av
+-- bruker: to ATSKILTE hindringer av samme type på samme side (f.eks.
+-- to venstre fairway-bunkere) ville da feilaktig blitt slått sammen
+-- til én.
+--
+-- Verken `golfapi_course_coordinate` eller `teeoff_course_coordinate`
+-- hadde noen eksisterende gruppe-/hindrings-id å bruke i stedet --
+-- `id`-kolonnen er en usorterbar `gen_random_uuid()`, ingen
+-- `created_at`, og verken GolfAPI sin kildedata eller den manuelle
+-- feltbefarings-flyten (ADR-081) har noe naturlig gruppe-nøkkel.
+--
+-- `hazard_group` er derfor en NY, nullable, FRITT SATT tekstkolonne --
+-- samme "menneskelig tolkning ved data-inntasting"-mønster som
+-- `poi_type`/`location` allerede er. `NULL` (dagens tilstand for ALT
+-- eksisterende data) = enkeltstående punkt, ALDRI slått sammen med
+-- noe -- ingen regresjon. Ikke-null = et menneske har eksplisitt
+-- bekreftet at to (eller flere) punkter er samme fysiske hindring.
+-- Kun meningsfullt unikt INNENFOR ett hull, ikke globalt håndhevet.
+-- =====================================================================
+
+\set ON_ERROR_STOP on
+
+ALTER TABLE golfapi_course_coordinate ADD COLUMN hazard_group text;
+ALTER TABLE teeoff_course_coordinate ADD COLUMN hazard_group text;
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 3767bf5..e077f7d 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -8363,6 +8363,97 @@ containerlogg, `https://teecup.golf/logg-inn` bekreftet 200 OK.
---
+## ADR-084: Hull-diagram v2 -- kategoriske venstre/senter/høyre-akser med egne ikoner (2026-08-17)
+
+Revisjon av ADR-083 SAMME DAG, etter reelt produksjonsskjermbilde fra
+Tjøme hull 18: både bunkeren og vannhinderet havnet visuelt nesten
+rett under senterlinjen i ADR-083 sin KONTINUERLIGE
+venstre/høyre-projeksjon (`crossMeters`), selv om bunkeren faktisk ligger
+til venstre og vannet er senter. Årsak: det faktiske sideveis-avviket i
+meter var for lite til synlig forskjell på ekte banedata -- geometrisk
+riktig, men ikke lesbart i sollys.
+
+**Ny løsning, bekreftet med bruker over flere spørsmål/svar-runder:**
+- Tre FASTE kategoriske "baner" (venstre/senter/høyre), drevet av
+ `side_fairway`-feltet direkte (allerede hentet, men ikke brukt før nå)
+ -- IKKE av utledet `crossMeters`. Garantert tydelig atskillelse
+ uansett faktisk avstand.
+- Green og tee er ALLTID på midtaksen, uansett egen `side_fairway`.
+ Spillerens "Deg"-posisjon likeså -- ingen kategorisk side for GPS.
+- Hindringer med et ekte forkant+bakkant-par vises som ETT ikon:
+ avstand til forkant rett UNDER, avstand til bakkant rett OVER
+ (speiler green sitt front/senter/bak). Enkeltpunkt: én avstand,
+ alltid under.
+- Egne PNG-ikoner (bruker lastet opp, 32×31 sand/vann, 40×40 green) i
+ `frontend/public/hole-diagram/` -- IKKE de tre opprinnelig opplastede
+ landskaps-SVG-ene (fulle illustrerte banekart, 237-495 path-elementer
+ hver), som ble vurdert og avvist: for detaljerte til å være lesbare
+ nedskalert til 32-40px, ville brutt CLAUDE.md sin STÅENDE
+ tilgjengelighetsregel. Typer uten dedikert ikon (i dag kun `rock`)
+ faller tilbake til lucide sin `TriangleAlert`.
+
+**Viktig rettelse fra bruker underveis (endret datalaget):** første
+forsøk grupperte hindringspunkter til forkant+bakkant-par basert på
+`poi_type`+`side_fairway` -- avvist: to ATSKILTE hindringer av samme
+type på samme side (f.eks. to venstre fairway-bunkere) ville da
+feilaktig blitt slått sammen. Undersøkt: verken `golfapi_course_
+coordinate` eller `teeoff_course_coordinate` hadde noen eksisterende
+gruppe-/hindrings-id (kun en usorterbar `gen_random_uuid()`, ingen
+`created_at`), og verken GolfAPI sin kildedata eller den manuelle
+feltbefarings-flyten (ADR-081) har noe naturlig gruppe-nøkkel.
+
+**Ny migrasjon 080:** `hazard_group text` (nullable) lagt til begge
+tabeller. `NULL` (dagens tilstand for ALT eksisterende data, inkl.
+Tjøme/Nesbyen) = enkeltstående punkt, ALDRI slått sammen -- ingen
+regresjon. Ikke-null = et menneske har eksplisitt bekreftet at to
+punkter er samme fysiske hindring, satt ved data-inntasting (samme
+tillitsnivå som `poi_type`/`location` i ADR-081). Eksponert i
+`app/target_points.py` (begge SELECT-lister) og
+`app/routers/courses.py` (`CoursePointIn`/`CoursePointOut`,
+`_COORDINATE_COLUMNS`, INSERT i `replace_course_coordinates` -- eneste
+skrive-vei, kun for offisielle TeeOff-baner; `golfapi_course_
+coordinate`, Tjømes personlige bane, har ingen skrive-endepunkt i det
+hele tatt, kun engangsskript).
+
+**Kjent, akseptert etterfølger-oppgave:** eksisterende Tjøme/Nesbyen-
+data har INGEN `hazard_group` satt ennå -- bunkeren+vannet fra
+skjermbildet vises derfor fortsatt som enkeltpunkt (uendret, korrekt
+oppførsel) inntil noen manuelt tagger dem like.
+
+**Frontend:** `hole-target-distance.tsx` fikk `groupDiagramHazards()`
+som erstatter den gamle per-punkt-mappingen -- grupperer UTELUKKENDE på
+delt, ikke-null `hazard_group`. `hole-diagram-view.tsx` skrevet om fra
+kontinuerlig `crossToLeftPct`/`MAX_CROSS_METERS`-logikk til
+`grid-cols-3`-layout med tre uavhengige baner, hver med egen
+kollisjons-forskyvning. `TargetDistance`-fallback (baner uten
+tee-koordinater) er UENDRET.
+
+**Verifisert:** `tsc --noEmit` rent, full `vitest run` 55/55, full
+`pytest` 111/111 (migrasjon 080 kjørt problemfritt i scratch-testsuiten
+sin egen per-kjøring-database). Egen scratch-database+scratch
+`teecup_api` (rebygd lokalt bilde for å inkludere backend-endringene)
++ lokal `next dev`, presist kjent syntetisk datasett: to ATSKILTE
+venstre fairway-bunkere (én paret via `hazard_group`, én enkeltstående)
+-- bekreftet TO atskilte ikoner, IKKE slått sammen, riktig
+forkant(54m)/bakkant(63m) på den parede, riktig enkelttall(103m) på
+den enkeltstående. Vannhinder (senter, enkeltpunkt, 130m) og rock
+(høyre, generic-ikon, 18m) begge riktig plassert og med riktig
+enkelttall. Green(40px)/tee bekreftet alltid midtbane. Lys+mørk
+bekreftet. Scratch-stacken fullstendig revet ned.
+
+**Migrasjon 080 rullet ut mot ekte database:** ikke ennå -- venter på
+bekreftelse fra bruker (eksakt `ALTER TABLE`-SQL vist FØR kjøring, per
+CLAUDE.md sin ufravikelige sikkerhetsregel).
+
+**Gjenstår:** liste over `_HAZARD_CONFIG`-typer uten eget ikon leveres
+til bruker etter utrulling (i dag kun `rock`/"Fjellknaus"). V0-prompt
+for polert visning skrives etter at bruker har godkjent den
+hånd-kodede versjonen.
+
+**Rullet ut: ikke ennå** -- venter på migrasjonsbekreftelse (se over).
+
+---
+
## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42c4a75..5ced362 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12042,3 +12042,68 @@ Neste steg:
**Rullet ut 2026-08-17** -- bruker bekreftet. `docker compose build
teecup_frontend && up -d`, ingen migrasjon. Ren containerlogg,
`https://teecup.golf/logg-inn` 200 OK.
+
+101. **Hull-diagram v2: kategoriske akser + hazard_group (ADR-084) —
+ 2026-08-17.** Bruker viste et ekte skjermbilde fra Tjøme hull 18:
+ ADR-083 sin kontinuerlige `crossMeters`-forskyvning ga for lite
+ synlig venstre/høyre-forskjell på ekte koordinater -- bunker
+ (faktisk venstre) og vann (faktisk senter) havnet begge nesten på
+ senterlinjen. Løsning: tre FASTE kategoriske baner
+ (venstre/senter/høyre) drevet direkte av `side_fairway`, ikke av
+ utledet `crossMeters`. Green og tee alltid senterakse (uansett egen
+ `side_fairway`); spiller ("Deg") også alltid senterakse, kun
+ langs-hull-posisjon. Carry-hindringer (front+bak-par) -> ett ikon,
+ forkant under/bakkant over; enkeltpunkt -> én avstand under.
+
+ Første forsøk grupperte forkant/bakkant via `poi_type`+
+ `side_fairway`-heuristikk -- bruker avviste eksplisitt: to atskilte
+ hindringer av samme type på samme side (f.eks. to venstre-bunkere)
+ må ALDRI slås sammen. Undersøkt: ingen eksisterende gruppe-/
+ hindrings-id fantes i verken `golfapi_course_coordinate` eller
+ `teeoff_course_coordinate`. Løst med ny migrasjon 080:
+ `hazard_group text` (nullable) på begge tabeller -- `NULL` (alt
+ eksisterende data i dag) = alltid enkeltstående, aldri slått
+ sammen; ikke-null = eksplisitt menneskelig bekreftet pardata, satt
+ ved data-inntasting (samme tillitsnivå som `poi_type`/`location`,
+ ADR-081). Eksponert i `app/target_points.py` og
+ `app/routers/courses.py` (`CoursePointIn`/`Out`,
+ `_COORDINATE_COLUMNS`, INSERT i `replace_course_coordinates` --
+ eneste skrive-vei, kun offisielle TeeOff-baner; Tjømes personlige
+ `golfapi_course_coordinate`-data har ingen skrive-endepunkt, kun
+ engangsskript).
+
+ Nye ikoner fra bruker (PNG, 32×31 sand/vann, 40×40 green) i
+ `frontend/public/hole-diagram/` -- IKKE de tre opplastede
+ landskaps-SVG-ene (fulle illustrerte banekart, 237-495 path-
+ elementer hver), avvist som for detaljerte til å være lesbare
+ nedskalert til 32-40px (CLAUDE.md sin ståenede tilgjengelighets-
+ regel). Typer uten dedikert ikon (i dag kun `rock`) faller tilbake
+ til `TriangleAlert`.
+
+ `hole-target-distance.tsx` fikk `groupDiagramHazards()` (grupperer
+ UTELUKKENDE på delt, ikke-null `hazard_group`) som erstatter den
+ gamle per-punkt-mappingen til `HoleDiagram`; den flate `hazards`-
+ listen til `TargetDistance`-fallback (baner uten tee-koordinater)
+ er uendret. `hole-diagram-view.tsx` skrevet om fra kontinuerlig
+ `crossToLeftPct` til `grid-cols-3` med tre uavhengige baner, hver
+ med egen kollisjons-forskyvning.
+
+ **Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, `pytest`
+ 111/111 (migrasjon 080 kjørt i scratch-testsuiten). Egen scratch-
+ database+rebygd scratch `teecup_api`+lokal `next dev`, presist
+ kjent syntetisk datasett med to ATSKILTE venstre fairway-bunkere
+ (én paret via `hazard_group`, én enkeltstående) -- bekreftet TO
+ atskilte ikoner (ikke slått sammen), riktig forkant(54m)/
+ bakkant(63m) på den parede, riktig enkelttall(103m) på den
+ enkeltstående. Vannhinder (senter, 130m) og rock (høyre, generic-
+ ikon, 18m) riktig plassert. Green/tee bekreftet alltid midtbane.
+ Lys+mørk bekreftet. Scratch-stacken revet ned.
+
+ **Ingen migrasjon rullet ut mot ekte database ennå** -- venter på
+ eksplisitt brukerbekreftelse av migrasjon 080 mot ekte `teecup_db`,
+ per CLAUDE.md sin ufravikelige sikkerhetsregel.
+
+ **Gjenstår:** liste over `_HAZARD_CONFIG`-typer uten eget ikon
+ (i dag kun `rock`/"Fjellknaus") leveres til bruker etter utrulling.
+ V0-prompt for polert visning skrives etter godkjenning av den
+ hånd-kodede versjonen.
diff --git a/app/routers/courses.py b/app/routers/courses.py
index 6c58640..58cd699 100644
--- a/app/routers/courses.py
+++ b/app/routers/courses.py
@@ -636,6 +636,11 @@ class CoursePointIn(BaseModel):
side_fairway: str | None = None
latitude: float = Field(ge=-90, le=90)
longitude: float = Field(ge=-180, le=180)
+ # Eksplisitt, menneske-satt paring av forkant/bakkant til SAMME
+ # fysiske hindring (migrasjon 080/ADR-084) -- se hazard_group-
+ # kommentaren i migrasjonsfilen. Frivillig, ingen validering utover
+ # tekst -- kun meningsfullt unikt innenfor ett hull.
+ hazard_group: str | None = None
class CoursePointOut(BaseModel):
@@ -645,9 +650,10 @@ class CoursePointOut(BaseModel):
side_fairway: str | None
latitude: float
longitude: float
+ hazard_group: str | None
-_COORDINATE_COLUMNS = "hole_number, poi_type, location, side_fairway, latitude, longitude"
+_COORDINATE_COLUMNS = "hole_number, poi_type, location, side_fairway, latitude, longitude, hazard_group"
@router.get("/orgs/{organization_id}/courses/{course_id}/coordinates", response_model=list[CoursePointOut])
@@ -694,10 +700,10 @@ async def replace_course_coordinates(
await conn.execute(
"""
INSERT INTO teeoff_course_coordinate
- (external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude)
- VALUES ($1, $2, $3, $4, $5, $6, $7)
+ (external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude, hazard_group)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
""",
- external_ref, p.hole_number, p.poi_type, p.location, p.side_fairway, p.latitude, p.longitude,
+ external_ref, p.hole_number, p.poi_type, p.location, p.side_fairway, p.latitude, p.longitude, p.hazard_group,
)
rows = await conn.fetch(
f"""
diff --git a/app/target_points.py b/app/target_points.py
index c6f784a..c63aa88 100644
--- a/app/target_points.py
+++ b/app/target_points.py
@@ -29,6 +29,7 @@ class TargetPoint(BaseModel):
side_fairway: str | None
latitude: float
longitude: float
+ hazard_group: str | None
async def get_target_points(conn: Connection, course_key: CourseKey | None, hole_number: int) -> list[TargetPoint]:
@@ -39,7 +40,7 @@ async def get_target_points(conn: Connection, course_key: CourseKey | None, hole
_, facility_slug, teeoff_course_id = course_key
rows = await conn.fetch(
"""
- SELECT poi_type, location, side_fairway, latitude, longitude
+ SELECT poi_type, location, side_fairway, latitude, longitude, hazard_group
FROM teeoff_course_coordinate
WHERE external_course_ref = $1 AND hole_number = $2
""",
@@ -50,7 +51,7 @@ async def get_target_points(conn: Connection, course_key: CourseKey | None, hole
_, golfapi_course_id = course_key
rows = await conn.fetch(
"""
- SELECT poi_type, location, side_fairway, latitude, longitude
+ SELECT poi_type, location, side_fairway, latitude, longitude, hazard_group
FROM golfapi_course_coordinate
WHERE golfapi_course_id = $1 AND hole_number = $2
""",
diff --git a/frontend/components/hole-diagram-view.tsx b/frontend/components/hole-diagram-view.tsx
index 787f977..d6bcbba 100644
--- a/frontend/components/hole-diagram-view.tsx
+++ b/frontend/components/hole-diagram-view.tsx
@@ -1,43 +1,45 @@
"use client"
-// V0-eksportert visning (2026-08-17, ADR-083) -- ren kontrollert
-// komponent, ingen egen GPS-/geometrilogikk. All geometri-utregning
-// (projectOntoAxis) bor i hole-target-distance.tsx, allerede bevist
-// riktig med enhetstester + scratch-verifisering FØR denne visningen
-// ble bestilt. Erstattet en midlertidig hånd-kodet stand-in med samme
-// props-kontrakt -- samme mønster som FlagPlantSheet/PlayerImportPanel.
+// Hull-diagram v2 (2026-08-17, ADR-084) -- kategoriske venstre/senter/
+// høyre-akser i stedet for kontinuerlig sideveis-forskyvning (ADR-083).
+// Se ADR-084: kontinuerlig `crossMeters` ga for liten synlig forskjell
+// på ekte banedata (Tjøme hull 18 -- bunker+vann havnet begge nesten på
+// senterlinjen selv om de faktisk ligger på ulik side). Banevalget
+// kommer nå fra `hazards[].lane`, satt av datalaget i
+// hole-target-distance.tsx basert på `side_fairway` -- IKKE utledet her.
+// Ren kontrollert komponent, ingen egen GPS-/geometrilogikk.
-import { Flag, MapPin, TriangleAlert } from "lucide-react"
+import { MapPin, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"
+export type HazardKind = "sand" | "water" | "generic"
+
export type DiagramHazard = {
+ key: string
+ kind: HazardKind
label: string
- /** Live distance FROM THE PLAYER, always accurate. */
- distanceMeters: number
- /** 0 = at tee, 100 = at green. May be <0 or >100. */
+ lane: "left" | "center" | "right"
+ /** 0 = ved tee, 100 = ved green. */
alongPercent: number
- /** Positive = right of the tee->green axis, negative = left. */
- crossMeters: number
+ /** Avstand FRA SPILLEREN, vises rett under ikonet. Alltid satt. */
+ below: number
+ /** Avstand til bakkant, vises rett over ikonet -- KUN satt når
+ * hindringen er et ekte forkant+bakkant-par (se hazard_group i
+ * datalaget). `null` = enkeltpunkt, ingen tekst over ikonet. */
+ above: number | null
}
type Props = {
distances: { front: number; middle: number; back: number }
- playerPosition: { alongPercent: number; crossMeters: number }
- /** Already sorted nearest-to-player first. */
+ playerPosition: { alongPercent: number }
hazards: DiagramHazard[]
size?: "compact" | "full"
className?: string
}
-// --- Diagram geometry (percentages of the field box) ---------------------
-// Fixed endpoints: green stays at the top, tee stays at the bottom, always.
const TRACK_TOP = 12 // where alongPercent === 100 lands (leaves room for the flag)
const TRACK_BOTTOM = 88 // where alongPercent === 0 lands (leaves room for the tee dot)
-// Sideways scale: this many meters of cross offset maps to the max visual swing.
-const MAX_CROSS_METERS = 30
-const MAX_OFFSET_PCT = 40 // ±40% of the field width, per spec
-// Label collision handling: minimum vertical gap between stacked labels.
-const LABEL_MIN_GAP_PCT = 11
+const LABEL_MIN_GAP_PCT = 16 // hindringer i samme bane trenger mer luft nå (ikon + to tall)
function clamp(n: number, lo: number, hi: number) {
return Math.min(hi, Math.max(lo, n))
@@ -48,26 +50,57 @@ function alongToTopPct(alongPercent: number) {
return TRACK_TOP + ((100 - a) / 100) * (TRACK_BOTTOM - TRACK_TOP)
}
-function crossToLeftPct(crossMeters: number) {
- const offset = clamp(
- (crossMeters / MAX_CROSS_METERS) * MAX_OFFSET_PCT,
- -MAX_OFFSET_PCT,
- MAX_OFFSET_PCT,
- )
- return 50 + offset
-}
-
function formatMeters(m: number) {
return `${Math.round(m)} m`
}
-export function HoleDiagram({
- distances,
- playerPosition,
- hazards,
- size = "full",
- className,
-}: Props) {
+function HazardIcon({ kind }: { kind: HazardKind }) {
+ if (kind === "sand") {
+ return
+ }
+ if (kind === "water") {
+ return
+ }
+ return