Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -19,10 +20,10 @@ export default function App() {
const [token, setToken] = useState<string | null>(loadToken)
const [isDemo, setIsDemo] = useState(false)
const [projects, setProjects] = useState<Project[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [selectedId, setSelectedId] = useLocalStorage<string | null>('planit:selectedProjectId', null)
const [sprints, setSprints] = useState<Sprint[]>([])
const [workItems, setWorkItems] = useState<WorkItem[]>([])
const [tab, setTab] = useState<Tab>('board')
const [tab, setTab] = useLocalStorage<Tab>('planit:tab', 'board')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [createType, setCreateType] = useState<'item' | 'sprint' | 'project' | 'ai' | null>(null)
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
37 changes: 35 additions & 2 deletions src/components/Board.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { useState } from 'react'
import { useLocalStorage } from '../utils/useLocalStorage'

type SortBy = 'none' | 'date' | 'priority' | 'points'
const PRIORITY_ORDER: Record<string, number> = { high: 0, medium: 1, low: 2 }
import {
DndContext,
DragEndEvent,
Expand Down Expand Up @@ -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 (
<>
<div style={cardRow}>
Expand All @@ -50,6 +55,11 @@ function CardContent({ item, listeners, attributes }: { item: WorkItem; listener
<span style={{ ...badge, color: PRIORITY_COLOR[item.priority] }}>{PRIORITY_LABELS[item.priority]}</span>
{item.storyPoints > 0 && <span style={pointsBadge}>{item.storyPoints}pt</span>}
</div>
{item.deadline && (
<div style={{ ...deadlineTag, color: isOverdue ? '#f87171' : '#64748b' }}>
{isOverdue ? '⚠ ' : ''}{item.deadline}
</div>
)}
{item.assignee && <div style={assigneeTag}>{item.assignee}</div>}
</>
)
Expand All @@ -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<WorkItem | null>(null)
const [dragging, setDragging] = useState<WorkItem | null>(null)
const [sortBy, setSortBy] = useLocalStorage<SortBy>('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)
Expand All @@ -95,6 +120,12 @@ export default function Board({ workItems, sprints, onUpdate, onDelete }: Props)
<div style={wrapper}>
<div style={toolbar}>
<h2 style={heading}>Board</h2>
<select style={sortSelect} value={sortBy} onChange={e => setSortBy(e.target.value as SortBy)}>
<option value="none">Sort: Default</option>
<option value="date">Sort: By Date</option>
<option value="priority">Sort: By Priority</option>
<option value="points">Sort: By Story Points</option>
</select>
</div>

<DndContext collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
Expand Down Expand Up @@ -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' }
19 changes: 19 additions & 0 deletions src/utils/useLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useState } from 'react'

export function useLocalStorage<T>(key: string, initial: T): [T, (v: T) => void] {
const [value, setValue] = useState<T>(() => {
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]
}
Loading