diff --git a/GUILDED_ROADMAP.md b/GUILDED_ROADMAP.md
index 755b504..dbef479 100644
--- a/GUILDED_ROADMAP.md
+++ b/GUILDED_ROADMAP.md
@@ -135,7 +135,7 @@
| # | Feature | Status |
|---|---------|--------|
| 5.1 | Calendar Channels | ✅ Events, RSVPs, month grid |
-| 5.2 | Docs Channels | Pending |
+| 5.2 | Docs Channels | ✅ CRUD, wiki sidebar, markdown editor |
| 5.3 | Lists Channels | Pending |
| 5.4 | Scheduling / Availability | Pending |
diff --git a/internal/channel/handlers.go b/internal/channel/handlers.go
index 1a15d94..6b1f7aa 100644
--- a/internal/channel/handlers.go
+++ b/internal/channel/handlers.go
@@ -33,6 +33,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
h.registerForumRoutes(r)
h.registerCalendarRoutes(r)
h.registerDocRoutes(r)
+ h.registerListRoutes(r)
h.registerOverrideRoutes(r)
}
diff --git a/internal/channel/lists.go b/internal/channel/lists.go
new file mode 100644
index 0000000..8bc469e
--- /dev/null
+++ b/internal/channel/lists.go
@@ -0,0 +1,155 @@
+package channel
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
+ "github.com/go-chi/chi/v5"
+)
+
+type listItem struct {
+ ID string `json:"id"`
+ ChannelID string `json:"channel_id"`
+ CreatorID string `json:"creator_id"`
+ Title string `json:"title"`
+ Status string `json:"status"`
+ Position int `json:"position"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+// ponytail: no description, assignee, due_date, parent_id, completed_at, bulk update, or filter.
+// Add when someone uses lists for more than a checklist.
+
+func (h *Handler) registerListRoutes(r chi.Router) {
+ r.Get("/{channelID}/items", h.ListItems)
+ r.Post("/{channelID}/items", h.CreateItem)
+ r.Patch("/items/{itemID}", h.UpdateItem)
+ r.Delete("/items/{itemID}", h.DeleteItem)
+}
+
+func (h *Handler) ListItems(w http.ResponseWriter, r *http.Request) {
+ channelID := chi.URLParam(r, "channelID")
+ userID, _ := middleware.UserIDFromContext(r.Context())
+ if !h.checkAccess(r.Context(), w, channelID, userID) {
+ return
+ }
+
+ rows, err := h.db.QueryContext(r.Context(), `
+ SELECT id, channel_id, creator_id, title, status, position, created_at, updated_at
+ FROM list_items WHERE channel_id = $1
+ ORDER BY position, created_at
+ `, channelID)
+ if err != nil {
+ http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
+ return
+ }
+ defer rows.Close()
+
+ items := make([]listItem, 0)
+ for rows.Next() {
+ var it listItem
+ if err := rows.Scan(&it.ID, &it.ChannelID, &it.CreatorID, &it.Title, &it.Status, &it.Position, &it.CreatedAt, &it.UpdatedAt); err != nil {
+ continue
+ }
+ items = append(items, it)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(items)
+}
+
+func (h *Handler) CreateItem(w http.ResponseWriter, r *http.Request) {
+ channelID := chi.URLParam(r, "channelID")
+ userID, _ := middleware.UserIDFromContext(r.Context())
+ if !h.checkAccess(r.Context(), w, channelID, userID) {
+ return
+ }
+
+ var req struct {
+ Title string `json:"title"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Title == "" {
+ http.Error(w, `{"error":"title required"}`, http.StatusBadRequest)
+ return
+ }
+
+ var it listItem
+ err := h.db.QueryRowContext(r.Context(), `
+ INSERT INTO list_items (channel_id, creator_id, title)
+ VALUES ($1, $2, $3)
+ RETURNING id, channel_id, creator_id, title, status, position, created_at, updated_at
+ `, channelID, userID, req.Title).Scan(
+ &it.ID, &it.ChannelID, &it.CreatorID, &it.Title, &it.Status, &it.Position, &it.CreatedAt, &it.UpdatedAt,
+ )
+ if err != nil {
+ http.Error(w, `{"error":"failed to create item"}`, http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(it)
+}
+
+func (h *Handler) UpdateItem(w http.ResponseWriter, r *http.Request) {
+ itemID := chi.URLParam(r, "itemID")
+ userID, _ := middleware.UserIDFromContext(r.Context())
+
+ var channelID string
+ err := h.db.QueryRowContext(r.Context(), `SELECT channel_id FROM list_items WHERE id = $1`, itemID).Scan(&channelID)
+ if err != nil {
+ http.Error(w, `{"error":"item not found"}`, http.StatusNotFound)
+ return
+ }
+ if !h.checkAccess(r.Context(), w, channelID, userID) {
+ return
+ }
+
+ var req struct {
+ Title string `json:"title"`
+ Status string `json:"status"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
+ return
+ }
+
+ var it listItem
+ err = h.db.QueryRowContext(r.Context(), `
+ UPDATE list_items
+ SET title = COALESCE(NULLIF($2, ''), title),
+ status = COALESCE(NULLIF($3, ''), status),
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING id, channel_id, creator_id, title, status, position, created_at, updated_at
+ `, itemID, req.Title, req.Status).Scan(
+ &it.ID, &it.ChannelID, &it.CreatorID, &it.Title, &it.Status, &it.Position, &it.CreatedAt, &it.UpdatedAt,
+ )
+ if err != nil {
+ http.Error(w, `{"error":"failed to update item"}`, http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(it)
+}
+
+func (h *Handler) DeleteItem(w http.ResponseWriter, r *http.Request) {
+ itemID := chi.URLParam(r, "itemID")
+ userID, _ := middleware.UserIDFromContext(r.Context())
+
+ var channelID string
+ err := h.db.QueryRowContext(r.Context(), `SELECT channel_id FROM list_items WHERE id = $1`, itemID).Scan(&channelID)
+ if err != nil {
+ http.Error(w, `{"error":"item not found"}`, http.StatusNotFound)
+ return
+ }
+ if !h.checkAccess(r.Context(), w, channelID, userID) {
+ return
+ }
+
+ _, err = h.db.ExecContext(r.Context(), `DELETE FROM list_items WHERE id = $1`, itemID)
+ if err != nil {
+ http.Error(w, `{"error":"failed to delete item"}`, http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
diff --git a/internal/db/db.go b/internal/db/db.go
index 9d88aa1..b2c6368 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -421,6 +421,20 @@ CREATE TABLE IF NOT EXISTS documents (
CREATE INDEX IF NOT EXISTS idx_documents_channel ON documents(channel_id, updated_at DESC);
+-- List items (task lists)
+CREATE TABLE IF NOT EXISTS list_items (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
+ creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ title VARCHAR(511) NOT NULL,
+ status VARCHAR(16) NOT NULL DEFAULT 'todo',
+ position INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_list_items_channel ON list_items(channel_id, position);
+
-- Message full-text search
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
diff --git a/web/src/components/ChannelList.tsx b/web/src/components/ChannelList.tsx
index 92ec5b7..ab87c6f 100644
--- a/web/src/components/ChannelList.tsx
+++ b/web/src/components/ChannelList.tsx
@@ -92,7 +92,7 @@ export function ChannelList() {
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
}`}
>
- {channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : '#'}
+ {channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
{channel.name}
{ e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx
index 1c9199a..50855ed 100644
--- a/web/src/components/ChatArea.tsx
+++ b/web/src/components/ChatArea.tsx
@@ -12,6 +12,7 @@ import { ThreadPanel } from "./ThreadPanel.tsx";
import { ForumView } from "./ForumView.tsx";
import { CalendarView } from "./CalendarView.tsx";
import { DocsView } from "./DocsView.tsx";
+import { ListView } from "./ListView.tsx";
import { UserProfileModal } from "./UserProfileModal.tsx";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -251,12 +252,14 @@ export function ChatArea() {
? `○ ${activeChannel.name}`
: activeChannel.type === 'docs'
? `☰ ${activeChannel.name}`
- : `# ${activeChannel.name}`
+ : activeChannel.type === 'list'
+ ? `☑ ${activeChannel.name}`
+ : `# ${activeChannel.name}`
: activeChannelId
? `#${activeChannelId}`
: "[NO CHANNEL SELECTED]"}
- {activeChannelId && activeChannel?.type !== 'forum' && (
+ {activeChannelId && activeChannel?.type === 'text' && (