Waters Boards: self-hosted work management to replace monday.com
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>
This commit is contained in:
2026-10-13 15:34:47 -04:00
co-authored by Claude Opus 5.5
commit fcf3805626
44 changed files with 7443 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
// Automations: "When … then …" recipes for a board.
import { html, useState } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, useStore, loadBoard, toast } from './store.js';
import { Icon, ModalHeader, confirmDialog } from './ui.js';
const TRIGGERS = {
status_change: 'When a status changes',
column_change: 'When a column changes',
item_created: 'When an item is created',
date_arrives: 'When a date arrives',
};
const ACTIONS = {
notify: 'notify someone',
move_to_group: 'move the item to a group',
set_status: 'set a status',
assign: 'assign a person',
set_date_today: "set a date to today",
clear_column: 'clear a column',
archive: 'archive the item',
};
export function AutomationsModal({ boardId }) {
const s = useStore();
const b = s.boardData[boardId];
const [adding, setAdding] = useState(false);
if (!b) return null;
const toggle = async (a) => { await api.patch(`/api/automations/${a.id}`, { enabled: !a.enabled }); loadBoard(boardId); };
const remove = async (a) => { if (await confirmDialog({ title: 'Delete this automation?' })) { await api.del(`/api/automations/${a.id}`); loadBoard(boardId); } };
return html`<div class="dialog automations"><${ModalHeader} title="Automations" sub="Let the board do the repetitive work." />
${b.automations.map((a) => html`<div key=${a.id} class="auto-row ${a.enabled ? '' : 'off'}">
<${Icon} name="bolt" />
<span class="auto-text">${describe(b, a.trigger, a.action)}</span>
<label class="switch"><input type="checkbox" checked=${a.enabled} onChange=${() => toggle(a)} /><span /></label>
<button class="icon-btn" onClick=${() => remove(a)}><${Icon} name="trash" /></button>
</div>`)}
${!b.automations.length && !adding ? html`<p class="muted">No automations yet. Some ideas: notify the owner when an item is stuck, move items to "Done" when their status is Done, remind people on their due date.</p>` : null}
${adding ? html`<${Builder} board=${b} done=${() => setAdding(false)} />` : html`<button class="btn primary" onClick=${() => setAdding(true)}><${Icon} name="plus" /> Create automation</button>`}
</div>`;
}
function describe(b, t, a) {
const col = (id) => b.columns.find((c) => c.id === id)?.title || '?';
const label = (cid, lid) => b.columns.find((c) => c.id === cid)?.settings.labels?.find((l) => l.id === lid)?.name || 'anything';
const group = (id) => b.groups.find((g) => g.id === id)?.title || '?';
const user = (id) => state.usersById[id]?.name || '?';
let when = '';
if (t.type === 'status_change') when = html`When <b>${col(t.column_id)}</b> changes to <b>${t.to ? label(t.column_id, t.to) : 'anything'}</b>`;
else if (t.type === 'column_change') when = html`When <b>${col(t.column_id)}</b> changes`;
else if (t.type === 'item_created') when = html`When an item is created`;
else if (t.type === 'date_arrives') when = html`When <b>${col(t.column_id)}</b> arrives`;
let then = '';
if (a.type === 'notify') then = html`notify <b>${a.to === 'user' ? user(a.user_id) : `people in ${col(a.column_id)}`}</b>`;
else if (a.type === 'move_to_group') then = html`move item to <b>${group(a.group_id)}</b>`;
else if (a.type === 'set_status') then = html`set <b>${col(a.column_id)}</b> to <b>${label(a.column_id, a.label)}</b>`;
else if (a.type === 'assign') then = html`assign <b>${user(a.user_id)}</b> in <b>${col(a.column_id)}</b>`;
else if (a.type === 'set_date_today') then = html`set <b>${col(a.column_id)}</b> to today`;
else if (a.type === 'clear_column') then = html`clear <b>${col(a.column_id)}</b>`;
else if (a.type === 'archive') then = html`archive the item`;
return html`${when}, ${then}`;
}
function Builder({ board, done }) {
const cols = (types) => board.columns.filter((c) => types.includes(c.type));
const [t, setT] = useState({ type: 'status_change', column_id: cols(['status'])[0]?.id, to: '' });
const [a, setA] = useState({ type: 'notify', to: 'people_column', column_id: cols(['people'])[0]?.id });
const statusCol = board.columns.find((c) => c.id === t.column_id);
const save = async () => {
try {
await api.post(`/api/boards/${board.board.id}/automations`, { trigger: { ...t, to: t.to || null }, action: a });
loadBoard(board.board.id); done();
} catch (e) { toast(e.message, 'error'); }
};
const colSelect = (types, value, onChange) => html`<select class="input sm" value=${value || ''} onChange=${(e) => onChange(e.target.value)}>
<option value="" disabled>column…</option>${cols(types).map((c) => html`<option value=${c.id}>${c.title}</option>`)}</select>`;
const valid = (t.type === 'item_created' || t.column_id) && (
a.type === 'archive' || (a.type === 'notify' && (a.to === 'user' ? a.user_id : a.column_id)) || (a.type === 'move_to_group' && a.group_id) ||
(a.type === 'set_status' && a.column_id && a.label) || (a.type === 'assign' && a.column_id && a.user_id) || (['set_date_today', 'clear_column'].includes(a.type) && a.column_id));
return html`<div class="auto-builder">
<div class="auto-sentence">
<select class="input sm" value=${t.type} onChange=${(e) => {
const type = e.target.value;
const first = { status_change: cols(['status'])[0]?.id, date_arrives: cols(['date', 'timeline'])[0]?.id, column_change: board.columns[0]?.id }[type];
setT({ type, column_id: first, to: '' });
}}>${Object.entries(TRIGGERS).map(([k, l]) => html`<option value=${k}>${l}</option>`)}</select>
${t.type === 'status_change' ? html`${colSelect(['status'], t.column_id, (v) => setT({ ...t, column_id: v, to: '' }))} <span>changes to</span>
<select class="input sm" value=${t.to} onChange=${(e) => setT({ ...t, to: e.target.value })}><option value="">anything</option>
${(statusCol?.settings.labels || []).map((l) => html`<option value=${l.id}>${l.name}</option>`)}</select>` : null}
${t.type === 'column_change' ? colSelect(board.columns.map((c) => c.type), t.column_id, (v) => setT({ ...t, column_id: v })) : null}
${t.type === 'date_arrives' ? colSelect(['date', 'timeline'], t.column_id, (v) => setT({ ...t, column_id: v })) : null}
</div>
<div class="auto-sentence"><span>then</span>
<select class="input sm" value=${a.type} onChange=${(e) => setA({ type: e.target.value })}>${Object.entries(ACTIONS).map(([k, l]) => html`<option value=${k}>${l}</option>`)}</select>
${a.type === 'notify' ? html`
<select class="input sm" value=${a.to || 'people_column'} onChange=${(e) => setA({ ...a, to: e.target.value })}><option value="people_column">people in column</option><option value="user">a specific person</option></select>
${a.to === 'user' ? html`<select class="input sm" value=${a.user_id || ''} onChange=${(e) => setA({ ...a, user_id: e.target.value })}><option value="" disabled>person…</option>${state.users.filter((u) => u.active).map((u) => html`<option value=${u.id}>${u.name}</option>`)}</select>`
: colSelect(['people'], a.column_id, (v) => setA({ ...a, column_id: v }))}
<input class="input sm grow" placeholder="Message (optional)" value=${a.text || ''} onInput=${(e) => setA({ ...a, text: e.target.value })} />` : null}
${a.type === 'move_to_group' ? html`<select class="input sm" value=${a.group_id || ''} onChange=${(e) => setA({ ...a, group_id: e.target.value })}><option value="" disabled>group…</option>${board.groups.map((g) => html`<option value=${g.id}>${g.title}</option>`)}</select>` : null}
${a.type === 'set_status' ? html`${colSelect(['status'], a.column_id, (v) => setA({ ...a, column_id: v, label: '' }))}
<select class="input sm" value=${a.label || ''} onChange=${(e) => setA({ ...a, label: e.target.value })}><option value="" disabled>label…</option>
${(board.columns.find((c) => c.id === a.column_id)?.settings.labels || []).map((l) => html`<option value=${l.id}>${l.name}</option>`)}</select>` : null}
${a.type === 'assign' ? html`${colSelect(['people'], a.column_id, (v) => setA({ ...a, column_id: v }))}
<select class="input sm" value=${a.user_id || ''} onChange=${(e) => setA({ ...a, user_id: e.target.value })}><option value="" disabled>person…</option>${state.users.filter((u) => u.active).map((u) => html`<option value=${u.id}>${u.name}</option>`)}</select>` : null}
${a.type === 'set_date_today' ? colSelect(['date'], a.column_id, (v) => setA({ ...a, column_id: v })) : null}
${a.type === 'clear_column' ? colSelect(board.columns.map((c) => c.type), a.column_id, (v) => setA({ ...a, column_id: v })) : null}
</div>
<div class="modal-actions"><button class="btn ghost" onClick=${done}>Cancel</button><button class="btn primary" disabled=${!valid} onClick=${save}>Create automation</button></div>
</div>`;
}