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:
parent
d12af07ba7
commit
0f0ddca7f4
4 changed files with 503 additions and 29 deletions
|
|
@ -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)
|
||||
|
|
|
|||
48
CHANGELOG.md
48
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.
|
||||
|
|
|
|||
328
app/email.py
328
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 <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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue