UNPKG

opening_hours

Version:

Library to parse and process opening_hours tag from OpenStreetMap data

411 lines (342 loc) 17.1 kB
// Import dependencies import i18next from '../../node_modules/i18next/dist/esm/i18next.bundled.js'; export const OpeningHoursTable = { // JS functions for generating the table {{{ // In English. Localization is done somewhere else (above). months: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'], weekdays: ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'], getLocalizedWeekday(date) { return date.toLocaleString(i18next.language, { weekday: 'short' }); }, formatdate (now, nextchange, from) { const now_daystart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const nextdays = (nextchange.getTime() - now_daystart.getTime()) / 1000 / 60 / 60 / 24; let timediff = ''; let delta = Math.floor((nextchange.getTime() - now.getTime()) / 1000 / 60); // delta is minutes if (delta < 60) { timediff = `${i18next.t('words.in duration')} ${delta} ${this.plural(delta, 'words.time.minute')}`; } const deltaminutes = delta % 60; delta = Math.floor(delta / 60); // delta is now hours if (delta < 48 && timediff === '') { timediff = `${i18next.t('words.in duration')} ` + `${delta} ` + `${this.plural(delta, 'words.time.hour')} ` + `${i18next.t('words.time.hours minutes sep')}` + `${this.pad(deltaminutes)} ` + `${this.plural(deltaminutes, 'words.time.minute')}`; } const deltahours = delta % 24; delta = Math.floor(delta / 24); // delta is now days if (delta < 14 && timediff === '') { timediff = `${i18next.t('words.in duration')} ${delta} ${this.plural(delta, 'words.time.day') } ${deltahours} ${this.plural(deltahours, 'words.time.hour')}`; } else if (timediff === '') { timediff = `${i18next.t('words.in duration')} ${delta} ${this.plural(delta, 'words.time.day')}`; } let atday = ''; if (from ? (nextdays < 1) : (nextdays <= 1)) { atday = i18next.t('words.today'); } else if (from ? (nextdays < 2) : (nextdays <= 2)) { atday = i18next.t('words.tomorrow'); } else if (from ? (nextdays < 7) : (nextdays <= 7)) { if (i18next.exists(`weekdays.days next week.${this.weekdays[nextchange.getDay()]}`)) { atday = i18next.t(`weekdays.days next week.${this.weekdays[nextchange.getDay()]}`, { day: nextchange.toLocaleString(i18next.language, {weekday: 'long'}) }); } else { atday = i18next.t('weekdays.day next week', { day: nextchange.toLocaleString(i18next.language, {weekday: 'long'}) }); } } let month_name = nextchange.toLocaleString(i18next.language, {month: 'long'}); const month_name_match = month_name.match(/\(([^|]+?)\|.*\)/); if (month_name_match && typeof month_name_match[1] === 'string') { /* The language has multiple words for the month (nominative, subjective). * Use the first one. * https://github.com/opening-hours/opening_hours_map/issues/41 */ month_name = month_name_match[1]; } const atdate = `${nextchange.getDate()} ${month_name}`; const res = []; if (atday !== '') res.push(atday); if (atdate !== '') res.push(atdate); if (timediff !== '') res.push(timediff); return res.join(', '); }, pad (n) { return n < 10 ? `0${n}` : n; }, plural (n, trans_base) { // i18next plural function call return i18next.t(trans_base, {count: n}); }, toISODateString (date) { // ISO 8601: https://xkcd.com/1179/ return `${date.getFullYear()}-${ this.pad(date.getMonth() + 1)}-${ this.pad(date.getDate())}`; }, printTime (date) { // return date.toLocaleTimeString('de'); return `${this.pad(date.getHours())}:${ this.pad(date.getMinutes())}:${ this.pad(date.getSeconds())}`; }, drawTable (it, date_today, has_next_change, evalDate) { date_today = new Date(date_today); date_today.setHours(0, 0, 0, 0); const date = new Date(date_today); date.setDate(date.getDate() - date.getDay() - 1); // start at begin of the week // Calculate current time position for "now" marker (percentage of day) // Use evalDate instead of new Date() to show the evaluation time, not browser time const now = evalDate || new Date(); const nowPercent = ((now.getHours() * 60 + now.getMinutes()) / (24 * 60)) * 100; const tableData = []; for (let row = 0; row < 7; row++) { date.setDate(date.getDate() + 1); it.setDate(date); let is_open = it.getState(); let unknown = it.getUnknown(); let state_string = it.getStateString(true); let prevdate = date; let curdate = date; const rowData = { date: new Date(date), times: [], text: [], isToday: date.getDay() === date_today.getDay() }; while (has_next_change && it.advance() && curdate.getTime() - date.getTime() < 24 * 60 * 60 * 1000) { curdate = it.getDate(); let fr = prevdate.getTime() - date.getTime(); let to = curdate.getTime() - date.getTime(); if (to > 24 * 60 * 60 * 1000) { to = 24 * 60 * 60 * 1000; } fr *= 100 / 1000 / 60 / 60 / 24; to *= 100 / 1000 / 60 / 60 / 24; const stateClass = is_open ? 'open' : (unknown ? 'unknown' : 'closed'); // Always use 24h format with HH:MM const timeFrom = `${String(prevdate.getHours()).padStart(2, '0')}:${String(prevdate.getMinutes()).padStart(2, '0')}`; const timeToDate = prevdate.getDay() !== curdate.getDay() ? null : curdate; const timeTo = timeToDate ? `${String(timeToDate.getHours()).padStart(2, '0')}:${String(timeToDate.getMinutes()).padStart(2, '0')}` : '24:00'; // Use current state_string for this period (before advancing) const currentStateString = state_string; const tooltip = `${i18next.t(`words.${currentStateString}`)}: ${timeFrom} - ${timeTo}`; rowData.times.push( `<div class="timebar ${stateClass}" style="width:${to - fr}%" title="${tooltip}"></div>` ); if (is_open || unknown) { const text = `${i18next.t(`words.${currentStateString}`)} ${i18next.t('words.from')} ${timeFrom} ${i18next.t('words.to')} ${timeTo}`; rowData.text.push(text); } prevdate = curdate; is_open = it.getState(); unknown = it.getUnknown(); state_string = it.getStateString(true); } if (!has_next_change && rowData.text.length === 0) { // 24/7 const stateClass = is_open ? 'open' : (unknown ? 'unknown' : 'closed'); const tooltip = is_open ? `${i18next.t('words.open')}: 00:00 - 24:00` : ''; rowData.times.push( `<div class="timebar ${stateClass}" style="width:100%" title="${tooltip}"></div>` ); if (is_open) { rowData.text.push(`${i18next.t('words.open')} 00:00 ${i18next.t('words.to')} 24:00`); } } tableData.push(rowData); } // Build table HTML const headerRow = ` <tr class="time-scale"> <td></td> <td> <div class="scale-labels"> <span>0h</span> <span>6h</span> <span>12h</span> <span>18h</span> <span>24h</span> </div> </td> <td></td> </tr>`; const rows = tableData.map(row => { const isToday = row.date.getDay() === date_today.getDay(); const isEndWeek = (row.date.getDay() + 1) % 7 === date_today.getDay(); const rowClass = isToday ? ' class="today"' : (isEndWeek ? ' class="endweek"' : ''); const dayClass = row.date.getDay() % 6 === 0 ? 'weekend' : 'workday'; const weekdayName = this.getLocalizedWeekday(row.date); // Add "now" marker for today const nowMarker = isToday ? `<div class="now-marker" style="left:${nowPercent}%" title="${i18next.t('words.time.now')}"></div>` : ''; return `<tr${rowClass}> <td class="day ${dayClass}"> <span class="weekday">${weekdayName}</span> <span class="date">${this.toISODateString(row.date)}</span> </td> <td class="times"> ${row.times.join('')} ${nowMarker} </td> <td class="description">${row.text.join(', ') || '&nbsp;'}</td> </tr>`; }).join(''); return `<table class="opening-hours-table">${headerRow}${rows}</table>`; }, getReadableState (startString, endString, oh, past) { if (past === true) past = 'd'; else past = ''; const output = ''; return `${startString + output + endString}.`; }, drawTableAndComments (oh, it, evalDate) { const prevdate = it.getDate(); const unknown = it.getUnknown(); const currentState = it.getState(); const state_string_past = it.getStateString(true); const comment = it.getComment(); const has_next_change = it.advance(); let output = ''; // 1. Current status output += `<p class="${state_string_past} status-info">${ i18next.t(`texts.${state_string_past} ${has_next_change ? 'now' : 'always'}`)}`; if (unknown) { output += i18next.t('texts.depends on', {comment: `"${comment}"`}); } output += '</p>'; // 2. Show reason (comment) if present and not unknown if (typeof comment !== 'undefined' && !unknown) { output += `<p class="status-reason">↳ ${i18next.t('texts.reason')}: ${comment}</p>`; } // 3. Find next REAL state change (not just interval boundary) if (has_next_change) { let nextRealChangeDate = null; let nextRealStateString = null; let time_diff = 0; // Check if immediate next change is a real state change if (it.getState() !== currentState) { nextRealChangeDate = it.getDate(); nextRealStateString = it.getStateString(false); time_diff = (nextRealChangeDate.getTime() - prevdate.getTime()) / 1000 + 60; } else { // Keep advancing until we find a real state change // Limit iterations to prevent infinite loops with complex values const maxIterations = 1000; let iterations = 0; while (it.advance() && iterations < maxIterations) { iterations++; if (it.getState() !== currentState) { nextRealChangeDate = it.getDate(); nextRealStateString = it.getStateString(false); time_diff = (nextRealChangeDate.getTime() - prevdate.getTime()) / 1000 + 60; break; } } } if (nextRealChangeDate) { const timeString = this.formatdate(prevdate, nextRealChangeDate, true); // Use "opens again" or "closes again" based on what will happen const translationKey = nextRealStateString === 'open' ? 'texts.opens again' : 'texts.closes again'; const statusText = i18next.t(translationKey); const buttonText = i18next.t('texts.jump to time'); const nextStateClass = nextRealStateString === 'open' ? 'opened' : 'closed'; output += `<p class="${nextStateClass} status-info next-change"> ${statusText}: ${timeString} <a href="#" class="time-jump-btn" data-offset="${time_diff}" title="${buttonText}"> ${buttonText} </a> </p>`; } } // Add upcoming changes timeline const upcomingChanges = this.generateUpcomingChanges(oh, evalDate, 5); output += this.generateUpcomingChangesHTML(upcomingChanges, evalDate); output += this.drawTable(it, prevdate, has_next_change, evalDate); if (oh.isWeekStable()) { output += `<p><b>${i18next.t('texts.week stable')}</b></p>`; } else { output += `<p><b>${i18next.t('texts.not week stable')}</b></p>`; } return output; }, // Generate upcoming changes timeline {{{ generateUpcomingChanges(oh, currentDate, maxChanges = 5) { const changes = []; const it = oh.getIterator(currentDate); const currentState = it.getState(); let previousState = currentState; // Collect next changes (all interval boundaries, not just state changes) let count = 0; while (count < maxChanges && it.advance()) { const changeDate = it.getDate(); const newState = it.getState(); const comment = it.getComment(); const stateString = it.getStateString(true); // Use past form for consistency changes.push({ date: changeDate, state: newState, stateString: stateString, comment: comment, isActualStateChange: previousState !== newState }); previousState = newState; count++; } return changes; }, formatUpcomingChangeTime(currentDate, changeDate) { const now_daystart = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate()); const change_daystart = new Date(changeDate.getFullYear(), changeDate.getMonth(), changeDate.getDate()); const daysDiff = Math.round((change_daystart.getTime() - now_daystart.getTime()) / (1000 * 60 * 60 * 24)); // Always use 24h format with HH:MM const hours = String(changeDate.getHours()).padStart(2, '0'); const minutes = String(changeDate.getMinutes()).padStart(2, '0'); const timeStr = `${hours}:${minutes}`; if (daysDiff === 0) { return `${i18next.t('words.today')} ${timeStr}`; } else if (daysDiff === 1) { return `${i18next.t('words.tomorrow')} ${timeStr}`; } else if (daysDiff === -1) { return `${i18next.t('words.yesterday')} ${timeStr}`; } else { // For dates further away, show date + time const dateStr = this.toISODateString(changeDate); return `${dateStr} ${timeStr}`; } }, generateUpcomingChangesHTML(changes, currentDate) { if (changes.length === 0) return ''; let html = `<details class="upcoming-changes"> <summary>${i18next.t('texts.interval boundaries')}</summary> <p class="timeline-hint">${i18next.t('texts.interval boundaries hint')}</p> <ul class="timeline">`; for (const change of changes) { const timeStr = this.formatUpcomingChangeTime(currentDate, change.date); const stateClass = change.state ? 'opened' : 'closed'; // Visual distinction: filled circle for real changes, empty for boundaries const changeIcon = change.isActualStateChange ? '●' : '○'; const changeType = change.isActualStateChange ? 'state-change' : 'boundary-only'; const stateText = i18next.t(`words.${change.stateString}`); const commentText = typeof change.comment === 'string' ? ` <span class="timeline-comment">(${change.comment})</span>` : ''; html += `<li class="timeline-item ${stateClass} ${changeType}"> <span class="timeline-icon">${changeIcon}</span> <span class="timeline-time">${timeStr}</span> <span class="timeline-arrow">→</span> <span class="timeline-state">${stateText}</span>${commentText} </li>`; } html += '</ul></details>'; return html; }, // }}} // }}} };