<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mps</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--card: #1c2128;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--purple: #a371f7;
--pink: #f778ba;
--red: #f85149;
--orange: #db6d28;
--task-c: #58a6ff;
--note-c: #3fb950;
--log-c: #d29922;
--reminder-c: #a371f7;
--character-c: #f778ba;
}
html.light {
--bg: #f6f8fa;
--surface: #ffffff;
--card: #ffffff;
--border: #d0d7de;
--text: #1f2328;
--muted: #656d76;
--accent: #0969da;
--green: #1a7f37;
--yellow: #9a6700;
--purple: #6639ba;
--pink: #bf3989;
--red: #cf222e;
--orange: #bc4c00;
--task-c: #0969da;
--note-c: #1a7f37;
--log-c: #9a6700;
--reminder-c: #6639ba;
--character-c: #bf3989;
}
.theme-btn {
width: 32px; height: 32px; border-radius: 6px; border: 1px solid var(--border);
background: var(--card); cursor: pointer; font-size: 15px;
display: flex; align-items: center; justify-content: center;
transition: background .15s, border-color .15s;
flex-shrink: 0;
}
.theme-btn:hover { background: var(--surface); border-color: var(--muted); }
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
min-height: 100vh;
}
a { color: var(--accent); text-decoration: none; }
.layout { display: flex; flex-direction: column; min-height: 100vh; }
.topbar {
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 0 20px; display: flex; align-items: center; gap: 24px;
height: 52px; position: sticky; top: 0; z-index: 100;
}
.logo { font-weight: 700; font-size: 16px; letter-spacing: -.5px; color: var(--text); display: flex; align-items: center; gap: 6px; }
.logo span { color: var(--accent); }
.nav { display: flex; gap: 4px; flex: 1; }
.nav-btn {
padding: 6px 14px; border-radius: 6px; border: none; background: transparent;
color: var(--muted); cursor: pointer; font-size: 13px; font-weight: 500;
transition: all .15s;
}
.nav-btn:hover { color: var(--text); background: var(--card); }
.nav-btn.active { color: var(--text); background: var(--card); border: 1px solid var(--border); }
.topbar-right { display: flex; align-items: center; gap: 10px; margin-left: auto; }
.version-badge {
font-size: 11px; color: var(--muted); background: var(--card);
border: 1px solid var(--border); padding: 2px 8px; border-radius: 20px;
}
.main { flex: 1; padding: 24px 20px; max-width: 900px; margin: 0 auto; width: 100%; }
.card {
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden;
}
.card-header {
padding: 14px 18px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 10px;
}
.card-title { font-weight: 600; font-size: 14px; }
.card-body { padding: 18px; }
.stats-row { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
.stat-pill {
background: var(--card); border: 1px solid var(--border);
border-radius: 20px; padding: 6px 14px; font-size: 12px; display: flex; align-items: center; gap: 6px;
}
.stat-pill .num { font-weight: 700; font-size: 15px; }
.stat-pill.tasks .num { color: var(--task-c); }
.stat-pill.notes .num { color: var(--note-c); }
.stat-pill.logs .num { color: var(--log-c); }
.stat-pill.reminders .num { color: var(--reminder-c); }
.stat-pill.characters .num { color: var(--character-c); }
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 6px; border: none; cursor: pointer;
font-size: 13px; font-weight: 500; transition: all .15s;
}
.btn-primary { background: var(--accent); color: #000; }
.btn-primary:hover { background: #79c0ff; }
.btn-secondary {
background: var(--card); color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover { background: var(--surface); }
.btn-danger { background: transparent; color: var(--red); border: 1px solid transparent; }
.btn-danger:hover { background: rgba(248,81,73,.1); border-color: var(--red); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-icon {
width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center;
border-radius: 6px; border: none; cursor: pointer; background: transparent;
font-size: 14px; transition: all .15s; color: var(--muted);
}
.btn-icon:hover { background: var(--surface); color: var(--text); }
.btn-icon.danger:hover { background: rgba(248,81,73,.15); color: var(--red); }
.btn-icon.success:hover { background: rgba(63,185,80,.15); color: var(--green); }
.form-row { display: flex; gap: 10px; align-items: flex-start; flex-wrap: wrap; }
.form-group { display: flex; flex-direction: column; gap: 5px; }
.form-group label { font-size: 12px; color: var(--muted); font-weight: 500; }
input, select, textarea {
background: var(--surface); border: 1px solid var(--border); color: var(--text);
border-radius: 6px; padding: 7px 10px; font-size: 13px; outline: none;
transition: border-color .15s; font-family: inherit;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
input::placeholder, textarea::placeholder { color: var(--muted); }
select option { background: var(--surface); }
textarea { resize: vertical; min-height: 70px; }
.input-body { flex: 1; min-width: 200px; }
.input-sm { padding: 5px 8px; font-size: 12px; }
.el-list { display: flex; flex-direction: column; }
.el-item {
display: flex; align-items: flex-start; gap: 10px;
padding: 12px 16px; border-bottom: 1px solid var(--border);
transition: background .1s;
}
.el-item:last-child { border-bottom: none; }
.el-item:hover { background: rgba(255,255,255,.02); }
.el-item.done .el-body { color: var(--muted); text-decoration: line-through; }
.el-badge {
font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 4px;
white-space: nowrap; flex-shrink: 0; margin-top: 2px; letter-spacing: .3px;
}
.badge-task { background: rgba(88,166,255,.15); color: var(--task-c); border: 1px solid rgba(88,166,255,.3); }
.badge-note { background: rgba(63,185,80,.15); color: var(--note-c); border: 1px solid rgba(63,185,80,.3); }
.badge-log { background: rgba(210,153,34,.15); color: var(--log-c); border: 1px solid rgba(210,153,34,.3); }
.badge-reminder { background: rgba(163,113,247,.15); color: var(--reminder-c); border: 1px solid rgba(163,113,247,.3); }
.badge-character { background: rgba(247,120,186,.15); color: var(--character-c); border: 1px solid rgba(247,120,186,.3); }
.badge-unknown { background: rgba(139,148,158,.15); color: var(--muted); border: 1px solid rgba(139,148,158,.3); }
.el-content { flex: 1; min-width: 0; }
.el-body { font-size: 13px; word-break: break-word; }
.el-meta { display: flex; align-items: center; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
.el-ref { font-size: 11px; color: var(--muted); font-family: monospace; }
.el-date { font-size: 11px; color: var(--muted); }
.el-attr { font-size: 11px; }
.el-attr.status-open { color: var(--yellow); }
.el-attr.status-done { color: var(--green); }
.el-attr.at { color: var(--purple); }
.el-attr.duration { color: var(--orange); }
.el-attr.name { color: var(--pink); }
.tag-chip {
font-size: 10px; padding: 1px 7px; border-radius: 10px;
background: var(--surface); border: 1px solid var(--border); color: var(--muted);
}
.el-actions { display: flex; gap: 2px; flex-shrink: 0; }
.empty-state { padding: 40px 20px; text-align: center; color: var(--muted); }
.section-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 14px;
}
.section-title { font-size: 15px; font-weight: 600; }
.section-sub { font-size: 12px; color: var(--muted); }
.search-row { display: flex; gap: 8px; margin-bottom: 14px; align-items: center; }
.search-input { flex: 1; }
.filter-row { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; align-items: center; }
.filter-label { font-size: 12px; color: var(--muted); }
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 200;
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.modal {
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
width: 100%; max-width: 500px; max-height: 90vh; overflow-y: auto;
}
.modal-header {
padding: 16px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
}
.modal-title { font-weight: 600; }
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 14px; }
.modal-footer {
padding: 14px 20px; border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: 8px;
}
.toast {
position: fixed; bottom: 24px; right: 24px; z-index: 300;
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
padding: 12px 18px; font-size: 13px; max-width: 320px;
box-shadow: 0 4px 20px rgba(0,0,0,.5);
display: flex; align-items: center; gap: 10px;
animation: slideIn .2s ease;
}
.toast.success { border-left: 3px solid var(--green); }
.toast.error { border-left: 3px solid var(--red); }
.toast.info { border-left: 3px solid var(--accent); }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
.tag-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.tag-name { width: 120px; font-size: 12px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; }
.tag-track { flex: 1; height: 6px; background: var(--border); border-radius: 3px; }
.tag-fill { height: 6px; background: var(--accent); border-radius: 3px; transition: width .3s; }
.tag-count { font-size: 12px; color: var(--muted); width: 30px; text-align: right; }
.append-toggle { margin-bottom: 16px; }
.append-form {
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
padding: 16px; margin-bottom: 16px; display: flex; flex-direction: column; gap: 12px;
}
.type-selector { display: flex; gap: 6px; flex-wrap: wrap; }
.type-btn {
padding: 5px 12px; border-radius: 20px; border: 1px solid var(--border);
background: transparent; cursor: pointer; font-size: 12px; font-weight: 600;
transition: all .15s; color: var(--muted);
}
.type-btn:hover { border-color: var(--muted); color: var(--text); }
.type-btn.active-task { background: rgba(88,166,255,.2); color: var(--task-c); border-color: var(--task-c); }
.type-btn.active-note { background: rgba(63,185,80,.2); color: var(--note-c); border-color: var(--note-c); }
.type-btn.active-log { background: rgba(210,153,34,.2); color: var(--log-c); border-color: var(--log-c); }
.type-btn.active-reminder { background: rgba(163,113,247,.2); color: var(--reminder-c); border-color: var(--reminder-c); }
.type-btn.active-character { background: rgba(247,120,186,.2); color: var(--character-c); border-color: var(--character-c); }
.spinner {
width: 16px; height: 16px; border: 2px solid var(--border);
border-top-color: var(--accent); border-radius: 50%;
animation: spin .6s linear infinite; display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-row { padding: 30px; display: flex; justify-content: center; }
.date-nav { display: flex; align-items: center; gap: 8px; }
.date-display { font-size: 13px; font-weight: 600; min-width: 110px; text-align: center; }
.inner-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); }
.inner-tab {
padding: 10px 16px; border: none; background: transparent; cursor: pointer;
font-size: 13px; color: var(--muted); border-bottom: 2px solid transparent;
transition: all .15s; font-family: inherit;
}
.inner-tab.active { color: var(--text); border-bottom-color: var(--accent); }
.divider { height: 1px; background: var(--border); margin: 8px 0; }
.flex { display: flex; }
.gap-2 { gap: 8px; }
.gap-1 { gap: 4px; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.ml-auto { margin-left: auto; }
.mt-2 { margin-top: 8px; }
.mb-2 { margin-bottom: 8px; }
.text-muted { color: var(--muted); }
.text-sm { font-size: 12px; }
.font-mono { font-family: monospace; }
</style>
</head>
<body>
<div class="layout" id="app">
<header class="topbar">
<div class="logo">🗒 <span>mps</span></div>
<nav class="nav">
<button class="nav-btn" id="nav-today" onclick="App.setTab('today')">Today</button>
<button class="nav-btn" id="nav-search" onclick="App.setTab('search')">Search</button>
<button class="nav-btn" id="nav-archive" onclick="App.setTab('archive')">Archive</button>
<button class="nav-btn" id="nav-tags" onclick="App.setTab('tags')">Tags</button>
<button class="nav-btn" id="nav-stats" onclick="App.setTab('stats')">Stats</button>
</nav>
<div class="topbar-right">
<button class="theme-btn" id="theme-btn" title="Toggle light/dark theme" onclick="App.toggleTheme()">🌙</button>
<span class="version-badge" id="version-badge">v…</span>
</div>
</header>
<main class="main">
<div id="tab-today" style="display:none">
<div class="section-header">
<div>
<div class="section-title">Today</div>
<div class="section-sub" id="today-date-label"></div>
</div>
<button class="btn btn-primary btn-sm" onclick="App.toggleAppendForm()">
<span id="append-btn-label">+ New</span>
</button>
</div>
<div class="stats-row" id="today-stats"></div>
<div class="append-form" id="append-form" style="display:none">
<div class="flex items-center justify-between">
<div class="type-selector" id="type-selector"></div>
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label>Body</label>
<textarea id="af-body" class="input-body" placeholder="What's on your mind?" rows="2"></textarea>
</div>
</div>
<div class="form-row" id="af-extra-fields"></div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label>Tags <span class="text-muted">(comma-separated)</span></label>
<input id="af-tags" type="text" placeholder="work, backend, …">
</div>
<div class="form-group" style="align-self:flex-end">
<button class="btn btn-primary" onclick="App.appendElement()">Append</button>
</div>
</div>
</div>
<div class="filter-row">
<span class="filter-label">Filter:</span>
<select id="filter-type" class="input-sm" onchange="App.applyFilters()" style="border-radius:20px;padding:4px 8px">
<option value="">All types</option>
<option value="task">task</option>
<option value="note">note</option>
<option value="log">log</option>
<option value="reminder">reminder</option>
<option value="character">character</option>
</select>
<input id="filter-tag" class="input-sm" type="text" placeholder="tag…" style="width:100px;border-radius:20px" oninput="App.applyFilters()">
<label class="flex items-center gap-1 text-sm text-muted" style="cursor:pointer">
<input type="checkbox" id="filter-all" onchange="App.loadToday()"> All dates
</label>
</div>
<div class="card" id="today-list">
<div class="loading-row"><span class="spinner"></span></div>
</div>
</div>
<div id="tab-search" style="display:none">
<div class="section-header">
<div class="section-title">Search</div>
</div>
<div class="search-row">
<input id="search-q" type="text" class="search-input" placeholder="Search across all entries…"
onkeydown="if(event.key==='Enter') App.doSearch()">
<select id="search-type" class="input-sm" style="border-radius:6px">
<option value="">All types</option>
<option value="task">task</option>
<option value="note">note</option>
<option value="log">log</option>
<option value="reminder">reminder</option>
<option value="character">character</option>
</select>
<input id="search-tag" type="text" class="input-sm" placeholder="tag" style="width:80px">
<input id="search-since" type="date" class="input-sm">
<button class="btn btn-primary btn-sm" onclick="App.doSearch()">Search</button>
</div>
<div id="search-results">
<div class="empty-state text-muted">Enter a query and press Search.</div>
</div>
</div>
<div id="tab-archive" style="display:none">
<div class="section-header">
<div class="section-title">Archive</div>
<div class="date-nav">
<button class="btn btn-secondary btn-sm" onclick="App.archiveStep(-1)">‹</button>
<span class="date-display" id="archive-date-display"></span>
<button class="btn btn-secondary btn-sm" onclick="App.archiveStep(1)">›</button>
<input type="date" id="archive-date-input" class="input-sm" onchange="App.loadArchive()">
</div>
</div>
<div class="card" id="archive-list">
<div class="loading-row"><span class="spinner"></span></div>
</div>
</div>
<div id="tab-tags" style="display:none">
<div class="section-header">
<div class="section-title">Tags</div>
<div class="flex gap-2 items-center">
<select id="tags-type" class="input-sm" onchange="App.loadTags()">
<option value="">All types</option>
<option value="task">task</option>
<option value="note">note</option>
<option value="log">log</option>
<option value="reminder">reminder</option>
<option value="character">character</option>
</select>
<label class="flex items-center gap-1 text-sm text-muted" style="cursor:pointer">
<input type="checkbox" id="tags-all" onchange="App.loadTags()"> All dates
</label>
</div>
</div>
<div class="card" id="tags-chart">
<div class="loading-row"><span class="spinner"></span></div>
</div>
</div>
<div id="tab-stats" style="display:none">
<div class="section-header">
<div class="section-title">Stats</div>
<div class="flex gap-2 items-center">
<input type="date" id="stats-since" class="input-sm">
<label class="flex items-center gap-1 text-sm text-muted" style="cursor:pointer">
<input type="checkbox" id="stats-all" onchange="App.loadStats()"> All time
</label>
<button class="btn btn-secondary btn-sm" onclick="App.loadStats()">Refresh</button>
</div>
</div>
<div id="stats-content">
<div class="loading-row"><span class="spinner"></span></div>
</div>
</div>
</main>
</div>
<div class="modal-overlay" id="edit-modal" style="display:none" onclick="if(event.target===this)App.closeEditModal()">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="edit-modal-title">Edit element</div>
<button class="btn-icon" onclick="App.closeEditModal()">✕</button>
</div>
<div class="modal-body" id="edit-modal-body"></div>
<div class="modal-footer">
<button class="btn btn-secondary btn-sm" onclick="App.closeEditModal()">Cancel</button>
<button class="btn btn-primary btn-sm" onclick="App.saveEdit()">Save</button>
</div>
</div>
</div>
<div class="toast" id="toast" style="display:none">
<span id="toast-icon"></span>
<span id="toast-msg"></span>
</div>
<script>
'use strict';
const App = (() => {
let state = {
tab: 'today',
todayElements: [],
filteredElements: [],
archiveDate: todayStr(),
archiveElements: [],
searchResults: [],
stats: null,
tags: {},
appendType: 'task',
appendFormOpen: false,
editTarget: null,
toastTimer: null,
};
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function fmtDate(s) {
return s ? s.replace(/(\d{4})-(\d{2})-(\d{2})/, '$3 $2 $4').replace(/(\d{4})-(\d{2})-(\d{2})/, '$1-$2-$3') : s;
}
function el(id) { return document.getElementById(id); }
function qs(sel) { return document.querySelector(sel); }
async function api(method, path, body) {
const opts = { method, headers: {} };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const r = await fetch(path, opts);
const json = await r.json().catch(() => null);
if (!r.ok) throw new Error(json?.error || `HTTP ${r.status}`);
return json;
}
function toast(msg, type = 'info') {
const t = el('toast');
const icons = { success: '✓', error: '✕', info: 'ℹ' };
el('toast-icon').textContent = icons[type] || '';
el('toast-msg').textContent = msg;
t.className = `toast ${type}`;
t.style.display = 'flex';
clearTimeout(state.toastTimer);
state.toastTimer = setTimeout(() => { t.style.display = 'none'; }, 3500);
}
function typeColor(t) {
return { task: 'task-c', note: 'note-c', log: 'log-c', reminder: 'reminder-c', character: 'character-c' }[t] || 'muted';
}
function badgeClass(t) {
return `el-badge badge-${t}` || 'el-badge badge-unknown';
}
function tagsHtml(tags) {
if (!tags || !tags.length) return '';
return tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join(' ');
}
function esc(s) {
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
function attrHtml(el) {
const bits = [];
if (el.status) {
const cls = el.status === 'done' ? 'status-done' : 'status-open';
bits.push(`<span class="el-attr ${cls}">${esc(el.status)}</span>`);
}
if (el.at) bits.push(`<span class="el-attr at">@ ${esc(el.at)}</span>`);
if (el.start || el.end) {
const dur = el.start && el.end ? ` (${calcDur(el.start, el.end)})` : '';
bits.push(`<span class="el-attr duration">${esc(el.start||'')}–${esc(el.end||'')}${dur}</span>`);
}
if (el.name) bits.push(`<span class="el-attr name">↪ ${esc(el.name)}</span>`);
return bits.join(' ');
}
function calcDur(start, end) {
try {
const [sh, sm] = start.split(':').map(Number);
const [eh, em] = end.split(':').map(Number);
const mins = (eh * 60 + em) - (sh * 60 + sm);
if (mins <= 0) return '';
return mins >= 60 ? `${Math.floor(mins/60)}h${mins%60?` ${mins%60}m`:''}` : `${mins}m`;
} catch { return ''; }
}
function elementHtml(el_data, showDate) {
const type = el_data.type || 'unknown';
const ref = el_data.human_ref || el_data.ref;
const isDone = el_data.status === 'done';
const extra = attrHtml(el_data);
const dateSpan = showDate ? `<span class="el-date">${esc(el_data.date)}</span>` : '';
const canDone = type === 'task' && !isDone;
return `
<div class="el-item${isDone ? ' done' : ''}" data-ref="${esc(el_data.ref)}" data-date="${esc(el_data.date)}">
<span class="${badgeClass(type)}">${type.toUpperCase()}</span>
<div class="el-content">
<div class="el-body">${esc(el_data.body)}</div>
<div class="el-meta">
${extra}
${tagsHtml(el_data.tags)}
<span class="el-ref">${esc(ref)}</span>
${dateSpan}
</div>
</div>
<div class="el-actions">
${canDone ? `<button class="btn-icon success" title="Mark done" onclick="App.markDone('${esc(el_data.ref)}','${esc(el_data.date)}')">✓</button>` : ''}
<button class="btn-icon" title="Edit" onclick="App.openEdit('${esc(el_data.ref)}','${esc(el_data.date)}','${esc(type)}')">✎</button>
<button class="btn-icon danger" title="Delete" onclick="App.confirmDelete('${esc(el_data.ref)}','${esc(el_data.date)}')">🗑</button>
</div>
</div>`;
}
function renderList(container, elements, showDate) {
if (!elements.length) {
container.innerHTML = '<div class="empty-state text-muted">No elements found.</div>';
return;
}
container.innerHTML = `<div class="el-list">${elements.map(e => elementHtml(e, showDate)).join('')}</div>`;
}
function setTab(name) {
state.tab = name;
['today','search','archive','tags','stats'].forEach(t => {
el(`tab-${t}`).style.display = t === name ? 'block' : 'none';
el(`nav-${t}`).classList.toggle('active', t === name);
});
if (name === 'today') loadToday();
if (name === 'archive') loadArchive();
if (name === 'tags') loadTags();
if (name === 'stats') loadStats();
}
async function loadToday() {
el('today-date-label').textContent = todayStr();
const allDates = el('filter-all').checked;
const params = new URLSearchParams();
if (allDates) params.set('all', 'true');
try {
const [elements, stats] = await Promise.all([
api('GET', `/elements?${params}`),
api('GET', `/stats?${allDates ? 'all=true' : ''}`)
]);
state.todayElements = elements;
renderStats(stats);
applyFilters();
} catch(e) { toast(e.message, 'error'); }
}
function renderStats(s) {
if (!s) return;
el('today-stats').innerHTML = `
<div class="stat-pill tasks"><span class="num">${s.tasks.total}</span> tasks
<span class="text-muted text-sm">(${s.tasks.open} open, ${s.tasks.done} done)</span>
</div>
<div class="stat-pill notes"><span class="num">${s.notes}</span> notes</div>
<div class="stat-pill logs"><span class="num">${s.logs.total}</span> logs
${s.logs.duration_minutes ? `<span class="text-muted text-sm">${fmtMins(s.logs.duration_minutes)}</span>` : ''}
</div>
<div class="stat-pill reminders"><span class="num">${s.reminders}</span> reminders</div>
${s.characters ? `<div class="stat-pill characters"><span class="num">${s.characters}</span> characters</div>` : ''}
`;
}
function fmtMins(m) {
return m >= 60 ? `${Math.floor(m/60)}h${m%60?` ${m%60}m`:''}` : `${m}m`;
}
function applyFilters() {
const type = el('filter-type').value;
const tag = el('filter-tag').value.trim().toLowerCase();
let filtered = state.todayElements;
if (type) filtered = filtered.filter(e => e.type === type);
if (tag) filtered = filtered.filter(e => e.tags && e.tags.some(t => t.toLowerCase().includes(tag)));
state.filteredElements = filtered;
renderList(el('today-list'), filtered, el('filter-all').checked);
}
const TYPES = ['task', 'note', 'log', 'reminder', 'character'];
function renderTypeSelector() {
el('type-selector').innerHTML = TYPES.map(t =>
`<button class="type-btn${state.appendType === t ? ` active-${t}` : ''}"
onclick="App.setAppendType('${t}')">${t}</button>`
).join('');
}
function setAppendType(t) {
state.appendType = t;
renderTypeSelector();
renderExtraFields();
}
function renderExtraFields() {
const t = state.appendType;
let html = '';
if (t === 'task') {
html = `
<div class="form-group">
<label>Status</label>
<select id="af-status"><option value="">open</option><option value="done">done</option></select>
</div>`;
} else if (t === 'log') {
html = `
<div class="form-group"><label>Start</label><input id="af-start" type="time" placeholder="09:00"></div>
<div class="form-group"><label>End</label><input id="af-end" type="time" placeholder="10:30"></div>`;
} else if (t === 'reminder') {
html = `
<div class="form-group"><label>At</label><input id="af-at" type="text" placeholder="3pm, 14:30…"></div>`;
} else if (t === 'character') {
html = `
<div class="form-group" style="flex:1"><label>Person name</label><input id="af-name" type="text" placeholder="Dr. Alice"></div>`;
}
el('af-extra-fields').innerHTML = html;
}
function toggleAppendForm() {
state.appendFormOpen = !state.appendFormOpen;
el('append-form').style.display = state.appendFormOpen ? 'flex' : 'none';
el('append-btn-label').textContent = state.appendFormOpen ? '✕ Close' : '+ New';
if (state.appendFormOpen) {
renderTypeSelector();
renderExtraFields();
setTimeout(() => el('af-body').focus(), 50);
}
}
async function appendElement() {
const body = el('af-body').value.trim();
if (!body) { toast('Body is required', 'error'); return; }
const type = state.appendType;
const tags = el('af-tags').value.split(',').map(t => t.trim()).filter(Boolean);
const payload = { type, body, tags };
const status = el('af-status')?.value;
const at = el('af-at')?.value;
const start = el('af-start')?.value;
const end = el('af-end')?.value;
const name = el('af-name')?.value;
if (status) payload.status = status;
if (at) payload.at = at;
if (start) payload.start = start;
if (end) payload.end = end;
if (name) payload.name = name;
try {
const r = await api('POST', '/elements', payload);
toast(`Appended ${type} (${r.human_ref || r.ref})`, 'success');
el('af-body').value = '';
el('af-tags').value = '';
if (el('af-start')) el('af-start').value = '';
if (el('af-end')) el('af-end').value = '';
if (el('af-at')) el('af-at').value = '';
if (el('af-name')) el('af-name').value = '';
if (el('af-status')) el('af-status').value = '';
loadToday();
} catch(e) { toast(e.message, 'error'); }
}
async function markDone(ref, date) {
try {
await api('PATCH', `/elements/${encodeURIComponent(ref)}`, { status: 'done', date });
toast('Marked done', 'success');
if (state.tab === 'today') loadToday();
if (state.tab === 'archive') loadArchive();
if (state.tab === 'search') doSearch();
} catch(e) { toast(e.message, 'error'); }
}
async function confirmDelete(ref, date) {
if (!confirm(`Delete element "${ref}"?`)) return;
try {
await api('DELETE', `/elements/${encodeURIComponent(ref)}?date=${date}`);
toast('Deleted', 'success');
if (state.tab === 'today') loadToday();
if (state.tab === 'archive') loadArchive();
if (state.tab === 'search') doSearch();
} catch(e) { toast(e.message, 'error'); }
}
async function openEdit(ref, date, type) {
state.editTarget = { ref, date, type };
el('edit-modal-title').textContent = `Edit ${type} — ${ref}`;
el('edit-modal').style.display = 'flex';
try {
const params = new URLSearchParams({ date });
const data = await api('GET', `/elements/${encodeURIComponent(ref)}?${params}`);
renderEditForm(data, type);
} catch(e) {
el('edit-modal-body').innerHTML = `<p class="text-muted">Could not load element: ${esc(e.message)}</p>`;
}
}
function renderEditForm(data, type) {
let html = `
<div class="form-group">
<label>Body</label>
<textarea id="em-body" rows="4">${esc(data.body || '')}</textarea>
</div>
<div class="form-row">`;
if (type === 'task') {
html += `<div class="form-group"><label>Status</label>
<select id="em-status">
<option value="open"${data.status !== 'done' ? ' selected':''}>open</option>
<option value="done"${data.status === 'done' ? ' selected':''}>done</option>
</select></div>`;
}
if (type === 'reminder') {
html += `<div class="form-group"><label>At</label><input id="em-at" type="text" value="${esc(data.at||'')}"></div>`;
}
if (type === 'log') {
html += `<div class="form-group"><label>Start</label><input id="em-start" type="time" value="${esc(data.start||'')}"></div>
<div class="form-group"><label>End</label><input id="em-end" type="time" value="${esc(data.end||'')}"></div>`;
}
if (type === 'character') {
html += `<div class="form-group" style="flex:1"><label>Name</label><input id="em-name" type="text" value="${esc(data.name||'')}"></div>`;
}
html += '</div>';
el('edit-modal-body').innerHTML = html;
}
async function saveEdit() {
const { ref, date, type } = state.editTarget;
const payload = { date };
const body = el('em-body')?.value;
const status = el('em-status')?.value;
const at = el('em-at')?.value;
const start = el('em-start')?.value;
const end = el('em-end')?.value;
const name = el('em-name')?.value;
if (body !== undefined) payload.body = body;
if (status !== undefined) payload.status = status;
if (at !== undefined) payload.at = at;
if (start !== undefined) payload.start = start;
if (end !== undefined) payload.end = end;
if (name !== undefined) payload.name = name;
try {
await api('PATCH', `/elements/${encodeURIComponent(ref)}`, payload);
toast('Saved', 'success');
closeEditModal();
if (state.tab === 'today') loadToday();
if (state.tab === 'archive') loadArchive();
if (state.tab === 'search') doSearch();
} catch(e) { toast(e.message, 'error'); }
}
function closeEditModal() {
el('edit-modal').style.display = 'none';
state.editTarget = null;
}
async function doSearch() {
const q = el('search-q').value.trim();
const type = el('search-type').value;
const tag = el('search-tag').value.trim();
const since = el('search-since').value;
const params = new URLSearchParams();
if (q) params.set('q', q);
if (type) params.set('type', type);
if (tag) params.set('tag', tag);
if (since) params.set('since', since);
const container = el('search-results');
container.innerHTML = '<div class="loading-row"><span class="spinner"></span></div>';
try {
const results = await api('GET', `/search?${params}`);
state.searchResults = results;
if (!results.length) {
container.innerHTML = '<div class="empty-state text-muted">No results found.</div>';
} else {
container.innerHTML = `
<div class="text-sm text-muted mb-2">${results.length} result${results.length===1?'':'s'}</div>
<div class="card"><div class="el-list">${results.map(e => elementHtml(e, true)).join('')}</div></div>`;
}
} catch(e) {
container.innerHTML = `<div class="empty-state text-muted">${esc(e.message)}</div>`;
toast(e.message, 'error');
}
}
function archiveStep(delta) {
const d = new Date(state.archiveDate + 'T12:00:00');
d.setDate(d.getDate() + delta);
state.archiveDate = d.toISOString().slice(0, 10);
el('archive-date-input').value = state.archiveDate;
loadArchive();
}
async function loadArchive() {
const dateVal = el('archive-date-input').value || state.archiveDate;
state.archiveDate = dateVal;
el('archive-date-display').textContent = dateVal;
const container = el('archive-list');
container.innerHTML = '<div class="loading-row"><span class="spinner"></span></div>';
try {
const elements = await api('GET', `/elements?date=${dateVal}`);
state.archiveElements = elements;
renderList(container, elements, false);
} catch(e) {
container.innerHTML = `<div class="empty-state text-muted">${esc(e.message)}</div>`;
}
}
async function loadTags() {
const all = el('tags-all').checked;
const type = el('tags-type').value;
const params = new URLSearchParams();
if (all) params.set('all', 'true');
if (type) params.set('type', type);
const container = el('tags-chart');
container.innerHTML = '<div class="loading-row"><span class="spinner"></span></div>';
try {
const tags = await api('GET', `/tags?${params}`);
state.tags = tags;
renderTagsChart(container, tags);
} catch(e) {
container.innerHTML = `<div class="empty-state text-muted">${esc(e.message)}</div>`;
}
}
function renderTagsChart(container, tags) {
const entries = Object.entries(tags).sort((a, b) => b[1] - a[1]);
if (!entries.length) {
container.innerHTML = '<div class="empty-state text-muted">No tags found.</div>';
return;
}
const max = entries[0][1];
container.innerHTML = `<div style="padding:18px">${entries.map(([name, count]) => `
<div class="tag-bar">
<span class="tag-name" title="${esc(name)}">${esc(name)}</span>
<div class="tag-track"><div class="tag-fill" style="width:${Math.round(count/max*100)}%"></div></div>
<span class="tag-count">${count}</span>
</div>`).join('')}</div>`;
}
async function loadStats() {
const all = el('stats-all').checked;
const since = el('stats-since').value;
const params = new URLSearchParams();
if (all) params.set('all', 'true');
if (since) params.set('since', since);
const container = el('stats-content');
container.innerHTML = '<div class="loading-row"><span class="spinner"></span></div>';
try {
const s = await api('GET', `/stats?${params}`);
state.stats = s;
renderStatsPage(container, s);
} catch(e) {
container.innerHTML = `<div class="empty-state text-muted">${esc(e.message)}</div>`;
}
}
function renderStatsPage(container, s) {
const donePct = s.tasks.total ? Math.round(s.tasks.done / s.tasks.total * 100) : 0;
container.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:20px">
${statCard('Tasks', s.tasks.total, `${s.tasks.open} open · ${s.tasks.done} done`, 'task-c')}
${statCard('Notes', s.notes, '', 'note-c')}
${statCard('Logs', s.logs.total, s.logs.duration_minutes ? fmtMins(s.logs.duration_minutes) + ' total' : '', 'log-c')}
${statCard('Reminders', s.reminders, '', 'reminder-c')}
${statCard('Characters', s.characters, '', 'character-c')}
</div>
${s.tasks.total ? `
<div class="card" style="margin-bottom:12px">
<div class="card-header"><div class="card-title">Task completion</div></div>
<div class="card-body">
<div style="display:flex;align-items:center;gap:12px">
<div style="flex:1;height:10px;background:var(--border);border-radius:5px">
<div style="width:${donePct}%;height:10px;background:var(--green);border-radius:5px;transition:width .3s"></div>
</div>
<span style="font-size:13px;font-weight:700;color:var(--green)">${donePct}%</span>
</div>
</div>
</div>` : ''}
${s.dates.length > 1 ? `
<div class="card">
<div class="card-header"><div class="card-title">Dates covered</div></div>
<div class="card-body">
<div style="display:flex;flex-wrap:wrap;gap:6px">
${s.dates.map(d => `<span class="tag-chip font-mono">${esc(d)}</span>`).join('')}
</div>
</div>
</div>` : ''}
`;
}
function statCard(label, num, sub, colorVar) {
return `<div class="card">
<div class="card-body" style="text-align:center">
<div style="font-size:36px;font-weight:700;color:var(--${colorVar})">${num}</div>
<div style="font-size:13px;font-weight:600;margin-top:4px">${label}</div>
${sub ? `<div style="font-size:11px;color:var(--muted);margin-top:2px">${esc(sub)}</div>` : ''}
</div>
</div>`;
}
function toggleTheme() {
const isLight = document.documentElement.classList.toggle('light');
el('theme-btn').textContent = isLight ? '☀️' : '🌙';
localStorage.setItem('theme', isLight ? 'light' : 'dark');
}
async function init() {
if (localStorage.getItem('theme') === 'light') {
document.documentElement.classList.add('light');
el('theme-btn').textContent = '☀️';
}
try {
const h = await api('GET', '/health');
el('version-badge').textContent = `v${h.version}`;
} catch(e) { }
const today = todayStr();
el('archive-date-input').value = today;
state.archiveDate = today;
el('archive-date-display').textContent = today;
setTab('today');
}
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
if (e.key === 'n') { setTab('today'); toggleAppendForm(); }
if (e.key === '/') { setTab('search'); setTimeout(() => el('search-q').focus(), 50); e.preventDefault(); }
if (e.key === 'Escape') { closeEditModal(); }
if (e.key === '1') setTab('today');
if (e.key === '2') setTab('search');
if (e.key === '3') setTab('archive');
if (e.key === '4') setTab('tags');
if (e.key === '5') setTab('stats');
});
return {
setTab, toggleAppendForm, setAppendType,
appendElement, loadToday, applyFilters,
markDone, confirmDelete,
openEdit, saveEdit, closeEditModal,
doSearch,
archiveStep, loadArchive,
loadTags, loadStats,
toggleTheme,
};
})();
document.addEventListener('DOMContentLoaded', () => App.setTab('today'));
</script>
</body>
</html>