Compare commits

5 Commits

Author SHA1 Message Date
3015fc2e78 disable workflow 2025-12-05 21:33:20 +01:00
dcec1e8898 Merge pull request #109 from fabrice404/dependabot/npm_and_yarn/ui/dependencies-patch-and-minor-32182d7a39
npm(deps): bump the dependencies-patch-and-minor group in /ui with 5 updates
2025-12-05 21:32:29 +01:00
bdf47b3957 npm(deps): bump the dependencies-patch-and-minor group
Bumps the dependencies-patch-and-minor group in /ui with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [next](https://github.com/vercel/next.js) | `16.0.6` | `16.0.7` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.0` | `19.2.1` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.0` | `19.2.1` |
| [daisyui](https://github.com/saadeghi/daisyui/tree/HEAD/packages/daisyui) | `5.5.5` | `5.5.8` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.0.6` | `16.0.7` |


Updates `next` from 16.0.6 to 16.0.7
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.0.6...v16.0.7)

Updates `react` from 19.2.0 to 19.2.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.1/packages/react)

Updates `react-dom` from 19.2.0 to 19.2.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.1/packages/react-dom)

Updates `daisyui` from 5.5.5 to 5.5.8
- [Release notes](https://github.com/saadeghi/daisyui/releases)
- [Changelog](https://github.com/saadeghi/daisyui/blob/master/CHANGELOG.md)
- [Commits](https://github.com/saadeghi/daisyui/commits/v5.5.8/packages/daisyui)

Updates `eslint-config-next` from 16.0.6 to 16.0.7
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.0.7/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
- dependency-name: react
  dependency-version: 19.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
- dependency-name: react-dom
  dependency-version: 19.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
- dependency-name: daisyui
  dependency-version: 5.5.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
- dependency-name: eslint-config-next
  dependency-version: 16.0.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependencies-patch-and-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-05 20:32:21 +00:00
e2b6c42f01 wip milano-cortina 2026 2025-12-05 21:30:34 +01:00
5074fd5155 wip milano-cortina 2026 2025-12-05 21:30:27 +01:00
32 changed files with 8394 additions and 34 deletions

View File

@ -2,7 +2,28 @@ version: 2
updates:
- package-ecosystem: npm
directory: "/"
directory: "/scraper"
schedule:
interval: daily
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
commit-message:
prefix: "npm"
include: "scope"
groups:
dependencies-patch-and-minor:
update-types:
- minor
- patch
open-pull-requests-limit: 50
reviewers:
- fabrice404
assignees:
- fabrice404
- package-ecosystem: npm
directory: "/ui"
schedule:
interval: daily
ignore:

View File

@ -1,27 +1,27 @@
name: Auto update calendar
# name: Auto update calendar
on:
# schedule:
# - cron: "*/10 * * * *"
workflow_dispatch:
# on:
# # schedule:
# # - cron: "*/10 * * * *"
# workflow_dispatch:
jobs:
run:
runs-on: ubuntu-latest
# jobs:
# run:
# runs-on: ubuntu-latest
permissions:
contents: write
# permissions:
# contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm start
- name: Set date
id: date_step
run: echo "today=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_ENV
- uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "calendar updates on ${{ env.today }}"
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-node@v4
# with:
# node-version: 20
# - run: npm install
# - run: npm start
# - name: Set date
# id: date_step
# run: echo "today=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_ENV
# - uses: stefanzweifel/git-auto-commit-action@v5
# with:
# commit_message: "calendar updates on ${{ env.today }}"

16
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,16 @@
{
"cSpell.words": [
"Cortina",
"NOC", // National Olympic Committee
"NOCs", // National Olympic Committees
// color palette
"azzurro",
"giallo",
"rosa",
"rosso",
"verde",
"viola"
]
}

0
Dockerfile Normal file
View File

3
LICENSE Normal file
View File

@ -0,0 +1,3 @@
Copyright (c) 2025 Fabrice Lamant. All rights reserved.
No part of this work may be copied, reproduced, distributed, transmitted, displayed, performed, or otherwise used, in whole or in part, by any means or in any form, without the prior written permission of the copyright holder.

View File

@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
const debug = Debug(`olympics-calendar:cache`);
const cachePath = (key: string): string => {
return `../cache/${key}`;
return `../cache/${key}.cached`;
}
export const get = (key: string): string | null => {

150
scraper/ics.ts Normal file
View File

@ -0,0 +1,150 @@
// BEGIN:VCALENDAR
// VERSION:2.0
// PRODID:-//fabrice404//olympics-calendar//archery/AUS//EN
// X-WR-CALNAME:🇦🇺 Australia Archery | Paris 2024
// NAME:🇦🇺 Australia Archery | Paris 2024
// BEGIN:VEVENT
// UID:20240725T073000Z-archery-WOMENS-INDIVIDUAL-RANKING-ROUND
// DTSTAMP:20240725T073000Z
// DTSTART:20240725T073000Z
// DTEND:20240725T103000Z
// DESCRIPTION:Archery - Women's Individual Ranking Round\n🇨🇳 AN
// Qixuan\n🇲🇽 Alejandra VALENCIA\n🇲🇩 Alexandra MIRCA\n🇵🇷 Alondra
// RIVERA\n🇫🇷 Amelie CORDEAU\n🇧🇷 Ana Luiza SLIACHTICAS CAETANO\n🇨🇴 Ana
// RENDON MARTINEZ\n🇲🇽 Ana VAZQUEZ\n🇲🇽 Angela RUIZ\n🇮🇳 Ankita
// BHAKAT\n🇲🇾 Ariana Nur Dania MOHAMAD ZAIRI\n🇮🇳 Bhajan KAUR\n🇬🇧
// Bryony PITMAN\n🇹🇼 CHIU Yi-Ching\n🇫🇷 Caroline LOPEZ\n🇺🇸 Casey
// KAUFHOLD\n🇺🇸 Catalina GNORIEGA\n🇩🇪 Charline SCHWARZ\n🇮🇹 Chiara
// REBAGLIATI\n🇮🇳 Deepika KUMARI\n🇸🇰 Denisa BARANKOVA\n🇮🇩 Diananda
// CHOIRUNISA\n🇪🇸 Elia CANALES\n🇹🇷 Elif Berra GOKKIR\n🇦🇹 Elisabeth
// STRAKA\n🇬🇳 Fatoumata SYLLA\n🇳🇱 Gaby SCHLOESSER\n🇸🇲 Giorgia
// CESARINI\n🇰🇷 JEON Hunyoung\n🇪🇬 Jana ALI\n🇺🇸 Jennifer MUCINO\n🇩🇪
// Katharina BAUER\n🇩🇰 Kirstine DANSTRUP ANDERSEN\n🇹🇼 LEI
// Chien-Ying\n🇨🇳 LI Jiaman\n🇹🇼 LI Tsai-Chi\n🇰🇷 LIM Sihyeon\n🇦🇺
// Laura PAEGLIS\n🇳🇱 Laura van der WINKEL\n🇫🇷 Lisa BARBELIN\n🇷🇴
// Madalina AMAISTROAIE\n🇨🇿 Marie HORACKOVA\n🇬🇧 Megs HAVERS\n🇩🇪
// Michelle KROPPEN\n🇮🇱 Mikaella MOSHE\n🇮🇷 Mobina FALLAH\n🇰🇷 NAM
// Suhyeon\n🇯🇵 NODA Satsuki\n🇲🇾 Nurul Azreena MOHAMAD FAZIL\n🇬🇧 Penny
// HEALEY\n🇳🇱 Quinty ROEFFEN\n🇪🇪 Reena PARNAT\n🇮🇩 Rezza OCTAVIA\n🇹🇳
// Rihab ELWALID\n🇲🇾 Syaqiera MASHAYIKH\n🇮🇩 Syifa Nurafifah KAMAL\n🇻🇳
// Thi Anh Nguyet DO\n🇺🇦 Veronika MARCHENKO\n🇨🇦 Virginie CHENIER\n🇵🇱
// Wioleta MYSZOR\n🇨🇳 YANG Xiaolei\n🇦🇿 Yaylagul RAMAZANOVA\n🇸🇮 Zana
// PINTARIC\n🇺🇿 Ziyodakhon ABDUSATTOROVA
// SUMMARY:🏹 Women's Individual Ranking Round
// LOCATION:Invalides
// END:VEVENT
import { mkdirSync, writeFileSync } from "fs";
import { Calendar } from "./types";
import { getFlag } from "./nocs";
// BEGIN:VCALENDAR
// VERSION:2.0
// PRODID:-//fabrice404//olympics-calendar//3x3-basketball/AUS//EN
// X-WR-CALNAME:🇦🇺 Australia 3x3 Basketball | Paris 2024
// NAME:🇦🇺 Australia 3x3 Basketball | Paris 2024
// BEGIN:VEVENT
// UID:20240730T160000Z-3x3-basketball-WOMENS-POOL-ROUND-AUS-CAN
// DTSTAMP:20240730T160000Z
// DTSTART:20240730T160000Z
// DTEND:20240730T162500Z
// DESCRIPTION:3x3 Basketball - Women's Pool Round
// SUMMARY:🏀 AUS 🇦🇺 - 🇨🇦 CAN
// LOCATION:La Concorde 1
// END:VEVENT
// END:VCALENDAR
export const generateICSFiles = (calendar: Calendar): void => {
generateICSFile(calendar, null, null);
calendar.sports.forEach((sport) => {
generateICSFile(calendar, sport.key, null);
calendar.nocs.forEach((noc) => {
generateICSFile(calendar, sport.key, noc.key);
});
});
calendar.nocs.forEach((noc) => {
generateICSFile(calendar, null, noc.key);
});
};
export const generateICSFile = (calendar: Calendar, sportKey: string | null, nocKey: string | null): void => {
calendar.languages.forEach((lang) => {
const pathSportKey = sportKey ? sportKey : "all-sports";
const pathNocKey = nocKey ? nocKey : "calendar"
const filepath = `../ui/public/data/${lang.code.toLowerCase()}/${pathSportKey.toLowerCase()}/${pathNocKey.toLowerCase()}.ics`;
mkdirSync(filepath.split('/').slice(0, -1).join('/'), { recursive: true });
const titleComponents = [];
if (nocKey) {
titleComponents.push(`${calendar.nocs.find(n => n.key === nocKey)!.name[lang.code]}`);
}
if (sportKey) {
titleComponents.push(calendar.sports.find(s => s.key === sportKey)!.name[lang.code]);
}
titleComponents.push("Milano Cortina 2026");
const title = titleComponents.join(' - ');
const lines = [];
lines.push("BEGIN:VCALENDAR");
lines.push("VERSION:2.0");
lines.push(`PRODID:-//fabrice404//olympics-calendar//${lang.code}/${pathSportKey}/${pathNocKey}`);
lines.push(`X-WR-CALNAME:${title}`);
lines.push(`NAME:${title}`);
calendar.events
.filter((event) => {
if (sportKey && event.sport !== sportKey) return false;
if (nocKey) {
if (event.match) {
const team1Key = event.match.team1.key;
const team2Key = event.match.team2.key;
if (team1Key !== nocKey && team2Key !== nocKey) {
return false;
}
} else {
return false;
}
}
return true;
})
.forEach((event) => {
lines.push("BEGIN:VEVENT");
lines.push(`UID:${event.key.replace(/--/g, '-')}`);
lines.push(`DTSTAMP:${event.start.replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`);
lines.push(`DTSTART:${event.start.replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`);
lines.push(`DTEND:${event.end.replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`);
lines.push(`LOCATION:${event.location[lang.code] || ''}`);
const sport = calendar.sports.find(s => s.key === event.sport)!;
lines.push(`DESCRIPTION:${sport.name[lang.code]} - ${event.name[lang.code] || ''}`);
let summary = `SUMMARY:${event.name[lang.code] || ''}`
if (event.match) {
const team1Name = event.match.team1.name[lang.code] || event.match.team1.key;
const team1Flag = getFlag(event.match.team1.key);
const team2Name = event.match.team2.name[lang.code] || event.match.team2.key;
const team2Flag = getFlag(event.match.team2.key);
if (team1Name && team2Name) {
lines.push(`SUMMARY:${team1Flag} ${team1Name} - ${team2Name} ${team2Flag}`);
}
}
lines.push(summary);
lines.push(`END:VEVENT`);
})
lines.push("END:VCALENDAR");
writeFileSync(filepath, lines.join('\n'));
});
};

View File

@ -1,12 +1,16 @@
import Debug from "debug";
import * as cache from "./cache";
import { mkdirSync, writeFileSync } from "fs";
import { Calendar, Event, Sport, Team } from "./types";
import { generateICSFiles } from "./ics";
const baseUrl = "https://www.olympics.com";
const basePath = "/milano-cortina-2026/schedule/overview";
const debug = Debug(`olympics-calendar:index`);
const getScheduleOverview = async (language: string) => {
debug(`getScheduleOverview: language=${language}`);
@ -48,7 +52,7 @@ const getScheduleSport = async (language: string, sportCode: string) => {
const scheduleSport = JSON.parse(cache.get(scheduleSportKey)!);
return scheduleSport;
}
};
const main = async () => {
const overview = await getScheduleOverview("en");
@ -59,9 +63,9 @@ const main = async () => {
name: lang.label,
}))
const sports: any = [];
const events: any[] = [];
let nocs: any[] = [];
const sports: Sport[] = [];
const events: Event[] = [];
let nocs: Team[] = [];
for (const lang of languages) {
const scheduleOverview = await getScheduleOverview(lang.code);
@ -76,7 +80,7 @@ const main = async () => {
if (sports.find((s: any) => s.key === key) == null) {
sports.push({ key, name: {}, order: -1 })
}
const sport = sports.find((s: any) => s.key === key)
const sport = sports.find((s: any) => s.key === key)!;
sport.name[lang.code] = discipline.description;
sport.order = discipline.order;
@ -93,10 +97,12 @@ const main = async () => {
isTraining: scheduleListElement.isTraining,
medal: scheduleListElement.medal,
name: {},
location: {},
})
}
const event = events.find(e => e.key === scheduleListElement.unitCode);
const event = events.find(e => e.key === scheduleListElement.unitCode)!;
event.name[lang.code] = scheduleListElement.description;
event.location[lang.code] = scheduleListElement.venue?.description || ''
if (scheduleListElement.match) {
if (event.match == null) {
@ -114,7 +120,7 @@ const main = async () => {
if (nocs.find(n => n.key === nocKey) == null) {
nocs.push({ key: nocKey, name: {} });
}
const noc = nocs.find(n => n.key === nocKey);
const noc = nocs.find(n => n.key === nocKey)!;
noc.name[lang.code] = (team.description || '').replace(/\,/gi, '');
}
}
@ -125,7 +131,11 @@ const main = async () => {
nocs = nocs.filter((noc) => noc.key !== noc.name.en);
cache.set('calendar.json', JSON.stringify({ languages, sports, nocs, events }));
const dataFolder = "../ui/public/data";
mkdirSync(dataFolder, { recursive: true });
const calendar: Calendar = { languages, sports, nocs, events };
writeFileSync(`${dataFolder}/calendar.json`, JSON.stringify(calendar));
generateICSFiles(calendar);
};
main();

212
scraper/nocs.ts Normal file
View File

@ -0,0 +1,212 @@
export const flags: { [key: string]: string } = {
AFG: "🇦🇫",
ALB: "🇦🇱",
ALG: "🇩🇿",
AIN: "🇦🇸",
AND: "🇦🇩",
ANG: "🇦🇴",
ANT: "🇦🇬",
ARG: "🇦🇷",
ARM: "🇦🇲",
ARU: "🇦🇼",
ASA: "🇦🇸",
AUS: "🇦🇺",
AUT: "🇦🇹",
AZE: "🇦🇿",
BAH: "🇧🇸",
BAN: "🇧🇩",
BAR: "🇧🇧",
BDI: "🇧🇮",
BEL: "🇧🇪",
BEN: "🇧🇯",
BER: "🇧🇲",
BHU: "🇧🇹",
BIH: "🇧🇦",
BIZ: "🇧🇿",
BOL: "🇧🇴",
BOT: "🇧🇼",
BRA: "🇧🇷",
BRN: "🇧🇭",
BRU: "🇧🇳",
BUL: "🇧🇬",
BUR: "🇧🇫",
CAF: "🇨🇫",
CAM: "🇰🇭",
CAN: "🇨🇦",
CAY: "🇰🇾",
CGO: "🇨🇬",
CHA: "🇹🇩",
CHI: "🇨🇱",
CHN: "🇨🇳",
CIV: "🇨🇮",
CMR: "🇨🇲",
COD: "🇨🇩",
COK: "🇨🇰",
COL: "🇨🇴",
COM: "🇰🇲",
CPV: "🇨🇻",
CRC: "🇨🇷",
CRO: "🇭🇷",
CUB: "🇨🇺",
CYP: "🇨🇾",
CZE: "🇨🇿",
DEN: "🇩🇰",
DJI: "🇩🇯",
DMA: "🇩🇲",
DOM: "🇩🇴",
ECU: "🇪🇨",
EGY: "🇪🇬",
EOR: "🏳️",
ERI: "🇪🇷",
ESA: "🇸🇻",
ESP: "🇪🇸",
EST: "🇪🇪",
ETH: "🇪🇹",
FIJ: "🇫🇯",
FIN: "🇫🇮",
FRA: "🇫🇷",
FSM: "🇫🇲",
GAB: "🇬🇦",
GAM: "🇬🇲",
GBR: "🇬🇧",
GBS: "🇬🇼",
GEO: "🇬🇪",
GEQ: "🇬🇶",
GER: "🇩🇪",
GHA: "🇬🇭",
GRE: "🇬🇷",
GRN: "🇬🇩",
GUA: "🇬🇹",
GUI: "🇬🇳",
GUM: "🇬🇺",
GUY: "🇬🇾",
HAI: "🇭🇹",
HKG: "🇭🇰",
HON: "🇭🇳",
HUN: "🇭🇺",
INA: "🇮🇩",
IND: "🇮🇳",
IRI: "🇮🇷",
IRL: "🇮🇪",
IRQ: "🇮🇶",
ISL: "🇮🇸",
ISR: "🇮🇱",
ISV: "🇻🇮",
ITA: "🇮🇹",
IVB: "🇻🇬",
JAM: "🇯🇲",
JOR: "🇯🇴",
JPN: "🇯🇵",
KAZ: "🇰🇿",
KEN: "🇰🇪",
KGZ: "🇰🇬",
KIR: "🇰🇮",
KOR: "🇰🇷",
KOS: "🇽🇰",
KSA: "🇸🇦",
KUW: "🇰🇼",
LAO: "🇱🇦",
LAT: "🇱🇻",
LBA: "🇱🇾",
LBN: "🇱🇧",
LBR: "🇱🇷",
LCA: "🇱🇨",
LES: "🇱🇸",
LIE: "🇱🇮",
LTU: "🇱🇹",
LUX: "🇱🇺",
MAD: "🇲🇬",
MAR: "🇲🇦",
MAS: "🇲🇾",
MAW: "🇲🇼",
MDA: "🇲🇩",
MDV: "🇲🇻",
MEX: "🇲🇽",
MGL: "🇲🇳",
MHL: "🇲🇭",
MKD: "🇲🇰",
MLI: "🇲🇱",
MLT: "🇲🇹",
MNE: "🇲🇪",
MON: "🇲🇨",
MOZ: "🇲🇿",
MRI: "🇲🇺",
MTN: "🇲🇷",
MYA: "🇲🇲",
NAM: "🇳🇦",
NCA: "🇳🇮",
NED: "🇳🇱",
NEP: "🇳🇵",
NGR: "🇳🇬",
NIG: "🇳🇪",
NOR: "🇳🇴",
NRU: "🇳🇷",
NZL: "🇳🇿",
OMA: "🇴🇲",
PAK: "🇵🇰",
PAN: "🇵🇦",
PAR: "🇵🇾",
PER: "🇵🇪",
PHI: "🇵🇭",
PLE: "🇵🇸",
PLW: "🇵🇼",
PNG: "🇵🇬",
POL: "🇵🇱",
POR: "🇵🇹",
PRK: "🇰🇵",
PUR: "🇵🇷",
QAT: "🇶🇦",
ROU: "🇷🇴",
RSA: "🇿🇦",
RWA: "🇷🇼",
SAM: "🇼🇸",
SEN: "🇸🇳",
SEY: "🇸🇨",
SGP: "🇸🇬",
SKN: "🇰🇳",
SLE: "🇸🇱",
SLO: "🇸🇮",
SMR: "🇸🇲",
SOL: "🇸🇧",
SOM: "🇸🇴",
SRB: "🇷🇸",
SRI: "🇱🇰",
SSD: "🇸🇸",
STP: "🇸🇹",
SUD: "🇸🇩",
SUI: "🇨🇭",
SUR: "🇸🇷",
SVK: "🇸🇰",
SWE: "🇸🇪",
SWZ: "🇸🇿",
SYR: "🇸🇾",
TAN: "🇹🇿",
TGA: "🇹🇴",
THA: "🇹🇭",
TJK: "🇹🇯",
TKM: "🇹🇲",
TLS: "🇹🇱",
TOG: "🇹🇬",
TPE: "🇹🇼",
TTO: "🇹🇹",
TUN: "🇹🇳",
TUR: "🇹🇷",
TUV: "🇹🇻",
UAE: "🇦🇪",
UGA: "🇺🇬",
UKR: "🇺🇦",
URU: "🇺🇾",
USA: "🇺🇸",
UZB: "🇺🇿",
VAN: "🇻🇺",
VEN: "🇻🇪",
VIE: "🇻🇳",
VIN: "🇻🇨",
YEM: "🇾🇪",
ZAM: "🇿🇲",
ZIM: "🇿🇼",
};
export const getFlag = (nocKey: string): string => {
return flags[nocKey.toUpperCase()] || "🏳️";
}

View File

@ -7,7 +7,9 @@
"type": "commonjs",
"main": "index.js",
"scripts": {
"dev": "DEBUG=olympics-calendar* nodemon index.ts"
"start": "find ./cache/**/*.cached -mmin +10 -exec rm -f {} \\; | DEBUG=olympics-calendar* ts-node index.ts",
"dev": "DEBUG=olympics-calendar* nodemon index.ts",
"lint": "eslint . --ext .ts"
},
"dependencies": {
"debug": "^4.4.3",

44
scraper/types.d.ts vendored Normal file
View File

@ -0,0 +1,44 @@
export interface MultilingualString {
[key: string]: string;
}
export interface Language {
code: string;
name: string;
}
export interface Sport {
key: string;
name: MultilingualString;
order: number;
}
export interface Team {
key: string;
name: MultilingualString;
}
export interface Match {
team1: Team;
team2: Team;
}
export interface Event {
key: string;
start: string;
end: string;
sport: string;
isTraining: boolean;
medal: '0' | '1' | '3';
name: MultilingualString;
location: MultilingualString;
match?: Match;
}
export interface Calendar {
languages: Language[];
sports: Sport[];
events: Event[];
nocs: Team[];
}

215
test.html Normal file
View File

@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Add to Calendar - All Sports Calendar</title>
<style>
.add-to-calendar-wrapper {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
max-width: 480px;
margin: 2rem auto;
padding: 1.5rem;
border-radius: 12px;
border: 1px solid #e2e2e2;
box-shadow: 0 8px 24px rgba(0,0,0,.04);
}
.add-to-calendar-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.add-to-calendar-subtitle {
font-size: 0.9rem;
color: #555;
margin-bottom: 1.25rem;
}
.atc-buttons {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.atc-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.6rem 0.9rem;
border-radius: 999px;
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
border: 1px solid transparent;
transition: transform 0.08s ease, box-shadow 0.08s ease, background-color 0.08s ease;
cursor: pointer;
white-space: nowrap;
}
.atc-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,.08);
}
.atc-btn svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* Variants */
.atc-google {
background: #fff;
color: #1a73e8;
border-color: #d2e3fc;
}
.atc-outlook {
background: #0078d4;
color: #fff;
}
.atc-office365 {
background: #ea4c1d;
color: #fff;
}
.atc-apple {
background: #000;
color: #fff;
}
.atc-yahoo {
background: #5f01d1;
color: #fff;
}
.atc-ics {
background: #f5f5f5;
color: #333;
border-color: #ddd;
}
.atc-footer {
margin-top: 0.9rem;
font-size: 0.8rem;
color: #777;
}
@media (min-width: 520px) {
.atc-buttons {
flex-direction: row;
flex-wrap: wrap;
}
.atc-btn {
flex: 1 1 calc(50% - 0.5rem);
}
}
</style>
</head>
<body>
<div class="add-to-calendar-wrapper">
<div class="add-to-calendar-title">Add “All Sports Calendar”</div>
<div class="add-to-calendar-subtitle">
Subscribe to keep all sports events up to date in your favorite calendar app.
</div>
<div class="atc-buttons">
<!-- Google Calendar -->
<a
class="atc-btn atc-google"
href="https://calendar.google.com/calendar/u/0/r?cid=http%3A%2F%2Flocalhost%3A3000%2Fdata%2Fen%2Fall-sports%2Fcalendar.ics"
target="_blank"
rel="noopener noreferrer"
>
<!-- simple calendar icon -->
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="4" width="18" height="17" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
<line x1="3" y1="9" x2="21" y2="9" stroke="currentColor" stroke-width="1.6"/>
<line x1="8" y1="3" x2="8" y2="7" stroke="currentColor" stroke-width="1.6"/>
<line x1="16" y1="3" x2="16" y2="7" stroke="currentColor" stroke-width="1.6"/>
</svg>
<span>Google Calendar</span>
</a>
<!-- Outlook.com (personal accounts) -->
<a
class="atc-btn atc-outlook"
href="https://outlook.live.com/calendar/0/deeplink/subscribe?url=http%3A%2F%2Flocalhost%3A3000%2Fdata%2Fen%2Fall-sports%2Fcalendar.ics&name=All%20Sports%20Calendar"
target="_blank"
rel="noopener noreferrer"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="5" width="10" height="14" rx="1.5" fill="none" stroke="#ffffff" stroke-width="1.6"/>
<path d="M13 7h8v10h-8" fill="none" stroke="#ffffff" stroke-width="1.6"/>
</svg>
<span>Outlook.com</span>
</a>
<!-- Office 365 / Microsoft 365 (business) -->
<a
class="atc-btn atc-office365"
href="https://outlook.office.com/calendar/0/deeplink/subscribe?url=http%3A%2F%2Flocalhost%3A3000%2Fdata%2Fen%2Fall-sports%2Fcalendar.ics&name=All%20Sports%20Calendar"
target="_blank"
rel="noopener noreferrer"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 5h8v14H5z" fill="none" stroke="#ffffff" stroke-width="1.6"/>
<path d="M13 7h6v10h-6" fill="none" stroke="#ffffff" stroke-width="1.6"/>
</svg>
<span>Office 365</span>
</a>
<!-- Apple Calendar (iCal) -->
<a
class="atc-btn atc-apple"
href="webcal://localhost:3000/data/en/all-sports/calendar.ics"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="4" width="18" height="17" rx="2" ry="2" fill="none" stroke="#ffffff" stroke-width="1.6"/>
<line x1="3" y1="9" x2="21" y2="9" stroke="#ffffff" stroke-width="1.6"/>
<line x1="8" y1="3" x2="8" y2="7" stroke="#ffffff" stroke-width="1.6"/>
<line x1="16" y1="3" x2="16" y2="7" stroke="#ffffff" stroke-width="1.6"/>
</svg>
<span>Apple Calendar</span>
</a>
<!-- Yahoo Calendar -->
<a
class="atc-btn atc-yahoo"
href="https://calendar.yahoo.com/?ics=http%3A%2F%2Flocalhost%3A3000%2Fdata%2Fen%2Fall-sports%2Fcalendar.ics"
target="_blank"
rel="noopener noreferrer"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" fill="none" stroke="#ffffff" stroke-width="1.6"/>
<path d="M9 9l3 6 3-6" fill="none" stroke="#ffffff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>Yahoo Calendar</span>
</a>
<!-- Direct ICS download (for any other app) -->
<a
class="atc-btn atc-ics"
href="http://localhost:3000/data/en/all-sports/calendar.ics"
download="all-sports-calendar.ics"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3v12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
<path d="M8 11l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="5" y="17" width="14" height="4" rx="1" fill="none" stroke="currentColor" stroke-width="1.6"/>
</svg>
<span>Download .ics</span>
</a>
</div>
<div class="atc-footer">
Note: this demo uses <code>localhost</code>. Replace it with your production domain when you deploy.
</div>
</div>
</body>
</html>

43
ui/.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/public/data/

BIN
ui/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

267
ui/app/flag.tsx Normal file
View File

@ -0,0 +1,267 @@
export default function Flag({ iso3, name }: { iso3: string; name: string }) {
const iso3to2 = {
AFG: "AF",
ALA: "AX",
ALB: "AL",
DZA: "DZ",
ASM: "AS",
AND: "AD",
AGO: "AO",
AIA: "AI",
ATA: "AQ",
ATG: "AG",
ARG: "AR",
ARM: "AM",
ABW: "AW",
AUS: "AU",
AUT: "AT",
AZE: "AZ",
BHS: "BS",
BHR: "BH",
BGD: "BD",
BRB: "BB",
BLR: "BY",
BEL: "BE",
BLZ: "BZ",
BEN: "BJ",
BMU: "BM",
BTN: "BT",
BOL: "BO",
BES: "BQ",
BIH: "BA",
BWA: "BW",
BVT: "BV",
BRA: "BR",
VGB: "VG",
IOT: "IO",
BRN: "BN",
BGR: "BG",
BFA: "BF",
BDI: "BI",
KHM: "KH",
CMR: "CM",
CAN: "CA",
CPV: "CV",
CYM: "KY",
CAF: "CF",
TCD: "TD",
CHL: "CL",
CHN: "CN",
HKG: "HK",
MAC: "MO",
CXR: "CX",
CCK: "CC",
COL: "CO",
COM: "KM",
COG: "CG",
COD: "CD",
COK: "CK",
CRI: "CR",
CIV: "CI",
HRV: "HR",
CUB: "CU",
CUW: "CW",
CYP: "CY",
CZE: "CZ",
DNK: "DK",
DJI: "DJ",
DMA: "DM",
DOM: "DO",
ECU: "EC",
EGY: "EG",
SLV: "SV",
GNQ: "GQ",
ERI: "ER",
EST: "EE",
ETH: "ET",
FLK: "FK",
FRO: "FO",
FJI: "FJ",
FIN: "FI",
FRA: "FR",
GUF: "GF",
PYF: "PF",
ATF: "TF",
GAB: "GA",
GMB: "GM",
GEO: "GE",
DEU: "DE",
GHA: "GH",
GIB: "GI",
GRC: "GR",
GRL: "GL",
GRD: "GD",
GLP: "GP",
GUM: "GU",
GTM: "GT",
GGY: "GG",
GIN: "GN",
GNB: "GW",
GUY: "GY",
HTI: "HT",
HMD: "HM",
VAT: "VA",
HND: "HN",
HUN: "HU",
ISL: "IS",
IND: "IN",
IDN: "ID",
IRN: "IR",
IRQ: "IQ",
IRL: "IE",
IMN: "IM",
ISR: "IL",
ITA: "IT",
JAM: "JM",
JPN: "JP",
JEY: "JE",
JOR: "JO",
KAZ: "KZ",
KEN: "KE",
KIR: "KI",
PRK: "KP",
KOR: "KR",
KWT: "KW",
KGZ: "KG",
LAO: "LA",
LVA: "LV",
LBN: "LB",
LSO: "LS",
LBR: "LR",
LBY: "LY",
LIE: "LI",
LTU: "LT",
LUX: "LU",
MKD: "MK",
MDG: "MG",
MWI: "MW",
MYS: "MY",
MDV: "MV",
MLI: "ML",
MLT: "MT",
MHL: "MH",
MTQ: "MQ",
MRT: "MR",
MUS: "MU",
MYT: "YT",
MEX: "MX",
FSM: "FM",
MDA: "MD",
MCO: "MC",
MNG: "MN",
MNE: "ME",
MSR: "MS",
MAR: "MA",
MOZ: "MZ",
MMR: "MM",
NAM: "NA",
NRU: "NR",
NPL: "NP",
NLD: "NL",
ANT: "AN",
NCL: "NC",
NZL: "NZ",
NIC: "NI",
NER: "NE",
NGA: "NG",
NIU: "NU",
NFK: "NF",
MNP: "MP",
NOR: "NO",
OMN: "OM",
PAK: "PK",
PLW: "PW",
PSE: "PS",
PAN: "PA",
PNG: "PG",
PRY: "PY",
PER: "PE",
PHL: "PH",
PCN: "PN",
POL: "PL",
PRT: "PT",
PRI: "PR",
QAT: "QA",
REU: "RE",
ROU: "RO",
RUS: "RU",
RWA: "RW",
BLM: "BL",
SHN: "SH",
KNA: "KN",
LCA: "LC",
MAF: "MF",
SPM: "PM",
VCT: "VC",
WSM: "WS",
SMR: "SM",
STP: "ST",
SAU: "SA",
SEN: "SN",
SRB: "RS",
SYC: "SC",
SLE: "SL",
SGP: "SG",
SXM: "SX",
SVK: "SK",
SVN: "SI",
SLB: "SB",
SOM: "SO",
ZAF: "ZA",
SGS: "GS",
SSD: "SS",
ESP: "ES",
LKA: "LK",
SDN: "SD",
SUR: "SR",
SJM: "SJ",
SWZ: "SZ",
SWE: "SE",
CHE: "CH",
SYR: "SY",
TWN: "TW",
TJK: "TJ",
TZA: "TZ",
THA: "TH",
TLS: "TL",
TGO: "TG",
TKL: "TK",
TON: "TO",
TTO: "TT",
TUN: "TN",
TUR: "TR",
TKM: "TM",
TCA: "TC",
TUV: "TV",
UGA: "UG",
UKR: "UA",
ARE: "AE",
GBR: "GB",
USA: "US",
UMI: "UM",
URY: "UY",
UZB: "UZ",
VUT: "VU",
VEN: "VE",
VNM: "VN",
VIR: "VI",
WLF: "WF",
ESH: "EH",
YEM: "YE",
ZMB: "ZM",
ZWE: "ZW",
XKX: "XK",
SUI: "CH",
GER: "DE",
};
const iso2 = (iso3to2[iso3.toUpperCase()] || "").toLowerCase();
return <img
src={`https://gstatic.olympics.com/s3/noc/oly/3x2/${iso3.toUpperCase()}.png`}
height="24"
alt={`${iso3} - ${iso2}`}
className="inline-block mx-2 h-5 border-1 border-gray-300" />
}

69
ui/app/globals.css Normal file
View File

@ -0,0 +1,69 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: winter;
}
html {
background-color: #eeeeee;
}
.fg-azzurro {
color: #b0cde9;
}
.fg-rosso {
color: #fe5a59;
}
.fg-viola {
color: #c9badc;
}
.fg-giallo {
color: #f2e95d;
}
.fg-rosa {
color: #e9bdd2;
}
.fg-verde {
color: #769d91;
}
.fg-main {
color: #01647c;
}
.bg-main {
background-color: #01647c;
color : #ffffff;
}
.fg-gold {
color: #FFD700;
}
.bg-gold {
background-color: #FFD700;
color : #000000;
}
.fg-silver {
color: #C0C0C0;
}
.bg-silver {
background-color: #C0C0C0;
color : #000000;
}
.fg-bronze {
color: #CD7F32;
}
.bg-bronze {
background-color: #CD7F32;
color : #000000;
}

38
ui/app/layout.tsx Normal file
View File

@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Head from "next/head";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Milano Cortina 2026 Winter Olympics Calendar",
description: "Made with ❤️ by Fabrice Lamant",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html data-theme="winter">
<Head>
<link rel="icon" href="/favicon.ico" sizes="any" />
</Head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

359
ui/app/page.tsx Normal file
View File

@ -0,0 +1,359 @@
"use client";
import { loadSchedule } from "../lib/data";
import { useEffect, useState } from "react";
import Flag from "./flag";
import { useSearchParams } from "next/navigation";
import { COPY, COPY_SUCCESS, FILTER_BY_COUNTRY, FILTER_BY_SPORT } from "../lib/text";
import useLocalStorage from "@/lib/local-storage";
interface MultilingualString {
[key: string]: string;
}
interface Language {
code: string;
name: string;
}
interface Sport {
key: string;
name: MultilingualString
}
interface Team {
key: string;
name: MultilingualString;
}
interface Match {
team1: Team;
team2: Team;
}
interface Event {
key: string;
start: string;
end: string;
sport: string;
isTraining: boolean;
medal: '0' | '1' | '3';
name: MultilingualString;
match?: Match;
}
interface Calendar {
languages: Language[];
sports: Sport[];
events: Event[];
nocs: Team[];
}
const COLORS = ['azzurro', 'giallo', 'rosa', 'rosso', 'verde', 'viola'];
export default function Home() {
const qs = useSearchParams();
const [data, setData] = useState<Calendar | null>(null);
const [language, setLanguage] = useLocalStorage('lang', (navigator.language || 'en').split('-')[0]);
const translate = (text: MultilingualString) => {
return text[`${language}`] || text['en'] || Object.values(text)[0] || '';
};
const generateLink = ({ noc, sport, lang }: { noc?: string; sport?: string, lang?: string }) => {
const currentParams = new URLSearchParams(qs.toString());
if (noc !== undefined) {
if (noc === "") {
currentParams.delete('noc');
} else {
currentParams.set('noc', noc);
}
}
if (sport !== undefined) {
if (sport === "") {
currentParams.delete('sport');
} else {
currentParams.set('sport', sport);
}
}
if (lang !== undefined) {
if (lang === "") {
currentParams.delete('lang');
} else {
currentParams.set('lang', lang);
}
}
const paramString = currentParams.toString();
return paramString ? `./?${paramString}` : '.';
}
const generateCalendarLink = () => {
const host = typeof window !== 'undefined' ? window.location.host : '';
const noc = qs.get('noc') || 'calendar';
const sport = qs.get('sport') || 'all-sports';
return `http://${host}/data/${language}/${sport}/${noc}.ics`;
};
const getColor = (i: number) => COLORS[i % COLORS.length];
useEffect(() => {
if (data == null) {
loadSchedule()
.then(setData)
.catch(console.log);
}
}, [data]);
const filter = (event: Event) => {
let visible = true;
if (event.end < new Date().toISOString()) {
return false;
}
const sport = qs.get('sport');
if (sport && event.sport !== sport) {
visible = false;
}
const noc = qs.get('noc');
if (noc) {
if (event.match) {
if (event.match.team1.key !== noc && event.match.team2.key !== noc) {
visible = false;
}
} else {
visible = false;
}
}
return visible;
}
const copyToClipboard = (text: string) => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
const button = document.getElementById('copy_button')!;
navigator.clipboard.writeText(text).then(() => {
button.textContent = translate(COPY_SUCCESS);
button.classList.add('text-success');
button.classList.add('font-bold');
setTimeout(() => {
// document.getElementById('copy_toast')?.classList.remove('toast-open');
button.textContent = translate(COPY);
button.classList.remove('text-success');
button.classList.remove('font-bold');
}, 2000);
});
}
}
const calendarLink = generateCalendarLink();
if (data) {
let lastDay = "";
if (data.languages.find(lang => lang.code === language) === undefined) {
setLanguage('en')
}
return (
<div>
<div className="navbar bg-main">
<div className="navbar-start">
<a href="." className="text-xl">Milano Cortina 2026 Winter Olympics Calendar</a>
</div>
<div className="navbar-end">
<ul className="menu menu-horizontal px-2">
<li className="px-2">
<div className="dropdown">
<div tabIndex={0} role="button" className="select bg-transparent">
{qs.get('sport') ? (
<>{translate(data.sports.find((sport) => sport.key === qs.get('sport'))!.name)}</>
) : (
<>{translate(FILTER_BY_SPORT)}</>
)}
</div>
<ul tabIndex={-1} className="dropdown-content menu bg-base-100 text-black rounded-box z-1 w-52 p-2 shadow-sm">
{data.sports.sort((a, b) => translate(a.name).localeCompare(translate(b.name))).map(sport => {
if (sport.key === qs.get('sport')) {
return (
<li key={sport.key}>
<a href={generateLink({ sport: "" })}><div aria-label="success" className="status status-success"></div> {translate(sport.name)}</a>
</li>
)
}
return (
<li key={sport.key}>
<a href={generateLink({ sport: sport.key })}>{translate(sport.name)}</a>
</li>
)
})}
</ul>
</div>
</li>
<li className="px-2">
<div className="dropdown">
<div tabIndex={0} role="button" className="select bg-transparent">
{qs.get('noc') ? (
<>{translate(data.nocs.find((noc) => noc.key === qs.get('noc'))!.name)}</>
) : (
<>{translate(FILTER_BY_COUNTRY)}</>
)}
</div>
<ul tabIndex={-1} className="dropdown-content menu bg-base-100 text-black rounded-box z-1 w-52 p-2 shadow-sm">
{data.nocs.sort((a, b) => translate(a.name).localeCompare(translate(b.name))).map(noc => {
if (noc.key === qs.get('noc')) {
return (
<li key={noc.key}>
<a href={generateLink({ noc: "" })}><div aria-label="success" className="status status-success"></div> {translate(noc.name)}</a>
</li>
)
}
return (
<li key={noc.key}>
<a href={generateLink({ noc: noc.key })}>{translate(noc.name)}</a>
</li>
)
})}
</ul>
</div>
</li>
<li className="px-2">
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-ghost">
<svg className="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path strokeLinejoin="round" strokeLinecap="round" strokeWidth="2" fill="none" stroke="currentColor" d="M12 21a9 9 0 1 0 0-18m0 18a9 9 0 1 1 0-18m0 18c2.761 0 3.941-5.163 3.941-9S14.761 3 12 3m0 18c-2.761 0-3.941-5.163-3.941-9S9.239 3 12 3M3.5 9h17m-17 6h17"></path>
</svg>
<svg className="mt-px hidden size-2 fill-current opacity-60 sm:inline-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2048 2048"><path d="M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z"></path></svg>
</div>
<ul tabIndex={-1} className="menu menu-sm dropdown-content bg-base-100 text-black rounded-box z-1 mt-3 w-52 p-2 shadow">
{data.languages.map(lang => (
<li key={lang.code}>
<a onClick={() => setLanguage(lang.code)}>{lang.code.toUpperCase()} - {lang.name}</a>
</li>
))}
</ul>
</div>
</li>
</ul>
</div>
</div>
<div>
<div className="text-center pt-6">
<span className="input w-1/3">
<input type="text" placeholder={calendarLink} readOnly={true} />
<button id="copy_button" className="label cursor-pointer" onClick={() => copyToClipboard(calendarLink)}>{translate(COPY)}</button>
</span>
<a className="inline-block" href={calendarLink.replace("https://", "webcal://")} target="_blank">
<img src="/img/icon-apple.svg" alt="Apple Calendar" className="inline-block size-6 ml-4 mr-2" />
</a>
<a className="inline-block" href={`https://calendar.google.com/calendar/u/0/r?cid=${encodeURIComponent(calendarLink)}`} target="_blank">
<img src="/img/icon-google.svg" alt="Google Calendar" className="inline-block size-5 ml-4 mr-2" />
</a>
<a className="inline-block" href={`https://outlook.office.com/calendar/0/deeplink/subscribe?url=${encodeURIComponent(calendarLink)}`} target="_blank">
<img src="/img/icon-office365.svg" alt="Office 365 Calendar" className="inline-block size-5 ml-4 mr-2" />
</a>
<a className="inline-block" href={`https://outlook.live.com/calendar/0/deeplink/subscribe?url=${encodeURIComponent(calendarLink)}`} target="_blank">
<img src="/img/icon-outlookcom.svg" alt="Outlook Calendar" className="inline-block size-5 ml-4 mr-2" />
</a>
<a className="inline-block" href={`https://calendar.yahoo.com/?ics=${encodeURIComponent(calendarLink)}`} target="_blank">
<img src="/img/icon-yahoo.svg" alt="Yahoo Calendar" className="inline-block size-5 ml-4 mr-2" />
</a>
</div>
{
data.events
.filter(event => filter(event))
.sort((a, b) => a.start.localeCompare(b.start))
.map((event, i) => {
const startDate = new Date(event.start);
const endDate = new Date(event.end);
const startHours = startDate.getHours().toString().padStart(2, '0');
const startMinutes = startDate.getMinutes().toString().padStart(2, '0');
const endHours = endDate.getHours().toString().padStart(2, '0');
const endMinutes = endDate.getMinutes().toString().padStart(2, '0');
const participants = [];
let titleColor = "fg-main";
if (event.medal === '1') {
titleColor = "bg-gold";
} else if (event.medal === '3') {
titleColor = "bg-bronze";
}
if (event.match) {
participants.push(event.match.team1.key);
participants.push(event.match.team2.key);
}
const day = event.start.split('T')[0];
let dayHeader = <></>;
if (lastDay !== day) {
dayHeader = (
<div className="day-header text-center my-8">
<h2 className="text-3xl font-light fg-main">
{new Date(day).toLocaleDateString(language, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</h2>
</div>
);
}
lastDay = day;
return (
<div key={event.key}>
{dayHeader}
<div className="py-4 mx-auto my-4 bg-white w-3/4 rounded-lg">
<div className={`fg-${getColor(i)} w-1/4 align-top text-right inline-block text-5xl tabular-nums pr-2 border-r border-slate-900/10`}>
<span className="time-start">{startHours}:{startMinutes}</span>
<div className="time-end text-xs">{endHours}:{endMinutes}</div>
</div>
<div className="w-3/5 align-top inline-block text-black pl-2">
<div className="px-2">
{translate(data.sports.find(sport => sport.key === event.sport)?.name || {}).toUpperCase()}
</div>
<div className={`font-bold inline-block px-2 ${titleColor}`}>{translate(event.name)}</div>
{event.match?.team1?.key && event.match?.team2.key && (
<div className="competitors min-w-md max-w-md px-2 font-light">
<div className="w-1/3 inline-block">
{translate(event.match.team1.name)}
</div>
<div className="w-1/9 inline-block">
<Flag iso3={event.match.team1.key} name={translate(event.match.team1.name)} />
</div>
<div className="w-1/9 inline-block text-center">-</div>
<div className="w-1/9 inline-block text-right">
<Flag iso3={event.match.team2.key} name={translate(event.match.team2.name)} />
</div>
<div className="w-1/3 inline-block text-right">
{translate(event.match.team2.name)}
</div>
</div>
)}
</div>
</div>
</div>
)
})
}
</div>
</div >
);
}
return (
<div>Loading</div>
);
}

18
ui/eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

5
ui/lib/data.ts Normal file
View File

@ -0,0 +1,5 @@
export const loadSchedule = async () => {
const response = await fetch('/data/calendar.json');
const data = await response.json();
return data;
};

30
ui/lib/local-storage.ts Normal file
View File

@ -0,0 +1,30 @@
import { useCallback, useEffect, useState } from "react"
export const useLocalStorage = (key: string, initialValue: string) => {
const [state, setState] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
if (state !== undefined) {
window.localStorage.setItem(key, JSON.stringify(state));
} else {
window.localStorage.removeItem(key);
}
} catch { }
}, [key, state]);
const setValue = useCallback((value: string) => {
setState(value);
}, [])
return [state, setValue];
};
export default useLocalStorage;

