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.
This commit is contained in:
jtricerolph 2026-10-12 14:54:54 +00:00
parent 2a75c7060e
commit e1c537d3f4
11 changed files with 517 additions and 114 deletions

View file

@ -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<TDetail | null>(null)
const { statuses } = useProjectStatuses(task?.project_id ?? null)
const [error, setError] = useState<string | null>(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 (
<Shell onClose={onClose}><div className="error-banner">{error}</div></Shell>
)
if (!task) return <Shell onClose={onClose}><div className="empty-state">Loading…</div></Shell>
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<string, unknown>) {
@ -126,7 +140,7 @@ export default function TaskDetail({ taskId, onChange, onClose }:
<div style={{ marginTop: 4 }}><Check_ isDone={!!currentStatus?.is_done} onClick={toggleDone} /></div>
<textarea
value={title}
disabled={!isOwner}
disabled={!canEdit}
onChange={e => setTitle(e.target.value)}
onBlur={() => title.trim() && title !== task.title && patch({ title: title.trim() })}
rows={1}
@ -135,10 +149,18 @@ export default function TaskDetail({ taskId, onChange, onClose }:
fontSize: 17, fontWeight: 600, fontFamily: 'var(--font)', color: 'var(--text-dark)',
}}
/>
<button className="btn-ghost-sm"
title={task.pinned_today ? 'Unpin from Today' : 'Pin to Today'}
style={task.pinned_today ? { color: 'var(--gold)' } : undefined}
onClick={() => patch({ pinned_today: !task.pinned_today })}>
<Pin size={16} strokeWidth={1.75} fill={task.pinned_today ? 'var(--gold)' : 'none'} />
</button>
</div>
{!isOwner && (
<div className="field-hint" style={{ marginBottom: 10 }}>
You're tagged on this task, owned by {task.owner_id}. You can comment and check off items, but editing is owner-only.
{editorViaProject
? "You're a collaborator on this task's project, owned by " + task.owner_id + ". You can edit it, but can't change its list, project, or assignment."
: "You're tagged on this task, owned by " + task.owner_id + ". You can comment and check off items, but editing is owner-only."}
</div>
)}
@ -146,7 +168,7 @@ export default function TaskDetail({ taskId, onChange, onClose }:
<div className="chip-bar">
{statuses.map(s => (
<button key={s.value} className={`chip${task.status === s.value ? ' active' : ''}`}
disabled={!isOwner} onClick={() => isOwner && patch({ status: s.value })}>
disabled={!canEdit} onClick={() => canEdit && patch({ status: s.value })}>
{s.label}
</button>
))}
@ -161,11 +183,12 @@ export default function TaskDetail({ taskId, onChange, onClose }:
{!task.list_id && <option value="">Unfiled</option>}
{lists.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
{!isOwner && canEdit && <div className="field-hint">Only the owner can move this between lists.</div>}
</div>
<div className="field-row">
<div className="field">
<label><Tag size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Category</label>
<select value={task.category_id ?? ''} disabled={!isOwner}
<select value={task.category_id ?? ''} disabled={!canEdit}
onChange={e => patch({ category_id: e.target.value ? Number(e.target.value) : null })}>
<option value="">None</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
@ -183,13 +206,13 @@ export default function TaskDetail({ taskId, onChange, onClose }:
<div className="field-row">
<div className="field">
<label>Priority</label>
<select value={task.priority} disabled={!isOwner} onChange={e => patch({ priority: e.target.value as Priority })}>
<select value={task.priority} disabled={!canEdit} onChange={e => patch({ priority: e.target.value as Priority })}>
{PRIORITIES.map(p => <option key={p} value={p}>{PRIORITY_LABELS[p]}</option>)}
</select>
</div>
<div className="field">
<label><Repeat size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Repeat</label>
<select value={task.recur_rule ?? ''} disabled={!isOwner} onChange={e => patch({ recur_rule: e.target.value || null })}>
<select value={task.recur_rule ?? ''} disabled={!canEdit} onChange={e => patch({ recur_rule: e.target.value || null })}>
<option value="">Doesn't repeat</option>
{RECUR_RULES.map(r => <option key={r} value={r}>{RECUR_LABELS[r as RecurRule]}</option>)}
</select>
@ -198,12 +221,12 @@ export default function TaskDetail({ taskId, onChange, onClose }:
<div className="field-row">
<div className="field">
<label><CalendarClock size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Due</label>
<input type="datetime-local" value={toLocalInput(task.due_at)} disabled={!isOwner}
<input type="datetime-local" value={toLocalInput(task.due_at)} disabled={!canEdit}
onChange={e => patch({ due_at: toLocalIso(e.target.value) })} />
</div>
<div className="field">
<label><Bell size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Remind</label>
<input type="datetime-local" value={toLocalInput(task.remind_at)} disabled={!isOwner}
<input type="datetime-local" value={toLocalInput(task.remind_at)} disabled={!canEdit}
onChange={e => patch({ remind_at: toLocalIso(e.target.value) })} />
</div>
</div>
@ -268,9 +291,9 @@ export default function TaskDetail({ taskId, onChange, onClose }:
{/* Notes */}
<div className="section-title">Notes</div>
<div className="field">
<textarea value={notes} placeholder="Add notes…" disabled={!isOwner}
<textarea value={notes} placeholder="Add notes…" disabled={!canEdit}
onChange={e => setNotes(e.target.value)}
onBlur={() => isOwner && notes !== (task.notes ?? '') && patch({ notes })} />
onBlur={() => canEdit && notes !== (task.notes ?? '') && patch({ notes })} />
</div>
{/* Checklist */}