Deploy Waters Boards to NAS / deploy (push) Canceled after 0s
Boards with groups, items, subitems and 18 column types; table, kanban, calendar, timeline, chart and files views; updates with @mentions, replies and attachments; inbox, My work, search, automations; Google sign-in restricted to company domains; live updates over SSE; a monday.com API importer; phone layout and installable PWA; Docker + Gitea Actions deploy to the NAS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
82 lines
5.9 KiB
JavaScript
82 lines
5.9 KiB
JavaScript
// Chart view (board dashboard) and Files gallery view.
|
|
import { html } from '../vendor/preact-htm.mjs';
|
|
import { api } from './api.js';
|
|
import { state, navigate, boardPath, mutateBoard } from './store.js';
|
|
import { Icon, fileUrl, isImage, fmtSize, timeAgo, Empty, todayStr, Avatar } from './ui.js';
|
|
import { labelOf, itemDone } from './cells.js';
|
|
|
|
export function ChartView({ board, view, items, subs }) {
|
|
const boardId = board.board.id;
|
|
const cols = ['status', 'people', 'dropdown'].flatMap((t) => board.columns.filter((c) => c.type === t));
|
|
const by = view.settings?.by || cols[0]?.id || '__group';
|
|
const kind = view.settings?.kind || 'bar';
|
|
const setting = (patch) => { mutateBoard(boardId, () => { view.settings = { ...view.settings, ...patch }; }); api.patch(`/api/views/${view.id}`, { settings: { ...view.settings, ...patch } }); };
|
|
|
|
const buckets = new Map();
|
|
const add = (key, label, color, n = 1) => { const b = buckets.get(key) || { label, color, n: 0 }; b.n += n; buckets.set(key, b); };
|
|
const col = board.columns.find((c) => c.id === by);
|
|
for (const it of items) {
|
|
if (by === '__group') { const g = board.groups.find((x) => x.id === it.group_id); add(g?.id, g?.title || '—', g?.color); continue; }
|
|
const v = it.vals[by];
|
|
if (col.type === 'status') { const l = labelOf(col, v); add(l?.id || '_', l?.name || 'Empty', l?.color || '#c4c4c4'); }
|
|
else if (col.type === 'dropdown') { if (!v?.length) add('_', 'Empty', '#c4c4c4'); for (const x of v || []) { const l = labelOf(col, x); if (l) add(l.id, l.name, l.color); } }
|
|
else if (col.type === 'people') { if (!v?.length) add('_', 'Unassigned', '#c4c4c4'); for (const u of v || []) add(u, state.usersById[u]?.name || '?', null); }
|
|
}
|
|
const data = [...buckets.values()].sort((a, b) => b.n - a.n);
|
|
const maxN = Math.max(1, ...data.map((d) => d.n));
|
|
const total = items.length;
|
|
const done = items.filter((i) => itemDone(board, i)).length;
|
|
const dateCol = board.columns.find((c) => c.type === 'date');
|
|
const overdue = dateCol ? items.filter((i) => i.vals[dateCol.id] && String(i.vals[dateCol.id]).slice(0, 10) < todayStr() && !itemDone(board, i)).length : 0;
|
|
const palette = ['#579bfc', '#a25ddc', '#00c875', '#fdab3d', '#e2445c', '#66ccff', '#ff642e', '#037f4c', '#ff158a', '#784bd1'];
|
|
|
|
let acc = 0;
|
|
const arcs = data.map((d, i) => {
|
|
const a0 = (acc / total) * Math.PI * 2; acc += d.n; const a1 = (acc / total) * Math.PI * 2;
|
|
const large = a1 - a0 > Math.PI ? 1 : 0;
|
|
const p = (a) => `${100 + 80 * Math.sin(a)} ${100 - 80 * Math.cos(a)}`;
|
|
const path = data.length === 1 ? 'M100 20 A80 80 0 1 1 99.99 20 Z' : `M100 100 L${p(a0)} A80 80 0 ${large} 1 ${p(a1)} Z`;
|
|
return html`<path key=${i} d=${path} fill=${d.color || palette[i % palette.length]}><title>${d.label}: ${d.n}</title></path>`;
|
|
});
|
|
|
|
return html`<div class="chart-view">
|
|
<div class="kpis">
|
|
<div class="kpi"><span class="kpi-n">${total}</span><span class="muted">${board.board.item_label}s</span></div>
|
|
<div class="kpi"><span class="kpi-n" style=${{ color: '#00c875' }}>${total ? Math.round((done / total) * 100) : 0}%</span><span class="muted">Done (${done})</span></div>
|
|
<div class="kpi"><span class="kpi-n" style=${{ color: overdue ? '#e2445c' : undefined }}>${overdue}</span><span class="muted">Overdue</span></div>
|
|
<div class="kpi"><span class="kpi-n">${Object.keys(subs).reduce((a, k) => a + subs[k].length, 0)}</span><span class="muted">Subitems</span></div>
|
|
</div>
|
|
<div class="chart-card">
|
|
<div class="view-options">
|
|
<span class="muted small">Group by</span>
|
|
<select class="input sm" value=${by} onChange=${(e) => setting({ by: e.target.value })}>
|
|
<option value="__group">Group</option>${cols.map((c) => html`<option value=${c.id}>${c.title}</option>`)}
|
|
</select>
|
|
<div class="seg"><button class=${kind === 'bar' ? 'on' : ''} onClick=${() => setting({ kind: 'bar' })}>Bar</button><button class=${kind === 'pie' ? 'on' : ''} onClick=${() => setting({ kind: 'pie' })}>Pie</button></div>
|
|
</div>
|
|
${!total ? html`<p class="muted pad">No items to chart.</p>` : kind === 'bar' ? html`<div class="bars">
|
|
${data.map((d, i) => html`<div key=${i} class="bar-row">
|
|
<span class="bar-label">${d.label}</span>
|
|
<span class="bar-track"><span class="bar" style=${{ width: `${(d.n / maxN) * 100}%`, background: d.color || palette[i % palette.length] }} /></span>
|
|
<span class="bar-n">${d.n} <span class="muted small">${Math.round((d.n / total) * 100)}%</span></span>
|
|
</div>`)}</div>`
|
|
: html`<div class="pie-wrap"><svg viewBox="0 0 200 200" width="240" height="240">${arcs}</svg>
|
|
<div class="legend">${data.map((d, i) => html`<div key=${i}><i style=${{ background: d.color || palette[i % palette.length] }} />${d.label}<span class="muted"> ${d.n}</span></div>`)}</div></div>`}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
export function FilesView({ board, view }) {
|
|
const files = board.files || [];
|
|
if (!files.length) return html`<${Empty} icon="paperclip" title="No files on this board yet"><p class="muted">Add a Files column or attach files to items.</p><//>`;
|
|
const itemName = Object.fromEntries(board.items.map((i) => [i.id, i.name]));
|
|
return html`<div class="file-gallery">
|
|
${[...files].sort((a, b) => b.created_at - a.created_at).map((f) => html`<div key=${f.id} class="file-card">
|
|
<a href=${fileUrl(f)} target="_blank" rel="noopener" class="file-thumb">${isImage(f) ? html`<img src=${fileUrl(f)} alt="" loading="lazy" />` : html`<span class="file-ext big">${(f.name.split('.').pop() || '').slice(0, 4).toUpperCase()}</span>`}</a>
|
|
<div class="file-info"><span class="file-name" title=${f.name}>${f.name}</span>
|
|
<button class="link small" onClick=${() => navigate(boardPath(board.board.id, { viewId: view.id, itemId: f.item_id }))}>${itemName[f.item_id] || 'Item'}</button>
|
|
<span class="muted small">${fmtSize(f.size)} · ${timeAgo(f.created_at)}</span></div>
|
|
</div>`)}
|
|
</div>`;
|
|
}
|