From 6172617df3b6dac523b7797bdb098a8da418e001 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 12 Oct 2026 13:59:54 +0000 Subject: [PATCH 1/4] Replace freetext email inputs with a user lookup for tag/assign/reallocate Typo'd addresses in the old fields silently matched nobody (assigned_to/task_tags.user_email have no FK), so a task could vanish into an unreachable owner with no error. New UsersContext + UserPicker pull the real user list from central auth (/api/auth/users?app=tasks), same pattern maintenance already uses for its assignee dropdown. --- frontend/src/App.tsx | 45 ++++++++++++++------------ frontend/src/api.ts | 7 +++- frontend/src/components/TaskDetail.tsx | 25 ++++---------- frontend/src/components/shared.tsx | 25 ++++++++++++++ frontend/src/context/UsersContext.tsx | 32 ++++++++++++++++++ frontend/src/pages/DispatchView.tsx | 8 ++--- frontend/src/types.ts | 7 ++++ 7 files changed, 103 insertions(+), 46 deletions(-) create mode 100644 frontend/src/context/UsersContext.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 57c0da1..af97530 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { ListsProvider } from './context/ListsContext' import { StatusesProvider } from './context/StatusesContext' import { CategoriesProvider } from './context/CategoriesContext' import { ProjectsProvider } from './context/ProjectsContext' +import { UsersProvider } from './context/UsersContext' import { SearchProvider } from './context/SearchContext' import { TaskFiltersProvider } from './context/TaskFiltersContext' import { TaskDrawerProvider } from './components/TaskDrawer' @@ -30,27 +31,29 @@ export default function App() { - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4f6e943..a31bd34 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail } from './types' +import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail, AuthUser } from './types' const BASE = '/tasks/api' @@ -137,6 +137,11 @@ export function addComment(taskId: number, body: string): Promise { return request(`/tasks/${taskId}/comments`, { method: 'POST', body: JSON.stringify({ body }) }) } +// ── Users (for tag/assign/reallocate pickers) ─────────────────── +export function fetchAssignableUsers(): Promise { + return request('/auth/users?app=tasks') +} + // ── Config ─────────────────────────────────────────────────── export function fetchConfig(): Promise { return request('/config') diff --git a/frontend/src/components/TaskDetail.tsx b/frontend/src/components/TaskDetail.tsx index 3e8cf23..6e03410 100644 --- a/frontend/src/components/TaskDetail.tsx +++ b/frontend/src/components/TaskDetail.tsx @@ -13,7 +13,7 @@ import { useProjects } from '../context/ProjectsContext' import { useStatuses } from '../context/StatusesContext' import { useAuth } from './AuthGate' import { can } from '../types' -import { Check_, formatDateTime, toLocalInput } from './shared' +import { Check_, formatDateTime, toLocalInput, UserPicker } from './shared' export default function TaskDetail({ taskId, onChange, onClose }: { taskId: number; onChange: () => void; onClose: () => void }) { @@ -226,24 +226,16 @@ export default function TaskDetail({ taskId, onChange, onClose }: )} {isOwner && ( -
- setTagEmail(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') addTag() }} /> - -
+ tg.user_email)]} /> )} {isOwner && can(user, 'assign') && (
-
- setAssignEmail(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') assignTo() }} /> - -
+ {task.assigned_to &&
Currently assigned to {task.assigned_to}
}
)} @@ -251,12 +243,7 @@ export default function TaskDetail({ taskId, onChange, onClose }: {can(user, 'reallocate') && (
-
- setReallocateEmail(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') reallocateTo() }} /> - -
+
)} diff --git a/frontend/src/components/shared.tsx b/frontend/src/components/shared.tsx index b771665..3368b04 100644 --- a/frontend/src/components/shared.tsx +++ b/frontend/src/components/shared.tsx @@ -6,6 +6,7 @@ import { useCategories } from '../context/CategoriesContext' import { useProjects } from '../context/ProjectsContext' import { useLists } from '../context/ListsContext' import { useTaskFilters } from '../context/TaskFiltersContext' +import { useUsers } from '../context/UsersContext' export function PriorityBadge({ priority }: { priority: Priority }) { if (priority === 'medium') return null @@ -22,6 +23,30 @@ export function Check_({ isDone, onClick }: { isDone: boolean; onClick: (e: Reac ) } +// User lookup + submit button, replacing a freetext email input wherever a +// task gets tagged/assigned/reallocated — avoids typo'd addresses silently +// matching nobody (assigned_to/task_tags.user_email have no FK to users). +export function UserPicker({ value, onChange, onSubmit, exclude = [], placeholder = 'Choose a person…', buttonLabel }: { + value: string + onChange: (email: string) => void + onSubmit: () => void + exclude?: string[] + placeholder?: string + buttonLabel: string +}) { + const { users } = useUsers() + const options = users.filter(u => !exclude.includes(u.email)) + return ( +
+ + +
+ ) +} + interface FilterableEntity { id: number; name: string; colour: string } // Collapsible multi-select filter chip-bar — generic over anything with an diff --git a/frontend/src/context/UsersContext.tsx b/frontend/src/context/UsersContext.tsx new file mode 100644 index 0000000..9d190af --- /dev/null +++ b/frontend/src/context/UsersContext.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext, useEffect, useState, useCallback } from 'react' +import type { AuthUser } from '../types' +import { fetchAssignableUsers } from '../api' + +interface UsersCtx { + users: AuthUser[] + loading: boolean + reload: () => Promise +} + +const Ctx = createContext(null) + +export function useUsers() { + const ctx = useContext(Ctx) + if (!ctx) throw new Error('useUsers must be used inside UsersProvider') + return ctx +} + +export function UsersProvider({ children }: { children: React.ReactNode }) { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + + const reload = useCallback(async () => { + const data = await fetchAssignableUsers().catch(() => []) + setUsers(data) + setLoading(false) + }, []) + + useEffect(() => { reload() }, [reload]) + + return {children} +} diff --git a/frontend/src/pages/DispatchView.tsx b/frontend/src/pages/DispatchView.tsx index 641265d..b89c7a2 100644 --- a/frontend/src/pages/DispatchView.tsx +++ b/frontend/src/pages/DispatchView.tsx @@ -6,7 +6,7 @@ import { useAuth } from '../components/AuthGate' import { useSearch } from '../context/SearchContext' import { useTaskFilters } from '../context/TaskFiltersContext' import { can } from '../types' -import { dueInfo, PriorityBadge, TaskFilterBar } from '../components/shared' +import { dueInfo, PriorityBadge, TaskFilterBar, UserPicker } from '../components/shared' // A flat, cross-user list for the 'reallocate' cap — deliberately not routed // through the shared task drawer, since GET /api/tasks/:id only grants @@ -83,10 +83,8 @@ export default function DispatchView() { {movingId === t.id ? (
- setTarget(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') move(t.id); if (e.key === 'Escape') setMovingId(null) }} /> - + move(t.id)} buttonLabel="Move" + exclude={[t.owner_id]} placeholder="reallocate to…" />
) : ( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index daee638..95d6ae4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -141,6 +141,13 @@ export interface User { caps: string[] // bare slugs — verify?app=tasks strips the prefix } +// Users with access to this app, served by the central auth service. +export interface AuthUser { + id: number + email: string + name: string +} + export function can(user: User, cap: string): boolean { return user.is_admin || user.caps.includes(cap) } From 4d19b37b8ae00c9594ede837083aa2dd34ad9e05 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 12 Oct 2026 14:10:24 +0000 Subject: [PATCH 2/4] Collapse Sharing by default; allow creating tasks directly in a project Sharing (tag/assign/reallocate) is now a collapsed-by-default section in the task drawer with a one-line summary, since it's a lesser-used function that was eating space on every task. list_id is now optional on task creation -- the task lands unfiled (same state assign/reallocate already use), scoped only by project_id. Projects are a cross-cutting grouping, not a list replacement, and the only way to add a task to a project was previously to create it elsewhere and set the Project field from the drawer. Added a "+ New task" input directly on ProjectDetailView. Verified against a throwaway Postgres: unfiled creation, distinct sort_order per unfiled task, project detail lists them, and existing validation (bad list_id, missing title) still rejects. --- backend/src/routes/tasks.js | 26 ++++--- frontend/src/components/TaskDetail.tsx | 93 ++++++++++++++---------- frontend/src/pages/ProjectDetailView.tsx | 23 +++++- 3 files changed, 90 insertions(+), 52 deletions(-) diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js index bdd44d4..d7cdf2d 100644 --- a/backend/src/routes/tasks.js +++ b/backend/src/routes/tasks.js @@ -149,12 +149,18 @@ export async function taskRoutes(app) { app.post('/api/tasks', { preHandler: requireCap('use') }, async (req, reply) => { const { list_id, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id } = req.body || {} if (!title?.trim()) return reply.status(400).send({ error: 'title required' }) - // Verify the target list belongs to the caller. - const { rows: [list] } = await pool.query( - 'SELECT id FROM lists WHERE id = $1 AND owner_id = $2', - [list_id, req.user.email] - ) - if (!list) return reply.status(400).send({ error: 'invalid list' }) + // list_id is optional — omitting it lands the task "unfiled" (list_id + // NULL), the same state assign/reallocate already use. Projects need this + // so a task can be created straight into a project without first needing + // a personal list, since projects are a cross-cutting grouping, not a + // list replacement. + if (list_id) { + const { rows: [list] } = await pool.query( + 'SELECT id FROM lists WHERE id = $1 AND owner_id = $2', + [list_id, req.user.email] + ) + if (!list) return reply.status(400).send({ error: 'invalid list' }) + } if (priority && !PRIORITIES.includes(priority)) return reply.status(400).send({ error: 'bad priority' }) if (recur_rule && !RECUR_RULES.includes(recur_rule)) return reply.status(400).send({ error: 'bad recur_rule' }) if (category_id) { @@ -166,14 +172,16 @@ export async function taskRoutes(app) { if (!p) return reply.status(400).send({ error: 'invalid project' }) } const { rows: [max] } = await pool.query( - 'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id = $1', - [list_id] + list_id + ? 'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id = $1' + : 'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id IS NULL', + list_id ? [list_id] : [] ) const { rows: [task] } = await pool.query( `INSERT INTO tasks (list_id, owner_id, created_by, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id, sort_order) VALUES ($1, $2, $2, $3, $4, COALESCE($5, 'medium'), $6, $7, $8, $9, $10, $11) RETURNING *`, - [list_id, req.user.email, title.trim(), notes || null, priority || null, + [list_id || null, req.user.email, title.trim(), notes || null, priority || null, due_at || null, remind_at || null, recur_rule || null, category_id || null, project_id || null, Number(max.m) + 1] ) return task diff --git a/frontend/src/components/TaskDetail.tsx b/frontend/src/components/TaskDetail.tsx index 6e03410..25b7a0c 100644 --- a/frontend/src/components/TaskDetail.tsx +++ b/frontend/src/components/TaskDetail.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput, Tag, FolderKanban, UserPlus, Send } from 'lucide-react' +import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput, Tag, FolderKanban, UserPlus, Send, ChevronDown } from 'lucide-react' import type { TaskDetail as TDetail, Priority, RecurRule } from '../types' import { PRIORITIES, PRIORITY_LABELS, RECUR_RULES, RECUR_LABELS } from '../types' import { @@ -31,6 +31,7 @@ export default function TaskDetail({ taskId, onChange, onClose }: const [tagEmail, setTagEmail] = useState('') const [assignEmail, setAssignEmail] = useState('') const [reallocateEmail, setReallocateEmail] = useState('') + const [sharingOpen, setSharingOpen] = useState(false) useEffect(() => { fetchTask(taskId).then(t => { setTask(t); setTitle(t.title); setNotes(t.notes ?? '') }) @@ -208,49 +209,61 @@ export default function TaskDetail({ taskId, onChange, onClose }: - {/* Sharing — tag (anyone with edit rights) / assign (owner + assign cap) / reallocate (dispatcher cap) */} -
Sharing
-
-
- - {task.tags.length > 0 && ( -
- {task.tags.map(tg => ( - - {tg.user_email} - {isOwner && ( - removeTag(tg.user_email)} /> - )} - - ))} + {/* Sharing — tag (anyone with edit rights) / assign (owner + assign cap) / reallocate (dispatcher cap). + Collapsed by default: a lesser-used function that otherwise eats space on every task. */} +
setSharingOpen(o => !o)}> + Sharing + + {task.tags.length > 0 || task.assigned_to + ? [task.assigned_to && `assigned to ${task.assigned_to}`, task.tags.length > 0 && `${task.tags.length} tagged`].filter(Boolean).join(', ') + : 'nobody'} + + +
+ {sharingOpen && ( +
+
+ + {task.tags.length > 0 && ( +
+ {task.tags.map(tg => ( + + {tg.user_email} + {isOwner && ( + removeTag(tg.user_email)} /> + )} + + ))} +
+ )} + {isOwner && ( + tg.user_email)]} /> + )} +
+ + {isOwner && can(user, 'assign') && ( +
+ + + {task.assigned_to &&
Currently assigned to {task.assigned_to}
}
)} - {isOwner && ( - tg.user_email)]} /> + + {can(user, 'reallocate') && ( +
+ + +
+ )} + + {!isOwner && !can(user, 'reallocate') && task.tags.length === 0 && ( +
Nobody else is tagged on this task.
)}
- - {isOwner && can(user, 'assign') && ( -
- - - {task.assigned_to &&
Currently assigned to {task.assigned_to}
} -
- )} - - {can(user, 'reallocate') && ( -
- - -
- )} - - {!isOwner && !can(user, 'reallocate') && task.tags.length === 0 && ( -
Nobody else is tagged on this task.
- )} -
+ )} {/* Notes */}
Notes
diff --git a/frontend/src/pages/ProjectDetailView.tsx b/frontend/src/pages/ProjectDetailView.tsx index dba1566..90f4721 100644 --- a/frontend/src/pages/ProjectDetailView.tsx +++ b/frontend/src/pages/ProjectDetailView.tsx @@ -1,8 +1,8 @@ import { useEffect, useState, useCallback } from 'react' import { useParams, useNavigate } from 'react-router-dom' -import { FolderKanban, Pencil } from 'lucide-react' +import { FolderKanban, Pencil, Plus } from 'lucide-react' import type { ProjectDetail, Task } from '../types' -import { fetchProject, updateTask } from '../api' +import { fetchProject, updateTask, createTask } from '../api' import { useAuth } from '../components/AuthGate' import { useStatuses } from '../context/StatusesContext' import { useTaskDrawer } from '../components/TaskDrawer' @@ -19,6 +19,7 @@ export default function ProjectDetailView() { const [project, setProject] = useState(null) const [error, setError] = useState(null) const [editing, setEditing] = useState(false) + const [newTitle, setNewTitle] = useState('') const load = useCallback(() => { if (!projectId) return @@ -26,6 +27,15 @@ export default function ProjectDetailView() { }, [projectId]) useEffect(() => { load() }, [load, changeVersion]) + // No list_id — the task lands unfiled (same state assign/reallocate already + // use), scoped into the project by project_id alone. + async function add() { + if (!newTitle.trim()) return + await createTask({ project_id: projectId, title: newTitle.trim() }) + setNewTitle('') + load() + } + // Only meaningful for tasks the viewer owns — the backend 404s a PATCH on // someone else's task, which is expected for a shared project view. async function toggle(t: Task) { @@ -63,10 +73,17 @@ export default function ProjectDetailView() {
{done.length}/{project.tasks.length} tasks done
+
+ + setNewTitle(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') add() }} /> +
+ {project.tasks.length === 0 && (

- No tasks in this project yet — set a task's Project field from its detail view. + No tasks in this project yet — add one above, or set a task's Project field from its detail view.
)} From 2a75c7060e281660e668f56a7b4e76bbf07c673d Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 12 Oct 2026 14:47:36 +0000 Subject: [PATCH 3/4] Backend: project membership, per-project status pipelines, pin-to-today - project_members (viewer/editor) makes a project + everything in it visible to someone before they own anything there; editors can edit any task's content/status in the project (not its list/project/assignment, which stay owner/cap-gated) via a new loadEditable() alongside the existing loadOwned/loadVisible. - project_statuses lets a project define its own status pipeline instead of the shared global one. Dropped the FK tasks.status had to the global statuses table (a project-only value would violate it) and added a task_statuses view that resolves each task's status against its project's pipeline if it has one, else the global one -- this is also why new tasks in a custom-pipeline project now default to that project's own first non-done status instead of the hardcoded 'todo'. - pinned_today column + GET /api/tasks?view=today no longer requires a due date, so the frontend can show pinned/undated tasks there too. Verified against a throwaway Postgres: 20 assertions covering nullable list_id, pin, broadened today view, custom status default/resolution/ cross-project leakage, and the full viewer/editor/owner/stranger permission matrix on a shared project. --- backend/src/db.js | 74 ++++++++++++++++- backend/src/routes/projects.js | 147 ++++++++++++++++++++++++++++++--- backend/src/routes/tasks.js | 101 ++++++++++++++++------ 3 files changed, 287 insertions(+), 35 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 6127840..f4d425c 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -50,7 +50,7 @@ export async function initDb() { -- A grouping container for tasks that can span multiple owners (once a task -- is assigned elsewhere it keeps its project_id). Progress is inferred from - -- child tasks' statuses — projects don't get their own status pipeline. + -- child tasks' statuses. CREATE TABLE IF NOT EXISTS projects ( id SERIAL PRIMARY KEY, created_by TEXT NOT NULL, @@ -62,6 +62,35 @@ export async function initDb() { created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); + -- Explicit project membership, separate from the task-level tag/assign/ + -- reallocate tiers. A project is otherwise only visible to its creator and + -- anyone who happens to own/be tagged on a task inside it — membership + -- lets someone see (viewer) or edit any task in the project (editor) + -- without needing to already own something there. + CREATE TABLE IF NOT EXISTS project_members ( + project_id INT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'editor', -- viewer | editor + added_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (project_id, user_email) + ); + + -- Optional per-project status pipeline override. Most projects have none + -- of these rows and just use the shared global 'statuses' table; a project + -- that defines any row here switches entirely to its own pipeline (see the + -- task_statuses view below for the resolution logic). + CREATE TABLE IF NOT EXISTS project_statuses ( + id SERIAL PRIMARY KEY, + project_id INT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + label TEXT NOT NULL, + value TEXT NOT NULL, + position INT NOT NULL DEFAULT 0, + is_done BOOLEAN NOT NULL DEFAULT FALSE, + is_hold BOOLEAN NOT NULL DEFAULT FALSE, + UNIQUE (project_id, value) + ); + -- A single to-do. assigned_to is a real ownership transfer (gated behind the -- 'assign' cap) — when set, owner_id is moved to match it and the task is -- unfiled (list_id NULL) since the assignee doesn't share the old owner's @@ -85,6 +114,7 @@ export async function initDb() { reminded_at TIMESTAMPTZ, recur_rule TEXT, -- null | daily | weekdays | weekly | monthly completed_at TIMESTAMPTZ, + pinned_today BOOLEAN NOT NULL DEFAULT FALSE, -- forced into the Today agenda regardless of due_at sort_order INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() @@ -147,6 +177,33 @@ export async function initDb() { // if the constraint is already gone). await pool.query(`ALTER TABLE tasks ALTER COLUMN list_id DROP NOT NULL`) + // Safety net for databases created before pinned_today / per-project statuses. + await pool.query(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pinned_today BOOLEAN NOT NULL DEFAULT FALSE`) + // status used to FK straight to the global statuses table. A project can now + // define its own status values (project_statuses), which that FK would + // reject outright, so validity is checked in application code instead (see + // getEffectiveStatus in routes/tasks.js). + await pool.query(`ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_status_fkey`) + + // Resolves each task's status against its project's own pipeline if it has + // one defined, falling back to the shared global pipeline otherwise (which + // is what every task without a project, or in a project with no custom + // statuses, always uses). + await pool.query(` + CREATE OR REPLACE VIEW task_statuses AS + SELECT t.id AS task_id, + COALESCE(ps.label, s.label) AS status_label, + COALESCE(ps.is_done, s.is_done, FALSE) AS is_done, + COALESCE(ps.is_hold, s.is_hold, FALSE) AS is_hold, + COALESCE(ps.position, s.position) AS status_position + FROM tasks t + LEFT JOIN project_statuses ps ON ps.project_id = t.project_id AND ps.value = t.status + LEFT JOIN statuses s ON s.value = t.status AND ( + t.project_id IS NULL + OR NOT EXISTS (SELECT 1 FROM project_statuses ps2 WHERE ps2.project_id = t.project_id) + ) + `) + await seedDefaults() await seedStatuses() } @@ -186,6 +243,21 @@ export async function getStatuses() { return rows } +// A project's own pipeline if it has defined any custom statuses, else the +// shared global one — the same resolution the task_statuses view applies +// per-task, exposed here for callers that need the whole ordered list (the +// board's columns, a new task's default status). +export async function getEffectiveStatuses(projectId) { + if (projectId) { + const { rows } = await pool.query( + 'SELECT * FROM project_statuses WHERE project_id = $1 ORDER BY position, id', + [projectId] + ) + if (rows.length) return rows + } + return getStatuses() +} + export async function getConfig() { const { rows } = await pool.query('SELECT key, value FROM config') return Object.fromEntries(rows.map(r => [r.key, r.value])) diff --git a/backend/src/routes/projects.js b/backend/src/routes/projects.js index d3be83f..79f9887 100644 --- a/backend/src/routes/projects.js +++ b/backend/src/routes/projects.js @@ -2,12 +2,16 @@ import { requireAuth, requireCap } from '../auth.js' import { pool } from '../db.js' // A project is visible to whoever created it, plus anyone who owns or is -// tagged on at least one task inside it — so a project stays visible to -// collaborators even though it isn't "theirs". `alias` lets the same clause -// be reused against different table aliases in one query. +// tagged on at least one task inside it, plus anyone added as a project +// member (viewer or editor) — membership exists specifically so someone can +// see a project (and everything in it) before they own anything there. +// `alias` lets the same clause be reused against different table aliases. const visibleTask = (alias) => `(${alias}.owner_id = $1 OR ${alias}.assigned_to = $1 OR EXISTS ( SELECT 1 FROM task_tags tt WHERE tt.task_id = ${alias}.id AND tt.user_email = $1 ))` +const isMember = (projectAlias) => `EXISTS ( + SELECT 1 FROM project_members pm WHERE pm.project_id = ${projectAlias}.id AND pm.user_email = $1 +)` async function canManageProject(email, isAdmin, projectId) { if (isAdmin) return true @@ -15,6 +19,15 @@ async function canManageProject(email, isAdmin, projectId) { return p?.created_by === email } +async function canEditProject(email, isAdmin, projectId) { + if (await canManageProject(email, isAdmin, projectId)) return true + const { rows: [m] } = await pool.query( + "SELECT 1 FROM project_members WHERE project_id = $1 AND user_email = $2 AND role = 'editor'", + [projectId, email] + ) + return !!m +} + export async function projectRoutes(app) { app.addHook('preHandler', requireAuth) @@ -22,12 +35,12 @@ export async function projectRoutes(app) { const { rows } = await pool.query( `SELECT p.*, COUNT(t.id) AS task_count, - COUNT(t.id) FILTER (WHERE s.is_done) AS done_count + COUNT(t.id) FILTER (WHERE ts.is_done) AS done_count FROM projects p LEFT JOIN tasks t ON t.project_id = p.id AND ${visibleTask('t')} - LEFT JOIN statuses s ON s.value = t.status + LEFT JOIN task_statuses ts ON ts.task_id = t.id WHERE p.is_archived = FALSE - AND (p.created_by = $1 OR EXISTS ( + AND (p.created_by = $1 OR ${isMember('p')} OR EXISTS ( SELECT 1 FROM tasks t2 WHERE t2.project_id = p.id AND ${visibleTask('t2')} )) GROUP BY p.id @@ -40,12 +53,26 @@ export async function projectRoutes(app) { app.get('/api/projects/:id', { preHandler: requireCap('use') }, async (req, reply) => { const { rows: [project] } = await pool.query('SELECT * FROM projects WHERE id = $1', [req.params.id]) if (!project) return reply.status(404).send({ error: 'project not found' }) + if (!(req.user.is_admin || project.created_by === req.user.email)) { + const { rows: [visible] } = await pool.query( + `SELECT 1 FROM project_members pm WHERE pm.project_id = $2 AND pm.user_email = $1 + UNION ALL + SELECT 1 FROM tasks t2 WHERE t2.project_id = $2 AND ${visibleTask('t2')} + LIMIT 1`, + [req.user.email, req.params.id] + ) + if (!visible) return reply.status(404).send({ error: 'project not found' }) + } + // Project members (viewer or editor) see every task inside, not just the + // ones they'd otherwise own/be tagged on — that's the point of membership. const { rows: tasks } = await pool.query( - `SELECT t.*, s.label AS status_label, s.is_done, s.is_hold + `SELECT t.*, ts.status_label, ts.is_done, ts.is_hold FROM tasks t - JOIN statuses s ON s.value = t.status - WHERE t.project_id = $2 AND ${visibleTask('t')} - ORDER BY s.position, t.sort_order, t.id`, + JOIN task_statuses ts ON ts.task_id = t.id + WHERE t.project_id = $2 AND (${visibleTask('t')} OR EXISTS ( + SELECT 1 FROM project_members pm WHERE pm.project_id = t.project_id AND pm.user_email = $1 + )) + ORDER BY ts.status_position, t.sort_order, t.id`, [req.user.email, req.params.id] ) return { ...project, tasks } @@ -89,4 +116,104 @@ export async function projectRoutes(app) { if (!rowCount) return reply.status(404).send({ error: 'project not found' }) return { ok: true } }) + + // ── Members (viewer/editor) — creator/admin manage, members themselves + // just need to be listed so the editor UI can show who's on a project ──── + app.get('/api/projects/:id/members', { preHandler: requireCap('use') }, async (req, reply) => { + const { rows: [project] } = await pool.query('SELECT id FROM projects WHERE id = $1', [req.params.id]) + if (!project) return reply.status(404).send({ error: 'project not found' }) + const { rows } = await pool.query( + 'SELECT user_email, role, added_by, created_at FROM project_members WHERE project_id = $1 ORDER BY created_at', + [req.params.id] + ) + return rows + }) + + app.post('/api/projects/:id/members', { preHandler: requireCap('use') }, async (req, reply) => { + if (!(await canManageProject(req.user.email, req.user.is_admin, req.params.id))) + return reply.status(403).send({ error: 'only the project creator can add members' }) + const email = req.body?.email?.trim() + const role = req.body?.role === 'viewer' ? 'viewer' : 'editor' + if (!email) return reply.status(400).send({ error: 'email required' }) + const { rows: [m] } = await pool.query( + `INSERT INTO project_members (project_id, user_email, role, added_by) VALUES ($1, $2, $3, $4) + ON CONFLICT (project_id, user_email) DO UPDATE SET role = EXCLUDED.role + RETURNING user_email, role, added_by, created_at`, + [req.params.id, email, role, req.user.email] + ) + return m + }) + + app.delete('/api/projects/:id/members/:email', { preHandler: requireCap('use') }, async (req, reply) => { + if (!(await canManageProject(req.user.email, req.user.is_admin, req.params.id))) + return reply.status(403).send({ error: 'only the project creator can remove members' }) + await pool.query( + 'DELETE FROM project_members WHERE project_id = $1 AND user_email = $2', + [req.params.id, req.params.email] + ) + return { ok: true } + }) + + // ── Per-project status pipeline (optional — most projects have none and + // just use the shared global pipeline; see task_statuses view in db.js) ── + app.get('/api/projects/:id/statuses', { preHandler: requireCap('use') }, async (req, reply) => { + const { rows: custom } = await pool.query( + 'SELECT * FROM project_statuses WHERE project_id = $1 ORDER BY position, id', + [req.params.id] + ) + if (custom.length) return custom + const { rows: global } = await pool.query('SELECT * FROM statuses ORDER BY position, id') + return global + }) + + app.post('/api/projects/:id/statuses', { preHandler: requireCap('use') }, async (req, reply) => { + if (!(await canEditProject(req.user.email, req.user.is_admin, req.params.id))) + return reply.status(403).send({ error: 'only the project creator or an editor can manage its pipeline' }) + const { label, value, is_hold } = req.body || {} + if (!label?.trim() || !value?.trim()) return reply.status(400).send({ error: 'label and value required' }) + if (!/^[a-z0-9_]+$/.test(value)) return reply.status(400).send({ error: 'value must be lowercase letters, numbers, underscores' }) + const { rows: [max] } = await pool.query('SELECT COALESCE(MAX(position), -1) AS m FROM project_statuses WHERE project_id = $1', [req.params.id]) + const { rows: [status] } = await pool.query( + `INSERT INTO project_statuses (project_id, label, value, position, is_hold) VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [req.params.id, label.trim(), value.trim(), Number(max.m) + 1, !!is_hold] + ) + return status + }) + + app.patch('/api/projects/:id/statuses/:statusId', { preHandler: requireCap('use') }, async (req, reply) => { + if (!(await canEditProject(req.user.email, req.user.is_admin, req.params.id))) + return reply.status(403).send({ error: 'only the project creator or an editor can manage its pipeline' }) + const fields = [] + const vals = [] + let i = 1 + for (const key of ['label', 'position', 'is_done', 'is_hold']) { + if (req.body?.[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(req.body[key]) } + } + if (!fields.length) return reply.status(400).send({ error: 'nothing to update' }) + vals.push(req.params.statusId, req.params.id) + const { rows } = await pool.query( + `UPDATE project_statuses SET ${fields.join(', ')} WHERE id = $${i++} AND project_id = $${i} RETURNING *`, + vals + ) + if (!rows.length) return reply.status(404).send({ error: 'status not found' }) + return rows[0] + }) + + // Removing the last custom status silently drops the project back onto the + // shared global pipeline (see task_statuses view) — any task still holding + // one of the deleted values just reads as "not done" until re-filed. + app.delete('/api/projects/:id/statuses/:statusId', { preHandler: requireCap('use') }, async (req, reply) => { + if (!(await canEditProject(req.user.email, req.user.is_admin, req.params.id))) + return reply.status(403).send({ error: 'only the project creator or an editor can manage its pipeline' }) + const { rows: [status] } = await pool.query( + 'SELECT value FROM project_statuses WHERE id = $1 AND project_id = $2', [req.params.statusId, req.params.id] + ) + if (!status) return reply.status(404).send({ error: 'status not found' }) + const { rows: [inUse] } = await pool.query( + 'SELECT 1 FROM tasks WHERE project_id = $1 AND status = $2 LIMIT 1', [req.params.id, status.value] + ) + if (inUse) return reply.status(409).send({ error: 'status is still in use by tasks in this project' }) + await pool.query('DELETE FROM project_statuses WHERE id = $1', [req.params.statusId]) + return { ok: true } + }) } diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js index d7cdf2d..34daf17 100644 --- a/backend/src/routes/tasks.js +++ b/backend/src/routes/tasks.js @@ -1,10 +1,23 @@ import { requireAuth, requireCap, hasCap } from '../auth.js' -import { pool } from '../db.js' +import { pool, getEffectiveStatuses } from '../db.js' import { nextOccurrence, RECUR_RULES } from '../lib/recurrence.js' const PRIORITIES = ['low', 'medium', 'high'] -async function getStatus(value) { +// Resolves a status value against a project's own pipeline if it has one +// defined, else the shared global pipeline — same rule as the task_statuses +// view, for the one-off lookups (old/new status on a PATCH) that don't want +// a full join. +async function getStatus(value, projectId) { + if (projectId) { + const { rows: [ps] } = await pool.query( + 'SELECT * FROM project_statuses WHERE project_id = $1 AND value = $2', + [projectId, value] + ) + if (ps) return ps + const { rows: [any] } = await pool.query('SELECT 1 FROM project_statuses WHERE project_id = $1', [projectId]) + if (any) return null // project has its own pipeline and this value isn't in it + } const { rows: [s] } = await pool.query('SELECT * FROM statuses WHERE value = $1', [value]) return s || null } @@ -20,8 +33,31 @@ async function loadOwned(id, email) { return rows[0] || null } -// Load a task the caller can see: they own it, it was assigned to them, or -// they've been tagged in. Used for read access and collaborative actions +// Load a task the caller can edit: they own it, or the task sits in a +// project they created or are an 'editor' member of. Narrower than it sounds +// — see the PATCH route, which still blocks a non-owner editor from touching +// list_id/project_id/assigned_to (those stay reserved for the owner or the +// assign/reallocate caps). +async function loadEditable(id, email) { + const { rows } = await pool.query( + `SELECT t.* FROM tasks t + WHERE t.id = $1 AND ( + t.owner_id = $2 + OR (t.project_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM projects p WHERE p.id = t.project_id AND p.created_by = $2 + )) + OR (t.project_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM project_members pm WHERE pm.project_id = t.project_id AND pm.user_email = $2 AND pm.role = 'editor' + )) + )`, + [id, email] + ) + return rows[0] || null +} + +// Load a task the caller can see: they own it, it was assigned to them, +// they've been tagged in, or it sits in a project they're a member of +// (viewer or editor). Used for read access and collaborative actions // (comments, checklist items) that a tagged-in collaborator should be able // to do without full edit rights. async function loadVisible(id, email) { @@ -29,7 +65,9 @@ async function loadVisible(id, email) { `SELECT t.* FROM tasks t WHERE t.id = $1 AND (t.owner_id = $2 OR t.assigned_to = $2 OR EXISTS ( SELECT 1 FROM task_tags tt WHERE tt.task_id = t.id AND tt.user_email = $2 - ))`, + ) OR (t.project_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM project_members pm WHERE pm.project_id = t.project_id AND pm.user_email = $2 + )))`, [id, email] ) return rows[0] || null @@ -69,8 +107,11 @@ async function spawnRecurrence(task) { export async function taskRoutes(app) { app.addHook('preHandler', requireAuth) - // List tasks. ?view=today = cross-list agenda (non-done, due within 8 days - // or overdue). ?view=assigned = tasks handed to me via the 'assign' cap. + // List tasks. ?view=today = every open task I own (the frontend buckets + // these into overdue/today/next-7-days/pinned/other — the backend only + // excludes done ones, so the view can show an "other open tasks" section + // instead of looking empty whenever nothing happens to be due). + // ?view=assigned = tasks handed to me via the 'assign' cap. // ?view=shared = tasks I've been tagged into (not mine, not assigned to me). // ?view=all = every user's tasks, dispatcher board for the 'reallocate' cap. // Otherwise defaults to everything I own. ?list_id/?status/?category_id/ @@ -104,25 +145,23 @@ export async function taskRoutes(app) { if (search) { const idx = i++; clauses.push(`(t.title ILIKE $${idx} OR t.notes ILIKE $${idx})`); vals.push(`%${search}%`) } if (view === 'today') { clauses.push(`NOT s.is_done`) - clauses.push(`t.due_at IS NOT NULL`) - clauses.push(`t.due_at < (NOW() + INTERVAL '8 days')`) } const { rows } = await pool.query( `SELECT t.*, l.name AS list_name, l.colour AS list_colour, - s.label AS status_label, s.is_done, s.is_hold, + s.status_label AS status_label, s.is_done, s.is_hold, c.name AS category_name, c.colour AS category_colour, p.name AS project_name, p.colour AS project_colour, (SELECT COUNT(*) FROM task_items ti WHERE ti.task_id = t.id) AS item_count, (SELECT COUNT(*) FROM task_items ti WHERE ti.task_id = t.id AND ti.is_done) AS item_done FROM tasks t ${joinTags} - LEFT JOIN lists l ON l.id = t.list_id - JOIN statuses s ON s.value = t.status - LEFT JOIN categories c ON c.id = t.category_id - LEFT JOIN projects p ON p.id = t.project_id + LEFT JOIN lists l ON l.id = t.list_id + JOIN task_statuses s ON s.task_id = t.id + LEFT JOIN categories c ON c.id = t.category_id + LEFT JOIN projects p ON p.id = t.project_id WHERE ${clauses.join(' AND ')} - ORDER BY s.is_done, s.position, t.sort_order, t.due_at NULLS LAST, t.id`, + ORDER BY s.is_done, s.status_position, t.sort_order, t.due_at NULLS LAST, t.id`, vals ) return rows.map(r => ({ ...r, item_count: Number(r.item_count), item_done: Number(r.item_done) })) @@ -177,24 +216,38 @@ export async function taskRoutes(app) { : 'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id IS NULL', list_id ? [list_id] : [] ) + // A project with its own custom pipeline won't necessarily have a 'todo' + // value in it — default to its own lowest-position non-done status instead. + const effectiveStatuses = await getEffectiveStatuses(project_id || null) + const defaultStatus = effectiveStatuses.find(s => !s.is_done)?.value ?? effectiveStatuses[0]?.value ?? 'todo' const { rows: [task] } = await pool.query( - `INSERT INTO tasks (list_id, owner_id, created_by, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id, sort_order) - VALUES ($1, $2, $2, $3, $4, COALESCE($5, 'medium'), $6, $7, $8, $9, $10, $11) + `INSERT INTO tasks (list_id, owner_id, created_by, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id, status, sort_order) + VALUES ($1, $2, $2, $3, $4, COALESCE($5, 'medium'), $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [list_id || null, req.user.email, title.trim(), notes || null, priority || null, - due_at || null, remind_at || null, recur_rule || null, category_id || null, project_id || null, Number(max.m) + 1] + due_at || null, remind_at || null, recur_rule || null, category_id || null, project_id || null, + defaultStatus, Number(max.m) + 1] ) return task }) app.patch('/api/tasks/:id', { preHandler: requireCap('use') }, async (req, reply) => { - const task = await loadOwned(req.params.id, req.user.email) + // A non-owner can only get here via a project 'editor' membership — see + // loadEditable. They can change a task's content/status, but not its + // container identity (list/project) or who owns/is assigned to it; those + // stay reserved for the owner or the assign/reallocate caps below. + const task = await loadEditable(req.params.id, req.user.email) if (!task) return reply.status(404).send({ error: 'task not found' }) + const isOwner = task.owner_id === req.user.email const b = req.body || {} + if (!isOwner && (b.list_id !== undefined || b.project_id !== undefined || b.assigned_to !== undefined)) { + return reply.status(403).send({ error: 'Only the owner can change list, project, or assignment' }) + } + let newStatus = null if (b.status !== undefined) { - newStatus = await getStatus(b.status) + newStatus = await getStatus(b.status, task.project_id) if (!newStatus) return reply.status(400).send({ error: 'bad status' }) } if (b.priority && !PRIORITIES.includes(b.priority)) return reply.status(400).send({ error: 'bad priority' }) @@ -221,7 +274,7 @@ export async function taskRoutes(app) { const fields = [] const vals = [] let i = 1 - for (const key of ['title', 'notes', 'priority', 'due_at', 'remind_at', 'recur_rule', 'sort_order', 'category_id', 'project_id']) { + for (const key of ['title', 'notes', 'priority', 'due_at', 'remind_at', 'recur_rule', 'sort_order', 'category_id', 'project_id', 'pinned_today']) { if (b[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(b[key] === '' ? null : b[key]) } } // list_id can be explicitly nulled (unfiled) as well as changed. @@ -243,7 +296,7 @@ export async function taskRoutes(app) { } } - const oldStatus = newStatus ? await getStatus(task.status) : null + const oldStatus = newStatus ? await getStatus(task.status, task.project_id) : null const wasDone = !!oldStatus?.is_done if (newStatus) { fields.push(`status = $${i++}`); vals.push(b.status) @@ -252,9 +305,9 @@ export async function taskRoutes(app) { } fields.push('updated_at = NOW()') - vals.push(task.id, req.user.email) + vals.push(task.id) const { rows: [updated] } = await pool.query( - `UPDATE tasks SET ${fields.join(', ')} WHERE id = $${i++} AND owner_id = $${i} RETURNING *`, + `UPDATE tasks SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`, vals ) if (newStatus?.is_done && !wasDone) await spawnRecurrence(task) From e1c537d3f43b4ec9e3755d84fb9dfc06ace8fb09 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 12 Oct 2026 14:54:54 +0000 Subject: [PATCH 4/4] Frontend: project members/statuses UI, pin-to-today, List/Board toggle - ProjectEditor gained two collapsed-by-default sections: Members (add/ remove viewer/editor via the UserPicker lookup) and Status pipeline (same add/rename/hold/done pattern as Settings' global one, scoped to the project via the new per-project status endpoints). - TaskDetail now resolves status pills against the task's project pipeline (useProjectStatuses) instead of always the global one, gained a Pin button (pinned_today), and a non-owner project 'editor' can now actually edit a task's content/status in the drawer (checked via a project-members lookup, since the task payload alone can't say who's an editor) -- list/project/assignment stay owner-only, matching the backend's 403s. - TodayView now fetches every open task (not just due-dated ones) and buckets into Overdue/Today(or pinned)/Next 7 days/Other, with "Other" collapsed by default -- so an empty agenda reads as "nothing due" instead of "nothing to do". - Extracted BoardView's column rendering into a reusable TaskBoard component (done columns collapse by default there too), and added a List/Board view-mode toggle to both ListView and ProjectDetailView instead of Board only existing as a separate global page you filter down to one list/project. - ListView and ProjectDetailView's Completed sections also collapse by default now, same reasoning as Board's done columns. tsc --noEmit and vite build both clean. --- frontend/src/api.ts | 28 +++- frontend/src/components/ProjectEditor.tsx | 177 +++++++++++++++++++++- frontend/src/components/TaskBoard.tsx | 97 ++++++++++++ frontend/src/components/TaskDetail.tsx | 51 +++++-- frontend/src/components/TaskRow.tsx | 3 +- frontend/src/hooks/useProjectStatuses.ts | 32 ++++ frontend/src/pages/BoardView.tsx | 59 +------- frontend/src/pages/ListView.tsx | 71 ++++++--- frontend/src/pages/ProjectDetailView.tsx | 63 +++++--- frontend/src/pages/TodayView.tsx | 40 ++++- frontend/src/types.ts | 10 ++ 11 files changed, 517 insertions(+), 114 deletions(-) create mode 100644 frontend/src/components/TaskBoard.tsx create mode 100644 frontend/src/hooks/useProjectStatuses.ts diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a31bd34..079ff4c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail, AuthUser } from './types' +import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail, ProjectMember, ProjectRole, AuthUser } from './types' const BASE = '/tasks/api' @@ -121,6 +121,32 @@ export function deleteProject(id: number): Promise<{ ok: boolean }> { return request(`/projects/${id}`, { method: 'DELETE' }) } +// ── Project members (viewer/editor) ────────────────────────── +export function fetchProjectMembers(projectId: number): Promise { + return request(`/projects/${projectId}/members`) +} +export function addProjectMember(projectId: number, email: string, role: ProjectRole): Promise { + return request(`/projects/${projectId}/members`, { method: 'POST', body: JSON.stringify({ email, role }) }) +} +export function removeProjectMember(projectId: number, email: string): Promise<{ ok: boolean }> { + return request(`/projects/${projectId}/members/${encodeURIComponent(email)}`, { method: 'DELETE' }) +} + +// ── Per-project status pipeline (falls back to the global one if a project +// has none of its own — see task_statuses view on the backend) ────────── +export function fetchProjectStatuses(projectId: number): Promise { + return request(`/projects/${projectId}/statuses`) +} +export function createProjectStatus(projectId: number, body: { label: string; value: string; is_hold?: boolean }): Promise { + return request(`/projects/${projectId}/statuses`, { method: 'POST', body: JSON.stringify(body) }) +} +export function updateProjectStatus(projectId: number, statusId: number, body: Partial): Promise { + return request(`/projects/${projectId}/statuses/${statusId}`, { method: 'PATCH', body: JSON.stringify(body) }) +} +export function deleteProjectStatus(projectId: number, statusId: number): Promise<{ ok: boolean }> { + return request(`/projects/${projectId}/statuses/${statusId}`, { method: 'DELETE' }) +} + // ── Checklist items ────────────────────────────────────────── export function addItem(taskId: number, label: string): Promise { return request(`/tasks/${taskId}/items`, { method: 'POST', body: JSON.stringify({ label }) }) diff --git a/frontend/src/components/ProjectEditor.tsx b/frontend/src/components/ProjectEditor.tsx index abd470b..8aba030 100644 --- a/frontend/src/components/ProjectEditor.tsx +++ b/frontend/src/components/ProjectEditor.tsx @@ -1,10 +1,15 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { X, Trash2 } from 'lucide-react' -import type { Project } from '../types' +import { X, Trash2, ChevronDown, PauseCircle, Plus } from 'lucide-react' +import type { Project, ProjectMember, ProjectRole, AppStatus } from '../types' import { LIST_COLOURS } from '../types' -import { createProject, updateProject, deleteProject } from '../api' +import { + createProject, updateProject, deleteProject, + fetchProjectMembers, addProjectMember, removeProjectMember, + fetchProjectStatuses, createProjectStatus, updateProjectStatus, deleteProjectStatus, +} from '../api' import { useProjects } from '../context/ProjectsContext' +import { UserPicker } from './shared' export default function ProjectEditor({ project, onClose }: { project: Project | null; onClose: () => void }) { const { reload } = useProjects() @@ -87,6 +92,9 @@ export default function ProjectEditor({ project, onClose }: { project: Project |
+ {project && } + {project && } +
{project && (
) } + +// Collapsed by default — membership is a lesser-used, occasional action +// compared to the name/description/colour fields above it. +function MembersManager({ projectId }: { projectId: number }) { + const [open, setOpen] = useState(false) + const [members, setMembers] = useState([]) + const [email, setEmail] = useState('') + const [role, setRole] = useState('editor') + const [error, setError] = useState(null) + + useEffect(() => { if (open) fetchProjectMembers(projectId).then(setMembers).catch(e => setError(e.message)) }, [open, projectId]) + + async function add() { + if (!email) return + try { + const m = await addProjectMember(projectId, email, role) + setMembers(ms => [...ms.filter(x => x.user_email !== m.user_email), m]) + setEmail('') + } catch (e) { setError(e instanceof Error ? e.message : 'Failed to add') } + } + async function remove(userEmail: string) { + await removeProjectMember(projectId, userEmail).catch(e => setError(e.message)) + setMembers(ms => ms.filter(m => m.user_email !== userEmail)) + } + + return ( + <> +
setOpen(o => !o)}> + Members {members.length > 0 && `(${members.length})`} + +
+ {open && ( +
+ {error &&
{error}
} +
+ Viewers can see this project and everything in it. Editors can also edit any task inside it — not just + their own — but still can't move a task out of the project or reassign it; that stays with the task's owner. +
+ {members.length > 0 && ( +
+ {members.map(m => ( + + {m.user_email} · {m.role} + remove(m.user_email)} /> + + ))} +
+ )} +
+ + m.user_email)} /> +
+
+ )} + + ) +} + +// Collapsed by default — most projects never need their own pipeline and +// just use the shared global one (see the task_statuses view on the backend). +function ProjectStatusManager({ projectId }: { projectId: number }) { + const [open, setOpen] = useState(false) + const [statuses, setStatuses] = useState([]) + const [isCustom, setIsCustom] = useState(false) + const [newLabel, setNewLabel] = useState('') + const [error, setError] = useState(null) + + async function reload() { + const data = await fetchProjectStatuses(projectId) + setStatuses(data) + // The endpoint returns global statuses verbatim (no project_id field) as + // a fallback when the project has none of its own — that's the cheapest + // way to tell "this project has its own pipeline" from "borrowing the + // shared one" without a second request. + setIsCustom(data.length > 0 && 'project_id' in data[0]) + } + useEffect(() => { if (open) reload().catch(e => setError(e.message)) }, [open, projectId]) + + async function add() { + if (!newLabel.trim()) return + const value = newLabel.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') + if (!value) return + try { + await createProjectStatus(projectId, { label: newLabel.trim(), value }) + setNewLabel('') + await reload() + } catch (e) { setError(e instanceof Error ? e.message : 'Failed to add') } + } + async function rename(id: number, label: string) { + await updateProjectStatus(projectId, id, { label }).catch(e => setError(e.message)) + await reload() + } + async function toggleHold(id: number, is_hold: boolean) { + await updateProjectStatus(projectId, id, { is_hold }).catch(e => setError(e.message)) + await reload() + } + async function toggleDone(id: number, is_done: boolean) { + await updateProjectStatus(projectId, id, { is_done }).catch(e => setError(e.message)) + await reload() + } + async function remove(id: number) { + if (!confirm('Delete this status? Only possible if no tasks in this project currently use it.')) return + try { await deleteProjectStatus(projectId, id); await reload() } + catch (e) { setError(e instanceof Error ? e.message : 'Could not delete — still in use by tasks') } + } + + return ( + <> +
setOpen(o => !o)}> + Status pipeline + +
+ {open && ( +
+ {error &&
{error}
} +
+ {isCustom + ? "This project has its own pipeline, separate from the shared one — tasks here only use these statuses." + : 'Currently using the shared pipeline (Settings → Status pipeline). Add a status below to give this project its own.'} +
+ {statuses.map(s => ( +
+ isCustom && e.target.value.trim() && e.target.value !== s.label && rename(s.id, e.target.value.trim())} /> + {isCustom && ( + <> + + + + + )} +
+ ))} +
+ setNewLabel(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') add() }} /> + +
+
+ )} + + ) +} diff --git a/frontend/src/components/TaskBoard.tsx b/frontend/src/components/TaskBoard.tsx new file mode 100644 index 0000000..7458506 --- /dev/null +++ b/frontend/src/components/TaskBoard.tsx @@ -0,0 +1,97 @@ +import { useState, useRef } from 'react' +import { PauseCircle } from 'lucide-react' +import type { Task, AppStatus } from '../types' +import { PriorityBadge, dueInfo } from './shared' + +// Kanban columns over a given task list + status pipeline — used by the +// global Board page, and as a per-list/per-project view-mode toggle (see +// ListView/ProjectDetailView). Parameterized on `statuses` so callers can +// pass either the app-wide pipeline or a project's own (useProjectStatuses). +export default function TaskBoard({ tasks, statuses, onOpen, onDrop, showList = true }: { + tasks: Task[] + statuses: AppStatus[] + onOpen: (id: number) => void + onDrop: (id: number, status: string) => void + showList?: boolean +}) { + const [over, setOver] = useState(null) + // Done columns collapse by default — otherwise the board fills up with + // finished cards the longer a list/project stays in use. Anything a user + // has explicitly expanded this session stays expanded. + const [openDone, setOpenDone] = useState>(new Set()) + const dragId = useRef(null) + + function drop(status: string) { + const id = dragId.current + dragId.current = null + setOver(null) + if (id !== null) onDrop(id, status) + } + + function toggleDoneColumn(value: string) { + setOpenDone(s => { + const next = new Set(s) + if (next.has(value)) next.delete(value); else next.add(value) + return next + }) + } + + return ( +
+ {statuses.map(status => { + const items = tasks.filter(t => t.status === status.value) + const collapsed = status.is_done && !openDone.has(status.value) + return ( +
{ e.preventDefault(); setOver(status.value) }} + onDragLeave={() => setOver(o => o === status.value ? null : o)} + onDrop={() => drop(status.value)}> +
status.is_done && toggleDoneColumn(status.value)}> + {status.is_hold && } + {status.label} + {items.length} +
+ {collapsed ? ( + items.length > 0 && ( +
toggleDoneColumn(status.value)}> + Show {items.length} done +
+ ) + ) : ( + <> + {items.map(t => ( +
{ dragId.current = t.id }} + onClick={() => onOpen(t.id)}> +
{t.title}
+
+ {showList && t.list_name && ( + + + {t.list_name} + + )} + {t.category_name && ( + + + {t.category_name} + + )} + {(() => { const d = dueInfo(t.due_at); return d && {d.label} })()} + +
+
+ ))} + {items.length === 0 &&
—
} + + )} +
+ ) + })} +
+ ) +} diff --git a/frontend/src/components/TaskDetail.tsx b/frontend/src/components/TaskDetail.tsx index 25b7a0c..6d094f6 100644 --- a/frontend/src/components/TaskDetail.tsx +++ b/frontend/src/components/TaskDetail.tsx @@ -1,16 +1,16 @@ import { useEffect, useState } from 'react' -import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput, Tag, FolderKanban, UserPlus, Send, ChevronDown } from 'lucide-react' +import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput, Tag, FolderKanban, UserPlus, Send, ChevronDown, Pin } from 'lucide-react' import type { TaskDetail as TDetail, Priority, RecurRule } from '../types' import { PRIORITIES, PRIORITY_LABELS, RECUR_RULES, RECUR_LABELS } from '../types' import { fetchTask, updateTask, deleteTask, addItem, updateItem, deleteItem, addComment, - tagTask, untagTask, reallocateTask, + tagTask, untagTask, reallocateTask, fetchProjectMembers, } from '../api' import { useLists } from '../context/ListsContext' import { useCategories } from '../context/CategoriesContext' import { useProjects } from '../context/ProjectsContext' -import { useStatuses } from '../context/StatusesContext' +import { useProjectStatuses } from '../hooks/useProjectStatuses' import { useAuth } from './AuthGate' import { can } from '../types' import { Check_, formatDateTime, toLocalInput, UserPicker } from './shared' @@ -21,8 +21,8 @@ export default function TaskDetail({ taskId, onChange, onClose }: const { lists } = useLists() const { categories } = useCategories() const { projects } = useProjects() - const { statuses } = useStatuses() const [task, setTask] = useState(null) + const { statuses } = useProjectStatuses(task?.project_id ?? null) const [error, setError] = useState(null) const [title, setTitle] = useState('') const [notes, setNotes] = useState('') @@ -32,18 +32,32 @@ export default function TaskDetail({ taskId, onChange, onClose }: const [assignEmail, setAssignEmail] = useState('') const [reallocateEmail, setReallocateEmail] = useState('') const [sharingOpen, setSharingOpen] = useState(false) + // Project 'editor' members can edit a task's content/status without + // owning it (see loadEditable on the backend) — not resolvable from the + // task payload alone, so check membership once the task's project is known. + const [editorViaProject, setEditorViaProject] = useState(false) useEffect(() => { fetchTask(taskId).then(t => { setTask(t); setTitle(t.title); setNotes(t.notes ?? '') }) .catch(e => setError(e.message)) }, [taskId]) + useEffect(() => { + if (!task?.project_id || task.owner_id === user.email) { setEditorViaProject(false); return } + fetchProjectMembers(task.project_id) + .then(members => setEditorViaProject(members.some(m => m.user_email === user.email && m.role === 'editor'))) + .catch(() => setEditorViaProject(false)) + }, [task?.project_id, task?.owner_id, user.email]) + if (error) return (
{error}
) if (!task) return
Loading…
const isOwner = task.owner_id === user.email + // Content/status fields; list/project/assignment stay owner-only (see the + // matching 403 on the backend). + const canEdit = isOwner || editorViaProject // Patch task fields, merge the result locally, and notify the parent view. async function patch(fields: Record) { @@ -126,7 +140,7 @@ export default function TaskDetail({ taskId, onChange, onClose }: