Files
hyprcrd/public/js/modules/tasks.js
T
thandClaude Opus 4.6 d50f9fe789 relay UI overhaul, favicon, settings, admin last-seen, fixes
Relay:
  - Inbox/shared/sent split into own view-inbox (nav button + poll).
    Compose is now single-column, centered.
  - Header logo links to compose. Inbox badge stays fresh on all
    views via a quiet loadInbox on every view switch.
  - Mobile menu: full-height sheet below header, larger buttons
    (15px font, 14px padding), full width.
  - Login form wrapped in <form> with name attributes + for/id on
    labels so password managers can autofill.
  - Admin user list shows "last seen <relative time>" per user.
    last_seen_at column on tenants, updated on every authenticated
    request (session + client API).
  - Cache-busting on style.css + app.js via filemtime.
  - View persistence in localStorage (survives reload).
  - Shared-bucket cards show title only (preview line removed).

Favicon:
  - favicon.svg added to project root + copied to public/, relay/,
    website/. Linked via <link rel="icon" type="image/svg+xml"> in
    all three <head> sections.

Settings:
  - New toggle: "Show shopping list items in task collector"
    (default off). collectTasks skips #einkauf/#shopping cards when
    disabled.

Fixes:
  - card-spinner gets display:inline-block so it rotates outside
    flex containers (shopping save spinner).
  - Tim tab test opens the plugin before checking active state.
  - Compose view centered with margin:auto on desktop.

Bumps to 1.9.29.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 13:16:23 +02:00

120 lines
5.3 KiB
JavaScript

// ── Global tasks view ──
import { $, esc, api } from './util.js';
import { state } from './state.js';
const overlay = $('tasks');
const btn = $('tasks-btn');
const closeBtn = $('tasks-close');
const statusFilter = $('tasks-status-filter');
const bucketFilter = $('tasks-bucket-filter');
const list = $('tasks-list');
const summary = $('tasks-summary');
const countBadge = $('tasks-open-count');
function collectTasks() {
const showShopping = localStorage.getItem('hyprcrd-tasks-show-shopping') === '1';
const all = [];
for (const card of state.cards) {
if (!showShopping) {
const tags = (card.tags || '').split(',').map(s => s.trim());
if (tags.includes('einkauf') || tags.includes('shopping')) continue;
}
const re = /- \[([ xX])\][ \t]*([^\n\r]*)/g;
let m, idx = 0;
while ((m = re.exec(card.body || '')) !== null) {
all.push({ cardId: card.id, cardTitle: card.title || 'untitled', cardColor: card.color || '#94a3b8',
bucketId: card.bucket_id, bucketName: card.bucket_name || '', done: m[1].toLowerCase() === 'x',
text: (m[2] || '').trim() || '(empty task)', todoIndex: idx++ });
}
}
return all;
}
function filter(tasks) {
const s = statusFilter?.value || 'open', b = bucketFilter?.value || '';
return tasks.filter(t => {
if (s === 'open' && t.done) return false;
if (s === 'done' && !t.done) return false;
if (b === 'none') return !t.bucketId;
if (b && t.bucketId != b) return false;
return true;
});
}
export function refreshTasksView() {
if (!countBadge) return;
const tasks = collectTasks();
const open = tasks.filter(t => !t.done).length;
countBadge.textContent = String(open);
if (!overlay?.classList.contains('open') || !list) return;
const filtered = filter(tasks);
if (summary) summary.textContent = `open: ${open} | done: ${tasks.length - open} | showing: ${filtered.length}`;
if (!filtered.length) {
list.innerHTML = `<div class="px-4 py-10 text-center text-t-dim"><i class="ph ph-check-circle text-3xl mb-2 block"></i><p class="font-pixel text-[10px]">no tasks in this filter</p></div>`;
return;
}
list.innerHTML = filtered.map(t => `
<div class="px-4 py-3 border-b border-t-border flex items-start gap-3 hover:bg-t-raised/40">
<button data-task-toggle="${t.cardId}:${t.todoIndex}" class="group mt-0.5 shrink-0 border rounded-sm flex items-center justify-center transition-colors ${t.done ? 'border-t-accent bg-t-accent' : 'border-t-border bg-transparent hover:border-t-accent'}" style="width:30px;height:30px;">
<i class="ph ph-check text-[18px] transition-opacity ${t.done ? 'opacity-100 text-t-bg' : 'opacity-0 text-t-accent group-hover:opacity-75'}"></i>
</button>
<div class="min-w-0 flex-1">
<div class="text-xs leading-relaxed ${t.done ? 'text-t-dim line-through' : 'text-t-text'}">${esc(t.text)}</div>
<button data-task-open="${t.cardId}" class="mt-1 inline-flex items-center gap-1.5 text-[10px] font-pixel text-t-muted hover:text-t-accent2 transition-colors">
<span class="w-1.5 h-1.5 rounded-full" style="background:${t.cardColor}"></span>
<span>${esc(t.cardTitle)}</span><span class="text-t-dim">/ ${t.bucketName ? esc(t.bucketName) : 'unsorted'}</span>
</button>
</div>
</div>`).join('');
list.querySelectorAll('[data-task-toggle]').forEach(b => b.addEventListener('click', async e => {
e.preventDefault(); e.stopPropagation();
const [cardId, idx] = b.dataset.taskToggle.split(':');
await toggleTask(cardId, Number(idx));
}));
list.querySelectorAll('[data-task-open]').forEach(b => b.addEventListener('click', e => {
e.preventDefault(); e.stopPropagation();
closeTasks();
window._openDetail(b.dataset.taskOpen);
}));
}
async function toggleTask(cardId, todoIdx) {
const card = state.cards.find(c => c.id == cardId);
if (!card) return;
let idx = 0, changed = false;
const next = (card.body || '').replace(/- \[([ xX])\]/g, (m, s) => {
if (idx++ !== todoIdx) return m;
changed = true;
return `- [${s.toLowerCase() === 'x' ? ' ' : 'x'}]`;
});
if (!changed) return;
const updated = await api(`api/cards/${cardId}`, { method: 'PATCH', body: { body: next } });
if (updated?.id) { const i = state.cards.findIndex(c => c.id == updated.id); if (i !== -1) state.cards[i] = updated; }
else card.body = next;
if (state.viewingCard?.id == cardId) {
const latest = state.cards.find(c => c.id == cardId);
if (latest) { state.viewingCard = latest; /* detail re-render handled by board */ }
}
window._renderBoard();
}
export function openTasks() {
if (!overlay) return;
window._closeSearch?.();
overlay.classList.add('open');
refreshTasksView();
}
export function closeTasks() { overlay?.classList.remove('open'); }
btn?.addEventListener('click', openTasks);
closeBtn?.addEventListener('click', closeTasks);
overlay?.addEventListener('click', e => { if (e.target === overlay) closeTasks(); });
statusFilter?.addEventListener('change', refreshTasksView);
bucketFilter?.addEventListener('change', refreshTasksView);