77 lines
13 KiB
JavaScript
77 lines
13 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 {}; } };
|
||
|
||
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.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' : ''}" aria-label="${esc(formatDate(key))} · ${events.length} ${esc(t('items'))}" 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'}')`;
|
||
return `<button class="cal-event ${event.kind} ${event.done ? 'complete' : ''}" onclick="${action}"><span class="cal-event-mark" aria-hidden="true">${event.kind === 'memory' ? '▣' : event.kind === 'task' ? (event.done ? '✓' : '○') : '◎'}</span><span class="cal-event-body"><span class="cal-event-meta">${esc(badge || (event.kind === 'memory' ? t('memory') : event.kind === 'task' ? tasksT('taskTitle') : tasksT('goalTitle')))}</span><strong>${esc(event.title)}</strong>${event.detail ? `<span class="cal-event-detail">${esc(event.detail)}</span>` : ''}</span><span class="cal-event-arrow" aria-hidden="true">↗</span></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) {
|
||
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>${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></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.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-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);
|
||
})();
|