feat(rtasks): add list view with checkmarks, drag-drop reordering with drop indicators

Board/List view toggle in nav bar. List view shows tasks grouped by status
with checkboxes (check → DONE, uncheck → TODO), priority left-border accent,
and strikethrough for completed items. Board view now shows a blue pulsing
drop indicator line during drag, supports in-column reordering via sort_order,
and cross-column drops land at the cursor position. Cache version bumped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 13:48:28 -07:00
parent 0afde7547c
commit d62a5e9b15
2 changed files with 178 additions and 33 deletions

View File

@ -24,6 +24,9 @@ class FolkTasksBoard extends HTMLElement {
private dragTaskId: string | null = null; private dragTaskId: string | null = null;
private editingTaskId: string | null = null; private editingTaskId: string | null = null;
private showCreateForm = false; private showCreateForm = false;
private boardView: "board" | "checklist" = "board";
private dragOverStatus: string | null = null;
private dragOverIndex = -1;
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"]; private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
private _offlineUnsubs: (() => void)[] = []; private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"list" | "board">("list"); private _history = new ViewHistory<"list" | "board">("list");
@ -101,7 +104,7 @@ class FolkTasksBoard extends HTMLElement {
this.tasks = Object.values(doc.tasks).map(t => ({ this.tasks = Object.values(doc.tasks).map(t => ({
id: t.id, title: t.title, description: t.description, id: t.id, title: t.title, description: t.description,
status: t.status, priority: t.priority, labels: t.labels, status: t.status, priority: t.priority, labels: t.labels,
assignee: t.assigneeId, assignee: t.assigneeId, sort_order: t.sortOrder ?? 0,
})); }));
this.render(); this.render();
} }
@ -113,17 +116,17 @@ class FolkTasksBoard extends HTMLElement {
this.view = "board"; this.view = "board";
this.workspaceSlug = "rspace-dev"; this.workspaceSlug = "rspace-dev";
this.tasks = [ this.tasks = [
{ id: "d1", title: "Add dark mode toggle to settings page", status: "TODO", priority: "MEDIUM", labels: ["feature"], assignee: "Alice" }, { id: "d1", title: "Add dark mode toggle to settings page", status: "TODO", priority: "MEDIUM", labels: ["feature"], assignee: "Alice", sort_order: 0 },
{ id: "d2", title: "Write API documentation for rPubs endpoints", status: "TODO", priority: "LOW", labels: ["docs"], assignee: "Bob" }, { id: "d2", title: "Write API documentation for rPubs endpoints", status: "TODO", priority: "LOW", labels: ["docs"], assignee: "Bob", sort_order: 1000 },
{ id: "d3", title: "Investigate slow PDF generation on large documents", status: "TODO", priority: "HIGH", labels: ["bug"], assignee: "Alice" }, { id: "d3", title: "Investigate slow PDF generation on large documents", status: "TODO", priority: "HIGH", labels: ["bug"], assignee: "Alice", sort_order: 2000 },
{ id: "d4", title: "Implement file search and filtering in rFiles", status: "IN_PROGRESS", priority: "HIGH", labels: ["feature"], assignee: "Alice" }, { id: "d4", title: "Implement file search and filtering in rFiles", status: "IN_PROGRESS", priority: "HIGH", labels: ["feature"], assignee: "Alice", sort_order: 0 },
{ id: "d5", title: "Set up SMTP relay for transactional notifications", status: "IN_PROGRESS", priority: "MEDIUM", labels: ["chore"], assignee: "Bob" }, { id: "d5", title: "Set up SMTP relay for transactional notifications", status: "IN_PROGRESS", priority: "MEDIUM", labels: ["chore"], assignee: "Bob", sort_order: 1000 },
{ id: "d6", title: "Add PDF export to rNotes notebooks", status: "REVIEW", priority: "MEDIUM", labels: ["feature"], assignee: "Bob" }, { id: "d6", title: "Add PDF export to rNotes notebooks", status: "REVIEW", priority: "MEDIUM", labels: ["feature"], assignee: "Bob", sort_order: 0 },
{ id: "d7", title: "Fix conviction score decay calculation in rVote", status: "REVIEW", priority: "HIGH", labels: ["bug"], assignee: "Alice" }, { id: "d7", title: "Fix conviction score decay calculation in rVote", status: "REVIEW", priority: "HIGH", labels: ["bug"], assignee: "Alice", sort_order: 1000 },
{ id: "d8", title: "Deploy EncryptID passkey authentication", status: "DONE", priority: "URGENT", labels: ["feature"], assignee: "Alice" }, { id: "d8", title: "Deploy EncryptID passkey authentication", status: "DONE", priority: "URGENT", labels: ["feature"], assignee: "Alice", sort_order: 0 },
{ id: "d9", title: "Set up Cloudflare tunnel for all r* domains", status: "DONE", priority: "HIGH", labels: ["chore"], assignee: "Bob" }, { id: "d9", title: "Set up Cloudflare tunnel for all r* domains", status: "DONE", priority: "HIGH", labels: ["chore"], assignee: "Bob", sort_order: 1000 },
{ id: "d10", title: "Create cosmolocal provider directory with 6 printers", status: "DONE", priority: "MEDIUM", labels: ["feature"], assignee: "Alice" }, { id: "d10", title: "Create cosmolocal provider directory with 6 printers", status: "DONE", priority: "MEDIUM", labels: ["feature"], assignee: "Alice", sort_order: 2000 },
{ id: "d11", title: "Migrate email from Resend to self-hosted Mailcow", status: "DONE", priority: "MEDIUM", labels: ["chore"], assignee: "Bob" }, { id: "d11", title: "Migrate email from Resend to self-hosted Mailcow", status: "DONE", priority: "MEDIUM", labels: ["chore"], assignee: "Bob", sort_order: 3000 },
]; ];
this.render(); this.render();
} }
@ -199,7 +202,7 @@ class FolkTasksBoard extends HTMLElement {
} catch { this.error = "Failed to create task"; this.render(); } } catch { this.error = "Failed to create task"; this.render(); }
} }
private async updateTask(taskId: string, fields: Record<string, string>) { private async updateTask(taskId: string, fields: Record<string, any>) {
if (this.isDemo) { if (this.isDemo) {
const task = this.tasks.find(t => t.id === taskId); const task = this.tasks.find(t => t.id === taskId);
if (task) Object.assign(task, fields); if (task) Object.assign(task, fields);
@ -227,23 +230,41 @@ class FolkTasksBoard extends HTMLElement {
this.updateTask(taskId, { priority: next }); this.updateTask(taskId, { priority: next });
} }
private async moveTask(taskId: string, newStatus: string) { private async moveTask(taskId: string, newStatus: string, sortOrder?: number) {
if (this.isDemo) { if (this.isDemo) {
const task = this.tasks.find(t => t.id === taskId); const task = this.tasks.find(t => t.id === taskId);
if (task) { task.status = newStatus; this.render(); } if (task) {
task.status = newStatus;
if (sortOrder !== undefined) task.sort_order = sortOrder;
this.render();
}
return; return;
} }
try { try {
const base = this.getApiBase(); const base = this.getApiBase();
const body: Record<string, any> = { status: newStatus };
if (sortOrder !== undefined) body.sort_order = sortOrder;
await fetch(`${base}/api/tasks/${taskId}`, { await fetch(`${base}/api/tasks/${taskId}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }), body: JSON.stringify(body),
}); });
this.loadTasks(); this.loadTasks();
} catch { this.error = "Failed to move task"; this.render(); } } catch { this.error = "Failed to move task"; this.render(); }
} }
private computeSortOrder(targetStatus: string, insertIndex: number): number {
const columnTasks = this.tasks
.filter(t => t.status === targetStatus && t.id !== this.dragTaskId)
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
if (columnTasks.length === 0) return 0;
if (insertIndex <= 0) return (columnTasks[0].sort_order ?? 0) - 1000;
if (insertIndex >= columnTasks.length) return (columnTasks[columnTasks.length - 1].sort_order ?? 0) + 1000;
const before = columnTasks[insertIndex - 1].sort_order ?? 0;
const after = columnTasks[insertIndex].sort_order ?? 0;
return (before + after) / 2;
}
private openBoard(slug: string) { private openBoard(slug: string) {
this.workspaceSlug = slug; this.workspaceSlug = slug;
this.view = "board"; this.view = "board";
@ -334,6 +355,36 @@ class FolkTasksBoard extends HTMLElement {
.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; } .empty { text-align: center; color: var(--rs-text-muted); padding: 40px; }
/* View toggle */
.view-toggle { display: flex; gap: 2px; background: var(--rs-bg-surface-sunken); border-radius: 6px; padding: 2px; }
.view-toggle__btn { padding: 4px 12px; border-radius: 5px; border: none; background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 12px; font-weight: 600; transition: all 0.15s; }
.view-toggle__btn.active { background: var(--rs-bg-surface); color: var(--rs-text-primary); box-shadow: 0 1px 2px rgba(0,0,0,0.2); }
.view-toggle__btn:hover:not(.active) { color: var(--rs-text-secondary); }
/* Drop indicator */
.drop-indicator { height: 2px; background: var(--rs-primary); border-radius: 1px; margin: 2px 0; animation: pulse-indicator 1.5s ease-in-out infinite; }
@keyframes pulse-indicator { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; box-shadow: 0 0 8px var(--rs-primary); } }
/* Checklist view */
.checklist { max-width: 720px; }
.checklist-group { margin-bottom: 16px; }
.checklist-header { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--rs-text-muted); margin-bottom: 6px; display: flex; align-items: center; gap: 8px; }
.checklist-row {
display: flex; align-items: center; gap: 10px; padding: 8px 12px;
border-left: 3px solid var(--rs-border); border-radius: 4px;
background: var(--rs-bg-surface); margin-bottom: 2px; min-height: 38px;
transition: background 0.1s;
}
.checklist-row:hover { background: var(--rs-bg-surface-sunken); }
.checklist-row.done .checklist-title { text-decoration: line-through; opacity: 0.5; }
.checklist-row.border-urgent { border-left-color: #f87171; }
.checklist-row.border-high { border-left-color: #fb923c; }
.checklist-row.border-medium { border-left-color: #facc15; }
.checklist-row.border-low { border-left-color: #60a5fa; }
.checklist-check { width: 16px; height: 16px; cursor: pointer; accent-color: var(--rs-primary); flex-shrink: 0; }
.checklist-title { font-size: 13px; font-weight: 500; flex: 1; cursor: text; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.checklist-badges { display: flex; gap: 4px; flex-shrink: 0; }
@media (max-width: 768px) { @media (max-width: 768px) {
.board { flex-direction: column; overflow-x: visible; } .board { flex-direction: column; overflow-x: visible; }
.column { min-width: 100%; max-width: 100%; } .column { min-width: 100%; max-width: 100%; }
@ -410,11 +461,23 @@ class FolkTasksBoard extends HTMLElement {
<div class="rapp-nav"> <div class="rapp-nav">
${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="list">\u2190 Workspaces</button>' : ''} ${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="list">\u2190 Workspaces</button>' : ''}
<span class="rapp-nav__title">${this.esc(this.workspaceSlug)}</span> <span class="rapp-nav__title">${this.esc(this.workspaceSlug)}</span>
<div class="view-toggle">
<button class="view-toggle__btn ${this.boardView === 'board' ? 'active' : ''}" data-set-view="board">Board</button>
<button class="view-toggle__btn ${this.boardView === 'checklist' ? 'active' : ''}" data-set-view="checklist">List</button>
</div>
<button class="rapp-nav__btn" id="create-task">+ New Task</button> <button class="rapp-nav__btn" id="create-task">+ New Task</button>
</div> </div>
${this.boardView === "checklist" ? this.renderChecklist() : this.renderKanban()}
`;
}
private renderKanban(): string {
return `
<div class="board"> <div class="board">
${this.statuses.map(status => { ${this.statuses.map(status => {
const columnTasks = this.tasks.filter(t => t.status === status); const columnTasks = this.tasks
.filter(t => t.status === status)
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
return ` return `
<div class="column" data-status="${status}"> <div class="column" data-status="${status}">
<div class="col-header"> <div class="col-header">
@ -430,6 +493,38 @@ class FolkTasksBoard extends HTMLElement {
`; `;
} }
private renderChecklist(): string {
return `
<div class="checklist">
${this.statuses.map(status => {
const columnTasks = this.tasks
.filter(t => t.status === status)
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
if (columnTasks.length === 0) return '';
const isDone = status === "DONE";
return `
<div class="checklist-group">
<div class="checklist-header">${this.esc(status.replace(/_/g, " "))} <span class="col-count">${columnTasks.length}</span></div>
${columnTasks.map(t => {
const pClass = `border-${(t.priority || 'medium').toLowerCase()}`;
return `
<div class="checklist-row ${pClass} ${isDone ? 'done' : ''}">
<input type="checkbox" class="checklist-check" data-check-task="${t.id}" ${isDone ? 'checked' : ''}>
<span class="checklist-title" data-start-edit="${t.id}">${this.esc(t.title)}</span>
<div class="checklist-badges">
<span class="badge clickable badge-${(t.priority || 'medium').toLowerCase()}" data-cycle-priority="${t.id}">${this.esc((t.priority || '').toLowerCase())}</span>
${(t.labels || []).map((l: string) => `<span class="badge">${this.esc(l)}</span>`).join("")}
</div>
</div>
`;
}).join("")}
</div>
`;
}).join("")}
</div>
`;
}
private renderTaskCard(task: any, currentStatus: string): string { private renderTaskCard(task: any, currentStatus: string): string {
const otherStatuses = this.statuses.filter(s => s !== currentStatus); const otherStatuses = this.statuses.filter(s => s !== currentStatus);
const isEditing = this.editingTaskId === task.id; const isEditing = this.editingTaskId === task.id;
@ -543,13 +638,29 @@ class FolkTasksBoard extends HTMLElement {
}); });
}); });
// View toggle
this.shadow.querySelectorAll("[data-set-view]").forEach(el => {
el.addEventListener("click", () => {
this.boardView = (el as HTMLElement).dataset.setView as "board" | "checklist";
this.render();
});
});
// Checklist checkboxes
this.shadow.querySelectorAll(".checklist-check").forEach(el => {
el.addEventListener("change", () => {
const taskId = (el as HTMLElement).dataset.checkTask!;
const checked = (el as HTMLInputElement).checked;
this.moveTask(taskId, checked ? "DONE" : "TODO");
});
});
// Pointer events drag-and-drop on task cards (works with touch, pen, mouse) // Pointer events drag-and-drop on task cards (works with touch, pen, mouse)
this.shadow.querySelectorAll(".task-card[draggable]").forEach(card => { this.shadow.querySelectorAll(".task-card[draggable]").forEach(card => {
const el = card as HTMLElement; const el = card as HTMLElement;
el.addEventListener("pointerdown", (e) => { el.addEventListener("pointerdown", (e) => {
const pe = e as PointerEvent; const pe = e as PointerEvent;
if (pe.button !== 0) return; if (pe.button !== 0) return;
// Only start drag if not editing
if (el.getAttribute("draggable") === "false") return; if (el.getAttribute("draggable") === "false") return;
this.dragTaskId = el.dataset.taskId || null; this.dragTaskId = el.dataset.taskId || null;
el.classList.add("dragging"); el.classList.add("dragging");
@ -558,33 +669,67 @@ class FolkTasksBoard extends HTMLElement {
}); });
el.addEventListener("pointermove", (e) => { el.addEventListener("pointermove", (e) => {
if (!this.dragTaskId) return; if (!this.dragTaskId) return;
(e as PointerEvent).preventDefault();
// Highlight target column under pointer
const pe = e as PointerEvent; const pe = e as PointerEvent;
pe.preventDefault();
// Clear previous state
this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over")); this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over"));
const target = this.shadow.elementFromPoint(pe.clientX, pe.clientY) as HTMLElement | null; this.shadow.querySelector('.drop-indicator')?.remove();
const targetCol = target?.closest?.(".column[data-status]") as HTMLElement | null; // Find target column
if (targetCol) targetCol.classList.add("drag-over");
});
el.addEventListener("pointerup", (e) => {
if (!this.dragTaskId) return;
const pe = e as PointerEvent;
el.classList.remove("dragging");
el.style.touchAction = "";
this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over"));
const target = this.shadow.elementFromPoint(pe.clientX, pe.clientY) as HTMLElement | null; const target = this.shadow.elementFromPoint(pe.clientX, pe.clientY) as HTMLElement | null;
const targetCol = target?.closest?.(".column[data-status]") as HTMLElement | null; const targetCol = target?.closest?.(".column[data-status]") as HTMLElement | null;
if (targetCol) { if (targetCol) {
const status = targetCol.dataset.status!; targetCol.classList.add("drag-over");
this.moveTask(this.dragTaskId!, status); // Calculate insert position
const cards = Array.from(targetCol.querySelectorAll('.task-card:not(.dragging)'));
let insertIndex = cards.length;
for (let i = 0; i < cards.length; i++) {
const rect = cards[i].getBoundingClientRect();
if (pe.clientY < rect.top + rect.height / 2) { insertIndex = i; break; }
}
this.dragOverStatus = targetCol.dataset.status || null;
this.dragOverIndex = insertIndex;
// Show drop indicator line
const indicator = document.createElement('div');
indicator.className = 'drop-indicator';
if (insertIndex < cards.length) {
cards[insertIndex].before(indicator);
} else {
targetCol.appendChild(indicator);
}
} else {
this.dragOverStatus = null;
this.dragOverIndex = -1;
}
});
el.addEventListener("pointerup", (e) => {
if (!this.dragTaskId) return;
el.classList.remove("dragging");
el.style.touchAction = "";
this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over"));
this.shadow.querySelector('.drop-indicator')?.remove();
if (this.dragOverStatus && this.dragOverIndex >= 0) {
const task = this.tasks.find(t => t.id === this.dragTaskId);
const sortOrder = this.computeSortOrder(this.dragOverStatus, this.dragOverIndex);
if (task) {
if (task.status === this.dragOverStatus) {
this.updateTask(this.dragTaskId!, { sort_order: sortOrder });
} else {
this.moveTask(this.dragTaskId!, this.dragOverStatus, sortOrder);
}
}
} }
this.dragTaskId = null; this.dragTaskId = null;
this.dragOverStatus = null;
this.dragOverIndex = -1;
}); });
el.addEventListener("pointercancel", () => { el.addEventListener("pointercancel", () => {
el.classList.remove("dragging"); el.classList.remove("dragging");
el.style.touchAction = ""; el.style.touchAction = "";
this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over")); this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over"));
this.shadow.querySelector('.drop-indicator')?.remove();
this.dragTaskId = null; this.dragTaskId = null;
this.dragOverStatus = null;
this.dragOverIndex = -1;
}); });
}); });
} }

View File

@ -465,7 +465,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-tasks-board space="${space}"></folk-tasks-board>`, body: `<folk-tasks-board space="${space}"></folk-tasks-board>`,
scripts: `<script type="module" src="/modules/rtasks/folk-tasks-board.js"></script>`, scripts: `<script type="module" src="/modules/rtasks/folk-tasks-board.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtasks/tasks.css">`, styles: `<link rel="stylesheet" href="/modules/rtasks/tasks.css">`,
})); }));
}); });