41d18d88aa
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1903 lines
78 KiB
React
1903 lines
78 KiB
React
// Main App — top shell, state management, real storage load/save flow.
|
||
|
||
const { useState: useStateMain, useEffect: useEffectMain, useMemo: useMemoMain, useRef: useRefMain } = React;
|
||
|
||
const LS_KEY = "personal-backlog-state-v1";
|
||
|
||
function loadLocalState() {
|
||
try {
|
||
const raw = localStorage.getItem(LS_KEY);
|
||
if (!raw) return null;
|
||
return JSON.parse(raw);
|
||
} catch { return null; }
|
||
}
|
||
|
||
// Only UI state (expanded rows, project section collapse, tweaks) is persisted locally.
|
||
// Backlog data always lives in backlog.md — never in localStorage.
|
||
function saveLocalState(expandedMap, tweaks, projectExpandedMap = {}) {
|
||
try { localStorage.setItem(LS_KEY, JSON.stringify({ expandedMap, tweaks, projectExpandedMap })); } catch { /* quota / private mode */ }
|
||
}
|
||
|
||
// Empty starting state — used when there is no saved data and no seed data loaded.
|
||
function buildEmptyData() {
|
||
const days = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'].map(day => ({ day, count: 0 }));
|
||
return {
|
||
entries: [],
|
||
history: [],
|
||
meta: { saved: null, checksum: '—', entryCount: 0, historyCount: 0 },
|
||
health: {
|
||
integrityOk: true, lastSave: null, lastBackup: null,
|
||
masterSize: 0, backupDirSize: 0, backupCount: 0,
|
||
statsSize: 0, historySize: 0, historyOldest: null,
|
||
mode: 'localStorage only',
|
||
},
|
||
stats: {
|
||
createdThisWeek: 0, completedThisWeek: 0, avgInProgressDays: null,
|
||
mostActiveProject: '—',
|
||
completionByDay: days.map(d => ({ ...d })),
|
||
createdByDay: days.map(d => ({ ...d })),
|
||
statusMix: { open: 0, 'in-progress': 0, blocked: 0, postponed: 0, done: 0, cancelled: 0 },
|
||
},
|
||
backups: [],
|
||
};
|
||
}
|
||
|
||
// Build ONE merged data object from per-project loads (api mode). Every entry
|
||
// and history row is tagged with _projectId; stats derive through the same
|
||
// buildDataFromStorage pipeline as single-project mode. A single registered
|
||
// project degenerates cleanly — the merged model is the ONLY api data path.
|
||
async function buildMergedDataFromProjects(projectsData, { sizeInfo = {}, backups = [] } = {}) {
|
||
// Parser ids derive from a Date.now() counter, so loadAllProjects' parallel
|
||
// per-project parses can mint IDENTICAL ids (same-millisecond start).
|
||
// De-collide deterministically per project — stable across reloads — and
|
||
// remap that project's history rows to the new id.
|
||
const seenIds = new Set();
|
||
const allEntries = [];
|
||
const taggedHistory = [];
|
||
|
||
for (const proj of projectsData) {
|
||
walkTree(proj.entries, it => {
|
||
if (seenIds.has(it.id)) {
|
||
const nid = `${it.id}~${proj.id}`;
|
||
for (const h of proj.history) if (h.itemId === it.id) h.itemId = nid;
|
||
it.id = nid;
|
||
}
|
||
seenIds.add(it.id);
|
||
it._projectId = proj.id;
|
||
});
|
||
allEntries.push(...proj.entries);
|
||
for (const h of proj.history) taggedHistory.push({ ...h, _projectId: proj.id });
|
||
}
|
||
taggedHistory.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||
|
||
const metas = projectsData.map(p => p.meta).filter(Boolean);
|
||
const mergedMeta = metas.length ? {
|
||
saved: metas.map(m => m.saved).filter(Boolean).sort().pop() ?? null,
|
||
checksum: '—',
|
||
entryCount: countAll(allEntries),
|
||
historyCount: taggedHistory.length,
|
||
} : null;
|
||
|
||
const merged = await buildDataFromStorage(
|
||
{ entries: allEntries, history: taggedHistory, meta: mergedMeta, checksumOk: projectsData.every(p => p.checksumOk) },
|
||
backups, 'api', sizeInfo,
|
||
);
|
||
|
||
// Stats above derive from the merged tree; mostActiveProject is the project
|
||
// with the most entries.
|
||
let mostActive = '—', maxCount = -1;
|
||
for (const proj of projectsData) {
|
||
const n = countAll(proj.entries);
|
||
if (n > maxCount) { maxCount = n; mostActive = proj.name; }
|
||
}
|
||
merged.stats.mostActiveProject = mostActive;
|
||
return merged;
|
||
}
|
||
|
||
// Which projects have local changes to persist? An explicit id wins (or an
|
||
// array of ids for cross-project moves); otherwise derive from _projectId tags
|
||
// on entries AND history rows, with untagged rows falling back to the first
|
||
// registered project.
|
||
function computeDirtyProjectIds(data, explicitProjectId, fallbackFirst) {
|
||
if (Array.isArray(explicitProjectId)) return explicitProjectId.filter(Boolean);
|
||
if (explicitProjectId) return [explicitProjectId];
|
||
const ids = new Set();
|
||
for (const e of data.entries) ids.add(e._projectId ?? fallbackFirst);
|
||
for (const h of data.history) ids.add(h._projectId ?? fallbackFirst);
|
||
ids.delete(null);
|
||
ids.delete(undefined);
|
||
return [...ids];
|
||
}
|
||
|
||
// Re-tag a node and all its descendants as belonging to `projectId`.
|
||
// Used by cross-project drag&drop (todo 6 drop zones) and project rename.
|
||
function retagSubtree(node, projectId) {
|
||
walkTree([node], it => { it._projectId = projectId; });
|
||
}
|
||
|
||
// Disk counterpart of the in-memory de-collision in buildMergedDataFromProjects:
|
||
// strip the merge-only `~<projectId>` suffix from entry ids, history itemIds and
|
||
// restore-path segments of THIS project's subset before serializing, so every
|
||
// project file stays a standard upstream backlog.md (upstream ids never contain
|
||
// `~`). Only an exact `~<projectId>` ending is stripped — other projects'
|
||
// suffixes or natural `~` pass through untouched. PURE: the in-memory tree keeps
|
||
// its suffixed ids for de-collision; deep-safe copies are returned.
|
||
function toDiskIds(entries, history, projectId) {
|
||
if (!projectId) return { entries, history };
|
||
const suffix = '~' + projectId;
|
||
const strip = id => (typeof id === 'string' && id.endsWith(suffix)) ? id.slice(0, id.length - suffix.length) : id;
|
||
const mapEntry = it => ({
|
||
...it,
|
||
id: strip(it.id),
|
||
restorePath: typeof it.restorePath === 'string' && it.restorePath
|
||
? it.restorePath.split('/').map(strip).join('/')
|
||
: it.restorePath,
|
||
children: it.children ? it.children.map(mapEntry) : it.children,
|
||
});
|
||
return {
|
||
entries: entries.map(mapEntry),
|
||
history: history.map(h => ({ ...h, itemId: strip(h.itemId) })),
|
||
};
|
||
}
|
||
|
||
function App() {
|
||
const [data, setData] = useStateMain(buildEmptyData);
|
||
const [storageMode, setStorageMode] = useStateMain('local'); // 'api' | 'direct' | 'local'
|
||
const [isLoading, setIsLoading] = useStateMain(true);
|
||
const [view, setView] = useStateMain('backlog');
|
||
const [filters, setFilters] = useStateMain({ statuses: [], priorities: [], tags: [], dueRange: null, scope: 'all', text: '' });
|
||
const [expandedMap, setExpandedMap] = useStateMain(() => loadLocalState()?.expandedMap ?? {});
|
||
const [saveState, setSaveState] = useStateMain({ status: 'idle', lastSaved: data.health?.lastSave || null });
|
||
const [toast, setToast] = useStateMain(null);
|
||
const [showWarning, setShowWarning] = useStateMain(false);
|
||
const [importExportOpen, setImportExportOpen] = useStateMain(false);
|
||
const [itemDialog, setItemDialog] = useStateMain(null);
|
||
const [confirm, setConfirm] = useStateMain(null);
|
||
const [needsConnect, setNeedsConnect] = useStateMain(false);
|
||
const [archiveData, setArchiveData] = useStateMain({ entries: [], history: [] });
|
||
const [projects, setProjects] = useStateMain([]);
|
||
const [projectExpandedMap, setProjectExpandedMap] = useStateMain(() => loadLocalState()?.projectExpandedMap ?? {});
|
||
const [prompt, setPrompt] = useStateMain(null); // PromptDialog state {title, message, placeholder, onConfirm}
|
||
|
||
const TWEAK_DEFAULTS = { accent_hue: 35, density: 'comfortable', show_ids: false, paper_texture: true, status_style: 'color', sort_mode: 'priority', theme: 'system' };
|
||
const [tweaks, setTweak] = useTweaks({ ...TWEAK_DEFAULTS, ...(loadLocalState()?.tweaks ?? {}) });
|
||
|
||
// Refs for async callbacks that need latest state without stale closures.
|
||
const latestData = useRefMain(data);
|
||
const latestExpandedMap = useRefMain(expandedMap);
|
||
const latestProjects = useRefMain(projects);
|
||
const latestProjectExpandedMap = useRefMain(projectExpandedMap);
|
||
const isDirtyRef = useRefMain(false);
|
||
const saveTimerRef = useRefMain(null);
|
||
const saveInFlightRef = useRefMain(null); // promise of the performSave currently running
|
||
const pendingSaveArgsRef = useRefMain(null); // { label, projectId } of the debounced save
|
||
|
||
useEffectMain(() => { latestData.current = data; }, [data]);
|
||
useEffectMain(() => { latestExpandedMap.current = expandedMap; }, [expandedMap]);
|
||
useEffectMain(() => { latestProjects.current = projects; }, [projects]);
|
||
useEffectMain(() => { latestProjectExpandedMap.current = projectExpandedMap; }, [projectExpandedMap]);
|
||
|
||
useEffectMain(() => {
|
||
document.documentElement.style.setProperty('--accent-hue', tweaks.accent_hue);
|
||
document.documentElement.dataset.density = tweaks.density;
|
||
document.documentElement.dataset.showIds = tweaks.show_ids ? 'true' : 'false';
|
||
document.documentElement.dataset.paper = tweaks.paper_texture ? 'true' : 'false';
|
||
}, [tweaks]);
|
||
|
||
useEffectMain(() => {
|
||
const apply = () => {
|
||
const t = tweaks.theme === 'system'
|
||
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||
: tweaks.theme;
|
||
document.documentElement.dataset.theme = t;
|
||
};
|
||
apply();
|
||
if (tweaks.theme !== 'system') return;
|
||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||
mq.addEventListener('change', apply);
|
||
return () => mq.removeEventListener('change', apply);
|
||
}, [tweaks.theme]);
|
||
|
||
// ---- Multi-project loading (api mode) ----
|
||
// Full reload of every registered project + merged data rebuild. Used by
|
||
// init, 'set-changed' polls, restores, and the admin flows (todos 6-8).
|
||
const reloadProjects = async ({ seedExpanded = false } = {}) => {
|
||
// Drain pending/in-flight saves first: replacing state from disk while
|
||
// edits sit in the 600ms debounce (or a save POST is mid-flight) would
|
||
// silently discard them. On flush failure abort — never clobber state.
|
||
try {
|
||
await flushPendingSave();
|
||
} catch (e) {
|
||
showToast('Save failed: ' + e.message, 'err');
|
||
return null;
|
||
}
|
||
const [projs, sizeInfo, backups] = await Promise.all([
|
||
ApiBackend.loadAllProjects(),
|
||
Storage.getHealthInfo(),
|
||
Storage.listBackups(),
|
||
]);
|
||
const newData = await buildMergedDataFromProjects(projs, { sizeInfo, backups });
|
||
latestProjects.current = projs;
|
||
setProjects(projs);
|
||
setData(newData);
|
||
if (seedExpanded && !loadLocalState()?.expandedMap) {
|
||
const em = {};
|
||
walkTree(newData.entries, it => { em[it.id] = !it.collapsed; });
|
||
setExpandedMap(em);
|
||
}
|
||
return { data: newData, projects: projs };
|
||
};
|
||
|
||
// SyncPoller callback for multi-project polling (api mode).
|
||
const handleExternalChangeMulti = async (kind, payload) => {
|
||
if (kind === 'warn') {
|
||
showToast('File changed externally — you have unsaved edits', 'warn');
|
||
} else if (kind === 'set-changed') {
|
||
try {
|
||
await reloadProjects({ seedExpanded: false });
|
||
showToast('Projects changed on server — reloaded');
|
||
} catch (e) {
|
||
console.error('Project set reload failed:', e); // keep current state; next poll retries
|
||
}
|
||
} else if (kind === 'project-reloaded' && payload) {
|
||
const { name, parsed } = payload;
|
||
const cur = latestProjects.current;
|
||
const idx = cur.findIndex(p => p.id === name);
|
||
if (idx === -1) return; // unknown project — a set-changed poll will pick it up
|
||
const newProjs = cur.map(p => p.id === name
|
||
? { ...p, entries: parsed.entries, history: parsed.history, meta: parsed.meta, checksumOk: parsed.checksumOk, missing: false, error: null }
|
||
: p);
|
||
latestProjects.current = newProjs;
|
||
setProjects(newProjs);
|
||
const merged = await buildMergedDataFromProjects(newProjs, {
|
||
sizeInfo: latestData.current.health,
|
||
backups: latestData.current.backups,
|
||
});
|
||
setData(merged);
|
||
showToast(`Reloaded ${name}`);
|
||
}
|
||
};
|
||
|
||
// Register a project and refresh every derived piece of state. Shared by
|
||
// the ItemDialog "Project" type (todo 7) and the admin Projects card
|
||
// register form (todo 8) — one implementation, two callers.
|
||
const registerProjectAndRefresh = async (path, name = null) => {
|
||
const result = await ApiBackend.registerProject(path, name);
|
||
if (!result.ok) throw new Error(result.error || 'Register failed');
|
||
await reloadProjects({ seedExpanded: false });
|
||
await SyncPoller.syncChecksums();
|
||
return result;
|
||
};
|
||
|
||
// ---- Storage initialisation (runs once on mount) ----
|
||
useEffectMain(() => {
|
||
let cancelled = false;
|
||
|
||
async function initStorage() {
|
||
try {
|
||
const mode = await Storage.detect();
|
||
setStorageMode(mode);
|
||
|
||
if (mode === 'local') { setIsLoading(false); return; }
|
||
|
||
if (mode === 'browser') {
|
||
if (cancelled) return;
|
||
await applyStorageData(mode, () => cancelled);
|
||
return;
|
||
}
|
||
|
||
if (mode === 'direct') {
|
||
// Try silent reconnect — works if permission is still active from a previous session.
|
||
const ok = await Storage.tryAutoConnect();
|
||
if (!ok) {
|
||
// Can't connect without a user gesture — show the connect button instead of blocking.
|
||
setIsLoading(false);
|
||
setNeedsConnect(true);
|
||
return;
|
||
}
|
||
}
|
||
// API mode needs no init — server is already running.
|
||
|
||
if (cancelled) return;
|
||
if (mode === 'api') {
|
||
await applyProjectsData(() => cancelled);
|
||
} else {
|
||
await applyStorageData(mode, () => cancelled);
|
||
}
|
||
} catch (e) {
|
||
if (cancelled) return;
|
||
setStorageMode('local');
|
||
showToast('Storage init failed: ' + e.message, 'err');
|
||
} finally {
|
||
if (!cancelled) setIsLoading(false);
|
||
}
|
||
}
|
||
|
||
async function applyProjectsData(isCancelled) {
|
||
const { data: newData, projects: projs } = await reloadProjects({ seedExpanded: true });
|
||
if (isCancelled?.()) return;
|
||
|
||
const saved = loadLocalState();
|
||
const pem = {};
|
||
for (const p of projs) pem[p.id] = saved?.projectExpandedMap?.[p.id] ?? true;
|
||
setProjectExpandedMap(pem);
|
||
|
||
if (projs.some(p => !p.checksumOk && p.meta)) setShowWarning(true);
|
||
|
||
SyncPoller.startMulti({
|
||
isDirty: () => isDirtyRef.current,
|
||
onExternalChange: handleExternalChangeMulti,
|
||
});
|
||
}
|
||
|
||
async function applyStorageData(mode, isCancelled) {
|
||
const [raw, backups, sizeInfo] = await Promise.all([
|
||
Storage.load(),
|
||
Storage.listBackups(),
|
||
Storage.getHealthInfo(),
|
||
]);
|
||
if (isCancelled?.()) return;
|
||
|
||
const parsed = await Parser.parse(raw?.content || '');
|
||
const newData = await buildDataFromStorage(parsed, backups, mode || Storage.mode, sizeInfo);
|
||
|
||
if (isCancelled?.()) return;
|
||
setData(newData);
|
||
|
||
if (!loadLocalState()?.expandedMap) {
|
||
const em = {};
|
||
walkTree(newData.entries, it => { em[it.id] = !it.collapsed; });
|
||
setExpandedMap(em);
|
||
}
|
||
|
||
if (!parsed.checksumOk && parsed.meta) setShowWarning(true);
|
||
|
||
SyncPoller.lastChecksum = parsed.meta?.checksum || '';
|
||
SyncPoller.start({
|
||
isDirty: () => isDirtyRef.current,
|
||
onExternalChange: async (kind, freshParsed) => {
|
||
if (kind === 'warn') {
|
||
showToast('File changed externally — you have unsaved edits', 'warn');
|
||
} else if (kind === 'reload' && freshParsed) {
|
||
const [nb, ns] = await Promise.all([Storage.listBackups(), Storage.getHealthInfo()]);
|
||
setData(await buildDataFromStorage(freshParsed, nb, Storage.mode, ns));
|
||
showToast('Reloaded from disk');
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
|
||
initStorage();
|
||
return () => { cancelled = true; SyncPoller.stop(); };
|
||
}, []);
|
||
|
||
// Persist expanded/collapsed row state and tweaks locally. Data itself lives in backlog.md.
|
||
useEffectMain(() => {
|
||
saveLocalState(latestExpandedMap.current, tweaks, latestProjectExpandedMap.current);
|
||
}, [expandedMap, projectExpandedMap, tweaks]);
|
||
|
||
const showToast = (msg, kind = 'ok') => {
|
||
setToast({ msg, kind, t: Date.now() });
|
||
setTimeout(() => setToast(t => (t && Date.now() - t.t >= 2400) ? null : t), 2500);
|
||
};
|
||
|
||
// ---- Connect to backlog.md (user-triggered, called from the connect banner) ----
|
||
const handleConnect = async () => {
|
||
try {
|
||
await Storage.connect();
|
||
const [raw, backups, sizeInfo] = await Promise.all([
|
||
Storage.load(),
|
||
Storage.listBackups(),
|
||
Storage.getHealthInfo(),
|
||
]);
|
||
const parsed = await Parser.parse(raw?.content || '');
|
||
const newData = await buildDataFromStorage(parsed, backups, Storage.mode, sizeInfo);
|
||
setData(newData);
|
||
if (!loadLocalState()?.expandedMap) {
|
||
const em = {};
|
||
walkTree(newData.entries, it => { em[it.id] = !it.collapsed; });
|
||
setExpandedMap(em);
|
||
}
|
||
if (!parsed.checksumOk && parsed.meta) setShowWarning(true);
|
||
SyncPoller.lastChecksum = parsed.meta?.checksum || '';
|
||
SyncPoller.start({
|
||
isDirty: () => isDirtyRef.current,
|
||
onExternalChange: async (kind, freshParsed) => {
|
||
if (kind === 'warn') {
|
||
showToast('File changed externally — you have unsaved edits', 'warn');
|
||
} else if (kind === 'reload' && freshParsed) {
|
||
const [nb, ns] = await Promise.all([Storage.listBackups(), Storage.getHealthInfo()]);
|
||
setData(await buildDataFromStorage(freshParsed, nb, Storage.mode, ns));
|
||
showToast('Reloaded from disk');
|
||
}
|
||
},
|
||
});
|
||
setNeedsConnect(false);
|
||
} catch (e) {
|
||
showToast('Connect failed: ' + e.message, 'err');
|
||
}
|
||
};
|
||
|
||
// ---- Real async save ----
|
||
// projectId (string or array) restricts the save to that project(s);
|
||
// omitted = derive the dirty set from _projectId tags.
|
||
// Returns null on success or the caught error (dirty stays set on failure).
|
||
const performSave = async (label, projectId = null) => {
|
||
const d = latestData.current;
|
||
const em = latestExpandedMap.current;
|
||
try {
|
||
if (Storage.mode === 'api') {
|
||
// Unified api save path: serialize every DIRTY project separately.
|
||
// A single registered project is the same loop with one element.
|
||
const fallbackFirst = latestProjects.current[0]?.id ?? null;
|
||
const dirtyIds = computeDirtyProjectIds(d, projectId, fallbackFirst);
|
||
for (const pId of dirtyIds) {
|
||
const projectEntries = d.entries.filter(e => (e._projectId ?? fallbackFirst) === pId);
|
||
const projectHistory = d.history.filter(h => (h._projectId ?? fallbackFirst) === pId);
|
||
const content = await Parser.serialize(toDiskIds(projectEntries, projectHistory, pId));
|
||
const result = await ApiBackend.saveProject(pId, content);
|
||
// Keep SyncPoller in sync with our own save to avoid false external-change triggers.
|
||
if (result?.checksum) SyncPoller.lastChecksums[pId] = result.checksum;
|
||
}
|
||
} else if (Storage.isConnected()) {
|
||
const content = await Parser.serialize({ entries: d.entries, history: d.history });
|
||
await Storage.save(content);
|
||
// Keep SyncPoller in sync with our own save to avoid false external-change triggers.
|
||
const cm = content.match(/checksum:\s*(sha256:[a-f0-9]+)/);
|
||
if (cm) SyncPoller.lastChecksum = cm[1];
|
||
}
|
||
saveLocalState(em, tweaks, latestProjectExpandedMap.current);
|
||
isDirtyRef.current = false;
|
||
const now = new Date().toISOString();
|
||
setSaveState({ status: 'saved', lastSaved: now });
|
||
setData(prev => ({
|
||
...prev,
|
||
health: { ...prev.health, lastSave: now },
|
||
meta: { ...prev.meta, saved: now },
|
||
}));
|
||
if (label) showToast(label);
|
||
return null;
|
||
} catch (e) {
|
||
setSaveState(prev => ({ ...prev, status: 'error' }));
|
||
showToast('Save failed: ' + e.message, 'err');
|
||
return e;
|
||
}
|
||
};
|
||
|
||
// Run performSave under in-flight tracking: the ref holds the promise
|
||
// while it runs and clears on settle (only if still the same promise).
|
||
const startTrackedSave = (label, projectId = null) => {
|
||
const p = performSave(label, projectId).finally(() => {
|
||
if (saveInFlightRef.current === p) saveInFlightRef.current = null;
|
||
});
|
||
saveInFlightRef.current = p;
|
||
return p;
|
||
};
|
||
|
||
const triggerSave = (label, projectId = null) => {
|
||
isDirtyRef.current = true;
|
||
setSaveState(prev => ({ ...prev, status: 'saving' }));
|
||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||
pendingSaveArgsRef.current = { label, projectId };
|
||
|
||
saveTimerRef.current = setTimeout(() => {
|
||
saveTimerRef.current = null;
|
||
const { label, projectId } = pendingSaveArgsRef.current ?? {};
|
||
pendingSaveArgsRef.current = null;
|
||
startTrackedSave(label, projectId);
|
||
}, 600);
|
||
};
|
||
|
||
// Drain pending edits into disk before a full state reload: fire the
|
||
// debounced save now, wait out any save already in flight, and retry once
|
||
// if edits landed meanwhile. No-op when clean. Mirrors the guard
|
||
// SyncPoller._tickMulti implements for its own path. Rethrows the error
|
||
// on failure — dirty is left set so the pending edits survive.
|
||
const flushPendingSave = async () => {
|
||
let err = null;
|
||
if (saveTimerRef.current) {
|
||
clearTimeout(saveTimerRef.current);
|
||
saveTimerRef.current = null;
|
||
const args = pendingSaveArgsRef.current;
|
||
pendingSaveArgsRef.current = null;
|
||
err = await startTrackedSave(args?.label ?? null, args?.projectId ?? null);
|
||
}
|
||
if (saveInFlightRef.current) {
|
||
const e = await saveInFlightRef.current;
|
||
err = err ?? e;
|
||
}
|
||
if (isDirtyRef.current) {
|
||
err = await startTrackedSave(null, null);
|
||
}
|
||
if (err) throw err;
|
||
};
|
||
|
||
const mutate = (fn) => {
|
||
isDirtyRef.current = true;
|
||
setData(d => { const c = structuredClone(d); fn(c); return c; });
|
||
};
|
||
|
||
// ---- Helpers ----
|
||
function findParentList(items, id, parent = null) {
|
||
for (const it of items) {
|
||
if (it.id === id) return { list: items, parent };
|
||
if (it.children?.length) {
|
||
const r = findParentList(it.children, id, it);
|
||
if (r) return r;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const recentTags = useMemoMain(() => {
|
||
const counts = {};
|
||
walkTree(data.entries, it => (it.tags || []).forEach(t => counts[t] = (counts[t] || 0) + 1));
|
||
return Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([t]) => t);
|
||
}, [data]);
|
||
|
||
// ---- Mutations ----
|
||
const onMutate = {
|
||
setStatus: (id, status) => {
|
||
const item = findItem(data.entries, id);
|
||
mutate(d => {
|
||
const it = findItem(d.entries, id);
|
||
if (!it || it.status === status) return;
|
||
const histEntry = { timestamp: new Date().toISOString(), itemId: id, action: 'status_changed', details: `${it.status} → ${status}` };
|
||
if (it._projectId) histEntry._projectId = it._projectId;
|
||
d.history.unshift(histEntry);
|
||
const wasDone = it.status === 'done';
|
||
it.status = status;
|
||
if (status !== 'blocked') it.reason = null;
|
||
if (status === 'done') it.progress = 100;
|
||
else if (wasDone && (it.progress ?? 0) >= 100) it.progress = 75;
|
||
});
|
||
triggerSave(null, item?._projectId);
|
||
},
|
||
|
||
setPriority: (id, priority) => {
|
||
const item = findItem(data.entries, id);
|
||
mutate(d => {
|
||
const it = findItem(d.entries, id);
|
||
if (!it || it.priority === priority) return;
|
||
const histEntry = { timestamp: new Date().toISOString(), itemId: id, action: 'priority_changed', details: `${it.priority} → ${priority}` };
|
||
if (it._projectId) histEntry._projectId = it._projectId;
|
||
d.history.unshift(histEntry);
|
||
it.priority = priority;
|
||
});
|
||
triggerSave(null, item?._projectId);
|
||
},
|
||
|
||
setProgress: (id, progress) => {
|
||
const item = findItem(data.entries, id);
|
||
mutate(d => {
|
||
const it = findItem(d.entries, id);
|
||
if (!it) return;
|
||
const v = snapProgress(progress);
|
||
if ((it.progress ?? 0) === v) return;
|
||
const before = it.progress ?? 0;
|
||
it.progress = v;
|
||
const histEntry = { timestamp: new Date().toISOString(), itemId: id, action: 'progress_changed', details: `${before}% → ${v}%` };
|
||
if (it._projectId) histEntry._projectId = it._projectId;
|
||
d.history.unshift(histEntry);
|
||
});
|
||
triggerSave(null, item?._projectId);
|
||
},
|
||
|
||
moveWithinPriority: (id, dir) => {
|
||
const item = findItem(data.entries, id);
|
||
mutate(d => {
|
||
const r = findParentList(d.entries, id);
|
||
if (!r) return;
|
||
const i = r.list.findIndex(x => x.id === id);
|
||
if (i < 0) return;
|
||
const me = r.list[i];
|
||
let j = i + dir;
|
||
while (j >= 0 && j < r.list.length && r.list[j].priority !== me.priority) j += dir;
|
||
if (j < 0 || j >= r.list.length) return;
|
||
[r.list[i], r.list[j]] = [r.list[j], r.list[i]];
|
||
const histEntry = { timestamp: new Date().toISOString(), itemId: id, action: 'item_reordered', details: `moved ${dir < 0 ? 'up' : 'down'}` };
|
||
if (me._projectId) histEntry._projectId = me._projectId;
|
||
d.history.unshift(histEntry);
|
||
});
|
||
triggerSave(null, item?._projectId);
|
||
},
|
||
|
||
reorder: (draggedId, targetId) => {
|
||
const item = findItem(data.entries, draggedId);
|
||
mutate(d => {
|
||
const src = findParentList(d.entries, draggedId);
|
||
const tgt = findParentList(d.entries, targetId);
|
||
if (!src || !tgt || src.list !== tgt.list) return;
|
||
const draggedIdx = src.list.findIndex(x => x.id === draggedId);
|
||
const dragged = src.list[draggedIdx];
|
||
src.list.splice(draggedIdx, 1);
|
||
const newTargetIdx = tgt.list.findIndex(x => x.id === targetId);
|
||
tgt.list.splice(newTargetIdx, 0, dragged);
|
||
const histEntry = { timestamp: new Date().toISOString(), itemId: draggedId, action: 'item_reordered', details: `dropped before ${targetId}` };
|
||
if (dragged._projectId) histEntry._projectId = dragged._projectId;
|
||
d.history.unshift(histEntry);
|
||
});
|
||
triggerSave('Reordered', item?._projectId);
|
||
},
|
||
|
||
// Cross-project drag&drop (todo 6 section drop zones call this): move the
|
||
// node to the destination project's root, re-tagging the whole subtree.
|
||
// Both source and destination files change, so both are saved.
|
||
moveToProject: (draggedId, projectId) => {
|
||
const dragged = findItem(data.entries, draggedId);
|
||
if (!dragged || !projectId || dragged._projectId === projectId) return;
|
||
const srcProjectId = dragged._projectId ?? latestProjects.current[0]?.id ?? null;
|
||
mutate(d => {
|
||
const r = findParentList(d.entries, draggedId);
|
||
if (!r) return;
|
||
const i = r.list.findIndex(x => x.id === draggedId);
|
||
if (i < 0) return;
|
||
const [node] = r.list.splice(i, 1);
|
||
retagSubtree(node, projectId);
|
||
(function setLevel(it, lv) { it.level = lv; (it.children || []).forEach(c => setLevel(c, lv + 1)); })(node, 1);
|
||
d.entries.push(node);
|
||
const histEntry = { timestamp: new Date().toISOString(), itemId: draggedId, action: 'item_reordered', details: `moved to project ${projectId}` };
|
||
histEntry._projectId = projectId;
|
||
d.history.unshift(histEntry);
|
||
});
|
||
triggerSave('Moved', [srcProjectId, projectId]);
|
||
},
|
||
|
||
addChild: (parentId) => setItemDialog({ mode: 'add-child', parentId, initial: null }),
|
||
addRoot: (projectId = null) => setItemDialog({ mode: 'add', parentId: null, initial: null, projectId }),
|
||
|
||
editItem: (id) => {
|
||
const it = findItem(data.entries, id);
|
||
if (!it) return;
|
||
setItemDialog({ mode: 'edit', itemId: id, initial: it });
|
||
},
|
||
|
||
deleteItem: (id) => {
|
||
const it = findItem(data.entries, id);
|
||
if (!it) return;
|
||
const childCount = (() => { let n = 0; walkTree([it], () => n++); return n - 1; })();
|
||
const projectId = it._projectId ?? null;
|
||
setConfirm({
|
||
title: 'Delete this item?',
|
||
message: <>Delete <strong>{it.title}</strong>{childCount > 0 ? ` and ${childCount} sub-item${childCount > 1 ? 's' : ''}` : ''}?</>,
|
||
detail: <span className="muted">This will be recorded in the history log.</span>,
|
||
confirmLabel: 'Delete',
|
||
danger: true,
|
||
onConfirm: () => {
|
||
mutate(d => {
|
||
function remove(list) {
|
||
const i = list.findIndex(x => x.id === id);
|
||
if (i >= 0) { list.splice(i, 1); return true; }
|
||
for (const x of list) if (x.children?.length && remove(x.children)) return true;
|
||
return false;
|
||
}
|
||
remove(d.entries);
|
||
const histEntry = { timestamp: new Date().toISOString(), itemId: id, action: 'item_deleted', details: `final: ${it.status}` };
|
||
if (projectId) histEntry._projectId = projectId;
|
||
d.history.unshift(histEntry);
|
||
});
|
||
setConfirm(null);
|
||
triggerSave('Deleted', projectId);
|
||
},
|
||
});
|
||
},
|
||
|
||
archiveItem: async (id) => { try { await flushPendingSave(); } catch (e) { showToast('Save failed: ' + e.message, 'err'); return; } handleArchiveItems([id]); },
|
||
};
|
||
|
||
// Which project does a NEW item belong to? An explicit hint (dialog select
|
||
// or section context — todos 6-7) wins; otherwise the first registered
|
||
// project. Non-api modes have no projects → null → item stays untagged.
|
||
const resolveTargetProjectId = (hint = null) => {
|
||
if (hint) return hint;
|
||
const first = latestProjects.current[0];
|
||
return first ? first.id : null;
|
||
};
|
||
|
||
const submitItemDialog = (vals) => {
|
||
if (vals.createAsProject && vals.projectPath) {
|
||
const path = vals.projectPath;
|
||
setItemDialog(null);
|
||
registerProjectAndRefresh(path, vals.title || null)
|
||
.then(result => showToast(`Project registered: ${result.name}`))
|
||
.catch(e => showToast('Register failed: ' + e.message, 'err'));
|
||
return;
|
||
}
|
||
if (itemDialog.mode === 'edit') {
|
||
const editedItem = findItem(data.entries, itemDialog.itemId);
|
||
mutate(d => {
|
||
const x = findItem(d.entries, itemDialog.itemId);
|
||
if (!x) return;
|
||
const before = { priority: x.priority, status: x.status };
|
||
Object.assign(x, vals);
|
||
if (before.status !== vals.status) {
|
||
const histEntry = { timestamp: new Date().toISOString(), itemId: x.id, action: 'status_changed', details: `${before.status} → ${vals.status}` };
|
||
if (x._projectId) histEntry._projectId = x._projectId;
|
||
d.history.unshift(histEntry);
|
||
}
|
||
if (before.priority !== vals.priority) {
|
||
const histEntry = { timestamp: new Date().toISOString(), itemId: x.id, action: 'priority_changed', details: `${before.priority} → ${vals.priority}` };
|
||
if (x._projectId) histEntry._projectId = x._projectId;
|
||
d.history.unshift(histEntry);
|
||
}
|
||
});
|
||
triggerSave('Saved', editedItem?._projectId);
|
||
} else {
|
||
const newId = 'n-' + Math.random().toString(36).slice(2, 8);
|
||
const parentForTarget = itemDialog.mode === 'add-child' && itemDialog.parentId
|
||
? findItem(data.entries, itemDialog.parentId)
|
||
: null;
|
||
const targetProjectId = resolveTargetProjectId(
|
||
vals.projectId || itemDialog.projectId || parentForTarget?._projectId || null
|
||
);
|
||
mutate(d => {
|
||
const node = { id: newId, level: 1, ...vals, children: [], collapsed: false };
|
||
if (targetProjectId) node._projectId = targetProjectId;
|
||
if (parentForTarget) {
|
||
const parent = findItem(d.entries, itemDialog.parentId);
|
||
if (parent) {
|
||
parent.children = parent.children || [];
|
||
node.level = (parent.level || 1) + 1;
|
||
parent.children.push(node);
|
||
setExpandedMap(m => ({ ...m, [parent.id]: true }));
|
||
}
|
||
} else {
|
||
d.entries.push(node);
|
||
}
|
||
const histEntry = { timestamp: new Date().toISOString(), itemId: newId, action: 'item_created', details: vals.title };
|
||
if (targetProjectId) histEntry._projectId = targetProjectId;
|
||
d.history.unshift(histEntry);
|
||
});
|
||
triggerSave('Added', targetProjectId);
|
||
}
|
||
setItemDialog(null);
|
||
};
|
||
|
||
const setExpanded = (id, val) => setExpandedMap(m => ({ ...m, [id]: val }));
|
||
|
||
// ---- Project section actions (multi-project api mode, todo 6) ----
|
||
const toggleProjectExpanded = (projectId) => {
|
||
setProjectExpandedMap(m => {
|
||
const isExpanded = m[projectId] !== false;
|
||
return { ...m, [projectId]: !isExpanded };
|
||
});
|
||
};
|
||
|
||
// Cross-project drag&drop: tree rows only accept drops from their OWN tree
|
||
// (draggedId is local state per BacklogTree), so a drag that lands anywhere
|
||
// else in a section falls through to this drop zone. The dragged id travels
|
||
// in the text/plain payload set by the row's dragstart.
|
||
const sectionDragOver = (e) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
e.currentTarget.classList.add('drop-target');
|
||
};
|
||
const sectionDragLeave = (e) => {
|
||
if (e.target === e.currentTarget) e.currentTarget.classList.remove('drop-target');
|
||
};
|
||
const handleSectionDrop = (e, projectId) => {
|
||
e.preventDefault();
|
||
e.currentTarget.classList.remove('drop-target');
|
||
let draggedId = null;
|
||
try { draggedId = e.dataTransfer.getData('text/plain') || null; }
|
||
catch { draggedId = null; } // protected drag data — nothing sensible to move
|
||
if (!draggedId) return;
|
||
const dragged = findItem(data.entries, draggedId);
|
||
if (!dragged) return;
|
||
const fromId = dragged._projectId ?? latestProjects.current[0]?.id;
|
||
if (fromId === projectId) return; // intra-project drops stay the tree's own reorder
|
||
onMutate.moveToProject(draggedId, projectId);
|
||
};
|
||
|
||
const handleRenameProject = (projectId) => {
|
||
const proj = projects.find(p => p.id === projectId);
|
||
if (!proj) return;
|
||
setPrompt({
|
||
title: 'Rename project',
|
||
message: <>New name for <strong>{proj.name}</strong>?</>,
|
||
placeholder: proj.name,
|
||
confirmLabel: 'Rename',
|
||
onConfirm: async (newName) => {
|
||
if (!newName || newName === proj.name) return;
|
||
try {
|
||
// Flush pending edits under the CURRENT id first — after the
|
||
// rename POST a save tagged with the old id would 404 forever.
|
||
// (reloadProjects' own flush cannot help: it runs too late here.)
|
||
await flushPendingSave();
|
||
const result = await ApiBackend.renameProject(projectId, newName);
|
||
if (!result.ok) throw new Error(result.error || 'Rename failed');
|
||
setProjectExpandedMap(prev => {
|
||
const next = { ...prev };
|
||
const was = next[projectId] ?? true;
|
||
delete next[projectId];
|
||
next[result.name] = was;
|
||
return next;
|
||
});
|
||
await reloadProjects({ seedExpanded: false });
|
||
await SyncPoller.syncChecksums();
|
||
showToast(`Renamed to ${result.name}`);
|
||
} catch (e) {
|
||
showToast('Rename failed: ' + e.message, 'err');
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleRemoveProject = (projectId) => {
|
||
const proj = projects.find(p => p.id === projectId);
|
||
if (!proj) return;
|
||
const itemCount = countAll(proj.entries);
|
||
setConfirm({
|
||
title: 'Delete project?',
|
||
message: <>Delete <strong>{proj.name}</strong> from the registry?</>,
|
||
detail: (
|
||
<span className="muted">
|
||
{`The file contains ${itemCount} item${itemCount !== 1 ? 's' : ''}. `}
|
||
Unregistering keeps the file on disk.
|
||
</span>
|
||
),
|
||
checkbox: 'Also delete the file from disk',
|
||
confirmLabel: 'Delete',
|
||
danger: true,
|
||
onConfirm: async (deleteFromDisk) => {
|
||
const del = deleteFromDisk === true;
|
||
setConfirm(null);
|
||
try {
|
||
const result = await ApiBackend.unregisterProject(projectId, del);
|
||
if (!result.ok) throw new Error(result.error || 'Delete failed');
|
||
// Optimistic removal of every trace — a partial clean would resurrect
|
||
// the section on the next reloadProjects.
|
||
latestProjects.current = latestProjects.current.filter(p => p.id !== projectId);
|
||
setProjects(prev => prev.filter(p => p.id !== projectId));
|
||
setData(prev => ({
|
||
...prev,
|
||
entries: prev.entries.filter(e => e._projectId !== projectId),
|
||
history: prev.history.filter(h => h._projectId !== projectId),
|
||
}));
|
||
setProjectExpandedMap(prev => { const n = { ...prev }; delete n[projectId]; return n; });
|
||
await SyncPoller.syncChecksums();
|
||
showToast(result.warning
|
||
? `Removed ${proj.name} — file not deleted: ${result.warning}`
|
||
: del ? `Deleted from disk: ${proj.name}` : `Removed: ${proj.name}`);
|
||
} catch (e) {
|
||
showToast('Delete failed: ' + e.message, 'err');
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
|
||
const tagsList = useMemoMain(() => allTags(data.entries), [data]);
|
||
const counts = useMemoMain(() => countByStatus(data.entries), [data]);
|
||
|
||
const filtered = useMemoMain(() => {
|
||
const f = { ...filters, text: filters.text?.trim() || '' };
|
||
const noFilters = !f.statuses?.length && !f.priorities?.length && !f.tags?.length && !f.dueRange && !f.text;
|
||
let tree = noFilters ? data.entries : filterTree(data.entries, f);
|
||
tree = structuredClone(tree);
|
||
if (tweaks.sort_mode === 'priority') {
|
||
function sortRecur(list) {
|
||
list.sort((a, b) => PRIORITIES.indexOf(a.priority) - PRIORITIES.indexOf(b.priority));
|
||
list.forEach(it => it.children?.length && sortRecur(it.children));
|
||
}
|
||
sortRecur(tree);
|
||
}
|
||
if (f.scope === 'top') tree = tree.map(it => ({ ...it, children: [] }));
|
||
return tree;
|
||
}, [data, filters, tweaks.sort_mode]);
|
||
|
||
const filteredCount = useMemoMain(() => countAll(filtered), [filtered]);
|
||
const totalCount = useMemoMain(() => countAll(data.entries), [data]);
|
||
const archiveMatchCount = useMemoMain(() => {
|
||
if (!filters.text?.trim()) return 0;
|
||
const q = filters.text.trim().toLowerCase();
|
||
let count = 0;
|
||
walkTree(archiveData.entries, (item) => {
|
||
if (item.title.toLowerCase().includes(q) ||
|
||
item.body?.toLowerCase().includes(q) ||
|
||
item.tags?.some(t => t.toLowerCase().includes(q))) {
|
||
count++;
|
||
}
|
||
});
|
||
return count;
|
||
}, [filters.text, archiveData.entries]);
|
||
|
||
// ---- Keyboard shortcuts ----
|
||
useEffectMain(() => {
|
||
const onKey = (e) => {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||
if (e.key === '/' && !e.metaKey) { e.preventDefault(); document.querySelector('.search-input')?.focus(); }
|
||
if (e.key === 'g') setView('backlog');
|
||
if (e.key === 'a') setView('admin');
|
||
if (e.key === 'n') onMutate.addRoot();
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, []);
|
||
|
||
// ---- Restore from backup ----
|
||
const handleRestore = (b) => {
|
||
setConfirm({
|
||
title: 'Restore this backup?',
|
||
danger: false,
|
||
message: <>Overwrite <code className="mono">backlog.md</code> with the contents of this backup.</>,
|
||
detail: (
|
||
<>
|
||
<div className="mono small">{b.name}</div>
|
||
{!b.valid && (
|
||
<div className="restore-warn">
|
||
<Icon name="warn" size={14}/>
|
||
<div>
|
||
<div className="restore-warn-title">Checksum mismatch on this backup</div>
|
||
<div className="restore-warn-body">The file may be partially written or edited externally.</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
),
|
||
confirmLabel: 'Restore',
|
||
onConfirm: async () => {
|
||
setConfirm(null);
|
||
try {
|
||
if (Storage.isConnected()) {
|
||
const result = await Storage.restoreBackup(b.name);
|
||
if (!result.ok) throw new Error(result.error || 'Restore failed');
|
||
if (Storage.mode === 'api') {
|
||
// The legacy backup route restored the default project — refresh everything.
|
||
// Null = flush failed ('Save failed' toast shown there); keep dirty state, skip success toast.
|
||
if (!(await reloadProjects({ seedExpanded: false }))) return;
|
||
await SyncPoller.syncChecksums();
|
||
} else {
|
||
const [raw, backups, sizeInfo] = await Promise.all([Storage.load(), Storage.listBackups(), Storage.getHealthInfo()]);
|
||
const parsed = await Parser.parse(raw?.content || '');
|
||
const newData = await buildDataFromStorage(parsed, backups, storageMode, sizeInfo);
|
||
setData(newData);
|
||
SyncPoller.lastChecksum = parsed.meta?.checksum || '';
|
||
}
|
||
isDirtyRef.current = false;
|
||
}
|
||
showToast(`Restored from ${b.name.slice(0, 28)}…`);
|
||
} catch (e) {
|
||
showToast('Restore failed: ' + e.message, 'err');
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
// ---- Import entries from parsed content ----
|
||
const handleImport = ({ entries, history, targetProjectId }) => {
|
||
// Multi-project target (todo 8): the dialog's "Target project" select
|
||
// routes the file into ONE project. Its backlog.md is written directly
|
||
// (merge-only id suffixes stripped via toDiskIds), other projects'
|
||
// files are untouched, then everything reloads from the server.
|
||
if (Storage.mode === 'api' && targetProjectId) {
|
||
if (!entries.length && !(history || []).length) {
|
||
showToast('Import failed: no entries found in file', 'err');
|
||
return;
|
||
}
|
||
(async () => {
|
||
try {
|
||
const content = await Parser.serialize(toDiskIds(entries, history || [], targetProjectId));
|
||
const result = await ApiBackend.saveProject(targetProjectId, content);
|
||
if (!result.ok) throw new Error(result.error || 'Import failed');
|
||
if (result.checksum) SyncPoller.lastChecksums[targetProjectId] = result.checksum;
|
||
setImportExportOpen(false);
|
||
await reloadProjects({ seedExpanded: false });
|
||
await SyncPoller.syncChecksums();
|
||
showToast(`Imported into ${targetProjectId}`);
|
||
} catch (e) {
|
||
showToast('Import failed: ' + e.message, 'err');
|
||
}
|
||
})();
|
||
return;
|
||
}
|
||
// Single-project path: imported rows land in the first project (api
|
||
// mode with one registered project) or in the single backlog file.
|
||
const pid = Storage.mode === 'api' ? resolveTargetProjectId() : null;
|
||
mutate(d => {
|
||
if (pid) {
|
||
for (const e of entries) e._projectId = pid;
|
||
}
|
||
d.entries = entries;
|
||
if (history?.length) d.history = [...history.map(h => pid ? { ...h, _projectId: pid } : h), ...d.history];
|
||
const histEntry = { timestamp: new Date().toISOString(), itemId: 'system', action: 'imported', details: `${entries.length} top-level entries` };
|
||
if (pid) histEntry._projectId = pid;
|
||
d.history.unshift(histEntry);
|
||
});
|
||
setImportExportOpen(false);
|
||
triggerSave('Imported', pid);
|
||
};
|
||
|
||
// ---- Archive functions ----
|
||
const loadArchive = async () => {
|
||
try {
|
||
if (Storage.mode === 'api') {
|
||
// Each project has its own archive.md — merge them, tagged per project.
|
||
const projs = latestProjects.current;
|
||
const parsedAll = await Promise.all(projs.map(async p => {
|
||
try {
|
||
const raw = await ApiBackend.loadArchive(p.id);
|
||
if (raw.exists && raw.content) return await Parser.parse(raw.content);
|
||
} catch (e) { console.error(`Failed to load archive for ${p.id}:`, e); }
|
||
return { entries: [], history: [] };
|
||
}));
|
||
const entries = [], history = [];
|
||
projs.forEach((p, i) => {
|
||
for (const e of parsedAll[i].entries) e._projectId = p.id;
|
||
for (const h of parsedAll[i].history) h._projectId = p.id;
|
||
entries.push(...parsedAll[i].entries);
|
||
history.push(...parsedAll[i].history);
|
||
});
|
||
setArchiveData({ entries, history });
|
||
} else {
|
||
const raw = await Storage.loadArchive();
|
||
if (raw.exists && raw.content) {
|
||
const parsed = await Parser.parse(raw.content);
|
||
setArchiveData({ entries: parsed.entries || [], history: parsed.history || [] });
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load archive:', e);
|
||
}
|
||
};
|
||
|
||
// Load archive when switching to admin view
|
||
useEffectMain(() => {
|
||
if (view === 'admin') loadArchive();
|
||
}, [view]);
|
||
|
||
const handleArchiveItems = async (itemIds) => {
|
||
const now = new Date().toISOString().slice(0, 10);
|
||
const toArchive = [];
|
||
|
||
// Build items to archive with restore-path metadata
|
||
for (const id of itemIds) {
|
||
const item = findItem(data.entries, id);
|
||
if (!item) continue;
|
||
const restorePath = getRestorePath(data.entries, id);
|
||
// Deep clone and add archive metadata
|
||
const cloned = JSON.parse(JSON.stringify(item));
|
||
cloned.archived = now;
|
||
cloned.restorePath = restorePath || null;
|
||
toArchive.push(cloned);
|
||
}
|
||
|
||
if (toArchive.length === 0) return;
|
||
|
||
// Remove from backlog
|
||
const { entries: newEntries } = removeItems([...data.entries], itemIds);
|
||
|
||
// Add history entries
|
||
const newHistory = [...data.history];
|
||
for (const item of toArchive) {
|
||
const histEntry = {
|
||
timestamp: new Date().toISOString(),
|
||
itemId: item.id,
|
||
action: 'item_archived',
|
||
details: `moved to archive.md`
|
||
};
|
||
if (item._projectId) histEntry._projectId = item._projectId;
|
||
newHistory.unshift(histEntry);
|
||
}
|
||
|
||
// Add to archive
|
||
const newArchiveEntries = [...archiveData.entries, ...toArchive];
|
||
|
||
try {
|
||
if (Storage.mode === 'api') {
|
||
// Per-project archive: group the batch by _projectId, then for each
|
||
// affected project write its remaining backlog + its own archive.
|
||
const fallbackFirst = latestProjects.current[0]?.id ?? null;
|
||
const pidOf = x => x._projectId ?? fallbackFirst;
|
||
const groups = new Map();
|
||
for (const item of toArchive) {
|
||
const pid = pidOf(item);
|
||
if (!groups.has(pid)) groups.set(pid, []);
|
||
groups.get(pid).push(item);
|
||
}
|
||
const existing = await Promise.all([...groups.keys()].map(async pid => {
|
||
try {
|
||
const raw = await ApiBackend.loadArchive(pid);
|
||
if (raw.exists && raw.content) {
|
||
const parsed = await Parser.parse(raw.content);
|
||
return { pid, entries: parsed.entries || [], history: parsed.history || [] };
|
||
}
|
||
} catch (e) { console.error(`Failed to load archive for ${pid}:`, e); }
|
||
return { pid, entries: [], history: [] };
|
||
}));
|
||
for (const { pid, entries: archEntries, history: archHistory } of existing) {
|
||
const backlogContent = await Parser.serialize(toDiskIds(
|
||
newEntries.filter(e => pidOf(e) === pid),
|
||
newHistory.filter(h => pidOf(h) === pid),
|
||
pid,
|
||
));
|
||
const archiveContent = await Parser.serialize(toDiskIds(
|
||
[...archEntries, ...groups.get(pid)],
|
||
archHistory,
|
||
pid,
|
||
));
|
||
const result = await ApiBackend.saveArchive(backlogContent, archiveContent, pid);
|
||
if (!result.ok) throw new Error('Archive failed');
|
||
}
|
||
await SyncPoller.syncChecksums();
|
||
} else {
|
||
// Serialize both files
|
||
const backlogContent = await Parser.serialize({ entries: newEntries, history: newHistory });
|
||
const archiveContent = await Parser.serialize({ entries: newArchiveEntries, history: archiveData.history });
|
||
|
||
// Save both atomically
|
||
const result = await Storage.saveArchive(backlogContent, archiveContent);
|
||
if (!result.ok) throw new Error('Archive failed');
|
||
}
|
||
|
||
// Update local state
|
||
setData(d => ({ ...d, entries: newEntries, history: newHistory }));
|
||
if (Storage.mode === 'api') {
|
||
await loadArchive(); // refresh the merged per-project archive view from disk
|
||
} else {
|
||
setArchiveData({ entries: newArchiveEntries, history: archiveData.history });
|
||
}
|
||
isDirtyRef.current = false;
|
||
|
||
showToast(`Archived ${toArchive.length} item${toArchive.length > 1 ? 's' : ''}`);
|
||
} catch (e) {
|
||
showToast('Archive failed: ' + e.message, 'err');
|
||
}
|
||
};
|
||
|
||
const handleRestoreItems = async (itemIds) => {
|
||
const toRestore = [];
|
||
const remainingArchive = [];
|
||
|
||
// Find items to restore
|
||
for (const item of archiveData.entries) {
|
||
if (itemIds.includes(item.id)) {
|
||
toRestore.push(item);
|
||
} else {
|
||
remainingArchive.push(item);
|
||
}
|
||
}
|
||
|
||
if (toRestore.length === 0) return;
|
||
|
||
// Restore to backlog
|
||
const newEntries = [...data.entries];
|
||
const warnings = [];
|
||
for (const item of toRestore) {
|
||
const restorePath = item.restorePath;
|
||
// Clear archive metadata
|
||
delete item.archived;
|
||
delete item.restorePath;
|
||
// Try to insert at original location
|
||
const inserted = insertAtPath(newEntries, item, restorePath);
|
||
if (!inserted) {
|
||
warnings.push(item.title);
|
||
}
|
||
}
|
||
|
||
// Add history entries
|
||
const newHistory = [...data.history];
|
||
for (const item of toRestore) {
|
||
const histEntry = {
|
||
timestamp: new Date().toISOString(),
|
||
itemId: item.id,
|
||
action: 'item_restored',
|
||
details: `restored from archive`
|
||
};
|
||
if (item._projectId) histEntry._projectId = item._projectId;
|
||
newHistory.unshift(histEntry);
|
||
}
|
||
|
||
try {
|
||
if (Storage.mode === 'api') {
|
||
// Mirror of the archive flow per project: only projects whose items
|
||
// were restored get their backlog + archive rewritten.
|
||
const fallbackFirst = latestProjects.current[0]?.id ?? null;
|
||
const pidOf = x => x._projectId ?? fallbackFirst;
|
||
for (const pid of [...new Set(toRestore.map(pidOf))]) {
|
||
const backlogContent = await Parser.serialize(toDiskIds(
|
||
newEntries.filter(e => pidOf(e) === pid),
|
||
newHistory.filter(h => pidOf(h) === pid),
|
||
pid,
|
||
));
|
||
const archiveContent = await Parser.serialize(toDiskIds(
|
||
remainingArchive.filter(e => pidOf(e) === pid),
|
||
archiveData.history.filter(h => pidOf(h) === pid),
|
||
pid,
|
||
));
|
||
const result = await ApiBackend.restoreFromArchive(backlogContent, archiveContent, pid);
|
||
if (!result.ok) throw new Error('Restore failed');
|
||
}
|
||
await SyncPoller.syncChecksums();
|
||
} else {
|
||
// Serialize both files
|
||
const backlogContent = await Parser.serialize({ entries: newEntries, history: newHistory });
|
||
const archiveContent = await Parser.serialize({ entries: remainingArchive, history: archiveData.history });
|
||
|
||
// Save both atomically
|
||
const result = await Storage.restoreFromArchive(backlogContent, archiveContent);
|
||
if (!result.ok) throw new Error('Restore failed');
|
||
}
|
||
|
||
// Update local state
|
||
setData(d => ({ ...d, entries: newEntries, history: newHistory }));
|
||
if (Storage.mode === 'api') {
|
||
await loadArchive(); // refresh the merged per-project archive view from disk
|
||
} else {
|
||
setArchiveData({ entries: remainingArchive, history: archiveData.history });
|
||
}
|
||
isDirtyRef.current = false;
|
||
|
||
let msg = `Restored ${toRestore.length} item${toRestore.length > 1 ? 's' : ''}`;
|
||
if (warnings.length) msg += ` (${warnings.length} to root - parent not found)`;
|
||
showToast(msg);
|
||
} catch (e) {
|
||
showToast('Restore failed: ' + e.message, 'err');
|
||
}
|
||
};
|
||
|
||
const saveLabel = saveState.status === 'saving' ? 'Saving…'
|
||
: saveState.status === 'error' ? 'Save failed'
|
||
: `Saved · ${fmtTimestamp(saveState.lastSaved)}`;
|
||
const hasFilters = filters.text || filters.statuses?.length || filters.priorities?.length || filters.tags?.length || filters.dueRange;
|
||
const isMulti = projects.length > 1; // UI gating only — the merged model is the single data path
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="app" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
|
||
<div style={{ textAlign: 'center', color: 'var(--ink-3)' }}>
|
||
<div style={{ fontSize: 22, marginBottom: 8 }}>Loading…</div>
|
||
<div style={{ fontSize: 13 }}>Connecting to storage</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="app">
|
||
<StatusStyleContext.Provider value={tweaks.status_style}>
|
||
<Header
|
||
view={view} setView={setView}
|
||
saveState={saveState} saveLabel={saveLabel}
|
||
storageMode={storageMode}
|
||
searchValue={filters.text}
|
||
onSearch={(v) => setFilters({ ...filters, text: v })}
|
||
onForceSave={() => triggerSave('Saved manually')}
|
||
onOpenImportExport={() => setImportExportOpen(true)}
|
||
/>
|
||
|
||
{storageMode === 'local' && (
|
||
<div className="banner warn" style={{gap:8}}>
|
||
<Icon name="warn" size={14}/>
|
||
<span>
|
||
This browser can't access local files directly — <strong>changes won't be saved.</strong>{' '}
|
||
Open in Chrome or Edge to save without a server, or run{' '}
|
||
<code className="mono">python3 server/server.py</code> for any browser.
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{storageMode === 'browser' && (
|
||
<div className="banner info" style={{gap:8}}>
|
||
<Icon name="folder" size={14}/>
|
||
<span>
|
||
Data saved in <strong>browser storage</strong> — persists until you clear site data.{' '}
|
||
Use <em>Import/Export</em> to back up to a file.
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{needsConnect && (
|
||
<div className="banner info">
|
||
<Icon name="folder" size={14}/>
|
||
No <code className="mono">backlog.md</code> connected — grant one-time folder access, then reloads are silent.
|
||
<button className="btn-primary" style={{marginLeft:'auto',flexShrink:0,fontSize:12,padding:'3px 10px'}}
|
||
onClick={handleConnect}>Open folder…</button>
|
||
</div>
|
||
)}
|
||
|
||
{showWarning && (
|
||
<div className="banner warn">
|
||
<Icon name="warn" size={14}/>
|
||
File was edited outside the app — checksum mismatch. The next save will rewrite a correct marker.
|
||
<button className="banner-close" onClick={() => setShowWarning(false)}>dismiss</button>
|
||
</div>
|
||
)}
|
||
|
||
{view === 'backlog' ? (
|
||
<div className="main">
|
||
<FilterPanel filters={filters} setFilters={setFilters} tagsList={tagsList} counts={counts}/>
|
||
<section className="content">
|
||
<div className="content-sticky">
|
||
<div className="content-head">
|
||
<div>
|
||
<div className="eyebrow">Backlog</div>
|
||
<h1 className="content-title">
|
||
{hasFilters ? (
|
||
<>
|
||
{filteredCount} of {totalCount} items
|
||
{archiveMatchCount > 0 && <> · {archiveMatchCount} in archive</>}
|
||
</>
|
||
) : (
|
||
<>{totalCount} items</>
|
||
)}
|
||
</h1>
|
||
</div>
|
||
<div className="content-head-actions">
|
||
<div className="seg seg-mini" title="Order">
|
||
<button className={`seg-btn ${tweaks.sort_mode === 'priority' ? 'active' : ''}`}
|
||
onClick={() => setTweak('sort_mode', 'priority')}>By priority</button>
|
||
<button className={`seg-btn ${tweaks.sort_mode === 'manual' ? 'active' : ''}`}
|
||
onClick={() => setTweak('sort_mode', 'manual')}>Manual</button>
|
||
</div>
|
||
<button className="btn-primary" onClick={onMutate.addRoot}>
|
||
<Icon name="plus" size={12}/> New item
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<ViewChips filters={filters} setFilters={setFilters}/>
|
||
|
||
<div className="legend">
|
||
<span className="legend-cap">Status:</span>
|
||
{STATUSES.map(s => (
|
||
<span key={s.key} className="legend-item">
|
||
<StatusIcon status={s.key} size={13}/>
|
||
<span>{s.label}</span>
|
||
</span>
|
||
))}
|
||
<span className="legend-sep">·</span>
|
||
<span className="legend-hint">
|
||
{tweaks.sort_mode === 'manual' ? <>Drag rows to reorder · </> : <>Auto-sorted by priority · drag/arrows reorder within priority · </>}
|
||
<kbd>/</kbd> search · <kbd>n</kbd> new
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="content-scroll">
|
||
{isMulti && projects.length > 0 ? (
|
||
<div className="project-sections">
|
||
{projects.map(p => {
|
||
const pidOf = e => e._projectId ?? projects[0]?.id;
|
||
const projEntries = filtered.filter(e => pidOf(e) === p.id);
|
||
const itemCount = countAll(data.entries.filter(e => pidOf(e) === p.id));
|
||
const isExpanded = projectExpandedMap[p.id] !== false;
|
||
const unhealthy = p.missing || p.error;
|
||
return (
|
||
<div key={p.id} className="project-section">
|
||
<div
|
||
className={`project-section-header ${isExpanded ? 'expanded' : 'collapsed'}${unhealthy ? ' missing' : ''}`}
|
||
onClick={() => toggleProjectExpanded(p.id)}
|
||
title={p.path}
|
||
>
|
||
<span className="project-section-chevron">
|
||
<Icon name={isExpanded ? 'chevron' : 'chevronRight'} size={12}/>
|
||
</span>
|
||
<span className="project-section-name">{p.name}</span>
|
||
{unhealthy && <span className="project-missing-badge">{p.missing ? 'missing' : 'error'}</span>}
|
||
<span className="project-section-count">{itemCount} item{itemCount !== 1 ? 's' : ''}</span>
|
||
<div className="project-section-actions">
|
||
<button className="project-section-btn"
|
||
title={`Add item to ${p.name}`}
|
||
onClick={(e) => { e.stopPropagation(); onMutate.addRoot(p.id); }}>
|
||
<Icon name="plus" size={11}/>
|
||
</button>
|
||
<button className="project-section-btn"
|
||
title="Rename project"
|
||
onClick={(e) => { e.stopPropagation(); handleRenameProject(p.id); }}>
|
||
<Icon name="edit" size={11}/>
|
||
</button>
|
||
<button className="project-section-btn project-section-btn-danger"
|
||
title="Remove project from registry"
|
||
onClick={(e) => { e.stopPropagation(); handleRemoveProject(p.id); }}>
|
||
<Icon name="trash" size={11}/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{isExpanded && (
|
||
<div
|
||
className="project-section-content"
|
||
onDragOver={sectionDragOver}
|
||
onDragLeave={sectionDragLeave}
|
||
onDrop={(e) => handleSectionDrop(e, p.id)}
|
||
>
|
||
{unhealthy ? (
|
||
<div className="project-empty">
|
||
{p.missing
|
||
? <>File not found on disk — restore it, or remove this project. <span className="mono">{p.path}</span></>
|
||
: <>Failed to load this project: {p.error}</>}
|
||
</div>
|
||
) : projEntries.length === 0 ? (
|
||
<div className="project-empty">{hasFilters ? 'No items match these filters.' : 'No items in this project yet.'}</div>
|
||
) : (
|
||
<BacklogTree
|
||
items={projEntries}
|
||
expandedMap={expandedMap}
|
||
setExpanded={setExpanded}
|
||
onMutate={onMutate}
|
||
query={filters.text?.trim() || ''}
|
||
statusStyle={tweaks.status_style}
|
||
manualOrder={tweaks.sort_mode === 'manual'}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : filtered.length === 0 ? (
|
||
<div className="empty-state">
|
||
<div className="empty-glyph">∅</div>
|
||
<div>{hasFilters ? 'No items match these filters.' : 'No items yet. Press n or click "+ New item" to start.'}</div>
|
||
</div>
|
||
) : (
|
||
<BacklogTree
|
||
items={filtered}
|
||
expandedMap={expandedMap}
|
||
setExpanded={setExpanded}
|
||
onMutate={onMutate}
|
||
query={filters.text?.trim() || ''}
|
||
statusStyle={tweaks.status_style}
|
||
manualOrder={tweaks.sort_mode === 'manual'}
|
||
/>
|
||
)}
|
||
{filters.text?.trim() && (
|
||
<ArchiveSearchResults
|
||
query={filters.text.trim()}
|
||
archiveEntries={archiveData.entries}
|
||
onRestore={handleRestoreItems}
|
||
onLoadArchive={loadArchive}
|
||
onView={(item) => setItemDialog({ mode: 'view', initial: item })}
|
||
/>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
) : (
|
||
<AdminPage
|
||
data={data}
|
||
history={data.history}
|
||
tweaks={tweaks}
|
||
setTweak={setTweak}
|
||
onClose={() => setView('backlog')}
|
||
onForceSave={() => triggerSave('Force-saved')}
|
||
onForceBackup={() => triggerSave('Force-backed up')}
|
||
onCompact={() => {
|
||
mutate(d => {
|
||
if (d.history.length > 200) d.history = d.history.slice(0, 200);
|
||
});
|
||
triggerSave('History compacted');
|
||
}}
|
||
onRestore={handleRestore}
|
||
onDownloadBackup={(name) => {
|
||
if (Storage.mode === 'api') {
|
||
const a = document.createElement('a');
|
||
a.href = `/api/backups/${encodeURIComponent(name)}`;
|
||
a.download = name;
|
||
a.click();
|
||
} else {
|
||
showToast(`${name.slice(0, 28)}… — download not available in direct mode`);
|
||
}
|
||
}}
|
||
archiveData={archiveData}
|
||
onArchiveItems={handleArchiveItems}
|
||
onRestoreItems={handleRestoreItems}
|
||
onViewItem={(item) => setItemDialog({ mode: 'view', initial: item })}
|
||
storageMode={storageMode}
|
||
projects={projects}
|
||
onRegisterProject={(path, name) =>
|
||
registerProjectAndRefresh(path, name)
|
||
.then(result => showToast(`Project registered: ${result.name}`))
|
||
.catch(e => showToast('Register failed: ' + e.message, 'err'))
|
||
}
|
||
onRenameProject={handleRenameProject}
|
||
onRemoveProject={handleRemoveProject}
|
||
/>
|
||
)}
|
||
|
||
<ItemDialog
|
||
open={!!itemDialog}
|
||
mode={itemDialog?.mode}
|
||
initial={itemDialog?.initial}
|
||
recentTags={recentTags}
|
||
isMultiProject={isMulti && storageMode === 'api'}
|
||
projects={projects}
|
||
defaultProjectId={itemDialog?.projectId ?? projects[0]?.id}
|
||
onClose={() => setItemDialog(null)}
|
||
onSubmit={submitItemDialog}
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
open={!!confirm}
|
||
title={confirm?.title}
|
||
message={confirm?.message}
|
||
detail={confirm?.detail}
|
||
checkbox={confirm?.checkbox}
|
||
confirmLabel={confirm?.confirmLabel}
|
||
danger={confirm?.danger}
|
||
onCancel={() => setConfirm(null)}
|
||
onConfirm={confirm?.onConfirm}
|
||
/>
|
||
|
||
{prompt && (
|
||
<window.PromptDialog
|
||
open
|
||
title={prompt.title}
|
||
message={prompt.message}
|
||
placeholder={prompt.placeholder}
|
||
confirmLabel={prompt.confirmLabel}
|
||
onCancel={() => setPrompt(null)}
|
||
onConfirm={(value) => { const cb = prompt.onConfirm; setPrompt(null); cb?.(value); }}
|
||
/>
|
||
)}
|
||
|
||
<ImportExportDialog
|
||
open={importExportOpen}
|
||
data={data}
|
||
storageMode={storageMode}
|
||
projects={projects}
|
||
onClose={() => setImportExportOpen(false)}
|
||
onImport={handleImport}
|
||
/>
|
||
|
||
{toast && (
|
||
<div className={`toast ${toast.kind}`}><Icon name="check" size={12}/> {toast.msg}</div>
|
||
)}
|
||
|
||
<TweaksPanel title="Tweaks">
|
||
<TweakSection title="Status icons">
|
||
<TweakRadio label="Style"
|
||
value={tweaks.status_style}
|
||
options={[
|
||
{ value: 'flat', label: 'Flat' },
|
||
{ value: 'ascii', label: 'ASCII' },
|
||
{ value: 'color', label: 'Color' },
|
||
{ value: 'emoji', label: 'Emoji' },
|
||
]}
|
||
onChange={v => setTweak('status_style', v)}/>
|
||
</TweakSection>
|
||
<TweakSection title="Order">
|
||
<TweakRadio label="Sort"
|
||
value={tweaks.sort_mode}
|
||
options={[
|
||
{ value: 'priority', label: 'By priority' },
|
||
{ value: 'manual', label: 'Manual' },
|
||
]}
|
||
onChange={v => setTweak('sort_mode', v)}/>
|
||
</TweakSection>
|
||
<TweakSection title="Accent">
|
||
<TweakSlider label="Hue" value={tweaks.accent_hue} min={0} max={360} step={1}
|
||
onChange={v => setTweak('accent_hue', v)} formatValue={v => `${v}°`}/>
|
||
</TweakSection>
|
||
<TweakSection title="Layout">
|
||
<TweakRadio label="Density"
|
||
value={tweaks.density}
|
||
options={[{ value: 'comfortable', label: 'Comfortable' }, { value: 'compact', label: 'Compact' }]}
|
||
onChange={v => setTweak('density', v)}/>
|
||
<TweakToggle label="Paper texture" checked={tweaks.paper_texture} onChange={v => setTweak('paper_texture', v)}/>
|
||
</TweakSection>
|
||
<TweakSection title="Demo">
|
||
<TweakButton label="Show checksum-mismatch banner" onClick={() => setShowWarning(true)}/>
|
||
<TweakButton label="Simulate external file change" onClick={() => showToast('File reloaded from disk')}/>
|
||
{window.SEED_DATA && (
|
||
<TweakButton label="Load sample / test data" onClick={() => {
|
||
const seed = structuredClone(window.SEED_DATA);
|
||
const defaults = { done: 100, cancelled: 0, 'in-progress': 50, blocked: 25, postponed: 25, open: 0 };
|
||
walkTree(seed.entries, it => {
|
||
if (typeof it.progress !== 'number') it.progress = defaults[it.status] ?? 0;
|
||
else if (it.status === 'done') it.progress = 100;
|
||
});
|
||
setData(seed);
|
||
const em = {};
|
||
walkTree(seed.entries, it => { em[it.id] = !it.collapsed; });
|
||
setExpandedMap(em);
|
||
showToast('Sample data loaded');
|
||
}}/>
|
||
)}
|
||
</TweakSection>
|
||
</TweaksPanel>
|
||
</StatusStyleContext.Provider>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ----- View chips (saved searches) -----
|
||
const VIEW_CHIPS = [
|
||
{ key: 'all', label: 'All', statuses: [] },
|
||
{ key: 'open', label: 'Open', statuses: ['open'] },
|
||
{ key: 'wip', label: 'In progress', statuses: ['in-progress'] },
|
||
{ key: 'active', label: 'Active', statuses: ['open', 'in-progress', 'blocked', 'postponed'], hint: 'everything not done or cancelled' },
|
||
{ key: 'blocked', label: 'Blocked', statuses: ['blocked'] },
|
||
{ key: 'closed', label: 'Closed', statuses: ['done', 'cancelled'], hint: 'done + cancelled' },
|
||
];
|
||
|
||
function ViewChips({ filters, setFilters }) {
|
||
const cur = filters.statuses || [];
|
||
const same = (a, b) => a.length === b.length && a.every(x => b.includes(x));
|
||
const activeKey = VIEW_CHIPS.find(v => same(v.statuses, cur))?.key;
|
||
return (
|
||
<div className="view-chips" role="tablist" aria-label="Saved views">
|
||
{VIEW_CHIPS.map(v => (
|
||
<button key={v.key}
|
||
role="tab"
|
||
aria-selected={activeKey === v.key}
|
||
className={`view-chip ${activeKey === v.key ? 'active' : ''}`}
|
||
title={v.hint || v.label}
|
||
onClick={() => setFilters({ ...filters, statuses: v.statuses })}>
|
||
{v.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ----- Header -----
|
||
function Header({ view, setView, saveState, saveLabel, storageMode, searchValue, onSearch, onOpenImportExport }) {
|
||
const modeIcon = storageMode === 'api' ? '⚡' : storageMode === 'direct' ? '📁' : storageMode === 'browser' ? '🌐' : '💾';
|
||
return (
|
||
<header className="header">
|
||
<div className="brand">
|
||
<span className="brand-glyph"><StatusIcon status="in-progress" size={16}/></span>
|
||
<span className="brand-name">backlog</span>
|
||
<span className="brand-sub mono" title={`Storage: ${storageMode}`}>backlog.md {modeIcon}</span>
|
||
</div>
|
||
|
||
<nav className="tabs">
|
||
<button className={`tab ${view === 'backlog' ? 'active' : ''}`} onClick={() => setView('backlog')}>
|
||
<Icon name="list" size={13}/> Backlog
|
||
</button>
|
||
<button className={`tab ${view === 'admin' ? 'active' : ''}`} onClick={() => setView('admin')}>
|
||
<Icon name="cog" size={13}/> Admin
|
||
</button>
|
||
</nav>
|
||
|
||
<div className="search-wrap">
|
||
<Icon name="search" size={14}/>
|
||
<input className="search-input"
|
||
placeholder="Search titles, tags, reasons… ( / )"
|
||
value={searchValue}
|
||
onChange={(e) => onSearch(e.target.value)}/>
|
||
{searchValue && <button className="search-clear" onClick={() => onSearch('')}>×</button>}
|
||
</div>
|
||
|
||
<div className="header-right">
|
||
<button className="header-btn" onClick={onOpenImportExport} title="Import / Export">
|
||
<Icon name="archive" size={13}/> Import/Export
|
||
</button>
|
||
<div className={`save-indicator ${saveState.status}`}>
|
||
<span className={`save-dot ${saveState.status}`}/>
|
||
{saveLabel}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
// ----- Import / Export dialog -----
|
||
// Multi-project api mode (projects.length > 1) adds a "Target project"
|
||
// select: export serializes only the selected project's entries/history (as a
|
||
// plain upstream file — merge-only id suffixes stripped), import writes only
|
||
// that project's backlog.md. Single-project mode is unchanged.
|
||
function ImportExportDialog({ open, data, storageMode, projects = [], onClose, onImport }) {
|
||
const { useState: useStateD, useEffect: useEffectD, useRef: useRefD } = React;
|
||
const [tab, setTab] = useStateD('md');
|
||
const [copied, setCopied] = useStateD(false);
|
||
const [mdContent, setMdContent] = useStateD('');
|
||
const [targetId, setTargetId] = useStateD(null);
|
||
const fileInputRef = useRefD(null);
|
||
|
||
const isMulti = storageMode === 'api' && projects.length > 1;
|
||
|
||
// Default the target to the first registered project on every open.
|
||
useEffectD(() => {
|
||
if (open) setTargetId(projects[0]?.id ?? null);
|
||
}, [open]);
|
||
|
||
// Export scope: full data in single mode; the selected project's rows in
|
||
// multi mode (toDiskIds keeps exported ids free of `~project` suffixes).
|
||
const exportScope = React.useMemo(() => {
|
||
if (!isMulti || !targetId) {
|
||
return { entries: data?.entries || [], history: data?.history || [], meta: data?.meta };
|
||
}
|
||
const pidOf = x => x._projectId ?? projects[0]?.id;
|
||
const scoped = toDiskIds(
|
||
(data?.entries || []).filter(e => pidOf(e) === targetId),
|
||
(data?.history || []).filter(h => pidOf(h) === targetId),
|
||
targetId,
|
||
);
|
||
return { ...scoped, meta: data?.meta };
|
||
}, [isMulti, targetId, data, projects]);
|
||
|
||
// Async-generate markdown when dialog opens or data changes.
|
||
useEffectD(() => {
|
||
if (!open || !data) return;
|
||
let cancelled = false;
|
||
Parser.serialize({ entries: exportScope.entries, history: exportScope.history })
|
||
.then(md => { if (!cancelled) setMdContent(md); })
|
||
.catch(() => {});
|
||
return () => { cancelled = true; };
|
||
}, [open, data, exportScope]);
|
||
|
||
const jsonContent = React.useMemo(() => {
|
||
if (!data) return '';
|
||
return JSON.stringify({ entries: exportScope.entries, history: exportScope.history, meta: exportScope.meta }, null, 2);
|
||
}, [data, exportScope]);
|
||
|
||
const currentContent = tab === 'md' ? mdContent : jsonContent;
|
||
const filename = tab === 'md' ? 'backlog.md' : 'backlog_export.json';
|
||
const mimeType = tab === 'md' ? 'text/markdown' : 'application/json';
|
||
|
||
const copyContent = async () => {
|
||
try { await navigator.clipboard.writeText(currentContent); }
|
||
catch {
|
||
const ta = Object.assign(document.createElement('textarea'), {
|
||
value: currentContent, style: 'position:fixed;opacity:0',
|
||
});
|
||
document.body.appendChild(ta); ta.select();
|
||
try { document.execCommand('copy'); } catch {}
|
||
document.body.removeChild(ta);
|
||
}
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 1400);
|
||
};
|
||
|
||
const downloadFile = () => {
|
||
const blob = new Blob([currentContent], { type: mimeType });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
const handleFileInput = async (e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
e.target.value = '';
|
||
try {
|
||
const text = await file.text();
|
||
let entries, history;
|
||
if (file.name.endsWith('.json')) {
|
||
const obj = JSON.parse(text);
|
||
entries = obj.entries || [];
|
||
history = obj.history || [];
|
||
} else {
|
||
const parsed = await Parser.parse(text);
|
||
entries = parsed.entries;
|
||
history = parsed.history;
|
||
}
|
||
onImport({ entries, history, targetProjectId: isMulti ? targetId : undefined });
|
||
} catch (err) {
|
||
alert('Import failed: ' + err.message);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onClose={onClose} width={760} labelledBy="dlg-ie-title">
|
||
<DialogHeader id="dlg-ie-title" eyebrow="Backup & sync" title="Import / Export" onClose={onClose}/>
|
||
<div className="dlg-body">
|
||
{isMulti && (
|
||
<div className="ie-target-row">
|
||
<label className="ie-target-label" htmlFor="ie-target-select">Target project</label>
|
||
<select
|
||
id="ie-target-select"
|
||
className="ie-target"
|
||
value={targetId ?? ''}
|
||
onChange={e => setTargetId(e.target.value)}
|
||
>
|
||
{projects.map(p => (
|
||
<option key={p.id} value={p.id}>{p.name}</option>
|
||
))}
|
||
</select>
|
||
<span className="card-sub">export and import apply to this project only</span>
|
||
</div>
|
||
)}
|
||
<div className="ie-tabs">
|
||
<button className={`ie-tab ${tab === 'md' ? 'active' : ''}`} onClick={() => setTab('md')}>
|
||
Markdown <span className="ie-tab-sub">.md</span>
|
||
</button>
|
||
<button className={`ie-tab ${tab === 'json' ? 'active' : ''}`} onClick={() => setTab('json')}>
|
||
JSON <span className="ie-tab-sub">structured</span>
|
||
</button>
|
||
<button
|
||
className={`ie-copy-btn ${copied ? 'copied' : ''}`}
|
||
onClick={copyContent}
|
||
title={`Copy ${tab === 'md' ? 'Markdown' : 'JSON'} to clipboard`}
|
||
aria-live="polite"
|
||
>
|
||
{copied
|
||
? <><Icon name="check" size={13}/> <span>Copied</span></>
|
||
: <><Icon name="copy" size={13}/> <span>Copy</span></>}
|
||
</button>
|
||
</div>
|
||
<pre className="export-pre">{currentContent || '…generating…'}</pre>
|
||
</div>
|
||
<div className="dlg-foot">
|
||
<div>
|
||
<input ref={fileInputRef} type="file" accept=".md,.json" style={{ display: 'none' }} onChange={handleFileInput}/>
|
||
<button className="btn-secondary" onClick={() => fileInputRef.current?.click()}>
|
||
<Icon name="upload" size={12}/> Import file…
|
||
</button>
|
||
</div>
|
||
<div className="dlg-foot-actions">
|
||
<button className="btn-secondary" onClick={onClose}>Close</button>
|
||
<button className="btn-primary" onClick={downloadFile}>
|
||
<Icon name="download" size={12}/> Download {tab === 'md' ? '.md' : '.json'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// ----- Archive Search Results -----
|
||
function ArchiveSearchResults({ query, archiveEntries, onRestore, onLoadArchive, onView }) {
|
||
const [loading, setLoading] = useStateMain(false);
|
||
const loadedRef = useRefMain(false);
|
||
|
||
// Auto-load archive on first render
|
||
useEffectMain(() => {
|
||
if (!loadedRef.current && archiveEntries.length === 0) {
|
||
loadedRef.current = true;
|
||
setLoading(true);
|
||
onLoadArchive().finally(() => setLoading(false));
|
||
}
|
||
}, []);
|
||
|
||
// Filter archive entries by query
|
||
const matches = [];
|
||
if (query) {
|
||
const q = query.toLowerCase();
|
||
walkTree(archiveEntries, (item) => {
|
||
if (item.title.toLowerCase().includes(q) ||
|
||
item.body?.toLowerCase().includes(q) ||
|
||
item.tags?.some(t => t.toLowerCase().includes(q))) {
|
||
matches.push(item);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="archive-search-section">
|
||
<div className="archive-search-header">Archive — loading...</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (matches.length === 0) return null;
|
||
|
||
return (
|
||
<div className="archive-search-section">
|
||
<div className="archive-search-header">Archive ({matches.length})</div>
|
||
<div className="archive-search-results">
|
||
{matches.slice(0, 10).map(item => (
|
||
<div key={item.id} className="archive-search-item">
|
||
<StatusIcon status={item.status} size={14}/>
|
||
<span className="archive-search-title" onDoubleClick={() => onView(item)}>{item.title}</span>
|
||
{item.archived && <span className="archive-search-date">{item.archived}</span>}
|
||
<button className="btn-secondary btn-xs" onClick={() => onRestore([item.id])}>Restore</button>
|
||
</div>
|
||
))}
|
||
{matches.length > 10 && (
|
||
<div className="archive-search-more">
|
||
...and {matches.length - 10} more (see Admin → Archive)
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Multi-project data-layer helpers — consumed by todos 6-8 (sections UI,
|
||
// dialogs, admin) and the todo-5 verification harness.
|
||
Object.assign(window, {
|
||
buildMergedDataFromProjects,
|
||
computeDirtyProjectIds,
|
||
retagSubtree,
|
||
toDiskIds,
|
||
});
|
||
|
||
window.App = App;
|