wip milano-cortina 2026

This commit is contained in:
Fabrice Lamant
2025-12-05 21:30:34 +01:00
parent 5074fd5155
commit e2b6c42f01
10 changed files with 684 additions and 11 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:

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"
]
}

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>