Finalize sprint/backlog status flow and document current behavior

This commit is contained in:
OpenClaw Bot 2026-02-19 23:25:43 -06:00
parent c5cf7703b8
commit eaf01cf634
6 changed files with 478 additions and 158 deletions

120
README.md
View File

@ -1,36 +1,100 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Gantt Board
## Getting Started
Task and sprint board built with Next.js + Zustand and file-backed API persistence.
First, run the development server:
## Current Product Behavior
### Data model and status rules
- Tasks use labels (`tags: string[]`) and can have multiple labels.
- There is no active `backlog` status in workflow logic.
- A task is considered in Backlog when `sprintId` is empty.
- Current status values:
- `open`
- `todo`
- `blocked`
- `in-progress`
- `review`
- `validate`
- `archived`
- `canceled`
- `done`
- New tasks default to `status: open`.
### Labels
- Project selection UI for tasks was removed in favor of labels.
- You can add/remove labels inline in:
- New Task modal
- Task Detail modal
- Label entry supports:
- Enter/comma to add
- Existing-label suggestions
- Quick-add chips
- Case-insensitive de-duplication
### Backlog drag and drop
Backlog view supports moving tasks between:
- Current sprint
- Other sprint sections
- Backlog
Status behavior on drop:
- Drop into any sprint section: `status -> open`
- Drop into backlog section: `status -> open`
- `sprintId` is set/cleared based on destination
Changes persist through store sync to `data/tasks.json`.
### Kanban drag and drop
Kanban supports drag by visible left handle on each task card.
Columns:
- `To Do` contains statuses: `open`, `todo`
- `In Progress` contains statuses: `blocked`, `in-progress`, `review`, `validate`
- `Done` contains statuses: `archived`, `canceled`, `done`
Drop behavior:
- Drop on column body: applies that column's default status
- `To Do` -> `open`
- `In Progress` -> `in-progress`
- `Done` -> `done`
- Drop on status chip target: applies exact status
- Drop on a task: adopts that task's exact status
During drag, the active target column shows expanded status drop zones for clarity.
### Layout
- Left sidebar cards (Current Sprint and Labels quick view) were removed.
- Main board now uses full width for Kanban/Backlog views.
## Persistence
- Client state is managed with Zustand.
- Persistence is done via `/api/tasks`.
- API reads/writes `data/tasks.json` (single-file storage).
## Run locally
```bash
npm install
npm run dev
```
Open `http://localhost:3000`.
## Scripts
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
npm run build
npm run start
npm run lint
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Notes
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
- You may see a Next.js warning about multiple lockfiles and inferred workspace root. This is non-blocking for local dev.

View File

@ -28,12 +28,12 @@
"title": "Redesign Gantt Board",
"description": "Make it actually work with proper notes system",
"type": "task",
"status": "in-progress",
"status": "archived",
"priority": "high",
"projectId": "2",
"sprintId": "sprint-1",
"createdAt": "2026-02-18T17:01:23.109Z",
"updatedAt": "2026-02-20T05:01:04.207Z",
"updatedAt": "2026-02-20T05:14:09.324Z",
"comments": [
{
"id": "c1",
@ -58,7 +58,7 @@
"id": "2",
"title": "MoodWeave App Idea - UPDATED",
"projectId": "1",
"status": "backlog",
"status": "open",
"priority": "high",
"type": "idea",
"comments": [],
@ -384,7 +384,7 @@
"title": "Research TTS options for Daily Digest podcast",
"description": "Research free text-to-speech (TTS) tools to convert the daily digest blog posts into an audio podcast format.",
"type": "research",
"status": "backlog",
"status": "open",
"priority": "medium",
"projectId": "2",
"sprintId": "sprint-1",
@ -465,13 +465,14 @@
"title": "Add Sprint functionality to Gantt Board",
"projectId": "2",
"sprintId": "sprint-1",
"updatedAt": "2026-02-20T01:52:57.259Z",
"updatedAt": "2026-02-20T05:24:24.353Z",
"tags": [
"Web Projects"
]
],
"status": "todo"
}
],
"lastUpdated": 1771563767290,
"lastUpdated": 1771565064468,
"sprints": [
{
"name": "Sprint 1",

View File

@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { join } from "path";
const DATA_FILE = join(process.cwd(), "data", "tasks.json");
@ -11,7 +11,7 @@ interface Task {
title: string;
description?: string;
type: 'idea' | 'task' | 'bug' | 'research' | 'plan';
status: 'open' | 'backlog' | 'blocked' | 'in-progress' | 'review' | 'validate' | 'archived' | 'canceled' | 'done';
status: 'open' | 'todo' | 'blocked' | 'in-progress' | 'review' | 'validate' | 'archived' | 'canceled' | 'done';
priority: 'low' | 'medium' | 'high' | 'urgent';
projectId: string;
sprintId?: string;
@ -81,7 +81,7 @@ function getData(): DataStore {
function saveData(data: DataStore) {
const dir = join(process.cwd(), "data");
if (!existsSync(dir)) {
require("fs").mkdirSync(dir, { recursive: true });
mkdirSync(dir, { recursive: true });
}
data.lastUpdated = Date.now();
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
@ -160,7 +160,7 @@ export async function DELETE(request: Request) {
data.tasks = data.tasks.filter((t) => t.id !== id);
saveData(data);
return NextResponse.json({ success: true });
} catch (error) {
} catch {
return NextResponse.json({ error: "Failed to delete" }, { status: 500 });
}
}

View File

@ -1,6 +1,20 @@
"use client"
import { useState, useEffect, useMemo } from "react"
import { useState, useEffect, useMemo, type ReactNode } from "react"
import {
DndContext,
DragEndEvent,
DragOverlay,
DragOverEvent,
DragStartEvent,
PointerSensor,
useDraggable,
useDroppable,
useSensor,
useSensors,
closestCorners,
} from "@dnd-kit/core"
import { CSS } from "@dnd-kit/utilities"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
@ -9,7 +23,7 @@ import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { useTaskStore, Task, TaskType, TaskStatus, Priority } from "@/stores/useTaskStore"
import { BacklogView } from "@/components/BacklogView"
import { Plus, MessageSquare, Calendar, Trash2, Edit2, X, LayoutGrid, ListTodo } from "lucide-react"
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical } from "lucide-react"
const typeColors: Record<TaskType, string> = {
idea: "bg-purple-500",
@ -34,14 +48,14 @@ const priorityColors: Record<Priority, string> = {
urgent: "text-red-400",
}
const allStatuses: TaskStatus[] = ["open", "backlog", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"]
const allStatuses: TaskStatus[] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"]
// Sprint board columns mapped to workflow statuses
const sprintColumns: { key: string; label: string; statuses: TaskStatus[] }[] = [
{
key: "todo",
label: "To Do",
statuses: ["open", "backlog"]
statuses: ["open", "todo"]
},
{
key: "inprogress",
@ -55,6 +69,215 @@ const sprintColumns: { key: string; label: string; statuses: TaskStatus[] }[] =
},
]
const sprintColumnDropStatus: Record<string, TaskStatus> = {
todo: "open",
inprogress: "in-progress",
done: "done",
}
const formatStatusLabel = (status: TaskStatus) =>
status === "todo"
? "To Do"
:
status
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ")
function KanbanStatusDropTarget({
status,
count,
expanded = false,
}: {
status: TaskStatus
count: number
expanded?: boolean
}) {
const { isOver, setNodeRef } = useDroppable({ id: `kanban-status-${status}` })
return (
<div
ref={setNodeRef}
className={`rounded-md border transition-colors ${
expanded ? "px-3 py-3 text-xs" : "px-2 py-1 text-[11px]"
} ${
isOver
? "border-blue-400/70 bg-blue-500/20 text-blue-200"
: "border-slate-700 text-slate-400 bg-slate-900/40"
}`}
title={`Drop to set status: ${formatStatusLabel(status)}`}
>
{formatStatusLabel(status)} ({count})
</div>
)
}
function KanbanDropColumn({
id,
label,
count,
statusTargets,
isDragging,
isActiveDropColumn,
children,
}: {
id: string
label: string
count: number
statusTargets: Array<{ status: TaskStatus; count: number }>
isDragging: boolean
isActiveDropColumn: boolean
children: ReactNode
}) {
const { isOver, setNodeRef } = useDroppable({ id })
return (
<div className="flex flex-col">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-slate-400">{label}</h3>
<Badge variant="secondary" className="bg-slate-800 text-slate-400">
{count}
</Badge>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{statusTargets.map((target) => (
<KanbanStatusDropTarget
key={target.status}
status={target.status}
count={target.count}
/>
))}
</div>
<div
ref={setNodeRef}
className={`space-y-3 min-h-32 rounded-lg p-2 transition-colors ${
isOver ? "bg-blue-500/10 ring-1 ring-blue-500/50" : ""
}`}
>
{isDragging && isActiveDropColumn ? (
<div className="space-y-2">
<p className="text-xs text-slate-400 px-1">Drop into an exact status:</p>
{statusTargets.map((target) => (
<KanbanStatusDropTarget
key={`expanded-${target.status}`}
status={target.status}
count={target.count}
expanded
/>
))}
</div>
) : (
children
)}
</div>
</div>
)
}
function KanbanTaskCard({
task,
taskTags,
onOpen,
onDelete,
}: {
task: Task
taskTags: string[]
onOpen: () => void
onDelete: () => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useDraggable({
id: task.id,
})
const style = {
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
}
return (
<Card
ref={setNodeRef}
style={style}
className="bg-slate-900 border-slate-800 hover:border-slate-700 cursor-pointer transition-colors group"
onClick={onOpen}
>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="h-7 w-6 shrink-0 rounded border border-slate-700/80 bg-slate-800/70 flex items-center justify-center text-slate-400 hover:text-slate-200 cursor-grab active:cursor-grabbing"
title="Drag task"
aria-label="Drag task"
{...attributes}
{...listeners}
>
<GripVertical className="w-3.5 h-3.5" />
</button>
<Badge
variant="outline"
className={`text-xs ${typeColors[task.type]} text-white border-0`}
>
{typeLabels[task.type]}
</Badge>
</div>
<button
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className="p-1 hover:bg-slate-800 rounded"
>
<Trash2 className="w-3 h-3 text-slate-400" />
</button>
</div>
<h4 className="font-medium text-white mb-1">{task.title}</h4>
{task.description && (
<p className="text-sm text-slate-400 line-clamp-2 mb-3">
{task.description}
</p>
)}
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className={priorityColors[task.priority]}>
{task.priority}
</span>
{task.comments && task.comments.length > 0 && (
<span className="flex items-center gap-1 text-slate-500">
<MessageSquare className="w-3 h-3" />
{task.comments.length}
</span>
)}
</div>
{task.dueDate && (
<span className="text-slate-500 flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(task.dueDate).toLocaleDateString()}
</span>
)}
</div>
{taskTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{taskTags.map((tag) => (
<span
key={tag}
className="text-xs px-2 py-0.5 bg-slate-800 text-slate-400 rounded"
>
{tag}
</span>
))}
</div>
)}
</CardContent>
</Card>
)
}
export default function Home() {
console.log('>>> PAGE: Component rendering')
const {
@ -79,7 +302,7 @@ export default function Home() {
description: "",
type: "task",
priority: "medium",
status: "backlog",
status: "open",
tags: [],
})
const [newComment, setNewComment] = useState("")
@ -87,6 +310,8 @@ export default function Home() {
const [editedTask, setEditedTask] = useState<Task | null>(null)
const [newTaskLabelInput, setNewTaskLabelInput] = useState("")
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
const [activeKanbanTaskId, setActiveKanbanTaskId] = useState<string | null>(null)
const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState<string | null>(null)
const getTags = (taskLike: { tags?: unknown }) => {
if (!Array.isArray(taskLike.tags)) return [] as string[]
@ -141,6 +366,9 @@ export default function Home() {
const sprintTasks = currentSprint
? tasks.filter((t) => t.sprintId === currentSprint.id)
: []
const activeKanbanTask = activeKanbanTaskId
? sprintTasks.find((task) => task.id === activeKanbanTaskId)
: null
const toLabel = (raw: string) => raw.trim().replace(/^#/, "")
@ -154,6 +382,87 @@ export default function Home() {
const removeLabel = (existing: string[], labelToRemove: string) =>
existing.filter((label) => label.toLowerCase() !== labelToRemove.toLowerCase())
const getColumnKeyForStatus = (status: TaskStatus) =>
sprintColumns.find((column) => column.statuses.includes(status))?.key
const kanbanSensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
)
const handleKanbanDragStart = (event: DragStartEvent) => {
setActiveKanbanTaskId(String(event.active.id))
}
const resolveKanbanColumnKey = (overId: string): string | null => {
if (overId.startsWith("kanban-col-")) {
return overId.replace("kanban-col-", "")
}
if (overId.startsWith("kanban-status-")) {
const status = overId.replace("kanban-status-", "") as TaskStatus
return getColumnKeyForStatus(status) || null
}
const overTask = sprintTasks.find((task) => task.id === overId)
if (!overTask) return null
return getColumnKeyForStatus(overTask.status) || null
}
const handleKanbanDragOver = (event: DragOverEvent) => {
if (!event.over) {
setDragOverKanbanColumnKey(null)
return
}
setDragOverKanbanColumnKey(resolveKanbanColumnKey(String(event.over.id)))
}
const handleKanbanDragEnd = (event: DragEndEvent) => {
const { active, over } = event
setActiveKanbanTaskId(null)
setDragOverKanbanColumnKey(null)
if (!over) return
const taskId = String(active.id)
const draggedTask = sprintTasks.find((task) => task.id === taskId)
if (!draggedTask) return
const destination = String(over.id)
const overTask = sprintTasks.find((task) => task.id === destination)
if (overTask) {
if (overTask.status !== draggedTask.status) {
updateTask(taskId, { status: overTask.status })
}
return
}
if (destination.startsWith("kanban-status-")) {
const exactStatus = destination.replace("kanban-status-", "") as TaskStatus
if (exactStatus !== draggedTask.status) {
updateTask(taskId, { status: exactStatus })
}
return
}
if (!destination.startsWith("kanban-col-")) return
const destinationColumnKey = destination.replace("kanban-col-", "")
const sourceColumnKey = getColumnKeyForStatus(draggedTask.status)
if (sourceColumnKey === destinationColumnKey) return
const newStatus = sprintColumnDropStatus[destinationColumnKey]
if (!newStatus) return
updateTask(taskId, { status: newStatus })
}
const handleKanbanDragCancel = () => {
setActiveKanbanTaskId(null)
setDragOverKanbanColumnKey(null)
}
const handleAddTask = () => {
if (newTask.title?.trim()) {
// If a specific sprint is selected, use that sprint's project
@ -165,14 +474,14 @@ export default function Home() {
description: newTask.description?.trim() || undefined,
type: (newTask.type || "task") as TaskType,
priority: (newTask.priority || "medium") as Priority,
status: (newTask.status || "backlog") as TaskStatus,
status: (newTask.status || "open") as TaskStatus,
tags: newTask.tags || [],
projectId: targetProjectId,
sprintId: newTask.sprintId || currentSprint?.id,
}
addTask(taskToCreate)
setNewTask({ title: "", description: "", type: "task", priority: "medium", status: "backlog", tags: [], sprintId: undefined })
setNewTask({ title: "", description: "", type: "task", priority: "medium", status: "open", tags: [], sprintId: undefined })
setNewTaskLabelInput("")
setNewTaskOpen(false)
}
@ -282,112 +591,58 @@ export default function Home() {
</div>
</div>
{/* Kanban Columns */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{sprintColumns.map((column) => {
// Filter tasks by column statuses
const columnTasks = sprintTasks.filter((t) =>
column.statuses.includes(t.status)
)
return (
<div key={column.key} className="flex flex-col">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-slate-400">
{column.label}
</h3>
<Badge variant="secondary" className="bg-slate-800 text-slate-400">
{columnTasks.length}
</Badge>
</div>
<div className="space-y-3">
{columnTasks.map((task) => {
const taskTags = getTags(task)
return (
<Card
key={task.id}
className="bg-slate-900 border-slate-800 hover:border-slate-700 cursor-pointer transition-colors group"
onClick={() => selectTask(task.id)}
>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={`text-xs ${typeColors[task.type]} text-white border-0`}
>
{typeLabels[task.type]}
</Badge>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<button
onClick={(e) => {
e.stopPropagation()
selectTask(task.id)
}}
className="p-1 hover:bg-slate-800 rounded"
>
<Edit2 className="w-3 h-3 text-slate-400" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
deleteTask(task.id)
}}
className="p-1 hover:bg-slate-800 rounded"
>
<Trash2 className="w-3 h-3 text-slate-400" />
</button>
</div>
</div>
<h4 className="font-medium text-white mb-1">{task.title}</h4>
{task.description && (
<p className="text-sm text-slate-400 line-clamp-2 mb-3">
{task.description}
</p>
)}
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className={priorityColors[task.priority]}>
{task.priority}
</span>
{task.comments && task.comments.length > 0 && (
<span className="flex items-center gap-1 text-slate-500">
<MessageSquare className="w-3 h-3" />
{task.comments.length}
</span>
)}
</div>
{task.dueDate && (
<span className="text-slate-500 flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(task.dueDate).toLocaleDateString()}
</span>
)}
</div>
{taskTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{taskTags.map((tag) => (
<span
key={tag}
className="text-xs px-2 py-0.5 bg-slate-800 text-slate-400 rounded"
>
{tag}
</span>
))}
</div>
)}
</CardContent>
</Card>
)
})}
</div>
</div>
)
})}
</div>
<DndContext
sensors={kanbanSensors}
collisionDetection={closestCorners}
onDragStart={handleKanbanDragStart}
onDragOver={handleKanbanDragOver}
onDragEnd={handleKanbanDragEnd}
onDragCancel={handleKanbanDragCancel}
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{sprintColumns.map((column) => {
const columnTasks = sprintTasks.filter((t) =>
column.statuses.includes(t.status)
)
return (
<KanbanDropColumn
key={column.key}
id={`kanban-col-${column.key}`}
label={column.label}
count={columnTasks.length}
statusTargets={column.statuses.map((status) => ({
status,
count: sprintTasks.filter((task) => task.status === status).length,
}))}
isDragging={!!activeKanbanTaskId}
isActiveDropColumn={dragOverKanbanColumnKey === column.key}
>
{columnTasks.map((task) => (
<KanbanTaskCard
key={task.id}
task={task}
taskTags={getTags(task)}
onOpen={() => selectTask(task.id)}
onDelete={() => deleteTask(task.id)}
/>
))}
</KanbanDropColumn>
)
})}
</div>
<DragOverlay>
{activeKanbanTask ? (
<Card className="bg-slate-900 border-slate-700 shadow-2xl rotate-1">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<GripVertical className="w-3.5 h-3.5 text-slate-400" />
<span className="text-sm font-medium text-white">{activeKanbanTask.title}</span>
</div>
</CardContent>
</Card>
) : null}
</DragOverlay>
</DndContext>
</>
)}
</main>

View File

@ -270,12 +270,12 @@ export function BacklogView() {
// If dropped over a section header, move task to that section's sprint
if (destinationId === "backlog") {
updateTask(taskId, { sprintId: undefined })
updateTask(taskId, { sprintId: undefined, status: "open" })
} else if (destinationId === "current" && currentSprint) {
updateTask(taskId, { sprintId: currentSprint.id })
updateTask(taskId, { sprintId: currentSprint.id, status: "open" })
} else if (destinationId.startsWith("sprint-")) {
const sprintId = destinationId.replace("sprint-", "")
updateTask(taskId, { sprintId })
updateTask(taskId, { sprintId, status: "open" })
}
}

View File

@ -2,7 +2,7 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type TaskType = 'idea' | 'task' | 'bug' | 'research' | 'plan'
export type TaskStatus = 'open' | 'backlog' | 'blocked' | 'in-progress' | 'review' | 'validate' | 'archived' | 'canceled' | 'done'
export type TaskStatus = 'open' | 'todo' | 'blocked' | 'in-progress' | 'review' | 'validate' | 'archived' | 'canceled' | 'done'
export type Priority = 'low' | 'medium' | 'high' | 'urgent'
export type SprintStatus = 'planning' | 'active' | 'completed'
@ -137,7 +137,7 @@ const defaultTasks: Task[] = [
title: 'MoodWeave App Idea',
description: 'Social mood tracking with woven visualizations',
type: 'idea',
status: 'backlog',
status: 'open',
priority: 'medium',
projectId: '1',
sprintId: 'sprint-1',
@ -359,7 +359,7 @@ const defaultTasks: Task[] = [
title: 'Research TTS options for Daily Digest podcast',
description: 'Research free text-to-speech (TTS) tools to convert the daily digest blog posts into an audio podcast format. Matt wants to listen to the digest during his morning dog walks with Tully and Remy. Look into: free TTS APIs (ElevenLabs free tier, Google TTS, AWS Polly), open-source solutions (Piper, Coqui TTS), browser-based options, RSS feed generation for podcast apps, and file hosting options. The solution should be cost-effective or free since budget is a concern.',
type: 'research',
status: 'backlog',
status: 'open',
priority: 'medium',
projectId: '2',
sprintId: 'sprint-1',