From 0f0ddca7f4a47dba791abe35cff78369afbdbfd4 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Fri, 14 Aug 2026 07:20:45 +0200 Subject: [PATCH] Fullt scorekort (alle stats, matcher Scorekort-siden visuelt) i scorekort-e-posten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bygget om fra en enkel slag/netto-liste til samme Ut/Inn-blokk-oppsett med fargede score-merker, fairway/GIR/innspill-glyffer og total-tiles som selve appens Scorekort-side, etter to runder brukerfeedback (først "all statistikk", deretter et skjermbilde av ønsket visuell match). Fant og fikset underveis at round.played_at kun er en dato (ikke klokkeslett) og en relatert type-bug i spilletid-utregningen. Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE_DECISIONS.md | 81 ++++++++++ CHANGELOG.md | 48 ++++++ app/email.py | 328 +++++++++++++++++++++++++++++++++++--- app/routers/rounds.py | 75 ++++++++- 4 files changed, 503 insertions(+), 29 deletions(-) diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 2179e13..37ce38e 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -6427,6 +6427,87 @@ slettet etter verifisering (`DELETE /rounds/{id}`, bekreftet fjernet fra migrasjon), `docker compose build teecup_frontend && up -d teecup_frontend`. +**Tillegg samme dag — "fullt scorekort" i e-posten, pluss dato/klokkeslett/ +spilletid i hilsenen.** Bruker, rett etter forrige tillegg: e-posten burde +sende DET FULLE scorekortet ("all statistikk for runden"), ikke bare +slag/netto -- og hilsen-linjen burde vise dato, klokkeslett og hvor lang +tid runden tok, med eksempel: "Her er scorekortet ditt fra Tjøme +Golfklubb – Bane, i dag klokken 08:40 (du spilte 18 hull på 3t 42min, +sammen med 3 andre):". Et FØRSTE forsøk bygde en enkel vertikal +hull-per-rad-tabell (kolonner Hcp/Par/Slag/Netto/Poeng/Fw/Putt/GIR/ +Innspill/Chip/Bunker/Straff, én rad per hull) -- bruker så testresultatet +og ba i stedet om at e-posten skal SE UT SOM selve Scorekort-siden i +appen (sendte skjermbilde av `round-scorecard.tsx`), ikke bare inneholde +de samme tallene i et annet oppsett. Hele HTML-tabellen ble derfor bygget +om fra bunnen -- se "faktisk skipet design" under, den vertikale +varianten eksisterer ikke lenger i koden. + +**Faktisk skipet design (HTML-delen av `send_round_summary_email`, +email.py):** hull som KOLONNER (ikke rader), i to 9-hulls-blokker +("Ut"/"Inn", eller "Tot" for en 9-hullsrunde) -- porterer +`ScoreBlock`-komponenten i frontend/components/round-scorecard.tsx +rad-for-rad til ren HTML/inline-CSS: mørk hull-nummer-header, en grønn +Ut/Inn-oppsummeringskolonne, fargede score-merker (sirkel/firkant, +fylt/omrisset etter eagle/birdie/par/bogey/double -- samme +`_classify()`-terskel som frontend sin `classify()`), og glyffer for +Fw/Innspill (◎ traff, ↖/↗/↑/↓/←/→ retning, farget grønt/oransje) og GIR +(✓). Rad-etikettene ble kortet ned samme dag etter et oppfølgingsbilde +av selve e-posten (`Score`/`Netto`/`Poeng` → `Scr`/`Net`/`Pnt` -- tok for +mye bredde i en allerede trang 9-kolonners tabell). Under blokkene: fire +"total tiles" (Par/Score/Til par/Poeng), identisk med `TotalTile`-raden +nederst på selve Scorekort-siden. Radene graderer seg fortsatt -- en rad +(Netto/Poeng/Fw/Putt/GIR/Innspill/Chip/Bnk/Str/Any) tas kun med når +MINST ett hull faktisk har data for den, samme show*-mønster som +round-scorecard.tsx. Tekst-fallbacken (for klienter uten HTML-støtte) +beholdt sin egen, enklere hull-per-linje-form (samme feltsett, men kan +ikke meningsfullt gjengi et 2D-rutenett i ren tekst) -- ett bevisst, +akseptert avvik mellom de to variantene, resten av innholdet er identisk. +`RoundSummaryHole` (email.py) utvidet med `stroke_index`/`picked_up`/ +`points` (Stableford, portert fra frontend sin `stablefordPoints`)/ +`putts`/`tee_shot_result`/`approach_result`/`chip_count`/ +`bunker_shot_count`/`penalty_strokes`/`anyway_strokes`. + +**To reelle bugs funnet UNDER ende-til-ende-verifisering mot en ekte, +tidligere fullført runde (ikke bare isolert forhåndsvisning):** +1. `round.played_at` er KUN en `date`-kolonne -- ingen klokkeslett lagret + i det hele tatt. "Utslagstid"-feltet i ny-runde-veiviseren fanges opp + av wizard-state, men sendes faktisk ALDRI til backend + (`wizard-context.tsx` sender kun `played_at: s.date`) -- en egen, + allerede eksisterende feil, ikke rettet her (FEATURE_BACKLOG.md-verdig + funn, utenfor denne rundens omfang). Løsning: brukte `round. + started_at` (timestamptz, satt når runden faktisk startes) som + klokkeslett i stedet, med graceful fallback (kun dato, ingen + klokkeslett-ledd) når `started_at` mangler. Nytt `start_hole`- + parameter styrer Ut/Inn-rekkefølgen etter SPILLEREKKEFØLGEN (samme + sirkulære `holeOrder`-logikk som round-scorecard.tsx), ikke rå + hole_number. +2. Samme root cause forplantet seg til spilletid-utregningen: + `duration_start` falt opprinnelig tilbake til `round.played_at` (en + `date`) når `started_at` manglet -- ville krasjet med + `TypeError` ved subtraksjon mot en ekte `datetime`. Fjernet fallbacken + helt (ingen varighet vises hvis runden aldri ble "startet", i stedet + for å blande typer). Spilletiden bruker `round_participant. + completed_at` (DENNE spillerens egen sluttid) minus `round. + started_at`, med fallback til `round.completed_at` for historiske + runder fullført FØR migrasjon 066 (ingen individuell completed_at satt + den gang) -- verifisert direkte mot en ekte, gammel runde (started_at + 06:30 UTC, completed_at 10:46 UTC → riktig utledet 4t16min). + +**Verifisert:** `python3 -m py_compile` rent, 31/31 backend-tester ved +hver iterasjon. Isolert forhåndsvisning (monkeypatchet `_send_sync` for å +fange opp HTML-en uten å faktisk sende, 18 hull med data for ALLE +graderte felt inkl. eagle/dobbel bogey/plukket opp, fairway/GIR/chip/ +bunker/straff/innspill i alle retninger) nettleser-rendret og +skjermbilde-sammenlignet direkte mot brukerens eget skjermbilde av +Scorekort-siden -- bekreftet visuelt samsvar (samme blokk-oppsett, +farger, glyffer, tiles). **Ekte ende-til-ende-kall** (`POST .../ +send-scorecard`) mot en reell, tidligere fullført runde i produksjon, +gjentatt etter hver av de to bug-fiksene og etter etikett-forkortelsen +-- endte med 204 og ren `teecup_api`-logg. **Rullet ut 2026-08-14** +(flere delrunder samme kveld/natt) -- ren backend-endring (ingen ny +migrasjon), `docker compose build teecup_api && up -d teecup_api` hver +gang. + --- ## Utviklingsplan (rekkefølge) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3557f7..a4fac29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10980,3 +10980,51 @@ Neste steg: **Rullet ut 2026-08-13**, samme økt. Migrasjon 067+068 kjørt mot ekte `teecup_db`, `docker compose build teecup_api && up -d`, ren oppstartslogg. + +80. **Scorekort-e-posten (punkt 78/ADR-065) erstattet med DET FULLE + scorekortet, matcher selve Scorekort-siden visuelt — 2026-08-13/14, + se ADR-065-tillegg.** Bruker, rett etter at punkt 78 var rullet ut: + ville ha "det fulle scorekortet, det som inneholder all statistikk + for runden" i e-posten (den sendte da kun slag/netto), pluss dato/ + klokkeslett/spilletid i hilsenen. Et første forsøk (enkel vertikal + hull-per-rad-tabell) ble erstattet samme kveld etter at bruker sendte + et skjermbilde av selve appens Scorekort-side og ba om at e-posten + skal SE UT SOM den, ikke bare inneholde de samme tallene. + + `send_round_summary_email` (email.py) bygget helt om: hull som + KOLONNER i to 9-hulls Ut/Inn-blokker (Tot for 9-hullsrunder), porterer + `ScoreBlock`/`TotalTile` fra frontend/components/round-scorecard.tsx + til inline-styled HTML -- fargede score-merker (eagle/birdie/par/ + bogey/double), Fw/Innspill-glyffer (◎/↖/↗/↑/↓/←/→), GIR-hake, fire + total-tiles (Par/Score/Til par/Poeng) nederst. Rader graderer seg + (kun tatt med når minst ett hull har data). `RoundSummaryHole` + utvidet med `stroke_index`/`picked_up`/`points`(Stableford)/`putts`/ + `tee_shot_result`/`approach_result`/`chip_count`/`bunker_shot_count`/ + `penalty_strokes`/`anyway_strokes`. Rad-etikettene `Score`/`Netto`/ + `Poeng` kortet til `Scr`/`Net`/`Pnt` etter enda et bruker-tilbakemeldt + skjermbilde av selve e-posten (for trangt i en 9-kolonners tabell). + + **To reelle bugs funnet under ekte ende-til-ende-testing (ikke bare + isolert forhåndsvisning):** (1) `round.played_at` er KUN en + `date`-kolonne, ikke timestamptz -- "Utslagstid" i ny-runde-veiviseren + fanges opp av wizard-state men sendes faktisk ALDRI til backend + (`wizard-context.tsx` sender kun `played_at: s.date`), egen + allerede-eksisterende feil, ikke rettet her. Løsning: brukte + `round.started_at` for klokkeslettet i stedet, med graceful fallback. + (2) Samme rot-årsak forplantet seg til spilletid-utregningen + (`duration_start` falt tilbake til `played_at`, en `date`, ville + krasjet mot en ekte `datetime` ved subtraksjon) -- fjernet fallbacken, + ingen varighet vises i stedet for å blande typer. + + **Verifisert:** `python3 -m py_compile` + 31/31 backend-tester ved + hver iterasjon. Isolert forhåndsvisning (monkeypatchet `_send_sync`, + 18 hull med data for ALLE graderte felt inkl. eagle/dobbel bogey/ + plukket opp) nettleser-rendret og skjermbilde-sammenlignet direkte + mot brukerens eget skjermbilde av Scorekort-siden -- bekreftet + visuelt samsvar. Ekte ende-til-ende `POST .../send-scorecard` mot en + reell, tidligere fullført runde i produksjon, gjentatt etter hver + fiks -- endte med 204 og ren `teecup_api`-logg. + + **Rullet ut 2026-08-14** (flere delrunder samme kveld/natt, spenner + over datogrensen) -- ren backend-endring, ingen ny migrasjon, + `docker compose build teecup_api && up -d teecup_api` hver gang. diff --git a/app/email.py b/app/email.py index b9fbd6f..2561012 100644 --- a/app/email.py +++ b/app/email.py @@ -14,12 +14,73 @@ vanlig tilkobling (typisk port 587/25). import smtplib from asyncio import to_thread +from collections.abc import Callable from dataclasses import dataclass +from datetime import date, datetime from email.message import EmailMessage from html import escape as _esc +from zoneinfo import ZoneInfo from .config import settings +_OSLO = ZoneInfo("Europe/Oslo") +_NORWEGIAN_MONTHS = [ + "januar", "februar", "mars", "april", "mai", "juni", + "juli", "august", "september", "oktober", "november", "desember", +] +# Glyffer for de to 5-veis retningsfeltene (2026-08-13, ADR-065 "fullt +# scorekort"-utvidelsen -- bruker ba eksplisitt om at e-postens tabell skal +# se ut som selve Scorekort-siden, ikke bare inneholde samme tall). Samme +# betydning som FairwayIcon/ApproachIcon i frontend/components/round- +# scorecard.tsx, portert til tekst-glyffer + inline farge siden e-post- +# klienter ikke kan antas å vise SVG-er pent. (glyff, farge)-par. +_GREEN = "#1f6b08" +_ORANGE = "#ff5722" +_MUTED = "#9aa39a" +_FAIRWAY_GLYPHS = {"fairway": ("◎", _GREEN), "left": ("↖", _ORANGE), "right": ("↗", _ORANGE)} +_APPROACH_GLYPHS = { + "hit": ("◎", _GREEN), "long": ("↑", _ORANGE), "short": ("↓", _ORANGE), + "left": ("←", _ORANGE), "right": ("→", _ORANGE), +} + + +def _signed(value: int) -> str: + if value == 0: + return "E" + return f"+{value}" if value > 0 else str(value) + + +def _classify(diff: int) -> str: + if diff <= -2: + return "eagle" + if diff == -1: + return "birdie" + if diff == 0: + return "par" + if diff == 1: + return "bogey" + return "double" + + +def _format_round_datetime(played_at: date, started_at: datetime | None) -> str: + """`round.played_at` er kun en DATO i skjemaet (ingen klokkeslett lagret + -- "Utslagstid" i ny-runde-veiviseren fanges opp av wizard-state, men + sendes reelt aldri til backend, oppdaget 2026-08-13 under denne + utvidelsen -- egen, separat feil, ikke rettet her). `started_at` + (tidspunktet runden faktisk ble startet, timestamptz) er derfor eneste + ekte klokkeslett å vise -- utelates helt når det mangler (f.eks. en + runde som aldri ble startet ennå) i stedet for å dikte opp et.""" + today = datetime.now(_OSLO).date() + date_part = "i dag" if played_at == today else f"{played_at.day}. {_NORWEGIAN_MONTHS[played_at.month - 1]} {played_at.year}" + if started_at is None: + return date_part + return f"{date_part} klokken {started_at.astimezone(_OSLO).strftime('%H:%M')}" + + +def _format_duration(total_minutes: int) -> str: + hours, minutes = divmod(total_minutes, 60) + return f"{minutes} min" if hours == 0 else f"{hours}t {minutes}min" + def _send_sync(to_email: str, subject: str, body: str, html_body: str | None = None) -> None: msg = EmailMessage() @@ -351,14 +412,39 @@ async def send_new_account_alert_email(to_email: str, new_user_email: str, displ class RoundSummaryHole: hole_number: int par: int + stroke_index: int score: int | None + picked_up: bool net: int | None + # Stableford (2 poeng for netto par, +/-1 per slag, gulvet på 0) -- + # samme formel som frontend/components/round-scorecard.tsx sin + # stablefordPoints(), portert til Python siden e-posten bygges + # server-side. None når netto ikke er kjent (samme betingelse som net). + points: int | None + # Feltene under (2026-08-13, ADR-065 "fullt scorekort"-utvidelsen) er + # ALLE valgfrie og graderer seg naturlig -- None for delt-ball-hull + # (ADR-039 Beslutning C, ingen individuell detalj der) og for felt + # spilleren ikke sporet (stat_level). send_round_summary_email viser + # kun en kolonne når MINST ett hull faktisk har data for den, akkurat + # som round-scorecard.tsx sine show*-flagg. + putts: int | None + tee_shot_result: str | None + approach_result: str | None + chip_count: int | None + bunker_shot_count: int | None + penalty_strokes: int | None + anyway_strokes: int | None async def send_round_summary_email( to_email: str, guest_first_name: str, round_label: str, + played_at: date, + started_at: datetime | None, + duration_minutes: int | None, + other_players_count: int, + start_hole: int, holes: list[RoundSummaryHole], stat_lines: list[tuple[str, str]], raw_token: str | None, @@ -390,7 +476,20 @@ async def send_round_summary_email( send_scorecard_invitations/send_session_result_email) -- graderer seg naturlig etter hva som faktisk ble spilt (stat_level, individuell-ball vs. delt-ball) siden kalleren rett og slett utelater linjer det ikke - finnes data for. + finnes data for. `holes` graderer seg på samme vis kolonne for kolonne + (2026-08-13, ADR-065 "fullt scorekort"-utvidelsen, se RoundSummaryHole). + + `played_at` (dato) + `started_at` (klokkeslett, valgfritt) brukes til + hilsen-linjens dato/klokkeslett, formatert til Europe/Oslo -- appen er + norsk-only, ingen lagret tidssone-preferanse å slå opp her. Se + _format_round_datetime for hvorfor disse er to separate felt. + `duration_minutes` er None når verken denne deltakeren eller runden har + en kjent sluttid ennå (kalleren avgjør -- se + _send_participant_round_summary i rounds.py). + `other_players_count` er resten av flighten, brukt i "sammen med N + andre"-leddet (utelatt helt hvis 0). `start_hole` avgjør Ut/Inn- + rekkefølgen i HTML-tabellen (spillerekkefølge, ikke rå hole_number -- + matcher round-scorecard.tsx sin holeOrder-logikk). Alltid norsk, samme presedens som send_scorecard_invitations (verken en gjest eller en spiller midt i en runde har nødvendigvis en lagret @@ -401,16 +500,62 @@ async def send_round_summary_email( link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}" subject = f"Scorekortet ditt fra {round_label}" + holes_played = sum(1 for h in holes if h.score is not None) + detail_bits = [f"du spilte {holes_played} hull"] + if duration_minutes is not None: + detail_bits[0] += f" på {_format_duration(duration_minutes)}" + if other_players_count > 0: + detail_bits.append(f"sammen med {other_players_count} andre") + datetime_phrase = _format_round_datetime(played_at, started_at) + detail_txt = ", ".join(detail_bits) + greeting_line = f"Her er scorekortet ditt fra {round_label}, {datetime_phrase} ({detail_txt}):" + greeting_html = ( + f"Her er scorekortet ditt fra {_esc(round_label)}, {_esc(datetime_phrase)} " + f"({_esc(detail_txt)}):" + ) + + # Kolonnesettet graderer seg -- en kolonne tas kun med når MINST ett + # hull faktisk har data for den, akkurat som show*-flaggene i + # frontend/components/round-scorecard.tsx. Samme spesifikasjon brukes + # til BÅDE tekst- og HTML-varianten under, så de aldri kan divergere. + def _gir(h: RoundSummaryHole) -> bool: + return h.score is not None and h.putts is not None and h.score - h.putts <= h.par - 2 + + columns: list[tuple[str, Callable[[RoundSummaryHole], str]]] = [ + ("Hcp", lambda h: str(h.stroke_index)), + ("Par", lambda h: str(h.par)), + ("Slag", lambda h: "PU" if h.picked_up else (str(h.score) if h.score is not None else "–")), + ] + if any(h.net is not None for h in holes): + columns.append(("Netto", lambda h: str(h.net) if h.net is not None else "–")) + if any(h.points is not None for h in holes): + columns.append(("Poeng", lambda h: str(h.points) if h.points is not None else "–")) + if any(h.tee_shot_result is not None for h in holes): + columns.append(("Fw", lambda h: _FAIRWAY_GLYPHS[h.tee_shot_result][0] if h.par != 3 and h.tee_shot_result is not None else "–")) + if any(h.putts is not None for h in holes): + columns.append(("Putt", lambda h: str(h.putts) if h.putts is not None else "–")) + if any(h.score is not None and h.putts is not None for h in holes): + columns.append(("GIR", lambda h: "Ja" if _gir(h) else "–")) + if any(h.approach_result is not None for h in holes): + columns.append(("Innspill", lambda h: _APPROACH_GLYPHS[h.approach_result][0] if h.approach_result is not None else "–")) + if any(h.chip_count is not None for h in holes): + columns.append(("Chip", lambda h: str(h.chip_count) if h.chip_count is not None else "–")) + if any(h.bunker_shot_count is not None for h in holes): + columns.append(("Bunker", lambda h: str(h.bunker_shot_count) if h.bunker_shot_count is not None else "–")) + if any(h.penalty_strokes is not None for h in holes): + columns.append(("Straff", lambda h: str(h.penalty_strokes) if h.penalty_strokes is not None else "–")) + if any(h.anyway_strokes is not None for h in holes): + columns.append(("Anyway", lambda h: str(h.anyway_strokes) if h.anyway_strokes is not None else "–")) + text_lines = [ f"Hei {guest_first_name},", "", - f"Her er scorekortet ditt fra {round_label}:", + greeting_line, "", ] for h in holes: - score_txt = str(h.score) if h.score is not None else "–" - net_txt = str(h.net) if h.net is not None else "–" - text_lines.append(f"Hull {h.hole_number} (par {h.par}): {score_txt} slag (netto {net_txt})") + cell_txt = ", ".join(f"{label} {fn(h)}" for label, fn in columns) + text_lines.append(f"Hull {h.hole_number}: {cell_txt}") text_lines.append("") for label, value in stat_lines: text_lines.append(f"{label}: {value}") @@ -434,18 +579,155 @@ async def send_round_summary_email( ] body = "\n".join(text_lines) - hole_rows_html = "\n".join( - f'{h.hole_number}' - f'{h.par}' - f'{h.score if h.score is not None else "–"}' - f'{h.net if h.net is not None else "–"}' - for h in holes + # HTML-tabellen (2026-08-13, samme dag som ovenstående -- bruker sendte + # et skjermbilde av selve Scorekort-siden i appen og ba om at e-posten + # skal SE UT SOM DEN, ikke bare inneholde de samme tallene): hull som + # KOLONNER (ikke rader, ulikt tekst-fallbacken over) i to 9-hulls- + # blokker (Ut/Inn) for en 18-hullsrunde, matcher `ScoreBlock` i + # frontend/components/round-scorecard.tsx radrekkefølge/-utvalg + # nøyaktig -- samme farger/glyffer/UT-INN-oppsummeringskolonne. + show_net = any(h.net is not None for h in holes) + show_points = any(h.points is not None for h in holes) + show_fairway = any(h.tee_shot_result is not None for h in holes) + show_putts = any(h.putts is not None for h in holes) + show_gir = any(h.score is not None and h.putts is not None for h in holes) + show_approach = any(h.approach_result is not None for h in holes) + show_chip = any(h.chip_count is not None for h in holes) + show_bunker = any(h.bunker_shot_count is not None for h in holes) + show_penalty = any(h.penalty_strokes is not None for h in holes) + show_anyway = any(h.anyway_strokes is not None for h in holes) + + _label_style = ( + "padding:5px 6px;border:1px solid #e4ebe3;text-align:left;font-size:10px;" + "font-weight:800;color:#5b665b;white-space:nowrap;background-color:#f6f8f5;" ) - stat_rows_html = "\n".join( - f'{_esc(label)}' - f'{_esc(value)}' - for label, value in stat_lines + _cell_style = "padding:5px 3px;border:1px solid #e4ebe3;text-align:center;font-size:11px;" + _sum_style = _cell_style + "font-weight:800;background-color:#eef4ec;" + + def _row_html(label: str, cell_htmls: list[str], summary_html: str) -> str: + cells = "".join(f'{c}' for c in cell_htmls) + return f'{_esc(label)}{cells}{summary_html}' + + def _score_mark_html(h: RoundSummaryHole) -> str: + if h.picked_up: + return f'PU' + if h.score is None: + return f'' + kind = _classify(h.score - h.par) + base = "display:inline-block;min-width:20px;padding:1px 3px;font-weight:800;font-size:12px;" + style = { + "eagle": base + f"border-radius:999px;background-color:{_GREEN};color:#ffffff;", + "birdie": base + f"border-radius:999px;border:2px solid {_GREEN};color:{_GREEN};", + "bogey": base + f"border-radius:4px;border:2px solid {_ORANGE};color:{_ORANGE};", + "double": base + f"border-radius:4px;background-color:{_ORANGE};color:#ffffff;", + }.get(kind, base + "color:#012c11;") + return f'{h.score}' + + def _fw_html(h: RoundSummaryHole) -> str: + if h.par == 3 or h.tee_shot_result is None: + return f'' + glyph, color = _FAIRWAY_GLYPHS[h.tee_shot_result] + return f'{glyph}' + + def _gir_html(h: RoundSummaryHole) -> str: + if h.score is None or h.putts is None: + return f'' + return f'' if _gir(h) else f'' + + def _approach_html(h: RoundSummaryHole) -> str: + if h.approach_result is None: + return f'' + glyph, color = _APPROACH_GLYPHS[h.approach_result] + return f'{glyph}' + + def _count_or_dash(values: list[int]) -> str: + return str(sum(values)) if values else "–" + + def _build_block_html(block_holes: list[RoundSummaryHole], summary_label: str) -> str: + header_cells = "".join( + f'{h.hole_number}' + for h in block_holes + ) + header = ( + f'Hull' + f"{header_cells}" + f'{_esc(summary_label)}' + ) + rows = [header] + rows.append(_row_html("Hcp", [str(h.stroke_index) for h in block_holes], "–")) + rows.append(_row_html("Par", [str(h.par) for h in block_holes], str(sum(h.par for h in block_holes)))) + played = [h.score for h in block_holes if h.score is not None] + rows.append(_row_html("Scr", [_score_mark_html(h) for h in block_holes], _count_or_dash(played))) + if show_net: + nets = [h.net for h in block_holes if h.net is not None] + rows.append(_row_html("Net", [str(h.net) if h.net is not None else "–" for h in block_holes], _count_or_dash(nets))) + if show_points: + points = [h.points for h in block_holes if h.points is not None] + rows.append(_row_html("Pnt", [str(h.points) if h.points is not None else "–" for h in block_holes], _count_or_dash(points))) + if show_fairway: + eligible = [h for h in block_holes if h.par != 3 and h.tee_shot_result is not None] + hits = [1 for h in eligible if h.tee_shot_result == "fairway"] + rows.append(_row_html("Fw", [_fw_html(h) for h in block_holes], _count_or_dash(hits) if eligible else "–")) + if show_putts: + putts = [h.putts for h in block_holes if h.putts is not None] + rows.append(_row_html("Putt", [str(h.putts) if h.putts is not None else "–" for h in block_holes], _count_or_dash(putts))) + if show_gir: + eligible = [h for h in block_holes if h.score is not None and h.putts is not None] + hits = [1 for h in eligible if _gir(h)] + rows.append(_row_html("GIR", [_gir_html(h) for h in block_holes], _count_or_dash(hits) if eligible else "–")) + if show_approach: + rows.append(_row_html("Appr.", [_approach_html(h) for h in block_holes], "–")) + if show_chip: + vals = [h.chip_count for h in block_holes if h.chip_count is not None] + rows.append(_row_html("Chip", [str(h.chip_count) if h.chip_count is not None else "–" for h in block_holes], _count_or_dash(vals))) + if show_bunker: + vals = [h.bunker_shot_count for h in block_holes if h.bunker_shot_count is not None] + rows.append(_row_html("Bnk", [str(h.bunker_shot_count) if h.bunker_shot_count is not None else "–" for h in block_holes], _count_or_dash(vals))) + if show_penalty: + vals = [h.penalty_strokes for h in block_holes if h.penalty_strokes is not None] + rows.append(_row_html("Str", [str(h.penalty_strokes) if h.penalty_strokes is not None else "–" for h in block_holes], _count_or_dash(vals))) + if show_anyway: + vals = [h.anyway_strokes for h in block_holes if h.anyway_strokes is not None] + rows.append(_row_html("Any", [str(h.anyway_strokes) if h.anyway_strokes is not None else "–" for h in block_holes], _count_or_dash(vals))) + return f'{"".join(rows)}
' + + # Ut/Inn-rekkefølgen følger SPILLEREKKEFØLGEN (start_hole-relativ, samme + # sirkulære logikk som round-scorecard.tsx sin holeOrder), ikke rå + # hole_number -- kun forskjell fra fysisk 1-9/10-18 for en runde som + # ikke starter på hull 1 (uvanlig, men strukturelt mulig). + holes_by_number = {h.hole_number: h for h in holes} + hole_order = [((start_hole - 1 + i) % 18) + 1 for i in range(len(holes))] + ordered_holes = [holes_by_number[n] for n in hole_order if n in holes_by_number] + front, back = ordered_holes[:9], ordered_holes[9:18] + blocks_html = _build_block_html(front, "Tot" if not back else "Ut") + if back: + blocks_html += _build_block_html(back, "Inn") + + total_par = sum(h.par for h in holes) + played_holes = [h for h in holes if h.score is not None] + total_gross = sum(h.score for h in played_holes) if played_holes else None + played_par = sum(h.par for h in played_holes) if played_holes else 0 + total_points = sum(h.points for h in holes if h.points is not None) if show_points else None + + def _tile_html(label: str, value: str, color: str = "#012c11") -> str: + return f""" +
{_esc(value)}
+
{_esc(label)}
+""" + + tiles = [ + _tile_html("Par", str(total_par)), + _tile_html("Score", str(total_gross) if total_gross is not None else "–"), + _tile_html("Til par", _signed(total_gross - played_par) if total_gross is not None else "–", _ORANGE), + ] + if show_points: + tiles.append(_tile_html("Poeng", str(total_points) if total_points is not None else "–", _GREEN)) + tiles_html = ( + '' + + "".join(tiles) + "
" ) + if is_linked_account: pitch_html = "" cta_label = "Se hele runden" @@ -472,17 +754,11 @@ e-posten -- den brukes ikke til noe annet.

Hei {_esc(guest_first_name)},

-

Her er scorekortet ditt fra {_esc(round_label)}:

- - - - - -{hole_rows_html} -
HullParSlagNetto
- -{stat_rows_html} -
+

{greeting_html}

+
+{blocks_html} +
+{tiles_html} {pitch_html}
duration_start: + duration_minutes = round((duration_end - duration_start).total_seconds() / 60) + stat_lines: list[tuple[str, str]] = [] if g["round_side_id"] is not None: side_holes = await _build_side_holes(conn, round_id, g["round_side_id"]) @@ -6007,10 +6027,24 @@ async def _send_participant_round_summary(conn, round_id: str, participant_id: s RoundSummaryHole( hole_number=h.hole_number, par=h.par, + stroke_index=h.stroke_index, score=h.score if h.played else None, + picked_up=False, net=h.score - h.strokes_received if h.played and h.score is not None and h.strokes_received is not None else None, + points=max(0, h.par - (h.score - h.strokes_received) + 2) + if h.played and h.score is not None and h.strokes_received is not None + else None, + # Delt-ball-hull (ADR-039 Beslutning C) har ingen individuell + # detalj -- kun sidens felles slag finnes. + putts=None, + tee_shot_result=None, + approach_result=None, + chip_count=None, + bunker_shot_count=None, + penalty_strokes=None, + anyway_strokes=None, ) for h in side_holes ] @@ -6020,10 +6054,22 @@ async def _send_participant_round_summary(conn, round_id: str, participant_id: s RoundSummaryHole( hole_number=h.hole_number, par=h.par, + stroke_index=h.stroke_index, score=h.score if h.played else None, + picked_up=h.picked_up, net=h.score - h.strokes_received if h.played and h.score is not None and h.strokes_received is not None else None, + points=max(0, h.par - (h.score - h.strokes_received) + 2) + if h.played and h.score is not None and h.strokes_received is not None + else None, + putts=h.putts, + tee_shot_result=h.tee_shot_result, + approach_result=h.approach_result, + chip_count=h.chip_count, + bunker_shot_count=h.bunker_shot_count, + penalty_strokes=h.penalty_strokes, + anyway_strokes=h.anyway_strokes, ) for h in p_holes ] @@ -6031,6 +6077,26 @@ async def _send_participant_round_summary(conn, round_id: str, participant_id: s total_putts = sum(h.putts for h in p_holes if h.played and h.putts is not None) if total_putts: stat_lines.append(("Putter totalt", str(total_putts))) + # Samme formler som frontend/components/round-scorecard.tsx sine + # showFairway/showGir-aggregater -- portert hit slik at e-postens + # sammendrag matcher det appen selv ville vist for denne runden. + fairway_eligible = [h for h in p_holes if h.par != 3 and h.tee_shot_result is not None] + if fairway_eligible: + fairway_hits = sum(1 for h in fairway_eligible if h.tee_shot_result == "fairway") + stat_lines.append(("Fairwaytreff", f"{fairway_hits} av {len(fairway_eligible)}")) + gir_eligible = [h for h in p_holes if h.score is not None and h.putts is not None] + if gir_eligible: + gir_hits = sum(1 for h in gir_eligible if h.score - h.putts <= h.par - 2) + stat_lines.append(("GIR", f"{gir_hits} av {len(gir_eligible)}")) + chip_values = [h.chip_count for h in p_holes if h.chip_count is not None] + if chip_values: + stat_lines.append(("Chip totalt", str(sum(chip_values)))) + bunker_values = [h.bunker_shot_count for h in p_holes if h.bunker_shot_count is not None] + if bunker_values: + stat_lines.append(("Bunkerslag totalt", str(sum(bunker_values)))) + penalty_values = [h.penalty_strokes for h in p_holes if h.penalty_strokes is not None] + if penalty_values: + stat_lines.append(("Straffeslag totalt", str(sum(penalty_values)))) played_holes = [h for h in summary_holes if h.score is not None] if played_holes: @@ -6041,6 +6107,8 @@ async def _send_participant_round_summary(conn, round_id: str, participant_id: s if net_holes: net_to_par = sum(h.net for h in net_holes) - sum(h.par for h in net_holes) lead_lines.append(("Netto til par", _signed(net_to_par))) + points_total = sum(h.points for h in net_holes if h.points is not None) + lead_lines.append(("Poeng totalt (Stableford)", str(points_total))) stat_lines = lead_lines + stat_lines email = email.strip().lower() @@ -6063,8 +6131,9 @@ async def _send_participant_round_summary(conn, round_id: str, participant_id: s if settings.SMTP_CONFIGURED: try: await send_round_summary_email( - email, first_name, round_label, summary_holes, stat_lines, raw_token, - is_linked_account=is_linked, round_id=round_id, + email, first_name, round_label, round_row["played_at"], round_row["started_at"], + duration_minutes, other_players_count, round_row["start_hole"], summary_holes, stat_lines, + raw_token, is_linked_account=is_linked, round_id=round_id, ) except Exception: # Se app/email.py sitt mønster -- en driftsfeil i selve