Waters Boards: self-hosted work management to replace monday.com
Deploy Waters Boards to NAS / deploy (push) Canceled after 0s
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:
@@ -0,0 +1,86 @@
|
||||
// Calendar: month grid of items by a date (or timeline) column; drag to reschedule.
|
||||
import { html, useState } from '../vendor/preact-htm.mjs';
|
||||
import { api } from './api.js';
|
||||
import { navigate, boardPath, mutateBoard } from './store.js';
|
||||
import { Icon, ymd, parseDate, todayStr, textOn, Empty, useMobile } from './ui.js';
|
||||
import { labelOf } from './cells.js';
|
||||
import * as A from './actions.js';
|
||||
|
||||
export function CalendarView({ board, view, items, subs }) {
|
||||
const boardId = board.board.id;
|
||||
const mobile = useMobile();
|
||||
const dateCols = board.columns.filter((c) => c.type === 'date' || c.type === 'timeline');
|
||||
const col = dateCols.find((c) => c.id === view.settings?.column) || dateCols[0];
|
||||
const status = board.columns.find((c) => c.type === 'status');
|
||||
const [month, setMonth] = useState(() => { const d = new Date(); return new Date(d.getFullYear(), d.getMonth(), 1); });
|
||||
const [drag, setDrag] = useState(null);
|
||||
if (!col) return html`<${Empty} icon="calendar" title="Calendar needs a date column"><button class="btn primary" onClick=${() => A.addColumn(boardId, 'date')}>Add a date column</button><//>`;
|
||||
const all = [...items, ...items.flatMap((i) => subs[i.id] || [])];
|
||||
const byDay = {};
|
||||
for (const it of all) {
|
||||
const v = it.vals[col.id];
|
||||
if (!v) continue;
|
||||
if (col.type === 'date') (byDay[String(v).slice(0, 10)] ||= []).push(it);
|
||||
else {
|
||||
const a = parseDate(v.from); const b = parseDate(v.to || v.from);
|
||||
for (let d = new Date(a); d <= b && (d - a) < 400 * 86400000; d.setDate(d.getDate() + 1)) (byDay[ymd(d)] ||= []).push(it);
|
||||
}
|
||||
}
|
||||
const start = new Date(month); start.setDate(1 - start.getDay());
|
||||
const days = Array.from({ length: 42 }, (_, i) => { const d = new Date(start); d.setDate(start.getDate() + i); return d; });
|
||||
const setCol = (id) => { mutateBoard(boardId, () => { view.settings = { ...view.settings, column: id }; }); api.patch(`/api/views/${view.id}`, { settings: { ...view.settings, column: id } }); };
|
||||
const drop = (day) => {
|
||||
if (!drag) return;
|
||||
const v = drag.vals[col.id];
|
||||
if (col.type === 'date') A.setValue(boardId, drag.id, col.id, String(v || '').includes('T') ? `${day}T${String(v).split('T')[1]}` : day);
|
||||
else {
|
||||
const len = v?.from ? (parseDate(v.to) - parseDate(v.from)) : 0;
|
||||
const to = new Date(parseDate(day).getTime() + len);
|
||||
A.setValue(boardId, drag.id, col.id, { from: day, to: ymd(to) });
|
||||
}
|
||||
setDrag(null);
|
||||
};
|
||||
const chip = (it) => {
|
||||
const l = status && labelOf(status, it.vals[status.id]);
|
||||
return html`<button key=${it.id} class="cal-item" draggable="true" onDragStart=${() => setDrag(it)}
|
||||
style=${{ background: l?.color || 'var(--primary)', color: textOn(l?.color || '#0073ea') }}
|
||||
onClick=${() => navigate(boardPath(boardId, { viewId: view.id, itemId: it.id }))}>${it.name}</button>`;
|
||||
};
|
||||
if (mobile) {
|
||||
// Agenda list on phones.
|
||||
const keys = Object.keys(byDay).filter((k) => k.slice(0, 7) === ymd(month).slice(0, 7)).sort();
|
||||
return html`<div class="calendar mobile">
|
||||
<${CalHead} month=${month} setMonth=${setMonth} dateCols=${dateCols} col=${col} setCol=${setCol} />
|
||||
${keys.map((k) => html`<div key=${k} class="agenda-day"><div class="agenda-date ${k === todayStr() ? 'today' : ''}">${parseDate(k).toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })}</div>
|
||||
<div class="agenda-items">${byDay[k].map(chip)}</div></div>`)}
|
||||
${!keys.length ? html`<p class="muted pad">Nothing scheduled this month.</p>` : null}
|
||||
</div>`;
|
||||
}
|
||||
return html`<div class="calendar">
|
||||
<${CalHead} month=${month} setMonth=${setMonth} dateCols=${dateCols} col=${col} setCol=${setCol} />
|
||||
<div class="cal-grid">
|
||||
${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => html`<div key=${d} class="cal-dow">${d}</div>`)}
|
||||
${days.map((d) => {
|
||||
const k = ymd(d); const list = byDay[k] || [];
|
||||
return html`<div key=${k} class="cal-day ${d.getMonth() !== month.getMonth() ? 'out' : ''} ${k === todayStr() ? 'today' : ''}"
|
||||
onDragOver=${(e) => e.preventDefault()} onDrop=${() => drop(k)}
|
||||
onDblClick=${async () => { const it = await A.createItem(boardId, { group_id: board.groups[0]?.id, name: 'New item', vals: { [col.id]: col.type === 'date' ? k : { from: k, to: k } } }); if (it) navigate(boardPath(boardId, { viewId: view.id, itemId: it.id })); }}>
|
||||
<span class="cal-num">${d.getDate()}</span>
|
||||
<div class="cal-items">${list.slice(0, 4).map(chip)}${list.length > 4 ? html`<span class="muted small">+${list.length - 4} more</span>` : null}</div>
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
<p class="muted small pad-x">Drag items to reschedule · double-click a day to add an item</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function CalHead({ month, setMonth, dateCols, col, setCol }) {
|
||||
return html`<div class="cal-head">
|
||||
<button class="btn ghost sm" onClick=${() => { const d = new Date(); setMonth(new Date(d.getFullYear(), d.getMonth(), 1)); }}>Today</button>
|
||||
<button class="icon-btn" onClick=${() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}><${Icon} name="chevLeft" /></button>
|
||||
<button class="icon-btn" onClick=${() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}><${Icon} name="chevRight" /></button>
|
||||
<h3>${month.toLocaleString([], { month: 'long', year: 'numeric' })}</h3>
|
||||
<span class="spacer" />
|
||||
${dateCols.length > 1 ? html`<select class="input sm" value=${col.id} onChange=${(e) => setCol(e.target.value)}>${dateCols.map((c) => html`<option value=${c.id}>${c.title}</option>`)}</select>` : null}
|
||||
</div>`;
|
||||
}
|
||||
Reference in New Issue
Block a user