Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ Under `prefers-reduced-motion: reduce`, `base.css` collapses every animation. `C

## Components

New in this system (`web/src/components`, all shown at `/_components`): `Card`/`Kicker`, `Reveal`, `CountUp`, `PageHeader`, `CourtArt`, `FormPills`, `SurfaceBadge`, `AreaChart`, `RadarChart`, `CommandPalette`, `ThemeToggle`, `SectionRail`, `RoundFunnel`, `Flag`, `DrawPicker`. Restyled: `Button`, `PlayerSearch`, `StatTable` (a card with uppercase heads and a grey row hover), `StatRow`, `SplitBar`, `WinSplit`, `SurfaceToggle`/`TourFilter` (pills), `SurfaceEloStrip` (bars), `RecentFinals` (cards), `SeedingSheet` and `TrajectoryChart` (coloured leaders), `WinLossMark`, `Skeleton` (shimmer), `EmptyState` (dashed card), `Scoreboard` (ink board, lime serve light), `DrawSheet`, `RoundList`.
New in this system (`web/src/components`, all shown at `/_components`): `Card`/`Kicker`, `Reveal`, `CountUp`, `PageHeader`, `CourtArt`, `FormPills`, `SurfaceBadge`, `AreaChart`, `RadarChart`, `ThisWeek`, `CommandPalette`, `ThemeToggle`, `SectionRail`, `RoundFunnel`, `Flag`, `DrawPicker`. Restyled: `Button`, `PlayerSearch`, `StatTable` (a card with uppercase heads and a grey row hover), `StatRow`, `SplitBar`, `WinSplit`, `SurfaceToggle`/`TourFilter` (pills), `SurfaceEloStrip` (bars), `RecentFinals` (cards), `SeedingSheet` and `TrajectoryChart` (coloured leaders), `WinLossMark`, `Skeleton` (shimmer), `EmptyState` (dashed card), `Scoreboard` (ink board, lime serve light), `DrawSheet`, `RoundList`.

### The command palette

Expand Down
3 changes: 2 additions & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ Seed fixture of ~4,100 real matches covers every data regime. Served as a static

## Capabilities and Constraints

- Read-only API. No accounts, comments, or social features. No live scores. No betting
- Read-only API. No accounts, comments, or social features. No live scores: this week's events show results so far, hourly and labelled provisional
(ADR-0016). No betting
odds, tipping, or predictions framed as picks; the simulator reports probabilities.
- Full-schema data reaches the current season on both tours since the live source was
added (ADR-0002, amended), and a weekly CronJob keeps it there. What remains thin is
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ GET /api/v1/tournaments?tour=&level=&q= the index, grouped by level, searchabl
GET /api/v1/tournaments/:slug an event across seasons, with how each got there
GET /api/v1/tournaments/:slug/:season one edition as a draw sheet
GET /api/v1/recent?tier=&through= the finals of the last complete week, both tours
GET /api/v1/this-week events in progress, as results so far (hourly, provisional)
GET /api/v1/seasons a row per year, both tours, with the Slam finals
GET /api/v1/seasons/:year?tour=&tier= every event of a year at one tier, with its final
GET /api/v1/leaders/:stat?tour=&tier=&surface=&season=&min_matches=
Expand Down Expand Up @@ -298,6 +299,13 @@ this is the fastest way to show a visitor it is current without saying the word.
off-season week says which week it is showing instead rather than vanishing, and a tour with
no final that week says so.

Above it, **This week** shows the tour-level events being played now as their results so
far: how far each draw has got and its latest matches, fetched hourly from the source's
ongoing files and labelled as provisional, with how long ago they last changed. Still not
live scores: a result appears once the source records the finished match. These rows are
kept apart from the match table and feed no rating until the weekly load brings the finished
event in ([ADR-0016](docs/decisions/0016-this-week.md)).

`/seasons` is the calendar a year at a time, both tours on every row since a season is the
one place the two share one: the events on each surface as the site's squares with their
counts, then the Slam champions in calendar order, and the women's rows reaching back to 1923
Expand Down
60 changes: 60 additions & 0 deletions cmd/ongoing/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Command ongoing refreshes this week's provisional results from the source's
// ongoing files. Hourly, on its own: it touches nothing a full load derives
// and flushes no cache, because /this-week is not cached.
package main

import (
"context"
"errors"
"flag"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/sami0076/tennis-wiki/internal/db"
"github.com/sami0076/tennis-wiki/internal/ingest"
"github.com/sami0076/tennis-wiki/internal/ongoing"
)

func main() {
dsn := flag.String("database-url", os.Getenv("DATABASE_URL"), "PostgreSQL connection string")
base := flag.String("base-url", ongoing.BaseURL, "where the ongoing files are published")
flag.Parse()

slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

if err := run(ctx, *dsn, *base); err != nil {
slog.Error("ongoing: failed", "error", err)
os.Exit(1)
}
}

func run(ctx context.Context, dsn, base string) error {
if dsn == "" {
return errors.New("no database URL: set DATABASE_URL or pass -database-url")
}
pool, err := db.Open(ctx, db.Config{DSN: dsn, MaxConns: 2})
if err != nil {
return err
}
defer pool.Close()

fetch := ingest.HTTPFetcher{Client: &http.Client{Timeout: time.Minute}}
var failed error
// One file failing leaves the other tour's week as fresh as it can be.
for _, f := range ongoing.Files {
res, err := ongoing.Refresh(ctx, pool, fetch, base, f)
if err != nil {
failed = errors.Join(failed, err)
continue
}
slog.Info("ongoing: refreshed", "file", f.Name,
"changed", res.Changed, "rows", res.Rows, "skipped", res.Skipped)
}
return failed
}
2 changes: 1 addition & 1 deletion deploy/docker/tools.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ RUN : "${GOOSE_VERSION:?run make images, or pass --build-arg GOOSE_VERSION}" \
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH \
go build -trimpath -ldflags="-s -w" -o /out/ \
./cmd/ingest ./cmd/rate ./cmd/dataqual ./cmd/validate
./cmd/ingest ./cmd/rate ./cmd/dataqual ./cmd/validate ./cmd/ongoing

FROM alpine:3.21

Expand Down
67 changes: 67 additions & 0 deletions deploy/k8s/base/65-ongoing-hourly.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# This week's results so far: the source's ongoing files, hourly (ADR-0016).
# Provisional, kept in ongoing_matches and never in matches; the weekly load
# brings each finished event in from its season file. An unchanged file costs
# one 304, and /this-week is uncached, so nothing is flushed.
#
# kubectl -n deucepoint get cronjob ongoing-hourly
# kubectl -n deucepoint create job --from=cronjob/ongoing-hourly ongoing-now
#
# Suspended until the tools image pinned below carries cmd/ongoing: the deploy
# that pins it sets suspend to false.
apiVersion: batch/v1
kind: CronJob
metadata:
name: ongoing-hourly
namespace: deucepoint
labels:
app.kubernetes.io/name: ongoing-hourly
app.kubernetes.io/part-of: deucepoint
spec:
suspend: true
schedule: "7 * * * *"
timeZone: Etc/UTC
concurrencyPolicy: Forbid
# A missed hour is covered by the next one.
startingDeadlineSeconds: 600
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 600
ttlSecondsAfterFinished: 3600
template:
metadata:
labels:
app.kubernetes.io/name: ongoing-hourly
app.kubernetes.io/part-of: deucepoint
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: ongoing
image: ghcr.io/sami0076/tennis-wiki/tools@sha256:49349d72dfc72442f4efb473b3e670f5a043be146b566f962acb60d0d29dfeda
command: ["ongoing"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: postgres
key: DATABASE_URL
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
53 changes: 53 additions & 0 deletions docs/decisions/0016-this-week.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# ADR-0016: This week, from the source's ongoing files

- **Status:** Accepted
- **Date:** 2026-09-26
- **Context:** the home page's "This week" card and ticker, and `GET /this-week`.

## Context

The weekly load brings a tournament in once its season file has it, so the home page could
show last week's finals and nothing of the week being played. The maintainer of layer D
(ADR-0002, amended) also publishes ongoing files: `ongoing_tourneys.csv` for the ATP,
`wta_ongoing_tourneys.csv` for the WTA and one for Challengers, in the season files' layout,
updated several times a day.

They are not live scores. They hold finished matches only, with no schedule and nothing in
progress. The ATP file dates each match by the day it was played and the WTA file by the
week's start. The WTA file writes rounds as words ("Quarterfinals"). Rows can still be
corrected or dropped until the event ends.

## Decision

**Kept apart from `matches`.** Ongoing rows go to `ongoing_matches`, and each fetch replaces
a file's rows in one transaction. Ingest only ever upserts: a row the source later dropped
or re-keyed would outlive it in `matches`, and ongoing matches dated to the week's start
would move `current_through` mid-week. Nothing that rates, ranks, aggregates or draws a
sheet reads `ongoing_matches`. The finished event reaches all of those the usual way, from
its season file, in the Monday load.

**Hourly, on its own.** `cmd/ongoing` runs from the `ongoing-hourly` CronJob. It sends the
stored ETag, so an unchanged file costs one 304. It runs none of ingest's refresh steps and
flushes no cache: `/this-week` sits outside the response cache because it reads a few dozen
rows.

**Tour level only.** Main-draw tour-level matches from the ATP and WTA files. The Challenger
file is not fetched, and team ties (levels D and T) and qualifying rounds are dropped. The
Laver Cup stays, because the season files already treat it as a tour event.

**Said as what it is.** The card says "results so far, not live scores" and how long ago the
files last changed, and that ratings take the results in once each event is over. Each event
shows the furthest round with a result in, or its champion once the final is in. An event
still listed a week after its last result is dropped as stale.

**Players linked where known.** A player links by source id: directly for one first seen in
the TennisMyLife files, through `player_aliases` for one the older files named differently.
An unknown id shows as a name without a link, never as a guessed match.

## Consequences

- The home page is current to within an hour of the source, instead of a week behind.
- The Challenger file and a partly played draw sheet are left out. The edition page would
need to render unplayed rounds (ADR-0010's sheet grows its tree from the latest round),
and that is a separate piece of work.
- Match dates mean different things per tour, so the page shows rounds, not dates.
10 changes: 10 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ kubectl -n deucepoint apply -f /root/deploy/k8s/jobs/refresh.yaml
kubectl -n deucepoint logs -f job/refresh
```

**This week (ADR-0016)** is the `ongoing-hourly` CronJob and migration 00020. The first
time: run the migrate Job, pin the `tools` image built with `cmd/ongoing` in
`base/65-ongoing-hourly.yaml`, set `suspend: false` there, and apply. It refreshes at seven
past every hour; to fill the card at once:

```sh
kubectl -n deucepoint create job --from=cronjob/ongoing-hourly ongoing-now
kubectl -n deucepoint logs -f job/ongoing-now
```

## Deploying the frontend

Nothing to do. Cloudflare Pages watches `main`, runs `npm run build` in `web/` with
Expand Down
28 changes: 28 additions & 0 deletions internal/db/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading