Initial commit: personal task planner with categories, projects, and tiered assignment

Personal-first lists/tasks/reminders/recurrence, plus an admin-editable status
pipeline, shared categories, project containers, and a three-tier permission
model for cross-user work: assign (hand off your own tasks), reallocate
(dispatcher — move anyone's tasks), admin (reserved, full manage-all).
This commit is contained in:
jtricerolph 2026-09-17 08:07:00 +00:00
commit d8dc4e2b81
54 changed files with 10908 additions and 0 deletions

40
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,40 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import AuthGate from './components/AuthGate'
import { UpdateBanner } from './components/UpdateBanner'
import IosInstallHint from './components/IosInstallHint'
import { useVersionCheck } from './hooks/useVersionCheck'
import Layout from './components/Layout'
import { ListsProvider } from './context/ListsContext'
import { TaskDrawerProvider } from './components/TaskDrawer'
import TodayView from './pages/TodayView'
import ListView from './pages/ListView'
import BoardView from './pages/BoardView'
import Settings from './pages/Settings'
export default function App() {
const updateAvailable = useVersionCheck('/tasks/health')
return (
<>
<BrowserRouter basename="/tasks">
<AuthGate>
<ListsProvider>
<TaskDrawerProvider>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/today" replace />} />
<Route path="/today" element={<TodayView />} />
<Route path="/board" element={<BoardView />} />
<Route path="/list/:id" element={<ListView />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/today" replace />} />
</Routes>
</Layout>
</TaskDrawerProvider>
</ListsProvider>
</AuthGate>
</BrowserRouter>
<UpdateBanner visible={updateAvailable} />
<IosInstallHint />
</>
)
}

82
frontend/src/api.ts Normal file
View file

@ -0,0 +1,82 @@
import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig } from './types'
const BASE = '/tasks/api'
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
})
if (res.status === 401) {
;(window.top ?? window).location.href = '/login'
throw new Error('Unauthenticated')
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Request failed: ${res.status}`)
}
if (res.status === 204) return undefined as T
return res.json()
}
// ── Lists ────────────────────────────────────────────────────
export function fetchLists(includeArchived = false): Promise<List[]> {
return request(`/lists${includeArchived ? '?archived=1' : ''}`)
}
export function createList(body: { name: string; colour?: string; icon?: string }): Promise<List> {
return request('/lists', { method: 'POST', body: JSON.stringify(body) })
}
export function updateList(id: number, body: Partial<List>): Promise<List> {
return request(`/lists/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteList(id: number): Promise<{ ok: boolean }> {
return request(`/lists/${id}`, { method: 'DELETE' })
}
// ── Tasks ────────────────────────────────────────────────────
export function fetchTasks(params: { list_id?: number; view?: 'today'; status?: string } = {}): Promise<Task[]> {
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) if (v !== undefined) qs.set(k, String(v))
const s = qs.toString()
return request(`/tasks${s ? `?${s}` : ''}`)
}
export function fetchTask(id: number): Promise<TaskDetail> {
return request(`/tasks/${id}`)
}
export function createTask(body: Record<string, unknown>): Promise<Task> {
return request('/tasks', { method: 'POST', body: JSON.stringify(body) })
}
export function updateTask(id: number, body: Record<string, unknown>): Promise<Task> {
return request(`/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteTask(id: number): Promise<{ ok: boolean }> {
return request(`/tasks/${id}`, { method: 'DELETE' })
}
export function reorderTasks(ids: number[]): Promise<{ ok: boolean }> {
return request('/tasks/reorder', { method: 'POST', body: JSON.stringify({ ids }) })
}
// ── Checklist items ──────────────────────────────────────────
export function addItem(taskId: number, label: string): Promise<TaskItem> {
return request(`/tasks/${taskId}/items`, { method: 'POST', body: JSON.stringify({ label }) })
}
export function updateItem(itemId: number, body: Partial<TaskItem>): Promise<TaskItem> {
return request(`/tasks/items/${itemId}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteItem(itemId: number): Promise<{ ok: boolean }> {
return request(`/tasks/items/${itemId}`, { method: 'DELETE' })
}
// ── Comments ─────────────────────────────────────────────────
export function addComment(taskId: number, body: string): Promise<TaskComment> {
return request(`/tasks/${taskId}/comments`, { method: 'POST', body: JSON.stringify({ body }) })
}
// ── Config ───────────────────────────────────────────────────
export function fetchConfig(): Promise<AppConfig> {
return request('/config')
}
export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean }> {
return request(`/config/${key}`, { method: 'PUT', body: JSON.stringify({ value }) })
}

View file

@ -0,0 +1,178 @@
import { useEffect, useRef, useState, createContext, useContext } from 'react'
import type { User } from '../types'
import { usePushSubscription } from '../hooks/usePushSubscription'
function getInactivityMs(): number | null {
if (window.matchMedia('(display-mode: standalone)').matches) return null
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('hnf_inactivity_mins='))
if (!c) return null
const mins = parseInt(c.split('=')[1])
return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000
}
// Only bounce to the central login when actually embedded in the portal shell.
// A standalone PWA or a directly-opened browser tab must never navigate away
// from its own start_url/scope — otherwise it loses its installed-app context.
function isEmbedded() {
return window.top !== window
}
interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(null)
export function useAuth() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
return ctx
}
function PushSubscriber({ user }: { user: User }) {
usePushSubscription(user)
return null
}
export default function AuthGate({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
const [user, setUser] = useState<User | null>(null)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
fetch('/api/auth/verify?app=tasks', { credentials: 'include' })
.then(async r => {
if (r.ok) {
setUser(await r.json())
setState('authed')
} else if (isEmbedded()) {
window.top!.location.href = `/login?from=${encodeURIComponent('/app/tasks')}`
} else {
setState('login')
}
})
.catch(() => { if (!isEmbedded()) setState('login') })
}, [])
// Inactivity auto-logout — disabled for installed PWAs; configurable per
// device (Admin Settings → Device) for shared/front-desk browser sessions.
useEffect(() => {
const ms = getInactivityMs()
if (state !== 'authed' || !ms) return
const timeoutMs: number = ms
async function forceLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
setUser(null)
setState('login')
}
function reset() {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(forceLogout, timeoutMs)
}
const events = ['mousemove', 'keydown', 'click', 'touchstart'] as const
events.forEach(e => window.addEventListener(e, reset, { passive: true }))
reset()
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
events.forEach(e => window.removeEventListener(e, reset))
}
}, [state])
async function login(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await fetch('/api/auth/login', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) { setError('Invalid email or password'); return }
const verify = await fetch('/api/auth/verify?app=tasks', { credentials: 'include' })
if (verify.ok) {
setUser(await verify.json())
setState('authed')
} else {
setError("You don't have access to this app.")
}
} catch {
setError('Connection error — please try again')
} finally {
setLoading(false)
}
}
if (state === 'checking') {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', fontFamily: 'var(--font)', color: 'var(--text-mid)'
}}>
Loading…
</div>
)
}
if (state === 'login') {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', height: '100dvh', padding: '1.5rem',
background: 'var(--navy-dark)',
}}>
<div style={{
background: 'var(--navy)', borderRadius: 'var(--radius)',
padding: '2rem', width: '100%', maxWidth: '360px',
border: '1px solid var(--surface-2)',
}}>
<h1 style={{ fontSize: '1.4rem', marginBottom: '0.25rem', color: 'var(--gold)' }}>
Tasks
</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: '1.5rem' }}>
{import.meta.env.VITE_HOTEL_NAME}
</p>
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<input
type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="Email" required autoComplete="email"
style={inputStyle}
/>
<input
type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Password" required autoComplete="current-password"
style={inputStyle}
/>
{error && <p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>}
<button type="submit" disabled={loading} style={{
background: loading ? 'var(--surface-2)' : 'var(--gold)',
color: loading ? 'var(--text-muted)' : 'var(--navy-dark)',
border: 'none', borderRadius: '6px', padding: '0.625rem',
fontSize: '1rem', fontWeight: 600, marginTop: '0.25rem',
}}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}
return (
<Ctx.Provider value={{ user: user! }}>
<PushSubscriber user={user!} />
{children}
</Ctx.Provider>
)
}
const inputStyle: React.CSSProperties = {
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem',
fontSize: '1rem', width: '100%', outline: 'none',
}

View file

@ -0,0 +1,89 @@
import { useEffect, useState } from 'react'
import { Share, ExternalLink, X } from 'lucide-react'
function isIos() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
// iPadOS 13+ reports as 'MacIntel' but has touch support, unlike a real Mac
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
}
function isStandalone() {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as unknown as { standalone?: boolean }).standalone === true
}
// Safari's UA also matches "Safari", so this checks for the other iOS
// browsers/in-app webviews that spoof it — none of them can add to the
// home screen; only Safari itself can.
function isNonSafariIosBrowser() {
return /CriOS|FxiOS|EdgiOS|OPiOS|mercury|GSA|DuckDuckGo|Instagram|FBAN|FBAV|Line\//.test(navigator.userAgent)
}
/**
* iOS has no `beforeinstallprompt` API — Safari never fires it, so the
* portal's Install button (?install=1) does nothing there. This shows the
* manual steps instead: Share -> Add to Home Screen in Safari, or a prompt
* to switch to Safari first if the page was opened in another browser/app.
*/
export default function IosInstallHint() {
const [mode, setMode] = useState<'safari' | 'other' | null>(null)
useEffect(() => {
if (!isIos() || isStandalone()) return
if (!new URLSearchParams(window.location.search).has('install')) return
setMode(isNonSafariIosBrowser() ? 'other' : 'safari')
}, [])
if (!mode) return null
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(15,15,32,0.55)',
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
padding: '0 16px 24px',
}}>
<div style={{
position: 'relative',
width: '100%', maxWidth: '360px',
background: 'var(--card-bg)', borderRadius: 'var(--radius)',
border: '1px solid var(--card-border)', boxShadow: 'var(--shadow-md)',
padding: '20px', textAlign: 'center',
}}>
<button
onClick={() => setMode(null)}
aria-label="Dismiss"
style={{
position: 'absolute', top: '10px', right: '10px',
background: 'none', border: 'none', color: 'var(--text-mid)',
padding: '4px', cursor: 'pointer', lineHeight: 0,
}}
>
<X size={16} strokeWidth={1.75} />
</button>
<div style={{
width: '44px', height: '44px', margin: '0 auto 12px',
borderRadius: '50%', background: 'var(--gold)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{mode === 'safari'
? <Share size={22} strokeWidth={1.75} color="var(--navy)" />
: <ExternalLink size={22} strokeWidth={1.75} color="var(--navy)" />}
</div>
{mode === 'safari' ? (
<p style={{ fontSize: '14px', color: 'var(--text-dark)', lineHeight: 1.5, margin: 0 }}>
To install this app, tap the <strong>Share</strong> icon in Safari's
toolbar, then choose <strong>Add to Home Screen</strong>.
</p>
) : (
<p style={{ fontSize: '14px', color: 'var(--text-dark)', lineHeight: 1.5, margin: 0 }}>
iOS only allows installing apps from <strong>Safari</strong>. Open this
page in Safari, then tap Share → <strong>Add to Home Screen</strong>.
</p>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,106 @@
import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { ListChecks, CalendarDays, Columns3, Settings, Menu, LogOut, Plus } from 'lucide-react'
import { useAuth } from './AuthGate'
import { useLists } from '../context/ListsContext'
import { can } from '../types'
import type { List } from '../types'
import ListEditor from './ListEditor'
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
export default function Layout({ children }: { children: React.ReactNode }) {
const { user } = useAuth()
const { lists } = useLists()
const location = useLocation()
const [menuOpen, setMenuOpen] = useState(false)
const [editing, setEditing] = useState<null | 'new' | List>(null)
async function logout() {
await fetch('/tasks/api/auth/logout', { method: 'POST', credentials: 'include' })
window.location.reload()
}
useEffect(() => { setMenuOpen(false) }, [location.pathname])
return (
<div className={`app-shell${menuOpen ? ' menu-open' : ''}`}>
<aside className="sidebar">
<div className="sidebar-logo">
<ListChecks size={18} strokeWidth={1.75} />
Tasks
</div>
<nav className="sidebar-nav">
<NavLink to="/today" className={({ isActive }) => isActive ? 'active' : ''}>
<CalendarDays {...ICON_PROPS} /> Today
</NavLink>
<NavLink to="/board" className={({ isActive }) => isActive ? 'active' : ''}>
<Columns3 {...ICON_PROPS} /> Board
</NavLink>
<div className="list-nav">
<div className="list-nav-head">
Lists
<button className="list-nav-add" onClick={() => setEditing('new')} title="New list">
<Plus size={15} strokeWidth={2} />
</button>
</div>
{lists.map(l => (
<NavLink key={l.id} to={`/list/${l.id}`} className={({ isActive }) => isActive ? 'active' : ''}>
<span className="list-dot" style={{ background: l.colour }} />
{l.name}
{l.open_count > 0 && <span className="list-count">{l.open_count}</span>}
</NavLink>
))}
{lists.length === 0 && (
<div style={{ padding: '4px 16px', fontSize: 12, color: 'var(--text-muted)' }}>
No lists yet
</div>
)}
</div>
{can(user, 'settings') && (
<NavLink to="/settings" className={({ isActive }) => isActive ? 'active' : ''} style={{ marginTop: 4 }}>
<Settings {...ICON_PROPS} /> Settings
</NavLink>
)}
</nav>
<div className="sidebar-user" style={{ whiteSpace: 'normal' }}>
<div style={{ fontWeight: 600, color: 'var(--text)', fontSize: '12px', marginBottom: '2px' }}>{user.name}</div>
<div style={{ fontSize: '11px', marginBottom: '8px' }}>{user.email}</div>
<button onClick={logout} style={{
display: 'flex', alignItems: 'center', gap: '6px',
background: 'none', border: 'none', color: 'inherit',
fontSize: '12px', padding: 0, cursor: 'pointer',
}}>
<LogOut size={13} strokeWidth={1.75} />
Sign out
</button>
</div>
</aside>
{menuOpen && <div className="menu-backdrop" onClick={() => setMenuOpen(false)} />}
<header className="top-bar">
<button className="top-bar-burger" onClick={() => setMenuOpen(o => !o)}>
<Menu size={20} strokeWidth={1.75} />
</button>
<ListChecks size={18} strokeWidth={1.75} color="var(--gold)" />
<span className="top-bar-title">Tasks</span>
</header>
<main className="page-content">
{children}
</main>
{editing && (
<ListEditor
list={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
/>
)}
</div>
)
}

View file

@ -0,0 +1,98 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { X, Trash2 } from 'lucide-react'
import type { List } from '../types'
import { LIST_COLOURS } from '../types'
import { createList, updateList, deleteList } from '../api'
import { useLists } from '../context/ListsContext'
export default function ListEditor({ list, onClose }: { list: List | null; onClose: () => void }) {
const { reload } = useLists()
const navigate = useNavigate()
const [name, setName] = useState(list?.name ?? '')
const [colour, setColour] = useState(list?.colour ?? LIST_COLOURS[0])
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function save() {
if (!name.trim()) return
setBusy(true); setError(null)
try {
if (list) {
await updateList(list.id, { name: name.trim(), colour })
} else {
const created = await createList({ name: name.trim(), colour })
await reload()
navigate(`/list/${created.id}`)
onClose()
return
}
await reload()
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed')
setBusy(false)
}
}
async function remove() {
if (!list) return
if (!confirm(`Delete "${list.name}" and all its tasks? This cannot be undone.`)) return
setBusy(true)
try {
await deleteList(list.id)
await reload()
navigate('/today')
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : 'Delete failed')
setBusy(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{list ? 'Edit list' : 'New list'}</h2>
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="field">
<label>Name</label>
<input type="text" value={name} autoFocus
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') save() }}
placeholder="e.g. Handover, Projects, Waiting on" />
</div>
<div className="field">
<label>Colour</label>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{LIST_COLOURS.map(c => (
<button key={c} onClick={() => setColour(c)} title={c}
style={{
width: 26, height: 26, borderRadius: '50%', background: c, cursor: 'pointer',
border: colour === c ? '3px solid var(--text-dark)' : '3px solid transparent',
}} />
))}
</div>
</div>
<div className="modal-footer-actions">
{list && (
<button className="btn-ghost-sm btn-ghost-danger" onClick={remove} disabled={busy} style={{ marginRight: 'auto' }}>
<Trash2 size={14} strokeWidth={1.75} /> Delete
</button>
)}
<button className="btn" onClick={onClose} disabled={busy}>Cancel</button>
<button className="btn btn-primary" onClick={save} disabled={busy || !name.trim()}>
{list ? 'Save' : 'Create'}
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,214 @@
import { useEffect, useState } from 'react'
import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput } from 'lucide-react'
import type { TaskDetail as TDetail, Status, Priority, RecurRule } from '../types'
import { PRIORITIES, PRIORITY_LABELS, STATUSES, STATUS_LABELS, RECUR_RULES, RECUR_LABELS } from '../types'
import {
fetchTask, updateTask, deleteTask,
addItem, updateItem, deleteItem, addComment,
} from '../api'
import { useLists } from '../context/ListsContext'
import { Check_, formatDateTime, toLocalInput } from './shared'
export default function TaskDetail({ taskId, onChange, onClose }:
{ taskId: number; onChange: () => void; onClose: () => void }) {
const { lists } = useLists()
const [task, setTask] = useState<TDetail | null>(null)
const [error, setError] = useState<string | null>(null)
const [title, setTitle] = useState('')
const [notes, setNotes] = useState('')
const [newItem, setNewItem] = useState('')
const [comment, setComment] = useState('')
useEffect(() => {
fetchTask(taskId).then(t => { setTask(t); setTitle(t.title); setNotes(t.notes ?? '') })
.catch(e => setError(e.message))
}, [taskId])
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>
// Patch task fields, merge the result locally, and notify the parent view.
async function patch(fields: Record<string, unknown>) {
const updated = await updateTask(taskId, fields)
setTask(t => t ? { ...t, ...updated } : t)
onChange()
}
const toLocalIso = (v: string) => v ? new Date(v).toISOString() : ''
async function toggleDone() {
await patch({ status: task!.status === 'done' ? 'todo' : 'done' })
}
async function addChecklistItem() {
if (!newItem.trim()) return
const item = await addItem(taskId, newItem.trim())
setTask(t => t ? { ...t, items: [...t.items, item] } : t)
setNewItem('')
onChange()
}
async function toggleItem(id: number, is_done: boolean) {
await updateItem(id, { is_done })
setTask(t => t ? { ...t, items: t.items.map(i => i.id === id ? { ...i, is_done } : i) } : t)
}
async function removeItem(id: number) {
await deleteItem(id)
setTask(t => t ? { ...t, items: t.items.filter(i => i.id !== id) } : t)
}
async function postComment() {
if (!comment.trim()) return
const c = await addComment(taskId, comment.trim())
setTask(t => t ? { ...t, comments: [...t.comments, c] } : t)
setComment('')
}
async function removeTask() {
if (!confirm('Delete this task?')) return
await deleteTask(taskId)
onChange()
onClose()
}
const doneItems = task.items.filter(i => i.is_done).length
return (
<Shell onClose={onClose} onDelete={removeTask}>
{/* Title + completion */}
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', marginBottom: 14 }}>
<div style={{ marginTop: 4 }}><Check_ status={task.status} onClick={toggleDone} /></div>
<textarea
value={title}
onChange={e => setTitle(e.target.value)}
onBlur={() => title.trim() && title !== task.title && patch({ title: title.trim() })}
rows={1}
style={{
flex: 1, border: 'none', outline: 'none', background: 'none', resize: 'none',
fontSize: 17, fontWeight: 600, fontFamily: 'var(--font)', color: 'var(--text-dark)',
}}
/>
</div>
{/* Status pills */}
<div className="chip-bar">
{STATUSES.map(s => (
<button key={s} className={`chip${task.status === s ? ' active' : ''}`} onClick={() => patch({ status: s })}>
{STATUS_LABELS[s]}
</button>
))}
</div>
{/* Attributes */}
<div className="card" style={{ padding: '12px 14px' }}>
<div className="field" style={{ marginBottom: 10 }}>
<label><FolderInput size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> List</label>
<select value={task.list_id} onChange={e => patch({ list_id: Number(e.target.value) })}>
{lists.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</div>
<div className="field-row">
<div className="field">
<label>Priority</label>
<select value={task.priority} 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 ?? ''} 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>
</div>
</div>
<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)}
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)}
onChange={e => patch({ remind_at: toLocalIso(e.target.value) })} />
</div>
</div>
</div>
{/* Notes */}
<div className="section-title">Notes</div>
<div className="field">
<textarea value={notes} placeholder="Add notes…"
onChange={e => setNotes(e.target.value)}
onBlur={() => notes !== (task.notes ?? '') && patch({ notes })} />
</div>
{/* Checklist */}
<div className="section-title">Checklist {task.items.length > 0 && `(${doneItems}/${task.items.length})`}</div>
{task.items.length > 0 && (
<div className="checklist-progress">
<div style={{ width: `${(doneItems / task.items.length) * 100}%` }} />
</div>
)}
{task.items.map(i => (
<div key={i.id} className={`checklist-item${i.is_done ? ' done' : ''}`}>
<button className={`check${i.is_done ? ' checked' : ''}`} onClick={() => toggleItem(i.id, !i.is_done)} type="button" />
<span>{i.label}</span>
<button className="checklist-del" onClick={() => removeItem(i.id)}><X size={14} strokeWidth={1.75} /></button>
</div>
))}
<div className="task-add" style={{ marginTop: 8 }}>
<Plus size={16} strokeWidth={1.75} color="var(--text-mid)" />
<input value={newItem} placeholder="Add a sub-task"
onChange={e => setNewItem(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addChecklistItem() }} />
</div>
{/* Comments / activity */}
<div className="section-title">Activity</div>
<div className="timeline">
{task.comments.map(c => (
<div key={c.id} className="timeline-item">
<div className="timeline-body">
<div className="timeline-note">{c.body}</div>
<div className="timeline-meta">{c.author_name || 'You'} · {formatDateTime(c.created_at)}</div>
</div>
</div>
))}
{task.comments.length === 0 && <div className="muted" style={{ fontSize: 12 }}>No activity yet.</div>}
</div>
<div className="task-add" style={{ marginTop: 8 }}>
<input value={comment} placeholder="Add a note…"
onChange={e => setComment(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') postComment() }} />
<button className="btn btn-sm btn-primary" onClick={postComment} disabled={!comment.trim()}>Post</button>
</div>
<div className="timeline-meta" style={{ marginTop: 14 }}>
Created {formatDateTime(task.created_at)}
{task.completed_at && ` · Completed ${formatDateTime(task.completed_at)}`}
</div>
</Shell>
)
}
function Shell({ children, onClose, onDelete }:
{ children: React.ReactNode; onClose: () => void; onDelete?: () => void }) {
return (
<>
<div className="drawer-overlay" onClick={onClose} />
<div className="drawer">
<div className="drawer-head">
<h2 style={{ visibility: 'hidden' }}>Task</h2>
{onDelete && (
<button className="btn-ghost-sm btn-ghost-danger" onClick={onDelete}>
<Trash2 size={15} strokeWidth={1.75} />
</button>
)}
<button className="modal-close" onClick={onClose}><X size={20} strokeWidth={1.75} /></button>
</div>
<div className="drawer-body">{children}</div>
</div>
</>
)
}

View file

@ -0,0 +1,44 @@
import { createContext, useContext, useState, useCallback } from 'react'
import { useLists } from '../context/ListsContext'
import TaskDetail from './TaskDetail'
interface DrawerCtx {
openTask: (id: number) => void
changeVersion: number // bumps whenever a task is mutated via the drawer
}
const Ctx = createContext<DrawerCtx | null>(null)
export function useTaskDrawer() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useTaskDrawer must be used inside TaskDrawerProvider')
return ctx
}
export function TaskDrawerProvider({ children }: { children: React.ReactNode }) {
const { reload: reloadLists } = useLists()
const [openId, setOpenId] = useState<number | null>(null)
const [changeVersion, setChangeVersion] = useState(0)
const openTask = useCallback((id: number) => setOpenId(id), [])
// Called by the drawer whenever it edits a task: refresh sidebar counts and
// signal open views to reload.
const notifyChange = useCallback(() => {
setChangeVersion(v => v + 1)
reloadLists()
}, [reloadLists])
return (
<Ctx.Provider value={{ openTask, changeVersion }}>
{children}
{openId !== null && (
<TaskDetail
taskId={openId}
onChange={notifyChange}
onClose={() => setOpenId(null)}
/>
)}
</Ctx.Provider>
)
}

View file

@ -0,0 +1,61 @@
import { GripVertical, Bell, Repeat, ListChecks } from 'lucide-react'
import type { Task } from '../types'
import { Check_, PriorityBadge, dueInfo } from './shared'
interface Props {
task: Task
onToggle: (t: Task) => void
onOpen: (t: Task) => void
showList?: boolean
draggable?: boolean
dragging?: boolean
onDragStart?: () => void
onDragOver?: (e: React.DragEvent) => void
onDrop?: () => void
}
export default function TaskRow({
task, onToggle, onOpen, showList,
draggable, dragging, onDragStart, onDragOver, onDrop,
}: Props) {
const due = dueInfo(task.due_at)
return (
<div
className={`task-row ${task.priority === 'high' ? 'high' : ''} ${task.status === 'done' ? 'done' : ''} ${dragging ? 'dragging' : ''}`}
onClick={() => onOpen(task)}
draggable={draggable}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
>
{draggable && (
<span className="task-row-drag" onClick={e => e.stopPropagation()}>
<GripVertical size={16} strokeWidth={1.75} />
</span>
)}
<div onClick={e => { e.stopPropagation(); onToggle(task) }}>
<Check_ status={task.status} onClick={() => {}} />
</div>
<div className="task-row-main">
<div className="task-row-title">{task.title}</div>
<div className="task-row-meta">
{showList && task.list_name && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span className="list-dot" style={{ background: task.list_colour, width: 8, height: 8 }} />
{task.list_name}
</span>
)}
{due && <span className={`due-chip${due.overdue ? ' overdue' : ''}`}>{due.label}</span>}
{task.remind_at && <Bell size={12} strokeWidth={1.75} />}
{task.recur_rule && <Repeat size={12} strokeWidth={1.75} />}
{!!task.item_count && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
<ListChecks size={12} strokeWidth={1.75} />{task.item_done}/{task.item_count}
</span>
)}
</div>
</div>
<PriorityBadge priority={task.priority} />
</div>
)
}

View file

@ -0,0 +1,44 @@
import { RefreshCw } from 'lucide-react'
export function UpdateBanner({ visible }: { visible: boolean }) {
if (!visible) return null
return (
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 9999,
background: 'var(--sidebar)',
color: 'var(--text-light)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
padding: '10px 16px',
fontSize: '14px',
boxShadow: '0 -2px 8px rgba(0,0,0,0.3)',
}}>
<span>A new version is available.</span>
<button
onClick={() => window.location.reload()}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
background: 'var(--accent)',
color: 'var(--sidebar)',
border: 'none',
borderRadius: '4px',
padding: '6px 14px',
fontWeight: 600,
cursor: 'pointer',
fontSize: '13px',
}}
>
<RefreshCw size={14} strokeWidth={1.75} />
Reload
</button>
</div>
)
}

View file

@ -0,0 +1,65 @@
import { Check } from 'lucide-react'
import type { Priority, Status } from '../types'
import { PRIORITY_LABELS, STATUS_LABELS } from '../types'
export function PriorityBadge({ priority }: { priority: Priority }) {
if (priority === 'medium') return null
return <span className={`badge badge-prio-${priority}`}>{PRIORITY_LABELS[priority]}</span>
}
export function StatusBadge({ status }: { status: Status }) {
return <span className={`badge badge-st-${status}`}>{STATUS_LABELS[status]}</span>
}
// 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'
return (
<button className={cls} onClick={onClick} aria-label="toggle done" type="button">
{status === 'done' && <Check size={13} strokeWidth={3} />}
{status === 'doing' && <span style={{ width: 8, height: 8, borderRadius: '50%', background: 'currentColor' }} />}
</button>
)
}
export function formatDate(iso: string | null | undefined): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
}
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + ' ' +
d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
}
function startOfDay(d: Date) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x }
// Human relative due label + whether it's overdue.
export function dueInfo(iso: string | null): { label: string; overdue: boolean } | null {
if (!iso) return null
const due = new Date(iso)
const today = startOfDay(new Date())
const dueDay = startOfDay(due)
const days = Math.round((dueDay.getTime() - today.getTime()) / 86400000)
const overdue = days < 0
let label: string
if (days === 0) label = 'Today'
else if (days === 1) label = 'Tomorrow'
else if (days === -1) label = 'Yesterday'
else if (days < 0) label = `${-days} days ago`
else if (days < 7) label = due.toLocaleDateString('en-GB', { weekday: 'short' })
else label = due.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
const hasTime = due.getHours() !== 0 || due.getMinutes() !== 0
if (hasTime) label += ' ' + due.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
return { label, overdue }
}
// Convert an ISO timestamp to the value a datetime-local input expects.
export function toLocalInput(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}

View file

@ -0,0 +1,32 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import type { List } from '../types'
import { fetchLists } from '../api'
interface ListsCtx {
lists: List[]
loading: boolean
reload: () => Promise<void>
}
const Ctx = createContext<ListsCtx | null>(null)
export function useLists() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useLists must be used inside ListsProvider')
return ctx
}
export function ListsProvider({ children }: { children: React.ReactNode }) {
const [lists, setLists] = useState<List[]>([])
const [loading, setLoading] = useState(true)
const reload = useCallback(async () => {
const data = await fetchLists().catch(() => [])
setLists(data)
setLoading(false)
}, [])
useEffect(() => { reload() }, [reload])
return <Ctx.Provider value={{ lists, loading, reload }}>{children}</Ctx.Provider>
}

View file

@ -0,0 +1,57 @@
import { useEffect } from 'react'
import type { User } from '../types'
const BASE = '/tasks/api'
function urlBase64ToUint8Array(base64: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(b64)
const buf = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i)
return buf.buffer
}
export function usePushSubscription(user: User) {
useEffect(() => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return
if (Notification.permission === 'denied') return
async function subscribe() {
try {
const reg = await navigator.serviceWorker.ready
const keyRes = await fetch(`${BASE}/push/vapid-key`, { credentials: 'include' })
if (!keyRes.ok) return
const { publicKey } = await keyRes.json() as { publicKey: string }
let sub = await reg.pushManager.getSubscription()
if (!sub) {
if (Notification.permission === 'default') {
const permission = await Notification.requestPermission()
if (permission !== 'granted') return
} else if (Notification.permission !== 'granted') {
return
}
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
}
// Always re-POST so subscription is tied to current user (handles re-login)
await fetch(`${BASE}/push/subscribe`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub.toJSON()),
})
} catch (err) {
console.error('[push] subscription failed:', err)
}
}
subscribe()
}, [user.email])
}

View file

@ -0,0 +1,43 @@
import { useEffect, useState } from 'react'
const POLL_MS = 2 * 60 * 1000
export function useVersionCheck(healthUrl: string) {
const [updateAvailable, setUpdateAvailable] = useState(false)
useEffect(() => {
let seenVersion: string | null = null
async function check() {
try {
const res = await fetch(healthUrl, { cache: 'no-store' })
if (!res.ok) return
const data = await res.json()
const v: string | undefined = data.version
if (!v) return
if (seenVersion === null) {
seenVersion = v
} else if (v !== seenVersion) {
setUpdateAvailable(true)
}
} catch {
// network error — skip silently
}
}
check()
const interval = setInterval(check, POLL_MS)
function onVisible() {
if (document.visibilityState === 'visible') check()
}
document.addEventListener('visibilitychange', onVisible)
return () => {
clearInterval(interval)
document.removeEventListener('visibilitychange', onVisible)
}
}, [healthUrl])
return updateAvailable
}

563
frontend/src/index.css Normal file
View file

@ -0,0 +1,563 @@
/* Stack design system tokens — include verbatim in every app */
:root {
--navy: #1a1a2e;
--navy-dark: #0f0f20;
--gold: #c9a84c;
--gold-light: #e8c96d;
--surface: rgba(255,255,255,0.07);
--surface-2: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.88);
--text-muted: rgba(255,255,255,0.48);
--body-bg: #f4f5f7;
--card-bg: #ffffff;
--card-border: #e4e8ee;
--text-dark: #1e293b;
--text-mid: #64748b;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--danger: #dc2626;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body { background: var(--body-bg); color: var(--text-dark); font-family: var(--font); }
/* App theme + semantic tokens */
:root {
--app-primary: #0d9488;
--app-primary-light: #14b8a6;
--prio-low: #64748b;
--prio-medium: #2563eb;
--prio-high: #dc2626;
--st-todo: #64748b;
--st-doing: #2563eb;
--st-done: #16a34a;
--danger-bg: #fef2f2;
--warn-bg: #fffbeb;
--ok-bg: #f0fdf4;
--sidebar-w: 240px;
--topbar-h: 56px;
}
*, *::before, *::after { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; font-size: 14px; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--card-border); border-radius: 2px; }
/* ── App shell ─────────────────────────────────────────────── */
.app-shell { display: flex; height: 100vh; overflow: hidden; }
.sidebar {
width: var(--sidebar-w);
background: var(--navy);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow-y: auto;
}
.sidebar-logo {
padding: 20px 16px 12px;
color: var(--gold);
font-size: 13px;
font-weight: 600;
letter-spacing: .05em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-logo svg { opacity: .8; }
.sidebar-nav { flex: 1; padding: 8px 0; }
.sidebar-nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
transition: background .15s, color .15s;
}
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
.sidebar-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); }
.sidebar-user {
padding: 12px 16px;
border-top: 1px solid var(--surface-2);
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-bar {
display: none;
height: var(--topbar-h);
background: var(--navy);
color: var(--text);
align-items: center;
padding: 0 12px;
gap: 10px;
flex-shrink: 0;
}
.top-bar-title { flex: 1; font-size: 15px; font-weight: 600; color: var(--gold); }
.top-bar-nav { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; }
.top-bar-nav::-webkit-scrollbar { display: none; }
.top-bar-nav a {
color: var(--text-muted);
padding: 6px 8px;
border-radius: 6px;
text-decoration: none;
font-size: 12px;
white-space: nowrap;
}
.top-bar-nav a.active { color: var(--gold); }
.page-content { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 200;
transform: translateX(calc(-1 * var(--sidebar-w)));
transition: transform 0.25s ease;
}
.app-shell.menu-open .sidebar { transform: translateX(0); }
.top-bar { display: flex; }
.app-shell { flex-direction: column; }
.field-row { flex-direction: column; }
}
.menu-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 199;
}
.top-bar-burger {
background: none;
border: none;
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
padding: 4px;
flex-shrink: 0;
}
/* ── Page chrome ───────────────────────────────────────────── */
.page { padding: 20px; max-width: 1100px; width: 100%; margin: 0 auto; }
.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.page-header h1 { font-size: 18px; margin: 0; flex: 1; }
/* ── Buttons ───────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--card-border);
background: var(--card-bg);
color: var(--text-dark);
border-radius: var(--radius);
padding: 7px 14px;
font-size: 13px;
cursor: pointer;
font-family: var(--font);
transition: background .12s, border-color .12s;
}
.btn:hover { border-color: var(--text-mid); }
.btn:disabled { opacity: .5; cursor: default; }
.btn-primary { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
.btn-primary:hover { background: var(--gold-light); border-color: var(--gold-light); }
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 8px; }
/* ── Forms ─────────────────────────────────────────────────── */
.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="number"], .field select, .field textarea {
width: 100%;
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 8px 10px;
font-size: 13.5px;
font-family: var(--font);
color: var(--text-dark);
background: var(--card-bg);
}
.field textarea { min-height: 72px; resize: vertical; }
.field-row { display: flex; gap: 12px; }
.field-row > .field { flex: 1; }
.field-check { display: flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; }
.field-check input { width: 16px; height: 16px; accent-color: var(--gold); }
.field-hint { font-size: 11.5px; color: var(--text-mid); margin-top: 3px; }
/* ── Cards & lists ─────────────────────────────────────────── */
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 14px 16px;
margin-bottom: 10px;
}
.task-card {
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
transition: box-shadow .12s;
}
.task-card:hover { box-shadow: var(--shadow-md); }
.task-card.high { border-left: 4px solid var(--prio-high); }
.task-card.done { opacity: .55; }
.task-card-main { flex: 1; min-width: 0; }
.task-card-title { font-weight: 600; font-size: 14px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.task-card-meta { font-size: 12px; color: var(--text-mid); display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.task-card-side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex-shrink: 0; }
/* ── Badges ────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 20px;
padding: 2px 9px;
font-size: 11px;
font-weight: 600;
color: #fff;
white-space: nowrap;
}
.badge-prio-low { background: var(--prio-low); }
.badge-prio-medium { background: var(--prio-medium); }
.badge-prio-high { background: var(--prio-high); }
.badge-st-todo { background: var(--st-todo); }
.badge-st-doing { background: var(--st-doing); }
.badge-st-done { background: var(--st-done); }
.badge-outline {
background: transparent;
border: 1px solid var(--card-border);
color: var(--text-mid);
font-weight: 500;
}
.badge-unusable { background: var(--danger); }
/* ── Filter chips ──────────────────────────────────────────── */
.filter-row { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 4px; }
.filter-row .filter-section { flex: 1; min-width: 0; margin-bottom: 0; }
.filter-row .sort-chip { flex-shrink: 0; align-self: center; white-space: nowrap; }
.filter-section { margin-bottom: 10px; }
.filter-section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 8px;
background: var(--card-bg);
border: 1px solid var(--card-border);
cursor: pointer;
user-select: none;
font-size: 12px;
color: var(--text-mid);
transition: border-color .12s;
}
.filter-section-header:hover { border-color: var(--gold); }
.filter-section-label { font-weight: 600; color: var(--text-dark); white-space: nowrap; }
.filter-section-summary { flex: 1; color: var(--gold); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.filter-section-summary.muted { color: var(--text-mid); font-weight: 400; }
.filter-section-chevron { flex-shrink: 0; transition: transform .18s; }
.filter-section-chevron.open { transform: rotate(180deg); }
.chip-bar { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0 4px; align-items: center; }
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border-radius: 20px;
border: 1px solid var(--card-border);
background: var(--card-bg);
cursor: pointer;
font-size: 12px;
color: var(--text-mid);
user-select: none;
font-family: var(--font);
transition: all .12s;
}
.chip:hover { border-color: var(--gold); color: var(--text-dark); }
.chip.active { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
/* ── Modal ─────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15,15,32,.55);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24px 12px;
z-index: 100;
overflow-y: auto;
}
.modal {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
width: 100%;
max-width: 680px;
padding: 20px;
margin: auto 0;
}
.modal-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; }
.modal-header h2 { font-size: 16px; margin: 0; flex: 1; }
.modal-close {
background: none;
border: none;
cursor: pointer;
color: var(--text-mid);
padding: 2px;
display: flex;
}
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
.modal-footer-actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--card-border); }
.btn-ghost-sm {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 8px; border-radius: 6px; border: none; background: none;
font-size: 12px; color: var(--text-mid); cursor: pointer; font-family: var(--font);
transition: color .12s, background .12s;
}
.btn-ghost-sm:hover { color: var(--text-dark); background: var(--card-border); }
.btn-ghost-danger { color: var(--danger); }
.btn-ghost-danger:hover { color: var(--danger); background: var(--danger-bg); }
/* ── Timeline / thread ─────────────────────────────────────── */
.timeline { margin: 8px 0; }
.timeline-item {
display: flex;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--card-border);
font-size: 13px;
}
.timeline-item:last-child { border-bottom: none; }
.timeline-icon { color: var(--text-mid); flex-shrink: 0; margin-top: 1px; }
.timeline-body { flex: 1; min-width: 0; }
.timeline-note { white-space: pre-wrap; }
.timeline-meta { font-size: 11.5px; color: var(--text-mid); margin-top: 2px; }
.timeline-photo-link { color: var(--gold); cursor: pointer; font-size: 12px; font-weight: 500; }
.timeline-photo-link:hover { text-decoration: underline; }
/* ── Photos ────────────────────────────────────────────────── */
.photo-grid { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
.photo-thumb {
width: 84px;
height: 84px;
border-radius: 8px;
object-fit: cover;
border: 1px solid var(--card-border);
cursor: pointer;
}
.photo-thumb-uploading {
display: flex; align-items: center; justify-content: center;
background: var(--card-border); color: var(--text-mid);
}
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin .75s linear infinite; }
.photo-thumb-wrap { position: relative; }
.photo-del {
position: absolute;
top: -6px;
right: -6px;
background: var(--danger);
color: #fff;
border: none;
border-radius: 50%;
width: 20px;
height: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
/* ── Lightbox ──────────────────────────────────────────────── */
.lightbox-overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,.92);
display: flex; align-items: center; justify-content: center;
padding: 16px;
}
.lightbox-img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; }
.lightbox-close {
position: absolute; top: 16px; right: 16px;
background: rgba(255,255,255,.15); border: none; border-radius: 50%;
width: 38px; height: 38px; display: flex; align-items: center; justify-content: center;
cursor: pointer; color: #fff; transition: background .12s;
}
.lightbox-close:hover { background: rgba(255,255,255,.28); }
/* ── Tables ────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
table.data { width: 100%; border-collapse: collapse; font-size: 13px; }
table.data th {
text-align: left;
padding: 9px 12px;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-mid);
border-bottom: 1px solid var(--card-border);
white-space: nowrap;
}
table.data td { padding: 9px 12px; border-bottom: 1px solid var(--card-border); vertical-align: top; }
table.data tr:last-child td { border-bottom: none; }
table.data tr.clickable { cursor: pointer; }
table.data tr.clickable:hover td { background: var(--body-bg); }
/* ── Stats strip ───────────────────────────────────────────── */
.stats-strip { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.stat-box {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 10px 16px;
min-width: 110px;
}
.stat-box .stat-value { font-size: 18px; font-weight: 700; }
.stat-box .stat-label { font-size: 11px; color: var(--text-mid); text-transform: uppercase; letter-spacing: .04em; }
/* ── Misc ──────────────────────────────────────────────────── */
.empty-state { text-align: center; color: var(--text-mid); padding: 40px 16px; font-size: 13.5px; }
.error-banner {
background: var(--danger-bg);
border: 1px solid var(--danger);
color: var(--danger);
border-radius: var(--radius);
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
}
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin: 18px 0 8px; }
.muted { color: var(--text-mid); }
.overdue { color: var(--danger); font-weight: 600; }
/* Sidebar scrollbar */
.nav-scroll::-webkit-scrollbar,
.sidebar::-webkit-scrollbar,
.sidebar-nav::-webkit-scrollbar { width: 4px; }
.nav-scroll::-webkit-scrollbar-track,
.sidebar::-webkit-scrollbar-track,
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.nav-scroll::-webkit-scrollbar-thumb,
.sidebar::-webkit-scrollbar-thumb,
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; }
.nav-scroll::-webkit-scrollbar-thumb:hover,
.sidebar::-webkit-scrollbar-thumb:hover,
.sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); }
.nav-scroll, .sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }
/* ── Tasks: list nav (in sidebar) ──────────────────────────── */
.list-nav { padding: 4px 0 8px; border-top: 1px solid var(--surface-2); margin-top: 4px; }
.list-nav-head {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 16px 4px; color: var(--text-muted);
font-size: 11px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase;
}
.list-nav-add { background: none; border: none; color: var(--text-muted); cursor: pointer; display: flex; padding: 2px; }
.list-nav-add:hover { color: var(--gold); }
.list-nav a {
display: flex; align-items: center; gap: 10px; padding: 8px 16px;
color: var(--text-muted); text-decoration: none; font-size: 13.5px;
}
.list-nav a:hover { background: var(--surface); color: var(--text); }
.list-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); }
.list-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.list-nav a .list-count { margin-left: auto; font-size: 11px; color: var(--text-muted); }
/* ── Tasks: inline add + rows ──────────────────────────────── */
.task-add {
display: flex; align-items: center; gap: 8px;
background: var(--card-bg); border: 1px solid var(--card-border);
border-radius: var(--radius); box-shadow: var(--shadow-sm);
padding: 4px 10px; margin-bottom: 12px;
}
.task-add input {
flex: 1; border: none; outline: none; background: none;
font-size: 14px; font-family: var(--font); color: var(--text-dark); padding: 8px 0;
}
.task-row {
display: flex; align-items: center; gap: 12px;
background: var(--card-bg); border: 1px solid var(--card-border);
border-radius: var(--radius); box-shadow: var(--shadow-sm);
padding: 10px 14px; margin-bottom: 6px; cursor: pointer;
transition: box-shadow .12s;
}
.task-row:hover { box-shadow: var(--shadow-md); }
.task-row.high { border-left: 3px solid var(--prio-high); }
.task-row.done .task-row-title { text-decoration: line-through; color: var(--text-mid); }
.task-row-main { flex: 1; min-width: 0; }
.task-row-title { font-size: 14px; font-weight: 500; }
.task-row-meta { font-size: 11.5px; color: var(--text-mid); display: flex; gap: 10px; flex-wrap: wrap; margin-top: 2px; align-items: center; }
.task-row-drag { color: var(--card-border); cursor: grab; display: flex; }
.task-row.dragging { opacity: .4; }
/* Round checkbox */
.check {
width: 20px; height: 20px; border-radius: 50%; flex-shrink: 0;
border: 2px solid var(--card-border); background: none; cursor: pointer;
display: flex; align-items: center; justify-content: center; color: #fff; padding: 0;
transition: background .12s, border-color .12s;
}
.check:hover { border-color: var(--st-done); }
.check.checked { background: var(--st-done); border-color: var(--st-done); }
.check.doing { border-color: var(--st-doing); color: var(--st-doing); }
.due-chip { color: var(--text-mid); }
.due-chip.overdue { color: var(--danger); font-weight: 600; }
/* ── Slide-over drawer (task detail) ───────────────────────── */
.drawer-overlay { position: fixed; inset: 0; background: rgba(15,15,32,.4); z-index: 150; }
.drawer {
position: fixed; top: 0; right: 0; bottom: 0; z-index: 151;
width: 440px; max-width: 100vw; background: var(--body-bg);
box-shadow: -4px 0 24px rgba(0,0,0,.15);
display: flex; flex-direction: column; overflow-y: auto;
animation: drawer-in .2s ease;
}
@keyframes drawer-in { from { transform: translateX(100%); } to { transform: translateX(0); } }
.drawer-head { display: flex; align-items: flex-start; gap: 10px; padding: 16px; background: var(--card-bg); border-bottom: 1px solid var(--card-border); }
.drawer-head h2 { font-size: 16px; margin: 0; flex: 1; }
.drawer-body { padding: 16px; flex: 1; }
/* Checklist */
.checklist-item { display: flex; align-items: center; gap: 10px; padding: 5px 0; }
.checklist-item .check { width: 18px; height: 18px; }
.checklist-item.done span { text-decoration: line-through; color: var(--text-mid); }
.checklist-item span { flex: 1; font-size: 13.5px; }
.checklist-del { background: none; border: none; color: var(--text-mid); cursor: pointer; display: flex; padding: 2px; }
.checklist-del:hover { color: var(--danger); }
.checklist-progress { height: 5px; background: var(--card-border); border-radius: 3px; overflow: hidden; margin: 6px 0 10px; }
.checklist-progress > div { height: 100%; background: var(--st-done); }
/* ── Board (kanban) ────────────────────────────────────────── */
.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-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; }
/* Agenda groups (Today view) */
.agenda-group-title { font-size: 13px; font-weight: 700; color: var(--text-dark); margin: 16px 0 8px; display: flex; align-items: center; gap: 8px; }
.agenda-group-title.overdue { color: var(--danger); }

18
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,18 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
if (new URLSearchParams(window.location.search).has('install')) {
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault()
;(e as Event & { prompt: () => Promise<void> }).prompt()
}, { once: true })
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View file

@ -0,0 +1,78 @@
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 { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
import { PriorityBadge, dueInfo } from '../components/shared'
export default function BoardView() {
const { openTask, changeVersion } = useTaskDrawer()
const [tasks, setTasks] = useState<Task[]>([])
const [error, setError] = useState<string | null>(null)
const [over, setOver] = useState<Status | null>(null)
const dragId = useRef<number | null>(null)
const load = useCallback(() => {
fetchTasks({}).then(setTasks).catch(e => setError(e.message))
}, [])
useEffect(() => { load() }, [load, changeVersion])
async function drop(status: Status) {
const id = dragId.current
dragId.current = null
setOver(null)
if (id === null) return
const task = tasks.find(t => t.id === id)
if (!task || task.status === status) return
setTasks(ts => ts.map(t => t.id === id ? { ...t, status } : t))
await updateTask(id, { status }).catch(() => load())
load()
}
return (
<div className="page" style={{ maxWidth: 1200 }}>
<div className="page-header">
<h1><Columns3 size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Board</h1>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="board">
{STATUSES.map(status => {
const items = tasks.filter(t => t.status === status)
return (
<div key={status}
className={`board-col${over === status ? ' drop-target' : ''}`}
onDragOver={e => { e.preventDefault(); setOver(status) }}
onDragLeave={() => setOver(o => o === status ? null : o)}
onDrop={() => drop(status)}>
<div className="board-col-head">
{STATUS_LABELS[status]}
<span className="board-col-count">{items.length}</span>
</div>
{items.map(t => (
<div key={t.id} className="card" style={{ cursor: 'grab', padding: '10px 12px' }}
draggable
onDragStart={() => { dragId.current = t.id }}
onClick={() => openTask(t.id)}>
<div style={{ fontSize: 13.5, fontWeight: 500, marginBottom: 4 }}>{t.title}</div>
<div className="task-row-meta">
{t.list_name && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span className="list-dot" style={{ background: t.list_colour, width: 8, height: 8 }} />
{t.list_name}
</span>
)}
{(() => { const d = dueInfo(t.due_at); return d && <span className={`due-chip${d.overdue ? ' overdue' : ''}`}>{d.label}</span> })()}
<PriorityBadge priority={t.priority} />
</div>
</div>
))}
{items.length === 0 && <div className="muted" style={{ fontSize: 12, padding: '4px 2px' }}>—</div>}
</div>
)
})}
</div>
</div>
)
}

View file

@ -0,0 +1,105 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { useParams } from 'react-router-dom'
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 { useTaskDrawer } from '../components/TaskDrawer'
import TaskRow from '../components/TaskRow'
import ListEditor from '../components/ListEditor'
export default function ListView() {
const { id } = useParams()
const listId = Number(id)
const { lists } = useLists()
const { openTask, changeVersion } = useTaskDrawer()
const list = lists.find(l => l.id === listId)
const [tasks, setTasks] = useState<Task[]>([])
const [error, setError] = useState<string | null>(null)
const [newTitle, setNewTitle] = useState('')
const [editing, setEditing] = useState(false)
const dragId = useRef<number | null>(null)
const load = useCallback(() => {
if (!listId) return
fetchTasks({ list_id: listId }).then(setTasks).catch(e => setError(e.message))
}, [listId])
useEffect(() => { load() }, [load, changeVersion])
async function add() {
if (!newTitle.trim()) return
await createTask({ list_id: listId, title: newTitle.trim() })
setNewTitle('')
load()
}
async function toggle(t: Task) {
await updateTask(t.id, { status: t.status === 'done' ? 'todo' : 'done' })
load()
}
// Native drag reorder within the open list.
function onDragStart(taskId: number) { dragId.current = taskId }
function onDrop(targetId: number) {
const from = dragId.current
dragId.current = null
if (from === null || from === targetId) return
const ids = tasks.map(t => t.id)
const fromIdx = ids.indexOf(from)
const toIdx = ids.indexOf(targetId)
ids.splice(toIdx, 0, ids.splice(fromIdx, 1)[0])
const reordered = ids.map(i => tasks.find(t => t.id === i)!).filter(Boolean)
setTasks(reordered)
reorderTasks(ids).catch(() => load())
}
const open = tasks.filter(t => t.status !== 'done')
const done = tasks.filter(t => t.status === 'done')
return (
<div className="page">
<div className="page-header">
<h1>
{list && <span className="list-dot" style={{ background: list.colour, width: 12, height: 12, display: 'inline-block', marginRight: 8, verticalAlign: 0 }} />}
{list?.name ?? 'List'}
</h1>
{list && (
<button className="btn btn-sm" onClick={() => setEditing(true)}>
<Pencil size={13} strokeWidth={1.75} /> Edit
</button>
)}
</div>
{error && <div className="error-banner">{error}</div>}
<div className="task-add">
<Plus size={18} strokeWidth={1.75} color="var(--text-mid)" />
<input value={newTitle} placeholder="Add a task…"
onChange={e => setNewTitle(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') add() }} />
</div>
{open.map(t => (
<TaskRow key={t.id} task={t} onToggle={toggle} onOpen={() => openTask(t.id)}
draggable dragging={dragId.current === t.id}
onDragStart={() => onDragStart(t.id)}
onDragOver={e => e.preventDefault()}
onDrop={() => onDrop(t.id)} />
))}
{open.length === 0 && done.length === 0 && (
<div className="empty-state">No tasks yet — add one above.</div>
)}
{done.length > 0 && (
<>
<div className="section-title">Completed ({done.length})</div>
{done.map(t => (
<TaskRow key={t.id} task={t} onToggle={toggle} onOpen={() => openTask(t.id)} />
))}
</>
)}
{editing && list && <ListEditor list={list} onClose={() => setEditing(false)} />}
</div>
)
}

View file

@ -0,0 +1,81 @@
import { useEffect, useState } from 'react'
import { Bell, BellOff } from 'lucide-react'
import type { AppConfig } from '../types'
import { fetchConfig, updateConfig } from '../api'
import { useAuth } from '../components/AuthGate'
import { can } from '../types'
export default function Settings() {
const { user } = useAuth()
const editable = can(user, 'settings')
const [config, setConfig] = useState<AppConfig | null>(null)
const [error, setError] = useState<string | null>(null)
const [perm, setPerm] = useState<NotificationPermission>(
typeof Notification !== 'undefined' ? Notification.permission : 'denied'
)
useEffect(() => { fetchConfig().then(setConfig).catch(e => setError(e.message)) }, [])
async function set(key: keyof AppConfig, value: boolean) {
if (!config) return
setConfig({ ...config, [key]: value })
try { await updateConfig(key, value) }
catch (e) { setError(e instanceof Error ? e.message : 'Save failed') }
}
async function enablePush() {
if (typeof Notification === 'undefined') return
const p = await Notification.requestPermission()
setPerm(p)
}
if (!config) return <div className="page">{error ? <div className="error-banner">{error}</div> : 'Loading…'}</div>
return (
<div className="page" style={{ maxWidth: 620 }}>
<div className="page-header"><h1>Settings</h1></div>
{error && <div className="error-banner">{error}</div>}
<div className="section-title">Reminders</div>
<div className="card">
<label className="field-check" style={{ marginBottom: 12 }}>
<input type="checkbox" checked={config.remind_by_push} disabled={!editable}
onChange={e => set('remind_by_push', e.target.checked)} />
Send a push notification when a task reminder is due
</label>
<label className="field-check">
<input type="checkbox" checked={config.remind_by_email} disabled={!editable}
onChange={e => set('remind_by_email', e.target.checked)} />
Also email me when a reminder is due
</label>
<div className="field-hint" style={{ marginTop: 8 }}>
Reminders are checked every few minutes, so they fire close to the time you set — not to the exact second.
Email uses the stack SMTP settings (Settings app → integrations).
{!editable && ' Only administrators can change these.'}
</div>
</div>
<div className="section-title">This device</div>
<div className="card">
{perm === 'granted' ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--st-done)' }}>
<Bell size={16} strokeWidth={1.75} /> Push notifications are enabled on this device.
</div>
) : perm === 'denied' ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-mid)' }}>
<BellOff size={16} strokeWidth={1.75} /> Notifications are blocked in your browser settings for this site.
</div>
) : (
<div>
<div style={{ marginBottom: 10, color: 'var(--text-mid)' }}>
Allow notifications so reminders can reach you on this device.
</div>
<button className="btn btn-primary" onClick={enablePush}>
<Bell size={14} strokeWidth={1.75} /> Enable notifications
</button>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,62 @@
import { useEffect, useState, useCallback } from 'react'
import { CalendarDays } from 'lucide-react'
import type { Task } from '../types'
import { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
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 [tasks, setTasks] = useState<Task[]>([])
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
fetchTasks({ view: 'today' }).then(setTasks).catch(e => setError(e.message))
}, [])
useEffect(() => { load() }, [load, changeVersion])
async function toggle(t: Task) {
await updateTask(t.id, { status: t.status === 'done' ? 'todo' : 'done' })
load()
}
const now = new Date()
const todayStart = startOfDay(now)
const tomorrowStart = new Date(todayStart); tomorrowStart.setDate(tomorrowStart.getDate() + 1)
const overdue = tasks.filter(t => t.due_at && new Date(t.due_at) < todayStart)
const today = tasks.filter(t => t.due_at && new Date(t.due_at) >= todayStart && new Date(t.due_at) < tomorrowStart)
const upcoming = tasks.filter(t => t.due_at && new Date(t.due_at) >= tomorrowStart)
const groups: Array<{ key: string; title: string; items: Task[]; overdue?: boolean }> = [
{ key: 'overdue', title: 'Overdue', items: overdue, overdue: true },
{ key: 'today', title: 'Today', items: today },
{ key: 'upcoming', title: 'Next 7 days', items: upcoming },
]
return (
<div className="page">
<div className="page-header">
<h1><CalendarDays size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Today</h1>
</div>
{error && <div className="error-banner">{error}</div>}
{tasks.length === 0 && (
<div className="empty-state">Nothing due in the next 7 days. Enjoy the calm. ☕</div>
)}
{groups.map(g => g.items.length > 0 && (
<div key={g.key}>
<div className={`agenda-group-title${g.overdue ? ' overdue' : ''}`}>
{g.title} <span className="muted" style={{ fontWeight: 400 }}>· {g.items.length}</span>
</div>
{g.items.map(t => (
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
))}
</div>
))}
</div>
)
}

35
frontend/src/sw.js Normal file
View file

@ -0,0 +1,35 @@
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'
import { NavigationRoute, registerRoute } from 'workbox-routing'
precacheAndRoute(self.__WB_MANIFEST)
// SPA fallback: navigate requests that don't match a cached asset serve index.html
registerRoute(
new NavigationRoute(createHandlerBoundToURL('/tasks/index.html'), {
denylist: [/\/api\//],
})
)
self.addEventListener('push', event => {
const data = event.data?.json() ?? {}
event.waitUntil(
self.registration.showNotification(data.title || 'Tasks', {
body: data.body || '',
icon: '/tasks/icons/icon-192.png',
badge: '/tasks/icons/icon-192.png',
data: { url: data.url || '/tasks/' },
tag: data.tag || 'tasks',
})
)
})
self.addEventListener('notificationclick', event => {
event.notification.close()
const url = event.notification.data?.url || '/tasks/'
event.waitUntil(
clients.matchAll({ type: 'window' }).then(list => {
const existing = list.find(c => c.url.includes('/tasks/') && 'focus' in c)
return existing ? existing.focus() : clients.openWindow(url)
})
)
})

97
frontend/src/types.ts Normal file
View file

@ -0,0 +1,97 @@
export type Priority = 'low' | 'medium' | 'high'
export type Status = 'todo' | 'doing' | 'done'
export type RecurRule = 'daily' | 'weekdays' | 'weekly' | 'monthly'
export const PRIORITIES: Priority[] = ['low', 'medium', 'high']
export const PRIORITY_LABELS: Record<Priority, string> = { low: 'Low', medium: 'Medium', high: 'High' }
export const STATUSES: Status[] = ['todo', 'doing', 'done']
export const STATUS_LABELS: Record<Status, string> = { todo: 'To do', doing: 'Doing', done: 'Done' }
export const RECUR_RULES: RecurRule[] = ['daily', 'weekdays', 'weekly', 'monthly']
export const RECUR_LABELS: Record<RecurRule, string> = {
daily: 'Every day',
weekdays: 'Every weekday',
weekly: 'Every week',
monthly: 'Every month',
}
// Preset list colours (user data, not design-system chrome).
export const LIST_COLOURS = ['#c9a84c', '#0d9488', '#2563eb', '#7c3aed', '#dc2626', '#d97706', '#16a34a', '#64748b']
export interface List {
id: number
owner_id: string
name: string
colour: string
icon: string
sort_order: number
is_archived: boolean
open_count: number
total_count: number
created_at: string
}
export interface Task {
id: number
list_id: number
owner_id: string
created_by: string
assigned_to: string | null
title: string
notes: string | null
status: Status
priority: Priority
due_at: string | null
remind_at: string | null
reminded_at: string | null
recur_rule: RecurRule | null
completed_at: string | null
sort_order: number
created_at: string
updated_at: string
// joined / derived
list_name?: string
list_colour?: string
item_count?: number
item_done?: number
}
export interface TaskItem {
id: number
task_id: number
label: string
is_done: boolean
sort_order: number
}
export interface TaskComment {
id: number
task_id: number
author_email: string | null
author_name: string | null
body: string
created_at: string
}
export interface TaskDetail extends Task {
items: TaskItem[]
comments: TaskComment[]
}
export interface AppConfig {
remind_by_push: boolean
remind_by_email: boolean
}
export interface User {
user_id: number
name: string
email: string
is_admin: boolean
caps: string[] // bare slugs — verify?app=tasks strips the prefix
}
export function can(user: User, cap: string): boolean {
return user.is_admin || user.caps.includes(cap)
}

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />