onOpen(task)}
draggable={draggable}
onDragStart={onDragStart}
@@ -34,7 +34,7 @@ export default function TaskRow({
)}
{task.title}
@@ -45,6 +45,12 @@ export default function TaskRow({
{task.list_name}
)}
+ {task.category_name && (
+
+
+ {task.category_name}
+
+ )}
{due &&
{due.label} }
{task.remind_at &&
}
{task.recur_rule &&
}
diff --git a/frontend/src/components/shared.tsx b/frontend/src/components/shared.tsx
index 54ca53a..7b43bf6 100644
--- a/frontend/src/components/shared.tsx
+++ b/frontend/src/components/shared.tsx
@@ -1,24 +1,61 @@
-import { Check } from 'lucide-react'
-import type { Priority, Status } from '../types'
-import { PRIORITY_LABELS, STATUS_LABELS } from '../types'
+import { useState } from 'react'
+import { Check, ChevronDown, Tag } from 'lucide-react'
+import type { Priority, Category } from '../types'
+import { PRIORITY_LABELS } from '../types'
+import { useCategories } from '../context/CategoriesContext'
export function PriorityBadge({ priority }: { priority: Priority }) {
if (priority === 'medium') return null
return
{PRIORITY_LABELS[priority]}
}
-export function StatusBadge({ status }: { status: Status }) {
- return
{STATUS_LABELS[status]}
+// Round completion checkbox. Driven by the status's is_done flag rather than
+// a fixed literal, since the pipeline is admin-editable.
+export function Check_({ isDone, onClick }: { isDone: boolean; onClick: (e: React.MouseEvent) => void }) {
+ return (
+
+ {isDone && }
+
+ )
}
-// Round completion checkbox. 'doing' shows a partial ring, 'done' a filled tick.
-export function Check_({ status, onClick }: { status: Status; onClick: (e: React.MouseEvent) => void }) {
- const cls = status === 'done' ? 'check checked' : status === 'doing' ? 'check doing' : 'check'
+// Collapsible multi-select category filter chip-bar — used on Board/List views.
+// `selected` empty means "no filter" (show everything).
+export function CategoryFilter({ selected, onChange }: { selected: number[]; onChange: (ids: number[]) => void }) {
+ const { categories } = useCategories()
+ const [open, setOpen] = useState(false)
+ if (categories.length === 0) return null
+
+ function toggle(id: number) {
+ onChange(selected.includes(id) ? selected.filter(i => i !== id) : [...selected, id])
+ }
+
+ const summary = selected.length === 0
+ ? 'All categories'
+ : categories.filter(c => selected.includes(c.id)).map(c => c.name).join(', ')
+
return (
-
- {status === 'done' && }
- {status === 'doing' && }
-
+
+
setOpen(o => !o)}>
+
+ Category
+ {summary}
+
+
+ {open && (
+
+ {selected.length > 0 && (
+ onChange([])}>Clear
+ )}
+ {categories.map((c: Category) => (
+ toggle(c.id)}>
+
+ {c.name}
+
+ ))}
+
+ )}
+
)
}
diff --git a/frontend/src/context/CategoriesContext.tsx b/frontend/src/context/CategoriesContext.tsx
new file mode 100644
index 0000000..9394387
--- /dev/null
+++ b/frontend/src/context/CategoriesContext.tsx
@@ -0,0 +1,32 @@
+import { createContext, useContext, useEffect, useState, useCallback } from 'react'
+import type { Category } from '../types'
+import { fetchCategories } from '../api'
+
+interface CategoriesCtx {
+ categories: Category[]
+ loading: boolean
+ reload: () => Promise
+}
+
+const Ctx = createContext(null)
+
+export function useCategories() {
+ const ctx = useContext(Ctx)
+ if (!ctx) throw new Error('useCategories must be used inside CategoriesProvider')
+ return ctx
+}
+
+export function CategoriesProvider({ children }: { children: React.ReactNode }) {
+ const [categories, setCategories] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ const reload = useCallback(async () => {
+ const data = await fetchCategories().catch(() => [])
+ setCategories(data)
+ setLoading(false)
+ }, [])
+
+ useEffect(() => { reload() }, [reload])
+
+ return {children}
+}
diff --git a/frontend/src/context/ProjectsContext.tsx b/frontend/src/context/ProjectsContext.tsx
new file mode 100644
index 0000000..a1969ca
--- /dev/null
+++ b/frontend/src/context/ProjectsContext.tsx
@@ -0,0 +1,32 @@
+import { createContext, useContext, useEffect, useState, useCallback } from 'react'
+import type { Project } from '../types'
+import { fetchProjects } from '../api'
+
+interface ProjectsCtx {
+ projects: Project[]
+ loading: boolean
+ reload: () => Promise
+}
+
+const Ctx = createContext(null)
+
+export function useProjects() {
+ const ctx = useContext(Ctx)
+ if (!ctx) throw new Error('useProjects must be used inside ProjectsProvider')
+ return ctx
+}
+
+export function ProjectsProvider({ children }: { children: React.ReactNode }) {
+ const [projects, setProjects] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ const reload = useCallback(async () => {
+ const data = await fetchProjects().catch(() => [])
+ setProjects(data)
+ setLoading(false)
+ }, [])
+
+ useEffect(() => { reload() }, [reload])
+
+ return {children}
+}
diff --git a/frontend/src/context/StatusesContext.tsx b/frontend/src/context/StatusesContext.tsx
new file mode 100644
index 0000000..1226901
--- /dev/null
+++ b/frontend/src/context/StatusesContext.tsx
@@ -0,0 +1,47 @@
+import { createContext, useContext, useEffect, useState, useCallback } from 'react'
+import type { AppStatus } from '../types'
+import { fetchStatuses } from '../api'
+
+interface StatusesCtx {
+ statuses: AppStatus[]
+ loading: boolean
+ reload: () => Promise
+ byValue: (value: string) => AppStatus | undefined
+ // Lowest-position done/not-done statuses — used as the toggle-done targets
+ // now that the pipeline is admin-editable rather than a fixed 'done'/'todo'.
+ firstDoneValue: () => string | undefined
+ firstNotDoneValue: () => string | undefined
+}
+
+const Ctx = createContext(null)
+
+export function useStatuses() {
+ const ctx = useContext(Ctx)
+ if (!ctx) throw new Error('useStatuses must be used inside StatusesProvider')
+ return ctx
+}
+
+export function StatusesProvider({ children }: { children: React.ReactNode }) {
+ const [statuses, setStatuses] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ const reload = useCallback(async () => {
+ const data = await fetchStatuses().catch(() => [])
+ setStatuses(data)
+ setLoading(false)
+ }, [])
+
+ useEffect(() => { reload() }, [reload])
+
+ const byValue = useCallback((value: string) => statuses.find(s => s.value === value), [statuses])
+ // statuses is already ordered by position (see GET /api/statuses), so the
+ // first match in array order is the lowest-position one.
+ const firstDoneValue = useCallback(() => statuses.find(s => s.is_done)?.value, [statuses])
+ const firstNotDoneValue = useCallback(() => statuses.find(s => !s.is_done)?.value, [statuses])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index c96b1fd..3c63bc6 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -183,6 +183,7 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mid); margin-bottom: 4px; }
.field input[type="text"], .field input[type="email"], .field input[type="date"],
+.field input[type="datetime-local"], .field input[type="time"],
.field input[type="number"], .field select, .field textarea {
width: 100%;
border: 1px solid var(--card-border);
@@ -193,6 +194,15 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
color: var(--text-dark);
background: var(--card-bg);
}
+/* Safari/iOS renders the calendar/clock glyph in the platform's default
+ near-black tint regardless of the input's own color — recolour it to match
+ the field's text colour so it doesn't look like an unstyled foreign control. */
+.field input[type="date"]::-webkit-calendar-picker-indicator,
+.field input[type="datetime-local"]::-webkit-calendar-picker-indicator,
+.field input[type="time"]::-webkit-calendar-picker-indicator {
+ filter: invert(46%) sepia(11%) saturate(495%) hue-rotate(176deg) brightness(94%) contrast(87%);
+ cursor: pointer;
+}
.field textarea { min-height: 72px; resize: vertical; }
.field-row { display: flex; gap: 12px; }
.field-row > .field { flex: 1; }
@@ -555,6 +565,8 @@ table.data tr.clickable:hover td { background: var(--body-bg); }
.board { display: flex; gap: 12px; align-items: flex-start; overflow-x: auto; padding-bottom: 8px; }
.board-col { flex: 1; min-width: 240px; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); padding: 10px; }
.board-col.drop-target { border-color: var(--gold); background: var(--warn-bg); }
+.board-col-hold { background: var(--warn-bg); }
+.board-col-hold.drop-target { background: var(--warn-bg); }
.board-col-head { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
.board-col-count { margin-left: auto; font-weight: 500; }
diff --git a/frontend/src/pages/BoardView.tsx b/frontend/src/pages/BoardView.tsx
index 38773b3..428b7bb 100644
--- a/frontend/src/pages/BoardView.tsx
+++ b/frontend/src/pages/BoardView.tsx
@@ -1,16 +1,18 @@
import { useEffect, useState, useCallback, useRef } from 'react'
-import { Columns3 } from 'lucide-react'
-import type { Task, Status } from '../types'
-import { STATUSES, STATUS_LABELS } from '../types'
+import { Columns3, PauseCircle } from 'lucide-react'
+import type { Task } from '../types'
import { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
-import { PriorityBadge, dueInfo } from '../components/shared'
+import { useStatuses } from '../context/StatusesContext'
+import { PriorityBadge, dueInfo, CategoryFilter } from '../components/shared'
export default function BoardView() {
const { openTask, changeVersion } = useTaskDrawer()
+ const { statuses } = useStatuses()
const [tasks, setTasks] = useState([])
const [error, setError] = useState(null)
- const [over, setOver] = useState(null)
+ const [over, setOver] = useState(null)
+ const [categoryFilter, setCategoryFilter] = useState([])
const dragId = useRef(null)
const load = useCallback(() => {
@@ -18,7 +20,7 @@ export default function BoardView() {
}, [])
useEffect(() => { load() }, [load, changeVersion])
- async function drop(status: Status) {
+ async function drop(status: string) {
const id = dragId.current
dragId.current = null
setOver(null)
@@ -30,24 +32,30 @@ export default function BoardView() {
load()
}
+ const visibleTasks = categoryFilter.length === 0
+ ? tasks
+ : tasks.filter(t => t.category_id !== null && categoryFilter.includes(t.category_id))
+
return (
Board
+
{error &&
{error}
}
- {STATUSES.map(status => {
- const items = tasks.filter(t => t.status === status)
+ {statuses.map(status => {
+ const items = visibleTasks.filter(t => t.status === status.value)
return (
-
{ e.preventDefault(); setOver(status) }}
- onDragLeave={() => setOver(o => o === status ? null : o)}
- onDrop={() => drop(status)}>
+
{ e.preventDefault(); setOver(status.value) }}
+ onDragLeave={() => setOver(o => o === status.value ? null : o)}
+ onDrop={() => drop(status.value)}>
- {STATUS_LABELS[status]}
+ {status.is_hold &&
}
+ {status.label}
{items.length}
{items.map(t => (
@@ -63,6 +71,12 @@ export default function BoardView() {
{t.list_name}
)}
+ {t.category_name && (
+
+
+ {t.category_name}
+
+ )}
{(() => { const d = dueInfo(t.due_at); return d &&
{d.label} })()}
diff --git a/frontend/src/pages/DispatchView.tsx b/frontend/src/pages/DispatchView.tsx
new file mode 100644
index 0000000..6ec220c
--- /dev/null
+++ b/frontend/src/pages/DispatchView.tsx
@@ -0,0 +1,87 @@
+import { useEffect, useState, useCallback } from 'react'
+import { Send } from 'lucide-react'
+import type { Task } from '../types'
+import { fetchTasks, reallocateTask } from '../api'
+import { useAuth } from '../components/AuthGate'
+import { can } from '../types'
+import { dueInfo, PriorityBadge } 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
+// visibility to the owner/assignee/tagged users, not dispatchers. This view
+// only needs enough per-row info to decide where a task should go.
+export default function DispatchView() {
+ const { user } = useAuth()
+ const [tasks, setTasks] = useState
([])
+ const [error, setError] = useState(null)
+ const [movingId, setMovingId] = useState(null)
+ const [target, setTarget] = useState('')
+
+ const load = useCallback(() => {
+ fetchTasks({ view: 'all' }).then(setTasks).catch(e => setError(e.message))
+ }, [])
+ useEffect(() => { load() }, [load])
+
+ if (!can(user, 'reallocate')) {
+ return You don't have the Reallocate capability.
+ }
+
+ async function move(id: number) {
+ if (!target.trim()) return
+ await reallocateTask(id, target.trim()).catch(e => setError(e.message))
+ setMovingId(null)
+ setTarget('')
+ load()
+ }
+
+ const open = tasks.filter(t => !t.is_done)
+
+ return (
+
+
+
Dispatch
+
+
+ Every open task across every user — move any of them to someone else. Only shows title/owner/status; open the
+ full task from that person's own view for details.
+
+ {error &&
{error}
}
+
+ {open.length === 0 &&
Nothing open anywhere.
}
+
+ {open.map(t => {
+ const due = dueInfo(t.due_at)
+ return (
+
+
+
{t.title}
+
+
{t.owner_id}
+
{t.status_label ?? t.status}
+ {t.category_name && (
+
+
+ {t.category_name}
+
+ )}
+ {due &&
{due.label} }
+
+
+
+ {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)} disabled={!target.trim()}>Move
+ setMovingId(null)}>Cancel
+
+ ) : (
+
{ setMovingId(t.id); setTarget('') }}>Reallocate
+ )}
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/pages/ListView.tsx b/frontend/src/pages/ListView.tsx
index e05b08e..042d41a 100644
--- a/frontend/src/pages/ListView.tsx
+++ b/frontend/src/pages/ListView.tsx
@@ -4,14 +4,17 @@ import { Plus, Pencil } from 'lucide-react'
import type { Task, List } from '../types'
import { fetchTasks, createTask, updateTask, reorderTasks } from '../api'
import { useLists } from '../context/ListsContext'
+import { useStatuses } from '../context/StatusesContext'
import { useTaskDrawer } from '../components/TaskDrawer'
import TaskRow from '../components/TaskRow'
import ListEditor from '../components/ListEditor'
+import { CategoryFilter } from '../components/shared'
export default function ListView() {
const { id } = useParams()
const listId = Number(id)
const { lists } = useLists()
+ const { firstDoneValue, firstNotDoneValue } = useStatuses()
const { openTask, changeVersion } = useTaskDrawer()
const list = lists.find(l => l.id === listId)
@@ -19,6 +22,7 @@ export default function ListView() {
const [error, setError] = useState(null)
const [newTitle, setNewTitle] = useState('')
const [editing, setEditing] = useState(false)
+ const [categoryFilter, setCategoryFilter] = useState([])
const dragId = useRef(null)
const load = useCallback(() => {
@@ -34,7 +38,9 @@ export default function ListView() {
load()
}
async function toggle(t: Task) {
- await updateTask(t.id, { status: t.status === 'done' ? 'todo' : 'done' })
+ const next = t.is_done ? firstNotDoneValue() : firstDoneValue()
+ if (!next) return
+ await updateTask(t.id, { status: next })
load()
}
@@ -53,8 +59,11 @@ export default function ListView() {
reorderTasks(ids).catch(() => load())
}
- const open = tasks.filter(t => t.status !== 'done')
- const done = tasks.filter(t => t.status === 'done')
+ const visible = categoryFilter.length === 0
+ ? tasks
+ : tasks.filter(t => t.category_id !== null && categoryFilter.includes(t.category_id))
+ const open = visible.filter(t => !t.is_done)
+ const done = visible.filter(t => t.is_done)
return (
@@ -69,6 +78,7 @@ export default function ListView() {
)}
+
{error && {error}
}
diff --git a/frontend/src/pages/ProjectDetailView.tsx b/frontend/src/pages/ProjectDetailView.tsx
new file mode 100644
index 0000000..dba1566
--- /dev/null
+++ b/frontend/src/pages/ProjectDetailView.tsx
@@ -0,0 +1,94 @@
+import { useEffect, useState, useCallback } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import { FolderKanban, Pencil } from 'lucide-react'
+import type { ProjectDetail, Task } from '../types'
+import { fetchProject, updateTask } from '../api'
+import { useAuth } from '../components/AuthGate'
+import { useStatuses } from '../context/StatusesContext'
+import { useTaskDrawer } from '../components/TaskDrawer'
+import TaskRow from '../components/TaskRow'
+import ProjectEditor from '../components/ProjectEditor'
+
+export default function ProjectDetailView() {
+ const { id } = useParams()
+ const projectId = Number(id)
+ const { user } = useAuth()
+ const { firstDoneValue, firstNotDoneValue } = useStatuses()
+ const navigate = useNavigate()
+ const { openTask, changeVersion } = useTaskDrawer()
+ const [project, setProject] = useState
(null)
+ const [error, setError] = useState(null)
+ const [editing, setEditing] = useState(false)
+
+ const load = useCallback(() => {
+ if (!projectId) return
+ fetchProject(projectId).then(setProject).catch(e => setError(e.message))
+ }, [projectId])
+ useEffect(() => { load() }, [load, changeVersion])
+
+ // 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) {
+ const next = t.is_done ? firstNotDoneValue() : firstDoneValue()
+ if (!next) return
+ await updateTask(t.id, { status: next }).catch(() => {})
+ load()
+ }
+
+ if (error) return
+ if (!project) return
+
+ const canManage = project.created_by === user.email || user.is_admin
+ const open = project.tasks.filter(t => !t.is_done)
+ const done = project.tasks.filter(t => t.is_done)
+ const pct = project.tasks.length ? Math.round((done.length / project.tasks.length) * 100) : 0
+
+ return (
+
+
+
+
+ {project.name}
+
+ {canManage && (
+
setEditing(true)}>
+ Edit
+
+ )}
+
+ {project.description &&
{project.description}
}
+
+
+
{done.length}/{project.tasks.length} tasks done
+
+ {project.tasks.length === 0 && (
+
+
+ No tasks in this project yet — set a task's Project field from its detail view.
+
+ )}
+
+ {open.map(t => (
+
openTask(t.id)} />
+ ))}
+
+ {done.length > 0 && (
+ <>
+ Completed ({done.length})
+ {done.map(t => (
+ openTask(t.id)} />
+ ))}
+ >
+ )}
+
+ {editing && { setEditing(false); load() }} />}
+ {!canManage && (
+
+ navigate('/projects')} style={{ padding: 0 }}>← Back to projects
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/ProjectsView.tsx b/frontend/src/pages/ProjectsView.tsx
new file mode 100644
index 0000000..db49524
--- /dev/null
+++ b/frontend/src/pages/ProjectsView.tsx
@@ -0,0 +1,51 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { FolderKanban, Plus } from 'lucide-react'
+import { useProjects } from '../context/ProjectsContext'
+import ProjectEditor from '../components/ProjectEditor'
+
+export default function ProjectsView() {
+ const { projects, loading } = useProjects()
+ const navigate = useNavigate()
+ const [editing, setEditing] = useState(false)
+
+ return (
+
+
+
Projects
+
setEditing(true)}>
+ New project
+
+
+
+ {!loading && projects.length === 0 && (
+
No projects yet — group related tasks together with one.
+ )}
+
+
+ {projects.map(p => {
+ const pct = p.task_count ? Math.round((p.done_count! / p.task_count) * 100) : 0
+ return (
+
navigate(`/project/${p.id}`)}>
+
+ {p.description && (
+
{p.description}
+ )}
+
+
+ {p.done_count ?? 0}/{p.task_count ?? 0} tasks done
+
+
+ )
+ })}
+
+
+ {editing &&
setEditing(false)} />}
+
+ )
+}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index baefac5..1df67c3 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -1,8 +1,11 @@
import { useEffect, useState } from 'react'
-import { Bell, BellOff } from 'lucide-react'
+import { Bell, BellOff, Plus, Trash2, PauseCircle } from 'lucide-react'
import type { AppConfig } from '../types'
-import { fetchConfig, updateConfig } from '../api'
+import { LIST_COLOURS } from '../types'
+import { fetchConfig, updateConfig, createStatus, updateStatus, deleteStatus, createCategory, updateCategory, deleteCategory } from '../api'
import { useAuth } from '../components/AuthGate'
+import { useStatuses } from '../context/StatusesContext'
+import { useCategories } from '../context/CategoriesContext'
import { can } from '../types'
export default function Settings() {
@@ -76,6 +79,145 @@ export default function Settings() {
)}
+
+ {editable && (
+ <>
+
+
+ >
+ )}
)
}
+
+function StatusManager() {
+ const { statuses, reload } = useStatuses()
+ const [newLabel, setNewLabel] = useState('')
+ const [error, setError] = useState
(null)
+
+ async function add() {
+ if (!newLabel.trim()) return
+ const value = newLabel.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
+ if (!value) return
+ try {
+ await createStatus({ 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 updateStatus(id, { label }).catch(e => setError(e.message))
+ await reload()
+ }
+ async function toggleHold(id: number, is_hold: boolean) {
+ await updateStatus(id, { is_hold }).catch(e => setError(e.message))
+ await reload()
+ }
+ async function remove(id: number) {
+ if (!confirm('Delete this status? Only possible if no tasks currently use it.')) return
+ try { await deleteStatus(id); await reload() }
+ catch (e) { setError(e instanceof Error ? e.message : 'Could not delete — still in use by tasks') }
+ }
+
+ return (
+ <>
+ Status pipeline
+ {error && {error}
}
+
+
+ Shared across every task and project. "On hold" columns just get a visual flag on the board — everything
+ else about them behaves like a normal status. "Done" behaviour is fixed on the built-in Done status.
+
+ {statuses.map(s => (
+
+ ))}
+
+
setNewLabel(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') add() }} />
+
+ Add
+
+
+
+ >
+ )
+}
+
+function CategoryManager() {
+ const { categories, reload } = useCategories()
+ const [newName, setNewName] = useState('')
+ const [newColour, setNewColour] = useState(LIST_COLOURS[0])
+ const [error, setError] = useState(null)
+
+ async function add() {
+ if (!newName.trim()) return
+ try {
+ await createCategory({ name: newName.trim(), colour: newColour })
+ setNewName('')
+ await reload()
+ } catch (e) { setError(e instanceof Error ? e.message : 'Failed to add') }
+ }
+ async function rename(id: number, name: string) {
+ await updateCategory(id, { name }).catch(e => setError(e.message))
+ await reload()
+ }
+ async function remove(id: number) {
+ if (!confirm('Delete this category? Tasks using it just become uncategorised.')) return
+ await deleteCategory(id).catch(e => setError(e.message))
+ await reload()
+ }
+
+ return (
+ <>
+ Categories
+ {error && {error}
}
+
+
+ Shared, flat labels — every user picks from the same list. One category per task.
+
+ {categories.map(c => (
+
+
+ e.target.value.trim() && e.target.value !== c.name && rename(c.id, e.target.value.trim())} />
+ remove(c.id)}>
+
+
+
+ ))}
+
+
setNewName(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') add() }} />
+
+ {LIST_COLOURS.slice(0, 5).map(c => (
+ setNewColour(c)} title={c}
+ style={{
+ width: 20, height: 20, borderRadius: '50%', background: c, cursor: 'pointer',
+ border: newColour === c ? '2px solid var(--text-dark)' : '2px solid transparent',
+ }} />
+ ))}
+
+
+ Add
+
+
+
+ >
+ )
+}
diff --git a/frontend/src/pages/TodayView.tsx b/frontend/src/pages/TodayView.tsx
index 90e43cf..41afa29 100644
--- a/frontend/src/pages/TodayView.tsx
+++ b/frontend/src/pages/TodayView.tsx
@@ -3,12 +3,14 @@ import { CalendarDays } from 'lucide-react'
import type { Task } from '../types'
import { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
+import { useStatuses } from '../context/StatusesContext'
import TaskRow from '../components/TaskRow'
function startOfDay(d: Date) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x }
export default function TodayView() {
const { openTask, changeVersion } = useTaskDrawer()
+ const { firstDoneValue, firstNotDoneValue } = useStatuses()
const [tasks, setTasks] = useState([])
const [error, setError] = useState(null)
@@ -18,7 +20,9 @@ export default function TodayView() {
useEffect(() => { load() }, [load, changeVersion])
async function toggle(t: Task) {
- await updateTask(t.id, { status: t.status === 'done' ? 'todo' : 'done' })
+ const next = t.is_done ? firstNotDoneValue() : firstDoneValue()
+ if (!next) return
+ await updateTask(t.id, { status: next })
load()
}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index d98bbd3..daee638 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -1,13 +1,12 @@
export type Priority = 'low' | 'medium' | 'high'
-export type Status = 'todo' | 'doing' | 'done'
+// Status is now an admin-editable pipeline (see AppStatus below), not a fixed
+// enum — the string is whatever `statuses.value` the admin has configured.
+export type Status = string
export type RecurRule = 'daily' | 'weekdays' | 'weekly' | 'monthly'
export const PRIORITIES: Priority[] = ['low', 'medium', 'high']
export const PRIORITY_LABELS: Record = { low: 'Low', medium: 'Medium', high: 'High' }
-export const STATUSES: Status[] = ['todo', 'doing', 'done']
-export const STATUS_LABELS: Record = { todo: 'To do', doing: 'Doing', done: 'Done' }
-
export const RECUR_RULES: RecurRule[] = ['daily', 'weekdays', 'weekly', 'monthly']
export const RECUR_LABELS: Record = {
daily: 'Every day',
@@ -34,10 +33,12 @@ export interface List {
export interface Task {
id: number
- list_id: number
+ list_id: number | null
owner_id: string
created_by: string
assigned_to: string | null
+ category_id: number | null
+ project_id: number | null
title: string
notes: string | null
status: Status
@@ -53,10 +54,57 @@ export interface Task {
// joined / derived
list_name?: string
list_colour?: string
+ status_label?: string
+ is_done?: boolean
+ is_hold?: boolean
+ category_name?: string
+ category_colour?: string
+ project_name?: string
+ project_colour?: string
item_count?: number
item_done?: number
}
+export interface AppStatus {
+ id: number
+ label: string
+ value: string
+ position: number
+ is_done: boolean
+ is_hold: boolean
+}
+
+export interface Category {
+ id: number
+ name: string
+ colour: string
+ sort_order: number
+ created_at: string
+}
+
+export interface Project {
+ id: number
+ created_by: string
+ name: string
+ description: string | null
+ colour: string
+ is_archived: boolean
+ sort_order: number
+ created_at: string
+ task_count?: number
+ done_count?: number
+}
+
+export interface ProjectDetail extends Project {
+ tasks: Task[]
+}
+
+export interface TaskTagEntry {
+ user_email: string
+ tagged_by: string
+ created_at: string
+}
+
export interface TaskItem {
id: number
task_id: number
@@ -77,6 +125,7 @@ export interface TaskComment {
export interface TaskDetail extends Task {
items: TaskItem[]
comments: TaskComment[]
+ tags: TaskTagEntry[]
}
export interface AppConfig {