From 413b505bf18a414f191baad3139f01a3f44207b8 Mon Sep 17 00:00:00 2001 From: Paola De Bartolo Date: Wed, 10 Jun 2026 16:58:39 -0300 Subject: [PATCH 1/4] feat!: migrate to Vaadin 25 with a live 2026 data source Upgrade the 2018-era demo (Vaadin 10, Spring Boot 2, Java 8, WAR, Polymer) to a modern stack, pointed at a free live data source for the 2026 tournament. Platform: Vaadin 25.1.7, Spring Boot 4.0.6, Java 21, executable jar; javax.* -> jakarta.*; drop WAR/Heroku; add vaadin-dev; ehcache 3 -> Caffeine (120s TTL). Frontend: remove Polymer/Bower/@HtmlImport/MarkedElement/shared-styles; plain-CSS theming via @StyleSheet(Lumo) (@Theme deprecated in 25); app shell on FixtureApp (AppShellConfigurator); AppLayout add-on 6.2.0-SNAPSHOT; PaperCard -> Vaadin Card; Label -> NativeLabel. Data: new worldcup26.ir client (RestClient) + TeamCatalog/StadiumCatalog replacing the dead worldcup.sfg.io layer; services map into the existing view DTOs (presenters unchanged); data-driven stage labels; match detail trimmed to available data (venue, kickoff, scorers). Docs: rename UI to "Global Football 2026 Stats" with trademark-safe naming and a non-affiliation disclaimer; rewrite README; add MIGRATION_PLAN.md. BREAKING CHANGE: requires Java 21 and Spring Boot 4; WAR packaging and the worldcup.sfg.io data source are removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 16 +- MIGRATION_PLAN.md | 202 +++++ README.md | 55 +- pom.xml | 166 ++-- .../com/flowingcode/fixture/FixtureApp.java | 29 +- .../fixture/repository/GroupRepository.java | 11 - .../repository/GroupRepositoryImpl.java | 24 - .../repository/MatchRepositoryImpl.java | 68 -- .../fixture/repository/MatchesRepository.java | 21 - .../fixture/repository/SFGRestTemplate.java | 21 - .../fixture/repository/TeamRepository.java | 11 - .../repository/TeamRepositoryImpl.java | 22 - .../repository/domain/GroupDetail.java | 27 - .../repository/domain/GroupWrapper.java | 70 -- .../fixture/repository/domain/Match.java | 195 ----- .../fixture/repository/domain/Team.java | 22 - .../repository/worldcup/StadiumCatalog.java | 50 ++ .../repository/worldcup/TeamCatalog.java | 71 ++ .../fixture/repository/worldcup/Wc26Dtos.java | 51 ++ .../repository/worldcup/WorldCupClient.java | 60 ++ .../fixture/service/FlagUtils.java | 22 +- .../fixture/service/GroupServiceImpl.java | 73 +- .../fixture/service/MatchServiceImpl.java | 341 ++++++--- .../fixture/view/component/GroupView.java | 55 +- .../fixture/view/component/MarkedElement.java | 22 - .../view/component/MatchResultComponent.java | 38 +- .../component/RouteNotFoundErrorHandler.java | 2 +- .../view/component/RuntimeErrorHandler.java | 2 +- .../view/component/TitleComponent.java | 6 +- .../fixture/view/model/EventGridDto.java | 31 - .../view/presenter/WelcomePresenter.java | 3 +- .../fixture/view/screen/AboutScreen.java | 48 +- .../fixture/view/screen/DateFilterDialog.java | 8 +- .../fixture/view/screen/MainLayout.java | 36 +- .../view/screen/MatchDetailScreen.java | 155 ++-- .../fixture/view/screen/MatchesScreen.java | 4 +- .../images/favicons/android-icon-144x144.png | Bin .../images/favicons/android-icon-192x192.png | Bin .../images/favicons/android-icon-36x36.png | Bin .../images/favicons/android-icon-48x48.png | Bin .../images/favicons/android-icon-72x72.png | Bin .../images/favicons/android-icon-96x96.png | Bin .../images/favicons/apple-icon-114x114.png | Bin .../images/favicons/apple-icon-120x120.png | Bin .../images/favicons/apple-icon-144x144.png | Bin .../images/favicons/apple-icon-152x152.png | Bin .../images/favicons/apple-icon-180x180.png | Bin .../images/favicons/apple-icon-57x57.png | Bin .../images/favicons/apple-icon-60x60.png | Bin .../images/favicons/apple-icon-72x72.png | Bin .../images/favicons/apple-icon-76x76.png | Bin .../favicons/apple-icon-precomposed.png | Bin .../frontend/images/favicons/apple-icon.png | Bin .../images/favicons/favicon-16x16.png | Bin .../images/favicons/favicon-32x32.png | Bin .../images/favicons/favicon-96x96.png | Bin .../frontend/images/favicons/favicon.ico | Bin .../frontend/images/favicons/icon-128x128.png | Bin .../frontend/images/favicons/icon-144x144.png | Bin .../frontend/images/favicons/icon-152x152.png | Bin .../frontend/images/favicons/icon-192x192.png | Bin .../frontend/images/favicons/icon-384x384.png | Bin .../frontend/images/favicons/icon-512x512.png | Bin .../frontend/images/favicons/icon-72x72.png | Bin .../frontend/images/favicons/icon-96x96.png | Bin .../images/favicons/ms-icon-144x144.png | Bin .../images/favicons/ms-icon-150x150.png | Bin .../images/favicons/ms-icon-310x310.png | Bin .../images/favicons/ms-icon-70x70.png | Bin .../META-INF/resources}/manifest.json | 4 +- .../resources/META-INF/resources/styles.css | 209 +++++ src/main/resources/application.properties | 9 +- src/main/resources/ehcache.xml | 42 - src/main/webapp/frontend/images/flags/arg.svg | 34 - src/main/webapp/frontend/images/flags/aus.svg | 27 - src/main/webapp/frontend/images/flags/bel.svg | 6 - src/main/webapp/frontend/images/flags/bra.svg | 90 --- src/main/webapp/frontend/images/flags/col.svg | 6 - src/main/webapp/frontend/images/flags/crc.svg | 393 ---------- src/main/webapp/frontend/images/flags/cro.svg | 241 ------ src/main/webapp/frontend/images/flags/den.svg | 6 - src/main/webapp/frontend/images/flags/egy.svg | 79 -- src/main/webapp/frontend/images/flags/eng.svg | 1 - src/main/webapp/frontend/images/flags/esp.svg | 406 ---------- src/main/webapp/frontend/images/flags/fra.svg | 2 - src/main/webapp/frontend/images/flags/ger.svg | 9 - src/main/webapp/frontend/images/flags/irn.svg | 32 - src/main/webapp/frontend/images/flags/isl.svg | 6 - src/main/webapp/frontend/images/flags/jpn.svg | 5 - src/main/webapp/frontend/images/flags/kor.svg | 12 - src/main/webapp/frontend/images/flags/ksa.svg | 5 - src/main/webapp/frontend/images/flags/mar.svg | 1 - src/main/webapp/frontend/images/flags/mex.svg | 720 ------------------ src/main/webapp/frontend/images/flags/nga.svg | 5 - src/main/webapp/frontend/images/flags/pan.svg | 21 - src/main/webapp/frontend/images/flags/per.svg | 10 - src/main/webapp/frontend/images/flags/pol.svg | 1 - src/main/webapp/frontend/images/flags/por.svg | 67 -- src/main/webapp/frontend/images/flags/rus.svg | 1 - src/main/webapp/frontend/images/flags/sen.svg | 16 - src/main/webapp/frontend/images/flags/srb.svg | 302 -------- src/main/webapp/frontend/images/flags/sui.svg | 6 - src/main/webapp/frontend/images/flags/swe.svg | 5 - src/main/webapp/frontend/images/flags/tbd.svg | 7 - src/main/webapp/frontend/images/flags/tun.svg | 1 - src/main/webapp/frontend/images/flags/uru.svg | 39 - .../webapp/frontend/styles/shared-styles.html | 255 ------- 107 files changed, 1229 insertions(+), 3890 deletions(-) create mode 100644 MIGRATION_PLAN.md delete mode 100644 src/main/java/com/flowingcode/fixture/repository/GroupRepository.java delete mode 100644 src/main/java/com/flowingcode/fixture/repository/GroupRepositoryImpl.java delete mode 100644 src/main/java/com/flowingcode/fixture/repository/MatchRepositoryImpl.java delete mode 100644 src/main/java/com/flowingcode/fixture/repository/MatchesRepository.java delete mode 100644 src/main/java/com/flowingcode/fixture/repository/SFGRestTemplate.java delete mode 100644 src/main/java/com/flowingcode/fixture/repository/TeamRepository.java delete mode 100644 src/main/java/com/flowingcode/fixture/repository/TeamRepositoryImpl.java delete mode 100644 src/main/java/com/flowingcode/fixture/repository/domain/GroupDetail.java delete mode 100644 src/main/java/com/flowingcode/fixture/repository/domain/GroupWrapper.java delete mode 100644 src/main/java/com/flowingcode/fixture/repository/domain/Match.java delete mode 100644 src/main/java/com/flowingcode/fixture/repository/domain/Team.java create mode 100644 src/main/java/com/flowingcode/fixture/repository/worldcup/StadiumCatalog.java create mode 100644 src/main/java/com/flowingcode/fixture/repository/worldcup/TeamCatalog.java create mode 100644 src/main/java/com/flowingcode/fixture/repository/worldcup/Wc26Dtos.java create mode 100644 src/main/java/com/flowingcode/fixture/repository/worldcup/WorldCupClient.java delete mode 100644 src/main/java/com/flowingcode/fixture/view/component/MarkedElement.java delete mode 100644 src/main/java/com/flowingcode/fixture/view/model/EventGridDto.java rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/android-icon-144x144.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/android-icon-192x192.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/android-icon-36x36.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/android-icon-48x48.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/android-icon-72x72.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/android-icon-96x96.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/apple-icon-114x114.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/apple-icon-120x120.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/apple-icon-144x144.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/apple-icon-152x152.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/apple-icon-180x180.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/apple-icon-57x57.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/apple-icon-60x60.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/apple-icon-72x72.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/apple-icon-76x76.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/apple-icon-precomposed.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/apple-icon.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/favicon-16x16.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/favicon-32x32.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/favicon-96x96.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/favicon.ico (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/icon-128x128.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/icon-144x144.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/icon-152x152.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/icon-192x192.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/icon-384x384.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/icon-512x512.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/icon-72x72.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/icon-96x96.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/ms-icon-144x144.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/ms-icon-150x150.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/ms-icon-310x310.png (100%) rename src/main/{webapp => resources/META-INF/resources}/frontend/images/favicons/ms-icon-70x70.png (100%) rename src/main/{webapp => resources/META-INF/resources}/manifest.json (94%) create mode 100644 src/main/resources/META-INF/resources/styles.css delete mode 100644 src/main/resources/ehcache.xml delete mode 100644 src/main/webapp/frontend/images/flags/arg.svg delete mode 100644 src/main/webapp/frontend/images/flags/aus.svg delete mode 100644 src/main/webapp/frontend/images/flags/bel.svg delete mode 100644 src/main/webapp/frontend/images/flags/bra.svg delete mode 100644 src/main/webapp/frontend/images/flags/col.svg delete mode 100644 src/main/webapp/frontend/images/flags/crc.svg delete mode 100644 src/main/webapp/frontend/images/flags/cro.svg delete mode 100644 src/main/webapp/frontend/images/flags/den.svg delete mode 100644 src/main/webapp/frontend/images/flags/egy.svg delete mode 100644 src/main/webapp/frontend/images/flags/eng.svg delete mode 100644 src/main/webapp/frontend/images/flags/esp.svg delete mode 100644 src/main/webapp/frontend/images/flags/fra.svg delete mode 100644 src/main/webapp/frontend/images/flags/ger.svg delete mode 100644 src/main/webapp/frontend/images/flags/irn.svg delete mode 100644 src/main/webapp/frontend/images/flags/isl.svg delete mode 100644 src/main/webapp/frontend/images/flags/jpn.svg delete mode 100644 src/main/webapp/frontend/images/flags/kor.svg delete mode 100644 src/main/webapp/frontend/images/flags/ksa.svg delete mode 100644 src/main/webapp/frontend/images/flags/mar.svg delete mode 100644 src/main/webapp/frontend/images/flags/mex.svg delete mode 100644 src/main/webapp/frontend/images/flags/nga.svg delete mode 100644 src/main/webapp/frontend/images/flags/pan.svg delete mode 100644 src/main/webapp/frontend/images/flags/per.svg delete mode 100644 src/main/webapp/frontend/images/flags/pol.svg delete mode 100644 src/main/webapp/frontend/images/flags/por.svg delete mode 100644 src/main/webapp/frontend/images/flags/rus.svg delete mode 100644 src/main/webapp/frontend/images/flags/sen.svg delete mode 100644 src/main/webapp/frontend/images/flags/srb.svg delete mode 100644 src/main/webapp/frontend/images/flags/sui.svg delete mode 100644 src/main/webapp/frontend/images/flags/swe.svg delete mode 100644 src/main/webapp/frontend/images/flags/tbd.svg delete mode 100644 src/main/webapp/frontend/images/flags/tun.svg delete mode 100644 src/main/webapp/frontend/images/flags/uru.svg delete mode 100644 src/main/webapp/frontend/styles/shared-styles.html diff --git a/.gitignore b/.gitignore index 535859f..adf5034 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,17 @@ /.project /.settings /.classpath -/src/main/webapp/frontend-es6 -/src/main/webapp/frontend-es5 +node_modules + +# IDE +/.vscode/ + +# Vaadin — generated / auto-scaffolded on build (regenerated, not source) +/src/main/frontend/ +/src/main/bundles/ +package.json +package-lock.json +tsconfig.json +types.d.ts +vite.config.ts +vite.generated.ts diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md new file mode 100644 index 0000000..a90574b --- /dev/null +++ b/MIGRATION_PLAN.md @@ -0,0 +1,202 @@ +# WorldCup Stats — Vaadin 25 Upgrade & 2026 World Cup Data: Findings & Plan + +**Prepared:** 2026-06-08 +**Goal:** (1) Upgrade this app to **Vaadin 25**, and (2) make it show data from the **2026 World Cup** (kicks off **June 11, 2026**). + +> Note: this is an unofficial demo. "FIFA" and "FIFA World Cup" are trademarks of FIFA; the app and its public-facing name avoid them and carry a non-affiliation disclaimer. Internal identifiers from the data API (e.g. `fifa_code`) are kept as-is. + +> TL;DR: Both are achievable, but neither is a simple version bump. The app is from 2018 (Vaadin 10, Polymer/Bower, Spring Boot 2.0) and its data source is permanently offline. The frontend needs a rebuild, and the entire data layer must be rewritten against a new API. The two biggest decisions are **which data source** and **how much of the original feature set** we rebuild — both are blocked on input and explained below. + +--- + +## 1. Current state (what we have today) + +| Area | Current | Notes | +|---|---|---| +| Vaadin | **10.0.1** (2018) | Polymer 2 era | +| Spring Boot | **2.0.2** | `javax.*` namespace | +| Java | **1.8** (source/target) | Build machine has JDK 21 ✓ | +| Packaging | **WAR** | + Heroku Maven plugin | +| Frontend | **Polymer templates** (`.html`), Bower webjars, `@HtmlImport`, `shared-styles.html`, `marked-element` | All removed in modern Vaadin | +| App layout | `com.flowingcode.addons.applayout:app-layout-addon:**1.0.1**` | Old FC add-on version | +| Data source | **`worldcup.sfg.io`** REST API | ❌ **Confirmed dead** (2018-only project; no longer responds) | +| Architecture | `repository → service → presenter → screen` (MVP) | Domain models map the old API's rich schema | + +**Source layout (for reference):** +- `repository/` — REST calls to `worldcup.sfg.io` + domain models (`Match`, `Team`, `GroupDetail`, …) +- `service/` — `GroupService`, `MatchService`, `FlagUtils` +- `view/presenter/` — `MatchesPresenter`, `MatchDetailPresenter`, `GroupsPresenter`, `CountryPresenter`, `WelcomePresenter` +- `view/screen/` — `MainLayout`, `MatchesScreen`, `MatchDetailScreen`, `GroupsScreen`, `CountryScreen`, `AboutScreen`, `WelcomeScreen`, `DateFilterDialog` +- `view/component/` — `MatchResultComponent`, `GroupView`, `TitleComponent`, `MarkedElement` (HtmlImport), error handlers +- `view/util/` — `MatchUpdater` (live polling/push), `DateTimeUtil`, `CssStyles` + +--- + +## 2. Two independent workstreams + +### Workstream A — Vaadin 10 → 25 (a frontend rebuild, not a bump) + +Everything the original frontend is built on has been removed from modern Vaadin. This is the larger of the two efforts. + +- **Polymer / Bower / `@HtmlImport` → Lit + npm + CSS themes.** Affects `shared-styles.html`, the `marked-element` webjar, and `MarkedElement.java`. Styles get reworked into a Vaadin theme (`frontend/themes/...` / `@CssImport`). +- **Spring Boot 2.0 → 4.0** → `javax.*` → `jakarta.*` migration across the whole app. +- **Java 8 → 17/21**, **WAR → Spring Boot executable jar** (recommended; the Heroku WAR setup goes away). +- **Removed/changed component APIs:** `Label` (removed → `Span`/`NativeLabel`), `Grid` API changes, routing/`@Route`/`@PageTitle` tweaks, layout APIs. +- **App layout:** bump `app-layout-addon` `1.0.1` → **`6.2.0-SNAPSHOT`** (Vaadin 25 compatible) — see **Decision 2**. API changed substantially across 1.x→6.x; the menu/drawer setup in `MainLayout` will need rewriting. +- **Live updates:** `MatchUpdater` (server push / polling) needs revalidation against the Vaadin 25 push API. + +> Node: the machine has **Node 18**; Vaadin 25 prefers **Node 20+**. Vaadin can provision its own Node automatically, so this is low-risk, but installing Node 20+ avoids surprises. + +### Workstream B — New 2026 World Cup data source + +`worldcup.sfg.io` is gone, and **no free source matches its old schema**, so the `repository / service / presenter / DTO` layer is rewritten **regardless of which source we pick**. The original app's richest features (live player events, lineups, possession/shot stats, weather) come **only** from a keyed/paid API — no free, no-key source provides them. + +**Options researched (June 2026):** + +| Source | Live in-match scores | API key | Real 2026 data | Rich stats (lineups / events / possession) | Reliability | +|---|---|---|---|---|---| +| **worldcup26.ir** | ✅ Yes | ❌ None | ✅ Yes (matches official draw) | ❌ Basic scores/scorers only | ⚠️ Third-party project; uptime/longevity not guaranteed | +| **openfootball/worldcup.json** | ❌ No (static, git-updated) | ❌ None | ✅ Yes (public domain) | ❌ No | ✅ Very reliable, but not real-time | +| **API-Football** (api-football.com) | ✅ Yes | ✅ Required | ✅ Yes | ✅ Yes | ✅ Commercial; free tier rate-limited (~100 req/day) | +| **football-data.org** | ✅ Yes | ✅ Required | ✅ Yes | ⚠️ Partial | ✅ Commercial; free tier ~10 req/min | +| **Sportmonks / TheStatsAPI / others** | ✅ Yes | ✅ Required | ✅ Yes | ✅ Yes | ✅ Commercial, paid | + +> Verified live during research: `worldcup26.ir/get/games` returns HTTP 200 with the real 2026 fixtures (e.g. Mexico v South Africa, June 11) — cross-checked against openfootball. Its schema is flat (team IDs, EN/FA names, group, matchday, scores) and very different from the old sfg.io model. + +--- + +## 3. Decisions the team needs to make + +These drive scope, effort, and feasibility. Nothing else can be finalized until **Decision 1 & 2** are made. + +### Decision 1 — Data source ⭐ (highest impact) +> **Team direction (2026-06-08): keep it free / no external API key.** This rules out Option C below and, with it, the rich match stats (lineups, possession, player events) — those are only available from keyed/paid providers. The realistic choice is therefore between the two free, no-key options (A and B) or a hybrid of them. +- **Option A — `worldcup26.ir`** (free, live, no key). Closest to the original's "live during the tournament" spirit. **Trade-off:** third-party reliability risk; basic data only. +- **Option B — openfootball** (free, reliable, no key). Best if correctness/uptime matter more than a live-ticking score. **Trade-off:** no real-time in-match updates. +- ~~**Option C — Keyed API (e.g. API-Football)**~~ — **excluded** by the free/no-key constraint above. +- **Recommended: hybrid (A + B)** — openfootball for fixtures/groups/results (reliable), `worldcup26.ir` for live scores. Both free, no key. Keep behind a clean interface so a keyed source could be added later if the constraint ever changes. + +### Decision 2 — App shell / navigation layout +> **Finding (verified 2026-06-08):** FC's own Vaadin 25 demo (`https://addonsv25.flowingcode.com/applayout/applayout-demo`) runs the AppLayout add-on at version **`6.2.0-SNAPSHOT`** on **Vaadin `25.1.5`** — confirmed by the team. So the add-on **works on Vaadin 25** and the target coordinates are known. *Caveat: `6.2.0-SNAPSHOT` is a snapshot, not a stable release; we'd consume the SNAPSHOT (and the FC snapshots repo) until a stable v25-compatible release is published.* +- **Option A — Flowing Code app-layout add-on. (Confirmed on Vaadin 25.)** Use **`6.2.0-SNAPSHOT`** with Vaadin **`25.1.7`** (latest; demo verified on the 25.1.x line). Keeps our look-and-feel and dogfoods our add-on. Only caveat is the SNAPSHOT dependency noted above. +- **Option B — Vaadin built-in `AppLayout`.** Zero external dependency, supported by the platform. A fallback if we'd rather avoid the add-on entirely. + +### Decision 3 — Feature scope +> With the free/no-key constraint (Decision 1), **Option A is effectively the only viable scope** — full parity is excluded because the rich stats require a keyed API. +- **Option A — Core rebuild (selected by the free constraint):** match list/fixtures, live scores, group standings, country pages, modernized Vaadin 25 UI. Drops rich match-detail stats that free sources don't provide. Faster. +- ~~**Option B — Full parity**~~ — needs a keyed/paid API; **excluded** by the free/no-key constraint. + +### Decision 4 — Vaadin target & migration style +- **Target confirmed: Vaadin `25.1.7` (latest) + app-layout add-on `6.2.0-SNAPSHOT`** (FC's v25 demo verified on the 25.1.x line). The earlier concern about the add-on blocking a Vaadin 25 target is **resolved**. Only open item is the SNAPSHOT caveat (Decision 2); a stable add-on release would be preferable before a production deploy. +- **In-place migration** of existing code vs. **clean rebuild** on the same domain. Given the volume of removed tech, a clean rebuild of the view layer (reusing business logic) is often faster and lower-risk than incremental migration. + +### Decision 5 — Deployment & timeline +- WAR/Heroku → Spring Boot jar; pick a hosting target. +- The tournament starts **June 11, 2026** — clarify whether there's a hard "live for kickoff" deadline (affects scope choices above). + +### Decision 6 — Assets +- Flag SVGs in `frontend/images/flags/` cover the **2018** teams. The 2026 lineup (48 teams) differs — we'll need to **add/replace flags** for the new participants. + +--- + +## 4. Recommended approach (proposal — open to discussion) + +For a demo/showcase that feels live for the tournament with the least external risk: + +1. **Data:** Use **worldcup26.ir** as the single free source — it covers teams, group standings, games and stadiums, which map cleanly onto the existing view DTOs. Keep the data access behind a thin client so a reliable fallback (openfootball) or a keyed API could be added later. *(As-built: worldcup26.ir only; the openfootball fallback was left as an optional, unimplemented item.)* +2. **Scope:** **Core rebuild** (Decision 3A) — drop the rich per-match stats that no free source offers. +3. **Layout:** **Flowing Code app-layout add-on `6.2.0-SNAPSHOT`** (Decision 2A) — confirmed running on Vaadin 25 via FC's own v25 demo. +4. **Platform:** Spring Boot 4.0.x jar, Java 21, **Vaadin 25.1.7**, Lit/CSS theme. + +This keeps it free/no-key (per the team's direction), reliable for fixtures, and live-ish for scores — at the cost of the detailed match statistics. If the free constraint is ever relaxed, the clean data interface lets us add a keyed API later for full-parity stats without reworking the views. + +--- + +## 5. Execution checklist + +> Locked targets: **Vaadin 25.1.7 · Java 21 · Spring Boot 4.0.6 · app-layout-addon 6.2.0-SNAPSHOT · free data (worldcup26.ir) · core-rebuild scope.** Suggested order — each phase should compile/run before moving on. + +> **As-built note:** the checklist below is the original plan. A few things landed differently in practice — see [§7 As-built](#7-as-built-what-actually-shipped) for the authoritative summary (theming via `@StyleSheet` instead of `@Theme`, `vaadin-dev` dependency for dev mode, Vaadin `Card` instead of `PaperCard`, single data source, app-shell on `FixtureApp`). + +### Phase 0 — Branch & baseline +- [ ] Create a migration branch off `master`. +- [ ] (Optional) Install Node 20+ to avoid Vaadin auto-provisioning surprises. +- [ ] Snapshot current behavior/screenshots for later comparison. + +### Phase 1 — Build & platform (get it compiling on the new stack) +- [ ] Rewrite `pom.xml`: `vaadin.version` → **25.1.7**, `vaadin-bom`; Spring Boot **4.0.6** (`spring-boot-starter-parent`); `maven.compiler.source/target` → **21**. +- [ ] Packaging `war` → **`jar`**; drop `maven-war-plugin`, `failOnMissingWebXml`, the `heroku-maven-plugin`, and the `frontend-es5/es6` clean filesets. +- [ ] Remove obsolete deps/repos: `vaadin-prereleases`, Bower `marked-element` webjar, the old `app-layout-addon:1.0.1`. Add **`app-layout-addon:6.2.0-SNAPSHOT`** (+ FC snapshots repo). +- [ ] Re-evaluate the Vaadin `productionMode` profile — Vaadin 25 uses the `vaadin-maven-plugin` `prepare-frontend`/`build-frontend` goals (not `copy-production-files`/`package-for-production`). +- [ ] **`javax.* → jakarta.*`** across all imports (servlet, etc.). +- [ ] Decide on ehcache: Spring Boot 4 cache abstraction (chosen: Caffeine, 120s TTL). Re-check `ehcache.xml`. + +### Phase 2 — Frontend foundation +- [ ] Delete Polymer/Bower artifacts: `shared-styles.html`, `src/main/webapp/frontend/*.html`, `MarkedElement.java`, any `@HtmlImport`. +- [ ] Create a Vaadin theme (`frontend/themes/worldcup/styles.css` + `@Theme`); port the CSS from `shared-styles.html` (drop the Polymer `app-*`/`paper-*` selectors). +- [ ] Move `frontend/images/**` (favicons, flags) under the theme / `src/main/resources/META-INF/resources` as appropriate. +- [ ] Replace the `marked-element` usage in `AboutScreen` with a Vaadin-native markdown approach (e.g. the FC Markdown add-on, or render to HTML server-side). + +### Phase 3 — Data layer (Workstream B) +- [ ] Define a `WorldCupDataSource` interface (matches, groups, teams, by-country) so sources are swappable. +- [ ] New domain/DTO models for the chosen schemas (openfootball + worldcup26.ir) — replace the sfg.io `Match`/`Team`/etc. +- [ ] Implement: openfootball for fixtures/groups/results; worldcup26.ir for live scores. Map both into the app's view DTOs. +- [ ] Update `GroupService`/`MatchService`/`FlagUtils` and the presenters to the new models. +- [ ] Re-point or remove `SFGRestTemplate`; use `RestClient`/`WebClient` (Spring 6). + +### Phase 4 — Views (Vaadin 25) +- [ ] `MainLayout` → rebuild the menu/drawer on app-layout-addon 6.x API. +- [ ] Fix removed APIs: `Label` → `Span`/`NativeLabel`; revalidate `Grid`, `@Route`, `@PageTitle`, dialogs. +- [ ] Rebuild `MatchesScreen`, `MatchDetailScreen`, `GroupsScreen`, `CountryScreen`, `WelcomeScreen`, `AboutScreen`, `DateFilterDialog`, `MatchResultComponent`, `GroupView`, `TitleComponent`. +- [ ] Revalidate live updates: `MatchUpdater` + `@Push` against the Vaadin 25 push API (and confirm the data source actually ticks). + +### Phase 5 — Assets & content +- [ ] Add/replace flag SVGs for the **48** participating nations (current set is the 2018 lineup). +- [ ] Update `AboutScreen` copy, page titles, manifest/favicons as needed. + +### Phase 6 — Test & deploy +- [ ] Run locally (`mvn spring-boot:run`) against live 2026 data; verify each screen. +- [ ] Production build (`-Pproduction` / `vaadin.productionMode`) sanity check. +- [ ] Pick a hosting target (Heroku setup is gone) and deploy. + +> Effort note: multi-day effort dominated by Phase 4 (view rebuild) and Phase 3 (data rewrite). Phases 1–2 are mechanical but touch everything. + +--- + +## 6. Key risks + +- **Data-source longevity** — free no-key sources (esp. `worldcup26.ir`) may change or go down mid-tournament. The client degrades gracefully (empty lists, no crash); a reliable fallback (openfootball) or stale-cache is the suggested mitigation but is **not yet implemented**. +- **Feature-parity gap** — rich match stats are simply unavailable for free; setting expectations early avoids rework. +- **Migration surprises** — 7 years of removed Vaadin APIs; the Polymer→Lit and Spring Boot 4 (Jakarta) steps are where unknowns hide. +- **Deadline pressure** — tournament starts **June 11, 2026**; core-rebuild scope is chosen to fit. +- **SNAPSHOT dependency** — relying on `app-layout-addon 6.2.0-SNAPSHOT`; pin a build or move to a stable release before any production deploy. + +--- + +## 7. As-built (what actually shipped) + +The migration was completed on branch `migrate/vaadin25-worldcup2026`. Final state, where it differs from or refines the plan above: + +**Platform** +- Vaadin **25.1.7**, Spring Boot **4.0.6**, Java **21**, executable-jar packaging. +- Added `com.vaadin:vaadin-dev` (optional) — required for the dev server (`spring-boot:run`); not needed by the production jar. Matches the official Vaadin 25 Spring Boot starter. +- Caching: **Caffeine** (`spring.cache.type=caffeine`, 120 s TTL); `ehcache.xml` removed. *Rationale:* the original Ehcache 3 integration relied on JSR-107 (JCache), which adds fragile wiring under Spring Boot 4 / Jakarta (Jakarta-classified provider, `cache-api`, `spring.cache.jcache.config` → XML). For this single-node, in-memory, short-TTL use case, Caffeine is Spring Boot's recommended default: native auto-config, same semantics (TTL + max-size in one `spec` line), `@Cacheable`/`@EnableCaching` unchanged, no JCache/XML. (A distributed cache like Redis/Hazelcast would only be warranted for a multi-node deployment.) +- `FixtureApp` no longer extends `SpringBootServletInitializer` (jar, not WAR). + +**Frontend / theming** +- `@Theme` is **deprecated in Vaadin 25**, so theming is done with plain CSS loaded via `@StyleSheet(Lumo.STYLESHEET)` + `@StyleSheet("styles.css")` on the app shell. The stylesheet lives at `src/main/resources/META-INF/resources/styles.css` (the deprecated `frontend/themes/` folder is not used). +- All app-shell annotations (`@Push`, `@Viewport`, `@StyleSheet`) live on **`FixtureApp implements AppShellConfigurator`** (Vaadin 25 requires a single shell class; no separate `AppShell`). +- `PaperCard` was removed from app-layout 6.2.0, so cards use the **built-in Vaadin `Card`** component. `Label` → `NativeLabel`. +- Markdown in `AboutScreen` is rendered via the `Html` component (no `marked-element`). + +**Data** +- Single source: **worldcup26.ir** (`WorldCupClient` with Spring `RestClient`, plus `TeamCatalog` / `StadiumCatalog` lookups). Domain records in `repository/worldcup`. +- Flags come from worldcup26.ir's remote flag URLs (via `FlagUtils`); local flag SVGs were removed. +- `MatchServiceImpl` / `GroupServiceImpl` map worldcup26 responses into the **unchanged** view DTOs; presenters/screens kept. Stage labels are humanized data-driven (handles future knockout `type` values). +- `MatchDetailScreen` shows only available data (score, venue/city, kickoff, goal scorers); the empty events grid and `EventGridDto` were removed. + +**Not implemented (optional)** +- openfootball fallback for fixtures (resilience if worldcup26.ir is down). +- Rich match stats (lineups/possession) — unavailable from a free, no-key source. + +*Original decisions (2026-06-08): Vaadin 25.1.7 · free data · core-rebuild scope · app-layout-addon 6.2.0-SNAPSHOT.* diff --git a/README.md b/README.md index bd3cb08..d84b9d7 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,53 @@ -# Demo Application: World Cup Rusia 2018 stats +# Demo Application: Global Football 2026 Stats -This project is a demo application that displays statistical data from the Football World Cup that is taking place in Russia +A demo application that displays the fixture and results of the **2026 international football +tournament** (Canada / USA / Mexico): match schedule, live scores, group standings and +per-country fixtures. -Import the project to the IDE of your choosing as a Maven project. +It is the modernized version of the original 2018 demo, migrated from Vaadin 10 to **Vaadin 25**. +See [MIGRATION_PLAN.md](MIGRATION_PLAN.md) for the full upgrade story. -Run application using -`mvn spring-boot:run` +## Tech stack -Open http://localhost:8080/ in browser +- **Vaadin 25.1.7** (Lit / npm frontend, plain-CSS theming via `@StyleSheet`) +- **Spring Boot 4.0.6**, **Java 21**, executable-jar packaging +- **Flowing Code AppLayout add-on** for the navigation shell +- **Caffeine** for short-lived response caching -For more information regarding this application, refer to [this blog post](https://www.flowingcode.com/2018/07/vaadin-10-spring-demo-application-world.html). +## Data source -For more information on Vaadin Flow, visit https://vaadin.com/flow. +Data comes from the free, no-API-key **[worldcup26.ir](https://worldcup26.ir/)** REST API +(teams, groups/standings, games, stadiums). Because it is a free source, rich per-match +statistics (lineups, possession, cards) are not available — the app shows fixtures, scores, +goal scorers and group standings. Live scores update during matches. -This demo is hosted at http://worldcup.flowingcode.com/ \ No newline at end of file +## Running + +Development mode (hot reload): + +``` +mvn spring-boot:run +``` + +Then open . + +Production build (compiles the optimized frontend bundle and a runnable jar): + +``` +mvn -Pproduction package +java -jar target/worldcup-fixture-2.0.0-SNAPSHOT.jar +``` + +## More information + +For more information on Vaadin Flow, visit . + +## Disclaimer + +Unofficial demo application. Not affiliated with, endorsed by, or sponsored by FIFA or any +football governing body. All team and tournament data comes from the public +[worldcup26.ir](https://worldcup26.ir/) API. + +## Author + +Developed by [Flowing Code](https://www.flowingcode.com). diff --git a/pom.xml b/pom.xml index cc999a3..c28032b 100644 --- a/pom.xml +++ b/pom.xml @@ -4,40 +4,42 @@ 4.0.0 com.flowingcode.fixture worldcup-fixture - World Cup Fixture in Vaadin 10 - 1.0.0-SNAPSHOT - war + World Cup Stats in Vaadin 25 + 2.0.0-SNAPSHOT + jar + + + org.springframework.boot + spring-boot-starter-parent + 4.0.6 + + - 1.8 - 1.8 + 21 UTF-8 UTF-8 - false - - src/main/webapp - 10.0.1 - 2.0.2.RELEASE + 25.1.7 + 6.2.0-SNAPSHOT - - - vaadin-prereleases - https://maven.vaadin.com/vaadin-prereleases - - - - vaadin-prereleases - https://maven.vaadin.com/vaadin-prereleases + Vaadin Directory + https://maven.vaadin.com/vaadin-addons - Vaadin Directory - http://maven.vaadin.com/vaadin-addons - + FlowingCode Snapshots + https://maven.flowingcode.com/snapshots + + true + + + false + + @@ -49,13 +51,6 @@ pom import - - org.springframework.boot - spring-boot-dependencies - ${spring-boot.version} - pom - import - @@ -63,12 +58,13 @@ com.vaadin vaadin-spring-boot-starter - ${vaadin.version} - + + com.vaadin - vaadin-spring + vaadin-dev + true @@ -76,96 +72,43 @@ spring-boot-devtools true - + + - com.flowingcode.addons.applayout - app-layout-addon - 1.0.1 + com.flowingcode.addons.applayout + app-layout-addon + ${app-layout-addon.version} - + + - org.webjars.bowergithub.polymerelements - marked-element - 2.4.0 - - + org.springframework.boot + spring-boot-starter-cache + - org.ehcache - ehcache - 3.5.2 + com.github.ben-manes.caffeine + caffeine + + org.springframework.boot + spring-boot-starter-test + test + - - - - org.apache.maven.plugins - maven-war-plugin - 3.1.0 - - - %regex[WEB-INF/lib/slf4j-simple.*.jar], - %regex[WEB-INF/lib/tomcat.*.jar] - - - - - - - com.heroku.sdk - heroku-maven-plugin - 2.0.4 - - worldcup-staging - - - - - - org.springframework.boot spring-boot-maven-plugin - ${spring-boot.version} - - - - org.apache.maven.plugins - maven-clean-plugin - 3.0.0 - - - - ${webapp.directory}/frontend-es5 - - - ${webapp.directory}/frontend-es6 - - - - productionMode - - - vaadin.productionMode - - - - - - com.vaadin - flow-server-production-mode - - - + production @@ -175,24 +118,13 @@ - copy-production-files - package-for-production + prepare-frontend + build-frontend - - ${webapp.directory} - + compile - - - org.springframework.boot - spring-boot-maven-plugin - ${spring-boot.version} - - -Dvaadin.productionMode - - diff --git a/src/main/java/com/flowingcode/fixture/FixtureApp.java b/src/main/java/com/flowingcode/fixture/FixtureApp.java index c0ab6c4..e613870 100644 --- a/src/main/java/com/flowingcode/fixture/FixtureApp.java +++ b/src/main/java/com/flowingcode/fixture/FixtureApp.java @@ -5,30 +5,41 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.builder.SpringApplicationBuilder; -import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; +import com.vaadin.flow.component.dependency.StyleSheet; +import com.vaadin.flow.component.page.AppShellConfigurator; +import com.vaadin.flow.component.page.Push; +import com.vaadin.flow.component.page.Viewport; +import com.vaadin.flow.server.AppShellSettings; +import com.vaadin.flow.theme.lumo.Lumo; + /** - * The entry point of the Spring Boot application. + * The entry point of the Spring Boot application. Also acts as the Vaadin + * application shell: in Vaadin 25 all app-shell annotations ({@code @Push}, + * {@code @Viewport}) and stylesheet loading must live on the single + * {@link AppShellConfigurator}. Theming is done with plain CSS loaded via + * {@code @StyleSheet} (the {@code @Theme} annotation is deprecated in 25). */ @SpringBootApplication @EnableCaching @EnableScheduling -@Configuration -public class FixtureApp extends SpringBootServletInitializer { +@Push +@Viewport("width=device-width, initial-scale=1.0") +@StyleSheet(Lumo.STYLESHEET) +@StyleSheet("styles.css") +public class FixtureApp implements AppShellConfigurator { public static void main(final String[] args) { SpringApplication.run(FixtureApp.class, args); } @Override - protected SpringApplicationBuilder configure( - final SpringApplicationBuilder builder) { - return builder.sources(FixtureApp.class); + public void configurePage(final AppShellSettings settings) { + settings.addFavIcon("icon", "/frontend/images/favicons/favicon-96x96.png", "96x96"); + settings.addLink("shortcut icon", "/frontend/images/favicons/favicon-96x96.png"); } @Bean diff --git a/src/main/java/com/flowingcode/fixture/repository/GroupRepository.java b/src/main/java/com/flowingcode/fixture/repository/GroupRepository.java deleted file mode 100644 index 624da2c..0000000 --- a/src/main/java/com/flowingcode/fixture/repository/GroupRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.flowingcode.fixture.repository; - -import java.util.List; - -import com.flowingcode.fixture.repository.domain.GroupWrapper.Group; - -public interface GroupRepository { - - List getGroups(); - -} diff --git a/src/main/java/com/flowingcode/fixture/repository/GroupRepositoryImpl.java b/src/main/java/com/flowingcode/fixture/repository/GroupRepositoryImpl.java deleted file mode 100644 index 3db97ee..0000000 --- a/src/main/java/com/flowingcode/fixture/repository/GroupRepositoryImpl.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.flowingcode.fixture.repository; - -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; - -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Repository; -import org.springframework.web.client.RestOperations; - -import com.flowingcode.fixture.repository.domain.GroupWrapper; -import com.flowingcode.fixture.repository.domain.GroupWrapper.Group; - -@Repository -public class GroupRepositoryImpl implements GroupRepository { - - @Override - public List getGroups() { - final RestOperations template = new SFGRestTemplate(); - final ResponseEntity responseEntity = template.getForEntity("http://worldcup.sfg.io/teams/group_results", Group[].class); - return Arrays.asList(responseEntity.getBody()); - } - -} diff --git a/src/main/java/com/flowingcode/fixture/repository/MatchRepositoryImpl.java b/src/main/java/com/flowingcode/fixture/repository/MatchRepositoryImpl.java deleted file mode 100644 index 831d025..0000000 --- a/src/main/java/com/flowingcode/fixture/repository/MatchRepositoryImpl.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.flowingcode.fixture.repository; - -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Optional; - -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Repository; -import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestOperations; -import org.springframework.web.util.UriComponentsBuilder; - -import com.flowingcode.fixture.repository.domain.Match; - -@Repository -public class MatchRepositoryImpl implements MatchesRepository { - - private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); - - @Override - public List getMatches() { - return getMatches(null, null); - } - - @Override - public List getMatches(final LocalDate startDate, final LocalDate endDate) { - final RestOperations template = new SFGRestTemplate(); - final UriComponentsBuilder builder = UriComponentsBuilder.fromUriString("http://worldcup.sfg.io/matches"); - if (startDate != null) { - builder.queryParam("start_date", startDate.format(DATE_FORMAT)); - } - if (endDate != null) { - builder.queryParam("end_date", endDate.format(DATE_FORMAT)); - } - final ResponseEntity responseEntity = template.getForEntity(builder.build().toUriString(), Match[].class); - return Arrays.asList(responseEntity.getBody()); - } - - @Override - public List getCurrentMatches() { - final RestOperations template = new SFGRestTemplate(); - try { - final ResponseEntity responseEntity = template.getForEntity("https://worldcup.sfg.io/matches/current", Match[].class); - return Arrays.asList(responseEntity.getBody()); - } catch (final RestClientException e) { - - } - return Collections.emptyList(); - } - - @Override - public Optional getByFifaID(final String fifaId) { - final RestOperations template = new SFGRestTemplate(); - final ResponseEntity responseEntity = template.getForEntity("http://worldcup.sfg.io/matches/fifa_id/" + fifaId, Match[].class); - return Arrays.asList(responseEntity.getBody()).stream().findFirst(); - } - - @Override - public List getByCountryCode(final String fifaCode) { - final RestOperations template = new SFGRestTemplate(); - final ResponseEntity responseEntity = template.getForEntity("http://worldcup.sfg.io/matches/country?fifa_code=" + fifaCode, Match[].class); - return Arrays.asList(responseEntity.getBody()); - } - -} diff --git a/src/main/java/com/flowingcode/fixture/repository/MatchesRepository.java b/src/main/java/com/flowingcode/fixture/repository/MatchesRepository.java deleted file mode 100644 index aa7ab3e..0000000 --- a/src/main/java/com/flowingcode/fixture/repository/MatchesRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.flowingcode.fixture.repository; - -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; - -import com.flowingcode.fixture.repository.domain.Match; - -public interface MatchesRepository { - - List getMatches(); - - List getCurrentMatches(); - - List getMatches(LocalDate startDate, LocalDate endDate); - - Optional getByFifaID(String fifaId); - - List getByCountryCode(String fifaCode); - -} diff --git a/src/main/java/com/flowingcode/fixture/repository/SFGRestTemplate.java b/src/main/java/com/flowingcode/fixture/repository/SFGRestTemplate.java deleted file mode 100644 index 75b5a66..0000000 --- a/src/main/java/com/flowingcode/fixture/repository/SFGRestTemplate.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.flowingcode.fixture.repository; - -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; -import org.springframework.web.client.RestTemplate; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; - -public class SFGRestTemplate extends RestTemplate { - - public SFGRestTemplate() { - getMessageConverters().stream().filter(e -> e instanceof MappingJackson2HttpMessageConverter).findFirst() - .ifPresent(e -> configureObjectMapper(((MappingJackson2HttpMessageConverter) e).getObjectMapper())); - } - - private void configureObjectMapper(final ObjectMapper objectMapper) { - objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); - } - - -} diff --git a/src/main/java/com/flowingcode/fixture/repository/TeamRepository.java b/src/main/java/com/flowingcode/fixture/repository/TeamRepository.java deleted file mode 100644 index d408b3a..0000000 --- a/src/main/java/com/flowingcode/fixture/repository/TeamRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.flowingcode.fixture.repository; - -import java.util.List; - -import com.flowingcode.fixture.repository.domain.Team; - -public interface TeamRepository { - - List getTeams(); - -} diff --git a/src/main/java/com/flowingcode/fixture/repository/TeamRepositoryImpl.java b/src/main/java/com/flowingcode/fixture/repository/TeamRepositoryImpl.java deleted file mode 100644 index 550f6b9..0000000 --- a/src/main/java/com/flowingcode/fixture/repository/TeamRepositoryImpl.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.flowingcode.fixture.repository; - -import java.util.Arrays; -import java.util.List; - -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Repository; -import org.springframework.web.client.RestOperations; - -import com.flowingcode.fixture.repository.domain.Team; - -@Repository -public class TeamRepositoryImpl implements TeamRepository { - - @Override - public List getTeams() { - final RestOperations template = new SFGRestTemplate(); - final ResponseEntity responseEntity = template.getForEntity("http://worldcup.sfg.io/teams", Team[].class); - return Arrays.asList(responseEntity.getBody()); - } - -} diff --git a/src/main/java/com/flowingcode/fixture/repository/domain/GroupDetail.java b/src/main/java/com/flowingcode/fixture/repository/domain/GroupDetail.java deleted file mode 100644 index 07eb5ec..0000000 --- a/src/main/java/com/flowingcode/fixture/repository/domain/GroupDetail.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.flowingcode.fixture.repository.domain; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public interface GroupDetail { - - String getGroup(); - - Integer getRank(); - - String getTeam(); - - Integer getTeamId(); - - Integer getPlayedGames(); - - String getCrestURI(); - - Integer getPoints(); - - Integer getGoals(); - - Integer getGoalsAgainst(); - - Integer getGoalDifference(); - -} \ No newline at end of file diff --git a/src/main/java/com/flowingcode/fixture/repository/domain/GroupWrapper.java b/src/main/java/com/flowingcode/fixture/repository/domain/GroupWrapper.java deleted file mode 100644 index 3e6ac6b..0000000 --- a/src/main/java/com/flowingcode/fixture/repository/domain/GroupWrapper.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.flowingcode.fixture.repository.domain; - -import java.util.List; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -public final class GroupWrapper { - public final Group group; - - @JsonCreator - public GroupWrapper(@JsonProperty("group") final Group group){ - this.group = group; - } - - public static final class Group { - public final int id; - public final String letter; - public final List ordered_teams; - - @JsonCreator - public Group(@JsonProperty("id") final int id, @JsonProperty("letter") final String letter, @JsonProperty("ordered_teams") final List teams) { - this.id = id; - this.letter = letter; - this.ordered_teams = teams; - } - - public static final class TeamWrapper { - public final Team team; - - @JsonCreator - public TeamWrapper(@JsonProperty("team") final Team team){ - this.team = team; - } - - public static final class Team { - public final int id; - public final String country; - public final String fifa_code; - public final int points; - public final int wins; - public final int draws; - public final int losses; - public final int games_played; - public final int goals_for; - public final int goals_against; - public final int goal_differential; - - @JsonCreator - public Team(@JsonProperty("id") final int id, @JsonProperty("country") final String country, @JsonProperty("fifa_code") final String fifa_code, - @JsonProperty("points") final int points, @JsonProperty("wins") final int wins, @JsonProperty("draws") final int draws, - @JsonProperty("losses") final int losses, @JsonProperty("games_played") final int games_played, - @JsonProperty("goals_for") final int goals_for, @JsonProperty("goals_against") final int goals_against, - @JsonProperty("goal_differential") final int goal_differential) { - this.id = id; - this.country = country; - this.fifa_code = fifa_code; - this.points = points; - this.wins = wins; - this.draws = draws; - this.losses = losses; - this.games_played = games_played; - this.goals_for = goals_for; - this.goals_against = goals_against; - this.goal_differential = goal_differential; - } - } - } - } -} diff --git a/src/main/java/com/flowingcode/fixture/repository/domain/Match.java b/src/main/java/com/flowingcode/fixture/repository/domain/Match.java deleted file mode 100644 index a718fc4..0000000 --- a/src/main/java/com/flowingcode/fixture/repository/domain/Match.java +++ /dev/null @@ -1,195 +0,0 @@ -package com.flowingcode.fixture.repository.domain; - -import java.time.ZonedDateTime; -import java.util.List; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -public final class Match { - public final String venue; - public final String location; - public final String status; - public final String time; - public final String fifa_id; - public final Weather weather; - public final String attendance; - public final List officials; - public final String stage_name; - public final Team_statistics home_team_statistics; - public final Team_statistics away_team_statistics; - public final ZonedDateTime datetime; - public final ZonedDateTime last_event_update_at; - public final ZonedDateTime last_score_update_at; - public final Team home_team; - public final Team away_team; - public final String winner; - public final String winner_code; - public final List home_team_events; - public final List away_team_events; - - @JsonCreator - public Match(@JsonProperty("venue") final String venue, @JsonProperty("location") final String location, @JsonProperty("status") final String status, - @JsonProperty("time") final String time, @JsonProperty("fifa_id") final String fifa_id, @JsonProperty("weather") final Weather weather, - @JsonProperty("attendance") final String attendance, @JsonProperty("officials") final List officials, - @JsonProperty("stage_name") final String stage_name, @JsonProperty("home_team_statistics") final Team_statistics home_team_statistics, - @JsonProperty("away_team_statistics") final Team_statistics away_team_statistics, @JsonProperty("datetime") final ZonedDateTime datetime, - @JsonProperty("last_event_update_at") final ZonedDateTime last_event_update_at, - @JsonProperty("last_score_update_at") final ZonedDateTime last_score_update_at, @JsonProperty("home_team") final Team home_team, - @JsonProperty("away_team") final Team away_team, @JsonProperty("winner") final String winner, - @JsonProperty("winner_code") final String winner_code, @JsonProperty("home_team_events") final List home_team_events, - @JsonProperty("away_team_events") final List away_team_events) { - this.venue = venue; - this.location = location; - this.status = status; - this.time = time; - this.fifa_id = fifa_id; - this.weather = weather; - this.attendance = attendance; - this.officials = officials; - this.stage_name = stage_name; - this.home_team_statistics = home_team_statistics; - this.away_team_statistics = away_team_statistics; - this.datetime = datetime; - this.last_event_update_at = last_event_update_at; - this.last_score_update_at = last_score_update_at; - this.home_team = home_team; - this.away_team = away_team; - this.winner = winner; - this.winner_code = winner_code; - this.home_team_events = home_team_events; - this.away_team_events = away_team_events; - } - - public static final class Weather { - public final String humidity; - public final String temp_celsius; - public final String temp_farenheit; - public final String wind_speed; - public final String description; - - @JsonCreator - public Weather(@JsonProperty("humidity") final String humidity, @JsonProperty("temp_celsius") final String temp_celsius, - @JsonProperty("temp_farenheit") final String temp_farenheit, @JsonProperty("wind_speed") final String wind_speed, - @JsonProperty("description") final String description) { - this.humidity = humidity; - this.temp_celsius = temp_celsius; - this.temp_farenheit = temp_farenheit; - this.wind_speed = wind_speed; - this.description = description; - } - } - - public static final class Team_statistics { - public final int attempts_on_goal; - public final int on_target; - public final int off_target; - public final int blocked; - public final int woodwork; - public final int corners; - public final int offsides; - public final int ball_possession; - public final int pass_accuracy; - public final int num_passes; - public final int passes_completed; - public final int distance_covered; - public final int balls_recovered; - public final int tackles; - public final int clearances; - public final int yellow_cards; - public final int red_cards; - public final int fouls_committed; - public final String country; - public final String tactics; - public final List starting_eleven; - public final List substitutes; - - @JsonCreator - public Team_statistics(@JsonProperty("attempts_on_goal") final int attempts_on_goal, @JsonProperty("on_target") final int on_target, - @JsonProperty("off_target") final int off_target, @JsonProperty("blocked") final int blocked, @JsonProperty("woodwork") final int woodwork, - @JsonProperty("corners") final int corners, @JsonProperty("offsides") final int offsides, - @JsonProperty("ball_possession") final int ball_possession, @JsonProperty("pass_accuracy") final int pass_accuracy, - @JsonProperty("num_passes") final int num_passes, @JsonProperty("passes_completed") final int passes_completed, - @JsonProperty("distance_covered") final int distance_covered, @JsonProperty("balls_recovered") final int balls_recovered, - @JsonProperty("tackles") final int tackles, @JsonProperty("clearances") final int clearances, - @JsonProperty("yellow_cards") final int yellow_cards, @JsonProperty("red_cards") final int red_cards, - @JsonProperty("fouls_committed") final int fouls_committed, @JsonProperty("country") final String country, - @JsonProperty("tactics") final String tactics, @JsonProperty("starting_eleven") final List starting_eleven, - @JsonProperty("substitutes") final List substitutes) { - this.attempts_on_goal = attempts_on_goal; - this.on_target = on_target; - this.off_target = off_target; - this.blocked = blocked; - this.woodwork = woodwork; - this.corners = corners; - this.offsides = offsides; - this.ball_possession = ball_possession; - this.pass_accuracy = pass_accuracy; - this.num_passes = num_passes; - this.passes_completed = passes_completed; - this.distance_covered = distance_covered; - this.balls_recovered = balls_recovered; - this.tackles = tackles; - this.clearances = clearances; - this.yellow_cards = yellow_cards; - this.red_cards = red_cards; - this.fouls_committed = fouls_committed; - this.country = country; - this.tactics = tactics; - this.starting_eleven = starting_eleven; - this.substitutes = substitutes; - } - - public static final class Player { - public final String name; - public final boolean captain; - public final int shirt_number; - public final String position; - - @JsonCreator - public Player(@JsonProperty("name") final String name, @JsonProperty("captain") final boolean captain, - @JsonProperty("shirt_number") final int shirt_number, @JsonProperty("position") final String position) { - this.name = name; - this.captain = captain; - this.shirt_number = shirt_number; - this.position = position; - } - } - - } - - public static final class Team { - public final String country; - public final String code; - public final int goals; - public final int penalties; - public final String team_tbd; - - @JsonCreator - public Team(@JsonProperty("country") final String country, @JsonProperty("code") final String code, @JsonProperty("goals") final int goals, - @JsonProperty("penalties") final int penalties, @JsonProperty("team_tbd") final String team_tbd) { - this.country = country; - this.code = code; - this.goals = goals; - this.penalties = penalties; - this.team_tbd = team_tbd; - } - } - - public static final class Team_event { - public final int id; - public final TeamEventType type_of_event; - public final String player; - public final String time; - - @JsonCreator - public Team_event(@JsonProperty("id") final int id, @JsonProperty("type_of_event") final TeamEventType type_of_event, - @JsonProperty("player") final String player, @JsonProperty("time") final String time) { - this.id = id; - this.type_of_event = type_of_event; - this.player = player; - this.time = time; - } - } - -} \ No newline at end of file diff --git a/src/main/java/com/flowingcode/fixture/repository/domain/Team.java b/src/main/java/com/flowingcode/fixture/repository/domain/Team.java deleted file mode 100644 index e3afae9..0000000 --- a/src/main/java/com/flowingcode/fixture/repository/domain/Team.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.flowingcode.fixture.repository.domain; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -public final class Team { - public final long id; - public final String country; - public final String fifa_code; - public final long group_id; - public final String group_letter; - - @JsonCreator - public Team(@JsonProperty("id") final long id, @JsonProperty("country") final String country, @JsonProperty("fifa_code") final String fifa_code, - @JsonProperty("group_id") final long group_id, @JsonProperty("group_letter") final String group_letter) { - this.id = id; - this.country = country; - this.fifa_code = fifa_code; - this.group_id = group_id; - this.group_letter = group_letter; - } -} \ No newline at end of file diff --git a/src/main/java/com/flowingcode/fixture/repository/worldcup/StadiumCatalog.java b/src/main/java/com/flowingcode/fixture/repository/worldcup/StadiumCatalog.java new file mode 100644 index 0000000..ae46690 --- /dev/null +++ b/src/main/java/com/flowingcode/fixture/repository/worldcup/StadiumCatalog.java @@ -0,0 +1,50 @@ +package com.flowingcode.fixture.repository.worldcup; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.Stadium; + +/** + * In-memory lookup over the worldcup26.ir stadiums endpoint, resolving a + * stadium id to its venue name and host city. + */ +@Component +public class StadiumCatalog { + + private final WorldCupClient client; + + private volatile Map byId = Map.of(); + + public StadiumCatalog(final WorldCupClient client) { + this.client = client; + } + + private synchronized void load() { + final List stadiums = client.getStadiums(); + byId = stadiums.stream().collect(Collectors.toMap(Stadium::id, Function.identity(), (a, b) -> a)); + } + + private void ensureLoaded() { + if (byId.isEmpty()) { + load(); + } + } + + public String venueById(final String id) { + ensureLoaded(); + final Stadium stadium = byId.get(id); + return stadium != null ? stadium.name_en() : ""; + } + + public String cityById(final String id) { + ensureLoaded(); + final Stadium stadium = byId.get(id); + return stadium != null ? stadium.city_en() : ""; + } + +} diff --git a/src/main/java/com/flowingcode/fixture/repository/worldcup/TeamCatalog.java b/src/main/java/com/flowingcode/fixture/repository/worldcup/TeamCatalog.java new file mode 100644 index 0000000..39047a4 --- /dev/null +++ b/src/main/java/com/flowingcode/fixture/repository/worldcup/TeamCatalog.java @@ -0,0 +1,71 @@ +package com.flowingcode.fixture.repository.worldcup; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.Team; +import com.flowingcode.fixture.service.FlagUtils; + +/** + * In-memory lookup over the worldcup26.ir teams endpoint, providing the + * team-id → FIFA-code → flag/name/group joins that the games and + * groups endpoints rely on. Loaded lazily and refreshed on demand; also feeds + * {@link FlagUtils} so flags resolve by FIFA code anywhere in the app. + */ +@Component +public class TeamCatalog { + + private final WorldCupClient client; + + private volatile Map byId = Map.of(); + private volatile Map byCode = Map.of(); + + public TeamCatalog(final WorldCupClient client) { + this.client = client; + } + + private synchronized void load() { + final List teams = client.getTeams(); + byId = teams.stream().collect(Collectors.toMap(Team::id, Function.identity(), (a, b) -> a)); + byCode = teams.stream().filter(t -> t.fifa_code() != null) + .collect(Collectors.toMap(Team::fifa_code, Function.identity(), (a, b) -> a)); + FlagUtils.setFlags(teams.stream().filter(t -> t.fifa_code() != null && t.flag() != null) + .collect(Collectors.toMap(Team::fifa_code, Team::flag, (a, b) -> a))); + } + + private void ensureLoaded() { + if (byId.isEmpty()) { + load(); + } + } + + public Team byId(final String id) { + ensureLoaded(); + return byId.get(id); + } + + public Team byCode(final String fifaCode) { + ensureLoaded(); + return byCode.get(fifaCode); + } + + public String codeById(final String id) { + final Team team = byId(id); + return team != null ? team.fifa_code() : ""; + } + + public String nameById(final String id) { + final Team team = byId(id); + return team != null ? team.name_en() : ""; + } + + public String flagById(final String id) { + final Team team = byId(id); + return team != null ? team.flag() : ""; + } + +} diff --git a/src/main/java/com/flowingcode/fixture/repository/worldcup/Wc26Dtos.java b/src/main/java/com/flowingcode/fixture/repository/worldcup/Wc26Dtos.java new file mode 100644 index 0000000..16c6b30 --- /dev/null +++ b/src/main/java/com/flowingcode/fixture/repository/worldcup/Wc26Dtos.java @@ -0,0 +1,51 @@ +package com.flowingcode.fixture.repository.worldcup; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Records mapping the JSON returned by the worldcup26.ir API + * (https://worldcup26.ir/get/...). All numeric fields arrive as strings. + */ +public final class Wc26Dtos { + + private Wc26Dtos() { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Team(String id, String name_en, String fifa_code, String iso2, String flag, String groups) { + } + + public record TeamsResponse(List teams) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Game(String id, String home_team_id, String away_team_id, String home_score, String away_score, + String home_scorers, String away_scorers, String group, String matchday, String local_date, + String stadium_id, String finished, String time_elapsed, String type, String home_team_name_en, + String away_team_name_en) { + } + + public record GamesResponse(List games) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record GroupTeam(String team_id, String mp, String w, String l, String d, String pts, String gf, String ga, + String gd) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Group(String name, List teams) { + } + + public record GroupsResponse(List groups) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Stadium(String id, String name_en, String city_en) { + } + + public record StadiumsResponse(List stadiums) { + } +} diff --git a/src/main/java/com/flowingcode/fixture/repository/worldcup/WorldCupClient.java b/src/main/java/com/flowingcode/fixture/repository/worldcup/WorldCupClient.java new file mode 100644 index 0000000..b55e940 --- /dev/null +++ b/src/main/java/com/flowingcode/fixture/repository/worldcup/WorldCupClient.java @@ -0,0 +1,60 @@ +package com.flowingcode.fixture.repository.worldcup; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.Game; +import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.GamesResponse; +import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.Group; +import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.GroupsResponse; +import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.Stadium; +import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.StadiumsResponse; +import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.Team; +import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.TeamsResponse; + +/** + * Thin REST client for the free, no-key worldcup26.ir 2026 World Cup API. + * Failures degrade gracefully to empty lists so the UI never crashes if the + * third-party service is unavailable. + */ +@Component +public class WorldCupClient { + + private static final Logger LOGGER = LoggerFactory.getLogger(WorldCupClient.class); + + private final RestClient restClient = RestClient.create("https://worldcup26.ir/get"); + + public List getGames() { + final GamesResponse response = fetch("/games", GamesResponse.class); + return response != null && response.games() != null ? response.games() : List.of(); + } + + public List getTeams() { + final TeamsResponse response = fetch("/teams", TeamsResponse.class); + return response != null && response.teams() != null ? response.teams() : List.of(); + } + + public List getGroups() { + final GroupsResponse response = fetch("/groups", GroupsResponse.class); + return response != null && response.groups() != null ? response.groups() : List.of(); + } + + public List getStadiums() { + final StadiumsResponse response = fetch("/stadiums", StadiumsResponse.class); + return response != null && response.stadiums() != null ? response.stadiums() : List.of(); + } + + private T fetch(final String path, final Class type) { + try { + return restClient.get().uri(path).retrieve().body(type); + } catch (final RuntimeException e) { + LOGGER.warn("Failed to fetch {} from worldcup26.ir: {}", path, e.getMessage()); + return null; + } + } + +} diff --git a/src/main/java/com/flowingcode/fixture/service/FlagUtils.java b/src/main/java/com/flowingcode/fixture/service/FlagUtils.java index 67ccffc..c9d56b0 100644 --- a/src/main/java/com/flowingcode/fixture/service/FlagUtils.java +++ b/src/main/java/com/flowingcode/fixture/service/FlagUtils.java @@ -1,9 +1,29 @@ package com.flowingcode.fixture.service; +import java.util.Collections; +import java.util.Map; + +/** + * Resolves a team flag (a remote image URL) from its FIFA code. The mapping is + * populated at runtime from the worldcup26.ir teams endpoint (which provides a + * flag URL per team), so no local flag assets are required. + */ public class FlagUtils { + private static volatile Map flagsByCode = Collections.emptyMap(); + + private FlagUtils() { + } + + public static void setFlags(final Map flags) { + flagsByCode = flags; + } + public static String getFlagForFifaCode(final String fifaCode) { - return "/frontend/images/flags/" + fifaCode.toLowerCase() + ".svg"; + if (fifaCode == null) { + return ""; + } + return flagsByCode.getOrDefault(fifaCode, ""); } } diff --git a/src/main/java/com/flowingcode/fixture/service/GroupServiceImpl.java b/src/main/java/com/flowingcode/fixture/service/GroupServiceImpl.java index b6c16cd..b2e6454 100644 --- a/src/main/java/com/flowingcode/fixture/service/GroupServiceImpl.java +++ b/src/main/java/com/flowingcode/fixture/service/GroupServiceImpl.java @@ -2,56 +2,77 @@ import java.util.Comparator; import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; -import com.flowingcode.fixture.repository.GroupRepository; -import com.flowingcode.fixture.repository.domain.GroupWrapper.Group; -import com.flowingcode.fixture.repository.domain.GroupWrapper.Group.TeamWrapper; -import com.flowingcode.fixture.repository.domain.GroupWrapper.Group.TeamWrapper.Team; +import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.Group; +import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.GroupTeam; +import com.flowingcode.fixture.repository.worldcup.TeamCatalog; +import com.flowingcode.fixture.repository.worldcup.WorldCupClient; import com.flowingcode.fixture.view.model.GroupDetailDto; import com.flowingcode.fixture.view.model.GroupDto; @Service public class GroupServiceImpl implements GroupService { - @Autowired - private GroupRepository groupRepository; + private static final Comparator STANDINGS_ORDER = + Comparator.comparingInt(GroupDetailDto::getPoints).reversed() + .thenComparing(Comparator.comparingInt(GroupDetailDto::getGoalDifference).reversed()) + .thenComparing(Comparator.comparingInt(GroupDetailDto::getGoalsFor).reversed()); + + private final WorldCupClient client; + + private final TeamCatalog teamCatalog; + + public GroupServiceImpl(final WorldCupClient client, final TeamCatalog teamCatalog) { + this.client = client; + this.teamCatalog = teamCatalog; + } @Override @Cacheable("groups") public List getGroups() { - return groupRepository.getGroups().stream().map(this::convertGroup).sorted(Comparator.comparing(GroupDto::getGroupName)).collect(Collectors.toList()); + return client.getGroups().stream().map(this::convertGroup) + .sorted(Comparator.comparing(GroupDto::getGroupName)).collect(Collectors.toList()); } protected GroupDto convertGroup(final Group source) { final GroupDto target = new GroupDto(); - target.setGroupName(source.letter); - final AtomicInteger position = new AtomicInteger(1); - target.setDetails( - source.ordered_teams.stream().map(e -> convert(e, position)).sorted(Comparator.comparingInt(GroupDetailDto::getPosition)).collect(Collectors.toList())); + target.setGroupName(source.name()); + final List details = source.teams().stream().map(this::convert) + .sorted(STANDINGS_ORDER).collect(Collectors.toList()); + int position = 1; + for (final GroupDetailDto detail : details) { + detail.setPosition(position++); + } + target.setDetails(details); return target; } - protected GroupDetailDto convert(final TeamWrapper.Team source, final AtomicInteger position) { + protected GroupDetailDto convert(final GroupTeam source) { final GroupDetailDto target = new GroupDetailDto(); - target.setTeamName(source.country); - target.setFifaCode(source.fifa_code); - target.setGoalDifference(source.goal_differential); - target.setGoalsAgainst(source.goals_against); - target.setGoalsFor(source.goals_for); - target.setMatchesPlayed(source.games_played); - target.setMatchesDrawn(source.draws); - target.setMatchesLost(source.losses); - target.setMatchesWon(source.wins); - target.setPoints(source.points); - target.setPosition(position.getAndIncrement()); - target.setTeamLogo(FlagUtils.getFlagForFifaCode(source.fifa_code)); + target.setTeamName(teamCatalog.nameById(source.team_id())); + target.setFifaCode(teamCatalog.codeById(source.team_id())); + target.setTeamLogo(teamCatalog.flagById(source.team_id())); + target.setMatchesPlayed(parseInt(source.mp())); + target.setMatchesWon(parseInt(source.w())); + target.setMatchesDrawn(parseInt(source.d())); + target.setMatchesLost(parseInt(source.l())); + target.setGoalsFor(parseInt(source.gf())); + target.setGoalsAgainst(parseInt(source.ga())); + target.setGoalDifference(parseInt(source.gd())); + target.setPoints(parseInt(source.pts())); return target; } + private static int parseInt(final String value) { + try { + return value == null || value.isBlank() ? 0 : Integer.parseInt(value.trim()); + } catch (final NumberFormatException e) { + return 0; + } + } + } diff --git a/src/main/java/com/flowingcode/fixture/service/MatchServiceImpl.java b/src/main/java/com/flowingcode/fixture/service/MatchServiceImpl.java index 5ecdc37..9c29f92 100644 --- a/src/main/java/com/flowingcode/fixture/service/MatchServiceImpl.java +++ b/src/main/java/com/flowingcode/fixture/service/MatchServiceImpl.java @@ -1,93 +1,88 @@ package com.flowingcode.fixture.service; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; -import com.flowingcode.fixture.repository.MatchesRepository; -import com.flowingcode.fixture.repository.TeamRepository; -import com.flowingcode.fixture.repository.domain.Match; -import com.flowingcode.fixture.repository.domain.Match.Team; -import com.flowingcode.fixture.repository.domain.Match.Team_event; -import com.flowingcode.fixture.repository.domain.Match.Team_statistics; +import com.flowingcode.fixture.repository.domain.TeamEventType; +import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.Game; +import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.Team; +import com.flowingcode.fixture.repository.worldcup.StadiumCatalog; +import com.flowingcode.fixture.repository.worldcup.TeamCatalog; +import com.flowingcode.fixture.repository.worldcup.WorldCupClient; import com.flowingcode.fixture.view.enums.MatchStatus; import com.flowingcode.fixture.view.model.MatchDetailDto; import com.flowingcode.fixture.view.model.MatchResultDto; import com.flowingcode.fixture.view.model.TeamDto; import com.flowingcode.fixture.view.model.TeamEventDto; -import com.flowingcode.fixture.view.model.TeamStatisticsDto; @Service public class MatchServiceImpl implements MatchService { - private static final String FUTURE = "future"; + private static final DateTimeFormatter LOCAL_DATE = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm"); - private static final String COMPLETED = "completed"; + /** Host-region timezone used to interpret the API's local kick-off times. */ + private static final ZoneId ZONE = ZoneId.of("America/New_York"); - private static final String IN_PROGRESS = "in progress"; + private static final String NULL = "null"; - private static final String FULL_TIME = "full-time"; + private final WorldCupClient client; - private static final Predicate IS_IN_PROGRESS = match -> IN_PROGRESS.equals(match.status) && !FULL_TIME.equals(match.time); + private final TeamCatalog teamCatalog; - private static final Predicate IS_COMPLETED = match -> COMPLETED.equals(match.status) || FULL_TIME.equals(match.time); - - private static final Predicate IS_FUTURE = match -> FUTURE.equals(match.status); - - @Autowired - private MatchesRepository matchesRepository; - - @Autowired - private TeamRepository teamRepository; - - private Map countryGroupMap; + private final StadiumCatalog stadiumCatalog; private volatile List matchDates; + public MatchServiceImpl(final WorldCupClient client, final TeamCatalog teamCatalog, final StadiumCatalog stadiumCatalog) { + this.client = client; + this.teamCatalog = teamCatalog; + this.stadiumCatalog = stadiumCatalog; + } + @Override @Cacheable("matches") public List getMatches() { - return matchesRepository.getMatches().stream().map(this::convert).collect(Collectors.toList()); + return client.getGames().stream().map(this::convert).collect(Collectors.toList()); } @Override public List getCurrentMatches() { - return matchesRepository.getCurrentMatches().stream().map(this::convert).collect(Collectors.toList()); + return client.getGames().stream().filter(this::isInProgress).map(this::convert).collect(Collectors.toList()); } - protected MatchResultDto convert(final Match source) { + protected MatchResultDto convert(final Game source) { final MatchResultDto target = new MatchResultDto(); - target.setAwayTeam(source.away_team.country); - target.setAwayTeamFlag(FlagUtils.getFlagForFifaCode(source.away_team.code)); - target.setAwayTeamGoals(String.valueOf(source.away_team.goals)); - target.setAwayTeamCode(source.away_team.code); - target.setHomeTeam(source.home_team.country); - target.setHomeTeamFlag(FlagUtils.getFlagForFifaCode(source.home_team.code)); - target.setHomeTeamGoals(String.valueOf(source.home_team.goals)); - target.setHomeTeamCode(source.home_team.code); - target.setKickoff(source.datetime); - target.setStage(source.location); - target.setFifaId(source.fifa_id); - target.setGroupName(getGroup(source.away_team.code)); - target.setStageName(source.stage_name); - if (IS_IN_PROGRESS.test(source)) { - target.setStatus(MatchStatus.IN_PROGRESS); - target.setMinutes(source.time); - } - if (IS_COMPLETED.test(source)) { - target.setStatus(MatchStatus.COMPLETED); - } - if (IS_FUTURE.test(source)) { - target.setStatus(MatchStatus.FUTURE); + final ZonedDateTime kickoff = parseKickoff(source); + target.setHomeTeam(teamName(source.home_team_id(), source.home_team_name_en())); + target.setAwayTeam(teamName(source.away_team_id(), source.away_team_name_en())); + target.setHomeTeamCode(teamCatalog.codeById(source.home_team_id())); + target.setAwayTeamCode(teamCatalog.codeById(source.away_team_id())); + target.setHomeTeamFlag(teamCatalog.flagById(source.home_team_id())); + target.setAwayTeamFlag(teamCatalog.flagById(source.away_team_id())); + target.setHomeTeamGoals(goals(source.home_score())); + target.setAwayTeamGoals(goals(source.away_score())); + target.setKickoff(kickoff); + target.setStage(stage(source)); + target.setStageName(stageName(source)); + target.setGroupName(source.group()); + target.setFifaId(source.id()); + final MatchStatus status = status(source, kickoff); + target.setStatus(status); + if (status == MatchStatus.IN_PROGRESS) { + target.setMinutes(source.time_elapsed()); } return target; } @@ -95,117 +90,211 @@ protected MatchResultDto convert(final Match source) { @Override @Cacheable("matchDetail") public Optional getByFifaId(final String fifaId) { - return matchesRepository.getByFifaID(fifaId).map(this::convertToDetail); + return client.getGames().stream().filter(g -> fifaId.equals(g.id())).findFirst().map(this::convertToDetail); } - protected MatchDetailDto convertToDetail(final Match source) { + protected MatchDetailDto convertToDetail(final Game source) { final MatchDetailDto target = new MatchDetailDto(); - target.setAwayTeamDto(createTeamDto(source.away_team)); - target.setAwayTeamEvents(source.away_team_events.stream().map(this::createTeamEventDto).collect(Collectors.toList())); - target.setAwayTeamStatistics(createTeamsStatisticsDto(source.away_team_statistics)); - target.setHomeTeamDto(createTeamDto(source.home_team)); - target.setHomeTeamEvents(source.home_team_events.stream().map(this::createTeamEventDto).collect(Collectors.toList())); - target.setHomeTeamStatistics(createTeamsStatisticsDto(source.home_team_statistics)); - target.setDateTime(source.datetime); - target.setId(source.fifa_id); - target.setLocation(source.location); - target.setVenue(source.venue); - target.setGroup(getGroup(source.away_team.code)); - target.setStageName(source.stage_name); - if (IS_IN_PROGRESS.test(source)) { - target.setStatus(MatchStatus.IN_PROGRESS); - target.setMinutes(source.time); - } - if (IS_COMPLETED.test(source)) { - target.setStatus(MatchStatus.COMPLETED); - } - if (IS_FUTURE.test(source)) { - target.setStatus(MatchStatus.FUTURE); + final ZonedDateTime kickoff = parseKickoff(source); + target.setHomeTeamDto(createTeamDto(source.home_team_id(), source.home_team_name_en(), source.home_score())); + target.setAwayTeamDto(createTeamDto(source.away_team_id(), source.away_team_name_en(), source.away_score())); + target.setHomeTeamEvents(scorerEvents(source.home_scorers())); + target.setAwayTeamEvents(scorerEvents(source.away_scorers())); + target.setDateTime(kickoff); + target.setId(source.id()); + target.setVenue(stadiumCatalog.venueById(source.stadium_id())); + target.setLocation(stadiumCatalog.cityById(source.stadium_id())); + target.setGroup(source.group()); + target.setStageName(stageName(source)); + final MatchStatus status = status(source, kickoff); + target.setStatus(status); + if (status == MatchStatus.IN_PROGRESS) { + target.setMinutes(source.time_elapsed()); } - target.setTime(source.time); - target.setWinner(source.winner); - target.setWinnerCode(source.winner_code); + target.setTime(source.time_elapsed()); return target; } - protected TeamDto createTeamDto(final Team source) { + protected TeamDto createTeamDto(final String teamId, final String nameEn, final String score) { final TeamDto target = new TeamDto(); - target.setCode(source.code); - target.setCountry(source.country); - target.setGoals(String.valueOf(source.goals)); - return target; - } - - protected TeamEventDto createTeamEventDto(final Team_event source) { - final TeamEventDto target = new TeamEventDto(); - target.setId(String.valueOf(source.id)); - target.setPlayer(source.player); - target.setTime(source.time); - target.setTypeOfEvent(source.type_of_event); + target.setCode(teamCatalog.codeById(teamId)); + target.setCountry(teamName(teamId, nameEn)); + target.setGoals(goals(score)); return target; } - protected TeamStatisticsDto createTeamsStatisticsDto(final Team_statistics source) { - final TeamStatisticsDto target = new TeamStatisticsDto(); - target.setAttemptsOnGoal(source.attempts_on_goal); - target.setBallPossession(source.ball_possession); - target.setBallsRecovered(source.balls_recovered); - target.setBlocked(source.blocked); - target.setClearances(source.clearances); - target.setCorners(source.corners); - target.setCountry(source.country); - target.setDistanceCovered(source.distance_covered); - target.setFoulsCommitted(source.fouls_committed); - target.setNumPasses(source.num_passes); - target.setOffsides(source.offsides); - target.setOffTarget(source.off_target); - target.setOnTarget(source.on_target); - target.setPassAccuracy(source.pass_accuracy); - target.setPassesCompleted(source.passes_completed); - target.setRedCards(source.red_cards); - target.setTackles(source.tackles); - target.setWoodwork(source.woodwork); - target.setYellowCards(source.yellow_cards); - return target; + /** worldcup26.ir only exposes goal scorers (as a name list), not full event feeds. */ + private List scorerEvents(final String scorers) { + final List events = new ArrayList<>(); + if (scorers == null || scorers.isBlank() || NULL.equalsIgnoreCase(scorers)) { + return events; + } + for (final String name : scorers.split(",")) { + final String player = name.trim(); + if (player.isEmpty()) { + continue; + } + final TeamEventDto event = new TeamEventDto(); + event.setTypeOfEvent(TeamEventType.GOAL); + event.setPlayer(player); + event.setTime(""); + event.setId(player); + events.add(event); + } + return events; } @Override @Cacheable("matchesByCountry") public List getByCountryCode(final String fifaCode) { - return matchesRepository.getByCountryCode(fifaCode).stream().map(this::convert).collect(Collectors.toList()); - } - - protected String getGroup(final String countryCode) { - if (countryGroupMap == null) { - countryGroupMap = teamRepository.getTeams().stream().collect(Collectors.toMap(t -> t.fifa_code, t -> t.group_letter)); + final Team team = teamCatalog.byCode(fifaCode); + if (team == null) { + return Collections.emptyList(); } - return countryGroupMap.get(countryCode); + final String id = team.id(); + return client.getGames().stream() + .filter(g -> id.equals(g.home_team_id()) || id.equals(g.away_team_id())) + .map(this::convert).collect(Collectors.toList()); } @Override @Cacheable("futureMatches") public List getFutureMatches(final LocalDate startDate, final LocalDate endDate) { - return matchesRepository.getMatches(startDate, endDate).stream().filter(m -> !m.home_team.code.equals("TBD") || !m.away_team.code.equals("TBD")) - .map(this::convert).collect(Collectors.toList()); + return client.getGames().stream().map(this::convert) + .filter(m -> { + final LocalDate date = m.getKickoff().toLocalDate(); + return !date.isBefore(startDate) && !date.isAfter(endDate); + }) + .collect(Collectors.toList()); } @Override public List getMatchDates() { if (matchDates == null) { - final List matchDates = matchesRepository.getMatches().stream() - .map(m -> m.datetime) + this.matchDates = Collections.unmodifiableList(client.getGames().stream() + .map(this::parseKickoff) .map(ZonedDateTime::toLocalDate) .sorted() .distinct() - .collect(Collectors.toList()); - this.matchDates = Collections.unmodifiableList(matchDates); + .collect(Collectors.toList())); } return matchDates; } @Override public List getMatchesByDate(final LocalDate date) { - return matchesRepository.getMatches(date, date).stream().map(this::convert).collect(Collectors.toList()); + return client.getGames().stream().map(this::convert) + .filter(m -> m.getKickoff().toLocalDate().equals(date)) + .collect(Collectors.toList()); + } + + // --- helpers ------------------------------------------------------------- + + private String teamName(final String teamId, final String nameEn) { + return nameEn != null && !nameEn.isBlank() ? nameEn : teamCatalog.nameById(teamId); + } + + private ZonedDateTime parseKickoff(final Game source) { + try { + return LocalDateTime.parse(source.local_date(), LOCAL_DATE).atZone(ZONE); + } catch (final RuntimeException e) { + return ZonedDateTime.now(ZONE); + } + } + + private boolean isFinished(final Game source) { + return "TRUE".equalsIgnoreCase(source.finished()); + } + + private boolean isInProgress(final Game source) { + if (isFinished(source)) { + return false; + } + final String elapsed = source.time_elapsed(); + return elapsed != null && !elapsed.isBlank() && !"notstarted".equalsIgnoreCase(elapsed); + } + + private MatchStatus status(final Game source, final ZonedDateTime kickoff) { + if (isFinished(source)) { + return MatchStatus.COMPLETED; + } + if (isInProgress(source)) { + return MatchStatus.IN_PROGRESS; + } + if (kickoff.toLocalDate().equals(LocalDate.now())) { + return MatchStatus.TODAY; + } + return MatchStatus.FUTURE; + } + + private String goals(final String score) { + return score == null || NULL.equalsIgnoreCase(score) ? "0" : score; + } + + private boolean isGroupStage(final Game source) { + return source.type() == null || "group".equalsIgnoreCase(source.type()); + } + + private String stageName(final Game source) { + return isGroupStage(source) ? "First stage" : prettyStage(source.type()); + } + + private String stage(final Game source) { + return isGroupStage(source) ? "Matchday " + source.matchday() : stageName(source); + } + + private static final Pattern ROUND_OF = Pattern.compile("(?i)round[\\s_-]*(?:of)?[\\s_-]*(\\d+)"); + + /** + * Maps the API's {@code type} value to a human-readable stage name. It is + * data-driven: known knockout stages get canonical labels, and any other + * value (including ones not yet seen, since the API currently only returns + * "group") is humanized rather than hardcoded — so it renders sensibly the + * moment knockout fixtures appear, with no code change required. + */ + private String prettyStage(final String type) { + final String trimmed = type.trim(); + final Matcher round = ROUND_OF.matcher(trimmed); + if (round.matches()) { + return "Round of " + round.group(1); + } + switch (trimmed.toLowerCase().replaceAll("[\\s_-]", "")) { + case "quarter": + case "quarterfinal": + case "quarterfinals": + return "Quarter-finals"; + case "semi": + case "semifinal": + case "semifinals": + return "Semi-finals"; + case "third": + case "thirdplace": + return "Third place"; + case "final": + return "Final"; + default: + return humanize(trimmed); + } + } + + /** Turns an arbitrary token ("roundOf16", "quarter_final", "play-off") into "Title Case Words". */ + private String humanize(final String value) { + final String spaced = value + .replaceAll("([a-z])([A-Z])", "$1 $2") + .replaceAll("([A-Za-z])(\\d)", "$1 $2") + .replaceAll("[_-]+", " ") + .trim(); + final StringBuilder builder = new StringBuilder(); + for (final String word : spaced.split("\\s+")) { + if (word.isEmpty()) { + continue; + } + if (builder.length() > 0) { + builder.append(' '); + } + builder.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1).toLowerCase()); + } + return builder.length() == 0 ? value : builder.toString(); } } diff --git a/src/main/java/com/flowingcode/fixture/view/component/GroupView.java b/src/main/java/com/flowingcode/fixture/view/component/GroupView.java index 06597f8..aededa9 100644 --- a/src/main/java/com/flowingcode/fixture/view/component/GroupView.java +++ b/src/main/java/com/flowingcode/fixture/view/component/GroupView.java @@ -1,49 +1,48 @@ package com.flowingcode.fixture.view.component; -import com.flowingcode.addons.applayout.PaperCard; import com.flowingcode.fixture.view.model.GroupDetailDto; import com.flowingcode.fixture.view.model.GroupDto; import com.flowingcode.fixture.view.screen.CountryScreen; -import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.card.Card; import com.vaadin.flow.component.html.Anchor; import com.vaadin.flow.component.html.Image; -import com.vaadin.flow.component.html.Label; +import com.vaadin.flow.component.html.NativeLabel; import com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; -public class GroupView extends PaperCard { +@SuppressWarnings("serial") +public class GroupView extends Card { - final Label groupName = new Label(); + final NativeLabel groupName = new NativeLabel(); final VerticalLayout groupContainer = new VerticalLayout(); public GroupView() { addClassName("group"); addClassName("common-card"); - setPadding(false); - final Label position = new Label("Pos"); + final NativeLabel position = new NativeLabel("Pos"); position.addClassName("stat"); - final Label logo = new Label(); + final NativeLabel logo = new NativeLabel(); logo.addClassName("logo"); - final Label team = new Label("Team"); + final NativeLabel team = new NativeLabel("Team"); team.addClassName("team"); - final Label matchesPlayed = new Label("MP"); + final NativeLabel matchesPlayed = new NativeLabel("MP"); matchesPlayed.addClassName("stat"); - final Label won = new Label("W"); + final NativeLabel won = new NativeLabel("W"); won.addClassNames("stat", "stat-hidden"); - final Label drawn = new Label("D"); + final NativeLabel drawn = new NativeLabel("D"); drawn.addClassNames("stat", "stat-hidden"); - final Label lost = new Label("L"); + final NativeLabel lost = new NativeLabel("L"); lost.addClassNames("stat", "stat-hidden"); - final Label goalDifference = new Label("GD"); + final NativeLabel goalDifference = new NativeLabel("GD"); goalDifference.addClassNames("stat", "stat-hidden"); - final Label goalsFor = new Label("GF"); + final NativeLabel goalsFor = new NativeLabel("GF"); goalsFor.addClassName("stat"); - final Label goalsAgainst = new Label("GA"); + final NativeLabel goalsAgainst = new NativeLabel("GA"); goalsAgainst.addClassName("stat"); - final Label points = new Label("Pts"); + final NativeLabel points = new NativeLabel("Pts"); points.addClassName("stat"); final HorizontalLayout groupHeader = new HorizontalLayout(position, logo, team, matchesPlayed, won, drawn, lost, goalDifference, goalsFor, @@ -60,7 +59,7 @@ public GroupView() { final VerticalLayout cardContent = new VerticalLayout(groupName, groupContainer); cardContent.setMargin(false); cardContent.setPadding(false); - setCardContent(cardContent); + add(cardContent); } public void init(final GroupDto group) { @@ -75,29 +74,29 @@ public void init(final GroupDto group) { } private HorizontalLayout buildGroupDetail(final GroupDetailDto group) { - final Label positionLabel = new Label(group.getPosition() == null ? "" : group.getPosition() + ""); + final NativeLabel positionLabel = new NativeLabel(group.getPosition() == null ? "" : group.getPosition() + ""); positionLabel.addClassName("stat"); final Image logo = new Image(group.getTeamLogo(), group.getTeamName()); logo.addClassName("logo"); - final String route = UI.getCurrent().getRouter().getUrl(CountryScreen.class, group.getFifaCode()); + final String route = "/" + CountryScreen.COUNTRY_ROUTE + "/" + group.getFifaCode(); final Anchor team = new Anchor(route, group.getTeamName()); team.addClassName("team"); - final Label matchesPlayed = new Label(group.getMatchesPlayed() + ""); + final NativeLabel matchesPlayed = new NativeLabel(group.getMatchesPlayed() + ""); matchesPlayed.addClassName("stat"); - final Label matchesWon = new Label(group.getMatchesWon() + ""); + final NativeLabel matchesWon = new NativeLabel(group.getMatchesWon() + ""); matchesWon.addClassNames("stat", "stat-hidden"); - final Label matchesDrawn = new Label(group.getMatchesDrawn() + ""); + final NativeLabel matchesDrawn = new NativeLabel(group.getMatchesDrawn() + ""); matchesDrawn.addClassNames("stat", "stat-hidden"); - final Label matchesLost = new Label(group.getMatchesLost() + ""); + final NativeLabel matchesLost = new NativeLabel(group.getMatchesLost() + ""); matchesLost.addClassNames("stat", "stat-hidden"); - final Label goalDifference = new Label(group.getGoalDifference() + ""); + final NativeLabel goalDifference = new NativeLabel(group.getGoalDifference() + ""); goalDifference.addClassNames("stat", "stat-hidden"); - final Label goalsFor = new Label(group.getGoalsFor() + ""); + final NativeLabel goalsFor = new NativeLabel(group.getGoalsFor() + ""); goalsFor.addClassName("stat"); - final Label goalsAgainst = new Label(group.getGoalsAgainst() + ""); + final NativeLabel goalsAgainst = new NativeLabel(group.getGoalsAgainst() + ""); goalsAgainst.addClassName("stat"); - final Label points = new Label(group.getPoints() + ""); + final NativeLabel points = new NativeLabel(group.getPoints() + ""); points.addClassName("stat"); final HorizontalLayout detailContainer = new HorizontalLayout(positionLabel, logo, team, matchesPlayed, matchesWon, matchesDrawn, matchesLost, goalDifference, goalsFor, goalsAgainst, points); diff --git a/src/main/java/com/flowingcode/fixture/view/component/MarkedElement.java b/src/main/java/com/flowingcode/fixture/view/component/MarkedElement.java deleted file mode 100644 index 5f666db..0000000 --- a/src/main/java/com/flowingcode/fixture/view/component/MarkedElement.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.flowingcode.fixture.view.component; - -import com.vaadin.flow.component.Component; -import com.vaadin.flow.component.HasSize; -import com.vaadin.flow.component.Tag; -import com.vaadin.flow.component.dependency.HtmlImport; - -@SuppressWarnings("serial") -@HtmlImport("bower_components/marked-element/marked-element.html") -@Tag("marked-element") -public class MarkedElement extends Component implements HasSize { - - public MarkedElement(String markdown) { - this.setMarkdown(markdown); - setSizeFull(); - } - - public void setMarkdown(String markdown) { - this.getElement().setAttribute("markdown", markdown); - } - -} diff --git a/src/main/java/com/flowingcode/fixture/view/component/MatchResultComponent.java b/src/main/java/com/flowingcode/fixture/view/component/MatchResultComponent.java index 69dd74f..c3476a2 100644 --- a/src/main/java/com/flowingcode/fixture/view/component/MatchResultComponent.java +++ b/src/main/java/com/flowingcode/fixture/view/component/MatchResultComponent.java @@ -4,8 +4,6 @@ import org.apache.commons.lang3.StringUtils; -import com.flowingcode.addons.applayout.PaperCard; -import com.flowingcode.addons.applayout.menu.MenuItem; import com.flowingcode.fixture.view.enums.MatchStatus; import com.flowingcode.fixture.view.model.MatchResume; import com.flowingcode.fixture.view.screen.CountryScreen; @@ -16,16 +14,18 @@ import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.DetachEvent; import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.card.Card; import com.vaadin.flow.component.html.Anchor; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Image; -import com.vaadin.flow.component.html.Label; +import com.vaadin.flow.component.html.NativeLabel; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; @SuppressWarnings("serial") -public class MatchResultComponent extends PaperCard { +public class MatchResultComponent extends Card { public static final String WIDTH_50 = "50%"; @@ -41,11 +41,11 @@ public class MatchResultComponent extends PaperCard { private static final String DETAILS_BUTTON_CAPTION = "Details"; - final Label matchDateRight = new Label(); + final NativeLabel matchDateRight = new NativeLabel(); - final Label homeTeamGoals = new Label(); + final NativeLabel homeTeamGoals = new NativeLabel(); - final Label awayTeamGoals = new Label(); + final NativeLabel awayTeamGoals = new NativeLabel(); private final MatchResume matchResume; @@ -64,11 +64,10 @@ public MatchResultComponent(final MatchResume dto, final MatchUpdater matchUpdat this.matchUpdater = matchUpdater; addClassName("common-card"); - setCardContent(createContent(dto)); + add(createContent(dto)); if (ZonedDateTime.now().compareTo(dto.getKickoff()) > 0 && showDetailsButton) { - setCardActions(new MenuItem(DETAILS_BUTTON_CAPTION, () -> { - UI.getCurrent().navigate(MatchDetailScreen.class, dto.getFifaId()); - })); + addToFooter(new Button(DETAILS_BUTTON_CAPTION, e -> + UI.getCurrent().navigate(MatchDetailScreen.class, dto.getFifaId()))); } } @@ -77,7 +76,7 @@ public Div createContent(final MatchResume dto) { content.setId(DateTimeUtil.styleDate(dto.getKickoff())); // header: date & time - final Label matchDateLeft = new Label(getMatchDateLeft(dto)); + final NativeLabel matchDateLeft = new NativeLabel(getMatchDateLeft(dto)); matchDateLeft.addClassName("font-bold"); matchDateRight.addClassName("font-bold"); @@ -105,7 +104,7 @@ public Div createContent(final MatchResume dto) { homeTeamGoals.addClassName("results-numbers-font-style"); homeTeamGoals.addClassName("text-align-center"); homeTeamGoals.setWidth("12%"); - final Label resultSeparator = new Label(SEPARATOR); + final NativeLabel resultSeparator = new NativeLabel(SEPARATOR); resultSeparator.addClassName("results-numbers-font-style"); resultSeparator.addClassName("text-align-center"); resultSeparator.setWidth("6%"); @@ -120,7 +119,8 @@ public Div createContent(final MatchResume dto) { // match result final VerticalLayout homeTeamLayout = new VerticalLayout(homeTeamImage, homeTeamName); - homeTeamLayout.getElement().setAttribute("style", "margin:0px;width:35%"); + homeTeamLayout.getStyle().set("margin", "0px"); + homeTeamLayout.setWidth("35%"); homeTeamLayout.addClassName("align-items-center"); final VerticalLayout awayTeamLayout = new VerticalLayout(awayTeamImage, awayTeamName); awayTeamLayout.setWidth("35%"); @@ -140,21 +140,21 @@ public Div createContent(final MatchResume dto) { private HorizontalLayout createFooterLayout(final MatchResume dto) { final HorizontalLayout footerLayout; - final Label stage = new Label(dto.getStage()); - final Label footerSeparator = new Label(SEPARATOR); - final Label phase; + final NativeLabel stage = new NativeLabel(dto.getStage()); + final NativeLabel footerSeparator = new NativeLabel(SEPARATOR); + final NativeLabel phase; if (StringUtils.isNotBlank(dto.getGroupName()) && "First stage".equals(dto.getStageName())) { stage.setWidth(WIDTH_50); stage.addClassName("text-align-right"); - phase = new Label(GROUP_LABEL + dto.getGroupName()); + phase = new NativeLabel(GROUP_LABEL + dto.getGroupName()); phase.setWidth(WIDTH_50); phase.addClassName("text-align-left"); footerLayout = new HorizontalLayout(stage, footerSeparator, phase); } else if (StringUtils.isNotBlank(dto.getStageName())) { stage.setWidth(WIDTH_50); stage.addClassName("text-align-right"); - phase = new Label(dto.getStageName()); + phase = new NativeLabel(dto.getStageName()); phase.setWidth(WIDTH_50); phase.addClassName("text-align-left"); footerLayout = new HorizontalLayout(stage, footerSeparator, phase); diff --git a/src/main/java/com/flowingcode/fixture/view/component/RouteNotFoundErrorHandler.java b/src/main/java/com/flowingcode/fixture/view/component/RouteNotFoundErrorHandler.java index 1cef9d3..88b8104 100644 --- a/src/main/java/com/flowingcode/fixture/view/component/RouteNotFoundErrorHandler.java +++ b/src/main/java/com/flowingcode/fixture/view/component/RouteNotFoundErrorHandler.java @@ -1,6 +1,6 @@ package com.flowingcode.fixture.view.component; -import javax.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponse; import com.flowingcode.fixture.view.screen.MainLayout; import com.vaadin.flow.component.Tag; diff --git a/src/main/java/com/flowingcode/fixture/view/component/RuntimeErrorHandler.java b/src/main/java/com/flowingcode/fixture/view/component/RuntimeErrorHandler.java index 5152838..bae3e96 100644 --- a/src/main/java/com/flowingcode/fixture/view/component/RuntimeErrorHandler.java +++ b/src/main/java/com/flowingcode/fixture/view/component/RuntimeErrorHandler.java @@ -1,6 +1,6 @@ package com.flowingcode.fixture.view.component; -import javax.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponse; import com.flowingcode.fixture.infra.HasLogger; import com.flowingcode.fixture.view.screen.MainLayout; diff --git a/src/main/java/com/flowingcode/fixture/view/component/TitleComponent.java b/src/main/java/com/flowingcode/fixture/view/component/TitleComponent.java index 08cd41c..b311375 100644 --- a/src/main/java/com/flowingcode/fixture/view/component/TitleComponent.java +++ b/src/main/java/com/flowingcode/fixture/view/component/TitleComponent.java @@ -1,14 +1,14 @@ package com.flowingcode.fixture.view.component; -import com.flowingcode.addons.applayout.PaperCard; import com.flowingcode.fixture.view.util.CssStyles; +import com.vaadin.flow.component.card.Card; import com.vaadin.flow.component.html.H3; import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.function.SerializableRunnable; @SuppressWarnings("serial") -public class TitleComponent extends PaperCard { +public class TitleComponent extends Card { private final SerializableRunnable searchListener; @@ -27,7 +27,7 @@ public TitleComponent(final String description, final SerializableRunnable searc addSearchIcon(title); } - setCardContent(title); + add(title); } private void addSearchIcon(final H3 title) { diff --git a/src/main/java/com/flowingcode/fixture/view/model/EventGridDto.java b/src/main/java/com/flowingcode/fixture/view/model/EventGridDto.java deleted file mode 100644 index 367ccfa..0000000 --- a/src/main/java/com/flowingcode/fixture/view/model/EventGridDto.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.flowingcode.fixture.view.model; - -/** - * Model for events grid - * - * @author mlopez - * - */ -public class EventGridDto { - - private TeamEventDto homeTeamEvent; - private TeamEventDto awayTeamEvent; - - public EventGridDto(TeamEventDto homeTeamEvent, TeamEventDto awayTeamEvent) { - this.homeTeamEvent = homeTeamEvent; - this.awayTeamEvent = awayTeamEvent; - } - public TeamEventDto getHomeTeamEvent() { - return homeTeamEvent; - } - public void setHomeTeamEvent(TeamEventDto homeTeamEvent) { - this.homeTeamEvent = homeTeamEvent; - } - public TeamEventDto getAwayTeamEvent() { - return awayTeamEvent; - } - public void setAwayTeamEvent(TeamEventDto awayTeamEvent) { - this.awayTeamEvent = awayTeamEvent; - } - -} diff --git a/src/main/java/com/flowingcode/fixture/view/presenter/WelcomePresenter.java b/src/main/java/com/flowingcode/fixture/view/presenter/WelcomePresenter.java index 79161da..c307f30 100644 --- a/src/main/java/com/flowingcode/fixture/view/presenter/WelcomePresenter.java +++ b/src/main/java/com/flowingcode/fixture/view/presenter/WelcomePresenter.java @@ -26,7 +26,8 @@ public void setView(final WelcomeScreen view) { } public void loadResults() { - view.init(matchService.getFutureMatches(LocalDate.of(2018, 7, 6), LocalDate.of(2018, 7, 31))); + // Opening matchdays of the 2026 tournament (runs Jun 11 - Jul 19, 2026). + view.init(matchService.getFutureMatches(LocalDate.of(2026, 6, 11), LocalDate.of(2026, 6, 15))); } } diff --git a/src/main/java/com/flowingcode/fixture/view/screen/AboutScreen.java b/src/main/java/com/flowingcode/fixture/view/screen/AboutScreen.java index 68ee901..812c590 100644 --- a/src/main/java/com/flowingcode/fixture/view/screen/AboutScreen.java +++ b/src/main/java/com/flowingcode/fixture/view/screen/AboutScreen.java @@ -1,10 +1,7 @@ package com.flowingcode.fixture.view.screen; -import org.springframework.beans.factory.annotation.Autowired; - -import com.flowingcode.addons.applayout.PaperCard; -import com.flowingcode.fixture.view.component.MarkedElement; -import com.flowingcode.fixture.view.presenter.WelcomePresenter; +import com.vaadin.flow.component.Html; +import com.vaadin.flow.component.card.Card; import com.vaadin.flow.component.html.H4; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.router.PageTitle; @@ -15,27 +12,34 @@ @PageTitle(value = MainLayout.SITE_TITLE) public class AboutScreen extends VerticalLayout { - @Autowired - public AboutScreen(final WelcomePresenter presenter) { + public AboutScreen() { this.setDefaultHorizontalComponentAlignment(Alignment.CENTER); final VerticalLayout vl = new VerticalLayout(); vl.setDefaultHorizontalComponentAlignment(Alignment.CENTER); - vl.add(new H4("Worldcup Stats Vaadin 10 Demo Application")); - final String markdown = "This is a demo application to try some new technologies:\r\n" + - "* Vaadin 10. You can learn more about it in [Vaadin's official site](https://www.vaadin.com/docs).\r\n" + - "* Spring framework. Find out more [here](https://spring.io/).\r\n" + - "* App Layout Addon for Vaadin 10. Find out more [here](https://vaadin.com/directory/component/app-layout-addon).\r\n" + - "* Polymer. Some components from [here](https://www.webcomponents.org), that you can easily integrate in Vaadin 10\r\n\r\n" + - "This application consumes the API from [http://worldcup.sfg.io/](http://worldcup.sfg.io/)\r\n\r\n" + - "Developed by [Flowing Code S.A.](https://www.flowingcode.com)"; - - vl.add(new MarkedElement(markdown)); - - final PaperCard pc = new PaperCard(vl); - - this.add(pc); - + vl.add(new H4("Global Football 2026 Stats - Vaadin 25 Demo Application")); + + final int year = 2026; + final String html = "
" + + "

This is a demo application showcasing:

" + + "" + + "

It shows the fixture and results of the 2026 international football tournament " + + "(Canada, USA & Mexico), with data from " + + "worldcup26.ir.

" + + "

Unofficial demo. Not affiliated with, endorsed by, or sponsored by FIFA or any " + + "football governing body. All team and tournament data comes from the public worldcup26.ir API.

" + + "

Developed by Flowing Code S.A. © " + year + "

" + + "
"; + vl.add(new Html(html)); + + final Card card = new Card(); + card.addClassName("common-card"); + card.add(vl); + this.add(card); } } diff --git a/src/main/java/com/flowingcode/fixture/view/screen/DateFilterDialog.java b/src/main/java/com/flowingcode/fixture/view/screen/DateFilterDialog.java index 12e6053..33a3cee 100644 --- a/src/main/java/com/flowingcode/fixture/view/screen/DateFilterDialog.java +++ b/src/main/java/com/flowingcode/fixture/view/screen/DateFilterDialog.java @@ -2,9 +2,6 @@ import java.time.LocalDate; import java.util.Objects; -import java.util.function.Consumer; - -import javax.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.BeanDefinition; @@ -12,12 +9,11 @@ import org.springframework.stereotype.Component; import com.flowingcode.fixture.service.MatchService; -import com.flowingcode.fixture.view.presenter.MatchesPresenter; import com.flowingcode.fixture.view.util.DateTimeUtil; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.dialog.Dialog; -import com.vaadin.flow.component.html.Label; +import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.function.SerializableConsumer; @@ -35,7 +31,7 @@ public class DateFilterDialog { public DateFilterDialog(@Autowired MatchService matchService) { dialog = new Dialog(); VerticalLayout layout = new VerticalLayout(); - layout.add(new Label("Filter by date")); + layout.add(new Span("Filter by date")); combobox = new ComboBox<>(); combobox.setItems(matchService.getMatchDates()); combobox.setItemLabelGenerator(DateTimeUtil::styleDate); diff --git a/src/main/java/com/flowingcode/fixture/view/screen/MainLayout.java b/src/main/java/com/flowingcode/fixture/view/screen/MainLayout.java index 93054d0..f7e78d3 100644 --- a/src/main/java/com/flowingcode/fixture/view/screen/MainLayout.java +++ b/src/main/java/com/flowingcode/fixture/view/screen/MainLayout.java @@ -3,45 +3,33 @@ import org.springframework.beans.factory.annotation.Autowired; import com.flowingcode.addons.applayout.AppLayout; -import com.flowingcode.addons.applayout.menu.MenuItem; +import com.flowingcode.addons.applayout.MenuItem; import com.flowingcode.fixture.view.util.MatchUpdater; import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.UI; -import com.vaadin.flow.component.dependency.HtmlImport; -import com.vaadin.flow.component.orderedlayout.VerticalLayout; -import com.vaadin.flow.component.page.Push; import com.vaadin.flow.router.PageTitle; -import com.vaadin.flow.router.RouterLayout; -import com.vaadin.flow.server.InitialPageSettings; -import com.vaadin.flow.server.PageConfigurator; -import com.vaadin.flow.shared.ui.LoadMode; /** - * The main view contains a simple label element and a template element. + * The main application layout: a Flowing Code AppLayout (which is itself a + * {@code RouterLayout}) hosting the navigation menu. Routed views are rendered + * inside it. */ @SuppressWarnings("serial") -@HtmlImport(value = "styles/shared-styles.html", loadMode = LoadMode.INLINE) -@Push @PageTitle(value = MainLayout.SITE_TITLE) -public class MainLayout extends VerticalLayout implements RouterLayout, PageConfigurator { +public class MainLayout extends AppLayout { - public static final String SITE_TITLE = "World Cup 2018 Stats - Flowing Code S.A."; + public static final String SITE_TITLE = "Global Football 2026 Stats - Flowing Code S.A."; @Autowired private MatchUpdater matchUpdater; public MainLayout() { - setMargin(false); - setSpacing(false); - setPadding(false); - final AppLayout app = new AppLayout("World Cup 2018 Stats"); - app.setMenuItems( + super("Global Football 2026 Stats"); + setMenuItems( new MenuItem("Home", () -> UI.getCurrent().navigate("")), new MenuItem("Matches", () -> UI.getCurrent().navigate("matches")), new MenuItem("Groups", () -> UI.getCurrent().navigate("groups")), new MenuItem("About ...", () -> UI.getCurrent().navigate("about"))); - - this.add(app); } @Override @@ -49,12 +37,4 @@ protected void onAttach(final AttachEvent attachEvent) { getUI().ifPresent(ui -> ui.addDetachListener(e -> matchUpdater.unregisterAll(e.getUI()))); } - @Override - public void configurePage(final InitialPageSettings settings) { - settings.addMetaTag("viewport", "width=device-width, initial-scale=1.0"); - settings.addLink("shortcut icon", "/frontend/images/favicons/favicon-96x96.png"); - settings.addLink("manifest", "/manifest.json"); - settings.addFavIcon("icon", "/frontend/images/favicons/favicon-96x96.png", "96x96"); - } - } diff --git a/src/main/java/com/flowingcode/fixture/view/screen/MatchDetailScreen.java b/src/main/java/com/flowingcode/fixture/view/screen/MatchDetailScreen.java index 620eb54..36b23ad 100644 --- a/src/main/java/com/flowingcode/fixture/view/screen/MatchDetailScreen.java +++ b/src/main/java/com/flowingcode/fixture/view/screen/MatchDetailScreen.java @@ -1,27 +1,25 @@ package com.flowingcode.fixture.view.screen; -import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.ArrayList; import java.util.List; +import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; -import com.flowingcode.addons.applayout.PaperCard; import com.flowingcode.fixture.view.component.MatchResultComponent; -import com.flowingcode.fixture.view.model.EventGridDto; import com.flowingcode.fixture.view.model.MatchDetailDto; import com.flowingcode.fixture.view.model.TeamEventDto; import com.flowingcode.fixture.view.presenter.MatchDetailPresenter; import com.flowingcode.fixture.view.util.MatchUpdater; -import com.vaadin.flow.component.grid.Grid; -import com.vaadin.flow.component.grid.Grid.Column; -import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.card.Card; import com.vaadin.flow.component.html.H4; -import com.vaadin.flow.component.html.Label; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; -import com.vaadin.flow.data.renderer.ComponentRenderer; import com.vaadin.flow.router.BeforeEvent; import com.vaadin.flow.router.HasUrlParameter; import com.vaadin.flow.router.PageTitle; @@ -32,104 +30,97 @@ @PageTitle(value = MainLayout.SITE_TITLE) public class MatchDetailScreen extends VerticalLayout implements HasUrlParameter { - private String matchId; - - private final Label date; - - private final Label status; + private static final DateTimeFormatter KICKOFF = DateTimeFormatter.ofPattern("EEE dd MMM yyyy, HH:mm"); private final MatchDetailPresenter presenter; - private final Grid grid; - private final MatchUpdater matchUpdater; - private final Column awayColumn; - - private final Column homeColumn; - - private final PaperCard pc; - @Autowired public MatchDetailScreen(final MatchDetailPresenter presenter, final MatchUpdater matchUpdater) { this.matchUpdater = matchUpdater; - presenter.setView(this); this.presenter = presenter; - final Div content = new Div(); - date = new Label(LocalDateTime.now().format(DateTimeFormatter.ISO_DATE)); - date.setSizeFull(); - status = new Label("Full-time"); - status.setWidth("100px"); - final HorizontalLayout hl = new HorizontalLayout(date, status); - hl.setSizeFull(); + presenter.setView(this); + setDefaultHorizontalComponentAlignment(Alignment.CENTER); + } - content.add(hl); + @Override + public void setParameter(final BeforeEvent event, final String parameter) { + presenter.loadResults(parameter); + } - grid = new Grid<>(); + public void init(final MatchDetailDto dto) { + removeAll(); - awayColumn = grid.addColumn(new ComponentRenderer(e -> createComponentRenderer(e.getAwayTeamEvent()))) - .setHeader("Away Team"); - homeColumn = grid.addColumn(new ComponentRenderer(e -> createComponentRenderer(e.getHomeTeamEvent()))) - .setHeader("Home Team"); + // Score card (teams, flags, score, kickoff, stage/group) — reused from the match list. + final MatchResultComponent score = new MatchResultComponent(dto, matchUpdater, false); + score.addClassName("common-card"); + add(score); - grid.setHeightByRows(true); + final Card detail = new Card(); + detail.addClassName("common-card"); + final VerticalLayout body = new VerticalLayout(); + body.setSpacing(true); - content.add(grid); - pc = new PaperCard(content); - pc.addClassName("common-card"); - setDefaultHorizontalComponentAlignment(Alignment.CENTER); - } + if (StringUtils.isNotBlank(dto.getVenue())) { + final String place = dto.getVenue() + (StringUtils.isNotBlank(dto.getLocation()) ? ", " + dto.getLocation() : ""); + body.add(infoLine(VaadinIcon.MAP_MARKER, place)); + } + if (dto.getDateTime() != null) { + body.add(infoLine(VaadinIcon.CALENDAR_CLOCK, dto.getDateTime().format(KICKOFF))); + } - private Div createComponentRenderer(final TeamEventDto event) { - final Div container = new Div(); - if (event != null) { - final Div firstLine = new Div(); - firstLine.setText(event.getTime() + " - " + event.getPlayer()); - firstLine.getElement().setAttribute("style", "text-align: center"); - final Div secondLine = new Div(); - secondLine.setText(event.getTypeOfEvent().getDisplay()); - secondLine.getElement().setAttribute("style", "font-weight: bold; text-align: center"); - container.add(firstLine, secondLine); - } else { - container.setText("-"); + final List homeGoals = dto.getHomeTeamEvents(); + final List awayGoals = dto.getAwayTeamEvents(); + if (!homeGoals.isEmpty() || !awayGoals.isEmpty()) { + final H4 goalsTitle = new H4("Goals"); + goalsTitle.getStyle().set("margin", "0"); + body.add(goalsTitle); + + final HorizontalLayout columns = new HorizontalLayout( + teamGoals(dto.getHomeTeam(), homeGoals), + teamGoals(dto.getAwayTeam(), awayGoals)); + columns.setWidthFull(); + body.add(columns); } - return container; + + detail.add(body); + add(detail); } - @Override - public void setParameter(final BeforeEvent event, final String parameter) { - matchId = parameter; - presenter.loadResults(matchId); + private Component infoLine(final VaadinIcon icon, final String text) { + final Icon vaadinIcon = icon.create(); + vaadinIcon.setSize("18px"); + final HorizontalLayout line = new HorizontalLayout(vaadinIcon, new Span(text)); + line.setDefaultVerticalComponentAlignment(Alignment.CENTER); + return line; } - public void init(final MatchDetailDto match) { - final List list = new ArrayList<>(); - final MatchDetailDto dto = match; - final MatchResultComponent mrc = new MatchResultComponent(dto, matchUpdater, false); - mrc.addClassName("common-card"); - this.add(mrc, pc); - final H4 awayHeader = new H4(dto.getAwayTeam()); - awayHeader.getElement().setAttribute("style", "text-align: center; margin: 9"); - final H4 homeHeader = new H4(dto.getHomeTeam()); - homeHeader.getElement().setAttribute("style", "text-align: center; margin: 9"); - awayColumn.setHeader(awayHeader); - homeColumn.setHeader(homeHeader); - - date.setText(dto.getDateTime().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"))); - final List ate = dto.getAwayTeamEvents(); - final List hte = dto.getHomeTeamEvents(); - if (ate.size() > hte.size()) { - ate.forEach(aevent -> list.add(new EventGridDto(null, aevent))); - hte.forEach(aevent -> list.get(hte.indexOf(aevent)).setHomeTeamEvent(aevent)); + private Component teamGoals(final String teamName, final List goals) { + final VerticalLayout column = new VerticalLayout(); + column.setPadding(false); + column.setSpacing(false); + column.setWidthFull(); + + final Span header = new Span(teamName); + header.addClassName("font-bold"); + column.add(header); + + if (goals.isEmpty()) { + final Span none = new Span("—"); + none.getStyle().set("color", "var(--lumo-secondary-text-color)"); + column.add(none); } else { - hte.forEach(aevent -> list.add(new EventGridDto(aevent, null))); - ate.forEach(aevent -> list.get(ate.indexOf(aevent)).setAwayTeamEvent(aevent)); + for (final TeamEventDto goal : goals) { + final String minute = StringUtils.isNotBlank(goal.getTime()) ? goal.getTime() + "' " : ""; + column.add(new Span("⚽ " + minute + goal.getPlayer())); + } } - grid.setItems(list); + return column; } public void goHome() { - getUI().get().navigate(WelcomeScreen.class); + getUI().ifPresent(ui -> ui.navigate(WelcomeScreen.class)); } } diff --git a/src/main/java/com/flowingcode/fixture/view/screen/MatchesScreen.java b/src/main/java/com/flowingcode/fixture/view/screen/MatchesScreen.java index 31baa35..87c8984 100644 --- a/src/main/java/com/flowingcode/fixture/view/screen/MatchesScreen.java +++ b/src/main/java/com/flowingcode/fixture/view/screen/MatchesScreen.java @@ -14,7 +14,7 @@ import com.flowingcode.fixture.view.util.MatchUpdater; import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.html.H3; -import com.vaadin.flow.component.html.Label; +import com.vaadin.flow.component.html.NativeLabel; import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.component.orderedlayout.VerticalLayout; @@ -57,7 +57,7 @@ public void init(final LocalDate date, final List results) { searchIcon.addClassName(CssStyles.CLICKABLE); searchIcon.getElement().addEventListener("click", e -> dateFilterDialog.open(presenter::filterByDate)); - this.add(new H3(new Label(titleCaption), searchIcon)); + this.add(new H3(new NativeLabel(titleCaption), searchIcon)); for (final MatchResultDto result : results) { final MatchResultComponent matchResultComponent = new MatchResultComponent(result, matchUpdater); diff --git a/src/main/webapp/frontend/images/favicons/android-icon-144x144.png b/src/main/resources/META-INF/resources/frontend/images/favicons/android-icon-144x144.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/android-icon-144x144.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/android-icon-144x144.png diff --git a/src/main/webapp/frontend/images/favicons/android-icon-192x192.png b/src/main/resources/META-INF/resources/frontend/images/favicons/android-icon-192x192.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/android-icon-192x192.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/android-icon-192x192.png diff --git a/src/main/webapp/frontend/images/favicons/android-icon-36x36.png b/src/main/resources/META-INF/resources/frontend/images/favicons/android-icon-36x36.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/android-icon-36x36.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/android-icon-36x36.png diff --git a/src/main/webapp/frontend/images/favicons/android-icon-48x48.png b/src/main/resources/META-INF/resources/frontend/images/favicons/android-icon-48x48.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/android-icon-48x48.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/android-icon-48x48.png diff --git a/src/main/webapp/frontend/images/favicons/android-icon-72x72.png b/src/main/resources/META-INF/resources/frontend/images/favicons/android-icon-72x72.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/android-icon-72x72.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/android-icon-72x72.png diff --git a/src/main/webapp/frontend/images/favicons/android-icon-96x96.png b/src/main/resources/META-INF/resources/frontend/images/favicons/android-icon-96x96.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/android-icon-96x96.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/android-icon-96x96.png diff --git a/src/main/webapp/frontend/images/favicons/apple-icon-114x114.png b/src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-114x114.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/apple-icon-114x114.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-114x114.png diff --git a/src/main/webapp/frontend/images/favicons/apple-icon-120x120.png b/src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-120x120.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/apple-icon-120x120.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-120x120.png diff --git a/src/main/webapp/frontend/images/favicons/apple-icon-144x144.png b/src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-144x144.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/apple-icon-144x144.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-144x144.png diff --git a/src/main/webapp/frontend/images/favicons/apple-icon-152x152.png b/src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-152x152.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/apple-icon-152x152.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-152x152.png diff --git a/src/main/webapp/frontend/images/favicons/apple-icon-180x180.png b/src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-180x180.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/apple-icon-180x180.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-180x180.png diff --git a/src/main/webapp/frontend/images/favicons/apple-icon-57x57.png b/src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-57x57.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/apple-icon-57x57.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-57x57.png diff --git a/src/main/webapp/frontend/images/favicons/apple-icon-60x60.png b/src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-60x60.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/apple-icon-60x60.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-60x60.png diff --git a/src/main/webapp/frontend/images/favicons/apple-icon-72x72.png b/src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-72x72.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/apple-icon-72x72.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-72x72.png diff --git a/src/main/webapp/frontend/images/favicons/apple-icon-76x76.png b/src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-76x76.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/apple-icon-76x76.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-76x76.png diff --git a/src/main/webapp/frontend/images/favicons/apple-icon-precomposed.png b/src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-precomposed.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/apple-icon-precomposed.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon-precomposed.png diff --git a/src/main/webapp/frontend/images/favicons/apple-icon.png b/src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/apple-icon.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/apple-icon.png diff --git a/src/main/webapp/frontend/images/favicons/favicon-16x16.png b/src/main/resources/META-INF/resources/frontend/images/favicons/favicon-16x16.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/favicon-16x16.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/favicon-16x16.png diff --git a/src/main/webapp/frontend/images/favicons/favicon-32x32.png b/src/main/resources/META-INF/resources/frontend/images/favicons/favicon-32x32.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/favicon-32x32.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/favicon-32x32.png diff --git a/src/main/webapp/frontend/images/favicons/favicon-96x96.png b/src/main/resources/META-INF/resources/frontend/images/favicons/favicon-96x96.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/favicon-96x96.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/favicon-96x96.png diff --git a/src/main/webapp/frontend/images/favicons/favicon.ico b/src/main/resources/META-INF/resources/frontend/images/favicons/favicon.ico similarity index 100% rename from src/main/webapp/frontend/images/favicons/favicon.ico rename to src/main/resources/META-INF/resources/frontend/images/favicons/favicon.ico diff --git a/src/main/webapp/frontend/images/favicons/icon-128x128.png b/src/main/resources/META-INF/resources/frontend/images/favicons/icon-128x128.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/icon-128x128.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/icon-128x128.png diff --git a/src/main/webapp/frontend/images/favicons/icon-144x144.png b/src/main/resources/META-INF/resources/frontend/images/favicons/icon-144x144.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/icon-144x144.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/icon-144x144.png diff --git a/src/main/webapp/frontend/images/favicons/icon-152x152.png b/src/main/resources/META-INF/resources/frontend/images/favicons/icon-152x152.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/icon-152x152.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/icon-152x152.png diff --git a/src/main/webapp/frontend/images/favicons/icon-192x192.png b/src/main/resources/META-INF/resources/frontend/images/favicons/icon-192x192.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/icon-192x192.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/icon-192x192.png diff --git a/src/main/webapp/frontend/images/favicons/icon-384x384.png b/src/main/resources/META-INF/resources/frontend/images/favicons/icon-384x384.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/icon-384x384.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/icon-384x384.png diff --git a/src/main/webapp/frontend/images/favicons/icon-512x512.png b/src/main/resources/META-INF/resources/frontend/images/favicons/icon-512x512.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/icon-512x512.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/icon-512x512.png diff --git a/src/main/webapp/frontend/images/favicons/icon-72x72.png b/src/main/resources/META-INF/resources/frontend/images/favicons/icon-72x72.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/icon-72x72.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/icon-72x72.png diff --git a/src/main/webapp/frontend/images/favicons/icon-96x96.png b/src/main/resources/META-INF/resources/frontend/images/favicons/icon-96x96.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/icon-96x96.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/icon-96x96.png diff --git a/src/main/webapp/frontend/images/favicons/ms-icon-144x144.png b/src/main/resources/META-INF/resources/frontend/images/favicons/ms-icon-144x144.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/ms-icon-144x144.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/ms-icon-144x144.png diff --git a/src/main/webapp/frontend/images/favicons/ms-icon-150x150.png b/src/main/resources/META-INF/resources/frontend/images/favicons/ms-icon-150x150.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/ms-icon-150x150.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/ms-icon-150x150.png diff --git a/src/main/webapp/frontend/images/favicons/ms-icon-310x310.png b/src/main/resources/META-INF/resources/frontend/images/favicons/ms-icon-310x310.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/ms-icon-310x310.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/ms-icon-310x310.png diff --git a/src/main/webapp/frontend/images/favicons/ms-icon-70x70.png b/src/main/resources/META-INF/resources/frontend/images/favicons/ms-icon-70x70.png similarity index 100% rename from src/main/webapp/frontend/images/favicons/ms-icon-70x70.png rename to src/main/resources/META-INF/resources/frontend/images/favicons/ms-icon-70x70.png diff --git a/src/main/webapp/manifest.json b/src/main/resources/META-INF/resources/manifest.json similarity index 94% rename from src/main/webapp/manifest.json rename to src/main/resources/META-INF/resources/manifest.json index 3dadfaf..bfda79f 100644 --- a/src/main/webapp/manifest.json +++ b/src/main/resources/META-INF/resources/manifest.json @@ -1,6 +1,6 @@ { - "name": "Worldcup Stats 2018", - "short_name": "Worldcup", + "name": "Global Football 2026 Stats", + "short_name": "Football2026", "theme_color": "#009100", "background_color": "#78079b", "display": "standalone", diff --git a/src/main/resources/META-INF/resources/styles.css b/src/main/resources/META-INF/resources/styles.css new file mode 100644 index 0000000..d7b7266 --- /dev/null +++ b/src/main/resources/META-INF/resources/styles.css @@ -0,0 +1,209 @@ +html { + --lumo-primary-color: #78079B; +} + +html, body { + margin: 0; + font-family: 'Roboto', 'Noto', sans-serif; + -webkit-font-smoothing: antialiased; + background: #f1f1f1; +} + +.hidden { + display: none; +} + +a { + color: inherit; +} + +/* Real hyperlinks in the About view should look like links, not body text. */ +.about-content a { + color: var(--lumo-primary-color); + text-decoration: underline; +} + +/* Group standings */ +.group { + box-shadow: 0 1px 6px rgba(32, 33, 36, 0.28); +} + +.group-container { + display: table; +} + +.group-title { + font-size: 16px; +} + +.group label.stat { + text-align: center; + padding: 0 10px; +} + +.group .team { + width: 100%; +} + +.group-header { + color: rgba(0, 0, 0, .54); + display: table-row; +} + +.group-detail-container { + border-bottom: 1px solid #DFE1E5; + width: 100%; + display: table-row; + height: 36px; +} + +.group-detail-container label, +.group-detail-container a { + display: table-cell; + padding: 0 8px; +} + +/* Logos / flags */ +.logo { + width: 24px; + line-height: 34px; + vertical-align: middle; +} + +.logo-result-card { + width: 80px; + line-height: 50px; + vertical-align: middle; +} + +/* Helpers */ +.align-items-center { + align-items: center; +} + +.results-numbers-font-style { + font-size: 30px; + font-weight: 800; +} + +.font-bold { + font-weight: bold; +} + +.text-align-left { + text-align: left; +} + +.text-align-right { + text-align: right; +} + +.text-align-center { + text-align: center; +} + +.centered { + text-align: center; +} + +.title-card { + margin-top: 0; + text-align: center; + margin-bottom: 0; +} + +.padding-left-10 { + padding-left: 10px; +} + +/* Cards (replaces former paper-card.common-card) */ +.common-card { + width: 700px; +} + +.match-detail-header { + font-size: 24px; + font-weight: 400; + line-height: 32px; +} + +.match-playing { + color: #259b24; +} + +.clickable { + cursor: pointer; +} + +/* jumping dots animation */ +.jumping-dots span { + position: relative; + bottom: 0px; + -webkit-animation: jump 1500ms infinite; + animation: jump 2s infinite; +} + +.jumping-dots .dot-1 { + -webkit-animation-delay: 200ms; + animation-delay: 200ms; +} + +.jumping-dots .dot-2 { + -webkit-animation-delay: 400ms; + animation-delay: 400ms; +} + +.jumping-dots .dot-3 { + -webkit-animation-delay: 600ms; + animation-delay: 600ms; +} + +@-webkit-keyframes jump { + 0% { bottom: 0px; } + 20% { bottom: 3px; } + 40% { bottom: 0px; } +} + +@keyframes jump { + 0% { bottom: 0px; } + 20% { bottom: 3px; } + 40% { bottom: 0px; } +} + +/* Extra small devices (phones, less than 768px) */ +@media (max-width: 767px) { + a { + color: inherit; + text-decoration: none !important; + } + + .common-card { + width: 100%; + } + + .group { + font-size: 14px; + } + + .group div.team { + width: 100%; + font-size: 14px; + } + + .group-header { + font-size: 12px; + } + + .group label.stat { + text-align: center; + padding: 0 6px; + } + + .group label.stat-hidden { + display: none; + } + + .logo-result-card { + width: 40px; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2db6b5f..23bc6e6 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,8 @@ -cron.match.refresh=0 */1 * * * * \ No newline at end of file +cron.match.refresh=0 */1 * * * * + +# Caching (Caffeine; 120s TTL mirrors the previous ehcache config) +spring.cache.type=caffeine +spring.cache.cache-names=matches,matchesByCountry,groups,matchDetail,futureMatches,teams +spring.cache.caffeine.spec=expireAfterWrite=120s,maximumSize=200 + +vaadin.frontend.hotdeploy=true diff --git a/src/main/resources/ehcache.xml b/src/main/resources/ehcache.xml deleted file mode 100644 index d10d02a..0000000 --- a/src/main/resources/ehcache.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - 120 - - 200 - - - - - 120 - - 200 - - - - - 120 - - 200 - - - - - 120 - - 200 - - - - - 120 - - 200 - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/arg.svg b/src/main/webapp/frontend/images/flags/arg.svg deleted file mode 100644 index 7a7e92b..0000000 --- a/src/main/webapp/frontend/images/flags/arg.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/webapp/frontend/images/flags/aus.svg b/src/main/webapp/frontend/images/flags/aus.svg deleted file mode 100644 index 9e17557..0000000 --- a/src/main/webapp/frontend/images/flags/aus.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/bel.svg b/src/main/webapp/frontend/images/flags/bel.svg deleted file mode 100644 index fc37b0c..0000000 --- a/src/main/webapp/frontend/images/flags/bel.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/bra.svg b/src/main/webapp/frontend/images/flags/bra.svg deleted file mode 100644 index 742dcf5..0000000 --- a/src/main/webapp/frontend/images/flags/bra.svg +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/webapp/frontend/images/flags/col.svg b/src/main/webapp/frontend/images/flags/col.svg deleted file mode 100644 index 832fcde..0000000 --- a/src/main/webapp/frontend/images/flags/col.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/crc.svg b/src/main/webapp/frontend/images/flags/crc.svg deleted file mode 100644 index 4d2b222..0000000 --- a/src/main/webapp/frontend/images/flags/crc.svg +++ /dev/null @@ -1,393 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/webapp/frontend/images/flags/cro.svg b/src/main/webapp/frontend/images/flags/cro.svg deleted file mode 100644 index f611703..0000000 --- a/src/main/webapp/frontend/images/flags/cro.svg +++ /dev/null @@ -1,241 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/den.svg b/src/main/webapp/frontend/images/flags/den.svg deleted file mode 100644 index 8dcd692..0000000 --- a/src/main/webapp/frontend/images/flags/den.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/main/webapp/frontend/images/flags/egy.svg b/src/main/webapp/frontend/images/flags/egy.svg deleted file mode 100644 index 668172a..0000000 --- a/src/main/webapp/frontend/images/flags/egy.svg +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/eng.svg b/src/main/webapp/frontend/images/flags/eng.svg deleted file mode 100644 index f95cc68..0000000 --- a/src/main/webapp/frontend/images/flags/eng.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/esp.svg b/src/main/webapp/frontend/images/flags/esp.svg deleted file mode 100644 index ec0808e..0000000 --- a/src/main/webapp/frontend/images/flags/esp.svg +++ /dev/null @@ -1,406 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/fra.svg b/src/main/webapp/frontend/images/flags/fra.svg deleted file mode 100644 index a4bded5..0000000 --- a/src/main/webapp/frontend/images/flags/fra.svg +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/src/main/webapp/frontend/images/flags/ger.svg b/src/main/webapp/frontend/images/flags/ger.svg deleted file mode 100644 index 4420470..0000000 --- a/src/main/webapp/frontend/images/flags/ger.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - Flag of Germany - - - - diff --git a/src/main/webapp/frontend/images/flags/irn.svg b/src/main/webapp/frontend/images/flags/irn.svg deleted file mode 100644 index a6f12e9..0000000 --- a/src/main/webapp/frontend/images/flags/irn.svg +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/isl.svg b/src/main/webapp/frontend/images/flags/isl.svg deleted file mode 100644 index 40c8e02..0000000 --- a/src/main/webapp/frontend/images/flags/isl.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/main/webapp/frontend/images/flags/jpn.svg b/src/main/webapp/frontend/images/flags/jpn.svg deleted file mode 100644 index 05ca699..0000000 --- a/src/main/webapp/frontend/images/flags/jpn.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/kor.svg b/src/main/webapp/frontend/images/flags/kor.svg deleted file mode 100644 index 18e339b..0000000 --- a/src/main/webapp/frontend/images/flags/kor.svg +++ /dev/null @@ -1,12 +0,0 @@ - - -Flag of South Korea - - - - - - - - - diff --git a/src/main/webapp/frontend/images/flags/ksa.svg b/src/main/webapp/frontend/images/flags/ksa.svg deleted file mode 100644 index 590766c..0000000 --- a/src/main/webapp/frontend/images/flags/ksa.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/main/webapp/frontend/images/flags/mar.svg b/src/main/webapp/frontend/images/flags/mar.svg deleted file mode 100644 index de409b7..0000000 --- a/src/main/webapp/frontend/images/flags/mar.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/mex.svg b/src/main/webapp/frontend/images/flags/mex.svg deleted file mode 100644 index 9296e1b..0000000 --- a/src/main/webapp/frontend/images/flags/mex.svg +++ /dev/null @@ -1,720 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/nga.svg b/src/main/webapp/frontend/images/flags/nga.svg deleted file mode 100644 index 2032c70..0000000 --- a/src/main/webapp/frontend/images/flags/nga.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/main/webapp/frontend/images/flags/pan.svg b/src/main/webapp/frontend/images/flags/pan.svg deleted file mode 100644 index 2e41454..0000000 --- a/src/main/webapp/frontend/images/flags/pan.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/per.svg b/src/main/webapp/frontend/images/flags/per.svg deleted file mode 100644 index 70b3629..0000000 --- a/src/main/webapp/frontend/images/flags/per.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/pol.svg b/src/main/webapp/frontend/images/flags/pol.svg deleted file mode 100644 index 909cb72..0000000 --- a/src/main/webapp/frontend/images/flags/pol.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/webapp/frontend/images/flags/por.svg b/src/main/webapp/frontend/images/flags/por.svg deleted file mode 100644 index 5c19329..0000000 --- a/src/main/webapp/frontend/images/flags/por.svg +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/rus.svg b/src/main/webapp/frontend/images/flags/rus.svg deleted file mode 100644 index 855b805..0000000 --- a/src/main/webapp/frontend/images/flags/rus.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/sen.svg b/src/main/webapp/frontend/images/flags/sen.svg deleted file mode 100644 index 52ea676..0000000 --- a/src/main/webapp/frontend/images/flags/sen.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/srb.svg b/src/main/webapp/frontend/images/flags/srb.svg deleted file mode 100644 index 45bb2a4..0000000 --- a/src/main/webapp/frontend/images/flags/srb.svg +++ /dev/null @@ -1,302 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/webapp/frontend/images/flags/sui.svg b/src/main/webapp/frontend/images/flags/sui.svg deleted file mode 100644 index 48bb387..0000000 --- a/src/main/webapp/frontend/images/flags/sui.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/swe.svg b/src/main/webapp/frontend/images/flags/swe.svg deleted file mode 100644 index 196a2a9..0000000 --- a/src/main/webapp/frontend/images/flags/swe.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/main/webapp/frontend/images/flags/tbd.svg b/src/main/webapp/frontend/images/flags/tbd.svg deleted file mode 100644 index 20d6986..0000000 --- a/src/main/webapp/frontend/images/flags/tbd.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/tun.svg b/src/main/webapp/frontend/images/flags/tun.svg deleted file mode 100644 index 72994e1..0000000 --- a/src/main/webapp/frontend/images/flags/tun.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/main/webapp/frontend/images/flags/uru.svg b/src/main/webapp/frontend/images/flags/uru.svg deleted file mode 100644 index e31aba8..0000000 --- a/src/main/webapp/frontend/images/flags/uru.svg +++ /dev/null @@ -1,39 +0,0 @@ - - -Flag of Uruguay - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/webapp/frontend/styles/shared-styles.html b/src/main/webapp/frontend/styles/shared-styles.html deleted file mode 100644 index f477c29..0000000 --- a/src/main/webapp/frontend/styles/shared-styles.html +++ /dev/null @@ -1,255 +0,0 @@ - - - - From 867d2ca6218ce7c5c5951b13744ab532891f5fa0 Mon Sep 17 00:00:00 2001 From: Paola De Bartolo Date: Thu, 11 Jun 2026 17:01:04 -0300 Subject: [PATCH 2/4] feat: drive live match updates with Vaadin shared signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the manual broadcaster (MatchUpdater + AccessUI) with Vaadin 25 shared signals. A single SharedMapSignal keyed by match id is updated by the scheduled poll; MatchResultComponent binds its score, minutes, status text and "playing" styling to the per-match signal, so a refresh propagates to every UI automatically — no static listener set, no register/unregister on attach/detach, no manual UI.access(). - Add LiveScore record and LiveScoreSignals holder. - Bind labels/CSS classes reactively in MatchResultComponent; drop the imperative class toggling and refresh() listener plumbing. - Inject LiveScoreSignals into the screens; remove the detach-listener cleanup from MainLayout (effects are torn down on detach by the framework). - Delete MatchUpdater and AccessUI. - Add unit tests for LiveScore.from and the MatchServiceImpl status/stage mapping. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fixture/view/component/AccessUI.java | 18 --- .../view/component/MatchResultComponent.java | 101 ++++++-------- .../fixture/view/model/LiveScore.java | 17 +++ .../fixture/view/screen/CountryScreen.java | 10 +- .../fixture/view/screen/MainLayout.java | 12 -- .../view/screen/MatchDetailScreen.java | 10 +- .../fixture/view/screen/MatchesScreen.java | 10 +- .../fixture/view/screen/WelcomeScreen.java | 14 +- .../fixture/view/util/LiveScoreSignals.java | 70 ++++++++++ .../fixture/view/util/MatchUpdater.java | 64 --------- .../fixture/service/MatchServiceImplTest.java | 126 ++++++++++++++++++ .../fixture/view/model/LiveScoreTest.java | 40 ++++++ 12 files changed, 313 insertions(+), 179 deletions(-) delete mode 100644 src/main/java/com/flowingcode/fixture/view/component/AccessUI.java create mode 100644 src/main/java/com/flowingcode/fixture/view/model/LiveScore.java create mode 100644 src/main/java/com/flowingcode/fixture/view/util/LiveScoreSignals.java delete mode 100644 src/main/java/com/flowingcode/fixture/view/util/MatchUpdater.java create mode 100644 src/test/java/com/flowingcode/fixture/service/MatchServiceImplTest.java create mode 100644 src/test/java/com/flowingcode/fixture/view/model/LiveScoreTest.java diff --git a/src/main/java/com/flowingcode/fixture/view/component/AccessUI.java b/src/main/java/com/flowingcode/fixture/view/component/AccessUI.java deleted file mode 100644 index 210e382..0000000 --- a/src/main/java/com/flowingcode/fixture/view/component/AccessUI.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.flowingcode.fixture.view.component; - -import com.vaadin.flow.component.Component; -import com.vaadin.flow.component.UI; -import com.vaadin.flow.server.Command; - -public class AccessUI { - - private AccessUI() { - } - - public static void on(final Component component, final Command action) { - UI ui = component.getUI().orElse(null); - if (ui!=null && ui.getSession()!=null) { - ui.access(action); - } - } -} diff --git a/src/main/java/com/flowingcode/fixture/view/component/MatchResultComponent.java b/src/main/java/com/flowingcode/fixture/view/component/MatchResultComponent.java index c3476a2..808d6e2 100644 --- a/src/main/java/com/flowingcode/fixture/view/component/MatchResultComponent.java +++ b/src/main/java/com/flowingcode/fixture/view/component/MatchResultComponent.java @@ -5,14 +5,13 @@ import org.apache.commons.lang3.StringUtils; import com.flowingcode.fixture.view.enums.MatchStatus; +import com.flowingcode.fixture.view.model.LiveScore; import com.flowingcode.fixture.view.model.MatchResume; import com.flowingcode.fixture.view.screen.CountryScreen; import com.flowingcode.fixture.view.screen.MatchDetailScreen; import com.flowingcode.fixture.view.util.CssStyles; import com.flowingcode.fixture.view.util.DateTimeUtil; -import com.flowingcode.fixture.view.util.MatchUpdater; -import com.vaadin.flow.component.AttachEvent; -import com.vaadin.flow.component.DetachEvent; +import com.flowingcode.fixture.view.util.LiveScoreSignals; import com.vaadin.flow.component.UI; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.card.Card; @@ -23,6 +22,7 @@ import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.signals.shared.SharedValueSignal; @SuppressWarnings("serial") public class MatchResultComponent extends Card { @@ -41,6 +41,8 @@ public class MatchResultComponent extends Card { private static final String DETAILS_BUTTON_CAPTION = "Details"; + final NativeLabel matchDateLeft = new NativeLabel(); + final NativeLabel matchDateRight = new NativeLabel(); final NativeLabel homeTeamGoals = new NativeLabel(); @@ -49,36 +51,39 @@ public class MatchResultComponent extends Card { private final MatchResume matchResume; - private final MatchUpdater matchUpdater; - private Span matchTimeContainer; private Span dotsContainer; - public MatchResultComponent(final MatchResume dto, final MatchUpdater matchUpdater) { - this(dto, matchUpdater, true); + public MatchResultComponent(final MatchResume dto, final LiveScoreSignals liveScores) { + this(dto, liveScores, true); } - public MatchResultComponent(final MatchResume dto, final MatchUpdater matchUpdater, final boolean showDetailsButton) { + public MatchResultComponent(final MatchResume dto, final LiveScoreSignals liveScores, final boolean showDetailsButton) { this.matchResume = dto; - this.matchUpdater = matchUpdater; + + // Bind every live-changing element to this match's shared signal. The + // bindings register effects that Vaadin tears down automatically on + // detach — no manual register/unregister, no UI.access(). + final SharedValueSignal live = liveScores.register(dto); addClassName("common-card"); - add(createContent(dto)); + add(createContent(dto, live)); if (ZonedDateTime.now().compareTo(dto.getKickoff()) > 0 && showDetailsButton) { addToFooter(new Button(DETAILS_BUTTON_CAPTION, e -> UI.getCurrent().navigate(MatchDetailScreen.class, dto.getFifaId()))); } } - public Div createContent(final MatchResume dto) { + public Div createContent(final MatchResume dto, final SharedValueSignal live) { final Div content = new Div(); content.setId(DateTimeUtil.styleDate(dto.getKickoff())); - // header: date & time - final NativeLabel matchDateLeft = new NativeLabel(getMatchDateLeft(dto)); + // header: date & time (both reflect the live status) matchDateLeft.addClassName("font-bold"); + matchDateLeft.bindText(live.map(this::matchDateLeftText)); matchDateRight.addClassName("font-bold"); + matchDateRight.bindText(live.map(this::matchDateRightText)); final Span dotSpan = new Span("."); dotSpan.addClassName("dot-1"); @@ -87,11 +92,14 @@ public Div createContent(final MatchResume dto) { final Span dotSpan3 = new Span("."); dotSpan3.addClassName("dot-3"); dotsContainer = new Span(dotSpan, dotSpan2, dotSpan3); - dotsContainer.addClassName(CssStyles.HIDDEN); matchTimeContainer = new Span(matchDateRight, dotsContainer); - matchDateRight.setText(getMatchDateRight(dto)); + // The "playing" styling and the jumping dots are now driven reactively + // by the match status, replacing the imperative class toggling. + matchTimeContainer.bindClassName(CssStyles.MATCH_PLAYING, live.map(this::isInProgress)); + dotsContainer.bindClassName(CssStyles.JUMPING_DOTS, live.map(this::isInProgress)); + dotsContainer.bindClassName(CssStyles.HIDDEN, live.map(ls -> !isInProgress(ls))); final HorizontalLayout headerLayout = new HorizontalLayout(matchDateLeft, matchTimeContainer); headerLayout.setFlexGrow(1.0, matchDateLeft); @@ -100,7 +108,7 @@ public Div createContent(final MatchResume dto) { homeTeamImage.addClassName("logo-result-card"); homeTeamImage.addClassName("group"); final Anchor homeTeamName = new Anchor("/" + CountryScreen.COUNTRY_ROUTE + "/" + dto.getHomeTeamCode(), dto.getHomeTeam()); - homeTeamGoals.setText(dto.getHomeTeamGoals()); + homeTeamGoals.bindText(live.map(LiveScore::homeGoals)); homeTeamGoals.addClassName("results-numbers-font-style"); homeTeamGoals.addClassName("text-align-center"); homeTeamGoals.setWidth("12%"); @@ -112,7 +120,7 @@ public Div createContent(final MatchResume dto) { awayTeamImage.addClassName("logo-result-card"); awayTeamImage.addClassName("group"); final Anchor awayTeamName = new Anchor("/" + CountryScreen.COUNTRY_ROUTE + "/" + dto.getAwayTeamCode(), dto.getAwayTeam()); - awayTeamGoals.setText(dto.getAwayTeamGoals()); + awayTeamGoals.bindText(live.map(LiveScore::awayGoals)); awayTeamGoals.addClassName("results-numbers-font-style"); awayTeamGoals.addClassName("text-align-center"); awayTeamGoals.setWidth("12%"); @@ -166,68 +174,35 @@ private HorizontalLayout createFooterLayout(final MatchResume dto) { return footerLayout; } - private String getMatchDateLeft(final MatchResume dto) { - switch (dto.getStatus()) { + private boolean isInProgress(final LiveScore live) { + return live.status() == MatchStatus.IN_PROGRESS; + } + + private String matchDateLeftText(final LiveScore live) { + switch (live.status()) { case COMPLETED: - return DateTimeUtil.styleDate(dto.getKickoff()); + case FUTURE: + return DateTimeUtil.styleDate(matchResume.getKickoff()); case IN_PROGRESS: - return MatchStatus.TODAY.name().toUpperCase(); case TODAY: - return MatchStatus.TODAY.name().toUpperCase(); - case FUTURE: - return DateTimeUtil.styleDate(dto.getKickoff()); + return MatchStatus.TODAY.name(); default: return EMPTY; } } - private String getMatchDateRight(final MatchResume dto) { - switch (dto.getStatus()) { + private String matchDateRightText(final LiveScore live) { + switch (live.status()) { case COMPLETED: - matchTimeContainer.removeClassName(CssStyles.MATCH_PLAYING); - dotsContainer.removeClassName(CssStyles.JUMPING_DOTS); - dotsContainer.addClassName(CssStyles.HIDDEN); return FULL_TIME; case IN_PROGRESS: - if (!matchTimeContainer.hasClassName(CssStyles.MATCH_PLAYING)) { - dotsContainer.removeClassName(CssStyles.HIDDEN); - matchTimeContainer.addClassName(CssStyles.MATCH_PLAYING); - dotsContainer.addClassName(CssStyles.JUMPING_DOTS); - } - return dto.getMinutes(); + return live.minutes(); case TODAY: - dotsContainer.addClassName(CssStyles.HIDDEN); - matchTimeContainer.removeClassName(CssStyles.MATCH_PLAYING); - dotsContainer.removeClassName(CssStyles.JUMPING_DOTS); - return DateTimeUtil.styleTime(dto.getKickoff()); case FUTURE: - dotsContainer.addClassName(CssStyles.HIDDEN); - matchTimeContainer.removeClassName(CssStyles.MATCH_PLAYING); - dotsContainer.removeClassName(CssStyles.JUMPING_DOTS); - return DateTimeUtil.styleTime(dto.getKickoff()); + return DateTimeUtil.styleTime(matchResume.getKickoff()); default: return EMPTY; } } - public void refresh(final MatchResume matchResume) { - if (this.matchResume.getFifaId().equals(matchResume.getFifaId())) { - matchDateRight.setText(getMatchDateRight(matchResume)); - homeTeamGoals.setText(matchResume.getHomeTeamGoals()); - awayTeamGoals.setText(matchResume.getAwayTeamGoals()); - } - } - - @Override - protected void onAttach(final AttachEvent attachEvent) { - if (!matchResume.getStatus().equals(MatchStatus.COMPLETED)) { - matchUpdater.registerListener(this); - } - } - - @Override - protected void onDetach(final DetachEvent detachEvent) { - matchUpdater.unregisterListener(this); - } - } diff --git a/src/main/java/com/flowingcode/fixture/view/model/LiveScore.java b/src/main/java/com/flowingcode/fixture/view/model/LiveScore.java new file mode 100644 index 0000000..bd961cd --- /dev/null +++ b/src/main/java/com/flowingcode/fixture/view/model/LiveScore.java @@ -0,0 +1,17 @@ +package com.flowingcode.fixture.view.model; + +import com.flowingcode.fixture.view.enums.MatchStatus; + +/** + * Immutable snapshot of the only parts of a match that change while it is being + * played: the running score, the elapsed minutes and the status. Carried by the + * shared signals in {@code LiveScoreSignals} so that a single background refresh + * propagates to every {@code MatchResultComponent} showing that match, with no + * manual listeners or {@code UI.access()}. + */ +public record LiveScore(String homeGoals, String awayGoals, String minutes, MatchStatus status) { + + public static LiveScore from(final MatchResume match) { + return new LiveScore(match.getHomeTeamGoals(), match.getAwayTeamGoals(), match.getMinutes(), match.getStatus()); + } +} diff --git a/src/main/java/com/flowingcode/fixture/view/screen/CountryScreen.java b/src/main/java/com/flowingcode/fixture/view/screen/CountryScreen.java index 4d930f8..188c369 100644 --- a/src/main/java/com/flowingcode/fixture/view/screen/CountryScreen.java +++ b/src/main/java/com/flowingcode/fixture/view/screen/CountryScreen.java @@ -14,7 +14,7 @@ import com.flowingcode.fixture.view.component.MatchResultComponent; import com.flowingcode.fixture.view.model.MatchResultDto; import com.flowingcode.fixture.view.presenter.CountryPresenter; -import com.flowingcode.fixture.view.util.MatchUpdater; +import com.flowingcode.fixture.view.util.LiveScoreSignals; import com.vaadin.flow.component.html.H3; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.router.BeforeEvent; @@ -33,12 +33,12 @@ public class CountryScreen extends VerticalLayout implements HasUrlParameter getTeamName(final List groups) { public void init(final List groups) { groupsContainer.add(new H3(getTeamName(groups).orElse("Country") + " matches")); for (final MatchResultDto dto : groups) { - groupsContainer.add(new MatchResultComponent(dto, matchUpdater)); + groupsContainer.add(new MatchResultComponent(dto, liveScores)); } } diff --git a/src/main/java/com/flowingcode/fixture/view/screen/MainLayout.java b/src/main/java/com/flowingcode/fixture/view/screen/MainLayout.java index f7e78d3..9a5759c 100644 --- a/src/main/java/com/flowingcode/fixture/view/screen/MainLayout.java +++ b/src/main/java/com/flowingcode/fixture/view/screen/MainLayout.java @@ -1,11 +1,7 @@ package com.flowingcode.fixture.view.screen; -import org.springframework.beans.factory.annotation.Autowired; - import com.flowingcode.addons.applayout.AppLayout; import com.flowingcode.addons.applayout.MenuItem; -import com.flowingcode.fixture.view.util.MatchUpdater; -import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.UI; import com.vaadin.flow.router.PageTitle; @@ -20,9 +16,6 @@ public class MainLayout extends AppLayout { public static final String SITE_TITLE = "Global Football 2026 Stats - Flowing Code S.A."; - @Autowired - private MatchUpdater matchUpdater; - public MainLayout() { super("Global Football 2026 Stats"); setMenuItems( @@ -32,9 +25,4 @@ public MainLayout() { new MenuItem("About ...", () -> UI.getCurrent().navigate("about"))); } - @Override - protected void onAttach(final AttachEvent attachEvent) { - getUI().ifPresent(ui -> ui.addDetachListener(e -> matchUpdater.unregisterAll(e.getUI()))); - } - } diff --git a/src/main/java/com/flowingcode/fixture/view/screen/MatchDetailScreen.java b/src/main/java/com/flowingcode/fixture/view/screen/MatchDetailScreen.java index 36b23ad..d39c4b9 100644 --- a/src/main/java/com/flowingcode/fixture/view/screen/MatchDetailScreen.java +++ b/src/main/java/com/flowingcode/fixture/view/screen/MatchDetailScreen.java @@ -10,7 +10,7 @@ import com.flowingcode.fixture.view.model.MatchDetailDto; import com.flowingcode.fixture.view.model.TeamEventDto; import com.flowingcode.fixture.view.presenter.MatchDetailPresenter; -import com.flowingcode.fixture.view.util.MatchUpdater; +import com.flowingcode.fixture.view.util.LiveScoreSignals; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.card.Card; import com.vaadin.flow.component.html.H4; @@ -34,11 +34,11 @@ public class MatchDetailScreen extends VerticalLayout implements HasUrlParameter private final MatchDetailPresenter presenter; - private final MatchUpdater matchUpdater; + private final LiveScoreSignals liveScores; @Autowired - public MatchDetailScreen(final MatchDetailPresenter presenter, final MatchUpdater matchUpdater) { - this.matchUpdater = matchUpdater; + public MatchDetailScreen(final MatchDetailPresenter presenter, final LiveScoreSignals liveScores) { + this.liveScores = liveScores; this.presenter = presenter; presenter.setView(this); setDefaultHorizontalComponentAlignment(Alignment.CENTER); @@ -53,7 +53,7 @@ public void init(final MatchDetailDto dto) { removeAll(); // Score card (teams, flags, score, kickoff, stage/group) — reused from the match list. - final MatchResultComponent score = new MatchResultComponent(dto, matchUpdater, false); + final MatchResultComponent score = new MatchResultComponent(dto, liveScores, false); score.addClassName("common-card"); add(score); diff --git a/src/main/java/com/flowingcode/fixture/view/screen/MatchesScreen.java b/src/main/java/com/flowingcode/fixture/view/screen/MatchesScreen.java index 87c8984..25808ef 100644 --- a/src/main/java/com/flowingcode/fixture/view/screen/MatchesScreen.java +++ b/src/main/java/com/flowingcode/fixture/view/screen/MatchesScreen.java @@ -11,7 +11,7 @@ import com.flowingcode.fixture.view.presenter.MatchesPresenter; import com.flowingcode.fixture.view.util.CssStyles; import com.flowingcode.fixture.view.util.DateTimeUtil; -import com.flowingcode.fixture.view.util.MatchUpdater; +import com.flowingcode.fixture.view.util.LiveScoreSignals; import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.html.H3; import com.vaadin.flow.component.html.NativeLabel; @@ -28,14 +28,14 @@ public class MatchesScreen extends VerticalLayout { private final MatchesPresenter presenter; - private final MatchUpdater matchUpdater; + private final LiveScoreSignals liveScores; private final DateFilterDialog dateFilterDialog; @Autowired - public MatchesScreen(final MatchesPresenter presenter, final MatchUpdater matchUpdater, final DateFilterDialog dateFilterDialog) { + public MatchesScreen(final MatchesPresenter presenter, final LiveScoreSignals liveScores, final DateFilterDialog dateFilterDialog) { this.presenter = presenter; - this.matchUpdater = matchUpdater; + this.liveScores = liveScores; this.dateFilterDialog = dateFilterDialog; presenter.setView(this); @@ -60,7 +60,7 @@ public void init(final LocalDate date, final List results) { this.add(new H3(new NativeLabel(titleCaption), searchIcon)); for (final MatchResultDto result : results) { - final MatchResultComponent matchResultComponent = new MatchResultComponent(result, matchUpdater); + final MatchResultComponent matchResultComponent = new MatchResultComponent(result, liveScores); this.add(matchResultComponent); } } diff --git a/src/main/java/com/flowingcode/fixture/view/screen/WelcomeScreen.java b/src/main/java/com/flowingcode/fixture/view/screen/WelcomeScreen.java index 95a43d4..e5fe6cf 100644 --- a/src/main/java/com/flowingcode/fixture/view/screen/WelcomeScreen.java +++ b/src/main/java/com/flowingcode/fixture/view/screen/WelcomeScreen.java @@ -9,7 +9,7 @@ import com.flowingcode.fixture.view.enums.MatchStatus; import com.flowingcode.fixture.view.model.MatchResultDto; import com.flowingcode.fixture.view.presenter.WelcomePresenter; -import com.flowingcode.fixture.view.util.MatchUpdater; +import com.flowingcode.fixture.view.util.LiveScoreSignals; import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.html.H3; import com.vaadin.flow.component.orderedlayout.VerticalLayout; @@ -23,12 +23,12 @@ public class WelcomeScreen extends VerticalLayout { private final WelcomePresenter presenter; - private final MatchUpdater matchUpdater; + private final LiveScoreSignals liveScores; @Autowired - public WelcomeScreen(final WelcomePresenter presenter, final MatchUpdater matchUpdater) { + public WelcomeScreen(final WelcomePresenter presenter, final LiveScoreSignals liveScores) { this.presenter = presenter; - this.matchUpdater = matchUpdater; + this.liveScores = liveScores; presenter.setView(this); // center components @@ -45,7 +45,7 @@ public void init(final List results) { if (!completedMatches.isEmpty()) { this.add(new H3("Completed matches")); for (final MatchResultDto result : completedMatches) { - final MatchResultComponent matchResultComponent = new MatchResultComponent(result, matchUpdater); + final MatchResultComponent matchResultComponent = new MatchResultComponent(result, liveScores); this.add(matchResultComponent); } } @@ -54,7 +54,7 @@ public void init(final List results) { if (!currentMatches.isEmpty()) { this.add(new H3("Current matches")); for (final MatchResultDto result : currentMatches) { - final MatchResultComponent matchResultComponent = new MatchResultComponent(result, matchUpdater); + final MatchResultComponent matchResultComponent = new MatchResultComponent(result, liveScores); this.add(matchResultComponent); } } @@ -63,7 +63,7 @@ public void init(final List results) { if (!upcomingMatches.isEmpty()) { this.add(new H3("Upcoming matches")); for (final MatchResultDto result : upcomingMatches) { - final MatchResultComponent matchResultComponent = new MatchResultComponent(result, matchUpdater); + final MatchResultComponent matchResultComponent = new MatchResultComponent(result, liveScores); this.add(matchResultComponent); } } diff --git a/src/main/java/com/flowingcode/fixture/view/util/LiveScoreSignals.java b/src/main/java/com/flowingcode/fixture/view/util/LiveScoreSignals.java new file mode 100644 index 0000000..0695bb5 --- /dev/null +++ b/src/main/java/com/flowingcode/fixture/view/util/LiveScoreSignals.java @@ -0,0 +1,70 @@ +package com.flowingcode.fixture.view.util; + +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.Executor; + +import org.springframework.scheduling.annotation.Scheduled; + +import com.flowingcode.fixture.service.MatchService; +import com.flowingcode.fixture.view.model.LiveScore; +import com.flowingcode.fixture.view.model.MatchResultDto; +import com.flowingcode.fixture.view.model.MatchResume; +import com.vaadin.flow.signals.shared.SharedMapSignal; +import com.vaadin.flow.signals.shared.SharedValueSignal; +import com.vaadin.flow.spring.annotation.SpringComponent; + +/** + * Application-scoped holder of the live match scores, exposed as shared signals. + * + *

This replaces the old listener-based {@code MatchUpdater}. A single + * scheduled task polls the data source and writes the latest scores into a + * {@link SharedMapSignal} keyed by match id. Components bind to the per-match + * entry signal (see {@code MatchResultComponent}); when an entry changes, every + * UI showing that match updates automatically. Shared signals are thread-safe + * and push their changes out on their own, so there is no manual listener set + * and no {@code UI.access()} — Vaadin Push ({@code @Push} on the app shell) is + * the only prerequisite. + */ +@SpringComponent +public class LiveScoreSignals { + + /** One entry per match (key = fifaId); each value is an independently reactive signal. */ + private final SharedMapSignal liveScores = new SharedMapSignal<>(LiveScore.class); + + private final MatchService matchService; + + private final Executor executor; + + public LiveScoreSignals(final MatchService matchService, final Executor executor) { + this.matchService = matchService; + this.executor = executor; + } + + /** + * Returns the shared signal carrying live updates for the given match, + * seeding it with the match's current values the first time it is requested. + * A later (fresher) value written by {@link #refreshLiveScores()} is kept. + */ + public SharedValueSignal register(final MatchResume match) { + final String fifaId = match.getFifaId(); + liveScores.putIfAbsent(fifaId, LiveScore.from(match)); + return liveScores.peek().get(fifaId); + } + + @Scheduled(cron = "${cron.match.refresh}") + public void refreshLiveScores() { + // Offload the blocking HTTP call so it never stalls the scheduler thread. + executor.execute(() -> { + List matches = matchService.getCurrentMatches(); + if (matches.isEmpty()) { + matches = matchService.getFutureMatches(LocalDate.now(), LocalDate.now().plusDays(1)); + } + for (final MatchResultDto match : matches) { + // Atomic, thread-safe; UI updates are pushed automatically. + liveScores.put(match.getFifaId(), LiveScore.from(match)); + } + }); + } + +} diff --git a/src/main/java/com/flowingcode/fixture/view/util/MatchUpdater.java b/src/main/java/com/flowingcode/fixture/view/util/MatchUpdater.java deleted file mode 100644 index 46d8a20..0000000 --- a/src/main/java/com/flowingcode/fixture/view/util/MatchUpdater.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.flowingcode.fixture.view.util; - -import java.time.LocalDate; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.Executor; -import java.util.stream.Collectors; - -import org.springframework.scheduling.annotation.Scheduled; - -import com.flowingcode.fixture.service.MatchService; -import com.flowingcode.fixture.view.component.AccessUI; -import com.flowingcode.fixture.view.component.MatchResultComponent; -import com.flowingcode.fixture.view.model.MatchResultDto; -import com.vaadin.flow.component.UI; -import com.vaadin.flow.spring.annotation.SpringComponent; - -@SpringComponent -public class MatchUpdater { - - private final Executor executor; - - private final MatchService matchService; - - private static Set listeners = new HashSet<>(); - - public MatchUpdater(final MatchService matchService, final Executor executor) { - this.matchService = matchService; - this.executor = executor; - } - - public synchronized void registerListener(final MatchResultComponent listener) { - listeners.add(listener); - } - - public synchronized void unregisterListener(final MatchResultComponent listener) { - listeners.remove(listener); - } - - @Scheduled(cron = "${cron.match.refresh}") - public void loadCurrentMatches() { - synchronized (this) { - listeners.removeAll(listeners.stream().filter(c -> !c.getUI().isPresent()).collect(Collectors.toList())); - } - executor.execute(() -> { - List matches = matchService.getCurrentMatches(); - if (matches.isEmpty()) { - matches = matchService.getFutureMatches(LocalDate.now(), LocalDate.now().plusDays(1)); - } - synchronized (this) { - for (final MatchResultDto matchResume : matches) { - listeners.forEach(l -> AccessUI.on(l, () -> l.refresh(matchResume))); - } - } - }); - } - - public synchronized void unregisterAll(final UI ui) { - listeners - .removeAll(listeners.stream().filter(c -> !c.getUI().isPresent() || c.getUI().get().equals(ui)).collect(Collectors.toList())); - } - -} diff --git a/src/test/java/com/flowingcode/fixture/service/MatchServiceImplTest.java b/src/test/java/com/flowingcode/fixture/service/MatchServiceImplTest.java new file mode 100644 index 0000000..e4508e0 --- /dev/null +++ b/src/test/java/com/flowingcode/fixture/service/MatchServiceImplTest.java @@ -0,0 +1,126 @@ +package com.flowingcode.fixture.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.flowingcode.fixture.repository.worldcup.StadiumCatalog; +import com.flowingcode.fixture.repository.worldcup.TeamCatalog; +import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.Game; +import com.flowingcode.fixture.repository.worldcup.WorldCupClient; +import com.flowingcode.fixture.view.enums.MatchStatus; +import com.flowingcode.fixture.view.model.MatchResultDto; + +/** + * Exercises the worldcup26.ir → view-DTO mapping in {@link MatchServiceImpl}: + * match status derivation and the data-driven stage labels. The remote client + * and catalogs are mocked, so these are pure mapping tests. + */ +class MatchServiceImplTest { + + private static final DateTimeFormatter LOCAL_DATE = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm"); + + private WorldCupClient client; + private MatchServiceImpl service; + + @BeforeEach + void setUp() { + client = mock(WorldCupClient.class); + service = new MatchServiceImpl(client, mock(TeamCatalog.class), mock(StadiumCatalog.class)); + } + + /** Builds a Game with sensible defaults; override only what a test cares about. */ + private static Game game(final String finished, final String timeElapsed, final String type, + final String matchday, final String localDate, final String homeScore, final String awayScore) { + return new Game("1", "10", "20", homeScore, awayScore, null, null, "A", matchday, localDate, + "100", finished, timeElapsed, type, "Mexico", "Canada"); + } + + private static String today() { + return LocalDate.now().atTime(20, 0).format(LOCAL_DATE); + } + + private MatchResultDto convertSingle(final Game g) { + when(client.getGames()).thenReturn(List.of(g)); + final List result = service.getMatches(); + assertThat(result).hasSize(1); + return result.get(0); + } + + @Test + void status_finishedGameIsCompleted() { + final MatchResultDto dto = convertSingle(game("TRUE", "90", "group", "1", today(), "2", "1")); + assertThat(dto.getStatus()).isEqualTo(MatchStatus.COMPLETED); + } + + @Test + void status_runningGameIsInProgressAndKeepsElapsedMinutes() { + final MatchResultDto dto = convertSingle(game("FALSE", "67'", "group", "1", today(), "1", "0")); + assertThat(dto.getStatus()).isEqualTo(MatchStatus.IN_PROGRESS); + assertThat(dto.getMinutes()).isEqualTo("67'"); + } + + @Test + void status_notStartedTodayIsToday() { + final MatchResultDto dto = convertSingle(game("FALSE", "notstarted", "group", "1", today(), "0", "0")); + assertThat(dto.getStatus()).isEqualTo(MatchStatus.TODAY); + } + + @Test + void status_notStartedFutureDateIsFuture() { + final String future = LocalDate.now().plusDays(5).atTime(20, 0).format(LOCAL_DATE); + final MatchResultDto dto = convertSingle(game("FALSE", "notstarted", "group", "2", future, "0", "0")); + assertThat(dto.getStatus()).isEqualTo(MatchStatus.FUTURE); + } + + @Test + void goals_nullOrLiteralNullBecomeZero() { + final MatchResultDto dto = convertSingle(game("FALSE", "notstarted", "group", "1", today(), null, "null")); + assertThat(dto.getHomeTeamGoals()).isEqualTo("0"); + assertThat(dto.getAwayTeamGoals()).isEqualTo("0"); + } + + @Test + void stage_groupGameUsesFirstStageAndMatchdayLabel() { + final MatchResultDto dto = convertSingle(game("FALSE", "notstarted", "group", "3", today(), "0", "0")); + assertThat(dto.getStageName()).isEqualTo("First stage"); + assertThat(dto.getStage()).isEqualTo("Matchday 3"); + } + + @Test + void stage_nullTypeIsTreatedAsGroup() { + final MatchResultDto dto = convertSingle(game("FALSE", "notstarted", null, "1", today(), "0", "0")); + assertThat(dto.getStageName()).isEqualTo("First stage"); + } + + @Test + void stage_knockoutTypesAreHumanized() { + assertThat(convertSingle(game("FALSE", "notstarted", "round_of_16", null, today(), "0", "0")).getStageName()) + .isEqualTo("Round of 16"); + assertThat(convertSingle(game("FALSE", "notstarted", "quarterfinal", null, today(), "0", "0")).getStageName()) + .isEqualTo("Quarter-finals"); + assertThat(convertSingle(game("FALSE", "notstarted", "semifinal", null, today(), "0", "0")).getStageName()) + .isEqualTo("Semi-finals"); + assertThat(convertSingle(game("FALSE", "notstarted", "thirdPlace", null, today(), "0", "0")).getStageName()) + .isEqualTo("Third place"); + assertThat(convertSingle(game("FALSE", "notstarted", "final", null, today(), "0", "0")).getStageName()) + .isEqualTo("Final"); + } + + @Test + void getCurrentMatches_returnsOnlyInProgressGames() { + when(client.getGames()).thenReturn(List.of( + game("FALSE", "67'", "group", "1", today(), "1", "0"), // in progress + game("TRUE", "90", "group", "1", today(), "2", "1"), // finished + game("FALSE", "notstarted", "group", "1", today(), "0", "0") // not started + )); + assertThat(service.getCurrentMatches()).hasSize(1); + } +} diff --git a/src/test/java/com/flowingcode/fixture/view/model/LiveScoreTest.java b/src/test/java/com/flowingcode/fixture/view/model/LiveScoreTest.java new file mode 100644 index 0000000..7ba3fbc --- /dev/null +++ b/src/test/java/com/flowingcode/fixture/view/model/LiveScoreTest.java @@ -0,0 +1,40 @@ +package com.flowingcode.fixture.view.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import com.flowingcode.fixture.view.enums.MatchStatus; + +class LiveScoreTest { + + @Test + void from_copiesTheLiveChangingFields() { + final MatchResultDto match = new MatchResultDto(); + match.setHomeTeamGoals("2"); + match.setAwayTeamGoals("1"); + match.setMinutes("67'"); + match.setStatus(MatchStatus.IN_PROGRESS); + + final LiveScore live = LiveScore.from(match); + + assertThat(live.homeGoals()).isEqualTo("2"); + assertThat(live.awayGoals()).isEqualTo("1"); + assertThat(live.minutes()).isEqualTo("67'"); + assertThat(live.status()).isEqualTo(MatchStatus.IN_PROGRESS); + } + + @Test + void from_toleratesNullMinutesForNonLiveMatches() { + final MatchResultDto match = new MatchResultDto(); + match.setHomeTeamGoals("0"); + match.setAwayTeamGoals("0"); + match.setStatus(MatchStatus.FUTURE); + // minutes left null (only set while IN_PROGRESS) + + final LiveScore live = LiveScore.from(match); + + assertThat(live.minutes()).isNull(); + assertThat(live.status()).isEqualTo(MatchStatus.FUTURE); + } +} From d8d942bd61d7eb3dfd4f5736e80764be7cc74167 Mon Sep 17 00:00:00 2001 From: Paola De Bartolo Date: Fri, 12 Jun 2026 12:06:16 -0300 Subject: [PATCH 3/4] feat: show kick-off times in the viewer's time zone and locale Kick-off times were always rendered in a hardcoded America/New_York zone with a fixed 12h pattern, ignoring the visitor's zone and locale. Now each match is anchored to its real venue zone and displayed in the viewer's own zone and locale. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../repository/worldcup/StadiumCatalog.java | 34 +++++++++ .../fixture/service/MatchServiceImpl.java | 10 +-- .../view/component/MatchResultComponent.java | 20 ++++-- .../fixture/view/screen/CountryScreen.java | 11 ++- .../view/screen/MatchDetailScreen.java | 18 +++-- .../fixture/view/screen/MatchesScreen.java | 12 +++- .../fixture/view/screen/WelcomeScreen.java | 15 ++-- .../fixture/view/util/DateTimeUtil.java | 29 ++++++-- .../fixture/view/util/ViewerClock.java | 69 +++++++++++++++++++ .../worldcup/StadiumCatalogTest.java | 35 ++++++++++ .../fixture/service/MatchServiceImplTest.java | 23 ++++++- 11 files changed, 243 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/flowingcode/fixture/view/util/ViewerClock.java create mode 100644 src/test/java/com/flowingcode/fixture/repository/worldcup/StadiumCatalogTest.java diff --git a/src/main/java/com/flowingcode/fixture/repository/worldcup/StadiumCatalog.java b/src/main/java/com/flowingcode/fixture/repository/worldcup/StadiumCatalog.java index ae46690..cf02e04 100644 --- a/src/main/java/com/flowingcode/fixture/repository/worldcup/StadiumCatalog.java +++ b/src/main/java/com/flowingcode/fixture/repository/worldcup/StadiumCatalog.java @@ -1,5 +1,6 @@ package com.flowingcode.fixture.repository.worldcup; +import java.time.ZoneId; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -16,6 +17,34 @@ @Component public class StadiumCatalog { + /** Fallback when a stadium id is unknown (the bulk of venues are Eastern). */ + private static final ZoneId DEFAULT_ZONE = ZoneId.of("America/New_York"); + + /** + * Host-venue time zone per worldcup26.ir stadium id. The API ships kick-off + * times as the venue's local wall-clock with no zone/offset, so we attach + * the real zone here. IANA ids carry the correct DST rules (including + * Mexico's nationwide DST abolition), so no manual offset handling is needed. + */ + private static final Map ZONE_BY_STADIUM_ID = Map.ofEntries( + Map.entry("1", ZoneId.of("America/Mexico_City")), // Estadio Azteca, Mexico City + Map.entry("2", ZoneId.of("America/Mexico_City")), // Estadio Akron, Guadalajara + Map.entry("3", ZoneId.of("America/Monterrey")), // Estadio BBVA, Monterrey + Map.entry("4", ZoneId.of("America/Chicago")), // AT&T Stadium, Dallas + Map.entry("5", ZoneId.of("America/Chicago")), // NRG Stadium, Houston + Map.entry("6", ZoneId.of("America/Chicago")), // Arrowhead Stadium, Kansas City + Map.entry("7", ZoneId.of("America/New_York")), // Mercedes-Benz Stadium, Atlanta + Map.entry("8", ZoneId.of("America/New_York")), // Hard Rock Stadium, Miami + Map.entry("9", ZoneId.of("America/New_York")), // Gillette Stadium, Boston + Map.entry("10", ZoneId.of("America/New_York")), // Lincoln Financial Field, Philadelphia + Map.entry("11", ZoneId.of("America/New_York")), // MetLife Stadium, New York/New Jersey + Map.entry("12", ZoneId.of("America/Toronto")), // BMO Field, Toronto + Map.entry("13", ZoneId.of("America/Vancouver")), // BC Place, Vancouver + Map.entry("14", ZoneId.of("America/Los_Angeles")), // Lumen Field, Seattle + Map.entry("15", ZoneId.of("America/Los_Angeles")), // Levi's Stadium, San Francisco Bay Area + Map.entry("16", ZoneId.of("America/Los_Angeles")) // SoFi Stadium, Los Angeles + ); + private final WorldCupClient client; private volatile Map byId = Map.of(); @@ -47,4 +76,9 @@ public String cityById(final String id) { return stadium != null ? stadium.city_en() : ""; } + /** Local time zone of the given venue; falls back to Eastern for unknown/missing ids. */ + public ZoneId zoneById(final String id) { + return id != null ? ZONE_BY_STADIUM_ID.getOrDefault(id, DEFAULT_ZONE) : DEFAULT_ZONE; + } + } diff --git a/src/main/java/com/flowingcode/fixture/service/MatchServiceImpl.java b/src/main/java/com/flowingcode/fixture/service/MatchServiceImpl.java index 9c29f92..5cc4732 100644 --- a/src/main/java/com/flowingcode/fixture/service/MatchServiceImpl.java +++ b/src/main/java/com/flowingcode/fixture/service/MatchServiceImpl.java @@ -33,9 +33,6 @@ public class MatchServiceImpl implements MatchService { private static final DateTimeFormatter LOCAL_DATE = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm"); - /** Host-region timezone used to interpret the API's local kick-off times. */ - private static final ZoneId ZONE = ZoneId.of("America/New_York"); - private static final String NULL = "null"; private final WorldCupClient client; @@ -195,10 +192,13 @@ private String teamName(final String teamId, final String nameEn) { } private ZonedDateTime parseKickoff(final Game source) { + // The API's local_date is the venue's local wall-clock; attach the real + // venue zone so the resulting instant is correct across host cities. + final ZoneId zone = stadiumCatalog.zoneById(source.stadium_id()); try { - return LocalDateTime.parse(source.local_date(), LOCAL_DATE).atZone(ZONE); + return LocalDateTime.parse(source.local_date(), LOCAL_DATE).atZone(zone); } catch (final RuntimeException e) { - return ZonedDateTime.now(ZONE); + return ZonedDateTime.now(zone); } } diff --git a/src/main/java/com/flowingcode/fixture/view/component/MatchResultComponent.java b/src/main/java/com/flowingcode/fixture/view/component/MatchResultComponent.java index 808d6e2..59ec3fe 100644 --- a/src/main/java/com/flowingcode/fixture/view/component/MatchResultComponent.java +++ b/src/main/java/com/flowingcode/fixture/view/component/MatchResultComponent.java @@ -1,6 +1,8 @@ package com.flowingcode.fixture.view.component; +import java.time.ZoneId; import java.time.ZonedDateTime; +import java.util.Locale; import org.apache.commons.lang3.StringUtils; @@ -12,6 +14,7 @@ import com.flowingcode.fixture.view.util.CssStyles; import com.flowingcode.fixture.view.util.DateTimeUtil; import com.flowingcode.fixture.view.util.LiveScoreSignals; +import com.flowingcode.fixture.view.util.ViewerClock; import com.vaadin.flow.component.UI; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.card.Card; @@ -51,16 +54,23 @@ public class MatchResultComponent extends Card { private final MatchResume matchResume; + private final ZoneId zone; + + private final Locale locale; + private Span matchTimeContainer; private Span dotsContainer; - public MatchResultComponent(final MatchResume dto, final LiveScoreSignals liveScores) { - this(dto, liveScores, true); + public MatchResultComponent(final MatchResume dto, final LiveScoreSignals liveScores, final ViewerClock clock) { + this(dto, liveScores, clock, true); } - public MatchResultComponent(final MatchResume dto, final LiveScoreSignals liveScores, final boolean showDetailsButton) { + public MatchResultComponent(final MatchResume dto, final LiveScoreSignals liveScores, final ViewerClock clock, + final boolean showDetailsButton) { this.matchResume = dto; + this.zone = clock.zone(); + this.locale = clock.locale(); // Bind every live-changing element to this match's shared signal. The // bindings register effects that Vaadin tears down automatically on @@ -182,7 +192,7 @@ private String matchDateLeftText(final LiveScore live) { switch (live.status()) { case COMPLETED: case FUTURE: - return DateTimeUtil.styleDate(matchResume.getKickoff()); + return DateTimeUtil.date(matchResume.getKickoff(), zone, locale); case IN_PROGRESS: case TODAY: return MatchStatus.TODAY.name(); @@ -199,7 +209,7 @@ private String matchDateRightText(final LiveScore live) { return live.minutes(); case TODAY: case FUTURE: - return DateTimeUtil.styleTime(matchResume.getKickoff()); + return DateTimeUtil.time(matchResume.getKickoff(), zone, locale); default: return EMPTY; } diff --git a/src/main/java/com/flowingcode/fixture/view/screen/CountryScreen.java b/src/main/java/com/flowingcode/fixture/view/screen/CountryScreen.java index 188c369..1d52941 100644 --- a/src/main/java/com/flowingcode/fixture/view/screen/CountryScreen.java +++ b/src/main/java/com/flowingcode/fixture/view/screen/CountryScreen.java @@ -15,6 +15,7 @@ import com.flowingcode.fixture.view.model.MatchResultDto; import com.flowingcode.fixture.view.presenter.CountryPresenter; import com.flowingcode.fixture.view.util.LiveScoreSignals; +import com.flowingcode.fixture.view.util.ViewerClock; import com.vaadin.flow.component.html.H3; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.router.BeforeEvent; @@ -35,10 +36,13 @@ public class CountryScreen extends VerticalLayout implements HasUrlParameter getTeamName(final List groups) { public void init(final List groups) { groupsContainer.add(new H3(getTeamName(groups).orElse("Country") + " matches")); for (final MatchResultDto dto : groups) { - groupsContainer.add(new MatchResultComponent(dto, liveScores)); + groupsContainer.add(new MatchResultComponent(dto, liveScores, clock)); } } @Override public void setParameter(final BeforeEvent ev, final String country) { - presenter.loadResults(country); + // Render now; re-render once the viewer's time zone resolves (local times). + clock.render(() -> presenter.loadResults(country)); } } diff --git a/src/main/java/com/flowingcode/fixture/view/screen/MatchDetailScreen.java b/src/main/java/com/flowingcode/fixture/view/screen/MatchDetailScreen.java index d39c4b9..dd2d46d 100644 --- a/src/main/java/com/flowingcode/fixture/view/screen/MatchDetailScreen.java +++ b/src/main/java/com/flowingcode/fixture/view/screen/MatchDetailScreen.java @@ -1,6 +1,5 @@ package com.flowingcode.fixture.view.screen; -import java.time.format.DateTimeFormatter; import java.util.List; import org.apache.commons.lang3.StringUtils; @@ -10,7 +9,9 @@ import com.flowingcode.fixture.view.model.MatchDetailDto; import com.flowingcode.fixture.view.model.TeamEventDto; import com.flowingcode.fixture.view.presenter.MatchDetailPresenter; +import com.flowingcode.fixture.view.util.DateTimeUtil; import com.flowingcode.fixture.view.util.LiveScoreSignals; +import com.flowingcode.fixture.view.util.ViewerClock; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.card.Card; import com.vaadin.flow.component.html.H4; @@ -30,15 +31,16 @@ @PageTitle(value = MainLayout.SITE_TITLE) public class MatchDetailScreen extends VerticalLayout implements HasUrlParameter { - private static final DateTimeFormatter KICKOFF = DateTimeFormatter.ofPattern("EEE dd MMM yyyy, HH:mm"); - private final MatchDetailPresenter presenter; private final LiveScoreSignals liveScores; + private final ViewerClock clock; + @Autowired - public MatchDetailScreen(final MatchDetailPresenter presenter, final LiveScoreSignals liveScores) { + public MatchDetailScreen(final MatchDetailPresenter presenter, final LiveScoreSignals liveScores, final ViewerClock clock) { this.liveScores = liveScores; + this.clock = clock; this.presenter = presenter; presenter.setView(this); setDefaultHorizontalComponentAlignment(Alignment.CENTER); @@ -46,14 +48,15 @@ public MatchDetailScreen(final MatchDetailPresenter presenter, final LiveScoreSi @Override public void setParameter(final BeforeEvent event, final String parameter) { - presenter.loadResults(parameter); + // Render now; re-render once the viewer's time zone resolves (local times). + clock.render(() -> presenter.loadResults(parameter)); } public void init(final MatchDetailDto dto) { removeAll(); // Score card (teams, flags, score, kickoff, stage/group) — reused from the match list. - final MatchResultComponent score = new MatchResultComponent(dto, liveScores, false); + final MatchResultComponent score = new MatchResultComponent(dto, liveScores, clock, false); score.addClassName("common-card"); add(score); @@ -67,7 +70,8 @@ public void init(final MatchDetailDto dto) { body.add(infoLine(VaadinIcon.MAP_MARKER, place)); } if (dto.getDateTime() != null) { - body.add(infoLine(VaadinIcon.CALENDAR_CLOCK, dto.getDateTime().format(KICKOFF))); + body.add(infoLine(VaadinIcon.CALENDAR_CLOCK, + DateTimeUtil.dateTime(dto.getDateTime(), clock.zone(), clock.locale()))); } final List homeGoals = dto.getHomeTeamEvents(); diff --git a/src/main/java/com/flowingcode/fixture/view/screen/MatchesScreen.java b/src/main/java/com/flowingcode/fixture/view/screen/MatchesScreen.java index 25808ef..15f900c 100644 --- a/src/main/java/com/flowingcode/fixture/view/screen/MatchesScreen.java +++ b/src/main/java/com/flowingcode/fixture/view/screen/MatchesScreen.java @@ -12,6 +12,7 @@ import com.flowingcode.fixture.view.util.CssStyles; import com.flowingcode.fixture.view.util.DateTimeUtil; import com.flowingcode.fixture.view.util.LiveScoreSignals; +import com.flowingcode.fixture.view.util.ViewerClock; import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.html.H3; import com.vaadin.flow.component.html.NativeLabel; @@ -30,12 +31,16 @@ public class MatchesScreen extends VerticalLayout { private final LiveScoreSignals liveScores; + private final ViewerClock clock; + private final DateFilterDialog dateFilterDialog; @Autowired - public MatchesScreen(final MatchesPresenter presenter, final LiveScoreSignals liveScores, final DateFilterDialog dateFilterDialog) { + public MatchesScreen(final MatchesPresenter presenter, final LiveScoreSignals liveScores, final ViewerClock clock, + final DateFilterDialog dateFilterDialog) { this.presenter = presenter; this.liveScores = liveScores; + this.clock = clock; this.dateFilterDialog = dateFilterDialog; presenter.setView(this); @@ -45,7 +50,8 @@ public MatchesScreen(final MatchesPresenter presenter, final LiveScoreSignals li @Override protected void onAttach(final AttachEvent attachEvent) { - presenter.loadResults(); + // Render now; re-render once the viewer's time zone resolves (local times). + clock.render(presenter::loadResults); } public void init(final LocalDate date, final List results) { @@ -60,7 +66,7 @@ public void init(final LocalDate date, final List results) { this.add(new H3(new NativeLabel(titleCaption), searchIcon)); for (final MatchResultDto result : results) { - final MatchResultComponent matchResultComponent = new MatchResultComponent(result, liveScores); + final MatchResultComponent matchResultComponent = new MatchResultComponent(result, liveScores, clock); this.add(matchResultComponent); } } diff --git a/src/main/java/com/flowingcode/fixture/view/screen/WelcomeScreen.java b/src/main/java/com/flowingcode/fixture/view/screen/WelcomeScreen.java index e5fe6cf..cf51631 100644 --- a/src/main/java/com/flowingcode/fixture/view/screen/WelcomeScreen.java +++ b/src/main/java/com/flowingcode/fixture/view/screen/WelcomeScreen.java @@ -10,6 +10,7 @@ import com.flowingcode.fixture.view.model.MatchResultDto; import com.flowingcode.fixture.view.presenter.WelcomePresenter; import com.flowingcode.fixture.view.util.LiveScoreSignals; +import com.flowingcode.fixture.view.util.ViewerClock; import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.html.H3; import com.vaadin.flow.component.orderedlayout.VerticalLayout; @@ -25,10 +26,13 @@ public class WelcomeScreen extends VerticalLayout { private final LiveScoreSignals liveScores; + private final ViewerClock clock; + @Autowired - public WelcomeScreen(final WelcomePresenter presenter, final LiveScoreSignals liveScores) { + public WelcomeScreen(final WelcomePresenter presenter, final LiveScoreSignals liveScores, final ViewerClock clock) { this.presenter = presenter; this.liveScores = liveScores; + this.clock = clock; presenter.setView(this); // center components @@ -37,7 +41,8 @@ public WelcomeScreen(final WelcomePresenter presenter, final LiveScoreSignals li @Override protected void onAttach(final AttachEvent attachEvent) { - presenter.loadResults(); + // Render now; re-render once the viewer's time zone resolves (local times). + clock.render(presenter::loadResults); } public void init(final List results) { @@ -45,7 +50,7 @@ public void init(final List results) { if (!completedMatches.isEmpty()) { this.add(new H3("Completed matches")); for (final MatchResultDto result : completedMatches) { - final MatchResultComponent matchResultComponent = new MatchResultComponent(result, liveScores); + final MatchResultComponent matchResultComponent = new MatchResultComponent(result, liveScores, clock); this.add(matchResultComponent); } } @@ -54,7 +59,7 @@ public void init(final List results) { if (!currentMatches.isEmpty()) { this.add(new H3("Current matches")); for (final MatchResultDto result : currentMatches) { - final MatchResultComponent matchResultComponent = new MatchResultComponent(result, liveScores); + final MatchResultComponent matchResultComponent = new MatchResultComponent(result, liveScores, clock); this.add(matchResultComponent); } } @@ -63,7 +68,7 @@ public void init(final List results) { if (!upcomingMatches.isEmpty()) { this.add(new H3("Upcoming matches")); for (final MatchResultDto result : upcomingMatches) { - final MatchResultComponent matchResultComponent = new MatchResultComponent(result, liveScores); + final MatchResultComponent matchResultComponent = new MatchResultComponent(result, liveScores, clock); this.add(matchResultComponent); } } diff --git a/src/main/java/com/flowingcode/fixture/view/util/DateTimeUtil.java b/src/main/java/com/flowingcode/fixture/view/util/DateTimeUtil.java index dcf1604..4f6af8b 100644 --- a/src/main/java/com/flowingcode/fixture/view/util/DateTimeUtil.java +++ b/src/main/java/com/flowingcode/fixture/view/util/DateTimeUtil.java @@ -1,24 +1,45 @@ package com.flowingcode.fixture.view.util; import java.time.LocalDate; +import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.Locale; public class DateTimeUtil { - + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("MM/dd/y"); private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("hh:mma"); public static String styleDate(ZonedDateTime zoneDate) { return zoneDate.format(DATE); } - + public static String styleTime(ZonedDateTime zoneDate) { return zoneDate.format(TIME); } - + public static String styleDate(LocalDate localDate) { return localDate.format(DATE); } - + + /** Time of {@code instant} shown in the viewer's zone and locale (e.g. "21:00" / "9:00 PM"). */ + public static String time(ZonedDateTime instant, ZoneId zone, Locale locale) { + return DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + .withLocale(locale).format(instant.withZoneSameInstant(zone)); + } + + /** Date of {@code instant} shown in the viewer's zone and locale. */ + public static String date(ZonedDateTime instant, ZoneId zone, Locale locale) { + return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(locale).format(instant.withZoneSameInstant(zone)); + } + + /** Date and time of {@code instant} shown in the viewer's zone and locale. */ + public static String dateTime(ZonedDateTime instant, ZoneId zone, Locale locale) { + return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) + .withLocale(locale).format(instant.withZoneSameInstant(zone)); + } + } diff --git a/src/main/java/com/flowingcode/fixture/view/util/ViewerClock.java b/src/main/java/com/flowingcode/fixture/view/util/ViewerClock.java new file mode 100644 index 0000000..d12de46 --- /dev/null +++ b/src/main/java/com/flowingcode/fixture/view/util/ViewerClock.java @@ -0,0 +1,69 @@ +package com.flowingcode.fixture.view.util; + +import java.time.ZoneId; +import java.util.Locale; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.spring.annotation.SpringComponent; +import com.vaadin.flow.spring.annotation.UIScope; + +/** + * Resolves the viewing user's time zone and locale so kick-off times can be + * shown in the visitor's own local time rather than the venue's. + * + *

The locale comes from {@link UI#getLocale()} (negotiated from the browser's + * {@code Accept-Language}). The time zone is only available asynchronously via + * {@code ExtendedClientDetails} (it needs a client round-trip), so views should + * render through {@link #whenReady(Runnable)}: the first time it resolves the + * zone and then runs the action; afterwards it runs synchronously with the + * cached zone. Scoped per UI, so the round-trip happens once per browser tab. + */ +@SpringComponent +@UIScope +public class ViewerClock { + + private static final ZoneId FALLBACK = ZoneId.of("UTC"); + + private ZoneId zone; + + /** + * Renders now and, the first time, again once the viewer's time zone is + * known. {@code renderAction} runs immediately (so the view never waits on, + * or is blocked by, the asynchronous zone lookup); if the zone isn't cached + * yet it is resolved in the background and {@code renderAction} re-runs once + * with the resolved zone. After that the zone is cached and a single render + * is enough. + */ + // retrieveExtendedClientDetails is soft-deprecated in 25.1 but remains the only + // API to read the client time zone (UI exposes locale only); the Vaadin docs + // still document it and there is no replacement yet. Revisit if one ships. + @SuppressWarnings("deprecation") + public void render(final Runnable renderAction) { + renderAction.run(); + if (zone != null) { + return; + } + UI.getCurrent().getPage().retrieveExtendedClientDetails(details -> { + zone = parse(details.getTimeZoneId()); + renderAction.run(); + }); + } + + public ZoneId zone() { + return zone != null ? zone : FALLBACK; + } + + public Locale locale() { + final UI ui = UI.getCurrent(); + return ui != null && ui.getLocale() != null ? ui.getLocale() : Locale.getDefault(); + } + + private static ZoneId parse(final String timeZoneId) { + try { + return timeZoneId != null ? ZoneId.of(timeZoneId) : FALLBACK; + } catch (final RuntimeException e) { + return FALLBACK; + } + } + +} diff --git a/src/test/java/com/flowingcode/fixture/repository/worldcup/StadiumCatalogTest.java b/src/test/java/com/flowingcode/fixture/repository/worldcup/StadiumCatalogTest.java new file mode 100644 index 0000000..c98c64e --- /dev/null +++ b/src/test/java/com/flowingcode/fixture/repository/worldcup/StadiumCatalogTest.java @@ -0,0 +1,35 @@ +package com.flowingcode.fixture.repository.worldcup; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.time.ZoneId; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class StadiumCatalogTest { + + private StadiumCatalog catalog; + + @BeforeEach + void setUp() { + // zoneById is a static lookup; the client is never hit. + catalog = new StadiumCatalog(mock(WorldCupClient.class)); + } + + @Test + void zoneById_resolvesEachHostVenueToItsZone() { + assertThat(catalog.zoneById("1")).isEqualTo(ZoneId.of("America/Mexico_City")); // Estadio Azteca + assertThat(catalog.zoneById("16")).isEqualTo(ZoneId.of("America/Los_Angeles")); // SoFi, Los Angeles + assertThat(catalog.zoneById("12")).isEqualTo(ZoneId.of("America/Toronto")); // BMO Field, Toronto + assertThat(catalog.zoneById("4")).isEqualTo(ZoneId.of("America/Chicago")); // AT&T Stadium, Dallas + assertThat(catalog.zoneById("11")).isEqualTo(ZoneId.of("America/New_York")); // MetLife, NY/NJ + } + + @Test + void zoneById_fallsBackToEasternForUnknownId() { + assertThat(catalog.zoneById("999")).isEqualTo(ZoneId.of("America/New_York")); + assertThat(catalog.zoneById(null)).isEqualTo(ZoneId.of("America/New_York")); + } +} diff --git a/src/test/java/com/flowingcode/fixture/service/MatchServiceImplTest.java b/src/test/java/com/flowingcode/fixture/service/MatchServiceImplTest.java index e4508e0..26b9ea8 100644 --- a/src/test/java/com/flowingcode/fixture/service/MatchServiceImplTest.java +++ b/src/test/java/com/flowingcode/fixture/service/MatchServiceImplTest.java @@ -4,7 +4,9 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.time.Duration; import java.time.LocalDate; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.List; @@ -33,7 +35,9 @@ class MatchServiceImplTest { @BeforeEach void setUp() { client = mock(WorldCupClient.class); - service = new MatchServiceImpl(client, mock(TeamCatalog.class), mock(StadiumCatalog.class)); + // Real StadiumCatalog: its zoneById is a static lookup (no network), and + // parseKickoff now depends on it. + service = new MatchServiceImpl(client, mock(TeamCatalog.class), new StadiumCatalog(mock(WorldCupClient.class))); } /** Builds a Game with sensible defaults; override only what a test cares about. */ @@ -114,6 +118,23 @@ void stage_knockoutTypesAreHumanized() { .isEqualTo("Final"); } + @Test + void kickoff_isInterpretedInTheVenueTimeZone() { + // Same wall-clock string at two venues must yield different instants: + // 13:00 in LA (UTC-7 in June) vs 13:00 in NY (UTC-4 in June) = 3h apart. + final Game la = new Game("1", "10", "20", "0", "0", null, null, "A", "1", "06/15/2026 13:00", + "16", "FALSE", "notstarted", "group", "Mexico", "Canada"); // SoFi → America/Los_Angeles + final Game ny = new Game("2", "10", "20", "0", "0", null, null, "A", "1", "06/15/2026 13:00", + "11", "FALSE", "notstarted", "group", "Mexico", "Canada"); // MetLife → America/New_York + + when(client.getGames()).thenReturn(List.of(la)); + final ZonedDateTime laKickoff = service.getMatches().get(0).getKickoff(); + when(client.getGames()).thenReturn(List.of(ny)); + final ZonedDateTime nyKickoff = service.getMatches().get(0).getKickoff(); + + assertThat(Duration.between(nyKickoff.toInstant(), laKickoff.toInstant()).toHours()).isEqualTo(3); + } + @Test void getCurrentMatches_returnsOnlyInProgressGames() { when(client.getGames()).thenReturn(List.of( From 1fc791be5ee85c1e17e62bf20e694e94a8daa485 Mon Sep 17 00:00:00 2001 From: Paola De Bartolo Date: Fri, 12 Jun 2026 12:18:36 -0300 Subject: [PATCH 4/4] fix: strip API set-wrapping from goal scorer names Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fixture/service/MatchServiceImpl.java | 11 +++++++++-- .../fixture/service/MatchServiceImplTest.java | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/flowingcode/fixture/service/MatchServiceImpl.java b/src/main/java/com/flowingcode/fixture/service/MatchServiceImpl.java index 5cc4732..60e967a 100644 --- a/src/main/java/com/flowingcode/fixture/service/MatchServiceImpl.java +++ b/src/main/java/com/flowingcode/fixture/service/MatchServiceImpl.java @@ -120,13 +120,20 @@ protected TeamDto createTeamDto(final String teamId, final String nameEn, final return target; } - /** worldcup26.ir only exposes goal scorers (as a name list), not full event feeds. */ + /** + * worldcup26.ir only exposes goal scorers (not full event feeds), and ships + * them as a set-like string with curly braces and quoted entries, e.g. + * {@code {"J. Quiñones 9'", "R. Jiménez 67'"}}. Strip that wrapping (braces + * and straight/typographic quotes) so only the names — with their inline + * minute — are shown. + */ private List scorerEvents(final String scorers) { final List events = new ArrayList<>(); if (scorers == null || scorers.isBlank() || NULL.equalsIgnoreCase(scorers)) { return events; } - for (final String name : scorers.split(",")) { + final String unwrapped = scorers.replaceAll("[{}\"“”]", ""); + for (final String name : unwrapped.split(",")) { final String player = name.trim(); if (player.isEmpty()) { continue; diff --git a/src/test/java/com/flowingcode/fixture/service/MatchServiceImplTest.java b/src/test/java/com/flowingcode/fixture/service/MatchServiceImplTest.java index 26b9ea8..a893714 100644 --- a/src/test/java/com/flowingcode/fixture/service/MatchServiceImplTest.java +++ b/src/test/java/com/flowingcode/fixture/service/MatchServiceImplTest.java @@ -18,7 +18,9 @@ import com.flowingcode.fixture.repository.worldcup.Wc26Dtos.Game; import com.flowingcode.fixture.repository.worldcup.WorldCupClient; import com.flowingcode.fixture.view.enums.MatchStatus; +import com.flowingcode.fixture.view.model.MatchDetailDto; import com.flowingcode.fixture.view.model.MatchResultDto; +import com.flowingcode.fixture.view.model.TeamEventDto; /** * Exercises the worldcup26.ir → view-DTO mapping in {@link MatchServiceImpl}: @@ -135,6 +137,20 @@ void kickoff_isInterpretedInTheVenueTimeZone() { assertThat(Duration.between(nyKickoff.toInstant(), laKickoff.toInstant()).toHours()).isEqualTo(3); } + @Test + void scorerEvents_stripTheApiSetWrappingFromPlayerNames() { + // worldcup26.ir ships scorers as {"Name 9'", "Name 67'"} with typographic quotes. + final String scorers = "{“J. Quiñones 9'”, “R. Jiménez 67'”}"; + final Game g = new Game("9", "10", "20", "2", "0", scorers, "null", "A", "1", today(), + "1", "FALSE", "live", "group", "Mexico", "Canada"); + when(client.getGames()).thenReturn(List.of(g)); + + final MatchDetailDto detail = service.getByFifaId("9").orElseThrow(); + + assertThat(detail.getHomeTeamEvents()).extracting(TeamEventDto::getPlayer) + .containsExactly("J. Quiñones 9'", "R. Jiménez 67'"); + } + @Test void getCurrentMatches_returnsOnlyInProgressGames() { when(client.getGames()).thenReturn(List.of(