Files
Life-OS/public/calendar.js
T

77 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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);
})();