Merge pull request 'Updates-and-holding-screens' (#8) from Updates-and-holding-screens into main
Reviewed-on: #8
This commit is contained in:
commit
f7cbcb793c
|
|
@ -0,0 +1,109 @@
|
||||||
|
/* 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); });
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
/* 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); });
|
||||||
|
|
@ -10,16 +10,10 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||||
import { ColorSwatchPicker } from "@/components/board/color-swatch-picker";
|
import { ColorSwatchPicker } from "@/components/board/color-swatch-picker";
|
||||||
import { useBoard } from "@/components/board/board-context";
|
import { useBoard } from "@/components/board/board-context";
|
||||||
import { useBoardView } from "@/components/board/board-view-provider";
|
import { useBoardView } from "@/components/board/board-view-provider";
|
||||||
import { useHoldOnComplete } from "@/components/hold-on-complete";
|
import { NEW_GROUP_HOLD_MS, useHoldOnComplete } from "@/components/hold-on-complete";
|
||||||
import { DEFAULT_GROUP_COLOR_KEY, type GroupColorKey } from "@/lib/colors";
|
import { DEFAULT_GROUP_COLOR_KEY, type GroupColorKey } from "@/lib/colors";
|
||||||
|
|
||||||
const TITLE_MAX = 20;
|
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 }) {
|
export function AddGroupPopover({ categoryId }: { categoryId: string }) {
|
||||||
const { addGroup } = useBoard();
|
const { addGroup } = useBoard();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback } from "react";
|
import { createContext, useContext, useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import type { CategoryDTO, GroupDTO, TodoDTO } from "@/types/board";
|
import type { CategoryDTO, GroupDTO, TodoDTO } from "@/types/board";
|
||||||
|
|
@ -25,6 +25,13 @@ import {
|
||||||
deleteTodo as deleteTodoAction,
|
deleteTodo as deleteTodoAction,
|
||||||
} from "@/lib/actions/todos";
|
} from "@/lib/actions/todos";
|
||||||
import { updateGroupNote } from "@/lib/actions/notes";
|
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 {
|
interface BoardContextValue {
|
||||||
categories: CategoryDTO[];
|
categories: CategoryDTO[];
|
||||||
|
|
@ -75,6 +82,10 @@ interface BoardContextValue {
|
||||||
completed: boolean
|
completed: boolean
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
removeTodo: (todoId: string, groupId: string, categoryId: string) => 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);
|
const BoardContext = createContext<BoardContextValue | null>(null);
|
||||||
|
|
@ -96,6 +107,94 @@ export function BoardProvider({
|
||||||
}) {
|
}) {
|
||||||
const [categories, setCategories] = useState(initialCategories);
|
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(
|
const updateCategory = useCallback(
|
||||||
(categoryId: string, updater: (c: CategoryDTO) => CategoryDTO) => {
|
(categoryId: string, updater: (c: CategoryDTO) => CategoryDTO) => {
|
||||||
setCategories((prev) => prev.map((c) => (c.id === categoryId ? updater(c) : c)));
|
setCategories((prev) => prev.map((c) => (c.id === categoryId ? updater(c) : c)));
|
||||||
|
|
@ -116,13 +215,13 @@ export function BoardProvider({
|
||||||
const addCategory = useCallback(
|
const addCategory = useCallback(
|
||||||
async (name: string) => {
|
async (name: string) => {
|
||||||
try {
|
try {
|
||||||
const category = await createCategory(name, projectId);
|
const category = await track(createCategory(name, projectId));
|
||||||
setCategories((prev) => [...prev, category]);
|
setCategories((prev) => [...prev, category]);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Couldn't create category. Try again.");
|
toast.error("Couldn't create category. Try again.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectId]
|
[projectId, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeCategory = useCallback(async (categoryId: string) => {
|
const removeCategory = useCallback(async (categoryId: string) => {
|
||||||
|
|
@ -132,7 +231,7 @@ export function BoardProvider({
|
||||||
return prev.filter((c) => c.id !== categoryId);
|
return prev.filter((c) => c.id !== categoryId);
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await deleteCategoryAction(categoryId);
|
const result = await track(deleteCategoryAction(categoryId));
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
setCategories(prevState);
|
setCategories(prevState);
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
|
|
@ -141,7 +240,7 @@ export function BoardProvider({
|
||||||
setCategories(prevState);
|
setCategories(prevState);
|
||||||
toast.error("Couldn't delete category. Try again.");
|
toast.error("Couldn't delete category. Try again.");
|
||||||
}
|
}
|
||||||
}, []);
|
}, [track]);
|
||||||
|
|
||||||
const reorderLanes = useCallback(
|
const reorderLanes = useCallback(
|
||||||
async (orderedIds: string[]) => {
|
async (orderedIds: string[]) => {
|
||||||
|
|
@ -152,36 +251,36 @@ export function BoardProvider({
|
||||||
return orderedIds.map((id, order) => ({ ...byId.get(id)!, order }));
|
return orderedIds.map((id, order) => ({ ...byId.get(id)!, order }));
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await reorderCategories(orderedIds, projectId);
|
await track(reorderCategories(orderedIds, projectId));
|
||||||
} catch {
|
} catch {
|
||||||
setCategories(prevState);
|
setCategories(prevState);
|
||||||
toast.error("Couldn't reorder categories. Try again.");
|
toast.error("Couldn't reorder categories. Try again.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectId]
|
[projectId, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const addGroup = useCallback(async (categoryId: string, title: string, color: string) => {
|
const addGroup = useCallback(async (categoryId: string, title: string, color: string) => {
|
||||||
try {
|
try {
|
||||||
const group = await createGroup(categoryId, title, color);
|
const group = await track(createGroup(categoryId, title, color));
|
||||||
updateCategory(categoryId, (c) => ({ ...c, groups: [...c.groups, group] }));
|
updateCategory(categoryId, (c) => ({ ...c, groups: [...c.groups, group] }));
|
||||||
return group;
|
return group;
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Couldn't create group. Try again.");
|
toast.error("Couldn't create group. Try again.");
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}, [updateCategory]);
|
}, [updateCategory, track]);
|
||||||
|
|
||||||
const editGroup = useCallback(
|
const editGroup = useCallback(
|
||||||
async (groupId: string, categoryId: string, data: { title?: string; color?: string }) => {
|
async (groupId: string, categoryId: string, data: { title?: string; color?: string }) => {
|
||||||
try {
|
try {
|
||||||
await updateGroupAction(groupId, data);
|
await track(updateGroupAction(groupId, data));
|
||||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, ...data }));
|
updateGroupInState(categoryId, groupId, (g) => ({ ...g, ...data }));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Couldn't update group. Try again.");
|
toast.error("Couldn't update group. Try again.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateGroupInState]
|
[updateGroupInState, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeGroup = useCallback(async (groupId: string, categoryId: string) => {
|
const removeGroup = useCallback(async (groupId: string, categoryId: string) => {
|
||||||
|
|
@ -193,12 +292,12 @@ export function BoardProvider({
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await deleteGroupAction(groupId);
|
await track(deleteGroupAction(groupId));
|
||||||
} catch {
|
} catch {
|
||||||
setCategories(prevState);
|
setCategories(prevState);
|
||||||
toast.error("Couldn't delete group. Try again.");
|
toast.error("Couldn't delete group. Try again.");
|
||||||
}
|
}
|
||||||
}, []);
|
}, [track]);
|
||||||
|
|
||||||
const archiveGroup = useCallback(async (groupId: string, categoryId: string) => {
|
const archiveGroup = useCallback(async (groupId: string, categoryId: string) => {
|
||||||
let prevState: CategoryDTO[] = [];
|
let prevState: CategoryDTO[] = [];
|
||||||
|
|
@ -209,12 +308,12 @@ export function BoardProvider({
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await archiveGroupAction(groupId);
|
await track(archiveGroupAction(groupId));
|
||||||
} catch {
|
} catch {
|
||||||
setCategories(prevState);
|
setCategories(prevState);
|
||||||
toast.error("Couldn't archive group. Try again.");
|
toast.error("Couldn't archive group. Try again.");
|
||||||
}
|
}
|
||||||
}, []);
|
}, [track]);
|
||||||
|
|
||||||
const reorderGroups = useCallback(async (categoryId: string, orderedIds: string[]) => {
|
const reorderGroups = useCallback(async (categoryId: string, orderedIds: string[]) => {
|
||||||
let prevState: CategoryDTO[] = [];
|
let prevState: CategoryDTO[] = [];
|
||||||
|
|
@ -227,12 +326,12 @@ export function BoardProvider({
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await reorderGroupsInCategory(categoryId, orderedIds);
|
await track(reorderGroupsInCategory(categoryId, orderedIds));
|
||||||
} catch {
|
} catch {
|
||||||
setCategories(prevState);
|
setCategories(prevState);
|
||||||
toast.error("Couldn't reorder groups. Try again.");
|
toast.error("Couldn't reorder groups. Try again.");
|
||||||
}
|
}
|
||||||
}, []);
|
}, [track]);
|
||||||
|
|
||||||
const moveGroup = useCallback(
|
const moveGroup = useCallback(
|
||||||
async (
|
async (
|
||||||
|
|
@ -273,19 +372,19 @@ export function BoardProvider({
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await moveGroupToCategory(groupId, toCategoryId, orderedTarget, orderedSource);
|
await track(moveGroupToCategory(groupId, toCategoryId, orderedTarget, orderedSource));
|
||||||
} catch {
|
} catch {
|
||||||
setCategories(prevState);
|
setCategories(prevState);
|
||||||
toast.error("Couldn't move group. Try again.");
|
toast.error("Couldn't move group. Try again.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
[track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const saveNote = useCallback(
|
const saveNote = useCallback(
|
||||||
async (groupId: string, categoryId: string, noteContent: string) => {
|
async (groupId: string, categoryId: string, noteContent: string) => {
|
||||||
try {
|
try {
|
||||||
await updateGroupNote(groupId, noteContent);
|
await track(updateGroupNote(groupId, noteContent));
|
||||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, noteContent }));
|
updateGroupInState(categoryId, groupId, (g) => ({ ...g, noteContent }));
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -293,13 +392,13 @@ export function BoardProvider({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateGroupInState]
|
[updateGroupInState, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const addTodo = useCallback(
|
const addTodo = useCallback(
|
||||||
async (groupId: string, categoryId: string, title: string, details?: string) => {
|
async (groupId: string, categoryId: string, title: string, details?: string) => {
|
||||||
try {
|
try {
|
||||||
const todo = await createTodo(groupId, title, details);
|
const todo = await track(createTodo(groupId, title, details));
|
||||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, todo] }));
|
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, todo] }));
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -307,13 +406,13 @@ export function BoardProvider({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateGroupInState]
|
[updateGroupInState, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const addTodos = useCallback(
|
const addTodos = useCallback(
|
||||||
async (groupId: string, categoryId: string, todos: { title: string; details?: string }[]) => {
|
async (groupId: string, categoryId: string, todos: { title: string; details?: string }[]) => {
|
||||||
try {
|
try {
|
||||||
const created = await createTodos(groupId, todos);
|
const created = await track(createTodos(groupId, todos));
|
||||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, ...created] }));
|
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, ...created] }));
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -321,7 +420,7 @@ export function BoardProvider({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateGroupInState]
|
[updateGroupInState, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const editTodo = useCallback(
|
const editTodo = useCallback(
|
||||||
|
|
@ -332,7 +431,7 @@ export function BoardProvider({
|
||||||
data: { title?: string; details?: string | null }
|
data: { title?: string; details?: string | null }
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await updateTodoAction(todoId, data);
|
await track(updateTodoAction(todoId, data));
|
||||||
// Approximates the server's own `updatedAt` (set by the same write,
|
// Approximates the server's own `updatedAt` (set by the same write,
|
||||||
// a moment later) closely enough for display purposes, without
|
// a moment later) closely enough for display purposes, without
|
||||||
// waiting on a round trip just to read it back.
|
// waiting on a round trip just to read it back.
|
||||||
|
|
@ -347,7 +446,7 @@ export function BoardProvider({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateGroupInState]
|
[updateGroupInState, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleTodoDone = useCallback(
|
const toggleTodoDone = useCallback(
|
||||||
|
|
@ -370,13 +469,13 @@ export function BoardProvider({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
await toggleTodoAction(todoId, completed);
|
await track(toggleTodoAction(todoId, completed));
|
||||||
} catch {
|
} catch {
|
||||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
|
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
|
||||||
toast.error("Couldn't update to-do. Try again.");
|
toast.error("Couldn't update to-do. Try again.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateCategory, updateGroupInState]
|
[updateCategory, updateGroupInState, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeTodo = useCallback(
|
const removeTodo = useCallback(
|
||||||
|
|
@ -391,13 +490,13 @@ export function BoardProvider({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
await deleteTodoAction(todoId);
|
await track(deleteTodoAction(todoId));
|
||||||
} catch {
|
} catch {
|
||||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
|
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
|
||||||
toast.error("Couldn't delete to-do. Try again.");
|
toast.error("Couldn't delete to-do. Try again.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateCategory, updateGroupInState]
|
[updateCategory, updateGroupInState, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -420,6 +519,7 @@ export function BoardProvider({
|
||||||
editTodo,
|
editTodo,
|
||||||
toggleTodoDone,
|
toggleTodoDone,
|
||||||
removeTodo,
|
removeTodo,
|
||||||
|
suspendRemoteSync,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import {
|
import {
|
||||||
|
|
@ -32,7 +32,7 @@ import { getBrighterColor, getComplementaryColor } from "@/lib/colors";
|
||||||
import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
|
import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
|
||||||
import { useBoard } from "@/components/board/board-context";
|
import { useBoard } from "@/components/board/board-context";
|
||||||
import { useBoardView } from "@/components/board/board-view-provider";
|
import { useBoardView } from "@/components/board/board-view-provider";
|
||||||
import { useHoldOnComplete } from "@/components/hold-on-complete";
|
import { NEW_GROUP_HOLD_MS, useHoldOnComplete } from "@/components/hold-on-complete";
|
||||||
import { NotesDialog } from "@/components/board/notes-dialog";
|
import { NotesDialog } from "@/components/board/notes-dialog";
|
||||||
import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
|
import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
|
||||||
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
||||||
|
|
@ -89,7 +89,7 @@ function AddTodoMenu({
|
||||||
export function GroupCard({ group }: { group: GroupDTO }) {
|
export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard();
|
const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard();
|
||||||
const { view } = useBoardView();
|
const { view } = useBoardView();
|
||||||
const { holds, beginHold, cancelHold } = useHoldOnComplete();
|
const { holds, beginHold, cancelHold, pauseHold } = useHoldOnComplete();
|
||||||
const compact = view === "compact";
|
const compact = view === "compact";
|
||||||
// True for any theme whose surfaces are dark -- Dark and Ocean both use
|
// 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`.
|
// their `dark` color variant (see lib/colors.ts); light themes use `light`.
|
||||||
|
|
@ -101,6 +101,39 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
const [todoCreateOpen, setTodoCreateOpen] = useState(false);
|
const [todoCreateOpen, setTodoCreateOpen] = useState(false);
|
||||||
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
||||||
const [expandedWhileComplete, setExpandedWhileComplete] = 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 {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
|
|
@ -341,8 +374,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
<AddTodoMenu
|
<AddTodoMenu
|
||||||
group={group}
|
group={group}
|
||||||
aiConfigured={aiConfigured}
|
aiConfigured={aiConfigured}
|
||||||
onAddClick={() => setTodoCreateOpen(true)}
|
onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)}
|
||||||
onAiClick={() => setTodoAiOpen(true)}
|
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -362,8 +395,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
<div className="mt-1 flex justify-end">
|
<div className="mt-1 flex justify-end">
|
||||||
<AddTodoMenu
|
<AddTodoMenu
|
||||||
group={group}
|
group={group}
|
||||||
onAddClick={() => setTodoCreateOpen(true)}
|
onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)}
|
||||||
onAiClick={() => setTodoAiOpen(true)}
|
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)}
|
||||||
aiConfigured={aiConfigured}
|
aiConfigured={aiConfigured}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -447,13 +480,17 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
|
|
||||||
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
|
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
|
||||||
|
|
||||||
<TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
|
<TodoAiDialog
|
||||||
|
group={group}
|
||||||
|
open={todoAiOpen}
|
||||||
|
onOpenChange={(next) => handleAddTodoWindow(next, setTodoAiOpen, todoCreateOpen)}
|
||||||
|
/>
|
||||||
|
|
||||||
<TodoCreateDialog
|
<TodoCreateDialog
|
||||||
groupId={group.id}
|
groupId={group.id}
|
||||||
categoryId={group.categoryId}
|
categoryId={group.categoryId}
|
||||||
open={todoCreateOpen}
|
open={todoCreateOpen}
|
||||||
onOpenChange={setTodoCreateOpen}
|
onOpenChange={(next) => handleAddTodoWindow(next, setTodoCreateOpen, todoAiOpen)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatusUpdateDialog group={group} open={statusUpdateOpen} onOpenChange={setStatusUpdateOpen} />
|
<StatusUpdateDialog group={group} open={statusUpdateOpen} onOpenChange={setStatusUpdateOpen} />
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ function Board({
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
aiConfigured: boolean;
|
aiConfigured: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard();
|
const { categories, reorderLanes, reorderGroups, moveGroup, suspendRemoteSync } = useBoard();
|
||||||
const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null);
|
const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null);
|
||||||
const [quickAddOpen, setQuickAddOpen] = useState(false);
|
const [quickAddOpen, setQuickAddOpen] = useState(false);
|
||||||
const pagerRef = useRef<HTMLDivElement>(null);
|
const pagerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -67,6 +67,11 @@ function Board({
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragStart(event: DragStartEvent) {
|
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;
|
const data = event.active.data.current;
|
||||||
if (data?.type === "group") {
|
if (data?.type === "group") {
|
||||||
const category = findCategory(data.categoryId as string);
|
const category = findCategory(data.categoryId as string);
|
||||||
|
|
@ -76,6 +81,7 @@ function Board({
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragEnd(event: DragEndEvent) {
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
suspendRemoteSync(false);
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
setDraggedGroup(null);
|
setDraggedGroup(null);
|
||||||
if (!over) return;
|
if (!over) return;
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,20 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
|
||||||
// Ids are opaque strings, so unrelated features (a to-do id, a group id, a
|
// Ids are opaque strings, so unrelated features (a to-do id, a group id, a
|
||||||
// scheduled occurrence's `${scheduledTodoId}-${occurrenceDate}` key) can
|
// scheduled occurrence's `${scheduledTodoId}-${occurrenceDate}` key) can
|
||||||
// safely share one instance without knowing about each other.
|
// 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 HOLD_MS = 6_000;
|
||||||
const FADE_MS = 1_000;
|
const FADE_MS = 1_000;
|
||||||
const COLLAPSE_MS = 200;
|
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";
|
export type HoldPhase = "visible" | "fading" | "collapsing";
|
||||||
|
|
||||||
|
|
@ -39,6 +50,12 @@ interface HoldOnCompleteContextValue {
|
||||||
// override just the "visible" stage's length.
|
// override just the "visible" stage's length.
|
||||||
beginHold: (id: string, holdMs?: number) => void;
|
beginHold: (id: string, holdMs?: number) => void;
|
||||||
cancelHold: (id: string) => 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);
|
const HoldOnCompleteContext = createContext<HoldOnCompleteContextValue | null>(null);
|
||||||
|
|
@ -86,6 +103,25 @@ export function HoldOnCompleteProvider({ children }: { children: React.ReactNode
|
||||||
[clearTimers, setPhase]
|
[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
|
// Belt-and-suspenders: drop any still-pending timers on unmount so they
|
||||||
// don't fire setState against a gone provider.
|
// don't fire setState against a gone provider.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -96,7 +132,7 @@ export function HoldOnCompleteProvider({ children }: { children: React.ReactNode
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold }}>
|
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold, pauseHold }}>
|
||||||
{children}
|
{children}
|
||||||
</HoldOnCompleteContext.Provider>
|
</HoldOnCompleteContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
"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 });
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue