diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 59b71c0da..b7e9fbf85 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -166,6 +166,12 @@ "description": "A comprehensive collection of specialized agents for software analysis, impact assessment, structural quality advisories, and architectural review using CAST Imaging.", "version": "1.0.0" }, + { + "name": "catpilot-canvas", + "source": "extensions/catpilot-canvas", + "description": "Visual command center canvas for CatPilot: manage tasks (list/board/calendar), run a configurable Pomodoro timer with a global mini-timer dock, and browse journal, milestones, memos, learning, growth, projects, Copilot reports and an activity timeline with dashboards, charts, a markdown editor and interactive settings.", + "version": "1.0.0" + }, { "name": "chrome-devtools-plugin", "description": "Reliable automation, in-depth debugging, and performance analysis in Chrome using Chrome DevTools and Puppeteer.", diff --git a/extensions/catpilot-canvas/.github/plugin/plugin.json b/extensions/catpilot-canvas/.github/plugin/plugin.json new file mode 100644 index 000000000..fc8486866 --- /dev/null +++ b/extensions/catpilot-canvas/.github/plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "catpilot-canvas", + "description": "Visual command center canvas for CatPilot: manage tasks (list/board/calendar), run a configurable Pomodoro timer with a global mini-timer dock, and browse journal, milestones, memos, learning, growth, projects, Copilot reports and an activity timeline with dashboards, charts, a markdown editor and interactive settings.", + "version": "1.0.0", + "author": { + "name": "Albert Tanure", + "url": "https://github.com/tanure" + }, + "keywords": [ + "canvas", + "productivity", + "task-management", + "pomodoro", + "timer", + "calendar", + "dashboard", + "personal-assistant", + "markdown-editor" + ], + "logo": "assets/preview.png", + "extensions": "." +} diff --git a/extensions/catpilot-canvas/README.md b/extensions/catpilot-canvas/README.md new file mode 100644 index 000000000..096279387 --- /dev/null +++ b/extensions/catpilot-canvas/README.md @@ -0,0 +1,28 @@ +# CatPilot Canvas + +A beautiful, modern **GitHub Copilot canvas extension** that turns [CatPilot](https://github.com/tanure/cat-copilot) into a visual command center β€” manage everything CatPilot tracks without leaving the Copilot app. + +![CatPilot canvas dashboard](assets/preview.png) + +## What it does + +The canvas reads and writes the **same** config-driven storage as the CatPilot CLI, agent, skills, and MCP server, so every surface stays in sync. + +- **Dashboard** β€” hero greeting, summary cards, an open-tasks-by-priority donut, a last-3-days activity chart, a focus list and a recent-activity feed, plus a **Today / Week / Month** filter and a **πŸ… Focus** analytics section (focus sessions, focus time, completed, abandoned). +- **Tasks** β€” switch between **list (table)**, **board (kanban)** and **πŸ“… calendar** views. The board has **Overdue Β· To do Β· Blocked Β· Done** columns with drag-and-drop and a **status selector** on create/edit; the calendar offers **Month / Week** layouts with per-day priority-coloured task chips, a field chooser, today highlight and the shared detail popup. Complete inline, edit/save locally, and open a detail popup from any view. +- **Pomodoro timer** β€” a configurable focus / short-break / long-break timer with a **global persistent mini-timer dock** that keeps ticking on every view, colour-coded session-type badges, **Pause / Resume / Stop** controls, end-of-session notifications and classic long-break-after-4-focus break suggestions. +- **Journal, Milestones, Memos, Learning, Growth, Projects** β€” browse, add, and open full detail views. +- **Reports** β€” generate GitHub Copilot **executive reports** for any period (this week, last month, all time…) and open them as markdown or HTML. +- **Timeline** β€” a 7/14/30-day activity rail grouped by day, with one-click **agent actions**. +- **Settings** β€” an interactive config wizard that **previews** exactly which files a storage/partition change would move, gated behind an explicit approval before anything is migrated. +- **Help** β€” an in-canvas capabilities guide. +- **Markdown everywhere** β€” every text field has a formatting toolbar, a live preview toggle, and a **Generate with Copilot** button. A global **Ask Copilot** button hands any prompt to the agent. +- **Light / dark** theme toggle. + +## Assets + +- `assets/preview.png` β€” dashboard screenshot used for the extension card. + +## Author + +Built by [Albert Tanure](https://github.com/tanure). Source: [tanure/cat-copilot](https://github.com/tanure/cat-copilot). diff --git a/extensions/catpilot-canvas/assets/preview.png b/extensions/catpilot-canvas/assets/preview.png new file mode 100644 index 000000000..627a5ca24 Binary files /dev/null and b/extensions/catpilot-canvas/assets/preview.png differ diff --git a/extensions/catpilot-canvas/catpilot-store.mjs b/extensions/catpilot-canvas/catpilot-store.mjs new file mode 100644 index 000000000..f63cb71c9 --- /dev/null +++ b/extensions/catpilot-canvas/catpilot-store.mjs @@ -0,0 +1,1526 @@ +// catpilot-store.mjs +// Self-contained storage engine for the CatPilot canvas. +// +// This mirrors the EXACT file formats used by CatPilot's lib/cli-utils.js and +// lib/domains.js so everything written here is read back identically by the +// CatPilot agent, the `cat-pilot` CLI, and the CatPilot MCP server. It is kept +// dependency-free and self-contained so the extension folder is portable and +// can be shared as a unit (repo commit or gist). + +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +const GLOBAL_CONFIG_DIR = path.join(os.homedir(), ".catpilot"); +const GLOBAL_CONFIG_PATH = path.join(GLOBAL_CONFIG_DIR, "config.json"); + +const DEFAULT_FILES = { + tasks: "tasks.md", + journal: "journal.md", + milestones: "milestones.md", + memos: "memos", + learning: "learning", + growth: "growth", + projects: "projects", + reports: "reports", + pomodoro: "pomodoro.md", +}; + +const DOMAIN_TAG = { learning: "learning", growth: "growth", projects: "project" }; + +// --------------------------------------------------------------------------- +// Config resolution (matches lib/cli-utils.js resolveConfigPath order) +// --------------------------------------------------------------------------- +function resolveConfigPath(projectRoot = process.cwd()) { + if (process.env.CATPILOT_CONFIG) return { path: process.env.CATPILOT_CONFIG, scope: "env-config" }; + if (process.env.CATPILOT_ROOT) return { path: path.join(process.env.CATPILOT_ROOT, "data", "config.json"), scope: "env-root" }; + const localPath = path.join(projectRoot, "data", "config.json"); + if (fs.existsSync(localPath)) return { path: localPath, scope: "local" }; + if (fs.existsSync(GLOBAL_CONFIG_PATH)) return { path: GLOBAL_CONFIG_PATH, scope: "global" }; + return { path: GLOBAL_CONFIG_PATH, scope: "none" }; +} + +function configBaseDir(configPath) { + const dir = path.dirname(configPath); + if (path.basename(dir).toLowerCase() === "data") return path.dirname(dir); + return dir; +} + +export function configStatus(projectRoot = process.cwd()) { + const { path: configPath, scope } = resolveConfigPath(projectRoot); + const exists = scope !== "none" && fs.existsSync(configPath); + return { configured: exists, configPath, scope }; +} + +export function loadConfig(projectRoot = process.cwd()) { + const { path: configPath } = resolveConfigPath(projectRoot); + if (!fs.existsSync(configPath)) { + const err = new Error("CatPilot is not set up yet."); + err.code = "NOT_CONFIGURED"; + throw err; + } + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + config.__baseDir = configBaseDir(configPath); + config.__configPath = configPath; + return config; +} + +// Write a global config during onboarding. First-run setup has no prior +// configured location, so there is nothing to move/copy β€” always adopt the +// chosen root. (Move/Copy migration is offered later from Settings, where the +// plan/preview/approve flow in applyConfigChange handles it safely.) +export function writeConfig({ root, partitioning = "month" } = {}) { + if (!root || !String(root).trim()) throw new Error("A storage root path is required."); + if (!["day", "week", "month"].includes(partitioning)) throw new Error("partitioning must be day, week, or month."); + const config = { + version: 1, + storage: { + root: String(root).trim(), + partitioning, + allowExternalPaths: true, + files: { ...DEFAULT_FILES }, + }, + migration: { mode: "adopt" }, + pomodoro: { ...POMODORO_DEFAULTS }, + }; + fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true }); + fs.writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8"); + config.__baseDir = configBaseDir(GLOBAL_CONFIG_PATH); + config.__configPath = GLOBAL_CONFIG_PATH; + return config; +} + +// --------------------------------------------------------------------------- +// Path resolution (matches getPartitionFolder / resolveFilePath) +// --------------------------------------------------------------------------- +function getISOWeek(date) { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil((((d - yearStart) / 86400000) + 1) / 7); +} + +function getPartitionFolder(partitioning = "month") { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const date = String(now.getDate()).padStart(2, "0"); + if (partitioning === "day") return `${year}/${year}-${month}/${year}-${month}-${date}`; + if (partitioning === "week") return `${year}/W${String(getISOWeek(now)).padStart(2, "0")}`; + return `${year}/${year}-${month}`; +} + +function resolveFilePath(type, config) { + const partition = getPartitionFolder(config.storage.partitioning); + const base = config.__baseDir || process.cwd(); + const storageRoot = path.resolve(base, config.storage.root); + const fileName = (config.storage.files && config.storage.files[type]) || DEFAULT_FILES[type]; + if (!fileName) throw new Error(`Unknown file type: ${type}`); + return path.join(storageRoot, partition, fileName); +} + +function ensureDir(dir) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} + +// Never overwrite an existing file: if the target path is taken, append +// -2, -3, … before the extension so a "Create" action can never destroy data. +function uniquePath(p) { + if (!fs.existsSync(p)) return p; + const dir = path.dirname(p); + const ext = path.extname(p); + const stem = path.basename(p, ext); + for (let i = 2; i < 10000; i++) { + const candidate = path.join(dir, `${stem}-${i}${ext}`); + if (!fs.existsSync(candidate)) return candidate; + } + throw new Error(`Could not allocate a unique filename for ${p}`); +} + +function readFileOrCreate(filePath, def = "") { + ensureDir(path.dirname(filePath)); + if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, def, "utf8"); + return fs.readFileSync(filePath, "utf8"); +} + +function todayISO() { + return new Date().toISOString().split("T")[0]; +} + +function slugify(title) { + return String(title) + .toLowerCase() + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .substring(0, 50) || "note"; +} + +// --------------------------------------------------------------------------- +// Tasks +// --------------------------------------------------------------------------- +// Markdown-table cells are pipe/newline delimited, so any user value that +// contains `|` or a line break must be escaped on write and restored on read, +// otherwise it spills into extra cells/rows and corrupts the table. +function encodeCell(v) { + return String(v == null ? "" : v).replace(/\r?\n/g, "
").replace(/\|/g, "\\|"); +} + +function decodeCell(v) { + return String(v == null ? "" : v).replace(//gi, "\n").replace(/\\\|/g, "|").trim(); +} + +function splitCells(line) { + return line.split(/(?= 7) { + tasks.push({ + id: parseInt(cells[0], 10) || 0, + status: cells[1] || "Open", + title: cells[2] || "", + dueDate: cells[3] || "", + priority: cells[4] || "", + tags: cells[5] || "", + context: cells[6] || "", + }); + } + } + } + return tasks; +} + +function formatTasksTable(tasks) { + const isDone = (t) => t.status === "Done" || t.status === "done"; + // Section 1 = every NOT-done task (Open, Blocked, etc.) so custom statuses + // persist across the round-trip; section 2 = completed tasks. + const open = tasks.filter((t) => !isDone(t)); + const done = tasks.filter(isDone); + const header = "| ID | Status | Title | Due Date | Priority | Tags | Context |\n| --- | --- | --- | --- | --- | --- | --- |\n"; + let out = ""; + if (open.length) { + out += "## Open Tasks\n\n" + header; + open.forEach((t) => { out += `| ${t.id} | ${encodeCell(t.status)} | ${encodeCell(t.title)} | ${encodeCell(t.dueDate)} | ${encodeCell(t.priority)} | ${encodeCell(t.tags)} | ${encodeCell(t.context)} |\n`; }); + out += "\n"; + } + if (done.length) { + out += "## Completed Tasks\n\n" + header; + done.forEach((t) => { out += `| ${t.id} | ${encodeCell(t.status)} | ${encodeCell(t.title)} | ${encodeCell(t.dueDate)} | ${encodeCell(t.priority)} | ${encodeCell(t.tags)} | ${encodeCell(t.context)} |\n`; }); + } + return out.trimEnd(); +} + +function nextId(rows) { + if (!rows.length) return 1; + return Math.max(...rows.map((r) => r.id || 0)) + 1; +} + +// Canonical task statuses. "Overdue" is derived from the due date, never stored. +export const TASK_STATUSES = ["Open", "Blocked", "Done"]; + +function normalizeTaskStatus(value, fallback = "Open") { + if (value === undefined || value === null || value === "") return fallback; + const match = TASK_STATUSES.find((s) => s.toLowerCase() === String(value).trim().toLowerCase()); + if (!match) throw new Error(`Invalid status "${value}". Use one of: ${TASK_STATUSES.join(", ")}`); + return match; +} + +export function listTasks(status = "all") { + const config = loadConfig(); + const p = resolveFilePath("tasks", config); + const tasks = parseTasksTable(readFileOrCreate(p)); + if (status === "all") return tasks; + return tasks.filter((t) => t.status.toLowerCase() === status.toLowerCase()); +} + +export function addTask(params = {}) { + if (!params.title) throw new Error("title is required"); + const config = loadConfig(); + const p = resolveFilePath("tasks", config); + const tasks = parseTasksTable(readFileOrCreate(p)); + const task = { + id: nextId(tasks), + status: normalizeTaskStatus(params.status), + title: params.title, + dueDate: params.due || "", + priority: params.priority || "", + tags: params.tags || "", + context: params.context || "", + }; + tasks.push(task); + fs.writeFileSync(p, formatTasksTable(tasks), "utf8"); + return task; +} + +export function updateTask(id, patch = {}) { + const config = loadConfig(); + const p = resolveFilePath("tasks", config); + const tasks = parseTasksTable(readFileOrCreate(p)); + const t = tasks.find((x) => x.id === parseInt(id, 10)); + if (!t) throw new Error(`Task #${id} not found`); + for (const k of ["status", "title", "dueDate", "priority", "tags", "context"]) { + if (patch[k] !== undefined) t[k] = k === "status" ? normalizeTaskStatus(patch[k]) : patch[k]; + } + fs.writeFileSync(p, formatTasksTable(tasks), "utf8"); + return t; +} + +export function completeTask(id) { + return updateTask(id, { status: "Done" }); +} + +export function removeTask(id) { + const config = loadConfig(); + const p = resolveFilePath("tasks", config); + const tasks = parseTasksTable(readFileOrCreate(p)); + const idx = tasks.findIndex((x) => x.id === parseInt(id, 10)); + if (idx === -1) throw new Error(`Task #${id} not found`); + const [removed] = tasks.splice(idx, 1); + fs.writeFileSync(p, formatTasksTable(tasks), "utf8"); + return removed; +} + +// --------------------------------------------------------------------------- +// Pomodoro +// --------------------------------------------------------------------------- +// Mirrors lib/pomodoro.js + lib/cli-utils.js file formats exactly so the CLI, +// MCP server and this canvas read/write the same timer state and history. +const POMODORO_TYPES = ["focus", "short-break", "long-break"]; +const POMODORO_DEFAULTS = { focus: 25, "short-break": 5, "long-break": 15 }; +const POMODORO_HISTORY_HEADER = "# Pomodoro Sessions\n\n"; + +function pomodoroActivePath(config) { + const base = config.__baseDir || process.cwd(); + const storageRoot = path.resolve(base, config.storage.root); + return path.join(storageRoot, "pomodoro-active.json"); +} + +function normalizePomodoroType(type) { + if (!type) return "focus"; + const t = String(type).toLowerCase().trim(); + if (["break", "short", "shortbreak", "short_break"].includes(t)) return "short-break"; + if (["long", "longbreak", "long_break"].includes(t)) return "long-break"; + return POMODORO_TYPES.includes(t) ? t : "focus"; +} + +function pomodoroDefaultMinutes(type, config) { + const t = normalizePomodoroType(type); + const override = config && config.pomodoro && config.pomodoro[t]; + const n = parseInt(override, 10); + return Number.isFinite(n) && n > 0 ? n : POMODORO_DEFAULTS[t]; +} + +function decoratePomodoro(session, at = Date.now()) { + if (!session) return null; + const started = Date.parse(session.startedAt); + const plannedSec = Math.round((session.plannedMin || 0) * 60); + // While paused the clock freezes: measure elapsed up to the pause instant. + const paused = !!session.pausedAt; + const effectiveAt = paused ? Date.parse(session.pausedAt) : at; + const elapsedSec = Math.max(0, Math.round((effectiveAt - started) / 1000)); + const remainingSec = Math.max(0, plannedSec - elapsedSec); + return { ...session, paused, elapsedSec, remainingSec, plannedSec, isExpired: remainingSec === 0 }; +} + +function readPomodoroActive(config) { + const p = pomodoroActivePath(config); + if (!fs.existsSync(p)) return null; + try { + const data = JSON.parse(fs.readFileSync(p, "utf8")); + return data && data.startedAt ? data : null; + } catch { return null; } +} + +function parsePomodoroTable(content) { + const lines = content.split("\n"); + const sessions = []; + let inTable = false; + let headerSeen = false; + for (const line of lines) { + if (line.match(/^##\s+Sessions$/)) { inTable = true; headerSeen = false; continue; } + if (line.match(/^##\s/) && inTable) { inTable = false; continue; } + if (!inTable) continue; + if (line.match(/^[\s\-|]+$/) || !line.trim()) continue; + if (line.includes("|") && !headerSeen) { headerSeen = true; continue; } + if (line.includes("|") && headerSeen) { + const cells = splitCells(line); + if (cells.length && cells[0] === "") cells.shift(); + if (cells.length && cells[cells.length - 1] === "") cells.pop(); + if (cells.length >= 9) { + sessions.push({ + id: parseInt(cells[0], 10) || 0, + type: cells[1] || "focus", + task: cells[2] || "", + started: cells[3] || "", + ended: cells[4] || "", + plannedMin: cells[5] || "", + actualMin: cells[6] || "", + status: cells[7] || "completed", + notes: cells[8] || "", + }); + } + } + } + return sessions; +} + +function formatPomodoroTable(sessions) { + let out = "## Sessions\n\n"; + out += "| ID | Type | Task | Started | Ended | Planned (min) | Actual (min) | Status | Notes |\n"; + out += "| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n"; + sessions.forEach((s) => { + out += `| ${s.id} | ${encodeCell(s.type)} | ${encodeCell(s.task)} | ${encodeCell(s.started)} | ${encodeCell(s.ended)} | ${encodeCell(s.plannedMin)} | ${encodeCell(s.actualMin)} | ${encodeCell(s.status)} | ${encodeCell(s.notes)} |\n`; + }); + return out.trimEnd(); +} + +function loadPomodoroHistory(config) { + const filePath = resolveFilePath("pomodoro", config); + const content = readFileOrCreate(filePath, POMODORO_HISTORY_HEADER + formatPomodoroTable([])); + return { filePath, sessions: parsePomodoroTable(content) }; +} + +function appendPomodoro(config, session, status, notes) { + const { filePath, sessions } = loadPomodoroHistory(config); + const started = Date.parse(session.startedAt); + const endedAt = new Date(); + const actualMin = Math.max(0, Math.round(((endedAt.getTime() - started) / 1000) / 60)); + const record = { + id: nextId(sessions), + type: session.type, + task: session.task || session.label || "", + started: session.startedAt, + ended: endedAt.toISOString(), + plannedMin: session.plannedMin, + actualMin, + status, + notes: notes ? String(notes) : "", + }; + sessions.push(record); + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, POMODORO_HISTORY_HEADER + formatPomodoroTable(sessions), "utf8"); + return record; +} + +export function pomodoroStatus() { + const config = loadConfig(); + return { active: decoratePomodoro(readPomodoroActive(config)) }; +} + +export function pomodoroStart(params = {}) { + const config = loadConfig(); + const existing = readPomodoroActive(config); + if (existing && !params.force) { + const err = new Error("A Pomodoro session is already running."); + err.active = decoratePomodoro(existing); + throw err; + } + const type = normalizePomodoroType(params.type); + const minutes = params.minutes != null && Number.isFinite(parseInt(params.minutes, 10)) + ? parseInt(params.minutes, 10) + : pomodoroDefaultMinutes(type, config); + if (!(minutes > 0)) throw new Error("minutes must be a positive number"); + const session = { + type, + task: params.task ? String(params.task) : "", + label: params.label ? String(params.label) : "", + plannedMin: minutes, + startedAt: new Date().toISOString(), + }; + ensureDir(path.dirname(pomodoroActivePath(config))); + fs.writeFileSync(pomodoroActivePath(config), JSON.stringify(session, null, 2), "utf8"); + return { active: decoratePomodoro(session) }; +} + +export function pomodoroComplete(params = {}) { + const config = loadConfig(); + const active = readPomodoroActive(config); + if (!active) throw new Error("No Pomodoro session is currently running."); + const record = appendPomodoro(config, active, "completed", params.notes); + fs.rmSync(pomodoroActivePath(config), { force: true }); + return { record }; +} + +export function pomodoroCancel(params = {}) { + const config = loadConfig(); + const active = readPomodoroActive(config); + if (!active) throw new Error("No Pomodoro session is currently running."); + const record = appendPomodoro(config, active, "abandoned", params.notes); + fs.rmSync(pomodoroActivePath(config), { force: true }); + return { record }; +} + +export function pomodoroPause() { + const config = loadConfig(); + const active = readPomodoroActive(config); + if (!active) throw new Error("No Pomodoro session is currently running."); + if (!active.pausedAt) { + active.pausedAt = new Date().toISOString(); + fs.writeFileSync(pomodoroActivePath(config), JSON.stringify(active, null, 2), "utf8"); + } + return { active: decoratePomodoro(active) }; +} + +export function pomodoroResume() { + const config = loadConfig(); + const active = readPomodoroActive(config); + if (!active) throw new Error("No Pomodoro session is currently running."); + if (active.pausedAt) { + // Shift startedAt forward by the paused span so remaining continues seamlessly. + const pausedFor = Date.now() - Date.parse(active.pausedAt); + active.startedAt = new Date(Date.parse(active.startedAt) + Math.max(0, pausedFor)).toISOString(); + delete active.pausedAt; + fs.writeFileSync(pomodoroActivePath(config), JSON.stringify(active, null, 2), "utf8"); + } + return { active: decoratePomodoro(active) }; +} + +export function pomodoroList({ limit } = {}) { + const config = loadConfig(); + const { sessions } = loadPomodoroHistory(config); + const ordered = sessions.slice().reverse(); + const n = parseInt(limit, 10); + return { sessions: Number.isFinite(n) && n > 0 ? ordered.slice(0, n) : ordered }; +} + +export function pomodoroStats({ period = "all" } = {}) { + const config = loadConfig(); + const { sessions } = loadPomodoroHistory(config); + const now = Date.now(); + const inPeriod = sessions.filter((s) => { + if (period === "all") return true; + const d = Date.parse(s.started); + if (!Number.isFinite(d)) return false; + if (period === "today") { + const t = new Date(); + return d >= new Date(t.getFullYear(), t.getMonth(), t.getDate()).getTime(); + } + if (period === "week") return d >= now - 7 * 864e5; + if (period === "month") return d >= now - 30 * 864e5; + return true; + }); + const completed = inPeriod.filter((s) => s.status === "completed"); + const focus = completed.filter((s) => s.type === "focus"); + const focusMinutes = focus.reduce((sum, s) => sum + (parseInt(s.actualMin, 10) || 0), 0); + return { + period, + totalSessions: inPeriod.length, + completedSessions: completed.length, + abandonedSessions: inPeriod.length - completed.length, + focusSessions: focus.length, + focusMinutes, + }; +} + +// --------------------------------------------------------------------------- +// Pomodoro reporting +// --------------------------------------------------------------------------- +function pomoPad2(n) { return String(n).padStart(2, "0"); } +function pomoLocalDayKey(d) { return `${d.getFullYear()}-${pomoPad2(d.getMonth() + 1)}-${pomoPad2(d.getDate())}`; } +function pomoIsoWeekKey(d) { + const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); + const day = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - day); + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + const week = Math.ceil((((date - yearStart) / 864e5) + 1) / 7); + return `${date.getUTCFullYear()}-W${pomoPad2(week)}`; +} +function pomoRate(num, den) { return den > 0 ? Math.round((num / den) * 1000) / 1000 : 0; } + +function pomoResolvePeriodRange(period, now = Date.now()) { + const d = new Date(now); + const startOfDay = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); + const mondayOf = (x) => { + const s = new Date(x.getFullYear(), x.getMonth(), x.getDate()); + const day = s.getDay() || 7; + s.setDate(s.getDate() - (day - 1)); + return s.getTime(); + }; + switch (period) { + case "today": return { from: startOfDay(d), to: null }; + case "this-week": return { from: mondayOf(d), to: null }; + case "last-week": { const tw = mondayOf(d); return { from: tw - 7 * 864e5, to: tw }; } + case "this-month": return { from: new Date(d.getFullYear(), d.getMonth(), 1).getTime(), to: null }; + case "last-month": return { from: new Date(d.getFullYear(), d.getMonth() - 1, 1).getTime(), to: new Date(d.getFullYear(), d.getMonth(), 1).getTime() }; + case "last-7": case "week": return { from: now - 7 * 864e5, to: null }; + case "last-30": case "month": return { from: now - 30 * 864e5, to: null }; + case "all": default: return { from: null, to: null }; + } +} + +function pomoSummarize(rows) { + const total = rows.length; + const completed = rows.filter((s) => s.status === "completed"); + const abandoned = rows.filter((s) => s.status !== "completed"); + const focus = rows.filter((s) => s.type === "focus"); + const completedFocus = focus.filter((s) => s.status === "completed"); + const focusMinutes = completedFocus.reduce((sum, s) => sum + (parseInt(s.actualMin, 10) || 0), 0); + const breakMinutes = completed.filter((s) => s.type !== "focus").reduce((sum, s) => sum + (parseInt(s.actualMin, 10) || 0), 0); + return { + totalSessions: total, + completedSessions: completed.length, + abandonedSessions: abandoned.length, + focusSessions: focus.length, + completedFocusSessions: completedFocus.length, + focusMinutes, + breakMinutes, + completionRate: pomoRate(completed.length, total), + focusCompletionRate: pomoRate(completedFocus.length, focus.length), + }; +} + +export function pomodoroReport({ period = "this-week", groupBy = "day" } = {}) { + const config = loadConfig(); + const { sessions } = loadPomodoroHistory(config); + const now = Date.now(); + const { from, to } = pomoResolvePeriodRange(period, now); + const rows = sessions.filter((s) => { + const t = Date.parse(s.started); + if (!Number.isFinite(t)) return false; + if (from != null && t < from) return false; + if (to != null && t >= to) return false; + return true; + }); + + const summary = { period, from, to, ...pomoSummarize(rows) }; + let groups = []; + + if (groupBy === "session") { + groups = rows + .slice() + .sort((a, b) => Date.parse(b.started) - Date.parse(a.started)) + .map((s) => ({ + key: String(s.id), + label: s.started, + type: s.type, + task: s.task || "", + started: s.started, + plannedMin: parseInt(s.plannedMin, 10) || 0, + actualMin: parseInt(s.actualMin, 10) || 0, + status: s.status, + })); + } else { + const buckets = new Map(); + for (const s of rows) { + let key; + if (groupBy === "week") key = pomoIsoWeekKey(new Date(Date.parse(s.started))); + else if (groupBy === "task") key = s.task || "(no task)"; + else key = pomoLocalDayKey(new Date(Date.parse(s.started))); + if (!buckets.has(key)) buckets.set(key, []); + buckets.get(key).push(s); + } + groups = Array.from(buckets.entries()).map(([key, list]) => { + const sum = pomoSummarize(list); + return { key, label: key, ...sum }; + }); + if (groupBy === "task") { + groups.sort((a, b) => b.focusMinutes - a.focusMinutes || b.completedFocusSessions - a.completedFocusSessions); + } else { + groups.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); + } + } + + return { period, groupBy, summary, groups }; +} + +// --------------------------------------------------------------------------- +// Journal +// --------------------------------------------------------------------------- +function parseJournalEntries(content) { + const lines = content.split("\n"); + const entries = []; + let date = null; + let text = []; + for (const line of lines) { + if (line.match(/^###\s+\d{4}-\d{2}-\d{2}$/)) { + if (date) entries.push({ date, text: text.join("\n").trim() }); + date = line.replace("### ", "").trim(); + text = []; + } else if (date) { + text.push(line); + } + } + if (date) entries.push({ date, text: text.join("\n").trim() }); + return entries; +} + +export function listJournal(days = 3650) { + const config = loadConfig(); + const p = resolveFilePath("journal", config); + const entries = parseJournalEntries(readFileOrCreate(p)); + return entries.slice(-days).reverse(); +} + +export function addJournal(text) { + if (!text || !String(text).trim()) throw new Error("text is required"); + const config = loadConfig(); + const p = resolveFilePath("journal", config); + const content = readFileOrCreate(p); + const today = todayISO(); + const heading = `### ${today}`; + let next; + if (content.includes(heading)) next = content.replace(heading, `${heading}\n\n${text}`); + else next = content ? `${content}\n\n${heading}\n\n${text}` : `${heading}\n\n${text}`; + fs.writeFileSync(p, next, "utf8"); + return { date: today, text }; +} + +// --------------------------------------------------------------------------- +// Milestones (table: ID | Name | Target Date | Status | Notes) +// --------------------------------------------------------------------------- +function parseMilestones(content) { + const lines = content.split("\n"); + const rows = []; + for (const line of lines) { + if (!line.includes("|")) continue; + if (line.match(/^[\s\-|]+$/)) continue; + const cells = splitCells(line); + if (cells.length && cells[0] === "") cells.shift(); + if (cells.length && cells[cells.length - 1] === "") cells.pop(); + if (!cells.length) continue; + if (/^id$/i.test(cells[0])) continue; // header + const id = parseInt(cells[0], 10); + if (!Number.isFinite(id)) continue; + rows.push({ + id, + name: cells[1] || "", + targetDate: cells[2] || "", + status: cells[3] || "Planned", + notes: cells[4] || "", + }); + } + return rows; +} + +function formatMilestones(rows) { + let out = "# Milestones\n\n| ID | Name | Target Date | Status | Notes |\n| --- | --- | --- | --- | --- |\n"; + rows.forEach((m) => { out += `| ${m.id} | ${encodeCell(m.name)} | ${encodeCell(m.targetDate)} | ${encodeCell(m.status)} | ${encodeCell(m.notes)} |\n`; }); + return out.trimEnd(); +} + +export function listMilestones() { + const config = loadConfig(); + const p = resolveFilePath("milestones", config); + return parseMilestones(readFileOrCreate(p)); +} + +export function addMilestone(params = {}) { + if (!params.name) throw new Error("name is required"); + const config = loadConfig(); + const p = resolveFilePath("milestones", config); + const rows = parseMilestones(readFileOrCreate(p)); + const m = { + id: nextId(rows), + name: params.name, + targetDate: params.targetDate || "", + status: params.status || "Planned", + notes: params.notes || "", + }; + rows.push(m); + fs.writeFileSync(p, formatMilestones(rows), "utf8"); + return m; +} + +export function updateMilestone(id, patch = {}) { + const config = loadConfig(); + const p = resolveFilePath("milestones", config); + const rows = parseMilestones(readFileOrCreate(p)); + const m = rows.find((x) => x.id === parseInt(id, 10)); + if (!m) throw new Error(`Milestone #${id} not found`); + for (const k of ["name", "targetDate", "status", "notes"]) if (patch[k] !== undefined) m[k] = patch[k]; + fs.writeFileSync(p, formatMilestones(rows), "utf8"); + return m; +} + +// --------------------------------------------------------------------------- +// Memos +// --------------------------------------------------------------------------- +export function listMemos() { + const config = loadConfig(); + const dir = resolveFilePath("memos", config); + ensureDir(dir); + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort().reverse(); + return files.map((filename) => { + let title = filename.replace(/\.md$/, ""); + try { + const first = fs.readFileSync(path.join(dir, filename), "utf8").split("\n")[0]; + title = first.replace(/^#\s+/, "").trim() || title; + } catch { /* ignore */ } + const dateMatch = filename.match(/^(\d{4}-\d{2}-\d{2})_/); + return { filename, title, date: dateMatch ? dateMatch[1] : "" }; + }); +} + +export function readMemo(filename) { + const config = loadConfig(); + const dir = resolveFilePath("memos", config); + const safe = path.basename(String(filename || "")); + const p = path.join(dir, safe); + if (path.dirname(path.resolve(p)) !== path.resolve(dir)) throw new Error("Invalid memo path"); + if (!fs.existsSync(p)) throw new Error(`Memo not found: ${safe}`); + const content = fs.readFileSync(p, "utf8"); + const lines = content.split("\n"); + const title = (lines[0] || "").replace(/^#\s+/, "").trim(); + const body = lines.slice(2).join("\n").trim(); + return { filename: safe, title, content: body }; +} + +export function createMemo(params = {}) { + if (!params.title) throw new Error("title is required"); + const config = loadConfig(); + const dir = resolveFilePath("memos", config); + ensureDir(dir); + const filename = `${todayISO()}_${slugify(params.title)}.md`; + const p = uniquePath(path.join(dir, filename)); + fs.writeFileSync(p, `# ${params.title}\n\n${params.content || "Add your memo content here."}`, "utf8"); + return { filename: path.basename(p), title: params.title }; +} + +// --------------------------------------------------------------------------- +// Per-file domains: learning, growth, projects (YAML frontmatter notes) +// --------------------------------------------------------------------------- +function toFrontmatter(obj) { + const lines = ["---"]; + for (const [k, v] of Object.entries(obj)) { + if (Array.isArray(v)) lines.push(`${k}: [${v.map((x) => JSON.stringify(String(x))).join(", ")}]`); + else lines.push(`${k}: ${JSON.stringify(v == null ? "" : String(v))}`); + } + lines.push("---"); + return lines.join("\n"); +} + +function parseFrontmatter(content) { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return {}; + const data = {}; + for (const line of match[1].split("\n")) { + const idx = line.indexOf(":"); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + let raw = line.slice(idx + 1).trim(); + if (raw.startsWith("[") && raw.endsWith("]")) { + raw = raw.slice(1, -1).split(",").map((s) => s.trim().replace(/^"|"$/g, "")).filter(Boolean); + } else { + raw = raw.replace(/^"|"$/g, ""); + } + data[key] = raw; + } + return data; +} + +function assertDomain(domain) { + if (!(domain in DOMAIN_TAG)) throw new Error(`Unknown domain: ${domain}`); +} + +export function listNotes(domain) { + assertDomain(domain); + const config = loadConfig(); + const dir = resolveFilePath(domain, config); + ensureDir(dir); + return fs.readdirSync(dir) + .filter((f) => f.endsWith(".md")) + .sort() + .reverse() + .map((filename) => { + let frontmatter = {}; + try { frontmatter = parseFrontmatter(fs.readFileSync(path.join(dir, filename), "utf8")); } catch { /* ignore */ } + return { filename, frontmatter }; + }); +} + +export function readNote(domain, filename) { + assertDomain(domain); + const config = loadConfig(); + const dir = resolveFilePath(domain, config); + const safe = path.basename(String(filename || "")); + const p = path.join(dir, safe); + if (path.dirname(path.resolve(p)) !== path.resolve(dir)) throw new Error("Invalid note path"); + if (!fs.existsSync(p)) throw new Error(`Note not found: ${safe}`); + const content = fs.readFileSync(p, "utf8"); + return { filename: safe, frontmatter: parseFrontmatter(content), body: content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim() }; +} + +export function addNote(domain, params = {}) { + assertDomain(domain); + if (!params.title) throw new Error("title is required"); + const config = loadConfig(); + const dir = resolveFilePath(domain, config); + ensureDir(dir); + const filename = `${todayISO()}_${slugify(params.title)}.md`; + const p = uniquePath(path.join(dir, filename)); + if (path.dirname(path.resolve(p)) !== path.resolve(dir)) throw new Error("Invalid note path"); + const frontmatter = { catpilot: DOMAIN_TAG[domain], title: params.title, date: todayISO(), ...(params.frontmatter || {}) }; + const body = params.body ? `\n${params.body}\n` : "\n"; + fs.writeFileSync(p, `${toFrontmatter(frontmatter)}\n\n# ${params.title}\n${body}`, "utf8"); + return { filename: path.basename(p), frontmatter }; +} + +// --------------------------------------------------------------------------- +// Dashboard summary aggregation +// --------------------------------------------------------------------------- +function daysAgoISO(n) { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().split("T")[0]; +} + +// --------------------------------------------------------------------------- +// Cross-partition aggregation. Range-based reads (dashboard last-3-days, +// timeline, report journal) must see data across every day/week/month +// partition, not just the current one. ID-keyed editable domains (tasks, +// milestones) intentionally stay current-partition to match their list views +// and avoid cross-partition ID collisions on edit. +// --------------------------------------------------------------------------- +function walkFind(root, targetName, isDir, depth, acc) { + if (depth > 5 || !fs.existsSync(root)) return acc; + let entries; + try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return acc; } + for (const e of entries) { + const full = path.join(root, e.name); + if (e.name === targetName && ((isDir && e.isDirectory()) || (!isDir && e.isFile()))) { + acc.push(full); + } else if (e.isDirectory() && !e.name.startsWith(".")) { + // Skip dotfolders (.trash, .git, .obsidian, …) β€” they are tooling + // state, not CatPilot partitions. + walkFind(full, targetName, isDir, depth + 1, acc); + } + } + return acc; +} + +function allPartitionPaths(config, type) { + const base = config.__baseDir || process.cwd(); + const storageRoot = path.resolve(base, config.storage.root); + const fileName = (config.storage.files && config.storage.files[type]) || DEFAULT_FILES[type]; + if (!fileName) return []; + const isDir = !/\.[a-z]+$/i.test(fileName); + return walkFind(storageRoot, fileName, isDir, 0, []); +} + +function collectJournal(config) { + const out = []; + for (const p of allPartitionPaths(config, "journal")) { + try { out.push(...parseJournalEntries(fs.readFileSync(p, "utf8"))); } catch { /* ignore */ } + } + return out; +} + +function collectMemos(config) { + const out = []; + for (const dir of allPartitionPaths(config, "memos")) { + let files = []; + try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")); } catch { /* ignore */ } + for (const filename of files) { + let title = filename.replace(/\.md$/, ""); + try { + const first = fs.readFileSync(path.join(dir, filename), "utf8").split("\n")[0]; + title = first.replace(/^#\s+/, "").trim() || title; + } catch { /* ignore */ } + const m = filename.match(/^(\d{4}-\d{2}-\d{2})_/); + out.push({ filename, title, date: m ? m[1] : "" }); + } + } + return out; +} + +function collectNotes(config, domain) { + const out = []; + for (const dir of allPartitionPaths(config, domain)) { + let files = []; + try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")); } catch { /* ignore */ } + for (const filename of files) { + let frontmatter = {}; + try { frontmatter = parseFrontmatter(fs.readFileSync(path.join(dir, filename), "utf8")); } catch { /* ignore */ } + out.push({ filename, frontmatter }); + } + } + return out; +} + +function collectReports(config) { + const out = []; + for (const dir of allPartitionPaths(config, "reports")) { + let files = []; + try { files = fs.readdirSync(dir).filter((f) => /\.(md|html?)$/i.test(f)); } catch { /* ignore */ } + for (const filename of files) { + let title = filename; + try { + const c = fs.readFileSync(path.join(dir, filename), "utf8"); + const h = c.match(/^#\s+(.+)$/m) || c.match(/([\s\S]*?)<\/title>/i); + if (h) title = h[1].replace(/<[^>]+>/g, "").trim(); + } catch { /* ignore */ } + out.push({ filename, title, date: reportDate(filename) }); + } + } + return out; +} + +// Existing destination paths that a migration would otherwise overwrite. +function destConflicts(src, dest, isDir) { + const out = []; + try { + if (isDir) { + const names = fs.existsSync(src) ? fs.readdirSync(src) : []; + for (const name of names) if (fs.existsSync(path.join(dest, name))) out.push(path.join(dest, name)); + } else if (fs.existsSync(dest)) { + out.push(dest); + } + } catch { /* ignore */ } + return out; +} + +export function summary() { + const config = loadConfig(); + const tasks = parseTasksTable(readFileOrCreate(resolveFilePath("tasks", config))); + const milestones = parseMilestones(readFileOrCreate(resolveFilePath("milestones", config))); + const journal = parseJournalEntries(readFileOrCreate(resolveFilePath("journal", config))); + const memos = listMemos(); + const learning = safeList("learning"); + const growth = safeList("growth"); + const projects = safeList("projects"); + + const today = todayISO(); + const open = tasks.filter((t) => t.status.toLowerCase() === "open"); + const done = tasks.filter((t) => t.status.toLowerCase() === "done"); + const blocked = tasks.filter((t) => t.status.toLowerCase() === "blocked"); + const overdue = open.filter((t) => t.dueDate && t.dueDate < today); + const dueToday = open.filter((t) => t.dueDate === today); + + // Priority distribution among open tasks + const priorityBuckets = { P0: 0, P1: 0, P2: 0, P3: 0, Other: 0 }; + for (const t of open) { + const key = (t.priority || "").toUpperCase().replace(/\s/g, ""); + if (key === "P0" || key === "HIGH") priorityBuckets.P0++; + else if (key === "P1") priorityBuckets.P1++; + else if (key === "P2" || key === "MED" || key === "MEDIUM") priorityBuckets.P2++; + else if (key === "P3" || key === "LOW") priorityBuckets.P3++; + else priorityBuckets.Other++; + } + + // Last-3-days activity spans partition boundaries, so aggregate across + // every partition rather than just the current one. + const journalAll = collectJournal(config); + const memosAll = collectMemos(config); + const learningAll = collectNotes(config, "learning"); + const growthAll = collectNotes(config, "growth"); + const projectsAll = collectNotes(config, "projects"); + + const since = daysAgoISO(2); // today, -1, -2 => 3 days inclusive + const last3 = []; + for (let i = 0; i < 3; i++) { + const day = daysAgoISO(i); + const j = journalAll.find((e) => e.date === day); + const memoCount = memosAll.filter((m) => m.date === day).length; + const noteCount = [...learningAll, ...growthAll, ...projectsAll].filter((n) => n.frontmatter?.date === day).length; + last3.push({ date: day, journal: j ? 1 : 0, memos: memoCount, notes: noteCount, doneTasks: 0 }); + } + + const recentActivity = buildTimeline({ journal: journalAll, memos: memosAll, learning: learningAll, growth: growthAll, projects: projectsAll, since }); + + const milestoneStatus = { Planned: 0, "In Progress": 0, Done: 0 }; + for (const m of milestones) { + const s = m.status || "Planned"; + if (milestoneStatus[s] === undefined) milestoneStatus[s] = 0; + milestoneStatus[s]++; + } + + return { + counts: { + tasksOpen: open.length, + tasksDone: done.length, + tasksBlocked: blocked.length, + tasksOverdue: overdue.length, + tasksDueToday: dueToday.length, + milestones: milestones.length, + memos: memos.length, + journal: journal.length, + learning: learning.length, + growth: growth.length, + projects: projects.length, + }, + priorityBuckets, + milestoneStatus, + last3, + recentActivity, + overdue: overdue.slice(0, 6), + dueToday: dueToday.slice(0, 6), + upcomingMilestones: milestones + .filter((m) => (m.status || "").toLowerCase() !== "done") + .sort((a, b) => (a.targetDate || "9999").localeCompare(b.targetDate || "9999")) + .slice(0, 5), + storageRoot: path.resolve(config.__baseDir, config.storage.root), + partition: getPartitionFolder(config.storage.partitioning), + }; +} + +function safeList(domain) { + try { return listNotes(domain); } catch { return []; } +} + +function buildTimeline({ journal, memos, learning, growth, projects, since }) { + const events = []; + for (const e of journal) if (e.date >= since) events.push({ date: e.date, type: "journal", label: e.text.slice(0, 80) || "Journal entry" }); + for (const m of memos) if (m.date && m.date >= since) events.push({ date: m.date, type: "memo", label: m.title }); + for (const n of learning) if (n.frontmatter?.date >= since) events.push({ date: n.frontmatter.date, type: "learning", label: n.frontmatter.title || n.filename }); + for (const n of growth) if (n.frontmatter?.date >= since) events.push({ date: n.frontmatter.date, type: "growth", label: n.frontmatter.title || n.filename }); + for (const n of projects) if (n.frontmatter?.date >= since) events.push({ date: n.frontmatter.date, type: "project", label: n.frontmatter.title || n.filename }); + return events.sort((a, b) => b.date.localeCompare(a.date)).slice(0, 25); +} + +// --------------------------------------------------------------------------- +// Timeline / activity feed (parametrized, grouped by day) +// --------------------------------------------------------------------------- +function oneLine(s, n = 120) { + const t = String(s || "").replace(/\s+/g, " ").trim(); + return t.length > n ? t.slice(0, n - 1) + "…" : t; +} + +export function activity({ days = 14 } = {}) { + const config = loadConfig(); + const since = daysAgoISO(Math.max(0, days - 1)); + const journal = collectJournal(config); + const memos = collectMemos(config); + const learning = collectNotes(config, "learning"); + const growth = collectNotes(config, "growth"); + const projects = collectNotes(config, "projects"); + const milestones = parseMilestones(readFileOrCreate(resolveFilePath("milestones", config))); + let reports = []; + try { reports = collectReports(config); } catch { /* ignore */ } + + const events = []; + const today = todayISO(); + for (const e of journal) if (e.date >= since) events.push({ date: e.date, type: "journal", label: "Journal entry", detail: oneLine(e.text) }); + for (const m of memos) if (m.date && m.date >= since) events.push({ date: m.date, type: "memo", label: m.title, detail: m.filename }); + for (const n of learning) if (n.frontmatter?.date >= since) events.push({ date: n.frontmatter.date, type: "learning", label: n.frontmatter.title || n.filename, detail: n.frontmatter.status || "" }); + for (const n of growth) if (n.frontmatter?.date >= since) events.push({ date: n.frontmatter.date, type: "growth", label: n.frontmatter.title || n.filename, detail: n.frontmatter.impact || "" }); + for (const n of projects) if (n.frontmatter?.date >= since) events.push({ date: n.frontmatter.date, type: "project", label: n.frontmatter.title || n.filename, detail: n.frontmatter.status || "" }); + for (const m of milestones) if (m.targetDate && m.targetDate >= since && m.targetDate <= today) events.push({ date: m.targetDate, type: "milestone", label: m.name, detail: `Target Β· ${m.status || "Planned"}` }); + for (const r of reports) if (r.date && r.date >= since) events.push({ date: r.date, type: "report", label: r.title, detail: r.filename }); + + events.sort((a, b) => b.date.localeCompare(a.date)); + const groups = []; + const idx = {}; + for (const e of events) { + if (!idx[e.date]) { idx[e.date] = { date: e.date, items: [] }; groups.push(idx[e.date]); } + idx[e.date].items.push(e); + } + const byType = {}; + for (const e of events) byType[e.type] = (byType[e.type] || 0) + 1; + return { since, days, count: events.length, byType, groups, events }; +} + +// --------------------------------------------------------------------------- +// Reports (Copilot-generated executive reports; shares the partitioned +// `reports/` folder used by the CatPilot report-generator skill) +// --------------------------------------------------------------------------- +function nowStamp() { + const d = new Date(); + const p = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`; +} + +function reportDate(filename) { + const m = filename.match(/(\d{4})-?(\d{2})-?(\d{2})/); + return m ? `${m[1]}-${m[2]}-${m[3]}` : ""; +} + +export function listReports() { + const config = loadConfig(); + const dir = resolveFilePath("reports", config); + ensureDir(dir); + const files = fs.readdirSync(dir).filter((f) => /\.(md|html?)$/i.test(f)); + const rows = files.map((filename) => { + const full = path.join(dir, filename); + let mtime = 0, size = 0; + try { const st = fs.statSync(full); mtime = st.mtimeMs; size = st.size; } catch { /* ignore */ } + const ext = path.extname(filename).slice(1).toLowerCase(); + let title = filename; + try { + const c = fs.readFileSync(full, "utf8"); + if (ext === "md") { + const h = c.match(/^#\s+(.+)$/m); + if (h) title = h[1].trim(); + } else { + const h = c.match(/<title>([\s\S]*?)<\/title>/i) || c.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i); + if (h) title = h[1].replace(/<[^>]+>/g, "").trim(); + } + } catch { /* ignore */ } + return { filename, title, date: reportDate(filename), ext, format: ext === "md" ? "markdown" : "html", mtime, size }; + }); + rows.sort((a, b) => (b.mtime - a.mtime) || b.filename.localeCompare(a.filename)); + return rows; +} + +export function readReport(filename) { + const config = loadConfig(); + const dir = resolveFilePath("reports", config); + const safe = path.basename(String(filename || "")); + const full = path.join(dir, safe); + if (!path.resolve(full).startsWith(path.resolve(dir))) throw new Error("Invalid report path"); + if (!fs.existsSync(full)) { const e = new Error(`Report not found: ${safe}`); e.code = "NOT_FOUND"; throw e; } + const ext = path.extname(safe).slice(1).toLowerCase(); + const content = fs.readFileSync(full, "utf8"); + let title = safe; + const h = content.match(/^#\s+(.+)$/m); + if (h) title = h[1].trim(); + return { filename: safe, title, content, format: ext === "md" ? "markdown" : "html", date: reportDate(safe) }; +} + +export function saveReport({ title, body, format = "markdown" } = {}) { + const config = loadConfig(); + const dir = resolveFilePath("reports", config); + ensureDir(dir); + const ext = format === "html" ? "html" : "md"; + const slug = slugify(title || "report"); + const filename = `report-${nowStamp()}${slug ? "-" + slug : ""}.${ext}`; + let content = body || ""; + if (ext === "md" && title && !/^#\s/m.test(content)) content = `# ${title}\n\n${content}`; + const full = uniquePath(path.join(dir, filename)); + fs.writeFileSync(full, content, "utf8"); + const finalName = path.basename(full); + return { filename: finalName, title: title || finalName, content, format: ext === "md" ? "markdown" : "html", date: reportDate(finalName) }; +} + +export function deleteReport(filename) { + const config = loadConfig(); + const dir = resolveFilePath("reports", config); + const safe = path.basename(String(filename || "")); + const full = path.join(dir, safe); + if (!path.resolve(full).startsWith(path.resolve(dir))) throw new Error("Invalid report path"); + if (fs.existsSync(full)) fs.unlinkSync(full); + return { ok: true, filename: safe }; +} + +function periodRange(period = "this-week") { + const now = new Date(); + const iso = (d) => d.toISOString().split("T")[0]; + const startOfWeek = (d) => { const x = new Date(d); const day = (x.getDay() + 6) % 7; x.setDate(x.getDate() - day); return x; }; + let since, until, label; + if (period === "today") { since = iso(now); until = iso(now); label = "Today"; } + else if (period === "last-week") { const s = startOfWeek(now); s.setDate(s.getDate() - 7); const e = new Date(s); e.setDate(e.getDate() + 6); since = iso(s); until = iso(e); label = "Last week"; } + else if (period === "this-month") { since = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`; until = iso(now); label = "This month"; } + else if (period === "last-month") { const m = new Date(now.getFullYear(), now.getMonth() - 1, 1); const e = new Date(now.getFullYear(), now.getMonth(), 0); since = iso(m); until = iso(e); label = "Last month"; } + else if (period === "last-7") { const s = new Date(now); s.setDate(s.getDate() - 6); since = iso(s); until = iso(now); label = "Last 7 days"; } + else if (period === "last-30") { const s = new Date(now); s.setDate(s.getDate() - 29); since = iso(s); until = iso(now); label = "Last 30 days"; } + else if (period === "all") { since = "0000-01-01"; until = "9999-12-31"; label = "All time"; } + else { const s = startOfWeek(now); since = iso(s); until = iso(now); label = "This week"; } + return { since, until, label }; +} + +// Build a professional executive report (markdown) from tasks + milestones + +// journal, mirroring the CatPilot report-generator skill's section layout, then +// persist it to the reports folder. +export function generateReport({ period = "this-week", title } = {}) { + const config = loadConfig(); + const tasks = parseTasksTable(readFileOrCreate(resolveFilePath("tasks", config))); + const milestones = parseMilestones(readFileOrCreate(resolveFilePath("milestones", config))); + const journal = collectJournal(config); + const { since, until, label } = periodRange(period); + const today = todayISO(); + + const isDone = (t) => /^(done|completed)$/i.test(t.status || ""); + const done = tasks.filter(isDone); + const open = tasks.filter((t) => !isDone(t)); + const total = tasks.length; + const rate = total ? Math.round((done.length / total) * 100) : 0; + const overdue = open.filter((t) => t.dueDate && t.dueDate < today); + const dueToday = open.filter((t) => t.dueDate === today); + + const msStatus = milestones.reduce((a, m) => { const s = m.status || "Planned"; a[s] = (a[s] || 0) + 1; return a; }, {}); + const msDue = milestones.filter((m) => m.targetDate && m.targetDate >= since && m.targetDate <= until); + const jHits = journal.filter((j) => j.date >= since && j.date <= until); + + const bar = (v, max, w = 20) => { const n = max ? Math.round((v / max) * w) : 0; return "β–ˆ".repeat(n) + "β–‘".repeat(w - n); }; + const rptTitle = title || `CatPilot Report β€” ${label}`; + + const insights = []; + if (overdue.length) insights.push(`⚠️ **${overdue.length} overdue task${overdue.length > 1 ? "s" : ""}** need attention.`); + if (rate >= 70) insights.push(`βœ… Strong completion rate at **${rate}%**.`); + else if (total) insights.push(`ℹ️ Completion rate is **${rate}%** β€” room to close out open work.`); + if ((msStatus["In Progress"] || 0) > 0) insights.push(`🎯 **${msStatus["In Progress"]} milestone${msStatus["In Progress"] > 1 ? "s" : ""}** in progress.`); + if (!jHits.length) insights.push(`ℹ️ No journal entries in ${label.toLowerCase()} β€” capture context as you go.`); + if (!insights.length) insights.push("ℹ️ Limited data for this period. Add tasks and milestones to enrich future reports."); + + const recs = []; + if (overdue.length) recs.push(`Reschedule or close the ${overdue.length} overdue item${overdue.length > 1 ? "s" : ""}.`); + if (dueToday.length) recs.push(`Prioritise ${dueToday.length} task${dueToday.length > 1 ? "s" : ""} due today.`); + if ((msStatus.Planned || 0) > 0) recs.push(`Kick off ${msStatus.Planned} planned milestone${msStatus.Planned > 1 ? "s" : ""}.`); + recs.push("Review this report with stakeholders and set next-period targets."); + + const md = [ + `# πŸ“Š ${rptTitle}`, + "", + `## 🧭 Executive Summary`, + `Over **${label.toLowerCase()}** (${since} β†’ ${until}), the workspace tracked **${total} task${total !== 1 ? "s" : ""}** ` + + `(${done.length} completed, ${open.length} open, ${rate}% completion) across **${milestones.length} milestone${milestones.length !== 1 ? "s" : ""}**. ` + + (overdue.length ? `There ${overdue.length === 1 ? "is" : "are"} **${overdue.length} overdue** item${overdue.length > 1 ? "s" : ""} to address.` : `Nothing is overdue.`), + "", + `## πŸ”’ KPI Snapshot`, + `| Metric | Value |`, + `| --- | --- |`, + `| Total tasks | ${total} |`, + `| Open tasks | ${open.length} |`, + `| Completed tasks | ${done.length} |`, + `| Completion rate | ${rate}% |`, + `| Overdue | ${overdue.length} |`, + `| Due today | ${dueToday.length} |`, + `| Milestones | ${milestones.length} |`, + `| Journal entries (period) | ${jHits.length} |`, + "", + `## βœ… Tasks Analysis`, + `\`\`\``, + `Completed ${bar(done.length, total)} ${done.length}/${total}`, + `Open ${bar(open.length, total)} ${open.length}/${total}`, + `\`\`\``, + overdue.length ? "**Overdue:**\n" + overdue.slice(0, 8).map((t) => `- ${t.title}${t.dueDate ? ` _(due ${t.dueDate})_` : ""}`).join("\n") : "_No overdue tasks._", + "", + `## 🎯 Milestones Analysis`, + Object.keys(msStatus).length + ? Object.entries(msStatus).map(([s, n]) => `- **${s}:** ${n}`).join("\n") + : "_No milestones tracked yet._", + msDue.length ? "\n**Targeting this period:**\n" + msDue.map((m) => `- ${m.name} β€” ${m.targetDate} _(${m.status})_`).join("\n") : "", + "", + `## πŸ“ˆ Trends`, + `- ${jHits.length} journal entr${jHits.length === 1 ? "y" : "ies"} logged in ${label.toLowerCase()}.`, + `- ${done.length} task${done.length !== 1 ? "s" : ""} marked done overall.`, + "", + `## ⚠️ Risks & Insights`, + insights.map((i) => `- ${i}`).join("\n"), + "", + `## πŸš€ Recommendations`, + recs.map((r, i) => `${i + 1}. ${r}`).join("\n"), + "", + `---`, + `_Generated by the CatPilot canvas on ${today}. Period: ${label}._`, + ].join("\n"); + + return saveReport({ title: rptTitle, body: md, format: "markdown" }); +} + +// --------------------------------------------------------------------------- +// Config editing + interactive data migration +// --------------------------------------------------------------------------- + +// Resolve every domain path for a given (root, partitioning) pair without +// mutating the active config. +function resolveDomainPaths(baseDir, root, partitioning, files = DEFAULT_FILES) { + const storageRoot = path.resolve(baseDir, root); + const partition = getPartitionFolder(partitioning); + const out = {}; + for (const [type, fileName] of Object.entries({ ...DEFAULT_FILES, ...(files || {}) })) { + out[type] = { path: path.join(storageRoot, partition, fileName), isDir: !/\.[a-z]+$/i.test(fileName) }; + } + return { storageRoot, partition, paths: out }; +} + +function countEntries(p, isDir) { + try { + if (isDir) return fs.existsSync(p) ? fs.readdirSync(p).length : 0; + return fs.existsSync(p) ? 1 : 0; + } catch { return 0; } +} + +// Return the current config plus a normalized view for the settings UI. +export function getConfig() { + const status = configStatus(); + if (!status.configured) return { configured: false }; + const config = loadConfig(); + const { storageRoot, partition } = resolveDomainPaths(config.__baseDir, config.storage.root, config.storage.partitioning, config.storage.files); + return { + configured: true, + configPath: config.__configPath, + scope: status.scope, + root: config.storage.root, + resolvedRoot: storageRoot, + partitioning: config.storage.partitioning, + partition, + migration: config.migration?.mode || "adopt", + allowExternalPaths: config.storage.allowExternalPaths !== false, + pomodoro: { + focus: pomodoroDefaultMinutes("focus", config), + "short-break": pomodoroDefaultMinutes("short-break", config), + "long-break": pomodoroDefaultMinutes("long-break", config), + }, + }; +} + +// Persist the Pomodoro session durations block into the active config file. +export function savePomodoroDurations(durations = {}) { + const status = configStatus(); + if (!status.configured) throw Object.assign(new Error("CatPilot is not set up yet."), { code: "NOT_CONFIGURED" }); + const config = loadConfig(); + const next = { ...POMODORO_DEFAULTS, ...(config.pomodoro || {}) }; + for (const key of Object.keys(POMODORO_DEFAULTS)) { + const n = parseInt(durations[key], 10); + if (Number.isFinite(n) && n > 0) next[key] = n; + } + config.pomodoro = next; + const { __baseDir, __configPath, ...clean } = config; + fs.writeFileSync(config.__configPath, JSON.stringify(clean, null, 2), "utf8"); + return { pomodoro: next }; +} + +// Build a preview of what changing the config would do β€” no writes. +export function planConfigChange({ root, partitioning, migration = "move" } = {}) { + const current = getConfig(); + if (!current.configured) throw Object.assign(new Error("CatPilot is not set up yet."), { code: "NOT_CONFIGURED" }); + const config = loadConfig(); + const baseDir = config.__baseDir; + const nextRoot = (root && String(root).trim()) || current.root; + const nextPart = partitioning || current.partitioning; + + const from = resolveDomainPaths(baseDir, current.root, current.partitioning, config.storage.files); + const to = resolveDomainPaths(baseDir, nextRoot, nextPart, config.storage.files); + + const rootChanged = path.resolve(from.storageRoot) !== path.resolve(to.storageRoot); + const partChanged = current.partitioning !== nextPart; + + const mkItem = (type, isDir, srcPath, destPath) => { + const count = countEntries(srcPath, isDir); + const samePath = path.resolve(srcPath) === path.resolve(destPath); + const willMove = !samePath && count > 0 && migration !== "adopt"; + return { + type, + isDir, + from: srcPath, + to: destPath, + count, + willMove, + exists: count > 0, + conflicts: willMove ? destConflicts(srcPath, destPath, isDir) : [], + }; + }; + + const items = []; + if (rootChanged && !partChanged) { + // Root-only change: migrate EVERY partition, preserving the relative + // layout, so historical data is not left behind in the old root. + for (const [type, fileName] of Object.entries({ ...DEFAULT_FILES, ...(config.storage.files || {}) })) { + const isDir = !/\.[a-z]+$/i.test(fileName); + const srcs = allPartitionPaths(config, type); + if (!srcs.length) { + items.push(mkItem(type, from.paths[type].isDir, from.paths[type].path, to.paths[type].path)); + continue; + } + for (const src of srcs) { + const rel = path.relative(from.storageRoot, src); + items.push(mkItem(type, isDir, src, path.join(to.storageRoot, rel))); + } + } + } else { + // Partitioning change (re-bucketing history is ambiguous) or same root: + // operate on the current partition only. + for (const [type, meta] of Object.entries(from.paths)) { + items.push(mkItem(type, meta.isDir, meta.path, to.paths[type].path)); + } + } + + const moving = items.filter((i) => i.willMove); + const conflicts = items.flatMap((i) => i.conflicts || []); + return { + current: { root: current.root, partitioning: current.partitioning, resolvedRoot: from.storageRoot, migration: current.migration }, + next: { root: nextRoot, partitioning: nextPart, resolvedRoot: to.storageRoot, migration }, + rootChanged, + partitioningChanged: partChanged, + needsMigration: (rootChanged || partChanged) && migration !== "adopt", + totalItems: moving.reduce((a, i) => a + i.count, 0), + conflicts, + hasConflicts: conflicts.length > 0, + items, + }; +} + +// Move or copy without ever overwriting an existing destination. Returns the +// count of entries moved and the count skipped because the destination existed. +function moveOrCopyPath(src, dest, isDir, mode) { + if (!fs.existsSync(src)) return { moved: 0, skipped: 0 }; + if (isDir) { + ensureDir(dest); + let moved = 0, skipped = 0; + for (const name of fs.readdirSync(src)) { + const s = path.join(src, name); + const d = path.join(dest, name); + if (fs.existsSync(d)) { skipped++; continue; } + if (mode === "copy") fs.copyFileSync(s, d); + else fs.renameSync(s, d); + moved++; + } + return { moved, skipped }; + } + if (fs.existsSync(dest)) return { moved: 0, skipped: 1 }; + ensureDir(path.dirname(dest)); + if (mode === "copy") fs.copyFileSync(src, dest); + else fs.renameSync(src, dest); + return { moved: 1, skipped: 0 }; +} + +// Persist an in-memory config object back to its own config file, preserving +// any custom fields (storage.files, allowExternalPaths, active config path). +function persistConfig(config) { + const { __baseDir, __configPath, ...clean } = config; + const target = __configPath || GLOBAL_CONFIG_PATH; + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, JSON.stringify(clean, null, 2), "utf8"); + return target; +} + +// Apply a config change, optionally migrating data across partitions. Requires +// an explicit confirm flag so the UI can gate it behind user approval. If any +// item fails to migrate, the whole operation aborts BEFORE the config is +// rewritten, so config and data never drift out of sync. +export function applyConfigChange({ root, partitioning, migration = "move", confirm = false } = {}) { + if (!confirm) throw new Error("applyConfigChange requires confirm=true"); + const config = loadConfig(); + const plan = planConfigChange({ root, partitioning, migration }); + + let moved = 0, skipped = 0; + const errors = []; + if (plan.needsMigration && (migration === "move" || migration === "copy")) { + for (const item of plan.items) { + if (!item.willMove) continue; + try { + const r = moveOrCopyPath(item.from, item.to, item.isDir, migration); + moved += r.moved; + skipped += r.skipped; + } catch (err) { + errors.push({ type: item.type, from: item.from, to: item.to, error: String(err && err.message || err) }); + } + } + } + + if (errors.length) { + // Config is left untouched; already-moved files stay where they landed + // but the sourceβ†’config mapping is unchanged, so a retry is safe. + throw Object.assign(new Error(`Migration failed for ${errors.length} item(s); configuration was not changed.`), { + code: "MIGRATION_FAILED", + details: errors, + moved, + skipped, + }); + } + + config.storage.root = plan.next.root; + config.storage.partitioning = plan.next.partitioning; + config.migration = { ...(config.migration || {}), mode: migration }; + const configPath = persistConfig(config); + + return { ok: true, migrated: moved, moved, skipped, config: getConfig(), applied: plan.next, note: configPath }; +} diff --git a/extensions/catpilot-canvas/extension.mjs b/extensions/catpilot-canvas/extension.mjs new file mode 100644 index 000000000..f117a63e1 --- /dev/null +++ b/extensions/catpilot-canvas/extension.mjs @@ -0,0 +1,360 @@ +// Extension: catpilot-canvas +// A modern visual command center for CatPilot. Serves a single-page app over a +// loopback HTTP server and reads/writes the SAME storage the CatPilot agent, +// `cat-pilot` CLI and CatPilot MCP server use (via catpilot-store.mjs). + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; +import * as store from "./catpilot-store.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const UI_DIR = path.join(HERE, "ui"); + +let sessionRef = null; +function log(message, level = "info") { + try { sessionRef?.log?.(message, { level }); } catch { /* ignore */ } +} + +// --------------------------------------------------------------------------- +// Single shared loopback server (data is global; every instance is identical). +// --------------------------------------------------------------------------- +let sharedServer = null; +let serverInit = null; +const openInstances = new Set(); + +const STATIC = { + "/": { file: "index.html", type: "text/html; charset=utf-8" }, + "/index.html": { file: "index.html", type: "text/html; charset=utf-8" }, + "/app.js": { file: "app.js", type: "text/javascript; charset=utf-8" }, + "/styles.css": { file: "styles.css", type: "text/css; charset=utf-8" }, + "/hero.png": { file: "hero.png", type: "image/png" }, + "/hero.svg": { file: "hero.svg", type: "image/svg+xml" }, +}; + +function sendJson(res, code, payload) { + const body = JSON.stringify(payload); + res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); + res.end(body); +} + +async function readBody(req) { + const chunks = []; + for await (const c of req) chunks.push(c); + if (!chunks.length) return {}; + try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { return {}; } +} + +// REST API: (method, pathname) -> handler(req, params, body) returning data +async function handleApi(req, url) { + const p = url.pathname; + const m = req.method; + + // Status / onboarding + if (p === "/api/status" && m === "GET") { + return { ...store.configStatus() }; + } + if (p === "/api/setup" && m === "POST") { + store.writeConfig(await readBody(req)); + return { ok: true, ...store.configStatus() }; + } + if (p === "/api/summary" && m === "GET") return store.summary(); + + // Tasks + if (p === "/api/tasks" && m === "GET") return { tasks: store.listTasks(url.searchParams.get("status") || "all") }; + if (p === "/api/tasks" && m === "POST") return { task: store.addTask(await readBody(req)) }; + let mm = p.match(/^\/api\/tasks\/(\d+)$/); + if (mm && m === "PUT") return { task: store.updateTask(mm[1], await readBody(req)) }; + if (mm && m === "DELETE") return { removed: store.removeTask(mm[1]) }; + mm = p.match(/^\/api\/tasks\/(\d+)\/complete$/); + if (mm && m === "POST") return { task: store.completeTask(mm[1]) }; + + // Journal + if (p === "/api/journal" && m === "GET") return { entries: store.listJournal(Number(url.searchParams.get("days")) || 3650) }; + if (p === "/api/journal" && m === "POST") return { entry: store.addJournal((await readBody(req)).text) }; + + // Milestones + if (p === "/api/milestones" && m === "GET") return { milestones: store.listMilestones() }; + if (p === "/api/milestones" && m === "POST") return { milestone: store.addMilestone(await readBody(req)) }; + mm = p.match(/^\/api\/milestones\/(\d+)$/); + if (mm && m === "PUT") return { milestone: store.updateMilestone(mm[1], await readBody(req)) }; + + // Memos + if (p === "/api/memos" && m === "GET") return { memos: store.listMemos() }; + if (p === "/api/memos" && m === "POST") return { memo: store.createMemo(await readBody(req)) }; + mm = p.match(/^\/api\/memos\/(.+)$/); + if (mm && m === "GET") return { memo: store.readMemo(decodeURIComponent(mm[1])) }; + + // Domains: learning | growth | projects + mm = p.match(/^\/api\/(learning|growth|projects)$/); + if (mm && m === "GET") return { notes: store.listNotes(mm[1]) }; + if (mm && m === "POST") { + const body = await readBody(req); + const { title, body: noteBody, ...frontmatter } = body; + return { note: store.addNote(mm[1], { title, body: noteBody, frontmatter }) }; + } + mm = p.match(/^\/api\/(learning|growth|projects)\/(.+)$/); + if (mm && m === "GET") return { note: store.readNote(mm[1], decodeURIComponent(mm[2])) }; + + // Reports (Copilot-generated executive reports) + if (p === "/api/reports" && m === "GET") return { reports: store.listReports() }; + if (p === "/api/reports" && m === "POST") { + const body = await readBody(req); + if (body && typeof body.body === "string" && body.body.length) return { report: store.saveReport(body) }; + return { report: store.generateReport(body || {}) }; + } + mm = p.match(/^\/api\/reports\/(.+)$/); + if (mm && m === "GET") return { report: store.readReport(decodeURIComponent(mm[1])) }; + if (mm && m === "DELETE") return { removed: store.deleteReport(decodeURIComponent(mm[1])) }; + + // Timeline / activity feed + if (p === "/api/timeline" && m === "GET") return store.activity({ days: Number(url.searchParams.get("days")) || 14 }); + + // Pomodoro + if (p === "/api/pomodoro/status" && m === "GET") return store.pomodoroStatus(); + if (p === "/api/pomodoro/stats" && m === "GET") return store.pomodoroStats({ period: url.searchParams.get("period") || "all" }); + if (p === "/api/pomodoro/report" && m === "GET") return store.pomodoroReport({ period: url.searchParams.get("period") || "this-week", groupBy: url.searchParams.get("by") || "day" }); + if (p === "/api/pomodoro/complete" && m === "POST") return store.pomodoroComplete(await readBody(req)); + if (p === "/api/pomodoro/cancel" && m === "POST") return store.pomodoroCancel(await readBody(req)); + if (p === "/api/pomodoro/pause" && m === "POST") return store.pomodoroPause(await readBody(req)); + if (p === "/api/pomodoro/resume" && m === "POST") return store.pomodoroResume(await readBody(req)); + if (p === "/api/pomodoro" && m === "GET") return store.pomodoroList({ limit: Number(url.searchParams.get("limit")) || undefined }); + if (p === "/api/pomodoro" && m === "POST") return store.pomodoroStart(await readBody(req)); + + // Config + interactive data migration + if (p === "/api/config" && m === "GET") return store.getConfig(); + if (p === "/api/config/plan" && m === "POST") return store.planConfigChange(await readBody(req)); + if (p === "/api/config/apply" && m === "POST") return store.applyConfigChange(await readBody(req)); + if (p === "/api/config/pomodoro" && m === "POST") return store.savePomodoroDurations((await readBody(req))?.pomodoro || {}); + + // Agent bridge β€” drive the Copilot session from the canvas + if (p === "/api/agent" && m === "POST") { + const { prompt } = await readBody(req); + if (!prompt || !String(prompt).trim()) throw new Error("prompt is required"); + if (!sessionRef?.send) return { ok: false, error: "No active session." }; + const id = await sessionRef.send(String(prompt)); + return { ok: true, id }; + } + if (p === "/api/agent/generate" && m === "POST") { + const { prompt, timeout } = await readBody(req); + if (!prompt || !String(prompt).trim()) throw new Error("prompt is required"); + if (!sessionRef?.sendAndWait) return { ok: false, error: "No active session." }; + const ev = await sessionRef.sendAndWait(String(prompt), Number(timeout) || 120000); + return { ok: true, content: ev?.data?.content || "" }; + } + + return null; // not an API route we know +} + +// Reject cross-origin / DNS-rebinding attempts against the loopback API. The +// server binds to 127.0.0.1, but a malicious web page could still POST to it +// via a spoofed Host header or a cross-site form/fetch, so we defend in depth: +// only loopback Host values are accepted, cross-site Sec-Fetch-Site is refused, +// and any Origin present must itself be loopback. +function isLoopbackHost(value) { + if (!value) return false; + let host = String(value).trim().toLowerCase(); + if (host.startsWith("[")) host = host.slice(1, host.indexOf("]") === -1 ? host.length : host.indexOf("]")); + else host = host.split(":")[0]; + return host === "127.0.0.1" || host === "localhost" || host === "::1"; +} + +function apiGuardReason(req) { + const host = req.headers["host"]; + if (host && !isLoopbackHost(host)) return "host"; + const site = req.headers["sec-fetch-site"]; + if (site && site !== "same-origin" && site !== "none") return "sec-fetch-site"; + const origin = req.headers["origin"]; + if (origin) { + try { if (!isLoopbackHost(new URL(origin).hostname)) return "origin"; } + catch { return "origin"; } + } + return null; +} + +async function requestListener(req, res) { + let url; + try { url = new URL(req.url, "http://127.0.0.1"); } catch { res.writeHead(400); return res.end(); } + const p = url.pathname; + + if (p.startsWith("/api/")) { + const denied = apiGuardReason(req); + if (denied) return sendJson(res, 403, { error: "Forbidden", code: "CROSS_ORIGIN_BLOCKED", reason: denied }); + try { + const data = await handleApi(req, url); + if (data === null) return sendJson(res, 404, { error: "Not found" }); + return sendJson(res, 200, data); + } catch (err) { + const notConfigured = err?.code === "NOT_CONFIGURED"; + return sendJson(res, notConfigured ? 409 : 400, { error: err.message, code: err.code || null }); + } + } + + const asset = STATIC[p]; + if (asset) { + try { + const buf = await readFile(path.join(UI_DIR, asset.file)); + res.writeHead(200, { "Content-Type": asset.type, "Cache-Control": "no-store" }); + return res.end(buf); + } catch { + res.writeHead(404); return res.end("Not found"); + } + } + res.writeHead(404); res.end("Not found"); +} + +async function ensureServer() { + if (sharedServer) return sharedServer; + // Cache the in-flight init so concurrent open() calls share one server + // instead of racing to bind two loopback listeners. + if (!serverInit) { + serverInit = (async () => { + const server = createServer((req, res) => { + requestListener(req, res).catch(() => { try { res.writeHead(500); res.end(); } catch { /* */ } }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const port = server.address().port; + sharedServer = { server, url: `http://127.0.0.1:${port}/` }; + log(`CatPilot canvas server listening on ${sharedServer.url}`); + return sharedServer; + })().catch((err) => { serverInit = null; throw err; }); + } + return serverInit; +} + +// --------------------------------------------------------------------------- +// Agent-facing actions (mirror the main mutations so the agent can drive it). +// --------------------------------------------------------------------------- +const actions = [ + { + name: "refresh", + description: "Return the latest CatPilot dashboard summary (counts, activity, charts data).", + handler: async () => { + try { return { ok: true, summary: store.summary() }; } + catch (err) { + if (err.code === "NOT_CONFIGURED") return { ok: false, configured: false }; + throw new CanvasError("summary_failed", err.message); + } + }, + }, + { + name: "add_task", + description: "Add a CatPilot task.", + inputSchema: { + type: "object", + required: ["title"], + properties: { + title: { type: "string" }, + due: { type: "string", description: "YYYY-MM-DD" }, + priority: { type: "string", description: "P0/P1/P2/P3 or High/Med/Low" }, + tags: { type: "string" }, + context: { type: "string" }, + }, + }, + handler: async (ctx) => { + try { return { ok: true, task: store.addTask(ctx.input || {}) }; } + catch (err) { throw new CanvasError("add_task_failed", err.message); } + }, + }, + { + name: "complete_task", + description: "Mark a CatPilot task as Done by numeric ID.", + inputSchema: { type: "object", required: ["id"], properties: { id: { type: "number" } } }, + handler: async (ctx) => { + try { return { ok: true, task: store.completeTask(ctx.input.id) }; } + catch (err) { throw new CanvasError("complete_task_failed", err.message); } + }, + }, + { + name: "add_journal", + description: "Append a CatPilot journal entry for today.", + inputSchema: { type: "object", required: ["text"], properties: { text: { type: "string" } } }, + handler: async (ctx) => { + try { return { ok: true, entry: store.addJournal(ctx.input.text) }; } + catch (err) { throw new CanvasError("add_journal_failed", err.message); } + }, + }, + { + name: "add_milestone", + description: "Add a CatPilot milestone.", + inputSchema: { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + targetDate: { type: "string", description: "YYYY-MM-DD" }, + status: { type: "string", enum: ["Planned", "In Progress", "Done"] }, + notes: { type: "string" }, + }, + }, + handler: async (ctx) => { + try { return { ok: true, milestone: store.addMilestone(ctx.input || {}) }; } + catch (err) { throw new CanvasError("add_milestone_failed", err.message); } + }, + }, + { + name: "generate_report", + description: "Generate and save a CatPilot executive report (markdown) for a period, built from tasks, milestones and journal.", + inputSchema: { + type: "object", + properties: { + period: { type: "string", enum: ["today", "this-week", "last-week", "last-7", "this-month", "last-month", "last-30", "all"], description: "Reporting period (default this-week)." }, + title: { type: "string", description: "Optional report title." }, + }, + }, + handler: async (ctx) => { + try { return { ok: true, report: store.generateReport(ctx.input || {}) }; } + catch (err) { throw new CanvasError("generate_report_failed", err.message); } + }, + }, + { + name: "save_report", + description: "Save a CatPilot report the agent has authored (e.g. via the report-generator skill) into the canvas reports folder.", + inputSchema: { + type: "object", + required: ["title", "body"], + properties: { + title: { type: "string" }, + body: { type: "string", description: "Report content (markdown or HTML)." }, + format: { type: "string", enum: ["markdown", "html"], description: "Defaults to markdown." }, + }, + }, + handler: async (ctx) => { + try { return { ok: true, report: store.saveReport(ctx.input || {}) }; } + catch (err) { throw new CanvasError("save_report_failed", err.message); } + }, + }, +]; + +const canvas = createCanvas({ + id: "catpilot-canvas", + displayName: "CatPilot", + description: "Visual command center for CatPilot: dashboard, tasks, journal, milestones, memos, learning, growth, projects, timeline, Copilot reports, settings/migration and a help guide β€” with charts and agent actions.", + inputSchema: { + type: "object", + properties: { view: { type: "string", description: "Optional initial view: dashboard|timeline|tasks|journal|milestones|memos|learning|growth|projects|reports|settings|help" } }, + }, + actions, + open: async (ctx) => { + const srv = await ensureServer(); + openInstances.add(ctx.instanceId); + const view = ctx.input?.view ? `#${encodeURIComponent(ctx.input.view)}` : ""; + return { title: "CatPilot", status: "Ready", url: `${srv.url}${view}` }; + }, + onClose: async (ctx) => { + openInstances.delete(ctx.instanceId); + if (openInstances.size === 0 && sharedServer) { + const { server } = sharedServer; + sharedServer = null; + serverInit = null; + await new Promise((resolve) => server.close(() => resolve())); + } + }, +}); + +sessionRef = await joinSession({ canvases: [canvas] }); diff --git a/extensions/catpilot-canvas/package-lock.json b/extensions/catpilot-canvas/package-lock.json new file mode 100644 index 000000000..3a2d1aaad --- /dev/null +++ b/extensions/catpilot-canvas/package-lock.json @@ -0,0 +1,218 @@ +{ + "name": "catpilot-canvas", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "catpilot-canvas", + "version": "1.0.0", + "dependencies": { + "@github/copilot-sdk": "1.0.1" + } + }, + "node_modules/@github/copilot": { + "version": "1.0.70", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot/-/copilot-1.0.70.tgz", + "integrity": "sha1-ZXwZ/95WrolCJp3qxmhGeJm+i48=", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.70", + "@github/copilot-darwin-x64": "1.0.70", + "@github/copilot-linux-arm64": "1.0.70", + "@github/copilot-linux-x64": "1.0.70", + "@github/copilot-linuxmusl-arm64": "1.0.70", + "@github/copilot-linuxmusl-x64": "1.0.70", + "@github/copilot-win32-arm64": "1.0.70", + "@github/copilot-win32-x64": "1.0.70" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.70", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.70.tgz", + "integrity": "sha1-a++8R/8ywfJGyVmS3eSvyg5EFXY=", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-darwin-x64": { + "version": "1.0.70", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.70.tgz", + "integrity": "sha1-cniNbh3UEEpdYuUcoWixBXVwIC0=", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-linux-arm64": { + "version": "1.0.70", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.70.tgz", + "integrity": "sha1-CC0twbGPuSbuPQF5J8QMn0tUNF0=", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linux-x64": { + "version": "1.0.70", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.70.tgz", + "integrity": "sha1-oqU2h9sYafFeM6hEA6+3rvB2wWo=", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-arm64": { + "version": "1.0.70", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.70.tgz", + "integrity": "sha1-GBDVmhv4wbopdNxFJHqaeNKV6fc=", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-x64": { + "version": "1.0.70", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.70.tgz", + "integrity": "sha1-67W11PTO3uE3mEVWDSzLMTivBRw=", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk": { + "version": "1.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz", + "integrity": "sha1-bmIiUzYx4TEzb9910i1Ksz2GJME=", + "license": "MIT", + "dependencies": { + "@github/copilot": "^1.0.61", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "1.0.70", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.70.tgz", + "integrity": "sha1-QMzrmlebPAOUNbCTMNk6TBQRmuU=", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-win32-x64": { + "version": "1.0.70", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.70.tgz", + "integrity": "sha1-GxOGU2eCSxWt2TUWxy8f+9/ODVU=", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha1-aJxdzcGQDvVYOky59te0c3QgdK0=", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha1-oyLMDx2X95T/2cTNKomKC94JfzQ=", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/zod/-/zod-4.4.3.tgz", + "integrity": "sha1-toDxcohdGLvr8hqDTqJeVaG781Y=", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/extensions/catpilot-canvas/package.json b/extensions/catpilot-canvas/package.json new file mode 100644 index 000000000..dc1c79139 --- /dev/null +++ b/extensions/catpilot-canvas/package.json @@ -0,0 +1,21 @@ +{ + "name": "catpilot-canvas", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "dependencies": { + "@github/copilot-sdk": "1.0.1" + }, + "description": "Visual command center canvas for CatPilot: tasks (list/board/calendar), a configurable Pomodoro timer with a global mini-timer dock, journal, milestones, memos, learning, growth, projects, Copilot reports and an activity timeline with dashboards, charts, a markdown editor and interactive settings.", + "keywords": [ + "canvas", + "productivity", + "task-management", + "pomodoro", + "timer", + "calendar", + "dashboard", + "personal-assistant", + "markdown-editor" + ] +} diff --git a/extensions/catpilot-canvas/ui/app.js b/extensions/catpilot-canvas/ui/app.js new file mode 100644 index 000000000..495407772 --- /dev/null +++ b/extensions/catpilot-canvas/ui/app.js @@ -0,0 +1,1948 @@ +/* CatPilot canvas SPA. Vanilla JS, no build step, no external deps. */ +(() => { + "use strict"; + + // ---------------------------------------------------------------- helpers + const $ = (sel, root = document) => root.querySelector(sel); + const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + const todayISO = () => new Date().toISOString().split("T")[0]; + + function el(tag, attrs = {}, ...kids) { + const n = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v == null || v === false) continue; + if (k === "class") n.className = v; + else if (k === "html") n.innerHTML = v; + else if (k === "text") n.textContent = v; + else if (k.startsWith("on") && typeof v === "function") n.addEventListener(k.slice(2), v); + else if (k === "dataset") Object.assign(n.dataset, v); + else n.setAttribute(k, v); + } + for (const kid of kids.flat()) { + if (kid == null || kid === false) continue; + n.append(kid.nodeType ? kid : document.createTextNode(kid)); + } + return n; + } + + async function api(path, { method = "GET", body } = {}) { + const res = await fetch(path, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error(data.error || `Request failed (${res.status})`); + err.code = data.code; err.status = res.status; + throw err; + } + return data; + } + + function toast(title, sub, kind = "") { + const icon = kind === "err" ? "!" : kind === "ok" ? "βœ“" : "😺"; + const t = el("div", { class: `toast ${kind}` }, + el("div", { class: "t-ic", text: icon }), + el("div", {}, el("strong", { text: title }), sub ? el("span", { text: sub }) : null)); + $("#toasts").append(t); + setTimeout(() => { t.style.opacity = "0"; t.style.transform = "translateX(20px)"; setTimeout(() => t.remove(), 250); }, 3200); + } + + // ---------------------------------------------------------------- modal + let modalReturnFocus = null; + function openModal({ title, body, foot, width }) { + closeModal(); + modalReturnFocus = document.activeElement; + const backdrop = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === backdrop) closeModal(); } }); + const titleId = `modal-title-${Date.now()}`; + const modal = el("div", { class: "modal", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, tabindex: "-1" }); + if (width) modal.style.width = `min(${width}px, 100%)`; + modal.append( + el("div", { class: "modal-head" }, + el("h3", { id: titleId, text: title }), + el("button", { class: "icon-btn", text: "βœ•", "aria-label": "Close dialog", onclick: closeModal })), + el("div", { class: "modal-body" }, body), + foot ? el("div", { class: "modal-foot" }, foot) : null, + ); + backdrop.append(modal); + $("#modal-root").append(backdrop); + document.addEventListener("keydown", escClose); + modal.addEventListener("keydown", trapFocus); + const focusables = modal.querySelectorAll("a[href], button, textarea, input, select, [tabindex]:not([tabindex='-1'])"); + (focusables[0] || modal).focus(); + return { close: closeModal, modal }; + } + function trapFocus(e) { + if (e.key !== "Tab") return; + const modal = e.currentTarget; + const f = [...modal.querySelectorAll("a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex='-1'])")] + .filter((n) => n.offsetParent !== null || n === document.activeElement); + if (!f.length) return; + const first = f[0], last = f[f.length - 1]; + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + } + function escClose(e) { if (e.key === "Escape") closeModal(); } + function closeModal() { + $("#modal-root").innerHTML = ""; + document.removeEventListener("keydown", escClose); + if (modalReturnFocus && typeof modalReturnFocus.focus === "function") { + try { modalReturnFocus.focus(); } catch { /* */ } + } + modalReturnFocus = null; + } + + // ---------------------------------------------------------------- state + const state = { summary: null, theme: localStorage.getItem("cp-theme") || "dark", view: "dashboard" }; + + const NAV = [ + { id: "dashboard", icon: "β—†", label: "Dashboard" }, + { id: "timeline", icon: "πŸ•‘", label: "Timeline" }, + { id: "tasks", icon: "βœ“", label: "Tasks", countKey: "tasksOpen" }, + { id: "journal", icon: "✎", label: "Journal", countKey: "journal" }, + { id: "milestones", icon: "βš‘", label: "Milestones", countKey: "milestones" }, + { id: "memos", icon: "β–€", label: "Memos", countKey: "memos" }, + { id: "learning", icon: "πŸŽ“", label: "Learning", countKey: "learning" }, + { id: "growth", icon: "β†—", label: "Growth", countKey: "growth" }, + { id: "projects", icon: "❏", label: "Projects", countKey: "projects" }, + { id: "pomodoro", icon: "πŸ…", label: "Pomodoro" }, + { id: "reports", icon: "πŸ“Š", label: "Reports" }, + { id: "settings", icon: "βš™οΈ", label: "Settings", footer: true }, + { id: "help", icon: "❔", label: "Help", footer: true }, + ]; + + const VIEW_SUB = { + dashboard: "Your CatPilot at a glance", + timeline: "A running story of what you've done", + tasks: "Capture, organize and complete work", + journal: "Daily notes and decisions", + milestones: "Track goals to completion", + memos: "Handoffs, summaries and notes", + learning: "Certifications and study topics", + growth: "Accomplishments and impact log", + projects: "Lightweight project status", + pomodoro: "Focus timers and logged sessions", + reports: "Executive reports from your data", + settings: "Storage, migration and preferences", + help: "Capabilities and how to use this canvas", + }; + + // ---------------------------------------------------------------- theme + function applyTheme() { + document.documentElement.setAttribute("data-theme", state.theme); + const btn = $("#theme-btn"); + if (btn) btn.textContent = state.theme === "dark" ? "β˜€οΈ" : "πŸŒ™"; + } + function toggleTheme() { state.theme = state.theme === "dark" ? "light" : "dark"; localStorage.setItem("cp-theme", state.theme); applyTheme(); } + + // ---------------------------------------------------------------- badges + function priorityClass(p) { + const k = (p || "").toUpperCase().replace(/\s/g, ""); + if (k === "P0" || k === "HIGH") return "p0"; + if (k === "P1") return "p1"; + if (k === "P2" || k === "MED" || k === "MEDIUM") return "p2"; + if (k === "P3" || k === "LOW") return "p3"; + return ""; + } + function priorityBadge(p) { return p ? el("span", { class: `badge ${priorityClass(p)}`, text: p }) : el("span", { class: "muted small", text: "β€”" }); } + function statusBadge(s) { + const k = (s || "").toLowerCase().replace(/\s/g, ""); + const cls = k === "done" ? "st-done" : k === "inprogress" ? "st-inprogress" : k === "planned" ? "st-planned" : k === "blocked" ? "st-blocked" : "st-open"; + return el("span", { class: `badge ${cls}`, text: s || "Open" }); + } + function tagChips(tags) { + const list = String(tags || "").split(",").map((t) => t.trim()).filter(Boolean); + if (!list.length) return el("span", { class: "muted small", text: "β€”" }); + return el("div", { class: "tags" }, list.map((t) => el("span", { class: "tag", text: t }))); + } + + // ---------------------------------------------------------------- charts + function donut(segments, size = 150) { + const total = segments.reduce((a, s) => a + s.value, 0); + const NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(NS, "svg"); + svg.setAttribute("viewBox", `0 0 ${size} ${size}`); + svg.setAttribute("width", size); svg.setAttribute("height", size); + const cx = size / 2, cy = size / 2, r = size / 2 - 14, C = 2 * Math.PI * r; + const track = document.createElementNS(NS, "circle"); + track.setAttribute("cx", cx); track.setAttribute("cy", cy); track.setAttribute("r", r); + track.setAttribute("fill", "none"); track.setAttribute("stroke", "var(--panel-2)"); track.setAttribute("stroke-width", 16); + svg.append(track); + let offset = 0; + if (total > 0) { + for (const s of segments) { + if (!s.value) continue; + const frac = s.value / total; + const c = document.createElementNS(NS, "circle"); + c.setAttribute("cx", cx); c.setAttribute("cy", cy); c.setAttribute("r", r); + c.setAttribute("fill", "none"); c.setAttribute("stroke", s.color); c.setAttribute("stroke-width", 16); + c.setAttribute("stroke-dasharray", `${C * frac} ${C}`); + c.setAttribute("stroke-dashoffset", -C * offset); + c.setAttribute("transform", `rotate(-90 ${cx} ${cy})`); + c.setAttribute("stroke-linecap", "round"); + svg.append(c); + offset += frac; + } + } + const t1 = document.createElementNS(NS, "text"); + t1.setAttribute("x", cx); t1.setAttribute("y", cy - 2); t1.setAttribute("text-anchor", "middle"); + t1.setAttribute("font-size", "26"); t1.setAttribute("font-weight", "800"); t1.setAttribute("fill", "var(--text)"); + t1.textContent = total; + const t2 = document.createElementNS(NS, "text"); + t2.setAttribute("x", cx); t2.setAttribute("y", cy + 16); t2.setAttribute("text-anchor", "middle"); + t2.setAttribute("font-size", "11"); t2.setAttribute("fill", "var(--text-faint)"); + t2.textContent = "total"; + svg.append(t1, t2); + return svg; + } + + function stackedBars(days) { + const series = [ + { key: "journal", color: "#7c5cff", label: "Journal" }, + { key: "memos", color: "#ff7ac2", label: "Memos" }, + { key: "notes", color: "#5aa9ff", label: "Notes" }, + ]; + const max = Math.max(1, ...days.map((d) => series.reduce((a, s) => a + (d[s.key] || 0), 0))); + const bars = el("div", { class: "bars" }); + for (const d of days) { + const stack = el("div", { class: "bar-stack" }); + for (const s of series) { + const v = d[s.key] || 0; + if (!v) continue; + const seg = el("div", { class: "bar-seg" }); + seg.style.height = `${(v / max) * 130}px`; + seg.style.background = s.color; + seg.title = `${s.label}: ${v}`; + stack.append(seg); + } + const label = new Date(d.date + "T00:00:00").toLocaleDateString(undefined, { weekday: "short" }); + bars.append(el("div", { class: "bar-col" }, stack, el("div", { class: "bar-label", text: label }))); + } + const legend = el("div", { class: "legend" }, series.map((s) => + el("span", {}, el("i", { style: `background:${s.color}` }), s.label))); + return el("div", {}, bars, legend); + } + + // Single-series vertical bar chart (value per labeled group). + function simpleBars(items, { color = "#7c5cff", unit = "" } = {}) { + const max = Math.max(1, ...items.map((d) => d.value || 0)); + const bars = el("div", { class: "bars" }); + for (const d of items) { + const stack = el("div", { class: "bar-stack" }); + if (d.value) { + const seg = el("div", { class: "bar-seg" }); + seg.style.height = `${(d.value / max) * 130}px`; + seg.style.background = color; + seg.title = `${d.label}: ${d.value}${unit}`; + stack.append(seg); + } + bars.append(el("div", { class: "bar-col" }, stack, el("div", { class: "bar-label", text: d.short || d.label }))); + } + return bars; + } + + // ---------------------------------------------------------------- nav + function renderNav() { + const nav = $("#nav"); + nav.innerHTML = ""; + const counts = state.summary?.counts || {}; + let footerStarted = false; + for (const item of NAV) { + if (item.footer && !footerStarted) { footerStarted = true; nav.append(el("div", { class: "nav-spacer" })); } + const count = item.countKey ? counts[item.countKey] : null; + const node = el("button", { + type: "button", + class: `nav-item ${state.view === item.id ? "active" : ""}`, + "aria-current": state.view === item.id ? "page" : null, + onclick: () => go(item.id), + }, + el("span", { class: "nav-ic", text: item.icon, "aria-hidden": "true" }), + el("span", { text: item.label }), + count ? el("span", { class: "nav-badge", text: String(count) }) : null); + nav.append(node); + } + const chip = $("#storage-chip"); + if (state.summary?.storageRoot) { + chip.textContent = `πŸ“ ${state.summary.storageRoot.split(/[\\/]/).slice(-2).join("/")}`; + chip.title = `${state.summary.storageRoot} Β· ${state.summary.partition}`; + } + } + + // ---------------------------------------------------------------- router + // Views append asynchronously; a fast Aβ†’B navigation must not let A's late + // resolution paint into B's screen. Each navigation gets a token and builds + // into detached staging nodes, committing to the DOM only if it is still the + // current navigation. + let navToken = 0; + let actionsHost = $("#view-actions"); + async function go(view) { + const token = ++navToken; + state.view = view; + location.hash = view; + $("#view-title").textContent = NAV.find((n) => n.id === view)?.label || "CatPilot"; + $("#view-sub").textContent = VIEW_SUB[view] || ""; + renderNav(); + const stage = el("div"); + const actionStage = el("div"); + actionsHost = actionStage; + stage.append(el("div", { class: "spinner" })); + $("#content").innerHTML = ""; + $("#content").append(el("div", { class: "spinner" })); + try { + await VIEWS[view](stage); + } catch (err) { + stage.innerHTML = ""; + stage.append(errorBox(err)); + } + if (token !== navToken) return; // superseded by a newer navigation + const content = $("#content"); + content.innerHTML = ""; + while (stage.firstChild) content.append(stage.firstChild); + const actions = $("#view-actions"); + actions.innerHTML = ""; + while (actionStage.firstChild) actions.append(actionStage.firstChild); + } + + function errorBox(err) { + return el("div", { class: "empty" }, + el("div", { class: "big", text: "😿" }), + el("h3", { text: "Something went wrong" }), + el("p", { class: "muted", text: err.message })); + } + + function emptyState(icon, title, sub, action) { + return el("div", { class: "empty" }, + el("div", { class: "big", text: icon }), + el("h3", { text: title }), + el("p", { class: "muted", text: sub }), + action ? el("div", { style: "margin-top:16px" }, action) : null); + } + + // ---------------------------------------------------------------- refresh + async function refreshSummary() { + try { state.summary = await api("/api/summary"); } + catch { state.summary = null; } + renderNav(); + } + + // ================================================================= + // VIEWS + // ================================================================= + const VIEWS = {}; + + // ---------- Dashboard ---------- + // Dashboard period filter (drives the focus/Pomodoro analytics section). + let dashPeriod = localStorage.getItem("cp-dash-period") || "today"; + const DASH_PERIODS = [["today", "Today"], ["week", "Week"], ["month", "Month"]]; + const DASH_PERIOD_LABEL = { today: "Today", week: "This week", month: "This month" }; + function fmtMinutes(m) { + m = parseInt(m, 10) || 0; + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60), r = m % 60; + return r ? `${h}h ${r}m` : `${h}h`; + } + + VIEWS.dashboard = async (root) => { + const [s, pomoStats] = await Promise.all([ + api("/api/summary"), + api(`/api/pomodoro/stats?period=${dashPeriod}`).catch(() => null), + ]); + state.summary = s; + renderNav(); + root.innerHTML = ""; + + // Period filter in the topbar β€” governs the focus analytics below. + const periodSeg = el("div", { class: "segmented" }, + ...DASH_PERIODS.map(([key, label]) => + el("button", { class: dashPeriod === key ? "active" : "", text: label, + onclick: () => { dashPeriod = key; localStorage.setItem("cp-dash-period", key); go("dashboard"); } }))); + actionsHost.append(periodSeg); + + // Hero + root.append(el("div", { class: "hero" }, + el("img", { src: "hero.png", alt: "CatPilot" }), + el("div", { class: "hero-txt" }, + el("h1", { text: greeting() }), + el("p", { text: heroLine(s) })), + el("div", { class: "hero-actions" }, + el("button", { class: "btn btn-primary", onclick: () => taskModal(), html: "οΌ‹ Task" }), + el("button", { class: "btn", onclick: () => journalModal(), html: "✎ Journal" }), + el("button", { class: "btn", onclick: () => milestoneModal(), html: "βš‘ Milestone" })))); + + // Stat cards + const c = s.counts; + const cards = [ + { n: c.tasksOpen, label: "Open tasks", ic: "βœ“", tone: "tone-accent", sub: `${c.tasksDone} completed` }, + { n: c.tasksOverdue, label: "Overdue", ic: "⏰", tone: c.tasksOverdue ? "tone-danger" : "tone-ok", sub: `${c.tasksDueToday} due today` }, + { n: c.milestones, label: "Milestones", ic: "βš‘", tone: "tone-accent", sub: `${s.milestoneStatus.Done || 0} done` }, + { n: c.memos, label: "Memos", ic: "β–€", tone: "", sub: `${c.journal} journal entries` }, + { n: c.learning, label: "Learning", ic: "πŸŽ“", tone: "", sub: "topics tracked" }, + { n: c.growth, label: "Growth", ic: "β†—", tone: "tone-ok", sub: "impact entries" }, + ]; + root.append(el("div", { class: "grid cards", style: "margin-top:16px" }, + cards.map((cd) => el("div", { class: `card stat hoverable ${cd.tone}` }, + el("span", { class: "stat-ic", text: cd.ic }), + el("div", { class: "stat-num", text: String(cd.n) }), + el("div", { class: "stat-label", text: cd.label }), + el("div", { class: "stat-sub muted", text: cd.sub }))))); + + // Focus / Pomodoro analytics β€” driven by the period filter above. + const ps = pomoStats || { focusSessions: 0, focusMinutes: 0, completedSessions: 0, abandonedSessions: 0 }; + const pomoCards = [ + { n: ps.focusSessions, label: "Focus sessions", ic: "πŸ…", tone: "tone-accent", sub: `${DASH_PERIOD_LABEL[dashPeriod]}` }, + { n: fmtMinutes(ps.focusMinutes), label: "Focus time", ic: "⏱", tone: "tone-ok", sub: "logged focus" }, + { n: ps.completedSessions, label: "Completed", ic: "βœ“", tone: "", sub: `${ps.totalSessions || 0} total` }, + { n: ps.abandonedSessions, label: "Abandoned", ic: "⏹", tone: ps.abandonedSessions ? "tone-danger" : "", sub: "cancelled early" }, + ]; + const pomoSection = el("div", { style: "margin-top:22px" }, + el("div", { class: "toolbar", style: "margin-bottom:12px" }, + el("h4", { style: "margin:0", text: `πŸ… Focus Β· ${DASH_PERIOD_LABEL[dashPeriod]}` }), + el("span", { class: "spacer" }), + el("button", { class: "btn small", html: "Open Pomodoro β†’", onclick: () => go("pomodoro") })), + el("div", { class: "grid cards" }, + pomoCards.map((cd) => el("div", { class: `card stat hoverable ${cd.tone}` }, + el("span", { class: "stat-ic", text: cd.ic }), + el("div", { class: "stat-num", text: String(cd.n) }), + el("div", { class: "stat-label", text: cd.label }), + el("div", { class: "stat-sub muted", text: cd.sub }))))); + root.append(pomoSection); + + // Charts row + const chartRow = el("div", { class: "chart-row", style: "margin-top:22px" }); + // Priority donut + const pb = s.priorityBuckets; + const pSegs = [ + { label: "P0 / High", value: pb.P0, color: "#ff6b7d" }, + { label: "P1", value: pb.P1, color: "#ffb020" }, + { label: "P2 / Med", value: pb.P2, color: "#7c5cff" }, + { label: "P3 / Low", value: pb.P3, color: "#35c88f" }, + { label: "Unset", value: pb.Other, color: "#5c5c74" }, + ]; + chartRow.append(el("div", { class: "card chart-card" }, + el("h4", { text: "Open tasks by priority" }), + el("div", { style: "display:flex;gap:20px;align-items:center;flex-wrap:wrap" }, + donut(pSegs), + el("div", { class: "legend", style: "flex-direction:column;gap:8px" }, + pSegs.map((seg) => el("span", {}, el("i", { style: `background:${seg.color}` }), `${seg.label} Β· ${seg.value}`)))))); + // Activity bars + chartRow.append(el("div", { class: "card chart-card" }, + el("h4", { text: "Activity Β· last 3 days" }), + stackedBars(s.last3))); + root.append(chartRow); + + // Two-column: focus + timeline + const twoCol = el("div", { class: "grid", style: "grid-template-columns:1fr 1fr;margin-top:22px;align-items:start" }); + + // Focus (overdue + due today + upcoming milestones) + const focus = el("div", { class: "card" }); + focus.append(el("h4", { text: "🎯 Focus", style: "margin:0 0 12px" })); + const focusItems = [ + ...s.overdue.map((t) => ({ t, tag: "Overdue", cls: "p0" })), + ...s.dueToday.map((t) => ({ t, tag: "Today", cls: "p1" })), + ]; + if (!focusItems.length && !s.upcomingMilestones.length) { + focus.append(el("p", { class: "muted", text: "You're all caught up. No overdue or due-today items. πŸŽ‰" })); + } else { + focusItems.forEach(({ t, tag, cls }) => { + focus.append(el("div", { class: "tl-item", onclick: () => taskDetail(t) }, + el("div", { class: "tl-dot", text: "βœ“" }), + el("div", { class: "tl-body" }, + el("div", { class: "tl-label", text: t.title }), + el("div", { class: "tl-meta", text: `${tag}${t.dueDate ? " Β· " + t.dueDate : ""}` })), + el("span", { class: `badge ${cls}`, text: tag }))); + }); + s.upcomingMilestones.forEach((m) => { + focus.append(el("div", { class: "tl-item" }, + el("div", { class: "tl-dot", text: "βš‘" }), + el("div", { class: "tl-body" }, + el("div", { class: "tl-label", text: m.name }), + el("div", { class: "tl-meta", text: `Milestone${m.targetDate ? " Β· " + m.targetDate : ""}` })), + statusBadge(m.status))); + }); + } + twoCol.append(focus); + + // Timeline + const tl = el("div", { class: "card" }); + tl.append(el("h4", { text: "πŸ•‘ Recent activity", style: "margin:0 0 12px" })); + if (!s.recentActivity.length) { + tl.append(el("p", { class: "muted", text: "No activity in the last 3 days. Add a journal entry or memo to get started." })); + } else { + const tlIcons = { journal: "✎", memo: "β–€", learning: "πŸŽ“", growth: "β†—", project: "❏" }; + const wrap = el("div", { class: "timeline" }); + s.recentActivity.forEach((a) => { + wrap.append(el("div", { class: "tl-item" }, + el("div", { class: "tl-dot", text: tlIcons[a.type] || "β€’" }), + el("div", { class: "tl-body" }, + el("div", { class: "tl-label", text: a.label || "(untitled)" }), + el("div", { class: "tl-meta", text: `${a.type} Β· ${a.date}` })))); + }); + tl.append(wrap); + } + twoCol.append(tl); + root.append(twoCol); + }; + + function greeting() { + const h = new Date().getHours(); + const g = h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening"; + return `${g}! 😺`; + } + function heroLine(s) { + const c = s.counts; + if (c.tasksOverdue) return `You have ${c.tasksOverdue} overdue task${c.tasksOverdue > 1 ? "s" : ""} and ${c.tasksOpen} open. Let's clear the deck.`; + if (c.tasksDueToday) return `${c.tasksDueToday} task${c.tasksDueToday > 1 ? "s" : ""} due today, ${c.tasksOpen} open in total. You've got this.`; + if (c.tasksOpen) return `${c.tasksOpen} open task${c.tasksOpen > 1 ? "s" : ""} and nothing overdue. Nice and steady.`; + return "Everything's clear. Capture something new to get rolling."; + } + + // ---------- Tasks ---------- + let taskViewMode = localStorage.getItem("cp-task-view") || "list"; + let taskDueFilter = localStorage.getItem("cp-task-filter") || "all"; // all | today | 7days + // Calendar sub-view state. + let taskCalMode = localStorage.getItem("cp-task-cal-mode") || "month"; // month | week + let taskCalAnchorISO = todayISO(); // which month/week is in view (module-persistent across re-renders) + let taskCalFields = (localStorage.getItem("cp-task-cal-fields") || "priority").split(",").filter(Boolean); + + // Apply the active due-date filter. Today = due today or overdue; + // 7days = due within the next 7 days (includes overdue). Undated tasks are + // hidden while a filter is active, per product decision. + function applyDueFilter(tasks) { + if (taskDueFilter === "all") return tasks; + const today = todayISO(); + const in7 = new Date(); in7.setDate(in7.getDate() + 7); + const limit = in7.toISOString().split("T")[0]; + if (taskDueFilter === "today") return tasks.filter((t) => t.dueDate && t.dueDate <= today); + if (taskDueFilter === "7days") return tasks.filter((t) => t.dueDate && t.dueDate <= limit); + return tasks; + } + + VIEWS.tasks = async (root) => { + const { tasks: allTasks } = await api("/api/tasks?status=all"); + const tasks = applyDueFilter(allTasks); + root.innerHTML = ""; + + // view switcher in topbar + const seg = el("div", { class: "segmented" }, + el("button", { class: taskViewMode === "list" ? "active" : "", text: "☰ List", onclick: () => { taskViewMode = "list"; localStorage.setItem("cp-task-view", "list"); go("tasks"); } }), + el("button", { class: taskViewMode === "board" ? "active" : "", text: "β–¦ Board", onclick: () => { taskViewMode = "board"; localStorage.setItem("cp-task-view", "board"); go("tasks"); } }), + el("button", { class: taskViewMode === "calendar" ? "active" : "", text: "πŸ“… Calendar", onclick: () => { taskViewMode = "calendar"; localStorage.setItem("cp-task-view", "calendar"); go("tasks"); } })); + actionsHost.append(seg); + + // Calendar mode uses the full task set (the due-window filter would hide most + // days) and its own toolbar; list/board keep the existing due filter. + if (taskViewMode === "calendar") { + const holderCal = el("div", { id: "task-holder" }); + root.append(buildCalendarToolbar(allTasks), holderCal); + if (!allTasks.length) { holderCal.append(emptyState("πŸ—’οΈ", "No tasks yet", "Capture your first task and it will show up on the calendar.", el("button", { class: "btn btn-primary", html: "οΌ‹ Add task", onclick: () => taskModal() }))); return; } + renderTaskCalendar(holderCal, allTasks); + root._tasks = allTasks; + return; + } + + const filterSeg = el("div", { class: "segmented" }, + ...[["all", "All"], ["today", "Today"], ["7days", "7 days"]].map(([key, label]) => + el("button", { class: taskDueFilter === key ? "active" : "", text: label, onclick: () => { taskDueFilter = key; localStorage.setItem("cp-task-filter", key); go("tasks"); } }))); + + const toolbar = el("div", { class: "toolbar" }, + el("button", { class: "btn btn-primary", html: "οΌ‹ Add task", onclick: () => taskModal() }), + el("input", { class: "search", placeholder: "Search tasks…", oninput: (e) => filterTasks(e.target.value) }), + filterSeg, + el("span", { class: "spacer" }), + el("span", { class: "muted small", text: `${tasks.filter((t) => t.status.toLowerCase() === "open").length} open Β· ${tasks.filter((t) => t.status.toLowerCase() === "blocked").length} blocked Β· ${tasks.filter((t) => t.status.toLowerCase() === "done").length} done` })); + root.append(toolbar); + + const holder = el("div", { id: "task-holder" }); + root.append(holder); + if (!allTasks.length) { holder.append(emptyState("πŸ—’οΈ", "No tasks yet", "Capture your first task and it will sync straight to CatPilot.", el("button", { class: "btn btn-primary", html: "οΌ‹ Add task", onclick: () => taskModal() }))); return; } + if (!tasks.length) { holder.append(emptyState("πŸ”", "Nothing in this window", "No tasks match the current filter. Try β€œAll”.")); return; } + if (taskViewMode === "list") renderTaskList(holder, tasks); + else renderTaskBoard(holder, tasks); + root._tasks = tasks; + }; + + function filterTasks(q) { + q = q.toLowerCase().trim(); + document.querySelectorAll("[data-task-title]").forEach((row) => { + const hit = !q || row.dataset.taskTitle.toLowerCase().includes(q); + row.style.display = hit ? "" : "none"; + }); + } + + function renderTaskList(holder, tasks) { + const open = tasks.filter((t) => t.status.toLowerCase() === "open"); + const blocked = tasks.filter((t) => t.status.toLowerCase() === "blocked"); + const done = tasks.filter((t) => t.status.toLowerCase() === "done"); + const ordered = [...open, ...blocked, ...done]; + const tbody = el("tbody"); + ordered.forEach((t) => tbody.append(taskRow(t))); + holder.append(el("div", { class: "table-wrap" }, + el("table", { class: "tbl" }, + el("thead", {}, el("tr", {}, + el("th", { text: "" }), + el("th", { text: "Task" }), + el("th", { text: "Status" }), + el("th", { text: "Due" }), + el("th", { text: "Priority" }), + el("th", { text: "Tags" }), + el("th", { text: "" }))), + tbody))); + } + + function taskRow(t) { + const done = t.status.toLowerCase() === "done"; + const tr = el("tr", { class: done ? "done-row" : "", dataset: { taskTitle: t.title } }); + const check = el("input", { type: "checkbox", style: "width:auto", "aria-label": done ? `Reopen ${t.title}` : `Complete ${t.title}`, ...(done ? { checked: "checked" } : {}) }); + check.addEventListener("change", async () => { + try { + if (check.checked) { await api(`/api/tasks/${t.id}/complete`, { method: "POST" }); toast("Task completed", t.title, "ok"); } + else { await api(`/api/tasks/${t.id}`, { method: "PUT", body: { status: "Open" } }); toast("Reopened", t.title); } + await refreshSummary(); go("tasks"); + } catch (e) { toast("Error", e.message, "err"); } + }); + tr.append( + el("td", { style: "width:34px" }, check), + el("td", {}, el("div", { class: "cell-title", text: t.title, onclick: () => taskDetail(t) }), + t.context ? el("div", { class: "muted small", text: t.context }) : null), + el("td", {}, statusBadge(t.status)), + el("td", {}, t.dueDate ? el("span", { class: dueClass(t.dueDate, done), text: t.dueDate }) : el("span", { class: "muted small", text: "β€”" })), + el("td", {}, priorityBadge(t.priority)), + el("td", {}, tagChips(t.tags)), + el("td", {}, el("div", { class: "row-actions" }, + el("button", { class: "btn btn-sm btn-ghost", text: "Edit", onclick: () => taskModal(t) }), + el("button", { class: "btn btn-sm btn-ghost btn-danger", text: "Delete", onclick: () => removeTask(t) })))); + return tr; + } + + function dueClass(due, done) { + if (done) return "muted small"; + if (due < todayISO()) return "badge p0"; + if (due === todayISO()) return "badge p1"; + return "small"; + } + + // Priority accent colours (index == priorityRank: P0..P3, then "none"). + const PR_COLORS = ["#ff6b7d", "#ffb020", "#7c5cff", "#35c88f", "var(--border)"]; + // Board columns are declared here so adding/re-ordering a column is a one-liner. + // `canAdd` gates the quick-add "οΌ‹" + footer for real statuses; Overdue is a + // derived urgency bucket (open tasks past due) so it has no quick-add. + const BOARD_COLS = [ + { key: "backlog", title: "Backlog", icon: "πŸ“₯", accent: "#8b93a7", status: "Open", canAdd: true, emptyIcon: "πŸ“­", emptyHint: "No unscheduled tasks", filter: (t) => t.status.toLowerCase() === "open" && !t.dueDate }, + { key: "overdue", title: "Overdue", icon: "⏰", accent: "#ff6b7d", status: "Open", addDue: true, canAdd: false, emptyIcon: "πŸŽ‰", emptyHint: "Nothing overdue", filter: (t) => t.status.toLowerCase() === "open" && t.dueDate && t.dueDate < todayISO() }, + { key: "open", title: "To do", icon: "πŸ—’οΈ", accent: "#7c5cff", status: "Open", addDue: true, canAdd: true, emptyIcon: "πŸ—“οΈ", emptyHint: "No scheduled tasks", filter: (t) => t.status.toLowerCase() === "open" && t.dueDate && t.dueDate >= todayISO() }, + { key: "blocked", title: "Blocked", icon: "β›”", accent: "#ff8c32", status: "Blocked", canAdd: true, emptyIcon: "🧯", emptyHint: "No blockers right now", filter: (t) => t.status.toLowerCase() === "blocked" }, + { key: "done", title: "Done", icon: "βœ…", accent: "#35c88f", status: "Done", canAdd: true, emptyIcon: "🌱", emptyHint: "Nothing done yet", filter: (t) => t.status.toLowerCase() === "done" }, + ]; + + function renderTaskBoard(holder, tasks) { + const board = el("div", { class: "board" }); + for (const col of BOARD_COLS) { + const items = tasks.filter(col.filter).sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority)); + const colEl = el("div", { class: "board-col", dataset: { col: col.key }, style: `--col-accent:${col.accent}` }); + + const addPrefill = () => taskModal(null, { status: col.status, due: col.addDue ? todayISO() : undefined }); + const head = el("div", { class: "board-col-head" }, + el("span", { class: "bch-dot" }), + el("span", { class: "bch-icon", text: col.icon }), + el("span", { class: "bch-title", text: col.title }), + el("span", { class: "bch-count", text: String(items.length) }), + col.canAdd ? el("button", { class: "bch-add", title: `Add to ${col.title}`, "aria-label": `Add task to ${col.title}`, html: "οΌ‹", onclick: addPrefill }) : null); + + const bodyEl = el("div", { class: "board-col-body" }); + if (items.length) items.forEach((t) => bodyEl.append(boardCard(t))); + else bodyEl.append(el("div", { class: "board-col-empty" }, + el("div", { class: "bce-emoji", text: col.emptyIcon || "πŸ—’οΈ" }), + el("div", { class: "bce-text", text: col.emptyHint }))); + + colEl.append(head, bodyEl); + if (col.canAdd) colEl.append(el("div", { class: "board-col-foot" }, + el("button", { class: "board-col-add", html: "οΌ‹ New", onclick: addPrefill }))); + + // Drag & drop -> change status. Highlight only the drop target. + colEl.addEventListener("dragover", (e) => { e.preventDefault(); colEl.classList.add("drop"); }); + colEl.addEventListener("dragleave", (e) => { if (!colEl.contains(e.relatedTarget)) colEl.classList.remove("drop"); }); + colEl.addEventListener("drop", async (e) => { + e.preventDefault(); colEl.classList.remove("drop"); + const id = e.dataTransfer.getData("text/plain"); + const task = tasks.find((x) => String(x.id) === String(id)); + try { + if (col.key === "done") { await api(`/api/tasks/${id}/complete`, { method: "POST" }); } + else { + // Backlog / To do share the stored "Open" status; the due date is what + // routes a card between them, so adjust it on drop. + const body = { status: col.status }; + if (col.key === "backlog") body.dueDate = ""; + else if (col.key === "open" && task && !task.dueDate) body.dueDate = todayISO(); + await api(`/api/tasks/${id}`, { method: "PUT", body }); + } + await refreshSummary(); go("tasks"); + } catch (err) { toast("Error", err.message, "err"); } + }); + board.append(colEl); + } + holder.append(board); + } + + function boardCard(t) { + const rank = priorityRank(t.priority); + const done = t.status.toLowerCase() === "done"; + const blocked = t.status.toLowerCase() === "blocked"; + const overdue = !done && t.dueDate && t.dueDate < todayISO(); + const card = el("div", { class: `board-card pr-${rank}${done ? " is-done" : ""}`, draggable: "true", dataset: { taskTitle: t.title }, style: `--pr-accent:${PR_COLORS[rank]}` }); + card.addEventListener("dragstart", (e) => { e.dataTransfer.setData("text/plain", String(t.id)); e.dataTransfer.effectAllowed = "move"; card.classList.add("dragging"); }); + card.addEventListener("dragend", () => card.classList.remove("dragging")); + + // Header row: priority + status flag, id pushed right β€” visually separate + // from the title so the card reads cleanly. + const head = el("div", { class: "bc-head" }); + if (t.priority) head.append(priorityBadge(t.priority)); + if (blocked) head.append(statusBadge("Blocked")); + if (overdue) head.append(el("span", { class: "badge st-overdue", html: "⏰ Overdue" })); + head.append(el("span", { class: "bc-id", text: `#${t.id}` })); + + card.append(head, el("div", { class: "bc-title", text: t.title, title: t.title, onclick: () => taskDetail(t) })); + + const foot = el("div", { class: "bc-foot" }); + if (t.dueDate) { + const state = done ? "" : overdue ? " overdue" : (t.dueDate === todayISO() ? " today" : ""); + const rel = dueLabel(t.dueDate, done); + foot.append(el("span", { class: `bc-due${state}`, title: `Due ${t.dueDate}` }, + el("span", { class: "bc-due-ico", text: "πŸ“…" }), + el("span", { class: "bc-due-date", text: t.dueDate }), + rel ? el("span", { class: "bc-due-rel", text: rel }) : null)); + } + if (String(t.tags || "").trim()) foot.append(tagChips(t.tags)); + if (foot.childNodes.length) card.append(foot); + return card; + } + + // Human-friendly relative due label, e.g. "Today", "Tomorrow", "in 3d", "2d overdue". + function dueLabel(iso, done) { + if (!iso || done) return ""; + const t0 = new Date(todayISO() + "T00:00:00"); + const d = new Date(iso + "T00:00:00"); + if (isNaN(d)) return ""; + const diff = Math.round((d - t0) / 86400000); + if (diff < 0) return `${Math.abs(diff)}d overdue`; + if (diff === 0) return "Today"; + if (diff === 1) return "Tomorrow"; + if (diff <= 14) return `in ${diff}d`; + return ""; + } + + // ---------- Tasks Β· Calendar view ---------- + const CAL_FIELD_OPTS = [["priority", "Priority"], ["status", "Status"], ["tags", "Tags"]]; + const pad2 = (n) => String(n).padStart(2, "0"); + function fmtLocalISO(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } + function parseISO(s) { const [y, m, d] = String(s).split("-").map(Number); return new Date(y, (m || 1) - 1, d || 1); } + function addDays(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; } + function startOfWeekMon(d) { const x = new Date(d); x.setHours(0, 0, 0, 0); x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); return x; } + + function buildCalendarToolbar(tasks) { + const anchor = parseISO(taskCalAnchorISO); + // Human title for the current window. + let title; + if (taskCalMode === "week") { + const ws = startOfWeekMon(anchor), we = addDays(ws, 6); + const opt = { month: "short", day: "numeric" }; + title = `${ws.toLocaleDateString(undefined, opt)} – ${we.toLocaleDateString(undefined, opt)}, ${we.getFullYear()}`; + } else { + title = anchor.toLocaleDateString(undefined, { month: "long", year: "numeric" }); + } + const step = (dir) => { + const a = parseISO(taskCalAnchorISO); + if (taskCalMode === "week") { taskCalAnchorISO = fmtLocalISO(addDays(a, dir * 7)); } + else { taskCalAnchorISO = fmtLocalISO(new Date(a.getFullYear(), a.getMonth() + dir, 1)); } + go("tasks"); + }; + const modeSeg = el("div", { class: "segmented" }, + ...[["month", "Month"], ["week", "Week"]].map(([k, l]) => + el("button", { class: taskCalMode === k ? "active" : "", text: l, + onclick: () => { taskCalMode = k; localStorage.setItem("cp-task-cal-mode", k); go("tasks"); } }))); + + // "Show fields" dropdown β€” pick which task info appears on each day chip. + const menu = el("div", { class: "cal-fields-menu" }, + CAL_FIELD_OPTS.map(([key, label]) => { + const cb = el("input", { type: "checkbox", ...(taskCalFields.includes(key) ? { checked: "checked" } : {}) }); + cb.addEventListener("change", () => { + const set = new Set(taskCalFields); + if (cb.checked) set.add(key); else set.delete(key); + taskCalFields = [...set]; + localStorage.setItem("cp-task-cal-fields", taskCalFields.join(",")); + go("tasks"); + }); + return el("label", { class: "cal-fields-opt" }, cb, el("span", { text: label })); + })); + const fields = el("details", { class: "cal-fields" }, + el("summary", { class: "btn", html: "β–Ύ Fields" }), menu); + + const nav = el("div", { class: "cal-nav" }, + el("button", { class: "btn btn-ghost", html: "β€Ή", title: "Previous", onclick: () => step(-1) }), + el("span", { class: "cal-title", text: title }), + el("button", { class: "btn btn-ghost", html: "β€Ί", title: "Next", onclick: () => step(1) }), + el("button", { class: "btn", text: "Today", onclick: () => { taskCalAnchorISO = todayISO(); go("tasks"); } })); + + return el("div", { class: "toolbar cal-toolbar" }, + el("button", { class: "btn btn-primary", html: "οΌ‹ Add task", onclick: () => taskModal() }), + nav, + el("span", { class: "spacer" }), + fields, + modeSeg, + el("input", { class: "search", placeholder: "Search tasks…", oninput: (e) => filterTasks(e.target.value) })); + } + + function renderTaskCalendar(holder, tasks) { + const anchor = parseISO(taskCalAnchorISO); + const todayIso = todayISO(); + // Bucket dated tasks by day; count undated for a footnote. + const byDay = new Map(); + let undated = 0; + for (const t of tasks) { + if (!t.dueDate) { undated++; continue; } + (byDay.get(t.dueDate) || byDay.set(t.dueDate, []).get(t.dueDate)).push(t); + } + + const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + let start, count; + if (taskCalMode === "week") { start = startOfWeekMon(anchor); count = 7; } + else { start = startOfWeekMon(new Date(anchor.getFullYear(), anchor.getMonth(), 1)); count = 42; } + + const grid = el("div", { class: `cal-grid ${taskCalMode === "week" ? "cal-week" : "cal-month"}` }); + weekdays.forEach((w) => grid.append(el("div", { class: "cal-wd", text: w }))); + + for (let i = 0; i < count; i++) { + const day = addDays(start, i); + const iso = fmtLocalISO(day); + const inMonth = taskCalMode === "week" || day.getMonth() === anchor.getMonth(); + const isToday = iso === todayIso; + const cell = el("div", { class: `cal-day${inMonth ? "" : " other"}${isToday ? " today" : ""}` }); + cell.append(el("div", { class: "cal-daynum" }, + el("span", { class: "cal-dow-sm", text: taskCalMode === "week" ? weekdays[i] + " " : "" }), + el("span", { text: String(day.getDate()) }))); + const list = el("div", { class: "cal-day-list" }); + const items = (byDay.get(iso) || []).slice().sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority)); + items.forEach((t) => list.append(calChip(t))); + cell.append(list); + grid.append(cell); + } + holder.append(grid); + if (undated) holder.append(el("p", { class: "muted small", style: "margin-top:12px", + text: `${undated} task${undated > 1 ? "s" : ""} without a due date ${undated > 1 ? "are" : "is"} not shown on the calendar.` })); + } + + function priorityRank(p) { + const s = String(p || "").toLowerCase(); + if (s === "p0" || s === "high") return 0; + if (s === "p1") return 1; + if (s === "p2" || s === "med") return 2; + if (s === "p3" || s === "low") return 3; + return 4; + } + + function calChip(t) { + const done = t.status.toLowerCase() === "done"; + const chip = el("div", { class: `cal-chip pr-${priorityRank(t.priority)}${done ? " done" : ""}`, + dataset: { taskTitle: t.title }, title: t.title, onclick: () => taskDetail(t) }); + chip.append(el("div", { class: "cal-chip-title", text: t.title })); + const meta = el("div", { class: "cal-chip-meta" }); + if (taskCalFields.includes("priority") && t.priority) meta.append(priorityBadge(t.priority)); + if (taskCalFields.includes("status")) meta.append(statusBadge(t.status)); + if (taskCalFields.includes("tags") && t.tags) meta.append(tagChips(t.tags)); + if (meta.childNodes.length) chip.append(meta); + return chip; + } + + async function removeTask(t) { + if (!confirm(`Delete task "${t.title}"?`)) return; + try { await api(`/api/tasks/${t.id}`, { method: "DELETE" }); toast("Deleted", t.title, "ok"); await refreshSummary(); go("tasks"); } + catch (e) { toast("Error", e.message, "err"); } + } + + function taskModal(t, prefill = {}) { + const editing = !!t; + const f = {}; + const body = el("div", { class: "form" }, + field("Title", f, "title", { value: t?.title, placeholder: "What needs doing?", required: true }), + el("div", { class: "form-row" }, + field("Due date", f, "due", { value: t?.dueDate || prefill.due, type: "date" }), + selectField("Priority", f, "priority", ["", "P0", "P1", "P2", "P3", "High", "Med", "Low"], t?.priority || prefill.priority)), + selectField("Status", f, "status", ["Open", "Blocked", "Done"], t?.status || prefill.status || "Open", { Open: "To do", Blocked: "Blocked", Done: "Done" }), + field("Tags", f, "tags", { value: t?.tags, placeholder: "comma,separated" }), + mdField("Context", f, "context", { value: t?.context, placeholder: "One-line context (markdown supported)" })); + const save = el("button", { class: "btn btn-primary", text: editing ? "Save changes" : "Add task", onclick: async () => { + const payload = { title: f.title.value.trim(), due: f.due.value, priority: f.priority.value, tags: f.tags.value.trim(), context: f.context.value.trim(), status: f.status.value }; + if (!payload.title) { toast("Title required", "", "err"); return; } + try { + if (editing) { await api(`/api/tasks/${t.id}`, { method: "PUT", body: { title: payload.title, dueDate: payload.due, priority: payload.priority, tags: payload.tags, context: payload.context, status: payload.status } }); toast("Task updated", payload.title, "ok"); } + else { await api("/api/tasks", { method: "POST", body: payload }); toast("Task added", payload.title, "ok"); } + closeModal(); await refreshSummary(); go("tasks"); + } catch (e) { toast("Error", e.message, "err"); } + } }); + openModal({ title: editing ? "Edit task" : "New task", body, foot: [el("button", { class: "btn", text: "Cancel", onclick: closeModal }), save] }); + setTimeout(() => f.title.focus(), 50); + } + + function taskDetail(t) { + const done = t.status.toLowerCase() === "done"; + const body = el("div", {}, + detailRow("Status", statusBadge(t.status)), + detailRow("Title", el("span", { text: t.title })), + detailRow("Due date", el("span", { text: t.dueDate || "β€”" })), + detailRow("Priority", priorityBadge(t.priority)), + detailRow("Tags", tagChips(t.tags)), + detailRow("Context", t.context ? mdRender(t.context) : el("span", { class: "muted small", text: "β€”" }))); + const foot = [ + el("button", { class: "btn btn-danger", text: "Delete", onclick: () => { closeModal(); removeTask(t); } }), + el("span", { class: "spacer", style: "flex:1" }), + el("button", { class: "btn", text: "Edit", onclick: () => taskModal(t) }), + !done ? el("button", { class: "btn btn-primary", text: "Complete", onclick: async () => { try { await api(`/api/tasks/${t.id}/complete`, { method: "POST" }); toast("Completed", t.title, "ok"); closeModal(); await refreshSummary(); go("tasks"); } catch (e) { toast("Error", e.message, "err"); } } }) : null, + ]; + openModal({ title: `Task #${t.id}`, body, foot }); + } + + // ---------- Journal ---------- + VIEWS.journal = async (root) => { + const { entries } = await api("/api/journal"); + root.innerHTML = ""; + root.append(el("div", { class: "toolbar" }, + el("button", { class: "btn btn-primary", html: "οΌ‹ Add entry", onclick: () => journalModal() }), + el("span", { class: "spacer" }), + el("span", { class: "muted small", text: `${entries.length} entr${entries.length === 1 ? "y" : "ies"}` }))); + if (!entries.length) { root.append(emptyState("✎", "No journal entries", "Capture decisions and notes as they happen.", el("button", { class: "btn btn-primary", html: "οΌ‹ Add entry", onclick: () => journalModal() }))); return; } + const list = el("div", { class: "journal-list" }); + entries.forEach((e) => list.append(el("div", { class: "journal-entry" }, + el("div", { class: "date" }, "πŸ“… ", e.date), + el("div", { class: "body", text: e.text })))); + root.append(list); + }; + + function journalModal() { + const f = {}; + const body = el("div", { class: "form" }, mdField("Entry", f, "text", { placeholder: "What happened today?", required: true })); + const save = el("button", { class: "btn btn-primary", text: "Add entry", onclick: async () => { + const text = f.text.value.trim(); + if (!text) { toast("Entry required", "", "err"); return; } + try { await api("/api/journal", { method: "POST", body: { text } }); toast("Journal saved", todayISO(), "ok"); closeModal(); await refreshSummary(); if (state.view === "journal") go("journal"); } + catch (e) { toast("Error", e.message, "err"); } + } }); + openModal({ title: "New journal entry", body, foot: [el("button", { class: "btn", text: "Cancel", onclick: closeModal }), save] }); + setTimeout(() => f.text.focus(), 50); + } + + // ---------- Milestones ---------- + VIEWS.milestones = async (root) => { + const { milestones } = await api("/api/milestones"); + root.innerHTML = ""; + root.append(el("div", { class: "toolbar" }, + el("button", { class: "btn btn-primary", html: "οΌ‹ Add milestone", onclick: () => milestoneModal() }), + el("span", { class: "spacer" }), + el("span", { class: "muted small", text: `${milestones.length} milestone${milestones.length === 1 ? "" : "s"}` }))); + if (!milestones.length) { root.append(emptyState("βš‘", "No milestones", "Set goals with target dates and track them to done.", el("button", { class: "btn btn-primary", html: "οΌ‹ Add milestone", onclick: () => milestoneModal() }))); return; } + const tbody = el("tbody"); + milestones.forEach((m) => { + const sel = el("select", { class: "inline-edit", style: "width:auto", onchange: async (e) => { try { await api(`/api/milestones/${m.id}`, { method: "PUT", body: { status: e.target.value } }); toast("Status updated", m.name, "ok"); await refreshSummary(); } catch (err) { toast("Error", err.message, "err"); } } }); + ["Planned", "In Progress", "Done"].forEach((o) => { const opt = el("option", { value: o, text: o }); if ((m.status || "Planned") === o) opt.selected = true; sel.append(opt); }); + tbody.append(el("tr", { dataset: { taskTitle: m.name } }, + el("td", {}, el("span", { class: "cell-title", text: m.name, onclick: () => noteLikeDetail(`Milestone #${m.id}`, [["Name", m.name], ["Target date", m.targetDate || "β€”"], ["Status", m.status || "Planned"], ["Notes", m.notes || "β€”"]]) })), + el("td", {}, m.targetDate ? el("span", { class: dueClass(m.targetDate, (m.status || "").toLowerCase() === "done"), text: m.targetDate }) : el("span", { class: "muted small", text: "β€”" })), + el("td", {}, sel), + el("td", {}, el("span", { class: "muted small", text: m.notes || "β€”" })))); + }); + root.append(el("div", { class: "table-wrap" }, el("table", { class: "tbl" }, + el("thead", {}, el("tr", {}, el("th", { text: "Milestone" }), el("th", { text: "Target" }), el("th", { text: "Status" }), el("th", { text: "Notes" }))), + tbody))); + }; + + function milestoneModal() { + const f = {}; + const body = el("div", { class: "form" }, + field("Name", f, "name", { placeholder: "Milestone name", required: true }), + el("div", { class: "form-row" }, + field("Target date", f, "targetDate", { type: "date" }), + selectField("Status", f, "status", ["Planned", "In Progress", "Done"], "Planned")), + mdField("Notes", f, "notes", { placeholder: "Optional notes (markdown supported)" })); + const save = el("button", { class: "btn btn-primary", text: "Add milestone", onclick: async () => { + const name = f.name.value.trim(); + if (!name) { toast("Name required", "", "err"); return; } + try { await api("/api/milestones", { method: "POST", body: { name, targetDate: f.targetDate.value, status: f.status.value, notes: f.notes.value.trim() } }); toast("Milestone added", name, "ok"); closeModal(); await refreshSummary(); if (state.view === "milestones") go("milestones"); } + catch (e) { toast("Error", e.message, "err"); } + } }); + openModal({ title: "New milestone", body, foot: [el("button", { class: "btn", text: "Cancel", onclick: closeModal }), save] }); + setTimeout(() => f.name.focus(), 50); + } + + // ---------- Memos ---------- + VIEWS.memos = async (root) => { + const { memos } = await api("/api/memos"); + root.innerHTML = ""; + root.append(el("div", { class: "toolbar" }, + el("button", { class: "btn btn-primary", html: "οΌ‹ New memo", onclick: () => memoModal() }), + el("span", { class: "spacer" }), + el("span", { class: "muted small", text: `${memos.length} memo${memos.length === 1 ? "" : "s"}` }))); + if (!memos.length) { root.append(emptyState("β–€", "No memos", "Create handoff notes, retros and summaries as markdown.", el("button", { class: "btn btn-primary", html: "οΌ‹ New memo", onclick: () => memoModal() }))); return; } + const grid = el("div", { class: "grid note-grid" }); + memos.forEach((m) => grid.append(el("div", { class: "card note-card hoverable", onclick: () => memoDetail(m.filename) }, + el("h4", { text: m.title }), + el("div", { class: "note-foot" }, el("span", { class: "tag", text: m.date || "" }), el("span", { class: "muted small", text: "πŸ“„ memo" }))))); + root.append(grid); + }; + + async function memoDetail(filename) { + try { + const { memo } = await api(`/api/memos/${encodeURIComponent(filename)}`); + openModal({ title: memo.title, width: 680, body: mdRender(memo.content || "(empty)"), + foot: [el("button", { class: "btn", text: "Close", onclick: closeModal })] }); + } catch (e) { toast("Error", e.message, "err"); } + } + + function memoModal() { + const f = {}; + const body = el("div", { class: "form" }, + field("Title", f, "title", { placeholder: "Memo title", required: true }), + mdField("Content", f, "content", { placeholder: "Markdown content…", minRows: 8 })); + const save = el("button", { class: "btn btn-primary", text: "Create memo", onclick: async () => { + const title = f.title.value.trim(); + if (!title) { toast("Title required", "", "err"); return; } + try { await api("/api/memos", { method: "POST", body: { title, content: f.content.value } }); toast("Memo created", title, "ok"); closeModal(); await refreshSummary(); if (state.view === "memos") go("memos"); } + catch (e) { toast("Error", e.message, "err"); } + } }); + openModal({ title: "New memo", body, foot: [el("button", { class: "btn", text: "Cancel", onclick: closeModal }), save] }); + setTimeout(() => f.title.focus(), 50); + } + + // ---------- Domain notes: learning / growth / projects ---------- + const DOMAIN_META = { + learning: { icon: "πŸŽ“", fields: [["goal", "Goal"], ["target_date", "Target date", "date"], ["next_review", "Next review", "date"], ["status", "Status"]] }, + growth: { icon: "β†—", fields: [["area", "Area"], ["impact", "Impact"]] }, + projects: { icon: "❏", fields: [["status", "Status"], ["owner", "Owner"], ["due", "Due", "date"]] }, + }; + for (const domain of ["learning", "growth", "projects"]) { + VIEWS[domain] = async (root) => { + const { notes } = await api(`/api/${domain}`); + root.innerHTML = ""; + root.append(el("div", { class: "toolbar" }, + el("button", { class: "btn btn-primary", html: `οΌ‹ New ${domain === "projects" ? "project" : domain === "growth" ? "entry" : "topic"}`, onclick: () => noteModal(domain) }), + el("span", { class: "spacer" }), + el("span", { class: "muted small", text: `${notes.length} item${notes.length === 1 ? "" : "s"}` }))); + if (!notes.length) { root.append(emptyState(DOMAIN_META[domain].icon, `No ${domain} notes`, "Add your first note β€” it's saved with frontmatter for Obsidian Dataview.", el("button", { class: "btn btn-primary", html: "οΌ‹ Add", onclick: () => noteModal(domain) }))); return; } + const grid = el("div", { class: "grid note-grid" }); + notes.forEach((n) => { + const fm = n.frontmatter || {}; + grid.append(el("div", { class: "card note-card hoverable", onclick: () => domainNoteDetail(domain, n.filename) }, + el("h4", { text: fm.title || n.filename }), + domainChips(domain, fm), + el("div", { class: "note-foot" }, el("span", { class: "tag", text: fm.date || "" })))); + }); + root.append(grid); + }; + } + + function domainChips(domain, fm) { + const chips = el("div", { class: "tags" }); + if (fm.status) chips.append(statusBadge(fm.status)); + if (fm.area) chips.append(el("span", { class: "tag", text: fm.area })); + if (fm.owner) chips.append(el("span", { class: "tag", text: "πŸ‘€ " + fm.owner })); + if (fm.goal) chips.append(el("span", { class: "muted small", text: fm.goal })); + if (fm.impact) chips.append(el("span", { class: "muted small", text: fm.impact })); + return chips; + } + + async function domainNoteDetail(domain, filename) { + try { + const { note } = await api(`/api/${domain}/${encodeURIComponent(filename)}`); + const fm = note.frontmatter || {}; + const rows = Object.entries(fm).filter(([k]) => k !== "catpilot").map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v]); + const body = el("div", {}, + ...rows.map(([k, v]) => detailRow(k, el("span", { text: String(v) }))), + note.body ? el("div", { style: "margin-top:14px" }, el("div", { class: "detail-row" }, el("span", { class: "k", text: "Notes" })), mdRender(note.body)) : null); + openModal({ title: fm.title || filename, width: 640, body, foot: [el("button", { class: "btn", text: "Close", onclick: closeModal })] }); + } catch (e) { toast("Error", e.message, "err"); } + } + + function noteModal(domain) { + const meta = DOMAIN_META[domain]; + const f = {}; + const rows = meta.fields.map(([key, label, type]) => field(label, f, key, { type: type || "text" })); + const body = el("div", { class: "form" }, + field("Title", f, "title", { required: true, placeholder: "Title" }), + el("div", { class: "form-row" }, rows), + mdField("Body", f, "body", { placeholder: "Markdown notes…" })); + const save = el("button", { class: "btn btn-primary", text: "Save", onclick: async () => { + const title = f.title.value.trim(); + if (!title) { toast("Title required", "", "err"); return; } + const payload = { title, body: f.body.value }; + meta.fields.forEach(([key]) => { if (f[key].value.trim()) payload[key] = f[key].value.trim(); }); + try { await api(`/api/${domain}`, { method: "POST", body: payload }); toast("Saved", title, "ok"); closeModal(); await refreshSummary(); if (state.view === domain) go(domain); } + catch (e) { toast("Error", e.message, "err"); } + } }); + openModal({ title: `New ${domain} note`, body, foot: [el("button", { class: "btn", text: "Cancel", onclick: closeModal }), save] }); + setTimeout(() => f.title.focus(), 50); + } + + // ---------------------------------------------------------------- fields + function field(label, store, key, opts = {}) { + const input = opts.area + ? el("textarea", { placeholder: opts.placeholder || "" }) + : el("input", { type: opts.type || "text", placeholder: opts.placeholder || "" }); + if (opts.value) input.value = opts.value; + store[key] = input; + return el("label", {}, el("span", {}, label, opts.required ? el("em", { text: " *" }) : null), input); + } + function selectField(label, store, key, options, value, labels) { + const sel = el("select", {}); + options.forEach((o) => { const opt = el("option", { value: o, text: (labels && labels[o]) || o || "β€”" }); if (o === value) opt.selected = true; sel.append(opt); }); + store[key] = sel; + return el("label", {}, el("span", { text: label }), sel); + } + function detailRow(k, valueNode) { + return el("div", { class: "detail-row" }, el("span", { class: "k", text: k }), el("div", {}, valueNode)); + } + function noteLikeDetail(title, rows) { + openModal({ title, body: el("div", {}, rows.map(([k, v]) => detailRow(k, el("span", { text: String(v) })))), foot: [el("button", { class: "btn", text: "Close", onclick: closeModal })] }); + } + + // ---------------------------------------------------------------- markdown + function mdInline(s) { + return s + .replace(/`([^`]+)`/g, "<code>$1</code>") + .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") + .replace(/(^|[^*])\*([^*\s][^*]*?)\*/g, "$1<em>$2</em>") + .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>'); + } + function mdTable(rows) { + const cells = (r) => r.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|").map((c) => c.trim()); + let body = rows.slice(1); + if (body[0] && /^[\s:|-]+$/.test(body[0])) body = body.slice(1); + let h = '<table class="md-table"><thead><tr>' + cells(rows[0]).map((c) => `<th>${mdInline(esc(c))}</th>`).join("") + "</tr></thead><tbody>"; + for (const r of body) h += "<tr>" + cells(r).map((c) => `<td>${mdInline(esc(c))}</td>`).join("") + "</tr>"; + return h + "</tbody></table>"; + } + function mdToHtml(md) { + const lines = String(md || "").replace(/\r\n/g, "\n").split("\n"); + let html = "", inCode = false, codeBuf = [], listType = null, listBuf = [], tableBuf = []; + const flushList = () => { if (listType) { html += `<${listType}>` + listBuf.map((li) => `<li>${mdInline(esc(li))}</li>`).join("") + `</${listType}>`; listType = null; listBuf = []; } }; + const flushTable = () => { if (tableBuf.length) { html += mdTable(tableBuf); tableBuf = []; } }; + const flush = () => { flushList(); flushTable(); }; + for (const raw of lines) { + if (/^```/.test(raw)) { if (inCode) { html += `<pre><code>${esc(codeBuf.join("\n"))}</code></pre>`; codeBuf = []; inCode = false; } else { flush(); inCode = true; } continue; } + if (inCode) { codeBuf.push(raw); continue; } + const line = raw.replace(/\s+$/, ""); + if (!line.trim()) { flush(); continue; } + if (/^\s*\|.*\|\s*$/.test(line)) { flushList(); tableBuf.push(line); continue; } + flushTable(); + let m; + if ((m = line.match(/^(#{1,6})\s+(.*)$/))) { flushList(); const lv = m[1].length; html += `<h${lv}>${mdInline(esc(m[2]))}</h${lv}>`; continue; } + if (/^\s*[-*]\s+\[[ xX]\]\s+/.test(line)) { if (listType !== "ul") { flushList(); listType = "ul"; } const done = /\[[xX]\]/.test(line); listBuf.push((done ? "β˜‘ " : "☐ ") + line.replace(/^\s*[-*]\s+\[[ xX]\]\s+/, "")); continue; } + if (/^\s*[-*]\s+/.test(line)) { if (listType !== "ul") { flushList(); listType = "ul"; } listBuf.push(line.replace(/^\s*[-*]\s+/, "")); continue; } + if (/^\s*\d+\.\s+/.test(line)) { if (listType !== "ol") { flushList(); listType = "ol"; } listBuf.push(line.replace(/^\s*\d+\.\s+/, "")); continue; } + if (/^>\s?/.test(line)) { flushList(); html += `<blockquote>${mdInline(esc(line.replace(/^>\s?/, "")))}</blockquote>`; continue; } + if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) { flushList(); html += "<hr/>"; continue; } + flushList(); html += `<p>${mdInline(esc(line))}</p>`; + } + flush(); if (inCode && codeBuf.length) html += `<pre><code>${esc(codeBuf.join("\n"))}</code></pre>`; + return html; + } + function mdRender(md) { const d = el("div", { class: "md" }); d.innerHTML = mdToHtml(md); return d; } + + // ---------------------------------------------------------------- markdown editor + function mdField(label, store, key, opts = {}) { + const ed = mdEditor({ value: opts.value || "", placeholder: opts.placeholder || "", minRows: opts.minRows || 6 }); + store[key] = ed.textarea; + return el("label", { class: "md-label" }, el("span", {}, label, opts.required ? el("em", { text: " *" }) : null), ed.node); + } + function mdEditor({ value = "", placeholder = "", minRows = 6 } = {}) { + const ta = el("textarea", { class: "md-input", placeholder, rows: minRows }); + ta.value = value; + const preview = el("div", { class: "md md-preview hidden" }); + let previewing = false; + const pvBtn = el("button", { type: "button", class: "md-tool md-toggle", text: "πŸ‘ Preview" }); + const renderPreview = () => { preview.innerHTML = ta.value.trim() ? mdToHtml(ta.value) : '<p class="muted">Nothing to preview yet.</p>'; }; + const setPreview = (on) => { previewing = on; if (on) renderPreview(); preview.classList.toggle("hidden", !on); ta.classList.toggle("hidden", on); pvBtn.textContent = on ? "✎ Write" : "πŸ‘ Preview"; }; + pvBtn.addEventListener("click", () => setPreview(!previewing)); + function surround(before, after = before, ph = "text") { const s = ta.selectionStart, e = ta.selectionEnd, v = ta.value, sel = v.slice(s, e) || ph; ta.value = v.slice(0, s) + before + sel + after + v.slice(e); ta.focus(); ta.selectionStart = s + before.length; ta.selectionEnd = s + before.length + sel.length; } + function linePrefix(pfx) { const s = ta.selectionStart, v = ta.value, ls = v.lastIndexOf("\n", s - 1) + 1; ta.value = v.slice(0, ls) + pfx + v.slice(ls); ta.focus(); ta.selectionStart = ta.selectionEnd = s + pfx.length; } + const tools = [ + { ic: "B", cls: "b", t: "Bold", fn: () => surround("**") }, + { ic: "I", cls: "i", t: "Italic", fn: () => surround("*") }, + { ic: "H", t: "Heading", fn: () => linePrefix("## ") }, + { ic: "”", t: "Quote", fn: () => linePrefix("> ") }, + { ic: "β€’", t: "Bullet list", fn: () => linePrefix("- ") }, + { ic: "β˜‘", t: "Checkbox", fn: () => linePrefix("- [ ] ") }, + { ic: "</>", t: "Code", fn: () => surround("`", "`", "code") }, + { ic: "πŸ”—", t: "Link", fn: () => surround("[", "](https://)", "label") }, + ]; + const bar = el("div", { class: "md-toolbar" }); + tools.forEach((t) => bar.append(el("button", { type: "button", class: "md-tool" + (t.cls ? " tt-" + t.cls : ""), title: t.t, text: t.ic, onclick: () => { if (previewing) setPreview(false); t.fn(); } }))); + bar.append(el("span", { class: "spacer", style: "flex:1" })); + const genBtn = el("button", { type: "button", class: "md-tool md-gen-btn", title: "Draft with Copilot", html: "✨ Copilot" }); + bar.append(genBtn, pvBtn); + const genInput = el("input", { class: "md-gen-input", placeholder: "Describe what to write β€” Copilot drafts it…" }); + const genRow = el("div", { class: "md-gen hidden" }); + async function doGen() { + const instr = genInput.value.trim(); + if (!instr) { toast("Describe what to generate", "", "err"); return; } + const existing = ta.value.trim(); + genBtn.disabled = true; genBtn.innerHTML = "⏳ Drafting…"; + const prompt = `You are drafting content inside the CatPilot canvas markdown editor. Write: ${instr}. Respond with ONLY the markdown to insert β€” no preamble, no surrounding code fences, no explanation.` + (existing ? `\n\nBuild on this existing draft:\n"""\n${existing}\n"""` : ""); + try { + const r = await api("/api/agent/generate", { method: "POST", body: { prompt, timeout: 120000 } }); + if (r && r.content) { ta.value = existing ? existing + "\n\n" + r.content.trim() : r.content.trim(); toast("Copilot drafted content", "", "ok"); if (previewing) renderPreview(); genRow.classList.add("hidden"); genInput.value = ""; } + else toast("No content returned", (r && r.error) || "The agent may be busy", "err"); + } catch (e) { toast("Generate failed", e.message, "err"); } + finally { genBtn.disabled = false; genBtn.innerHTML = "✨ Copilot"; } + } + genRow.append(genInput, + el("button", { type: "button", class: "btn btn-sm btn-primary", text: "Generate", onclick: doGen }), + el("button", { type: "button", class: "btn btn-sm", text: "Cancel", onclick: () => genRow.classList.add("hidden") })); + genBtn.addEventListener("click", () => { genRow.classList.toggle("hidden"); if (!genRow.classList.contains("hidden")) genInput.focus(); }); + genInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); doGen(); } }); + const node = el("div", { class: "md-editor" }, bar, genRow, el("div", { class: "md-surface" }, ta, preview)); + return { node, textarea: ta, getValue: () => ta.value, setValue: (v) => { ta.value = v; } }; + } + + // ---------------------------------------------------------------- agent + function askAgent(prompt, okMsg) { + return api("/api/agent", { method: "POST", body: { prompt } }) + .then((r) => { if (r && r.ok) toast(okMsg || "Sent to Copilot", "Check the chat panel", "ok"); else toast("No active session", (r && r.error) || "", "err"); }) + .catch((e) => toast("Error", e.message, "err")); + } + function agentBar(items) { + const bar = el("div", { class: "agent-bar" }); + bar.append(el("span", { class: "agent-bar-label", html: "✨ Copilot actions" })); + items.forEach((it) => bar.append(el("button", { class: "chip agent-chip", title: it.prompt, onclick: () => askAgent(it.prompt, it.ok) }, (it.icon ? it.icon + " " : "") + it.label))); + return bar; + } + function agentModal() { + const chips = [ + { label: "Summarize my week", prompt: "Summarize what I've worked on in CatPilot over the past week using my tasks, journal and milestones." }, + { label: "Plan my day", prompt: "Look at my CatPilot open and overdue tasks and propose a prioritized plan for today." }, + { label: "Generate weekly report", prompt: "Generate a CatPilot executive report for this week using the report-generator skill and save it with the save_report canvas action." }, + { label: "Triage overdue tasks", prompt: "Review my overdue CatPilot tasks and suggest reschedules or which to drop." }, + { label: "Draft a standup", prompt: "Write a concise standup update from my recent CatPilot activity." }, + ]; + const ta = el("textarea", { class: "md-input", rows: 4, placeholder: "Ask Copilot to do something with your CatPilot data…" }); + const chipRow = el("div", { class: "chip-row" }, chips.map((c) => el("button", { class: "chip", onclick: () => { ta.value = c.prompt; ta.focus(); } }, c.label))); + const send = el("button", { class: "btn btn-primary", text: "Send to Copilot", onclick: async () => { const p = ta.value.trim(); if (!p) { toast("Type a request", "", "err"); return; } await askAgent(p); closeModal(); } }); + openModal({ title: "✨ Ask Copilot", width: 560, body: el("div", { class: "form" }, el("p", { class: "muted small", text: "Sends a message to the Copilot agent in the chat. It reads and updates the same CatPilot data shown here." }), chipRow, ta), foot: [el("button", { class: "btn", text: "Cancel", onclick: closeModal }), send] }); + setTimeout(() => ta.focus(), 50); + } + + // ---------------------------------------------------------------- timeline + const TL_ICON = { task: "βœ“", "task-done": "βœ…", journal: "✎", memo: "β–€", milestone: "βš‘", learning: "πŸŽ“", growth: "β†—", project: "❏", report: "πŸ“Š" }; + let tlDays = Number(localStorage.getItem("cp-tl-days")) || 14; + function prettyDate(iso) { + const d = new Date(iso + "T00:00:00"); + if (isNaN(d)) return iso; + const today = new Date(); today.setHours(0, 0, 0, 0); + const diff = Math.round((today - d) / 86400000); + const label = diff === 0 ? "Today" : diff === 1 ? "Yesterday" : d.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" }); + return label; + } + VIEWS.timeline = async (root) => { + const data = await api(`/api/timeline?days=${tlDays}`); + root.innerHTML = ""; + const seg = el("div", { class: "segmented" }, [7, 14, 30].map((d) => el("button", { class: tlDays === d ? "active" : "", text: `${d}d`, onclick: () => { tlDays = d; localStorage.setItem("cp-tl-days", d); go("timeline"); } }))); + actionsHost.append(seg); + root.append(agentBar([ + { icon: "🧭", label: "Summarize this period", prompt: `Summarize what I've worked on in CatPilot over the last ${tlDays} days using my journal, memos, tasks and milestones.` }, + { icon: "🎯", label: "What should I do next?", prompt: "Based on my CatPilot tasks and milestones, what should I focus on next?" }, + { icon: "πŸ—£οΈ", label: "Draft a standup update", prompt: `Write a concise standup update from my CatPilot activity in the last ${tlDays} days.` }, + { icon: "πŸ“Š", label: "Generate a report", prompt: "Generate a CatPilot executive report for this week using the report-generator skill and save it via the save_report canvas action." }, + ])); + const groups = data.groups || []; + const total = groups.reduce((n, g) => n + g.items.length, 0); + if (data.byType && Object.keys(data.byType).length) { + root.append(el("div", { class: "tl-summary" }, Object.entries(data.byType).map(([k, v]) => el("span", { class: "chip" }, `${TL_ICON[k] || "β€’"} ${v} ${k}`)))); + } + if (!total) { root.append(emptyState("πŸ•‘", "Nothing in this window yet", "Add tasks, journal entries, memos or milestones and they'll show up here as a story.")); return; } + const wrap = el("div", { class: "tl-wrap" }); + groups.forEach((g) => { + const day = el("div", { class: "tl-day" }, + el("div", { class: "tl-day-head" }, el("span", { class: "tl-day-date", text: prettyDate(g.date) }), el("span", { class: "muted small", text: `${g.items.length} item${g.items.length > 1 ? "s" : ""}` }))); + const items = el("div", { class: "timeline" }); + g.items.forEach((a) => items.append(el("div", { class: "tl-item" }, + el("div", { class: `tl-dot tl-${a.type}` }, TL_ICON[a.type] || "β€’"), + el("div", { class: "tl-body" }, el("div", { class: "tl-label", text: a.label || "(untitled)" }), el("div", { class: "tl-meta", text: `${a.type}${a.detail ? " Β· " + a.detail : ""}` }))))); + day.append(items); wrap.append(day); + }); + root.append(wrap); + }; + + // ---------------------------------------------------------------- pomodoro + const POMO_TYPES = [["focus", "Focus"], ["short-break", "Short break"], ["long-break", "Long break"]]; + const POMO_DEFAULT_MIN = { focus: 25, "short-break": 5, "long-break": 15 }; + const POMO_TYPE_LABEL = { focus: "Focus", "short-break": "Short break", "long-break": "Long break" }; + const POMO_LONG_BREAK_EVERY = 4; // classic Pomodoro: long break after every 4th focus + // Distinct accent per session type (used for the ring arc and the dock badge). + const POMO_TYPE_COLOR = { focus: "#ff6b6b", "short-break": "#35c88f", "long-break": "#5aa9ff" }; + const POMO_TYPE_BADGE = { focus: "st-focus", "short-break": "st-short", "long-break": "st-long" }; + const pomoColor = (type) => POMO_TYPE_COLOR[type] || POMO_TYPE_COLOR.focus; + + function fmtClock(sec) { + const s = Math.max(0, Math.round(sec)); + const m = Math.floor(s / 60); + return `${String(m).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; + } + + // Circular progress ring for the running timer. + function pomoRing(remainingSec, plannedSec, size = 220, color = null) { + const NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(NS, "svg"); + svg.setAttribute("viewBox", `0 0 ${size} ${size}`); + svg.setAttribute("width", size); svg.setAttribute("height", size); + const stroke = Math.max(4, Math.round(size * 0.064)); + const inset = Math.max(6, Math.round(size * 0.073)); + const cx = size / 2, cy = size / 2, r = size / 2 - inset, C = 2 * Math.PI * r; + const frac = plannedSec > 0 ? Math.max(0, Math.min(1, remainingSec / plannedSec)) : 0; + const track = document.createElementNS(NS, "circle"); + track.setAttribute("cx", cx); track.setAttribute("cy", cy); track.setAttribute("r", r); + track.setAttribute("fill", "none"); track.setAttribute("stroke", "var(--panel-2)"); track.setAttribute("stroke-width", stroke); + svg.append(track); + const arc = document.createElementNS(NS, "circle"); + arc.setAttribute("cx", cx); arc.setAttribute("cy", cy); arc.setAttribute("r", r); + arc.setAttribute("fill", "none"); arc.setAttribute("stroke", remainingSec === 0 ? "#4caf7d" : (color || "#ff6b6b")); + arc.setAttribute("stroke-width", stroke); arc.setAttribute("stroke-linecap", "round"); + arc.setAttribute("stroke-dasharray", `${C * frac} ${C}`); + arc.setAttribute("transform", `rotate(-90 ${cx} ${cy})`); + svg.append(arc); + // Compact rings (dock) are pure progress indicators; text only on the large ring. + if (size >= 90) { + const t1 = document.createElementNS(NS, "text"); + t1.setAttribute("x", cx); t1.setAttribute("y", cy - 2); t1.setAttribute("text-anchor", "middle"); + t1.setAttribute("font-size", String(Math.round(size * 0.18))); t1.setAttribute("font-weight", "800"); t1.setAttribute("fill", "var(--text)"); + t1.textContent = fmtClock(remainingSec); + const t2 = document.createElementNS(NS, "text"); + t2.setAttribute("x", cx); t2.setAttribute("y", cy + Math.round(size * 0.1)); t2.setAttribute("text-anchor", "middle"); + t2.setAttribute("font-size", String(Math.round(size * 0.055))); t2.setAttribute("fill", "var(--text-faint)"); + t2.textContent = remainingSec === 0 ? "done" : "remaining"; + svg.append(t1, t2); + } + return svg; + } + + // ------------------------------------------------- global pomodoro controller + // A single app-wide loop drives BOTH the floating mini-timer dock and (when + // visible) the full Pomodoro page, so the timer keeps running regardless of the + // current view. Replaces the old view-local `pomoTick`. + const pomo = { active: null, remaining: 0, planned: 0, expiredHandled: false, durations: null }; + let pomoLoop = null; + let pomoSyncAccum = 0; + let pomoPageUpdater = null; // set by VIEWS.pomodoro while it is mounted + let pomoNotifyAsked = false; + + async function pomoDurations() { + if (pomo.durations) return pomo.durations; + try { const cfg = await api("/api/config"); pomo.durations = (cfg && cfg.pomodoro) || POMO_DEFAULT_MIN; } + catch { pomo.durations = POMO_DEFAULT_MIN; } + return pomo.durations; + } + + function pomoAskNotifyPermission() { + if (pomoNotifyAsked) return; + pomoNotifyAsked = true; + try { + if (typeof Notification !== "undefined" && Notification.permission === "default") { + Notification.requestPermission().catch(() => {}); + } + } catch { /* webview may not support Notifications */ } + } + + function pomoBeep() { + try { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return; + const ctx = new Ctx(); + const now = ctx.currentTime; + [0, 0.18].forEach((offset, i) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = "sine"; + osc.frequency.value = i === 0 ? 880 : 660; + gain.gain.setValueAtTime(0.0001, now + offset); + gain.gain.exponentialRampToValueAtTime(0.25, now + offset + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, now + offset + 0.16); + osc.connect(gain); gain.connect(ctx.destination); + osc.start(now + offset); osc.stop(now + offset + 0.18); + }); + setTimeout(() => { try { ctx.close(); } catch { /* */ } }, 600); + } catch { /* audio not available */ } + } + + /** Re-fetch authoritative state from the server and refresh dock + page. */ + async function pomoSync() { + let active = null; + try { const res = await api("/api/pomodoro/status"); active = res && res.active; } catch { return; } + const hadId = pomo.active ? pomo.active.startedAt : null; + const newId = active ? active.startedAt : null; + pomo.active = active || null; + if (active) { + // A different (new) session appeared -> reset expiry latch and ask for notifications. + if (newId !== hadId) { pomo.expiredHandled = false; pomoAskNotifyPermission(); } + pomo.planned = active.plannedSec; + pomo.remaining = active.remainingSec; + if (active.remainingSec === 0 && !pomo.expiredHandled) { pomo.expiredHandled = true; onPomoExpire(active); } + } else { + pomo.remaining = 0; pomo.planned = 0; + } + renderPomoDock(); + if (pomoPageUpdater) pomoPageUpdater(); + } + + function startPomoLoop() { + if (pomoLoop) return; + pomoSyncAccum = 0; + pomoLoop = setInterval(async () => { + pomoSyncAccum += 1; + if (pomo.active && !pomo.active.paused) { + pomo.remaining = Math.max(0, pomo.remaining - 1); + if (pomo.remaining === 0 && !pomo.expiredHandled) { + pomo.expiredHandled = true; + onPomoExpire(pomo.active); + } + renderPomoDock(); + if (pomoPageUpdater) pomoPageUpdater(); + } + // Periodic re-sync (~15s) corrects drift and catches CLI/other-surface changes. + if (pomoSyncAccum >= 15) { pomoSyncAccum = 0; pomoSync(); } + }, 1000); + } + + function stopPomoLoop() { if (pomoLoop) { clearInterval(pomoLoop); pomoLoop = null; } } + + async function pomoAction(path) { + await api(path, { method: "POST", body: {} }); + await pomoSync(); + if (state.view === "pomodoro") go("pomodoro"); + } + + /** Render / update the floating mini-timer dock (persists across navigation). */ + function renderPomoDock() { + const host = $("#pomo-dock"); + if (!host) return; + const a = pomo.active; + if (!a) { host.classList.add("hidden"); host.innerHTML = ""; host.className = "pomo-dock hidden"; return; } + const paused = !!a.paused; + host.className = `pomo-dock pomo-${a.type}` + (pomo.remaining === 0 ? " is-done" : "") + (paused ? " is-paused" : ""); + host.innerHTML = ""; + const focusOn = a.task || a.label || ""; + const typeLabel = POMO_TYPE_LABEL[a.type] || a.type; + const ringHost = el("div", { class: "pomo-dock-ring", title: "Open Pomodoro", + onclick: () => go("pomodoro") }, pomoRing(pomo.remaining, pomo.planned, 46, pomoColor(a.type))); + const info = el("div", { class: "pomo-dock-info", title: "Open Pomodoro", onclick: () => go("pomodoro") }, + el("div", { class: "pomo-dock-time", text: fmtClock(pomo.remaining) }), + el("div", { class: "pomo-dock-label" }, + el("span", { class: `badge ${POMO_TYPE_BADGE[a.type] || "st-inprogress"}`, text: typeLabel }), + paused ? el("span", { class: "badge st-paused", text: "Paused" }) + : (focusOn ? el("span", { class: "muted small pomo-dock-task", text: focusOn }) : null))); + const controls = el("div", { class: "pomo-dock-controls" }, + paused + ? el("button", { class: "icon-btn pomo-dock-btn", title: "Resume", "aria-label": "Resume session", html: "β–Ά", + onclick: async () => { try { await pomoAction("/api/pomodoro/resume"); toast("Resumed", typeLabel, "ok"); } catch (e) { toast("Error", e.message, "err"); } } }) + : el("button", { class: "icon-btn pomo-dock-btn", title: "Pause", "aria-label": "Pause session", html: "⏸", + onclick: async () => { try { await pomoAction("/api/pomodoro/pause"); toast("Paused", typeLabel, ""); } catch (e) { toast("Error", e.message, "err"); } } }), + el("button", { class: "icon-btn pomo-dock-btn pomo-dock-stop", title: "Stop & log session", "aria-label": "Stop session", html: "⏹", + onclick: async () => { try { await pomoAction("/api/pomodoro/complete"); toast("Pomodoro stopped", "Logged πŸ…", "ok"); } catch (e) { toast("Error", e.message, "err"); } } })); + host.append(ringHost, info, controls); + } + + /** Fired once when a running session's countdown reaches zero. */ + async function onPomoExpire(session) { + const finishedType = session.type; + const finishedTask = session.task || session.label || ""; + pomoBeep(); + try { + if (typeof Notification !== "undefined" && Notification.permission === "granted") { + new Notification("CatPilot πŸ…", { + body: finishedType === "focus" ? "Focus session done β€” time for a break?" : "Break's over β€” back to focus?", + silent: true, + }); + } + } catch { /* best-effort only */ } + toast("Time! ⏰", finishedType === "focus" ? "Focus session finished πŸ…" : "Break finished", "ok"); + renderPomoDock(); + + const durations = await pomoDurations(); + const min = (t) => parseInt(durations[t], 10) || POMO_DEFAULT_MIN[t]; + + // Log the finished session, then start the chosen next one (if any). + async function choose(nextType, carryTask) { + try { + await api("/api/pomodoro/complete", { method: "POST", body: {} }); + if (nextType) { + const body = { type: nextType, minutes: min(nextType) }; + if (nextType === "focus" && carryTask) body.task = carryTask; + await api("/api/pomodoro", { method: "POST", body }); + toast("Started", POMO_TYPE_LABEL[nextType], "ok"); + } + } catch (e) { toast("Error", e.message, "err"); } + closeModal(); + await pomoSync(); + if (state.view === "pomodoro") go("pomodoro"); + } + + let suggestLong = false; + if (finishedType === "focus") { + try { + const stats = await api("/api/pomodoro/stats?period=today"); + // The just-finished focus isn't logged yet, so add it to today's count. + const done = (parseInt(stats && stats.focusSessions, 10) || 0) + 1; + suggestLong = done % POMO_LONG_BREAK_EVERY === 0; + } catch { /* default to short */ } + } + + const btn = (label, cls, onclick) => el("button", { class: `btn ${cls}`, html: label, onclick }); + let foot; + let bodyText; + if (finishedType === "focus") { + bodyText = suggestLong + ? `Nice work β€” that's ${POMO_LONG_BREAK_EVERY} focus sessions. Time for a long break?` + : "Focus session complete. Take a short break?"; + foot = el("div", { class: "toolbar", style: "flex-wrap:wrap;gap:8px" }, + btn(`β˜• Short break (${min("short-break")}m)`, suggestLong ? "" : "btn-primary", () => choose("short-break")), + btn(`πŸ›‹οΈ Long break (${min("long-break")}m)`, suggestLong ? "btn-primary" : "", () => choose("long-break")), + btn(`πŸ… Another focus (${min("focus")}m)`, "", () => choose("focus", finishedTask)), + btn("Skip", "", () => choose(null))); + } else { + bodyText = "Break's over. Ready to focus again?"; + foot = el("div", { class: "toolbar", style: "flex-wrap:wrap;gap:8px" }, + btn(`πŸ… Start focus (${min("focus")}m)`, "btn-primary", () => choose("focus")), + btn("Skip", "", () => choose(null))); + } + openModal({ + title: finishedType === "focus" ? "Focus session done πŸ…" : "Break over β˜•", + width: 460, + body: el("div", {}, + el("p", { text: bodyText }), + finishedTask ? el("p", { class: "muted small", text: `Was focusing on: ${finishedTask}` }) : null, + el("p", { class: "muted small", text: "You decide β€” nothing starts automatically." })), + foot, + }); + } + + VIEWS.pomodoro = async (root) => { + pomoPageUpdater = null; + const [{ active }, stats, { sessions }, cfg] = await Promise.all([ + api("/api/pomodoro/status"), + api("/api/pomodoro/stats?period=today"), + api("/api/pomodoro?limit=8"), + api("/api/config").catch(() => ({})), + ]); + const durations = (cfg && cfg.pomodoro) || POMO_DEFAULT_MIN; + pomo.durations = durations; + root.innerHTML = ""; + + const timerCard = el("div", { class: "card", style: "text-align:center;padding:24px" }); + root.append(timerCard); + + let pageRingHost = null; + let pageRenderedFor = "__init__"; // startedAt of the session the page structure was built for + + function renderRunning(activeSession) { + timerCard.innerHTML = ""; + const paused = !!pomo.active?.paused; + pageRingHost = el("div", { class: "pomo-ring" }, pomoRing(pomo.remaining, pomo.planned, 220, pomoColor(activeSession.type))); + const focusOn = activeSession.task || activeSession.label || ""; + const typeLabel = POMO_TYPE_LABEL[activeSession.type] || activeSession.type; + timerCard.append( + pageRingHost, + el("div", { class: "pomo-meta" }, + el("span", { class: `badge ${POMO_TYPE_BADGE[activeSession.type] || "st-inprogress"}`, text: typeLabel }), + paused ? el("span", { class: "badge st-paused", text: "Paused" }) : null, + focusOn ? el("span", { class: "muted", text: `🎯 ${focusOn}` }) : null), + el("div", { class: "toolbar", style: "justify-content:center;margin-top:16px" }, + paused + ? el("button", { class: "btn btn-primary", html: "β–Ά Resume", onclick: async () => { + try { await api("/api/pomodoro/resume", { method: "POST", body: {} }); toast("Resumed", typeLabel, "ok"); await pomoSync(); go("pomodoro"); } + catch (e) { toast("Error", e.message, "err"); } } }) + : el("button", { class: "btn", html: "⏸ Pause", onclick: async () => { + try { await api("/api/pomodoro/pause", { method: "POST", body: {} }); toast("Paused", typeLabel, ""); await pomoSync(); go("pomodoro"); } + catch (e) { toast("Error", e.message, "err"); } } }), + el("button", { class: "btn btn-primary", html: "βœ“ Complete", onclick: async () => { + try { await api("/api/pomodoro/complete", { method: "POST", body: {} }); toast("Pomodoro completed", "Logged πŸ…", "ok"); await pomoSync(); go("pomodoro"); } + catch (e) { toast("Error", e.message, "err"); } + } }), + el("button", { class: "btn", html: "βœ• Cancel", onclick: async () => { + try { await api("/api/pomodoro/cancel", { method: "POST", body: {} }); toast("Pomodoro cancelled", "", ""); await pomoSync(); go("pomodoro"); } + catch (e) { toast("Error", e.message, "err"); } + } }))); + } + + // The global loop calls this every second while the Pomodoro page is mounted. + pomoPageUpdater = () => { + if (state.view !== "pomodoro") return; + const a = pomo.active; + const id = a ? a.startedAt + (a.paused ? ":paused" : "") : null; + if (id !== pageRenderedFor) { + pageRenderedFor = id; + if (a) renderRunning(a); else renderStartForm(); + return; + } + if (a && pageRingHost) pageRingHost.replaceChildren(pomoRing(pomo.remaining, pomo.planned, 220, pomoColor(a.type))); + }; + + async function renderStartForm() { + pageRingHost = null; + timerCard.innerHTML = ""; + const f = {}; + let tasks = []; + try { tasks = await api("/api/tasks?status=open"); tasks = tasks.tasks || tasks || []; } catch { tasks = []; } + const typeSel = el("select", {}, POMO_TYPES.map(([v, l]) => el("option", { value: v, text: l }))); + const minutes = el("input", { type: "number", min: "1", value: String(durations.focus || 25), style: "width:90px" }); + typeSel.addEventListener("change", () => { minutes.value = String(durations[typeSel.value] || POMO_DEFAULT_MIN[typeSel.value] || 25); }); + const taskSel = el("select", {}, el("option", { value: "", text: "β€” No task β€”" }), + tasks.map((t) => el("option", { value: `#${t.id} ${t.title}`, text: `#${t.id} ${t.title}` }))); + f.type = typeSel; f.minutes = minutes; f.task = taskSel; + + timerCard.append( + el("div", { class: "big", text: "πŸ…", style: "font-size:52px" }), + el("h3", { text: "Start a focus session" }), + el("div", { class: "form", style: "max-width:420px;margin:16px auto 0;text-align:left" }, + el("div", { class: "form-row" }, + el("label", {}, el("span", { text: "Type" }), typeSel), + el("label", {}, el("span", { text: "Minutes" }), minutes)), + el("label", {}, el("span", { text: "Focus on task (optional)" }), taskSel)), + el("div", { class: "toolbar", style: "justify-content:center;margin-top:16px" }, + el("button", { class: "btn btn-primary", html: "β–Ά Start", onclick: async () => { + const body = { type: typeSel.value, minutes: parseInt(minutes.value, 10) || undefined, task: taskSel.value || undefined }; + try { await api("/api/pomodoro", { method: "POST", body }); toast("Pomodoro started", body.type, "ok"); await pomoSync(); go("pomodoro"); } + catch (e) { toast("Error", e.message, "err"); } + } }))); + } + + // Seed the page from the controller's authoritative state. + pomo.active = active || null; + if (active) { pomo.planned = active.plannedSec; pomo.remaining = active.remainingSec; renderRunning(active); pageRenderedFor = active.startedAt; } + else { pomo.remaining = 0; pomo.planned = 0; await renderStartForm(); pageRenderedFor = null; } + + // Stats strip (today) + root.append(el("div", { class: "grid stat-grid", style: "margin-top:16px" }, + statTile("πŸ…", stats.completedSessions, "Sessions today"), + statTile("🎯", stats.focusSessions, "Focus sessions"), + statTile("⏱️", stats.focusMinutes, "Focus minutes"))); + + // Recent sessions + if (sessions.length) { + const tbody = el("tbody"); + sessions.forEach((s) => tbody.append(el("tr", {}, + el("td", {}, el("span", { class: "badge", text: s.type })), + el("td", {}, el("span", { text: s.task || "β€”" })), + el("td", {}, el("span", { class: "muted small", text: `${s.actualMin || 0} min` })), + el("td", {}, statusBadge(s.status === "completed" ? "Done" : s.status))))); + root.append(el("h3", { text: "Recent sessions", style: "margin:20px 0 8px" }), + el("div", { class: "table-wrap" }, el("table", { class: "tbl" }, + el("thead", {}, el("tr", {}, el("th", { text: "Type" }), el("th", { text: "Task" }), el("th", { text: "Actual" }), el("th", { text: "Status" }))), + tbody))); + } + + // Productivity reports + const reportHost = el("div", { style: "margin-top:24px" }); + root.append(reportHost); + await renderPomoReports(reportHost); + }; + + let pomoReport = { period: "this-week", by: "day" }; + async function renderPomoReports(host) { + host.innerHTML = ""; + const periodSel = el("select", {}, PERIODS.map(([v, l]) => el("option", { value: v, text: l, selected: v === pomoReport.period }))); + periodSel.value = pomoReport.period; + periodSel.addEventListener("change", () => { pomoReport.period = periodSel.value; renderPomoReports(host); }); + const BYS = [["day", "By day"], ["week", "By week"], ["task", "By task"], ["session", "By session"]]; + const bySel = el("select", {}, BYS.map(([v, l]) => el("option", { value: v, text: l, selected: v === pomoReport.by }))); + bySel.value = pomoReport.by; + bySel.addEventListener("change", () => { pomoReport.by = bySel.value; renderPomoReports(host); }); + + host.append(el("div", { class: "toolbar", style: "align-items:center;gap:10px;margin-bottom:12px" }, + el("h3", { text: "πŸ“Š Productivity", style: "margin:0;margin-right:auto" }), + periodSel, bySel)); + + let data; + try { data = await api(`/api/pomodoro/report?period=${encodeURIComponent(pomoReport.period)}&by=${encodeURIComponent(pomoReport.by)}`); } + catch (e) { host.append(el("div", { class: "muted", text: e.message })); return; } + const s = data.summary; + const pct = (x) => `${Math.round((x || 0) * 100)}%`; + + host.append(el("div", { class: "grid stat-grid" }, + statTile("🎯", `${s.completedFocusSessions}/${s.focusSessions}`, "Focus completed"), + statTile("⏱️", s.focusMinutes, "Focus minutes"), + statTile("βœ…", pct(s.focusCompletionRate), "Focus completion"), + statTile("πŸ…", s.totalSessions, "Total sessions"))); + + if (!data.groups.length) { host.append(el("div", { class: "muted small", style: "margin-top:12px", text: "No sessions in this period yet." })); return; } + + // Charts row (skip bars for session view) + const charts = el("div", { class: "grid", style: "grid-template-columns:2fr 1fr;gap:16px;margin-top:16px" }); + if (pomoReport.by !== "session") { + const barItems = data.groups.map((g) => ({ + label: g.label, + short: pomoReport.by === "day" ? g.label.slice(5) : (pomoReport.by === "week" ? g.label.replace(/^\d+-/, "") : g.label), + value: g.focusMinutes || 0, + })); + charts.append(el("div", { class: "card" }, + el("div", { class: "muted small", style: "margin-bottom:8px", text: "Focus minutes" }), + simpleBars(barItems, { color: "#7c5cff", unit: " min" }))); + } + const donutHost = el("div", { class: "card", style: "text-align:center" }, + el("div", { class: "muted small", style: "margin-bottom:8px", text: "Completed vs abandoned" }), + donut([ + { value: s.completedSessions, color: "#4ade80" }, + { value: s.abandonedSessions, color: "#f87171" }, + ]), + el("div", { class: "legend", style: "justify-content:center" }, + el("span", {}, el("i", { style: "background:#4ade80" }), "Completed"), + el("span", {}, el("i", { style: "background:#f87171" }), "Abandoned"))); + charts.append(donutHost); + if (pomoReport.by === "session") charts.style.gridTemplateColumns = "1fr"; + host.append(charts); + + // Grouped table + const tbody = el("tbody"); + if (pomoReport.by === "session") { + data.groups.forEach((g) => tbody.append(el("tr", {}, + el("td", {}, el("span", { class: "muted small", text: new Date(g.started).toLocaleString() })), + el("td", {}, el("span", { class: "badge", text: g.type })), + el("td", {}, el("span", { text: g.task || "β€”" })), + el("td", {}, el("span", { class: "muted small", text: `${g.actualMin}/${g.plannedMin} min` })), + el("td", {}, statusBadge(g.status === "completed" ? "Done" : g.status))))); + host.append(el("div", { class: "table-wrap", style: "margin-top:16px" }, el("table", { class: "tbl" }, + el("thead", {}, el("tr", {}, el("th", { text: "Started" }), el("th", { text: "Type" }), el("th", { text: "Task" }), el("th", { text: "Actual/Planned" }), el("th", { text: "Status" }))), + tbody))); + } else { + const head = pomoReport.by === "task" ? "Task" : (pomoReport.by === "week" ? "Week" : "Day"); + data.groups.forEach((g) => tbody.append(el("tr", {}, + el("td", {}, el("span", { text: g.label })), + el("td", {}, el("span", { text: `${g.completedFocusSessions}/${g.focusSessions}` })), + el("td", {}, el("span", { text: String(g.focusMinutes) })), + el("td", {}, el("span", { class: "muted small", text: pct(g.completionRate) }))))); + host.append(el("div", { class: "table-wrap", style: "margin-top:16px" }, el("table", { class: "tbl" }, + el("thead", {}, el("tr", {}, el("th", { text: head }), el("th", { text: "Focus done" }), el("th", { text: "Focus min" }), el("th", { text: "Completion" }))), + tbody))); + } + } + + function statTile(icon, value, label) { + return el("div", { class: "card", style: "text-align:center" }, + el("div", { class: "big", text: icon, style: "font-size:26px" }), + el("div", { style: "font-size:24px;font-weight:800", text: String(value) }), + el("div", { class: "muted small", text: label })); + } + + // ---------------------------------------------------------------- reports + const PERIODS = [["this-week", "This week"], ["last-week", "Last week"], ["this-month", "This month"], ["last-month", "Last month"], ["last-7", "Last 7 days"], ["last-30", "Last 30 days"], ["all", "All time"]]; + let reportPeriod = "this-week"; + VIEWS.reports = async (root) => { + const { reports } = await api("/api/reports"); + root.innerHTML = ""; + const sel = el("select", { class: "inline-edit", style: "width:auto" }, PERIODS.map(([v, l]) => { const o = el("option", { value: v, text: l }); if (v === reportPeriod) o.selected = true; return o; })); + sel.addEventListener("change", (e) => { reportPeriod = e.target.value; }); + const genBtn = el("button", { class: "btn btn-primary", html: "πŸ“Š Generate report" }); + genBtn.addEventListener("click", async () => { + genBtn.disabled = true; genBtn.innerHTML = "⏳ Generating…"; + try { const { report } = await api("/api/reports", { method: "POST", body: { period: reportPeriod } }); toast("Report generated", report.title, "ok"); go("reports"); reportDetail(report.filename); } + catch (e) { toast("Error", e.message, "err"); } + finally { genBtn.disabled = false; genBtn.innerHTML = "πŸ“Š Generate report"; } + }); + root.append(el("div", { class: "toolbar" }, sel, genBtn, + el("button", { class: "btn", html: "✨ Ask Copilot", onclick: () => askAgent(`Generate a detailed CatPilot executive report for "${reportPeriod}" using the report-generator skill, then save it with the save_report canvas action.`, "Report requested") }), + el("span", { class: "spacer" }), + el("span", { class: "muted small", text: `${reports.length} report${reports.length === 1 ? "" : "s"}` }))); + if (!reports.length) { root.append(emptyState("πŸ“Š", "No reports yet", "Generate an executive report from your tasks and milestones, or ask Copilot to write one.", genBtn)); return; } + const grid = el("div", { class: "grid note-grid" }); + reports.forEach((r) => grid.append(el("div", { class: "card report-card hoverable", onclick: () => reportDetail(r.filename) }, + el("div", { class: "report-ic", text: r.format === "html" ? "🌐" : "πŸ“„" }), + el("h4", { text: r.title }), + el("div", { class: "note-foot" }, el("span", { class: "tag", text: r.date || "" }), el("span", { class: "tag", text: r.format || "md" }))))); + root.append(grid); + }; + async function reportDetail(filename) { + try { + const { report } = await api(`/api/reports/${encodeURIComponent(filename)}`); + let body; + if (report.format === "html") { body = el("iframe", { class: "report-iframe", sandbox: "", title: report.title || "Report preview" }); body.srcdoc = report.content; } + else body = mdRender(report.content); + const del = el("button", { class: "btn btn-danger", text: "Delete", onclick: async () => { + try { await api(`/api/reports/${encodeURIComponent(filename)}`, { method: "DELETE" }); toast("Report deleted", "", "ok"); closeModal(); if (state.view === "reports") go("reports"); } + catch (e) { toast("Error", e.message, "err"); } + } }); + openModal({ title: report.title, width: 860, body, foot: [del, el("span", { style: "flex:1" }), el("button", { class: "btn", text: "Close", onclick: closeModal })] }); + } catch (e) { toast("Error", e.message, "err"); } + } + + // ---------------------------------------------------------------- settings + function shortPath(p) { const parts = String(p || "").split(/[\\/]/).filter(Boolean); return parts.length > 3 ? "…/" + parts.slice(-3).join("/") : p; } + VIEWS.settings = async (root) => { + const cfg = await api("/api/config"); + root.innerHTML = ""; + if (!cfg.configured) { root.append(emptyState("βš™οΈ", "Not configured", "Complete onboarding first to set your storage root.")); return; } + root.append(el("div", { class: "card settings-card" }, + el("h4", { text: "Storage configuration" }), + detailRow("Storage root", el("code", { text: cfg.root })), + detailRow("Resolved path", el("code", { text: cfg.resolvedRoot || cfg.root })), + detailRow("Partitioning", el("span", { text: cfg.partitioning })), + detailRow("Active partition", el("code", { text: cfg.partition || "β€”" })), + detailRow("Migration mode", el("span", { text: cfg.migration || "move" })), + detailRow("Config file", el("code", { text: cfg.configPath || "~/.catpilot/config.json" })))); + root.append(el("div", { class: "toolbar", style: "margin-top:16px" }, + el("button", { class: "btn btn-primary", html: "βš™οΈ Change configuration…", onclick: () => configModal(cfg) }), + el("button", { class: "btn", html: "✨ Ask Copilot about my setup", onclick: () => askAgent("Explain my current CatPilot storage configuration and suggest improvements.") }))); + root.append(el("div", { class: "card settings-card", style: "margin-top:16px" }, + el("h4", { text: "Appearance" }), + el("p", { class: "muted small", text: "Toggle light/dark from the top bar. Your preference is remembered on this machine." }), + el("button", { class: "btn", html: state.theme === "dark" ? "β˜€οΈ Switch to light" : "πŸŒ™ Switch to dark", onclick: () => { toggleTheme(); go("settings"); } }))); + + // Pomodoro durations + const pd = cfg.pomodoro || { focus: 25, "short-break": 5, "long-break": 15 }; + const dur = {}; + const durInput = (key) => { const i = el("input", { type: "number", min: "1", value: String(pd[key] || 1), style: "width:90px" }); dur[key] = i; return i; }; + const saveDur = el("button", { class: "btn btn-primary", html: "πŸ’Ύ Save durations", onclick: async () => { + const body = { pomodoro: { + focus: parseInt(dur.focus.value, 10), + "short-break": parseInt(dur["short-break"].value, 10), + "long-break": parseInt(dur["long-break"].value, 10), + } }; + try { await api("/api/config/pomodoro", { method: "POST", body }); toast("Pomodoro durations saved", "", "ok"); go("settings"); } + catch (e) { toast("Error", e.message, "err"); } + } }); + root.append(el("div", { class: "card settings-card", style: "margin-top:16px" }, + el("h4", { text: "πŸ… Pomodoro durations" }), + el("p", { class: "muted small", text: "Default session lengths (minutes) used when you start a timer without a custom value." }), + el("div", { class: "form" }, + el("div", { class: "form-row" }, + el("label", {}, el("span", { text: "Focus" }), durInput("focus")), + el("label", {}, el("span", { text: "Short break" }), durInput("short-break")), + el("label", {}, el("span", { text: "Long break" }), durInput("long-break")))), + el("div", { class: "toolbar", style: "margin-top:12px" }, saveDur))); + }; + function configModal(cfg) { + const f = {}; + const form = el("div", { class: "form" }, + field("Storage root", f, "root", { value: cfg.root, placeholder: "Folder path (e.g. C:\\Users\\you\\Vault)" }), + el("div", { class: "form-row" }, + selectField("Partitioning", f, "partitioning", ["month", "week", "day"], cfg.partitioning), + selectField("If data exists", f, "migration", ["move", "copy", "adopt"], "move"))); + const planBox = el("div", { class: "plan-box hidden" }); + const approve = el("label", { class: "approve hidden" }, el("input", { type: "checkbox" }), el("span", { text: "I understand and want to apply these changes" })); + const approveCb = approve.querySelector("input"); + const applyBtn = el("button", { class: "btn btn-primary", text: "Confirm & apply", disabled: true }); + approveCb.addEventListener("change", () => { applyBtn.disabled = !approveCb.checked; }); + function bodyFromForm(withConfirm) { const b = { root: f.root.value.trim(), partitioning: f.partitioning.value, migration: f.migration.value }; if (withConfirm) b.confirm = true; return b; } + // A preview must precede every apply. Editing any field invalidates the + // previewed plan so the user can never apply a stale/mismatched plan. + let previewedBody = null; + function invalidate() { previewedBody = null; planBox.classList.add("hidden"); planBox.innerHTML = ""; approve.classList.add("hidden"); approveCb.checked = false; applyBtn.disabled = true; } + [f.root, f.partitioning, f.migration].forEach((inp) => { if (inp) { inp.addEventListener("input", invalidate); inp.addEventListener("change", invalidate); } }); + function renderPlan(plan) { + planBox.innerHTML = ""; planBox.classList.remove("hidden"); + const changed = plan.rootChanged || plan.partitioningChanged; + planBox.append(el("div", { class: "plan-head" }, el("strong", { text: changed ? "Proposed changes" : "No path change" }))); + planBox.append(el("div", { class: "plan-diff" }, + el("div", {}, el("span", { class: "muted small", text: "From " }), el("code", { text: `${shortPath(plan.current.resolvedRoot)} Β· ${plan.current.partitioning}` })), + el("div", {}, el("span", { class: "muted small", text: "To " }), el("code", { text: `${shortPath(plan.next.resolvedRoot)} Β· ${plan.next.partitioning}` })))); + if (plan.needsMigration && plan.totalItems) { + const moving = (plan.items || []).filter((i) => i.willMove); + planBox.append(el("p", { class: "muted small", text: `${plan.next.migration === "copy" ? "Copy" : "Move"} ${plan.totalItems} item(s) across ${moving.length} location(s):` })); + const list = el("div", { class: "plan-list" }); + moving.forEach((i) => list.append(el("div", { class: "plan-item" }, el("span", { class: "tag", text: i.type }), el("span", { class: "muted small", text: `${i.count} β†’ ${shortPath(i.to)}` })))); + planBox.append(list); + approve.classList.remove("hidden"); applyBtn.disabled = !approveCb.checked; + } else { + planBox.append(el("p", { class: "muted small", text: plan.next.migration === "adopt" ? "Adopt mode: config points at the new location; no files are moved." : "Nothing to migrate β€” paths are unchanged." })); + approve.classList.add("hidden"); applyBtn.disabled = false; + } + if (plan.hasConflicts) { + planBox.append(el("div", { class: "plan-warn small" }, + el("strong", { text: `⚠ ${plan.conflicts.length} destination file(s) already exist` }), + el("div", { class: "muted small", text: "Existing files are kept and those sources are skipped β€” nothing is overwritten." }))); + } + } + const previewBtn = el("button", { class: "btn", text: "Preview changes", onclick: async () => { + const b = bodyFromForm(false); + if (!b.root) { toast("Root required", "", "err"); return; } + try { const plan = await api("/api/config/plan", { method: "POST", body: b }); previewedBody = b; renderPlan(plan); } + catch (e) { toast("Preview failed", e.message, "err"); } + } }); + applyBtn.addEventListener("click", async () => { + if (!previewedBody) { toast("Preview first", "Preview the changes before applying.", "err"); return; } + applyBtn.disabled = true; const orig = applyBtn.textContent; applyBtn.textContent = "Applying…"; + try { + const r = await api("/api/config/apply", { method: "POST", body: { ...previewedBody, confirm: true } }); + const moved = r.moved != null ? r.moved : r.migrated; + const parts = []; + if (moved) parts.push(`${moved} item(s) ${previewedBody.migration === "copy" ? "copied" : "moved"}`); + if (r.skipped) parts.push(`${r.skipped} skipped (already existed)`); + toast("Configuration updated", parts.join(" Β· ") || "Saved", "ok"); + closeModal(); await refreshSummary(); go("settings"); + } + catch (e) { toast("Apply failed", e.message, "err"); applyBtn.disabled = false; applyBtn.textContent = orig; } + }); + openModal({ title: "βš™οΈ CatPilot configuration", width: 660, body: el("div", {}, form, el("div", { class: "toolbar", style: "margin:4px 0 2px" }, previewBtn, el("span", { class: "spacer" }), el("span", { class: "muted small", text: "Preview before applying" })), planBox, approve), foot: [el("button", { class: "btn", text: "Cancel", onclick: closeModal }), applyBtn] }); + } + + // ---------------------------------------------------------------- help + VIEWS.help = async (root) => { + root.innerHTML = ""; + const cards = [ + { icon: "🐱", title: "What is this canvas?", body: "A visual command center for **CatPilot**. Everything you see reads and writes the *same files* CatPilot's CLI, agent and MCP server use β€” so edits here show up everywhere. Nothing is duplicated." }, + { icon: "🧭", title: "Navigation", body: "The sidebar covers every CatPilot domain: **Tasks, Journal, Milestones, Memos, Learning, Growth, Projects**. Each has add buttons, inline edit and detail popups. **Tasks** offers a table and a kanban board with **Overdue Β· To do Β· Blocked Β· Done** columns, a status selector on create/edit, and **All / Today / 7 days** due-date filters." }, + { icon: "πŸ•‘", title: "Timeline", body: "A day-grouped story of your recent activity across every domain, with a **7/14/30 day** switch. Use the Copilot action chips to summarize the period, plan next steps or draft a standup." }, + { icon: "πŸ“Š", title: "Reports", body: "Generate an **executive report** for a period from your tasks and milestones β€” or ask Copilot to write a richer one via the *report-generator* skill. Reports are saved as markdown/HTML in your storage `reports/` folder and open in a reader." }, + { icon: "✨", title: "Ask Copilot", body: "The **✨ button** (top bar) and the action chips send prompts to the Copilot agent in the chat panel. The agent works on the same data, so it can add tasks, write memos, generate reports and more on your behalf." }, + { icon: "✍️", title: "Markdown everywhere", body: "Every notes field is a full **markdown editor**: a formatting toolbar (bold, italic, headings, lists, checkboxes, code, links), a live **Preview** toggle, and **✨ Copilot** to draft the content from a short instruction." }, + { icon: "βš™οΈ", title: "Settings & migration", body: "Change your storage **root** or **partitioning** from Settings. The wizard shows an exact **preview** of what will move, requires your **explicit approval**, then migrates your current data (move/copy/adopt) before switching config." }, + { icon: "πŸŒ—", title: "Themes & data", body: "Light/dark toggle lives in the top bar. Files are written in CatPilot's exact formats (markdown tables, `### date` journal headings, YAML frontmatter notes) so they stay **Obsidian/Dataview friendly**." }, + ]; + const grid = el("div", { class: "grid help-grid" }); + cards.forEach((c) => grid.append(el("div", { class: "card help-card" }, + el("div", { class: "help-ic", text: c.icon }), + el("h4", { text: c.title }), + mdRender(c.body)))); + root.append(grid); + root.append(el("div", { class: "toolbar", style: "margin-top:16px" }, + el("button", { class: "btn btn-primary", html: "✨ Ask Copilot what it can do", onclick: () => askAgent("What can you help me do with my CatPilot data? List the most useful things.") }))); + }; + + // ---------------------------------------------------------------- boot + async function boot() { + applyTheme(); + $("#theme-btn").addEventListener("click", toggleTheme); + $("#refresh-btn").addEventListener("click", async () => { await refreshSummary(); go(state.view); toast("Refreshed", "", "ok"); }); + const agentBtn = $("#agent-btn"); if (agentBtn) agentBtn.addEventListener("click", agentModal); + + const status = await api("/api/status").catch(() => ({ configured: false })); + if (!status.configured) { showOnboarding(); return; } + startApp(); + } + + function showOnboarding() { + $("#onboarding").classList.remove("hidden"); + $("#app").classList.add("hidden"); + $("#setup-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const payload = { root: fd.get("root"), partitioning: fd.get("partitioning") }; + if (!payload.root.trim()) { toast("Path required", "", "err"); return; } + try { + await api("/api/setup", { method: "POST", body: payload }); + toast("Workspace ready!", "CatPilot is set up", "ok"); + $("#onboarding").classList.add("hidden"); + startApp(); + } catch (err) { toast("Setup failed", err.message, "err"); } + }, { once: false }); + } + + async function startApp() { + $("#app").classList.remove("hidden"); + await refreshSummary(); + await pomoSync(); + startPomoLoop(); + const initial = (location.hash || "").replace("#", ""); + go(NAV.some((n) => n.id === initial) ? initial : "dashboard"); + } + + window.addEventListener("hashchange", () => { + const v = (location.hash || "").replace("#", ""); + if (v && v !== state.view && NAV.some((n) => n.id === v)) go(v); + }); + + boot(); +})(); diff --git a/extensions/catpilot-canvas/ui/hero.png b/extensions/catpilot-canvas/ui/hero.png new file mode 100644 index 000000000..a1264704a Binary files /dev/null and b/extensions/catpilot-canvas/ui/hero.png differ diff --git a/extensions/catpilot-canvas/ui/hero.svg b/extensions/catpilot-canvas/ui/hero.svg new file mode 100644 index 000000000..590a00d12 --- /dev/null +++ b/extensions/catpilot-canvas/ui/hero.svg @@ -0,0 +1,15 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="160" height="160" shape-rendering="crispEdges"> + <rect width="16" height="16" fill="#0f172a"/> + <rect x="3" y="2" width="2" height="2" fill="#f59e0b"/> + <rect x="11" y="2" width="2" height="2" fill="#f59e0b"/> + <rect x="2" y="4" width="12" height="8" fill="#f97316"/> + <rect x="4" y="6" width="2" height="2" fill="#ffffff"/> + <rect x="10" y="6" width="2" height="2" fill="#ffffff"/> + <rect x="5" y="7" width="1" height="1" fill="#111827"/> + <rect x="10" y="7" width="1" height="1" fill="#111827"/> + <rect x="7" y="8" width="2" height="1" fill="#111827"/> + <rect x="6" y="9" width="4" height="1" fill="#111827"/> + <rect x="4" y="12" width="8" height="2" fill="#f59e0b"/> + <rect x="12" y="10" width="2" height="1" fill="#f59e0b"/> + <rect x="13" y="11" width="1" height="2" fill="#f59e0b"/> +</svg> diff --git a/extensions/catpilot-canvas/ui/index.html b/extensions/catpilot-canvas/ui/index.html new file mode 100644 index 000000000..5ed3d9d76 --- /dev/null +++ b/extensions/catpilot-canvas/ui/index.html @@ -0,0 +1,77 @@ +<!doctype html> +<html lang="en" data-theme="dark"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>CatPilot + + + + + + + + + + + + + +
+ + + + diff --git a/extensions/catpilot-canvas/ui/styles.css b/extensions/catpilot-canvas/ui/styles.css new file mode 100644 index 000000000..d02ecc041 --- /dev/null +++ b/extensions/catpilot-canvas/ui/styles.css @@ -0,0 +1,612 @@ +/* CatPilot canvas β€” modern design system. + Uses app theme tokens as defaults, layered with a CatPilot accent palette. + Light/dark controlled by [data-theme] on . */ + +:root { + --accent: #7c5cff; + --accent-2: #ff7ac2; + --accent-grad: linear-gradient(135deg, #7c5cff 0%, #b06cff 45%, #ff7ac2 100%); + --radius: 14px; + --radius-sm: 9px; + --shadow-sm: 0 1px 2px rgba(0,0,0,.08), 0 1px 3px rgba(0,0,0,.06); + --shadow: 0 6px 24px rgba(0,0,0,.12); + --shadow-lg: 0 20px 60px rgba(0,0,0,.28); + --font-sans-ui: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif); + --font-mono-ui: var(--font-mono, "SFMono-Regular", Consolas, "Liberation Mono", monospace); + --sq: 260ms cubic-bezier(.2,.7,.2,1); +} + +html[data-theme="dark"] { + --bg: #0e0e14; + --bg-2: #15151f; + --panel: #1a1a26; + --panel-2: #20202e; + --panel-hover: #262636; + --border: #2b2b3d; + --border-soft: #23233200; + --text: #ececf4; + --text-muted: #9a9ab5; + --text-faint: #6d6d86; + --shadow-lg: 0 20px 60px rgba(0,0,0,.55); +} + +html[data-theme="light"] { + --bg: #f4f4fb; + --bg-2: #ececf6; + --panel: #ffffff; + --panel-2: #f7f7fc; + --panel-hover: #f0f0f8; + --border: #e3e3ef; + --border-soft: #eeeef6; + --text: #1a1a2b; + --text-muted: #64647e; + --text-faint: #9797ad; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--font-sans-ui); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +.hidden { display: none !important; } +.muted { color: var(--text-muted); } +.small { font-size: 12px; } +em { font-style: normal; color: var(--text-faint); } + +/* ---------- Layout ---------- */ +.app { display: grid; grid-template-columns: 244px 1fr; height: 100vh; } + +.sidebar { + display: flex; + flex-direction: column; + background: var(--bg-2); + border-right: 1px solid var(--border); + padding: 18px 14px; + gap: 10px; + min-width: 0; +} + +.brand { display: flex; align-items: center; gap: 11px; padding: 4px 6px 12px; } +.brand-logo { + width: 40px; height: 40px; border-radius: 11px; object-fit: cover; + box-shadow: var(--shadow-sm); background: var(--panel); +} +.brand-text { display: flex; flex-direction: column; line-height: 1.15; } +.brand-text strong { font-size: 16px; letter-spacing: .2px; } + +.nav { display: flex; flex-direction: column; gap: 3px; overflow-y: auto; flex: 1; } +.nav-item { + display: flex; align-items: center; gap: 11px; + padding: 9px 11px; border-radius: var(--radius-sm); + color: var(--text-muted); cursor: pointer; border: 1px solid transparent; + transition: background var(--sq), color var(--sq), transform var(--sq); + user-select: none; font-weight: 500; + width: 100%; text-align: left; font-family: inherit; font-size: inherit; + background: transparent; appearance: none; +} +.nav-item:hover { background: var(--panel-hover); color: var(--text); } +.nav-item:focus-visible { outline: 2px solid var(--color-focus-outline, var(--accent)); outline-offset: 2px; } +.nav-item.active { + background: var(--panel); color: var(--text); + border-color: var(--border); + box-shadow: var(--shadow-sm); +} +.nav-item.active .nav-ic { background: var(--accent-grad); -webkit-background-clip: text; background-clip: text; color: transparent; } +.nav-ic { width: 20px; text-align: center; font-size: 15px; } +.nav-badge { + margin-left: auto; font-size: 11px; font-weight: 700; + background: var(--panel-2); color: var(--text-muted); + padding: 1px 7px; border-radius: 999px; min-width: 20px; text-align: center; + border: 1px solid var(--border); +} +.nav-item.active .nav-badge { background: var(--accent); color: #fff; border-color: transparent; } + +.sidebar-foot { margin-top: auto; } +.storage-chip { + font-size: 11px; color: var(--text-faint); + background: var(--panel); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 8px 10px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-family: var(--font-mono-ui); +} + +.main { display: flex; flex-direction: column; min-width: 0; background: var(--bg); } + +.topbar { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 26px; border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--bg) 82%, transparent); + backdrop-filter: blur(8px); + position: sticky; top: 0; z-index: 5; +} +.topbar-left h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: .2px; } +.topbar-right { display: flex; align-items: center; gap: 10px; } + +.icon-btn { + width: 36px; height: 36px; border-radius: 10px; + border: 1px solid var(--border); background: var(--panel); + color: var(--text); cursor: pointer; font-size: 16px; + display: grid; place-items: center; transition: all var(--sq); +} +.icon-btn:hover { background: var(--panel-hover); transform: translateY(-1px); } + +.segmented { display: flex; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 3px; gap: 2px; } +.segmented button { + border: 0; background: transparent; color: var(--text-muted); + padding: 6px 13px; border-radius: 7px; cursor: pointer; font-weight: 600; font-size: 13px; + transition: all var(--sq); +} +.segmented button.active { background: var(--accent-grad); color: #fff; box-shadow: var(--shadow-sm); } + +.content { padding: 24px 26px 60px; overflow-y: auto; flex: 1; } + +/* ---------- Buttons ---------- */ +.btn { + display: inline-flex; align-items: center; gap: 7px; + border: 1px solid var(--border); background: var(--panel); + color: var(--text); padding: 8px 14px; border-radius: 10px; + cursor: pointer; font-weight: 600; font-size: 13px; transition: all var(--sq); + white-space: nowrap; +} +.btn:hover { background: var(--panel-hover); transform: translateY(-1px); } +.btn-primary { background: var(--accent-grad); border-color: transparent; color: #fff; box-shadow: var(--shadow-sm); } +.btn-primary:hover { filter: brightness(1.06); } +.btn-lg { padding: 12px 18px; font-size: 15px; border-radius: 12px; width: 100%; justify-content: center; } +.btn-ghost { background: transparent; border-color: transparent; } +.btn-ghost:hover { background: var(--panel-hover); } +.btn-danger { color: #ff6b7d; } +.btn-sm { padding: 5px 10px; font-size: 12px; } + +/* ---------- Cards & grid ---------- */ +.grid { display: grid; gap: 16px; } +.cards { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); } +.card { + background: var(--panel); border: 1px solid var(--border); + border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow-sm); + transition: transform var(--sq), border-color var(--sq), box-shadow var(--sq); +} +.card.hoverable:hover { transform: translateY(-2px); border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); box-shadow: var(--shadow); } + +.stat { position: relative; overflow: hidden; } +.stat .stat-ic { + position: absolute; right: 12px; top: 12px; font-size: 26px; opacity: .18; +} +.stat .stat-num { font-size: 30px; font-weight: 800; letter-spacing: -.5px; line-height: 1; } +.stat .stat-label { color: var(--text-muted); font-size: 13px; margin-top: 6px; font-weight: 500; } +.stat .stat-sub { font-size: 11px; margin-top: 8px; } +.tone-accent .stat-num { background: var(--accent-grad); -webkit-background-clip: text; background-clip: text; color: transparent; } +.tone-warn .stat-num { color: #ffb020; } +.tone-danger .stat-num { color: #ff6b7d; } +.tone-ok .stat-num { color: #35c88f; } + +.section-title { display: flex; align-items: center; justify-content: space-between; margin: 26px 0 13px; } +.section-title h3 { margin: 0; font-size: 15px; font-weight: 700; } +.section-title .muted { font-weight: 500; } + +/* Dashboard hero */ +.hero { + display: flex; align-items: center; gap: 22px; + background: + radial-gradient(1200px 300px at 90% -40%, color-mix(in srgb, var(--accent-2) 26%, transparent), transparent 60%), + radial-gradient(900px 260px at 5% 130%, color-mix(in srgb, var(--accent) 26%, transparent), transparent 60%), + var(--panel); + border: 1px solid var(--border); border-radius: 20px; padding: 22px 26px; + box-shadow: var(--shadow); overflow: hidden; position: relative; +} +.hero img { width: 92px; height: 92px; border-radius: 20px; box-shadow: var(--shadow); flex: 0 0 auto; } +.hero-txt h1 { margin: 0 0 4px; font-size: 24px; font-weight: 800; letter-spacing: -.3px; } +.hero-txt p { margin: 0; color: var(--text-muted); max-width: 60ch; } +.hero-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap; } + +/* Charts */ +.chart-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; } +.chart-card h4 { margin: 0 0 14px; font-size: 14px; font-weight: 700; } +.legend { display: flex; flex-wrap: wrap; gap: 10px 16px; margin-top: 14px; } +.legend span { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--text-muted); } +.legend i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; } + +.bars { display: flex; align-items: flex-end; gap: 14px; height: 150px; padding-top: 8px; } +.bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 8px; height: 100%; justify-content: flex-end; } +.bar-stack { width: 60%; max-width: 46px; display: flex; flex-direction: column-reverse; border-radius: 7px 7px 3px 3px; overflow: hidden; background: var(--panel-2); min-height: 3px; } +.bar-seg { width: 100%; } +.bar-label { font-size: 11px; color: var(--text-faint); } + +/* Timeline */ +.timeline { display: flex; flex-direction: column; gap: 2px; } +.tl-item { display: flex; gap: 12px; padding: 10px 6px; border-radius: 9px; transition: background var(--sq); } +.tl-item:hover { background: var(--panel-hover); } +.tl-dot { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; font-size: 14px; flex: 0 0 auto; background: var(--panel-2); border: 1px solid var(--border); } +.tl-body { min-width: 0; flex: 1; } +.tl-label { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tl-meta { font-size: 11px; color: var(--text-faint); text-transform: capitalize; } + +/* ---------- Tables ---------- */ +.table-wrap { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm); } +table.tbl { width: 100%; border-collapse: collapse; } +table.tbl th { + text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .6px; + color: var(--text-faint); padding: 12px 14px; border-bottom: 1px solid var(--border); font-weight: 700; +} +table.tbl td { padding: 11px 14px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; } +table.tbl tr:last-child td { border-bottom: 0; } +table.tbl tbody tr { transition: background var(--sq); } +table.tbl tbody tr:hover { background: var(--panel-hover); } +.row-actions { display: flex; gap: 4px; opacity: 0; transition: opacity var(--sq); justify-content: flex-end; } +table.tbl tr:hover .row-actions { opacity: 1; } +table.tbl tr:focus-within .row-actions { opacity: 1; } +.cell-title { font-weight: 600; cursor: pointer; } +.cell-title:hover { color: var(--accent); } +.done-row .cell-title { text-decoration: line-through; color: var(--text-faint); } + +/* Badges / chips */ +.badge { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--border); } +.badge.p0, .badge.high { background: rgba(255,107,125,.14); color: #ff6b7d; border-color: transparent; } +.badge.p1 { background: rgba(255,176,32,.15); color: #ffb020; border-color: transparent; } +.badge.p2, .badge.med { background: rgba(124,92,255,.16); color: #a58bff; border-color: transparent; } +.badge.p3, .badge.low { background: rgba(53,200,143,.15); color: #35c88f; border-color: transparent; } +.badge.st-open { background: rgba(124,92,255,.14); color: #a58bff; border-color: transparent; } +.badge.st-done { background: rgba(53,200,143,.15); color: #35c88f; border-color: transparent; } +.badge.st-planned { background: var(--panel-2); color: var(--text-muted); } +.badge.st-inprogress { background: rgba(90,169,255,.16); color: #5aa9ff; border-color: transparent; } +.badge.st-blocked { background: rgba(255,140,50,.16); color: #ff8c32; border-color: transparent; } +.tag { font-size: 11px; padding: 2px 8px; border-radius: 6px; background: var(--panel-2); color: var(--text-muted); border: 1px solid var(--border); } +.tags { display: flex; flex-wrap: wrap; gap: 5px; } + +/* ---------- Board (kanban) ---------- */ +.board { + display: flex; gap: 16px; align-items: flex-start; + overflow-x: auto; padding: 2px 2px 10px; + scroll-snap-type: x proximity; +} +.board-col { + flex: 1 1 288px; min-width: 288px; max-width: 440px; + display: flex; flex-direction: column; + background: var(--bg-2); border: 1px solid var(--border); + border-radius: var(--radius); scroll-snap-align: start; + max-height: calc(100vh - 214px); + transition: border-color var(--sq), box-shadow var(--sq), background var(--sq); +} +.board-col.drop { + border-color: var(--col-accent, var(--accent)); + box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--col-accent, var(--accent)) 45%, transparent); + background: color-mix(in srgb, var(--col-accent, var(--accent)) 7%, var(--bg-2)); +} +.board-col-head { + display: flex; align-items: center; gap: 8px; + padding: 10px 12px; border-bottom: 1px solid var(--border); + border-radius: var(--radius) var(--radius) 0 0; + background: color-mix(in srgb, var(--col-accent, var(--accent)) 13%, var(--panel)); + position: sticky; top: 0; z-index: 2; +} +.board-col-head .bch-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--col-accent, var(--accent)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--col-accent, var(--accent)) 24%, transparent); flex: none; } +.board-col-head .bch-icon { font-size: 14px; line-height: 1; } +.board-col-head .bch-title { font-weight: 800; font-size: 12px; letter-spacing: .4px; text-transform: uppercase; color: var(--text); } +.board-col-head .bch-count { font-weight: 800; font-size: 11px; color: var(--col-accent, var(--accent)); background: color-mix(in srgb, var(--col-accent, var(--accent)) 18%, transparent); border-radius: 999px; padding: 1px 8px; min-width: 20px; text-align: center; } +.board-col-head .bch-add { + margin-left: auto; width: 26px; height: 26px; flex: none; + display: inline-flex; align-items: center; justify-content: center; + border: 1px solid transparent; border-radius: 8px; cursor: pointer; + background: transparent; color: var(--text-muted); font-size: 15px; line-height: 1; + transition: background var(--sq), color var(--sq); +} +.board-col-head .bch-add:hover { background: color-mix(in srgb, var(--col-accent, var(--accent)) 20%, transparent); color: var(--text); } +.board-col-body { display: flex; flex-direction: column; gap: 9px; padding: 10px; overflow-y: auto; flex: 1 1 auto; min-height: 56px; scrollbar-width: thin; } +.board-col-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; text-align: center; color: var(--text-faint); font-size: 12px; font-weight: 500; padding: 22px 10px; border: 1px dashed var(--border); border-radius: 10px; } +.bce-emoji { font-size: 26px; line-height: 1; opacity: .92; } +.bce-text { color: var(--text-muted); font-weight: 600; } +.board-col-foot { padding: 0 10px 10px; } +.board-col-add { + width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 6px; + padding: 8px; border: 1px dashed var(--border); border-radius: 9px; cursor: pointer; + background: transparent; color: var(--text-muted); font-size: 12.5px; font-weight: 600; + transition: background var(--sq), color var(--sq), border-color var(--sq); +} +.board-col-add:hover { color: var(--text); border-color: color-mix(in srgb, var(--col-accent, var(--accent)) 55%, var(--border)); background: color-mix(in srgb, var(--col-accent, var(--accent)) 8%, transparent); } + +.board-card { + position: relative; background: var(--panel); + border: 1px solid var(--border); border-radius: 11px; + padding: 11px 12px 11px 15px; cursor: grab; box-shadow: var(--shadow-sm); + transition: transform var(--sq), box-shadow var(--sq), border-color var(--sq); + overflow: hidden; +} +.board-card::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--pr-accent, var(--border)); } +.board-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); border-color: color-mix(in srgb, var(--pr-accent, var(--accent)) 45%, var(--border)); } +.board-card:active { cursor: grabbing; } +.board-card.dragging { opacity: .5; transform: rotate(1.5deg); box-shadow: var(--shadow-lg); } +.board-card.is-done { opacity: .68; } +.bc-head { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; } +.bc-id { margin-left: auto; font-size: 11px; font-weight: 600; color: var(--text-faint); font-family: var(--font-mono-ui); } +.bc-title { font-weight: 700; font-size: 13.5px; line-height: 1.35; color: var(--text); cursor: pointer; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } +.bc-title:hover { color: var(--accent); } +.board-card.is-done .bc-title { text-decoration: line-through; color: var(--text-faint); } +.bc-foot { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; margin-top: 10px; padding-top: 9px; border-top: 1px dashed var(--border); } +.bc-due { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 700; color: var(--text); background: color-mix(in srgb, var(--accent) 12%, var(--panel-2)); border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border)); border-radius: 7px; padding: 3px 8px; font-variant-numeric: tabular-nums; } +.bc-due-ico { font-size: 12px; line-height: 1; } +.bc-due-date { letter-spacing: .2px; } +.bc-due-rel { font-weight: 600; opacity: .8; padding-left: 5px; margin-left: 1px; border-left: 1px solid color-mix(in srgb, currentColor 30%, transparent); } +.bc-due.overdue { color: #ff6b7d; background: rgba(255,107,125,.13); border-color: transparent; } +.bc-due.today { color: #ffb020; background: rgba(255,176,32,.15); border-color: transparent; } +.badge.st-overdue { background: rgba(255,107,125,.15); color: #ff6b7d; border-color: transparent; } + +/* ---------- Tasks Β· Calendar view ---------- */ +.cal-toolbar { align-items: center; gap: 10px; flex-wrap: wrap; } +.cal-nav { display: flex; align-items: center; gap: 6px; } +.cal-title { font-weight: 700; font-size: 15px; min-width: 150px; text-align: center; } +.cal-fields { position: relative; } +.cal-fields > summary { list-style: none; cursor: pointer; } +.cal-fields > summary::-webkit-details-marker { display: none; } +.cal-fields-menu { + position: absolute; right: 0; top: calc(100% + 6px); z-index: 20; + background: var(--panel); border: 1px solid var(--border); border-radius: 10px; + box-shadow: var(--shadow-lg); padding: 8px; min-width: 160px; + display: flex; flex-direction: column; gap: 2px; +} +.cal-fields-opt { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 500; padding: 5px 8px; border-radius: 7px; cursor: pointer; } +.cal-fields-opt:hover { background: var(--panel-hover); } +.cal-fields-opt input { width: auto; } +.cal-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; } +.cal-wd { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .4px; color: var(--text-faint); padding: 2px 4px 4px; } +.cal-day { + background: var(--panel); border: 1px solid var(--border); border-radius: 10px; + padding: 6px; display: flex; flex-direction: column; gap: 5px; min-height: 96px; + transition: border-color var(--sq); +} +.cal-month .cal-day { min-height: 104px; } +.cal-week .cal-day { min-height: 340px; } +.cal-day.other { opacity: .45; } +.cal-day.today { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); } +.cal-daynum { font-size: 12px; font-weight: 700; color: var(--text-muted); display: flex; justify-content: flex-end; gap: 4px; } +.cal-day.today .cal-daynum { color: var(--accent); } +.cal-dow-sm { color: var(--text-faint); font-weight: 600; margin-right: auto; } +.cal-day-list { display: flex; flex-direction: column; gap: 4px; overflow-y: auto; } +.cal-chip { + background: var(--panel-2); border: 1px solid var(--border-soft); border-radius: 7px; + padding: 4px 7px; cursor: pointer; font-size: 12px; line-height: 1.25; + border-left-width: 3px; border-left-style: solid; border-left-color: var(--border-soft); + transition: background var(--sq), transform var(--sq); +} +.cal-chip:hover { background: var(--panel-hover); transform: translateX(1px); } +.cal-chip.pr-0 { border-left-color: #ff6b7d; } +.cal-chip.pr-1 { border-left-color: #ffb020; } +.cal-chip.pr-2 { border-left-color: #7c5cff; } +.cal-chip.pr-3 { border-left-color: #35c88f; } +.cal-chip.done .cal-chip-title { text-decoration: line-through; color: var(--text-faint); } +.cal-chip-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cal-chip-meta { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; } +.cal-chip-meta .badge, .cal-chip-meta .tag { font-size: 10px; padding: 1px 6px; } +@media (max-width: 720px) { + .cal-day { min-height: 72px; } + .cal-chip-meta { display: none; } +} + +/* ---------- Note cards ---------- */ +.note-grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); } +.note-card { cursor: pointer; display: flex; flex-direction: column; gap: 8px; } +.note-card h4 { margin: 0; font-size: 15px; } +.note-card .note-foot { margin-top: auto; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.note-card .excerpt { color: var(--text-muted); font-size: 13px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } + +/* Journal */ +.journal-list { display: flex; flex-direction: column; gap: 14px; max-width: 820px; } +.journal-entry { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow-sm); } +.journal-entry .date { font-weight: 700; font-size: 13px; color: var(--accent); margin-bottom: 8px; display: flex; align-items: center; gap: 8px; } +.journal-entry .body { white-space: pre-wrap; } + +/* ---------- Forms ---------- */ +.form { display: flex; flex-direction: column; gap: 14px; } +.form label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 600; } +.form label span em { font-weight: 400; } +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } + +/* pomodoro */ +.pomo-ring { display: flex; justify-content: center; margin-bottom: 8px; } +.pomo-meta { display: flex; gap: 10px; align-items: center; justify-content: center; margin-top: 6px; } + +/* Floating persistent mini-timer dock */ +.pomo-dock { + position: fixed; right: 22px; bottom: 22px; z-index: 45; + display: flex; align-items: center; gap: 12px; + background: var(--panel); border: 1px solid var(--border); + border-radius: 16px; padding: 10px 12px; box-shadow: var(--shadow-lg); + max-width: 320px; +} +.pomo-dock.hidden { display: none; } +.pomo-dock.is-done { border-color: color-mix(in srgb, #4caf7d 55%, var(--border)); animation: pomoPulse 1.6s ease-in-out infinite; } +.pomo-dock-ring { flex: 0 0 auto; cursor: pointer; line-height: 0; opacity: .95; } +.pomo-dock-info { display: flex; flex-direction: column; gap: 3px; cursor: pointer; min-width: 0; } +.pomo-dock-time { font-size: 20px; font-weight: 800; line-height: 1; letter-spacing: .5px; font-variant-numeric: tabular-nums; } +.pomo-dock-label { display: flex; align-items: center; gap: 6px; min-width: 0; } +.pomo-dock-task { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pomo-dock-controls { display: flex; gap: 6px; margin-left: 4px; padding-left: 10px; border-left: 1px solid var(--border-soft); } +.pomo-dock-btn { width: 32px; height: 32px; font-size: 13px; border-radius: 9px; } +.pomo-dock-btn:hover { background: var(--panel-hover); } +.pomo-dock-stop:hover { background: rgba(255,107,107,.16); color: #ff8080; } +.pomo-dock.is-paused { opacity: .92; } +.pomo-dock.is-paused .pomo-dock-time { color: var(--text-muted); } +/* Session-type & paused badges */ +.badge.st-focus { background: rgba(255,107,107,.16); color: #ff8080; border-color: transparent; } +.badge.st-short { background: rgba(53,200,143,.16); color: #35c88f; border-color: transparent; } +.badge.st-long { background: rgba(90,169,255,.16); color: #5aa9ff; border-color: transparent; } +.badge.st-paused { background: rgba(255,176,32,.16); color: #ffb020; border-color: transparent; } +@keyframes pomoPulse { + 0%, 100% { box-shadow: var(--shadow-lg); } + 50% { box-shadow: 0 0 0 3px color-mix(in srgb, #4caf7d 35%, transparent), var(--shadow-lg); } +} +/* Keep toasts stacked clear above the dock's corner */ +.pomo-dock:not(.hidden) ~ .toasts { bottom: 92px; } +.stat-grid { grid-template-columns: repeat(3, 1fr); gap: 14px; } + +input, textarea, select { + font-family: inherit; font-size: 14px; color: var(--text); + background: var(--panel-2); border: 1px solid var(--border); + border-radius: 10px; padding: 10px 12px; width: 100%; transition: border-color var(--sq), box-shadow var(--sq); +} +input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); } +textarea { resize: vertical; min-height: 90px; } +.inline-edit { padding: 6px 8px; border-radius: 7px; font-size: 13px; } + +/* ---------- Modal ---------- */ +.modal-backdrop { + position: fixed; inset: 0; background: rgba(6,6,12,.55); backdrop-filter: blur(3px); + display: grid; place-items: center; z-index: 50; padding: 24px; animation: fade var(--sq); +} +.modal { + background: var(--panel); border: 1px solid var(--border); border-radius: 18px; + box-shadow: var(--shadow-lg); width: min(620px, 100%); max-height: 88vh; overflow: hidden; + display: flex; flex-direction: column; animation: pop var(--sq); +} +.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--border); } +.modal-head h3 { margin: 0; font-size: 17px; } +.modal-body { padding: 20px 22px; overflow-y: auto; } +.modal-foot { padding: 14px 22px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 10px; } +.detail-row { display: grid; grid-template-columns: 130px 1fr; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--border-soft); } +.detail-row:last-child { border-bottom: 0; } +.detail-row .k { color: var(--text-faint); font-size: 12px; text-transform: uppercase; letter-spacing: .5px; padding-top: 2px; } +.markdown-body { white-space: pre-wrap; line-height: 1.6; } + +@keyframes fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes pop { from { opacity: 0; transform: translateY(10px) scale(.98); } to { opacity: 1; transform: none; } } + +/* ---------- Onboarding ---------- */ +.onboarding { position: fixed; inset: 0; display: grid; place-items: center; padding: 24px; + background: radial-gradient(1000px 500px at 50% -10%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 60%), var(--bg); z-index: 40; } +.onboarding-card { background: var(--panel); border: 1px solid var(--border); border-radius: 22px; box-shadow: var(--shadow-lg); padding: 34px; width: min(460px, 100%); text-align: center; } +.onboarding-hero { width: 88px; height: 88px; border-radius: 22px; box-shadow: var(--shadow); margin-bottom: 8px; } +.onboarding h1 { margin: 8px 0 6px; font-size: 24px; } +.onboarding .form { text-align: left; margin-top: 22px; } + +/* ---------- Empty & toasts ---------- */ +.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); } +.empty .big { font-size: 46px; margin-bottom: 12px; opacity: .8; } +.empty h3 { margin: 0 0 6px; color: var(--text); } + +.toasts { position: fixed; bottom: 22px; right: 22px; display: flex; flex-direction: column; gap: 10px; z-index: 60; } +.toast { display: flex; gap: 11px; align-items: flex-start; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 12px 15px; box-shadow: var(--shadow-lg); min-width: 250px; animation: slidein var(--sq); } +.toast .t-ic { width: 22px; height: 22px; border-radius: 7px; display: grid; place-items: center; font-size: 13px; flex: 0 0 auto; background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); } +.toast.ok .t-ic { background: rgba(53,200,143,.16); color: #35c88f; } +.toast.err .t-ic { background: rgba(255,107,125,.16); color: #ff6b7d; } +.toast strong { display: block; font-size: 13px; } +.toast span { font-size: 12px; color: var(--text-muted); } +@keyframes slidein { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: none; } } + +.spinner { width: 34px; height: 34px; border-radius: 50%; border: 3px solid var(--border); border-top-color: var(--accent); animation: spin .7s linear infinite; margin: 60px auto; } +@keyframes spin { to { transform: rotate(360deg); } } + +.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; } +.toolbar .spacer { flex: 1; } +.search { max-width: 260px; } + +@media (max-width: 720px) { + /* Collapse the sidebar into a horizontal, scrollable top bar so every + destination stays reachable on narrow viewports (no hidden nav). */ + .app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } + .sidebar { flex-direction: row; align-items: center; gap: 8px; overflow-x: auto; padding: 8px 10px; } + .sidebar .brand-text, .sidebar .nav-spacer, .sidebar .sidebar-foot { display: none; } + .sidebar .nav { flex-direction: row; gap: 6px; overflow-x: auto; overflow-y: visible; } + .nav-item { width: auto; white-space: nowrap; } + .nav-item .nav-badge { display: none; } +} + +/* ---------- Nav footer separator ---------- */ +.nav-spacer { flex: 1; min-height: 14px; } + +/* ---------- Chips & agent actions ---------- */ +.chip { + display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 999px; + background: var(--panel); border: 1px solid var(--border); color: var(--text); font-size: 12.5px; + cursor: pointer; transition: var(--sq); font-family: inherit; +} +.chip:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); background: var(--panel-hover); transform: translateY(-1px); } +.chip-row { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 12px; } +.agent-bar { + display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; padding: 12px 14px; + border: 1px solid var(--border); border-radius: 14px; + background: linear-gradient(120deg, color-mix(in srgb, var(--accent) 10%, transparent), color-mix(in srgb, var(--accent-2, var(--accent)) 6%, transparent)); +} +.agent-bar-label { font-size: 12px; font-weight: 600; color: var(--text-muted); margin-right: 4px; letter-spacing: .3px; } +.agent-chip { background: color-mix(in srgb, var(--accent) 12%, var(--panel)); border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); } +.agent-icon { color: var(--accent); font-size: 15px; } + +/* ---------- Timeline groups ---------- */ +.tl-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; } +.tl-wrap { display: flex; flex-direction: column; gap: 22px; } +.tl-day { border-left: 2px solid var(--border); padding-left: 16px; position: relative; } +.tl-day-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 6px; } +.tl-day-date { font-weight: 600; font-size: 14px; } +.tl-dot.tl-journal { color: #8ea2ff; } .tl-dot.tl-memo { color: #35c88f; } +.tl-dot.tl-milestone { color: #ffb454; } .tl-dot.tl-report { color: var(--accent); } +.tl-dot.tl-learning { color: #f78fff; } .tl-dot.tl-growth { color: #35c88f; } .tl-dot.tl-project { color: #7cd3ff; } +.tl-meta { white-space: normal; } + +/* ---------- Rendered markdown ---------- */ +.md { line-height: 1.62; word-wrap: break-word; } +.md > :first-child { margin-top: 0; } +.md > :last-child { margin-bottom: 0; } +.md h1, .md h2, .md h3 { margin: 18px 0 8px; line-height: 1.3; } +.md h1 { font-size: 20px; } .md h2 { font-size: 17px; } .md h3 { font-size: 15px; } +.md p { margin: 8px 0; } +.md ul, .md ol { margin: 8px 0; padding-left: 22px; } +.md li { margin: 3px 0; } +.md code { background: var(--panel-2); padding: 1.5px 5px; border-radius: 5px; font-size: 12.5px; font-family: var(--font-mono, monospace); } +.md pre { background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; overflow-x: auto; } +.md pre code { background: none; padding: 0; } +.md blockquote { margin: 8px 0; padding: 4px 14px; border-left: 3px solid var(--accent); color: var(--text-muted); } +.md hr { border: 0; border-top: 1px solid var(--border); margin: 16px 0; } +.md a { color: var(--accent); } +.md-table, table.md-table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 13px; } +.md-table th, .md-table td { border: 1px solid var(--border); padding: 7px 10px; text-align: left; } +.md-table th { background: var(--panel-2); font-weight: 600; } + +/* ---------- Markdown editor ---------- */ +.md-label { display: flex; flex-direction: column; gap: 6px; } +.md-editor { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: var(--panel); } +.md-toolbar { display: flex; align-items: center; gap: 3px; padding: 6px 8px; border-bottom: 1px solid var(--border); background: var(--panel-2); flex-wrap: wrap; } +.md-tool { + min-width: 30px; height: 28px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; + background: none; color: var(--text-muted); cursor: pointer; font-size: 13px; font-family: inherit; transition: var(--sq); +} +.md-tool:hover { background: var(--panel-hover); color: var(--text); border-color: var(--border); } +.md-tool.tt-b { font-weight: 800; } .md-tool.tt-i { font-style: italic; } +.md-gen-btn { color: var(--accent); font-weight: 600; } +.md-gen { display: flex; gap: 8px; padding: 8px; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--accent) 7%, var(--panel)); } +.md-gen-input { flex: 1; padding: 7px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--panel); color: var(--text); font-family: inherit; font-size: 13px; } +.md-surface { position: relative; } +.md-input { display: block; width: 100%; box-sizing: border-box; border: 0; border-radius: 0; resize: vertical; min-height: 120px; background: var(--panel); color: var(--text); padding: 12px 14px; font-family: inherit; font-size: 13.5px; line-height: 1.6; } +.md-input:focus { outline: none; box-shadow: none; } +.md-preview { padding: 12px 14px; min-height: 120px; } +.hidden { display: none !important; } + +/* ---------- Reports ---------- */ +.report-card { display: flex; flex-direction: column; gap: 8px; } +.report-ic { font-size: 26px; } +.report-iframe { width: 100%; height: 62vh; border: 1px solid var(--border); border-radius: 10px; background: #fff; } + +/* ---------- Settings & migration wizard ---------- */ +.settings-card { max-width: 640px; } +.settings-card h4 { margin: 0 0 12px; font-size: 15px; } +.plan-box { margin-top: 14px; padding: 14px 16px; border: 1px dashed var(--border); border-radius: 12px; background: var(--panel-2); } +.plan-head { margin-bottom: 10px; } +.plan-diff { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; } +.plan-diff code { display: inline-block; word-break: break-all; } +.plan-list { display: flex; flex-direction: column; gap: 6px; } +.plan-item { display: flex; align-items: center; gap: 10px; } +.approve { display: flex; align-items: center; gap: 9px; margin-top: 14px; padding: 10px 12px; border-radius: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); cursor: pointer; } +.approve input { width: 16px; height: 16px; accent-color: var(--accent); } +.plan-warn { margin-top: 12px; padding: 10px 12px; border-radius: 10px; border: 1px solid color-mix(in srgb, var(--true-color-red, #d1242f) 40%, var(--border)); background: color-mix(in srgb, var(--true-color-red, #d1242f) 8%, transparent); } +.plan-warn strong { color: var(--true-color-red, #d1242f); } + +/* ---------- Help ---------- */ +.help-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } +.help-card { display: flex; flex-direction: column; gap: 6px; } +.help-ic { font-size: 26px; } +.help-card h4 { margin: 2px 0 2px; font-size: 15px; } +.help-card .md { font-size: 13px; color: var(--text-muted); }