Main branch Resync on w/ gitea. v0.1.6 #1

Merged
serversdown merged 20 commits from claude/nested-todo-app-mvp-014vUjLpD8wNjkMhLMXS6Kd5 into main 2026-01-04 04:30:52 -05:00
5 changed files with 256 additions and 143 deletions
Showing only changes of commit c9555737d8 - Show all commits

View File

@@ -8,6 +8,7 @@ import {
} from '../utils/api' } from '../utils/api'
import { formatTimeWithTotal } from '../utils/format' import { formatTimeWithTotal } from '../utils/format'
import TaskMenu from './TaskMenu' import TaskMenu from './TaskMenu'
import TaskForm from './TaskForm'
const STATUSES = [ const STATUSES = [
{ key: 'backlog', label: 'Backlog', color: 'border-gray-600' }, { key: 'backlog', label: 'Backlog', color: 'border-gray-600' },
@@ -150,20 +151,18 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, onDragOver }) { function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, onDragOver }) {
const [showAddTask, setShowAddTask] = useState(false) const [showAddTask, setShowAddTask] = useState(false)
const [newTaskTitle, setNewTaskTitle] = useState('')
const handleAddTask = async (e) => {
e.preventDefault()
if (!newTaskTitle.trim()) return
const handleAddTask = async (taskData) => {
try { try {
await createTask({ await createTask({
project_id: parseInt(projectId), project_id: parseInt(projectId),
parent_task_id: null, parent_task_id: null,
title: newTaskTitle, title: taskData.title,
status: status.key status: status.key,
tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color
}) })
setNewTaskTitle('')
setShowAddTask(false) setShowAddTask(false)
onUpdate() onUpdate()
} catch (err) { } catch (err) {
@@ -192,31 +191,11 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
{showAddTask && ( {showAddTask && (
<div className="mb-3"> <div className="mb-3">
<form onSubmit={handleAddTask}> <TaskForm
<input onSubmit={handleAddTask}
type="text" onCancel={() => setShowAddTask(false)}
value={newTaskTitle} submitLabel="Add Task"
onChange={(e) => setNewTaskTitle(e.target.value)} />
placeholder="Task title..."
className="w-full px-2 py-2 bg-cyber-darkest border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange mb-2"
autoFocus
/>
<div className="flex gap-2">
<button
type="submit"
className="px-3 py-1 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright text-sm font-semibold"
>
Add
</button>
<button
type="button"
onClick={() => setShowAddTask(false)}
className="px-3 py-1 text-gray-400 hover:text-gray-200 text-sm"
>
Cancel
</button>
</div>
</form>
</div> </div>
)} )}

View File

@@ -0,0 +1,142 @@
import { useState } from 'react'
import { Flag } from 'lucide-react'
const FLAG_COLORS = [
{ name: null, label: 'None', color: 'bg-gray-700' },
{ name: 'red', label: 'Red', color: 'bg-red-500' },
{ name: 'orange', label: 'Orange', color: 'bg-orange-500' },
{ name: 'yellow', label: 'Yellow', color: 'bg-yellow-500' },
{ name: 'green', label: 'Green', color: 'bg-green-500' },
{ name: 'blue', label: 'Blue', color: 'bg-blue-500' },
{ name: 'purple', label: 'Purple', color: 'bg-purple-500' },
{ name: 'pink', label: 'Pink', color: 'bg-pink-500' }
]
function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
const [title, setTitle] = useState('')
const [tags, setTags] = useState('')
const [hours, setHours] = useState('')
const [minutes, setMinutes] = useState('')
const [flagColor, setFlagColor] = useState(null)
const handleSubmit = (e) => {
e.preventDefault()
if (!title.trim()) return
// Convert hours and minutes to total minutes
const totalMinutes = (parseInt(hours) || 0) * 60 + (parseInt(minutes) || 0)
// Parse tags
const tagList = tags
? tags.split(',').map(t => t.trim()).filter(t => t.length > 0)
: null
const taskData = {
title: title.trim(),
tags: tagList && tagList.length > 0 ? tagList : null,
estimated_minutes: totalMinutes > 0 ? totalMinutes : null,
flag_color: flagColor
}
onSubmit(taskData)
}
return (
<form onSubmit={handleSubmit} className="bg-cyber-darkest border border-cyber-orange/30 rounded-lg p-4 space-y-3">
{/* Title */}
<div>
<label className="block text-xs text-gray-400 mb-1">Task Title *</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter task title..."
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
autoFocus
/>
</div>
{/* Tags */}
<div>
<label className="block text-xs text-gray-400 mb-1">Tags (comma-separated)</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="coding, bug-fix, frontend"
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
</div>
{/* Time Estimate */}
<div>
<label className="block text-xs text-gray-400 mb-1">Time Estimate</label>
<div className="flex gap-2">
<div className="flex-1">
<input
type="number"
min="0"
value={hours}
onChange={(e) => setHours(e.target.value)}
placeholder="Hours"
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
</div>
<div className="flex-1">
<input
type="number"
min="0"
max="59"
value={minutes}
onChange={(e) => setMinutes(e.target.value)}
placeholder="Minutes"
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
</div>
</div>
</div>
{/* Flag Color */}
<div>
<label className="block text-xs text-gray-400 mb-1">Flag Color</label>
<div className="flex gap-2 flex-wrap">
{FLAG_COLORS.map(({ name, label, color }) => (
<button
key={name || 'none'}
type="button"
onClick={() => setFlagColor(name)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-all ${
flagColor === name
? 'bg-cyber-orange/20 border-2 border-cyber-orange'
: 'border-2 border-transparent hover:border-cyber-orange/40'
}`}
title={label}
>
<div className={`w-4 h-4 ${color} rounded`} />
{flagColor === name && '✓'}
</button>
))}
</div>
</div>
{/* Buttons */}
<div className="flex gap-2 pt-2">
<button
type="submit"
className="flex-1 px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold text-sm transition-colors"
>
{submitLabel}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-400 hover:text-gray-200 text-sm transition-colors"
>
Cancel
</button>
</div>
</form>
)
}
export default TaskForm

View File

@@ -25,7 +25,13 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
const [showTagsEdit, setShowTagsEdit] = useState(false) const [showTagsEdit, setShowTagsEdit] = useState(false)
const [showFlagEdit, setShowFlagEdit] = useState(false) const [showFlagEdit, setShowFlagEdit] = useState(false)
const [showStatusEdit, setShowStatusEdit] = useState(false) const [showStatusEdit, setShowStatusEdit] = useState(false)
const [editTime, setEditTime] = useState(task.estimated_minutes || '')
// Calculate hours and minutes from task.estimated_minutes
const initialHours = task.estimated_minutes ? Math.floor(task.estimated_minutes / 60) : ''
const initialMinutes = task.estimated_minutes ? task.estimated_minutes % 60 : ''
const [editHours, setEditHours] = useState(initialHours)
const [editMinutes, setEditMinutes] = useState(initialMinutes)
const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '') const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '')
const menuRef = useRef(null) const menuRef = useRef(null)
@@ -48,7 +54,8 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
const handleUpdateTime = async () => { const handleUpdateTime = async () => {
try { try {
const minutes = editTime ? parseInt(editTime) : null const totalMinutes = (parseInt(editHours) || 0) * 60 + (parseInt(editMinutes) || 0)
const minutes = totalMinutes > 0 ? totalMinutes : null
await updateTask(task.id, { estimated_minutes: minutes }) await updateTask(task.id, { estimated_minutes: minutes })
setShowTimeEdit(false) setShowTimeEdit(false)
setIsOpen(false) setIsOpen(false)
@@ -125,29 +132,42 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
<div className="p-3 border-b border-cyber-orange/20"> <div className="p-3 border-b border-cyber-orange/20">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Clock size={14} className="text-cyber-orange" /> <Clock size={14} className="text-cyber-orange" />
<span className="text-sm text-gray-300">Time Estimate (minutes)</span> <span className="text-sm text-gray-300">Time Estimate</span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2 mb-2">
<input <input
type="number" type="number"
value={editTime} min="0"
onChange={(e) => setEditTime(e.target.value)} value={editHours}
placeholder="Minutes" onChange={(e) => setEditHours(e.target.value)}
placeholder="Hours"
className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
autoFocus autoFocus
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
<input
type="number"
min="0"
max="59"
value={editMinutes}
onChange={(e) => setEditMinutes(e.target.value)}
placeholder="Minutes"
className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="flex gap-2">
<button <button
onClick={handleUpdateTime} onClick={handleUpdateTime}
className="text-green-400 hover:text-green-300" className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
> >
<Check size={16} /> Save
</button> </button>
<button <button
onClick={() => setShowTimeEdit(false)} onClick={() => setShowTimeEdit(false)}
className="text-gray-400 hover:text-gray-300" className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
> >
<X size={16} /> Cancel
</button> </button>
</div> </div>
</div> </div>

View File

@@ -16,6 +16,7 @@ import {
} from '../utils/api' } from '../utils/api'
import { formatTimeWithTotal } from '../utils/format' import { formatTimeWithTotal } from '../utils/format'
import TaskMenu from './TaskMenu' import TaskMenu from './TaskMenu'
import TaskForm from './TaskForm'
const STATUS_COLORS = { const STATUS_COLORS = {
backlog: 'text-gray-400', backlog: 'text-gray-400',
@@ -47,7 +48,6 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
const [editTitle, setEditTitle] = useState(task.title) const [editTitle, setEditTitle] = useState(task.title)
const [editStatus, setEditStatus] = useState(task.status) const [editStatus, setEditStatus] = useState(task.status)
const [showAddSubtask, setShowAddSubtask] = useState(false) const [showAddSubtask, setShowAddSubtask] = useState(false)
const [newSubtaskTitle, setNewSubtaskTitle] = useState('')
const hasSubtasks = task.subtasks && task.subtasks.length > 0 const hasSubtasks = task.subtasks && task.subtasks.length > 0
@@ -74,18 +74,17 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
} }
} }
const handleAddSubtask = async (e) => { const handleAddSubtask = async (taskData) => {
e.preventDefault()
if (!newSubtaskTitle.trim()) return
try { try {
await createTask({ await createTask({
project_id: parseInt(projectId), project_id: parseInt(projectId),
parent_task_id: task.id, parent_task_id: task.id,
title: newSubtaskTitle, title: taskData.title,
status: 'backlog' status: 'backlog',
tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color
}) })
setNewSubtaskTitle('')
setShowAddSubtask(false) setShowAddSubtask(false)
setIsExpanded(true) setIsExpanded(true)
onUpdate() onUpdate()
@@ -213,29 +212,11 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
{/* Add Subtask Form */} {/* Add Subtask Form */}
{showAddSubtask && ( {showAddSubtask && (
<div style={{ marginLeft: `${level * 1.5}rem` }} className="mt-2"> <div style={{ marginLeft: `${level * 1.5}rem` }} className="mt-2">
<form onSubmit={handleAddSubtask} className="flex gap-2"> <TaskForm
<input onSubmit={handleAddSubtask}
type="text" onCancel={() => setShowAddSubtask(false)}
value={newSubtaskTitle} submitLabel="Add Subtask"
onChange={(e) => setNewSubtaskTitle(e.target.value)} />
placeholder="New subtask title..."
className="flex-1 px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
autoFocus
/>
<button
type="submit"
className="px-3 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright text-sm font-semibold"
>
Add
</button>
<button
type="button"
onClick={() => setShowAddSubtask(false)}
className="px-3 py-2 text-gray-400 hover:text-gray-200 text-sm"
>
Cancel
</button>
</form>
</div> </div>
)} )}
@@ -262,7 +243,6 @@ function TreeView({ projectId }) {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [showAddRoot, setShowAddRoot] = useState(false) const [showAddRoot, setShowAddRoot] = useState(false)
const [newTaskTitle, setNewTaskTitle] = useState('')
useEffect(() => { useEffect(() => {
loadTasks() loadTasks()
@@ -280,18 +260,17 @@ function TreeView({ projectId }) {
} }
} }
const handleAddRootTask = async (e) => { const handleAddRootTask = async (taskData) => {
e.preventDefault()
if (!newTaskTitle.trim()) return
try { try {
await createTask({ await createTask({
project_id: parseInt(projectId), project_id: parseInt(projectId),
parent_task_id: null, parent_task_id: null,
title: newTaskTitle, title: taskData.title,
status: 'backlog' status: 'backlog',
tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color
}) })
setNewTaskTitle('')
setShowAddRoot(false) setShowAddRoot(false)
loadTasks() loadTasks()
} catch (err) { } catch (err) {
@@ -322,29 +301,11 @@ function TreeView({ projectId }) {
{showAddRoot && ( {showAddRoot && (
<div className="mb-4"> <div className="mb-4">
<form onSubmit={handleAddRootTask} className="flex gap-2"> <TaskForm
<input onSubmit={handleAddRootTask}
type="text" onCancel={() => setShowAddRoot(false)}
value={newTaskTitle} submitLabel="Add Task"
onChange={(e) => setNewTaskTitle(e.target.value)} />
placeholder="New task title..."
className="flex-1 px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 focus:outline-none focus:border-cyber-orange"
autoFocus
/>
<button
type="submit"
className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold"
>
Add
</button>
<button
type="button"
onClick={() => setShowAddRoot(false)}
className="px-4 py-2 text-gray-400 hover:text-gray-200"
>
Cancel
</button>
</form>
</div> </div>
)} )}

View File

@@ -1,4 +1,4 @@
// Format minutes into display string // Format minutes into display string (e.g., "1h 30m" or "45m")
export function formatTime(minutes) { export function formatTime(minutes) {
if (!minutes || minutes === 0) return null; if (!minutes || minutes === 0) return null;
@@ -6,8 +6,14 @@ export function formatTime(minutes) {
return `${minutes}m`; return `${minutes}m`;
} }
const hours = minutes / 60; const hours = Math.floor(minutes / 60);
return `${hours.toFixed(1)}h`; const mins = minutes % 60;
if (mins === 0) {
return `${hours}h`;
}
return `${hours}h ${mins}m`;
} }
// Format tags as comma-separated string // Format tags as comma-separated string
@@ -16,58 +22,63 @@ export function formatTags(tags) {
return tags.join(', '); return tags.join(', ');
} }
// Recursively calculate total time estimate including all subtasks // Calculate sum of all LEAF descendant estimates (hierarchical structure)
export function calculateTotalTime(task) { export function calculateLeafTime(task) {
let total = task.estimated_minutes || 0; // If no subtasks, this is a leaf - return its own estimate
if (!task.subtasks || task.subtasks.length === 0) {
return task.estimated_minutes || 0;
}
if (task.subtasks && task.subtasks.length > 0) { // Has subtasks, so sum up all leaf descendants
for (const subtask of task.subtasks) { let total = 0;
total += calculateTotalTime(subtask); for (const subtask of task.subtasks) {
} total += calculateLeafTime(subtask);
} }
return total; return total;
} }
// Calculate total time for a task from a flat list of all tasks // Calculate sum of all LEAF descendant estimates (flat task list)
export function calculateTotalTimeFlat(task, allTasks) { export function calculateLeafTimeFlat(task, allTasks) {
let total = task.estimated_minutes || 0; // Find direct children
// Find all direct children
const children = allTasks.filter(t => t.parent_task_id === task.id); const children = allTasks.filter(t => t.parent_task_id === task.id);
// Recursively add their times // If no children, this is a leaf - return its own estimate
if (children.length === 0) {
return task.estimated_minutes || 0;
}
// Has children, so sum up all leaf descendants
let total = 0;
for (const child of children) { for (const child of children) {
total += calculateTotalTimeFlat(child, allTasks); total += calculateLeafTimeFlat(child, allTasks);
} }
return total; return total;
} }
// Format time display showing own estimate and total if different // Format time display based on leaf calculation logic
export function formatTimeWithTotal(task, allTasks = null) { export function formatTimeWithTotal(task, allTasks = null) {
const ownTime = task.estimated_minutes || 0; // Check if task has subtasks
const hasSubtasks = allTasks
? allTasks.some(t => t.parent_task_id === task.id)
: (task.subtasks && task.subtasks.length > 0);
// If we have a flat task list, use that to calculate total // Leaf task: use own estimate
const totalTime = allTasks if (!hasSubtasks) {
? calculateTotalTimeFlat(task, allTasks) return formatTime(task.estimated_minutes);
: calculateTotalTime(task);
const subtaskTime = totalTime - ownTime;
// No time estimates at all
if (totalTime === 0) return null;
// Only own estimate, no subtasks with time
if (subtaskTime === 0) {
return formatTime(ownTime);
} }
// Only subtask estimates, no own estimate // Parent task: calculate sum of leaf descendants
if (ownTime === 0) { const leafTotal = allTasks
return `(${formatTime(totalTime)} from subtasks)`; ? calculateLeafTimeFlat(task, allTasks)
: calculateLeafTime(task);
// If no leaf estimates exist, fall back to own estimate
if (leafTotal === 0) {
return formatTime(task.estimated_minutes);
} }
// Both own and subtask estimates // Show leaf total
return `${formatTime(ownTime)} (${formatTime(totalTime)} total)`; return formatTime(leafTotal);
} }