217 lines
7.6 KiB
TypeScript
217 lines
7.6 KiB
TypeScript
/**
|
|
* <folk-tasks-backlog> — Flat backlog list widget for rTasks canvas.
|
|
*
|
|
* Shows all tasks as a unified, filterable list (cross-status), sorted by
|
|
* priority then due date. Complements <folk-tasks-board> (column view) by
|
|
* offering a single sorted stream.
|
|
*
|
|
* Attribute: space — space slug
|
|
*/
|
|
|
|
interface TaskRow {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
status: string;
|
|
priority: string | null;
|
|
labels: string[];
|
|
due_date: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
class FolkTasksBacklog extends HTMLElement {
|
|
private shadow: ShadowRoot;
|
|
private space = '';
|
|
private tasks: TaskRow[] = [];
|
|
private loading = true;
|
|
private error = '';
|
|
private query = '';
|
|
private priorityFilter: '' | 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT' = '';
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: 'open' });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.space = this.getAttribute('space') || 'demo';
|
|
this.render();
|
|
this.load();
|
|
}
|
|
|
|
static get observedAttributes() { return ['space']; }
|
|
attributeChangedCallback(name: string, _old: string, val: string) {
|
|
if (name === 'space' && val !== this.space) {
|
|
this.space = val;
|
|
this.load();
|
|
}
|
|
}
|
|
|
|
private getApiBase(): string {
|
|
const path = window.location.pathname;
|
|
const match = path.match(/^(\/[^/]+)?\/rtasks/);
|
|
return match ? match[0] : '/rtasks';
|
|
}
|
|
|
|
private async load() {
|
|
this.loading = true;
|
|
this.error = '';
|
|
this.render();
|
|
try {
|
|
const res = await fetch(`${this.getApiBase()}/api/spaces/${encodeURIComponent(this.space)}/tasks`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
this.tasks = await res.json();
|
|
} catch (e) {
|
|
this.error = e instanceof Error ? e.message : String(e);
|
|
}
|
|
this.loading = false;
|
|
this.render();
|
|
}
|
|
|
|
private filtered(): TaskRow[] {
|
|
const q = this.query.trim().toLowerCase();
|
|
const prio = this.priorityFilter;
|
|
const priOrder: Record<string, number> = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
|
return this.tasks
|
|
.filter(t => !q || t.title.toLowerCase().includes(q) || (t.description || '').toLowerCase().includes(q))
|
|
.filter(t => !prio || t.priority === prio)
|
|
.sort((a, b) => {
|
|
const pa = priOrder[a.priority || 'LOW'] ?? 4;
|
|
const pb = priOrder[b.priority || 'LOW'] ?? 4;
|
|
if (pa !== pb) return pa - pb;
|
|
const da = a.due_date ? Date.parse(a.due_date) : Number.MAX_SAFE_INTEGER;
|
|
const db = b.due_date ? Date.parse(b.due_date) : Number.MAX_SAFE_INTEGER;
|
|
return da - db;
|
|
});
|
|
}
|
|
|
|
private render() {
|
|
const items = this.filtered();
|
|
this.shadow.innerHTML = `
|
|
<style>${this.getStyles()}</style>
|
|
<div class="backlog">
|
|
<div class="controls">
|
|
<input class="search" type="search" placeholder="Filter tasks…" value="${this.esc(this.query)}">
|
|
<select class="prio">
|
|
<option value="">All priorities</option>
|
|
<option value="URGENT" ${this.priorityFilter === 'URGENT' ? 'selected' : ''}>Urgent</option>
|
|
<option value="HIGH" ${this.priorityFilter === 'HIGH' ? 'selected' : ''}>High</option>
|
|
<option value="MEDIUM" ${this.priorityFilter === 'MEDIUM' ? 'selected' : ''}>Medium</option>
|
|
<option value="LOW" ${this.priorityFilter === 'LOW' ? 'selected' : ''}>Low</option>
|
|
</select>
|
|
<span class="count">${items.length} / ${this.tasks.length}</span>
|
|
</div>
|
|
<div class="list">
|
|
${this.loading ? '<div class="state">Loading…</div>' :
|
|
this.error ? `<div class="state err">${this.esc(this.error)}</div>` :
|
|
items.length === 0 ? '<div class="state">No matching tasks.</div>' :
|
|
items.map(t => this.renderRow(t)).join('')}
|
|
</div>
|
|
</div>`;
|
|
this.bindEvents();
|
|
}
|
|
|
|
private renderRow(t: TaskRow): string {
|
|
const due = t.due_date ? new Date(t.due_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
|
|
const prioClass = `prio-${(t.priority || 'LOW').toLowerCase()}`;
|
|
const statusCls = `status-${t.status.toLowerCase().replace(/_/g, '-')}`;
|
|
return `<div class="row" data-task-id="${this.esc(t.id)}">
|
|
<span class="prio-dot ${prioClass}" title="${this.esc(t.priority || 'LOW')}"></span>
|
|
<div class="row-main">
|
|
<div class="row-title">${this.esc(t.title)}</div>
|
|
<div class="row-meta">
|
|
<span class="status ${statusCls}">${this.esc(t.status)}</span>
|
|
${due ? `<span class="due">${this.esc(due)}</span>` : ''}
|
|
${t.labels.slice(0, 3).map(l => `<span class="label">${this.esc(l)}</span>`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
private bindEvents() {
|
|
this.shadow.querySelector('.search')?.addEventListener('input', (e) => {
|
|
this.query = (e.target as HTMLInputElement).value;
|
|
this.render();
|
|
});
|
|
this.shadow.querySelector('.prio')?.addEventListener('change', (e) => {
|
|
this.priorityFilter = (e.target as HTMLSelectElement).value as any;
|
|
this.render();
|
|
});
|
|
this.shadow.querySelectorAll('.row').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
const id = (el as HTMLElement).dataset.taskId;
|
|
if (!id) return;
|
|
this.dispatchEvent(new CustomEvent('task-selected', { bubbles: true, composed: true, detail: { taskId: id } }));
|
|
});
|
|
});
|
|
}
|
|
|
|
private getStyles(): string {
|
|
return `
|
|
:host { display: block; height: 100%; }
|
|
.backlog { display: flex; flex-direction: column; height: 100%; min-height: 0; }
|
|
.controls {
|
|
display: flex; gap: 0.5rem; align-items: center;
|
|
padding: 0.5rem 0.75rem;
|
|
border-bottom: 1px solid var(--rs-border);
|
|
flex-shrink: 0;
|
|
}
|
|
.search, .prio {
|
|
padding: 0.375rem 0.5rem;
|
|
border: 1px solid var(--rs-border);
|
|
border-radius: 6px;
|
|
background: var(--rs-input-bg, var(--rs-bg-surface));
|
|
color: var(--rs-text-primary);
|
|
font-size: 0.8125rem;
|
|
}
|
|
.search { flex: 1; min-width: 0; }
|
|
.count { font-size: 0.75rem; color: var(--rs-text-secondary); flex-shrink: 0; }
|
|
.list { flex: 1; overflow-y: auto; padding: 0.5rem 0; min-height: 0; }
|
|
.state { text-align: center; padding: 1.5rem 1rem; color: var(--rs-text-secondary); font-size: 0.875rem; }
|
|
.state.err { color: #f87171; }
|
|
.row {
|
|
display: flex; align-items: flex-start; gap: 0.625rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border-bottom: 1px solid var(--rs-border);
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
}
|
|
.row:hover { background: var(--rs-bg-hover); }
|
|
.prio-dot {
|
|
width: 8px; height: 8px; border-radius: 50%;
|
|
margin-top: 0.375rem; flex-shrink: 0;
|
|
background: var(--rs-border);
|
|
}
|
|
.prio-urgent { background: #ef4444; }
|
|
.prio-high { background: #f97316; }
|
|
.prio-medium { background: #eab308; }
|
|
.prio-low { background: #60a5fa; }
|
|
.row-main { flex: 1; min-width: 0; }
|
|
.row-title { font-size: 0.875rem; color: var(--rs-text-primary); font-weight: 500; overflow: hidden; text-overflow: ellipsis; }
|
|
.row-meta { display: flex; flex-wrap: wrap; gap: 0.375rem; margin-top: 0.25rem; align-items: center; }
|
|
.status {
|
|
font-size: 0.6875rem; font-weight: 600;
|
|
padding: 0.0625rem 0.375rem; border-radius: 4px;
|
|
background: var(--rs-bg-hover); color: var(--rs-text-secondary);
|
|
text-transform: uppercase; letter-spacing: 0.03em;
|
|
}
|
|
.status-done { background: rgba(34,197,94,0.15); color: #16a34a; }
|
|
.status-in-progress { background: rgba(59,130,246,0.15); color: #2563eb; }
|
|
.status-review { background: rgba(168,85,247,0.15); color: #9333ea; }
|
|
.due { font-size: 0.75rem; color: var(--rs-text-muted); }
|
|
.label { font-size: 0.6875rem; color: var(--rs-text-secondary); background: var(--rs-bg-hover); padding: 0.0625rem 0.375rem; border-radius: 4px; }
|
|
`;
|
|
}
|
|
|
|
private esc(s: string): string {
|
|
const d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
}
|
|
|
|
if (!customElements.get('folk-tasks-backlog')) customElements.define('folk-tasks-backlog', FolkTasksBacklog);
|
|
|
|
export {};
|