From e1c537d3f43b4ec9e3755d84fb9dfc06ace8fb09 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 12 Oct 2026 14:54:54 +0000 Subject: [PATCH] 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 }: