From 279899be2ac23ea48dbf23da24330c1d68f3fd1b Mon Sep 17 00:00:00 2001 From: Isaiah Date: Mon, 18 May 2026 09:35:58 -0400 Subject: [PATCH] feat: board sort dropdown and persistent view state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sort dropdown on the board: by date (soonest first), priority (high→low), or story points (low→high); selection persists across refreshes - Deadline shown on cards; overdue items highlighted in red - Optimistic drag-and-drop: card moves instantly, API updates in background, reverts on failure so there's no flicker - useLocalStorage hook persists selected project, active tab, and board sort so the full view is restored on refresh Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 22 ++++++++++++++++----- src/components/Board.tsx | 37 ++++++++++++++++++++++++++++++++++-- src/utils/useLocalStorage.ts | 19 ++++++++++++++++++ 3 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 src/utils/useLocalStorage.ts diff --git a/src/App.tsx b/src/App.tsx index 45119ee..6df4dd7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import type { Project, Sprint, WorkItem, Tab } from './types' import * as realApi from './api/calendar' import * as mockApi from './demo/mockApi' import { saveToken, loadToken, clearToken } from './utils/auth' +import { useLocalStorage } from './utils/useLocalStorage' import Login from './components/Login' import ProjectList from './components/ProjectList' import Board from './components/Board' @@ -19,10 +20,10 @@ export default function App() { const [token, setToken] = useState(loadToken) const [isDemo, setIsDemo] = useState(false) const [projects, setProjects] = useState([]) - const [selectedId, setSelectedId] = useState(null) + const [selectedId, setSelectedId] = useLocalStorage('planit:selectedProjectId', null) const [sprints, setSprints] = useState([]) const [workItems, setWorkItems] = useState([]) - const [tab, setTab] = useState('board') + const [tab, setTab] = useLocalStorage('planit:tab', 'board') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [createType, setCreateType] = useState<'item' | 'sprint' | 'project' | 'ai' | null>(null) @@ -35,7 +36,11 @@ export default function App() { if (!effectiveToken) return setLoading(true) api.listProjects(effectiveToken) - .then((p: Project[]) => { setProjects(p); if (p.length > 0) setSelectedId(p[0].id) }) + .then((p: Project[]) => { + setProjects(p) + if (p.length === 0) { setSelectedId(null); return } + if (!selectedId || !p.some(proj => proj.id === selectedId)) setSelectedId(p[0].id) + }) .catch((e: Error) => setError(e.message)) .finally(() => setLoading(false)) }, [token, isDemo]) // eslint-disable-line react-hooks/exhaustive-deps @@ -110,8 +115,15 @@ export default function App() { async function handleUpdateItem(item: WorkItem) { if (!effectiveToken || !selectedId) return - const updated = await api.updateWorkItem(effectiveToken, selectedId, item) - setWorkItems(w => w.map(x => x.id === item.id ? updated : x)) + const original = workItems.find(x => x.id === item.id) + setWorkItems(w => w.map(x => x.id === item.id ? item : x)) + try { + const updated = await api.updateWorkItem(effectiveToken, item.projectId, item) + setWorkItems(w => w.map(x => x.id === item.id ? updated : x)) + } catch (e) { + if (original) setWorkItems(w => w.map(x => x.id === item.id ? original : x)) + setError(e instanceof Error ? e.message : 'Failed to update item') + } } async function handleDeleteItem(item: WorkItem) { diff --git a/src/components/Board.tsx b/src/components/Board.tsx index 64cf7cf..e3e37d9 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -1,4 +1,8 @@ import { useState } from 'react' +import { useLocalStorage } from '../utils/useLocalStorage' + +type SortBy = 'none' | 'date' | 'priority' | 'points' +const PRIORITY_ORDER: Record = { high: 0, medium: 1, low: 2 } import { DndContext, DragEndEvent, @@ -39,6 +43,7 @@ function DraggableCard({ item, onClick }: { item: WorkItem; onClick: () => void } function CardContent({ item, listeners, attributes }: { item: WorkItem; listeners?: object; attributes?: object }) { + const isOverdue = item.deadline && item.deadline < new Date().toISOString().slice(0, 10) && item.status !== 'resolved' return ( <>
@@ -50,6 +55,11 @@ function CardContent({ item, listeners, attributes }: { item: WorkItem; listener {PRIORITY_LABELS[item.priority]} {item.storyPoints > 0 && {item.storyPoints}pt}
+ {item.deadline && ( +
+ {isOverdue ? '⚠ ' : ''}{item.deadline} +
+ )} {item.assignee &&
{item.assignee}
} ) @@ -75,8 +85,23 @@ function DroppableColumn({ id, label, count, children }: { id: string; label: st export default function Board({ workItems, sprints, onUpdate, onDelete }: Props) { const [editing, setEditing] = useState(null) const [dragging, setDragging] = useState(null) + const [sortBy, setSortBy] = useLocalStorage('planit:boardSort', 'none') + + function sortedItems(items: WorkItem[]) { + if (sortBy === 'date') return [...items].sort((a, b) => { + if (!a.deadline && !b.deadline) return 0 + if (!a.deadline) return 1 + if (!b.deadline) return -1 + return a.deadline.localeCompare(b.deadline) + }) + if (sortBy === 'priority') return [...items].sort((a, b) => + (PRIORITY_ORDER[a.priority] ?? 9) - (PRIORITY_ORDER[b.priority] ?? 9) + ) + if (sortBy === 'points') return [...items].sort((a, b) => a.storyPoints - b.storyPoints) + return items + } - const byStatus = (status: string) => workItems.filter(i => i.status === status) + const byStatus = (status: string) => sortedItems(workItems.filter(i => i.status === status)) function handleDragStart(e: DragStartEvent) { setDragging(workItems.find(i => i.id === e.active.id) ?? null) @@ -95,6 +120,12 @@ export default function Board({ workItems, sprints, onUpdate, onDelete }: Props)

Board

+
@@ -146,4 +177,6 @@ const cardTitle: React.CSSProperties = { fontSize: 14, color: '#e2e8f0', lineHei const cardMeta: React.CSSProperties = { display: 'flex', gap: 6, flexWrap: 'wrap' } const badge: React.CSSProperties = { fontSize: 11, fontWeight: 600 } const pointsBadge: React.CSSProperties = { fontSize: 11, color: '#64748b', marginLeft: 'auto' } -const assigneeTag: React.CSSProperties = { fontSize: 11, color: '#475569', borderTop: '1px solid #334155', paddingTop: 6, marginTop: 2 } +const assigneeTag: React.CSSProperties = { fontSize: 11, color: '#475569', borderTop: '1px solid #334155', paddingTop: 6, marginTop: 2 } +const deadlineTag: React.CSSProperties = { fontSize: 11, marginTop: 2 } +const sortSelect: React.CSSProperties = { background: '#1e293b', border: '1px solid #334155', color: '#94a3b8', borderRadius: 6, padding: '4px 10px', fontSize: 12, cursor: 'pointer', outline: 'none' } diff --git a/src/utils/useLocalStorage.ts b/src/utils/useLocalStorage.ts new file mode 100644 index 0000000..0977fe8 --- /dev/null +++ b/src/utils/useLocalStorage.ts @@ -0,0 +1,19 @@ +import { useState } from 'react' + +export function useLocalStorage(key: string, initial: T): [T, (v: T) => void] { + const [value, setValue] = useState(() => { + try { + const stored = localStorage.getItem(key) + return stored !== null ? (JSON.parse(stored) as T) : initial + } catch { + return initial + } + }) + + function set(v: T) { + setValue(v) + try { localStorage.setItem(key, JSON.stringify(v)) } catch { /* quota exceeded */ } + } + + return [value, set] +}