/* 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 '';
const key = `${year}-${pad(monthIndex + 1)}-${pad(number)}`, events = eventsFor(key), types = [...new Set(events.map(event => event.kind))];
return ``;
}).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}"`}>${esc(event.title)}${event.detail ? `${esc(event.detail)}` : ''}${informational ? '' : ''}${informational ? 'div' : 'button'}>`;
}
function selectedDayMarkup() {
const items = eventsFor(selected);
return `${esc(formatDate(selected))}