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

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())}`
}