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 migrasjon), `docker compose build teecup_frontend && up -d
teecup_frontend`. 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) ## Utviklingsplan (rekkefølge)

View file

@ -10980,3 +10980,51 @@ Neste steg:
**Rullet ut 2026-08-13**, samme økt. Migrasjon 067+068 kjørt mot **Rullet ut 2026-08-13**, samme økt. Migrasjon 067+068 kjørt mot
ekte `teecup_db`, `docker compose build teecup_api && up -d`, ren ekte `teecup_db`, `docker compose build teecup_api && up -d`, ren
oppstartslogg. 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 import smtplib
from asyncio import to_thread from asyncio import to_thread
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime
from email.message import EmailMessage from email.message import EmailMessage
from html import escape as _esc from html import escape as _esc
from zoneinfo import ZoneInfo
from .config import settings 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: def _send_sync(to_email: str, subject: str, body: str, html_body: str | None = None) -> None:
msg = EmailMessage() msg = EmailMessage()
@ -351,14 +412,39 @@ async def send_new_account_alert_email(to_email: str, new_user_email: str, displ
class RoundSummaryHole: class RoundSummaryHole:
hole_number: int hole_number: int
par: int par: int
stroke_index: int
score: int | None score: int | None
picked_up: bool
net: int | None 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( async def send_round_summary_email(
to_email: str, to_email: str,
guest_first_name: str, guest_first_name: str,
round_label: 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], holes: list[RoundSummaryHole],
stat_lines: list[tuple[str, str]], stat_lines: list[tuple[str, str]],
raw_token: str | None, raw_token: str | None,
@ -390,7 +476,20 @@ async def send_round_summary_email(
send_scorecard_invitations/send_session_result_email) -- graderer seg send_scorecard_invitations/send_session_result_email) -- graderer seg
naturlig etter hva som faktisk ble spilt (stat_level, individuell-ball naturlig etter hva som faktisk ble spilt (stat_level, individuell-ball
vs. delt-ball) siden kalleren rett og slett utelater linjer det ikke 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 Alltid norsk, samme presedens som send_scorecard_invitations (verken en
gjest eller en spiller midt i en runde har nødvendigvis en lagret 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}" link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
subject = f"Scorekortet ditt fra {round_label}" 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 = [ text_lines = [
f"Hei {guest_first_name},", f"Hei {guest_first_name},",
"", "",
f"Her er scorekortet ditt fra {round_label}:", greeting_line,
"", "",
] ]
for h in holes: for h in holes:
score_txt = str(h.score) if h.score is not None else "" cell_txt = ", ".join(f"{label} {fn(h)}" for label, fn in columns)
net_txt = str(h.net) if h.net is not None else "" text_lines.append(f"Hull {h.hole_number}: {cell_txt}")
text_lines.append(f"Hull {h.hole_number} (par {h.par}): {score_txt} slag (netto {net_txt})")
text_lines.append("") text_lines.append("")
for label, value in stat_lines: for label, value in stat_lines:
text_lines.append(f"{label}: {value}") text_lines.append(f"{label}: {value}")
@ -434,18 +579,155 @@ async def send_round_summary_email(
] ]
body = "\n".join(text_lines) body = "\n".join(text_lines)
hole_rows_html = "\n".join( # HTML-tabellen (2026-08-13, samme dag som ovenstående -- bruker sendte
f'<tr><td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.hole_number}</td>' # et skjermbilde av selve Scorekort-siden i appen og ba om at e-posten
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.par}</td>' # skal SE UT SOM DEN, ikke bare inneholde de samme tallene): hull som
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.score if h.score is not None else ""}</td>' # KOLONNER (ikke rader, ulikt tekst-fallbacken over) i to 9-hulls-
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.net if h.net is not None else ""}</td></tr>' # blokker (Ut/Inn) for en 18-hullsrunde, matcher `ScoreBlock` i
for h in holes # 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( _cell_style = "padding:5px 3px;border:1px solid #e4ebe3;text-align:center;font-size:11px;"
f'<tr><td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;color:#424941;">{_esc(label)}</td>' _sum_style = _cell_style + "font-weight:800;background-color:#eef4ec;"
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;font-weight:700;">{_esc(value)}</td></tr>'
for label, value in stat_lines 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: if is_linked_account:
pitch_html = "" pitch_html = ""
cta_label = "Se hele runden" cta_label = "Se hele runden"
@ -472,17 +754,11 @@ e-posten -- den brukes ikke til noe annet.
</td></tr> </td></tr>
<tr><td style="padding:24px;"> <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 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> <p style="margin:0 0 20px;font-size:16px;">{greeting_html}</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;margin-bottom:20px;"> <div style="overflow-x:auto;">
<tr style="background-color:#edf3ef;font-weight:700;"> {blocks_html}
<td style="padding:6px 10px;">Hull</td><td style="padding:6px 10px;">Par</td> </div>
<td style="padding:6px 10px;">Slag</td><td style="padding:6px 10px;">Netto</td> {tiles_html}
</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>
{pitch_html} {pitch_html}
<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:12px;background-color:#1f6b08;"> <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; <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.""" som ikke finnes er rett og slett fraværende i kildedataene."""
g = await conn.fetchrow( 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.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 rp.user_id::text AS user_id, au.email::text AS account_email, au.first_name AS account_first_name
FROM round_participant rp 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) 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") 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]] = [] stat_lines: list[tuple[str, str]] = []
if g["round_side_id"] is not None: if g["round_side_id"] is not None:
side_holes = await _build_side_holes(conn, round_id, g["round_side_id"]) 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( RoundSummaryHole(
hole_number=h.hole_number, hole_number=h.hole_number,
par=h.par, par=h.par,
stroke_index=h.stroke_index,
score=h.score if h.played else None, score=h.score if h.played else None,
picked_up=False,
net=h.score - h.strokes_received net=h.score - h.strokes_received
if h.played and h.score is not None and h.strokes_received is not None if h.played and h.score is not None and h.strokes_received is not None
else 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 for h in side_holes
] ]
@ -6020,10 +6054,22 @@ async def _send_participant_round_summary(conn, round_id: str, participant_id: s
RoundSummaryHole( RoundSummaryHole(
hole_number=h.hole_number, hole_number=h.hole_number,
par=h.par, par=h.par,
stroke_index=h.stroke_index,
score=h.score if h.played else None, score=h.score if h.played else None,
picked_up=h.picked_up,
net=h.score - h.strokes_received net=h.score - h.strokes_received
if h.played and h.score is not None and h.strokes_received is not None if h.played and h.score is not None and h.strokes_received is not None
else 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 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) total_putts = sum(h.putts for h in p_holes if h.played and h.putts is not None)
if total_putts: if total_putts:
stat_lines.append(("Putter totalt", str(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] played_holes = [h for h in summary_holes if h.score is not None]
if played_holes: if played_holes:
@ -6041,6 +6107,8 @@ async def _send_participant_round_summary(conn, round_id: str, participant_id: s
if net_holes: if net_holes:
net_to_par = sum(h.net for h in net_holes) - sum(h.par for h in 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))) 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 stat_lines = lead_lines + stat_lines
email = email.strip().lower() 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: if settings.SMTP_CONFIGURED:
try: try:
await send_round_summary_email( await send_round_summary_email(
email, first_name, round_label, summary_holes, stat_lines, raw_token, email, first_name, round_label, round_row["played_at"], round_row["started_at"],
is_linked_account=is_linked, round_id=round_id, 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: except Exception:
# Se app/email.py sitt mønster -- en driftsfeil i selve # Se app/email.py sitt mønster -- en driftsfeil i selve