4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
346f56d3c5 Add fallback handling for missing translations and fix TypeScript types
Co-authored-by: fabrice404 <12575390+fabrice404@users.noreply.github.com>
2026-02-04 18:54:28 +00:00
copilot-swe-agent[bot]
e6a1863717 Fix French translation and add localization for medal events label
Co-authored-by: fabrice404 <12575390+fabrice404@users.noreply.github.com>
2026-02-04 18:52:01 +00:00
copilot-swe-agent[bot]
8594803a2e Add medal events filter to UI and ICS generator
Co-authored-by: fabrice404 <12575390+fabrice404@users.noreply.github.com>
2026-02-04 18:46:01 +00:00
copilot-swe-agent[bot]
e6be05743d Initial plan 2026-02-04 18:42:40 +00:00
3 changed files with 137 additions and 26 deletions

View File

@@ -2,7 +2,21 @@ import Debug from "debug";
import { mkdirSync, writeFileSync } from "fs";
import { getFlag } from "./nocs";
import { Calendar } from "./types";
import { Calendar, Event } from "./types";
const MEDAL_EVENTS_LABEL: { [key: string]: string } = {
en: "Medal Events",
fr: "Événements avec médailles",
es: "Eventos de medallas",
de: "Medaillen-Events",
it: "Eventi con medaglie",
pt: "Eventos de medalhas",
zh: "奖牌赛事",
ja: "メダルイベント",
hi: "पदक स्पर्धाएं",
ko: "메달 이벤트",
ru: "Медальные события",
};
export class ICSGenerator {
private calendar: Calendar;
@@ -13,6 +27,30 @@ export class ICSGenerator {
this.calendar = calendar;
}
private buildTitle(
lang: { code: string; name: string; code3: string },
sportKey: string | null,
nocKey: string | null,
medalOnly: boolean,
): string {
const titleComponents: string[] = [];
if (nocKey) {
const noc = this.calendar.nocs.find((n) => n.key === nocKey);
const nocName = noc?.name[lang.code] || noc?.name["en"] || "";
titleComponents.push(nocName);
}
if (sportKey) {
const sport = this.calendar.sports.find((s) => s.key === sportKey);
const sportName = sport?.name[lang.code] || sport?.name["en"] || "";
titleComponents.push(sportName);
}
if (medalOnly) {
titleComponents.push(MEDAL_EVENTS_LABEL[lang.code] ?? MEDAL_EVENTS_LABEL["en"] ?? "Medal Events");
}
titleComponents.push("Milano Cortina 2026");
return titleComponents.join(" - ");
}
private cleanLine(line: string): string {
if (line.length <= 75) {
return line;
@@ -33,53 +71,37 @@ export class ICSGenerator {
private generateICSFile(
sportKey: string | null,
nocKey: string | null,
medalOnly: boolean = false,
): void {
this.debug(
"generateICSFile",
sportKey || "all-sports",
nocKey || "all-nocs",
medalOnly ? "medal-only" : "all-events",
);
for (const lang of this.calendar.languages) {
const pathSportKey = sportKey ? sportKey : "all-sports";
const pathNocKey = nocKey ? nocKey : "calendar";
const medalPath = medalOnly ? "medal/" : "";
const filepath = `./output/${lang.code.toLowerCase()}/${pathSportKey.toLowerCase()}/${pathNocKey.toLowerCase()}.ics`;
const filepath = `./output/${lang.code.toLowerCase()}/${pathSportKey.toLowerCase()}/${medalPath}${pathNocKey.toLowerCase()}.ics`;
mkdirSync(filepath.split("/").slice(0, -1).join("/"), { recursive: true });
const titleComponents: string[] = [];
if (nocKey) {
titleComponents.push(
`${this.calendar.nocs.find((n) => n.key === nocKey)!.name[lang.code]}`,
);
}
if (sportKey) {
titleComponents.push(this.calendar.sports.find((s) => s.key === sportKey)!.name[lang.code] || "");
}
titleComponents.push("Milano Cortina 2026");
const title = titleComponents.join(" - ");
const title = this.buildTitle(lang, sportKey, nocKey, medalOnly);
const lines: string[] = [];
lines.push("BEGIN:VCALENDAR");
lines.push("VERSION:2.0");
lines.push(
`PRODID:-//fabrice404//olympics-calendar//${lang.code}/${pathSportKey}/${pathNocKey}`,
`PRODID:-//fabrice404//olympics-calendar//${lang.code}/${pathSportKey}/${medalPath}${pathNocKey}`,
);
lines.push(`X-WR-CALNAME:${title}`);
lines.push(`NAME:${title}`);
this.calendar.events
.filter((event) => {
if ((sportKey && event.sport !== sportKey) || event.sport === "CER") {
return false;
}
if (nocKey && !event.nocs.includes(nocKey)) {
return false;
}
return true;
})
.filter((event) => this.shouldIncludeEvent(event, sportKey, nocKey, medalOnly))
.forEach((event) => {
lines.push("BEGIN:VEVENT");
lines.push(`UID:${event.key.replace(/--/g, "-")}`);
@@ -141,19 +163,41 @@ export class ICSGenerator {
};
}
private shouldIncludeEvent(
event: Event,
sportKey: string | null,
nocKey: string | null,
medalOnly: boolean,
): boolean {
if ((sportKey && event.sport !== sportKey) || event.sport === "CER") {
return false;
}
if (nocKey && !event.nocs.includes(nocKey)) {
return false;
}
if (medalOnly && event.medal === "0") {
return false;
}
return true;
}
public generate(): void {
this.debug("generate");
this.generateICSFile(null, null);
this.generateICSFile(null, null, true); // Generate medal-only calendar for all sports and all NOCs
this.calendar.sports.forEach((sport) => {
this.generateICSFile(sport.key, null);
this.generateICSFile(sport.key, null, true); // Generate medal-only calendar for this sport
this.calendar.nocs.forEach((noc) => {
this.generateICSFile(sport.key, noc.key);
this.generateICSFile(sport.key, noc.key, true); // Generate medal-only calendar for this sport and NOC
});
});
this.calendar.nocs.forEach((noc) => {
this.generateICSFile(null, noc.key);
this.generateICSFile(null, noc.key, true); // Generate medal-only calendar for this NOC
});
}
}

View File

@@ -3,7 +3,7 @@
import { loadSchedule } from "../lib/data";
import { useEffect, useState } from "react";
import Flag from "./flag";
import { COPY, COPY_SUCCESS, FILTER_BY_COUNTRY, FILTER_BY_SPORT, MADE_BY_FABRICE, NOT_AFFILIATED, NO_EVENT_FOR_FILTERS } from "../lib/text";
import { COPY, COPY_SUCCESS, FILTER_BY_COUNTRY, FILTER_BY_SPORT, MADE_BY_FABRICE, NOT_AFFILIATED, NO_EVENT_FOR_FILTERS, MEDAL_EVENTS_ONLY, ALL_EVENTS } from "../lib/text";
import useLocalStorage from "@/lib/local-storage";
import { GoogleAnalytics } from "@next/third-parties/google";
@@ -67,7 +67,7 @@ export default function Home() {
return text[`${language}`] || text['en'] || Object.values(text)[0] || '';
};
const generateLink = ({ noc, sport, lang }: { noc?: string; sport?: string, lang?: string }) => {
const generateLink = ({ noc, sport, lang, medal }: { noc?: string; sport?: string, lang?: string, medal?: string }) => {
const currentParams = new URLSearchParams(qs.toString());
if (noc !== undefined) {
if (noc === "") {
@@ -92,6 +92,14 @@ export default function Home() {
currentParams.set('lang', lang);
}
}
if (medal !== undefined) {
if (medal === "") {
currentParams.delete('medal');
} else {
currentParams.set('medal', medal);
}
}
const paramString = currentParams.toString();
return paramString ? `./?${paramString}` : '.';
}
@@ -100,7 +108,11 @@ export default function Home() {
const host = typeof window !== 'undefined' ? window.location.host : '';
const noc = (qs.get('noc') || 'calendar').toLowerCase();
const sport = (qs.get('sport') || 'all-sports').toLowerCase();
const medal = qs.get('medal') === 'true' ? 'medal' : '';
if (medal) {
return `http://${host}/api/data/${language}/${sport}/${medal}/${noc}.ics`;
}
return `http://${host}/api/data/${language}/${sport}/${noc}.ics`;
};
@@ -133,6 +145,12 @@ export default function Home() {
}
}
const medal = qs.get('medal');
if (medal === 'true') {
if (event.medal === '0') {
visible = false;
}
}
return visible;
}
@@ -382,6 +400,31 @@ export default function Home() {
</ul>
</div>
</li>
<li className="px-2">
<div className="dropdown">
<div tabIndex={0} role="button" className="select bg-transparent">
{qs.get('medal') === 'true' ? (
<>{translate(MEDAL_EVENTS_ONLY)}</>
) : (
<>{translate(ALL_EVENTS)}</>
)}
</div>
<ul tabIndex={-1} className="dropdown-content menu bg-base-100 text-black rounded-box z-1 w-52 p-2 shadow-sm">
<li>
<a href={generateLink({ medal: "" })}>
{qs.get('medal') !== 'true' && <div aria-label="success" className="status status-success"></div>}
{translate(ALL_EVENTS)}
</a>
</li>
<li>
<a href={generateLink({ medal: "true" })}>
{qs.get('medal') === 'true' && <div aria-label="success" className="status status-success"></div>}
{translate(MEDAL_EVENTS_ONLY)}
</a>
</li>
</ul>
</div>
</li>
<li className="px-2">
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-ghost">

View File

@@ -23,6 +23,30 @@ export const FILTER_BY_SPORT = {
ru: "Фильтр по виду спорта"
};
export const MEDAL_EVENTS_ONLY = {
en: "Medal events only",
fr: "Événements avec médailles uniquement",
es: "Solo eventos de medallas",
de: "Nur Medaillen-Events",
it: "Solo eventi con medaglie",
pt: "Apenas eventos de medalhas",
zh: "仅奖牌赛事",
ja: "メダルイベントのみ",
ru: "Только медальные события"
};
export const ALL_EVENTS = {
en: "All events",
fr: "Tous les événements",
es: "Todos los eventos",
de: "Alle Veranstaltungen",
it: "Tutti gli eventi",
pt: "Todos os eventos",
zh: "所有赛事",
ja: "すべてのイベント",
ru: "Все события"
};
export const GET_CALENDAR = {
en: "Get Calendar",
fr: "Obtenir le calendrier",