Compare commits
No commits in common. "main" and "Profile" have entirely different histories.
|
|
@ -1,91 +0,0 @@
|
|||
const { spawn } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||
const PORT = 9394;
|
||||
const BASE = "http://localhost:3210";
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
function httpJson(p) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => {
|
||||
let d = ""; res.on("data", (c) => (d += c)); res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
|
||||
});
|
||||
req.on("error", reject); req.end();
|
||||
});
|
||||
}
|
||||
(async () => {
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), "edge-prof-"));
|
||||
const proc = spawn(EDGE, ["--headless=new", "--remote-debugging-port=" + PORT, "--user-data-dir=" + profile, "--no-first-run", "--disable-gpu", "--window-size=1440,900", "about:blank"], { stdio: "ignore" });
|
||||
let targets = null;
|
||||
for (let i = 0; i < 60; i++) { await sleep(500); try { targets = await httpJson("/json/list"); break; } catch {} }
|
||||
const page = targets.find((t) => t.type === "page");
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
let id = 0; const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const myId = ++id; pending.set(myId, { resolve, reject });
|
||||
ws.send(JSON.stringify({ id: myId, method, params }));
|
||||
setTimeout(() => { if (pending.has(myId)) { pending.delete(myId); reject(new Error("timeout " + method)); } }, 20000);
|
||||
});
|
||||
ws.onmessage = (m) => {
|
||||
const msg = JSON.parse(m.data);
|
||||
if (msg.id) { const p = pending.get(msg.id); if (p) { pending.delete(msg.id); msg.error ? p.reject(new Error(JSON.stringify(msg.error))) : p.resolve(msg.result); } return; }
|
||||
if (msg.method === "Runtime.exceptionThrown") console.log("[EXCEPTION]", (msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text ?? "").slice(0, 200));
|
||||
};
|
||||
await send("Runtime.enable"); await send("Page.enable");
|
||||
const js = (expression) => send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }).then((r) => (r && r.result ? r.result.value : undefined));
|
||||
const clickAt = async (x, y) => {
|
||||
const opts = { x, y, button: "left", clickCount: 1 };
|
||||
await send("Input.dispatchMouseEvent", { type: "mouseMoved", ...opts });
|
||||
await send("Input.dispatchMouseEvent", { type: "mousePressed", ...opts });
|
||||
await sleep(80);
|
||||
await send("Input.dispatchMouseEvent", { type: "mouseReleased", ...opts });
|
||||
await sleep(400);
|
||||
};
|
||||
const state = () => js(`(() => ({
|
||||
dialogOpen: !!document.querySelector('[data-slot="dialog-content"]'),
|
||||
toggle: (Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Edit' || b.textContent.trim() === 'Preview') || {}).textContent,
|
||||
noDetails: document.body.innerText.includes('No details yet'),
|
||||
textareas: document.querySelectorAll('.w-md-editor textarea').length,
|
||||
toolbarDisabled: document.querySelectorAll('.w-md-editor-toolbar button[disabled]').length
|
||||
}))()`);
|
||||
|
||||
await send("Emulation.setDeviceMetricsOverride", { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
|
||||
await send("Page.navigate", { url: BASE + "/repro-notes" });
|
||||
await sleep(5000);
|
||||
|
||||
// Flow A: to-do -> Edit dialog -> Edit -> click in box -> type
|
||||
const todoBtn = await js(`(() => { const b = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Todo with no details'); const r = b.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; })()`);
|
||||
await clickAt(todoBtn.x, todoBtn.y);
|
||||
await sleep(1000);
|
||||
const editBtn = await js(`(() => { const b = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Edit'); const r = b.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; })()`);
|
||||
await clickAt(editBtn.x, editBtn.y);
|
||||
await sleep(2500);
|
||||
console.log("1 after Edit click:", JSON.stringify(await state()));
|
||||
// click into the editor box and type
|
||||
const ta = await js(`(() => { const t = document.querySelector('.w-md-editor textarea'); if (!t) return null; t.focus(); const r = t.getBoundingClientRect(); return { x: r.x + 50, y: r.y + 10, w: r.width, h: r.height }; })()`);
|
||||
console.log(" textarea rect:", JSON.stringify(ta));
|
||||
if (ta) {
|
||||
await clickAt(ta.x, ta.y);
|
||||
await send("Input.insertText", { text: "hello from the fix" });
|
||||
await sleep(500);
|
||||
}
|
||||
const v1 = await state();
|
||||
const typed = await js(`(() => { const t = document.querySelector('.w-md-editor textarea'); return t ? t.value : null; })()`);
|
||||
console.log("2 after click+type:", JSON.stringify(v1), "textarea value:", JSON.stringify(typed));
|
||||
// toggle to preview and back
|
||||
const toggle = await js(`(() => { const b = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Edit' || b.textContent.trim() === 'Preview'); const r = b.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2, text: b.textContent.trim() }; })()`);
|
||||
await clickAt(toggle.x, toggle.y);
|
||||
await sleep(600);
|
||||
console.log("3 after toggle (preview):", JSON.stringify(await state()));
|
||||
const toggle2 = await js(`(() => { const b = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Edit' || b.textContent.trim() === 'Preview'); const r = b.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2, text: b.textContent.trim() }; })()`);
|
||||
await clickAt(toggle2.x, toggle2.y);
|
||||
await sleep(2500);
|
||||
const v2 = await state();
|
||||
const typed2 = await js(`(() => { const t = document.querySelector('.w-md-editor textarea'); return t ? t.value : null; })()`);
|
||||
console.log("4 after toggle back (edit):", JSON.stringify(v2), "textarea value:", JSON.stringify(typed2));
|
||||
|
||||
ws.close(); proc.kill(); process.exit(0);
|
||||
})().catch((e) => { console.error("FATAL", e); process.exit(1); });
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
/* Same sync check as verify-sync.cjs, but on a Project board (verifies the
|
||||
projectId branch of getBoardSnapshot + the provider's scoping). */
|
||||
const { spawn } = require("node:child_process");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
|
||||
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||
const PORT = 9342;
|
||||
const BASE = "http://localhost:3210";
|
||||
|
||||
function httpJson(p) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => {
|
||||
let d = ""; res.on("data", (c) => (d += c));
|
||||
res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
|
||||
});
|
||||
req.on("error", reject); req.end();
|
||||
});
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
function sql(statement) {
|
||||
execFileSync("docker", ["exec", "organize-polltest-db", "psql", "-U", "organize", "-d", "organize", "-c", statement]);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const devUserId = execFileSync("docker", ["exec", "organize-polltest-db", "psql", "-U", "organize", "-d", "organize", "-tAc", "select id from \"User\" where email='dev@example.com'"]).toString().trim();
|
||||
|
||||
// Test project with one open to-do (clean slate regardless of prior runs).
|
||||
sql(`delete from "Todo" where id='ptest-todo'; delete from "Group" where id='ptest-grp'; delete from "Category" where id='ptest-cat'; delete from "Project" where id='ptest-proj';`);
|
||||
sql(`insert into "Project" (id, title, "ownerId", "createdAt", "updatedAt") values ('ptest-proj', 'Sync Test Project', '${devUserId}', now(), now());`);
|
||||
sql(`insert into "Category" (id, name, "order", "projectId", "createdAt", "updatedAt") values ('ptest-cat', 'Lane A', 0, 'ptest-proj', now(), now());`);
|
||||
sql(`insert into "Group" (id, title, color, "order", "noteContent", "categoryId", "createdAt", "updatedAt") values ('ptest-grp', 'P group', 'ocean', 0, '', 'ptest-cat', now(), now());`);
|
||||
sql(`insert into "Todo" (id, title, "order", "groupId", "createdAt", "updatedAt") values ('ptest-todo', 'Project milk', 0, 'ptest-grp', now(), now());`);
|
||||
|
||||
const profile = fs.mkdtempSync("/tmp/edge-psync-");
|
||||
const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" });
|
||||
let targets = null;
|
||||
for (let i = 0; i < 40; i++) { await sleep(500); try { targets = await httpJson("/json/list"); break; } catch {} }
|
||||
if (!targets) { console.error("no targets"); process.exit(1); }
|
||||
const page = targets.find((t) => t.type === "page");
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((resolve) => {
|
||||
const myId = ++id; pending.set(myId, resolve);
|
||||
ws.send(JSON.stringify({ id: myId, method, params }));
|
||||
});
|
||||
const errors = [];
|
||||
ws.onmessage = (m) => {
|
||||
const msg = JSON.parse(m.data);
|
||||
if (msg.id) { const r = pending.get(msg.id); if (r) { pending.delete(msg.id); r(msg.result ?? msg.error); } return; }
|
||||
if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") {
|
||||
const txt = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" ");
|
||||
if (!/hydrated|React DevTools|HMR|Download the React DevTools/.test(txt)) errors.push(txt.slice(0, 200));
|
||||
}
|
||||
};
|
||||
const evalJs = (expression) =>
|
||||
send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true })
|
||||
.then((r) => (r?.result ? r.result.value : r));
|
||||
await send("Runtime.enable");
|
||||
|
||||
await send("Page.enable");
|
||||
await send("Page.navigate", { url: `${BASE}/login` });
|
||||
for (let i = 0; i < 60; i++) { await sleep(500); if (await evalJs("document.readyState") === "complete") break; }
|
||||
const login = await evalJs(`(async () => {
|
||||
const c = await (await fetch('/api/auth/csrf')).json();
|
||||
const r = await fetch('/api/auth/callback/credentials', { method: 'POST', redirect: 'manual',
|
||||
body: new URLSearchParams({ csrfToken: c.csrfToken, email: 'dev@example.com', password: 'password123' }) });
|
||||
return r.status; })()`);
|
||||
console.log("login status:", login);
|
||||
|
||||
await send("Page.navigate", { url: `${BASE}/projects/ptest-proj` });
|
||||
let checkboxFound = false;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await sleep(500);
|
||||
checkboxFound = await evalJs(`!!Array.from(document.querySelectorAll('[data-todo-checkbox]'))
|
||||
.some((el) => (el.getAttribute('aria-label') || '').includes('Project milk'))`);
|
||||
if (checkboxFound) break;
|
||||
}
|
||||
if (!checkboxFound) { console.error("FAIL: project board checkbox not found"); process.exit(1); }
|
||||
console.log("Project milk before:", await evalJs(`Array.from(document.querySelectorAll('[data-todo-checkbox]'))
|
||||
.find((el) => (el.getAttribute('aria-label') || '').includes('Project milk')).getAttribute('aria-checked')`));
|
||||
|
||||
const t0 = Date.now();
|
||||
console.log("simulating phone: completing the project's to-do in the DB...");
|
||||
sql(`update "Todo" set "completed"=true, "completedAt"=now() where id='ptest-todo';`);
|
||||
let done = false;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await sleep(1000);
|
||||
const state = await evalJs(`(() => {
|
||||
const el = Array.from(document.querySelectorAll('[data-todo-checkbox]'))
|
||||
.find((el) => (el.getAttribute('aria-label') || '').includes('Project milk'));
|
||||
if (el) return el.getAttribute('aria-checked');
|
||||
return document.querySelector('[aria-label="Expand P group"]') ? "collapsed-done" : "missing";
|
||||
})()`);
|
||||
if (state === "true" || state === "collapsed-done") { done = true; break; }
|
||||
}
|
||||
const elapsed = Math.round((Date.now() - t0) / 1000);
|
||||
console.log(`project to-do after (${elapsed}s): ${done ? "synced" : "NOT synced"}`);
|
||||
|
||||
sql(`delete from "Todo" where id='ptest-todo'; delete from "Group" where id='ptest-grp'; delete from "Category" where id='ptest-cat'; delete from "Project" where id='ptest-proj';`);
|
||||
console.log("console errors:", errors.length ? errors : "none");
|
||||
const ok = done && errors.length === 0;
|
||||
console.log(ok ? "ALL PASS" : "FAILURES PRESENT");
|
||||
ws.close(); proc.kill();
|
||||
process.exit(ok ? 0 : 1);
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
/* Verifies cross-device board sync: a "second device" (direct DB write)
|
||||
changes a to-do, and the open desktop tab must pick it up via the 30s
|
||||
poll -- and immediately when the tab regains visibility. */
|
||||
const { spawn } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
|
||||
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||
const PORT = 9341;
|
||||
const BASE = "http://localhost:3210";
|
||||
|
||||
function httpJson(p) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => {
|
||||
let d = ""; res.on("data", (c) => (d += c));
|
||||
res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
|
||||
});
|
||||
req.on("error", reject); req.end();
|
||||
});
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function dbTodo(title, completed) {
|
||||
// Simulates the phone: a change that lands in the DB, not this tab.
|
||||
return new Promise((resolve) => {
|
||||
const { execFile } = require("node:child_process");
|
||||
const sql = `UPDATE "Todo" SET "completed"=${completed}, "completedAt"=${completed ? "now()" : "NULL"} WHERE title='${title}' AND "completed"=${!completed};`;
|
||||
execFile("docker", ["exec", "organize-polltest-db", "psql", "-U", "organize", "-d", "organize", "-c", sql], (err, out, errOut) => {
|
||||
resolve({ ok: !err, out, errOut });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const profile = fs.mkdtempSync("/tmp/edge-sync-");
|
||||
const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" });
|
||||
let targets = null;
|
||||
for (let i = 0; i < 40; i++) { await sleep(500); try { targets = await httpJson("/json/list"); break; } catch {} }
|
||||
if (!targets) { console.error("no targets"); process.exit(1); }
|
||||
const page = targets.find((t) => t.type === "page");
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((resolve) => {
|
||||
const myId = ++id; pending.set(myId, resolve);
|
||||
ws.send(JSON.stringify({ id: myId, method, params }));
|
||||
});
|
||||
const consoleErrors = [];
|
||||
ws.onmessage = (m) => {
|
||||
const msg = JSON.parse(m.data);
|
||||
if (msg.id) { const r = pending.get(msg.id); if (r) { pending.delete(msg.id); r(msg.result ?? msg.error); } return; }
|
||||
if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") {
|
||||
const txt = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" ");
|
||||
if (!/hydrated|React DevTools|HMR|Download the React DevTools/.test(txt)) consoleErrors.push(txt.slice(0, 200));
|
||||
}
|
||||
};
|
||||
|
||||
const evalJs = (expression) =>
|
||||
send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true })
|
||||
.then((r) => (r?.result ? r.result.value : r));
|
||||
const goto = async (url) => {
|
||||
await send("Page.enable");
|
||||
await send("Page.navigate", { url });
|
||||
// Wait for load to settle.
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await sleep(500);
|
||||
const s = await evalJs("document.readyState");
|
||||
if (s === "complete") return;
|
||||
}
|
||||
throw new Error("page did not load: " + url);
|
||||
};
|
||||
const checkboxState = (title) =>
|
||||
evalJs(`(() => {
|
||||
const el = Array.from(document.querySelectorAll('[data-todo-checkbox]'))
|
||||
.find((el) => (el.getAttribute('aria-label') || '').includes(${JSON.stringify(title)}));
|
||||
return el ? el.getAttribute('aria-checked') : "missing";
|
||||
})()`);
|
||||
|
||||
await send("Runtime.enable");
|
||||
|
||||
// --- Login ---
|
||||
await goto(`${BASE}/login`);
|
||||
const login = await evalJs(`(async () => {
|
||||
const c = await (await fetch('/api/auth/csrf')).json();
|
||||
const r = await fetch('/api/auth/callback/credentials', {
|
||||
method: 'POST', redirect: 'manual',
|
||||
body: new URLSearchParams({ csrfToken: c.csrfToken, email: 'dev@example.com', password: 'password123' }),
|
||||
});
|
||||
return r.status;
|
||||
})()`);
|
||||
console.log("login status:", login);
|
||||
|
||||
// --- Reset test data before the board loads (idempotent) ---
|
||||
await dbTodo("Milk", false);
|
||||
await dbTodo("Eggs", false);
|
||||
|
||||
// --- Open the Home board ---
|
||||
await goto(`${BASE}/`);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await sleep(500);
|
||||
if (await evalJs(`!!document.querySelector('[data-todo-checkbox]')`)) break;
|
||||
}
|
||||
console.log("Milk before:", await checkboxState('Milk'));
|
||||
if ((await checkboxState("Milk")) === "missing") { console.error("FAIL: Milk checkbox not found"); process.exit(1); }
|
||||
|
||||
// --- Test 1: 30s poll picks up a remote change ---
|
||||
const t0 = Date.now();
|
||||
console.log("simulating phone: checking off 'Milk' in the DB...");
|
||||
await dbTodo("Milk", true);
|
||||
let saw = "missing";
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await sleep(1000);
|
||||
saw = await checkboxState("Milk");
|
||||
if (saw === "true") break;
|
||||
}
|
||||
const elapsed = Math.round((Date.now() - t0) / 1000);
|
||||
console.log(`Milk after (${elapsed}s):`, saw);
|
||||
const test1 = saw === "true" && elapsed <= 45;
|
||||
console.log(test1 ? "PASS: 30s poll applied the remote change" : "FAIL: poll did not apply the remote change");
|
||||
|
||||
// --- Test 2: hidden tab skips, visibilitychange catches up ---
|
||||
await evalJs(`Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' })`);
|
||||
// Let a poll tick fire while "hidden" (interval is 30s; wait 32s).
|
||||
await sleep(32000);
|
||||
console.log("simulating phone: checking off 'Eggs' in the DB (tab hidden)...");
|
||||
await dbTodo("Eggs", true);
|
||||
await sleep(2000);
|
||||
const whileHidden = await checkboxState("Eggs");
|
||||
console.log("Eggs while still hidden:", whileHidden);
|
||||
await evalJs(`Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' }); document.dispatchEvent(new Event('visibilitychange'))`);
|
||||
// When the sync lands, 'Groceries' becomes fully completed and the card
|
||||
// collapses (its checkboxes disappear from the DOM, replaced by an
|
||||
// "Expand Groceries" disclosure) -- that is exactly how a fully-checked
|
||||
// group renders, so assert the collapsed card rather than the checkbox.
|
||||
let settled = false;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await sleep(1000);
|
||||
const state = await checkboxState("Eggs");
|
||||
const collapsed = await evalJs(`!!document.querySelector('[aria-label="Expand Groceries"]')`);
|
||||
if (state === "missing" && collapsed) { settled = true; break; }
|
||||
if (state === "true") { settled = true; break; }
|
||||
}
|
||||
const test2 = settled && whileHidden === "false";
|
||||
console.log(`Eggs group after tab shown: ${settled ? "synced (card collapsed as fully-complete)" : "NOT synced"}`);
|
||||
console.log(test2 ? "PASS: visibilitychange triggered immediate catch-up" : "FAIL: visibility catch-up");
|
||||
|
||||
console.log("console errors:", consoleErrors.length ? consoleErrors : "none");
|
||||
const ok = test1 && test2 && consoleErrors.length === 0;
|
||||
console.log(ok ? "ALL PASS" : "FAILURES PRESENT");
|
||||
ws.close(); proc.kill();
|
||||
process.exit(ok ? 0 : 1);
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
|
|
@ -2268,41 +2268,10 @@ html.theme-notebook [data-group-card] .line-through::after {
|
|||
the app's type scale so notes blend into dialogs instead of looking
|
||||
pasted in.
|
||||
--------------------------------------------------------------------------- */
|
||||
/* Prefixed with `html` so this out-specificity the library's own
|
||||
`.wmde-markdown` rules regardless of stylesheet order -- its CSS ships in
|
||||
a lazy chunk that injects after globals.css, and at equal specificity the
|
||||
later sheet would win the cascade. `html.theme-cyberpunk .wmde-markdown`
|
||||
(0,2,1) still overrides this (0,1,1) where the theme wants its own look. */
|
||||
html .wmde-markdown {
|
||||
.wmde-markdown {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: -0.006em;
|
||||
/* The library's GitHub-style palette paints a hard canvas background onto
|
||||
the preview root; make it transparent so styled markdown sits flush with
|
||||
whatever card/box it is rendered inside. */
|
||||
background-color: transparent;
|
||||
color: var(--foreground);
|
||||
}
|
||||
/* Tailwind v4's preflight strips list markers ("Make lists unstyled by
|
||||
default": `ol, ul, menu { list-style: none; }`), and the library's stylesheet
|
||||
only sets list-style-type for *nested* list levels -- top-level lists fall
|
||||
back to the (now-removed) browser default, so their bullets disappear.
|
||||
Re-declare the markers (classic disc/circle/square nesting). Unlayered CSS
|
||||
beats the base preflight regardless of the lazy chunk's injection order, and
|
||||
`.no-list` (task lists) still wins at higher specificity. */
|
||||
html .wmde-markdown ul {
|
||||
list-style: disc outside;
|
||||
}
|
||||
html .wmde-markdown ol {
|
||||
list-style: decimal outside;
|
||||
}
|
||||
html .wmde-markdown ul ul,
|
||||
html .wmde-markdown ol ul {
|
||||
list-style: circle outside;
|
||||
}
|
||||
html .wmde-markdown ul ul ul,
|
||||
html .wmde-markdown ol ul ul {
|
||||
list-style: square outside;
|
||||
}
|
||||
.w-md-editor {
|
||||
--md-editor-font-family: var(--font-sans);
|
||||
|
|
|
|||
|
|
@ -10,10 +10,16 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||
import { ColorSwatchPicker } from "@/components/board/color-swatch-picker";
|
||||
import { useBoard } from "@/components/board/board-context";
|
||||
import { useBoardView } from "@/components/board/board-view-provider";
|
||||
import { NEW_GROUP_HOLD_MS, useHoldOnComplete } from "@/components/hold-on-complete";
|
||||
import { useHoldOnComplete } from "@/components/hold-on-complete";
|
||||
import { DEFAULT_GROUP_COLOR_KEY, type GroupColorKey } from "@/lib/colors";
|
||||
|
||||
const TITLE_MAX = 20;
|
||||
// Compact hides any group with nothing open in it -- without a hold, a
|
||||
// brand-new (necessarily empty) group would never appear at all. Longer
|
||||
// than the usual 6s grace period since there's no accidental click to
|
||||
// forgive here; this is purely "give it a moment to be noticed / add a
|
||||
// to-do to it before it disappears".
|
||||
const NEW_GROUP_HOLD_MS = 15_000;
|
||||
|
||||
export function AddGroupPopover({ categoryId }: { categoryId: string }) {
|
||||
const { addGroup } = useBoard();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useEffect, useRef } from "react";
|
||||
import { createContext, useContext, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { CategoryDTO, GroupDTO, TodoDTO } from "@/types/board";
|
||||
|
|
@ -25,13 +25,6 @@ import {
|
|||
deleteTodo as deleteTodoAction,
|
||||
} from "@/lib/actions/todos";
|
||||
import { updateGroupNote } from "@/lib/actions/notes";
|
||||
import { getBoardSnapshot } from "@/lib/actions/board";
|
||||
|
||||
// How often a visible tab checks for changes made on another device (see
|
||||
// the sync effect in BoardProvider). Deliberately not real time: a phone
|
||||
// tap lands here within a tick at most -- and immediately if the tab only
|
||||
// just came to the foreground.
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
interface BoardContextValue {
|
||||
categories: CategoryDTO[];
|
||||
|
|
@ -82,10 +75,6 @@ interface BoardContextValue {
|
|||
completed: boolean
|
||||
) => Promise<void>;
|
||||
removeTodo: (todoId: string, groupId: string, categoryId: string) => Promise<void>;
|
||||
// True while a dnd-kit drag is live -- a state replacement mid-drag would
|
||||
// swap the SortableContext items out from under it, so remote sync
|
||||
// defers until the drag ends.
|
||||
suspendRemoteSync: (suspended: boolean) => void;
|
||||
}
|
||||
|
||||
const BoardContext = createContext<BoardContextValue | null>(null);
|
||||
|
|
@ -107,94 +96,6 @@ export function BoardProvider({
|
|||
}) {
|
||||
const [categories, setCategories] = useState(initialCategories);
|
||||
|
||||
// -- Cross-device sync ---------------------------------------------------
|
||||
// The board is seeded once from the server and otherwise only changed by
|
||||
// this tab's own optimistic mutations, so edits made on a second device
|
||||
// (checking off a to-do on a phone, renaming a lane, ...) never reach
|
||||
// this tab on their own. The poll below closes that gap: fetch a fresh
|
||||
// snapshot and, if it differs from local state and no local write is
|
||||
// pending or just landed, apply it. The refs are the guards that keep a
|
||||
// poll from ever clobbering local work.
|
||||
// Always mirrors `categories` for the diff below (updated by the effect
|
||||
// under it rather than every setCategories call site).
|
||||
const categoriesRef = useRef(categories);
|
||||
// Local mutations in flight (optimistic state ahead of the server).
|
||||
const inflightRef = useRef(0);
|
||||
// True while a drag is live (set from Board's drag start/end).
|
||||
const remoteSyncSuspendedRef = useRef(false);
|
||||
// A poll is already running.
|
||||
const pollingRef = useRef(false);
|
||||
// When the most recent local mutation settled (success or rollback).
|
||||
const lastLocalWriteAtRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
categoriesRef.current = categories;
|
||||
}, [categories]);
|
||||
|
||||
// Marks a server action as "local work in progress" for the poll's
|
||||
// duration, and stamps when it settled. Every mutation below goes
|
||||
// through this so the sync effect can tell "state we are about to lose"
|
||||
// from "state we will happily overwrite with the server's copy".
|
||||
const track = useCallback(
|
||||
<T,>(promise: Promise<T>): Promise<T> => {
|
||||
inflightRef.current += 1;
|
||||
return promise.finally(() => {
|
||||
inflightRef.current -= 1;
|
||||
lastLocalWriteAtRef.current = Date.now();
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const suspendRemoteSync = useCallback((suspended: boolean) => {
|
||||
remoteSyncSuspendedRef.current = suspended;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
async function refreshBoard() {
|
||||
if (disposed || pollingRef.current) return;
|
||||
if (remoteSyncSuspendedRef.current || inflightRef.current > 0) return;
|
||||
// No point fetching while the tab is hidden; the visibilitychange
|
||||
// handler below catches up the moment it is shown again.
|
||||
if (document.visibilityState !== "visible") return;
|
||||
pollingRef.current = true;
|
||||
try {
|
||||
const board = await getBoardSnapshot(projectId ?? null);
|
||||
if (disposed) return;
|
||||
// Re-check the guards at apply time, not just at start: a mutation
|
||||
// (or drag) that began while the fetch was in flight makes this
|
||||
// snapshot stale relative to local state. A write that settled in
|
||||
// the last couple of seconds may post-date the snapshot's read on
|
||||
// the server too, so defer it -- the next tick picks everything up.
|
||||
if (remoteSyncSuspendedRef.current || inflightRef.current > 0) return;
|
||||
if (Date.now() - lastLocalWriteAtRef.current < 2000) return;
|
||||
// The common case is "nothing changed" -- skip the setState so the
|
||||
// whole board doesn't re-render every tick.
|
||||
if (JSON.stringify(board) === JSON.stringify(categoriesRef.current)) return;
|
||||
setCategories(board);
|
||||
} catch {
|
||||
// Offline or a server hiccup -- the next tick retries. (If the
|
||||
// session itself is gone, the page's own navigation handles it.)
|
||||
} finally {
|
||||
pollingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "visible") void refreshBoard();
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
const timer = window.setInterval(() => void refreshBoard(), POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
disposed = true;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const updateCategory = useCallback(
|
||||
(categoryId: string, updater: (c: CategoryDTO) => CategoryDTO) => {
|
||||
setCategories((prev) => prev.map((c) => (c.id === categoryId ? updater(c) : c)));
|
||||
|
|
@ -215,13 +116,13 @@ export function BoardProvider({
|
|||
const addCategory = useCallback(
|
||||
async (name: string) => {
|
||||
try {
|
||||
const category = await track(createCategory(name, projectId));
|
||||
const category = await createCategory(name, projectId);
|
||||
setCategories((prev) => [...prev, category]);
|
||||
} catch {
|
||||
toast.error("Couldn't create category. Try again.");
|
||||
}
|
||||
},
|
||||
[projectId, track]
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const removeCategory = useCallback(async (categoryId: string) => {
|
||||
|
|
@ -231,7 +132,7 @@ export function BoardProvider({
|
|||
return prev.filter((c) => c.id !== categoryId);
|
||||
});
|
||||
try {
|
||||
const result = await track(deleteCategoryAction(categoryId));
|
||||
const result = await deleteCategoryAction(categoryId);
|
||||
if (result?.error) {
|
||||
setCategories(prevState);
|
||||
toast.error(result.error);
|
||||
|
|
@ -240,7 +141,7 @@ export function BoardProvider({
|
|||
setCategories(prevState);
|
||||
toast.error("Couldn't delete category. Try again.");
|
||||
}
|
||||
}, [track]);
|
||||
}, []);
|
||||
|
||||
const reorderLanes = useCallback(
|
||||
async (orderedIds: string[]) => {
|
||||
|
|
@ -251,36 +152,36 @@ export function BoardProvider({
|
|||
return orderedIds.map((id, order) => ({ ...byId.get(id)!, order }));
|
||||
});
|
||||
try {
|
||||
await track(reorderCategories(orderedIds, projectId));
|
||||
await reorderCategories(orderedIds, projectId);
|
||||
} catch {
|
||||
setCategories(prevState);
|
||||
toast.error("Couldn't reorder categories. Try again.");
|
||||
}
|
||||
},
|
||||
[projectId, track]
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const addGroup = useCallback(async (categoryId: string, title: string, color: string) => {
|
||||
try {
|
||||
const group = await track(createGroup(categoryId, title, color));
|
||||
const group = await createGroup(categoryId, title, color);
|
||||
updateCategory(categoryId, (c) => ({ ...c, groups: [...c.groups, group] }));
|
||||
return group;
|
||||
} catch {
|
||||
toast.error("Couldn't create group. Try again.");
|
||||
return undefined;
|
||||
}
|
||||
}, [updateCategory, track]);
|
||||
}, [updateCategory]);
|
||||
|
||||
const editGroup = useCallback(
|
||||
async (groupId: string, categoryId: string, data: { title?: string; color?: string }) => {
|
||||
try {
|
||||
await track(updateGroupAction(groupId, data));
|
||||
await updateGroupAction(groupId, data);
|
||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, ...data }));
|
||||
} catch {
|
||||
toast.error("Couldn't update group. Try again.");
|
||||
}
|
||||
},
|
||||
[updateGroupInState, track]
|
||||
[updateGroupInState]
|
||||
);
|
||||
|
||||
const removeGroup = useCallback(async (groupId: string, categoryId: string) => {
|
||||
|
|
@ -292,12 +193,12 @@ export function BoardProvider({
|
|||
);
|
||||
});
|
||||
try {
|
||||
await track(deleteGroupAction(groupId));
|
||||
await deleteGroupAction(groupId);
|
||||
} catch {
|
||||
setCategories(prevState);
|
||||
toast.error("Couldn't delete group. Try again.");
|
||||
}
|
||||
}, [track]);
|
||||
}, []);
|
||||
|
||||
const archiveGroup = useCallback(async (groupId: string, categoryId: string) => {
|
||||
let prevState: CategoryDTO[] = [];
|
||||
|
|
@ -308,12 +209,12 @@ export function BoardProvider({
|
|||
);
|
||||
});
|
||||
try {
|
||||
await track(archiveGroupAction(groupId));
|
||||
await archiveGroupAction(groupId);
|
||||
} catch {
|
||||
setCategories(prevState);
|
||||
toast.error("Couldn't archive group. Try again.");
|
||||
}
|
||||
}, [track]);
|
||||
}, []);
|
||||
|
||||
const reorderGroups = useCallback(async (categoryId: string, orderedIds: string[]) => {
|
||||
let prevState: CategoryDTO[] = [];
|
||||
|
|
@ -326,12 +227,12 @@ export function BoardProvider({
|
|||
});
|
||||
});
|
||||
try {
|
||||
await track(reorderGroupsInCategory(categoryId, orderedIds));
|
||||
await reorderGroupsInCategory(categoryId, orderedIds);
|
||||
} catch {
|
||||
setCategories(prevState);
|
||||
toast.error("Couldn't reorder groups. Try again.");
|
||||
}
|
||||
}, [track]);
|
||||
}, []);
|
||||
|
||||
const moveGroup = useCallback(
|
||||
async (
|
||||
|
|
@ -372,19 +273,19 @@ export function BoardProvider({
|
|||
});
|
||||
});
|
||||
try {
|
||||
await track(moveGroupToCategory(groupId, toCategoryId, orderedTarget, orderedSource));
|
||||
await moveGroupToCategory(groupId, toCategoryId, orderedTarget, orderedSource);
|
||||
} catch {
|
||||
setCategories(prevState);
|
||||
toast.error("Couldn't move group. Try again.");
|
||||
}
|
||||
},
|
||||
[track]
|
||||
[]
|
||||
);
|
||||
|
||||
const saveNote = useCallback(
|
||||
async (groupId: string, categoryId: string, noteContent: string) => {
|
||||
try {
|
||||
await track(updateGroupNote(groupId, noteContent));
|
||||
await updateGroupNote(groupId, noteContent);
|
||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, noteContent }));
|
||||
return true;
|
||||
} catch {
|
||||
|
|
@ -392,13 +293,13 @@ export function BoardProvider({
|
|||
return false;
|
||||
}
|
||||
},
|
||||
[updateGroupInState, track]
|
||||
[updateGroupInState]
|
||||
);
|
||||
|
||||
const addTodo = useCallback(
|
||||
async (groupId: string, categoryId: string, title: string, details?: string) => {
|
||||
try {
|
||||
const todo = await track(createTodo(groupId, title, details));
|
||||
const todo = await createTodo(groupId, title, details);
|
||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, todo] }));
|
||||
return true;
|
||||
} catch {
|
||||
|
|
@ -406,13 +307,13 @@ export function BoardProvider({
|
|||
return false;
|
||||
}
|
||||
},
|
||||
[updateGroupInState, track]
|
||||
[updateGroupInState]
|
||||
);
|
||||
|
||||
const addTodos = useCallback(
|
||||
async (groupId: string, categoryId: string, todos: { title: string; details?: string }[]) => {
|
||||
try {
|
||||
const created = await track(createTodos(groupId, todos));
|
||||
const created = await createTodos(groupId, todos);
|
||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, ...created] }));
|
||||
return true;
|
||||
} catch {
|
||||
|
|
@ -420,7 +321,7 @@ export function BoardProvider({
|
|||
return false;
|
||||
}
|
||||
},
|
||||
[updateGroupInState, track]
|
||||
[updateGroupInState]
|
||||
);
|
||||
|
||||
const editTodo = useCallback(
|
||||
|
|
@ -431,7 +332,7 @@ export function BoardProvider({
|
|||
data: { title?: string; details?: string | null }
|
||||
) => {
|
||||
try {
|
||||
await track(updateTodoAction(todoId, data));
|
||||
await updateTodoAction(todoId, data);
|
||||
// Approximates the server's own `updatedAt` (set by the same write,
|
||||
// a moment later) closely enough for display purposes, without
|
||||
// waiting on a round trip just to read it back.
|
||||
|
|
@ -446,7 +347,7 @@ export function BoardProvider({
|
|||
return false;
|
||||
}
|
||||
},
|
||||
[updateGroupInState, track]
|
||||
[updateGroupInState]
|
||||
);
|
||||
|
||||
const toggleTodoDone = useCallback(
|
||||
|
|
@ -469,13 +370,13 @@ export function BoardProvider({
|
|||
}),
|
||||
}));
|
||||
try {
|
||||
await track(toggleTodoAction(todoId, completed));
|
||||
await toggleTodoAction(todoId, completed);
|
||||
} catch {
|
||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
|
||||
toast.error("Couldn't update to-do. Try again.");
|
||||
}
|
||||
},
|
||||
[updateCategory, updateGroupInState, track]
|
||||
[updateCategory, updateGroupInState]
|
||||
);
|
||||
|
||||
const removeTodo = useCallback(
|
||||
|
|
@ -490,13 +391,13 @@ export function BoardProvider({
|
|||
}),
|
||||
}));
|
||||
try {
|
||||
await track(deleteTodoAction(todoId));
|
||||
await deleteTodoAction(todoId);
|
||||
} catch {
|
||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
|
||||
toast.error("Couldn't delete to-do. Try again.");
|
||||
}
|
||||
},
|
||||
[updateCategory, updateGroupInState, track]
|
||||
[updateCategory, updateGroupInState]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -519,7 +420,6 @@ export function BoardProvider({
|
|||
editTodo,
|
||||
toggleTodoDone,
|
||||
removeTodo,
|
||||
suspendRemoteSync,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
|
|
@ -9,7 +9,6 @@ import {
|
|||
ClipboardList,
|
||||
GripVertical,
|
||||
MoreVertical,
|
||||
NotebookPen,
|
||||
Pencil,
|
||||
Plus,
|
||||
Sparkles,
|
||||
|
|
@ -33,8 +32,8 @@ import { getBrighterColor, getComplementaryColor } from "@/lib/colors";
|
|||
import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
|
||||
import { useBoard } from "@/components/board/board-context";
|
||||
import { useBoardView } from "@/components/board/board-view-provider";
|
||||
import { NEW_GROUP_HOLD_MS, useHoldOnComplete } from "@/components/hold-on-complete";
|
||||
import { GroupSessionDialog } from "@/components/board/group-session-dialog";
|
||||
import { useHoldOnComplete } from "@/components/hold-on-complete";
|
||||
import { NotesDialog } from "@/components/board/notes-dialog";
|
||||
import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
|
||||
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
||||
import { TodoCreatePopover } from "@/components/board/todo-create-popover";
|
||||
|
|
@ -90,7 +89,7 @@ function AddTodoMenu({
|
|||
export function GroupCard({ group }: { group: GroupDTO }) {
|
||||
const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard();
|
||||
const { view } = useBoardView();
|
||||
const { holds, beginHold, cancelHold, pauseHold } = useHoldOnComplete();
|
||||
const { holds, beginHold, cancelHold } = useHoldOnComplete();
|
||||
const compact = view === "compact";
|
||||
// True for any theme whose surfaces are dark -- Dark and Ocean both use
|
||||
// their `dark` color variant (see lib/colors.ts); light themes use `light`.
|
||||
|
|
@ -98,44 +97,10 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [sessionOpen, setSessionOpen] = useState(false);
|
||||
const [todoAiOpen, setTodoAiOpen] = useState(false);
|
||||
const [todoCreateOpen, setTodoCreateOpen] = useState(false);
|
||||
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
||||
const [expandedWhileComplete, setExpandedWhileComplete] = useState(false);
|
||||
// True while one of this card's "add to-do" windows is open and we paused
|
||||
// the group's own hold to keep the card (and the window mounted inside it)
|
||||
// from unmounting mid-form -- a brand-new empty group in compact view is
|
||||
// the case that needs it: its creation hold would otherwise expire while
|
||||
// the user is still typing and close the window right out from under them.
|
||||
const addTodoHoldPausedRef = useRef(false);
|
||||
|
||||
// Opens or closes one of this card's "add to-do" windows (the plain
|
||||
// dialog or the AI dialog). While at least one is open, a group that is
|
||||
// on screen only because of its own hold (i.e. still empty) gets that
|
||||
// hold frozen, so the card -- and the window mounted inside it -- can't
|
||||
// be unmounted mid-form. When the last window closes, a still-empty
|
||||
// group gets a fresh grace period to be noticed or filled before finally
|
||||
// fading out; a group the window did fill drops the paused hold entirely
|
||||
// (it stays visible for its own open work, and a leftover hold entry
|
||||
// would otherwise pin it to compact view forever).
|
||||
function handleAddTodoWindow(
|
||||
next: boolean,
|
||||
setter: (open: boolean) => void,
|
||||
otherOpen: boolean
|
||||
) {
|
||||
setter(next);
|
||||
if (next) {
|
||||
if (!otherOpen && compact && holds.has(group.id)) {
|
||||
addTodoHoldPausedRef.current = true;
|
||||
pauseHold(group.id);
|
||||
}
|
||||
} else if (!otherOpen && addTodoHoldPausedRef.current) {
|
||||
addTodoHoldPausedRef.current = false;
|
||||
if (group.todos.some((t) => !t.completed)) cancelHold(group.id);
|
||||
else beginHold(group.id, NEW_GROUP_HOLD_MS);
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
attributes,
|
||||
|
|
@ -272,15 +237,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
</h3>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
aria-label={`Notes for ${group.title}`}
|
||||
onClick={() => setSessionOpen(true)}
|
||||
>
|
||||
<NotebookPen className="size-4" />
|
||||
</Button>
|
||||
<NotesDialog group={group} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
|
|
@ -384,8 +341,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
<AddTodoMenu
|
||||
group={group}
|
||||
aiConfigured={aiConfigured}
|
||||
onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)}
|
||||
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)}
|
||||
onAddClick={() => setTodoCreateOpen(true)}
|
||||
onAiClick={() => setTodoAiOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -405,8 +362,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
<div className="mt-1 flex justify-end">
|
||||
<AddTodoMenu
|
||||
group={group}
|
||||
onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)}
|
||||
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)}
|
||||
onAddClick={() => setTodoCreateOpen(true)}
|
||||
onAiClick={() => setTodoAiOpen(true)}
|
||||
aiConfigured={aiConfigured}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -490,19 +447,13 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
|
||||
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
|
||||
|
||||
<GroupSessionDialog group={group} open={sessionOpen} onOpenChange={setSessionOpen} />
|
||||
|
||||
<TodoAiDialog
|
||||
group={group}
|
||||
open={todoAiOpen}
|
||||
onOpenChange={(next) => handleAddTodoWindow(next, setTodoAiOpen, todoCreateOpen)}
|
||||
/>
|
||||
<TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
|
||||
|
||||
<TodoCreateDialog
|
||||
groupId={group.id}
|
||||
categoryId={group.categoryId}
|
||||
open={todoCreateOpen}
|
||||
onOpenChange={(next) => handleAddTodoWindow(next, setTodoCreateOpen, todoAiOpen)}
|
||||
onOpenChange={setTodoCreateOpen}
|
||||
/>
|
||||
|
||||
<StatusUpdateDialog group={group} open={statusUpdateOpen} onOpenChange={setStatusUpdateOpen} />
|
||||
|
|
|
|||
|
|
@ -1,640 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Eye, Pencil, Plus, Sparkles, Trash2, X, XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { MDEditor, MarkdownPreview } from "@/components/markdown/markdown-widgets";
|
||||
import { colorModeFromTheme, isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
|
||||
import { getBrighterColor } from "@/lib/colors";
|
||||
import { useBoard } from "@/components/board/board-context";
|
||||
import { TodoCheckbox } from "@/components/board/todo-checkbox";
|
||||
import { GroupAiDialog } from "@/components/board/group-ai-dialog";
|
||||
import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
|
||||
import { useAutosave } from "@/hooks/use-autosave";
|
||||
import type { GroupDTO, TodoDTO } from "@/types/board";
|
||||
|
||||
const TITLE_MAX = 20;
|
||||
|
||||
type TodoDraft = {
|
||||
id: string;
|
||||
title: string;
|
||||
details: string;
|
||||
completed: boolean;
|
||||
isNew: boolean;
|
||||
order: number;
|
||||
};
|
||||
|
||||
function tempId(): string {
|
||||
return `temp-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
function toDrafts(todos: TodoDTO[]): TodoDraft[] {
|
||||
return todos.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
details: t.details ?? "",
|
||||
completed: t.completed,
|
||||
isNew: false,
|
||||
order: t.order,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges fresh server todos into the local draft list without clobbering
|
||||
* anything the user has mid-edit: a draft whose id is in `dirtyIds` (still
|
||||
* unsaved, or mid-save) is kept as-is; everything else is refreshed from
|
||||
* the server, including dropping drafts the server no longer has and
|
||||
* picking up server todos not yet represented locally.
|
||||
*/
|
||||
function reconcileTodoDrafts(
|
||||
current: TodoDraft[],
|
||||
serverTodos: TodoDTO[],
|
||||
dirtyIds: Set<string>
|
||||
): TodoDraft[] {
|
||||
const currentById = new Map(current.map((d) => [d.id, d]));
|
||||
const merged: TodoDraft[] = [];
|
||||
|
||||
for (const todo of serverTodos) {
|
||||
const existing = currentById.get(todo.id);
|
||||
if (existing && dirtyIds.has(todo.id)) {
|
||||
merged.push(existing);
|
||||
} else {
|
||||
merged.push({
|
||||
id: todo.id,
|
||||
title: todo.title,
|
||||
details: todo.details ?? "",
|
||||
completed: todo.completed,
|
||||
isNew: false,
|
||||
order: todo.order,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Local-only new rows (not yet persisted) and any dirty draft the server
|
||||
// list somehow doesn't have yet both survive untouched.
|
||||
for (const draft of current) {
|
||||
if (draft.isNew || (dirtyIds.has(draft.id) && !serverTodos.some((t) => t.id === draft.id))) {
|
||||
if (!merged.some((d) => d.id === draft.id)) merged.push(draft);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function GroupSessionDialog({
|
||||
group,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
group: GroupDTO;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { saveNote, addTodo, editTodo, toggleTodoDone, removeTodo } = useBoard();
|
||||
const colorMode = colorModeFromTheme();
|
||||
const isDark = isDarkTheme();
|
||||
const color = useGroupColor(group.color);
|
||||
const accentColor = isDark ? color.dark : color.light;
|
||||
const todoTextColor = getBrighterColor(accentColor, isDark ? "dark" : "light");
|
||||
// The group's card treatment, scaled to a window: the same hand-picked
|
||||
// tint fill the card uses (blended into the theme's card surface), plus a
|
||||
// softer version of the group's stroke for the header/footer rules so
|
||||
// they read as the same hue without competing with the 3px border.
|
||||
const dialogBackground = `color-mix(in srgb, ${isDark ? color.softDark : color.soft} 85%, var(--card))`;
|
||||
const panelBackground = `color-mix(in srgb, ${isDark ? color.softDark : color.soft} 100%, var(--card))`;
|
||||
const ruleColor = `color-mix(in srgb, ${accentColor} 40%, transparent)`;
|
||||
const subtleRuleColor = `color-mix(in srgb, ${accentColor} 25%, transparent)`;
|
||||
|
||||
const [noteDraft, setNoteDraft] = useState(group.noteContent);
|
||||
const [todoDrafts, setTodoDrafts] = useState<TodoDraft[]>(() => toDrafts(group.todos));
|
||||
// Notes default to a rendered markdown preview; the pencil by the label
|
||||
// flips into the full editor (and back). Kept out of the todo row state
|
||||
// so it never collides with per-row edit toggles.
|
||||
const [notesEditing, setNotesEditing] = useState(false);
|
||||
const [aiOpen, setAiOpen] = useState(false);
|
||||
const [todoAiOpen, setTodoAiOpen] = useState(false);
|
||||
const [savingAll, setSavingAll] = useState(false);
|
||||
const [completedOpen, setCompletedOpen] = useState(false);
|
||||
const [expandedCompletedIds, setExpandedCompletedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const rowFlushersRef = useRef(new Map<string, () => Promise<void>>());
|
||||
const dirtyRowIdsRef = useRef(new Set<string>());
|
||||
|
||||
const noteAutosave = useAutosave(
|
||||
noteDraft,
|
||||
useCallback((v: string) => saveNote(group.id, group.categoryId, v), [group.id, group.categoryId, saveNote])
|
||||
);
|
||||
|
||||
// Seed fresh drafts every time the session opens, so a prior open's
|
||||
// leftover local-only "new" rows never leak into the next session.
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (next) {
|
||||
setNoteDraft(group.noteContent);
|
||||
setTodoDrafts(toDrafts(group.todos));
|
||||
setNotesEditing(false);
|
||||
} else {
|
||||
// Fire-and-forget: close immediately, flush any pending autosaves in
|
||||
// the background. board-context's mutators already toast on failure,
|
||||
// and their state lives above this dialog so it's safe to keep
|
||||
// running after this component unmounts.
|
||||
void handleSaveAll();
|
||||
}
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
// Picks up remote changes (cross-device poll, an accepted "Ask AI" edit)
|
||||
// while this session is open, but never while the corresponding field is
|
||||
// dirty or mid-save -- otherwise a poll landing mid-keystroke would wipe
|
||||
// out what the user just typed.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!noteAutosave.isDirty && !noteAutosave.isSaving) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- syncing from an external source (cross-device poll / accepted AI edit), same pattern as theme-provider.tsx
|
||||
setNoteDraft(group.noteContent);
|
||||
}
|
||||
}, [group.noteContent, open, noteAutosave.isDirty, noteAutosave.isSaving]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTodoDrafts((prev) => reconcileTodoDrafts(prev, group.todos, dirtyRowIdsRef.current));
|
||||
}, [group.todos, open]);
|
||||
|
||||
const activeTodoDrafts = todoDrafts.filter((d) => !d.completed);
|
||||
const completedTodoDrafts = todoDrafts.filter((d) => d.completed);
|
||||
|
||||
function updateTodoDraft(id: string, patch: Partial<TodoDraft>) {
|
||||
setTodoDrafts((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)));
|
||||
}
|
||||
|
||||
function removeTodoDraftLocalOnly(id: string) {
|
||||
setTodoDrafts((prev) => prev.filter((d) => d.id !== id));
|
||||
dirtyRowIdsRef.current.delete(id);
|
||||
rowFlushersRef.current.delete(id);
|
||||
}
|
||||
|
||||
function registerRowFlush(id: string, flush: () => Promise<void>) {
|
||||
rowFlushersRef.current.set(id, flush);
|
||||
return () => {
|
||||
rowFlushersRef.current.delete(id);
|
||||
};
|
||||
}
|
||||
|
||||
function setRowDirty(id: string, dirty: boolean) {
|
||||
if (dirty) dirtyRowIdsRef.current.add(id);
|
||||
else dirtyRowIdsRef.current.delete(id);
|
||||
}
|
||||
|
||||
async function handleAddTodoCreated(tempIdToRemove: string) {
|
||||
removeTodoDraftLocalOnly(tempIdToRemove);
|
||||
}
|
||||
|
||||
function toggleCompletedExpanded(id: string) {
|
||||
setExpandedCompletedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function addEmptyTodoDraft() {
|
||||
setTodoDrafts((prev) => [
|
||||
...prev,
|
||||
{ id: tempId(), title: "", details: "", completed: false, isNew: true, order: prev.length },
|
||||
]);
|
||||
}
|
||||
|
||||
async function handleSaveAll() {
|
||||
setSavingAll(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
noteAutosave.flush(),
|
||||
...Array.from(rowFlushersRef.current.values()).map((f) => f()),
|
||||
]);
|
||||
} finally {
|
||||
setSavingAll(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
data-color-mode={colorMode}
|
||||
showCloseButton={false}
|
||||
className="fixed top-[2vh] left-1/2 h-[96vh] max-h-[96vh] w-[calc(100%-1rem)] max-w-[calc(100%-1rem)] sm:max-w-5xl -translate-x-1/2 translate-y-0 flex flex-col gap-0 overflow-hidden rounded-2xl border-[3px] p-0 ring-0"
|
||||
style={{ borderColor: accentColor, backgroundColor: dialogBackground }}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between gap-2 border-b p-4"
|
||||
style={{ borderColor: ruleColor, backgroundColor: panelBackground }}
|
||||
>
|
||||
<DialogTitle className="min-w-0 truncate" style={{ color: todoTextColor }}>
|
||||
{group.title}
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{savingAll || noteAutosave.isSaving ? "Saving…" : "Saved"}
|
||||
</span>
|
||||
<DialogClose
|
||||
render={<Button variant="ghost" size="icon-sm" aria-label="Close" />}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-6 overflow-y-auto overscroll-contain p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm font-medium">Notes</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setNotesEditing((v) => !v)}
|
||||
aria-label={notesEditing ? "Preview notes" : "Edit notes"}
|
||||
>
|
||||
{notesEditing ? <Eye className="size-4" /> : <Pencil className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{notesEditing ? (
|
||||
<MDEditor
|
||||
value={noteDraft}
|
||||
onChange={(v) => setNoteDraft(v ?? "")}
|
||||
height={280}
|
||||
preview="edit"
|
||||
/>
|
||||
) : (
|
||||
// Rendered as a distinct group-note panel -- full group tint,
|
||||
// stronger rule, no indent -- so it reads as notes for the
|
||||
// whole group rather than another to-do row.
|
||||
<div
|
||||
className="max-h-72 overflow-y-auto rounded-lg border p-4"
|
||||
style={{
|
||||
borderColor: ruleColor,
|
||||
backgroundColor: panelBackground,
|
||||
}}
|
||||
>
|
||||
{noteDraft.trim() ? (
|
||||
<MarkdownPreview source={noteDraft} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No notes yet — use the pencil above to add some.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">To-dos</Label>
|
||||
<div className="space-y-2">
|
||||
{activeTodoDrafts.map((draft) => (
|
||||
<SessionTodoRow
|
||||
key={draft.id}
|
||||
draft={draft}
|
||||
groupId={group.id}
|
||||
categoryId={group.categoryId}
|
||||
accentColor={accentColor}
|
||||
todoTextColor={todoTextColor}
|
||||
isDark={isDark}
|
||||
onChangeLocal={(patch) => updateTodoDraft(draft.id, patch)}
|
||||
onRemoveLocalOnly={() => removeTodoDraftLocalOnly(draft.id)}
|
||||
onCreated={() => handleAddTodoCreated(draft.id)}
|
||||
onDirtyChange={(dirty) => setRowDirty(draft.id, dirty)}
|
||||
registerFlush={registerRowFlush}
|
||||
addTodo={addTodo}
|
||||
editTodo={editTodo}
|
||||
toggleTodoDone={toggleTodoDone}
|
||||
removeTodo={removeTodo}
|
||||
/>
|
||||
))}
|
||||
{todoDrafts.length === 0 && (
|
||||
<p className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
No to-dos yet. Add one below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{completedTodoDrafts.length > 0 && (
|
||||
<div
|
||||
className="space-y-2 rounded-md border"
|
||||
style={{ borderColor: ruleColor }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCompletedOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2 p-3 text-left text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{completedOpen ? (
|
||||
<ChevronDown className="size-4 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-4 shrink-0" />
|
||||
)}
|
||||
Completed ({completedTodoDrafts.length})
|
||||
</button>
|
||||
{completedOpen && (
|
||||
<div className="space-y-2 p-3 pt-0">
|
||||
{completedTodoDrafts.map((draft) =>
|
||||
expandedCompletedIds.has(draft.id) ? (
|
||||
<SessionTodoRow
|
||||
key={draft.id}
|
||||
draft={draft}
|
||||
groupId={group.id}
|
||||
categoryId={group.categoryId}
|
||||
accentColor={accentColor}
|
||||
todoTextColor={todoTextColor}
|
||||
isDark={isDark}
|
||||
onChangeLocal={(patch) => updateTodoDraft(draft.id, patch)}
|
||||
onRemoveLocalOnly={() => removeTodoDraftLocalOnly(draft.id)}
|
||||
onCreated={() => handleAddTodoCreated(draft.id)}
|
||||
onDirtyChange={(dirty) => setRowDirty(draft.id, dirty)}
|
||||
registerFlush={registerRowFlush}
|
||||
addTodo={addTodo}
|
||||
editTodo={editTodo}
|
||||
toggleTodoDone={toggleTodoDone}
|
||||
removeTodo={removeTodo}
|
||||
onCollapse={() => toggleCompletedExpanded(draft.id)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
key={draft.id}
|
||||
type="button"
|
||||
onClick={() => toggleCompletedExpanded(draft.id)}
|
||||
className="flex w-full items-center gap-2 rounded-md border border-dashed p-2 text-left text-sm text-muted-foreground hover:bg-muted/50"
|
||||
>
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate line-through">{draft.title}</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-t p-4"
|
||||
style={{ borderColor: ruleColor, backgroundColor: panelBackground }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" className="gap-2" onClick={addEmptyTodoDraft}>
|
||||
<Plus className="size-4" />
|
||||
Add To-Do
|
||||
</Button>
|
||||
<Button variant="outline" className="gap-2" onClick={() => setTodoAiOpen(true)}>
|
||||
<Sparkles className="size-4" />
|
||||
Add To-Dos with AI
|
||||
</Button>
|
||||
<Button variant="outline" className="gap-2" onClick={() => setAiOpen(true)}>
|
||||
<Sparkles className="size-4" />
|
||||
Ask AI
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Save first (flushing the note + every dirty row), then
|
||||
// close -- handleOpenChange's own background flush is a
|
||||
// no-op at that point.
|
||||
void handleSaveAll().then(() => handleOpenChange(false));
|
||||
}}
|
||||
disabled={savingAll}
|
||||
className="hover:brightness-110"
|
||||
style={{
|
||||
backgroundColor: accentColor,
|
||||
borderColor: accentColor,
|
||||
// The stroke is a darker ink on light themes (white type
|
||||
// clears it) and a brighter neon on dark themes (dark type
|
||||
// clears it) -- the same pairing the to-do text uses.
|
||||
color: isDark ? "#18181b" : "#ffffff",
|
||||
}}
|
||||
>
|
||||
{savingAll ? "Saving…" : "Save and Close"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<GroupAiDialog group={group} open={aiOpen} onOpenChange={setAiOpen} />
|
||||
<TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionTodoRow({
|
||||
draft,
|
||||
groupId,
|
||||
categoryId,
|
||||
accentColor,
|
||||
todoTextColor,
|
||||
isDark,
|
||||
onChangeLocal,
|
||||
onRemoveLocalOnly,
|
||||
onCreated,
|
||||
onDirtyChange,
|
||||
registerFlush,
|
||||
addTodo,
|
||||
editTodo,
|
||||
toggleTodoDone,
|
||||
removeTodo,
|
||||
onCollapse,
|
||||
}: {
|
||||
draft: TodoDraft;
|
||||
groupId: string;
|
||||
categoryId: string;
|
||||
accentColor: string;
|
||||
todoTextColor: string;
|
||||
isDark: boolean;
|
||||
onChangeLocal: (patch: Partial<TodoDraft>) => void;
|
||||
onRemoveLocalOnly: () => void;
|
||||
onCreated: () => void;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
registerFlush: (id: string, flush: () => Promise<void>) => () => void;
|
||||
addTodo: ReturnType<typeof useBoard>["addTodo"];
|
||||
editTodo: ReturnType<typeof useBoard>["editTodo"];
|
||||
toggleTodoDone: ReturnType<typeof useBoard>["toggleTodoDone"];
|
||||
removeTodo: ReturnType<typeof useBoard>["removeTodo"];
|
||||
onCollapse?: () => void;
|
||||
}) {
|
||||
const [editingDetails, setEditingDetails] = useState(false);
|
||||
// Guards against creating the same new to-do twice if title/details both
|
||||
// become dirty in the same tick (title autosave and details autosave
|
||||
// would otherwise both try to create it).
|
||||
const creatingRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
const createIfNeeded = useCallback(async (): Promise<void> => {
|
||||
if (!draft.isNew) return;
|
||||
const title = draft.title.trim();
|
||||
if (!title) return;
|
||||
if (creatingRef.current) {
|
||||
await creatingRef.current;
|
||||
return;
|
||||
}
|
||||
creatingRef.current = (async () => {
|
||||
const ok = await addTodo(groupId, categoryId, title, draft.details.trim() || undefined);
|
||||
if (ok) onCreated();
|
||||
})();
|
||||
await creatingRef.current;
|
||||
}, [draft.isNew, draft.title, draft.details, groupId, categoryId, addTodo, onCreated]);
|
||||
|
||||
const saveTitle = useCallback(
|
||||
async (title: string) => {
|
||||
if (draft.isNew) {
|
||||
await createIfNeeded();
|
||||
return;
|
||||
}
|
||||
if (!title.trim()) return;
|
||||
await editTodo(draft.id, groupId, categoryId, { title: title.trim() });
|
||||
},
|
||||
[draft.isNew, draft.id, groupId, categoryId, editTodo, createIfNeeded]
|
||||
);
|
||||
|
||||
const saveDetails = useCallback(
|
||||
async (details: string) => {
|
||||
if (draft.isNew) {
|
||||
await createIfNeeded();
|
||||
return;
|
||||
}
|
||||
await editTodo(draft.id, groupId, categoryId, { details: details.trim() || null });
|
||||
},
|
||||
[draft.isNew, draft.id, groupId, categoryId, editTodo, createIfNeeded]
|
||||
);
|
||||
|
||||
const titleAutosave = useAutosave(draft.title, saveTitle);
|
||||
const detailsAutosave = useAutosave(draft.details, saveDetails);
|
||||
|
||||
const dirty = titleAutosave.isDirty || titleAutosave.isSaving || detailsAutosave.isDirty || detailsAutosave.isSaving;
|
||||
useEffect(() => {
|
||||
onDirtyChange(dirty);
|
||||
}, [dirty, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const flush = async () => {
|
||||
if (draft.isNew && draft.title.trim()) {
|
||||
await createIfNeeded();
|
||||
return;
|
||||
}
|
||||
await Promise.all([titleAutosave.flush(), detailsAutosave.flush()]);
|
||||
};
|
||||
return registerFlush(draft.id, flush);
|
||||
}, [draft.id, draft.isNew, draft.title, createIfNeeded, titleAutosave, detailsAutosave, registerFlush]);
|
||||
|
||||
function handleToggle(next: boolean) {
|
||||
onChangeLocal({ completed: next });
|
||||
if (!draft.isNew) {
|
||||
void toggleTodoDone(draft.id, groupId, categoryId, next);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (draft.isNew) {
|
||||
onRemoveLocalOnly();
|
||||
} else {
|
||||
onRemoveLocalOnly();
|
||||
void removeTodo(draft.id, groupId, categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
const checkbox = (
|
||||
<TodoCheckbox
|
||||
checked={draft.completed}
|
||||
onCheckedChange={handleToggle}
|
||||
accentColor={accentColor}
|
||||
isDark={isDark}
|
||||
className="mt-1"
|
||||
aria-label={`Mark "${draft.title || "new to-do"}" ${draft.completed ? "incomplete" : "complete"}`}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-2 rounded-md border p-3"
|
||||
// A whisper of this group's own stroke on the row frame (see
|
||||
// subtleRuleColor in GroupSessionDialog) so the rows tie into the
|
||||
// card's color instead of generic gray rules.
|
||||
style={{ borderColor: `color-mix(in srgb, ${accentColor} 25%, transparent)` }}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{draft.isNew ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<span className="mt-1 inline-flex pointer-events-none opacity-50" />}
|
||||
>
|
||||
{checkbox}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Save a title first</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
checkbox
|
||||
)}
|
||||
<Input
|
||||
value={draft.title}
|
||||
maxLength={TITLE_MAX}
|
||||
placeholder="To-do title"
|
||||
autoFocus={draft.isNew && !draft.title}
|
||||
onChange={(e) => onChangeLocal({ title: e.target.value })}
|
||||
style={draft.completed ? undefined : { color: todoTextColor }}
|
||||
className={cn("w-40 shrink-0", draft.completed && "text-muted-foreground line-through")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => setEditingDetails((v) => !v)}
|
||||
aria-label={`${editingDetails ? "Preview" : "Edit"} details for "${draft.title || "new to-do"}"`}
|
||||
>
|
||||
{editingDetails ? <Eye className="size-4" /> : <Pencil className="size-4" />}
|
||||
</Button>
|
||||
{onCollapse && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={onCollapse}
|
||||
aria-label={`Collapse "${draft.title || "to-do"}"`}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={handleDelete}
|
||||
aria-label={`Delete "${draft.title || "new to-do"}"`}
|
||||
>
|
||||
{draft.isNew ? <X className="size-4" /> : <Trash2 className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{editingDetails ? (
|
||||
// ml-6 indents the details block (checkbox's 16px + the 8px gap) so
|
||||
// its left edge lines up with the title above instead of the checkbox.
|
||||
<MDEditor
|
||||
value={draft.details}
|
||||
onChange={(v) => onChangeLocal({ details: v ?? "" })}
|
||||
height={160}
|
||||
preview="edit"
|
||||
className="ml-6"
|
||||
/>
|
||||
) : draft.details ? (
|
||||
<div
|
||||
className="ml-6 max-h-40 overflow-y-auto rounded-md border p-2"
|
||||
style={{ borderColor: `color-mix(in srgb, ${accentColor} 25%, transparent)` }}
|
||||
>
|
||||
<MarkdownPreview source={draft.details} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ function Board({
|
|||
projectId?: string;
|
||||
aiConfigured: boolean;
|
||||
}) {
|
||||
const { categories, reorderLanes, reorderGroups, moveGroup, suspendRemoteSync } = useBoard();
|
||||
const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard();
|
||||
const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null);
|
||||
const [quickAddOpen, setQuickAddOpen] = useState(false);
|
||||
const pagerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -67,11 +67,6 @@ function Board({
|
|||
}
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
// Hold cross-device sync off for the drag's duration: replacing the
|
||||
// board state mid-drag would swap the SortableContext items out from
|
||||
// under dnd-kit. Drag ends (including cancelled ones) always fire
|
||||
// handleDragEnd, which resumes it.
|
||||
suspendRemoteSync(true);
|
||||
const data = event.active.data.current;
|
||||
if (data?.type === "group") {
|
||||
const category = findCategory(data.categoryId as string);
|
||||
|
|
@ -81,7 +76,6 @@ function Board({
|
|||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
suspendRemoteSync(false);
|
||||
const { active, over } = event;
|
||||
setDraggedGroup(null);
|
||||
if (!over) return;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { NotebookPen, Pencil, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { MDEditor, MarkdownPreview } from "@/components/markdown/markdown-widgets";
|
||||
import { useBoard } from "@/components/board/board-context";
|
||||
import { GroupAiDialog } from "@/components/board/group-ai-dialog";
|
||||
import type { GroupDTO } from "@/types/board";
|
||||
|
||||
export function NotesDialog({ group }: { group: GroupDTO }) {
|
||||
const { saveNote } = useBoard();
|
||||
const colorMode = colorModeFromTheme();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(group.noteContent);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [aiOpen, setAiOpen] = useState(false);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
setOpen(next);
|
||||
if (next) {
|
||||
setDraft(group.noteContent);
|
||||
setEditing(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The "Ask AI" dialog can update this group's notes (via saveNote) while
|
||||
// this dialog stays open behind it. Pick up that change so the accepted
|
||||
// AI notes show up here immediately instead of the stale pre-AI draft.
|
||||
useEffect(() => {
|
||||
if (open && !editing) {
|
||||
setDraft(group.noteContent);
|
||||
}
|
||||
}, [group.noteContent, open, editing]);
|
||||
|
||||
async function handleSave() {
|
||||
setPending(true);
|
||||
const ok = await saveNote(group.id, group.categoryId, draft);
|
||||
setPending(false);
|
||||
if (ok) setEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
aria-label={`Notes for ${group.title}`}
|
||||
onClick={() => handleOpenChange(true)}
|
||||
>
|
||||
<NotebookPen className="size-4" />
|
||||
</Button>
|
||||
<DialogContent className="sm:max-w-2xl" data-color-mode={colorMode}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{group.title} — Notes</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{editing ? (
|
||||
<MDEditor value={draft} onChange={(v) => setDraft(v ?? "")} height={360} />
|
||||
) : draft ? (
|
||||
<div className="max-h-[60vh] overflow-y-auto rounded-md border p-4">
|
||||
<MarkdownPreview source={draft} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||
No notes yet. Click Edit to add some.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{editing ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEditing(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={pending}>
|
||||
{pending ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setAiOpen(true)} className="gap-2">
|
||||
<Sparkles className="size-4" />
|
||||
Ask AI
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setEditing(true)} className="gap-2">
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<GroupAiDialog group={group} open={aiOpen} onOpenChange={setAiOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -136,7 +136,6 @@ export function TodoCreateDialog({
|
|||
value={details}
|
||||
onChange={(v) => setDetails(v ?? "")}
|
||||
height={160}
|
||||
preview="edit"
|
||||
textareaProps={{ id: `create-todo-details-${groupId}` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -78,7 +78,6 @@ export function TodoCreatePopover({
|
|||
value={details}
|
||||
onChange={(v) => setDetails(v ?? "")}
|
||||
height={160}
|
||||
preview="edit"
|
||||
textareaProps={{ id: `todo-details-${groupId}` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -113,7 +113,6 @@ export function TodoEditDialog({
|
|||
value={details}
|
||||
onChange={(v) => setDetails(v ?? "")}
|
||||
height={220}
|
||||
preview="edit"
|
||||
/>
|
||||
) : details ? (
|
||||
<div className="max-h-48 overflow-y-auto rounded-md border p-3">
|
||||
|
|
|
|||
|
|
@ -26,20 +26,9 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
|
|||
// Ids are opaque strings, so unrelated features (a to-do id, a group id, a
|
||||
// scheduled occurrence's `${scheduledTodoId}-${occurrenceDate}` key) can
|
||||
// safely share one instance without knowing about each other.
|
||||
//
|
||||
// A hold can also be frozen mid-flight (pauseHold) and later resumed
|
||||
// (beginHold) or dropped (cancelHold) -- e.g. to keep a brand-new group on
|
||||
// screen for as long as its "add to-do" window is still open, however long
|
||||
// that takes.
|
||||
const HOLD_MS = 6_000;
|
||||
const FADE_MS = 1_000;
|
||||
const COLLAPSE_MS = 200;
|
||||
// The one-off grace period a brand-new (necessarily empty) group gets in
|
||||
// compact view -- without it, compact's "hide empty groups" rule would drop
|
||||
// it before it was ever seen, so this is its "moment to be noticed / have a
|
||||
// to-do added" window. Longer than the usual hold since there's no
|
||||
// accidental click to forgive here.
|
||||
export const NEW_GROUP_HOLD_MS = 15_000;
|
||||
|
||||
export type HoldPhase = "visible" | "fading" | "collapsing";
|
||||
|
||||
|
|
@ -50,12 +39,6 @@ interface HoldOnCompleteContextValue {
|
|||
// override just the "visible" stage's length.
|
||||
beginHold: (id: string, holdMs?: number) => void;
|
||||
cancelHold: (id: string) => void;
|
||||
// Freeze a live hold in place: clears its pending stage timers and
|
||||
// (re)sets it to full "visible" with nothing scheduled after, so the item
|
||||
// stays on screen until beginHold (resume the staged fade-out) or
|
||||
// cancelHold (drop it) is called for it again. No-op if the id has no
|
||||
// live hold.
|
||||
pauseHold: (id: string) => void;
|
||||
}
|
||||
|
||||
const HoldOnCompleteContext = createContext<HoldOnCompleteContextValue | null>(null);
|
||||
|
|
@ -103,25 +86,6 @@ export function HoldOnCompleteProvider({ children }: { children: React.ReactNode
|
|||
[clearTimers, setPhase]
|
||||
);
|
||||
|
||||
// Freeze an existing hold: the item snaps back (and stays) at full
|
||||
// "visible" opacity with no stage timers pending, so it lingers on screen
|
||||
// indefinitely -- call beginHold to resume the staged fade-out, or
|
||||
// cancelHold to drop the hold outright.
|
||||
const pauseHold = useCallback(
|
||||
(id: string) => {
|
||||
clearTimers(id);
|
||||
setHolds((prev) => {
|
||||
// Pausing is only meaningful for an id that's already mid-hold --
|
||||
// don't invent a hold for one that isn't.
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(id, "visible");
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[clearTimers]
|
||||
);
|
||||
|
||||
// Belt-and-suspenders: drop any still-pending timers on unmount so they
|
||||
// don't fire setState against a gone provider.
|
||||
useEffect(() => {
|
||||
|
|
@ -132,7 +96,7 @@ export function HoldOnCompleteProvider({ children }: { children: React.ReactNode
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold, pauseHold }}>
|
||||
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold }}>
|
||||
{children}
|
||||
</HoldOnCompleteContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,16 +9,7 @@ import type { RehypeRewriteOptions } from "rehype-rewrite";
|
|||
// out of the initial board bundle. Shared here so every place that needs
|
||||
// markdown (group notes, to-do details) uses the exact same editor/preview
|
||||
// widgets instead of each declaring its own dynamic import.
|
||||
const RawMDEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false });
|
||||
|
||||
// `preview` only seeds the editor's initial mode -- @uiw/react-md-editor
|
||||
// owns it as internal state after that, flipped by the edit/live/preview
|
||||
// toolbar buttons. Defaulting it to "preview" opens every editor rendered
|
||||
// instead of raw markdown, while the toolbar still lets someone drop into
|
||||
// "edit" (or the split "live" view) when they want granular control.
|
||||
export function MDEditor(props: ComponentProps<typeof RawMDEditor>) {
|
||||
return <RawMDEditor preview="preview" {...props} />;
|
||||
}
|
||||
export const MDEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false });
|
||||
|
||||
const RawMarkdownPreview = dynamic(
|
||||
() => import("@uiw/react-md-editor").then((mod) => mod.default.Markdown),
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// "A few seconds" per the group session's live-editing requirement --
|
||||
// short enough to feel live, long enough not to fire a save on every
|
||||
// keystroke. Deliberately well under board-context's 30s cross-device
|
||||
// poll interval, so a local edit always settles before the poll could
|
||||
// otherwise consider overwriting it.
|
||||
export const AUTOSAVE_DELAY_MS = 2500;
|
||||
|
||||
/**
|
||||
* Debounced autosave for a single field's value. Call `onSave` a beat
|
||||
* after `value` stops changing, and again immediately via `flush()` (used
|
||||
* by a manual "Save" button and by flush-on-close) -- both paths funnel
|
||||
* through the same `runSave`, so there's no separate "save now" logic to
|
||||
* keep in sync with the debounced one.
|
||||
*
|
||||
* `onSave` must be one of the board-context mutators (e.g. `saveNote`,
|
||||
* `editTodo`), never a raw server action directly -- those mutators already
|
||||
* mark the write as in-flight (see `track()` in board-context.tsx) so the
|
||||
* app's cross-device poll doesn't clobber it mid-save.
|
||||
*/
|
||||
export function useAutosave<T>(
|
||||
value: T,
|
||||
onSave: (value: T) => Promise<boolean | void>,
|
||||
delayMs = AUTOSAVE_DELAY_MS
|
||||
): { flush: () => Promise<void>; isDirty: boolean; isSaving: boolean } {
|
||||
const [savedValue, setSavedValue] = useState(value);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const onSaveRef = useRef(onSave);
|
||||
const savingPromiseRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
onSaveRef.current = onSave;
|
||||
}, [onSave]);
|
||||
|
||||
const runSave = useCallback((v: T) => {
|
||||
setIsSaving(true);
|
||||
const promise = (async () => {
|
||||
await onSaveRef.current(v);
|
||||
setSavedValue(v);
|
||||
setIsSaving(false);
|
||||
})();
|
||||
savingPromiseRef.current = promise;
|
||||
return promise;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === savedValue) return;
|
||||
const timer = setTimeout(() => {
|
||||
void runSave(value);
|
||||
}, delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, savedValue, delayMs, runSave]);
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (value !== savedValue) {
|
||||
await runSave(value);
|
||||
} else if (savingPromiseRef.current) {
|
||||
await savingPromiseRef.current;
|
||||
}
|
||||
}, [value, savedValue, runSave]);
|
||||
|
||||
return { flush, isDirty: value !== savedValue, isSaving };
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUserId } from "@/lib/auth-helpers";
|
||||
import { projectAccessFilter } from "@/lib/access";
|
||||
import { getBoard } from "@/lib/board";
|
||||
import type { CategoryDTO } from "@/types/board";
|
||||
|
||||
/**
|
||||
* A fresh read of one board for the client's cross-device sync (see the
|
||||
* poll in board-context.tsx). Returns the exact same shape the page's
|
||||
* server component renders, so BoardProvider can diff it against its own
|
||||
* state and apply it only if something actually changed. `projectId` null
|
||||
* is Home scope -- same convention as getScheduledBoard.
|
||||
*/
|
||||
export async function getBoardSnapshot(projectId: string | null): Promise<CategoryDTO[]> {
|
||||
const userId = await requireUserId();
|
||||
if (projectId) {
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id: projectId, ...projectAccessFilter(userId) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!project) throw new Error("Project not found");
|
||||
return getBoard({ projectId: project.id });
|
||||
}
|
||||
return getBoard({ userId, projectId: null });
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 71 KiB |
Loading…
Reference in New Issue