60
ui/lib/text.ts Normal file
View File

@ -0,0 +1,60 @@
export const FILTER_BY_COUNTRY = {
en: "Filter by country",
fr: "Filtrer par pays",
es: "Filtrar por país",
de: "Nach Land filtern",
it: "Filtra per paese",
pt: "Filtrar por país",
zh: "按国家筛选",
ja: "国でフィルタリング",
ru: "Фильтр по стране"
}
export const FILTER_BY_SPORT = {
en: "Filter by sport",
fr: "Filtrer par sport",
es: "Filtrar por deporte",
de: "Nach Sport filtern",
it: "Filtra per sport",
pt: "Filtrar por esporte",
zh: "按运动筛选",
ja: "スポーツでフィルタリング",
ru: "Фильтр по виду спорта"
};
export const GET_CALENDAR = {
en: "Get Calendar",
fr: "Obtenir le calendrier",
es: "Obtener calendario",
de: "Kalender abrufen",
it: "Ottieni calendario",
pt: "Obter calendário",
zh: "获取日历",
ja: "カレンダーを取得",
ru: "Получить календарь"
}
export const COPY = {
en: "Copy",
fr: "Copier",
es: "Copiar",
de: "Kopieren",
it: "Copia",
pt: "Copiar",
zh: "复制",
ja: "コピー",
ru: "Копировать"
}
export const COPY_SUCCESS = {
en: "Link copied to clipboard!",
fr: "Lien copié dans le presse-papiers !",
es: "¡Enlace copiado al portapapeles!",
de: "Link in die Zwischenablage kopiert!",
it: "Link copiato negli appunti!",
pt: "Link copiado para a área de transferência!",
zh: "链接已复制到剪贴板!",
ja: "リンクがクリップボードにコピーされました!",
ru: "Ссылка скопирована в буфер обмена!"
}

