Skip to content

Commit dc0b09f

Browse files
committed
i18n and url hash config
1 parent 6b0fa3b commit dc0b09f

File tree

4 files changed

+147
-53
lines changed

4 files changed

+147
-53
lines changed

app.js

Lines changed: 102 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -13,30 +13,56 @@ const countries = [
1313
{code: "NL", name: "Niederlande", flag: "🇳🇱"},
1414
{code: "CZ", name: "Tschechien", flag: "🇨🇿"}
1515
];
16-
let selectedCountries = ["DE"];
16+
17+
18+
let selectedMonthRange = document.location.hash.split("#")[1] || null;
19+
let selectedCountries = document.location.hash.split("#")[2]?.split("+") || ["DE"];
20+
selectedCountries = [...new Set(selectedCountries).intersection(new Set(countries.map(c => c.code)))];
21+
let locale = document.location.hash.split("#")[3];
22+
23+
function updateHash() {
24+
document.location.hash = selectedMonthRange + "#" + selectedCountries.toSorted().join("+") + (locale !== "de" ? "#" + locale : "");
25+
}
1726

1827

1928
// ------------------------------------------------------------
2029
// Initialisierung
2130
const API_BASE = "https://openholidaysapi.org";
2231
let populationData = null;
2332
let cachedData = {Regions: {}};
33+
let i18n = {
34+
publicHoliday: "Feiertag in",
35+
schoolHoliday: "Ferien in",
36+
noHoliday: "Keine Ferien/Feiertage",
37+
in: "in",
38+
nationwide: "landesweit",
39+
mioResidents: "Mio. Einwohner",
40+
dataSources: "Datenquellen"
41+
};
2442

