feat(calendar): unify life events by date

This commit is contained in:
Codex committed 2026-09-27 12:58:05 +00:00
1 parent 1e7f8ed549
commit f5e689d68b
11 files changed
+101 -20

No files matched your search

+3 -3
View File
@@ -1,8 +1,8 @@
/* Life OS reliability and productivity add-ons. */
(() => {
const DRAFT_KEY = 'lifeos_today_draft_v1';
const APP_VERSION = '1.2.0';
const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'system'];
const APP_VERSION = '1.3.0';
const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system'];
const t = (key, values) => LifeI18n.t(key, values);
function readDraft() {
@@ -163,7 +163,7 @@
if (mod && e.key.toLowerCase() === 'k') { e.preventDefault(); toggleLauncher(); }
if (mod && e.key.toLowerCase() === 'f') { e.preventDefault(); openSearch(); }
if (mod && e.key.toLowerCase() === 's' && document.querySelector('#entry')) { e.preventDefault(); archiveToday(); }
if (e.altKey && /^[1-7]$/.test(e.key)) { e.preventDefault(); openApp(appIds[Number(e.key) - 1]); }
if (e.altKey && /^[1-8]$/.test(e.key)) { e.preventDefault(); openApp(appIds[Number(e.key) - 1]); }
if (e.key === 'Escape') { closeSearch(); document.querySelector('#launcher')?.classList.add('hidden'); document.querySelector('#calendar')?.classList.add('hidden'); document.querySelector('#life-context')?.remove(); }
});
})();
+76
View File
@@ -0,0 +1,76 @@
/* 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(lang() === 'zh' ? 'zh-CN' : 'en-US', 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.filmTitle || t('memory'), 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><span class="cal-day-count">${items.length}</span></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('today')">${t('openToday')}</button><button class="secondary" onclick="openApp('tasks')">${t('openTasks')}</button></div></div>`}</section>`;
}
function render(container) {
const title = new Intl.DateTimeFormat(lang() === 'zh' ? 'zh-CN' : 'en-US', { 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-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);
})();
+1 -1
View File
@@ -55,7 +55,7 @@
window.saveProfile = () => { save('lifeos_profile', { name: pname.value, birthday: pbday.value, startDate: pstart.value || day(), motto: pmotto.value }); renderAll(); toast(t('profile.saved')); };
window.systemApp = container => {
const state = settings(), bytes = new Blob([JSON.stringify({ mems: mems(), museum: museum(), profile: profile(), system: sys() })]).size, oldBackup = state.lastBackup && Date.now() - new Date(state.lastBackup) > 12096e5, detected = browserLanguage() === 'zh' ? t('system.detectedZh') : t('system.detectedEn');
container.innerHTML = `<div class="eyebrow">${t('system.eyebrow')}</div><h1>${t('system.title')}</h1><div class="settings"><section><div class="eyebrow">${t('system.appearance')}</div><div class="choices">${['dark','light','auto'].map(item => `<button class="chip ${state.theme === item ? 'selected' : ''}" onclick="theme('${item}')">${t(`system.theme.${item}`)}</button>`).join('')}</div></section><section><div class="eyebrow">${t('system.language')}</div><p class="sub">${detected}</p><div class="choices"><button class="chip ${state.languageMode !== 'manual' ? 'selected' : ''}" onclick="setLanguage('auto')">${t('system.auto')}</button><button class="chip ${state.languageMode === 'manual' && state.language === 'en' ? 'selected' : ''}" onclick="setLanguage('en')">ENGLISH</button><button class="chip ${state.languageMode === 'manual' && state.language === 'zh' ? 'selected' : ''}" onclick="setLanguage('zh')">中文</button></div></section><section><div class="eyebrow">${t('system.data')}</div><div class="actions"><button class="primary" onclick="exportData()">${t('system.export')}</button><button class="secondary" onclick="document.querySelector('#importer').click()">${t('system.import')}</button><button class="danger" onclick="resetConfirm()">${t('system.reset')}</button><input id="importer" class="hidden" type="file" accept=".json" onchange="readImport(this)"></div></section><section><div class="eyebrow">${t('system.storage')}</div><p>${t('system.local')}<br>${lang() === 'zh' ? `${mems().length} 条记忆 · ${museum().length} 件博物馆藏品 · ${sys().tasksBoard?.tasks?.length || 0} 项待办 · 约 ${Math.ceil(bytes/1024)} KB` : `${mems().length} memories · ${museum().length} museum items · ${sys().tasksBoard?.tasks?.length || 0} tasks · Approx. ${Math.ceil(bytes/1024)} KB`}</p></section><section><div class="eyebrow">${t('system.dataStorage')}</div><p>${t('system.stored')}<br>${t('system.backups')}</p><p>${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `<br><b style="color:var(--accent)">${t('system.recommended')}</b>` : ''}</p></section><section><div class="eyebrow">${t('system.version')}</div><p>Life OS<br>Version 1.2.0 · Schema v1</p></section></div>`;
container.innerHTML = `<div class="eyebrow">${t('system.eyebrow')}</div><h1>${t('system.title')}</h1><div class="settings"><section><div class="eyebrow">${t('system.appearance')}</div><div class="choices">${['dark','light','auto'].map(item => `<button class="chip ${state.theme === item ? 'selected' : ''}" onclick="theme('${item}')">${t(`system.theme.${item}`)}</button>`).join('')}</div></section><section><div class="eyebrow">${t('system.language')}</div><p class="sub">${detected}</p><div class="choices"><button class="chip ${state.languageMode !== 'manual' ? 'selected' : ''}" onclick="setLanguage('auto')">${t('system.auto')}</button><button class="chip ${state.languageMode === 'manual' && state.language === 'en' ? 'selected' : ''}" onclick="setLanguage('en')">ENGLISH</button><button class="chip ${state.languageMode === 'manual' && state.language === 'zh' ? 'selected' : ''}" onclick="setLanguage('zh')">中文</button></div></section><section><div class="eyebrow">${t('system.data')}</div><div class="actions"><button class="primary" onclick="exportData()">${t('system.export')}</button><button class="secondary" onclick="document.querySelector('#importer').click()">${t('system.import')}</button><button class="danger" onclick="resetConfirm()">${t('system.reset')}</button><input id="importer" class="hidden" type="file" accept=".json" onchange="readImport(this)"></div></section><section><div class="eyebrow">${t('system.storage')}</div><p>${t('system.local')}<br>${lang() === 'zh' ? `${mems().length} 条记忆 · ${museum().length} 件博物馆藏品 · ${sys().tasksBoard?.tasks?.length || 0} 项待办 · 约 ${Math.ceil(bytes/1024)} KB` : `${mems().length} memories · ${museum().length} museum items · ${sys().tasksBoard?.tasks?.length || 0} tasks · Approx. ${Math.ceil(bytes/1024)} KB`}</p></section><section><div class="eyebrow">${t('system.dataStorage')}</div><p>${t('system.stored')}<br>${t('system.backups')}</p><p>${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `<br><b style="color:var(--accent)">${t('system.recommended')}</b>` : ''}</p></section><section><div class="eyebrow">${t('system.version')}</div><p>Life OS<br>Version 1.3.0 · Schema v1</p></section></div>`;
};
window.importPreview = () => { const data = importData; rootModal().innerHTML = `<div class="modal"><div class="modalbox"><div class="eyebrow">${t('dialog.backup')}</div><h2>${t('dialog.ready')}</h2><p class="sub">${t('dialog.exported',{date:short(data.exportedAt.slice(0,10))})}<br>${t('dialog.memories',{count:data.memories.length})}<br>${t('dialog.museumItems',{count:data.museum.length})}</p><div class="actions"><button class="primary" onclick="applyImport('merge')">${t('dialog.merge')}</button><button class="danger" onclick="replaceConfirm()">${t('dialog.replaceAll')}</button><button class="secondary" onclick="modal.innerHTML=''">${t('common.cancel')}</button></div></div></div>`; };
window.replaceConfirm = () => { rootModal().innerHTML = `<div class="modal"><div class="modalbox"><div class="eyebrow">${t('dialog.final')}</div><h2>${t('dialog.replaceQuestion')}</h2><p class="sub">${t('dialog.replaceWarning')}</p><div class="actions"><button class="danger" onclick="applyImport('replace')">${t('dialog.replace')}</button><button class="secondary" onclick="importPreview()">${t('common.cancel')}</button></div></div></div>`; };
Loaded 3 of 11 files, more files were not shown because too many files have changed in this diff. Show more