@noe-teritorio/opening_hours 3.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,414 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: © 2013 Robin Schneider <ypid@riseup.net>
3
+ *
4
+ * SPDX-License-Identifier: LGPL-3.0-only
5
+ */
6
+ // Import dependencies
7
+ import i18next from '../../node_modules/i18next/dist/esm/i18next.js';
8
+
9
+ export const OpeningHoursTable = {
10
+
11
+ // JS functions for generating the table {{{
12
+ // In English. Localization is done somewhere else (above).
13
+ months: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'],
14
+ weekdays: ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'],
15
+
16
+ getLocalizedWeekday(date) {
17
+ return date.toLocaleString(i18next.language, { weekday: 'short' });
18
+ },
19
+
20
+ // Returns a date's local time as a percentage of the day (0–100).
21
+ dayPercent(date) {
22
+ return (date.getHours() * 60 + date.getMinutes()) / 1440 * 100;
23
+ },
24
+
25
+ // Formats a date's local time as "HH:MM".
26
+ formatHM(date) {
27
+ return date.toLocaleString('en', { hourCycle: 'h23', hour: '2-digit', minute: '2-digit' });
28
+ },
29
+
30
+ formatdate (now, nextchange, from) {
31
+ const now_daystart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
32
+ const nextdays = (nextchange.getTime() - now_daystart.getTime()) / 1000 / 60 / 60 / 24;
33
+
34
+ let timediff = '';
35
+
36
+ let delta = Math.floor((nextchange.getTime() - now.getTime()) / 1000 / 60); // delta is minutes
37
+ if (delta < 60) {
38
+ timediff = `${i18next.t('words.in duration')} ${delta} ${this.plural(delta, 'words.time.minute')}`;
39
+ }
40
+
41
+ const deltaminutes = delta % 60;
42
+ delta = Math.floor(delta / 60); // delta is now hours
43
+
44
+ if (delta < 48 && timediff === '') {
45
+ timediff =
46
+ `${i18next.t('words.in duration')} `
47
+ + `${delta} `
48
+ + `${this.plural(delta, 'words.time.hour')} `
49
+ + `${i18next.t('words.time.hours minutes sep')}`
50
+ + `${this.pad(deltaminutes)} `
51
+ + `${this.plural(deltaminutes, 'words.time.minute')}`;
52
+ }
53
+
54
+ const deltahours = delta % 24;
55
+ delta = Math.floor(delta / 24); // delta is now days
56
+
57
+ if (delta < 14 && timediff === '') {
58
+ timediff = `${i18next.t('words.in duration')} ${delta} ${this.plural(delta, 'words.time.day')
59
+ } ${deltahours} ${this.plural(deltahours, 'words.time.hour')}`;
60
+ } else if (timediff === '') {
61
+ timediff = `${i18next.t('words.in duration')} ${delta} ${this.plural(delta, 'words.time.day')}`;
62
+ }
63
+ let atday = '';
64
+ if (from ? (nextdays < 1) : (nextdays <= 1)) {
65
+ atday = i18next.t('words.today');
66
+ } else if (from ? (nextdays < 2) : (nextdays <= 2)) {
67
+ atday = i18next.t('words.tomorrow');
68
+ } else if (from ? (nextdays < 7) : (nextdays <= 7)) {
69
+ if (i18next.exists(`weekdays.days next week.${this.weekdays[nextchange.getDay()]}`)) {
70
+ atday = i18next.t(`weekdays.days next week.${this.weekdays[nextchange.getDay()]}`, {
71
+ day: nextchange.toLocaleString(i18next.language, {weekday: 'long'})
72
+ });
73
+ } else {
74
+ atday = i18next.t('weekdays.day next week', {
75
+ day: nextchange.toLocaleString(i18next.language, {weekday: 'long'})
76
+ });
77
+ }
78
+ }
79
+
80
+ let month_name = nextchange.toLocaleString(i18next.language, {month: 'long'});
81
+ const month_name_match = month_name.match(/\(([^|]+?)\|.*\)/);
82
+ if (month_name_match && typeof month_name_match[1] === 'string') {
83
+ /* The language has multiple words for the month (nominative, subjective).
84
+ * Use the first one.
85
+ * https://github.com/opening-hours/opening_hours_map/issues/41
86
+ */
87
+ month_name = month_name_match[1];
88
+ }
89
+
90
+ const atdate = `${nextchange.getDate()} ${month_name}`;
91
+ const res = [];
92
+
93
+ if (atday !== '') res.push(atday);
94
+ if (atdate !== '') res.push(atdate);
95
+ if (timediff !== '') res.push(timediff);
96
+
97
+ return res.join(', ');
98
+ },
99
+
100
+ pad (n) { return n < 10 ? `0${n}` : n; },
101
+
102
+ plural (n, trans_base) {
103
+ // i18next plural function call
104
+ return i18next.t(trans_base, {count: n});
105
+ },
106
+
107
+ toISODateString (date) {
108
+ // ISO 8601: https://xkcd.com/1179/
109
+ return `${date.getFullYear()}-${
110
+ this.pad(date.getMonth() + 1)}-${
111
+ this.pad(date.getDate())}`;
112
+ },
113
+
114
+ printTime (date) {
115
+ // return date.toLocaleTimeString('de');
116
+ return `${this.pad(date.getHours())}:${
117
+ this.pad(date.getMinutes())}:${
118
+ this.pad(date.getSeconds())}`;
119
+ },
120
+
121
+ drawTable (it, date_today, has_next_change, evalDate) {
122
+ date_today = new Date(date_today);
123
+ date_today.setHours(0, 0, 0, 0);
124
+
125
+ const date = new Date(date_today);
126
+ date.setDate(date.getDate() - date.getDay() - 1); // start at begin of the week
127
+
128
+ // Calculate current time position for "now" marker (percentage of day)
129
+ // Use evalDate instead of new Date() to show the evaluation time, not browser time
130
+ const now = evalDate || new Date();
131
+ const nowPercent = this.dayPercent(now);
132
+
133
+ const tableData = [];
134
+
135
+ for (let row = 0; row < 7; row++) {
136
+ date.setDate(date.getDate() + 1);
137
+
138
+ it.setDate(date);
139
+ let is_open = it.getState();
140
+ let unknown = it.getUnknown();
141
+ let state_string = it.getStateString(true);
142
+ let prevdate = date;
143
+ let curdate = date;
144
+
145
+ const rowData = {
146
+ date: new Date(date),
147
+ times: [],
148
+ text: [],
149
+ isToday: date.getDay() === date_today.getDay()
150
+ };
151
+
152
+ while (has_next_change && it.advance() && curdate.getTime() - date.getTime() < 24 * 60 * 60 * 1000) {
153
+ curdate = it.getDate();
154
+
155
+ // Use local clock time (not elapsed ms) so bars stay aligned
156
+ // with labels on DST transition days.
157
+ const crossesMidnight = prevdate.getDay() !== curdate.getDay();
158
+
159
+ const from = this.dayPercent(prevdate);
160
+ const to = crossesMidnight ? 100 : this.dayPercent(curdate);
161
+
162
+ const stateClass = is_open ? 'open' : (unknown ? 'unknown' : 'closed');
163
+ const timeFrom = this.formatHM(prevdate);
164
+ const timeTo = crossesMidnight ? '24:00' : this.formatHM(curdate);
165
+
166
+ // Use current state_string for this period (before advancing)
167
+ const currentStateString = state_string;
168
+ const tooltip = `${i18next.t(`words.${currentStateString}`)}: ${timeFrom} - ${timeTo}`;
169
+
170
+ rowData.times.push(
171
+ `<div class="timebar ${stateClass}" style="width:${to - from}%" title="${tooltip}"></div>`
172
+ );
173
+
174
+ if (is_open || unknown) {
175
+ const text = `${i18next.t(`words.${currentStateString}`)} ${i18next.t('words.from')} ${timeFrom} ${i18next.t('words.to')} ${timeTo}`;
176
+ rowData.text.push(text);
177
+ }
178
+
179
+ prevdate = curdate;
180
+ is_open = it.getState();
181
+ unknown = it.getUnknown();
182
+ state_string = it.getStateString(true);
183
+ }
184
+
185
+ if (!has_next_change && rowData.text.length === 0) { // 24/7
186
+ const stateClass = is_open ? 'open' : (unknown ? 'unknown' : 'closed');
187
+ const tooltip = is_open ? `${i18next.t('words.open')}: 00:00 - 24:00` : '';
188
+ rowData.times.push(
189
+ `<div class="timebar ${stateClass}" style="width:100%" title="${tooltip}"></div>`
190
+ );
191
+ if (is_open) {
192
+ rowData.text.push(`${i18next.t('words.open')} 00:00 ${i18next.t('words.to')} 24:00`);
193
+ }
194
+ }
195
+
196
+ tableData.push(rowData);
197
+ }
198
+
199
+ // Build table HTML
200
+ const headerRow = `
201
+ <tr class="time-scale">
202
+ <td></td>
203
+ <td>
204
+ <div class="scale-labels">
205
+ <span>0h</span>
206
+ <span>6h</span>
207
+ <span>12h</span>
208
+ <span>18h</span>
209
+ <span>24h</span>
210
+ </div>
211
+ </td>
212
+ <td></td>
213
+ </tr>`;
214
+
215
+ const rows = tableData.map(row => {
216
+ const isToday = row.date.getDay() === date_today.getDay();
217
+ const isEndWeek = (row.date.getDay() + 1) % 7 === date_today.getDay();
218
+ const rowClass = isToday ? ' class="today"' : (isEndWeek ? ' class="endweek"' : '');
219
+ const dayClass = row.date.getDay() % 6 === 0 ? 'weekend' : 'workday';
220
+ const weekdayName = this.getLocalizedWeekday(row.date);
221
+
222
+ // Add "now" marker for today
223
+ const nowMarker = isToday
224
+ ? `<div class="now-marker" style="left:${nowPercent}%" title="${i18next.t('words.time.now')}"></div>`
225
+ : '';
226
+
227
+ return `<tr${rowClass}>
228
+ <td class="day ${dayClass}">
229
+ <span class="weekday">${weekdayName}</span>
230
+ <span class="date">${this.toISODateString(row.date)}</span>
231
+ </td>
232
+ <td class="times">
233
+ ${row.times.join('')}
234
+ ${nowMarker}
235
+ </td>
236
+ <td class="description">${row.text.join(', ') || '&nbsp;'}</td>
237
+ </tr>`;
238
+ }).join('');
239
+
240
+ return `<table class="opening-hours-table">${headerRow}${rows}</table>`;
241
+ },
242
+
243
+ drawTableAndComments (oh, it, evalDate, warnings = [], publicHolidayContext = {}) {
244
+ const prevdate = it.getDate();
245
+ const unknown = it.getUnknown();
246
+ const currentState = it.getState();
247
+ const state_string_past = it.getStateString(true);
248
+ const comment = it.getComment();
249
+ const has_next_change = it.advance();
250
+ const hasPublicHolidayWarning = warnings.some(
251
+ warning => warning.type === 'public_holiday');
252
+ const hasPublicHolidayWarningForDate = publicHolidayContext.isHoliday
253
+ && hasPublicHolidayWarning;
254
+
255
+ let output = '';
256
+
257
+ // 1. Current status
258
+ output += `<p class="${state_string_past} status-info">${
259
+ i18next.t(`texts.${state_string_past} ${has_next_change ? 'now' : 'always'}`)}`;
260
+ if (unknown) {
261
+ output += i18next.t('texts.depends on', {comment: `"${comment}"`});
262
+ }
263
+ if (hasPublicHolidayWarningForDate) {
264
+ output += `<span class="public-holiday-status">${i18next.t('texts.public holiday status context')}</span>`;
265
+ }
266
+ output += '</p>';
267
+
268
+ // 2. Show reason (comment) if present and not unknown
269
+ if (typeof comment !== 'undefined' && !unknown) {
270
+ output += `<p class="status-reason">↳ ${i18next.t('texts.reason')}: ${comment}</p>`;
271
+ }
272
+
273
+ // 3. Find next REAL state change (not just interval boundary)
274
+ if (has_next_change) {
275
+ let nextRealChangeDate = null;
276
+ let nextRealStateString = null;
277
+ let time_diff = 0;
278
+
279
+ // Check if immediate next change is a real state change
280
+ if (it.getState() !== currentState) {
281
+ nextRealChangeDate = it.getDate();
282
+ nextRealStateString = it.getStateString(false);
283
+ time_diff = (nextRealChangeDate.getTime() - prevdate.getTime()) / 1000 + 60;
284
+ } else {
285
+ // Keep advancing until we find a real state change
286
+ // Limit iterations to prevent infinite loops with complex values
287
+ const maxIterations = 1000;
288
+ let iterations = 0;
289
+ while (it.advance() && iterations < maxIterations) {
290
+ iterations++;
291
+ if (it.getState() !== currentState) {
292
+ nextRealChangeDate = it.getDate();
293
+ nextRealStateString = it.getStateString(false);
294
+ time_diff = (nextRealChangeDate.getTime() - prevdate.getTime()) / 1000 + 60;
295
+ break;
296
+ }
297
+ }
298
+ }
299
+
300
+ if (nextRealChangeDate) {
301
+ const timeString = this.formatdate(prevdate, nextRealChangeDate, true);
302
+ // Use "opens again" or "closes again" based on what will happen
303
+ const translationKey = nextRealStateString === 'open' ? 'texts.opens again' : 'texts.closes again';
304
+ const statusText = i18next.t(translationKey);
305
+ const buttonText = i18next.t('texts.jump to time');
306
+
307
+ const nextStateClass = nextRealStateString === 'open' ? 'opened' : 'closed';
308
+ output += `<p class="${nextStateClass} status-info next-change">
309
+ ${statusText}: ${timeString}
310
+ <a href="#" class="time-jump-btn" data-offset="${time_diff}" title="${buttonText}">
311
+ ${buttonText}
312
+ </a>
313
+ </p>`;
314
+ }
315
+ }
316
+
317
+ // Add upcoming changes timeline
318
+ const upcomingChanges = this.generateUpcomingChanges(oh, evalDate, 5);
319
+ output += this.generateUpcomingChangesHTML(upcomingChanges, evalDate);
320
+
321
+ output += this.drawTable(it, prevdate, has_next_change, evalDate);
322
+
323
+ if (oh.isWeekStable()) {
324
+ output += `<p><b>${i18next.t('texts.week stable')}</b></p>`;
325
+ } else {
326
+ output += `<p><b>${i18next.t('texts.not week stable')}</b></p>`;
327
+ }
328
+
329
+ return output;
330
+ },
331
+
332
+ // Generate upcoming changes timeline {{{
333
+ generateUpcomingChanges(oh, currentDate, maxChanges = 5) {
334
+ const changes = [];
335
+ const it = oh.getIterator(currentDate);
336
+ const currentState = it.getState();
337
+ let previousState = currentState;
338
+
339
+ // Collect next changes (all interval boundaries, not just state changes)
340
+ let count = 0;
341
+ while (count < maxChanges && it.advance()) {
342
+ const changeDate = it.getDate();
343
+ const newState = it.getState();
344
+ const comment = it.getComment();
345
+ const stateString = it.getStateString(true); // Use past form for consistency
346
+
347
+ changes.push({
348
+ date: changeDate,
349
+ state: newState,
350
+ stateString: stateString,
351
+ comment: comment,
352
+ isActualStateChange: previousState !== newState
353
+ });
354
+
355
+ previousState = newState;
356
+ count++;
357
+ }
358
+
359
+ return changes;
360
+ },
361
+
362
+ formatUpcomingChangeTime(currentDate, changeDate) {
363
+ const now_daystart = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate());
364
+ const change_daystart = new Date(changeDate.getFullYear(), changeDate.getMonth(), changeDate.getDate());
365
+ const daysDiff = Math.round((change_daystart.getTime() - now_daystart.getTime()) / (1000 * 60 * 60 * 24));
366
+
367
+ const timeStr = this.formatHM(changeDate);
368
+
369
+ if (daysDiff === 0) {
370
+ return `${i18next.t('words.today')} ${timeStr}`;
371
+ } else if (daysDiff === 1) {
372
+ return `${i18next.t('words.tomorrow')} ${timeStr}`;
373
+ } else if (daysDiff === -1) {
374
+ return `${i18next.t('words.yesterday')} ${timeStr}`;
375
+ } else {
376
+ // For dates further away, show date + time
377
+ const dateStr = this.toISODateString(changeDate);
378
+ return `${dateStr} ${timeStr}`;
379
+ }
380
+ },
381
+
382
+ generateUpcomingChangesHTML(changes, currentDate) {
383
+ if (changes.length === 0) return '';
384
+
385
+ let html = `<details class="upcoming-changes">
386
+ <summary>${i18next.t('texts.interval boundaries')}</summary>
387
+ <p class="timeline-hint">${i18next.t('texts.interval boundaries hint')}</p>
388
+ <ul class="timeline">`;
389
+
390
+ for (const change of changes) {
391
+ const timeStr = this.formatUpcomingChangeTime(currentDate, change.date);
392
+ const stateClass = change.state ? 'opened' : 'closed';
393
+ // Visual distinction: filled circle for real changes, empty for boundaries
394
+ const changeIcon = change.isActualStateChange ? '●' : '○';
395
+ const changeType = change.isActualStateChange ? 'state-change' : 'boundary-only';
396
+ const stateText = i18next.t(`words.${change.stateString}`);
397
+ const commentText = typeof change.comment === 'string'
398
+ ? ` <span class="timeline-comment">(${change.comment})</span>`
399
+ : '';
400
+
401
+ html += `<li class="timeline-item ${stateClass} ${changeType}">
402
+ <span class="timeline-icon">${changeIcon}</span>
403
+ <span class="timeline-time">${timeStr}</span>
404
+ <span class="timeline-arrow">→</span>
405
+ <span class="timeline-state">${stateText}</span>${commentText}
406
+ </li>`;
407
+ }
408
+
409
+ html += '</ul></details>';
410
+ return html;
411
+ },
412
+ // }}}
413
+ // }}}
414
+ };
@@ -0,0 +1,73 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: © 2025 Kristjan ESPERANTO <https://github.com/KristjanESPERANTO>
3
+ *
4
+ * SPDX-License-Identifier: LGPL-3.0-only
5
+ */
6
+
7
+ // Theme management: explicit preference or browser preference in auto mode
8
+ (function() {
9
+ 'use strict';
10
+
11
+ const STORAGE_KEY = 'theme-preference';
12
+ const THEMES = ['auto', 'light', 'dark'];
13
+ const THEME_ICONS = { auto: '🌗', light: '☀️', dark: '🌙' };
14
+
15
+ function getThemePreference() {
16
+ const stored = localStorage.getItem(STORAGE_KEY);
17
+ if (stored === 'light' || stored === 'dark') {
18
+ return stored;
19
+ }
20
+ if (stored !== null) {
21
+ localStorage.removeItem(STORAGE_KEY);
22
+ }
23
+ return 'auto';
24
+ }
25
+
26
+ const themeToggle = document.getElementById('theme-toggle');
27
+
28
+ function applyTheme(theme) {
29
+ if (theme === 'auto') {
30
+ document.body.removeAttribute('data-theme');
31
+ } else {
32
+ document.body.setAttribute('data-theme', theme);
33
+ }
34
+
35
+ const currentIcon = document.getElementById('theme-current-icon');
36
+ if (currentIcon) {
37
+ currentIcon.textContent = THEME_ICONS[theme];
38
+ }
39
+
40
+ if (themeToggle) {
41
+ themeToggle.dataset.theme = theme;
42
+ themeToggle.dispatchEvent(new CustomEvent('themechange', { detail: theme }));
43
+ }
44
+ }
45
+
46
+ function setTheme(theme) {
47
+ const selectedTheme = THEMES.includes(theme) ? theme : 'auto';
48
+ applyTheme(selectedTheme);
49
+
50
+ if (selectedTheme === 'auto') {
51
+ localStorage.removeItem(STORAGE_KEY);
52
+ } else {
53
+ localStorage.setItem(STORAGE_KEY, selectedTheme);
54
+ }
55
+ }
56
+
57
+ applyTheme(getThemePreference());
58
+
59
+ if (themeToggle) {
60
+ themeToggle.addEventListener('click', () => {
61
+ const currentTheme = getThemePreference();
62
+ const nextTheme = THEMES[(THEMES.indexOf(currentTheme) + 1) % THEMES.length];
63
+ setTheme(nextTheme);
64
+ });
65
+ }
66
+
67
+ const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
68
+ darkModeQuery.addEventListener('change', () => {
69
+ if (getThemePreference() === 'auto') {
70
+ applyTheme('auto');
71
+ }
72
+ });
73
+ })();