rspace-online/modules/rtasks/components/folk-tasks-activity.ts

167 lines
5.2 KiB
TypeScript

/**
* <folk-tasks-activity> — Recent task activity widget for rTasks canvas.
*
* Derives a lightweight activity feed from task updated_at timestamps until
* the server activity endpoint is populated. Each entry shows what changed
* most recently (task title + relative time).
*
* Attribute: space — space slug
*/
interface TaskRow {
id: string;
title: string;
status: string;
priority: string | null;
updated_at: string;
created_at: string;
}
class FolkTasksActivity extends HTMLElement {
private shadow: ShadowRoot;
private space = '';
private tasks: TaskRow[] = [];
private loading = true;
private error = '';
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 recent(): TaskRow[] {
return [...this.tasks]
.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at))
.slice(0, 20);
}
private relTime(iso: string): string {
const delta = Date.now() - Date.parse(iso);
const m = Math.floor(delta / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
private render() {
const items = this.recent();
this.shadow.innerHTML = `
<style>${this.getStyles()}</style>
<div class="activity">
<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 activity yet.</div>' :
items.map(t => this.renderEntry(t)).join('')}
</div>
</div>`;
this.bindEvents();
}
private renderEntry(t: TaskRow): string {
const isNew = t.created_at === t.updated_at;
const verb = isNew ? 'created' : 'updated';
const rel = this.relTime(t.updated_at);
const statusCls = `status-${t.status.toLowerCase().replace(/_/g, '-')}`;
return `<div class="entry" data-task-id="${this.esc(t.id)}">
<div class="entry-main">
<span class="verb">${verb}</span>
<span class="title">${this.esc(t.title)}</span>
</div>
<div class="entry-meta">
<span class="status ${statusCls}">${this.esc(t.status)}</span>
<span class="rel">${this.esc(rel)}</span>
</div>
</div>`;
}
private bindEvents() {
this.shadow.querySelectorAll('.entry').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%; }
.activity { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.list { flex: 1; overflow-y: auto; min-height: 0; }
.state { text-align: center; padding: 1.5rem 1rem; color: var(--rs-text-secondary); font-size: 0.875rem; }
.state.err { color: #f87171; }
.entry {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--rs-border);
cursor: pointer;
}
.entry:hover { background: var(--rs-bg-hover); }
.entry-main { display: flex; gap: 0.375rem; align-items: baseline; }
.verb { font-size: 0.75rem; color: var(--rs-text-secondary); font-weight: 500; }
.title { font-size: 0.8125rem; color: var(--rs-text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.entry-meta { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.25rem; }
.status {
font-size: 0.65rem; 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; }
.rel { font-size: 0.7rem; color: var(--rs-text-muted); }
`;
}
private esc(s: string): string {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
}
if (!customElements.get('folk-tasks-activity')) customElements.define('folk-tasks-activity', FolkTasksActivity);
export {};