// Column types: metadata, cell display/editing, pickers and group-footer summaries. import { html, useState, useRef, useEffect } from '../vendor/preact-htm.mjs'; import { state, toast } from './store.js'; import { api } from './api.js'; import { setValue, patchColumn } from './actions.js'; import { Icon, Avatar, AvatarStack, openPopover, closePopover, InlineEdit, fmtDate, fmtRange, parseDate, ymd, todayStr, textOn, LABEL_COLORS, fileUrl, isImage, fmtSize, timeAgo, ColorPicker, } from './ui.js'; export const COLUMN_TYPES = { status: { label: 'Status', icon: 'check', color: '#00c875' }, priority: { label: 'Priority', icon: 'bolt', color: '#401694' }, text: { label: 'Text', icon: 'edit', color: '#fdab3d' }, people: { label: 'People', icon: 'person', color: '#579bfc' }, date: { label: 'Date', icon: 'calendar', color: '#a25ddc' }, timeline: { label: 'Timeline', icon: 'timeline', color: '#9d50dd' }, number: { label: 'Numbers', icon: 'chart', color: '#ffcb00' }, long_text: { label: 'Long text', icon: 'message', color: '#ff642e' }, dropdown: { label: 'Dropdown', icon: 'chevDown', color: '#00a9ff' }, tags: { label: 'Tags', icon: 'pin', color: '#00c875' }, checkbox: { label: 'Checkbox', icon: 'check', color: '#ff7575' }, files: { label: 'Files', icon: 'paperclip', color: '#f65f7c' }, link: { label: 'Link', icon: 'link', color: '#66ccff' }, email: { label: 'Email', icon: 'message', color: '#0086c0' }, phone: { label: 'Phone', icon: 'message', color: '#037f4c' }, rating: { label: 'Rating', icon: 'star', color: '#ffcb00' }, created: { label: 'Creation log', icon: 'activity', color: '#7f5347' }, updated: { label: 'Last updated', icon: 'refresh', color: '#7f5347' }, item_id: { label: 'Item ID', icon: 'info', color: '#676879' }, }; export const DEFAULT_WIDTH = { status: 140, people: 110, date: 120, timeline: 170, number: 110, checkbox: 90, rating: 130, files: 120, text: 170, long_text: 200, dropdown: 170, tags: 170, link: 170, email: 180, phone: 140, created: 160, updated: 140, item_id: 110 }; export const colWidth = (c) => c.width || DEFAULT_WIDTH[c.type] || 150; export function labelOf(col, id) { return (col.settings?.labels || []).find((l) => l.id === id); } export function isDoneLabel(l) { return !!l && (l.done || /^done$/i.test(l.name)); } // Is the item "done"? — first status column with a done label. export function itemDone(board, item) { for (const c of board.columns) if (c.type === 'status' && isDoneLabel(labelOf(c, item.vals[c.id]))) return true; return false; } // Plain-text value, for search, sort and filters. export function cellText(board, col, item) { const v = item.vals[col.id]; switch (col.type) { case 'status': return labelOf(col, v)?.name || ''; case 'dropdown': return (v || []).map((x) => labelOf(col, x)?.name).filter(Boolean).join(', '); case 'people': return (v || []).map((u) => state.usersById[u]?.name).filter(Boolean).join(', '); case 'timeline': return v?.from ? `${v.from} ${v.to}` : ''; case 'link': return v ? (v.text || v.url || '') : ''; case 'tags': return (v || []).join(', '); case 'checkbox': return v ? 'checked' : ''; case 'files': return (board.files || []).filter((f) => (v || []).includes(f.id)).map((f) => f.name).join(', '); case 'created': return state.usersById[item.created_by]?.name || ''; default: return v == null ? '' : String(v); } } // ─── Cell ──────────────────────────────────────────────── export function Cell({ board, col, item, compact }) { const v = item.vals[col.id]; const boardId = board.board.id; const set = (val) => setValue(boardId, item.id, col.id, val); const open = (e, Comp, extra = {}, opts = {}) => { e.stopPropagation(); openPopover(e.currentTarget, Comp, { board, col, item, set, ...extra }, opts); }; switch (col.type) { case 'status': { const l = labelOf(col, v); return html``; } case 'people': return html``; case 'date': { const d = parseDate(v); const overdue = d && ymd(d) < todayStr() && !itemDone(board, item); const today = d && ymd(d) === todayStr(); return html``; } case 'timeline': { const g = board.groups.find((x) => x.id === (item.group_id || board.items.find((i) => i.id === item.parent_id)?.group_id)); const pct = v?.from ? timelineProgress(v) : 0; return html``; } case 'number': return html`
<${InlineEdit} value=${v == null ? '' : String(v)} placeholder="" onSave=${(s) => { const n = s === '' ? null : Number(s.replace(/[^0-9.\-]/g, '')); if (s !== '' && !Number.isFinite(n)) return toast('Numbers only', 'error'); set(n); }} /> ${v != null && col.settings?.unit ? html`${col.settings.unit}` : null}
`; case 'checkbox': return html``; case 'rating': { const max = col.settings?.max || 5; return html`
${Array.from({ length: max }, (_, i) => html` `)}
`; } case 'long_text': return html``; case 'dropdown': return html``; case 'tags': return html``; case 'link': return html``; case 'email': return html``; case 'phone': return html``; case 'files': { const files = (board.files || []).filter((f) => (v || []).includes(f.id)); return html``; } case 'created': return html`
<${Avatar} user=${state.usersById[item.created_by]} size=${22} />${fmtDate(ymd(new Date(item.created_at)))}
`; case 'updated': return html`
${timeAgo(item.updated_at)} ago
`; case 'item_id': return html`
${item.id}
`; default: return html`
<${InlineEdit} value=${v || ''} onSave=${(s) => set(s || null)} />
`; } } const ext = (n) => (n.split('.').pop() || 'file').slice(0, 4).toUpperCase(); function timelineProgress(v) { const a = parseDate(v.from); const b = parseDate(v.to || v.from); const now = new Date(); now.setHours(0, 0, 0, 0); if (now < a) return 0; if (now >= b) return 100; return Math.round(((now - a) / (b - a + 86400000)) * 100); } // ─── Status picker (with label editing) ────────────────── export function StatusPicker({ board, col, set, item, onPick }) { const [editing, setEditing] = useState(false); const labels = col.settings?.labels || []; const pick = (id) => { closePopover(); onPick ? onPick(id) : set(id); }; if (editing) return html`<${LabelEditor} board=${board} col=${col} onDone=${() => setEditing(false)} />`; return html`
${labels.map((l) => html``)}
`; } function LabelEditor({ board, col, onDone }) { const [labels, setLabels] = useState(() => (col.settings?.labels || []).map((l) => ({ ...l }))); const save = () => { patchColumn(board.board.id, col.id, { settings: { ...col.settings, labels: labels.filter((l) => l.name.trim()) } }); onDone(); }; const upd = (i, patch) => setLabels(labels.map((l, j) => (j === i ? { ...l, ...patch } : l))); return html`
${labels.map((l, i) => html`
` : null}
`)}
`; } // ─── Dropdown labels (multi) ───────────────────────────── function LabelsPicker({ board, col, item, set }) { const [q, setQ] = useState(''); const sel = item.vals[col.id] || []; const labels = col.settings?.labels || []; const shown = labels.filter((l) => l.name.toLowerCase().includes(q.toLowerCase())); const toggle = (id) => set(sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id]); const create = () => { const l = { id: Math.random().toString(36).slice(2, 9), name: q.trim(), color: LABEL_COLORS[labels.length % LABEL_COLORS.length] }; patchColumn(board.board.id, col.id, { settings: { ...col.settings, labels: [...labels, l] } }); set([...sel, l.id]); setQ(''); }; return html`
setQ(e.target.value)} autofocus onKeyDown=${(e) => { if (e.key === 'Enter' && q.trim()) { const ex = labels.find((l) => l.name.toLowerCase() === q.trim().toLowerCase()); ex ? toggle(ex.id) : create(); } }} />
${shown.map((l) => html``)} ${q.trim() && !labels.some((l) => l.name.toLowerCase() === q.trim().toLowerCase()) ? html`` : null} ${!labels.length && !q ? html`
Type to add the first option
` : null}
`; } function TagsEditor({ col, item, set }) { const [q, setQ] = useState(''); const tags = item.vals[col.id] || []; return html`
${tags.map((t) => html`#${t}`)}
setQ(e.target.value)} onKeyDown=${(e) => { if (e.key === 'Enter' && q.trim()) { const t = q.trim().replace(/^#/, ''); if (!tags.includes(t)) set([...tags, t]); setQ(''); } }} />
`; } // ─── People ────────────────────────────────────────────── export function PeoplePicker({ col, item, set, value, onChange, single }) { const [q, setQ] = useState(''); const sel = value ?? (item?.vals[col.id] || []); const apply = onChange || set; const users = state.users.filter((u) => u.active && (u.name + ' ' + u.email).toLowerCase().includes(q.toLowerCase())); const toggle = (id) => { if (single) { apply(sel.includes(id) ? [] : [id]); closePopover(); return; } apply(sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id]); }; const chosen = sel.map((id) => state.usersById[id]).filter(Boolean); return html`
${chosen.length ? html`
${chosen.map((u) => html`<${Avatar} user=${u} size=${20} />${u.name} `)}
` : null} setQ(e.target.value)} autofocus />
Suggested people
${users.filter((u) => !sel.includes(u.id)).slice(0, 50).map((u) => html``)} ${!users.length ? html`
No one matches "${q}"
` : null}
`; } // ─── Dates ─────────────────────────────────────────────── export function DatePicker({ col, item, set, range, value: v0, onChange }) { const value = v0 !== undefined ? v0 : item?.vals[col.id]; const apply = onChange || set; const init = range ? parseDate(value?.from) : parseDate(value); const [month, setMonth] = useState(() => { const d = init || new Date(); return new Date(d.getFullYear(), d.getMonth(), 1); }); const [from, setFrom] = useState(range ? value?.from || null : null); const [time, setTime] = useState(!range && String(value || '').includes('T') ? String(value).split('T')[1] : ''); const [showTime, setShowTime] = useState(!!time); const sel = range ? null : (value ? String(value).slice(0, 10) : null); const days = []; const start = new Date(month); start.setDate(1 - start.getDay()); for (let i = 0; i < 42; i++) { const d = new Date(start); d.setDate(start.getDate() + i); days.push(d); } const pending = useRef(false); const click = (d) => { const s = ymd(d); if (!range) { apply(showTime && time ? `${s}T${time}` : s); closePopover(); return; } if (!pending.current) { setFrom(s); pending.current = true; apply({ from: s, to: s }); return; } const [a, b] = s < from ? [s, from] : [from, s]; pending.current = false; apply({ from: a, to: b }); closePopover(); }; const inRange = (s) => range && value?.from && s >= value.from && s <= (value.to || value.from); return html`
${month.toLocaleString([], { month: 'long', year: 'numeric' })}
${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d, i) => html`${d}`)} ${days.map((d) => { const s = ymd(d); return html``; })}
${range ? html`
${pending.current ? 'Now pick the end date' : 'Pick a start date, then an end date'}
` : null}
${!range ? html`` : null} ${value ? html`` : null}
${showTime && !range ? html`
setTime(e.target.value)} />
` : null}
`; } // ─── Text-ish editors ──────────────────────────────────── function LongTextEditor({ col, item, set }) { const [v, setV] = useState(item.vals[col.id] || ''); const save = () => { if (v !== (item.vals[col.id] || '')) set(v.trim() || null); closePopover(); }; return html`