7
ui/next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

6628
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
ui/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "ui",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.0.7",
"react": "19.2.1",
"react-dom": "19.2.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"daisyui": "^5.5.8",
"eslint": "^9.39.1",
"eslint-config-next": "16.0.7",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
ui/postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 56.7 56.7" style="enable-background:new 0 0 56.7 56.7;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill:#606060;}
</style>
<g>
<title>background</title>
<rect x="-1" y="-1" class="st0" width="582" height="402"/>
</g>
<g>
<title>Layer 1</title>
<path class="st1" d="M41.8,30.5c-0.1-6.2,5.1-9.2,5.3-9.4c-2.9-4.2-7.4-4.8-9-4.9c-3.8-0.4-7.5,2.3-9.4,2.3c-1.9,0-4.9-2.2-8.1-2.1
c-4.2,0.1-8,2.4-10.2,6.2C6,30.1,9.3,41.2,13.5,47.3c2.1,3,4.5,6.3,7.8,6.2c3.1-0.1,4.3-2,8.1-2s4.8,2,8.1,2c3.4-0.1,5.5-3,7.5-6
c2.4-3.5,3.3-6.8,3.4-7C48.3,40.4,41.8,38,41.8,30.5z"/>
<path class="st1" d="M35.6,12.2c1.7-2.1,2.9-5,2.6-7.9c-2.5,0.1-5.5,1.6-7.2,3.7c-1.6,1.8-3,4.8-2.6,7.6
C31,15.9,33.9,14.3,35.6,12.2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 128 128" style="enable-background:new 0 0 128 128;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill:#FBBC05;}
.st2{fill:#EA4335;}
.st3{fill:#34A853;}
.st4{fill:#4285F4;}
</style>
<g>
<rect class="st0" width="128" height="128"/>
<path class="st1" d="M27.6,64c0-4.2,0.7-8.1,1.9-11.9L7.9,35.6C3.7,44.2,1.4,53.8,1.4,64c0,10.2,2.4,19.8,6.6,28.3l21.6-16.5
C28.3,72.1,27.6,68.1,27.6,64"/>
<path class="st2" d="M65.5,26.2c9,0,17.2,3.2,23.6,8.4L107.7,16C96.3,6.1,81.8,0,65.5,0C40.1,0,18.4,14.5,7.9,35.6l21.6,16.5
C34.5,37,48.6,26.2,65.5,26.2"/>
<path class="st3" d="M65.5,101.8c-16.8,0-31-10.9-35.9-25.9L7.9,92.4C18.4,113.5,40.1,128,65.5,128c15.6,0,30.6-5.6,41.8-16
L86.7,96.2C81,99.9,73.7,101.8,65.5,101.8"/>
<path class="st4" d="M126.6,64c0-3.8-0.6-7.9-1.5-11.6H65.5v24.7h34.4c-1.7,8.4-6.4,14.9-13.1,19.1l20.5,15.8
C119,101.1,126.6,84.9,126.6,64"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1 @@
<svg enable-background="new 0 0 2075 2499.8" viewBox="0 0 2075 2499.8" xmlns="http://www.w3.org/2000/svg"><path d="m0 2016.6v-1519.8l1344.4-496.8 730.6 233.7v2045.9l-730.6 220.3-1344.4-483.3 1344.4 161.8v-1769.2l-876.8 204.6v1198.3z" fill="#eb3c00"/></svg>

After

Width:  |  Height:  |  Size: 256 B

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1024 1024" style="enable-background:new 0 0 1024 1024;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill:#0072C6;}
</style>
<g>
<title>background</title>
<rect x="-1" y="-1" class="st0" width="582" height="402"/>
</g>
<g>
<title>Layer 1</title>
<path class="st1" d="M644.9,764.4V492.7c19.3,13.3,37.4,25.6,55.4,38c16.2,11.1,18.9,11,35.6-0.1c92.5-61.5,185-122.9,277.5-184.4
c2.8-1.9,5.7-3.6,9.8-6.1c0.3,4.2,0.8,7.4,0.8,10.5c0,118.2,0.1,236.4,0,354.7c0,39.3-19.9,59.1-59,59.1c-102.8,0-205.6,0-308.4,0
C652.9,764.4,649.2,764.4,644.9,764.4L644.9,764.4z"/>
<path class="st1" d="M645.6,216.9h10.5c108.6,0,217.1,0,325.7,0c19.4,0,34.4,11.9,40.5,30.5c2.7,8.2,0,12.2-6.7,16.6
c-84.1,56.3-168,112.9-252,169.4c-12,8.1-24.1,15.9-35.9,24.3c-6.4,4.6-12.2,4.8-18.8,0.5c-19.2-12.4-38.7-24.2-57.8-36.7
c-3.2-2.1-6.5-6.9-6.5-10.4c-0.3-63.5-0.2-127-0.1-190.5C644.5,219.8,644.9,219.1,645.6,216.9L645.6,216.9z"/>
<path class="st1" d="M596.7,1024C397.6,982.3,199.3,940.8,0.3,899.2v-13.1c0-252.5,0.1-505-0.3-757.5c0-10.5,2.8-14.1,13-16
c159.1-30.4,318.2-61.2,477.3-92C525.4,13.7,560.5,7,596.7,0L596.7,1024L596.7,1024z M438,511c-0.4-41.6-6.1-80-26.7-114.9
c-16.6-28.1-39-49.4-71.3-58.6c-62.8-17.9-122.8,11.5-152.4,75c-18.9,40.7-23,83.9-19.3,128.1c3.2,38.3,14.3,73.7,39,104
c49.1,60.2,138.4,60.6,188.2,0.8C428.4,605.8,437.4,558.7,438,511L438,511z"/>
<path class="st1" d="M232.9,511.9c-0.1-29.7,3.9-58.4,20.7-83.8c15.5-23.5,39-33.1,64.9-26.6c20.1,5,32.3,19.3,40.4,37.2
c13.8,30.7,16.2,63.3,12.1,95.9c-2.3,18.1-7.5,36.5-14.8,53.2c-9.5,21.6-27.8,34-52.2,34.5c-24.6,0.5-41.7-12.2-53.6-32.6
C236.3,565.8,232.9,539.2,232.9,511.9L232.9,511.9z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1792 1792" style="enable-background:new 0 0 1792 1792;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill:#4A089F;}
</style>
<g>
<title>background</title>
<rect x="-1" y="-1" class="st0" width="582" height="402"/>
</g>
<g>
<title>Layer 1</title>
<path class="st1" d="M987,957l13,707c-41.3-7.3-76.3-11-105-11c-27.3,0-62.3,3.7-105,11l13-707c-26.7-46-82.8-144.5-168.5-295.5
S476.7,385.7,418,287S299,92.7,237,0c38.7,10,74.7,15,108,15c28.7,0,65.7-5,111-15c42,74,86.5,150.5,133.5,229.5
s102.7,171.2,167,276.5S867,687,895,733c24.7-40.7,61.2-99.8,109.5-177.5c48.3-77.7,87.5-141,117.5-190s65-107.7,105-176
S1302.7,58,1334,0c36,9.3,71.7,14,107,14c37.3,0,75.3-4.7,114-14c-18.7,26-38.7,55.5-60,88.5s-37.8,59.2-49.5,78.5
s-30.5,51.3-56.5,96s-42.3,72.7-49,84C1242.7,512.3,1125,715.7,987,957z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

34
ui/tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}