@@ -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
2130const API_BASE = "https://openholidaysapi.org" ;
2231let populationData = null ;
2332let 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
2543const calendarContainer = document . getElementById ( "calendar" ) ;
2644const sourceInfo = document . getElementById ( "sourceInfo" ) ;
2745const yearSelect = document . getElementById ( "yearSelect" ) ;
2846const countryList = document . getElementById ( "countryList" ) ;
47+ document . getElementById ( "languageSelector" ) . onclick = async e => {
48+ locale = locale === "de" ? "en" : "de" ;
49+ updateHash ( )
50+ document . location . reload ( ) ;
51+ } ;
2952document . 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
120151function 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
143176async 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
157190async 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
174207async 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
182215async 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// ------------------------------------------------------------
383418function 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
387423function 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+ }
0 commit comments