@unpolarize/knowledge-planning 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,862 @@
1
+ import {
2
+ PlanningIndex,
3
+ Store,
4
+ buildLookback,
5
+ buildTopics,
6
+ cmdCapture,
7
+ cmdCreate,
8
+ cmdEditObject,
9
+ cmdExport,
10
+ cmdRecategorize,
11
+ cmdSetDue,
12
+ cmdSetPriority,
13
+ cmdSetProject,
14
+ cmdSetStatus,
15
+ readAllSessions,
16
+ sessionsByDay,
17
+ today
18
+ } from "./chunk-6GL2IYUE.js";
19
+
20
+ // src/serve/server.ts
21
+ import { createServer } from "http";
22
+ import { execFile } from "child_process";
23
+ import { readdirSync, statSync, existsSync } from "fs";
24
+ import { join, resolve, basename } from "path";
25
+
26
+ // src/serve/page.ts
27
+ var DASHBOARD_HTML = `<!DOCTYPE html>
28
+ <html><head><meta charset="utf-8"><title>Planning \u2014 kp</title>
29
+ <meta name="viewport" content="width=device-width, initial-scale=1">
30
+ <style>
31
+ :root{--bg:#101418;--panel:#171d24;--card:#1e2630;--line:#2a3440;--fg:#dbe4ee;--dim:#8494a6;
32
+ --blue:#569cd6;--green:#4ec9b0;--yellow:#dcdcaa;--red:#d16969;--purple:#c586c0;--tan:#d7ba7d;}
33
+ *{box-sizing:border-box}
34
+ body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.45 -apple-system,'Segoe UI',sans-serif}
35
+ #top{display:flex;gap:10px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5;flex-wrap:wrap}
36
+ #top .brand{font-weight:800;letter-spacing:.3px}
37
+ .seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
38
+ .seg button{background:none;border:0;color:var(--dim);padding:5px 12px;cursor:pointer;font:inherit}
39
+ .seg button.on{background:var(--panel);color:var(--fg)}
40
+ #counts{color:var(--dim);font-size:11px}
41
+ #syncPill{margin-left:auto;font-size:11px;padding:2px 9px;border-radius:11px;border:1px solid var(--line);cursor:pointer;white-space:nowrap;color:var(--dim)}
42
+ #syncPill:hover{background:var(--panel);color:var(--fg)}
43
+ #syncPill.ok{border-color:var(--green);color:var(--green)}
44
+ #syncPill.syncing{border-color:var(--blue);color:var(--blue)}
45
+ #syncPill.warn{border-color:var(--red);color:var(--red)}
46
+ #syncPill.active{box-shadow:0 0 0 1px var(--blue) inset}
47
+ #capture{display:flex;gap:6px}
48
+ #capture input{background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--fg);padding:5px 10px;width:220px;font:inherit}
49
+ #capture button,button.act{background:var(--blue);border:0;border-radius:8px;color:#08131d;font-weight:700;padding:5px 12px;cursor:pointer;font:inherit}
50
+ button.ghost{background:var(--panel);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:4px 10px;cursor:pointer;font:inherit}
51
+ #board{display:flex;flex-direction:column;gap:10px;padding:12px}
52
+ .boardfilter{display:flex;gap:8px;align-items:center;font-size:12px}
53
+ .boardfilter select,.boardfilter input{background:var(--panel);border:1px solid var(--line);border-radius:6px;color:var(--fg);padding:3px 6px;font:inherit}
54
+ .boardfilter .chip{cursor:pointer;border:1px solid var(--line)}
55
+ .lanes{display:flex;gap:10px;align-items:flex-start;overflow-x:auto}
56
+ .col{background:var(--panel);border:1px solid var(--line);border-radius:10px;min-width:230px;max-width:280px;flex:1}
57
+ .col h3{margin:0;padding:8px 10px;font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--dim);display:flex;gap:6px;align-items:center;border-bottom:1px solid var(--line)}
58
+ .col h3 .dot{width:8px;height:8px;border-radius:50%}
59
+ .col h3 .n{margin-left:auto;color:var(--dim)}
60
+ .cards{padding:8px;display:flex;flex-direction:column;gap:8px;min-height:30px;max-height:72vh;overflow-y:auto}
61
+ .card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px 9px;cursor:pointer}
62
+ .card:hover{border-color:var(--blue)}
63
+ .card.dragging{opacity:.45}
64
+ .card.nomatch,.titem.nomatch{display:none}
65
+ .col.over{border-color:var(--blue);box-shadow:0 0 0 1px var(--blue) inset}
66
+ .card .t{font-weight:600}
67
+ .card .m{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px;font-size:10.5px;color:var(--dim);align-items:center}
68
+ .chip{border-radius:4px;padding:1px 6px;background:#2a3440}
69
+ .chip.p0{background:var(--red);color:#fff}.chip.p1{background:var(--tan);color:#222}.chip.p2{background:var(--blue);color:#fff}.chip.p3{background:#2a3440}
70
+ .chip.due{background:#233042}.chip.due.late{background:var(--red);color:#fff;font-weight:700}
71
+ .chip.sess{background:#20303f;color:#9fd0ff}
72
+ #calendar,#topics,#day{padding:14px;max-width:1200px;margin:0 auto}
73
+ #calbar{display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
74
+ #calbar .title{font-weight:700;font-size:15px;min-width:170px}
75
+ /* month grid */
76
+ .mgrid{display:grid;grid-template-columns:repeat(7,1fr);gap:6px}
77
+ .mgrid .dow{color:var(--dim);font-size:10.5px;text-transform:uppercase;text-align:center;padding:2px}
78
+ .mcell{background:var(--panel);border:1px solid var(--line);border-radius:8px;min-height:96px;padding:6px;cursor:pointer;overflow:hidden}
79
+ .mcell:hover{border-color:var(--blue)}
80
+ .mcell.dim{opacity:.35}
81
+ .mcell.today{border-color:var(--blue);box-shadow:0 0 0 1px var(--blue) inset}
82
+ .mcell .d{font-size:11px;color:var(--dim);display:flex;justify-content:space-between}
83
+ .mcell .item{font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:3px}
84
+ .mcell .item.late{color:var(--red)}
85
+ /* week columns */
86
+ .wgrid{display:grid;gap:8px}
87
+ .wcol{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px;min-height:200px;min-width:0}
88
+ .wcol h4{margin:0 0 6px;font-size:11px;color:var(--dim);display:flex;justify-content:space-between;cursor:pointer}
89
+ .wcol h4:hover{color:var(--fg)}
90
+ .wcol.today{border-color:var(--blue)}
91
+ .witem{font-size:11px;padding:3px 5px;border-radius:5px;margin-top:3px;background:var(--card);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
92
+ .witem.due{border-left:3px solid var(--tan)}
93
+ .witem.due.late{border-left-color:var(--red)}
94
+ .witem.sess{border-left:3px solid #4fc1ff;color:#b6d9f5}
95
+ /* day / lookback */
96
+ .section{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:12px}
97
+ .section h3{margin:0 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--dim)}
98
+ .sessrow{display:flex;gap:10px;align-items:baseline;padding:4px 2px;border-bottom:1px dotted var(--line)}
99
+ .sessrow:last-child{border-bottom:0}
100
+ .sessrow .time{color:var(--dim);font-size:11px;width:44px;flex:none}
101
+ .sessrow .proj{color:#9fd0ff;font-size:11px;width:170px;flex:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
102
+ .sessrow .ttl{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
103
+ .sessrow .dur{color:var(--dim);font-size:11px;flex:none}
104
+ pre.plan{white-space:pre-wrap;font:12px/1.5 ui-monospace,monospace;color:var(--fg);margin:0}
105
+ /* topics */
106
+ .tgroup{margin-bottom:18px}
107
+ .tgroup>h2{font-size:14px;text-transform:uppercase;letter-spacing:1px;color:var(--fg);border-bottom:1px solid var(--line);padding-bottom:6px}
108
+ .tdomains{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:10px}
109
+ .tdomain{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px}
110
+ .tdomain h3{margin:0 0 6px;font-size:12.5px;display:flex;gap:8px;align-items:center}
111
+ .tdomain h3 .n{color:var(--dim);font-weight:400;font-size:11px}
112
+ .titem{font-size:12px;padding:3px 0;display:flex;gap:6px;align-items:baseline;cursor:pointer}
113
+ .titem:hover .t{color:var(--blue)}
114
+ .titem .st{color:var(--dim);font-size:10.5px;flex:none}
115
+ .titem .t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
116
+ .tsess{margin-top:8px;border-top:1px dotted var(--line);padding-top:6px}
117
+ .tsess .lbl{color:var(--dim);font-size:10.5px;text-transform:uppercase}
118
+ #drawer{position:fixed;right:0;top:0;bottom:0;width:380px;max-width:92vw;background:var(--panel);border-left:1px solid var(--line);padding:16px;overflow-y:auto;transform:translateX(105%);transition:transform .15s;z-index:10}
119
+ #drawer.open{transform:none}
120
+ #drawer h2{margin:0 0 4px;font-size:15px}
121
+ #drawer .id{color:var(--dim);font-size:11px;margin-bottom:14px;word-break:break-all}
122
+ #drawer label{display:block;color:var(--dim);font-size:11px;margin:10px 0 3px;text-transform:uppercase;letter-spacing:.5px}
123
+ #drawer select,#drawer input{width:100%;background:var(--card);border:1px solid var(--line);border-radius:6px;color:var(--fg);padding:6px 8px;font:inherit}
124
+ #drawer .actions{display:flex;gap:8px;margin-top:16px}
125
+ #msg{position:fixed;bottom:14px;left:50%;transform:translateX(-50%);background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px 14px;display:none;z-index:20}
126
+ .empty{color:var(--dim);padding:20px;text-align:center}
127
+ </style></head><body>
128
+ <div id="top">
129
+ <span class="brand">\u25E7 Planning</span>
130
+ <span class="seg" id="views">
131
+ <button data-v="board" class="on">Board</button>
132
+ <button data-v="calendar">Calendar</button>
133
+ <button data-v="topics">Topics</button>
134
+ <button data-v="day">Day</button>
135
+ </span>
136
+ <span class="seg" id="lanes">
137
+ <button data-l="task" class="on">Tasks</button>
138
+ <button data-l="idea">Ideas</button>
139
+ <button data-l="plan">Plans</button>
140
+ <button data-l="thought">Thoughts</button>
141
+ </span>
142
+ <span class="seg" id="calmodes" style="display:none">
143
+ <button data-m="month" class="on">Month</button>
144
+ <button data-m="week">Week</button>
145
+ <button data-m="workweek">Work week</button>
146
+ </span>
147
+ <input id="search" placeholder="Search\u2026 (\u2318F)" style="display:none;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--fg);padding:5px 10px;width:220px;font:inherit">
148
+ <button id="newBtn" class="act" style="background:var(--blue)">\uFF0B New</button>
149
+ <span id="syncBtns"></span>
150
+ <span id="syncPill" title="Store sync \u2014 click to pull now">\u25CC sync</span>
151
+ <span id="counts"></span>
152
+ </div>
153
+ <div id="board"></div>
154
+ <div id="calendar" style="display:none"></div>
155
+ <div id="topics" style="display:none"></div>
156
+ <div id="day" style="display:none"></div>
157
+ <div id="drawer"></div>
158
+ <div id="msg"></div>
159
+ <script>
160
+ const LANES={task:['inbox','today','in_progress','done','deferred','outdated'],
161
+ idea:['capture','refine','accepted','plan','parked','done'],
162
+ plan:['plan','prototype','implement','validate','done','parked'],
163
+ thought:['new','kept','converted','archived']};
164
+ const LANE_COLOR={inbox:'#8494a6',today:'#569cd6',in_progress:'#dcdcaa',done:'#4ec9b0',deferred:'#c586c0',outdated:'#d16969',
165
+ capture:'#d7ba7d',refine:'#dcdcaa',accepted:'#4ec9b0',parked:'#8494a6',plan:'#569cd6',prototype:'#c586c0',implement:'#dcdcaa',validate:'#4fc1ff',
166
+ new:'#d7ba7d',kept:'#4ec9b0',converted:'#569cd6',archived:'#5a6672'};
167
+ const DEFAULT_STATUS={task:'inbox',idea:'capture',plan:'plan',thought:'new'};
168
+ let S=null,view='board',lane='task',calMode='month',anchor=null,dayDate=null;
169
+ const $=s=>document.querySelector(s);
170
+ const esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
171
+ const todayStr=()=>(S&&S.board&&S.board.date)||new Date().toISOString().slice(0,10);
172
+ const addDays=(d,n)=>{const x=new Date(d+'T00:00:00');x.setDate(x.getDate()+n);
173
+ return x.getFullYear()+'-'+String(x.getMonth()+1).padStart(2,'0')+'-'+String(x.getDate()).padStart(2,'0');};
174
+ function toast(t){const m=$('#msg');m.textContent=t;m.style.display='block';setTimeout(()=>m.style.display='none',1800);}
175
+ async function api(p,opts){const r=await fetch(p,opts);const j=await r.json();if(!r.ok)throw new Error(j.error||r.status);return j;}
176
+ async function load(){S=await api('/api/snapshot');render();}
177
+ async function act(op,id,value,extra){try{await api('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(Object.assign({op,id,value},extra||{}))});await load();toast(op+' \u2713');}catch(e){toast('\u2717 '+e.message);}}
178
+ const CLOSING=new Set(['done','deferred','outdated','parked','archived']);
179
+ // closing a task deserves a word on WHY \u2014 prompt for a resolution note.
180
+ // OK+text => saved as a '## Resolution' body section; OK+empty => no note; Cancel => abort the move.
181
+ function setStatusAsking(id,status){
182
+ if(!CLOSING.has(status))return act('set-status',id,status);
183
+ const note=window.prompt('Moving to "'+status+'" \u2014 add a resolution note? (empty = none, Cancel = abort)','');
184
+ if(note===null){load();return Promise.resolve();}
185
+ return act('set-status',id,status,note.trim()?{note:note.trim()}:undefined);
186
+ }
187
+ function chips(o){let h='';
188
+ if(o.priority)h+='<span class="chip '+esc(o.priority)+'">'+esc(o.priority)+'</span>';
189
+ if(o.due){const late=o.due<todayStr()&&o.status!=='done'&&o.status!=='outdated';
190
+ h+='<span class="chip due'+(late?' late':'')+'">\u23F0 '+esc(o.due)+'</span>';}
191
+ if(o.domain)h+='<span>'+esc(o.domain)+'</span>';
192
+ if(o.type==='thought'){
193
+ if(o.context)h+='<span class="chip" title="captured under">\u25D4 '+esc(o.context)+'</span>';
194
+ if(o.surfaced_on||o.created)h+='<span>'+esc(o.surfaced_on||o.created)+'</span>';}
195
+ return h;}
196
+ function domainList(){const s=new Set();(S&&S.objects||[]).forEach(o=>{if(o.type==='domain')s.add((o.title||o.id.split('/').pop()));else if(o.domain)s.add(o.domain);});return [...s].sort();}
197
+ function render(){
198
+ $('#counts').textContent=Object.entries(S.counts||{}).map(([k,v])=>k+':'+v).join(' ');
199
+ for(const v of ['board','calendar','topics','day'])$('#'+v).style.display=view===v?'block':'none';
200
+ if(view==='board')$('#board').style.display='flex';
201
+ document.querySelectorAll('#views button').forEach(b=>b.classList.toggle('on',b.dataset.v===view));
202
+ document.querySelectorAll('#lanes button').forEach(b=>b.classList.toggle('on',b.dataset.l===lane));
203
+ document.querySelectorAll('#calmodes button').forEach(b=>b.classList.toggle('on',b.dataset.m===calMode));
204
+ $('#lanes').style.display=view==='board'?'inline-flex':'none';
205
+ $('#calmodes').style.display=view==='calendar'?'inline-flex':'none';
206
+ if(view==='board')renderBoard();
207
+ else if(view==='calendar')renderCalendar();
208
+ else if(view==='topics')renderTopics();
209
+ else renderDay();
210
+ applySearch();
211
+ }
212
+ function renderBoard(){
213
+ const board=$('#board');board.innerHTML='';
214
+ // date filter bar (worked-on / due) \u2014 full-width row above the lanes
215
+ const fb=document.createElement('div');fb.className='boardfilter';
216
+ fb.innerHTML='<span style="opacity:.6">filter by</span>'+
217
+ '<select id="bfField"><option value="updated"'+(boardField==='updated'?' selected':'')+'>worked on (updated)</option>'+
218
+ '<option value="due"'+(boardField==='due'?' selected':'')+'>due</option></select>'+
219
+ '<input type="date" id="bfDate" value="'+esc(boardDate||'')+'">'+
220
+ '<button class="chip" id="bfToday">today</button>'+
221
+ (boardDate?'<button class="chip" id="bfClear">clear \u2715</button><span style="opacity:.6" id="bfN"></span>':'');
222
+ board.appendChild(fb);
223
+ fb.querySelector('#bfField').onchange=e=>{boardField=e.target.value;renderBoard();};
224
+ fb.querySelector('#bfDate').onchange=e=>{boardDate=e.target.value;renderBoard();};
225
+ fb.querySelector('#bfToday').onclick=()=>{boardDate=todayStr();renderBoard();};
226
+ if(fb.querySelector('#bfClear'))fb.querySelector('#bfClear').onclick=()=>{boardDate='';renderBoard();};
227
+ const lanesWrap=document.createElement('div');lanesWrap.className='lanes';board.appendChild(lanesWrap);
228
+ let objs=(S.objects||[]).filter(o=>o.type===lane);
229
+ if(boardDate)objs=objs.filter(o=>(o[boardField]||'').slice(0,10)===boardDate);
230
+ if(fb.querySelector('#bfN'))fb.querySelector('#bfN').textContent=objs.length+' '+lane+'(s) '+boardField+' '+boardDate;
231
+ LANES[lane].forEach(l=>{
232
+ const rows=objs.filter(o=>(o.status||'')===l);
233
+ const col=document.createElement('div');col.className='col';col.dataset.lane=l;
234
+ col.innerHTML='<h3><span class="dot" style="background:'+(LANE_COLOR[l]||'#888')+'"></span>'+l+'<span class="n">'+rows.length+'</span></h3>';
235
+ const cards=document.createElement('div');cards.className='cards';
236
+ rows.forEach(o=>{
237
+ const c=document.createElement('div');c.className='card';c.draggable=true;
238
+ c.innerHTML='<div class="t">'+esc(o.title||o.id)+'</div><div class="m">'+chips(o)+'</div>';
239
+ c.onclick=()=>openDrawer(o);
240
+ c.addEventListener('dragstart',ev=>{ev.dataTransfer.setData('text/plain',o.id);ev.dataTransfer.effectAllowed='move';c.classList.add('dragging');});
241
+ c.addEventListener('dragend',()=>c.classList.remove('dragging'));
242
+ cards.appendChild(c);
243
+ });
244
+ col.appendChild(cards);
245
+ col.addEventListener('dragover',ev=>{ev.preventDefault();ev.dataTransfer.dropEffect='move';col.classList.add('over');});
246
+ col.addEventListener('dragleave',()=>col.classList.remove('over'));
247
+ col.addEventListener('drop',ev=>{ev.preventDefault();col.classList.remove('over');
248
+ const id=ev.dataTransfer.getData('text/plain');if(id)setStatusAsking(id,l);});
249
+ lanesWrap.appendChild(col);
250
+ });
251
+ }
252
+ // \u2500\u2500 calendar (month / week / workweek) with sessions \u2500\u2500
253
+ async function fetchWindow(from,to){
254
+ const [cal,sess]=await Promise.all([
255
+ api('/api/calendar?from='+from+'&to='+to),
256
+ api('/api/sessions?from='+from+'&to='+to)]);
257
+ const dueByDay={};cal.rows.forEach(r=>{(dueByDay[r.due]??=[]).push(r);});
258
+ return {dueByDay,sessByDay:sess.days||{}};
259
+ }
260
+ function calTitle(){
261
+ if(calMode==='month')return new Date(anchor+'T00:00:00').toLocaleDateString(undefined,{month:'long',year:'numeric'});
262
+ return 'Week of '+weekStart(anchor);
263
+ }
264
+ function weekStart(d){const x=new Date(d+'T00:00:00');const dow=(x.getDay()+6)%7;return addDays(d,-dow);} // Monday
265
+ async function renderCalendar(){
266
+ const now=todayStr();
267
+ if(!anchor)anchor=now;
268
+ const cal=$('#calendar');cal.innerHTML='';
269
+ const bar=document.createElement('div');bar.id='calbar';
270
+ bar.innerHTML='<button class="ghost" id="prev">\u2039</button><span class="title">'+esc(calTitle())+'</span>'+
271
+ '<button class="ghost" id="next">\u203A</button><button class="ghost" id="tdy">Today</button>';
272
+ cal.appendChild(bar);
273
+ bar.querySelector('#tdy').onclick=()=>{anchor=now;renderCalendar();};
274
+ if(calMode==='month'){
275
+ bar.querySelector('#prev').onclick=()=>{const d=new Date(anchor+'T00:00:00');d.setMonth(d.getMonth()-1,1);anchor=d.toISOString().slice(0,10);renderCalendar();};
276
+ bar.querySelector('#next').onclick=()=>{const d=new Date(anchor+'T00:00:00');d.setMonth(d.getMonth()+1,1);anchor=d.toISOString().slice(0,10);renderCalendar();};
277
+ const first=anchor.slice(0,8)+'01';
278
+ const gridStart=weekStart(first);
279
+ const gridEnd=addDays(gridStart,41);
280
+ const {dueByDay,sessByDay}=await fetchWindow(gridStart,gridEnd);
281
+ const grid=document.createElement('div');grid.className='mgrid';
282
+ ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'].forEach(d=>grid.insertAdjacentHTML('beforeend','<div class="dow">'+d+'</div>'));
283
+ for(let i=0;i<42;i++){
284
+ const d=addDays(gridStart,i);
285
+ const cell=document.createElement('div');
286
+ cell.className='mcell'+(d.slice(0,7)!==anchor.slice(0,7)?' dim':'')+(d===now?' today':'');
287
+ const sess=(sessByDay[d]||[]).length;
288
+ let h='<div class="d"><span>'+Number(d.slice(8))+'</span>'+(sess?'<span class="chip sess">'+sess+' sess</span>':'')+'</div>';
289
+ cell.innerHTML=h;
290
+ const due=dueByDay[d]||[];
291
+ due.slice(0,3).forEach(t=>{const closed=t.status==='done'||t.status==='outdated';
292
+ const it=document.createElement('div');it.className='item'+(d<now&&!closed?' late':'');
293
+ it.textContent=(t.priority?t.priority+' \xB7 ':'')+(t.title||t.id);
294
+ it.draggable=true;
295
+ it.addEventListener('dragstart',ev=>{ev.stopPropagation();ev.dataTransfer.setData('text/plain',t.id);ev.dataTransfer.effectAllowed='move';});
296
+ cell.appendChild(it);});
297
+ if(due.length>3)cell.insertAdjacentHTML('beforeend','<div class="item">+'+(due.length-3)+' more\u2026</div>');
298
+ cell.onclick=(ev)=>{ if(ev.target.closest('.item'))return; openCreate({due:d}); };
299
+ cell.querySelector('.d').addEventListener('click',ev=>{ev.stopPropagation();dayDate=d;view='day';render();});
300
+ cell.querySelector('.d').style.cursor='pointer';cell.querySelector('.d').title='open day view';
301
+ cell.addEventListener('dragover',ev=>{ev.preventDefault();ev.dataTransfer.dropEffect='move';cell.style.borderColor='var(--blue)';});
302
+ cell.addEventListener('dragleave',()=>{cell.style.borderColor='';});
303
+ cell.addEventListener('drop',ev=>{ev.preventDefault();cell.style.borderColor='';
304
+ const id=ev.dataTransfer.getData('text/plain');if(id)act('set-due',id,d).then(()=>renderCalendar());});
305
+ grid.appendChild(cell);
306
+ }
307
+ cal.appendChild(grid);
308
+ }else{
309
+ const days=calMode==='workweek'?5:7;
310
+ bar.querySelector('#prev').onclick=()=>{anchor=addDays(anchor,-7);renderCalendar();};
311
+ bar.querySelector('#next').onclick=()=>{anchor=addDays(anchor,7);renderCalendar();};
312
+ const start=weekStart(anchor);
313
+ const end=addDays(start,days-1);
314
+ const {dueByDay,sessByDay}=await fetchWindow(start,end);
315
+ const grid=document.createElement('div');grid.className='wgrid';
316
+ grid.style.gridTemplateColumns='repeat('+days+',1fr)';
317
+ for(let i=0;i<days;i++){
318
+ const d=addDays(start,i);
319
+ const col=document.createElement('div');col.className='wcol'+(d===now?' today':'');
320
+ const label=new Date(d+'T00:00:00').toLocaleDateString(undefined,{weekday:'short',month:'short',day:'numeric'});
321
+ col.innerHTML='<h4><span>'+esc(label)+'</span><span>\u203A</span></h4>';
322
+ col.querySelector('h4').onclick=()=>{dayDate=d;view='day';render();};
323
+ col.addEventListener('click',ev=>{ if(ev.target.closest('.witem')||ev.target.closest('h4'))return; openCreate({due:d}); });
324
+ col.style.cursor='pointer';col.title='click empty space to add a task due this day';
325
+ (dueByDay[d]||[]).forEach(t=>{const closed=t.status==='done'||t.status==='outdated';
326
+ const it=document.createElement('div');it.className='witem due'+(d<now&&!closed?' late':'');
327
+ it.textContent=(t.priority?t.priority+' \xB7 ':'')+'\u23F0 '+(t.title||t.id);
328
+ it.draggable=true;
329
+ it.addEventListener('dragstart',ev=>{ev.dataTransfer.setData('text/plain',t.id);ev.dataTransfer.effectAllowed='move';});
330
+ it.onclick=()=>{const o=(S.objects||[]).find(x=>x.id===t.id);if(o)openDrawer(o);};
331
+ col.appendChild(it);});
332
+ col.addEventListener('dragover',ev=>{ev.preventDefault();ev.dataTransfer.dropEffect='move';col.style.borderColor='var(--blue)';});
333
+ col.addEventListener('dragleave',()=>{col.style.borderColor='';});
334
+ col.addEventListener('drop',ev=>{ev.preventDefault();col.style.borderColor='';
335
+ const id=ev.dataTransfer.getData('text/plain');if(id)act('set-due',id,d).then(()=>renderCalendar());});
336
+ (sessByDay[d]||[]).forEach(s=>{
337
+ const it=document.createElement('div');it.className='witem sess';
338
+ const t=s.started_at?new Date(s.started_at).toTimeString().slice(0,5):'--:--';
339
+ it.textContent=t+' ['+(s.project_name||'?')+'] '+(s.title||'(session)');
340
+ it.title=s.title||'';
341
+ col.appendChild(it);});
342
+ grid.appendChild(col);
343
+ }
344
+ cal.appendChild(grid);
345
+ }
346
+ }
347
+ // \u2500\u2500 day lookback \u2500\u2500
348
+ async function renderDay(){
349
+ if(!dayDate)dayDate=todayStr();
350
+ const el=$('#day');el.innerHTML='';
351
+ const bar=document.createElement('div');bar.id='calbar';
352
+ bar.innerHTML='<button class="ghost" id="prev">\u2039</button><span class="title">'+esc(new Date(dayDate+'T00:00:00').toDateString())+'</span>'+
353
+ '<button class="ghost" id="next">\u203A</button><button class="ghost" id="tdy">Today</button>';
354
+ el.appendChild(bar);
355
+ bar.querySelector('#prev').onclick=()=>{dayDate=addDays(dayDate,-1);renderDay();};
356
+ bar.querySelector('#next').onclick=()=>{dayDate=addDays(dayDate,1);renderDay();};
357
+ bar.querySelector('#tdy').onclick=()=>{dayDate=todayStr();renderDay();};
358
+ const lb=await api('/api/lookback?date='+dayDate);
359
+ if(lb.daily_plan&&lb.daily_plan.body&&lb.daily_plan.body.trim()){
360
+ const s=document.createElement('div');s.className='section';
361
+ s.innerHTML='<h3>Plan of the day</h3><pre class="plan">'+esc(lb.daily_plan.body.trim())+'</pre>';
362
+ el.appendChild(s);
363
+ }
364
+ const sess=document.createElement('div');sess.className='section';
365
+ sess.innerHTML='<h3>What I was up to \u2014 '+lb.sessions.length+' coding session(s)</h3>';
366
+ if(!lb.sessions.length)sess.insertAdjacentHTML('beforeend','<div class="empty">no captured sessions this day</div>');
367
+ lb.sessions.forEach(s=>{
368
+ const t=s.started_at?new Date(s.started_at).toTimeString().slice(0,5):'--:--';
369
+ sess.insertAdjacentHTML('beforeend','<div class="sessrow"><span class="time">'+t+'</span>'+
370
+ '<span class="proj">'+esc(s.project_name||'?')+'</span>'+
371
+ '<span class="ttl">'+esc(s.title||'(untitled session)')+'</span>'+
372
+ '<span class="dur">'+(s.minutes?s.minutes+'m':'')+(s.turns?' \xB7 '+s.turns+'t':'')+'</span></div>');
373
+ });
374
+ el.appendChild(sess);
375
+ const mk=(title,rows)=>{if(!rows.length)return;
376
+ const s=document.createElement('div');s.className='section';
377
+ s.innerHTML='<h3>'+title+'</h3>';
378
+ rows.forEach(t=>{s.insertAdjacentHTML('beforeend','<div class="titem"><span class="st">'+esc(t.status||'')+'</span><span class="t">'+esc(t.title||t.id)+'</span></div>');});
379
+ el.appendChild(s);};
380
+ mk('Worked on (updated) this day',lb.tasks_worked);
381
+ mk('Created this day',lb.tasks_created);
382
+ mk('Due this day',lb.tasks_due);
383
+ }
384
+ // \u2500\u2500 topics (life/work \u2192 domain \u2192 items + sessions) \u2500\u2500
385
+ const topicsExpanded=new Set(); // "group/domain" keys \u2014 dblclick (or +N more) toggles full lists
386
+ async function renderTopics(){
387
+ const el=$('#topics');el.innerHTML='';
388
+ const data=await api('/api/topics');
389
+ const order=Object.keys(data.groups).sort((a,b)=>(a==='other')-(b==='other')||a.localeCompare(b));
390
+ for(const g of order){
391
+ const grp=document.createElement('div');grp.className='tgroup';
392
+ grp.innerHTML='<h2>'+esc(g)+'</h2>';
393
+ const wrap=document.createElement('div');wrap.className='tdomains';
394
+ const domains=Object.keys(data.groups[g]).sort();
395
+ for(const dom of domains){
396
+ const d=data.groups[g][dom];
397
+ const key=g+'/'+dom;
398
+ const expanded=topicsExpanded.has(key);
399
+ const card=document.createElement('div');card.className='tdomain';
400
+ card.title='double-click to '+(expanded?'collapse':'expand all items');
401
+ card.innerHTML='<h3>'+esc(dom)+'<span class="n">'+d.tasks.length+' tasks \xB7 '+d.ideas.length+' ideas'+(d.sessions.length?' \xB7 '+d.sessions.length+' recent sessions':'')+(expanded?' \xB7 expanded':'')+'</span></h3>';
402
+ const toggle=()=>{expanded?topicsExpanded.delete(key):topicsExpanded.add(key);renderTopics();};
403
+ card.addEventListener('dblclick',ev=>{if(ev.target.closest('.titem')&&!ev.target.closest('.tmore'))return;toggle();});
404
+ (expanded?d.tasks:d.tasks.slice(0,8)).forEach(t=>{
405
+ const it=document.createElement('div');it.className='titem';
406
+ it.innerHTML='<span class="st">'+esc(t.status||'')+(t.priority?' '+esc(t.priority):'')+'</span><span class="t">'+esc(t.title||t.id)+'</span>';
407
+ it.onclick=()=>{const o=(S.objects||[]).find(x=>x.id===t.id);if(o)openDrawer(o);};
408
+ card.appendChild(it);});
409
+ if(!expanded&&d.tasks.length>8){
410
+ const more=document.createElement('div');more.className='titem tmore';
411
+ more.innerHTML='<span class="t" style="color:var(--blue);cursor:pointer">\u2026+'+(d.tasks.length-8)+' more tasks \u2014 click to expand</span>';
412
+ more.onclick=toggle;
413
+ card.appendChild(more);}
414
+ (expanded?d.ideas:d.ideas.slice(0,4)).forEach(t=>{
415
+ const it=document.createElement('div');it.className='titem';
416
+ it.innerHTML='<span class="st">idea</span><span class="t">'+esc(t.title||t.id)+'</span>';
417
+ it.onclick=()=>{const o=(S.objects||[]).find(x=>x.id===t.id);if(o)openDrawer(o);};
418
+ card.appendChild(it);});
419
+ if(!expanded&&d.ideas.length>4){
420
+ const more=document.createElement('div');more.className='titem tmore';
421
+ more.innerHTML='<span class="t" style="color:var(--blue);cursor:pointer">\u2026+'+(d.ideas.length-4)+' more ideas \u2014 click to expand</span>';
422
+ more.onclick=toggle;
423
+ card.appendChild(more);}
424
+ if(d.sessions.length){
425
+ const ts=document.createElement('div');ts.className='tsess';
426
+ ts.innerHTML='<div class="lbl">recent sessions</div>';
427
+ d.sessions.slice(0,expanded?20:4).forEach(s=>{
428
+ ts.insertAdjacentHTML('beforeend','<div class="titem"><span class="st">'+esc(s.day)+'</span><span class="t">'+esc((s.project_name?'['+s.project_name+'] ':'')+(s.title||'(untitled)'))+'</span></div>');});
429
+ card.appendChild(ts);
430
+ }
431
+ wrap.appendChild(card);
432
+ }
433
+ grp.appendChild(wrap);el.appendChild(grp);
434
+ }
435
+ }
436
+ function openCreate(prefill){
437
+ const d=$('#drawer');
438
+ const type=prefill.obj_type||'task';
439
+ const statusOpts=(LANES[type]||LANES.task).map(x=>'<option'+(x===(DEFAULT_STATUS[type]||'inbox')?' selected':'')+'>'+x+'</option>').join('');
440
+ const prioOpts=['','p0','p1','p2','p3'].map(x=>'<option value="'+x+'">'+(x||'(none)')+'</option>').join('');
441
+ const projOpts='<option value="">(none)</option>'+(snap.objects||[]).filter(x=>x.type==='project').map(p=>'<option value="'+esc(p.id)+'">'+esc(p.title||p.id)+'</option>').join('');
442
+ d.innerHTML='<h2>New '+esc(type)+'</h2><div class="id">creates a '+esc(type)+' in the store</div>'+
443
+ '<label>Type</label><select id="cType"><option value="task"'+(type==='task'?' selected':'')+'>task</option><option value="idea"'+(type==='idea'?' selected':'')+'>idea</option><option value="plan"'+(type==='plan'?' selected':'')+'>plan</option><option value="thought"'+(type==='thought'?' selected':'')+'>thought</option></select>'+
444
+ '<label>Title</label><input id="cTitle" value="'+esc(prefill.title||'')+'" placeholder="What needs doing?">'+
445
+ '<label>Status</label><select id="cStatus">'+statusOpts+'</select>'+
446
+ '<label>Priority</label><select id="cPrio">'+prioOpts+'</select>'+
447
+ '<label>Due (optional)</label><input type="date" id="cDue" value="'+esc(prefill.due||'')+'">'+
448
+ '<label>Domain (optional)</label><input id="cDomain" placeholder="tech / kids / career\u2026">'+
449
+ '<label>Project (optional)</label><select id="cProject">'+projOpts+'</select>'+
450
+ '<label>Body (markdown)</label><textarea id="cBody" rows="8" style="width:100%;background:var(--card);border:1px solid var(--line);border-radius:6px;color:var(--fg);padding:8px;font:12px/1.5 ui-monospace,monospace"></textarea>'+
451
+ '<div class="actions"><button class="act" id="cCreate">Create</button><button class="ghost" id="cClose">Close</button></div>';
452
+ d.classList.add('open');
453
+ setTimeout(()=>$('#cTitle').focus(),50);
454
+ const refreshStatus=()=>{const t=$('#cType').value;
455
+ $('#cStatus').innerHTML=(LANES[t]||LANES.task).map(x=>'<option>'+x+'</option>').join('');};
456
+ $('#cType').onchange=refreshStatus;
457
+ $('#cClose').onclick=()=>d.classList.remove('open');
458
+ const submit=async()=>{
459
+ const title=$('#cTitle').value.trim();if(!title){toast('title required');return;}
460
+ try{const r=await api('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({op:'create',
461
+ obj_type:$('#cType').value,title,status:$('#cStatus').value,priority:$('#cPrio').value||undefined,
462
+ due:$('#cDue').value||undefined,domain:$('#cDomain').value.trim()||undefined,
463
+ project:$('#cProject').value||undefined,body:$('#cBody').value})});
464
+ d.classList.remove('open');await load();toast('created '+(r.id||'\u2713'));}catch(e){toast('\u2717 '+e.message);}
465
+ };
466
+ $('#cCreate').onclick=submit;
467
+ $('#cTitle').addEventListener('keydown',e=>{if(e.key==='Enter'&&(e.metaKey||e.ctrlKey))submit();});
468
+ }
469
+ async function openDrawer(o){
470
+ const d=$('#drawer');
471
+ let body='';
472
+ try{const full=await api('/api/object?id='+encodeURIComponent(o.id));body=full.body||'';
473
+ o={...o,...{title:full.data.title??o.title,status:full.data.status??o.status,priority:full.data.priority??o.priority,due:full.data.due??o.due,project:full.data.project??o.project,domain:full.data.domain??o.domain}};}catch(e){}
474
+ const laneOpts=(LANES[o.type]||LANES.task).map(s=>'<option'+(s===o.status?' selected':'')+'>'+s+'</option>').join('');
475
+ const prioOpts=['','p0','p1','p2','p3'].map(p=>'<option value="'+p+'"'+((o.priority||'')===p?' selected':'')+'>'+(p||'(none)')+'</option>').join('');
476
+ const domOpts='<datalist id="domList">'+domainList().map(x=>'<option value="'+esc(x)+'">').join('')+'</datalist>';
477
+ const projList=(snap.objects||[]).filter(x=>x.type==='project');
478
+ const projOpts='<option value="">(none)</option>'+projList.map(p=>{
479
+ const label=(p.title||p.id).replace(/^projects//,'');
480
+ return '<option value="'+esc(p.id)+'"'+(o.project===p.id?' selected':'')+'>'+esc(label)+'</option>';
481
+ }).join('');
482
+ const prov=(o.context?'<div class="id" title="captured under">\u25D4 '+esc(o.context)+(o.surfaced_on?' \xB7 '+esc(o.surfaced_on):'')+'</div>':'');
483
+ d.innerHTML='<h2>Edit</h2><div class="id">'+esc(o.id)+' \xB7 '+esc(o.type)+'</div>'+prov+
484
+ '<label>Title</label><input id="dTitle" value="'+esc(o.title||'')+'">'+
485
+ '<label>Status</label><select id="dStatus">'+laneOpts+'</select>'+
486
+ '<label>Category / domain</label><input id="dDomain" list="domList" value="'+esc(o.domain||'')+'" placeholder="kids / tech / career\u2026">'+domOpts+
487
+ '<label>Project</label><select id="dProject">'+projOpts+'</select>'+
488
+ (o.type==='task'?'<label>Priority</label><select id="dPrio">'+prioOpts+'</select>'+
489
+ '<label>Due (blank clears)</label><input type="date" id="dDue" value="'+esc(o.due||'')+'">':'')+
490
+ '<label>Body (markdown)</label><textarea id="dBody" rows="12" style="width:100%;background:var(--card);border:1px solid var(--line);border-radius:6px;color:var(--fg);padding:8px;font:12px/1.5 ui-monospace,monospace"></textarea>'+
491
+ (o.type==='thought'?'<div class="actions"><button class="ghost" id="dToIdea">\u2192 \u{1F4A1} idea</button><button class="ghost" id="dToTask">\u2192 \u2611 task</button></div>':'')+
492
+ (o.type==='idea'?'<div class="actions"><button class="ghost" id="dToTask">\u2192 \u2611 task</button></div>':'')+
493
+ '<div class="actions"><button class="act" id="dApply">Apply</button><button class="ghost" id="dClose">Close</button></div>';
494
+ $('#dBody').value=body;
495
+ d.classList.add('open');
496
+ $('#dClose').onclick=()=>d.classList.remove('open');
497
+ const convert=async(toType)=>{
498
+ try{const r=await api('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({op:'recategorize',id:o.id,to_type:toType})});
499
+ d.classList.remove('open');await load();toast('\u2192 '+(r.newId||toType));}catch(e){toast('\u2717 '+e.message);}
500
+ };
501
+ if($('#dToIdea'))$('#dToIdea').onclick=()=>convert('idea');
502
+ if($('#dToTask'))$('#dToTask').onclick=()=>convert('task');
503
+ $('#dApply').onclick=async()=>{
504
+ const st=$('#dStatus').value;
505
+ if(st!==o.status)await setStatusAsking(o.id,st);
506
+ const dom=$('#dDomain').value.trim();
507
+ if(dom!==(o.domain||''))await act2({op:'recategorize',id:o.id,domain:dom});
508
+ const proj=$('#dProject')?$('#dProject').value:'';
509
+ if((o.project||'')!==proj)await act('set-project',o.id,proj||'-');
510
+ if(o.type==='task'){
511
+ const pr=$('#dPrio').value;
512
+ if((o.priority||'')!==pr)await act('set-priority',o.id,pr||'-');
513
+ const du=$('#dDue').value;
514
+ if((o.due||'')!==du)await act('set-due',o.id,du||'-');
515
+ }
516
+ const nt=$('#dTitle').value.trim();
517
+ const nb=$('#dBody').value;
518
+ if((nt&&nt!==(o.title||''))||nb!==body){
519
+ try{await api('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({op:'edit',id:o.id,title:nt,body:nb})});toast('edit \u2713');await load();}catch(e){toast('\u2717 '+e.message);}
520
+ }
521
+ d.classList.remove('open');
522
+ };
523
+ }
524
+ async function act2(payload){try{await api('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload)});await load();toast(payload.op+' \u2713');}catch(e){toast('\u2717 '+e.message);}}
525
+ $('#views').onclick=e=>{const b=e.target.closest('button');if(b){view=b.dataset.v;render();}};
526
+ $('#lanes').onclick=e=>{const b=e.target.closest('button');if(b){lane=b.dataset.l;render();}};
527
+ $('#calmodes').onclick=e=>{const b=e.target.closest('button');if(b){calMode=b.dataset.m;render();}};
528
+ let boardField='updated',boardDate='';
529
+ let searchTerm='';
530
+ function applySearch(){
531
+ const q=searchTerm.toLowerCase();
532
+ document.querySelectorAll('#board .card, #topics .titem').forEach(c=>{
533
+ c.classList.toggle('nomatch', !!q && !c.textContent.toLowerCase().includes(q));
534
+ });
535
+ // hide now-empty board lanes' counts stay; empty columns just show 0 visible
536
+ }
537
+ document.addEventListener('keydown',e=>{
538
+ if((e.metaKey||e.ctrlKey)&&e.key==='f'){e.preventDefault();
539
+ const s=$('#search');s.style.display='inline-block';s.focus();s.select();}
540
+ if(e.key==='Escape'){const s=$('#search');if(document.activeElement===s){searchTerm='';s.value='';s.style.display='none';applySearch();}}
541
+ });
542
+ $('#search').addEventListener('input',e=>{searchTerm=e.target.value;applySearch();});
543
+ $('#newBtn').onclick=()=>openCreate({});
544
+ // on-demand sync buttons \u2014 one per executable in the KB's scripts/sync/ folder
545
+ // (convention: scripts/sync/README.md; sync.sh is the standard/default action)
546
+ async function renderSyncButtons(){
547
+ try{
548
+ const r=await api('/api/sync-scripts');
549
+ const el=$('#syncBtns');el.innerHTML='';
550
+ (r.scripts||[]).forEach(s=>{
551
+ const b=document.createElement('button');
552
+ b.className=s.standard?'act':'ghost';
553
+ if(s.standard)b.style.background='var(--green)';
554
+ b.textContent='\u27F3 '+s.name.replace(/\\.sh$/,'');
555
+ b.title='run '+r.dir+'/'+s.name;
556
+ b.onclick=async()=>{
557
+ b.disabled=true;const t=b.textContent;b.textContent='\u27F3 running\u2026';
558
+ try{const res=await api('/api/sync',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({script:s.name})});
559
+ toast(s.name+' \u2713 (exit 0)');console.log('[sync]',s.name,res.output);await load();}
560
+ catch(e){toast('\u2717 '+s.name+': '+e.message);}
561
+ b.disabled=false;b.textContent=t;
562
+ };
563
+ el.appendChild(b);
564
+ });
565
+ }catch(e){/* no sync folder \u2014 no buttons */}
566
+ }
567
+ renderSyncButtons();
568
+
569
+ // \u2500\u2500 store git pull: on open + aggressively while active, backing off when idle \u2500\u2500
570
+ let syncState={status:'idle',lastSyncAt:0}, activeUntil=0, syncTimer=null;
571
+ const ACTIVE_WINDOW=3*60*1000, ACTIVE_POLL=20000, IDLE_POLL=90000;
572
+ function agoStr(t){ if(!t)return 'never'; const s=Math.round((Date.now()-t)/1000);
573
+ return s<60?s+'s ago':s<3600?Math.round(s/60)+'m ago':Math.round(s/3600)+'h ago'; }
574
+ function renderSyncPill(){ const p=$('#syncPill'); if(!p)return; const s=syncState;
575
+ const active=Date.now()<activeUntil;
576
+ const cls=s.status==='syncing'?'syncing':(s.status==='conflict'||s.status==='error'||s.status==='offline')?'warn':(s.status==='ok'||s.status==='unchanged')?'ok':'';
577
+ p.className=cls+(active?' active':'');
578
+ const icon=s.status==='syncing'?'\u27F3':(cls==='warn'?'\u26A0':'\u2601');
579
+ p.textContent=icon+' '+(s.status==='syncing'?'syncing\u2026':agoStr(s.lastSyncAt))+(active?' \u26A1':'');
580
+ p.title='Store sync \u2014 '+s.status+(s.detail?': '+s.detail:'')+
581
+ ' \xB7 last '+(s.lastSyncAt?new Date(s.lastSyncAt).toLocaleTimeString():'never')+
582
+ (active?' \xB7 \u26A1 active':'')+' \xB7 click to pull now'; }
583
+ async function pullNow(){ syncState=Object.assign({},syncState,{status:'syncing'}); renderSyncPill();
584
+ try{ const st=await api('/api/pull',{method:'POST'}); syncState=st; renderSyncPill(); if(st.changed)await load(); }
585
+ catch(e){ syncState={status:'error',detail:e.message,lastSyncAt:Date.now()}; renderSyncPill(); } }
586
+ function noteActivity(){ activeUntil=Date.now()+ACTIVE_WINDOW; renderSyncPill(); }
587
+ let lastAct=0;
588
+ ['click','keydown','wheel'].forEach(ev=>document.addEventListener(ev,()=>{const n=Date.now();if(n-lastAct>8000){lastAct=n;noteActivity();}},{passive:true}));
589
+ $('#syncPill')&&$('#syncPill').addEventListener('click',()=>{noteActivity();pullNow();});
590
+ function scheduleSync(){ if(syncTimer)clearTimeout(syncTimer);
591
+ const delay=Date.now()<activeUntil?ACTIVE_POLL:IDLE_POLL;
592
+ syncTimer=setTimeout(async()=>{ await pullNow(); scheduleSync(); }, delay); }
593
+ setInterval(renderSyncPill,15000); // keep "Xs ago" fresh
594
+ noteActivity(); // opening the page counts as activity
595
+ pullNow().then(load); // load changes on open
596
+ scheduleSync();
597
+ </script></body></html>`;
598
+
599
+ // src/serve/server.ts
600
+ var JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
601
+ function syncDir(config) {
602
+ return config.sync_dir ?? join(config.kb_root, "scripts", "sync");
603
+ }
604
+ function listSyncScripts(config) {
605
+ const dir = syncDir(config);
606
+ let names = [];
607
+ try {
608
+ names = readdirSync(dir).filter((f) => {
609
+ if (!f.endsWith(".sh")) return false;
610
+ try {
611
+ const st = statSync(join(dir, f));
612
+ return st.isFile() && (st.mode & 73) !== 0;
613
+ } catch {
614
+ return false;
615
+ }
616
+ });
617
+ } catch {
618
+ return [];
619
+ }
620
+ return names.sort((a, b) => a === "sync.sh" ? -1 : b === "sync.sh" ? 1 : a.localeCompare(b)).map((name) => ({ name, standard: name === "sync.sh" }));
621
+ }
622
+ function runSyncScript(config, name) {
623
+ const dir = syncDir(config);
624
+ const file = resolve(dir, basename(String(name || "sync.sh")));
625
+ if (!file.startsWith(resolve(dir) + "/") || !listSyncScripts(config).some((s) => s.name === basename(file))) {
626
+ return Promise.resolve({ ok: false, code: 126, output: `not a sync script: ${name}` });
627
+ }
628
+ return new Promise((resolveP) => {
629
+ execFile(file, { timeout: 5 * 6e4, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
630
+ const output = `${stdout ?? ""}${stderr ? "\n" + stderr : ""}`.trim();
631
+ const code = err ? err.code ?? 1 : 0;
632
+ resolveP({ ok: !err, code: typeof code === "number" ? code : 1, output: output.slice(-4e3) });
633
+ });
634
+ });
635
+ }
636
+ function fresh(root) {
637
+ const store = new Store(root);
638
+ return { store, idx: new PlanningIndex(store) };
639
+ }
640
+ var pullState = { status: "idle", lastSyncAt: 0, changed: false };
641
+ var pulling = null;
642
+ function git(dir, args) {
643
+ return new Promise((res) => {
644
+ execFile("git", ["-C", dir, ...args], { timeout: 6e4, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
645
+ res({ code: err ? err.code ?? 1 : 0, out: `${stdout ?? ""}${stderr ?? ""}`.trim() });
646
+ });
647
+ });
648
+ }
649
+ function getPullState() {
650
+ return { ...pullState };
651
+ }
652
+ function pullStore(config) {
653
+ if (pulling) return pulling;
654
+ const dir = config.kb_root;
655
+ pullState.status = "syncing";
656
+ pulling = (async () => {
657
+ try {
658
+ if (!existsSync(join(dir, ".git"))) {
659
+ Object.assign(pullState, { status: "unchanged", detail: "not a git repo", lastSyncAt: Date.now(), changed: false });
660
+ return getPullState();
661
+ }
662
+ const before = (await git(dir, ["rev-parse", "HEAD"])).out;
663
+ const fetched = await git(dir, ["fetch"]);
664
+ if (fetched.code !== 0) {
665
+ Object.assign(pullState, { status: "offline", detail: firstLine(fetched.out), lastSyncAt: Date.now(), changed: false });
666
+ return getPullState();
667
+ }
668
+ const pull = await git(dir, ["pull", "--rebase", "--autostash"]);
669
+ if (pull.code !== 0) {
670
+ await git(dir, ["rebase", "--abort"]);
671
+ Object.assign(pullState, { status: "conflict", detail: firstLine(pull.out), lastSyncAt: Date.now(), changed: false });
672
+ return getPullState();
673
+ }
674
+ const after = (await git(dir, ["rev-parse", "HEAD"])).out;
675
+ const changed = before !== after;
676
+ Object.assign(pullState, { status: changed ? "ok" : "unchanged", detail: void 0, lastSyncAt: Date.now(), changed });
677
+ return getPullState();
678
+ } catch (e) {
679
+ Object.assign(pullState, { status: "error", detail: String(e), lastSyncAt: Date.now(), changed: false });
680
+ return getPullState();
681
+ } finally {
682
+ pulling = null;
683
+ }
684
+ })();
685
+ return pulling;
686
+ }
687
+ function firstLine(s) {
688
+ return (s || "").split("\n").find((l) => l.trim()) ?? "";
689
+ }
690
+ async function readBody(req) {
691
+ const chunks = [];
692
+ for await (const c of req) chunks.push(c);
693
+ return Buffer.concat(chunks).toString("utf8");
694
+ }
695
+ async function handle(opts, req, res) {
696
+ const url = new URL(req.url ?? "/", "http://localhost");
697
+ try {
698
+ if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
699
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
700
+ res.end(DASHBOARD_HTML);
701
+ return;
702
+ }
703
+ if (req.method === "GET" && url.pathname === "/api/snapshot") {
704
+ const { store, idx } = fresh(opts.root);
705
+ const date = url.searchParams.get("date") || today();
706
+ const snap = cmdExport(store, idx, opts.config, date);
707
+ idx.close();
708
+ res.writeHead(200, JSON_HEADERS);
709
+ res.end(JSON.stringify(snap));
710
+ return;
711
+ }
712
+ if (req.method === "GET" && url.pathname === "/api/sessions") {
713
+ const from = url.searchParams.get("from") || "0000-01-01";
714
+ const to = url.searchParams.get("to") || "9999-12-31";
715
+ const days = sessionsByDay(readAllSessions(), from, to);
716
+ res.writeHead(200, JSON_HEADERS);
717
+ res.end(JSON.stringify({ from, to, days }));
718
+ return;
719
+ }
720
+ if (req.method === "GET" && url.pathname === "/api/lookback") {
721
+ const date = url.searchParams.get("date") || today();
722
+ const store = new Store(opts.root);
723
+ res.writeHead(200, JSON_HEADERS);
724
+ res.end(JSON.stringify(buildLookback(store, date)));
725
+ return;
726
+ }
727
+ if (req.method === "GET" && url.pathname === "/api/topics") {
728
+ const store = new Store(opts.root);
729
+ res.writeHead(200, JSON_HEADERS);
730
+ res.end(JSON.stringify(buildTopics(store, opts.config)));
731
+ return;
732
+ }
733
+ if (req.method === "GET" && url.pathname === "/api/object") {
734
+ const id = url.searchParams.get("id") || "";
735
+ const store = new Store(opts.root);
736
+ const doc = store.get(id);
737
+ if (!doc) {
738
+ res.writeHead(404, JSON_HEADERS);
739
+ res.end(JSON.stringify({ error: `not found: ${id}` }));
740
+ return;
741
+ }
742
+ res.writeHead(200, JSON_HEADERS);
743
+ res.end(JSON.stringify({ id: doc.id, type: doc.type, data: doc.data, body: doc.body ?? "" }));
744
+ return;
745
+ }
746
+ if (req.method === "GET" && url.pathname === "/api/calendar") {
747
+ const { store, idx } = fresh(opts.root);
748
+ const from = url.searchParams.get("from") || "0000-01-01";
749
+ const to = url.searchParams.get("to") || "9999-12-31";
750
+ const rows = idx.calendar(from, to);
751
+ idx.close();
752
+ void store;
753
+ res.writeHead(200, JSON_HEADERS);
754
+ res.end(JSON.stringify({ from, to, rows }));
755
+ return;
756
+ }
757
+ if (req.method === "GET" && url.pathname === "/api/sync-status") {
758
+ res.writeHead(200, JSON_HEADERS);
759
+ res.end(JSON.stringify(getPullState()));
760
+ return;
761
+ }
762
+ if (req.method === "POST" && url.pathname === "/api/pull") {
763
+ const st = await pullStore(opts.config);
764
+ res.writeHead(200, JSON_HEADERS);
765
+ res.end(JSON.stringify(st));
766
+ return;
767
+ }
768
+ if (req.method === "GET" && url.pathname === "/api/sync-scripts") {
769
+ res.writeHead(200, JSON_HEADERS);
770
+ res.end(JSON.stringify({ dir: syncDir(opts.config), scripts: listSyncScripts(opts.config) }));
771
+ return;
772
+ }
773
+ if (req.method === "POST" && url.pathname === "/api/sync") {
774
+ const body = JSON.parse(await readBody(req) || "{}");
775
+ const r = await runSyncScript(opts.config, body.script ?? "sync.sh");
776
+ res.writeHead(r.ok ? 200 : 422, JSON_HEADERS);
777
+ res.end(JSON.stringify(r));
778
+ return;
779
+ }
780
+ if (req.method === "POST" && url.pathname === "/api/action") {
781
+ const body = JSON.parse(await readBody(req) || "{}");
782
+ const store = new Store(opts.root);
783
+ let result = { ok: true };
784
+ switch (body.op) {
785
+ case "set-status":
786
+ cmdSetStatus(store, String(body.id), String(body.value), body.note);
787
+ break;
788
+ case "set-due":
789
+ cmdSetDue(store, String(body.id), String(body.value));
790
+ break;
791
+ case "set-priority":
792
+ cmdSetPriority(store, String(body.id), String(body.value));
793
+ break;
794
+ case "set-project":
795
+ cmdSetProject(store, String(body.id), String(body.value));
796
+ break;
797
+ case "capture": {
798
+ if (!body.text || !body.text.trim()) throw new Error("capture needs text");
799
+ result = cmdCapture(store, body.text.trim(), body.domain || void 0);
800
+ break;
801
+ }
802
+ case "edit": {
803
+ const edits = body;
804
+ cmdEditObject(store, String(body.id), { title: edits.title, body: edits.body });
805
+ break;
806
+ }
807
+ case "recategorize": {
808
+ const b = body;
809
+ result = cmdRecategorize(store, String(body.id), { toType: b.to_type, domain: body.domain, lane: b.lane });
810
+ break;
811
+ }
812
+ case "create": {
813
+ const b = body;
814
+ result = cmdCreate(store, {
815
+ type: b.obj_type,
816
+ title: String(b.title ?? ""),
817
+ status: b.status,
818
+ domain: b.domain,
819
+ project: b.project,
820
+ priority: b.priority,
821
+ due: b.due,
822
+ body: b.body
823
+ });
824
+ break;
825
+ }
826
+ default:
827
+ res.writeHead(400, JSON_HEADERS);
828
+ res.end(JSON.stringify({ error: `unknown op: ${body.op}` }));
829
+ return;
830
+ }
831
+ res.writeHead(200, JSON_HEADERS);
832
+ res.end(JSON.stringify(result));
833
+ return;
834
+ }
835
+ res.writeHead(404, JSON_HEADERS);
836
+ res.end(JSON.stringify({ error: "not found" }));
837
+ } catch (e) {
838
+ res.writeHead(422, JSON_HEADERS);
839
+ res.end(JSON.stringify({ error: String(e?.message ?? e) }));
840
+ }
841
+ }
842
+ function serve(opts) {
843
+ const server = createServer((req, res) => void handle(opts, req, res));
844
+ const host = opts.host ?? "127.0.0.1";
845
+ return new Promise((resolvePromise, reject) => {
846
+ server.once("error", reject);
847
+ server.listen(opts.port, host, () => {
848
+ const addr = server.address();
849
+ const port = typeof addr === "object" && addr ? addr.port : opts.port;
850
+ resolvePromise({ server, port });
851
+ });
852
+ });
853
+ }
854
+ export {
855
+ getPullState,
856
+ handle,
857
+ listSyncScripts,
858
+ pullStore,
859
+ runSyncScript,
860
+ serve,
861
+ syncDir
862
+ };