From 4d19b37b8ae00c9594ede837083aa2dd34ad9e05 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 12 Oct 2026 14:10:24 +0000 Subject: [PATCH] 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.
)}