138 lines
20 KiB
JavaScript
138 lines
20 KiB
JavaScript
/* Calendar brings memories, due/completed tasks, and goal milestones onto one date axis. */
|
||
(() => {
|
||
const t = (key, values) => LifeI18n.t(`calendar.${key}`, values);
|
||
const tasksT = (key, values) => LifeI18n.t(`tasks.${key}`, values);
|
||
const pad = value => String(value).padStart(2, '0');
|
||
const localKey = date => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||
let selected = localKey(new Date()), month = new Date(`${selected}T12:00:00`), view = 'month';
|
||
const fromKey = key => new Date(`${key}T12:00:00`);
|
||
const esc = value => String(value || '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
||
const formatDate = (key, options = { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) => new Intl.DateTimeFormat(LifeI18n.locale(), options).format(fromKey(key));
|
||
const localDatePart = value => value ? localKey(new Date(value)) : '';
|
||
const system = () => { try { return JSON.parse(localStorage.getItem('lifeos_system') || '{}'); } catch { return {}; } };
|
||
const countryKey = 'lifeos_holiday_country_v1';
|
||
const defaults = { 'zh-CN': 'CN', 'zh-HK': 'HK', 'zh-MO': 'MO', 'zh-TW': 'TW', fr: 'FR', ru: 'RU', es: 'ES', ja: 'JP', ar: 'EG' };
|
||
const fallbackCountries = ['CN','HK','MO','TW','US','GB','CA','AU','IN','FR','RU','ES','JP','EG','AE','SA'];
|
||
let countries = fallbackCountries, countriesRequested = false;
|
||
const feeds = new Map();
|
||
const browserCountry = () => {
|
||
const language = LifeI18n.language();
|
||
const match = (navigator.languages || [navigator.language || '']).find(code => code.toLowerCase().startsWith(language.toLowerCase().split('-')[0] + '-')) || navigator.language || '';
|
||
const region = match.match(/-(?:[A-Za-z]{4}-)?([A-Za-z]{2})(?:-|$)/)?.[1]?.toUpperCase();
|
||
return region && /^[A-Z]{2}$/.test(region) ? region : (defaults[language] || 'US');
|
||
};
|
||
const preference = () => { try { return localStorage.getItem(countryKey) || 'auto'; } catch { return 'auto'; } };
|
||
const country = () => preference() === 'auto' ? (defaults[LifeI18n.language()] || browserCountry()) : preference();
|
||
const countryName = code => { try { return new Intl.DisplayNames([LifeI18n.locale()], { type: 'region' }).of(code) || code; } catch { return code; } };
|
||
const feedKey = (code, year) => `${code}/${year}`;
|
||
function loadHolidays(code, year, retry = false) {
|
||
if (year < 2024 || year > 2030) return;
|
||
const key = feedKey(code, year), current = feeds.get(key);
|
||
if (current && !retry) return;
|
||
feeds.set(key, { status: 'loading', holidays: [], workingDays: [] });
|
||
fetch(`/api/holidays?country=${encodeURIComponent(code)}&year=${year}`)
|
||
.then(async response => { if (!response.ok) throw new Error('holiday feed'); return response.json(); })
|
||
.then(data => { feeds.set(key, { status: 'ready', holidays: data.holidays || [], workingDays: data.workingDays || [] }); renderAll(); })
|
||
.catch(() => { feeds.set(key, { status: 'error', holidays: [], workingDays: [] }); renderAll(); });
|
||
}
|
||
function loadCountries() {
|
||
if (countriesRequested) return;
|
||
countriesRequested = true;
|
||
fetch('/api/holiday-countries').then(async response => { if (!response.ok) throw new Error('countries'); return response.json(); })
|
||
.then(data => { countries = [...new Set(data.countries.map(item => item.code).concat(fallbackCountries))]; renderAll(); })
|
||
.catch(() => { /* Keep the basic choices if the directory is temporarily unavailable. */ });
|
||
}
|
||
const currentFeed = () => feeds.get(feedKey(country(), month.getFullYear()));
|
||
const holidayName = name => {
|
||
const translations = LifeI18n.dictionaries[LifeI18n.language()]?.calendar?.holidayNames || {};
|
||
return typeof translations === 'object' ? translations[name] || name : name;
|
||
};
|
||
const observances = {
|
||
'02-14': { key: 'valentine', countries: ['US','GB','CA','AU','FR','ES','JP','CN','HK','MO','TW'] },
|
||
'04-22': { key: 'earth', countries: null },
|
||
'10-31': { key: 'halloween', countries: ['US','GB','CA','AU'] },
|
||
'12-24': { key: 'christmasEve', countries: ['US','GB','CA','AU','FR','ES'] },
|
||
'09-10': { key: 'teachers', countries: ['CN'] }
|
||
};
|
||
function holidayEvents(key) {
|
||
const feed = currentFeed();
|
||
const publicItems = feed?.status === 'ready' ? feed.holidays.filter(item => item.date === key).map(item => ({ kind: 'holiday', rank: 6, title: holidayName(item.name), detail: item.estimated ? t('estimated') : '', time: item.observed ? t('observed') : t('publicHoliday') })) : [];
|
||
const working = feed?.status === 'ready' && feed.workingDays.includes(key) ? [{ kind: 'workday', rank: 7, title: t('workingWeekend'), detail: '', time: t('workingDay') }] : [];
|
||
const festival = observances[key.slice(5)];
|
||
const cultural = festival && (!festival.countries || festival.countries.includes(country())) && !publicItems.length ? [{ kind: 'festival', rank: 8, title: t(`festivals.${festival.key}`), detail: '', time: t('festival') }] : [];
|
||
return [...publicItems, ...working, ...cultural];
|
||
}
|
||
|
||
function eventsFor(key) {
|
||
const events = [];
|
||
for (const memory of mems()) if (memory.date === key) events.push({ kind: 'memory', rank: 0, title: memory.title || memory.text?.trim().split('\n')[0].slice(0, 64) || t('timeline.untitled'), detail: memory.text || LifeI18n.t('timeline.noNote'), time: '', id: memory.id, mood: memory.mood, energy: memory.energy });
|
||
const board = system().tasksBoard || { goals: [], tasks: [] };
|
||
for (const task of board.tasks || []) {
|
||
const due = task.dueDate === key, completed = localDatePart(task.completedAt) === key;
|
||
if (due) events.push({ kind: 'task', rank: completed ? 1 : 2, title: task.title, detail: task.goalId ? board.goals.find(goal => goal.id === task.goalId)?.title || '' : '', time: completed ? t('taskDone') : t('taskDue'), done: Boolean(task.done), id: task.id });
|
||
if (completed && !due) events.push({ kind: 'task', rank: 1, title: task.title, detail: task.goalId ? board.goals.find(goal => goal.id === task.goalId)?.title || '' : '', time: t('taskDone'), done: true, id: task.id });
|
||
}
|
||
for (const goal of board.goals || []) {
|
||
if (localDatePart(goal.createdAt) === key) events.push({ kind: 'goal', rank: 3, title: goal.title, detail: goal.description || '', time: t('goalCreated'), id: goal.id });
|
||
if (goal.targetDate === key) events.push({ kind: 'goal', rank: 4, title: goal.title, detail: goal.description || '', time: t('goalTarget'), id: goal.id });
|
||
if (localDatePart(goal.completedAt) === key) events.push({ kind: 'goal', rank: 5, title: goal.title, detail: goal.description || '', time: t('goalDone'), id: goal.id, done: true });
|
||
}
|
||
return events.concat(holidayEvents(key)).sort((a, b) => a.rank - b.rank || a.title.localeCompare(b.title));
|
||
}
|
||
|
||
function monthCells() {
|
||
const year = month.getFullYear(), monthIndex = month.getMonth();
|
||
const firstOffset = (new Date(year, monthIndex, 1).getDay() + 6) % 7;
|
||
const days = new Date(year, monthIndex + 1, 0).getDate();
|
||
const rows = Math.ceil((firstOffset + days) / 7), today = localKey(new Date());
|
||
return Array.from({ length: rows * 7 }, (_, index) => {
|
||
const number = index - firstOffset + 1, inMonth = number > 0 && number <= days;
|
||
if (!inMonth) return '<span class="cal-blank" aria-hidden="true"></span>';
|
||
const key = `${year}-${pad(monthIndex + 1)}-${pad(number)}`, events = eventsFor(key), types = [...new Set(events.map(event => event.kind))];
|
||
return `<button class="cal-cell ${key === selected ? 'selected' : ''} ${key === today ? 'today' : ''} ${types.includes('holiday') ? 'has-holiday' : ''}" aria-label="${esc(formatDate(key))} · ${events.length} ${esc(t('items'))}${events.some(event => event.kind === 'holiday') ? ` · ${esc(events.filter(event => event.kind === 'holiday').map(event => event.title).join(', '))}` : ''}" aria-pressed="${key === selected}" onclick="selectCalendarDate('${key}')"><span class="cal-number">${number}</span>${types.length ? `<span class="cal-dots">${types.map(type => `<i class="${type}" aria-hidden="true"></i>`).join('')}</span>` : ''}</button>`;
|
||
}).join('');
|
||
}
|
||
|
||
function eventMarkup(event) {
|
||
const badge = event.kind === 'memory' ? `${event.mood ? LifeI18n.t(`weather.${event.mood}`) : ''}${event.energy ? ` · ${LifeI18n.t('common.energy')} ${event.energy}` : ''}` : event.time;
|
||
const action = event.kind === 'memory'
|
||
? `openApp('timeline');setTimeout(()=>detail('${esc(event.id)}'),20)`
|
||
: `openApp('tasks');setTaskFilter('${event.done ? 'done' : 'all'}')`;
|
||
const informational = ['holiday','festival','workday'].includes(event.kind);
|
||
return `<${informational ? 'div' : 'button'} class="cal-event ${event.kind} ${event.done ? 'complete' : ''}" ${informational ? '' : `onclick="${action}"`}><span class="cal-event-mark" aria-hidden="true">${event.kind === 'memory' ? '▣' : event.kind === 'task' ? (event.done ? '✓' : '○') : event.kind === 'goal' ? '◎' : event.kind === 'workday' ? '◇' : '✦'}</span><span class="cal-event-body"><span class="cal-event-meta">${esc(badge || t('festival'))}</span><strong>${esc(event.title)}</strong>${event.detail ? `<span class="cal-event-detail">${esc(event.detail)}</span>` : ''}</span>${informational ? '' : '<span class="cal-event-arrow" aria-hidden="true">↗</span>'}</${informational ? 'div' : 'button'}>`;
|
||
}
|
||
|
||
function selectedDayMarkup() {
|
||
const items = eventsFor(selected);
|
||
return `<section class="cal-day-card"><div class="cal-day-head"><div><div class="eyebrow">${t('dayAtGlance')}</div><h2>${esc(formatDate(selected))}</h2></div><div class="cal-day-actions"><span class="cal-day-count">${items.length}</span><button class="secondary cal-add-memory" onclick="addMemoryForDate('${selected}')">${t('writeMemory')}</button></div></div>${items.length ? `<div class="cal-events">${items.map(eventMarkup).join('')}</div>` : `<div class="cal-empty">${t('emptyDay')}<div class="cal-empty-actions"><button class="secondary" onclick="openApp('tasks')">${t('openTasks')}</button></div></div>`}</section>`;
|
||
}
|
||
|
||
function render(container) {
|
||
loadCountries();
|
||
const code = country(), year = month.getFullYear();
|
||
loadHolidays(code, year);
|
||
const feed = currentFeed();
|
||
const availability = year < 2024 || year > 2030 ? t('outsideRange') : feed?.status === 'loading' ? t('loadingHolidays') : feed?.status === 'error' ? `${t('holidayUnavailable')} <button class="text-link" onclick="retryCalendarHolidays()">${t('retryHolidays')}</button>` : t('holidaySource');
|
||
const options = [...new Set([code, ...countries])].sort((a, b) => countryName(a).localeCompare(countryName(b), LifeI18n.locale()));
|
||
const title = new Intl.DateTimeFormat(LifeI18n.locale(), { month: 'long', year: 'numeric' }).format(month);
|
||
const heading = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map((key, index) => `<span class="cal-weekday ${index > 4 ? 'weekend' : ''}">${t(key)}</span>`).join('');
|
||
container.innerHTML = `<div class="eyebrow">${t('eyebrow')}</div><header class="cal-toolbar"><div class="cal-title"><button class="cal-nav" aria-label="${t('previous')}" onclick="moveCalendarMonth(-1)">‹</button><h1>${esc(title)}</h1><button class="cal-nav" aria-label="${t('next')}" onclick="moveCalendarMonth(1)">›</button></div><div class="cal-toolbar-actions"><button class="secondary cal-today" onclick="jumpCalendarToday()">${t('today')}</button><div class="cal-view-switch"><button class="${view === 'month' ? 'active' : ''}" aria-pressed="${view === 'month'}" onclick="setCalendarView('month')">${t('month')}</button><button class="${view === 'day' ? 'active' : ''}" aria-pressed="${view === 'day'}" onclick="setCalendarView('day')">${t('day')}</button></div></div></header><div class="cal-holiday-controls"><label>${t('holidayCountry')} <select aria-label="${t('holidayCountry')}" onchange="setCalendarHolidayCountry(this.value)"><option value="auto" ${preference() === 'auto' ? 'selected' : ''}>${esc(t('autoCountry', { country: countryName(defaults[LifeI18n.language()] || browserCountry()) }))}</option>${options.map(item => `<option value="${item}" ${preference() === item ? 'selected' : ''}>${esc(countryName(item))}</option>`).join('')}</select></label><span class="cal-holiday-source">${availability}${feed?.status === 'ready' ? ` · ${t('holidayCaveat')} · <a href="https://openworkdays.com/api/" target="_blank" rel="noopener noreferrer">OpenWorkdays</a>` : ''}</span></div>${view === 'month' ? `<div class="cal-month-layout"><section class="cal-month"><div class="cal-grid cal-weekdays">${heading}</div><div class="cal-grid cal-days">${monthCells()}</div><div class="cal-legend"><span><i class="memory"></i>${t('memory')}</span><span><i class="task"></i>${t('task')}</span><span><i class="goal"></i>${t('goal')}</span><span><i class="holiday"></i>${t('publicHoliday')}</span><span><i class="festival"></i>${t('festival')}</span><span><i class="workday"></i>${t('workingDay')}</span></div></section>${selectedDayMarkup()}</div>` : `<section class="cal-single-day">${selectedDayMarkup()}<div class="cal-day-navigate"><button class="secondary" onclick="moveCalendarDay(-1)">← ${t('previousDay')}</button><button class="secondary" onclick="moveCalendarDay(1)">${t('nextDay')} →</button></div></section>`}`;
|
||
}
|
||
|
||
window.calendarApp = render;
|
||
window.setCalendarHolidayCountry = value => { if (value !== 'auto' && !/^[A-Z]{2}$/.test(value)) return; try { localStorage.setItem(countryKey, value); } catch {} renderAll(); };
|
||
window.retryCalendarHolidays = () => loadHolidays(country(), month.getFullYear(), true);
|
||
window.selectCalendarDate = key => { selected = key; month = fromKey(key); renderAll(); };
|
||
window.setCalendarView = value => { view = value; renderAll(); };
|
||
window.moveCalendarMonth = delta => {
|
||
const oldDay = fromKey(selected).getDate(); month = new Date(month.getFullYear(), month.getMonth() + delta, 1, 12);
|
||
const maxDay = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate(); selected = localKey(new Date(month.getFullYear(), month.getMonth(), Math.min(oldDay, maxDay), 12)); renderAll();
|
||
};
|
||
window.jumpCalendarToday = () => { selected = localKey(new Date()); month = fromKey(selected); renderAll(); };
|
||
window.moveCalendarDay = delta => { const date = fromKey(selected); date.setDate(date.getDate() + delta); selected = localKey(date); month = fromKey(selected); renderAll(); };
|
||
|
||
const style = document.createElement('style');
|
||
style.textContent = `.cal-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:16px 0}.cal-title,.cal-toolbar-actions,.cal-view-switch{display:flex;align-items:center}.cal-title{gap:8px}.cal-title h1{font-size:22px;min-width:130px;text-align:center;margin:0}.cal-nav{border:1px solid var(--line);background:var(--panel2);color:var(--ink);border-radius:8px;width:32px;height:32px;font-size:21px;line-height:1}.cal-toolbar-actions{gap:8px}.cal-view-switch{padding:3px;background:var(--panel2);border:1px solid var(--line);border-radius:9px}.cal-view-switch button{border:0;background:transparent;color:var(--muted);border-radius:6px;padding:7px 10px;font:10px 'DM Mono'}.cal-view-switch button.active{background:var(--panel);color:var(--ink);box-shadow:0 1px 4px #0002}.cal-today{padding:9px 11px;font-size:9px}.cal-month-layout{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(220px,.85fr);gap:15px;align-items:start}.cal-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr))}.cal-weekdays{margin-bottom:5px}.cal-weekday{text-align:center;padding:5px 0;color:var(--muted);font:9px 'DM Mono';letter-spacing:.05em}.cal-weekday.weekend{color:var(--accent)}.cal-days{gap:3px}.cal-cell,.cal-blank{min-width:0;min-height:57px}.cal-cell{position:relative;display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:7px;border:1px solid transparent;border-radius:9px;background:transparent;color:var(--ink);text-align:left}.cal-cell:hover{background:var(--panel2)}.cal-cell.selected{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 9%,var(--panel))}.cal-cell.today .cal-number{color:var(--accent);font-weight:700}.cal-number{font-size:12px}.cal-dots{display:flex;gap:3px}.cal-dots i,.cal-legend i{display:block;width:5px;height:5px;border-radius:50%;background:var(--muted)}.cal-dots i.memory,.cal-legend i.memory{background:var(--accent)}.cal-dots i.task,.cal-legend i.task{background:#78a7d7}.cal-dots i.goal,.cal-legend i.goal{background:#c38dd3}.cal-dots i.holiday,.cal-legend i.holiday{background:#ffb55b}.cal-dots i.festival,.cal-legend i.festival{background:#f28a9d}.cal-dots i.workday,.cal-legend i.workday{background:#879398}.cal-cell.has-holiday .cal-number{color:#ffbf75}.cal-event.holiday,.cal-event.festival,.cal-event.workday{cursor:default}.cal-event.holiday .cal-event-mark{color:#ffb55b}.cal-event.festival .cal-event-mark{color:#f28a9d}.cal-event.workday .cal-event-mark{color:#879398}.cal-holiday-controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:-4px 0 15px;color:var(--muted);font-size:10px}.cal-holiday-controls label{display:flex;align-items:center;gap:7px}.cal-holiday-controls select{max-width:200px;padding:6px 8px;border:1px solid var(--line);border-radius:7px;background:var(--panel2);color:var(--ink)}.cal-holiday-source a{color:var(--accent)}.cal-legend{display:flex;gap:12px;flex-wrap:wrap;padding:10px 2px 0;color:var(--muted);font-size:9px}.cal-legend span{display:flex;align-items:center;gap:5px}.cal-day-card{min-width:0;padding:14px;border:1px solid var(--line);border-radius:12px;background:color-mix(in srgb,var(--panel2) 45%,transparent)}.cal-day-head{display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:9px}.cal-day-head .eyebrow{margin-bottom:5px}.cal-day-head h2{font-size:15px;line-height:1.35;margin:0}.cal-day-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap;justify-content:flex-end}.cal-add-memory{font-size:9px;padding:7px 8px}.cal-day-count{display:grid;place-items:center;min-width:25px;height:25px;padding:0 5px;border-radius:99px;background:var(--panel);color:var(--muted);font:10px 'DM Mono'}.cal-events{display:grid;gap:5px}.cal-event{display:flex;align-items:flex-start;gap:8px;width:100%;padding:8px;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--ink);text-align:left}.cal-event:hover{background:var(--panel);border-color:var(--line)}.cal-event-mark{width:18px;flex:none;text-align:center;color:var(--accent);font-size:13px}.cal-event.task .cal-event-mark{color:#78a7d7}.cal-event.goal .cal-event-mark{color:#c38dd3}.cal-event-body{display:grid;gap:3px;min-width:0;flex:1}.cal-event-meta{color:var(--muted);font:8px 'DM Mono';letter-spacing:.06em;text-transform:uppercase}.cal-event-body strong{font-size:11px;line-height:1.4;overflow-wrap:anywhere}.cal-event-detail{color:var(--muted);font-size:10px;line-height:1.4;overflow-wrap:anywhere}.cal-event.complete .cal-event-body strong{text-decoration:line-through;color:var(--muted)}.cal-event-arrow{color:var(--muted);font-size:11px}.cal-empty{padding:20px 4px 7px;text-align:center;color:var(--muted);font-size:11px;line-height:1.6}.cal-empty-actions{display:flex;justify-content:center;gap:6px;margin-top:10px}.cal-empty-actions .secondary,.cal-day-navigate .secondary{font-size:9px;padding:8px}.cal-single-day{max-width:580px;margin:10px auto}.cal-single-day .cal-day-card{padding:19px}.cal-single-day .cal-day-head h2{font-size:19px}.cal-single-day .cal-event{padding:11px}.cal-single-day .cal-event-body strong{font-size:13px}.cal-day-navigate{display:flex;justify-content:space-between;margin-top:10px}@media(max-width:700px){.cal-month-layout{grid-template-columns:1fr}.cal-toolbar{align-items:flex-start;flex-wrap:wrap}.cal-title{gap:5px}.cal-title h1{font-size:18px;min-width:112px}.cal-toolbar-actions{gap:6px}.cal-cell,.cal-blank{min-height:46px}.cal-cell{padding:5px 4px;gap:6px}.cal-number{font-size:11px}.cal-legend{gap:9px}.cal-day-card{padding:12px}.cal-event{padding:8px 5px}}`;
|
||
document.head.append(style);
|
||
})();
|