2543
const calendarContainer = document.getElementById("calendar");
2644
const sourceInfo = document.getElementById("sourceInfo");
2745
const yearSelect = document.getElementById("yearSelect");
2846
const countryList = document.getElementById("countryList");
47+
document.getElementById("languageSelector").onclick = async e => {
48+
locale = locale === "de" ? "en" : "de";
49+
updateHash()
50+
document.location.reload();
51+
};
2952
document.addEventListener("DOMContentLoaded", async () => {
3053

54+
await i18ninit();
55+
3156
try {
3257
populateYearSelect();
3358
renderCountrySelection();
3459
await updateCalendar();
3560
} catch (e) {
36-
calendarContainer.innerHTML = e.message;
61+
calendarContainer.innerHTML = e.message + `<br/><a href=".">Reload page</a>`;
62+
throw e
3763
}
3864

39-
sourceInfo.append("Datenquellen: ")
65+
sourceInfo.append(i18n.dataSources + ": ")
4066

4167
function sourceLink(url, label) {
4268
let link = document.createElement("a");
@@ -87,7 +113,7 @@ function showTooltip(e, tooltip) {
87113
tooltipElement.style.opacity = 1;
88114
}
89115

90-
function registerTooptip(element, tooltip){
116+
function registerTooptip(element, tooltip) {
91117
element.addEventListener("pointerover", e => showTooltip(e, tooltip));
92118
element.addEventListener("pointerdown", e => showTooltip(e, tooltip));
93119
element.addEventListener("pointermove", e => showTooltip(e, tooltip));
@@ -102,22 +128,28 @@ function populateYearSelect() {
102128
const currentYear = now.getFullYear();
103129
for (let y = currentYear - 1; y <= currentYear + 2; y++) {
104130
const optCal = document.createElement("option");
105-
optCal.value = `${y}-01-01|${y}-12-31`;
131+
optCal.value = `${y}-01~${y}-12`;
106132
optCal.textContent = `${y}`;
107133
yearSelect.appendChild(optCal);
108134

109135
const optShifted = document.createElement("option");
110-
optShifted.value = `${y}-07-01|${y + 1}-06-30`;
136+
optShifted.value = `${y}-07~${y + 1}-06`;
111137
optShifted.textContent = `${y}/${(y + 1).toString().slice(-2)}`;
112138
yearSelect.appendChild(optShifted);
113139
}
114-
yearSelect.value = now.getMonth() < 6 ? `${currentYear}-01-01|${currentYear}-12-31` : `${currentYear}-07-01|${currentYear + 1}-06-30`;
115-
yearSelect.addEventListener("change", updateCalendar);
140+
selectedMonthRange = selectedMonthRange || (now.getMonth() < 6 ? `${currentYear}-01~${currentYear}-12` : `${currentYear}-07~${currentYear + 1}-06`);
141+
yearSelect.value = selectedMonthRange
142+
yearSelect.addEventListener("change", async e => {
143+
selectedMonthRange = e.currentTarget.value;
144+
updateHash();
145+
await updateCalendar();
146+
});
116147
}
117148

118149
// ------------------------------------------------------------
119150
// Länder-Auswahl rendern
120151
function renderCountrySelection() {
152+
countryList.innerHTML = "";
121153
countries.forEach((c) => {
122154
const div = document.createElement("div");
123155
div.className = "country-item";
@@ -134,6 +166,7 @@ function renderCountrySelection() {
134166
selectedCountries.push(c.code);
135167
div.classList.add("active");
136168
}
169+
updateHash();
137170
await updateCalendar();
138171
});
139172
countryList.appendChild(div);
@@ -142,12 +175,12 @@ function renderCountrySelection() {
142175

143176
async function fetchPopulationData() {
144177
const res = await fetch("population.json");
145-
if (!res.ok) throw new Error("Fehler beim Laden der Bevölkerungsdaten");
178+
if (!res.ok) throw new Error("Error loading population data");
146179
populationData = await res.json();
147180

148181
for (let element of document.getElementsByClassName("country-item")) {
149182
const population = Object.values(populationData.countries[element.dataset.code].subdivisions).reduce((a, b) => a + b, 0);
150-
registerTooptip(element, `<span class="tooltip-title">${(population / 1e6).toFixed(1)} Mio. Einwohner</span>\n`);
183+
registerTooptip(element, `<span class="tooltip-title">${(population / 1e6).toFixed(1)} ${i18n.mioResidents}</span>\n`);
151184
}
152185

153186
}
@@ -156,13 +189,13 @@ async function fetchPopulationData() {
156189
// Hole Ferien- und Feiertagsdaten aus der API
157190
async function fetchCountryData(year, countryCode) {
158191
const requests = [
159-
fetch(`${API_BASE}/PublicHolidays?countryIsoCode=${countryCode}&validFrom=${year}-01-01&validTo=${year}-12-31&languageIsoCode=DE`),
160-
fetch(`${API_BASE}/SchoolHolidays?countryIsoCode=${countryCode}&validFrom=${year}-01-01&validTo=${year}-12-31&languageIsoCode=DE`)
192+
fetch(`${API_BASE}/PublicHolidays?countryIsoCode=${countryCode}&validFrom=${year}-01-01&validTo=${year}-12-31&languageIsoCode=${locale.toUpperCase()}`),
193+
fetch(`${API_BASE}/SchoolHolidays?countryIsoCode=${countryCode}&validFrom=${year}-01-01&validTo=${year}-12-31&languageIsoCode=${locale.toUpperCase()}`)
161194
];
162195

163196
const responses = await Promise.all(requests);
164197
if (responses.some(r => !r.ok)) {
165-
throw new Error(`Fehler beim Abrufen der Daten für ${countryCode} für ${year}`);
198+
throw new Error(`Error loading data of ${countryCode} for ${year}`);
166199
}
167200
const [holidays, schoolHolidays] = await Promise.all(responses.map(r => r.json()));
168201

@@ -172,17 +205,19 @@ async function fetchCountryData(year, countryCode) {
172205
}
173206

174207
async function fetchRegionData(countryCode) {
175-
const RegionRes = await fetch(`${API_BASE}/Subdivisions?countryIsoCode=${countryCode}&languageIsoCode=DE`);
208+
const RegionRes = await fetch(`${API_BASE}/Subdivisions?countryIsoCode=${countryCode}&languageIsoCode=${locale.toUpperCase()}`);
176209
cachedData.Regions[countryCode] = await RegionRes.json();
177210
}
178211

179212

180213
// ------------------------------------------------------------
181214
// Aktualisiere Kalender
182215
async function updateCalendar() {
183-
const [fromStr, toStr] = yearSelect.value.split("|");
216+
const [fromStr, toStr] = selectedMonthRange.split("~");
184217
const fromDate = new Date(fromStr);
185-
const toDate = new Date(toStr);
218+
let toDate = new Date(toStr);
219+
if (!fromDate || isNaN(fromDate) || !toDate || isNaN(toDate) || toDate < fromDate || toDate - fromDate > 2 * 365 * 24 * 60 * 60 * 1000)
220+
throw Error("Invalid date range " + selectedMonthRange);
186221

187222
// Lade Daten, falls noch nicht vorhanden
188223
const fetch = [];
@@ -196,8 +231,8 @@ async function updateCalendar() {
196231
await Promise.all(fetch);
197232

198233
// Daten aggregieren
199-
const dayStats = calculateDayStatistics(fromDate, toDate);
200-
renderCalendar(fromDate, toDate, dayStats);
234+
const stats = calculateDayStatistics(fromDate, toDate);
235+
renderCalendar(stats);
201236
}
202237

203238
// ------------------------------------------------------------
@@ -210,10 +245,13 @@ function calculateDayStatistics(fromDate, toDate) {
210245
return sum + Object.values(subs).reduce((a, b) => a + b, 0);
211246
}, 0);
212247

248+
fromDate.setDate(1)
249+
toDate.setMonth(toDate.getMonth() + 1, 0)
213250
for (let d = new Date(fromDate); d <= new Date(toDate); d.setDate(d.getDate() + 1)) {
214251
d.setHours(0, 0, 0, 0);
215-
const key = dateKey(d);
216-
stats[key] = {share: 0, off: false, tooltip: []};
252+
const [m, key] = dateKey(d);
253+
if (!stats[m]) stats[m] = {};
254+
stats[m][key] = {share: 0, off: false, tooltip: []};
217255

218256
let holidayPopulationTotal = 0;
219257
let nationwideHolidayAnyCountry = false;
@@ -226,14 +264,14 @@ function calculateDayStatistics(fromDate, toDate) {
226264

227265
// --- Feiertage ---
228266
for (const h of holidays) {
229-
if (inDateRange(d, h.startDate, h.endDate)) relevant.push({...h, type: "Feiertag"});
267+
if (inDateRange(d, h.startDate, h.endDate)) relevant.push({...h, type: i18n.publicHoliday});
230268
}
231269

232270
// --- Ferien ---
233271
for (const f of schoolHolidays) {
234272
if (inDateRange(d, f.startDate, f.endDate, true)) relevant.push({
235273
...f,
236-
type: "Ferien"
274+
type: i18n.schoolHoliday
237275
});
238276
}
239277

@@ -251,7 +289,7 @@ function calculateDayStatistics(fromDate, toDate) {
251289
}
252290

253291
if (r.nationwide) {
254-
if (r.type === "Ferien") {
292+
if (r.type === i18n.schoolHoliday) {
255293
nationwideSchoolHoliday = true;
256294
} else {
257295
nationwideHoliday = true;
@@ -283,23 +321,23 @@ function calculateDayStatistics(fromDate, toDate) {
283321
if (holidayPopulation > 0) {
284322
const c = countries.find(c => c.code === country);
285323
if (selectedCountries.length > 1) {
286-
tooltip.push(`\n<span class="tooltip-country">${c.name}: ${(holidayPopulation / 1e6).toFixed(1)} Mio. (${(100 * holidayPopulation / countryPopTotal).toFixed(0)}%)</span>`);
324+
tooltip.push(`\n<span class="tooltip-country">${c.name}: ${(holidayPopulation / 1e6).toFixed(1)} ${i18n.mioResidents} (${(100 * holidayPopulation / countryPopTotal).toFixed(0)}%)</span>`);
287325
}
288326
for (const [label, info] of Object.entries(infos)) {
289327
if (info.All || info.Subdivisions.size > 0) {
290328
//const divisionsText = [...info.Subdivisions].map((s) => s.split("-")[1]).toSorted().join(", ");
291-
const divisionsText = "in " + [...info.Subdivisions].map((s) => regionNames[s]).toSorted().join(", ");
292-
tooltip.push(`${label} <span class="tooltip-info">(${info.Type} ${info.All ? "landesweit" : divisionsText})</span>`)
329+
const divisionsText = i18n.in + " " + [...info.Subdivisions].map((s) => regionNames[s]).toSorted().join(", ");
330+
tooltip.push(`${label} <span class="tooltip-info">(${info.Type} ${info.All ? i18n.nationwide : divisionsText})</span>`)
293331
}
294332
}
295333
}
296334

297335
}
298-
const summary = `<span class="tooltip-title">${(holidayPopulationTotal / 1e6).toFixed(1)} Mio. Einwohner (${(100 * holidayPopulationTotal / totalPop).toFixed(0)}%)</span>\n`;
299-
stats[key].tooltip = holidayPopulationTotal > 0 ? summary + tooltip.join("\n") : `<span class="tooltip-title">Keine Ferien/Feiertage</span>`;
336+
const summary = `<span class="tooltip-title">${(holidayPopulationTotal / 1e6).toFixed(1)} ${i18n.mioResidents} (${(100 * holidayPopulationTotal / totalPop).toFixed(0)}%)</span>\n`;
337+
stats[m][key].tooltip = holidayPopulationTotal > 0 ? summary + tooltip.join("\n") : `<span class="tooltip-title">${i18n.noHoliday}</span>`;
300338

301-
stats[key].off = nationwideHolidayAnyCountry || d.getDay() === 0; // Sunday
302-
stats[key].share = holidayPopulationTotal / totalPop;
339+
stats[m][key].off = nationwideHolidayAnyCountry || d.getDay() === 0; // Sunday
340+
stats[m][key].share = holidayPopulationTotal / totalPop;
303341

304342
}
305343

@@ -312,30 +350,26 @@ function calculateDayStatistics(fromDate, toDate) {
312350

313351
// ------------------------------------------------------------
314352
// Kalenderdarstellung
315-
function renderCalendar(fromDate, toDate, stats) {
353+
function renderCalendar(stats) {
316354
calendarContainer.innerHTML = "";
317355
tooltipElement.style.opacity = 0;
318356

319-
const startMonth = fromDate.getMonth();
320-
const months = [];
321-
for (let i = 0; i < 12; i++) {
322-
const m = new Date(fromDate.getFullYear(), startMonth + i, 1);
323-
months.push(m);
324-
}
325-
326-
for (const monthDate of months) {
357+
for (const month of Object.keys(stats)) {
358+
const monthDate = new Date(month);
327359
const monthDiv = document.createElement("div");
328360
monthDiv.className = "month";
329361
const monthName = monthDate.toLocaleString("de-DE", {month: "long", year: "numeric"});
330362
monthDiv.innerHTML = `<h3>${monthName}</h3>`;
331363
const table = document.createElement("table");
332364

333365
const headerRow = document.createElement("tr");
334-
["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"].forEach((d) => {
335-
const th = document.createElement("th");
336-
th.textContent = d;
337-
headerRow.appendChild(th);
338-
});
366+
Array.of(1, 2, 3, 4, 5, 6, 7).map(d => new Date(Date.UTC(2001, 0, d)))
367+
.map(d => Intl.DateTimeFormat(locale, {weekday: "short"}).format(d))
368+
.forEach((d) => {
369+
const th = document.createElement("th");
370+
th.textContent = d;
371+
headerRow.appendChild(th);
372+
});
339373
table.appendChild(headerRow);
340374

341375
const firstDay = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
@@ -349,18 +383,19 @@ function renderCalendar(fromDate, toDate, stats) {
349383

350384
for (let day = 1; day <= lastDay.getDate(); day++) {
351385
const date = new Date(monthDate.getFullYear(), monthDate.getMonth(), day);
352-
const key = dateKey(date);
386+
const [m, key] = dateKey(date);
387+
const dayStat = stats[month][key]
353388
const cell = document.createElement("td");
354389
cell.textContent = day;
355390
cell.dataset.code = key;
356391

357-
if (stats[key]) {
358-
const share = stats[key].share || 0;
392+
if (dayStat) {
393+
const share = dayStat.share || 0;
359394
cell.style.backgroundColor = densityColor(share);
360-
cell.style.fontWeight = stats[key].off ? "bold" : "regular";
395+
cell.style.fontWeight = dayStat.off ? "bold" : "regular";
361396

362397
// tooltip
363-
registerTooptip(cell, stats[key].tooltip);
398+
registerTooptip(cell, dayStat.tooltip);
364399

365400
}
366401

@@ -381,7 +416,8 @@ function renderCalendar(fromDate, toDate, stats) {
381416

382417
// ------------------------------------------------------------
383418
function dateKey(date) {
384-
return new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000).toISOString().split("T")[0];
419+
const key = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000).toISOString().split("T")[0];
420+
return [key.slice(0, 7), key]
385421
}
386422

387423
function inDateRange(date, startDate, endDate, orAdjacentWeekend = false) {
@@ -408,3 +444,19 @@ function densityColor(factor) {
408444

409445
}
410446

447+
async function i18ninit() {
448+
locale = (locale || navigator.language?.split("-")[0]).toLowerCase()
449+
if (locale !== "de") locale = "en";
450+
document.getElementsByTagName("html")[0].lang = locale;
451+
countries.forEach(c => c.name = new Intl.DisplayNames([locale], {type: "region"}).of(c.code))
452+
if (locale !== "de") {
453+
const res = await fetch(`i18n/${locale}.json`);
454+
if (!res.ok) throw new Error("Error loading localization data");
455+
i18n = await res.json();
456+
document.querySelectorAll('[data-i18n]').forEach(element => {
457+
const key = element.getAttribute('data-i18n');
458+
if (i18n[key]) element.innerHTML = i18n[key];
459+
});
460+
}
461+
462+
}

i18n/en.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"title": "Holidense: Calendar of holiday density",
3+
"about": "This interactive calendar visualizes how many people in the selected countries are currently off due to school or public holidays. The data is based on the <a href=\"https://openholidaysapi.org\" target=\"_blank\">OpenHolidays API</a>.",
4+
"madeWithLoveBy": "Made with ♥\uFE0F by",
5+
"publicHoliday": "Public holiday",
6+
"schoolHoliday": "School holiday",
7+
"noHoliday": "No holidays",
8+
"in": "in",
9+
"nationwide": "national",
10+
"mioResidents": "M people",
11+
"dataSources": "Data sources"
12+
}

index.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@
88
</head>
99
<body>
1010
<header>
11-
<h1>Holidense: Feriendichte Kalender</h1>
12-
<p>
11+
<h1 data-i18n="title" onclick="document.location='.'">Holidense: Feriendichte Kalender</h1>
12+
<p data-i18n="about">
1313
Diese interaktive Übersicht zeigt für jeden Tag den Anteil der Bevölkerung,
1414
der aufgrund von Schulferien oder Feiertagen in den ausgewählten Ländern
1515
gerade Urlaub hat. Die Daten basieren auf der
1616
<a href="https://www.openholidaysapi.org" target="_blank">OpenHolidays API</a>.
1717
</p>
18+
<button id="languageSelector">🌐</button>
1819
</header>
1920

2021
<section id="controls">
@@ -28,7 +29,7 @@ <h1>Holidense: Feriendichte Kalender</h1>
2829

2930
<footer>
3031
<p>
31-
Gemacht mit ♥️ von Philipp<br/>
32+
<span data-i18n="madeWithLoveBy">Gemacht mit ♥️ von</span> Philipp<br/>
3233
Open Source: <a href="https://github.com/eltos/holidense">github.com/eltos/holidense</a>
3334
</p>
3435
<p id="sourceInfo">

0 commit comments

Comments
 (0)