Fullt scorekort (alle stats, matcher Scorekort-siden visuelt) i scorekort-e-posten

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 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-14 07:20:45 +02:00
parent d12af07ba7
commit 0f0ddca7f4
4 changed files with 503 additions and 29 deletions

View file

@ -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)

View file

@ -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.

View file

@ -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 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 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"{_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 <strong>{_esc(round_label)}</strong>, {_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'<tr><td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.hole_number}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.par}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.score if h.score is not None else ""}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.net if h.net is not None else ""}</td></tr>'
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'<tr><td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;color:#424941;">{_esc(label)}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;font-weight:700;">{_esc(value)}</td></tr>'
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'<td style="{_cell_style}">{c}</td>' for c in cell_htmls)
return f'<tr><th style="{_label_style}">{_esc(label)}</th>{cells}<td style="{_sum_style}">{summary_html}</td></tr>'
def _score_mark_html(h: RoundSummaryHole) -> str:
if h.picked_up:
return f'<span style="display:inline-block;min-width:20px;padding:1px 3px;border:2px solid {_ORANGE};border-radius:4px;color:{_ORANGE};font-size:9px;font-weight:800;">PU</span>'
if h.score is None:
return f'<span style="color:{_MUTED};"></span>'
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'<span style="{style}">{h.score}</span>'
def _fw_html(h: RoundSummaryHole) -> str:
if h.par == 3 or h.tee_shot_result is None:
return f'<span style="color:{_MUTED};"></span>'
glyph, color = _FAIRWAY_GLYPHS[h.tee_shot_result]
return f'<span style="color:{color};font-weight:800;">{glyph}</span>'
def _gir_html(h: RoundSummaryHole) -> str:
if h.score is None or h.putts is None:
return f'<span style="color:{_MUTED};"></span>'
return f'<span style="color:{_GREEN};font-weight:800;">✓</span>' if _gir(h) else f'<span style="color:{_MUTED};"></span>'
def _approach_html(h: RoundSummaryHole) -> str:
if h.approach_result is None:
return f'<span style="color:{_MUTED};"></span>'
glyph, color = _APPROACH_GLYPHS[h.approach_result]
return f'<span style="color:{color};font-weight:800;">{glyph}</span>'
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'<td style="padding:5px 3px;border:1px solid #10331a;text-align:center;font-size:11px;font-weight:800;color:#ffffff;background-color:#1a4325;">{h.hole_number}</td>'
for h in block_holes
)
header = (
f'<tr><th style="padding:5px 6px;border:1px solid #10331a;text-align:left;font-size:10px;font-weight:800;color:#ffffff;background-color:#1a4325;">Hull</th>'
f"{header_cells}"
f'<td style="padding:5px 3px;border:1px solid #10331a;text-align:center;font-size:9px;font-weight:800;color:#ffffff;background-color:{_GREEN};text-transform:uppercase;">{_esc(summary_label)}</td></tr>'
)
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'<table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse;width:100%;table-layout:fixed;">{"".join(rows)}</table>'
# 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"""<td style="padding:10px 2px;text-align:center;border:1px solid #e4ebe3;border-radius:10px;">
<div style="font-size:20px;font-weight:800;color:{color};">{_esc(value)}</div>
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.03em;color:#767f75;margin-top:2px;">{_esc(label)}</div>
</td>"""
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 = (
'<table role="presentation" cellpadding="0" cellspacing="0" width="100%" '
'style="border-collapse:separate;border-spacing:4px 0;margin:12px 0 20px;"><tr>'
+ "".join(tiles) + "</tr></table>"
)
if is_linked_account:
pitch_html = ""
cta_label = "Se hele runden"
@ -472,17 +754,11 @@ e-posten -- den brukes ikke til noe annet.
</td></tr>
<tr><td style="padding:24px;">
<p style="margin:0 0 16px;font-size:16px;">Hei {_esc(guest_first_name)},</p>
<p style="margin:0 0 20px;font-size:16px;">Her er scorekortet ditt fra <strong>{_esc(round_label)}</strong>:</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;margin-bottom:20px;">
<tr style="background-color:#edf3ef;font-weight:700;">
<td style="padding:6px 10px;">Hull</td><td style="padding:6px 10px;">Par</td>
<td style="padding:6px 10px;">Slag</td><td style="padding:6px 10px;">Netto</td>
</tr>
{hole_rows_html}
</table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;margin-bottom:24px;">
{stat_rows_html}
</table>
<p style="margin:0 0 20px;font-size:16px;">{greeting_html}</p>
<div style="overflow-x:auto;">
{blocks_html}
</div>
{tiles_html}
{pitch_html}
<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:12px;background-color:#1f6b08;">
<a href="{link}" style="display:inline-block;padding:14px 28px;color:#ffffff;font-size:16px;

View file

@ -5983,7 +5983,7 @@ async def _send_participant_round_summary(conn, round_id: str, participant_id: s
som ikke finnes er rett og slett fraværende i kildedataene."""
g = await conn.fetchrow(
"""
SELECT rp.id::text AS id, rp.guest_email, rp.guest_first_name,
SELECT rp.id::text AS id, rp.guest_email, rp.guest_first_name, rp.completed_at,
rp.round_side_id::text AS round_side_id, rp.stat_level,
rp.user_id::text AS user_id, au.email::text AS account_email, au.first_name AS account_first_name
FROM round_participant rp
@ -6000,6 +6000,26 @@ async def _send_participant_round_summary(conn, round_id: str, participant_id: s
return False # ingen registrert adresse -- ingenting å sende (vanligste tilfellet)
first_name = (g["account_first_name"] if is_linked else g["guest_first_name"]) or ("Der" if is_linked else "Gjest")
# ADR-065 "fullt scorekort"-utvidelsen (2026-08-13, etterspurt av bruker
# rett etter selve fullførings-funksjonen ble rullet ut): dato/klokkeslett
# + spilletid i hilsen-linjen, og hele hull-for-hull-detaljen (ikke bare
# slag/netto) i selve e-posten -- se send_round_summary_email.
round_row = await conn.fetchrow(
"SELECT played_at, started_at, completed_at, start_hole FROM round WHERE id = $1", round_id
)
other_players_count = (
await conn.fetchval("SELECT count(*) FROM round_participant WHERE round_id = $1", round_id) - 1
)
duration_minutes: int | None = None
# `played_at` er kun en DATO (se _format_round_datetime i email.py) --
# duger IKKE som klokke-fallback her, ulikt `started_at`/`completed_at`
# som begge er ekte timestamptz. Ingen varighet vises i det hele tatt
# hvis runden aldri ble "startet" (started_at fortsatt NULL).
duration_start = round_row["started_at"]
duration_end = g["completed_at"] or round_row["completed_at"]
if duration_start is not None and duration_end is not None and duration_end > 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