From eaa7c88953912b06a3d526a743242a1e305be547 Mon Sep 17 00:00:00 2001 From: Sami Saleh Date: Sat, 26 Sep 2026 12:52:35 -0400 Subject: [PATCH 01/13] db: ongoing matches, replaced a file at a time, and when each was fetched --- internal/db/models.go | 28 ++++ internal/db/ongoing.sql.go | 239 ++++++++++++++++++++++++++++++++ internal/db/queries/ongoing.sql | 53 +++++++ migrations/00020_ongoing.sql | 43 ++++++ 4 files changed, 363 insertions(+) create mode 100644 internal/db/ongoing.sql.go create mode 100644 internal/db/queries/ongoing.sql create mode 100644 migrations/00020_ongoing.sql diff --git a/internal/db/models.go b/internal/db/models.go index b317dc3..6670f62 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -367,6 +367,34 @@ type MatchPlayer struct { BpFaced *int16 } +type OngoingFile struct { + File string + Etag *string + CheckedAt pgtype.Timestamptz + ChangedAt pgtype.Timestamptz + Rows int32 +} + +type OngoingMatch struct { + File string + Tour Tour + TourneySourceID string + TourneyName string + Level string + Surface *Surface + Indoor *bool + MatchNum int32 + Round string + PlayedOn time.Time + Score *string + WinnerSourceID string + WinnerName string + WinnerSeed *int16 + LoserSourceID string + LoserName string + LoserSeed *int16 +} + type Player struct { ID int64 SourceID string diff --git a/internal/db/ongoing.sql.go b/internal/db/ongoing.sql.go new file mode 100644 index 0000000..21fce1a --- /dev/null +++ b/internal/db/ongoing.sql.go @@ -0,0 +1,239 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: ongoing.sql + +package db + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteOngoingMatches = `-- name: DeleteOngoingMatches :exec +DELETE FROM ongoing_matches WHERE file = $1 +` + +func (q *Queries) DeleteOngoingMatches(ctx context.Context, file string) error { + _, err := q.db.Exec(ctx, deleteOngoingMatches, file) + return err +} + +const getOngoingFile = `-- name: GetOngoingFile :one +SELECT file, etag, checked_at, changed_at, rows FROM ongoing_files WHERE file = $1 +` + +func (q *Queries) GetOngoingFile(ctx context.Context, file string) (OngoingFile, error) { + row := q.db.QueryRow(ctx, getOngoingFile, file) + var i OngoingFile + err := row.Scan( + &i.File, + &i.Etag, + &i.CheckedAt, + &i.ChangedAt, + &i.Rows, + ) + return i, err +} + +const insertOngoingMatch = `-- name: InsertOngoingMatch :exec +INSERT INTO ongoing_matches (file, tour, tourney_source_id, tourney_name, level, surface, indoor, + match_num, round, played_on, score, + winner_source_id, winner_name, winner_seed, + loser_source_id, loser_name, loser_seed) +VALUES ($1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, + $12, $13, $14, + $15, $16, $17) +` + +type InsertOngoingMatchParams struct { + File string + Tour Tour + TourneySourceID string + TourneyName string + Level string + Surface *Surface + Indoor *bool + MatchNum int32 + Round string + PlayedOn time.Time + Score *string + WinnerSourceID string + WinnerName string + WinnerSeed *int16 + LoserSourceID string + LoserName string + LoserSeed *int16 +} + +func (q *Queries) InsertOngoingMatch(ctx context.Context, arg InsertOngoingMatchParams) error { + _, err := q.db.Exec(ctx, insertOngoingMatch, + arg.File, + arg.Tour, + arg.TourneySourceID, + arg.TourneyName, + arg.Level, + arg.Surface, + arg.Indoor, + arg.MatchNum, + arg.Round, + arg.PlayedOn, + arg.Score, + arg.WinnerSourceID, + arg.WinnerName, + arg.WinnerSeed, + arg.LoserSourceID, + arg.LoserName, + arg.LoserSeed, + ) + return err +} + +const listOngoingFiles = `-- name: ListOngoingFiles :many +SELECT file, checked_at, changed_at, rows FROM ongoing_files ORDER BY file +` + +type ListOngoingFilesRow struct { + File string + CheckedAt pgtype.Timestamptz + ChangedAt pgtype.Timestamptz + Rows int32 +} + +func (q *Queries) ListOngoingFiles(ctx context.Context) ([]ListOngoingFilesRow, error) { + rows, err := q.db.Query(ctx, listOngoingFiles) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListOngoingFilesRow{} + for rows.Next() { + var i ListOngoingFilesRow + if err := rows.Scan( + &i.File, + &i.CheckedAt, + &i.ChangedAt, + &i.Rows, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listOngoingMatches = `-- name: ListOngoingMatches :many +SELECT o.tour, o.tourney_source_id, o.tourney_name, o.level, o.surface, o.indoor, + o.match_num, o.round, o.played_on, o.score, + o.winner_name, o.winner_seed, coalesce(w.slug, '') AS winner_slug, + o.loser_name, o.loser_seed, coalesce(l.slug, '') AS loser_slug + FROM ongoing_matches o + LEFT JOIN LATERAL ( + SELECT p.slug FROM players p + WHERE (p.source_id = o.winner_source_id AND p.tour = o.tour) + OR p.id = (SELECT a.player_id FROM player_aliases a + WHERE a.source_id = o.winner_source_id AND a.source LIKE 'tml-%' + LIMIT 1) + LIMIT 1) w ON true + LEFT JOIN LATERAL ( + SELECT p.slug FROM players p + WHERE (p.source_id = o.loser_source_id AND p.tour = o.tour) + OR p.id = (SELECT a.player_id FROM player_aliases a + WHERE a.source_id = o.loser_source_id AND a.source LIKE 'tml-%' + LIMIT 1) + LIMIT 1) l ON true + ORDER BY o.tour, o.tourney_name, o.played_on DESC, o.match_num DESC +` + +type ListOngoingMatchesRow struct { + Tour Tour + TourneySourceID string + TourneyName string + Level string + Surface *Surface + Indoor *bool + MatchNum int32 + Round string + PlayedOn time.Time + Score *string + WinnerName string + WinnerSeed *int16 + WinnerSlug string + LoserName string + LoserSeed *int16 + LoserSlug string +} + +// Every provisional result, with a player's slug where the database knows the +// source id: directly for a player first seen in these files, through an alias +// for one the older files named differently. +func (q *Queries) ListOngoingMatches(ctx context.Context) ([]ListOngoingMatchesRow, error) { + rows, err := q.db.Query(ctx, listOngoingMatches) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListOngoingMatchesRow{} + for rows.Next() { + var i ListOngoingMatchesRow + if err := rows.Scan( + &i.Tour, + &i.TourneySourceID, + &i.TourneyName, + &i.Level, + &i.Surface, + &i.Indoor, + &i.MatchNum, + &i.Round, + &i.PlayedOn, + &i.Score, + &i.WinnerName, + &i.WinnerSeed, + &i.WinnerSlug, + &i.LoserName, + &i.LoserSeed, + &i.LoserSlug, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const saveOngoingFile = `-- name: SaveOngoingFile :exec +INSERT INTO ongoing_files (file, etag, checked_at, changed_at, rows) +VALUES ($1, $2, now(), now(), $3) +ON CONFLICT (file) DO UPDATE + SET checked_at = now(), + etag = CASE WHEN $4::boolean THEN EXCLUDED.etag ELSE ongoing_files.etag END, + changed_at = CASE WHEN $4::boolean THEN now() ELSE ongoing_files.changed_at END, + rows = CASE WHEN $4::boolean THEN EXCLUDED.rows ELSE ongoing_files.rows END +` + +type SaveOngoingFileParams struct { + File string + Etag *string + Rows int32 + Changed bool +} + +// changed is false for a 304: the file was asked for and had not moved. +func (q *Queries) SaveOngoingFile(ctx context.Context, arg SaveOngoingFileParams) error { + _, err := q.db.Exec(ctx, saveOngoingFile, + arg.File, + arg.Etag, + arg.Rows, + arg.Changed, + ) + return err +} diff --git a/internal/db/queries/ongoing.sql b/internal/db/queries/ongoing.sql new file mode 100644 index 0000000..182a887 --- /dev/null +++ b/internal/db/queries/ongoing.sql @@ -0,0 +1,53 @@ +-- name: GetOngoingFile :one +SELECT file, etag, checked_at, changed_at, rows FROM ongoing_files WHERE file = @file; + +-- name: DeleteOngoingMatches :exec +DELETE FROM ongoing_matches WHERE file = @file; + +-- name: InsertOngoingMatch :exec +INSERT INTO ongoing_matches (file, tour, tourney_source_id, tourney_name, level, surface, indoor, + match_num, round, played_on, score, + winner_source_id, winner_name, winner_seed, + loser_source_id, loser_name, loser_seed) +VALUES (@file, @tour, @tourney_source_id, @tourney_name, @level, sqlc.narg(surface), sqlc.narg(indoor), + @match_num, @round, @played_on, sqlc.narg(score), + @winner_source_id, @winner_name, sqlc.narg(winner_seed), + @loser_source_id, @loser_name, sqlc.narg(loser_seed)); + +-- name: SaveOngoingFile :exec +-- changed is false for a 304: the file was asked for and had not moved. +INSERT INTO ongoing_files (file, etag, checked_at, changed_at, rows) +VALUES (@file, sqlc.narg(etag), now(), now(), @rows) +ON CONFLICT (file) DO UPDATE + SET checked_at = now(), + etag = CASE WHEN @changed::boolean THEN EXCLUDED.etag ELSE ongoing_files.etag END, + changed_at = CASE WHEN @changed::boolean THEN now() ELSE ongoing_files.changed_at END, + rows = CASE WHEN @changed::boolean THEN EXCLUDED.rows ELSE ongoing_files.rows END; + +-- name: ListOngoingMatches :many +-- Every provisional result, with a player's slug where the database knows the +-- source id: directly for a player first seen in these files, through an alias +-- for one the older files named differently. +SELECT o.tour, o.tourney_source_id, o.tourney_name, o.level, o.surface, o.indoor, + o.match_num, o.round, o.played_on, o.score, + o.winner_name, o.winner_seed, coalesce(w.slug, '') AS winner_slug, + o.loser_name, o.loser_seed, coalesce(l.slug, '') AS loser_slug + FROM ongoing_matches o + LEFT JOIN LATERAL ( + SELECT p.slug FROM players p + WHERE (p.source_id = o.winner_source_id AND p.tour = o.tour) + OR p.id = (SELECT a.player_id FROM player_aliases a + WHERE a.source_id = o.winner_source_id AND a.source LIKE 'tml-%' + LIMIT 1) + LIMIT 1) w ON true + LEFT JOIN LATERAL ( + SELECT p.slug FROM players p + WHERE (p.source_id = o.loser_source_id AND p.tour = o.tour) + OR p.id = (SELECT a.player_id FROM player_aliases a + WHERE a.source_id = o.loser_source_id AND a.source LIKE 'tml-%' + LIMIT 1) + LIMIT 1) l ON true + ORDER BY o.tour, o.tourney_name, o.played_on DESC, o.match_num DESC; + +-- name: ListOngoingFiles :many +SELECT file, checked_at, changed_at, rows FROM ongoing_files ORDER BY file; diff --git a/migrations/00020_ongoing.sql b/migrations/00020_ongoing.sql new file mode 100644 index 0000000..da1143e --- /dev/null +++ b/migrations/00020_ongoing.sql @@ -0,0 +1,43 @@ +-- +goose Up +-- This week's results so far, from the source's ongoing files. Provisional and +-- kept apart from matches on purpose: ingest only ever upserts, so a row the +-- source later corrects or drops would outlive it there, and nothing that +-- rates, ranks or aggregates should see a draw that is still being played. +-- Each fetch replaces a file's rows wholesale; the weekly load brings the +-- finished event into matches from the season file. +CREATE TABLE ongoing_matches ( + file text NOT NULL, + tour tour NOT NULL, + tourney_source_id text NOT NULL, + tourney_name text NOT NULL, + level text NOT NULL, + surface surface, + indoor boolean, + match_num integer NOT NULL, + round text NOT NULL, + -- The ongoing files date each match by the day it was played, where the + -- season files use the week the event started. + played_on date NOT NULL, + score text, + winner_source_id text NOT NULL, + winner_name text NOT NULL, + winner_seed smallint, + loser_source_id text NOT NULL, + loser_name text NOT NULL, + loser_seed smallint, + PRIMARY KEY (file, tourney_source_id, match_num) +); + +-- When each file was last asked for and last changed, so the page can say how +-- fresh "so far" is rather than implying it is live. +CREATE TABLE ongoing_files ( + file text PRIMARY KEY, + etag text, + checked_at timestamptz NOT NULL, + changed_at timestamptz NOT NULL, + rows integer NOT NULL +); + +-- +goose Down +DROP TABLE IF EXISTS ongoing_files; +DROP TABLE IF EXISTS ongoing_matches; From 2da8fd5a445ae4236e9ead252520a74d4b72369a Mon Sep 17 00:00:00 2001 From: Sami Saleh Date: Sat, 26 Sep 2026 12:55:05 -0400 Subject: [PATCH 02/13] ongoing: fetch this week's files and replace them a file at a time --- internal/ingest/classify.go | 3 +- internal/ingest/store.go | 2 +- internal/ongoing/ongoing.go | 162 +++++++++++++++++++ internal/ongoing/ongoing_integration_test.go | 110 +++++++++++++ 4 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 internal/ongoing/ongoing.go create mode 100644 internal/ongoing/ongoing_integration_test.go diff --git a/internal/ingest/classify.go b/internal/ingest/classify.go index 31f78c2..b3ed9e2 100644 --- a/internal/ingest/classify.go +++ b/internal/ingest/classify.go @@ -54,7 +54,8 @@ var teamEventLevels = map[string]struct{}{ "T": {}, // team events in some WTA files } -func isTeamEvent(level string) bool { +// IsTeamEvent reports whether a tourney_level is a team tie. +func IsTeamEvent(level string) bool { _, ok := teamEventLevels[strings.ToUpper(strings.TrimSpace(level))] return ok } diff --git a/internal/ingest/store.go b/internal/ingest/store.go index 7c25d4c..0c2f2c1 100644 --- a/internal/ingest/store.go +++ b/internal/ingest/store.go @@ -358,7 +358,7 @@ func (s *Store) upsertMatches( RETURNING id`, tid, r.MatchNum, r.Round, r.BestOf, r.Surface, r.Score, r.Minutes, winnerID, loserID, r.TourneyDate, parsed.incomplete, r.IsQualifying(), - isTeamEvent(r.Level), r.HasDetailedStats(), r.Indoor, src.Name, + IsTeamEvent(r.Level), r.HasDetailedStats(), r.Indoor, src.Name, parsed.tiebreaksWinner, parsed.tiebreaksLoser, parsed.decidingSet, parsed.setsWinner, parsed.setsLoser, parsed.gamesWinner, parsed.gamesLoser) diff --git a/internal/ongoing/ongoing.go b/internal/ongoing/ongoing.go new file mode 100644 index 0000000..f3ccb01 --- /dev/null +++ b/internal/ongoing/ongoing.go @@ -0,0 +1,162 @@ +// Package ongoing keeps this week's provisional results: the source's ongoing +// files, fetched on their own schedule and replaced a file at a time. They +// never reach matches; the weekly load brings a finished event in from the +// season file (ADR-0016). +package ongoing + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/sami0076/tennis-wiki/internal/db" + "github.com/sami0076/tennis-wiki/internal/ingest" +) + +// BaseURL is where the maintainer publishes; ADR-0002's amendment. +const BaseURL = "https://stats.tennismylife.org/data" + +// File is one ongoing file and the tour it carries. +type File struct { + Name string + Tour db.Tour +} + +// Files are the tour-level ones. The Challenger file exists too and is left +// out until something shows Challengers week by week. +var Files = []File{ + {Name: "ongoing_tourneys.csv", Tour: db.TourAtp}, + {Name: "wta_ongoing_tourneys.csv", Tour: db.TourWta}, +} + +// Opener fetches a file unless it matches the validator; ingest.HTTPFetcher is one. +type Opener interface { + OpenPathIfChanged(ctx context.Context, baseURL, relPath, validator string) (io.ReadCloser, string, error) +} + +// maxSkipped is far more bad rows than a week's file has matches. +const maxSkipped = 100 + +// Result is what one refresh did. +type Result struct { + Changed bool + Rows int + Skipped int +} + +// Refresh fetches one file and, if it moved, replaces its rows in one +// transaction, so a reader sees the old week or the new one and never half. +func Refresh(ctx context.Context, pool *pgxpool.Pool, open Opener, baseURL string, f File) (Result, error) { + q := db.New(pool) + var validator string + if prev, err := q.GetOngoingFile(ctx, f.Name); err == nil && prev.Etag != nil { + validator = *prev.Etag + } else if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return Result{}, err + } + + body, etag, err := open.OpenPathIfChanged(ctx, baseURL, f.Name, validator) + if errors.Is(err, ingest.ErrUnchanged) { + return Result{}, q.SaveOngoingFile(ctx, db.SaveOngoingFileParams{File: f.Name}) + } + if err != nil { + return Result{}, err + } + defer func() { _ = body.Close() }() + + rows, skipped, err := read(f, body) + if err != nil { + return Result{}, err + } + + err = pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error { + qtx := db.New(tx) + if err := qtx.DeleteOngoingMatches(ctx, f.Name); err != nil { + return err + } + for _, row := range rows { + if err := qtx.InsertOngoingMatch(ctx, row); err != nil { + return fmt.Errorf("%s %s/%d: %w", f.Name, row.TourneySourceID, row.MatchNum, err) + } + } + var tag *string + if etag != "" { + tag = &etag + } + return qtx.SaveOngoingFile(ctx, db.SaveOngoingFileParams{ + File: f.Name, Etag: tag, Rows: int32(len(rows)), Changed: true, + }) + }) + return Result{Changed: true, Rows: len(rows), Skipped: skipped}, err +} + +// read parses the file with the season files' own reader, keeping main-draw +// matches at tour level. A row the reader rejects is counted and skipped, as +// ingest does, rather than failing the week. +func read(f File, r io.Reader) ([]db.InsertOngoingMatchParams, int, error) { + reader, err := ingest.NewReader(ingest.Source{Name: f.Name, Profile: "tml"}, r) + if err != nil { + return nil, 0, err + } + var out []db.InsertOngoingMatchParams + skipped := 0 + for { + m, err := reader.Next() + if errors.Is(err, io.EOF) { + return out, skipped, nil + } + if err != nil { + // A failed read repeats its error rather than reaching EOF. + if skipped++; skipped > maxSkipped { + return nil, skipped, fmt.Errorf("%s: unreadable: %w", f.Name, err) + } + slog.Warn("ongoing: row skipped", "error", err) + continue + } + if m.Tier("tour") != "tour" || m.IsQualifying() || ingest.IsTeamEvent(m.Level) { + continue + } + out = append(out, params(f, m)) + } +} + +func params(f File, m ingest.MatchRow) db.InsertOngoingMatchParams { + p := db.InsertOngoingMatchParams{ + File: f.Name, + Tour: f.Tour, + TourneySourceID: m.TourneyID, + TourneyName: m.TourneyName, + Level: m.Level, + Indoor: m.Indoor, + MatchNum: int32(m.MatchNum), + Round: m.Round, + PlayedOn: m.TourneyDate, + WinnerSourceID: m.Winner.SourceID, + WinnerName: m.Winner.Name, + WinnerSeed: seed(m.Winner.Seed), + LoserSourceID: m.Loser.SourceID, + LoserName: m.Loser.Name, + LoserSeed: seed(m.Loser.Seed), + } + if m.Surface != "" { + s := db.Surface(m.Surface) + p.Surface = &s + } + if m.Score != "" { + p.Score = &m.Score + } + return p +} + +func seed(s *int) *int16 { + if s == nil { + return nil + } + v := int16(*s) + return &v +} diff --git a/internal/ongoing/ongoing_integration_test.go b/internal/ongoing/ongoing_integration_test.go new file mode 100644 index 0000000..9db56b4 --- /dev/null +++ b/internal/ongoing/ongoing_integration_test.go @@ -0,0 +1,110 @@ +package ongoing + +import ( + "context" + "io" + "strconv" + "strings" + "testing" + + "github.com/sami0076/tennis-wiki/internal/db" + "github.com/sami0076/tennis-wiki/internal/ingest" + "github.com/sami0076/tennis-wiki/internal/testdb" +) + +func TestMain(m *testing.M) { testdb.Run(m) } + +const header = "tourney_id,tourney_name,surface,draw_size,tourney_level,indoor,tourney_date,match_num," + + "winner_id,winner_seed,winner_entry,winner_name,winner_hand,winner_ht,winner_ioc,winner_age," + + "winner_rank,winner_rank_points,loser_id,loser_seed,loser_entry,loser_name,loser_hand,loser_ht," + + "loser_ioc,loser_age,loser_rank,loser_rank_points,score,best_of,round,minutes," + + "w_ace,w_df,w_svpt,w_1stIn,w_1stWon,w_2ndWon,w_SvGms,w_bpSaved,w_bpFaced," + + "l_ace,l_df,l_svpt,l_1stIn,l_1stWon,l_2ndWon,l_SvGms,l_bpSaved,l_bpFaced" + +func row(tourney, name, level, date string, num int, winner, loser, round string) string { + return strings.Join([]string{ + tourney, name, "Hard", "32", level, "O", date, strconv.Itoa(num), + winner, "1", "", "Winner " + winner, "R", "", "", "", + "", "", loser, "", "", "Loser " + loser, "R", "", "", + "", "", "", "6-4 6-4", "3", round, "", + "", "", "", "", "", "", "", "", "", + "", "", "", "", "", "", "", "", "", + }, ",") +} + +// fake answers with a fixed body, or 304 when the validator matches. +type fake struct { + body, etag string +} + +func (f fake) OpenPathIfChanged(_ context.Context, _, _, validator string) (io.ReadCloser, string, error) { + if validator != "" && validator == f.etag { + return nil, validator, ingest.ErrUnchanged + } + return io.NopCloser(strings.NewReader(f.body)), f.etag, nil +} + +func TestRefreshReplacesTheWeekAndKeepsOnlyTheMainDraw(t *testing.T) { + ctx := context.Background() + pool, err := db.Open(ctx, db.Config{DSN: testdb.Start(t)}) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + if _, err := pool.Exec(ctx, "TRUNCATE ongoing_matches, ongoing_files"); err != nil { + t.Fatal(err) + } + file := Files[0] + + first := strings.Join([]string{ + header, + row("2026-4713", "Hangzhou", "250", "20260924", 1, "A1", "B1", "R32"), + row("2026-4713", "Hangzhou", "250", "20260925", 2, "A2", "B2", "R16"), + row("2026-4713", "Hangzhou", "250", "20260922", 3, "A3", "B3", "Q1"), + row("2026-9999", "Davis Cup", "D", "20260925", 1, "A4", "B4", "RR"), + }, "\n") + res, err := Refresh(ctx, pool, fake{body: first, etag: `"v1"`}, "", file) + if err != nil { + t.Fatalf("first refresh: %v", err) + } + if !res.Changed || res.Rows != 2 { + t.Errorf("first refresh = %+v, want 2 main-draw tour rows", res) + } + + rows, err := db.New(pool).ListOngoingMatches(ctx) + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 || rows[0].PlayedOn.Format("2006-01-02") != "2026-09-25" { + t.Fatalf("rows = %+v, want two, newest first by the day played", rows) + } + + // The source dropped one match and added one: the week is what the file + // says now, not the union of every fetch. + second := strings.Join([]string{ + header, + row("2026-4713", "Hangzhou", "250", "20260925", 2, "A2", "B2", "R16"), + row("2026-4713", "Hangzhou", "250", "20260926", 4, "A5", "B5", "QF"), + }, "\n") + if _, err := Refresh(ctx, pool, fake{body: second, etag: `"v2"`}, "", file); err != nil { + t.Fatalf("second refresh: %v", err) + } + rows, _ = db.New(pool).ListOngoingMatches(ctx) + if len(rows) != 2 || rows[0].Round != "QF" { + t.Fatalf("rows after replace = %+v, want R16 and QF only", rows) + } + + // Unchanged: nothing written, and the file still says when it last moved. + res, err = Refresh(ctx, pool, fake{body: second, etag: `"v2"`}, "", file) + if err != nil || res.Changed { + t.Fatalf("unchanged refresh = %+v, %v; want no change", res, err) + } + files, err := db.New(pool).ListOngoingFiles(ctx) + if err != nil || len(files) != 1 || files[0].Rows != 2 { + t.Fatalf("files = %+v, %v; want one file of 2 rows", files, err) + } + if !files[0].CheckedAt.Time.After(files[0].ChangedAt.Time) { + t.Errorf("checked %v, changed %v; want the check after the change", + files[0].CheckedAt.Time, files[0].ChangedAt.Time) + } +} From 950a201c0b69c406fc21fffe2b2c506a003a38ea Mon Sep 17 00:00:00 2001 From: Sami Saleh Date: Sat, 26 Sep 2026 12:56:19 -0400 Subject: [PATCH 03/13] cmd/ongoing: refresh both tours' ongoing files, built into the tools image --- cmd/ongoing/main.go | 60 ++++++++++++++++++++++++++++++++++ deploy/docker/tools.Dockerfile | 2 +- 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 cmd/ongoing/main.go diff --git a/cmd/ongoing/main.go b/cmd/ongoing/main.go new file mode 100644 index 0000000..922ffc5 --- /dev/null +++ b/cmd/ongoing/main.go @@ -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 +} diff --git a/deploy/docker/tools.Dockerfile b/deploy/docker/tools.Dockerfile index 1ffa00d..9ac6132 100644 --- a/deploy/docker/tools.Dockerfile +++ b/deploy/docker/tools.Dockerfile @@ -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 From 80b092efde6078654dd57068ae5e3090a3d4f871 Mon Sep 17 00:00:00 2001 From: Sami Saleh Date: Sat, 26 Sep 2026 12:57:08 -0400 Subject: [PATCH 04/13] ongoing: the WTA file's rounds in the codes every other file uses --- internal/ongoing/ongoing.go | 21 ++++++++++++++++++++- internal/ongoing/ongoing_test.go | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 internal/ongoing/ongoing_test.go diff --git a/internal/ongoing/ongoing.go b/internal/ongoing/ongoing.go index f3ccb01..700349b 100644 --- a/internal/ongoing/ongoing.go +++ b/internal/ongoing/ongoing.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "log/slog" + "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -134,7 +135,7 @@ func params(f File, m ingest.MatchRow) db.InsertOngoingMatchParams { Level: m.Level, Indoor: m.Indoor, MatchNum: int32(m.MatchNum), - Round: m.Round, + Round: roundCode(m.Round), PlayedOn: m.TourneyDate, WinnerSourceID: m.Winner.SourceID, WinnerName: m.Winner.Name, @@ -153,6 +154,24 @@ func params(f File, m ingest.MatchRow) db.InsertOngoingMatchParams { return p } +// roundCode puts the WTA ongoing file's words into the codes every other file +// uses: "Quarterfinals" there is QF everywhere else. +func roundCode(round string) string { + switch r := strings.ToLower(strings.TrimSpace(round)); { + case r == "final" || r == "finals": + return "F" + case strings.HasPrefix(r, "semi"): + return "SF" + case strings.HasPrefix(r, "quarter"): + return "QF" + case strings.HasPrefix(r, "round of "): + return "R" + strings.TrimPrefix(r, "round of ") + case strings.HasPrefix(r, "round robin"): + return "RR" + } + return strings.TrimSpace(round) +} + func seed(s *int) *int16 { if s == nil { return nil diff --git a/internal/ongoing/ongoing_test.go b/internal/ongoing/ongoing_test.go new file mode 100644 index 0000000..740af46 --- /dev/null +++ b/internal/ongoing/ongoing_test.go @@ -0,0 +1,19 @@ +package ongoing + +import "testing" + +func TestRoundCodeReadsTheWTAFilesWords(t *testing.T) { + for in, want := range map[string]string{ + "Quarterfinals": "QF", + "Semifinals": "SF", + "Final": "F", + "Round of 16": "R16", + "R32": "R32", + "QF": "QF", + "RR": "RR", + } { + if got := roundCode(in); got != want { + t.Errorf("roundCode(%q) = %q, want %q", in, got, want) + } + } +} From c049fc6ecc72eceb583eb1c70065a0e790807f6c Mon Sep 17 00:00:00 2001 From: Sami Saleh Date: Sat, 26 Sep 2026 12:59:18 -0400 Subject: [PATCH 05/13] api: /this-week, events in progress from results so far, uncached --- internal/httpapi/router.go | 8 ++ internal/httpapi/thisweek.go | 179 +++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 internal/httpapi/thisweek.go diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index 0ef3a2e..bcd9126 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -61,6 +61,14 @@ func (a *API) Router() http.Handler { public.Use(Cached(a.Cache)) a.routes(public) }) + + // Uncached: the hourly ongoing refresh would otherwise have to flush + // the whole cache, and a few dozen rows cost nothing to read. + v1.Group(func(fresh chi.Router) { + fresh.Use(NewRateLimiter(a.Config.RateLimitPerMin).Middleware(a.Config.TrustProxy)) + fresh.Use(ETag) + fresh.Get("/this-week", a.handleThisWeek) + }) }) return r } diff --git a/internal/httpapi/thisweek.go b/internal/httpapi/thisweek.go new file mode 100644 index 0000000..72f7af3 --- /dev/null +++ b/internal/httpapi/thisweek.go @@ -0,0 +1,179 @@ +package httpapi + +import ( + "net/http" + "sort" + "time" + + "github.com/sami0076/tennis-wiki/internal/db" +) + +// ThisWeek is the events being played now, from results so far. Provisional: +// the source's ongoing files, fetched hourly and kept apart from matches until +// the weekly load brings a finished event in (ADR-0016). Not live scores. +type ThisWeek struct { + // CheckedAt is when the files were last asked for, ChangedAt when they + // last moved. Both null before the first fetch. + CheckedAt *string `json:"checked_at"` + ChangedAt *string `json:"changed_at"` + Events []WeekEvent `json:"events"` +} + +// WeekEvent is one event and how far it has got. +type WeekEvent struct { + Tour string `json:"tour"` + Name string `json:"name"` + Level string `json:"level"` + Surface *string `json:"surface"` + // Round is the furthest round with a result in; "F" means it is decided. + Round string `json:"round"` + Matches int `json:"matches"` + LastPlayed string `json:"last_played"` + Champion *WeekPlayer `json:"champion"` + Latest []WeekResult `json:"latest"` +} + +// WeekResult is one finished match. +type WeekResult struct { + // Date is the source's: the day played in the ATP file, the week's start + // in the WTA file. + Date string `json:"date"` + Round string `json:"round"` + Winner WeekPlayer `json:"winner"` + Loser WeekPlayer `json:"loser"` + Score *string `json:"score"` +} + +// WeekPlayer links to the player where the database knows them. +type WeekPlayer struct { + Name string `json:"name"` + Slug *string `json:"slug"` + Seed *int16 `json:"seed"` +} + +const ( + weekLatest = 4 + // An event the source has not dropped a week after its last result is + // over, whatever the file still says. + weekStale = 7 * 24 * time.Hour +) + +// weekRoundRank ranks how deep a round is; a round robin sits before the knockout +// rounds that follow it. +var weekRoundRank = map[string]int{ + "R128": 1, "R64": 2, "R32": 3, "R16": 4, "RR": 5, "QF": 6, "SF": 7, "F": 8, +} + +// levelOrder puts the biggest events first. +var levelOrder = map[string]int{"G": 0, "F": 1, "M": 2, "1000": 2, "PM": 2, "500": 3, "P": 3} + +func (a *API) handleThisWeek(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + rows, err := a.Queries.ListOngoingMatches(ctx) + if err != nil { + Internal(w, r, err) + return + } + files, err := a.Queries.ListOngoingFiles(ctx) + if err != nil { + Internal(w, r, err) + return + } + out := buildThisWeek(rows, time.Now()) + for _, f := range files { + out.CheckedAt = laterStamp(out.CheckedAt, f.CheckedAt.Time) + out.ChangedAt = laterStamp(out.ChangedAt, f.ChangedAt.Time) + } + writeJSON(w, r, http.StatusOK, out) +} + +func laterStamp(current *string, t time.Time) *string { + s := t.UTC().Format(time.RFC3339) + if current == nil || s > *current { + return &s + } + return current +} + +func weekPlayer(name string, slug string, seed *int16) WeekPlayer { + p := WeekPlayer{Name: name, Seed: seed} + if slug != "" { + p.Slug = &slug + } + return p +} + +func buildThisWeek(rows []db.ListOngoingMatchesRow, now time.Time) ThisWeek { + type key struct{ tour, id string } + events := map[key]*WeekEvent{} + results := map[key][]db.ListOngoingMatchesRow{} + var order []key + + for _, row := range rows { + k := key{string(row.Tour), row.TourneySourceID} + if _, ok := events[k]; !ok { + e := &WeekEvent{Tour: string(row.Tour), Name: row.TourneyName, Level: row.Level} + if row.Surface != nil { + s := string(*row.Surface) + e.Surface = &s + } + events[k] = e + order = append(order, k) + } + results[k] = append(results[k], row) + } + + out := ThisWeek{Events: []WeekEvent{}} + for _, k := range order { + e, played := events[k], results[k] + sort.SliceStable(played, func(i, j int) bool { + if !played[i].PlayedOn.Equal(played[j].PlayedOn) { + return played[i].PlayedOn.After(played[j].PlayedOn) + } + return weekRoundRank[played[i].Round] > weekRoundRank[played[j].Round] + }) + if now.Sub(played[0].PlayedOn) > weekStale { + continue + } + e.Matches = len(played) + e.LastPlayed = played[0].PlayedOn.Format(time.DateOnly) + for _, m := range played { + if weekRoundRank[m.Round] > weekRoundRank[e.Round] { + e.Round = m.Round + } + if m.Round == "F" { + champion := weekPlayer(m.WinnerName, m.WinnerSlug, m.WinnerSeed) + e.Champion = &champion + } + } + for _, m := range played[:min(weekLatest, len(played))] { + e.Latest = append(e.Latest, WeekResult{ + Date: m.PlayedOn.Format(time.DateOnly), + Round: m.Round, + Winner: weekPlayer(m.WinnerName, m.WinnerSlug, m.WinnerSeed), + Loser: weekPlayer(m.LoserName, m.LoserSlug, m.LoserSeed), + Score: m.Score, + }) + } + out.Events = append(out.Events, *e) + } + + sort.SliceStable(out.Events, func(i, j int) bool { + li, lj := levelRank(out.Events[i].Level), levelRank(out.Events[j].Level) + if li != lj { + return li < lj + } + if out.Events[i].Tour != out.Events[j].Tour { + return out.Events[i].Tour < out.Events[j].Tour + } + return out.Events[i].Name < out.Events[j].Name + }) + return out +} + +func levelRank(level string) int { + if r, ok := levelOrder[level]; ok { + return r + } + return 4 +} From e7935a5b55d20871c0535b8f80158d6f8beaba02 Mon Sep 17 00:00:00 2001 From: Sami Saleh Date: Sat, 26 Sep 2026 12:59:18 -0400 Subject: [PATCH 06/13] test: this week groups, ranks and links its events --- internal/httpapi/thisweek_integration_test.go | 56 +++++++++++++++++++ internal/httpapi/thisweek_test.go | 50 +++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 internal/httpapi/thisweek_integration_test.go create mode 100644 internal/httpapi/thisweek_test.go diff --git a/internal/httpapi/thisweek_integration_test.go b/internal/httpapi/thisweek_integration_test.go new file mode 100644 index 0000000..693213e --- /dev/null +++ b/internal/httpapi/thisweek_integration_test.go @@ -0,0 +1,56 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "testing" +) + +func decodeThisWeek(t *testing.T, res *http.Response) ThisWeek { + t.Helper() + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", res.StatusCode) + } + if res.Header.Get("X-Cache") != "" { + t.Errorf("X-Cache = %q, want the endpoint outside the cache", res.Header.Get("X-Cache")) + } + var w ThisWeek + if err := json.NewDecoder(res.Body).Decode(&w); err != nil { + t.Fatalf("decode this week: %v", err) + } + return w +} + +func TestThisWeekLinksWhoItKnowsAndSaysWhenItLooked(t *testing.T) { + f := newAPIFixture(t) + + empty := decodeThisWeek(t, f.get("/api/v1/this-week")) + if empty.Events == nil || len(empty.Events) != 0 || empty.CheckedAt != nil { + t.Errorf("before a fetch = %+v, want no events and no time", empty) + } + + f.player("itg-week-known", "Itg Weekknown", "atp") + if _, err := f.tx.Exec(f.ctx, ` + UPDATE players SET source_id = 'WK01' WHERE slug = 'itg-week-known'; + INSERT INTO ongoing_matches (file, tour, tourney_source_id, tourney_name, level, match_num, + round, played_on, winner_source_id, winner_name, + loser_source_id, loser_name) + VALUES ('ongoing_tourneys.csv', 'atp', '2026-1', 'Itg Open', '250', 1, 'R32', current_date, + 'WK01', 'Itg Weekknown', 'ZZ99', 'Nobody Known'); + INSERT INTO ongoing_files (file, checked_at, changed_at, rows) + VALUES ('ongoing_tourneys.csv', now(), now(), 1);`); err != nil { + t.Fatalf("seed: %v", err) + } + + week := decodeThisWeek(t, f.get("/api/v1/this-week")) + if len(week.Events) != 1 || week.CheckedAt == nil { + t.Fatalf("week = %+v, want one event and a checked time", week) + } + result := week.Events[0].Latest[0] + if result.Winner.Slug == nil || *result.Winner.Slug != "itg-week-known" { + t.Errorf("winner slug = %v, want the player with that source id", result.Winner.Slug) + } + if result.Loser.Slug != nil { + t.Errorf("loser slug = %v, want none for an id the database does not know", *result.Loser.Slug) + } +} diff --git a/internal/httpapi/thisweek_test.go b/internal/httpapi/thisweek_test.go new file mode 100644 index 0000000..38b3288 --- /dev/null +++ b/internal/httpapi/thisweek_test.go @@ -0,0 +1,50 @@ +package httpapi + +import ( + "testing" + "time" + + "github.com/sami0076/tennis-wiki/internal/db" +) + +func weekRow(tour db.Tour, id, name, level, day, round, winner, slug string) db.ListOngoingMatchesRow { + on, _ := time.Parse(time.DateOnly, day) + return db.ListOngoingMatchesRow{ + Tour: tour, TourneySourceID: id, TourneyName: name, Level: level, + Round: round, PlayedOn: on, WinnerName: winner, WinnerSlug: slug, LoserName: "Foil", + } +} + +func TestThisWeekGroupsEventsBiggestFirst(t *testing.T) { + now, _ := time.Parse(time.DateOnly, "2026-09-27") + rows := []db.ListOngoingMatchesRow{ + weekRow(db.TourAtp, "2026-1", "Chengdu", "250", "2026-09-24", "R32", "A", "a"), + weekRow(db.TourAtp, "2026-1", "Chengdu", "250", "2026-09-26", "F", "Champ", "champ"), + weekRow(db.TourAtp, "2026-1", "Chengdu", "250", "2026-09-26", "SF", "B", ""), + weekRow(db.TourWta, "2026-2", "Beijing", "1000", "2026-09-21", "QF", "C", "c"), + weekRow(db.TourAtp, "2026-3", "Old Open", "250", "2026-09-10", "F", "D", "d"), + } + + week := buildThisWeek(rows, now) + + if len(week.Events) != 2 { + t.Fatalf("events = %d, want 2 with the stale one dropped", len(week.Events)) + } + if week.Events[0].Name != "Beijing" { + t.Errorf("first event = %s, want the 1000 ahead of the 250", week.Events[0].Name) + } + chengdu := week.Events[1] + if chengdu.Round != "F" || chengdu.Matches != 3 { + t.Errorf("Chengdu round %s over %d matches, want F over 3", chengdu.Round, chengdu.Matches) + } + if chengdu.Champion == nil || chengdu.Champion.Slug == nil || *chengdu.Champion.Slug != "champ" { + t.Errorf("champion = %+v, want the final's winner", chengdu.Champion) + } + if chengdu.Latest[0].Round != "F" || chengdu.Latest[1].Round != "SF" { + t.Errorf("latest = %s, %s; want the final first, then the semi played the same day", + chengdu.Latest[0].Round, chengdu.Latest[1].Round) + } + if chengdu.Latest[1].Winner.Slug != nil { + t.Errorf("unlinked winner has slug %v, want none", *chengdu.Latest[1].Winner.Slug) + } +} From 26b2f393f888588a8fe8bd8b51b8670266f5614d Mon Sep 17 00:00:00 2001 From: Sami Saleh Date: Sat, 26 Sep 2026 13:01:56 -0400 Subject: [PATCH 07/13] web: format how long ago a figure was fetched --- web/src/lib/format.test.ts | 12 +++++++++++- web/src/lib/format.ts | 10 ++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/web/src/lib/format.test.ts b/web/src/lib/format.test.ts index 6ea47e8..356bc20 100644 --- a/web/src/lib/format.test.ts +++ b/web/src/lib/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { ageOn, careerSpan, formatHand, formatOrdinal, formatScore } from './format' +import { ageOn, careerSpan, formatAgo, formatHand, formatOrdinal, formatScore } from './format' describe('formatScore', () => { it('types scores as the sheet does, with hyphens', () => { @@ -66,3 +66,13 @@ describe('formatOrdinal', () => { ]) }) }) + +describe('formatAgo', () => { + it('reads minutes, hours and days', () => { + const now = new Date('2026-09-26T16:00:00Z') + expect(formatAgo('2026-09-26T15:59:50Z', now)).toBe('just now') + expect(formatAgo('2026-09-26T15:59:00Z', now)).toBe('1 minute ago') + expect(formatAgo('2026-09-26T13:00:00Z', now)).toBe('3 hours ago') + expect(formatAgo('2026-09-23T16:00:00Z', now)).toBe('3 days ago') + }) +}) diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 02e1ff9..3d6342a 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -28,6 +28,16 @@ export function formatPercent(value: number, places = 1): string { return `${value.toFixed(places)}%` } +/** "40 minutes ago", "3 hours ago": how old a fetched figure is. */ +export function formatAgo(iso: string, now: Date): string { + const minutes = Math.max(0, Math.round((now.getTime() - Date.parse(iso)) / 60000)) + if (minutes < 1) return 'just now' + if (minutes < 60) return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago` + const hours = Math.round(minutes / 60) + if (hours < 48) return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago` + return `${Math.round(hours / 24)} days ago` +} + /** 1st, 2nd, 3rd, 11th, 22nd: how a percentile is spoken. */ export function formatOrdinal(n: number): string { const teen = n % 100 >= 11 && n % 100 <= 13 From 3b70610aaf07ecf6c1d851005aa9b9f49968654c Mon Sep 17 00:00:00 2001 From: Sami Saleh Date: Sat, 26 Sep 2026 13:01:56 -0400 Subject: [PATCH 08/13] web: this week types and client --- tygo.yaml | 1 + web/src/api/endpoints.ts | 6 +++++ web/src/api/types.gen.ts | 57 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/tygo.yaml b/tygo.yaml index 895c65e..99fa7d0 100644 --- a/tygo.yaml +++ b/tygo.yaml @@ -26,6 +26,7 @@ packages: - "tournaments.go" - "seasons.go" - "recent.go" + - "thisweek.go" - "leaders.go" - "problem.go" - "cursor.go" diff --git a/web/src/api/endpoints.ts b/web/src/api/endpoints.ts index ea45886..f5920bf 100644 --- a/web/src/api/endpoints.ts +++ b/web/src/api/endpoints.ts @@ -24,6 +24,7 @@ import type { RecentFinals, SeasonEventsResponse, SeasonsResponse, + ThisWeek, Trajectories, } from './types.gen' @@ -112,6 +113,11 @@ export function getPlayerPercentiles(slug: string, signal?: AbortSignal): Promis return request(`/players/${encodeURIComponent(slug)}/percentiles`, {}, signal) } +/** Events in progress, from results so far. Provisional and hourly, not live. */ +export function getThisWeek(signal?: AbortSignal): Promise { + return request('/this-week', {}, signal) +} + export function getPlayerRankings(slug: string, signal?: AbortSignal): Promise { return request(`/players/${encodeURIComponent(slug)}/rankings`, {}, signal) } diff --git a/web/src/api/types.gen.ts b/web/src/api/types.gen.ts index 28dedac..5da548b 100644 --- a/web/src/api/types.gen.ts +++ b/web/src/api/types.gen.ts @@ -1548,6 +1548,63 @@ export interface PlayerSeason { dominance: number /* float64 */ | null; } +////////// +// source: thisweek.go + +/** + * ThisWeek is the events being played now, from results so far. Provisional: + * the source's ongoing files, fetched hourly and kept apart from matches until + * the weekly load brings a finished event in (ADR-0016). Not live scores. + */ +export interface ThisWeek { + /** + * CheckedAt is when the files were last asked for, ChangedAt when they + * last moved. Both null before the first fetch. + */ + checked_at: string | null; + changed_at: string | null; + events: WeekEvent[]; +} +/** + * WeekEvent is one event and how far it has got. + */ +export interface WeekEvent { + tour: string; + name: string; + level: string; + surface: string | null; + /** + * Round is the furthest round with a result in; "F" means it is decided. + */ + round: string; + matches: number /* int */; + last_played: string; + champion: WeekPlayer | null; + latest: WeekResult[]; +} +/** + * WeekResult is one finished match. + */ +export interface WeekResult { + /** + * Date is the source's: the day played in the ATP file, the week's start + * in the WTA file. + */ + date: string; + round: string; + winner: WeekPlayer; + loser: WeekPlayer; + score: string | null; +} +/** + * WeekPlayer links to the player where the database knows them. + */ +export interface WeekPlayer { + name: string; + slug: string | null; + seed: number /* int16 */ | null; +} + ////////// // source: tournaments.go From 7d9d79a7d80001eb1355922e79c77d784aa495c0 Mon Sep 17 00:00:00 2001 From: Sami Saleh Date: Sat, 26 Sep 2026 13:01:57 -0400 Subject: [PATCH 09/13] web: ThisWeek, events in progress as the results so far --- web/src/components/ThisWeek.module.css | 110 +++++++++++++++++++++++++ web/src/components/ThisWeek.test.tsx | 72 ++++++++++++++++ web/src/components/ThisWeek.tsx | 93 +++++++++++++++++++++ 3 files changed, 275 insertions(+) create mode 100644 web/src/components/ThisWeek.module.css create mode 100644 web/src/components/ThisWeek.test.tsx create mode 100644 web/src/components/ThisWeek.tsx diff --git a/web/src/components/ThisWeek.module.css b/web/src/components/ThisWeek.module.css new file mode 100644 index 0000000..7c6c70a --- /dev/null +++ b/web/src/components/ThisWeek.module.css @@ -0,0 +1,110 @@ +.quiet { + max-width: 72ch; + margin: 0; + font-size: var(--t-12); + line-height: 1.55; + color: var(--pencil); +} + +.events { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); + gap: var(--s-3); + list-style: none; + margin: var(--s-4) 0 0; + padding: 0; +} + +/* One tile per event: what it is, how far it has got, its latest matches. */ +.event { + display: grid; + align-content: start; + gap: var(--s-1); + padding: var(--s-4); + background: var(--card); + border: 1px solid var(--card-line); + border-radius: var(--radius); + font-size: var(--t-13); +} + +.head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-2); +} + +.name { + font-size: var(--t-16); + font-weight: 700; + color: var(--ink); +} + +.level { + font-size: var(--t-11); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--pencil); +} + +.stage { + margin-top: var(--s-1); + font-weight: 600; + color: var(--ink); +} + +.results { + display: grid; + gap: var(--s-2); + margin: var(--s-3) 0 0; + padding: var(--s-3) 0 0; + border-top: 1px solid var(--card-line); + list-style: none; +} + +.result { + display: grid; + grid-template-columns: 3.5ch minmax(0, 1fr); + gap: 0 var(--s-2); + line-height: 1.4; +} + +.round { + font-size: var(--t-11); + font-weight: 600; + color: var(--pencil); + padding-top: 2px; +} + +.match { + color: var(--ink); +} + +.score { + grid-column: 2; + font-size: var(--t-12); + font-variant-numeric: tabular-nums; + color: var(--pencil); +} + +.seed { + font-size: var(--t-11); + color: var(--pencil-mid); +} + +.player { + color: inherit; + text-decoration: none; +} + +.player:hover { + text-decoration: underline; +} + +@media (prefers-reduced-motion: no-preference) { + .event { + animation: dp-rise 600ms var(--ease-out) both; + animation-delay: calc(var(--i) * 80ms + 100ms); + } +} diff --git a/web/src/components/ThisWeek.test.tsx b/web/src/components/ThisWeek.test.tsx new file mode 100644 index 0000000..81adb31 --- /dev/null +++ b/web/src/components/ThisWeek.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it } from 'vitest' + +import type { ThisWeek as ThisWeekData } from '../api/client' +import { ThisWeek } from './ThisWeek' + +const week: ThisWeekData = { + checked_at: '2026-09-26T16:00:00Z', + changed_at: '2026-09-26T15:20:00Z', + events: [ + { + tour: 'atp', + name: 'Chengdu', + level: '250', + surface: 'hard', + round: 'QF', + matches: 24, + last_played: '2026-09-26', + champion: null, + latest: [ + { + date: '2026-09-26', + round: 'QF', + winner: { name: 'Lloyd Harris', slug: 'lloyd-harris', seed: null }, + loser: { name: 'Unknown Qualifier', slug: null, seed: 3 }, + score: '6-4 7-6(5)', + }, + ], + }, + ], +} + +const now = new Date('2026-09-26T16:00:00Z') + +function renderWeek(data: ThisWeekData) { + return render( + + + , + ) +} + +describe('ThisWeek', () => { + it('says it is results so far, and how old', () => { + renderWeek(week) + expect(screen.getByText(/Results so far, not live scores: updated 40 minutes ago/)).toBeInTheDocument() + }) + + it('writes how far each event has got and links the players it knows', () => { + renderWeek(week) + expect(screen.getByText('Quarterfinals under way')).toBeInTheDocument() + expect(screen.getByText('ATP 250')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Lloyd Harris' })).toHaveAttribute('href', '/players/lloyd-harris') + expect(screen.queryByRole('link', { name: 'Unknown Qualifier' })).toBeNull() + }) + + it('names the champion once the final is in', () => { + const decided = week.events[0]! + renderWeek({ + ...week, + events: [{ ...decided, round: 'F', champion: { name: 'Lloyd Harris', slug: 'lloyd-harris', seed: null } }], + }) + expect(screen.getByText(/Champion/)).toBeInTheDocument() + }) + + it('says so when nothing is in yet', () => { + renderWeek({ ...week, events: [] }) + expect(screen.getByText(/No tour-level event has a result in yet/)).toBeInTheDocument() + }) +}) + diff --git a/web/src/components/ThisWeek.tsx b/web/src/components/ThisWeek.tsx new file mode 100644 index 0000000..3957aeb --- /dev/null +++ b/web/src/components/ThisWeek.tsx @@ -0,0 +1,93 @@ +import type { CSSProperties } from 'react' +import { Link } from 'react-router-dom' +import type { ThisWeek as ThisWeekData, WeekEvent, WeekPlayer } from '../api/client' +import type { Resource } from '../api/useResource' +import { ROUND_WORDS } from '../lib/bracket' +import { formatAgo, formatScore } from '../lib/format' +import { levelLabel } from '../lib/tier' +import { Skeleton } from './Skeleton' +import { SurfaceBadge } from './SurfaceBadge' +import styles from './ThisWeek.module.css' + +function Name({ player }: { player: WeekPlayer }) { + const seed = player.seed === null ? null : ({player.seed}) + return player.slug === null ? ( + + {player.name} {seed} + + ) : ( + + + {player.name} + {' '} + {seed} + + ) +} + +function Stage({ event }: { event: WeekEvent }) { + if (event.champion !== null) { + return ( + + Champion + + ) + } + return {ROUND_WORDS[event.round] ?? event.round} under way +} + +/** + * ThisWeek is the events being played now, as the results so far: one tile + * per event with how far it has got and its latest matches. Provisional and + * refreshed hourly, which the heading says rather than implying it is live. + */ +export function ThisWeek({ week, now = new Date() }: { week: Resource; now?: Date }) { + if (week.state === 'loading') return + if (week.state === 'error') { + return

This week's results could not be loaded: {week.error.message}

+ } + const { events, changed_at: changed } = week.data + if (events.length === 0) { + return

No tour-level event has a result in yet this week.

+ } + + return ( + <> +

+ Results so far, not live scores + {changed === null ? '' : `: updated ${formatAgo(changed, now)}`}. Ratings take them in once + each event is over. +

+
    + {events.map((event, index) => ( +
  1. +
    + {event.name} + +
    + {levelLabel(event.level, 'tour', event.tour)} + +
      + {event.latest.slice(0, 3).map((result) => ( +
    • + {result.round} + + d. + + {formatScore(result.score)} +
    • + ))} +
    +
  2. + ))} +
+ + ) +} From 917839de6b00a7a9387c0f065e7eb9e91f5fd49f Mon Sep 17 00:00:00 2001 From: Sami Saleh Date: Sat, 26 Sep 2026 13:03:57 -0400 Subject: [PATCH 10/13] web: home shows this week under the ticker, and runs its latest results --- web/src/components/index.ts | 1 + web/src/routes/Home.test.tsx | 36 ++++++++++++++++++++++++++++++++++++ web/src/routes/Home.tsx | 36 +++++++++++++++++++++++++++++++++--- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/web/src/components/index.ts b/web/src/components/index.ts index c4befe1..b71c487 100644 --- a/web/src/components/index.ts +++ b/web/src/components/index.ts @@ -14,6 +14,7 @@ export { Playback } from './Playback' export { PlayerSummary } from './PlayerSummary' export { RankDelta } from './RankDelta' export { RecentFinals } from './RecentFinals' +export { ThisWeek } from './ThisWeek' export { RivalryStrip } from './RivalryStrip' export { MatchList, RoundList, RoundStepper } from './RoundList' export { Score } from './Score' diff --git a/web/src/routes/Home.test.tsx b/web/src/routes/Home.test.tsx index 9b4cda5..504f015 100644 --- a/web/src/routes/Home.test.tsx +++ b/web/src/routes/Home.test.tsx @@ -7,6 +7,7 @@ import type { HeadToHead, RankingPage, RecentFinals, + ThisWeek, Trajectories, } from '../api/client' import { Home } from './Home' @@ -178,6 +179,32 @@ const recent: RecentFinals = { without: ['wta'], } +const thisWeek: ThisWeek = { + checked_at: '2026-09-26T16:00:00Z', + changed_at: '2026-09-26T15:00:00Z', + events: [ + { + tour: 'atp', + name: 'Chengdu', + level: '250', + surface: 'hard', + round: 'R16', + matches: 20, + last_played: '2026-09-26', + champion: null, + latest: [ + { + date: '2026-09-26', + round: 'R16', + winner: { name: 'Lloyd Harris', slug: 'lloyd-harris', seed: null }, + loser: { name: 'Valentin Vacherot', slug: 'valentin-vacherot', seed: 1 }, + score: '6-4 7-6(5)', + }, + ], + }, + ], +} + const rivalry = { players: [ { slug: 'jannik-sinner', name: 'Jannik Sinner', tour: 'atp', country: 'ITA' }, @@ -195,6 +222,7 @@ function stub(lines: Trajectories = trajectories, finals: RecentFinals = recent) else if (path.endsWith('/rankings')) body = rankings else if (path.endsWith('/simulate/draw')) body = draw else if (path.endsWith('/recent')) body = finals + else if (path.endsWith('/this-week')) body = thisWeek else if (path.includes('/h2h/')) body = rivalry return Promise.resolve( new Response(JSON.stringify(body), { @@ -313,4 +341,12 @@ describe('Home', () => { expect(await screen.findByText(/Nothing began in the week of 2026-12-07; the last week with a final was 2026-08-31 to 2026-09-06/)).toBeInTheDocument() }) + it("shows this week's events as results so far, and runs the latest past in the ticker", async () => { + stub() + renderHome() + expect(await screen.findByText('Round of 16 under way')).toBeInTheDocument() + expect(screen.getByText(/Results so far, not live scores/)).toBeInTheDocument() + expect(screen.getAllByText(/Chengdu R16 · Harris d\. Vacherot/).length).toBeGreaterThan(0) + }) + }) diff --git a/web/src/routes/Home.tsx b/web/src/routes/Home.tsx index 6c0c714..05a3e60 100644 --- a/web/src/routes/Home.tsx +++ b/web/src/routes/Home.tsx @@ -5,6 +5,7 @@ import { getHeadToHead, getRankings, getRecentFinals, + getThisWeek, getTrajectories, simulateDraw, } from '../api/endpoints' @@ -28,6 +29,7 @@ import { StatTable, SurfaceDot, Ticker, + ThisWeek, TourFilter, type Column, type TickerItem, @@ -41,6 +43,7 @@ import type { HeadToHead, RankingPage, RankingRow, + ThisWeek as ThisWeekData, Trajectories, } from '../api/client' import { FEATURED_DRAW, roundsReached } from '../lib/featuredDraw' @@ -92,6 +95,7 @@ export function Home() { ) const draw = useResource((signal) => simulateDraw(FEATURED_DRAW, signal), []) const recent = useResource((signal) => getRecentFinals(signal), []) + const week = useResource((signal) => getThisWeek(signal), []) useJsonLd('website', website()) const top = leaders.state === 'ready' ? leaders.data.data : [] @@ -109,7 +113,14 @@ export function Home() { - + + + + +
@@ -179,7 +190,26 @@ export function Home() { ) } -function tickerItems(top: ReadonlyArray, recent: Resource): TickerItem[] { +function tickerItems( + top: ReadonlyArray, + recent: Resource, + week: Resource, +): TickerItem[] { + // One per event, its latest result: the ticker is a glance, the card the list. + const current: TickerItem[] = + week.state === 'ready' + ? week.data.events.flatMap((event) => { + const last = event.latest[0] + if (last === undefined) return [] + return [ + { + key: `week-${event.tour}-${event.name}`, + label: `${event.name} ${last.round} · ${surname(last.winner.name)} d. ${surname(last.loser.name)}`, + value: last.score ?? '', + }, + ] + }) + : [] const leaders: TickerItem[] = top.map((row) => ({ key: `elo-${row.slug}`, label: `#${row.position} ${surname(row.name)}`, @@ -193,7 +223,7 @@ function tickerItems(top: ReadonlyArray, recent: Resource }) { From bb6b3a6a714591bd8c0fe6e98ec626fcdf18147c Mon Sep 17 00:00:00 2001 From: Sami Saleh Date: Sat, 26 Sep 2026 13:05:59 -0400 Subject: [PATCH 11/13] deploy: an hourly CronJob for the ongoing files, suspended until its image exists --- deploy/k8s/base/65-ongoing-hourly.yaml | 67 ++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 deploy/k8s/base/65-ongoing-hourly.yaml diff --git a/deploy/k8s/base/65-ongoing-hourly.yaml b/deploy/k8s/base/65-ongoing-hourly.yaml new file mode 100644 index 0000000..36109b6 --- /dev/null +++ b/deploy/k8s/base/65-ongoing-hourly.yaml @@ -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"] From 709c3761e09ac4ed3fb6c0df9b8c13abddb1e72b Mon Sep 17 00:00:00 2001 From: Sami Saleh Date: Sat, 26 Sep 2026 13:06:37 -0400 Subject: [PATCH 12/13] docs: ADR-0016, this week from the ongoing files --- DESIGN.md | 2 +- PRODUCT.md | 3 +- README.md | 8 +++++ docs/decisions/0016-this-week.md | 53 ++++++++++++++++++++++++++++++++ docs/deployment.md | 10 ++++++ 5 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 docs/decisions/0016-this-week.md diff --git a/DESIGN.md b/DESIGN.md index 9a2f3fa..242a519 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/PRODUCT.md b/PRODUCT.md index 7a105a9..b06d9a9 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -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 diff --git a/README.md b/README.md index 7126a2e..1a3107b 100644 --- a/README.md +++ b/README.md @@ -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= @@ -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 diff --git a/docs/decisions/0016-this-week.md b/docs/decisions/0016-this-week.md new file mode 100644 index 0000000..957c88f --- /dev/null +++ b/docs/decisions/0016-this-week.md @@ -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. diff --git a/docs/deployment.md b/docs/deployment.md index 929bf2c..d95f350 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 From 5187958233cb53669f6fa71b6662bca5057d820b Mon Sep 17 00:00:00 2001 From: Sami Saleh Date: Sat, 26 Sep 2026 13:46:07 -0400 Subject: [PATCH 13/13] ongoing: log a file that fails to close, as ingest does --- internal/ongoing/ongoing.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/ongoing/ongoing.go b/internal/ongoing/ongoing.go index 700349b..d1a0919 100644 --- a/internal/ongoing/ongoing.go +++ b/internal/ongoing/ongoing.go @@ -68,7 +68,11 @@ func Refresh(ctx context.Context, pool *pgxpool.Pool, open Opener, baseURL strin if err != nil { return Result{}, err } - defer func() { _ = body.Close() }() + defer func() { + if cerr := body.Close(); cerr != nil { + slog.WarnContext(ctx, "ongoing: closing file", "file", f.Name, "error", cerr) + } + }() rows, skipped, err := read(f, body) if err != nil {