s.replace(/<\\\/script/gi, ' { const m = /]*>([\s\S]*?)<\/title>/i.exec(html); return m ? m[1].trim() : ''; };
function factory() {
if (typeof document === 'undefined') return [];
return Array.from(document.querySelectorAll('script[type="text/plain"][data-layout]')).map((n) => {
const html = unescape(n.textContent.replace(/^\n/, ''));
return { id: n.dataset.layout, name: titleOf(html) || n.dataset.layout, html, factory: true, hidden: n.hasAttribute('data-hidden') };
});
}
// Vistas propias de la app (Now, List): cubren cosas que el contrato no da (todos los proyectos), así que se dibujan
// en la página. Las registra Shell con registerNative(id, name, render).
const natives = [];
function registerNative(id, name, render) { natives.push({ id, name, render, factory: true, native: true }); }
// Los que se ven en el selector: de fábrica visibles, las vistas propias y los subidos que cumplen el contrato 2.
function list() {
return factory().filter((l) => !l.hidden).concat(natives)
.concat(Store.get().layouts.filter((l) => validate(l.html).ok).map((l) => ({ id: l.id, name: l.name || titleOf(l.html) || l.id, html: l.html, factory: false })));
}
const find = (id) => list().find((l) => l.id === id) || null;
const example = () => factory().find((l) => l.id === 'minimo') || null;
// Un layout subido necesita y un con texto.
function validate(html) {
if (typeof html !== 'string' || !html.trim()) return { ok: false, missing: ['content (the file is empty)'] };
const metas = html.match(/]*>/gi) || [];
const meta = metas.find((m) => /\bname\s*=\s*["']?tasky-layout["'\s/>]/i.test(m));
const version = meta ? ((/\bcontent\s*=\s*["']?(\d+)/i.exec(meta) || [])[1] || '') : '';
const name = titleOf(html).replace(/\s+/g, ' ');
if (version === '1') return { ok: false, old: true, missing: ['contract version 2: this layout was made for contract 1 (scores, no workflows)'] };
const missing = [];
if (version !== '2') missing.push('');
if (!name) missing.push('a with the layout name');
return missing.length ? { ok: false, missing } : { ok: true, name };
}
// Prompt 020 (E7): campos opcionales del modo Team para el universo (los layouts que no los conocen los ignoran):
// tarea.reward (no para un Viewer), tarea.locked/lockText, tarea.owner {name, color} si está tomada o en revisión,
// agent.ownerName/ownerColor, workflow.locked, people[].me (tu nave) y fill (el mapa ocupa todo su lugar).
const inTeam = () => typeof Team !== 'undefined' && Team.on();
const shipColorOf = (uid) => { const m = inTeam() ? Team.memberOf(uid) : null; return m && /^#[0-9A-Fa-f]{6}$/.test(m.ship_color || '') ? m.ship_color : '#FFD23F'; };
function teamExtras(p, t, status) {
if (!inTeam()) return {};
const lock = Store.lockOf(p.id, t), reward = Team.role() === 'viewer' ? 0 : Store.rewardOf(t);
const out = { locked: !!lock };
if (lock) { out.lockText = lock.text; out.lockAfter = String(lock.after || '').slice(0, 40); }
else if (Store.notReady(t)) { out.locked = true; out.lockText = 'Not ready yet'; out.notReady = true; }
if (reward) out.reward = reward;
// 021 (G1): "tomada" (candado topacio con el nombre) mientras no esté en revisión ni terminada.
if (t.assignee && ['doing', 'needs-you', 'blocked', 'todo', 'review'].includes(status)) out.owner = { name: Team.nameOf(t.assignee) || '', color: shipColorOf(t.assignee) };
if (t.assignee && ['doing', 'needs-you', 'blocked', 'todo'].includes(status)) out.taken = true;
return out;
}
function agentOwner(a) {
if (!inTeam() || !a.owner) return {};
return { ownerName: Team.nameOf(a.owner) || '', ownerColor: shipColorOf(a.owner), ...(a.owner === Team.myId() ? { mine: true } : {}) };
}
// Datos para el layout, con la forma exacta de docs/spec/contrato-layout.md (versión 2).
function data() {
const p = Store.current();
if (!p) return { type: 'tasky:data', version: 2, project: null, workflows: [], tasks: [], selected: null, filter: null };
// Línea de tiempo: con past, las tareas y los estados son los de ese momento (sin agentes) y no se edita nada.
const past = getPast();
// Universos (A7): la vista muestra solo los workflows del universo elegido; el avance del proyecto suma todos.
const uni = getUniverse(), inUni = (wid) => !uni || Store.universeOf(p.id, wid) === uni;
const tasks = Store.tasksOf(p.id).filter((t) => (!past || past.statuses.get(t.id) != null) && inUni(t.workflow)).map((t) => {
const a0 = past || (typeof Team !== 'undefined' && Team.on() && Team.role() === 'viewer') ? null : Store.agentOn(p.id, t.id);
const a = a0 && Store.agentAlive(a0) ? a0 : null; // E8: 2 minutos sin señal, deja de mostrarse
const status = past ? past.statuses.get(t.id) : t.status;
// Archivos (modo Team): cuántas versiones tiene y cuántos entregables pedidos faltan.
const fl = past ? [] : Store.get().files.filter((x) => x.projectId === p.id && x.taskId === t.id && !x.deleted);
const delivered = new Set(fl.filter((x) => x.kind === 'delivery').map((x) => x.name));
const pending = typeof Team !== 'undefined' && Team.on() && !past ? (t.deliverables || []).filter((n) => !delivered.has(n)).length : 0;
return { id: t.id, title: t.title, mission: t.mission || '', workflow: t.workflow, status, needs: status === 'needs-you' && !past ? (t.needs || '') : '', ...(typeof Team !== 'undefined' && Team.on() ? { files: fl.length, pending, unread: !past && Team.unreadIn(p.id, t.id) > 0 } : {}), agent: a ? { id: a.id, name: a.name, icon: a.icon, color: safeColor(a.color, '#5B9DFF'), path: Store.agentPath(a.icon), state: typeof Agents !== 'undefined' ? Agents.liveLine(a).state : 'working', ...agentOwner(a) } : null, ...(!past ? teamExtras(p, t, status) : {}) };
});
const count = (wid) => { const ts = tasks.filter((t) => wid == null || t.workflow === wid); const done = ts.filter((t) => t.status === 'done').length; return { done, total: ts.length, left: ts.length - done }; };
const c = past ? count(null) : Store.counts(p.id);
const workflows = Store.workflowsOf(p.id).filter((w) => inUni(w.id) && (!past || tasks.some((t) => t.workflow === w.id))).map((w) => ({ id: w.id, name: w.name, mission: w.mission || '', color: Store.colorOf(p.id, w.id), ...pick(count(w.id)), ...(inTeam() && !past && Store.wfLockOf(p.id, w) ? { locked: true } : {}) }));
const out = { type: 'tasky:data', version: 2, project: { id: p.id, name: p.name, status: p.status, left: c.left, done: c.done, total: c.total }, workflows, tasks, selected: getSelected(), filter: getFilter(), past: past ? { at: past.at, label: past.label } : null, ...(Store.universes().length > 1 ? { universe: uni } : {}) };
// Modo Team: las personas conectadas que miran este proyecto, y qué miran (sus naves).
if (typeof Team !== 'undefined' && Team.on()) out.people = Team.people().filter((x) => x.userId !== Team.myId() && x.focus && x.focus.project === p.id && (!uni || (x.focus.universe || 'Main') === uni))
.map((x) => ({ id: x.userId, name: String(x.name || '').slice(0, 60), color: safeColor(x.color, '#FFD23F'), shape: Ships.SHAPES.includes(x.shape) ? x.shape : 'arrow', path: Ships.path(x.shape), kind: x.focus.kind, target: x.focus.id }));
// Tu propia nave (E7): vuela a lo que miras; "Home" la busca.
if (inTeam() && Team.isEntered() && Team.me()) {
const me = Team.me(), sel = getSelected();
const kind = sel && (sel.kind === 'task' || sel.kind === 'workflow') ? sel.kind : 'project';
out.people = [...(out.people || []), { id: Team.myId(), name: String(me.name || '').slice(0, 60), color: safeColor(me.ship_color, '#FFD23F'), shape: Ships.SHAPES.includes(me.ship_shape) ? me.ship_shape : 'arrow', path: Ships.path(me.ship_shape), kind, target: kind === 'project' ? p.id : sel.id, me: true }];
}
if (inTeam() && Team.isEntered() && !Team.isVisitor() && Team.viewers) {
const vs = Team.viewers().filter((x) => x.focus && x.focus.project === p.id && (!uni || (x.focus.universe || 'Main') === uni));
out.people = [...(out.people || []), ...vs.slice(0, 10).map((x) => ({ id: x.userId, name: x.name, color: safeColor(x.color, '#E8E4FF'), shape: Ships.SHAPES.includes(x.shape) ? x.shape : 'arrow', path: Ships.path(x.shape), kind: x.focus.kind, target: x.focus.id, viewer: true }))];
if (vs.length > 10) out.viewersMore = vs.length - 10;
}
if (typeof Shell !== 'undefined' && Shell.teamUI && Shell.teamUI()) out.fill = true;
if (inTeam() && Team.role() !== 'viewer') out.legend = true; // 021 (G1): la leyenda de los 5 estados
// 022 (R4): el Viewer: el mapa sin textos (ni ids, ni nombres de galaxias, ni naves con nombre); la app pone su leyenda.
if (inTeam() && Team.role() === 'viewer') { out.minimal = true; out.people = (out.people || []).map((x) => ({ ...x, name: '' })); out.workflows = out.workflows.map((w) => ({ ...w, name: '', mission: '' })); }
return out;
}
const pick = (c) => ({ done: c.done, total: c.total });
// Colores que viajan a los layouts: solo #RRGGBB. En modo Team los escribe otra persona (agentes, naves).
const safeColor = (c, fallback) => (/^#[0-9A-Fa-f]{6}$/.test(String(c || '')) ? c : fallback);
// La app le pasa Fredoka a cada layout: el mismo @font-face, insertado al principio del del srcdoc.
function withFont(html) {
const css = typeof document !== 'undefined' && document.getElementById('tasky-font') ? document.getElementById('tasky-font').textContent : '';
if (!css) return html;
const tag = '';
return /]*>/i.test(html) ? html.replace(/]*>/i, (m) => m + tag) : tag + html;
}
function post(msg) { if (frame && frame.contentWindow) frame.contentWindow.postMessage(msg, '*'); }
function push() {
if (current && current.native) { current.render(root.querySelector('.native-view')); return; }
if (ready) post(data());
}
function stalled() {
if (ready || !root) return;
const box = document.createElement('div');
box.className = 'layout-stalled';
box.setAttribute('role', 'alert');
box.append(Help.TEXT.stalled(current ? current.name : '?'));
for (const l of list()) {
if (current && l.id === current.id) continue;
const b = document.createElement('button');
b.textContent = l.name;
b.setAttribute('aria-label', 'Use the ' + l.name + ' view');
b.dataset.detail = 'view:' + l.id;
b.onclick = () => Actions.chooseLayout(l.id);
box.append(b);
}
root.querySelectorAll('.layout-stalled').forEach((n) => n.remove());
root.prepend(box);
}
function use(layoutId) {
if (!root) return null;
const l = find(layoutId) || list()[0];
if (!l) return null;
clearTimeout(timer);
ready = false;
current = l;
root.textContent = '';
if (l.native) {
frame = null;
const box = document.createElement('div');
box.className = 'native-view native-' + l.id;
root.append(box);
l.render(box);
return l.id;
}
frame = document.createElement('iframe');
frame.setAttribute('sandbox', 'allow-scripts');
frame.setAttribute('title', l.name + ' view');
frame.className = 'layout-frame';
frame.srcdoc = withFont(l.html);
root.append(frame);
timer = setTimeout(stalled, READY_MS);
return l.id;
}
const str = (v) => typeof v === 'string' && v.length > 0 && v.length < 200;
function onMessage(e) {
if (!frame || e.source !== frame.contentWindow) return;
const m = e.data;
if (!m || typeof m !== 'object' || typeof m.type !== 'string') return;
switch (m.type) {
case 'tasky:ready':
ready = true; clearTimeout(timer);
root.querySelectorAll('.layout-stalled').forEach((n) => n.remove());
push();
if (pending) { focus(pending); pending = null; }
break;
case 'tasky:task':
if (str(m.id)) Shell.taskClicked(m.id);
break;
case 'tasky:select':
if (m.kind === 'workflow' && str(m.id)) Shell.select({ kind: 'workflow', id: m.id });
else if (m.kind === 'task' && str(m.id)) Shell.select({ kind: 'task', id: m.id }); // selecciona, no cambia el estado
break;
case 'tasky:hover':
if (m.kind === 'none') Shell.peek(null);
else if (m.kind === 'project') Shell.peek({ kind: 'project' });
else if ((m.kind === 'task' || m.kind === 'workflow') && str(m.id)) Shell.peek({ kind: m.kind, id: m.id });
break;
case 'tasky:interact':
Shell.interacted();
break;
case 'tasky:height': {
const v = Number(m.value);
if (Number.isFinite(v) && v > 0) frame.style.height = Math.min(Math.max(Math.ceil(v), 120), 20000) + 'px';
break;
}
}
}
function mount(element, selected, filter, past, universe) {
root = element;
if (universe) getUniverse = universe;
if (selected) getSelected = selected;
if (filter) getFilter = filter;
if (past) getPast = past;
addEventListener('message', onMessage);
Store.subscribe((s) => {
if (!current || (s.settings.layoutId !== current.id && find(s.settings.layoutId))) use(s.settings.layoutId);
else if (!find(current.id)) use(s.settings.layoutId);
else push();
});
return use(Store.get().settings.layoutId);
}
// La app lleva la cámara (o la vista) a un workflow o a una tarea: tasky:focus { workflow } | { task }.
let pending = null; // un foco pedido antes de que la vista esté lista se aplica al llegar tasky:ready
function focus(target) {
if (!target) return;
if (!ready) { pending = target; return; }
if (target.task) post({ type: 'tasky:focus', task: String(target.task) });
else if (target.workflow) post({ type: 'tasky:focus', workflow: String(target.workflow) });
else if (target.ship) post({ type: 'tasky:focus', ship: String(target.ship) }); // E9: seguir una nave
}
return { mount, use, list, data, push, focus, registerNative, validate, example, current: () => current, isReady: () => ready, isNative: () => !!(current && current.native) };
})();
/* ==================== Shell ==================== */
const Shell = (() => {
const $ = (sel) => document.querySelector(sel);
const T = () => Help.TEXT;
const el = (tag, props = {}, ...kids) => {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (/^aria[A-Z]/.test(k)) n.setAttribute('aria-' + k.slice(4).toLowerCase(), v);
else if (k === 'data') Object.assign(n.dataset, v);
else if (k === 'role') n.setAttribute('role', v);
else n[k] = v;
}
n.append(...kids.filter((x) => x != null));
return n;
};
const btn = (text, onclick, props = {}, ...kids) => { const b = el('button', { type: 'button', textContent: text, onclick, ...props }); if (kids.length) b.append(...kids.filter((x) => x != null)); return b; };
const svg = (paths, cls = '') => { const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); if (cls) s.setAttribute('class', cls); s.innerHTML = paths; return s; };
const ico = (status, color) => { const i = el('i', { className: 'ico ' + status, ariaHidden: 'true' }); if (color) i.style.setProperty('--c', color); return i; };
// Lo que muestra la tarjeta de detalle: lo señalado (peek) o, si no, lo elegido (focus).
let focus = null;
const prevPct = new Map();
const bumpUntil = new Map(); // el pulso de una fila dura aunque se redibuje
function notify(text) {
if (/not saved|could not|failed|did not work/i.test(String(text))) noteError(text);
const box = $('#notice');
box.textContent = '';
box.append(el('button', { className: 'close', textContent: '✕', ariaLabel: 'Close message', onclick: () => { box.hidden = true; placeDuelAsk(); } }), text);
box.hidden = false;
placeDuelAsk();
}
// ---- Versión nueva: la app publicada (Team: version.json) o la que sirve el puente (/version) cambió ----
// No recarga sola: avisa "New version available · Reload" y espera; si hay texto sin enviar, pregunta antes.
let buildSeen = null, updateShown = false;
const myBuild = () => { const m = typeof document !== 'undefined' && document.querySelector('meta[name="tasky-build"]'); return m ? m.content : null; };
async function checkVersion() {
let url = null;
if (Team.on() && /^https?:$/.test(location.protocol)) url = 'version.json';
else if (typeof Bridge !== 'undefined' && Bridge.servedByBridge()) url = location.origin + '/version';
if (!url) return null;
let build = null;
try { const r = await fetch(url + '?t=' + Date.now(), { cache: 'no-store' }); if (r.ok) build = (await r.json()).build || null; } catch {}
if (!build) return null;
const mine = myBuild() || buildSeen;
if (!mine) { buildSeen = build; return false; }
if (build !== mine) { showUpdate(); return true; }
return false;
}
const UNSENT_SKIP = ['d-needs', 'search', 'acc-name'];
const unsentText = () => [...document.querySelectorAll('textarea, input')].some((n) => ['text', 'textarea', 'email', 'password', 'search', ''].includes(n.type === 'textarea' ? 'textarea' : n.type || '') && n.value && n.value.trim() && !n.readOnly && n.offsetParent !== null && !UNSENT_SKIP.includes(n.dataset.key) && !n.classList.contains('tname') && !n.classList.contains('ainput'));
// Prompt 021 (G0): a la base le faltan migraciones. El líder sabe cuáles; un miembro solo que Tasky se está actualizando.
function showSchema(text) {
const bar = $('#schemabar');
if (!bar) return;
bar.hidden = !text;
bar.textContent = text || '';
}
function showUpdate() {
const bar = $('#update');
if (!bar || updateShown) return;
updateShown = true;
bar.hidden = false; bar.textContent = '';
const reload = btn('Reload', () => {
if (unsentText()) {
if (bar.querySelector('.uconfirm')) return;
bar.append(el('span', { className: 'uconfirm' }, el('span', { textContent: T().update.unsent }),
btn('Reload anyway', () => location.reload(), { className: 'abtn', data: { key: 'update-anyway' } }),
btn('Not now', () => { const c = bar.querySelector('.uconfirm'); if (c) c.remove(); }, { className: 'abtn', data: { key: 'update-later' } })));
return;
}
location.reload();
}, { className: 'abtn', data: { key: 'update-reload' } });
bar.append(el('strong', { textContent: T().update.title }), el('span', { textContent: '· ' + T().update.text }), reload);
}
function watchVersion() {
checkVersion();
setInterval(checkVersion, 5 * 60 * 1000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) checkVersion(); });
}
// ---- "Report a problem": un archivo de diagnóstico SIN contenido de tareas, mensajes ni archivos ----
const errorsSeen = [];
function noteError(msg) {
const m = String((msg && (msg.message || msg)) || '').split('\n')[0].slice(0, 300);
if (!m) return;
errorsSeen.push({ at: new Date().toISOString(), message: m });
if (errorsSeen.length > 30) errorsSeen.shift();
}
function bindErrors() {
addEventListener('error', (e) => noteError(e.message || e.error));
addEventListener('unhandledrejection', (e) => noteError(e.reason));
const orig = console.error.bind(console);
console.error = (...a) => { try { noteError(a.map((x) => (x && x.message) || String(x)).join(' ')); } catch {} orig(...a); };
}
// Todo texto escrito por personas o agentes (tareas, mensajes, archivos, nombres) se tapa en los errores anotados.
function scrubber() {
const s = Store.get(), secrets = new Set();
const add = (v) => { v = String(v == null ? '' : v).trim(); if (v.length >= 3) secrets.add(v); };
for (const x of s.projects) { add(x.name); add(x.doneWhen); add(x.notes); }
for (const w of s.workflows) { add(w.name); add(w.mission); add(w.universe); }
for (const t of s.tasks) { add(t.title); add(t.mission); add(t.needs); add(t.reviewNote); add(t.backComment); for (const f of [].concat(t.deliverables || [], t.files || [], t.touched || [])) add(f && f.path || f); }
for (const m of s.messages) add(m.body);
for (const f of s.files) { add(f.name); add(f.note); add(f.path); }
for (const n of s.notifications) add(n.body);
for (const a of s.agents) { add(a.name); add(a.cwd); add(a.transcript); }
for (const e of s.trash || []) add(e.name);
for (const e of s.history.slice(-300)) { add(e.title); add(e.note); add(e.comment); add(e.name); }
if (Team.on()) for (const m of Team.members()) { add(m.name); add(m.email); }
const list = [...secrets].sort((a, b) => b.length - a.length);
// Un error puede traer un trozo de un texto (por ejemplo una palabra de un título): también se tapa cada palabra
// que venga de lo escrito, salvo las palabras de la propia app (así el error se sigue entendiendo).
const words = new Set();
for (const v of list) for (const w of v.toLowerCase().split(/[^\p{L}\p{N}_-]+/u)) if (w.length >= 4) words.add(w);
const appWords = new Set();
const walk = (x) => { if (typeof x === 'string' || typeof x === 'function') for (const w of String(x).toLowerCase().split(/[^\p{L}\p{N}_-]+/u)) appWords.add(w); else if (x && typeof x === 'object') for (const v of Object.values(x)) walk(v); };
walk(Help.TEXT);
return (text) => {
let out = String(text);
for (const v of list) out = out.split(v).join('[hidden]');
out = out.replace(/[\p{L}\p{N}_-]{4,}/gu, (w) => (words.has(w.toLowerCase()) && !appWords.has(w.toLowerCase()) ? '[hidden]' : w));
return out.replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, '[email]').replace(/eyJ[\w-]+\.[\w-]+\.[\w-]+/g, '[token]').replace(/\b[0-9a-f]{24,}\b/gi, '[id]').replace(/(sb_(?:publishable|secret)_)[\w-]+/g, '$1[key]').replace(/“[^”]*”|"[^"]*"/g, '"[text]"');
};
}
function diagnostic() {
const s = Store.get(), scrub = scrubber();
const size = (() => { try { return (localStorage.getItem(Team.on() ? 'tasky:team:cache' : Store.KEY) || '').length * 2; } catch { return null; } })();
const data = {
kind: 'tasky-diagnostic',
note: 'Made by "Report a problem". It has no task, message or file content: only the version, the browser, the mode, the bridge and the last errors (with any names or text hidden).',
createdAt: new Date().toISOString(),
version: myBuild() || buildSeen || 'unknown (opened from the disk)',
mode: Team.on() ? 'team' : 'personal',
openedFrom: location.protocol === 'file:' ? 'disk' : typeof Bridge !== 'undefined' && Bridge.servedByBridge() ? 'bridge' : 'web',
browser: navigator.userAgent, language: navigator.language,
timeZone: (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return ''; } })(),
window: innerWidth + 'x' + innerHeight, view: s.settings.layoutId,
bridge: typeof Bridge !== 'undefined' ? { on: Bridge.isOn(), port: Number(s.settings.bridgePort) || (Team.on() && Team.config().bridgePort) || 4317 } : null,
team: Team.on() ? { signedIn: Team.isEntered(), online: Team.isOnline(), role: Team.role(), people: Team.members().length, missingTables: [...Team.missing()] } : null,
counts: { projects: s.projects.length, workflows: s.workflows.length, tasks: s.tasks.length, history: s.history.length, agents: s.agents.length, files: s.files.length, messages: s.messages.length, notifications: s.notifications.length, trash: (s.trash || []).length, views: s.layouts.length },
savedBytes: size, storageProblem: Store.problem(),
errors: errorsSeen.map((e) => ({ at: e.at, message: scrub(e.message) })),
};
return JSON.stringify(data, null, 2);
}
function reportProblem() {
const f = 'tasky-diagnostic-' + Store.today() + '.json';
download(f, diagnostic(), 'application/json');
notify(T().report.done(f));
return f;
}
// El dueño ve un aviso si los archivos se acercan al límite (una vez por sesión).
let storageReminded = false;
function storageReminder() {
if (storageReminded || !Team.on() || Team.role() !== 'owner') return;
const u = Team.storageUsage();
if (!u.near) return;
storageReminded = true;
notify(T().team.storage(fileSize(u.used), fileSize(u.limit), Math.round(100 * u.pct)) + ' ' + T().team.storageNear);
}
function storageNotice() {
const p = Store.problem();
if (p && T().storage[p]) notify(T().storage[p]);
}
// ---- Importar: soltar, elegir o pegar ----
function summarize(name, r) {
const head = name ? name + ': ' : '';
if (!r.ok) return head + r.error + (r.warnings.length ? '\n · ' + r.warnings.join('\n · ') : '');
let s = head + r.name + ' ' + (r.created ? 'created' : 'updated') + ', ' + r.tasks + (r.tasks === 1 ? ' task' : ' tasks') + ' in ' + r.workflows + (r.workflows === 1 ? ' workflow' : ' workflows');
if (!r.created && r.added) s += ' (' + r.added + ' new)';
if (r.total > r.tasks) s += ', ' + r.total + ' in total';
s += '.';
if (r.warnings.length) s += '\n ' + r.warnings.length + (r.warnings.length === 1 ? ' warning' : ' warnings') + ':\n · ' + r.warnings.join('\n · ');
return s;
}
function importTexts(items) {
// Prompt 021 (G4): solo el líder importa handoffs. A los demás se les dice qué hacer en su lugar.
if (typeof Team !== 'undefined' && Team.on() && Team.role() !== 'owner') { notify(T().team.noHandoff); return []; }
const lines = items.map(({ name, text }) => { const r = Actions.importHandoff(text); if (typeof Team !== 'undefined' && Team.on()) Team.logImport(r, name); return summarize(name, r); });
if (lines.length) { focus = null; notify(lines.join('\n')); render(); }
return lines;
}
// "3 tasks updated, 2 new tasks sent as suggestions, 1 change ignored (not your task)". Confirmar o cancelar.
function mergeHandoff(item) {
if (!item) return;
const M = T().merge;
const parsed = Handoff.parse(item.text || '');
if (!parsed.ok) { notify((item.name ? item.name + ': ' : '') + parsed.error); return; }
const plan = Team.planMerge(parsed);
if (plan.error) { notify(plan.error); return; }
const root = openPanel('merge', M.title(plan.projectName));
const n = (k, one, many) => k + ' ' + (k === 1 ? one : many);
const notYours = plan.ignored.every((x) => x.why === 'notYours');
const line = [n(plan.updated.length, 'task updated', 'tasks updated'), n(plan.suggested.length, 'new task sent as a suggestion', 'new tasks sent as suggestions'), n(plan.ignored.length, 'change ignored', 'changes ignored') + (plan.ignored.length ? (notYours ? ' (not your task)' : ' (see below)') : '')].join(', ');
const list = el('ul', { className: 'merge-list' });
for (const id of plan.updated) list.append(el('li', { textContent: M.updatedRow(id) }));
for (const x of plan.suggested) list.append(el('li', { textContent: M.suggestedRow(x) }));
for (const x of plan.ignored) list.append(el('li', { textContent: M.ignoredRow(x.id, x.why) }));
root.append(el('p', { className: 'merge-sum', data: { key: 'merge-summary' }, textContent: line }), el('p', { className: 'k', textContent: M.rules }), list,
el('div', { className: 'pn-actions' },
btn(M.confirm, async () => {
const r = await Team.memberHandoff(plan, item.name);
closePanel();
notify(r.error ? M.failed(r.error) : M.done(r.updated || 0, r.suggested || 0, r.ignored || 0));
render();
}, { className: 'primary', data: { key: 'merge-yes' } }),
btn(M.cancel, () => closePanel(), { data: { key: 'merge-no' } })));
}
const isLayoutFile = (name) => /\.html?$/i.test(String(name || ''));
async function importFiles(files) {
const all = Array.from(files || []);
const layouts = all.filter((f) => isLayoutFile(f.name));
if (layouts.length) await uploadLayouts(layouts);
const items = [];
for (const f of all.filter((x) => !isLayoutFile(x.name))) {
try { items.push({ name: f.name, text: await f.text() }); } catch { items.push({ name: f.name, text: null }); }
}
return importTexts(items);
}
let picker = null;
function pickFile() {
if (!picker) {
picker = el('input', { type: 'file', multiple: true, accept: '.md,.txt,text/markdown,text/plain', hidden: true });
picker.addEventListener('change', () => { importFiles(picker.files).then(() => { picker.value = ''; }); });
document.body.append(picker);
}
picker.click();
}
function bindDrop() {
let depth = 0;
const hasFiles = (e) => e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files');
addEventListener('dragenter', (e) => { if (!hasFiles(e)) return; e.preventDefault(); depth++; document.body.classList.add('dragging'); });
addEventListener('dragover', (e) => { if (!hasFiles(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
addEventListener('dragleave', () => { if (--depth <= 0) { depth = 0; document.body.classList.remove('dragging'); } });
addEventListener('drop', (e) => {
if (!e.dataTransfer) return;
e.preventDefault(); depth = 0; document.body.classList.remove('dragging');
if (e.dataTransfer.files && e.dataTransfer.files.length) importFiles(e.dataTransfer.files);
});
}
// Pegar un handoff en cualquier parte (fuera de campos de texto) también lo importa.
function bindPaste() {
addEventListener('paste', (e) => {
const t = e.target;
const hasFiles = !!(e.clipboardData && e.clipboardData.files && e.clipboardData.files.length);
if (!hasFiles && t && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.isContentEditable)) return;
// 020-B (A2): una imagen (o un video) pegada va a la tarea abierta: el dueño la deja como Reference; los demás, como
// entrega. Solo con la tarjeta de una tarea a la vista y permiso para subir.
const pasted = e.clipboardData ? Array.from(e.clipboardData.files || []).filter((x) => Media.kindOf({ name: x.name, mime: x.type }) !== 'file' || /^image\//.test(x.type)) : [];
if (pasted.length && typeof Team !== 'undefined' && Team.on() && Team.can('upload') && focus && focus.kind === 'task' && Store.current()) {
const p = Store.current(), t = Store.task(p.id, focus.id);
if (t) {
e.preventDefault();
const ext = (x) => ({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'video/mp4': 'mp4', 'video/webm': 'webm' })[x.type] || 'png';
const named = pasted.map((x, i) => new File([x], 'pasted-' + new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-') + (i ? '-' + i : '') + '.' + ext(x), { type: x.type }));
Team.uploadFiles(p.id, t.id, named, { kind: Team.role() === 'owner' ? 'starting' : 'delivery' }).then((res) => { notify(res.map((x) => (x.error ? x.error : T().files.uploaded(x.name, x.version))).join('\n')); });
return;
}
}
const text = e.clipboardData ? e.clipboardData.getData('text') : '';
if (/^\s*---/.test(text) && /##\s*tasks/i.test(text)) { e.preventDefault(); importTexts([{ name: '', text }]); }
});
}
// ---- Descargas y portapapeles ----
const PERSONAL_LIMIT = 5 * 1048576;
function downloadBlob(filename, blob) {
const url = URL.createObjectURL(blob);
const a = el('a', { href: url, download: filename, hidden: true });
document.body.append(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60000);
}
function download(filename, text, type = 'text/markdown;charset=utf-8') {
const url = URL.createObjectURL(new Blob([text], { type }));
const a = el('a', { href: url, download: filename, hidden: true });
document.body.append(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
}
async function copyText(text) {
try { await navigator.clipboard.writeText(text); return true; } catch {}
const ta = el('textarea', { value: text });
ta.style.cssText = 'position:fixed;left:-9999px;top:0';
document.body.append(ta); ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch {}
ta.remove();
return ok;
}
const copied = (ok, what) => notify(ok ? what + ' copied.' : 'Could not copy; select the text and press Ctrl+C.');
function fileName(name, used = new Set()) {
const base = String(name).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'project';
let f = base + '.tasky.md';
for (let n = 2; used.has(f); n++) f = base + '-' + n + '.tasky.md';
used.add(f);
return f;
}
function downloadProject(projectId, used) {
const p = Store.project(projectId);
if (!p) return null;
const f = fileName(p.name, used);
download(f, Handoff.serialize(Store.bundle(projectId)));
return f;
}
async function downloadProjects(ids) {
const used = new Set(), names = [];
for (const id of ids) { const f = downloadProject(id, used); if (f) names.push(f); await new Promise((r) => setTimeout(r, 300)); }
if (names.length) notify('Downloaded: ' + names.join(', ') + '.');
return names;
}
// ---- Panel lateral (diálogo) ----
let panel = null;
function focusables(root) {
return Array.from(root.querySelectorAll('button, input, textarea, select, [tabindex]:not([tabindex="-1"])')).filter((n) => !n.disabled && !n.closest('[hidden]') && n.offsetParent !== null);
}
function closePanel() {
if (!panel) return;
const { opener, root, scrim } = panel;
panel = null;
root.remove(); scrim.remove();
const again = opener && !opener.isConnected && opener.dataset && opener.dataset.key ? Array.from(document.querySelectorAll('[data-key]')).find((n) => n.dataset.key === opener.dataset.key) : null;
const target = again || (opener && opener.isConnected ? opener : document.querySelector('.tool'));
if (target && typeof target.focus === 'function') target.focus();
}
function openPanel(kind, title) {
closePanel();
const opener = document.activeElement;
const scrim = el('div', { className: 'scrim', onclick: closePanel });
const root = el('aside', { id: 'panel', className: 'panel-' + kind, ariaModal: 'true', ariaLabelledby: 'panel-title' });
root.setAttribute('role', 'dialog');
root.append(el('div', { className: 'panel-head' }, el('h2', { id: 'panel-title', textContent: title }), btn('✕', closePanel, { className: 'panel-close', ariaLabel: 'Close panel' })));
root.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
const f = focusables(root);
if (!f.length) return;
if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); }
else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); }
});
document.body.append(scrim, root);
panel = { kind, opener, root, scrim };
return root;
}
function exportAll() {
const live = Store.get().projects.filter((p) => !p.archived);
if (!live.length) { notify(T().noProjectsToExport); return; }
if (live.length === 1) { notify('Downloaded: ' + downloadProject(live[0].id) + '.'); return; }
const root = openPanel('export', 'Export');
const list = el('ul', { className: 'pn-list' });
const boxes = live.map((p) => {
const cb = el('input', { type: 'checkbox', checked: true, value: p.id, ariaLabel: 'Export ' + p.name });
list.append(el('li', {}, el('label', { className: 'pn-item' }, cb, el('span', { textContent: p.name }))));
return cb;
});
const go = btn('', () => { const ids = boxes.filter((b) => b.checked).map((b) => b.value); if (ids.length) { closePanel(); downloadProjects(ids); } }, { className: 'primary' });
const label = () => { const n = boxes.filter((b) => b.checked).length; go.textContent = n ? 'Download ' + n + (n === 1 ? ' file' : ' files') : 'Pick at least one'; go.disabled = !n; };
boxes.forEach((b) => b.addEventListener('change', label));
label();
root.append(el('p', { textContent: T().exportHint }), list, el('div', { className: 'pn-actions' }, go));
boxes[0].focus();
}
function openTemplate() {
const root = openPanel('template', 'Template for your agent');
const copyPhrase = btn('Copy instruction + template', async () => copied(await copyText(T().agentPhrase + '\n\n' + Handoff.TEMPLATE), 'Instruction and template'));
const dl = btn('Download tasky-template.tasky.md', () => { download('tasky-template.tasky.md', Handoff.TEMPLATE); notify('Downloaded: tasky-template.tasky.md.'); }, { className: 'primary' });
const cp = btn('Copy template', async () => copied(await copyText(Handoff.TEMPLATE), 'Template'));
root.append(
el('h3', { textContent: 'For your agent' }), el('p', { className: 'phrase', textContent: T().agentPhrase }), el('div', { className: 'pn-actions' }, copyPhrase),
el('h3', { textContent: 'tasky-template.tasky.md' }), el('pre', { className: 'pn-text', tabIndex: 0, textContent: Handoff.TEMPLATE, ariaLabel: 'Template text' }),
el('div', { className: 'pn-actions' }, dl, cp),
);
dl.focus();
}
function openBackup() {
const root = openPanel('backup', 'Backup');
const dl = btn('Download backup', () => { const f = 'tasky-backup-' + Store.today() + '.json'; download(f, JSON.stringify(Store.get(), null, 2), 'application/json'); notify('Downloaded: ' + f + '.'); }, { className: 'primary' });
const file = el('input', { type: 'file', accept: '.json,application/json', hidden: true });
let pending = null;
const yes = btn('Yes, replace everything', () => {
if (!pending) return;
const r = Actions.restoreBackup(pending);
if (r.ok) { closePanel(); focus = null; notify('Backup restored: ' + r.projects + (r.projects === 1 ? ' project.' : ' projects.')); }
else notify('That backup could not be restored.');
pending = null;
}, { className: 'danger' });
const no = btn('No', () => { pending = null; confirm.hidden = true; restore.focus(); });
const confirmText = el('span');
const confirm = el('div', { className: 'pn-confirm', hidden: true }, confirmText, yes, no);
file.addEventListener('change', async () => {
const f = file.files && file.files[0];
file.value = '';
if (!f) return;
let data = null;
try { data = JSON.parse(await f.text()); } catch {}
if (!Actions.isBackup(data)) { notify(f.name + ': this is not a Tasky backup.'); return; }
pending = data;
confirmText.textContent = 'Replace everything in this browser with ' + f.name + ' (' + data.projects.length + ' projects)? ';
confirm.hidden = false;
yes.focus();
});
const restore = btn('Restore backup', () => file.click());
if (Team.on()) restore.hidden = true; // en Team, el respaldo del equipo va abajo (solo el dueño)
root.append(el('p', { textContent: Team.on() ? T().team.backupMine : T().backupHint }), el('div', { className: 'pn-actions' }, dl, restore), confirm, file);
if (!Team.on()) {
// Cuánto ocupa lo guardado en este navegador (los navegadores permiten unos 5 MB por sitio).
const used = (() => { try { return (localStorage.getItem(Store.KEY) || '').length * 2; } catch { return 0; } })();
const near = used >= 0.8 * PERSONAL_LIMIT;
root.append(el('p', { className: 'store-use' + (near ? ' near' : ''), data: { key: 'storage' }, textContent: T().personalStorage(fileSize(used), fileSize(PERSONAL_LIMIT)) + (near ? ' ' + T().personalStorageNear : '') }));
} else if (Team.can('restore')) {
const zipIn = el('input', { type: 'file', accept: '.zip,application/zip', hidden: true });
const status = el('p', { className: 'fnote', role: 'status' });
const tdl = btn('Download the team backup (.zip)', async () => {
tdl.disabled = true;
const r = await Team.backupTeam((i, n) => { status.textContent = T().team.backupProgress(i, n); });
tdl.disabled = false;
if (!r) return;
const f = 'tasky-team-backup-' + Store.today() + '.zip';
downloadBlob(f, r.blob);
status.textContent = T().team.backupDone(f, r.counts);
}, { className: 'primary', data: { key: 'team-backup' } });
const trs = btn('Restore a team backup…', () => zipIn.click(), { disabled: !Team.isEmptyTeam(), title: T().team.restoreTitle, data: { key: 'team-restore' } });
zipIn.addEventListener('change', async () => {
const f = zipIn.files && zipIn.files[0]; zipIn.value = '';
if (!f) return;
trs.disabled = true;
const r = await Team.restoreTeam(await f.arrayBuffer(), (i, n) => { status.textContent = T().team.restoreProgress(i, n); });
trs.disabled = !Team.isEmptyTeam();
if (r.error) { status.textContent = r.error; notify(r.error); return; }
status.textContent = T().team.restored(r);
const links = (r.invited || []).map((x) => x.email + ' (' + x.role + '): ' + Team.inviteLink(x)).join('\n');
notify(T().team.restored(r) + (links ? '\n' + T().team.restoredInvites + '\n' + links : ''));
});
root.append(el('h3', { textContent: 'Team backup' }), el('p', { textContent: T().team.backupHint }), el('div', { className: 'pn-actions' }, tdl, trs), status, zipIn);
}
dl.focus();
}
function viewHandoff(projectId) {
const p = Store.project(projectId);
if (!p) return;
const root = openPanel('handoff', p.name + ' · handoff');
const text = Handoff.serialize(Store.bundle(projectId));
const dl = btn('Download', () => { notify('Downloaded: ' + downloadProject(projectId) + '.'); }, { className: 'primary' });
const cp = btn('Copy', async () => copied(await copyText(Handoff.serialize(Store.bundle(projectId))), 'Handoff'));
root.append(el('p', { textContent: T().handoffHint }), el('pre', { className: 'pn-text', tabIndex: 0, textContent: text, ariaLabel: 'Handoff text' }), el('div', { className: 'pn-actions' }, dl, cp));
dl.focus();
}
function openArchive() {
const root = openPanel('archive', 'Archived projects');
const list = el('ul', { className: 'pn-list' });
const fill = () => {
list.textContent = '';
const arch = Store.get().projects.filter((p) => p.archived);
if (!arch.length) { list.append(el('li', { className: 'pn-item', textContent: 'No archived projects.' })); return; }
for (const p of arch) list.append(el('li', { className: 'pn-item' }, el('span', { textContent: p.name }),
btn('Restore', () => { Actions.archiveProject(p.id, false); notify(p.name + ' is back.'); fill(); }, { className: 'row-btn', ariaLabel: 'Restore ' + p.name, data: { key: 'restore|' + p.id } })));
};
fill();
root.append(el('p', { textContent: T().archiveHint }), list);
const f = focusables(root); (f[1] || f[0]).focus();
}
// ---- Papelera: restaurar, o borrar para siempre pasados 30 días (con confirmación) ----
function openTrash() {
const root = openPanel('trash', 'Trash');
const list = el('ul', { className: 'pn-list' });
let confirmId = null;
const fill = () => {
list.textContent = '';
const items = (Store.get().trash || []).slice().sort((a, b) => b.deletedAt - a.deletedAt);
if (!items.length) { list.append(el('li', { className: 'pn-item', textContent: T().trash.empty })); return; }
for (const e of items) {
const left = Actions.TRASH_DAYS - Math.floor((Date.now() - (Number(e.deletedAt) || 0)) / 864e5);
const li = el('li', { className: 'pn-item trow', data: { key: 'trash|' + e.id } },
el('span', {}, el('b', { textContent: (e.kind === 'project' ? 'Project · ' : 'Task · ') + e.name }), el('small', { className: 'tmeta', textContent: ' ' + T().trash.when(ago(e.deletedAt), e.deletedByName, left) })),
btn('Restore', () => { const r = Actions.restoreFromTrash(e.id); if (r && r.ok) { notify(T().trash.restored(e.name)); fill(); } else if (r && r.why) notify(T().trash.cannot[r.why]); }, { className: 'row-btn', ariaLabel: 'Restore ' + e.name, data: { key: 'trash-restore|' + e.id } }));
if (Actions.canPurge(e)) {
if (confirmId === e.id) li.append(el('span', { className: 'pn-confirm' }, el('span', { textContent: T().trash.purgeAsk(e.name) }),
btn('Yes, delete forever', async () => { confirmId = null; const ok = await Actions.purgeTrash(e.id); notify(ok ? T().trash.purged(e.name) : T().trash.purgeFailed); fill(); }, { className: 'danger', data: { key: 'trash-purge-yes|' + e.id } }),
btn('No', () => { confirmId = null; fill(); })));
else li.append(btn('Delete forever', () => { confirmId = e.id; fill(); }, { className: 'row-btn', ariaLabel: 'Delete ' + e.name + ' forever', data: { key: 'trash-purge|' + e.id } }));
}
list.append(li);
}
};
fill();
root.append(el('p', { textContent: T().trash.hint(Actions.TRASH_DAYS) }), list);
if (Team.on()) Team.setPanelRefresh(() => { if (document.querySelector('#panel.panel-trash')) fill(); });
const f = focusables(root); (f[1] || f[0]).focus();
}
// Una vez por sesión: si algo lleva más de 30 días en la papelera, se avisa (nada se borra solo).
let trashReminded = false;
function trashReminder() {
if (trashReminded || (Team.on() && !Team.can('editProjects'))) return;
const n = (Store.get().trash || []).filter((e) => Actions.canPurge(e)).length;
if (!n) return;
trashReminded = true;
notify(T().trash.expired(n));
}
// ---- 023: New project (solo el líder, en Team). Un universo nuevo desde cero: primero baja el respaldo del equipo;
// después el .tasky.md entra siempre como un proyecto nuevo (aunque se llame igual) y el de ahora se archiva, sin borrar
// nada. Las wallets, los pagos y la historia son del equipo, no del proyecto: no cambian. «Archived» lo trae de vuelta.
function openNewProject() {
if (!Team.on() || Team.role() !== 'owner') { notify(T().team.ownerOnly); return; }
const N = T().newProject, cur = Store.current();
const root = openPanel('new-project', N.title);
const status = el('p', { className: 'fnote', role: 'status', data: { key: 'np-status' } });
const arch = el('input', { type: 'checkbox', checked: !!cur, data: { key: 'np-archive' } });
const pick = el('input', { type: 'file', accept: '.md,.txt,text/markdown,text/plain', hidden: true });
const go = btn(N.choose, () => pick.click(), { className: cur ? '' : 'primary', disabled: !!cur, title: cur ? N.firstBackup : '', data: { key: 'np-choose' } });
const bk = btn(N.backup, async () => {
bk.disabled = true;
const r = await Team.backupTeam((i, n) => { status.textContent = T().team.backupProgress(i, n); });
bk.disabled = false;
if (!r) { status.textContent = N.backupFailed; return; }
const f = 'tasky-team-backup-' + Store.today() + '.zip';
downloadBlob(f, r.blob);
status.textContent = N.saved(f);
go.disabled = false; go.title = ''; go.classList.add('primary'); bk.classList.remove('primary');
go.focus();
}, { className: 'primary', data: { key: 'np-backup' } });
pick.addEventListener('change', async () => {
const f = pick.files && pick.files[0];
pick.value = '';
if (!f) return;
let t = null;
try { t = await f.text(); } catch {}
startNewProject(f.name, t, arch.checked);
});
root.append(...(cur ? [el('ol', { className: 'msteps' }, ...N.steps.map((x) => el('li', { textContent: x }))), el('p', { textContent: N.keep(cur.name) })] : [el('p', { textContent: N.first })]),
...(cur ? [el('label', { className: 'te-field np-arch' }, arch, el('span', { textContent: N.archive(cur.name) }))] : []),
el('div', { className: 'pn-actions' }, ...(cur ? [bk] : []), go, btn(N.template, openTemplate, { data: { key: 'np-template' } })), status, pick);
(cur ? bk : go).focus();
}
function startNewProject(file, text, archive) {
if (!Team.on() || Team.role() !== 'owner') { notify(T().team.ownerOnly); return null; }
const old = Store.current();
const r = Actions.importHandoff(text, { asNew: true });
Team.logImport(r, file);
if (!r.ok) { notify(summarize(file, r)); return r; }
if (archive && old && old.id !== r.projectId) Actions.archiveProject(old.id, true);
Actions.selectProject(r.projectId);
closePanel(); focus = null;
notify(T().newProject.done(r.name, archive && old ? old.name : '') + '\n' + summarize(file, r));
render();
return r;
}
let replaceInput = null;
function replaceHandoff(projectId) {
if (!replaceInput) {
replaceInput = el('input', { type: 'file', accept: '.md,.txt,text/markdown,text/plain', hidden: true });
document.body.append(replaceInput);
}
replaceInput.onchange = async () => {
const f = replaceInput.files && replaceInput.files[0];
replaceInput.value = '';
if (!f) return;
let t = null;
try { t = await f.text(); } catch {}
notify(summarize(f.name, Actions.importHandoff(t, { projectId })));
};
replaceInput.click();
}
// ---- Layouts propios ----
let layoutPicker = null;
function addLayout() {
if (!layoutPicker) {
layoutPicker = el('input', { type: 'file', multiple: true, accept: '.html,.htm,text/html', hidden: true });
layoutPicker.addEventListener('change', () => { uploadLayouts(layoutPicker.files).then(() => { layoutPicker.value = ''; }); });
document.body.append(layoutPicker);
}
layoutPicker.click();
}
async function uploadLayouts(files) {
const lines = [];
for (const f of Array.from(files || [])) {
let html = null;
try { html = await f.text(); } catch {}
const v = LayoutHost.validate(html);
if (!v.ok) { lines.push(f.name + (v.old ? ': ' + T().oldLayout : ': this is not a Tasky layout. It needs ' + v.missing.join(' and ') + '.')); continue; }
const name = LayoutHost.list().some((l) => l.factory && l.name.toLowerCase() === v.name.toLowerCase()) ? v.name + ' (own)' : v.name;
const same = Store.get().layouts.find((l) => String(l.name).trim().toLowerCase() === name.toLowerCase());
if (same) { if (lines.length) notify(lines.join('\n')); askReplaceLayout(same, name, html); return; }
const id = Actions.saveLayout(name, html);
Actions.chooseLayout(id);
lines.push('View “' + name + '” added and selected.');
}
if (lines.length) notify(lines.join('\n'));
}
function askReplaceLayout(old, name, html) {
const root = openPanel('replaceLayout', 'Repeated view');
const yes = btn('Yes, replace', () => { Actions.saveLayout(name, html, old.id); Actions.chooseLayout(old.id); closePanel(); notify('View “' + name + '” replaced.'); }, { className: 'primary' });
const no = btn('No, keep the current one', () => { closePanel(); notify('Kept the view “' + old.name + '”.'); });
root.append(el('p', { textContent: 'You already have a view called “' + old.name + '”. Replace it with the one you just uploaded?' }), el('div', { className: 'pn-actions' }, yes, no));
yes.focus();
}
function removeLayout(id) {
const l = Store.get().layouts.find((x) => x.id === id);
if (!l) return;
const wasCurrent = Store.get().settings.layoutId === id;
Actions.deleteLayout(id);
const plus = document.querySelector('[data-key="view|+"]');
if (plus) plus.focus();
notify('View “' + l.name + '” deleted.' + (wasCurrent ? ' Back to Universe.' : ''));
}
function downloadExampleLayout() {
const l = LayoutHost.example();
if (l) { download('minimal-example.tasky-layout.html', l.html, 'text/html;charset=utf-8'); notify('Downloaded: minimal-example.tasky-layout.html.'); }
}
// Aviso breve abajo a la derecha que se cierra solo (" is back", "See you later, "). Un clic lo cierra.
function brief(text, kind = '', ms = 4500) {
const zone = $('#toasts');
if (!zone) return null;
const t = el('div', { className: 'toast brief ' + kind, role: 'status', textContent: text, title: 'Click to close' });
t.addEventListener('click', () => t.remove());
zone.append(t);
setTimeout(() => t.remove(), ms);
return t;
}
// ---- Aviso flotante: un agente nuevo, ¿en qué trabaja? ----
function askAgentTask(agentId) {
const a = Store.agent(agentId);
if (!a) return;
const zone = $('#toasts');
const toast = el('section', { className: 'toast', role: 'dialog', ariaLabel: T().toast.title, data: { agent: agentId } });
const close = () => toast.remove();
const name = el('input', { type: 'text', className: 'tname', value: a.name, ariaLabel: 'Agent name', autocomplete: 'off' });
name.addEventListener('input', () => { if (name.value.trim()) Actions.updateAgent(agentId, { name: name.value }); });
// Solo proyectos con tareas abiertas. Si el actual no tiene, se ofrecen los otros; si ninguno tiene, solo el nombre.
const openOf = (pid) => Store.tasksOf(pid).filter((t) => t.status !== 'done');
const withOpen = Store.get().projects.filter((p) => !p.archived && openOf(p.id).length);
const cur = Store.current();
const curHasOpen = !!cur && withOpen.some((p) => p.id === cur.id);
const tasks = el('div', { className: 'ttasks' });
const fill = (pid) => {
tasks.textContent = '';
for (const t of openOf(pid)) {
const b = btn(t.id + ' · ' + t.title, () => {
Actions.assignAgent(agentId, pid, t.id);
close();
if (!Store.current() || Store.current().id !== pid) Actions.selectProject(pid);
select({ kind: 'task', id: t.id });
}, { className: 'ttask', ariaLabel: 'Working on ' + t.id + ' ' + t.title });
b.style.setProperty('--c', Store.colorOf(pid, t.workflow));
tasks.append(b);
}
};
const parts = [];
// Prompt 018: si lo que edita apunta a una sola tarea abierta, se propone esa ("Looks like S3 — confirm?").
const guess = Agents.guessTask(a);
if (guess) {
const gt = Store.task(guess.projectId, guess.taskId);
parts.push(el('div', { className: 'tguess', data: { key: 'toast-guess-box' } },
el('p', { className: 'tq', textContent: T().toast.guess(gt.id) }),
btn(T().toast.confirm(gt.id, gt.title), () => {
if (guess.fromPrompt) Bridge.confirmPrompt(agentId);
else Actions.assignAgent(agentId, guess.projectId, gt.id);
close();
if (!Store.current() || Store.current().id !== guess.projectId) Actions.selectProject(guess.projectId);
select({ kind: 'task', id: gt.id });
}, { className: 'ttask go', ariaLabel: 'Yes, ' + a.name + ' is working on ' + gt.id + ' ' + gt.title, data: { key: 'toast-guess' } })));
}
if (withOpen.length) {
parts.push(el('p', { className: 'tq', textContent: guess ? T().toast.questionOther : T().toast.question }));
if (!curHasOpen) parts.push(el('p', { className: 'tnote', textContent: cur ? T().toast.otherProjects(cur.name) : T().toast.pickProject }));
if (withOpen.length > 1 || !curHasOpen) {
const pick = el('select', { className: 'tproj', ariaLabel: 'Project' });
for (const p of withOpen) pick.append(el('option', { value: p.id, textContent: p.name + ' (' + openOf(p.id).length + ' open)' }));
pick.value = curHasOpen ? cur.id : withOpen[0].id;
pick.addEventListener('change', () => fill(pick.value));
parts.push(pick);
}
parts.push(tasks);
fill(curHasOpen ? cur.id : withOpen[0].id);
} else parts.push(el('p', { className: 'tnote', textContent: T().toast.noOpenAnywhere }));
const icon = el('span', { className: 'aicon big' }, Agents.iconSvg(a, 24));
// A3: la primera vez que aparece un agente en una carpeta, también a qué proyecto pertenece (y se recuerda).
let linkRow = null;
if (a.cwd) {
const linked = Agents.linkOf(a.cwd);
if (linked) linkRow = el('p', { className: 'tnote', textContent: T().toast.linked(Store.project(linked.projectId).name) });
else {
const lsel = el('select', { className: 'tlinksel', ariaLabel: 'Project of this folder', data: { key: 'toast-link' } }, el('option', { value: '', textContent: T().toast.linkPick }), ...Store.get().projects.filter((p) => !p.archived).map((p) => el('option', { value: p.id, textContent: p.name })));
lsel.addEventListener('change', () => { if (lsel.value) { Actions.linkFolder(a.cwd, lsel.value); notify(T().toast.linkedNow(Store.project(lsel.value).name)); } });
linkRow = el('label', { className: 'tlink' }, el('span', { textContent: T().toast.linkQuestion }), lsel);
}
}
toast.append(
el('div', { className: 'thead' }, icon, el('div', {}, el('strong', { textContent: T().toast.title }), el('small', { textContent: T().toast.started }))),
el('p', { className: 'tfolder', title: a.cwd || '' }, el('b', { textContent: 'Folder: ' }), a.cwd || T().toast.noFolder),
...(linkRow ? [linkRow] : []), name, ...parts,
btn(withOpen.length ? T().toast.later : T().toast.ok, close, { className: withOpen.length ? 'linkish tlater' : 'ttask', ariaLabel: withOpen.length ? T().toast.later : T().toast.ok }),
);
zone.append(toast);
return toast;
}
// Prompt 018: la sesión de un agente terminó con su tarea en In progress y sin enviarla a revisión.
function sessionEndedAsk(agentId, projectId, taskId) {
const a = Store.agent(agentId), t = Store.task(projectId, taskId);
if (!a || !t) return null;
document.querySelectorAll('#toasts .toast[data-ended="' + agentId + '"]').forEach((n) => n.remove());
const toast = el('section', { className: 'toast', role: 'dialog', ariaLabel: T().toast.endedTitle, data: { ended: agentId, key: 'toast-ended' } });
const close = () => toast.remove();
const can = (what) => !(typeof Team !== 'undefined' && Team.on()) || Team.can(what);
toast.append(
el('div', { className: 'thead' }, el('span', { className: 'aicon big' }, Agents.iconSvg(a, 24)), el('div', {}, el('strong', { textContent: T().toast.endedTitle }), el('small', { textContent: a.name }))),
el('p', { className: 'tq', textContent: T().toast.ended(t.id) }),
el('p', { className: 'tnote', textContent: t.id + ' · ' + t.title }),
...(can('submit') ? [btn(T().toast.endedReview, () => { Actions.setTaskStatus(projectId, taskId, 'review'); close(); }, { className: 'ttask go', data: { key: 'ended-review' } })] : []),
btn(T().toast.endedKeep, close, { className: 'ttask', data: { key: 'ended-keep' } }),
...(can('status') ? [btn(T().toast.endedTodo, () => { Actions.unassignAgent(agentId); Actions.setTaskStatus(projectId, taskId, 'todo'); close(); }, { className: 'ttask', data: { key: 'ended-todo' } })] : []),
);
$('#toasts').append(toast);
return toast;
}
function closeAgentToast(agentId) { document.querySelectorAll('#toasts .toast[data-agent="' + agentId + '"]').forEach((n) => n.remove()); }
// Hace cuánto: "just now", "5 min", "3 h", "2 d".
function ago(ms) {
const m = Math.max(0, Math.round((Date.now() - Number(ms)) / 60000));
return m < 1 ? 'just now' : m < 60 ? m + ' min' : m < 60 * 24 ? Math.round(m / 60) + ' h' : Math.round(m / 1440) + ' d';
}
// Ir a una tarea o a un workflow de cualquier proyecto: cambia de proyecto, abre su tarjeta y vuela allí.
function goTo(projectId, item) {
if (!Store.project(projectId)) return;
// Lo que abarca todos los universos (bandeja, Now, Live, búsqueda, avisos, reportes): un clic salta al correcto.
if (multiUni() && item) {
const wid = item.kind === 'task' ? (Store.task(projectId, item.id) || {}).workflow : item.id;
const u = wid ? Store.universeOf(projectId, wid) : null;
if (u && u !== currentUniverse() || (u && Store.get().settings.universe !== u)) { const changed = u !== currentUniverse(); Actions.chooseUniverse(u); if (changed) jump(); }
}
focus = item;
if (LayoutHost.isNative()) Actions.chooseLayout('universe'); // desde Now o List, se va a la tarea en Universe
if (!Store.current() || Store.current().id !== projectId) Actions.selectProject(projectId);
select(item);
LayoutHost.focus(item.kind === 'task' ? { task: item.id } : { workflow: item.id });
}
// Lo que espera a quien mira: en Team, al dueño también las entregas en revisión.
const waitOn = () => {
if (!(Team.on() && Team.isEntered())) return Store.waitingOnYou({ reviews: false });
if (Team.isVisitor && Team.isVisitor()) return [];
const out = [];
for (const w of Team.waiting()) {
const p = Store.project(w.project), t = p && Store.task(p.id, w.task);
if (!t) continue;
const wf = Store.workflow(p.id, t.workflow);
const agent = w.kind === 'agent' || w.kind === 'needs' ? Store.agentOn(p.id, t.id) : null;
out.push({ project: p, task: t, agent: agent && agent.status === 'waiting' ? agent : null, open: 0, workflowName: wf ? wf.name : t.workflow, since: Date.parse(w.since) || 0, kind: w.kind,
review: w.kind === 'review', reason: w.kind === 'review' ? 'review' : agent && agent.status === 'waiting' ? 'agent' : 'age' });
}
return out;
};
// Agentes propios que esperan permiso o una respuesta de su dueño (A2): entran en su bandeja y salen solos.
const agentWaits = () => Store.visibleAgents().filter((a) => Actions.ownAgent(a) && a.activity && a.activity.waiting).sort((x, y) => (x.activity.since || 0) - (y.activity.since || 0));
function agentWaitRow(a, cls) {
const L = T().live, w = a.taskId && Store.project(a.projectId) && Store.task(a.projectId, a.taskId);
const what = a.activity.waiting === 'permission' ? L.needsPermission(a.name) : L.hasQuestion(a.name);
const b = btn('', () => { if (w) goTo(a.projectId, { kind: 'task', id: a.taskId }); else { const n = document.querySelector('[data-key="agent|' + a.id + '"]'); if (n) { n.scrollIntoView({ block: 'center' }); n.focus(); } } },
{ className: cls, data: { key: 'await|' + a.id }, ariaLabel: what + (w ? '. ' + w.id + ' ' + w.title : '') + '. Go to it' });
b.append(el('span', { className: 'ineed', textContent: what }), el('span', { className: 'imeta', textContent: w ? Store.project(a.projectId).name + ' · ' + w.id + ' ' + w.title : L.noTask }),
el('span', { className: 'itags' }, el('span', { className: 'itag why', textContent: L.agentTag }), el('span', { className: 'itag', textContent: a.activity.since ? 'waiting ' + ago(a.activity.since) : 'waiting' })));
return b;
}
// ---- Live (A2): lo que pasa ahora en todo el equipo, en orden de llegada ----
const live = [];
const lastLive = new Map();
function recordLive() {
for (const a of Store.visibleAgents()) {
if (!a.activity && a.source !== 'bridge') continue;
const l = Agents.liveLine(a);
const key = l.state + '|' + l.text.replace(/ · \d+ min/, '');
if (lastLive.get(a.id) === key) continue;
lastLive.set(a.id, key);
const owner = Team.on() ? Team.memberOf(a.owner) : null;
live.push({ at: Date.now(), agentId: a.id, name: a.name, owner: Team.on() ? (a.owner === Team.myId() ? 'You' : (owner ? owner.name : '')) : '', color: Team.on() ? ((owner && /^#[0-9A-Fa-f]{6}$/.test(owner.ship_color || '') && owner.ship_color) || '#FFD23F') : (/^#[0-9A-Fa-f]{6}$/.test(a.color) ? a.color : '#5B9DFF'), state: l.state, text: l.text, projectId: a.projectId, taskId: a.taskId });
if (live.length > 200) live.shift();
}
for (const id of [...lastLive.keys()]) if (!Store.visibleAgents().some((a) => a.id === id)) lastLive.delete(id);
if (document.querySelector('#panel.panel-live')) fillLive();
}
let liveList = null;
function fillLive() {
if (!liveList) return;
liveList.textContent = '';
if (!live.length) { liveList.append(el('li', { className: 'pempty', textContent: T().live.empty })); return; }
for (const e of live) {
const t = e.taskId && Store.project(e.projectId) ? Store.task(e.projectId, e.taskId) : null;
const dot = el('i', { className: 'ldot', ariaHidden: 'true' }); dot.style.setProperty('--c', e.color);
const li = el('li', { className: 'lvrow ' + e.state, data: { key: 'live|' + e.agentId + '|' + e.at } }, el('span', { className: 'htime', textContent: clock(e.at) }), dot,
el('span', { className: 'lvtext' }, el('b', { textContent: (e.owner ? e.owner + ' · ' : '') + e.name }), el('span', { textContent: ' ' + e.text })),
t ? btn(t.id + ' ' + t.title + uniTag(Store.universeOf(e.projectId, t.workflow)), () => { closePanel(); goTo(e.projectId, { kind: 'task', id: t.id }); }, { className: 'linkish lvtask' }) : el('small', { className: 'lvtask', textContent: T().live.noTask }));
liveList.append(li);
}
liveList.scrollTop = liveList.scrollHeight;
}
function openLive() {
if (!live.length) recordLive();
const root = openPanel('live', 'Live');
liveList = el('ul', { className: 'pn-list lvlist', role: 'log', ariaLive: 'polite' });
root.append(el('p', { textContent: T().live.hint }), liveList);
fillLive();
}
// ---- Bandeja "Waiting on you" (arriba en la barra izquierda) ----
// ---- Prompt 020 (E4): la tarjeta "Your wallet" (Member) o " budget" (Owner) ----
const MATS = [['crystal', 'Crystal'], ['iron', 'Iron'], ['gold', 'Gold']];
const matChips = (m, { all = false, key = '' } = {}) => el('div', { className: 'mats', data: key ? { key } : {} },
...MATS.filter(([k]) => all || m[k]).map(([k, name]) => el('span', { className: 'mat mat-' + k }, el('i', { ariaHidden: 'true' }), el('span', { textContent: (m[k] || 0) + ' ' + name }))));
const matsText = (m) => MATS.filter(([k]) => m[k]).map(([k, name]) => Math.abs(m[k]) + ' ' + name).join(', ');
const forPrice = (r) => ({ crystal: r / 5 * 2, iron: r / 5, gold: 0 });
const usd = (n) => (n < 0 ? '−$' : '$') + Math.abs(n).toLocaleString('en-US');
const BUDGET_SEGS = [['paid', 'Paid', '#7ED957'], ['committed', 'Committed', '#5B9DFF'], ['bonusLeft', 'Bonus pool', '#FFD23F'], ['reserve', 'Reserve', '#B28DFF'], ['free', 'Free', '#D9D4EE']];
function budgetBar(b) {
const total = Math.max(b.total, b.paid + b.committed + b.bonusLeft + b.reserve, 1);
const bar = el('div', { className: 'mbar', role: 'img', ariaLabel: BUDGET_SEGS.map(([k, n]) => n + ' ' + usd(b[k])).join(', ') });
for (const [k, n, c] of BUDGET_SEGS) { const v = Math.max(0, b[k]); if (!v) continue; const sp = el('span', { title: n + ' ' + usd(b[k]) }); sp.style.width = (100 * v / total) + '%'; sp.style.background = c; bar.append(sp); }
const legend = el('div', { className: 'mlegend', data: { key: 'budget-legend' } });
for (const [k, n, c] of BUDGET_SEGS) { const i = el('i', { ariaHidden: 'true' }); i.style.background = c; legend.append(el('span', {}, i, n + ' ', el('b', { textContent: usd(b[k]) }))); }
return [bar, legend];
}
function cashoutButton(w, cls = 'mbtn') {
const M = T().money, owner = Team.ownerName();
if (w.cashout) return el('button', { type: 'button', className: cls, ariaDisabled: 'true', textContent: M.asked(owner), data: { key: 'cashout' } });
if (w.amount < 20) return el('button', { type: 'button', className: cls, ariaDisabled: 'true', textContent: M.minimum, title: M.minimumWhy, data: { key: 'cashout' } });
return btn(M.cashOut(w.amount), () => Team.askCashout(), { className: cls, data: { key: 'cashout' } });
}
function renderMoney() {
const box = $('#money');
if (!box) return;
if (viewerUI()) { box.hidden = true; box.textContent = ''; return; } // 022 (R4): el Viewer no tiene tarjeta: solo el universo
const on = typeof Team !== 'undefined' && Team.on() && Team.hasMoney() && Team.money().loaded;
box.hidden = !on; box.textContent = '';
if (!on) return;
const M = T().money;
if (Team.role() === 'owner') {
const p = Store.current(), b = Team.money().budget;
if (p && Team.money().budgetPid !== p.id) Team.loadMoneySoon();
box.setAttribute('aria-label', M.budgetTitle(p ? p.name : ''));
box.append(btn('', openBudget, { className: 'mhead', data: { key: 'budget-open' }, ariaLabel: M.budgetTitle(p ? p.name : '') + (b && b.set ? ': ' + usd(b.total) : '') + '. Open' },
el('b', { textContent: M.budgetTitle(p ? p.name : '') }), el('span', { className: 'mbig', textContent: b && b.set ? usd(b.total) : '—' })));
if (b && b.set) box.append(...budgetBar(b));
else box.append(el('p', { className: 'msub', textContent: M.noBudget }));
const asked = Team.money().cashouts.filter((c) => c.status === 'asked');
if (asked.length) box.append(el('p', { className: 'msub', data: { key: 'cash-waiting' }, textContent: M.cashWaiting(asked.length) }));
return;
}
const w = Team.walletOf();
box.setAttribute('aria-label', M.walletTitle);
// 021 (G6): toda la tarjeta abre la wallet completa (no solo su título).
box.onclick = (e) => { if (!e.target.closest('button, a, input')) openWallet(); };
box.style.cursor = 'pointer';
box.append(btn('', () => openWallet(), { className: 'mhead', data: { key: 'wallet-open' }, ariaLabel: M.walletTitle + ': ' + usd(w.amount) + '. Open the full wallet' },
el('b', { textContent: M.walletTitle }), el('span', { className: 'mbig', data: { key: 'wallet-amount' }, textContent: usd(w.amount) })),
matChips(w, { all: true, key: 'wallet-mats' }),
el('p', { className: 'msub', data: { key: 'wallet-line' }, textContent: M.line(w) }),
cashoutButton(w));
}
// La wallet completa: saldo por material, en revisión, pagado, el próximo bono y el estado de cuenta.
function openWallet(uid) {
const M = T().money, mine = !uid || uid === Team.myId(), id = uid || Team.myId();
const draw = () => {
const root = openPanel('wallet', mine ? M.walletTitle : M.walletOf(Team.nameOf(id) || 'Someone'));
const w = Team.walletOf(id);
root.append(el('p', { className: 'mbig', data: { key: 'wp-amount' }, textContent: usd(w.amount) }), matChips(w, { all: true }),
el('div', { className: 'mgrid', data: { key: 'wp-grid' } },
el('span', { textContent: M.ready }), el('b', { textContent: usd(w.amount) }),
el('span', { textContent: M.inReview }), el('b', { textContent: usd(w.inReview) }),
el('span', { textContent: M.earned }), el('b', { textContent: usd(w.earned) }),
el('span', { textContent: M.cashedOut }), el('b', { textContent: usd(w.cashedOut) })),
el('p', { data: { key: 'wp-bonus' } }, el('span', { className: 'bdots', ariaHidden: 'true' }, ...[0, 1, 2, 3].map((i) => el('i', { className: i < 4 - w.toBonus ? 'on' : '' }))), M.bonusLine(w)));
if (w.poolEmpty) root.append(el('p', { className: 'k', textContent: M.poolEmpty }));
if (mine) root.append(cashoutButton(w, 'mbtn'), el('p', { className: 'k', textContent: w.cashout ? M.askedNote(Team.ownerName()) : M.cashNote(Team.ownerName()) }));
root.append(el('h3', { className: 'label', textContent: M.statement }));
const list = el('ul', { className: 'ledger', data: { key: 'wp-ledger' } });
for (const r of w.rows) list.append(ledgerRow(r));
if (!w.rows.length) list.append(el('li', { className: 'empty', textContent: M.noRows }));
root.append(list, el('p', { className: 'k', textContent: M.howPay }));
};
draw(); Team.setPanelRefresh(() => { if (document.querySelector('.panel-wallet')) draw(); });
}
function ledgerRow(r) {
const M = T().money;
const what = r.kind === 'pay' ? (r.task_id || '') + ' · ' + (r.task_title || '') + ' · ' + matsText(r) : r.kind === 'bonus' ? M.rowBonus(matsText(r)) : r.kind === 'bonus_empty' ? M.rowEmpty : M.rowCashout + ' · ' + matsText(r);
return el('li', { className: r.amount < 0 ? 'neg' : '', data: { key: 'ledger-row' } }, el('span', { textContent: new Date(r.at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }),
el('span', { textContent: what }), el('b', { textContent: r.kind === 'bonus_empty' ? '' : (r.amount > 0 ? '+' : '') + usd(r.amount) }));
}
// El presupuesto del proyecto (solo el dueño): reparto, fijarlo, cash outs por pagar, las wallets de cada uno y el CSV.
function openBudget() {
const M = T().money;
const draw = () => {
const p = Store.current(); if (!p) return;
const b = Team.money().budget || { total: 0, paid: 0, committed: 0, bonusPool: 60, bonusLeft: 60, reserve: 0, free: 0, set: false };
const root = openPanel('budget', M.budgetTitle(p.name));
root.append(...budgetBar(b));
if (b.set && b.free < 0) root.append(el('p', { className: 'rwarn', role: 'alert', textContent: M.freeNegative }));
if (!b.bonusLeft) root.append(el('p', { className: 'k', textContent: M.poolEmptyOwner }));
const num = (key, v, label) => el('label', { className: 'te-field' }, el('span', { textContent: label }), el('input', { type: 'number', min: '0', step: '1', className: 'pn-input', value: String(v), ariaLabel: label, data: { key } }));
root.append(el('h3', { className: 'label', textContent: M.setTitle }), el('div', { className: 'bud-in' }, num('bud-total', b.total, M.total), num('bud-pool', b.bonusPool, M.pool), num('bud-reserve', b.reserve, M.reserve)),
el('div', { className: 'pn-actions' }, btn(M.save, () => { const v = (k) => Math.max(0, Math.round(Number(root.querySelector('[data-key="' + k + '"]').value) || 0)); Team.setBudget(p.id, v('bud-total'), v('bud-pool'), v('bud-reserve')); }, { className: 'primary', data: { key: 'bud-save' } })));
const asked = Team.money().cashouts.filter((c) => c.status === 'asked');
root.append(el('h3', { className: 'label', textContent: M.payouts }));
if (!asked.length) root.append(el('p', { className: 'k', textContent: M.noPayouts }));
for (const c of asked) root.append(el('div', { className: 'cashrow', data: { key: 'cash|' + c.id } }, el('b', { textContent: c.user_name + ' · ' + usd(c.amount) }), el('span', { className: 'k', textContent: matsText(c) + ' · ' + M.askedAgo(ago(new Date(c.asked_at).getTime())) }),
btn(M.markPaid, () => Team.markCashoutPaid(c.id), { className: 'dbtn go', data: { key: 'cash-paid|' + c.id } })));
root.append(el('p', { className: 'k', textContent: M.payOutside }), el('h3', { className: 'label', textContent: M.people }));
const list = el('ul', { className: 'ledger' });
for (const m of Team.members().filter((x) => x.role !== 'viewer')) {
const w = Team.walletOf(m.user_id);
list.append(el('li', {}, el('span', { textContent: m.role === 'owner' ? 'Team lead' : 'Member' }), el('span', { textContent: m.name + ' · ' + M.short(w, matsText(w)) }), btn(usd(w.amount), () => openWallet(m.user_id), { className: 'dbtn small', ariaLabel: 'Open the wallet of ' + m.name, data: { key: 'wallet-of|' + m.user_id } })));
}
root.append(list, el('div', { className: 'pn-actions' }, btn(M.csv, async () => { const csv = await Team.statementCsv(); if (csv) download('tasky-statement-' + new Date().toISOString().slice(0, 10) + '.csv', csv, 'text/csv;charset=utf-8'); }, { data: { key: 'ledger-csv' } })));
};
draw(); Team.setPanelRefresh(() => { if (document.querySelector('.panel-budget')) draw(); });
}
// Prompt 020 (E5): ¿la pantalla nueva? Solo en Team, con la sesión abierta. El modo Personal conserva la suya.
const teamUI = () => typeof Team !== 'undefined' && Team.on() && Team.isEntered();
let waitIdx = 0, hmenu = null;
function renderInbox() {
const box = $('#inbox');
if (!box) return;
if (teamUI()) return renderWaitCarousel(box);
const rows = waitOn(), waits = agentWaits();
const cashWaits = typeof Team !== 'undefined' && Team.on() && Team.role() === 'owner' && Team.hasMoney() ? Team.money().cashouts.filter((c) => c.status === 'asked') : [];
const open = Store.get().settings.inboxOpen !== false;
box.textContent = '';
const n = rows.length + waits.length + cashWaits.length;
const head = btn('', () => Actions.setPreference('inboxOpen', !open), { className: 'ibox-head' + (n ? ' has' : ''), ariaExpanded: String(open),
ariaLabel: 'Waiting on you: ' + n + (n === 1 ? ' item' : ' items') + (open ? '. Click to fold' : '. Click to unfold'), title: T().inbox.title, data: { key: 'inbox' } });
head.append(el('span', { textContent: T().inbox.name }), el('span', { className: 'ibox-count', textContent: String(n) }), el('span', { className: 'chev', ariaHidden: 'true', textContent: open ? '▾' : '▸' }));
box.append(head);
if (!open) return;
if (!n) { box.append(el('p', { className: 'iempty', textContent: T().inbox.empty })); return; }
const list = el('ul', { className: 'ibox-list' });
for (const a of waits) list.append(el('li', {}, agentWaitRow(a, 'irow agentwait')));
for (const c of cashWaits) {
const b = btn('', openBudget, { className: 'irow', data: { key: 'inbox|cash|' + c.id }, ariaLabel: T().money.cashTitle(c.user_name, usd(c.amount)) + '. ' + T().money.cashSub });
b.append(el('span', { className: 'ineed', textContent: T().money.cashTitle(c.user_name, usd(c.amount)) }), el('span', { className: 'imeta', textContent: T().money.cashSub }));
list.append(el('li', {}, b));
}
for (const r of rows) {
const why = r.reason === 'review' ? T().inbox.review(Team.nameOf(r.task.assignee) || '') : r.reason === 'agent' ? T().inbox.agent(r.agent.name) : r.reason === 'open' ? T().inbox.open(r.open, r.workflowName) : T().inbox.age;
const b = btn('', () => goTo(r.project.id, { kind: 'task', id: r.task.id }), { className: 'irow', data: { key: 'inbox|' + r.project.id + '|' + r.task.id },
ariaLabel: (r.task.needs || T().inbox.noText) + '. ' + r.project.name + ', ' + r.task.id + ' ' + r.task.title + '. Waiting ' + (r.since ? ago(r.since) : '') + '. ' + why });
b.append(el('span', { className: 'ineed', textContent: r.review ? T().inbox.reviewTitle(r.task.title) : r.task.needs || T().inbox.noText }),
el('span', { className: 'imeta', textContent: r.project.name + uniTag(Store.universeOf(r.project.id, r.task.workflow)) + ' · ' + r.task.id + ' ' + r.task.title }),
el('span', { className: 'itags' }, el('span', { className: 'itag why', textContent: why }), el('span', { className: 'itag', textContent: r.since ? 'waiting ' + ago(r.since) : 'waiting' })));
list.append(el('li', {}, b));
}
box.append(list);
}
// "Waiting on you" (E5): un solo elemento a la vez, flechas a los lados y "2 of 6". Los renglones son los de la bandeja
// (mismas claves): Owner: entregas por revisar, needs-you, cash outs, agentes esperando (y sugerencias, E6).
// Member: lo que el dueño le pidió revisar, sus tareas en curso y agentes esperando.
function waitNodes() {
const nodes = [];
for (const a of agentWaits()) nodes.push(agentWaitRow(a, 'irow agentwait'));
for (const r of waitOn()) {
if (r.kind === 'check' || r.kind === 'doing') {
const t = r.task, W = T().wait;
nodes.push(btn('', () => goTo(t.projectId, { kind: 'task', id: t.id }), { className: 'irow', data: { key: 'inbox|' + t.projectId + '|' + t.id } },
el('span', { className: 'ineed', textContent: r.kind === 'check' ? W.check(Team.ownerName()) : W.yours(t.title) }),
el('span', { className: 'imeta', textContent: t.id + ' · ' + (r.kind === 'check' ? String(t.backComment || '').slice(0, 90) : W.inProgress + (Store.rewardOf(t) ? ' · $' + Store.rewardOf(t) + ' when approved' : '')) })));
continue;
}
const why = r.reason === 'review' ? T().inbox.review(Team.nameOf(r.task.assignee) || '') : r.reason === 'agent' && r.agent ? T().inbox.agent(r.agent.name) : r.reason === 'open' ? T().inbox.open(r.open, r.workflowName) : T().inbox.age;
const b = btn('', () => goTo(r.project.id, { kind: 'task', id: r.task.id }), { className: 'irow', data: { key: 'inbox|' + r.project.id + '|' + r.task.id },
ariaLabel: (r.review ? T().inbox.reviewTitle(r.task.title) : r.task.needs || T().inbox.noText) + '. ' + r.project.name + ', ' + r.task.id + ' ' + r.task.title + '. ' + why });
b.append(el('span', { className: 'ineed', textContent: r.review ? T().inbox.reviewTitle(r.task.title) : r.task.needs || T().inbox.noText }),
el('span', { className: 'imeta', textContent: r.task.id + ' ' + r.task.title + (Store.rewardOf(r.task) ? ' · $' + Store.rewardOf(r.task) : '') + ' · ' + why + (r.since ? ' · ' + ago(r.since) : '') }));
nodes.push(b);
}
if (Team.role() === 'owner' && Team.hasMoney()) for (const c of Team.money().cashouts.filter((x) => x.status === 'asked')) {
const amt = '$' + c.amount;
nodes.push(btn('', openBudget, { className: 'irow', data: { key: 'inbox|cash|' + c.id }, ariaLabel: T().money.cashTitle(c.user_name, amt) + '. ' + T().money.cashSub },
el('span', { className: 'ineed', textContent: T().money.cashTitle(c.user_name, amt) }), el('span', { className: 'imeta', textContent: T().money.cashSub })));
}
if (Team.role() === 'owner') for (const sg of Team.suggestions().filter((x) => x.status === 'open')) {
nodes.push(btn('', () => openSuggestion(sg.id), { className: 'irow', data: { key: 'inbox|sug|' + sg.id } },
el('span', { className: 'ineed', textContent: T().sug.waitTitle(sg.author_name, T().sug.kinds[sg.kind]) }), el('span', { className: 'imeta', textContent: (sg.body || sg.title).slice(0, 110) })));
}
if (Team.role() !== 'owner') for (const sg of Team.suggestions().filter((x) => x.status === 'accepted' && x.task_id)) {
const t = Store.task(sg.project_id, sg.task_id);
if (!t || t.status === 'done' || (t.assignee && t.assignee !== Team.myId())) continue;
nodes.push(btn('', () => goTo(sg.project_id, { kind: 'task', id: t.id }), { className: 'irow', data: { key: 'inbox|sugtask|' + sg.id } },
el('span', { className: 'ineed', textContent: T().sug.became }), el('span', { className: 'imeta', textContent: t.id + ' · ' + t.title + (Store.rewardOf(t) ? ' · $' + Store.rewardOf(t) : '') })));
}
return nodes;
}
function renderWaitCarousel(box) {
const nodes = waitNodes(), n = nodes.length, W = T().wait;
if (waitIdx >= n) waitIdx = Math.max(0, n - 1);
box.textContent = '';
box.append(el('div', { className: 'whead' }, el('span', { textContent: T().inbox.name }), el('span', { className: 'wcount', data: { key: 'wait-count' }, textContent: String(n) }),
el('span', { className: 'wpos', data: { key: 'wait-pos' }, textContent: n ? W.pos(waitIdx + 1, n) : '' })));
if (!n) { box.append(el('p', { className: 'iempty', textContent: Team.role() === 'owner' ? W.emptyOwner : W.emptyMember })); return; }
const arrow = (dir) => btn('', () => { waitIdx = (waitIdx + dir + n) % n; renderInbox(); }, { className: 'ar', ariaLabel: dir < 0 ? 'Previous' : 'Next', disabled: n < 2, data: { key: dir < 0 ? 'wait-prev' : 'wait-next' } },
svg(dir < 0 ? ''
: ''));
box.append(el('div', { className: 'wcar' }, arrow(-1), nodes[waitIdx], arrow(1)));
}
// ---- Sugerencias (E6): "Suggest something" (herramienta o tarjeta de un workflow) y la decisión del dueño ----
function openSuggest(workflow = '', opts = {}) {
const S = T().sug, p = Store.current();
if (!p) return;
let kind = opts.kind || 'task', sent = false;
const root = openPanel('suggest', S.title);
const pills = el('div', { className: 'sgkinds', role: 'radiogroup', ariaLabel: S.kindLabel });
const drawPills = () => { pills.textContent = ''; for (const k of ['task', 'design', 'idea', 'finding']) { const b = btn(S.kinds[k], () => { kind = k; drawPills(); }, { className: 'hbtn' + (kind === k ? ' on' : ''), data: { key: 'sg-kind|' + k } }); b.setAttribute('role', 'radio'); b.setAttribute('aria-checked', String(kind === k)); pills.append(b); } };
drawPills();
const text = el('textarea', { className: 'pn-input te-area', rows: 4, maxLength: 2000, placeholder: S.placeholder, ariaLabel: S.textLabel, data: { key: 'sg-text' } });
const wf = el('select', { className: 'hsel', ariaLabel: S.wfLabel, data: { key: 'sg-wf' } }, el('option', { value: '', textContent: S.anyWf }), ...Store.workflowsOf(p.id).map((w) => el('option', { value: w.id, textContent: w.id + ' · ' + w.name })));
wf.value = workflow || '';
const files = el('input', { type: 'file', multiple: true, className: 'pn-input', ariaLabel: S.filesLabel, data: { key: 'sg-files' } });
// 021 (G11): un hallazgo puede decir de qué tarea es.
const about = el('select', { className: 'hsel', ariaLabel: 'About task', data: { key: 'sg-about' } }, el('option', { value: '', textContent: 'No task in particular' }), ...Store.tasksOf(p.id).map((t) => el('option', { value: t.id, textContent: t.id + ' · ' + t.title })));
about.value = opts.aboutTask || '';
const send = btn(S.send, async () => {
if (sent) return;
if (!text.value.trim()) { text.focus(); notify(S.empty); return; }
send.disabled = true;
const r = await Team.suggest({ kind, text: text.value, workflow: wf.value, projectId: p.id, files: files.files, aboutTask: kind === 'finding' ? about.value : '' });
send.disabled = false;
if (r.ok) { sent = true; send.textContent = S.sent(Team.ownerName()); send.setAttribute('aria-disabled', 'true'); notify(S.sentNote(Team.ownerName())); }
}, { className: 'primary', data: { key: 'sg-send' } });
const field = (label, input) => el('label', { className: 'te-field' }, el('span', { textContent: label }), input);
root.append(el('p', { className: 'k', textContent: S.intro(Team.ownerName()) }), pills, field(S.textLabel, text), field(S.wfLabel, wf), field('About task (for a finding)', about), field(S.filesLabel, files), el('div', { className: 'pn-actions' }, send));
text.focus();
}
function openSuggestion(id) {
const S = T().sug;
const draw = () => {
const sg = Team.suggestions().find((x) => x.id === id);
const root = openPanel('suggestion', S.fromTitle(sg ? sg.author_name : ''));
if (!sg) { root.append(el('p', { className: 'pempty', textContent: S.gone })); return; }
root.append(el('p', { className: 'k', textContent: S.kinds[sg.kind] + ' · ' + ago(new Date(sg.created_at).getTime()) + (sg.source === 'handoff' ? ' · ' + S.fromHandoff : '') }), el('p', { className: 'sgtext', data: { key: 'sg-body' }, textContent: sg.body || sg.title }));
if ((sg.files || []).length) {
const ul = el('ul', { className: 'pn-list' });
for (const path of sg.files) ul.append(el('li', {}, btn(path.replace(/^.*\/[0-9a-f-]{36}-/, ''), async () => { const u = await Team.suggestionFileUrl(path); if (u) { const a = el('a', { href: u, rel: 'noopener', hidden: true }); document.body.append(a); a.click(); a.remove(); } else notify(S.fileGone); }, { className: 'linkish', data: { key: 'sg-file' } })));
root.append(el('p', { className: 'k', textContent: S.files }), ul);
}
if (sg.status !== 'open') { root.append(el('p', { className: 'k', data: { key: 'sg-state' }, textContent: sg.status === 'accepted' ? S.accepted(sg.task_id) : S.declined })); return; }
if (Team.role() !== 'owner') { root.append(el('p', { className: 'k', textContent: S.waiting(Team.ownerName()) })); return; }
const p = Store.project(sg.project_id) || Store.current();
const title = el('input', { type: 'text', className: 'pn-input', value: sg.title, maxLength: 300, ariaLabel: S.taskTitle, data: { key: 'sg-title' } });
const wf = el('select', { className: 'hsel', ariaLabel: S.wfLabel, data: { key: 'sg-make-wf' } }, ...Store.workflowsOf(p.id).map((w) => el('option', { value: w.id, textContent: w.id + ' · ' + w.name })));
if (sg.workflow && Store.workflow(p.id, sg.workflow)) wf.value = sg.workflow;
let reward = 10;
const sizes = el('div', { className: 'sgkinds', role: 'radiogroup', ariaLabel: S.price });
const go = btn('', () => {}, { className: 'primary', data: { key: 'sg-make-go' } });
const drawSizes = () => { sizes.textContent = ''; for (const [n, v] of [['No price', 0], ['Small $5', 5], ['Medium $10', 10], ['Large $20', 20]]) { const b = btn(n, () => { reward = v; drawSizes(); }, { className: 'hbtn' + (reward === v ? ' on' : ''), data: { key: 'sg-price|' + v } }); b.setAttribute('role', 'radio'); b.setAttribute('aria-checked', String(reward === v)); sizes.append(b); } go.textContent = reward ? S.addTask(reward) : S.addTaskFree; };
drawSizes();
go.onclick = async () => { go.disabled = true; const r = await Team.decideSuggestion(sg.id, 'task', { workflow: wf.value, reward, title: title.value }); go.disabled = false; if (r.ok) { closePanel(); notify(S.made(r.data.task)); goTo(p.id, { kind: 'task', id: r.data.task }); } };
const field = (label, input) => el('label', { className: 'te-field' }, el('span', { textContent: label }), input);
root.append(el('h3', { className: 'label', textContent: S.makeTitle }), field(S.taskTitle, title), field(S.wfLabel, wf), el('p', { className: 'k', textContent: S.price }), sizes,
el('div', { className: 'pn-actions' }, go, btn(S.notNow, async () => { const r = await Team.decideSuggestion(sg.id, 'not_now'); if (r.ok) { closePanel(); notify(S.thanks); } }, { data: { key: 'sg-decline' } })));
};
draw(); Team.setPanelRefresh(() => { if (document.querySelector('.panel-suggestion')) draw(); });
}
// ---- "Online now" (E9, Viewer): una ficha por nave conectada; un clic y la cámara la sigue ----
function renderOnline() {
const box = $('#online');
if (!box) return;
box.hidden = !viewerUI();
box.textContent = '';
if (box.hidden) return;
const ppl = Team.people();
box.append(el('h2', { className: 'label', data: { key: 'online-count' }, textContent: 'Online now · ' + (ppl.length + 1) }));
const wrap = el('div', { className: 'onl' });
const me = Team.me();
const chip = (who, id, follow) => { const b = btn('', follow, { className: 'onchip', title: 'Follow this ship', data: { key: 'online|' + id } }, Ships.icon({ ship_shape: who.shape || who.ship_shape, ship_color: who.color || who.ship_color }, 16), el('span', { textContent: who.name })); return b; };
if (me) wrap.append(chip({ name: me.name + ' (you)', shape: me.ship_shape, color: me.ship_color }, 'me', () => LayoutHost.focus({ ship: Team.myId() })));
for (const x of ppl) wrap.append(chip(x, x.userId, () => LayoutHost.focus({ ship: x.userId })));
box.append(wrap);
}
// ---- La fila de arriba (E5): proyecto, "28 of 88 done", búsqueda, Filter ▾, View ▾, Tour ----
const TEAM_FILTERS = [
{ id: 'open', test: (p, t) => !t.assignee && t.status !== 'done' && t.status !== 'review' && !Store.lockOf(p.id, t) && !Store.notReady(t) && (Team.role() === 'owner' || Store.rewardOf(t) > 0) },
{ id: 'mine', test: (p, t) => t.assignee === Team.myId() },
{ id: 'review', test: (p, t) => t.status === 'review' },
{ id: 'locked', test: (p, t) => !!Store.lockOf(p.id, t) },
{ id: 'done', test: (p, t) => t.status === 'done' },
{ id: 'detail', test: (p, t) => Store.needsDetail(t) },
];
function menu(kind, items, anchor) {
const m = el('div', { className: 'hmenu', role: 'menu', data: { key: kind + '-list' } });
for (const it of items) {
if (it.sep) { m.append(el('div', { className: 'hsep', role: 'separator' })); continue; }
const b = btn(it.name, () => { hmenu = null; it.run(); render(); }, { data: { key: it.key }, title: it.title || '' });
b.setAttribute('role', it.checked == null ? 'menuitem' : 'menuitemradio');
if (it.checked != null) b.setAttribute('aria-checked', String(!!it.checked));
m.append(b);
}
requestAnimationFrame(() => { if (anchor && anchor.isConnected) { m.style.left = Math.max(0, anchor.offsetLeft + anchor.offsetWidth - Math.max(m.offsetWidth, 190)) + 'px'; } });
return m;
}
function renderHeadTeam(head) {
const p = Store.current();
if (!p) return;
const c = Store.counts(p.id), F = T().filters;
head.append(btn(p.name, () => { if (!Team.can('editProjects')) { notify(T().team.ownerOnly); return; } openProject(p.id); }, { className: 'ptitle', ariaLabel: p.name + ': project actions', title: T().projectTitle, data: { key: 'ptitle' } }));
const live = Store.get().projects.filter((x) => !x.archived);
if (live.length > 1) head.append(btn('▾', () => { hmenu = hmenu === 'projects' ? null : 'projects'; render(); }, { className: 'hbtn', ariaLabel: 'Switch project', ariaExpanded: String(hmenu === 'projects'), data: { key: 'proj-menu' } }));
head.append(el('span', { className: 'count', textContent: c.done + ' of ' + c.total + ' done' }));
if (!searchBox) renderBar();
head.append(searchBox);
const fName = filterId ? F[filterId] : F.all;
const fb = btn(fName + ' ▾', () => { hmenu = hmenu === 'filter' ? null : 'filter'; render(); }, { className: 'hbtn' + (filterId ? ' on' : ''), ariaExpanded: String(hmenu === 'filter'), ariaLabel: 'Filter: ' + fName, title: F.title, data: { key: 'filter-menu' } });
const curView = LayoutHost.current() ? LayoutHost.current() : null;
const vb = btn((curView ? curView.name : 'View') + ' ▾', () => { hmenu = hmenu === 'view' ? null : 'view'; render(); }, { className: 'hbtn', ariaExpanded: String(hmenu === 'view'), ariaLabel: 'View: ' + (curView ? curView.name : ''), data: { key: 'view-menu' } });
head.append(fb, vb);
if (curView && curView.id === 'universe') head.append(btn('Tour', startTour, { className: 'hbtn', title: T().tour.title, ariaLabel: 'Start the tour', data: { key: 'tour' } }));
// 021 (G8): el interruptor de visitantes, en la barra del líder.
if (Team.role() === 'owner') { const on = Team.viewersOn(); head.append(btn('Viewers: ' + (on ? 'on' : 'off'), () => Team.setViewersOn(!on), { className: 'hbtn vchip' + (on ? ' on' : ''), ariaPressed: String(on), title: on ? 'Anyone can look around from the sign-in screen. Click to turn it off.' : 'Click to let anyone look around (text only, nothing to change).', data: { key: 'viewers-chip' } })); }
// 021 (G10): un regalo dorado «Bonus» cuando hay un giro de ruleta esperando.
if (Team.role() !== 'viewer' && Team.spins().some((x) => x.status === 'ready' && x.user_id === Team.myId())) head.append(btn('', () => openWheel(), { className: 'hbtn giftbtn', ariaLabel: 'Bonus: open your gift and spin the wheel', data: { key: 'bonus-gift' } }, svg('', 'gicon'), el('span', { textContent: 'Bonus' })));
// 021 (G6): el saldo de la wallet, siempre a la vista; abre la wallet completa.
if (Team.role() === 'member' && Team.hasMoney() && Team.money().loaded) { const w = Team.walletOf(); head.append(btn(usd(w.amount), () => openWallet(), { className: 'hbtn wchip', title: 'Your wallet', ariaLabel: 'Your wallet: ' + usd(w.amount) + '. Open', data: { key: 'wallet-chip' } })); }
if (hmenu === 'filter') head.append(menu('filter', [{ id: null }, ...TEAM_FILTERS.filter((x) => !(Team.role() === 'viewer' && (x.id === 'open' || x.id === 'mine')) && !(x.id === 'detail' && Team.role() !== 'owner'))].map((x) => ({ name: x.id ? F[x.id] : F.all, key: 'tfilter|' + (x.id || 'all'), checked: filterId === x.id || (!filterId && !x.id), run: () => { filterId = x.id || null; LayoutHost.push(); } })), fb));
if (hmenu === 'view') {
const items = LayoutHost.list().map((l) => ({ name: l.name, key: 'view|' + l.id, checked: !!curView && curView.id === l.id, title: T().views[l.id] || T().views.own, run: () => Actions.chooseLayout(l.id) }));
head.append(menu('view', [...items, { sep: true }, { name: 'Upload a layout…', key: 'view|+', title: T().views.add, run: addLayout }], vb));
}
if (hmenu === 'projects') head.append(menu('proj', live.map((x) => ({ name: x.name, key: 'proj|' + x.id, checked: x.id === p.id, run: () => Actions.selectProject(x.id) })), head.querySelector('[data-key="proj-menu"]')));
}
// ---- La segunda fila (E5): Universe a la izquierda; a la derecha Prompts ▾ (E7) y las herramientas como iconos ----
function renderRow2() {
const row = $('#row2');
if (!row) return;
row.hidden = !teamUI() || !Store.current();
if (row.hidden) return;
if (!row.dataset.built) {
row.dataset.built = '1';
const nav = $('#universes');
row.append(nav, el('span', { className: 'r2grow' }), el('div', { id: 'prompts-slot' }), el('div', { id: 'tool-icons', className: 'ticons', role: 'toolbar', ariaLabel: 'Tools' }),
btn('Panels', () => document.body.classList.toggle('right-open'), { className: 'hbtn rtoggle', ariaLabel: 'Show or hide the right column', data: { key: 'right-toggle' } }));
}
}
// "Edit my ship" (E9): con cuenta, en su perfil; el visitante, en su navegador.
function openShipPicker() {
const root = openPanel('ship', 'Edit my ship');
if (!Team.isVisitor()) { root.append(Ships.picker(Team.me(), () => closePanel())); return; }
const me = Team.me();
let shape = me.ship_shape, color = me.ship_color;
const shapes = el('div', { className: 'ship-opts', role: 'radiogroup', ariaLabel: 'Shape' }), colors = el('div', { className: 'ship-opts', role: 'radiogroup', ariaLabel: 'Color' });
const draw = () => {
shapes.textContent = ''; colors.textContent = '';
for (const sh of Ships.SHAPES) { const b = btn('', () => { shape = sh; draw(); }, { className: 'ship-opt' + (shape === sh ? ' on' : ''), ariaLabel: sh, data: { key: 'vship|' + sh } }, Ships.icon({ ship_shape: sh, ship_color: color }, 26)); b.setAttribute('aria-checked', String(shape === sh)); shapes.append(b); }
for (const c of ['#E8E4FF', '#BFD4FF', '#FFD9C2', '#C8F5D8', '#FFE6A8', '#F9C6E4', '#FFD23F', '#3EE0CF']) { const b = btn('', () => { color = c; draw(); }, { className: 'ship-opt color' + (color === c ? ' on' : ''), ariaLabel: c, data: { key: 'vcolor|' + c } }); b.style.background = c; colors.append(b); }
};
draw();
root.append(shapes, colors, el('div', { className: 'pn-actions' }, btn('Save ship', () => { Team.setVisitorShip(shape, color); closePanel(); }, { className: 'primary', data: { key: 'vship-save' } })));
}
// "Prompts ▾" (E7): mensajes para el agente de Claude Code, con "When:" y "Copy". No van atados a una tarea: el
// agente dice en qué trabaja. El primero, resaltado. Con el puente prendido, el comando lleva su ruta completa.
let promptsOpen = false, promptCopied = -1;
function promptList() {
const P = T().prompts, cli = typeof Bridge !== 'undefined' && Bridge.diag && Bridge.diag().cli ? 'node "' + Bridge.diag().cli + '"' : 'node puente/tasky.mjs';
const list = P.items.filter((x) => !x.owner || Team.role() === 'owner');
return list.map((x) => ({ ...x, text: x.text.split('{{tasky}}').join(cli) }));
}
let promptPreview = -1;
function renderPrompts() {
const slot = document.getElementById('prompts-slot');
if (!slot) return;
slot.textContent = '';
if (!teamUI() || Team.role() === 'viewer') return;
const P = T().prompts;
const b = btn('', () => { promptsOpen = !promptsOpen; renderPrompts(); }, { className: 'hbtn', ariaExpanded: String(promptsOpen), ariaLabel: 'Prompts for your agent', data: { key: 'prompts' } },
svg('', 'picon'), el('span', { textContent: 'Prompts ▾' }));
slot.append(b);
if (!promptsOpen) return;
const box = el('div', { className: 'pmenu', role: 'dialog', ariaLabel: 'Prompts for your agent', data: { key: 'prompts-list' } }, el('p', { className: 'pintro' }, P.intro, el('b', { textContent: P.first })));
promptList().forEach((x, i) => {
const copy = btn(promptCopied === i ? 'Copied' : 'Copy', async () => { if (await copyText(x.text)) { promptCopied = i; renderPrompts(); setTimeout(() => { if (promptCopied === i) { promptCopied = -1; renderPrompts(); } }, 2000); } }, { className: 'pcopy' + (promptCopied === i ? ' done' : ''), data: { key: 'prompt-copy|' + i } });
// 021 (G6): el texto no se ve de entrada; "Preview" lo muestra debajo, de solo lectura, y lo vuelve a cerrar.
const open = promptPreview === i;
const prev = btn(open ? 'Hide' : 'Preview', () => { promptPreview = open ? -1 : i; renderPrompts(); }, { className: 'pcopy ppreview', ariaExpanded: String(open), ariaLabel: (open ? 'Hide ' : 'Preview ') + x.name, data: { key: 'prompt-preview|' + i } });
box.append(el('div', { className: 'pitem prow' + (i === 0 ? ' first' : ''), data: { key: 'prompt|' + i } },
el('div', { className: 'prow-main' }, el('span', { className: 'pname', textContent: x.name }), el('div', { className: 'pwhen' }, el('b', { textContent: 'When: ' }), x.when)),
el('div', { className: 'prow-btns' }, copy, prev),
open ? el('pre', { className: 'ptext', tabIndex: 0, ariaLabel: 'Text of ' + x.name + ' (read only)', textContent: x.text }) : null));
});
slot.append(box);
}
// "My work" (E5, de Economy.dc.html): lo que tienes, lo que puedes tomar y lo que está bloqueado, con precio y "Done when".
function renderMyWork(box) {
if (!box) return;
box.textContent = '';
const p = Store.current();
if (!p || !Team.on()) { box.append(el('p', { className: 'pempty', textContent: T().mywork.teamOnly })); return; }
const M = T().mywork, me = Team.myId(), tasks = Store.tasksOf(p.id);
const open = TEAM_FILTERS[0].test;
const groups = [
[M.mine, tasks.filter((t) => t.assignee === me && t.status !== 'done'), 'mine'],
[M.next, tasks.filter((t) => open(p, t)), 'next'],
[M.locked, tasks.filter((t) => !!Store.lockOf(p.id, t)), 'locked'],
];
const sel = selectedForLayout();
for (const [name, rows, key] of groups) {
const sec = el('section', { className: 'mwgroup', data: { key: 'mw-' + key } }, el('h3', { textContent: name + ' · ' + rows.length }));
const ul = el('ul', { className: 'mwlist' });
if (!rows.length) sec.append(el('p', { className: 'pempty', textContent: M.none[key] }));
for (const t of rows) {
const lock = Store.lockOf(p.id, t), reward = Store.rewardOf(t);
const sub = lock ? lock.text : t.assignee === me ? T().cycle.status[t.status] + ' · ' + T().cycle.yours : t.assignee ? T().cycle.status[t.status] + ' · ' + (Team.nameOf(t.assignee) || '') : T().cycle.status[t.status];
const b = btn('', () => select({ kind: 'task', id: t.id }), { className: 'mwrow' + (lock ? ' locked' : ''), data: { key: 'mw|' + t.id }, ariaLabel: t.id + ' ' + t.title + ', ' + sub + (reward ? ', $' + reward : '') },
el('span', { className: 'dot' }), el('span', { className: 'mwid', textContent: t.id }), el('span', { className: 'mwt' }, el('b', { textContent: t.title }), el('small', { textContent: sub })),
el('span', { className: 'mwp', textContent: reward ? '$' + reward : '' }));
b.style.setProperty('--c', Store.colorOf(p.id, t.workflow));
b.setAttribute('aria-current', String(!!sel && sel.kind === 'task' && sel.id === t.id));
if (t.doneWhen) b.append(el('span', { className: 'mwdone', textContent: M.doneWhen + ': ' + t.doneWhen }));
ul.append(el('li', {}, b));
}
sec.append(ul);
box.append(sec);
}
}
// ---- Historia ----
// Una línea en inglés por entrada; quién la hizo va aparte.
const whoName = (w, e) => {
if (e && Team.on() && e.agentAct && w && w !== 'you') return String(w) + ' · ' + (e.actor === Team.myId() ? 'you' : e.actorName || Team.nameOf(e.actor) || 'someone');
if (e && Team.on() && e.actor && e.actor === Team.myId() && !e.agentAct && w !== 'handoff') return 'You';
return w === 'you' ? 'You' : w === 'handoff' ? 'Handoff' : String(w || 'You');
};
function describe(e) {
const S = T().status, P = T().projectStatus;
const task = (e.taskId ? e.taskId + (e.title ? ' ' + e.title : '') : '');
switch (e.kind) {
case 'status': return task + ': ' + (S[e.from] || e.from) + ' → ' + (S[e.to] || e.to) + (e.review === 'approved' ? ' (approved)' : e.review === 'sent back' ? ' (sent back: “' + e.comment + '”)' : e.to === 'review' && e.note ? ' (“' + e.note + '”)' : '');
case 'import': return e.created ? 'Imported ' + (e.projectName || 'a project') + ' (new, ' + e.total + (e.total === 1 ? ' task)' : ' tasks)') : 'Imported a handoff for ' + (e.projectName || 'a project') + ': ' + (e.changes || []).length + ((e.changes || []).length === 1 ? ' task changed' : ' tasks changed');
case 'rename': return 'Renamed the project “' + e.from + '” → “' + e.to + '”';
case 'projectStatus': return (e.projectName || 'Project') + ': ' + (P[e.from] || e.from) + ' → ' + (P[e.to] || e.to);
case 'archive': return e.to ? 'Archived ' + (e.projectName || 'a project') : 'Brought ' + (e.projectName || 'a project') + ' back from the archive';
case 'delete': return 'Deleted ' + (e.projectName || 'a project') + ' (' + e.tasks + (e.tasks === 1 ? ' task)' : ' tasks)');
case 'duplicate': return 'Duplicated ' + e.from + ' as ' + (e.projectName || 'a copy');
case 'assign': return e.removed ? e.agentName + ' left ' + task : e.agentName + ' → ' + task;
case 'agentIn': return e.isNew ? e.agentName + ' appeared (a new Claude Code conversation)' : e.agentName + ' is back';
case 'agentOut': return e.agentName + ' left (its conversation closed)';
case 'agentDelete': return 'Deleted the agent ' + e.agentName;
case 'note': return task + ': note: “' + (e.note || '') + '”';
case 'agentSession': return e.agentName + ' finished a session' + (task ? ' on ' + task : '') + ': ' + (e.minutes || 0) + ' min' + (e.tools && Object.keys(e.tools).length ? ' · ' + T().live.toolCounts(e.tools) : '') + (e.files && e.files.length ? ' · ' + e.files.length + (e.files.length === 1 ? ' file: ' : ' files: ') + e.files.slice(0, 8).join(', ') + (e.files.length > 8 ? '…' : '') : '');
case 'person': return task + ': ' + (e.toName ? 'assigned to ' + e.toName : 'nobody has it now') + (e.fromName ? ' (was ' + e.fromName + ')' : '');
case 'task': return (e.what === 'create' ? 'Created ' : e.what === 'delete' ? 'Deleted the task ' : 'Edited ') + task;
case 'universe': return e.what === 'rename' ? 'Renamed the universe ' + e.from + ' → ' + e.to : 'Moved the workflow ' + (e.name || e.workflow) + ' from ' + e.from + ' to ' + e.to;
case 'restore': return 'Brought ' + (e.what === 'task' ? 'the task ' : '') + (e.name || 'something') + ' back from the Trash';
case 'purge': return 'Deleted ' + (e.what === 'task' ? 'the task ' : '') + (e.name || 'something') + ' forever (from the Trash)';
case 'welcome': return 'Added the welcome galaxy for ' + (e.person || 'someone') + ' (' + e.total + (e.total === 1 ? ' task)' : ' tasks)');
case 'file': return e.what === 'upload' ? (e.fileKind === 'starting' ? 'Added the starting file ' : 'Uploaded ') + e.name + ' v' + e.version + (e.taskId ? ' to ' + task : '') : e.what === 'download' ? 'Downloaded ' + e.name + ' v' + e.version + (e.taskId ? ' from ' + task : '') : 'Deleted ' + e.name + ' v' + e.version + (e.taskId ? ' of ' + task : '');
case 'team': return e.what === 'invite' ? 'Invited ' + e.email + ' as ' + e.role : e.what === 'revoke' ? 'Withdrew the invitation for ' + e.email : e.what === 'role' ? e.person + ' is now ' + e.to : e.what === 'remove' ? 'Removed ' + e.person + ' from the team' + (e.reassigned ? ' (' + e.reassigned + (e.reassigned === 1 ? ' task' : ' tasks') + ' reassigned)' : '') : e.what === 'purge' ? 'Deleted ' + (e.name || 'something') + ' forever (from the Trash)' : e.what === 'importPersonal' ? 'Brought ' + e.projects + (e.projects === 1 ? ' project' : ' projects') + ' from Personal mode (' + e.tasks + ' tasks, ' + e.events + ' history entries)' : e.what;
case 'undo': { const o = Store.get().history.find((x) => x.id === e.undoOf); return 'Undo: ' + (o ? describe(o) : e.undoKind); }
default: return e.kind;
}
}
const dayKey = (ms) => { const d = new Date(ms); return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate(); };
function dayLabel(ms) {
const k = dayKey(ms);
if (k === dayKey(Date.now())) return 'Today';
if (k === dayKey(Date.now() - 864e5)) return 'Yesterday';
return new Date(ms).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
}
const clock = (ms) => { const d = new Date(ms); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); };
// Clic en una entrada: vuela a la tarea, al workflow o al proyecto (si sigue a la vista).
function goToEntry(e) {
const p = Store.project(e.projectId);
if (!p) { notify(T().history.gone); return; }
if (p.archived) { notify(T().history.archived(p.name)); return; }
closePanel();
if (e.taskId && Store.task(p.id, e.taskId)) goTo(p.id, { kind: 'task', id: e.taskId });
else if (e.workflow && Store.workflow(p.id, e.workflow)) goTo(p.id, { kind: 'workflow', id: e.workflow });
else { focus = null; Actions.selectProject(p.id); }
}
let historyFilter = { project: '', workflow: '', limit: 200 };
// Prompt 021 (G4): las versiones guardadas antes de cada importación, con «Restore this version» (dos clics: el segundo
// confirma). En History y en el historial del líder.
let confirmRestore = null;
function versionsBox(refill) {
const list = Store.get().history.filter((e) => Actions.canRestoreVersion(e)).slice(-10).reverse();
if (!list.length) return null;
const ul = el('ul', { className: 'hloglist' });
for (const e of list) {
const name = (Store.project(e.projectId) || {}).name || e.projectId;
const sure = confirmRestore === e.id;
ul.append(el('li', { data: { key: 'version|' + e.id } }, el('span', { textContent: name + ' · before the import of ' + new Date(e.at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) + ' (' + ((e.before && e.before.tasks) || []).length + ' tasks)' }),
btn(sure ? 'Yes, restore it' : 'Restore this version', () => {
if (!sure) { confirmRestore = e.id; refill(); return; }
confirmRestore = null; Actions.restoreVersion(e.id); notify(T().restoredVersion(name)); refill();
}, { className: 'row-btn' + (sure ? ' danger' : ''), data: { key: 'hrestore|' + e.id } })));
}
return el('section', { className: 'versions', data: { key: 'versions' } }, el('p', { className: 'k', textContent: 'Saved versions · a copy is kept before every handoff import' }), ul);
}
function openHistory() {
const root = openPanel('history', 'History');
const fpro = el('select', { className: 'hsel', ariaLabel: 'Filter by project' });
const fwf = el('select', { className: 'hsel', ariaLabel: 'Filter by workflow' });
const list = el('div', { className: 'hlist' });
const vbox = el('div');
const fill = () => {
vbox.textContent = ''; const vb = versionsBox(fill); if (vb) vbox.append(vb);
const all = Store.get().history;
// Proyectos: los que hay y los que aparecen en la historia (con su último nombre).
const names = new Map();
for (const e of all) if (e.projectId) names.set(e.projectId, e.projectName || e.projectId);
for (const p of Store.get().projects) names.set(p.id, p.name);
fpro.textContent = '';
fpro.append(el('option', { value: '', textContent: 'All projects' }));
for (const [id, n] of names) fpro.append(el('option', { value: id, textContent: n }));
fpro.value = names.has(historyFilter.project) ? historyFilter.project : '';
fwf.textContent = '';
fwf.append(el('option', { value: '', textContent: 'All workflows' }));
const wfs = new Map();
if (fpro.value) {
for (const w of Store.workflowsOf(fpro.value)) wfs.set(w.id, w.name);
for (const e of all) if (e.projectId === fpro.value && e.workflow && !wfs.has(e.workflow)) wfs.set(e.workflow, e.workflow);
}
for (const [id, n] of wfs) fwf.append(el('option', { value: id, textContent: n }));
fwf.value = wfs.has(historyFilter.workflow) ? historyFilter.workflow : '';
fwf.disabled = !fpro.value;
const rows = all.filter((e) => (!fpro.value || e.projectId === fpro.value) && (!fwf.value || e.workflow === fwf.value || (e.kind === 'import' && (e.changes || []).some((c) => c.workflow === fwf.value)))).slice().reverse();
list.textContent = '';
if (!rows.length) { list.append(el('p', { className: 'pempty', textContent: T().history.empty })); return; }
let day = null, ul = null;
for (const e of rows.slice(0, historyFilter.limit)) {
if (dayKey(e.at) !== day) { day = dayKey(e.at); list.append(el('h3', { className: 'hday', textContent: dayLabel(e.at) })); ul = el('ul', { className: 'pn-list' }); list.append(ul); }
const go = btn('', () => goToEntry(e), { className: 'hrow' + (e.undone ? ' undone' : ''), data: { key: 'h|' + e.id }, ariaLabel: clock(e.at) + ', ' + whoName(e.who, e) + ': ' + describe(e) + (e.undone ? ' (undone)' : '') + '. Go there' });
go.append(el('span', { className: 'htime', textContent: clock(e.at) }), el('span', { className: 'hwho' + (e.who === 'you' ? ' you' : e.who === 'handoff' ? ' handoff' : ' agent'), textContent: whoName(e.who, e) }),
el('span', { className: 'htext', textContent: describe(e) + (e.projectName && fpro.value === '' && !/project|Imported|Archived|Brought|Deleted|Duplicated/.test(describe(e)) ? ' · ' + e.projectName : '') }));
const li = el('li', { className: 'hitem' }, go);
if (Actions.canUndo(e)) li.append(btn('Undo', () => { Actions.undo(e.id); fill(); }, { className: 'row-btn hundo', ariaLabel: 'Undo: ' + describe(e), data: { key: 'hu|' + e.id } }));
ul.append(li);
}
if (rows.length > historyFilter.limit) list.append(btn('Show older (' + (rows.length - historyFilter.limit) + ')', () => { historyFilter.limit += 200; fill(); }, { className: 'linkish' }));
};
fpro.addEventListener('change', () => { historyFilter = { project: fpro.value, workflow: '', limit: 200 }; fill(); });
fwf.addEventListener('change', () => { historyFilter.workflow = fwf.value; fill(); });
fill();
root.append(el('p', { textContent: T().history.hint }), vbox, el('div', { className: 'pn-actions hfilters' }, fpro, fwf), list);
fpro.focus();
}
// Tras un cambio hecho con un clic: aviso de 5 s con "Undo".
let undoToast = null;
function offerUndo(e) {
if (e.who !== 'you' || e.kind === 'undo' || !Actions.canUndo(e)) return;
if (undoToast) undoToast.remove();
const zone = $('#toasts');
const t = el('div', { className: 'toast brief undo', role: 'status' }, el('span', { textContent: describe(e) }),
btn('Undo', () => { Actions.undo(e.id); t.remove(); }, { className: 'ubtn', ariaLabel: 'Undo: ' + describe(e), data: { key: 'undo-toast' } }));
zone.append(t);
undoToast = t;
setTimeout(() => { t.remove(); if (undoToast === t) undoToast = null; }, 5000);
}
// ---- Filtros de un clic sobre el mapa: lo que no coincide se atenúa ----
const FILTERS = [
{ id: 'doing', test: (p, t) => t.status === 'doing' },
{ id: 'needs', test: (p, t) => t.status === 'needs-you' },
{ id: 'agents', test: (p, t) => !!Store.agentOn(p.id, t.id) },
{ id: 'notdone', test: (p, t) => t.status !== 'done' },
];
let filterId = null;
function activeFilter() {
const p = Store.current(), F = [...FILTERS, ...TEAM_FILTERS].find((x) => x.id === filterId);
if (!p || !F) return null;
return { id: F.id, label: T().filters[F.id], match: Store.tasksOf(p.id).filter((t) => F.test(p, t)).map((t) => t.id) };
}
function setFilter(id) { filterId = filterId === id ? null : id; renderBar(); LayoutHost.push(); }
// ---- Buscar: tareas, workflows y proyectos por id, título o misión ----
function searchItems(q) {
q = String(q || '').trim().toLowerCase();
if (!q) return [];
const out = [];
const score = (id, title, ...rest) => {
id = String(id || '').toLowerCase(); title = String(title || '').toLowerCase();
if (id === q) return 0;
if (id.startsWith(q)) return 1;
if (title.startsWith(q)) return 2;
if (title.includes(q)) return 3;
if (rest.some((x) => String(x || '').toLowerCase().includes(q))) return 4;
return -1;
};
for (const p of Store.get().projects.filter((x) => !x.archived)) {
const sp = score('', p.name);
if (sp >= 0) out.push({ kind: 'project', p, s: sp, label: p.name, sub: T().search.project });
for (const w of Store.workflowsOf(p.id)) { const sw = score(w.id, w.name, w.mission); if (sw >= 0) out.push({ kind: 'workflow', p, w, s: sw, label: w.name, sub: 'Workflow · ' + p.name + uniTag(w.universe || 'Main') }); }
for (const t of Store.tasksOf(p.id)) {
const st = score(t.id, t.title, t.mission, t.needs);
if (st >= 0) out.push({ kind: 'task', p, t, s: st, label: t.id + ' ' + t.title, sub: T().status[t.status] + ' · ' + p.name + ' · ' + ((Store.workflow(p.id, t.workflow) || {}).name || t.workflow) + uniTag(Store.universeOf(p.id, t.workflow)) });
}
}
return out.sort((a, b) => a.s - b.s).slice(0, 12);
}
function goToResult(r) {
if (r.kind === 'task') goTo(r.p.id, { kind: 'task', id: r.t.id });
else if (r.kind === 'workflow') goTo(r.p.id, { kind: 'workflow', id: r.w.id });
else { focus = null; Actions.selectProject(r.p.id); }
}
let searchBox = null;
function buildSearch() {
const input = el('input', { type: 'search', id: 'search', className: 'search', placeholder: T().search.placeholder, autocomplete: 'off', spellcheck: false, ariaLabel: T().search.label, ariaExpanded: 'false', ariaControls: 'search-results', data: { key: 'search' } });
input.setAttribute('role', 'combobox');
const list = el('ul', { id: 'search-results', className: 'sresults', hidden: true });
list.setAttribute('role', 'listbox');
let results = [], active = 0;
const close = () => { list.hidden = true; input.setAttribute('aria-expanded', 'false'); };
const show = () => {
results = searchItems(input.value);
list.textContent = '';
if (!input.value.trim()) { close(); return; }
if (!results.length) list.append(el('li', { className: 'snone', textContent: T().search.none }));
results.forEach((r, i) => {
const b = btn('', () => { pick(r); }, { className: 'sitem' + (i === active ? ' active' : ''), data: { key: 'sr|' + i } });
b.setAttribute('role', 'option'); b.setAttribute('aria-selected', String(i === active)); b.tabIndex = -1;
if (r.kind === 'task') b.append(ico(r.t.status, Store.colorOf(r.p.id, r.t.workflow)));
else b.append(el('span', { className: 'skind', textContent: r.kind === 'workflow' ? 'W' : 'P' }));
b.append(el('span', { className: 'stext' }, el('b', { textContent: r.label }), el('small', { textContent: r.sub })));
b.addEventListener('mousedown', (e) => e.preventDefault()); // que el clic no cierre la lista antes de tiempo
list.append(el('li', {}, b));
});
list.hidden = false; input.setAttribute('aria-expanded', 'true');
};
const pick = (r) => { close(); input.value = ''; input.blur(); goToResult(r); };
input.addEventListener('input', () => { active = 0; show(); });
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (results.length) { active = (active + (e.key === 'ArrowDown' ? 1 : results.length - 1)) % results.length; show(); } }
else if (e.key === 'Enter') { e.preventDefault(); if (results[active]) pick(results[active]); }
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); input.value = ''; close(); input.blur(); }
});
input.addEventListener('blur', () => setTimeout(close, 150));
input.addEventListener('focus', () => { if (input.value.trim()) show(); });
searchBox = el('div', { className: 'sbox' }, svg('', 'sicon'), input, el('kbd', { textContent: '/', ariaHidden: 'true' }), list);
return searchBox;
}
// "/" o Ctrl+K desde cualquier parte (salvo mientras se escribe) lleva a la búsqueda.
function bindSearchKeys() {
addEventListener('keydown', (e) => {
const tgt = e.target, typing = tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.tagName === 'SELECT' || tgt.isContentEditable);
if ((e.key === '/' && !typing && !e.ctrlKey && !e.metaKey) || ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K'))) {
const n = document.getElementById('search');
if (!n || panel) return;
e.preventDefault(); n.focus(); n.select();
}
});
}
// La barra: búsqueda y filtros. Se arma una vez; los filtros se actualizan sin rehacer la búsqueda.
let chipsBox = null;
function renderBar() {
const bar = $('#bar');
if (!bar) return;
bar.hidden = !Store.current();
if (!searchBox) { chipsBox = el('div', { className: 'chips', role: 'group', ariaLabel: T().filters.label }); bar.append(buildSearch(), chipsBox); }
chipsBox.textContent = '';
const f = activeFilter();
for (const F of FILTERS) {
const on = filterId === F.id;
const b = btn(T().filters[F.id] + (on ? ' ✕' : ''), () => setFilter(F.id), { className: 'chip' + (on ? ' on' : ''), title: on ? T().filters.clearTitle : T().filters.title, data: { key: 'filter|' + F.id } });
b.setAttribute('aria-pressed', String(on));
chipsBox.append(b);
}
if (LayoutHost.current() && LayoutHost.current().id === 'universe') chipsBox.append(btn('▶ Tour', startTour, { className: 'chip tour', title: T().tour.title, ariaLabel: 'Start the tour', data: { key: 'tour' } }));
if (f) {
const total = Store.counts(Store.current().id).total;
chipsBox.append(el('span', { className: 'fsum', role: 'status', textContent: T().filters.showing(f.label, f.match.length, total) }),
btn(T().filters.clear, () => setFilter(filterId), { className: 'linkish fclear', data: { key: 'filter-clear' } }));
}
}
// ---- Línea de tiempo: el universo como estaba en un momento pasado, reconstruido desde la historia ----
// Momentos: cada cambio de estado o importación del proyecto. Hacia atrás, cada entrada posterior al momento se
// deshace sobre los estados de hoy (status: vuelve a from; import: from, o la tarea todavía no existía).
let pastAt = null;
const isPast = () => pastAt != null;
const changesStatus = (e) => e.kind === 'status' || e.kind === 'import' || (e.kind === 'undo' && e.undoKind === 'status');
function moments(pid) { return [...new Set(Store.get().history.filter((e) => e.projectId === pid && changesStatus(e)).map((e) => e.at))].sort((a, b) => a - b); }
function statusesAt(pid, at) {
const map = new Map(Store.tasksOf(pid).map((t) => [t.id, t.status]));
for (const e of Store.get().history.filter((x) => x.projectId === pid && x.at > at && changesStatus(x)).sort((a, b) => b.at - a.at)) {
if (e.kind === 'import') { for (const c of e.changes || []) if (map.has(c.taskId)) map.set(c.taskId, c.from); }
else if (map.has(e.taskId)) map.set(e.taskId, e.from);
}
return map;
}
const whenLabel = (ms) => new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + clock(ms);
function pastState() {
const p = Store.current();
if (!isPast() || !p) return null;
return { at: pastAt, label: whenLabel(pastAt), statuses: statusesAt(p.id, pastAt) };
}
function setPast(at) {
pastAt = at;
renderPast(); LayoutHost.push(); renderDetail();
}
let timelineKey = '';
function renderTimeline() {
const box = $('#timeline');
const p = Store.current();
const ms = p ? moments(p.id) : [];
const show = !!p && !LayoutHost.isNative() && ms.length > 0;
if (!show && isPast()) { pastAt = null; LayoutHost.push(); }
box.hidden = !show;
if (!show) { timelineKey = ''; renderPast(); return; }
const key = p.id + '|' + ms.length + '|' + ms[ms.length - 1];
if (key === timelineKey) { renderPast(); return; }
timelineKey = key;
if (isPast() && !ms.includes(pastAt)) pastAt = null;
box.textContent = '';
const range = el('input', { type: 'range', id: 'timeline-range', min: 0, max: ms.length, step: 1, value: isPast() ? ms.indexOf(pastAt) : ms.length, ariaLabel: T().timeline.label, data: { key: 'timeline' } });
const out = el('span', { className: 'tlabel', id: 'timeline-label' });
range.addEventListener('input', () => { const i = Number(range.value); setPast(i >= ms.length ? null : ms[i]); });
box.append(el('span', { className: 'tlname', textContent: T().timeline.name }), el('span', { className: 'tedge', textContent: whenLabel(ms[0]) }), range, el('span', { className: 'tedge', textContent: 'Today' }), out);
renderPast();
}
function renderPast() {
const banner = $('#past'), out = document.getElementById('timeline-label'), range = document.getElementById('timeline-range');
banner.hidden = !isPast();
$('#timeline').classList.toggle('past', isPast());
if (out) out.textContent = isPast() ? T().timeline.viewing(whenLabel(pastAt)) : T().timeline.today;
if (range && !isPast() && document.activeElement !== range) range.value = range.max;
banner.textContent = '';
if (!isPast()) return;
banner.append(el('strong', { textContent: T().timeline.viewing(whenLabel(pastAt)) }), el('span', { textContent: T().timeline.readOnly }),
btn(T().timeline.back, () => { setPast(null); const r = document.getElementById('timeline-range'); if (r) r.value = r.max; }, { className: 'btn primary', data: { key: 'past-back' } }));
}
// ---- Recorrido ("Tour"): la cámara de Universe pasa galaxia por galaxia y se detiene en cada tarea sin terminar ----
const tour = { on: false, paused: false, steps: [], i: 0, timer: null, stepMs: 4000 };
function tourSteps() {
const p = Store.current(), steps = [];
if (!p) return steps;
for (const w of Store.workflowsOf(p.id).filter((x) => !multiUni() || (x.universe || 'Main') === currentUniverse())) {
const open = Store.tasksOf(p.id).filter((t) => t.workflow === w.id && t.status !== 'done');
if (!open.length) continue;
steps.push({ kind: 'workflow', id: w.id });
for (const t of open) steps.push({ kind: 'task', id: t.id });
}
return steps;
}
function startTour() {
if (isPast()) setPast(null);
if (!LayoutHost.current() || LayoutHost.current().id !== 'universe') Actions.chooseLayout('universe');
tour.steps = tourSteps();
if (!tour.steps.length) { notify(T().tour.nothing); return; }
Object.assign(tour, { on: true, paused: false, i: 0 });
tourShow();
}
function tourShow() {
clearTimeout(tour.timer);
const st = tour.steps[tour.i];
select(st);
LayoutHost.focus(st.kind === 'task' ? { task: st.id } : { workflow: st.id });
renderTourBar();
if (!tour.paused) tour.timer = setTimeout(tourNext, st.kind === 'workflow' ? Math.round(tour.stepMs * 0.6) : tour.stepMs);
}
function tourNext() { if (!tour.on) return; tour.i++; if (tour.i >= tour.steps.length) stopTour(true); else tourShow(); }
function tourPause() { tour.paused = !tour.paused; if (tour.paused) clearTimeout(tour.timer); tourShow(); }
function stopTour(finished) {
if (!tour.on) return;
clearTimeout(tour.timer);
tour.on = false;
renderTourBar();
if (finished) notify(T().tour.finished);
}
function renderTourBar() {
const bar = $('#tourbar');
bar.hidden = !tour.on;
bar.textContent = '';
if (!tour.on) return;
const st = tour.steps[tour.i], p = Store.current();
const what = st.kind === 'task' ? (Store.task(p.id, st.id) ? st.id + ' ' + Store.task(p.id, st.id).title : st.id) : ((Store.workflow(p.id, st.id) || {}).name || st.id);
bar.append(el('strong', { textContent: 'Tour' }), el('span', { className: 'tpos', textContent: (tour.i + 1) + ' of ' + tour.steps.length + ' · ' + what }),
btn(tour.paused ? 'Resume' : 'Pause', tourPause, { className: 'abtn', data: { key: 'tour-pause' } }),
btn('Next', () => { tour.paused = false; tourNext(); }, { className: 'abtn', data: { key: 'tour-next' } }),
btn('Exit', () => stopTour(false), { className: 'abtn', data: { key: 'tour-exit' } }));
}
// Cualquier arrastre en el mapa (lo avisa el layout) o tecla fuera de los botones del recorrido lo detiene.
function interacted() { stopTour(false); stopFollow(); }
function bindTourKeys() {
addEventListener('keydown', (e) => { if (tour.on && !(e.target && e.target.closest && e.target.closest('#tourbar'))) stopTour(false); }, true);
}
// ---- Vista "List": todo el proyecto como lista simple, agrupada por workflow ----
function renderList(box) {
if (!box) return;
box.textContent = '';
const p = Store.current();
if (!p) return;
const f = activeFilter();
box.append(el('p', { className: 'nhint', textContent: T().list.hint + (multiUni() ? ' Universe: ' + currentUniverse() + '.' : '') }));
for (const w of Store.workflowsOf(p.id).filter((x) => !multiUni() || (x.universe || 'Main') === currentUniverse())) {
const c = Store.counts(p.id, w.id), color = Store.colorOf(p.id, w.id);
const head = el('div', { className: 'lhead' }, el('span', { className: 'dot' }), el('b', { textContent: w.name }), el('span', { className: 'wnum', textContent: c.done + '/' + c.total }), el('small', { textContent: w.mission || '' }));
head.style.setProperty('--c', color);
const ul = el('ul', { className: 'llist' });
for (const t of Store.tasksOf(p.id).filter((x) => x.workflow === w.id)) {
const a = Store.agentOn(p.id, t.id);
const sel = focus && focus.kind === 'task' && focus.id === t.id;
const b = btn('', () => select({ kind: 'task', id: t.id }), { className: 'lrow' + (sel ? ' sel' : '') + (f && !f.match.includes(t.id) ? ' dim' : ''), data: { key: 'lt|' + t.id },
ariaLabel: t.id + ' ' + t.title + ', ' + T().status[t.status] + (a ? ', agent ' + a.name : '') + (t.mission ? '. ' + t.mission : '') });
b.setAttribute('aria-pressed', String(!!sel));
b.append(ico(t.status, color), el('b', { className: 'lid', textContent: t.id }),
el('span', { className: 'ltext' }, el('span', { textContent: t.title }), el('small', { textContent: t.status === 'needs-you' && t.needs ? '@ ' + t.needs : t.mission || '' })),
el('span', { className: 'lstatus', textContent: T().status[t.status] + (Team.on() && t.assignee ? ' · ' + (Team.nameOf(t.assignee) || '') : '') }),
a ? el('span', { className: 'nagent' }, el('span', { className: 'aicon' }, Agents.iconSvg(a, 14)), a.name) : el('span', { className: 'ntag', textContent: '' }));
ul.append(el('li', {}, b));
}
box.append(el('section', { className: 'lgroup', ariaLabel: w.name }, head, ul));
}
}
// ---- Vista "Now": para leer de un vistazo, en todos los proyectos ----
// Waiting on you (la bandeja), In progress (con su agente) y Recently done (terminadas en los últimos 7 días, según
// la historia). Clic en una fila: va a esa tarea en Universe.
function recentlyDone(days = 7) {
const since = Date.now() - days * 864e5, seen = new Set(), out = [];
for (const e of Store.get().history.slice().reverse()) {
if (e.at < since) continue;
// Una tarea que llega nueva ya hecha en un handoff no cuenta: no se sabe cuándo se terminó.
const hits = e.kind === 'status' && e.to === 'done' ? [{ taskId: e.taskId }] : e.kind === 'import' ? (e.changes || []).filter((c) => c.to === 'done' && c.from) : [];
for (const h of hits) {
const key = e.projectId + '|' + h.taskId;
const p = Store.project(e.projectId), t = p && Store.task(p.id, h.taskId);
if (seen.has(key) || !t || p.archived || t.status !== 'done') continue;
seen.add(key);
out.push({ project: p, task: t, at: e.at, who: e.who });
}
}
return out;
}
function nowRow(p, t, extra, onclick) {
const w = Store.workflow(p.id, t.workflow);
const b = btn('', onclick || (() => goTo(p.id, { kind: 'task', id: t.id })), { className: 'nrow', data: { key: 'now|' + p.id + '|' + t.id }, ariaLabel: t.id + ' ' + t.title + ', ' + p.name + ', ' + T().status[t.status] + (extra.aria ? ', ' + extra.aria : '') + '. Go to it in Universe' });
b.append(ico(t.status, Store.colorOf(p.id, t.workflow)),
el('span', { className: 'ntext' }, el('b', { textContent: extra.lead || (t.id + ' ' + t.title) }), el('small', { textContent: (extra.lead ? t.id + ' ' + t.title + ' · ' : '') + p.name + ' · ' + (w ? w.name : t.workflow) + uniTag(w ? w.universe || 'Main' : 'Main') })),
extra.tail || null);
return b;
}
function renderNow(box) {
if (!box) return;
box.textContent = '';
const live = Store.get().projects.filter((p) => !p.archived);
const block = (title, rows, empty, key) => {
const sec = el('section', { className: 'nblock', ariaLabel: title }, el('h3', {}, el('span', { textContent: title }), el('span', { className: 'ncount', textContent: String(rows.length) })));
if (!rows.length) sec.append(el('p', { className: 'pempty', textContent: empty }));
const ul = el('ul', { className: 'nlist' });
for (const r of rows) ul.append(el('li', {}, r));
sec.append(ul);
sec.dataset.block = key;
return sec;
};
const waiting = agentWaits().map((a) => agentWaitRow(a, 'nrow agentwait')).concat(waitOn().map((r) => nowRow(r.project, r.task, { lead: r.review ? T().inbox.reviewTitle(r.task.title) + (r.task.reviewNote ? ' · ' + r.task.reviewNote : '') : r.task.needs || T().inbox.noText, aria: 'waiting ' + (r.since ? ago(r.since) : ''), tail: el('span', { className: 'ntag', textContent: r.since ? ago(r.since) : '' }) })));
const doing = [];
for (const p of live) for (const t of Store.tasksOf(p.id).filter((x) => x.status === 'doing')) {
const a = Store.agentOn(p.id, t.id);
const person = Team.on() && t.assignee ? Team.nameOf(t.assignee) : '';
const chip = a ? el('span', { className: 'nagent' }, el('span', { className: 'aicon' }, Agents.iconSvg(a, 14)), a.name + (person ? ' · ' + person : '')) : el('span', { className: 'ntag', textContent: person || T().now.noAgent });
doing.push(nowRow(p, t, { aria: a ? 'agent ' + a.name : 'no agent', tail: chip }));
}
const done = recentlyDone().map((r) => nowRow(r.project, r.task, { aria: 'done ' + ago(r.at) + ' ago', tail: el('span', { className: 'ntag', textContent: ago(r.at) + ' · ' + whoName(r.who) }) }));
box.append(el('p', { className: 'nhint', textContent: T().now.hint }),
el('div', { className: 'ngrid' },
block(T().inbox.name, waiting, T().inbox.empty, 'waiting'),
block(T().status.doing, doing, T().now.noDoing, 'doing'),
block(T().now.recent, done, T().now.noDone, 'done')));
const clashes = live.flatMap((p) => collisions(p.id).map((c) => ({ p, ...c })));
if (clashes.length) {
const rows = clashes.map((c) => { const b = btn('', () => goTo(c.p.id, { kind: 'task', id: c.tasks[0].id }), { className: 'nrow clashrow', data: { key: 'nclash|' + c.p.id + '|' + c.file } }); b.append(el('span', { className: 'ntext' }, el('b', { textContent: c.file }), el('small', { textContent: c.tasks.map((x) => x.id + ' ' + x.title + ' (' + holder(c.p, x) + ')').join(' · ') + ' · ' + c.p.name }))); return b; });
const sec = block(T().files.clashesTitle, rows, '', 'collisions');
sec.classList.add('nclashes');
box.append(sec);
}
if (Team.on()) box.append(yesterdayBlock());
}
// ---- "Yesterday" (modo Team): por persona, lo que hizo el día anterior, tal como lo cuenta la historia ----
function yesterday(now = Date.now()) {
const end = new Date(now); end.setHours(0, 0, 0, 0);
const start = new Date(end); start.setDate(start.getDate() - 1);
const by = new Map();
for (const e of Store.get().history) {
if (!e.actor || e.at < start.getTime() || e.at >= end.getTime()) continue;
if (!by.has(e.actor)) by.set(e.actor, []);
by.get(e.actor).push(e);
}
return { day: start, people: Team.members().map((m) => ({ member: m, events: (by.get(m.user_id) || []).sort((a, b) => a.at - b.at) })) };
}
function yesterdayBlock() {
const Y = T().now, MAX = 8, y = yesterday();
const sec = el('section', { className: 'nblock nyesterday', ariaLabel: Y.yesterday }, el('h3', {}, el('span', { textContent: Y.yesterday }), el('small', { className: 'nday', textContent: y.day.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }) })));
sec.dataset.block = 'yesterday';
const grid = el('div', { className: 'ypeople' });
for (const { member, events } of y.people) {
const box = el('div', { className: 'yperson', data: { key: 'yday|' + member.user_id } }, el('h4', {}, typeof Ships !== 'undefined' ? Ships.icon(member, 16) : el('span'), el('span', { textContent: member.name + (member.user_id === Team.myId() ? ' (you)' : '') })));
if (!events.length) box.append(el('p', { className: 'pempty', textContent: Y.yesterdayNone }));
else {
const ul = el('ul', { className: 'ylist' });
if (events.length > MAX) ul.append(el('li', { className: 'ymore', textContent: Y.yesterdayMore(events.length - MAX) }));
for (const e of events.slice(-MAX)) ul.append(el('li', {}, el('span', { className: 'htime', textContent: clock(e.at) }), el('span', { textContent: ' ' + describe(e) })));
box.append(ul);
}
grid.append(box);
}
sec.append(grid);
return sec;
}
// ---- "Since last time": al abrir, si pasó más de una hora, un resumen corto de la historia ----
const SEEN_KEY = 'tasky:lastSeen';
const readSeen = () => { try { return Number(localStorage.getItem(SEEN_KEY)) || 0; } catch { return 0; } };
const writeSeen = () => { try { localStorage.setItem(SEEN_KEY, String(Date.now())); } catch {} };
function sinceLastTime(last) {
const c = { done: 0, needs: 0, came: new Set(), left: new Set() };
for (const e of Store.get().history) {
if (e.at <= last) continue;
if (e.kind === 'status') { if (e.to === 'done') c.done++; if (e.to === 'needs-you') c.needs++; }
if (e.kind === 'import') for (const x of e.changes || []) { if (x.to === 'done' && x.from) c.done++; if (x.to === 'needs-you') c.needs++; }
if (e.kind === 'agentIn') c.came.add(e.agentName);
if (e.kind === 'agentOut') c.left.add(e.agentName);
}
return { done: c.done, needs: c.needs, came: [...c.came], left: [...c.left] };
}
function showSince(last) {
const r = sinceLastTime(last);
const S = T().since, lines = [];
if (r.done) lines.push(S.done(r.done));
if (r.needs) lines.push(S.needs(r.needs));
if (r.came.length) lines.push(S.came(r.came));
if (r.left.length) lines.push(S.left(r.left));
if (!lines.length) return null;
const ul = el('ul', { className: 'slist' });
for (const l of lines) ul.append(el('li', { textContent: l }));
const t = el('section', { className: 'toast since', role: 'status', ariaLabel: S.title },
el('div', { className: 'thead' }, el('div', {}, el('strong', { textContent: S.title }), el('small', { textContent: S.ago(ago(last)) }))), ul,
btn(S.ok, () => t.remove(), { className: 'linkish tlater', data: { key: 'since-ok' } }));
t.addEventListener('click', (e) => { if (e.target === t) t.remove(); });
$('#toasts').append(t);
setTimeout(() => t.remove(), 15000);
return t;
}
function trackSeen() {
const last = readSeen();
if (last && Date.now() - last > 3600e3) showSince(last);
writeSeen();
setInterval(() => { if (!document.hidden) writeSeen(); }, 60000);
addEventListener('pagehide', writeSeen);
document.addEventListener('visibilitychange', () => { if (document.hidden) writeSeen(); });
}
// ---- Archivos de una tarea: pedidos, de partida y entregados (con versiones) ----
const TEXT_EXT = /\.(md|markdown|txt|py|json|csv|tsv|js|mjs|cjs|ts|tsx|jsx|css|html?|xml|ya?ml|toml|ini|sql|sh|log|rst|tex|r|java|go|rb|php|c|h|cpp)$/i;
const fileSize = (n) => n < 1024 ? n + ' B' : n < 1048576 ? Math.round(n / 1024) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
function filesOf(p, t) { return Store.get().files.filter((x) => x.projectId === p.id && x.taskId === t.id && !x.deleted); }
function deliveredNames(p, t) { return new Set(filesOf(p, t).filter((x) => x.kind === 'delivery').map((x) => x.name)); }
// 020-B (A2): imágenes como miniaturas (en grande al hacer clic), videos cortos que se reproducen dentro y enlaces como
// fichas que abren en otra pestaña. El contenido llega por enlaces firmados que caducan.
function latestOf(list) {
const by = new Map();
for (const x of list) if (!by.has(x.kind + '|' + x.name) || by.get(x.kind + '|' + x.name).version < x.version) by.set(x.kind + '|' + x.name, x);
return [...by.values()];
}
function mediaGallery(files, label) {
const media = latestOf(files).filter((x) => Media.kindOf(x) !== 'file');
if (!media.length) return null;
const box = el('div', { className: 'mgallery', role: 'group', ariaLabel: label, data: { key: 'mg|' + label } });
for (const fl of media) {
const k = Media.kindOf(fl);
if (k === 'link') {
let host = ''; try { host = new URL(fl.link).hostname.replace(/^www\./, ''); } catch {}
box.append(el('a', { className: 'mlink', href: fl.link, target: '_blank', rel: 'noopener noreferrer', title: fl.link, data: { key: 'mlink|' + fl.id } }, el('b', { textContent: '▶', ariaHidden: 'true' }), el('span', { textContent: fl.name }), el('small', { textContent: host })));
} else if (k === 'image') {
const img = el('img', { alt: fl.name, loading: 'lazy', decoding: 'async' });
const now = Team.mediaUrlNow(fl);
if (now) img.src = now; else Team.mediaUrl(fl).then((u) => { if (u) img.src = u; });
box.append(btn('', () => openMedia(fl), { className: 'mthumb', ariaLabel: 'Open ' + fl.name + ' large', title: fl.name, data: { key: 'mthumb|' + fl.id } }, img));
} else {
const v = el('video', { className: 'mvideo', controls: true, preload: 'metadata', playsInline: true, ariaLabel: fl.name, data: { key: 'mvideo|' + fl.id } });
const now = Team.mediaUrlNow(fl);
if (now) v.src = now; else Team.mediaUrl(fl).then((u) => { if (u) v.src = u; });
box.append(el('div', { style: 'width:100%' }, el('p', { className: 'mcap', textContent: fl.name }), v));
}
}
return box;
}
async function openMedia(fl) {
const root = openPanel('media', fl.name + ' · v' + fl.version);
root.classList.add('wide');
const url = await Team.mediaUrl(fl);
if (!url) { root.append(el('p', { textContent: Help.TEXT.team.fileGone(fl.name) })); return; }
root.append(Media.kindOf(fl) === 'video' ? el('video', { src: url, controls: true, autoplay: true, playsInline: true, data: { key: 'media-big' } }) : el('img', { src: url, alt: fl.name, data: { key: 'media-big' } }),
el('div', { className: 'pn-actions' }, btn('Download', () => Team.download(fl), { className: 'primary', data: { key: 'media-dl' } })));
}
function fileRow(fl, older) {
const who = Team.on() ? (Team.nameOf(fl.by) || '') : '';
const row = el('div', { className: 'frow' + (older ? ' older' : '') },
el('span', { className: 'fname' }, el('b', { textContent: fl.name }), el('span', { className: 'fver', textContent: 'v' + fl.version })),
el('small', { className: 'fmeta', textContent: [who, fl.at ? (ago(fl.at) === 'just now' ? 'just now' : ago(fl.at) + ' ago') : '', fl.link ? 'link' : fileSize(fl.size)].filter(Boolean).join(' · ') + (fl.note ? ' · “' + fl.note + '”' : '') }));
const acts = el('span', { className: 'facts' });
if (fl.link) acts.append(el('a', { className: 'dbtn small', href: fl.link, target: '_blank', rel: 'noopener noreferrer', textContent: 'Open link', ariaLabel: 'Open the link ' + fl.name, data: { key: 'fopen|' + fl.id } }));
else acts.append(btn('Download', () => Team.download(fl), { className: 'dbtn small', ariaLabel: 'Download ' + fl.name + ' version ' + fl.version, data: { key: 'fdl|' + fl.id } }));
if (!fl.link && TEXT_EXT.test(fl.name)) acts.append(btn('Preview', () => previewFile(fl), { className: 'dbtn small', ariaLabel: 'Preview ' + fl.name + ' version ' + fl.version, data: { key: 'fpv|' + fl.id } }));
if (Team.can('deleteFiles')) acts.append(btn('Delete', () => { if (confirmDelete === fl.id) { confirmDelete = null; Team.deleteFileVersion(fl); } else { confirmDelete = fl.id; renderDetail(); } }, { className: 'dbtn small danger', ariaLabel: (confirmDelete === fl.id ? 'Confirm: delete ' : 'Delete ') + fl.name + ' version ' + fl.version, textContent: confirmDelete === fl.id ? 'Sure? Delete' : 'Delete', data: { key: 'fdel|' + fl.id } }));
row.append(acts);
return row;
}
let confirmDelete = null, openVersions = new Set();
// ---- Asignar y revisar (modo Team) ----
let submitting = null, sendingBack = null, approveAsk = null;
// 022 (R3): aprobar una tarea sin precio no paga nada: se pregunta antes.
function approveClick(p, t) {
if (Team.on() && !Store.rewardOf(t) && approveAsk !== p.id + '|' + t.id) { approveAsk = p.id + '|' + t.id; select({ kind: 'task', id: t.id }); renderDetail(); return; }
approveAsk = null; Actions.approveTask(p.id, t.id);
}
function approveConfirm(p, t) {
if (approveAsk !== p.id + '|' + t.id) return null;
const R = T().cycle;
return el('div', { className: 'pconfirm', role: 'group', ariaLabel: 'Confirm approving ' + t.id + ' without pay', data: { key: 'approve-ask' } },
el('p', { className: 'pq', textContent: R.noRewardAsk }),
btn(R.approveNoPay, () => approveClick(p, t), { className: 'bigbtn go', data: { key: 'approve-nopay' } }),
btn('Cancel', () => { approveAsk = null; renderDetail(); }, { className: 'bigbtn soft', data: { key: 'approve-cancel' } }));
}
function personRow(box, p, t) {
const who = t.assignee ? Team.nameOf(t.assignee) || 'someone' : '';
const row = el('div', { className: 'person' }, el('span', { className: 'k', textContent: 'Person' }));
if (Team.can('assignAny')) {
const sel = el('select', { className: 'fkind', ariaLabel: 'Who has ' + t.id, data: { key: 'assignee' } }, el('option', { value: '', textContent: 'Nobody' }), ...Team.members().filter((m) => m.role !== 'viewer').map((m) => el('option', { value: m.user_id, textContent: m.name + (m.user_id === Team.myId() ? ' (you)' : '') })));
sel.value = t.assignee || '';
sel.addEventListener('change', () => Actions.setAssignee(p.id, t.id, sel.value || null));
row.append(sel);
} else row.append(el('b', { className: 'pname', textContent: who || 'Nobody yet' }));
if (!t.assignee && Team.can('take') && !Team.can('assignAny') && !Store.lockOf(p.id, t)) row.append(btn("I'm on it", () => Actions.takeTask(p.id, t.id), { className: 'dbtn small go', data: { key: 'take' } }));
else if (!t.assignee && Team.can('assignAny')) row.append(btn("I'm on it", () => Actions.takeTask(p.id, t.id), { className: 'dbtn small', data: { key: 'take' } }));
if (t.assignee === Team.myId() && !Team.can('assignAny')) row.append(btn('Let it go', () => Actions.setAssignee(p.id, t.id, null), { className: 'dbtn small', data: { key: 'letgo' } }));
box.append(row);
}
function reviewBlock(box, p, t) {
if (t.backComment && t.status !== 'review' && t.status !== 'done') box.append(el('div', { className: 'reviewbox back' }, el('p', { className: 'k', textContent: T().review.sentBackTitle }), el('p', { className: 'rtext', textContent: t.backComment })));
if (submitting === t.id && t.status !== 'review') {
const note = el('textarea', { className: 'needsin', rows: 2, placeholder: T().review.notePlaceholder, ariaLabel: 'Note for the reviewer', data: { key: 'submit-note' } });
const missing = (t.deliverables || []).filter((n) => !Store.get().files.some((x) => x.projectId === p.id && x.taskId === t.id && x.kind === 'delivery' && x.name === n && !x.deleted));
const warn = missing.length ? el('p', { className: 'rwarn', role: 'alert', textContent: T().review.missing(missing) }) : null;
box.append(el('div', { className: 'reviewbox' }, el('p', { className: 'k', textContent: T().review.submitTitle }), warn, note,
el('div', { className: 'dbtns' }, btn(missing.length ? T().review.submitAnyway : T().review.send, () => { submitting = null; note.blur(); Actions.submitForReview(p.id, t.id, note.value); }, { className: 'dbtn go', data: { key: 'submit-go' } }),
btn('Cancel', () => { submitting = null; note.blur(); renderDetail(); }, { className: 'dbtn', data: { key: 'submit-cancel' } }))));
}
if (t.status !== 'review') return;
const by = Team.nameOf(t.assignee) || T().review.someone;
const block = el('div', { className: 'reviewbox' }, el('p', { className: 'k', textContent: T().review.inReview(by) }), el('p', { className: 'rtext', textContent: t.reviewNote || T().review.noNote }));
if (Team.can('approve')) {
if (sendingBack === t.id) {
const c = el('textarea', { className: 'needsin', rows: 2, placeholder: T().review.backPlaceholder, ariaLabel: 'What needs to change (required)', data: { key: 'back-comment' } });
const go = btn('Send back', () => { if (!c.value.trim()) { c.focus(); notify(T().review.needComment); return; } sendingBack = null; c.blur(); Actions.sendBack(p.id, t.id, c.value); if (typeof Team.postBack === 'function') Team.postBack(p.id, t.id, c.value); }, { className: 'dbtn danger', data: { key: 'back-go' } });
block.append(c, el('div', { className: 'dbtns' }, go, btn('Cancel', () => { sendingBack = null; c.blur(); renderDetail(); }, { className: 'dbtn', data: { key: 'back-cancel' } })));
} else block.append(el('div', { className: 'dbtns' }, btn('Approve', () => approveClick(p, t), { className: 'dbtn go', data: { key: 'approve' } }), btn('Send back', () => { sendingBack = t.id; renderDetail(); const n = document.querySelector('[data-key="back-comment"]'); if (n) n.focus(); }, { className: 'dbtn danger', data: { key: 'send-back' } })));
}
box.append(block);
}
// Crear o editar una tarea desde la app (dueño). Workflow, título, misión, entregables y persona.
function openTaskEditor(p, t, workflowId, opts = {}) {
const root = openPanel('task-editor', t ? 'Edit ' + t.id : 'New task');
const wf = el('select', { className: 'hsel', ariaLabel: 'Workflow', data: { key: 'te-wf' } }, ...Store.workflowsOf(p.id).map((w) => el('option', { value: w.id, textContent: w.name })), el('option', { value: '', textContent: 'New workflow…' }));
wf.value = t ? t.workflow : workflowId || (Store.workflowsOf(p.id)[0] || {}).id || '';
wf.disabled = !!t;
const wfName = el('input', { type: 'text', className: 'pn-input', placeholder: 'Name of the new workflow', ariaLabel: 'Name of the new workflow', data: { key: 'te-wfname' } });
wfName.hidden = !!wf.value;
wf.addEventListener('change', () => { wfName.hidden = !!wf.value; });
const title = el('input', { type: 'text', className: 'pn-input', value: t ? t.title : '', maxLength: 300, ariaLabel: 'Title', data: { key: 'te-title' } });
const mission = el('textarea', { className: 'pn-input te-area', rows: 3, value: t ? t.mission || '' : '', ariaLabel: 'Mission', data: { key: 'te-mission' } });
const deliv = el('input', { type: 'text', className: 'pn-input', value: t ? (t.deliverables || []).join(', ') : '', placeholder: 'report.md, extract.py', ariaLabel: 'Deliverables (comma-separated)', data: { key: 'te-deliv' } });
const planned = el('input', { type: 'text', className: 'pn-input', value: t ? (t.files || []).join(', ') : '', placeholder: 'src/extract.py, tests/test_extract.py', ariaLabel: 'Planned files (comma-separated)', data: { key: 'te-files' } });
const person = Team.on() ? el('select', { className: 'hsel', ariaLabel: 'Person', data: { key: 'te-person' } }, el('option', { value: '', textContent: 'Nobody' }), ...Team.members().filter((m) => m.role !== 'viewer').map((m) => el('option', { value: m.user_id, textContent: m.name }))) : null;
if (person) person.value = t ? t.assignee || '' : '';
// Prompt 020 (E1): precio, "done when" y "after" (modo Team).
const reward = Team.on() ? el('select', { className: 'hsel', ariaLabel: 'Reward', data: { key: 'te-reward' } }, ...Store.REWARDS.map((v) => el('option', { value: String(v), textContent: v ? '$' + v + ' · ' + ({ 5: 'Small', 10: 'Medium', 20: 'Large' })[v] : 'No reward (not paid)' }))) : null;
if (reward) reward.value = String(t ? Store.rewardOf(t) : 0);
const doneWhen = Team.on() ? el('input', { type: 'text', className: 'pn-input', value: t ? t.doneWhen || '' : '', maxLength: 300, placeholder: 'One sentence that can be checked', ariaLabel: 'Done when', data: { key: 'te-donewhen' } }) : null;
const after = Team.on() ? el('select', { className: 'hsel', ariaLabel: 'Unlocks after', data: { key: 'te-after' } }, el('option', { value: '', textContent: 'Nothing — open now' }), ...Store.tasksOf(p.id).filter((x) => !t || x.id !== t.id).map((x) => el('option', { value: x.id, textContent: 'Task ' + x.id + ' · ' + x.title }))) : null;
if (after) after.value = t ? t.after || '' : '';
// Prompt 020-B (A1): el detalle. Context y Notes son texto; los demás, una línea por elemento.
const dv = t ? Store.detailOf(t) : {};
const detailInputs = Store.DETAIL_SECTIONS.map((sec) => ({ sec, input: el('textarea', { className: 'pn-input te-area', rows: sec.kind === 'text' ? 3 : 4, value: sec.kind === 'text' ? dv[sec.key] || '' : (dv[sec.key] || []).join('\n'), ariaLabel: sec.name, data: { key: 'te-d|' + sec.key } }) }));
const detailsValue = () => Object.fromEntries(detailInputs.map((x) => [x.sec.key, x.input.value]));
const detailBox = el('details', { className: 'te-details', data: { key: 'te-details' } }, el('summary', { textContent: 'Task details · steps, tests and more' }),
...detailInputs.map((x) => el('label', { className: 'te-field' }, el('span', {}, x.sec.name + ' ', el('small', { textContent: x.sec.kind === 'text' ? '(text)' : '(one per line)' })), x.input)));
detailBox.open = !!(t && Store.hasDetail(t)) || opts.focus === 'details';
const extras = () => ({ ...(reward ? { reward: Number(reward.value), doneWhen: doneWhen.value, after: after.value } : {}), details: detailsValue() });
const save = btn(t ? 'Save' : 'Create task', () => {
if (!title.value.trim()) { title.focus(); notify(T().review.needTitle); return; }
if (t) Actions.updateTask(p.id, t.id, { title: title.value, mission: mission.value, deliverables: deliv.value, files: planned.value, ...(person ? { assignee: person.value || null } : {}), ...extras() });
else {
const id = Actions.createTask({ projectId: p.id, workflow: wf.value, workflowName: wfName.value, title: title.value, mission: mission.value, deliverables: deliv.value, files: planned.value, assignee: person ? person.value || null : null, ...extras() });
if (id) { closePanel(); select({ kind: 'task', id }); LayoutHost.focus({ task: id }); notify(T().review.created(id)); return; }
}
closePanel();
}, { className: 'primary', data: { key: 'te-save' } });
const field = (label, input) => el('label', { className: 'te-field' }, el('span', { textContent: label }), input);
root.append(field('Workflow', wf), wfName, field('Title', title), field('Mission', mission), ...(reward ? [field('Reward', reward), field('Done when', doneWhen), field('Unlocks after', after)] : []), detailBox, field('Deliverables · uploaded to Tasky for review (comma-separated)', deliv), field('Planned files · code this task touches (comma-separated)', planned), ...(person ? [field('Person', person)] : []), el('div', { className: 'pn-actions' }, save));
if (t) {
const confirm = el('div', { className: 'pn-confirm', hidden: true }, el('span', { textContent: 'Delete ' + t.id + ' “' + t.title + '”? It goes to the Trash for ' + Actions.TRASH_DAYS + ' days. ' }),
btn('Yes, delete', () => { closePanel(); focus = null; Actions.deleteTask(p.id, t.id); notify(T().review.deleted(t.id)); }, { className: 'danger', data: { key: 'te-del-yes' } }), btn('No', () => { confirm.hidden = true; }));
root.append(el('div', { className: 'pn-actions' }, btn('Delete task', () => { confirm.hidden = false; }, { className: 'danger', data: { key: 'te-del' } })), confirm);
}
if (opts.focus === 'reward' && reward) reward.focus(); else if (opts.focus === 'details') detailInputs[1].input.focus(); else title.focus();
}
// ---- Reportes (A6): periodo y filtros; se ve aquí y se descarga como .md ----
let reportOpts = { period: 'days', days: 7, from: '', to: '', projectId: '', workflow: '', person: '', agent: '', universe: '' };
function openReport() {
const root = openPanel('report', 'Report');
root.classList.add('wide');
const R = T().report2;
const sel = (key, label, options, value) => { const s0 = el('select', { className: 'hsel', ariaLabel: label, data: { key: 'rp-' + key } }, ...options.map(([v, l]) => el('option', { value: v, textContent: l }))); s0.value = value; return s0; };
const period = sel('period', 'Period', [['today', 'Today'], ['days', 'Last 7 days'], ['range', 'Between two dates']], reportOpts.period);
const from = el('input', { type: 'date', className: 'hsel', value: reportOpts.from || Store.today(), ariaLabel: 'From', data: { key: 'rp-from' } });
const to = el('input', { type: 'date', className: 'hsel', value: reportOpts.to || Store.today(), ariaLabel: 'To', data: { key: 'rp-to' } });
const projects = Store.get().projects.filter((p) => !p.archived);
const universes = [...new Set(Store.get().workflows.map((w) => w.universe || 'Main'))].sort();
const fu = sel('universe', 'Universe', [['', 'All universes'], ...universes.map((x) => [x, x])], reportOpts.universe);
fu.hidden = universes.length < 2;
const fp = sel('project', 'Project', [['', 'All projects'], ...projects.map((p) => [p.id, p.name])], reportOpts.projectId);
const fw = el('select', { className: 'hsel', ariaLabel: 'Workflow', data: { key: 'rp-workflow' } });
const fillWf = () => { fw.textContent = ''; fw.append(el('option', { value: '', textContent: 'All workflows' }), ...(fp.value ? Store.workflowsOf(fp.value).map((w) => el('option', { value: w.id, textContent: w.name })) : [])); fw.value = fp.value ? reportOpts.workflow : ''; fw.disabled = !fp.value; };
const fpe = sel('person', 'Person', [['', 'All people'], ...(Team.on() ? Team.members().map((m) => [m.user_id, m.name]) : [])], reportOpts.person);
fpe.hidden = !Team.on();
const agentNames = [...new Set([...Store.get().agents.map((a) => a.name), ...Store.get().history.filter((e) => e.agentName).map((e) => e.agentName)])].sort();
const fa = sel('agent', 'Agent', [['', 'All agents'], ...agentNames.map((n) => [n, n])], reportOpts.agent);
const out = el('div', { className: 'report-out', data: { key: 'report-out' } });
let md = '';
const draw = () => {
reportOpts = { period: period.value, days: 7, from: from.value, to: to.value, projectId: fp.value, workflow: fw.value, person: fpe.value, agent: fa.value, universe: fu.value };
from.hidden = to.hidden = period.value !== 'range';
md = Reports.markdown(Reports.build(reportOpts));
out.textContent = '';
out.append(renderMarkdown(md));
};
for (const n of [period, from, to, fu, fw, fpe, fa]) n.addEventListener('change', draw);
fp.addEventListener('change', () => { reportOpts.workflow = ''; fillWf(); draw(); });
fillWf();
const dl = btn('Download .md', () => { const f = 'tasky-report-' + Store.today() + '.md'; download(f, md); notify('Downloaded: ' + f + '.'); }, { className: 'primary', data: { key: 'rp-download' } });
root.append(el('p', { className: 'fnote', textContent: R.hint }), el('div', { className: 'pn-actions hfilters' }, period, from, to), el('div', { className: 'pn-actions hfilters' }, fu, fp, fw, fpe, fa), el('div', { className: 'pn-actions' }, dl), out);
draw();
period.focus();
}
// Un Markdown simple (el del reporte) como HTML seguro: todo va como texto.
function renderMarkdown(text) {
const box = el('div', { className: 'md' });
let table = null;
for (const line of String(text).split('\n')) {
if (/^\|/.test(line)) {
if (/^\|[-| ]+\|$/.test(line)) continue;
const cells = line.replace(/^\||\|$/g, '').split(/(? c.trim().replace(/\\\|/g, '|'));
if (!table) { table = el('table', { className: 'md-table' }); box.append(table); table.append(el('tr', {}, ...cells.map((c) => el('th', { textContent: c })))); }
else table.append(el('tr', {}, ...cells.map((c) => el('td', { textContent: c }))));
continue;
}
table = null;
if (/^# /.test(line)) box.append(el('h2', { textContent: line.slice(2) }));
else if (/^## /.test(line)) box.append(el('h3', { textContent: line.slice(3) }));
else if (/^### /.test(line)) box.append(el('h4', { textContent: line.slice(4) }));
else if (/^- /.test(line)) box.append(el('p', { className: 'md-li', textContent: '• ' + line.slice(2) }));
else if (line.trim()) box.append(el('p', { textContent: line }));
}
return box;
}
// ---- Archivos previstos (A5): lo que la tarea va a tocar en el código, frente a lo que los agentes tocaron ----
// Distinto de los entregables (deliverables): esos se suben a Tasky para revisión.
const ACTIVE = ['doing', 'needs-you', 'blocked', 'review'];
function collisions(pid) {
const by = new Map();
for (const t of Store.tasksOf(pid).filter((x) => ACTIVE.includes(x.status))) for (const f of new Set([...(t.files || []), ...(t.touched || [])])) { if (!by.has(f)) by.set(f, []); by.get(f).push(t); }
const out = [];
for (const [file, ts] of by) if (ts.length > 1) out.push({ file, tasks: ts });
return out;
}
const holder = (p, t) => { const a = Store.agentOn(p.id, t.id); const person = Team.on() && t.assignee ? Team.nameOf(t.assignee) : ''; return [person, a ? a.name : ''].filter(Boolean).join(' · ') || T().files.nobody; };
function plannedSection(box, p, t) {
const planned = t.files || [], touched = t.touched || [];
const clash = collisions(p.id).filter((c) => c.tasks.includes(t));
if (!planned.length && !touched.length) return;
const sec = el('section', { className: 'files planned', ariaLabel: 'Planned files' });
sec.append(el('p', { className: 'k', textContent: T().files.plannedTitle }));
const group = (label, list, cls, mark) => { if (!list.length) return; sec.append(el('p', { className: 'fsub', textContent: label }), el('p', { className: 'freq' }, ...list.map((f) => el('span', { className: 'fchip ' + cls, textContent: mark + ' ' + f })))); };
group(T().files.plannedTouched, planned.filter((f) => touched.includes(f)), 'ok', '✓');
group(T().files.plannedNotYet, planned.filter((f) => !touched.includes(f)), 'pending', '○');
group(T().files.touchedUnplanned, touched.filter((f) => !planned.includes(f)), 'extra', '+');
for (const c of clash) for (const o of c.tasks.filter((x) => x !== t)) sec.append(el('p', { className: 'rwarn clash', role: 'alert', data: { key: 'clash|' + o.id + '|' + c.file } }, T().files.clash(c.file, o.id + ' ' + o.title, holder(p, o))));
box.append(sec);
}
function filesSection(box, p, t) {
const req = t.deliverables || [];
if (!Team.on()) {
if (!req.length) return;
box.append(el('p', { className: 'k', textContent: 'Files' }), el('p', { className: 'fsub', textContent: T().files.deliverablesTitle }), el('p', { className: 'freq' }, ...req.map((n) => el('span', { className: 'fchip', textContent: n }))), el('p', { className: 'fnote', textContent: T().files.personal }));
return;
}
const all = filesOf(p, t), done = deliveredNames(p, t);
const sec = el('section', { className: 'files', ariaLabel: 'Files' });
sec.append(el('p', { className: 'k', textContent: 'Files' }));
if (req.length) {
sec.append(el('p', { className: 'fsub', textContent: 'Requested · ' + T().files.deliverablesTitle }));
const chips = el('p', { className: 'freq' });
for (const n of req) {
const ok = done.has(n);
const latest = all.filter((x) => x.kind === 'delivery' && x.name === n).reduce((m, x) => Math.max(m, x.version), 0);
chips.append(el('span', { className: 'fchip ' + (ok ? 'ok' : 'pending'), textContent: (ok ? '✓ ' : '○ ') + n + (ok ? ' · v' + latest : ' · pending'), title: ok ? 'Delivered' : 'Not delivered yet' }));
}
sec.append(chips);
}
const group = (kind) => {
const byName = new Map();
for (const x of all.filter((y) => y.kind === kind).sort((a, b) => b.version - a.version)) { if (!byName.has(x.name)) byName.set(x.name, []); byName.get(x.name).push(x); }
return byName;
};
const starting = group('starting'), delivered = group('delivery');
if (starting.size || Team.can('starting')) {
sec.append(el('p', { className: 'fsub', textContent: 'Starting files' }));
for (const [, vs] of starting) sec.append(fileRow(vs[0]));
if (!starting.size) sec.append(el('p', { className: 'fnote', textContent: T().files.noStarting }));
}
sec.append(el('p', { className: 'fsub', textContent: 'Delivered' }));
if (!delivered.size) sec.append(el('p', { className: 'fnote', textContent: T().files.none }));
else { const g = mediaGallery(all.filter((x) => x.kind === 'delivery'), 'Delivery'); if (g) sec.append(g); }
for (const [name, vs] of delivered) {
sec.append(fileRow(vs[0]));
if (vs.length > 1) {
const key = p.id + '|' + t.id + '|' + name, isOpen = openVersions.has(key);
sec.append(btn((isOpen ? '▾ ' : '▸ ') + (vs.length - 1) + (vs.length === 2 ? ' older version' : ' older versions'), () => { if (isOpen) openVersions.delete(key); else openVersions.add(key); renderDetail(); }, { className: 'linkish fmore', ariaExpanded: String(isOpen), data: { key: 'fold|' + key } }));
if (isOpen) for (const old of vs.slice(1)) sec.append(fileRow(old, true));
}
}
if (Team.can('upload')) {
const input = el('input', { type: 'file', multiple: true, hidden: true });
const note = el('input', { type: 'text', className: 'fnote-in', placeholder: T().files.notePlaceholder, maxLength: 500, ariaLabel: 'Note for this upload (optional)', data: { key: 'fnote' } });
const kindSel = Team.can('starting') ? el('select', { className: 'fkind', ariaLabel: 'Upload as', data: { key: 'fkind' } }, el('option', { value: 'delivery', textContent: 'as a delivery' }), el('option', { value: 'starting', textContent: 'as a starting file' })) : null;
const send = async (files) => {
const res = await Team.uploadFiles(p.id, t.id, files, { kind: kindSel ? kindSel.value : 'delivery', note: note.value });
const bad = res.filter((x) => x.error), good = res.filter((x) => !x.error);
notify([...good.map((x) => T().files.uploaded(x.name, x.version)), ...bad.map((x) => x.error)].join('\n'));
};
input.addEventListener('change', () => { const fs2 = input.files; send(Array.from(fs2)); input.value = ''; });
const zone = el('div', { className: 'fdrop', role: 'group', ariaLabel: 'Upload files' },
el('span', { textContent: T().files.drop(Team.maxMB()) }), btn('Choose files', () => input.click(), { className: 'dbtn small', data: { key: 'fpick' } }));
zone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); zone.classList.add('over'); });
zone.addEventListener('dragleave', () => zone.classList.remove('over'));
zone.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); zone.classList.remove('over'); document.body.classList.remove('dragging'); if (e.dataTransfer && e.dataTransfer.files.length) send(Array.from(e.dataTransfer.files)); });
// 020-B (A2): un video largo (o cualquier página) va como enlace: una ficha que abre en otra pestaña.
const link = el('input', { type: 'url', className: 'fnote-in', placeholder: T().files.linkPlaceholder, ariaLabel: 'Paste a link', data: { key: 'flink' } });
const addLink = async () => {
const r = await Team.addLink(p.id, t.id, link.value, { kind: kindSel ? kindSel.value : 'delivery', note: note.value });
if (r.error) { notify(r.error); link.focus(); return; }
link.value = ''; notify(T().files.linked(r.name));
};
link.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addLink(); } });
sec.append(zone, el('div', { className: 'frow' }, note, kindSel), el('div', { className: 'flinkrow' }, link, btn('Add link', addLink, { className: 'dbtn small', data: { key: 'flink-go' } })), input);
}
// El espacio que usan los archivos (solo el dueño): este proyecto y todo el equipo frente a storageLimitMB, con aviso
// al 80 % (el mismo cálculo que la pantalla Team).
if (Team.role() === 'owner') {
const u = Team.storageUsage(), here = Store.get().files.filter((x) => x.projectId === p.id && !x.deleted).reduce((a, x) => a + (Number(x.size) || 0), 0), pct = Math.round(100 * u.pct);
const mb = (n) => (n / 1048576 < 10 ? (n / 1048576).toFixed(1) : Math.round(n / 1048576)) + ' MB';
sec.append(el('p', { className: 'fspace' + (u.near ? ' warn' : ''), role: u.near ? 'alert' : null, data: { key: u.near ? 'fspace-warn' : 'fspace' }, textContent: u.near ? T().files.spaceWarn(pct, mb(u.used), mb(u.limit)) : T().files.space(mb(here), mb(u.used), mb(u.limit), pct) }));
}
box.append(sec);
}
async function previewFile(fl) {
const root = openPanel('preview', fl.name + ' · v' + fl.version);
const pre = el('pre', { className: 'pn-text', tabIndex: 0, ariaLabel: 'Content of ' + fl.name, textContent: 'Loading…' });
root.append(el('p', { className: 'fnote', textContent: T().files.previewNote }), pre, el('div', { className: 'pn-actions' }, btn('Download', () => Team.download(fl), { className: 'primary', data: { key: 'pv-dl' } })));
const text = await Team.readText(fl);
// Siempre como texto: textContent nunca interpreta HTML ni ejecuta nada.
pre.textContent = text == null ? T().files.noPreview : text;
}
// ---- Conversación: hilo de una tarea y chat del proyecto (texto simple, siempre escapado) ----
// Los enlaces http(s) se vuelven enlaces seguros (otra pestaña, sin referrer); @nombre de alguien del equipo se resalta.
function messageBody(text) {
const out = el('p', { className: 'mbody' });
const names = Team.members().map((m) => m.name).sort((a, b) => b.length - a.length);
const re = /\bhttps?:\/\/[^\s<>"']+|@[^\s@]+(?: [^\s@]+)?/g;
let last = 0, mt;
while ((mt = re.exec(text))) {
let tok = mt[0];
if (tok[0] === '@') {
const hit = names.find((n) => text.slice(mt.index + 1, mt.index + 1 + n.length).toLowerCase() === n.toLowerCase());
if (!hit) { re.lastIndex = mt.index + 1; continue; }
tok = '@' + text.slice(mt.index + 1, mt.index + 1 + hit.length);
re.lastIndex = mt.index + tok.length;
out.append(document.createTextNode(text.slice(last, mt.index)), el('b', { className: 'mention' + (hit.toLowerCase() === Team.myName().toLowerCase() ? ' me' : ''), textContent: tok }));
} else {
tok = tok.replace(/[.,;:!?)]+$/, '');
re.lastIndex = mt.index + tok.length;
const a = el('a', { href: tok, target: '_blank', rel: 'noopener noreferrer', textContent: tok });
out.append(document.createTextNode(text.slice(last, mt.index)), a);
}
last = mt.index + tok.length;
}
out.append(document.createTextNode(text.slice(last)));
return out;
}
let editingMsg = null, openThread = null;
function threadMessages(projectId, taskId) { return Store.get().messages.filter((m) => m.projectId === projectId && (m.taskId || null) === (taskId || null)).sort((a, b) => a.at - b.at); }
function openConversation(projectId, taskId) {
const p = Store.project(projectId);
if (!p) return;
const task = taskId ? Store.task(projectId, taskId) : null;
const root = openPanel(taskId ? 'thread' : 'chat', taskId ? 'Thread · ' + taskId + ' ' + (task ? task.title : '') : 'Chat · ' + p.name);
openThread = { projectId, taskId: taskId || null };
const list = el('div', { className: 'mlist', role: 'log', ariaLive: 'polite' });
const fill = () => {
list.textContent = '';
const msgs = threadMessages(projectId, taskId);
if (!msgs.length) list.append(el('p', { className: 'pempty', textContent: taskId ? T().chat.emptyThread : T().chat.emptyChat }));
for (const m of msgs) {
const mine = m.author === Team.myId();
const item = el('article', { className: 'msg' + (mine ? ' mine' : '') + (m.deleted ? ' deleted' : ''), data: { id: m.id } });
const who = Team.memberOf(m.author);
item.append(el('header', {}, typeof Ships !== 'undefined' && who ? Ships.icon(who, 16) : null, el('b', { textContent: (who ? who.name : m.authorName || 'Someone') + (mine ? ' (you)' : '') }), el('small', { textContent: ' ' + clock(m.at) + (m.editedAt && !m.deleted ? ' · edited' : '') })));
if (m.deleted) item.append(el('p', { className: 'mbody gone', textContent: T().chat.deleted }));
else if (editingMsg === m.id) {
const ta = el('textarea', { className: 'needsin', rows: 2, value: m.body, ariaLabel: 'Edit your message', data: { key: 'msg-edit-in' } });
item.append(ta, el('div', { className: 'dbtns' }, btn('Save', () => { editingMsg = null; Actions.editMessage(m.id, ta.value); fill(); }, { className: 'dbtn small go', data: { key: 'msg-save' } }), btn('Cancel', () => { editingMsg = null; fill(); }, { className: 'dbtn small' })));
} else {
item.append(messageBody(m.body));
if (mine && Team.can('comment')) item.append(el('div', { className: 'macts' }, btn('Edit', () => { editingMsg = m.id; fill(); }, { className: 'linkish', ariaLabel: 'Edit your message', data: { key: 'msg-edit|' + m.id } }), btn('Delete', () => Actions.deleteMessage(m.id), { className: 'linkish', ariaLabel: 'Delete your message', data: { key: 'msg-del|' + m.id } })));
}
list.append(item);
}
list.scrollTop = list.scrollHeight;
Team.markRead(projectId, taskId);
};
fill();
Team.setPanelRefresh(() => { if (document.querySelector('#panel.panel-thread, #panel.panel-chat') && !document.activeElement?.matches?.('[data-key="msg-edit-in"]')) fill(); });
root.append(list);
if (Team.can('comment')) {
const ta = el('textarea', { className: 'needsin mcompose', rows: 3, placeholder: T().chat.placeholder(Team.members().filter((m) => m.user_id !== Team.myId()).map((m) => m.name)), maxLength: 4000, ariaLabel: 'Write a message', data: { key: 'msg-in' } });
const send = () => { if (!ta.value.trim()) return; Actions.postMessage(projectId, taskId, ta.value); ta.value = ''; fill(); ta.focus(); };
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); } });
root.append(ta, el('div', { className: 'pn-actions' }, btn('Send', send, { className: 'primary', data: { key: 'msg-send' } }), el('small', { className: 'fnote', textContent: T().chat.hint })));
ta.focus();
}
LayoutHost.push();
}
// ---- Campana de notificaciones ----
function notifText(n) {
const t = n.taskId ? (Store.task(n.projectId, n.taskId) || { id: n.taskId, title: '' }) : null;
const task = t ? t.id + (t.title ? ' ' + t.title : '') : 'the chat';
const a = n.actorName || 'Someone';
return ({ assigned: a + ' assigned you ' + task, mention: a + ' mentioned you in ' + task + ': “' + n.body + '”', review: a + ' sent ' + task + ' for review', approved: a + ' approved ' + task,
sent_back: a + ' asked you to check ' + task + ': “' + n.body + '”', rejected: a + ' rejected ' + task + (n.body ? ': “' + n.body + '”' : ''), suggestion: 'Your suggestion became a task: ' + task, needs_you: task + ' needs you: ' + n.body, message: a + ' wrote in ' + task + ': “' + n.body + '”',
took: a + ' took ' + n.body, uploaded: a + ' uploaded files to ' + n.body, suggested: a + ' sent a suggestion: “' + n.body + '”', finding: a + ' sent a finding: “' + n.body + '”',
stale: n.body, announcement: 'Announcement: ' + n.body, chat: a + ': ' + n.body, level: n.body })[n.kind] || n.body;
}
const unreadNotifs = () => Store.get().notifications.filter((n) => !n.readAt);
// Prompt 021 (G3): un aviso nuevo entra arriba a la derecha, pegado a la barra, se queda unos 6 s y se desvanece. La
// campana conserva el número de no leídos. Como mucho 3 a la vez.
const toastSeen = new Set();
// 021 (G11): al subir de nivel, una celebración breve (y un aviso al líder, desde el servidor, una vez por nivel).
function levelCheck() {
if (!Team.on() || !Team.isEntered() || Team.role() === 'viewer') return;
const mine = Team.statOf(Team.myId());
if (!mine) return;
const lv = levelOf(mine), key = 'tasky:team:level:' + Team.myId();
let last = null; try { last = JSON.parse(localStorage.getItem(key) || 'null'); } catch {}
try { localStorage.setItem(key, JSON.stringify(lv)); } catch {}
if (!last || lv.rank <= last.rank) return;
let zone = document.getElementById('ntoasts');
if (!zone) { zone = el('div', { id: 'ntoasts', ariaLive: 'polite' }); document.body.append(zone); }
const t = el('div', { className: 'ntoast levelup', role: 'status', data: { key: 'levelup' } }, el('b', { textContent: 'New level: ' + lv.name + '!' }), el('small', { textContent: 'Well done. ' + (Team.ownerName() || 'The team lead') + ' will see it.' }));
zone.append(t);
setTimeout(() => t.classList.add('out'), 6000); setTimeout(() => t.remove(), 6700);
if (Team.role() !== 'owner') Team.notifyLevel(lv.name);
}
function notifToast(id) {
const n = Store.get().notifications.find((x) => x.id === id);
if (!n || toastSeen.has(id) || n.readAt) return;
toastSeen.add(id);
let zone = document.getElementById('ntoasts');
if (!zone) { zone = el('div', { id: 'ntoasts', ariaLive: 'polite' }); document.body.append(zone); }
// Debajo de la barra de arriba (y de su fila de herramientas), alineado a su borde derecho: justo bajo «Tour».
const head = document.getElementById('head'), row2 = document.getElementById('row2');
if (head) { const r = head.getBoundingClientRect(), r2 = row2 && !row2.hidden ? row2.getBoundingClientRect() : r; zone.style.top = Math.round(Math.max(r.bottom, r2.bottom) + 6) + 'px'; zone.style.right = Math.max(12, Math.round(innerWidth - r.right)) + 'px'; }
while (zone.children.length >= 3) zone.firstChild.remove();
const go = () => { Actions.markNotificationsRead([n.id]); t.remove(); if (n.kind === 'chat') { openChat(); return; } if (n.kind === 'announcement') { openNews(); return; } if (n.taskId && Store.task(n.projectId, n.taskId)) goTo(n.projectId, { kind: 'task', id: n.taskId }); };
const t = btn('', go, { className: 'ntoast k-' + n.kind, role: 'status', data: { key: 'ntoast|' + n.id } }, el('b', { textContent: notifText(n) }), el('small', { textContent: 'just now' }));
zone.append(t);
setTimeout(() => t.classList.add('out'), 6000);
setTimeout(() => t.remove(), 6700);
}
function renderBell() {
const box = $('#bell');
if (!box) return;
box.hidden = !Team.on() || !Team.isEntered() || teamUI();
box.textContent = '';
if (box.hidden) return;
const n = unreadNotifs().length;
const b = btn('', openBell, { className: 'bell' + (n ? ' has' : ''), data: { key: 'bell' }, ariaLabel: 'Notifications: ' + n + ' unread' });
b.append(svg('', 'bicon'), el('span', { textContent: 'Notifications' }), el('span', { className: 'ibox-count', textContent: String(n) }));
box.append(b);
}
function openBell() {
const root = openPanel('notifications', 'Notifications');
const fill = () => {
root.querySelectorAll('.nlist2, .pn-actions, .pempty').forEach((x) => x.remove());
const all = Store.get().notifications.slice().sort((a, b) => b.at - a.at);
root.append(el('div', { className: 'pn-actions' }, btn('Mark all as read', () => { Actions.markNotificationsRead(); fill(); }, { className: 'primary', data: { key: 'bell-all' }, disabled: !unreadNotifs().length })));
if (!all.length) { root.append(el('p', { className: 'pempty', textContent: T().chat.noNotifications })); return; }
const ul = el('ul', { className: 'pn-list nlist2' });
for (const n of all.slice(0, 200)) {
const b = btn('', () => { Actions.markNotificationsRead([n.id]); closePanel(); if (n.taskId && Store.task(n.projectId, n.taskId)) goTo(n.projectId, { kind: 'task', id: n.taskId }); else if (n.projectId && Store.project(n.projectId)) { Actions.selectProject(n.projectId); if (n.kind === 'mention' || n.kind === 'message') openConversation(n.projectId, null); } },
{ className: 'hrow' + (n.readAt ? ' read' : ' unread'), data: { key: 'notif|' + n.id } });
b.append(el('span', { className: 'htime', textContent: ago(n.at) }), el('span', { className: 'htext', textContent: notifText(n) + (n.taskId && Store.task(n.projectId, n.taskId) ? uniTag(Store.universeOf(n.projectId, Store.task(n.projectId, n.taskId).workflow)) : '') }));
ul.append(el('li', { className: 'hitem' }, b));
}
root.append(ul);
};
fill();
Team.setPanelRefresh(() => { if (document.querySelector('#panel.panel-notifications')) fill(); });
}
// ---- Prompt 021 (G3): el chat del equipo. Canal "Team" y mensajes directos; texto, imágenes y archivos. ----
let chatWith = null; // null = Team; si no, el user_id de la otra persona
const CHAT_READ = () => 'tasky:team:chatread:' + (Team.myId() || '');
const chatRead = () => { try { return JSON.parse(localStorage.getItem(CHAT_READ()) || '{}'); } catch { return {}; } };
const chatKey = (m) => (m.to_user ? (m.author === Team.myId() ? m.to_user : m.author) : 'team');
function chatUnread(key) {
const seen = chatRead()[key] || 0, me = Team.myId();
return Team.chat().filter((m) => !m.deleted && m.author !== me && chatKey(m) === key && Date.parse(m.created_at) > seen).length;
}
const chatUnreadAll = () => { const keys = new Set(Team.chat().map(chatKey)); let n = 0; for (const k of keys) n += chatUnread(k); return n; };
function markChatRead(key) { const r = chatRead(); r[key] = Date.now(); try { localStorage.setItem(CHAT_READ(), JSON.stringify(r)); } catch {} }
function openChat(withUser) {
if (!Team.on() || !(Team.role() === 'owner' || Team.role() === 'member')) return;
if (withUser !== undefined) chatWith = withUser;
const root = openPanel('chat', 'Chat');
root.classList.add('wide');
const wrap = el('div', { className: 'chatwrap' });
const fill = () => {
wrap.textContent = '';
const key = chatWith || 'team';
markChatRead(key);
const people = Team.members().filter((m) => m.user_id !== Team.myId() && (m.role === 'owner' || m.role === 'member'));
const side = el('nav', { className: 'chatside', ariaLabel: 'Channels' });
const item = (k, label, u) => { const n = k === key ? 0 : chatUnread(k); return btn('', () => { chatWith = u || null; fill(); }, { className: 'chatch' + (k === key ? ' on' : ''), ariaCurrent: k === key ? 'page' : null, data: { key: 'chat-ch|' + k } }, el('span', { textContent: label }), n ? el('span', { className: 'cbadge', textContent: String(n) }) : null); };
side.append(item('team', '# Team'), el('p', { className: 'k', textContent: 'Direct messages' }), ...people.map((m) => item(m.user_id, Team.nameOf(m.user_id) || m.name, m.user_id)));
const msgs = Team.chat().filter((m) => !m.deleted && chatKey(m) === key);
const list = el('div', { className: 'chatlist', data: { key: 'chat-list' } });
if (!msgs.length) list.append(el('p', { className: 'fnote', textContent: chatWith ? 'No messages with ' + (Team.nameOf(chatWith) || 'them') + ' yet.' : 'No messages in Team yet. Say hi.' }));
for (const m of msgs.slice(-300)) {
const mine = m.author === Team.myId();
const box = el('div', { className: 'cmsg' + (mine ? ' mine' : ''), data: { key: 'chat-msg|' + m.id } }, el('small', { className: 'cmeta', textContent: (mine ? 'You' : Team.nameOf(m.author) || m.author_name || 'Someone') + ' · ' + ago(Date.parse(m.created_at)) }));
if (m.body) box.append(messageBody(m.body));
const fls = (m.files || []).map(Team.chatFile);
const imgs = fls.filter((x) => Media.kindOf(x) === 'image');
if (imgs.length) { const g = el('div', { className: 'mgallery' }); for (const x of imgs) { const img = el('img', { alt: x.name, loading: 'lazy' }); Team.mediaUrl(x).then((u) => { if (u) img.src = u; }); g.append(btn('', () => openMedia({ ...x, version: 1 }), { className: 'mthumb', title: x.name, data: { key: 'chat-img|' + x.name } }, img)); } box.append(g); }
for (const x of fls.filter((y) => Media.kindOf(y) !== 'image')) box.append(btn('⤓ ' + x.name, async () => { const u = await Team.chatFileUrl(x.path, true); if (u) { const a = el('a', { href: u, download: x.name, hidden: true }); document.body.append(a); a.click(); a.remove(); } }, { className: 'dbtn small', data: { key: 'chat-file|' + x.name } }));
list.append(box);
}
const text = el('textarea', { className: 'needsin', rows: 2, placeholder: chatWith ? 'Write to ' + (Team.nameOf(chatWith) || 'them') : 'Write to the team', ariaLabel: 'Message', data: { key: 'chat-text' } });
const pick = el('input', { type: 'file', multiple: true, hidden: true });
let pending = [];
const chips = el('div', { className: 'cchips' });
const showChips = () => { chips.textContent = ''; for (const f of pending) chips.append(el('span', { className: 'fchip', textContent: f.name })); };
pick.addEventListener('change', () => { pending = pending.concat(Array.from(pick.files)).slice(0, 10); pick.value = ''; showChips(); });
text.addEventListener('paste', (e) => { const fs2 = e.clipboardData ? Array.from(e.clipboardData.files || []) : []; if (fs2.length) { e.preventDefault(); e.stopPropagation(); pending = pending.concat(fs2).slice(0, 10); showChips(); } });
const send = async () => { const body = text.value; const files = pending; if (!body.trim() && !files.length) return; text.value = ''; pending = []; showChips(); await Team.sendChat(body, chatWith, files); };
text.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } });
const main = el('section', { className: 'chatmain', ariaLabel: chatWith ? 'Messages with ' + (Team.nameOf(chatWith) || '') : 'Team channel' },
el('h3', { className: 'label', textContent: chatWith ? (Team.nameOf(chatWith) || 'Direct') : '# Team' }), list, chips,
el('div', { className: 'chatform' }, text, btn('Attach', () => pick.click(), { className: 'dbtn', data: { key: 'chat-attach' } }), btn('Send', send, { className: 'dbtn go', data: { key: 'chat-send' } }), pick));
wrap.append(side, main);
list.scrollTop = list.scrollHeight;
};
root.append(wrap); fill();
Team.setPanelRefresh(() => { if (document.querySelector('#panel.panel-chat') && !document.activeElement.closest('.chatform')) fill(); else if (document.querySelector('#panel.panel-chat')) markChatRead(chatWith || 'team'); });
}
// ---- Prompt 021 (G10): la ruleta. El premio ya lo decidió el servidor; la ruleta solo lo muestra. ----
// 20 casillas alternadas: 12 Iron, 6 Crystal y 2 Gold (60 %, 30 % y 10 %).
const WHEEL = (() => { const out = Array(20).fill('iron'); for (const i of [0, 10]) out[i] = 'gold'; for (const i of [3, 6, 8, 13, 16, 18]) out[i] = 'crystal'; return out; })();
const MAT_COLOR = { iron: '#9AA3B5', crystal: '#3EE0CF', gold: '#FFD23F' };
const reduceMotion = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
function openWheel() {
const sp = Team.spins().find((x) => x.status === 'ready' && x.user_id === Team.myId()) || Team.spins().find((x) => x.user_id === Team.myId());
const root = openPanel('wheel', 'Bonus');
if (!sp) { root.append(el('p', { className: 'pempty', textContent: 'No gift waiting right now.' })); return; }
const size = 340, r = 160, c = size / 2, n = WHEEL.length, step = 360 / n;
const ns = 'http://www.w3.org/2000/svg';
const svgEl = document.createElementNS(ns, 'svg'); svgEl.setAttribute('viewBox', '0 0 ' + size + ' ' + size); svgEl.setAttribute('class', 'wheel'); svgEl.setAttribute('role', 'img'); svgEl.setAttribute('aria-label', 'Bonus wheel: 12 Iron, 6 Crystal, 2 Gold');
const g = document.createElementNS(ns, 'g'); g.setAttribute('class', 'wheel-rot'); g.style.transformOrigin = c + 'px ' + c + 'px';
WHEEL.forEach((m, i) => {
const a0 = (i * step - 90 - step / 2) * Math.PI / 180, a1 = ((i + 1) * step - 90 - step / 2) * Math.PI / 180;
const p = document.createElementNS(ns, 'path');
p.setAttribute('d', 'M' + c + ' ' + c + ' L' + (c + r * Math.cos(a0)) + ' ' + (c + r * Math.sin(a0)) + ' A' + r + ' ' + r + ' 0 0 1 ' + (c + r * Math.cos(a1)) + ' ' + (c + r * Math.sin(a1)) + ' Z');
p.setAttribute('fill', MAT_COLOR[m]); p.setAttribute('stroke', '#17142B'); p.setAttribute('stroke-width', '2'); p.dataset.slot = String(i); p.dataset.mat = m;
g.append(p);
const am = (i * step - 90) * Math.PI / 180, tx = document.createElementNS(ns, 'text');
tx.setAttribute('x', String(c + r * 0.72 * Math.cos(am))); tx.setAttribute('y', String(c + r * 0.72 * Math.sin(am) + 4)); tx.setAttribute('text-anchor', 'middle'); tx.setAttribute('font-size', '11'); tx.setAttribute('font-weight', '700'); tx.setAttribute('fill', '#17142B');
tx.textContent = m === 'gold' ? 'Gold' : m === 'crystal' ? 'Crys' : 'Iron'; g.append(tx);
});
const hub = document.createElementNS(ns, 'circle'); hub.setAttribute('cx', String(c)); hub.setAttribute('cy', String(c)); hub.setAttribute('r', '22'); hub.setAttribute('fill', '#17142B'); g.append(hub);
const ptr = document.createElementNS(ns, 'path'); ptr.setAttribute('d', 'M' + (c - 14) + ' 4 L' + (c + 14) + ' 4 L' + c + ' 30 Z'); ptr.setAttribute('fill', '#FF6B6B'); ptr.setAttribute('stroke', '#17142B'); ptr.setAttribute('stroke-width', '2');
svgEl.append(g, ptr);
const result = el('p', { className: 'wresult', role: 'status', data: { key: 'wheel-result' } });
const show = (res) => { result.textContent = 'You won 1 ' + res.material.charAt(0).toUpperCase() + res.material.slice(1) + ' (+$' + res.amount + ')!'; result.classList.add('on'); root.classList.add('won'); };
const land = (res, animate) => {
const slots = WHEEL.map((m, i) => (m === res.material ? i : -1)).filter((i) => i >= 0);
const slot = slots[Math.floor((res.id ? parseInt(String(res.id).replace(/[^0-9a-f]/g, '').slice(0, 6), 16) : 0) % slots.length)];
const deg = 360 * 6 + (360 - slot * step);
if (!animate) { g.style.transition = 'none'; g.style.transform = 'rotate(' + (deg % 360) + 'deg)'; show(res); return; }
g.style.transition = 'transform 4s cubic-bezier(.12,.72,.14,1)'; requestAnimationFrame(() => { g.style.transform = 'rotate(' + deg + 'deg)'; });
setTimeout(() => show(res), 4100);
g.dataset.slot = String(slot);
};
const spinBtn = btn('Spin the wheel', async () => {
spinBtn.disabled = true;
const res = await Team.spinBonus(sp.id); // el servidor lo apunta ya: si se recarga a mitad, se ve el resultado
if (!res) { spinBtn.disabled = false; return; }
spinBtn.hidden = true; land(res, !reduceMotion());
}, { className: 'bigbtn go', data: { key: 'wheel-spin' } });
root.append(el('p', { className: 'k', textContent: sp.source === 'duel' ? 'You won the duels of the week.' : 'Four tasks approved: this gift is yours.' }), el('div', { className: 'wheelbox' }, svgEl), result);
if (sp.status === 'spun') { land({ id: sp.id, material: sp.material, amount: sp.amount }, false); return; }
root.append(spinBtn);
}
// ---- Prompt 021 (G10): encuentros y duelos «Star catch» ----
let duelOpen = null, duelAsked = new Set();
function duelSeen(row) {
if (!row || !Team.isEntered()) return;
const me = Team.myId();
if (row.status === 'invited' && row.b === me && !duelAsked.has(row.id)) { duelAsked.add(row.id); render(); }
if (row.status === 'playing' && (row.a === me || row.b === me) && duelOpen !== row.id) openDuel(row.id);
if (duelOpen === row.id) drawDuel();
}
// Dos personas conectadas, con su nave en el mismo planeta: «Duel Leo?» a las dos.
function encounters() {
if (!Team.on() || !Team.isEntered() || !(Team.role() === 'owner' || Team.role() === 'member') || !Team.teamSettings().duels) return [];
const mine = focus && focus.kind === 'task' ? focus.id : null;
if (!mine) return [];
const today = new Date(Team.serverNow()).toISOString().slice(0, 10), me = Team.myId();
return Team.people().filter((x) => x.focus && x.focus.kind === 'task' && x.focus.id === mine && x.userId !== me && Team.members().some((m) => m.user_id === x.userId && m.role !== 'viewer'))
.filter((x) => !Team.duels().some((d) => ((d.a === me && d.b === x.userId) || (d.b === me && d.a === x.userId)) && d.day === today && d.status !== 'declined'));
}
let duelAskSig = '';
// La invitación al duelo y el aviso de abajo nunca se tapan: la invitación se pone encima del aviso.
function placeDuelAsk() {
const box = document.getElementById('duelask'), n = document.getElementById('notice');
if (!box) return;
const nr = n && !n.hidden && document.body.classList.contains('team-ui') ? n.getBoundingClientRect() : null;
box.style.bottom = nr && nr.height ? Math.round(innerHeight - nr.top + 10) + 'px' : '';
}
function renderDuelAsk() {
let box = document.getElementById('duelask');
if (!box) { box = el('div', { id: 'duelask', ariaLive: 'polite' }); document.body.append(box); }
placeDuelAsk();
if (!Team.on() || !Team.isEntered()) { box.textContent = ''; duelAskSig = ''; return; }
const me = Team.myId();
// Se redibuja solo si cambió algo (así un clic no se pierde a mitad).
const sig = JSON.stringify([Team.duels().filter((x) => x.status === 'invited' && (x.b === me || x.a === me)).map((x) => x.id + x.status), encounters().map((x) => x.userId)]);
if (sig === duelAskSig && box.childNodes.length + (sig === '[[],[]]' ? 0 : 0) >= 0) return;
duelAskSig = sig;
box.textContent = '';
for (const d of Team.duels().filter((x) => x.status === 'invited' && x.b === me)) box.append(el('div', { className: 'dask', data: { key: 'duel-invite|' + d.id } }, el('b', { textContent: (d.a_name || 'Someone') + ' wants a duel: Star catch' }),
btn('Accept', () => Team.duelAnswer(d.id, true), { className: 'dbtn go', data: { key: 'duel-accept' } }), btn('Not now', () => Team.duelAnswer(d.id, false), { className: 'dbtn', data: { key: 'duel-decline' } })));
for (const x of encounters()) {
if (Team.duels().some((d) => d.status === 'invited' && d.a === me && d.b === x.userId)) { box.append(el('div', { className: 'dask', data: { key: 'duel-wait|' + x.userId } }, el('b', { textContent: 'Waiting for ' + (Team.nameOf(x.userId) || x.name) + '…' }))); continue; }
box.append(el('div', { className: 'dask', data: { key: 'duel-ask|' + x.userId } }, el('b', { textContent: 'You and ' + (Team.nameOf(x.userId) || x.name) + ' are on the same planet' }), btn('Duel ' + (Team.nameOf(x.userId) || x.name) + '?', () => Team.duelInvite(x.userId), { className: 'dbtn go', data: { key: 'duel-go' } })));
}
}
function openDuel(id) {
duelOpen = id;
const root = openPanel('duel', 'Star catch');
root.append(el('div', { className: 'duelbody', data: { key: 'duel-body' } }));
drawDuel();
const tick = () => { if (duelOpen !== id || !document.querySelector('#panel.panel-duel')) { duelOpen = null; return; } drawDuel(true); requestAnimationFrame(tick); };
requestAnimationFrame(tick);
}
let duelLast = '';
function drawDuel(fromTick) {
const box = document.querySelector('#panel.panel-duel .duelbody');
const d = Team.duels().find((x) => x.id === duelOpen);
if (!box || !d) return;
const me = Team.myId(), meA = d.a === me, other = meA ? d.b_name : d.a_name;
const now = Team.serverNow(), go = d.go_at ? Date.parse(d.go_at) : 0;
// Entre el fin de una ronda y el comienzo de la siguiente, la fila llega en dos cambios: en el primero ya está el
// ganador pero todavía el go_at viejo. Si la ronda ya tiene ganador, no hay estrella (tocar ahí perdería la siguiente).
const decided = (d.rounds || []).some((r) => r.round === d.round);
const starOn = d.status === 'playing' && go && now >= go && !decided, tapped = (meA ? !!d.a_at : !!d.b_at) || decided;
const state = [d.status, d.round, d.a_score, d.b_score, starOn, tapped, d.winner].join('|');
if (fromTick && state === duelLast) return;
duelLast = state;
box.textContent = '';
const mine = meA ? d.a_score : d.b_score, theirs = meA ? d.b_score : d.a_score;
box.append(el('p', { className: 'dscore', data: { key: 'duel-score' }, textContent: 'You ' + mine + ' · ' + theirs + ' ' + other + (d.status === 'playing' ? ' · round ' + d.round + ' of 3' : '') }));
if (d.status === 'done') {
box.append(el('p', { className: 'wresult on', data: { key: 'duel-result' }, textContent: d.winner === me ? 'You won the duel!' : d.winner ? other + ' won this time.' : 'A draw.' }), btn('Close', () => closePanel(), { className: 'bigbtn soft' }));
return;
}
if (d.status !== 'playing') { box.append(el('p', { textContent: 'Waiting for ' + other + '…' })); return; }
const last = (d.rounds || [])[d.rounds.length - 1];
if (last && last.round === d.round - 1) box.append(el('p', { className: 'k', textContent: 'Round ' + last.round + ': ' + (last.winner === me ? 'yours' : other + "'s") + (last.early ? ' (someone tapped too early)' : '') }));
const arena = btn('', () => Team.duelTap(d.id), { className: 'arena' + (starOn ? ' star' : ''), ariaLabel: starOn ? 'Catch the star now' : 'Wait for the star, then tap', disabled: tapped, data: { key: 'duel-arena' } },
el('span', { className: 'astar', ariaHidden: 'true', textContent: starOn ? '★' : '' }), el('span', { className: 'ahint', textContent: tapped ? 'Waiting for ' + other + '…' : starOn ? 'Tap!' : 'Wait for the star… (tapping early loses the round)' }));
box.append(arena);
}
// ---- Prompt 021 (G11): la clasificación del equipo (sin dinero; el líder además ve lo ganado) y los niveles ----
let ranksTab = 'total';
function openRanks() {
if (!Team.on() || !(Team.role() === 'owner' || Team.role() === 'member')) return;
const lead = Team.role() === 'owner';
const root = openPanel('ranks', 'Leaderboard');
root.classList.add('wide');
const body = el('div');
const fill = () => {
body.textContent = '';
const st = Team.stats();
const tabs = el('div', { className: 'dbtns', role: 'tablist' }, ...[['total', 'Total'], ['universe', 'By universe'], ['workflow', 'By workflow'], ['first', 'First try'], ...(Team.teamSettings().duels ? [['duels', 'Duels this week']] : [])].map(([k, n]) => btn(n, () => { ranksTab = k; fill(); }, { className: 'dbtn small' + (ranksTab === k ? ' go' : ''), role: 'tab', ariaSelected: String(ranksTab === k), data: { key: 'rank-tab|' + k } })));
body.append(tabs);
const row = (x, cells) => el('tr', { data: { key: 'rank|' + x.user } }, el('th', { scope: 'row' }, x.name + (x.user === Team.myId() ? ' (you)' : ''), el('span', { className: 'lvbadge', textContent: levelOf(x).name })), ...cells.map((c) => el('td', { textContent: String(c) })), ...(lead ? [el('td', { className: 'earn', textContent: '$' + (x.earned || 0) })] : []));
const table = el('table', { className: 'ovtable', data: { key: 'rank-table' } });
if (ranksTab === 'duels') {
const t2 = el('table', { className: 'ovtable', data: { key: 'duel-table' } }, el('thead', {}, el('tr', {}, el('th', { textContent: 'Person' }), el('th', { textContent: 'Wins' }), el('th', { textContent: 'Played' }))));
const tb = el('tbody'); t2.append(tb);
Team.duelWeek().then((w) => { if (!w.length) tb.append(el('tr', {}, el('td', { colSpan: 3, textContent: 'No duels this week yet.' }))); for (const x of w) tb.append(el('tr', { data: { key: 'duelrank|' + x.user } }, el('th', { textContent: x.name }), el('td', { textContent: String(x.wins) }), el('td', { textContent: String(x.played) }))); });
body.append(el('div', { className: 'ovscroll' }, t2), el('p', { className: 'fnote', textContent: Team.teamSettings().prize ? 'At the end of the week, the first one gets a spin of the bonus wheel.' : 'The weekly prize is off.' }));
return;
}
if (ranksTab === 'total' || ranksTab === 'first') {
const sorted = st.slice().sort((a, b) => ranksTab === 'first' ? (pct(b) - pct(a)) || (b.approved - a.approved) : (b.approved - a.approved) || a.name.localeCompare(b.name));
table.append(el('thead', {}, el('tr', {}, el('th', { textContent: 'Person' }), el('th', { textContent: ranksTab === 'first' ? 'Approved at the first try' : 'Tasks approved' }), ...(lead ? [el('th', { textContent: 'Earned' })] : []))));
const tb = el('tbody'); for (const x of sorted) tb.append(row(x, [ranksTab === 'first' ? pct(x) + '% (' + x.firstTry + ' of ' + x.approved + ')' : x.approved])); table.append(tb);
} else {
const key = ranksTab === 'universe' ? 'byUniverse' : 'byWorkflow';
const cols = [...new Set(st.flatMap((x) => Object.keys(x[key] || {})))].sort();
table.append(el('thead', {}, el('tr', {}, el('th', { textContent: 'Person' }), ...cols.map((c) => el('th', { textContent: c })), ...(lead ? [el('th', { textContent: 'Earned' })] : []))));
const tb = el('tbody'); for (const x of st.slice().sort((a, b) => b.approved - a.approved)) tb.append(row(x, cols.map((c) => (x[key] || {})[c] || 0))); table.append(tb);
if (!cols.length) body.append(el('p', { className: 'fnote', textContent: 'Nothing approved yet.' }));
}
body.append(el('div', { className: 'ovscroll' }, table), el('p', { className: 'fnote', textContent: 'Levels: Rookie, Junior Backend or Frontend Engineer (5 approved in that universe), Full Stack Engineer (10 approved, at least 3 in Back and 3 in Front), Senior Engineer (25), Principal Engineer (50).' }));
};
const pct = (x) => (x.approved ? Math.round(100 * x.firstTry / x.approved) : 0);
root.append(body); fill();
Team.loadStats();
Team.setPanelRefresh(() => { if (document.querySelector('#panel.panel-ranks')) fill(); });
}
// ---- Prompt 021 (G3): anuncios. Solo el líder publica, fija y borra; elige quién lo ve. ----
// 022 (R2): «Check project data» (solo el líder): lo que un handoff dejó a medias, con arreglo de un clic donde se
// puede. El número de problemas va en el icono de la herramienta. Los arreglos pasan por el servidor como siempre.
const SPANISH = /\b(que|para|los|las|una|con|del|por|tarea|equipo|cuando|archivo|prueba|pruebas|usuario|también|está|según)\b|[¿¡ñÑ]/gi;
const notEnglish = (t) => { const txt = [t.title, t.mission, t.doneWhen, ...Object.values(Store.detailOf(t)).flat()].filter((x) => typeof x === 'string').join(' '); const m = txt.match(SPANISH) || []; return m.length >= 2 || /[¿¡]/.test(txt); };
function dataProblems(p) {
if (!p || !Team.on() || Team.role() !== 'owner') return [];
const out = [], tasks = Store.tasksOf(p.id), ids = new Set(tasks.map((t) => t.id.toLowerCase())), wfs = Store.workflowsOf(p.id), wids = new Set(wfs.map((w) => w.id.toLowerCase()));
const open = (t) => t.status !== 'done' && t.status !== 'review';
for (const t of tasks) {
if (!t.assignee && (t.status === 'doing' || t.status === 'review')) out.push({ kind: 'orphan', t, fix: ['Move to Open', () => Actions.setTaskStatus(p.id, t.id, 'todo')] });
// 023: una «Needs you» espera al líder y nadie la toma: no le falta precio.
if (open(t) && t.status !== 'needs-you' && !t.assignee && !Store.rewardOf(t)) out.push({ kind: 'noreward', t, fix: ['Set a price', () => openTaskEditor(p, t, null, { focus: 'reward' })] });
if (open(t) && !t.assignee && Store.rewardOf(t) > 0 && !t.ready) out.push({ kind: 'notready', t, fix: ['Mark Ready', async () => { const r = await Team.setReady(p.id, t.id, true); if (r && r.ok === false) notify(T().path.missing(r.missing || [])); }] });
if (notEnglish(t)) out.push({ kind: 'english', t, fix: ['Edit task', () => openTaskEditor(p, t)] });
if (t.status !== 'done' && ['steps', 'outcomes', 'tests'].some((k) => !(Store.detailOf(t)[k] || []).length)) out.push({ kind: 'detail', t, fix: ['Edit task', () => openTaskEditor(p, t)] });
if (t.after && !ids.has(String(t.after).toLowerCase()) && !wids.has(String(t.after).toLowerCase())) out.push({ kind: 'after', t, fix: ['Remove “after ' + t.after + '”', () => Actions.updateTask(p.id, t.id, { after: '' })] });
}
for (const w of wfs) if (w.after && !wids.has(String(w.after).toLowerCase())) out.push({ kind: 'after', w, fix: ['Remove “after ' + w.after + '”', () => Actions.updateWorkflow(p.id, w.id, { after: '' })] });
return out;
}
const CHECK_KINDS = [
['orphan', 'In progress or in review with nobody on it'], ['noreward', 'Open tasks without a reward'], ['notready', 'Priced tasks not marked Ready'],
['english', 'Text that is not in English'], ['detail', 'Missing Steps, Expected outcomes or How to test'], ['after', '“After” pointing to a task that does not exist'],
];
function openCheck() {
if (!Team.on() || Team.role() !== 'owner') return;
const root = openPanel('check', 'Check project data');
const body = el('div', { data: { key: 'check-body' } });
const fill = () => {
body.textContent = '';
const p = Store.current(), list = dataProblems(p);
const fixes = (Team.ownerLog() || []).filter((x) => x.kind === 'fix');
if (fixes.length) body.append(el('h3', { textContent: 'Fixed by Tasky' }), el('ul', { className: 'chk-list', data: { key: 'check-fixed' } }, ...fixes.slice(0, 10).map((x) => el('li', { textContent: x.text }))));
body.append(el('p', { data: { key: 'check-count' }, textContent: list.length ? list.length + (list.length === 1 ? ' thing to look at in ' : ' things to look at in ') + (p ? p.name : 'this project') + '.' : 'Everything looks right in ' + (p ? p.name : 'this project') + '.' }));
for (const [kind, title] of CHECK_KINDS) {
const rows = list.filter((x) => x.kind === kind);
if (!rows.length) continue;
body.append(el('h3', { textContent: title + ' · ' + rows.length }), el('ul', { className: 'chk-list', data: { key: 'check|' + kind } }, ...rows.map((x) => el('li', {},
btn((x.t ? x.t.id + ' · ' + x.t.title : 'Workflow ' + x.w.id + ' · ' + x.w.name), () => { if (x.t) { select({ kind: 'task', id: x.t.id }); LayoutHost.focus({ task: x.t.id }); } else select({ kind: 'workflow', id: x.w.id }); }, { className: 'chk-what', data: { key: 'check-open|' + (x.t ? x.t.id : x.w.id) } }),
btn(x.fix[0], async () => { await x.fix[1](); setTimeout(fill, 400); }, { className: 'dbtn small go', data: { key: 'check-fix|' + kind + '|' + (x.t ? x.t.id : x.w.id) } })))));
}
};
root.append(el('p', { textContent: 'What a handoff can leave half done. One click fixes it where it can; the rest opens the task.' }), body);
fill();
}
function openNews() {
if (!Team.on() || !(Team.role() === 'owner' || Team.role() === 'member')) return;
const lead = Team.role() === 'owner';
const root = openPanel('news', 'Announcements');
const body = el('div');
const fill = () => {
body.textContent = '';
if (lead) {
const title = el('input', { type: 'text', className: 'pn-input', maxLength: 200, placeholder: 'Title', ariaLabel: 'Title', data: { key: 'news-title' } });
const text = el('textarea', { className: 'pn-input te-area', rows: 3, placeholder: 'What everyone should know', ariaLabel: 'Announcement', data: { key: 'news-body' } });
const everyone = el('input', { type: 'radio', name: 'aud', checked: true, data: { key: 'news-everyone' } }), some = el('input', { type: 'radio', name: 'aud', data: { key: 'news-some' } });
const ppl = Team.members().filter((m) => m.role === 'member');
const boxes = ppl.map((m) => el('label', { className: 'pcheck' }, el('input', { type: 'checkbox', value: m.user_id, data: { key: 'news-to|' + m.user_id } }), el('span', { textContent: Team.nameOf(m.user_id) || m.name })));
const pin = el('input', { type: 'checkbox', data: { key: 'news-pin' } });
body.append(el('section', { className: 'newsform', data: { key: 'news-form' } },
title, text,
el('div', { className: 'pcheckrow' }, el('label', { className: 'pcheck' }, everyone, el('span', { textContent: 'Everyone' })), el('label', { className: 'pcheck' }, some, el('span', { textContent: 'Only:' })), ...boxes),
el('label', { className: 'pcheck' }, pin, el('span', { textContent: 'Pin it at the top' })),
btn('Publish', async () => {
const audience = some.checked ? boxes.map((b) => b.querySelector('input')).filter((x) => x.checked).map((x) => x.value) : null;
if (some.checked && !audience.length) { notify('Pick at least one person, or choose Everyone.'); return; }
if (!title.value.trim() && !text.value.trim()) { title.focus(); return; }
const r = await Team.announce({ title: title.value, body: text.value, audience, pinned: pin.checked });
if (r.ok) { notify('Published.'); fill(); }
}, { className: 'primary', data: { key: 'news-go' } })));
}
const list = Team.announcements().slice().sort((a, b) => (b.pinned - a.pinned) || (Date.parse(b.created_at) - Date.parse(a.created_at)));
if (!list.length) body.append(el('p', { className: 'pempty', textContent: 'No announcements yet.' }));
for (const a of list) {
const who = a.audience && a.audience.length ? 'For ' + a.audience.map((u) => Team.nameOf(u) || 'someone').join(', ') : 'For everyone';
const card = el('article', { className: 'newscard' + (a.pinned ? ' pinned' : ''), data: { key: 'news|' + a.id } },
el('p', { className: 'k', textContent: (a.pinned ? '📌 Pinned · ' : '') + ago(Date.parse(a.created_at)) + (lead ? ' · ' + who : '') }),
a.title ? el('h3', { textContent: a.title }) : null, a.body ? messageBody(a.body) : null);
if (lead) card.append(el('div', { className: 'dbtns' },
btn(a.pinned ? 'Unpin' : 'Pin', () => Team.pinAnnouncement(a.id, !a.pinned).then(fill), { className: 'dbtn small', data: { key: 'news-pin|' + a.id } }),
btn('Delete', () => Team.deleteAnnouncement(a.id).then(fill), { className: 'dbtn small danger', data: { key: 'news-del|' + a.id } })));
body.append(card);
}
};
root.append(body); fill();
Team.setPanelRefresh(() => { if (document.querySelector('#panel.panel-news') && !document.activeElement.closest('.newsform')) fill(); });
}
// ---- Modo Team: People (quién está, dónde mira, qué hizo), Follow y Activity ----
let peopleOpen = null, following = null;
const localTime = (tz) => { try { return new Intl.DateTimeFormat('en-GB', { timeZone: tz || undefined, hour: '2-digit', minute: '2-digit' }).format(new Date()); } catch { return ''; } };
const lastBy = (uid) => { const h = Store.get().history; for (let i = h.length - 1; i >= 0; i--) if (h[i].actor === uid && !h[i].agentAct) return h[i]; return null; };
function renderPeople() {
const box = $('#people');
if (!box) return;
box.hidden = !Team.on() || !Team.isEntered();
box.textContent = '';
if (box.hidden) return;
box.append(el('h2', { className: 'label', textContent: 'People' }));
const list = el('div', { className: 'plist people-list' });
const vOnline = Team.viewers ? Team.viewers().length : 0;
if (vOnline && !Team.isVisitor()) list.append(el('div', { className: 'person-row viewers-row', data: { key: 'people-viewers' } }, el('span', { className: 'pr-main' }, el('span', { className: 'pr-name', textContent: 'Viewers · ' + vOnline + ' online' }), el('small', { textContent: Team.viewers().slice(0, 6).map((x) => x.name).join(', ') + (vOnline > 6 ? '…' : '') }))));
const order = Team.members().slice().sort((a, b) => (Team.isOnlineUser(b.user_id) - Team.isOnlineUser(a.user_id)) || a.name.localeCompare(b.name));
for (const m of order) {
const on = Team.isOnlineUser(m.user_id), self = m.user_id === Team.myId();
const tasks = Store.get().tasks.filter((x) => x.assignee === m.user_id && x.status !== 'done');
const last = lastBy(m.user_id);
const b = btn('', () => { peopleOpen = peopleOpen === m.user_id ? null : m.user_id; renderPeople(); }, { className: 'person-row', ariaExpanded: String(peopleOpen === m.user_id), data: { key: 'person|' + m.user_id },
ariaLabel: m.name + (self ? ' (you)' : '') + ', ' + (on ? 'online' : 'offline') + (m.timezone ? ', local time ' + localTime(m.timezone) : '') + (tasks[0] ? ', on ' + tasks[0].id : '') });
b.append(Ships.icon(m, 24), el('span', { className: 'pr-main' },
el('span', { className: 'pr-name' }, el('i', { className: 'dot-online' + (on ? ' on' : ''), ariaHidden: 'true' }), m.name + (self ? ' (you)' : ''), m.role !== 'viewer' && Team.statOf(m.user_id) ? el('span', { className: 'lvbadge', data: { key: 'level|' + m.user_id }, textContent: levelOf(Team.statOf(m.user_id)).name }) : null, el('small', { textContent: m.timezone ? ' · ' + localTime(m.timezone) : '' })),
el('small', { className: 'pr-meta', textContent: (tasks[0] ? 'On ' + tasks[0].id + ' ' + tasks[0].title : T().people.noTask) }),
el('small', { className: 'pr-meta', textContent: last ? T().people.last(describe(last), ago(last.at)) : T().people.nothing })));
list.append(b);
if (peopleOpen === m.user_id) list.append(el('div', { className: 'dbtns pr-acts' },
!self && on ? btn(following === m.user_id ? 'Stop following' : 'Follow', () => (following === m.user_id ? stopFollow() : startFollow(m.user_id)), { className: 'abtn', data: { key: 'follow|' + m.user_id } }) : null,
btn('Activity', () => openActivity(m.user_id), { className: 'abtn', data: { key: 'activity|' + m.user_id } }),
Team.role() === 'owner' && m.role !== 'viewer' ? btn('Open panel', () => openMemberPanel(m.user_id), { className: 'abtn', data: { key: 'panel|' + m.user_id } }) : null));
}
box.append(list);
}
// Follow: mi cámara sigue a su nave hasta que toque algo.
function startFollow(uid) { following = uid; lastFollow = ''; followNow(); renderFollow(); renderPeople(); }
function stopFollow() { if (!following) return; following = null; renderFollow(); renderPeople(); }
let lastFollow = '';
function followNow() {
if (!following) return;
const x = Team.people().find((p) => p.userId === following);
if (!x) { stopFollow(); return; }
const f0 = x.focus;
if (JSON.stringify(f0) === lastFollow) return; // solo cuando cambia de objetivo
lastFollow = JSON.stringify(f0);
if (!f0 || !f0.project || !Store.project(f0.project)) return;
if (!Store.current() || Store.current().id !== f0.project) Actions.selectProject(f0.project);
if (f0.kind === 'task' || f0.kind === 'workflow') { focus = { kind: f0.kind, id: f0.id }; renderDetail(); LayoutHost.push(); LayoutHost.focus(f0.kind === 'task' ? { task: f0.id } : { workflow: f0.id }); }
}
function renderFollow() {
const bar = $('#following');
if (!bar) return;
bar.hidden = !following;
bar.textContent = '';
if (!following) return;
bar.append(Ships.icon(Team.memberOf(following), 20), el('strong', { textContent: T().people.following(Team.nameOf(following)) }), el('span', { textContent: T().people.followHint }),
btn('Stop', stopFollow, { className: 'abtn', data: { key: 'follow-stop' } }));
}
function bindFollowStop() {
// Cualquier toque fuera del aviso detiene el seguimiento (el mapa avisa con tasky:interact).
addEventListener('pointerdown', (e) => { if (following && !(e.target.closest && e.target.closest('#following, #people'))) stopFollow(); }, true);
addEventListener('keydown', (e) => { if (following && !(e.target.closest && e.target.closest('#following'))) stopFollow(); }, true);
}
// Activity de una persona: lo que hizo, y lo que le aprobaron o devolvieron, por día.
function openActivity(uid) {
const name = Team.nameOf(uid) || 'Someone';
const root = openPanel('activity', name + ' · Activity');
const rows = Store.get().history.filter((e) => (e.actor === uid && !e.agentAct) || e.forUser === uid).slice().reverse();
const tag = (e) => e.kind === 'file' && e.what === 'upload' ? 'Delivered' : e.review === 'approved' && e.forUser === uid ? 'Approved' : e.review === 'sent back' && e.forUser === uid ? 'Sent back' : e.kind === 'status' && e.to === 'review' ? 'Submitted' : '';
if (!rows.length) { root.append(el('p', { className: 'pempty', textContent: T().people.noActivity(name) })); return; }
const counts = { Delivered: 0, Approved: 0, 'Sent back': 0 };
for (const e of rows) if (counts[tag(e)] != null) counts[tag(e)]++;
root.append(el('p', { className: 'act-sum', textContent: T().people.summary(counts.Delivered, counts.Approved, counts['Sent back']) }));
let day = null, ul = null;
for (const e of rows.slice(0, 300)) {
if (dayKey(e.at) !== day) { day = dayKey(e.at); root.append(el('h3', { className: 'hday', textContent: dayLabel(e.at) })); ul = el('ul', { className: 'pn-list' }); root.append(ul); }
const tg = tag(e);
const go = btn('', () => goToEntry(e), { className: 'hrow', data: { key: 'act|' + e.id } });
go.append(el('span', { className: 'htime', textContent: clock(e.at) }), el('span', { className: 'hwho ' + (tg === 'Approved' ? 'ok' : tg === 'Sent back' ? 'back' : tg ? 'you' : ''), textContent: tg || whoName(e.who, e) }), el('span', { className: 'htext', textContent: describe(e) }));
ul.append(el('li', { className: 'hitem' }, go));
}
}
// La primera vez que alguien entra: elegir su nave.
function shipFirstTime(then) {
const me = Team.me();
if (!me || me.ship_shape) { if (then) then(); return; }
const root = openPanel('ship', T().ships.chooseTitle);
root.append(el('p', { textContent: T().ships.chooseText }), Ships.picker(me, () => { closePanel(); if (then) then(); }));
}
// ---- Galaxia de bienvenida: un clic por persona, en el proyecto que se ve ----
const welcomeText = () => { const n = document.getElementById('welcome-template'); return n ? n.textContent.replace(/<\\\/script/gi, ' t.projectId === p.id && t.assignee === m.user_id && /^WEL\d*$/.test(t.workflow));
return btn(has ? 'Welcome added' : 'Add welcome galaxy', () => {
const cur = Store.current();
if (!cur) return notify(T().team.welcomeNoProject);
const id = Actions.addWelcome(cur.id, m.user_id, welcomeText());
if (id) { notify(T().team.welcomeAdded(m.name, cur.name)); closePanel(); goTo(cur.id, { kind: 'workflow', id }); }
}, { className: 'row-btn', disabled: has || !p, title: T().team.welcomeTitle, ariaLabel: 'Add the welcome galaxy for ' + m.name + (p ? ' in ' + p.name : ''), data: { key: 'welcome|' + m.user_id } });
}
// ---- Primera vez de un Member: cuatro pasos ----
function memberSteps(then) {
const me = Team.me();
if (!me || me.role !== 'member' || me.onboarded) return false;
const S = T().steps;
const root = openPanel('steps', S.title);
const ol = el('ol', { className: 'steps' });
S.list.forEach(([h, d], i) => ol.append(el('li', { data: { key: 'step|' + (i + 1) } }, el('b', { textContent: h }), el('span', { textContent: d }))));
root.append(el('p', { textContent: S.intro }), ol, el('div', { className: 'pn-actions' }, btn(S.ok, () => { Team.updateProfile({ onboarded: true }); Actions.setHelpSeen(); closePanel(); if (then) then(); }, { className: 'primary', data: { key: 'steps-ok' } })));
return true;
}
// ---- Modo Team: cuenta y equipo ----
const ROLE = { owner: 'Team lead', member: 'Member', viewer: 'Viewer' };
function renderAccount() {
const box = $('#account');
if (!box) return;
box.hidden = !Team.on() || !Team.me();
box.textContent = '';
if (box.hidden) return;
const me = Team.me();
const b = btn('', openAccount, { className: 'acct', data: { key: 'account' }, ariaLabel: 'Your account: ' + me.name + ', ' + ROLE[me.role] + (Team.isOnline() ? '' : ', offline') });
b.append(typeof Ships !== 'undefined' ? Ships.icon(me, 26) : el('span'), el('span', { className: 'acct-name', textContent: me.name }), el('span', { className: 'acct-role', textContent: ROLE[me.role] }));
if (!teamUI() || Team.role() === 'viewer') { box.append(b); return; } // el Viewer no tiene campana
const n = unreadNotifs().length;
const bell = btn('', openBell, { className: 'bell-ic', data: { key: 'bell' }, ariaLabel: 'Notifications: ' + n + ' unread' },
svg(''));
if (n) bell.append(el('span', { className: 'bn', ariaHidden: 'true', textContent: n > 99 ? '99+' : String(n) }));
box.append(el('div', { className: 'acct-wrap' }, b, bell));
}
function openAccount() {
const me = Team.me();
if (!me) return;
const root = openPanel('account', 'Account');
const name = el('input', { type: 'text', className: 'pn-input', value: me.name, maxLength: 60, ariaLabel: 'Your name', data: { key: 'acc-name' } });
const saveName = btn('Save name', async () => { if (name.value.trim()) { await Team.updateProfile({ name: name.value }); notify(T().team.saved); } }, { className: 'primary', data: { key: 'acc-save' } });
root.append(el('h3', { textContent: 'Name' }), name, el('div', { className: 'pn-actions' }, saveName),
el('p', {}, el('b', { textContent: 'Email: ' }), Team.myEmail()), el('p', {}, el('b', { textContent: 'Role: ' }), ROLE[me.role] + ' · ' + T().team.roleText[me.role]),
el('p', {}, el('b', { textContent: 'Team: ' }), (Team.team() || {}).name || ''));
if (typeof Ships !== 'undefined') root.append(Ships.picker(me));
// E12: correos
if (!Team.isVisitor()) {
const E = T().emails, box = el('section', { className: 'emails', data: { key: 'emails' } }, el('h3', { textContent: E.title }));
root.append(box);
Team.emailsStatus().then((st) => {
if (!st) { box.append(el('p', { className: 'k', textContent: E.notYet })); return; }
box.append(el('p', { className: 'k', data: { key: 'emails-status' }, textContent: st.configured ? E.ready : E.notYet }));
const on = el('input', { type: 'checkbox', checked: !!st.on, data: { key: 'em-on' } });
on.addEventListener('change', async () => { const r = await Team.setEmailPrefs(on.checked, null); if (r) notify(r.on ? E.onSaved : E.offSaved); });
box.append(el('label', { className: 'te-field vl-switch' }, on, el('span', { textContent: Team.role() === 'owner' ? E.ownerToggle : E.memberToggle })));
if (Team.role() === 'owner') {
const hour = el('select', { className: 'hsel', ariaLabel: E.hour, data: { key: 'em-hour' } }, ...Array.from({ length: 24 }, (_, h) => el('option', { value: String(h), textContent: String(h).padStart(2, '0') + ':00' })));
hour.value = String(st.hour);
hour.addEventListener('change', async () => { if (await Team.setEmailPrefs(null, Number(hour.value))) notify(E.hourSaved(hour.value)); });
box.append(el('label', { className: 'te-field' }, el('span', { textContent: E.hour }), hour));
}
});
}
const cur = el('input', { type: 'password', className: 'pn-input', autocomplete: 'current-password', ariaLabel: 'Current password', placeholder: 'Current password', data: { key: 'acc-pw-old' } });
const nw = el('input', { type: 'password', className: 'pn-input', autocomplete: 'new-password', minLength: 8, ariaLabel: 'New password', placeholder: 'New password (8 or more characters)', data: { key: 'acc-pw-new' } });
const savePw = btn('Change password', async () => {
savePw.disabled = true;
const err = await Team.changePassword(cur.value, nw.value);
savePw.disabled = false;
if (err) { notify(err); return; }
cur.value = ''; nw.value = ''; notify(T().team.passwordChanged);
}, { data: { key: 'acc-pw-save' } });
root.append(el('h3', { textContent: 'Password' }), cur, nw, el('div', { className: 'pn-actions' }, savePw));
// Cuánto ven los demás de mis agentes (A2). En modo Personal no aplica: siempre se ve todo lo propio.
const lv = Team.sharing();
const share = el('div', { className: 'pn-actions', role: 'group', ariaLabel: 'What the team sees of your agents' });
for (const [k, label] of [['status', 'Status only'], ['tools', 'Status and tools'], ['files', 'Status, tools and file names']]) {
const b0 = btn(label, async () => { if (await Team.setSharing(k)) { notify(T().live.sharingSaved(label)); for (const x of share.children) x.setAttribute('aria-pressed', String(x.dataset.level === k)); } }, { data: { key: 'share|' + k, level: k } });
b0.setAttribute('aria-pressed', String(lv === k));
share.append(b0);
}
root.append(el('h3', { textContent: 'Your agents: what the team sees' }), el('p', { className: 'fnote', textContent: T().live.sharingHint }), share);
root.append(el('h3', { textContent: 'Session' }), el('div', { className: 'pn-actions' }, btn('Sign out', () => Team.signOut(), { className: 'danger', data: { key: 'acc-signout' } })));
name.focus();
}
// ---- Salida de un miembro, en un solo paso: reasignar sus tareas, retirar sus agentes, cerrar sus sesiones ----
function openOffboard(m) {
const root = openPanel('offboard', 'Remove ' + m.name);
const tasks = Store.get().tasks.filter((t) => t.assignee === m.user_id && t.status !== 'done');
const agents = Store.get().agents.filter((a) => a.owner === m.user_id);
const choices = Team.members().filter((x) => x.user_id !== m.user_id && x.role !== 'viewer');
const sels = [];
root.append(el('p', { textContent: T().team.offboardIntro(m.name) }), el('h3', { textContent: 'Their open tasks (' + tasks.length + ')' }));
if (!tasks.length) root.append(el('p', { className: 'pempty', textContent: T().team.offboardNoTasks }));
else {
const ul = el('ul', { className: 'pn-list' });
for (const t of tasks) {
const pr = Store.project(t.projectId);
const sel = el('select', { className: 'hsel', ariaLabel: 'Who takes ' + t.id, data: { key: 'off-to|' + t.projectId + '|' + t.id } }, el('option', { value: '', textContent: 'Nobody (free task)' }), ...choices.map((x) => el('option', { value: x.user_id, textContent: x.name })));
sels.push({ t, sel });
ul.append(el('li', { className: 'pn-item tmember' }, el('span', {}, el('b', { textContent: t.id + ' ' + t.title }), el('small', { textContent: ' · ' + (pr ? pr.name : '') + ' · ' + T().status[t.status] })), sel));
}
root.append(ul);
}
root.append(el('h3', { textContent: 'Their agents' }), el('p', { textContent: T().team.offboardAgents(agents.length) }), el('h3', { textContent: 'Their sessions' }), el('p', { textContent: T().team.offboardSessions }));
const go = btn('Remove ' + m.name, async () => {
go.disabled = true;
const r = await Team.offboard(m.user_id, sels.map(({ t, sel }) => ({ project: t.projectId, task: t.id, to: sel.value || null })));
go.disabled = false;
if (r) { closePanel(); notify(T().team.offboarded(m.name, r)); }
}, { className: 'danger', data: { key: 'rm-yes|' + m.user_id } });
root.append(el('div', { className: 'pn-actions' }, go, btn('Cancel', closePanel, { data: { key: 'off-cancel' } })));
go.focus();
}
function viewerLinkBox() {
const V = T().viewerLink, box = el('section', { className: 'vlink', data: { key: 'vl-box' } }, el('h3', { textContent: V.title }), el('p', { className: 'k', textContent: V.text }));
const body = el('div');
box.append(body);
const draw = (st) => {
body.textContent = '';
const on = !!(st && st.enabled);
const sw = el('input', { type: 'checkbox', checked: on, data: { key: 'vl-toggle' } });
sw.addEventListener('change', async () => { sw.disabled = true; draw(await Team.setViewerLink(sw.checked) || st); });
body.append(el('label', { className: 'te-field vl-switch' }, sw, el('span', { textContent: on ? V.on : V.off })));
// 021 (G8): además del enlace, el botón grande en la pantalla de entrada.
const ss = el('input', { type: 'checkbox', checked: !!(st && st.enabled && st.onSignin), data: { key: 'vl-signin' } });
ss.addEventListener('change', async () => { ss.disabled = true; draw(await Team.setViewersOn(ss.checked) || st); });
body.append(el('label', { className: 'te-field vl-switch' }, ss, el('span', { textContent: V.signin })));
if (!on || !st.token) return;
const url = Team.appBase() + '#view=' + st.token;
const inp = el('input', { type: 'text', className: 'pn-input', readOnly: true, value: url, ariaLabel: V.title, data: { key: 'vl-url' } });
body.append(inp, el('div', { className: 'pn-actions' },
btn(V.copy, async () => { if (await copyText(url)) notify(V.copied); }, { className: 'primary', data: { key: 'vl-copy' } }),
btn(V.regen, async () => draw(await Team.regenerateViewerLink() || st), { data: { key: 'vl-regen' } })));
};
Team.viewerLink().then(draw);
return box;
}
// 020-B (A4): el panel del dueño. Periodos en la hora local del dueño.
const PERIODS = { week: 'This week', month: 'This month', all: 'All time' };
function periodFrom(p) {
const d = new Date(); d.setHours(0, 0, 0, 0);
if (p === 'week') { d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); return d.getTime(); }
if (p === 'month') { d.setDate(1); return d.getTime(); }
return null;
}
const hm = (sec) => { sec = Math.round(Number(sec) || 0); const h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60); return h ? h + ' h ' + String(m).padStart(2, '0') + ' min' : m + ' min'; };
const span = (ms) => { const h = ms / 3600000; return h < 1 ? Math.max(1, Math.round(ms / 60000)) + ' min' : h < 48 ? Math.round(h) + ' h' : Math.round(h / 24) + ' days'; };
const day = (t) => (t ? new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: new Date(t).getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined }) : '—');
let overviewSort = { col: 'name', dir: 1 };
function overviewBox() {
const box = el('section', { className: 'ovbox', ariaLabel: 'Team at a glance', data: { key: 'overview' } }, el('h3', { className: 'label', textContent: 'At a glance' }), el('p', { className: 'fnote', textContent: 'Loading…' }));
const COLS = [['name', 'Person'], ['finished', 'Finished'], ['inProgress', 'In progress'], ['inReview', 'In review'], ['collected', 'Collected'], ['waiting', 'Waiting'], ['week', 'This week']];
Team.overview(periodFrom('week')).then((r) => {
box.querySelector('.fnote').remove();
if (r.error) { box.append(el('p', { className: 'fnote', textContent: r.error })); return; }
const rows = r.rows.map((x) => ({ ...x, week: (x.agentSeconds || 0) + (x.appSeconds || 0) }));
const table = el('table', { className: 'ovtable', data: { key: 'ov-table' } });
const draw = () => {
table.textContent = '';
const sorted = rows.slice().sort((a, b) => { const k = overviewSort.col; const va = a[k], vb = b[k]; return (typeof va === 'string' ? String(va).localeCompare(String(vb)) : (va || 0) - (vb || 0)) * overviewSort.dir; });
table.append(el('thead', {}, el('tr', {}, ...COLS.map(([k, name]) => el('th', { scope: 'col', ariaSort: overviewSort.col === k ? (overviewSort.dir > 0 ? 'ascending' : 'descending') : 'none' },
btn(name + (overviewSort.col === k ? (overviewSort.dir > 0 ? ' ▲' : ' ▼') : ''), () => { overviewSort = { col: k, dir: overviewSort.col === k ? -overviewSort.dir : k === 'name' ? 1 : -1 }; draw(); }, { className: 'linkish', data: { key: 'ov-sort|' + k } }))), el('th', {}))));
const tb = el('tbody');
for (const x of sorted) tb.append(el('tr', { data: { key: 'ov-row|' + x.user } },
el('th', { scope: 'row', textContent: x.name + (x.role === 'owner' ? ' (team lead)' : '') }), el('td', { textContent: String(x.finished) }), el('td', { textContent: String(x.inProgress) }), el('td', { textContent: String(x.inReview) }),
el('td', { textContent: '$' + x.collected }), el('td', { textContent: '$' + x.waiting }), el('td', { textContent: hm(x.week), title: 'Agents ' + hm(x.agentSeconds) + ' + app ' + hm(x.appSeconds) + ' (estimate)' }),
el('td', {}, btn('Open panel', () => openMemberPanel(x.user), { className: 'dbtn small', ariaLabel: 'Open the panel of ' + x.name, data: { key: 'ov-open|' + x.user } }))));
table.append(tb);
};
draw();
box.append(el('div', { className: 'ovscroll' }, table), el('p', { className: 'fnote', textContent: 'Time this week is an estimate: agents working plus Tasky open and in use.' }));
});
return box;
}
async function openMemberPanel(userId, period = 'week') {
if (!Team.on() || Team.role() !== 'owner') return;
const m = Team.members().find((x) => x.user_id === userId);
const root = openPanel('member', (m ? m.name : 'Person') + ' · panel');
root.classList.add('wide');
const bar = el('div', { className: 'dbtns', role: 'group', ariaLabel: 'Period' }, ...Object.entries(PERIODS).map(([k, name]) => {
const b = btn(name, () => openMemberPanel(userId, k), { className: 'dbtn small' + (k === period ? ' go' : ''), data: { key: 'mp-period|' + k } });
b.setAttribute('aria-pressed', String(k === period));
return b;
}));
const body = el('div', { className: 'mpanel', data: { key: 'member-panel' } }, el('p', { className: 'fnote', textContent: 'Loading…' }));
root.append(bar, body);
const r = await Team.memberReport(userId, periodFrom(period));
body.textContent = '';
if (r.error) { body.append(el('p', { className: 'fnote', textContent: r.error })); return; }
const d = r.data, M = d.money || {};
const sec = (title, key, ...kids) => el('section', { className: 'mpsec', data: { key: 'mp|' + key } }, el('h3', { className: 'label', textContent: title }), ...kids);
const row = (...kids) => el('li', {}, ...kids);
const go = (pid, tid) => { closePanel(); if (!Store.current() || Store.current().id !== pid) Actions.selectProject(pid); select({ kind: 'task', id: tid }); LayoutHost.focus({ task: tid }); };
const total = (d.agentSeconds || 0) + (d.appSeconds || 0);
body.append(sec('Time worked · ' + PERIODS[period], 'time',
el('p', { className: 'mpbig', textContent: hm(total) }),
el('p', { textContent: 'Agents: ' + hm(d.agentSeconds) + ' · Tasky open and in use: ' + hm(d.appSeconds) }),
el('p', { className: 'fnote', textContent: 'This is an estimate: agents count while they work on a task; the app counts while its tab is visible and in use.' })));
const done = d.finished || [];
body.append(sec('Finished · ' + done.length, 'finished', done.length ? el('ul', { className: 'mplist' }, ...done.map((x) => row(
el('b', { textContent: x.task + ' · ' + x.title }), el('small', { textContent: ' · approved ' + day(x.at) + ' · $' + (x.amount || 0) + (x.takenAt ? ' · took ' + span(Date.parse(x.at) - Date.parse(x.takenAt)) : '') })))) : el('p', { className: 'fnote', textContent: 'Nothing approved in this period.' })));
const open = d.open || [];
body.append(sec('Not finished · ' + open.length, 'open', open.length ? el('ul', { className: 'mplist' }, ...open.map((x) => {
const idle = x.movedAt ? Date.now() - Date.parse(x.movedAt) : 0, stale = idle > 3 * 86400000;
return el('li', { className: stale ? 'stale' : '', data: { key: 'mp-open|' + x.task } }, el('b', { textContent: x.task + ' · ' + x.title }),
el('small', { textContent: ' · ' + (Help.TEXT.status[x.status] || x.status) + (x.takenAt ? ' since ' + day(x.takenAt) : '') + (x.steps ? ' · ' + x.stepsDone + ' of ' + x.steps + ' steps' : '') }),
stale ? el('span', { className: 'mpstale', textContent: ' No movement for ' + Math.floor(idle / 86400000) + ' days' }) : null);
})) : el('p', { className: 'fnote', textContent: 'Nothing open.' })));
const rev = d.review || [];
body.append(sec('In review · ' + rev.length, 'review', rev.length ? el('ul', { className: 'mplist' }, ...rev.map((x) => row(el('b', { textContent: x.task + ' · ' + x.title }), el('small', { textContent: ' · $' + x.reward + ' · since ' + day(x.since) + ' ' }),
btn('Review', () => go(x.project, x.task), { className: 'dbtn small go', data: { key: 'mp-review|' + x.task } })))) : el('p', { className: 'fnote', textContent: 'Nothing waiting for you.' })));
const back = d.returned || [], checks = back.filter((x) => x.kind === 'check').length, rejects = back.filter((x) => x.kind === 'reject').length;
body.append(sec('Returned', 'returned', el('p', { textContent: 'Check this: ' + checks + ' · Reject: ' + rejects }), back.length ? el('p', { className: 'fnote', textContent: back.map((x) => (x.kind === 'check' ? 'Check this' : 'Reject') + ' on ' + x.task + ' (' + day(x.at) + ')').join(' · ') }) : null));
const pct = d.taken ? Math.round(100 * d.approved / d.taken) : null;
body.append(sec('Completed', 'completed', el('p', { textContent: pct == null ? 'No tasks taken in this period.' : pct + '% · ' + d.approved + ' approved of ' + d.taken + ' taken' })));
const bon = M.bonuses || [];
body.append(sec('Money', 'money', el('ul', { className: 'mplist' },
row('Earned: $' + (M.earned || 0)), row('Collected (cash outs paid): $' + (M.collected || 0)), row('In the wallet now: $' + (M.balance || 0)),
row('Asked, waiting for payment: $' + (M.asked || 0)), row('Waiting for review: $' + (M.waiting || 0))),
el('p', { className: 'fnote', textContent: bon.length ? 'Bonuses (apart): ' + bon.map((b) => [b.iron ? b.iron + ' Iron' : '', b.crystal ? b.crystal + ' Crystal' : '', b.gold ? b.gold + ' Gold' : ''].filter(Boolean).join(', ') + ' (' + day(b.at) + ')').join(' · ') : 'No bonuses in this period.' })));
const sg = d.suggestions || {};
body.append(sec('Suggestions', 'suggestions', el('p', { textContent: (sg.sent || 0) + ' sent · ' + (sg.tasks || 0) + ' became tasks' }),
(d.suggestionsList || []).length ? el('ul', { className: 'mplist' }, ...d.suggestionsList.slice(0, 20).map((x) => el('li', { textContent: x.title + ' · ' + x.status + (x.task ? ' (' + x.task + ')' : '') + ' · ' + day(x.at) }))) : null));
// 021 (G11): entregadas, a la primera, rechazadas; tiempo por tarea; hallazgos; por universo y por workflow.
const dl = d.delivered || [];
body.append(sec('Delivered · ' + dl.length, 'delivered', el('p', { textContent: 'Approved at the first try: ' + (d.firstTry || 0) + ' of ' + (d.approved || 0) + ' · Rejected: ' + (d.rejected || 0) }),
dl.length ? el('ul', { className: 'mplist' }, ...dl.slice(0, 30).map((x) => el('li', { textContent: x.task + ' · sent for review ' + day(x.at) }))) : null));
const tt = d.tasksTime || [];
body.append(sec('Time by task · an estimate', 'tasktime', tt.length ? el('table', { className: 'ovtable' }, el('thead', {}, el('tr', {}, el('th', { textContent: 'Task' }), el('th', { textContent: 'Agents' }), el('th', { textContent: 'Tasky open' }), el('th', { textContent: 'Total' }))),
el('tbody', {}, ...tt.slice(0, 40).map((x) => el('tr', {}, el('th', { textContent: x.task }), el('td', { textContent: hm(x.agent) }), el('td', { textContent: hm(x.app) }), el('td', { textContent: hm((x.agent || 0) + (x.app || 0)) }))))) : el('p', { className: 'fnote', textContent: 'No time on tasks in this period.' })));
const fd = d.findings || [];
body.append(sec('Findings · ' + fd.length, 'findings', fd.length ? el('ul', { className: 'mplist' }, ...fd.map((x) => el('li', {}, el('b', { textContent: (x.task ? x.task + ' · ' : '') + x.title }), el('small', { textContent: ' · ' + day(x.at) })))) : el('p', { className: 'fnote', textContent: 'No findings in this period.' })));
const by = (o) => Object.entries(o || {}).map(([k, n]) => k + ': ' + n).join(' · ') || '—';
body.append(sec('Approved by universe and workflow', 'stats', el('p', { textContent: 'Universes · ' + by(d.byUniverse) }), el('p', { textContent: 'Workflows · ' + by(d.byWorkflow) }),
typeof d.announcements === 'number' && d.role === 'owner' ? el('p', { textContent: 'Announcements published: ' + d.announcements }) : null));
body.append(sec('Seen', 'seen', el('p', { textContent: 'Last seen: ' + (d.lastSeen ? day(d.lastSeen) + ' ' + new Date(d.lastSeen).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) : 'not yet') + ' · First entered: ' + day(d.joinedAt) })));
// CSV con el detalle del periodo.
const csv = () => {
const q = (v) => { const x = String(v == null ? '' : v); return /[",\n]/.test(x) ? '"' + x.replace(/"/g, '""') + '"' : x; };
const L = [['section', 'task', 'title', 'status', 'date', 'amount', 'detail']];
for (const x of done) L.push(['finished', x.task, x.title, 'approved', x.at, x.amount, x.takenAt ? 'took ' + span(Date.parse(x.at) - Date.parse(x.takenAt)) : '']);
for (const x of open) L.push(['open', x.task, x.title, x.status, x.takenAt || '', x.reward, x.steps ? x.stepsDone + ' of ' + x.steps + ' steps' : '']);
for (const x of rev) L.push(['review', x.task, x.title, 'review', x.since, x.reward, '']);
for (const x of back) L.push(['returned', x.task, '', x.kind, x.at, '', '']);
for (const b of bon) L.push(['bonus', '', '', '', b.at, b.amount, [b.iron && b.iron + ' Iron', b.crystal && b.crystal + ' Crystal', b.gold && b.gold + ' Gold'].filter(Boolean).join(' ')]);
L.push(['money', '', 'earned', '', '', M.earned || 0, ''], ['money', '', 'collected', '', '', M.collected || 0, ''], ['money', '', 'balance', '', '', M.balance || 0, ''], ['money', '', 'asked', '', '', M.asked || 0, ''], ['money', '', 'waiting', '', '', M.waiting || 0, '']);
L.push(['time', '', 'agents (estimate)', '', '', d.agentSeconds || 0, 'seconds'], ['time', '', 'app (estimate)', '', '', d.appSeconds || 0, 'seconds']);
return L.map((r) => r.map(q).join(',')).join('\n') + '\n';
};
body.append(el('div', { className: 'pn-actions' }, btn('Export CSV', () => downloadBlob(((m && m.name) || 'person').replace(/[^\w-]+/g, '_') + '-' + period + '.csv', new Blob([csv()], { type: 'text/csv' })), { className: 'primary', data: { key: 'mp-csv' } })));
}
function openTeam() {
if (Team.role() !== 'owner') return;
const root = openPanel('team', 'Team');
root.classList.add('wide');
root.append(overviewBox());
// 021 (G10): los duelos y su premio semanal, los apaga el líder.
const ts = Team.teamSettings();
const dOn = el('input', { type: 'checkbox', checked: !!ts.duels, data: { key: 'set-duels' } }), pOn = el('input', { type: 'checkbox', checked: !!ts.prize, data: { key: 'set-prize' } });
dOn.addEventListener('change', () => Team.setTeamSettings(dOn.checked, null)); pOn.addEventListener('change', () => Team.setTeamSettings(null, pOn.checked));
root.append(el('section', { className: 'vlink', data: { key: 'duel-settings' } }, el('h3', { textContent: 'Duels' }),
el('label', { className: 'te-field vl-switch' }, dOn, el('span', { textContent: 'Duels on: two people on the same planet can play Star catch, once a day per pair' })),
el('label', { className: 'te-field vl-switch' }, pOn, el('span', { textContent: 'Weekly prize: the first of the week gets a spin of the bonus wheel' }))));
root.append(viewerLinkBox());
const body = el('div', { className: 'team-body' });
const fill = () => {
body.textContent = '';
const people = el('ul', { className: 'pn-list' });
for (const m of Team.members()) {
const self = m.user_id === Team.myId();
const roleSel = el('select', { className: 'hsel', ariaLabel: 'Role of ' + m.name, disabled: self, data: { key: 'role|' + m.user_id } });
for (const r of ['owner', 'member', 'viewer']) roleSel.append(el('option', { value: r, textContent: ROLE[r] }));
roleSel.value = m.role;
roleSel.addEventListener('change', async () => { if (await Team.setRole(m.user_id, roleSel.value)) { notify(T().team.roleChanged(m.name, ROLE[roleSel.value])); fill(); } });
const li = el('li', { className: 'pn-item tmember' }, typeof Ships !== 'undefined' ? Ships.icon(m, 22) : el('span'), el('span', {}, el('b', { textContent: m.name + (self ? ' (you)' : '') }), el('small', { textContent: ' ' + Team.emailOf(m.user_id) })), roleSel);
if (!self) {
// Quitar a alguien abre un solo paso: sus tareas (para reasignarlas), sus agentes y sus sesiones.
li.append(btn('Remove', () => openOffboard(m), { className: 'row-btn', ariaLabel: 'Remove ' + m.name, data: { key: 'rm|' + m.user_id } }));
if (m.role !== 'viewer') li.append(welcomeButton(m));
}
// Un enlace de un solo uso para poner contraseña nueva (Tasky no manda correos).
li.append(btn('Password link', async () => {
const r = await Team.passwordLink(m.user_id);
if (!r) return;
const o = body.querySelector('.inv-out'); o.hidden = false; o.textContent = T().team.passwordLink(m.name, new Date(r.expires).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })) + ' ' + r.url;
copied(await copyText(r.url), 'Password link');
}, { className: 'row-btn', title: T().team.passwordLinkTitle, ariaLabel: 'Password link for ' + m.name, data: { key: 'pw-link|' + m.user_id } }));
people.append(li);
}
const invs = Team.invitations().filter((i) => !i.accepted_at);
const pend = el('ul', { className: 'pn-list' });
for (const i of invs) {
const link = Team.inviteLink(i);
pend.append(el('li', { className: 'pn-item tinv' }, el('span', {}, el('b', { textContent: i.email }), el('small', { textContent: ' · ' + ROLE[i.role] + ' · code ' + i.code })),
btn('Copy link', async () => copied(await copyText(link), 'Invitation link'), { className: 'row-btn', data: { key: 'inv-copy|' + i.id } }),
btn('Withdraw', async () => { if (await Team.revoke(i.id)) fill(); }, { className: 'row-btn', data: { key: 'inv-del|' + i.id } })));
}
const email = el('input', { type: 'email', className: 'pn-input', placeholder: 'name@example.com', ariaLabel: 'Email to invite', data: { key: 'inv-email' } });
const roleNew = el('select', { className: 'hsel', ariaLabel: 'Role for the new person', data: { key: 'inv-role' } });
for (const r of ['member', 'viewer']) roleNew.append(el('option', { value: r, textContent: ROLE[r] }));
const out = el('p', { className: 'phrase', hidden: true });
const go = btn('Invite', async () => {
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.value.trim())) { notify(T().team.badEmail); return; }
const inv = await Team.invite(email.value, roleNew.value);
if (inv) { email.value = ''; fill(); const o = body.querySelector('.inv-out'); o.hidden = false; o.textContent = T().team.sendLink(inv.email) + ' ' + Team.inviteLink(inv); }
}, { className: 'primary', data: { key: 'inv-go' } });
out.classList.add('inv-out');
const backup = el('input', { type: 'file', accept: '.json,application/json', hidden: true });
backup.addEventListener('change', async () => {
const file = backup.files && backup.files[0]; backup.value = '';
if (!file) return;
let data = null; try { data = JSON.parse(await file.text()); } catch {}
const r = data ? Actions.importPersonal(data) : { ok: false };
notify(r && r.ok ? T().team.imported(r.projects, r.tasks, r.events) : T().team.notBackup);
});
const u = Team.storageUsage();
const bar = el('span', { className: 'bar store-bar' + (u.near ? ' near' : '') }, el('i'));
bar.firstChild.style.width = Math.min(100, Math.round(100 * u.pct)) + '%';
body.append(el('h3', { textContent: 'Storage' }), el('p', { className: 'store-use' + (u.near ? ' near' : ''), data: { key: 'storage' }, textContent: T().team.storage(fileSize(u.used), fileSize(u.limit), Math.round(100 * u.pct)) + (u.near ? ' ' + T().team.storageNear : '') }), bar);
body.append(el('h3', { textContent: 'People' }), people,
el('h3', { textContent: 'Invite someone' }), el('p', { textContent: T().team.inviteHint }), el('div', { className: 'pn-actions' }, email, roleNew, go), out,
el('h3', { textContent: 'Pending invitations' }), invs.length ? pend : el('p', { className: 'pempty', textContent: 'None.' }),
el('h3', { textContent: 'Bring your projects from Personal mode' }), el('p', { textContent: T().team.personalHint }),
el('div', { className: 'pn-actions' }, btn('Choose a Tasky backup…', () => backup.click(), { data: { key: 'team-import' } })), backup);
};
fill();
Team.setPanelRefresh(() => { if (document.querySelector('#panel.panel-team')) fill(); });
root.append(body);
}
// ---- Universos (A7) ----
// El universo que se ve: el elegido (se recuerda por persona); si el proyecto a la vista no tiene nada allí, el primero
// que tenga. Sin universos (todo en Main) no hay selector y nada cambia.
const multiUni = () => Store.universes().length > 1;
function currentUniverse() {
const p = Store.current(), list = Store.universes();
const want = Store.get().settings.universe;
const mine = p ? [...new Set(Store.workflowsOf(p.id).map((w) => w.universe || 'Main'))] : [];
if (want && list.includes(want) && (!p || !mine.length || mine.includes(want))) return want;
return mine[0] || (list.includes(want) ? want : list[0]);
}
const uniTag = (u) => (multiUni() ? ' · ' + u : '');
function jump() {
const st = $('#stage');
if (!st) return;
st.classList.remove('warp'); void st.offsetWidth; st.classList.add('warp');
setTimeout(() => st.classList.remove('warp'), 800);
}
// Ir a un universo: si el proyecto a la vista no tiene nada allí, se abre el primero que sí.
function setUniverse(u, { quiet = false } = {}) {
const before = currentUniverse();
const p = Store.current();
if (!(p && Store.workflowsOf(p.id).some((w) => (w.universe || 'Main') === u))) {
const other = Store.get().projects.find((x) => !x.archived && Store.workflowsOf(x.id).some((w) => (w.universe || 'Main') === u));
if (other) { focus = null; Actions.selectProject(other.id); }
}
Actions.chooseUniverse(u);
if (!quiet && before !== u) jump();
}
// Cuánta gente mira cada universo y cuántos agentes trabajan en él.
function universeCounts(u) {
const people = Team.on() ? Team.people().filter((x) => x.userId !== Team.myId() && x.focus && (x.focus.universe || 'Main') === u).length : 0;
const agents = Store.visibleAgents().filter((a) => a.taskId && Store.project(a.projectId) && Store.universeOf(a.projectId, (Store.task(a.projectId, a.taskId) || {}).workflow) === u).length;
return { people, agents };
}
function renderUniverses() {
const nav = $('#universes');
if (!nav) return;
nav.hidden = !multiUni() || !Store.current();
nav.textContent = '';
if (nav.hidden) return;
const cur = currentUniverse();
nav.append(el('span', { className: 'ulabel', textContent: 'Universe' }));
for (const u of Store.universes()) {
const c = universeCounts(u);
const extra = [c.people ? c.people + (c.people === 1 ? ' person' : ' people') : '', c.agents ? c.agents + (c.agents === 1 ? ' agent' : ' agents') : ''].filter(Boolean).join(' · ');
const b = btn('', () => setUniverse(u), { className: 'ubtn2', ariaPressed: String(u === cur), ariaLabel: 'Universe ' + u + (extra ? ', ' + extra : ''), data: { key: 'uni|' + u } });
b.append(el('b', { textContent: u }));
if (extra) b.append(el('small', { textContent: extra }));
nav.append(b);
}
if (!Team.on() || Team.can('editProjects')) nav.append(btn('✎', openUniverses, { className: 'linkish urename', title: T().universes.manageTitle, ariaLabel: 'Rename universes', data: { key: 'uni-manage' } }));
}
function openUniverses() {
const root = openPanel('universes', 'Universes');
const list = el('ul', { className: 'pn-list' });
for (const u of Store.universes()) {
const n = Store.get().workflows.filter((w) => (w.universe || 'Main') === u).length;
const input = el('input', { type: 'text', className: 'pn-input', value: u, maxLength: 60, ariaLabel: 'Name of the universe ' + u, data: { key: 'uni-name|' + u } });
list.append(el('li', { className: 'pn-item tmember' }, el('span', { textContent: n + (n === 1 ? ' workflow' : ' workflows') }), input,
btn('Rename', () => { if (Actions.renameUniverse(u, input.value)) { notify(T().universes.renamed(u, input.value.trim())); closePanel(); } }, { className: 'row-btn', data: { key: 'uni-rename|' + u } })));
}
root.append(el('p', { textContent: T().universes.hint }), list);
}
// En la tarjeta de un workflow: su universo, y moverlo a otro (o a uno nuevo).
function universeRow(box, p, w) {
const can = !Team.on() || Team.can('editProjects');
if (!can && !multiUni()) return;
const row = el('div', { className: 'person' }, el('span', { className: 'k', textContent: 'Universe' }));
if (!can) { row.append(el('b', { textContent: w.universe || 'Main' })); box.append(row); return; }
const sel = el('select', { className: 'fkind', ariaLabel: 'Universe of ' + w.name, data: { key: 'wf-universe' } }, ...Store.universes().map((u) => el('option', { value: u, textContent: u })), el('option', { value: '+', textContent: 'New universe…' }));
sel.value = w.universe || 'Main';
const name = el('input', { type: 'text', className: 'fnote-in', placeholder: 'Name of the new universe', maxLength: 60, ariaLabel: 'Name of the new universe', hidden: true, data: { key: 'wf-universe-new' } });
const go = btn('Move', () => { name.blur(); const u = sel.value === '+' ? name.value : sel.value; if (u && Actions.setWorkflowUniverse(p.id, w.id, u)) { notify(T().universes.moved(w.name, u.trim())); setUniverse(u.trim().replace(/\s+/g, ' ')); } }, { className: 'dbtn small', hidden: true, data: { key: 'wf-universe-go' } });
sel.addEventListener('change', () => { name.hidden = sel.value !== '+'; go.hidden = sel.value === (w.universe || 'Main'); if (sel.value === '+') name.focus(); });
row.append(sel, name, go);
box.append(row);
}
// ---- Selección y tarjeta de detalle ----
function selectedForLayout() { return focus && (focus.kind === 'task' || focus.kind === 'workflow') ? { kind: focus.kind, id: focus.id } : null; }
function select(item) { focus = item; renderDetail(); renderWorkflows(); LayoutHost.push(); publishFocus(); }
// Modo Team: los demás ven mi nave junto a lo que miro.
function publishFocus() {
if (!Team.on() || !Team.isEntered()) return;
const p = Store.current();
Team.setFocus(p ? { project: p.id, kind: focus ? focus.kind : 'project', id: focus ? focus.id : null, universe: currentUniverse() } : null);
}
// Señalar (cursor o foco) ya no cambia la tarjeta: solo los clics dentro del mapa.
function peek() {}
function taskClicked(id) {
const p = Store.current();
if (!p || !Store.task(p.id, id)) return;
if (isPast()) { focus = { kind: 'task', id }; renderDetail(); LayoutHost.push(); notify(T().timeline.noEdit); return; }
focus = { kind: 'task', id };
Actions.advanceTask(p.id, id);
render();
}
function resolve(item) {
const p = Store.current();
if (!item) return null;
if (item.kind === 'task' && p) { const t = Store.task(p.id, item.id); return t ? { kind: 'task', p, t } : null; }
if (item.kind === 'workflow' && p) { const w = Store.workflow(p.id, item.id); return w ? { kind: 'workflow', p, w } : null; }
return null;
}
// Si el usuario escribe dentro de la tarjeta, no se redibuja debajo de sus dedos.
const typingInDetail = () => { const a = document.activeElement; return a && a.closest && a.closest('#detail') && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA'); };
function renderDetail() {
const box = $('#detail');
if (!box || typingInDetail()) return;
const key = document.activeElement && document.activeElement.closest && document.activeElement.closest('#detail') ? document.activeElement.dataset.key : null;
box.textContent = '';
const r = resolve(focus);
if (!r) detailDefault(box);
else if (r.kind === 'task') detailTask(box, r.p, r.t);
else detailWorkflow(box, r.p, r.w);
// En Team (E5), los botones de la tarea van fijos al pie de la tarjeta (siguen dentro de #detail).
if (teamUI()) { const acts = box.querySelector('.dacts'); if (acts) box.append(acts); }
if (key) { const n = Array.from(box.querySelectorAll('[data-key]')).find((x) => x.dataset.key === key); if (n) n.focus(); }
}
function legend() {
const g = el('div', { className: 'legend' });
for (const st of Store.statusList()) g.append(el('span', {}, ico(st, '#2EC4B6'), T().status[st]));
return g;
}
function detailDefault(box) {
if (typeof Team !== 'undefined' && Team.on() && Team.role() === 'viewer') { box.append(el('div', { className: 'dhead' }, el('span', { className: 'qmark', textContent: '?', ariaHidden: 'true' }), el('h3', { textContent: T().touchTitle })), el('p', { textContent: T().viewerHow }), legend()); return; }
box.append(el('div', { className: 'dhead' }, el('span', { className: 'qmark', textContent: '?', ariaHidden: 'true' }), el('h3', { textContent: T().touchTitle })),
el('p', { className: 'k', textContent: 'How it works' }), el('p', { textContent: T().howItWorks }), legend());
}
function detailTask(box, p, t) {
if (isPast()) { const ps = statusesAt(p.id, pastAt).get(t.id); t = { ...t, status: ps || t.status }; }
const w = Store.workflow(p.id, t.workflow);
const color = Store.colorOf(p.id, t.workflow);
const big = el('span', { className: 'bigid', textContent: t.id });
big.style.setProperty('--c', color);
box.append(el('div', { className: 'dhead' }, big, el('div', {}, el('h3', { textContent: t.title }),
btn(w ? w.name : t.workflow, () => { select({ kind: 'workflow', id: t.workflow }); LayoutHost.focus({ workflow: t.workflow }); }, { className: 'wflink', ariaLabel: 'Workflow ' + (w ? w.name : t.workflow), data: { key: 'd-wf' } }))));
if (t.status === 'needs-you') {
// Lo que Felipe tiene que hacer, destacado y editable, con el botón para destrabarla.
const need = el('textarea', { className: 'needsin', rows: 2, value: t.needs || '', placeholder: T().needsPlaceholder, ariaLabel: 'What you need to do for ' + t.id, data: { key: 'd-needs' } });
need.addEventListener('input', () => Actions.setTaskNeeds(p.id, t.id, need.value));
need.addEventListener('blur', () => renderDetail());
const go = btn('', () => { focus = { kind: 'task', id: t.id }; Actions.setTaskStatus(p.id, t.id, 'doing'); }, { className: 'dbtn go', ariaLabel: 'Done, unblock ' + t.id + ': move it to In progress', data: { key: 'd-unblock' } });
go.append(ico('doing', '#FFD23F'), T().unblock);
box.append(el('div', { className: 'needsbox' }, el('p', { className: 'k', textContent: T().needsTitle + (t.needsSince ? ' · waiting ' + ago(t.needsSince) : '') }), need, go));
}
if (t.mission && !Team.on()) box.append(el('p', { className: 'k', textContent: 'Mission' }), el('p', { textContent: t.mission }));
if (!Team.on()) detailSections(box, p, t);
if (Team.on()) priceBlock(box, p, t);
if (Team.on()) { if (Team.role() === 'viewer') teamTaskActions(box, p, t); else taskPath(box, p, t); if (Team.can('editTasks')) box.append(el('div', { className: 'dbtns' }, btn('Edit task', () => openTaskEditor(p, t), { className: 'dbtn small', ariaLabel: 'Edit ' + t.id, data: { key: 'task-edit' } }))); }
if (!Team.on()) box.append(el('div', { className: 'dbtns' }, btn('Edit task', () => openTaskEditor(p, t), { className: 'dbtn small', ariaLabel: 'Edit ' + t.id, data: { key: 'task-edit' } })));
if (!Team.on()) {
box.append(el('p', { className: 'k', textContent: 'Status' }));
const row = el('div', { className: 'dbtns', ariaLabel: 'Set status', role: 'group' });
for (const st of Store.statusList()) {
if (st === 'done' && Team.on() && !Team.can('approve') && t.status !== 'done') continue; // un Member no aprueba
if (st === 'review' && Team.on() && !Team.can('approve')) {
// Un Member no marca Done: su botón es "Submit for review" (con una nota).
if (t.status === 'review') { const b = btn('', null, { className: 'dbtn', disabled: true, data: { key: 'st|review' } }); b.append(ico('review', color), T().status.review); b.setAttribute('aria-pressed', 'true'); row.append(b); }
else { const b = btn('', () => { submitting = t.id; renderDetail(); }, { className: 'dbtn go', ariaLabel: 'Submit ' + t.id + ' for review', data: { key: 'submit' } }); b.append(ico('review', '#B28DFF'), T().review.submit); row.append(b); }
continue;
}
const b = btn('', () => { focus = { kind: 'task', id: t.id }; Actions.setTaskStatus(p.id, t.id, st); }, { className: 'dbtn', ariaLabel: 'Set ' + t.id + ' to ' + T().status[st], data: { key: 'st|' + st } });
b.append(ico(st, color), T().status[st]);
b.setAttribute('aria-pressed', String(t.status === st));
row.append(b);
}
box.append(row);
}
const viewerCard = Team.on() && Team.role() === 'viewer';
if (!viewerCard) plannedSection(box, p, t);
// 021 (G1): un miembro entrega dentro del paso «Deliver»; la sección de archivos queda para el líder (Reference).
if (!viewerCard && (!Team.on() || Team.role() === 'owner')) filesSection(box, p, t);
if (Team.on() && !viewerCard) {
const n = threadMessages(p.id, t.id).filter((m) => !m.deleted).length, u = Team.unreadIn(p.id, t.id);
box.append(el('div', { className: 'dbtns' }, btn('Thread' + (n ? ' (' + n + ')' : '') + (u ? ' · ' + u + ' new' : ''), () => openConversation(p.id, t.id), { className: 'dbtn' + (u ? ' go' : ''), ariaLabel: 'Thread of ' + t.id + ': ' + n + ' messages, ' + u + ' unread', data: { key: 'thread' } })));
}
if (typeof Agents !== 'undefined' && !viewerCard) Agents.assignRow(box, p, t);
if (Team.on() && !Team.can('status')) for (const n of box.querySelectorAll('.dbtns button:not(.wflink), .needsbox')) n.remove();
// En el pasado se mira, no se edita.
if (isPast()) {
for (const n of box.querySelectorAll('button:not(.wflink), textarea')) { n.disabled = true; }
box.append(el('p', { className: 'k', textContent: T().timeline.cardNote(whenLabel(pastAt)) }));
}
}
// Prompt 021 (G1): el camino de una tarea en 4 pasos: Read, Take, Deliver, In review. Una barra arriba; una caja por
// paso, con su color; solo la del paso actual está abierta. Poco texto y botones grandes. El servidor revisa cada paso.
const PATH = ['Read', 'Take', 'Deliver', 'In review'];
let takeAsk = null, giveAsk = null, lastStep = new Map(), celebrated = new Set(), takeLost = null, stepPeek = new Map();
const LOCK_SVG = '';
function pathStep(t) {
const me = Team.myId();
if (t.status === 'review' || t.status === 'done') return 4;
// Para el líder, una tarea tomada ya está en el paso 3 (ve quién la trabaja y su avance); para los demás, en el 2 (tomada).
if (t.assignee) return t.assignee === me || Team.role() === 'owner' ? 3 : 2;
return takeAsk === t.projectId + '|' + t.id ? 2 : 1;
}
function taskPath(box, p, t) {
const R = T().cycle, P = T().path, me = Team.myId(), owner = Team.role() === 'owner', reward = Store.rewardOf(t), lock = Store.lockOf(p.id, t);
const lead = Team.ownerName ? Team.ownerName() : 'the team lead';
const who = t.assignee ? (t.assignee === me ? 'you' : Team.nameOf(t.assignee) || 'someone') : '';
const mine = t.assignee === me, key = p.id + '|' + t.id;
const step = pathStep(t);
// Al cambiar de paso, la caja nueva entra deslizándose (también si la tarjeta se redibuja enseguida).
const last = lastStep.get(key);
if (!last || last.step !== step) lastStep.set(key, { step, prev: last ? last.step : null, at: last ? Date.now() : 0 });
const ls = lastStep.get(key), moved = !!ls.at && Date.now() - ls.at < 700, prev = ls.prev;
// Una línea de estado (como antes: la leen las pruebas y los lectores de pantalla)
let line = t.assignee ? (mine ? R.yours : R.takenBy(who)) : lock ? 'after ' + lock.after : R.nobody;
if (t.status === 'review') line = R.delivered(who, owner ? 'you' : lead);
if (t.status === 'done') line = reward ? R.paid : R.finished;
box.append(el('div', { className: 'dstatus', data: { key: 'd-status' } }, el('b', { textContent: lock && t.status !== 'done' ? 'Locked' : R.status[t.status] || t.status }), ' · ' + line));
agentBlock(box, p, t);
// La barra de pasos
const bar = el('div', { className: 'pbar', role: 'list', ariaLabel: 'Steps of this task', data: { key: 'path-bar' } });
PATH.forEach((name, i) => bar.append(el('span', { className: 'ps s' + (i + 1) + (i + 1 < step ? ' done' : i + 1 === step ? ' now' : ''), role: 'listitem', ariaCurrent: i + 1 === step ? 'step' : null, data: { key: 'path|' + (i + 1) } }, name, el('i'))));
box.append(bar);
const stepBox = (n, title, done, ...kids) => {
// Solo la del paso actual se abre sola; las demás se pueden mirar tocando su título (y se vuelven a cerrar).
const peeking = stepPeek.get(key) === n, open = n === step || peeking || (n === 2 && step === 1 && !!why);
const head = btn('', () => { stepPeek.set(key, peeking ? 0 : n); renderDetail(); }, { className: 'phead', ariaExpanded: String(open), ariaLabel: 'Step ' + n + ', ' + title + (n === step ? ', current step' : open ? ', click to close' : ', click to look'), data: { key: 'step-head|' + n } },
el('span', { className: 'pn', textContent: String(n) }), el('span', { textContent: title }), done ? el('span', { className: 'pdone', textContent: done }) : null);
if (n === step) head.disabled = true;
const b = el('section', { className: 'pstep st' + n + (open ? ' open' : '') + (n === step && moved ? ' enter' : ''), ariaLabel: 'Step ' + n + ': ' + title, data: { key: 'step|' + n } }, head);
if (open) b.append(el('div', { className: 'pbody' }, ...kids.filter(Boolean)));
box.append(b);
return b;
};
const big = (text, run, kind, k) => btn(text, run, { className: 'bigbtn' + (kind ? ' ' + kind : ''), data: { key: k } });
// Paso 1 · Read: todo lo que hay que saber, y "Take this task".
const canTake = !t.assignee && t.status !== 'review' && t.status !== 'done' && !lock && !isPast() && (owner || (Team.can('take') && reward > 0));
// 022 (R1): si no se puede tomar, el paso Take dice por qué (y se abre aunque el paso actual sea Read).
const why = takeWhy(p, t, { owner, lock, reward, big });
const readKids = [];
if (t.mission) readKids.push(el('p', { className: 'k', textContent: 'Mission' }), el('p', { textContent: t.mission }));
if (t.doneWhen) readKids.push(el('div', { className: 'ddone', data: { key: 'd-donewhen' } }, el('b', { textContent: 'Done when' }), el('span', { textContent: t.doneWhen })));
const rd = el('div'); detailSections(rd, p, t, false, { only: ['steps', 'outcomes', 'tests'] }); readKids.push(...rd.childNodes.length ? [rd] : []);
if (lock && step === 1) readKids.push(el('p', { className: 'dlock', data: { key: 'd-lock' }, textContent: lock.text }));
readKids.push(...taskFilesBox(p, t, false));
if (owner && reward > 0 && t.ready && t.status !== 'done') readKids.push(readyBox(p, t));
if (canTake && !why) readKids.push(big(P.take, () => { takeAsk = key; renderDetail(); }, 'go', 'take'));
else if (canTake && owner && why && why.kind === 'notready') readKids.push(big(R.takeMyself, () => { takeAsk = key; renderDetail(); }, 'soft', 'take'));
stepBox(1, 'Read', step > 1 ? '✓' : '', ...readKids);
// Paso 2 · Take: la confirmación; o, si es de otra persona, el candado con su nombre.
const takeKids = [];
if (step === 2 && takeAsk === key && !t.assignee) {
takeKids.push(el('div', { className: 'pconfirm', role: 'group', ariaLabel: 'Confirm taking ' + t.id },
el('p', { className: 'pq', textContent: P.takeAsk(t.id) }),
big(P.takeYes, async () => {
takeAsk = null; takeLost = null;
await Actions.takeTask(p.id, t.id);
// Si no quedó a mi nombre, otra persona la tomó antes: lo dirá en cuanto llegue su nombre.
const now = Store.task(p.id, t.id);
if (!now || now.assignee !== Team.myId()) { takeLost = key; renderDetail(); }
}, 'go', 'take-yes'),
big('Cancel', () => { takeAsk = null; renderDetail(); }, 'soft', 'take-no')));
} else if (why && !t.assignee) {
takeKids.push(whyLine(why));
if (why.extra) takeKids.push(...[].concat(why.extra).filter(Boolean));
} else if (t.assignee && !mine) {
if (takeLost === key) takeKids.push(el('p', { className: 'pnote', role: 'alert', data: { key: 'take-lost' }, textContent: P.lost(Team.nameOf(t.assignee) || 'Someone') }));
const ow = Team.memberOf ? Team.memberOf(t.assignee) : null;
const lk = el('div', { className: 'ptaken', data: { key: 'taken-lock' } }); lk.innerHTML = LOCK_SVG;
lk.append(el('span', { textContent: P.takenBy }), el('span', { className: 'who', textContent: Team.nameOf(t.assignee) || 'someone' }));
lk.setAttribute('aria-label', P.whyTaken(Team.nameOf(t.assignee) || 'someone'));
if (ow && ow.ship_color) lk.style.setProperty('--pc', ow.ship_color);
takeKids.push(lk);
}
stepBox(2, 'Take', t.assignee ? (mine ? 'Yours' : 'Taken') : '', ...takeKids);
// Paso 3 · Deliver: archivos, nota, pruebas, agente, y "Send for review".
const delKids = [];
if (t.backComment && t.status !== 'review' && t.status !== 'done') delKids.push(el('div', { className: 'pnote lead', role: 'alert', data: { key: 'd-checkthis' } }, el('b', { textContent: R.checkTitle(lead) + ' ' }), document.createTextNode(t.backComment)));
if (step === 3 && mine) {
// Al tomarla, los archivos de la tarea se desbloquean (con una animación al entrar a este paso).
const ref = el('div', { className: moved ? 'unlock' : '', data: { key: 'ref-files' } }, ...taskFilesBox(p, t, true)); if (ref.childNodes.length) delKids.push(ref);
const files = el('div'); deliverZone(files, p, t); delKids.push(files);
const dt = el('div'); detailSections(dt, p, t, false, { only: ['steps', 'tests'] }); if (dt.childNodes.length) delKids.push(dt);
const note = el('textarea', { className: 'needsin', rows: 2, placeholder: P.notePlaceholder, ariaLabel: 'A short note for ' + lead + ' (optional)', data: { key: 'submit-note' }, value: (pathDraft.get(key) || {}).note || '' });
const agent = el('input', { type: 'text', className: 'fnote-in', placeholder: 'Agent that helped (optional)', ariaLabel: 'Agent that helped (optional)', maxLength: 80, data: { key: 'submit-agent' }, value: (pathDraft.get(key) || {}).agent || '' });
const keep = () => pathDraft.set(key, { note: note.value, agent: agent.value });
note.addEventListener('input', keep); agent.addEventListener('input', keep);
delKids.push(note, agent);
const tests = Store.checkProgress(t).tests, unticked = tests.total - tests.done;
const missing = (t.deliverables || []).filter((n) => !Store.get().files.some((x) => x.projectId === p.id && x.taskId === t.id && x.kind === 'delivery' && x.name === n && !x.deleted));
if (submitting === t.id) {
delKids.push(el('div', { className: 'pconfirm', role: 'group', ariaLabel: 'Confirm sending ' + t.id, data: { key: 'submit-warn' } },
el('p', { className: 'pq', textContent: P.sendAsk(t.id, owner ? 'You' : lead) }),
missing.length ? el('p', { className: 'rwarn', role: 'alert', textContent: T().review.missing(missing) }) : null,
unticked ? el('p', { className: 'rwarn', role: 'alert', data: { key: 'submit-untested' }, textContent: T().review.untested(unticked, tests.total) }) : null,
big(P.sendYes, () => { submitting = null; const d = pathDraft.get(key) || {}; pathDraft.delete(key); Actions.submitForReview(p.id, t.id, [d.note || '', d.agent ? 'Agent that helped: ' + d.agent : ''].filter((x) => x.trim()).join(' · ')); }, 'go', 'submit-go'),
big('Cancel', () => { submitting = null; renderDetail(); }, 'soft', 'submit-cancel')));
} else if (!isPast()) delKids.push(big(P.send, () => { keep(); submitting = t.id; renderDetail(); }, 'go', 'submit'));
giveBackLink(delKids, p, t, key);
delKids.push(btn('Report a finding', () => openSuggest(t.workflow, { kind: 'finding', aboutTask: t.id }), { className: 'smallbtn', data: { key: 'finding' } }));
} else if (step === 3 && owner) delKids.push(el('p', { className: 'pwait', textContent: P.working(who) }), (() => { const d = el('div'); const pr = progressText(t); if (pr) d.append(el('p', { className: 'dprog', textContent: pr })); return d; })());
stepBox(3, 'Deliver', step > 3 ? '✓' : '', ...delKids);
// Paso 4 · In review: lo entregado; después, Approved (dorado, con el pago), Check this o Rejected.
const revKids = [];
if (t.status === 'done') {
if (!celebrated.has(key) && mine && moved) celebrated.add(key);
revKids.push(el('p', { className: 'pmoney', textContent: reward ? P.approvedPaid(reward) : P.approved }), reward ? matChips(forPrice(reward), { key: 'd-mats-paid' }) : null);
} else if (t.status === 'review') {
if (t.reviewNote) revKids.push(el('div', { className: 'pnote' }, el('b', { textContent: R.noteFrom(who) + ' ' }), document.createTextNode(t.reviewNote)));
const dl = filesOf(p, t).filter((x) => x.kind === 'delivery');
const g = mediaGallery(dl, 'Delivery'); if (g) revKids.push(g);
for (const x of latestOf(dl).filter((y) => Media.kindOf(y) === 'file')) revKids.push(fileRow(x));
if (!owner) revKids.push(el('p', { className: 'pwait', data: { key: 'waiting-for' }, textContent: P.waitingFor(lead) }));
if (owner && !isPast()) {
const dt = el('div'); detailSections(dt, p, t, false, { only: ['tests'] }); if (dt.childNodes.length) revKids.push(dt);
if (sendingBack === t.id) {
const c = el('textarea', { className: 'needsin', rows: 3, placeholder: R.checkPlaceholder, ariaLabel: 'What to check (required)', data: { key: 'back-comment' } });
revKids.push(el('div', { className: 'pconfirm' }, c, big(R.checkSend, () => { if (!c.value.trim()) { c.focus(); notify(T().review.needComment); return; } sendingBack = null; c.blur(); Actions.sendBack(p.id, t.id, c.value); }, 'go', 'back-go'),
big('Cancel', () => { sendingBack = null; renderDetail(); }, 'soft', 'back-cancel')));
} else if (approveAsk === key) revKids.push(approveConfirm(p, t));
else revKids.push(big(reward ? R.approvePay(reward) : R.approve, () => approveClick(p, t), 'go', 'approve'),
big(R.check, () => { sendingBack = t.id; renderDetail(); const n = document.querySelector('[data-key="back-comment"]'); if (n) n.focus(); }, 'soft', 'send-back'),
big(R.reject, () => Actions.rejectTask(p.id, t.id), 'danger', 'reject'));
}
}
const rb = stepBox(4, t.status === 'done' ? 'Approved' : 'In review', '', ...revKids);
if (t.status === 'done') { rb.classList.add('gold'); if (celebrated.has(key)) rb.classList.add('celebrate'); }
// El líder, en una tarea libre o con otra persona: "Mark done" para sus tareas sin precio, y "Decided" en Needs you.
if (owner && !isPast()) {
const acts = [];
if (t.status === 'needs-you' && t.assignee) acts.push(btn(R.decided, () => (reward ? Actions.giveBack(p.id, t.id) : Actions.setTaskStatus(p.id, t.id, t.assignee ? 'doing' : 'todo')), { className: 'dbtn go', data: { key: 'decided' } }));
if (t.status === 'doing' && mine && !reward) acts.push(btn(R.markDone, () => Actions.setTaskStatus(p.id, t.id, 'done'), { className: 'dbtn go', data: { key: 'markdone' } }));
if (acts.length) box.append(el('div', { className: 'dbtns' }, ...acts));
}
if (Team.can('editTasks')) box.append(el('div', { className: 'dbtns' }, btn('Edit price', () => openTaskEditor(p, t, null, { focus: 'reward' }), { className: 'dbtn small', ariaLabel: 'Edit the price of ' + t.id, data: { key: 'd-editprice' } })));
}
const pathDraft = new Map();
// 022 (R1): por qué una tarea libre no se puede tomar ahora, y qué lo desbloquea. Lo vuelve a revisar el servidor
// (take_it): esto solo lo explica.
function takeWhy(p, t, { owner, lock, reward, big }) {
const P = T().path;
if (isPast() || t.assignee || t.status === 'review' || t.status === 'done') return null;
if (t.status === 'needs-you') return { kind: 'waiting', ico: '@', text: P.whyWaiting, extra: owner ? big(T().cycle.decided, () => (reward ? Actions.giveBack(p.id, t.id) : Actions.setTaskStatus(p.id, t.id, 'todo')), 'go', 'why-decide') : null };
if (lock) return { kind: 'locked', ico: 'lock', text: P.whyLocked(lock.after) };
if (owner) {
if (reward > 0 && t.ready) return null;
const miss = [...(reward > 0 ? [] : ['a price']), ...[['steps', 'Steps'], ['outcomes', 'Expected outcomes'], ['tests', 'How to test']].filter(([k]) => !(Store.detailOf(t)[k] || []).length).map((x) => x[1])];
return { kind: 'notready', ico: '…', text: P.whyLeadNotReady, extra: [miss.length ? el('p', { className: 'pmiss', data: { key: 'why-missing' }, textContent: P.whyMissing(miss) }) : null,
reward > 0 ? readyBox(p, t, true) : big(P.whyLeadPrice, () => openTaskEditor(p, t, null, { focus: 'reward' }), 'go', 'why-price')] };
}
if (!(reward > 0) || !t.ready) return { kind: 'notready', ico: '…', text: P.whyNotReady };
if (Team.doingCount() >= 2) return { kind: 'two', ico: '2', text: P.whyTwo };
return null;
}
function whyLine(why) {
const ico = el('span', { className: 'wico', ariaHidden: 'true' });
if (why.ico === 'lock') ico.innerHTML = LOCK_SVG; else ico.textContent = why.ico;
return el('p', { className: 'pwhy ' + why.kind, role: 'status', data: { key: 'why-' + why.kind } }, ico, el('span', { textContent: why.text }));
}
// 021 (G2): los archivos de referencia de una tarea. Antes de tomarla: cuántos y sus nombres, nunca su contenido (el
// servidor tampoco lo da). Quien la tomó y el líder: miniaturas, enlaces y descargas.
function taskFilesBox(p, t, unlocked) {
const P = T().path;
const ref = latestOf(filesOf(p, t).filter((x) => x.kind === 'starting'));
if (!ref.length) return [];
const can = Team.role() === 'owner' || t.assignee === Team.myId();
if (!can || !unlocked) {
if (can) return [];
return [el('div', { className: 'pfiles', data: { key: 'files-locked' } }, el('p', { className: 'k', textContent: P.filesLocked(ref.length) }),
el('ul', { className: 'plist' }, ...ref.map((x) => el('li', { textContent: x.name }))))];
}
const out = [el('p', { className: 'k', textContent: P.filesOpen(ref.length) })];
const g = mediaGallery(ref, 'Reference'); if (g) out.push(g);
for (const x of ref.filter((y) => Media.kindOf(y) === 'file')) out.push(fileRow(x));
return out;
}
// El "Ready" del líder: lo revisa el servidor y dice qué falta.
const readyMissing = new Map();
function readyBox(p, t, bigOne = false) {
const P = T().path, key = p.id + '|' + t.id;
const box = el('div', { className: 'pready' + (t.ready ? ' on' : ''), data: { key: 'ready-box' } });
if (t.ready) {
box.append(el('b', { textContent: P.ready }), btn(P.unready, () => Team.setReady(p.id, t.id, false), { className: 'smallbtn', data: { key: 'ready-off' } }));
return box;
}
const nf = el('input', { type: 'checkbox', checked: !!t.noFiles, data: { key: 'ready-nofiles' } });
box.append(...(bigOne ? [] : [el('p', { className: 'k', textContent: P.notReadyLead })]),
btn(bigOne ? P.markReadyBig : P.markReady, async () => {
const r = await Team.setReady(p.id, t.id, true, nf.checked);
if (r && r.ok === false) readyMissing.set(key, r.missing || []); else readyMissing.delete(key);
renderDetail();
}, { className: 'bigbtn ' + (bigOne ? 'go' : 'soft'), data: { key: 'ready-on' } }),
el('label', { className: 'pcheck' }, nf, el('span', { textContent: P.noFiles })));
const miss = readyMissing.get(key);
if (miss && miss.length) box.append(el('p', { className: 'rwarn', role: 'alert', data: { key: 'ready-missing' }, textContent: P.missing(miss) }));
return box;
}
// "Give it back": pequeño, con confirmación.
function giveBackLink(kids, p, t, key) {
const P = T().path;
if (isPast()) return;
if (giveAsk === key) kids.push(el('div', { className: 'pconfirm', role: 'group', ariaLabel: 'Confirm giving back ' + t.id },
el('p', { className: 'pq', textContent: P.giveAsk(t.id) }),
btn(P.giveYes, () => { giveAsk = null; Actions.giveBack(p.id, t.id); }, { className: 'bigbtn soft', data: { key: 'giveback-yes' } }),
btn('Cancel', () => { giveAsk = null; renderDetail(); }, { className: 'smallbtn', data: { key: 'giveback-no' } })));
else kids.push(btn(T().cycle.giveBack, () => { giveAsk = key; renderDetail(); }, { className: 'smallbtn', data: { key: 'giveback' } }));
}
// La zona de entrega: arrastrar, elegir o pegar; y lo entregado hasta ahora.
function deliverZone(box, p, t) {
const P = T().path;
const dl = filesOf(p, t).filter((x) => x.kind === 'delivery');
const g = mediaGallery(dl, 'Delivery'); if (g) box.append(g);
for (const x of latestOf(dl).filter((y) => Media.kindOf(y) === 'file')) box.append(fileRow(x));
if (!Team.can('upload')) return;
const input = el('input', { type: 'file', multiple: true, hidden: true });
const send = async (files) => {
const res = await Team.uploadFiles(p.id, t.id, files, { kind: 'delivery' });
notify(res.map((x) => (x.error ? x.error : T().files.uploaded(x.name, x.version))).join('\n'));
};
input.addEventListener('change', () => { send(Array.from(input.files)); input.value = ''; });
const zone = el('div', { className: 'fdrop big', role: 'group', ariaLabel: 'Upload your delivery', data: { key: 'deliver-zone' } },
el('span', { textContent: P.drop(Team.maxMB()) }), btn('Choose files', () => input.click(), { className: 'dbtn small', data: { key: 'fpick' } }));
zone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); zone.classList.add('over'); });
zone.addEventListener('dragleave', () => zone.classList.remove('over'));
zone.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); zone.classList.remove('over'); document.body.classList.remove('dragging'); if (e.dataTransfer && e.dataTransfer.files.length) send(Array.from(e.dataTransfer.files)); });
const link = el('input', { type: 'url', className: 'fnote-in', placeholder: T().files.linkPlaceholder, ariaLabel: 'Paste a link', data: { key: 'flink' } });
const addLink = async () => { const r = await Team.addLink(p.id, t.id, link.value, { kind: 'delivery' }); if (r.error) { notify(r.error); return; } link.value = ''; notify(T().files.linked(r.name)); };
link.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addLink(); } });
box.append(zone, el('div', { className: 'flinkrow' }, link, btn('Add link', addLink, { className: 'dbtn small', data: { key: 'flink-go' } })), input);
}
// Prompt 020 (E3): el estado, de quién es, y los botones del ciclo de la tarea (como en la maqueta). El servidor revisa
// cada uno por su cuenta. (Desde el 021 lo usa la tarjeta de un Viewer con cuenta; el resto, taskPath.)
function teamTaskActions(box, p, t) {
const R = T().cycle, me = Team.myId(), owner = Team.role() === 'owner', reward = Store.rewardOf(t), lock = Store.lockOf(p.id, t);
const ownerName = Team.ownerName ? Team.ownerName() : 'Felipe';
const who = t.assignee ? (t.assignee === me ? 'you' : Team.nameOf(t.assignee) || 'someone') : '';
const mineOpen = Team.doingCount();
let line = t.assignee ? (t.assignee === me ? R.yours : R.takenBy(who)) : R.nobody;
if (!owner && !t.assignee && t.status !== 'review' && t.status !== 'done' && !lock && reward && mineOpen >= 2) line = R.finishOne;
if (t.status === 'review') line = R.delivered(who, owner ? 'you' : ownerName);
if (t.status === 'done') line = reward ? R.paid : R.finished;
if (t.status === 'doing' && t.backComment) line += ' · ' + R.checkThis(ownerName);
box.append(el('div', { className: 'dstatus', data: { key: 'd-status' } }, el('b', { textContent: lock ? 'Locked' : R.status[t.status] || t.status }), ' · ' + line));
if (t.status === 'doing' && t.backComment) box.append(el('div', { className: 'reviewbox back' }, el('p', { className: 'k', textContent: R.checkTitle(ownerName) }), el('p', { className: 'rtext', textContent: t.backComment })));
if (t.status === 'review' && t.reviewNote) box.append(el('div', { className: 'reviewbox' }, el('p', { className: 'k', textContent: R.noteFrom(who) }), el('p', { className: 'rtext', textContent: t.reviewNote })));
agentBlock(box, p, t);
missionBlock(box, p, t);
const acts = [];
const add = (name, key, run, kind = '') => acts.push(btn(name, run, { className: 'dbtn' + (kind ? ' ' + kind : ''), data: { key } }));
// Enviar a revisión: un clic; si faltan entregables, lo advierte y deja seguir.
const missing = (t.deliverables || []).filter((n) => !Store.get().files.some((x) => x.projectId === p.id && x.taskId === t.id && x.kind === 'delivery' && x.name === n && !x.deleted));
const untested = Store.checkProgress(t).tests;
const unticked = untested.total - untested.done;
const sendReview = () => { if (missing.length || unticked) { submitting = t.id; renderDetail(); return; } Actions.submitForReview(p.id, t.id, ''); };
if (!lock && !isPast()) {
if (owner) {
if (t.status === 'review') {
add(reward ? R.approvePay(reward) : R.approve, 'approve', () => approveClick(p, t), 'go');
add(R.check, 'send-back', () => { sendingBack = t.id; renderDetail(); const n = document.querySelector('[data-key="back-comment"]'); if (n) n.focus(); });
add(R.reject, 'reject', () => Actions.rejectTask(p.id, t.id), 'danger');
} else if (t.status === 'needs-you') add(R.decided, 'decided', () => (reward ? Actions.giveBack(p.id, t.id) : Actions.setTaskStatus(p.id, t.id, t.assignee ? 'doing' : 'todo')), 'go');
else if (!t.assignee && t.status !== 'done') add(R.takeMyself, 'take', () => Actions.takeTask(p.id, t.id), 'go');
else if (t.status === 'doing' && t.assignee === me && !reward) add(R.markDone, 'markdone', () => Actions.setTaskStatus(p.id, t.id, 'done'), 'go');
else if (t.status === 'doing' && t.assignee === me) add(R.sendReview, 'submit', sendReview, 'go');
} else if (Team.can('take')) {
// Libre y sin entregar (también si llegó «In progress» en un handoff): se toma, como en take_it del servidor.
if (!t.assignee && t.status !== 'review' && t.status !== 'done' && reward && mineOpen < 2) add(R.take(reward), 'take', () => Actions.takeTask(p.id, t.id), 'go');
if ((t.status === 'doing' || t.status === 'needs-you' || t.status === 'blocked') && t.assignee === me) {
add(R.sendReview, 'submit', sendReview, 'go');
add(R.giveBack, 'giveback', () => Actions.giveBack(p.id, t.id));
}
}
}
if (acts.length) box.append(el('div', { className: 'dbtns dacts', role: 'group', ariaLabel: 'What to do with ' + t.id }, ...acts));
if (submitting === t.id && t.status !== 'review' && (missing.length || unticked)) box.append(el('div', { className: 'reviewbox', data: { key: 'submit-warn' } }, missing.length ? el('p', { className: 'rwarn', role: 'alert', textContent: T().review.missing(missing) }) : null,
unticked ? el('p', { className: 'rwarn', role: 'alert', data: { key: 'submit-untested' }, textContent: T().review.untested(unticked, untested.total) }) : null,
el('div', { className: 'dbtns' }, btn(T().review.submitAnyway, () => { submitting = null; Actions.submitForReview(p.id, t.id, ''); }, { className: 'dbtn go', data: { key: 'submit-go' } }), btn('Cancel', () => { submitting = null; renderDetail(); }, { className: 'dbtn', data: { key: 'submit-cancel' } }))));
if (sendingBack === t.id && owner && t.status === 'review') {
const c = el('textarea', { className: 'needsin', rows: 2, placeholder: R.checkPlaceholder, ariaLabel: 'What to check (required)', data: { key: 'back-comment' } });
const go = btn(R.checkSend, () => { if (!c.value.trim()) { c.focus(); notify(T().review.needComment); return; } sendingBack = null; c.blur(); Actions.sendBack(p.id, t.id, c.value); }, { className: 'dbtn go', data: { key: 'back-go' } });
box.append(el('div', { className: 'reviewbox' }, c, el('div', { className: 'dbtns' }, go, btn('Cancel', () => { sendingBack = null; c.blur(); renderDetail(); }, { className: 'dbtn', data: { key: 'back-cancel' } }))));
}
}
// Prompt 020 (E1): precio, candado y "Done when" de una tarea (modo Team). El dueño edita el precio.
function priceBlock(box, p, t) {
const reward = Team.role() === 'viewer' ? 0 : Store.rewardOf(t), lock = Store.lockOf(p.id, t);
// Como la maqueta: el precio grande arriba a la derecha; debajo, los materiales y el candado.
const head = box.querySelector('.dhead');
if (head && reward) head.append(el('span', { className: 'dhprice', ariaHidden: 'true', textContent: '$' + reward }));
if (reward) box.append(el('p', { className: 'dprice', data: { key: 'd-price' }, textContent: '$' + reward + (t.status === 'done' ? ' · paid' : ' · when approved') }), matChips(forPrice(reward), { key: 'd-mats' }));
// 021 (G1): con el camino, el candado se dice una vez, en el paso Read (un Viewer lo ve aquí).
if (lock && Team.role() === 'viewer') box.append(el('p', { className: 'dlock', data: { key: 'd-lock' }, textContent: lock.text }));
}
// E8: "An agent is working on this now" / "Agent is waiting for an OK". El dueño y el dueño del agente ven el detalle
// (nombre, última acción, nota y tiempo en la tarea); un Member, de los demás, solo "Leo's agent is on it".
function agentBlock(box, p, t) {
if (Team.role() === 'viewer') return;
const a0 = Store.agentOn(p.id, t.id), A = T().agentCard;
const secs = Team.role() === 'owner' ? (Team.agentTime().tasks || {})[t.id] || 0 : 0;
if (Team.role() === 'owner') Team.loadAgentTime();
if (a0 && Store.agentAlive(a0)) {
const mine = Actions.ownAgent(a0), owner = Team.role() === 'owner';
const L = Agents.liveLine(a0), waiting = L.state === 'waiting';
const det = mine ? { note: a0.note || '' } : Team.agentDetail(a0.id) || {};
const whose = mine ? A.yours : A.whose(Team.nameOf(a0.owner) || 'Someone');
const text = mine || owner ? [a0.name + ' · ' + whose, L.text, det.note ? '“' + det.note + '”' : '', secs ? A.time(fmtSecs(secs)) : ''].filter(Boolean).join(' · ') : A.onIt(Team.nameOf(a0.owner) || 'Someone');
const b = el('div', { className: 'dagent' + (waiting ? ' wait' : ''), data: { key: 'd-agent' } }, el('b', { textContent: waiting ? A.waitingTitle : A.workingTitle }), el('span', { textContent: text }));
if (!waiting) b.style.setProperty('--ac', (Team.memberOf(a0.owner) || {}).ship_color || '#3EE0CF');
box.append(b);
} else if (secs) box.append(el('p', { className: 'k', data: { key: 'd-agent-time' }, textContent: A.timeOnly(fmtSecs(secs)) }));
}
const fmtSecs = (n) => { const m = Math.round(n / 60); return m >= 60 ? Math.floor(m / 60) + ' h ' + String(m % 60).padStart(2, '0') + ' m' : m + ' m'; };
// "Please read this" (E11): la primera vez que entra un Member, arriba a la izquierda; "Got it" lo quita para esa persona.
// La guía queda siempre en Help. El nombre del dueño sale del equipo.
let guideClosed = false;
function renderReadme() {
const box = document.getElementById('readme');
if (!box) return;
box.hidden = !teamUI() || Team.role() !== 'member' || Team.guideRead() || guideClosed;
box.textContent = '';
if (box.hidden) return;
box.append(btn('', () => openGuide(), { className: 'readme', data: { key: 'readme' }, ariaLabel: 'Please read this: how tasks and pay work, 1 minute' },
svg('', 'ricon'),
el('span', {}, 'Please read this', el('small', { textContent: 'How tasks and pay work · 1 minute' }))));
}
function openGuide() {
if (typeof Team !== 'undefined' && Team.on()) Team.guideLog(false);
const owner = typeof Team !== 'undefined' && Team.on() ? Team.ownerName() : 'The team lead';
const G = T().guide;
const root = openPanel('guide', G.title);
root.append(brandMark('glogo'));
const mat = (k) => el('i', { className: 'gm gm-' + k, ariaHidden: 'true' });
const flow = el('div', { className: 'gflow' });
[['1', 'Pick a planet', 'max 2 at a time', ''], ['2', 'Do the work', 'you or your agent', ''], ['3', 'Send for review', 'one click', ''], ['4', owner + ' checks', 'against "Done when"', 'chk'], ['5', 'You are paid', 'into your wallet', 'paid']]
.forEach(([n, name, sub, cls], i, arr) => { flow.append(el('div', { className: 'gstep ' + cls }, el('b', { className: 'gn', textContent: n }), el('strong', { textContent: name }), el('small', { textContent: sub }))); if (i < arr.length - 1) flow.append(el('span', { className: 'garrow', ariaHidden: 'true', textContent: '→' })); });
const answers = el('div', { className: 'gans' }, el('span', { className: 'ok' }, el('b', { textContent: 'Approve' }), ' → you are paid'), el('span', {}, el('b', { textContent: 'Check this' }), ' → fix it, send again'), el('span', { className: 'no' }, el('b', { textContent: 'Reject' }), ' → the task is open again'));
const mats = el('div', { className: 'gmats' }, ...[['iron', 'Iron', '$1', 'in every task'], ['crystal', 'Crystal', '$2', 'in every task'], ['gold', 'Gold', '$5', 'bonus only']].map(([k, n, v, sub]) => el('div', { className: 'gmat' }, mat(k), el('b', { textContent: n }), el('strong', { textContent: v }), el('small', { textContent: sub }))));
const sizes = el('div', { className: 'gsizes' }, ...[['Small', 1], ['Medium', 2], ['Large', 4]].map(([n, k]) => el('div', { className: 'gsize' }, el('b', { textContent: n }), el('span', { className: 'gsm' }, ...Array.from({ length: k * 3 }, (_, i) => mat(i < k * 2 ? 'crystal' : 'iron'))), el('strong', { textContent: '$' + k * 5 }))));
const bonus = el('div', { className: 'gbonus' }, ...[1, 2, 3, 4].map(() => el('i', { className: 'gdot', ariaHidden: 'true' })), el('span', { className: 'garrow', ariaHidden: 'true', textContent: '→' }), el('span', { className: 'gpill', textContent: '1 random bonus' }));
const odds = el('div', { className: 'godds' }, ...[['Iron 60%', 60, 'iron'], ['Crystal 30%', 30, 'crystal'], ['Gold', 10, 'gold']].map(([n, w, k]) => { const d = el('div', { className: 'go-' + k, textContent: n }); d.style.width = w + '%'; return d; }));
const cash = el('div', { className: 'gcash' }, el('div', { className: 'gcbar' }, el('i'), el('span', { className: 'gcline' })), el('p', { className: 'gcflow', textContent: '$20 or more → ask to cash out → ' + owner + ' pays you outside Tasky' }));
const rules = el('div', { className: 'grules' }, ...[['2', 'Two tasks at a time', 'Finish one to take another.'], ['L', 'Locked planets', 'They open when the task before is approved.'], ['A', 'Your agent counts', 'What it delivers is paid to you.']].map(([g, n, sub]) => el('div', { className: 'grule' }, el('b', { className: 'gg', textContent: g }), el('span', {}, el('strong', { textContent: n }), el('br'), sub))));
const got = btn('Got it', () => { guideClosed = true; if (typeof Team !== 'undefined' && Team.on()) Team.guideLog(true); closePanel(); render(); }, { className: 'primary', data: { key: 'guide-got' } });
root.append(el('div', { className: 'gwrap', data: { key: 'guide' } },
el('section', {}, el('h3', { textContent: '1 · A task, from planet to pay' }), flow, answers),
el('div', { className: 'gcols' },
el('section', {}, el('h3', { textContent: '2 · What a task pays' }), mats, sizes),
el('section', {}, el('h3', { textContent: '3 · Surprise bonus' }), bonus, el('p', { className: 'k', textContent: 'Every 4 approved tasks' }), odds, el('h3', { textContent: '4 · Cash out' }), cash)),
el('section', {}, el('h3', { textContent: '5 · Three rules' }), rules),
el('div', { className: 'pn-actions' }, got)));
}
// "History · only you see this" (E10, solo el dueño): lo último, y la vista completa con filtros.
const HIST_KINDS = { status: 'Status', task: 'Tasks', take: 'Status', giveback: 'Status', approve: 'Approvals and pay', check: 'Check this', reject: 'Rejected', paid: 'Cash outs', cashout: 'Cash outs', budget: 'Budget', suggestion: 'Suggestions', handoff: 'Handoffs', agent: 'Agents', viewer: 'Viewers', member: 'People', guide: 'People' };
const histKind = (k) => HIST_KINDS[k] || 'Other';
function histRow(x) {
return el('li', { className: 'hlog', data: { key: 'hlog|' + x.id } }, el('span', { className: 'htime', textContent: ago(new Date(x.at).getTime()) }), el('span', { className: 'htext', textContent: String(x.text || '').replace(/\bas Owner\b/g, 'as Team lead') }));
}
function renderHistCard() {
const box = document.getElementById('hist-card');
if (!box) return;
box.hidden = !teamUI() || Team.role() !== 'owner' || !Team.ownerLogLoaded();
if (box.hidden) return;
box.textContent = '';
box.append(el('div', { className: 'hchead' }, el('h2', { className: 'label', textContent: T().ownerHistory.title }), btn(T().ownerHistory.all, openOwnerHistory, { className: 'linkish', data: { key: 'hist-all' } })));
const list = el('ul', { className: 'hloglist' });
for (const x of Team.ownerLog().slice(0, 6)) list.append(histRow(x));
if (!Team.ownerLog().length) list.append(el('li', { className: 'k', textContent: T().ownerHistory.none }));
box.append(list);
}
function openOwnerHistory() {
const H = T().ownerHistory;
let who = '', kind = '';
const root = openPanel('ownerhist', H.title);
const body = el('div');
const fill = () => {
body.textContent = '';
const all = Team.ownerLog();
const people = [...new Set(all.map((x) => x.actor_name).filter(Boolean))].sort();
const kinds = [...new Set(all.map((x) => histKind(x.kind)))].sort();
const sw = el('select', { className: 'hsel', ariaLabel: H.byPerson, data: { key: 'hist-who' } }, el('option', { value: '', textContent: H.everyone }), ...people.map((n) => el('option', { value: n, textContent: n })));
const sk = el('select', { className: 'hsel', ariaLabel: H.byKind, data: { key: 'hist-kind' } }, el('option', { value: '', textContent: H.everything }), ...kinds.map((n) => el('option', { value: n, textContent: n })));
sw.value = who; sk.value = kind;
sw.addEventListener('change', () => { who = sw.value; fill(); }); sk.addEventListener('change', () => { kind = sk.value; fill(); });
const rows = all.filter((x) => (!who || x.actor_name === who) && (!kind || histKind(x.kind) === kind));
const list = el('ul', { className: 'hloglist full', data: { key: 'hist-list' } });
for (const x of rows.slice(0, 500)) list.append(histRow(x));
if (!rows.length) list.append(el('li', { className: 'k', textContent: H.none }));
body.append(el('p', { className: 'k', textContent: H.text }), ...[versionsBox(fill)].filter(Boolean), el('div', { className: 'pn-actions' }, sw, sk), list);
};
root.append(body); fill();
Team.setPanelRefresh(() => { if (document.querySelector('.panel-ownerhist')) fill(); });
}
// "Agent time · this week" (E8, solo el dueño): una barra por persona con horas, agentes y tareas.
function renderAgentTime() {
const box = document.getElementById('agent-time');
if (!box) return;
box.hidden = !teamUI() || Team.role() !== 'owner';
if (box.hidden) return;
Team.loadAgentTime();
const A = T().agentCard, week = Team.agentTime().week;
box.textContent = '';
box.append(el('h2', { className: 'label', textContent: A.weekTitle }));
if (!week) { box.append(el('p', { className: 'pempty', textContent: A.loading })); return; }
if (!week.length) { box.append(el('p', { className: 'pempty', textContent: A.none })); return; }
const max = Math.max(...week.map((x) => x.seconds), 1);
for (const x of week) {
const bar = el('span', { className: 'bar' }, el('i'));
bar.firstChild.style.width = Math.round(100 * x.seconds / max) + '%';
bar.style.setProperty('--c', (Team.memberOf(x.user) || {}).ship_color || '#3EE0CF');
box.append(el('div', { className: 'atrow', data: { key: 'atime|' + x.user } }, el('div', { className: 'athead' }, el('b', { textContent: x.name || 'Someone' }), el('span', { textContent: fmtSecs(x.seconds) })), bar,
el('span', { className: 'k', textContent: A.counts(x.agents, x.tasks) })));
}
box.append(el('p', { className: 'k', textContent: A.note }));
}
// Después del estado: la misión, "Done when" y (el dueño) «Edit price».
function missionBlock(box, p, t) {
if (t.mission) box.append(el('p', { className: 'k', textContent: 'Mission' }), el('p', { textContent: t.mission }));
if (t.doneWhen) box.append(el('div', { className: 'ddone', data: { key: 'd-donewhen' } }, el('b', { textContent: 'Done when' }), el('span', { textContent: t.doneWhen })));
detailSections(box, p, t);
if (Team.can('editTasks')) box.append(el('div', { className: 'dbtns' }, btn('Edit price', () => openTaskEditor(p, t, null, { focus: 'reward' }), { className: 'dbtn small', ariaLabel: 'Edit the price of ' + t.id, data: { key: 'd-editprice' } })));
}
// Prompt 020-B (A1): el detalle de la tarea. Cada apartado se pliega; Steps y How to test empiezan abiertos (lo que la
// persona abre o pliega se recuerda mientras la app está abierta). Steps y How to test son casillas de quien tiene la
// tarea; en la revisión, el dueño ve lo que marcó y marca la suya. Al Viewer no le llega nada de esto.
const DETAIL_ORDER = ['steps', 'outcomes', 'tests', 'resources', 'outOfScope', 'context', 'notes'];
const detailOpen = new Map();
const linkOrText = (x) => (/^https?:\/\/\S+$/.test(x) ? el('a', { href: x, target: '_blank', rel: 'noopener noreferrer', textContent: x }) : document.createTextNode(x));
function progressText(t) {
const pr = Store.checkProgress(t), out = [];
if (pr.steps.total) out.push(pr.steps.done + ' of ' + pr.steps.total + ' steps');
if (pr.tests.total) out.push(pr.tests.done + ' of ' + pr.tests.total + ' tests');
return out.join(' · ');
}
function detailSections(box, p, t, full = false, opts = {}) {
if (Team.on() && (Team.role() === 'viewer' || Team.isVisitor && Team.isVisitor())) return;
const owner = !Team.on() || Team.role() === 'owner';
if (owner && Store.needsDetail(t) && !full) box.append(el('div', { className: 'dbtns' }, el('p', { className: 'dneeds', data: { key: 'd-needsdetail' }, textContent: 'Needs detail · it has a price but no steps or tests yet' }),
...(!isPast() && (!Team.on() || Team.can('editTasks')) ? [btn('Add details', () => openTaskEditor(p, t, null, { focus: 'details' }), { className: 'dbtn small', data: { key: 'd-adddetail' } })] : [])));
const refFiles = Team.on() && (Team.role() === 'owner' || t.assignee === Team.myId()) ? Store.get().files.filter((x) => x.projectId === t.projectId && x.taskId === t.id && x.kind === 'starting' && !x.deleted) : [];
const refGallery = refFiles.some((x) => Media.kindOf(x) !== 'file') ? () => mediaGallery(refFiles, 'Reference') : null;
if (!Store.hasDetail(t) && !refGallery) return;
const d = { ...Store.detailOf(t) }, pr = Store.checkProgress(t);
if (refGallery && !d.resources) d.resources = [];
const canTick = (!Team.on() || t.assignee === Team.myId()) && t.status !== 'done' && !isPast();
const ownerReview = Team.on() && Team.role() === 'owner' && t.status === 'review';
const assigneeName = Team.on() && t.assignee ? (t.assignee === Team.myId() ? 'you' : Team.nameOf(t.assignee) || 'them') : '';
const wrap = el('div', { className: 'ddetail' + (full ? ' full' : ''), data: { key: full ? 'full-detail' : 'd-detail' } });
const prog = progressText(t);
if (prog) wrap.append(el('p', { className: 'dprog', data: { key: full ? 'full-progress' : 'd-progress' }, textContent: prog }));
for (const key of DETAIL_ORDER) {
if (opts.only && !opts.only.includes(key)) continue;
const v = d[key];
if (!v || (!v.length && !(key === 'resources' && refGallery))) continue;
const sec = Store.DETAIL_SECTIONS.find((x) => x.key === key);
const id = t.projectId + '|' + t.id + '|' + key;
const count = key === 'steps' ? pr.steps : key === 'tests' ? pr.tests : null;
const det = el('details', { className: 'dsec', data: { key: 'dsec|' + key } }, el('summary', { textContent: sec.name + (count && count.total ? ' · ' + count.done + '/' + count.total : '') }));
det.open = full || (detailOpen.has(id) ? detailOpen.get(id) : key === 'steps' || key === 'tests');
det.addEventListener('toggle', () => { if (!full) detailOpen.set(id, det.open); });
if (sec.kind === 'text') det.append(el('p', { className: 'dtext', textContent: v }));
else if (key === 'steps' || key === 'tests') {
const marked = new Set(((t.checks || {})[key]) || []), ownerMarked = new Set(((t.ownerChecks || {}).tests) || []);
const withOwner = key === 'tests' && Team.on() && Team.role() === 'owner' && (ownerReview || ownerMarked.size > 0);
if (withOwner) det.append(el('p', { className: 'dhint', textContent: 'Left: what ' + (assigneeName || 'the person') + ' ticked. Right (You): your own check before approving.' }));
const list = el(key === 'steps' ? 'ol' : 'ul', { className: 'dchecks' });
v.forEach((item, i) => {
const cb = el('input', { type: 'checkbox', checked: marked.has(item), disabled: !canTick, ariaLabel: (key === 'steps' ? 'Step ' + (i + 1) : 'Test ' + (i + 1)) + ': ' + item, data: { key: 'chk|' + key + '|' + i } });
cb.addEventListener('change', () => { Actions.setCheck(t.projectId, t.id, key, item, cb.checked); if (full) openFullTask(p, Store.task(t.projectId, t.id) || t); });
const li = el('li', {}, el('label', {}, cb, el('span', { textContent: (key === 'steps' ? (i + 1) + '. ' : '') + item })));
if (withOwner) {
const ob = el('input', { type: 'checkbox', checked: ownerMarked.has(item), disabled: !ownerReview || isPast(), ariaLabel: 'Your check, test ' + (i + 1) + ': ' + item, data: { key: 'ochk|' + i } });
ob.addEventListener('change', () => { Actions.setCheck(t.projectId, t.id, 'owner', item, ob.checked); if (full) openFullTask(p, Store.task(t.projectId, t.id) || t); });
li.append(el('label', { className: 'ochk' }, ob, el('span', { textContent: 'You' })));
}
list.append(li);
});
det.append(list);
} else {
if (v.length) det.append(el('ul', {}, ...v.map((x) => el('li', {}, linkOrText(x)))));
if (key === 'resources' && refGallery) det.append(refGallery());
}
wrap.append(det);
}
if (!full && !(opts.only && !opts.only.includes('outcomes'))) wrap.append(el('div', { className: 'dbtns' }, btn('Open full task', () => openFullTask(p, t), { className: 'dbtn small', ariaLabel: 'Open ' + t.id + ' in a wide panel', data: { key: 'd-full' } })));
box.append(wrap);
}
// "Open full task": la tarea entera en un panel ancho sobre el mapa, con todo abierto.
function openFullTask(p, t) {
const root = openPanel('task-full', t.id + ' · ' + t.title);
root.classList.add('wide');
const body = el('div', { data: { key: 'full-task' } });
if (t.mission) body.append(el('p', { className: 'k', textContent: 'Mission' }), el('p', { textContent: t.mission }));
if (t.doneWhen) body.append(el('div', { className: 'ddone' }, el('b', { textContent: 'Done when' }), el('span', { textContent: t.doneWhen })));
detailSections(body, p, t, true);
root.append(body);
}
function detailWorkflow(box, p, w) {
const c = Store.counts(p.id, w.id);
const color = Store.colorOf(p.id, w.id);
const dot = el('span', { className: 'bigid', textContent: w.id });
dot.style.setProperty('--c', color);
box.append(el('div', { className: 'dhead' }, dot, el('h3', { textContent: w.name })));
box.append(el('p', { textContent: w.mission || T().noMission }));
const wlock = Team.on() ? Store.wfLockOf(p.id, w) : null;
if (wlock) box.append(el('p', { className: 'dlock', data: { key: 'd-wflock' }, textContent: wlock.text }));
universeRow(box, p, w);
const bar = el('span', { className: 'bar' }, el('i'));
bar.firstChild.style.width = (c.total ? 100 * c.done / c.total : 0) + '%';
bar.style.setProperty('--c', color);
box.append(el('p', { className: 'k', textContent: c.done + ' of ' + c.total + ' done' }), bar, el('p', { className: 'k', textContent: 'Tasks' }));
const list = el('ul', { className: 'dtasks' });
for (const t of Store.tasksOf(p.id).filter((x) => x.workflow === w.id)) {
const b = btn('', () => { select({ kind: 'task', id: t.id }); LayoutHost.focus({ task: t.id }); }, { className: 'dtask', ariaLabel: t.id + ' ' + t.title + ', ' + T().status[t.status], data: { key: 'dt|' + t.id } });
b.append(ico(t.status, color), el('b', { textContent: t.id }), el('span', { textContent: t.title }));
list.append(el('li', {}, b));
}
box.append(list);
if (Team.on() && Team.can('editTasks')) box.append(el('div', { className: 'dbtns' },
btn('Add a task', () => openTaskEditor(p, null, w.id), { className: 'dbtn small', ariaLabel: 'Add a task to ' + w.name, data: { key: 'task-new' } }),
btn('Edit workflow', () => openWorkflowEditor(p, w), { className: 'dbtn small', ariaLabel: 'Edit the workflow ' + w.name, data: { key: 'wf-edit' } })));
else if (Team.on() && Team.role() !== 'owner' && Team.can('take')) box.append(el('div', { className: 'dbtns' }, btn(T().sug.tool, () => openSuggest(w.id), { className: 'dbtn small', ariaLabel: T().sug.tool + ' for ' + w.name, data: { key: 'wf-suggest' } })));
else if (!Team.on()) box.append(el('div', { className: 'dbtns' }, btn('New task', () => openTaskEditor(p, null, w.id), { className: 'dbtn small', ariaLabel: 'New task in ' + w.name, data: { key: 'task-new' } })));
}
// Editar un workflow a mano (prompt 020, E1; solo el dueño): nombre, misión, universo y "after".
function openWorkflowEditor(p, w) {
const root = openPanel('wf-editor', 'Edit workflow ' + w.id);
const name = el('input', { type: 'text', className: 'pn-input', value: w.name, maxLength: 200, ariaLabel: 'Name', data: { key: 'we-name' } });
const mission = el('textarea', { className: 'pn-input te-area', rows: 3, value: w.mission || '', ariaLabel: 'Mission', data: { key: 'we-mission' } });
const uni = el('input', { type: 'text', className: 'pn-input', value: w.universe || 'Main', maxLength: 60, ariaLabel: 'Universe', data: { key: 'we-universe' } });
const after = el('select', { className: 'hsel', ariaLabel: 'Unlocks after', data: { key: 'we-after' } }, el('option', { value: '', textContent: 'Nothing — open now' }), ...Store.workflowsOf(p.id).filter((x) => x.id !== w.id).map((x) => el('option', { value: x.id, textContent: 'Whole workflow: ' + x.name })));
after.value = w.after || '';
const field = (label, input) => el('label', { className: 'te-field' }, el('span', { textContent: label }), input);
root.append(field('Name', name), field('Mission', mission), field('Universe', uni), field('Unlocks after', after),
el('div', { className: 'pn-actions' }, btn('Save', () => { Actions.updateWorkflow(p.id, w.id, { name: name.value, mission: mission.value, universe: uni.value, after: after.value }); closePanel(); }, { className: 'primary', data: { key: 'we-save' } })));
name.focus();
}
// Panel del proyecto: renombrar, estado, handoff, reemplazar, duplicar, archivar, borrar.
const otherProjectButtons = () => {
const s = Store.get(), arch = s.projects.filter((x) => x.archived).length, trash = (s.trash || []).length;
return [...(arch ? [btn('Archived (' + arch + ')', openArchive, { title: T().tools.archived.text, data: { key: 'p-archived' } })] : []),
...(trash ? [btn('Trash (' + trash + ')', openTrash, { title: T().trash.title, data: { key: 'p-trash' } })] : [])];
};
function openProject(projectId) {
const p = Store.project(projectId);
if (!p) return;
const root = openPanel('project', 'Project');
const c = Store.counts(p.id);
const name = el('input', { type: 'text', className: 'pn-input', value: p.name, ariaLabel: 'Project name', autocomplete: 'off' });
name.addEventListener('input', () => { if (name.value.trim()) Actions.renameProject(p.id, name.value); });
name.addEventListener('keydown', (e) => { if (e.key === 'Enter') name.blur(); });
name.addEventListener('blur', () => { const q = Store.project(p.id); if (q) name.value = q.name; });
const st = el('div', { className: 'pn-actions', role: 'group', ariaLabel: 'Project status' });
const stBtns = Actions.PROJECT_STATUSES.map((x) => {
const b = btn(T().projectStatus[x], () => { Actions.setProjectStatus(p.id, x); stBtns.forEach((y) => y.setAttribute('aria-pressed', String(y.dataset.status === x))); }, { ariaLabel: 'Mark project ' + T().projectStatus[x], data: { status: x } });
b.setAttribute('aria-pressed', String(p.status === x));
st.append(b);
return b;
});
const confirm = el('div', { className: 'pn-confirm', hidden: true });
const del = btn('Delete', () => { confirm.hidden = false; yes.focus(); }, { className: 'danger' });
const yes = btn('Yes, delete', () => { const n = p.name; closePanel(); Actions.deleteProject(p.id); focus = null; notify(T().trash.moved(n)); render(); }, { className: 'danger' });
const no = btn('No', () => { confirm.hidden = true; del.focus(); });
confirm.append(el('span', { textContent: 'Delete “' + p.name + '” with all its tasks? It goes to the Trash for ' + Actions.TRASH_DAYS + ' days, with its history, and you can restore it. ' }), yes, no);
root.append(el('h3', { textContent: 'Name' }), name, el('p', { textContent: c.done + ' of ' + c.total + ' done' + (p.doneWhen ? ' · Done when: ' + p.doneWhen : '') }),
el('h3', { textContent: 'Status' }), st, el('h3', { textContent: 'Actions' }),
el('div', { className: 'pn-actions' },
btn('View handoff', () => viewHandoff(p.id), { className: 'primary' }),
btn('Replace handoff', () => replaceHandoff(p.id)),
btn('Duplicate', () => { const id = Actions.duplicateProject(p.id); closePanel(); focus = null; if (id) notify('Copy created: ' + Store.project(id).name + '.'); render(); }),
btn('Archive', () => { const n = p.name; closePanel(); Actions.archiveProject(p.id, true); focus = null; notify(n + ' archived. Find it under “Archived”.'); render(); }),
del), confirm);
// 023: en Team, el líder empieza un universo nuevo desde aquí; y como la columna de proyectos no se ve, aquí están
// también Archived y Trash para traer de vuelta lo que archivó o borró.
if (Team.on() && Team.role() === 'owner') root.append(el('h3', { textContent: T().newProject.title }), el('p', { className: 'k', textContent: T().newProject.short }),
el('div', { className: 'pn-actions' }, btn(T().newProject.title + '…', openNewProject, { className: 'primary', data: { key: 'new-project' } }), ...otherProjectButtons()));
name.focus();
}
// ---- Columnas ----
const TOOL_ICONS = {
ship: '',
live: '',
report: '',
suggest: '',
drop: '',
template: '',
export: '',
backup: '',
chat: '',
ranks: '',
news: '',
team: '',
history: '',
check: '',
help: '',
};
const TOOL_RUN = { guide: () => openGuide(), ownerHistory: () => openOwnerHistory(), ship: () => openShipPicker(), suggest: () => openSuggest(), report: () => openReport(), live: () => openLive(), chat: () => (Team.on() ? openChat() : (() => { const p = Store.current(); if (p) openConversation(p.id, null); })()), news: () => openNews(), check: () => openCheck(), ranks: () => openRanks(), team: () => openTeam(), drop: () => pickFile(), template: () => openTemplate(), export: () => exportAll(), history: () => openHistory(), backup: () => openBackup(), help: () => Help.open() };
function renderTools() {
const box = $('#tool-list');
box.textContent = '';
const icons = document.getElementById('tool-icons');
if (icons) icons.textContent = '';
if (viewerUI() && icons) {
for (const id of ['ship', 'help']) icons.append(el('button', { type: 'button', className: 'tic', onclick: TOOL_RUN[id], title: T().tools[id].name + ' · ' + T().tools[id].text, ariaLabel: T().tools[id].name, data: { key: 'tool|' + id } }, svg(TOOL_ICONS[id])));
return;
}
if (teamUI() && icons) {
// Prompt 021 (G0, G4): lo que una persona no puede usar no aparece (nada en gris). Handoffs, su plantilla, Team y
// Backup son del líder.
const lead = Team.role() === 'owner';
const writer = Team.role() === 'owner' || Team.role() === 'member';
const ids = [...(lead ? ['drop', 'check'] : []), ...(!lead && Team.can('take') ? ['suggest'] : []), ...(writer ? ['chat', 'news', 'ranks'] : []), 'report', ...(typeof Bridge !== 'undefined' ? ['live'] : []), ...(lead ? ['template'] : []), 'export', 'history', ...(lead ? ['team', 'backup'] : []), 'help'];
for (const id of ids) {
const ownerOnly = false;
const b = el('button', { type: 'button', className: 'tic' + (id === 'drop' && !ownerOnly ? ' primary' : ''), onclick: ownerOnly ? () => notify(T().team.ownerOnly) : id === 'history' && Team.role() === 'owner' && Team.ownerLogLoaded() ? openOwnerHistory : TOOL_RUN[id],
title: T().tools[id].name + ' · ' + (ownerOnly ? T().team.ownerOnly : T().tools[id].text), ariaLabel: T().tools[id].name, data: { key: id === 'report' || id === 'live' ? id : 'tool|' + id } }, svg(TOOL_ICONS[id])); // «Report» y «Live» conservan su clave de antes
if (ownerOnly) b.setAttribute('aria-disabled', 'true');
if (id === 'check') { const n = dataProblems(Store.current()).length; if (n) { b.append(el('span', { className: 'cbadge tb', textContent: String(n), data: { key: 'check-count-badge' } })); b.setAttribute('aria-label', 'Check project data: ' + n + ' problems'); } }
if (id === 'chat') { const n = chatUnreadAll(); if (n) { b.append(el('span', { className: 'cbadge tb', textContent: String(n), data: { key: 'chat-unread' } })); b.setAttribute('aria-label', 'Chat: ' + n + ' unread'); } }
icons.append(b);
}
return;
}
// Modo Team: importar handoffs es del dueño; el dueño tiene además la pantalla Team.
const ids = !Team.on() ? ['drop', 'template', 'export', 'history', 'backup', 'help']
: [...(Team.can('import') ? ['drop'] : []), 'chat', ...(Team.role() === 'owner' ? ['template'] : []), 'export', 'history', ...(Team.role() === 'owner' ? ['team'] : []), 'backup', 'help'];
for (const id of ids) {
const b = el('button', { type: 'button', className: 'tool' + (id === 'drop' ? ' primary' : ''), onclick: TOOL_RUN[id], title: T().tools[id].text, data: { key: 'tool|' + id } }, svg(TOOL_ICONS[id]), T().tools[id].name);
box.append(b);
}
}
function renderProjects() {
const box = $('#project-list');
const s = Store.get();
const cur = Store.current();
box.textContent = '';
const live = s.projects.filter((p) => !p.archived);
const list = el('div', { className: 'plist' });
if (!live.length) list.append(el('p', { className: 'pempty', textContent: T().noProjects }));
for (const p of live) {
const c = Store.counts(p.id);
const pct = c.total ? Math.round(100 * c.done / c.total) : 0;
const bar = el('span', { className: 'bar' }, el('i'));
bar.firstChild.style.width = pct + '%';
const elsewhere = multiUni() && !Store.workflowsOf(p.id).some((w) => (w.universe || 'Main') === currentUniverse()) && Store.workflowsOf(p.id).length > 0;
const b = el('button', { type: 'button', className: 'pitem ' + p.status + (elsewhere ? ' elsewhere' : ''), title: elsewhere ? T().universes.elsewhere([...new Set(Store.workflowsOf(p.id).map((w) => w.universe || 'Main'))].join(', ')) : '', onclick: () => { if (Store.current() && Store.current().id === p.id) openProject(p.id); else { focus = null; Actions.selectProject(p.id); } },
ariaLabel: p.name + ', ' + pct + '% done, ' + T().projectStatus[p.status], data: { key: 'proj|' + p.id } },
el('span', { className: 'pname', textContent: p.name }), el('span', { className: 'pct', textContent: pct + '%' }), bar);
b.setAttribute('aria-current', String(!!cur && cur.id === p.id));
list.append(b);
}
box.append(list);
const archived = s.projects.length - live.length;
if (archived) box.append(btn('Archived (' + archived + ')', openArchive, { className: 'linkish', title: T().tools.archived.text, data: { key: 'archived' } }));
const trash = s.trash || [];
if (trash.length && (!Team.on() || Team.can('editProjects'))) box.append(btn('Trash (' + trash.length + ')', openTrash, { className: 'linkish', title: T().trash.title, data: { key: 'trash' } }));
}
function renderHead() {
const head = $('#head');
head.textContent = '';
if (teamUI()) return renderHeadTeam(head);
const p = Store.current();
if (!p) return;
const c = Store.counts(p.id);
head.append(btn(p.name, () => { if (Team.on() && !Team.can('editProjects')) { notify(T().team.ownerOnly); return; } openProject(p.id); }, { className: 'ptitle', ariaLabel: p.name + ': project actions', title: T().projectTitle, data: { key: 'ptitle' } }),
el('span', { className: 'count', textContent: c.done + ' of ' + c.total + ' done' }),
btn('Report', () => openReport(), { className: 'chip report', title: T().report2.title, data: { key: 'report' } }));
const views = el('div', { className: 'views', role: 'group', ariaLabel: 'View' });
const cur = LayoutHost.current() ? LayoutHost.current().id : Store.get().settings.layoutId;
for (const l of LayoutHost.list().filter((x) => x.id !== 'mywork')) {
const b = btn(l.name, () => Actions.chooseLayout(l.id), { title: T().views[l.id] || T().views.own, data: { key: 'view|' + l.id } });
b.setAttribute('aria-pressed', String(l.id === cur));
if (l.factory) { views.append(b); continue; }
views.append(el('span', { className: 'own' }, b, btn('✕', () => removeLayout(l.id), { className: 'del', ariaLabel: 'Delete the view ' + l.name, data: { key: 'view-del|' + l.id } })));
}
views.append(btn('+', addLayout, { ariaLabel: 'Add your own view', title: T().views.add, data: { key: 'view|+' } }));
head.append(views);
}
function renderEmpty() {
const show = !Store.current();
const box = $('#empty');
box.hidden = !show;
$('#stage').hidden = show;
// 023: si el líder archivó todo, desde aquí lo trae de vuelta (en Team la columna de proyectos no se ve).
const other = box.querySelector('.empty-other');
if (show && other) { other.textContent = ''; if (teamUI() && Team.role() === 'owner') other.append(...otherProjectButtons().map((b) => { b.className = 'btn'; return b; })); }
if (!show || box.childElementCount) return;
const area = el('textarea', { rows: 5, placeholder: '…or paste the text of a .tasky.md here', ariaLabel: 'Handoff text to import' });
const big = el('button', { type: 'button', className: 'drop-zone', onclick: pickFile, ariaLabel: 'Drop or pick your first handoff' },
el('strong', { textContent: 'Drop your first handoff here' }), el('span', { textContent: 'a .tasky.md file, or click to pick it' }));
if (typeof Bridge !== 'undefined' && Bridge.servedByBridge()) {
const M = T().migrate;
const steps = el('ol', { className: 'msteps' });
for (const line of M.steps) steps.append(el('li', { textContent: line }));
box.append(el('section', { className: 'migrate', ariaLabel: M.title },
el('strong', { textContent: M.title }), el('p', { textContent: M.text }), steps,
btn(M.button, openBackup, { className: 'btn primary', data: { key: 'migrate-restore' } })));
}
box.append(big, el('p', { textContent: T().noHandoffYet }),
btn('Template for your agent', openTemplate, { className: 'btn' }),
el('div', { className: 'paste' }, area, btn('Import text', () => { if (area.value.trim()) { importTexts([{ name: '', text: area.value }]); area.value = ''; } }, { className: 'btn primary' })));
const more = el('div', { className: 'pn-actions empty-other' });
if (teamUI() && Team.role() === 'owner') more.append(...otherProjectButtons().map((b) => { b.className = 'btn'; return b; }));
box.append(more);
}
function renderWorkflows() {
const box = $('#wf-list');
box.textContent = '';
const p = Store.current();
if (!p) { box.append(el('p', { className: 'pempty', textContent: T().noWorkflows })); return; }
const uni = currentUniverse();
if (multiUni()) box.append(el('p', { className: 'wuni', textContent: uni }));
for (const w of Store.workflowsOf(p.id).filter((x) => !multiUni() || (x.universe || 'Main') === uni)) {
const c = Store.counts(p.id, w.id);
const pct = c.total ? 100 * c.done / c.total : 0;
const key = p.id + '|' + w.id;
const before = prevPct.has(key) ? prevPct.get(key) : pct;
prevPct.set(key, pct);
if (pct > before) bumpUntil.set(key, Date.now() + 900);
const color = Store.colorOf(p.id, w.id);
const bar = el('span', { className: 'bar' }, el('i'));
bar.firstChild.style.width = before + '%';
const b = el('button', { type: 'button', className: 'witem' + ((bumpUntil.get(key) || 0) > Date.now() ? ' bump' : ''), onclick: () => { select({ kind: 'workflow', id: w.id }); LayoutHost.focus({ workflow: w.id }); },
ariaLabel: w.name + ', ' + c.done + ' of ' + c.total + ' done' + (w.mission ? '. ' + w.mission : ''), data: { key: 'wf|' + w.id } },
el('span', { className: 'dot' }), el('span', { className: 'wname', textContent: w.name }), el('span', { className: 'wnum', textContent: c.done + '/' + c.total }),
el('span', { className: 'wmis', textContent: w.mission || '' }), bar);
const wl = Team.on() ? Store.wfLockOf(p.id, w) : null;
if (wl) { b.classList.add('locked'); b.append(el('span', { className: 'wlock', textContent: 'locked · ' + wl.text })); }
b.style.setProperty('--c', color);
b.setAttribute('aria-current', String(!!focus && focus.kind === 'workflow' && focus.id === w.id));
box.append(b);
if (before !== pct) requestAnimationFrame(() => requestAnimationFrame(() => { bar.firstChild.style.width = pct + '%'; }));
}
}
function keep(fn) {
const key = document.activeElement && document.activeElement.dataset ? document.activeElement.dataset.key : null;
fn();
if (key && !document.activeElement.closest('#panel')) { const n = Array.from(document.querySelectorAll('[data-key]')).find((x) => x.dataset.key === key); if (n && n !== document.activeElement) n.focus(); }
}
const viewerUI = () => teamUI() && Team.role() === 'viewer';
// 022 (R4): la franja de totales y el pie (leyenda y "Sign in") del Viewer.
const VCOLORS = [['done', 'Done', '#9BE8C2'], ['doing', 'In progress', '#FFD23F'], ['todo', 'Open', '#6660A0']];
function renderViewerBars() {
const top = document.getElementById('vbar'), foot = document.getElementById('vfoot');
if (!top || !foot) return;
top.textContent = ''; foot.textContent = '';
if (!viewerUI()) return;
const p = Store.current(), ts = p ? Store.tasksOf(p.id) : [];
const n = (st) => ts.filter((t) => (st === 'todo' ? t.status !== 'done' && t.status !== 'doing' : t.status === st)).length;
top.textContent = VCOLORS.map(([st, name]) => name + ' ' + n(st)).join(' · ');
const lg = el('div', { className: 'vlegend', data: { key: 'viewer-legend' } });
for (const [, name, c] of VCOLORS) { const i = el('i', { ariaHidden: 'true' }); i.style.background = c; lg.append(el('span', {}, i, name)); }
foot.append(lg, btn('Sign in', () => (Team.isVisitor() ? Team.leaveVisitor('') : Team.signOut('')), { className: 'vsign', data: { key: 'viewer-signin' } }));
}
function render() {
document.body.classList.toggle('team-ui', teamUI());
document.body.classList.toggle('viewer-ui', viewerUI());
if (!teamUI()) document.body.classList.remove('right-open');
keep(() => {
renderRow2(); renderTools(); renderPrompts(); renderReadme(); renderAccount(); renderBell(); renderAgentTime(); renderOnline(); renderHistCard();
renderMoney(); renderInbox(); renderProjects(); renderHead(); renderBar(); renderUniverses(); renderEmpty(); renderTimeline(); renderWorkflows(); renderDetail(); renderPeople(); publishFocus();
if (Team.on()) renderDuelAsk();
renderViewerBars();
if (tour.on && (!LayoutHost.current() || LayoutHost.current().id !== 'universe')) stopTour(false);
const n = waitOn().length + agentWaits().length + (Team.on() ? unreadNotifs().length : 0); // lo que te espera y lo no leído
document.title = n ? '(' + n + ') Tasky · work better as a team' : 'Tasky · work better as a team';
$('#agents').hidden = typeof Agents === 'undefined';
if (typeof Agents !== 'undefined') Agents.render();
});
}
function bindKeys() {
addEventListener('keydown', (e) => { if (e.key === 'Escape' && panel) { e.preventDefault(); closePanel(); } });
}
function start() {
bindErrors();
Team.prepare(); // en modo Team: el Store guarda en el servidor (en Personal no hace nada)
Store.load();
renderTools();
if (!Team.on()) storageNotice();
bindDrop(); bindPaste(); bindKeys();
LayoutHost.registerNative('now', 'Now', renderNow);
LayoutHost.registerNative('list', 'List', renderList);
LayoutHost.registerNative('mywork', 'My work', renderMyWork);
setInterval(() => { if (Team.on() && Team.isEntered()) LayoutHost.push(); }, Math.min(10000, Math.max(1000, (window.__taskyStaleMs || 120000) / 3)));
setInterval(() => { if (Team.on() && Team.isEntered()) renderDuelAsk(); }, 1500); // 021 (G10): encuentros
// Los menús de la fila de arriba se cierran al hacer clic fuera
document.addEventListener('click', (e) => { if (hmenu && !e.target.closest('.hmenu, [data-key$="-menu"]')) { hmenu = null; render(); } if (promptsOpen && e.target.isConnected && !e.target.closest('#prompts-slot')) { promptsOpen = false; renderPrompts(); } });
addEventListener('keydown', (e) => { if (e.key === 'Escape' && hmenu) { hmenu = null; render(); } });
LayoutHost.mount($('#stage'), selectedForLayout, activeFilter, pastState, () => (multiUni() ? currentUniverse() : null));
bindSearchKeys(); bindTourKeys();
Store.subscribe((st, opts) => { if (Team.on() && !(opts && (opts.local || opts.remote))) Team.sync(); });
Store.subscribe((st, opts) => { if (Team.on() && opts && opts.remote) Team.refreshPanel(); });
Store.subscribe(render);
Store.subscribe(() => recordLive());
Actions.onLog(offerUndo);
setInterval(renderInbox, 60000); // "waiting 5 min" sigue al día
render();
if (Team.on()) {
let onlineBefore = null;
Team.onPresence = () => {
// Los agentes de otra persona se ven solo mientras ella tiene Tasky abierto: al irse, "See you later".
const now = new Set(Team.people().map((x) => x.userId));
if (onlineBefore) for (const a of Store.get().agents) if (a.owner && a.owner !== Team.myId() && !a.away && onlineBefore.has(a.owner) && !now.has(a.owner)) brief(Help.TEXT.agents.seeYouTeam(a.name, Team.nameOf(a.owner)), 'bye');
const changed = !onlineBefore || [...now].some((x) => !onlineBefore.has(x)) || [...onlineBefore].some((x) => !now.has(x));
onlineBefore = now;
if (changed) render(); else LayoutHost.push();
renderPeople(); if (following) followNow();
};
Team.onEnter = () => { shipFirstTime(() => { if (memberSteps(trackSeen)) return; if (!Store.get().settings.helpSeen) { Help.open(); Actions.setHelpSeen(); } trackSeen(); }); setTimeout(() => { trashReminder(); storageReminder(); }, 1200); };
bindFollowStop();
Team.start();
}
else { if (!Store.get().settings.helpSeen) { Help.open(); Actions.setHelpSeen(); } trackSeen(); setTimeout(trashReminder, 1200); }
if (typeof Bridge !== 'undefined') Bridge.start();
watchVersion();
}
return { start, render, notify, importTexts, importFiles, pickFile, select, peek, taskClicked, openPanel, closePanel, focusables, el, btn, svg, ico,
exportAll, openTemplate, openBackup, viewHandoff, openArchive, openTrash, trashReminder, openProject, openNewProject, startNewProject, replaceHandoff, addLayout, uploadLayouts, removeLayout, downloadExampleLayout,
openLive, recordLive, live: () => live.slice(), agentWaits, openReport, renderMarkdown,
teamUI, showSchema, notifToast, openChat, openNews, levelCheck, openRanks, openWheel, openDuel, duelSeen, encounters, focusedTask: () => (focus && focus.kind === 'task' && Store.current() ? { projectId: Store.current().id, taskId: focus.id } : null), openSuggestion, openSuggest, openGuide, openOwnerHistory, openMemberPanel, openTeam, closeAgentToast, sessionEndedAsk, download, downloadBlob, copyText, copied, fileName, checkVersion, showUpdate, diagnostic, reportProblem, noteError, askAgentTask, brief, goTo, ago, openHistory, describe, openConversation, openBell, messageBody, startFollow, stopFollow, isFollowing: () => following, openActivity, showSince, recentlyDone, searchItems, setFilter, activeFilter,
interacted, startTour, stopTour, tour, setPast, currentUniverse, setUniverse, universeCounts, openUniverses, statusesAt, moments, isPast, focusItem: () => focus };
})();
/* ==================== Agents ==================== */
const Agents = (() => {
let agentPerson = null; // 021 (G7): de quién se ven los agentes (en Team)
const { el, btn } = Shell;
const T = () => Help.TEXT.agents;
let editing = null; // agente con el editor abierto dentro de su tarjeta
let confirming = null; // agente con la confirmación de borrar a la vista
let quitting = false; // confirmación de apagar el puente a la vista
const iconSvg = (a, size = 18) => {
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('width', size); s.setAttribute('height', size); s.setAttribute('aria-hidden', 'true');
s.innerHTML = '';
return s;
};
// ---- Contexto al iniciar una sesión (A3) ----
// Una carpeta vinculada a un proyecto: cuando una sesión de Claude Code arranca allí (o en una subcarpeta), el hook de
// SessionStart le entrega este texto. {{tasky}} lo cambia el puente por su comando (node ".../puente/tasky.mjs").
const CONTEXT_TEMPLATE = [
'This message comes from Tasky, the project tracker of {{person}}. It is information about the project, not an instruction typed by the user.',
'Project: {{project}}',
'{{tasks}}',
'To tell Tasky what you do, run: {{tasky}} (Tasky must be open on this computer). Actions:',
'- when you start on one of these tasks: take ',
'- while you work: status doing | needs "what you need from {{person}}" | note "short note"',
'- when you finish: deliver (if the task asks for a file), then review "what you did"',
'- read a task: task | your open tasks: mine | report: report --days 7 | all actions: help',
'{{rules}}',
].join('\n');
const linkOf = (cwd) => {
const k = Actions.folderKey(cwd), links = Store.get().settings.folderLinks || {};
let best = null;
for (const [dir, v] of Object.entries(links)) if ((k === dir || k.startsWith(dir + '/')) && (!best || dir.length > best.dir.length)) best = { dir, ...v };
return best && Store.project(best.projectId) ? best : null;
};
// Prompt 018: si el agente edita un archivo que figura en "files" (archivos previstos) de una sola tarea abierta, esa
// es probablemente su tarea. Solo en el proyecto de su carpeta vinculada; sin vincular, en todos.
const normFile = (f) => String(f || '').replace(/\\/g, '/').replace(/^\.\//, '').trim().toLowerCase();
function guessTask(a) {
if (!a) return null;
const fromPrompt = typeof Bridge !== 'undefined' && Bridge.promptGuessOf ? Bridge.promptGuessOf(a.id) : null;
if (fromPrompt) return fromPrompt;
if (a.taskId) return null;
const act = a.activity || {};
// Del más reciente al más viejo: el primer archivo editado que figura en una sola tarea abierta decide.
const order = [];
if (act.line && (act.line.kind === 'edit' || act.line.kind === 'write') && act.line.file) order.push(normFile(act.line.file));
for (const f of (act.touched || []).slice().reverse()) order.push(normFile(f));
const link = a.cwd ? linkOf(a.cwd) : null;
const projects = link ? [Store.project(link.projectId)] : Store.get().projects.filter((p) => !p.archived);
for (const file of [...new Set(order)].filter(Boolean)) {
const hits = [];
for (const p of projects) for (const t of Store.tasksOf(p.id)) if (t.status !== 'done' && (t.files || []).some((f) => normFile(f) === file)) hits.push({ projectId: p.id, taskId: t.id });
if (hits.length === 1) return hits[0];
}
return null;
}
function contextText(link) {
const p = link && Store.project(link.projectId);
if (!p) return '';
const team = typeof Team !== 'undefined' && Team.on();
const person = team ? Team.myName() : 'the user';
const ORDER = { doing: 0, 'needs-you': 1, review: 2, todo: 3, blocked: 4 };
// Prompt 018: todas las tareas abiertas del proyecto; en Team, primero las suyas y cada una dice de quién es.
const mine = (t) => team && t.assignee === Team.myId();
const all = Store.tasksOf(p.id).filter((t) => t.status !== 'done').sort((a, b) => (mine(b) - mine(a)) || ((ORDER[a.status] ?? 5) - (ORDER[b.status] ?? 5)));
const open = all.slice(0, team ? 12 : 8);
const whose = (t) => !team ? '' : mine(t) ? ' · yours' : t.assignee ? ' · ' + (Team.nameOf(t.assignee) || 'someone else') : ' · unassigned';
const clip = (x, n) => { x = String(x || '').replace(/\s+/g, ' ').trim(); return x.length > n ? x.slice(0, n - 1) + '…' : x; };
const S = Help.TEXT.status;
const tasks = open.length
? (team ? 'Open tasks (yours are marked "yours"; take one of those, or an unassigned one):' : 'Open tasks:') + '\n' + open.map((t) => '- ' + t.id + ' · ' + S[t.status] + whose(t) + ' · ' + clip(t.title, 80) + (t.mission ? ' — ' + clip(t.mission, team ? 100 : 140) : '') + ((t.files || []).length ? ' · files: ' + t.files.slice(0, 6).join(', ') : '')).join('\n') + (all.length > open.length ? '\n(' + (all.length - open.length) + ' more: ' + '{{tasky}} mine)' : '')
: 'No open tasks in this project right now.';
const rules = team ? 'You never approve a task or mark it done: when you finish, send it to review. Do not delete anything, invite people or change roles.' : 'When you finish, send the task to review: the user marks it done.';
const tpl = Store.get().settings.contextTemplate || CONTEXT_TEMPLATE;
return tpl.split('{{person}}').join(person).split('{{project}}').join(p.name).split('{{tasks}}').join(tasks).split('{{rules}}').join(rules).trim().slice(0, 3000);
}
// Prompt 018: para el recordatorio de declarar la tarea, el puente necesita las carpetas vinculadas (con o sin texto
// de inicio), las sesiones de los agentes propios que ya declararon una tarea, y el texto. {{tasky}} lo pone el puente.
const REMIND = 'Tasky (the project tracker of {{person}}) here: you started editing files in a folder that is linked to a Tasky project, but you have not said which task you are working on. Before you go on, run {{tasky}} take (your open tasks: {{tasky}} mine; all actions: {{tasky}} help). This is only a reminder: nothing is blocked, and it is the only one in this session.';
function remindInfo() {
const links = Object.values(Store.get().settings.folderLinks || {}).filter((l) => Store.project(l.projectId)).map((l) => l.cwd);
const declared = Store.get().agents.filter((a) => a.source === 'bridge' && a.session && !a.away && a.taskId && Actions.ownAgent(a)).map((a) => a.session);
const person = typeof Team !== 'undefined' && Team.on() ? Team.myName() : 'the user';
return { links, declared, remind: REMIND.split('{{person}}').join(person) };
}
// Lo que la app le da al puente: el texto de cada carpeta vinculada con el contexto encendido.
function contexts() {
return Object.values(Store.get().settings.folderLinks || {}).filter((l) => l.context !== false && Store.project(l.projectId)).map((l) => ({ cwd: l.cwd, text: contextText(l) }));
}
// ---- La línea viva (A2): "Working · editing extract.py · 12 min", "Waiting for you · needs permission"… ----
const mins = (ms) => Math.max(1, Math.round(ms / 60000)) + ' min';
function liveLine(a) {
const L = Help.TEXT.live;
const mine = Actions.ownAgent(a);
const teamOn = typeof Team !== 'undefined' && Team.on();
// E8: el dueño ve el detalle de todos (el que publica cada uno); un Member, de los demás solo que hay un agente.
const det = teamOn && !mine ? Team.agentDetail(a.id) : null;
const act = det && det.activity && Object.keys(det.activity).length ? Actions.cleanActivity(det.activity) : a && a.activity;
if (teamOn && !mine && Team.role() !== 'owner' && a) {
const st = act && act.waiting ? 'waiting' : act && (act.state === 'working' || act.workingSince) ? 'working' : a.status === 'working' ? 'working' : 'paused';
const n = Team.nameOf(a.owner) || L.owner;
return { state: st, text: st === 'paused' ? L.paused : L.onIt(n) + (st === 'waiting' ? ' · ' + L.needsOk : '') };
}
const who = mine ? L.you : (typeof Team !== 'undefined' && Team.on() ? Team.nameOf(a.owner) || L.owner : L.owner);
if (!act) return { state: a && a.status === 'working' ? 'working' : 'paused', text: a && a.status === 'working' ? L.working : L.paused };
const state = act.state || (act.waiting ? 'waiting' : act.workingSince ? 'working' : 'paused');
if (state === 'waiting' || act.waiting) return { state: 'waiting', text: L.waitingFor(who) + (act.waiting === 'permission' ? ' · ' + L.permission : act.waiting === 'answer' ? ' · ' + L.question : '') };
if (state === 'paused') return { state: 'paused', text: L.paused };
const l = act.line || {}, dur = act.workingSince ? ' · ' + mins(Date.now() - act.workingSince) : '';
const helpers = act.subagents ? ' · ' + L.helpers(act.subagents) : '';
if (l.kind === 'command') return { state, text: L.command + dur + helpers };
if (l.kind === 'read' && l.count > 1) return { state, text: L.reading(l.count) + dur + helpers };
const what = L.what(l.kind, l.file);
return { state, text: L.working + (what ? ' · ' + what : '') + dur + helpers };
}
const working = (a) => {
const p = a.taskId && Store.project(a.projectId);
const t = p && Store.task(p.id, a.taskId);
return t ? { p, t } : null;
};
// Botón de estado: Working (con ruedita), Waiting u Offline. Un clic lo cambia en los agentes manuales.
function statusButton(a) {
const label = Help.TEXT.agentStatus[a.status] || a.status;
const b = btn('', () => Actions.cycleAgentStatus(a.id), { className: 'aswitch ' + a.status, ariaLabel: a.name + ' is ' + label + (a.source === 'bridge' ? '' : '. Click to change'), data: { key: 'agent-sw|' + a.id, status: a.status } });
b.append(el('i', { className: 'aspin', ariaHidden: 'true' }), el('span', { textContent: label }));
if (a.source === 'bridge') { b.disabled = true; b.title = T().bridgeControls; }
else b.title = T().manualControls;
if (!Actions.ownAgent(a)) { b.disabled = true; b.title = T().theirs(Team.nameOf(a.owner)); }
return b;
}
// Editor dentro de la tarjeta: nombre, icono, color y borrar con confirmación.
function editor(a) {
const box = el('div', { className: 'aedit' });
const name = el('input', { type: 'text', className: 'ainput', value: a.name, ariaLabel: 'Agent name', autocomplete: 'off', data: { key: 'a-name|' + a.id } });
name.addEventListener('input', () => { if (name.value.trim()) Actions.updateAgent(a.id, { name: name.value }); });
name.addEventListener('keydown', (e) => { if (e.key === 'Enter') { editing = null; Shell.render(); } });
const icons = el('div', { className: 'apick', role: 'group', ariaLabel: 'Icon' });
for (const ic of Actions.AGENT_ICONS) {
const b = btn('', () => Actions.updateAgent(a.id, { icon: ic }), { className: 'aopt', ariaLabel: 'Icon ' + ic, data: { key: 'ai|' + a.id + '|' + ic } });
b.append(iconSvg({ icon: ic, color: a.color }));
b.setAttribute('aria-pressed', String(a.icon === ic));
icons.append(b);
}
const colors = el('div', { className: 'apick', role: 'group', ariaLabel: 'Color' });
for (const c of Actions.AGENT_COLORS) {
const b = btn('', () => Actions.updateAgent(a.id, { color: c }), { className: 'aopt swatch', ariaLabel: 'Color ' + c, data: { key: 'ac|' + a.id + '|' + c } });
b.style.setProperty('--c', c);
b.setAttribute('aria-pressed', String(a.color === c));
colors.append(b);
}
box.append(name, icons, colors);
if (confirming === a.id) box.append(deleteConfirm(a));
else box.append(el('div', { className: 'arow' },
btn('Done', () => { editing = null; Shell.render(); }, { className: 'abtn', data: { key: 'a-done|' + a.id } }),
btn('Delete agent', () => { confirming = a.id; Shell.render(); }, { className: 'abtn danger', data: { key: 'a-del|' + a.id } })));
return box;
}
function deleteConfirm(a) {
const box = el('div', { className: 'aconfirm', role: 'group', ariaLabel: 'Confirm delete' });
const done = () => { confirming = null; editing = null; };
if (a.source === 'bridge' && a.session) {
box.append(el('p', { textContent: T().deleteBridge(a.name) }),
el('div', { className: 'acol' },
btn('Delete and end its Claude Code session', async () => {
const n = a.name, session = a.session;
done(); Actions.deleteAgent(a.id);
Shell.notify(T().ending(n));
const r = await Bridge.end(session);
Shell.notify(r.ended ? (r.terminal === 'closed' ? T().endedClosed(n) : r.terminal === 'left-open' ? T().endedLeftOpen(n) : T().ended(n)) : T().endFailed(n, r.reason));
}, { className: 'abtn danger', data: { key: 'a-end|' + a.id } }),
btn('Delete only', () => { const n = a.name; done(); Actions.deleteAgent(a.id); Shell.notify(T().deletedOnly(n)); }, { className: 'abtn danger', data: { key: 'a-yes|' + a.id } }),
btn('No', () => { confirming = null; Shell.render(); }, { className: 'abtn', data: { key: 'a-no|' + a.id } })));
return box;
}
box.append(el('p', { textContent: 'Delete “' + a.name + '”?' }),
el('div', { className: 'arow' },
btn('Yes, delete', () => { const n = a.name; done(); Actions.deleteAgent(a.id); Shell.notify(n + ' was deleted.'); }, { className: 'abtn danger', data: { key: 'a-yes|' + a.id } }),
btn('No', () => { confirming = null; Shell.render(); }, { className: 'abtn', data: { key: 'a-no|' + a.id } })));
return box;
}
// Diagnóstico del puente (prompt 016): una línea con lo que ve, y por qué una sesión vista no aparece o por qué no
// contesta. Nunca muestra contenido: solo la carpeta de cada sesión.
function bridgeDiag() {
const d = Bridge.diag(), D = T().diag;
const at = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } catch { return ''; } };
const box = el('div', { className: 'adiag', data: { key: 'bridge-diag' } });
if (d.status === 'on' || d.status === 'old') {
const parts = [d.version ? D.version(d.version) : D.oldVersion, D.sessions(d.sessions), d.lastHook ? D.lastHook(at(d.lastHook), d.lastEvent) : D.noHook];
box.append(el('small', { textContent: parts.join(' · ') }));
if (d.status === 'old') box.append(el('p', { className: 'awarn', textContent: D.old }));
for (const h of d.hidden) box.append(el('p', { className: 'awarn', data: { key: 'bridge-hidden|' + h.session }, textContent: D.hidden(h.cwd ? h.cwd.split(/[\\/]/).filter(Boolean).pop() : h.session.slice(0, 8), h.cwd, D.why[h.reason] || D.why.notAdded) }));
return box;
}
// Sin puente es el modo manual de siempre: no se avisa nada. Solo se explica lo que hay que arreglar.
if (d.status !== 'denied' && d.status !== 'refused') return null;
box.append(el('p', { className: 'awarn', textContent: d.status === 'denied' ? D.denied : D.refused(location.origin) }));
return box;
}
// Panel Agents de la columna derecha. Cada agente se edita dentro de su propia tarjeta.
function render() {
const box = document.getElementById('agents');
box.textContent = '';
const head = el('div', { className: 'ahead' }, el('h2', { className: 'label', textContent: 'Agents' }));
if (typeof Bridge !== 'undefined' && Shell.teamUI()) {
const on = Bridge.isOn();
const b = btn('', () => { if (!on) { Shell.openLive(); return; } quitting = true; Shell.render(); const n = document.querySelector('[data-key="bridge-quit-yes"]'); if (n) n.focus(); },
{ className: 'brbtn ' + (on ? 'ison' : 'isoff'), title: on ? T().quitTitle : Help.TEXT.tools.bridge.text, ariaLabel: on ? 'Bridge: on. Turn off the bridge' : 'Bridge: off', data: { key: on ? 'bridge-quit' : 'bridge-off' } },
el('span', { className: 'on', textContent: 'Bridge: ' + (on ? 'on' : 'off') }), el('span', { className: 'off', textContent: 'Turn off' }));
head.append(el('div', { className: 'arow' }, b));
} else if (typeof Bridge !== 'undefined') {
const right = el('div', { className: 'arow' }, btn('Live', () => Shell.openLive(), { className: 'abtn small', title: Help.TEXT.live.title, ariaLabel: 'Live: what is happening now', data: { key: 'live' } }), Bridge.badge());
if (Bridge.isOn()) right.append(btn('Turn off', () => { quitting = true; Shell.render(); const n = document.querySelector('[data-key="bridge-quit-yes"]'); if (n) n.focus(); },
{ className: 'abtn small', title: T().quitTitle, ariaLabel: 'Turn off the bridge', data: { key: 'bridge-quit' } }));
head.append(right);
}
box.append(head);
if (typeof Bridge !== 'undefined') { const d = bridgeDiag(); if (d) box.append(d); }
if (quitting && typeof Bridge !== 'undefined' && Bridge.isOn()) {
box.append(el('div', { className: 'aconfirm', role: 'group', ariaLabel: 'Confirm turning off the bridge' },
el('p', { textContent: T().quitAsk + (Bridge.servedByBridge() ? ' ' + T().quitWindow : '') }),
el('div', { className: 'arow' },
btn('Yes, turn off', async () => { quitting = false; const ok = await Bridge.quit(); Shell.notify(ok ? T().quitDone : T().quitFailed); Shell.render(); }, { className: 'abtn danger', data: { key: 'bridge-quit-yes' } }),
btn('No', () => { quitting = false; Shell.render(); }, { className: 'abtn', data: { key: 'bridge-quit-no' } }))));
} else quitting = false;
// Prompt 021 (G7): en Team, una burbuja por persona (su nave en pequeño y un punto verde si está conectada). Al pulsar
// una, sus agentes. El líder ve el detalle de cualquiera; un miembro, el suyo, y de los demás solo cuántos trabajan.
const teamMode = Shell.teamUI() && typeof Team !== 'undefined' && Team.on() && Team.isEntered();
let showAgents = Store.visibleAgents();
if (teamMode) {
const me = Team.myId(), lead = Team.role() === 'owner';
if (!agentPerson || !Team.members().some((m) => m.user_id === agentPerson)) agentPerson = me;
const ownerOf = (a) => a.owner || me;
const people = Team.members().filter((m) => m.role === 'owner' || m.role === 'member').sort((x, y) => (y.user_id === me) - (x.user_id === me) || x.name.localeCompare(y.name));
const bubbles = el('div', { className: 'abubbles', role: 'tablist', ariaLabel: 'Agents by person' });
for (const m of people) {
const n = showAgents.filter((a) => ownerOf(a) === m.user_id && Store.agentAlive(a)).length, on = Team.isOnlineUser(m.user_id);
const b = btn('', () => { agentPerson = m.user_id; Shell.render(); }, { className: 'abub' + (agentPerson === m.user_id ? ' on' : ''), role: 'tab', ariaSelected: String(agentPerson === m.user_id), ariaLabel: (m.user_id === me ? 'You' : m.name) + (on ? ', online' : ', offline') + ', ' + n + (n === 1 ? ' agent' : ' agents'), title: m.user_id === me ? 'You' : m.name, data: { key: 'abub|' + m.user_id } },
typeof Ships !== 'undefined' ? Ships.icon(m, 22) : el('span'), on ? el('i', { className: 'abdot', ariaHidden: 'true' }) : null, n ? el('span', { className: 'abn', textContent: String(n) }) : null);
bubbles.append(b);
}
box.append(bubbles);
const who = agentPerson === me ? 'Your agents' : (Team.nameOf(agentPerson) || 'Their') + "'s agents";
box.append(el('p', { className: 'k', data: { key: 'agents-of' }, textContent: who }));
showAgents = showAgents.filter((a) => ownerOf(a) === agentPerson);
if (agentPerson !== me && !lead) {
const working = showAgents.filter((a) => Store.agentAlive(a) && a.status === 'working').length;
box.append(el('p', { className: 'asum', data: { key: 'agents-summary' }, textContent: working ? working + (working === 1 ? ' agent working' : ' agents working') : 'No agents working right now' }));
showAgents = [];
}
}
const list = el('div', { className: 'alist' });
for (const a of showAgents) {
const w = working(a);
const card = el('div', { className: 'acard ' + a.status + (editing === a.id ? ' editing' : '') });
const name = btn('', () => { editing = editing === a.id ? null : a.id; confirming = null; Shell.render(); }, { className: 'aname', ariaLabel: (editing === a.id ? 'Close the editor of ' : 'Edit ') + a.name, ariaExpanded: String(editing === a.id), data: { key: 'agent|' + a.id } });
name.append(el('span', { className: 'aicon' }, iconSvg(a)), el('span', { textContent: a.name }));
const job = w
? btn('Working on: ' + w.p.name + ' · ' + w.t.id, () => jump(a), { className: 'ajob', ariaLabel: a.name + ' is working on ' + w.p.name + ', task ' + w.t.id + '. Go to the task', data: { key: 'agent-job|' + a.id } })
: el('span', { className: 'ajob idle', textContent: 'No task' });
const ll = liveLine(a);
card.append(el('div', { className: 'arow' }, name, statusButton(a)), job, el('small', { className: 'alive ' + ll.state, data: { key: 'alive|' + a.id }, textContent: ll.text }));
if (!Actions.ownAgent(a)) {
const who = Team.nameOf(a.owner) || 'Someone';
name.disabled = true; name.setAttribute('aria-label', a.name + ', agent of ' + who); name.title = T().theirs(who);
card.classList.add('theirs');
card.append(el('small', { className: 'aowner', textContent: T().ownerOf(who) }));
}
if (editing === a.id) card.append(editor(a));
list.append(card);
}
if (!showAgents.length && !(teamMode && agentPerson !== Team.myId() && Team.role() !== 'owner')) list.append(el('p', { className: 'pempty', textContent: teamMode && agentPerson !== Team.myId() ? (Team.nameOf(agentPerson) || 'They') + ' has no agents working right now.' : T().none }));
// Prompt 021 (G0): sin «New agent»: un agente aparece solo cuando el puente lo detecta.
box.append(list, known(), folders());
}
// "Known agents": los agentes del puente recordados que no están abiertos, plegado por defecto. Solo para
// renombrarlos u olvidarlos; no es una lista de Offline.
let knownOpen = false;
function known() {
const away = Store.get().agents.filter((a) => a.away && Actions.ownAgent(a));
const box = el('div', { className: 'known' });
if (!away.length) return box;
box.append(btn('Known agents (' + away.length + ') ' + (knownOpen ? '▾' : '▸'), () => { knownOpen = !knownOpen; Shell.render(); },
{ className: 'linkish', ariaExpanded: String(knownOpen), title: T().knownTitle, data: { key: 'known' } }));
if (!knownOpen) return box;
box.append(el('p', { className: 'knote', textContent: T().knownHint }));
for (const a of away) {
const name = el('input', { type: 'text', className: 'ainput', value: a.name, ariaLabel: 'Name of the known agent ' + a.name, autocomplete: 'off', data: { key: 'k-name|' + a.id } });
name.addEventListener('change', () => { if (name.value.trim()) Actions.updateAgent(a.id, { name: name.value }); });
box.append(el('div', { className: 'krow' }, el('span', { className: 'aicon' }, iconSvg(a)), name,
btn('Forget', () => { const n = a.name; Actions.forgetAgent(a.id); Shell.notify(T().forgot(n)); }, { className: 'abtn', ariaLabel: 'Forget ' + a.name, data: { key: 'k-forget|' + a.id } })));
}
return box;
}
// "Folders": carpetas vinculadas a un proyecto, con su interruptor de contexto y el texto exacto que recibe el agente.
let foldersOpen = false;
function folders() {
const links = Object.values(Store.get().settings.folderLinks || {});
const box = el('div', { className: 'known' });
if (!links.length) return box;
box.append(btn('Folders (' + links.length + ') ' + (foldersOpen ? '▾' : '▸'), () => { foldersOpen = !foldersOpen; Shell.render(); }, { className: 'linkish', ariaExpanded: String(foldersOpen), title: T().foldersTitle, data: { key: 'folders' } }));
if (!foldersOpen) return box;
box.append(el('p', { className: 'knote', textContent: T().foldersHint }));
for (const l of links) {
const name = String(l.cwd).split(/[\\/]/).filter(Boolean).pop() || l.cwd;
const sel = el('select', { className: 'tproj fsel', ariaLabel: 'Project of the folder ' + name, data: { key: 'fl-proj|' + Actions.folderKey(l.cwd) } }, el('option', { value: '', textContent: 'Not linked' }), ...Store.get().projects.filter((p) => !p.archived).map((p) => el('option', { value: p.id, textContent: p.name })));
sel.value = Store.project(l.projectId) ? l.projectId : '';
sel.addEventListener('change', () => Actions.linkFolder(l.cwd, sel.value || null));
const on = l.context !== false;
const sw = btn(on ? 'Briefing: on' : 'Briefing: off', () => Actions.setFolderContext(l.cwd, !on), { className: 'abtn small' + (on ? ' on' : ''), title: T().briefingTitle, ariaPressed: String(on), data: { key: 'fl-ctx|' + Actions.folderKey(l.cwd) } });
box.append(el('div', { className: 'frow2' }, el('span', { className: 'fname2', title: l.cwd, textContent: name }), sel,
el('div', { className: 'arow' }, sw, btn('Show text', () => openBriefing(l.cwd), { className: 'abtn small', data: { key: 'fl-text|' + Actions.folderKey(l.cwd) } }))));
}
return box;
}
// El texto exacto que recibe el agente (si el puente está encendido, tal como lo entrega él) y su plantilla editable.
async function openBriefing(cwd) {
const link = linkOf(cwd);
const root = Shell.openPanel('briefing', 'Session briefing');
const pre = el('pre', { className: 'pn-text', tabIndex: 0, ariaLabel: 'Text the agent receives', data: { key: 'brief-text' } });
const tpl = el('textarea', { className: 'pn-input te-area brief-tpl', rows: 9, value: Store.get().settings.contextTemplate || CONTEXT_TEMPLATE, ariaLabel: 'Briefing template', data: { key: 'brief-tpl' } });
const fill = async () => {
let text = link ? contextText(link) : '';
if (typeof Bridge !== 'undefined' && Bridge.isOn()) { const exact = await Bridge.contextFor(cwd); if (exact != null) text = exact; }
pre.textContent = text || T().briefingNone;
};
root.append(el('p', { textContent: T().briefingHint(cwd, link ? Store.project(link.projectId).name : '') }), el('h3', { textContent: 'Exactly what the agent receives' }), pre,
el('h3', { textContent: 'Template (for every linked folder)' }), el('p', { className: 'fnote', textContent: T().briefingTemplate }), tpl,
el('div', { className: 'pn-actions' },
btn('Save template', async () => { Actions.setContextTemplate(tpl.value); await Bridge.pushContext(true); fill(); Shell.notify(T().briefingSaved); }, { className: 'primary', data: { key: 'brief-save' } }),
btn('Back to the default', async () => { Actions.setContextTemplate(''); tpl.value = CONTEXT_TEMPLATE; await Bridge.pushContext(true); fill(); }, { data: { key: 'brief-reset' } })));
await fill();
}
function jump(a) {
const w = working(a);
if (!w) return;
if (!Store.current() || Store.current().id !== w.p.id) Actions.selectProject(w.p.id);
Shell.select({ kind: 'task', id: w.t.id });
LayoutHost.focus({ task: w.t.id });
}
// Fila de agentes en la tarjeta de una tarea: un clic asigna o quita.
function assignRow(box, p, t) {
box.append(el('p', { className: 'k', textContent: 'Agents' }));
const agents = Store.visibleAgents().filter(Actions.ownAgent);
if (!agents.length) { box.append(el('p', { textContent: T().noneToAssign })); return; }
const row = el('div', { className: 'dbtns', role: 'group', ariaLabel: 'Assign an agent' });
for (const a of agents) {
const on = a.projectId === p.id && a.taskId === t.id;
const b = btn('', () => Actions.assignAgent(a.id, p.id, t.id), { className: 'dbtn', ariaLabel: (on ? 'Remove ' : 'Assign ') + a.name + (on ? ' from ' : ' to ') + t.id, data: { key: 'as|' + a.id } });
b.append(iconSvg(a), a.name);
b.setAttribute('aria-pressed', String(on));
row.append(b);
}
box.append(row);
}
return { render, assignRow, jump, iconSvg, liveLine, linkOf, guessTask, remindInfo, contextText, contexts, openBriefing, CONTEXT_TEMPLATE, edit: (id) => { editing = id; Shell.render(); } };
})();
/* ==================== Reports ==================== */
// Reportes (prompt 011, A6): solo hechos tomados de la historia, los entregables y la actividad de los agentes, para un
// periodo (hoy, últimos 7 días o entre dos fechas) y con filtros (universo, proyecto, workflow, persona, agente). Nada
// de texto inventado ni valoraciones: si un dato no existe, se omite. Se ve en pantalla y se descarga como .md; un agente
// lo pide con "node puente/tasky.mjs report". En Team se arma con lo que esa persona ve en la app.
const Reports = (() => {
const team = () => typeof Team !== 'undefined' && Team.on();
const ST = () => Help.TEXT.status;
const pad = (n) => String(n).padStart(2, '0');
const dayOf = (ms) => { const d = new Date(ms); return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); };
const fmtDay = (ms) => new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const fmtWhen = (ms) => new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + pad(new Date(ms).getHours()) + ':' + pad(new Date(ms).getMinutes());
const universeOf = (pid, wid) => ((Store.workflow(pid, wid) || {}).universe) || 'Main';
function range({ period = 'days', days = 7, from, to } = {}) {
const now = Date.now();
if (period === 'today') { const d = new Date(now); d.setHours(0, 0, 0, 0); return { start: d.getTime(), end: now, label: 'Today, ' + fmtDay(now) }; }
if (period === 'range' && from) {
const a = new Date(from + 'T00:00:00'), b = new Date((to || from) + 'T23:59:59.999');
if (!isNaN(a) && !isNaN(b)) return { start: a.getTime(), end: Math.min(b.getTime(), now), label: fmtDay(a.getTime()) + ' – ' + fmtDay(b.getTime()) };
}
const n = Math.max(1, Math.min(366, Number(days) || 7));
return { start: now - n * 864e5, end: now, label: 'Last ' + n + (n === 1 ? ' day' : ' days') + ' (' + fmtDay(now - n * 864e5) + ' – ' + fmtDay(now) + ')' };
}
const personName = (id) => (id ? (team() ? Team.nameOf(id) || '' : '') : '');
// El último agente que trabajó en la tarea, según la historia (asignación o resumen de sesión).
function agentOf(pid, tid, until) {
const h = Store.get().history;
for (let i = h.length - 1; i >= 0; i--) { const e = h[i]; if (e.at > until || e.projectId !== pid || e.taskId !== tid) continue; if ((e.kind === 'assign' && !e.removed) || e.kind === 'agentSession') return e.agentName || ''; }
const a = Store.agentOn(pid, tid);
return a ? a.name : '';
}
function progressAt(pid, wid, ms) {
const day = dayOf(ms);
const list = Store.history(pid, wid).filter((e) => e.date <= day);
return list.length ? list[list.length - 1] : null;
}
function build(opts = {}) {
const r = range(opts);
const projects = Store.get().projects.filter((p) => !p.archived && (!opts.projectId || p.id === opts.projectId));
const wfOk = (pid, wid) => (!opts.workflow || String(wid).toLowerCase() === String(opts.workflow).toLowerCase() || (Store.workflow(pid, wid) || {}).name === opts.workflow) && (!opts.universe || universeOf(pid, wid).toLowerCase() === String(opts.universe).toLowerCase());
const personOk = (t) => !opts.person || (team() ? (t.assignee === opts.person || personName(t.assignee).toLowerCase() === String(opts.person).toLowerCase()) : true);
const agentOk = (pid, t, until) => !opts.agent || agentOf(pid, t.id, until).toLowerCase() === String(opts.agent).toLowerCase();
// Tareas terminadas en el periodo: la última vez que pasaron a Done (de la historia), si siguen Done.
const doneAt = new Map();
for (const e of Store.get().history) {
if (e.at < r.start || e.at > r.end) continue;
const hits = e.kind === 'status' && e.to === 'done' ? [{ taskId: e.taskId }] : e.kind === 'import' ? (e.changes || []).filter((c) => c.to === 'done' && c.from) : [];
for (const h of hits) doneAt.set(e.projectId + '|' + h.taskId, e);
}
const done = [], workflows = [], open = [];
for (const p of projects) {
for (const w of Store.workflowsOf(p.id).filter((x) => wfOk(p.id, x.id))) {
const tasks = Store.tasksOf(p.id).filter((t) => t.workflow === w.id);
const finished = [];
for (const t of tasks) {
const e = doneAt.get(p.id + '|' + t.id);
if (!e || t.status !== 'done' || !personOk(t) || !agentOk(p.id, t, e.at)) continue;
const files = Store.get().files.filter((x) => x.projectId === p.id && x.taskId === t.id && x.kind === 'delivery' && !x.deleted);
const finals = [...new Set(files.map((x) => x.name))].map((n) => ({ name: n, version: files.filter((x) => x.name === n).reduce((m, x) => Math.max(m, x.version), 0) }));
const sessions = Store.get().history.filter((x) => x.kind === 'agentSession' && x.projectId === p.id && x.taskId === t.id);
const touched = [...new Set([...(t.touched || []), ...sessions.flatMap((x) => x.files || [])])];
const person = team() ? personName(t.assignee) || (e.actorName && !e.agentAct ? e.actorName : '') : '';
const item = { project: p.name, projectId: p.id, workflow: w.name, universe: universeOf(p.id, w.id), id: t.id, title: t.title, mission: t.mission || '', at: e.at, person, agent: agentOf(p.id, t.id, e.at), touched, deliverables: finals, approvedBy: e.review === 'approved' ? (e.actorName || e.who || '') : '' };
finished.push(item); done.push(item);
}
const a = progressAt(p.id, w.id, r.start - 1), b = progressAt(p.id, w.id, r.end);
const touchedHere = finished.length || (a && b && (a.done !== b.done || a.total !== b.total));
if (opts.person || opts.agent ? finished.length : (touchedHere || tasks.some((t) => t.status !== 'done'))) workflows.push({ project: p.name, projectId: p.id, id: w.id, name: w.name, universe: universeOf(p.id, w.id), mission: w.mission || '', start: a ? { done: a.done, total: a.total } : null, end: b ? { done: b.done, total: b.total } : null, done: finished.slice().sort((x, y) => x.at - y.at).map((x) => x.id + ' ' + x.title) });
for (const t of tasks.filter((x) => x.status !== 'done' && personOk(x) && (!opts.agent || agentOf(p.id, x.id, r.end).toLowerCase() === String(opts.agent).toLowerCase()))) {
const ag = Store.agentOn(p.id, t.id);
const waits = t.status === 'needs-you' ? (team() ? 'the team lead' : 'you') + (t.needs ? ': ' + t.needs : '')
: t.status === 'review' ? (team() ? 'the team lead (review)' : 'you (review)')
: t.status === 'blocked' ? 'unblocking'
: t.status === 'doing' ? [personName(t.assignee), ag ? ag.name : ''].filter(Boolean).join(' · ')
: team() && t.assignee ? personName(t.assignee) : '';
open.push({ project: p.name, projectId: p.id, workflow: w.name, universe: universeOf(p.id, w.id), id: t.id, title: t.title, status: t.status, waits });
}
}
}
done.sort((x, y) => x.at - y.at);
const filters = [opts.universe ? 'Universe ' + opts.universe : '', opts.projectId && Store.project(opts.projectId) ? 'Project ' + Store.project(opts.projectId).name : '', opts.workflow ? 'Workflow ' + opts.workflow : '', opts.person ? 'Person ' + (personName(opts.person) || opts.person) : '', opts.agent ? 'Agent ' + opts.agent : ''].filter(Boolean);
return { label: r.label, start: r.start, end: r.end, filters, counts: { done: done.length, review: open.filter((x) => x.status === 'review').length, waiting: open.filter((x) => x.status === 'needs-you').length }, workflows, done, open, multiUniverse: new Set(Store.get().workflows.map((w) => w.universe || 'Main')).size > 1 };
}
// El reporte como Markdown (lo mismo que se ve en pantalla). Solo hechos; lo que no existe no se escribe.
function markdown(r) {
const L = [], u = (x) => (r.multiUniverse ? ' · ' + x.universe : '');
L.push('# Tasky report · ' + r.label, '');
if (r.filters.length) L.push('Filters: ' + r.filters.join(' · '), '');
L.push('Made by Tasky from the history, the deliverables and the agents\' activity. Only facts: what is not recorded is left out.', '');
L.push('## In numbers', '', '- Done in this period: ' + r.counts.done, '- In review now: ' + r.counts.review, '- Waiting on someone now: ' + r.counts.waiting, '');
if (r.workflows.length) {
L.push('## By workflow', '');
for (const w of r.workflows) {
L.push('### ' + w.project + ' · ' + w.name + u(w), '');
if (w.mission) L.push('- Mission: ' + w.mission);
if (w.start && w.end) L.push('- Progress: ' + w.start.done + '/' + w.start.total + ' at the start → ' + w.end.done + '/' + w.end.total + ' at the end');
else if (w.end) L.push('- Progress at the end: ' + w.end.done + '/' + w.end.total);
if (w.done.length) L.push('- Done in this period: ' + w.done.join(', '));
L.push('');
}
}
if (r.done.length) {
L.push('## Done tasks', '');
for (const d of r.done) {
L.push('### ' + d.id + ' ' + d.title + ' (' + d.project + ' · ' + d.workflow + u(d) + ')', '');
if (d.mission) L.push('- Mission: ' + d.mission);
const by = [d.person, d.agent ? 'agent ' + d.agent : ''].filter(Boolean).join(' · ');
if (by) L.push('- Done by: ' + by);
L.push('- When: ' + fmtWhen(d.at) + (d.approvedBy ? ' (approved by ' + d.approvedBy + ')' : ''));
if (d.touched.length) L.push('- Files touched: ' + d.touched.join(', '));
if (d.deliverables.length) L.push('- Deliverables: ' + d.deliverables.map((x) => x.name + ' v' + x.version).join(', '));
L.push('');
}
}
if (r.open.length) {
L.push('## Still open', '', '| Task | Project · workflow | Status | Waiting for |', '|---|---|---|---|');
const cell = (x) => String(x || '').replace(/\|/g, '\\|').replace(/\s+/g, ' ');
for (const o of r.open) L.push('| ' + cell(o.id + ' ' + o.title) + ' | ' + cell(o.project + ' · ' + o.workflow + u(o)) + ' | ' + ST()[o.status] + ' | ' + cell(o.waits) + ' |');
L.push('');
}
return L.join('\n');
}
return { range, build, markdown };
})();
/* ==================== AgentCmd ==================== */
// Acciones del agente hacia Tasky (prompt 011, A4). Llegan por el puente (puente/tasky.mjs → POST /action → GET /agents)
// y corren aquí, con la sesión de quien tiene Tasky abierto. Límites: solo sobre el proyecto vinculado a la carpeta del
// agente y con los permisos de su dueño; en Team un agente nunca aprueba ni marca Done (su "terminé" es enviar a
// revisión), no borra, no invita, no cambia roles. Todo queda en la historia a nombre del agente (y de su dueño).
const AgentCmd = (() => {
// Prompt 020-B (A1): los apartados del detalle como texto para el agente (Steps numerados; los demás, con guiones).
function detailText(t, keys) {
const d = Store.detailOf(t), out = [];
for (const key of keys) {
const sec = Store.DETAIL_SECTIONS.find((x) => x.key === key), v = d[key];
if (!sec || !v || !v.length) continue;
out.push(sec.name + ':');
if (sec.kind === 'text') out.push(...v.split('\n').map((x) => ' ' + x));
else out.push(...v.map((x, i) => ' ' + (key === 'steps' ? (i + 1) + '. ' : '- ') + x));
}
return out.join('\n');
}
const team = () => typeof Team !== 'undefined' && Team.on();
const fail = (message) => ({ ok: false, message });
const done = (message, data) => ({ ok: true, message, ...(data !== undefined ? { data } : {}) });
const ST = () => Help.TEXT.status;
const clip = (x, n) => { x = String(x || '').replace(/\s+/g, ' ').trim(); return x.length > n ? x.slice(0, n - 1) + '…' : x; };
const who = (id) => (id ? (team() ? Team.nameOf(id) || 'someone' : '') : 'nobody');
const line = (t) => t.id + ' · ' + ST()[t.status] + ' · ' + clip(t.title, 80) + (t.mission ? ' — ' + clip(t.mission, 120) : '') + ((t.files || []).length ? ' · files: ' + t.files.slice(0, 6).join(', ') : '');
async function run(a) {
const agent = Store.get().agents.find((x) => x.source === 'bridge' && x.session === a.session && Actions.ownAgent(x));
if (!agent) return fail('Tasky does not know this agent yet: wait a few seconds after the session starts and try again. Nothing was changed.');
const link = Agents.linkOf(a.cwd || agent.cwd || '');
if (!link) return fail('This folder is not linked to a Tasky project, so this agent cannot change anything there. Ask the user to link it (the new agent notice, or Agents → Folders). Nothing was changed.');
const p = Store.project(link.projectId), by = agent.name, args = a.args || {};
const find = (id) => Store.tasksOf(p.id).find((x) => x.id.toLowerCase() === String(id || '').trim().toLowerCase()) || null;
const need = (id) => { const t = find(id); if (!t) throw new Error('There is no task "' + id + '" in ' + p.name + '. See your open tasks with: node tasky.mjs mine'); return t; };
const mine = (t) => !team() || t.assignee === Team.myId();
try {
switch (a.action) {
case 'mine': {
const open = Store.tasksOf(p.id).filter((t) => t.status !== 'done' && mine(t));
const free = team() ? Store.tasksOf(p.id).filter((t) => t.status === 'todo' && !t.assignee).slice(0, 5) : [];
return done('Project: ' + p.name + '\n' + (open.length ? (team() ? 'Your open tasks:' : 'Open tasks:') + '\n' + open.map((t) => '- ' + line(t)).join('\n') : (team() ? 'You have no open tasks in ' + p.name + '.' : 'No open tasks in ' + p.name + '.')) + (free.length ? '\nFree tasks you can take:\n' + free.map((t) => '- ' + line(t)).join('\n') : ''), { project: p.name, tasks: open.map((t) => ({ id: t.id, status: t.status, title: t.title })) });
}
case 'task': {
const t = need(args.id);
const prog = Store.checkProgress(t);
const files = Store.get().files.filter((x) => x.projectId === p.id && x.taskId === t.id && !x.deleted);
const latest = (kind, name) => files.filter((x) => x.kind === kind && x.name === name).reduce((m, x) => Math.max(m, x.version), 0);
const starting = [...new Set(files.filter((x) => x.kind === 'starting').map((x) => x.name))];
const ag = Store.agentOn(p.id, t.id);
return done([
t.id + ' ' + t.title + ' (' + ST()[t.status] + (team() ? ', person: ' + who(t.assignee) : '') + (ag ? ', agent: ' + ag.name : '') + ')',
'Project: ' + p.name + ' · workflow: ' + ((Store.workflow(p.id, t.workflow) || {}).name || t.workflow),
t.mission ? 'Mission: ' + t.mission : null,
t.doneWhen ? 'Done when: ' + t.doneWhen : null,
detailText(t, ['context', 'steps', 'outcomes', 'tests', 'resources', 'outOfScope', 'notes']) || null,
prog.steps.total || prog.tests.total ? 'Ticked: ' + prog.steps.done + ' of ' + prog.steps.total + ' steps, ' + prog.tests.done + ' of ' + prog.tests.total + ' tests (the person ticks them in Tasky).' : null,
(t.files || []).length ? 'Planned files (code this task touches): ' + t.files.join(', ') : null,
(t.deliverables || []).length ? 'Deliverables (upload to Tasky for review): ' + t.deliverables.map((n) => n + (latest('delivery', n) ? ' (delivered v' + latest('delivery', n) + ')' : ' (pending)')).join(', ') : null,
starting.length ? 'Starting files: ' + starting.map((n) => n + ' v' + latest('starting', n)).join(', ') : null,
t.status === 'needs-you' && t.needs ? 'Needs: ' + t.needs : null,
t.backComment ? 'Sent back: ' + t.backComment : null,
].filter(Boolean).join('\n'));
}
case 'take': {
const t = need(args.id);
if (t.status === 'done') return fail(t.id + ' is already done.');
if (team()) {
if (!Team.can('take')) return fail('Your role cannot take tasks.');
if (t.assignee && t.assignee !== Team.myId()) return fail(t.id + ' is already taken by ' + who(t.assignee) + '. Nothing was changed.');
if (!t.assignee && Team.role() !== 'owner') {
// 020/E3: las reglas de tomar (las vuelve a revisar el servidor)
const lock = Store.lockOf(p.id, t);
if (lock) return fail(t.id + ' is locked: ' + lock.text + '. Nothing was changed.');
if (!Store.rewardOf(t)) return fail(t.id + ' has no price: it belongs to the team lead. Pick a task with a price. Nothing was changed.');
if (Team.doingCount() >= 2) return fail('Your person already has two tasks in progress: finish one first. Nothing was changed.');
}
if (!t.assignee) Actions.setAssignee(p.id, t.id, Team.myId(), { by });
}
if (!(agent.projectId === p.id && agent.taskId === t.id)) Actions.assignAgent(agent.id, p.id, t.id, { by });
Shell.closeAgentToast(agent.id);
const now = Store.task(p.id, t.id);
// 020-B (A1): al tomarla, el agente recibe lo que hay que hacer y cómo comprobarlo.
const detail = detailText(now, ['steps', 'outcomes', 'tests', 'outOfScope']);
return done('You took ' + now.id + ' ' + now.title + '. It is ' + ST()[now.status] + '.' + (now.mission ? '\nMission: ' + now.mission : '') + (now.doneWhen ? '\nDone when: ' + now.doneWhen : '') + (detail ? '\n' + detail : '') + ((now.files || []).length ? '\nPlanned files: ' + now.files.join(', ') : ''));
}
case 'status': {
const t = need(args.id), st = String(args.value || '').trim().toLowerCase().replace(/\s+/g, '-');
const map = { todo: 'todo', 'to-do': 'todo', doing: 'doing', 'in-progress': 'doing', blocked: 'blocked', done: 'done', review: 'review', 'needs-you': 'needs-you', needs: 'needs-you' }[st];
if (!map) return fail('Unknown status "' + args.value + '". Use todo, doing or blocked.');
if (map === 'review') return fail('To send it to review, use: node tasky.mjs review ' + t.id + ' "what you did"');
if (map === 'needs-you') return fail('To ask the person for something, use: node tasky.mjs needs ' + t.id + ' "what you need"');
if (map === 'done' && team()) return fail('In Team mode an agent never marks a task done: send it to review with node tasky.mjs review ' + t.id + ' "what you did". Nothing was changed.');
if (team() && !Team.can('status')) return fail('Your role cannot change tasks.');
if (team() && t.status === 'review') return fail(t.id + ' is in review: the team lead approves it or sends it back. Nothing was changed.');
Actions.setTaskStatus(p.id, t.id, map, { by });
return done(t.id + ' is now ' + ST()[Store.task(p.id, t.id).status] + '.');
}
case 'needs': {
const t = need(args.id), reason = String(args.value || '').trim();
if (!reason) return fail('Write what you need, in quotes.');
if (team() && !Team.can('status')) return fail('Your role cannot change tasks.');
if (team() && Team.role() !== 'owner' && t.assignee !== Team.myId()) return fail(t.id + ' is not your person\'s task: take it first with node tasky.mjs take ' + t.id + '. Nothing was changed.');
Actions.setTaskStatus(p.id, t.id, 'needs-you', { by });
Actions.setTaskNeeds(p.id, t.id, reason);
return done(t.id + ' is now Needs you: "' + clip(reason, 200) + '". The person sees it in Waiting on you.');
}
case 'note': {
const t = need(args.id), text = String(args.value || '').trim();
if (!text) return fail('Write the note in quotes.');
if (team()) {
if (!Team.can('comment')) return fail('Your role cannot write in threads.');
Actions.postMessage(p.id, t.id, '[' + by + '] ' + text);
}
Actions.agentNote(p.id, t.id, by, text);
Actions.setAgentNote(agent.id, text);
if (team()) Team.publishActivity(true); // la nota llega al dueño enseguida
return done('Note added to ' + t.id + (team() ? ' (in its thread).' : ' (in the history).'));
}
case 'deliver': {
const t = need(args.id);
if (!team()) return fail('Files are delivered to Tasky in Team mode. In Personal mode, leave the file in the project and mention it with node tasky.mjs note ' + t.id + ' "…".');
if (!Team.can('upload')) return fail('Your role cannot upload files.');
const f = a.file;
if (!f || !f.data) return fail('No file came with the command.');
const bin = Uint8Array.from(atob(f.data), (c) => c.charCodeAt(0));
const res = await Team.uploadFiles(p.id, t.id, [new File([bin], String(f.name || 'file').slice(0, 200))], { kind: 'delivery', note: 'Delivered by ' + by, by });
const r = res[0] || {};
if (r.error) return fail('Not uploaded: ' + r.error);
return done('Delivered ' + r.name + ' as version ' + r.version + ' of ' + t.id + '.' + ((t.deliverables || []).length && !t.deliverables.includes(r.name) ? ' (The task asks for: ' + t.deliverables.join(', ') + '.)' : ''));
}
case 'review': {
const t = need(args.id), note = String(args.value || '').trim();
if (team()) {
if (!Team.can('submit')) return fail('Your role cannot send tasks to review.');
if (t.status === 'review') return fail(t.id + ' is already in review.');
const tests = Store.checkProgress(t).tests;
Actions.submitForReview(p.id, t.id, note, { by });
return done(t.id + ' was sent to review. The team lead approves it or sends it back.' + (tests.total > tests.done ? ' Note: ' + (tests.total - tests.done) + ' of ' + tests.total + ' tests in How to test are not ticked in Tasky.' : ''));
}
Actions.setTaskStatus(p.id, t.id, 'needs-you', { by });
Actions.setTaskNeeds(p.id, t.id, 'Review: ' + (note || 'the agent finished it'));
return done(t.id + ' waits for the user to review it (Needs you): "Review: ' + clip(note || 'the agent finished it', 200) + '".');
}
case 'report': {
if (typeof Reports === 'undefined') return fail('Reports are not available in this Tasky.');
const r = Reports.build({ period: args.today ? 'today' : args.from ? 'range' : 'days', days: Number(args.days) || 7, from: args.from, to: args.to, projectId: p.id, workflow: args.workflow, person: args.person, agent: args.agent, universe: args.universe });
return done(Reports.markdown(r));
}
default: return fail('Unknown action.');
}
} catch (e) { return fail(String((e && e.message) || e)); }
}
return { run };
})();
/* ==================== Bridge ==================== */
// Puente con Claude Code: si puente/tasky-bridge.mjs responde, cada 3 s trae las sesiones y las pasa por Actions.reportAgent.
// Si no responde, la app sigue en modo manual sin mostrar errores y vuelve a probar cada 15 s.
const Bridge = (() => {
// E8: cada 1,5 s con el puente encendido (es una llamada local): lo que hace un agente se ve en menos de 3 s.
const POLL_MS = 1500, RETRY_MS = 15000;
let on = false, timer = null;
// En modo Team la app viene de un hosting (o de otro puerto local): nunca la sirve el puente.
const servedByBridge = () => !(typeof Team !== 'undefined' && Team.on()) && typeof location !== 'undefined' && /^https?:$/.test(location.protocol) && /^(127\.0\.0\.1|localhost)$/.test(location.hostname);
const port = () => Number(Store.get().settings.bridgePort || (typeof Team !== 'undefined' && Team.on() && Team.config().bridgePort)) || 4317;
const url = () => servedByBridge() ? location.origin + '/agents' : 'http://127.0.0.1:' + port() + '/agents';
// Desde una página https, Chrome (Local Network Access) pide permiso para hablar con 127.0.0.1. 127.0.0.1 es del
// espacio "loopback" (permiso loopback-network), no "local" (la red de la casa): si el pedido declara 'local',
// Chrome lo bloquea. Se declara 'loopback' solo si este navegador conoce la opción; si no, nada (127.0.0.1 no es
// contenido mixto). Un fallo aquí solo deja el puente en "off". Ver docs/spec/agentes.md.
const LOOPBACK_OK = (() => { try { return typeof Request !== 'undefined' && new Request('http://127.0.0.1/', { targetAddressSpace: 'loopback' }).targetAddressSpace === 'loopback'; } catch { return false; } })();
const fetchInit = (init = {}, protocol = typeof location !== 'undefined' ? location.protocol : '') => (protocol === 'https:' && LOOPBACK_OK ? { ...init, targetAddressSpace: 'loopback' } : init);
const localInit = (init) => fetchInit(init);
let pollSeq = 0;
async function poll() {
clearTimeout(timer);
// Si alguien pide un sondeo mientras otro está en camino, solo vale la respuesta del último: una respuesta vieja
// que llega tarde no pisa a una nueva (y queda un solo ciclo de sondeo).
const seq = ++pollSeq;
let data = null, failure = null;
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 1500);
const r = await fetch(url(), localInit({ cache: 'no-store', signal: ctrl.signal }));
clearTimeout(t);
if (r.ok) data = await r.json();
else failure = r.status === 403 ? 'refused' : 'error';
} catch (e) { failure = e && e.name === 'AbortError' ? 'timeout' : 'unreachable'; }
if (seq !== pollSeq) return;
const was = on;
const ok = !!(data && Array.isArray(data.agents));
if (!ok && !failure) failure = 'error';
await diagnose(ok ? data : null, failure);
if (seq !== pollSeq) return;
// Un sondeo fallido justo después de estar encendido se reintenta enseguida antes de darlo por apagado.
if (!ok && was && !retrying) { retrying = true; timer = setTimeout(poll, 1500); return; }
retrying = false;
on = ok;
if (on) { if (!was) quiet = true; apply(data.agents); pushContext(); if (Array.isArray(data.actions)) for (const a of data.actions) runAction(a); }
else allGone(); // sin puente no se sabe qué sesiones siguen abiertas: los agentes del puente dejan de verse
if (was !== on) Agents.render();
timer = setTimeout(poll, on ? POLL_MS : RETRY_MS);
}
// working → Working; waiting → Waiting; ended, o ya no está en la lista → se va ("See you later").
// Una conversación nunca vista pregunta nombre y tarea; una ya nombrada que se retomó vuelve sola ("… is back").
// El primer sondeo (al abrir Tasky o al volver a encontrar el puente) aplica todo sin avisos breves.
const STATE = { working: 'working', waiting: 'waiting', idle: 'waiting' };
const ASK_DELAY_MS = () => Number(Store.get().settings.askDelayMs) || 60000;
// Prompt 018: cuánto espera un agente (permiso o respuesta) antes de que su tarea pase a Needs you.
const WAIT_MS = () => Number(Store.get().settings.agentWaitMs) || 120000;
const waitSeen = new Map(), guessed = new Map();
// Prompt 020 (E8). Latido: la hora de la última señal de cada sesión (cada evento de hook la renueva). Tiempo activo: el
// tiempo entre señales seguidas, sin las pausas de más de 5 minutos, por sesión y tarea; se manda al servidor cada
// minuto. Prompt: si nombra una sola tarea abierta del proyecto vinculado y el agente no declaró otra, se enlaza.
const lastSeen = new Map(), pendingTime = new Map(), promptSeen = new Map(), promptLinked = new Set();
const PAUSE_MS = 5 * 60 * 1000;
const seen = (agentId) => lastSeen.get(agentId) || 0;
function tickTime(a, at) {
const prev = lastSeen.get(a.id);
lastSeen.set(a.id, at);
if (!prev || at <= prev || !a.taskId) return;
const gap = at - prev;
if (gap > PAUSE_MS) return; // una pausa larga no cuenta
const key = a.id + '|' + (a.session || a.id) + '|' + a.projectId + '|' + a.taskId;
pendingTime.set(key, (pendingTime.get(key) || 0) + gap);
}
function flushTime() {
if (typeof Team === 'undefined' || !Team.on() || !pendingTime.size) return;
for (const [key, ms] of [...pendingTime]) {
const secs = Math.round(ms / 1000);
if (secs < 1) continue;
pendingTime.delete(key);
const [agentId, session, projectId, taskId] = key.split('|');
Team.addAgentTime(agentId, session, projectId, taskId, Math.min(secs, 300)).catch(() => {});
}
}
if (typeof setInterval !== 'undefined') setInterval(flushTime, 60000);
// Prompt 020-B (A0): que un prompt nombre una tarea ya no la enlaza sola. Queda como propuesta y el aviso le pregunta al
// dueño del agente ("Looks like S3 — confirm?"); solo "take" enlaza sin preguntar. Un satélite en la tarea equivocada
// es peor que ninguno.
const promptGuess = new Map();
function promptTarget(a, s) {
const link = typeof Agents !== 'undefined' && a.cwd ? Agents.linkOf(a.cwd) : null;
if (!link) return null;
const hits = s.promptIds.map((id) => Store.tasksOf(link.projectId).find((t) => t.id.toUpperCase() === id && t.status !== 'done')).filter(Boolean);
const uniq = [...new Set(hits.map((t) => t.id))];
if (uniq.length !== 1 || (a.projectId === link.projectId && a.taskId === uniq[0])) return null;
const t = Store.task(link.projectId, uniq[0]);
const team = typeof Team !== 'undefined' && Team.on();
if (team && t.assignee && t.assignee !== Team.myId()) return null; // de otra persona: no se toca
if (team && !t.assignee && Team.role() !== 'owner' && (!Store.rewardOf(t) || Store.lockOf(link.projectId, t) || Team.doingCount() >= 2)) return null;
return { projectId: link.projectId, taskId: t.id, fromPrompt: true };
}
function promptLink(a, s) {
if (!s.promptIds || !s.promptAt || promptSeen.get(a.id) === s.promptAt) return;
promptSeen.set(a.id, s.promptAt);
if (a.taskId && !promptLinked.has(a.id)) return; // lo declarado con "take" manda
const g = promptTarget(a, s);
if (!g) return;
const key = g.projectId + '|' + g.taskId;
if (promptGuess.has(a.id) && promptGuess.get(a.id).projectId + '|' + promptGuess.get(a.id).taskId === key) return;
promptGuess.set(a.id, g);
Shell.closeAgentToast(a.id);
Shell.askAgentTask(a.id);
}
// Lo que el dueño confirma en el aviso: en Team, si la tarea está libre, primero la toma (el servidor vuelve a revisar
// el límite de 2 y el precio).
function confirmPrompt(agentId) {
const a = Store.agent(agentId), g = promptGuess.get(agentId);
promptGuess.delete(agentId);
if (!a || !g) return false;
const t = Store.task(g.projectId, g.taskId);
if (!t || t.status === 'done') return false;
const team = typeof Team !== 'undefined' && Team.on();
if (team && !t.assignee) Actions.setAssignee(g.projectId, t.id, Team.myId(), { by: a.name });
if (!(a.projectId === g.projectId && a.taskId === t.id)) Actions.assignAgent(a.id, g.projectId, t.id, { by: a.name });
promptLinked.add(a.id);
return true;
}
const promptGuessOf = (agentId) => {
const g = promptGuess.get(agentId), a = Store.agent(agentId);
if (!g || !a) return null;
const t = Store.task(g.projectId, g.taskId);
if (!t || t.status === 'done' || (a.projectId === g.projectId && a.taskId === g.taskId)) { promptGuess.delete(agentId); return null; }
return g;
};
// Al terminar una sesión con su tarea en curso (sin enviarla a revisión), se le pregunta al dueño qué hacer con ella.
function sessionEnded(a, silent) {
waitSeen.delete(a.id);
Actions.agentWaiting(a.id, false); // si la había dejado en "Agent waiting", vuelve a In progress
const t = a.taskId && Store.task(a.projectId, a.taskId);
if (!silent && t && t.status === 'doing') Shell.sessionEndedAsk(a.id, a.projectId, t.id);
}
let quiet = true, retrying = false;
const T = () => Help.TEXT.agents;
// Diagnóstico (prompt 016), visible en el panel Agents: qué puente contesta (versión), cuántas sesiones ve, cuándo
// llegó el último aviso de un hook y, si una sesión vista no se muestra, por qué. Si no contesta, por qué: no hay
// puente, el puente no acepta esta dirección, o el navegador no da permiso para hablar con este computador.
const diag = { status: 'unknown', version: null, hooks: null, lastHook: null, lastEvent: null, sessions: 0, hidden: [], at: null, permission: null };
// El permiso de Chrome para hablar con 127.0.0.1 desde una página https: "loopback-network" (Chrome 145 en
// adelante; antes "local-network-access"). Desde el disco o desde el propio puente no hace falta.
async function loopbackPermission() {
if (typeof location === 'undefined' || location.protocol !== 'https:' || typeof navigator === 'undefined' || !navigator.permissions) return null;
for (const name of ['loopback-network', 'local-network-access']) {
try { return (await navigator.permissions.query({ name })).state; } catch {}
}
return null;
}
async function diagnose(data, failure) {
const before = JSON.stringify([diag.status, diag.version, diag.permission]);
if (data) {
const b = data.bridge || {};
diag.status = b.version ? 'on' : 'old';
diag.version = b.version || null; diag.hooks = typeof b.hooks === 'number' ? b.hooks : null;
diag.lastHook = b.lastHook || null; diag.lastEvent = b.lastEvent || null;
diag.cli = typeof b.cli === 'string' && b.cli.length < 500 ? b.cli : diag.cli || null; // el comando del puente (E7: los Prompts lo usan)
diag.sessions = data.agents.filter((x) => x && x.state !== 'ended').length;
diag.at = Date.now(); diag.permission = null;
} else {
diag.permission = failure === 'unreachable' ? await loopbackPermission() : null;
diag.status = diag.permission === 'denied' ? 'denied' : failure;
diag.sessions = 0; diag.hidden = [];
}
if (before !== JSON.stringify([diag.status, diag.version, diag.permission]) && typeof Agents !== 'undefined') Agents.render();
}
const hiddenWhy = (s) => {
if (typeof Team !== 'undefined' && Team.on()) {
if (!Team.isOnline()) return 'offline';
if (Team.role() === 'viewer') return 'viewer';
}
return 'notAdded';
};
function apply(list) {
const silent = quiet;
quiet = false;
const seen = new Set(), hidden = [];
const ignored = new Set(Store.get().settings.ignoredSessions || []);
const bySession = (id) => Store.get().agents.find((x) => x.source === 'bridge' && x.session === id && Actions.ownAgent(x));
for (const s of list) {
if (!s || typeof s.session !== 'string' || !s.session) continue;
seen.add(s.session);
if (ignored.has(s.session)) { if (s.state !== 'ended') hidden.push({ session: s.session, cwd: s.cwd || '', reason: 'deleted' }); continue; } // un agente borrado no reaparece aunque su sesión siga viva
if (s.state === 'ended') {
const a = bySession(s.session);
if (a && !a.away) {
if (s.activity) Actions.sessionSummary(a.id, s.activity, typeof Team !== 'undefined' && Team.on() ? Team.sharing() : 'files');
sessionEnded(a, silent);
Actions.sessionGone(a.id); if (!silent) Shell.brief(T().seeYou(a.name), 'bye');
}
continue;
}
const r = Actions.sessionOpen({ session: s.session, transcript: s.transcript || null, cwd: s.cwd || null, status: STATE[s.state] || 'waiting' });
if (!r) { hidden.push({ session: s.session, cwd: s.cwd || '', reason: hiddenWhy(s) }); continue; }
if (s.activity) Actions.agentActivity(r.id, s.activity);
{ const ag0 = Store.agent(r.id), at = Date.parse(s.last) || 0; if (ag0 && at) tickTime(ag0, at); if (ag0) promptLink(ag0, s); }
// A5: lo que el agente edita o escribe mientras trabaja en una tarea queda como "tocado" en esa tarea. En Team, solo
// si su dueño comparte nombres de archivo (el servidor lo vuelve a revisar).
const ag = Store.agent(r.id);
if (ag && ag.taskId && s.activity && Array.isArray(s.activity.touched) && s.activity.touched.length && (!(typeof Team !== 'undefined' && Team.on()) || Team.sharing() === 'files')) Actions.recordTouched(ag.projectId, ag.taskId, s.activity.touched);
// Prompt 018: esperando permiso o respuesta más de 2 minutos con una tarea → Needs you; al seguir, In progress.
const waitingNow = !!(s.activity && s.activity.waiting);
if (waitingNow) { if (!waitSeen.has(r.id)) waitSeen.set(r.id, Date.now()); if (Date.now() - waitSeen.get(r.id) >= WAIT_MS()) Actions.agentWaiting(r.id, true); }
else { waitSeen.delete(r.id); Actions.agentWaiting(r.id, false); }
// Prompt 018: sin tarea, si lo que edita figura en los archivos previstos de una sola tarea abierta, el aviso la propone.
const ag2 = Store.agent(r.id);
const g = !r.created && ag2 && !ag2.taskId && typeof Agents !== 'undefined' ? Agents.guessTask(ag2) : null;
if (g && guessed.get(r.id) !== g.projectId + '|' + g.taskId) { guessed.set(r.id, g.projectId + '|' + g.taskId); Shell.closeAgentToast(r.id); Shell.askAgentTask(r.id); }
if (r.created) {
// En una carpeta vinculada el agente recibe el contexto y puede declarar su tarea él solo (A4): el aviso manual
// espera un minuto y solo aparece si para entonces no declaró nada.
if (s.cwd && typeof Agents !== 'undefined' && Agents.linkOf(s.cwd)) setTimeout(() => { const a = Store.agent(r.id); if (a && !a.away && !a.taskId) Shell.askAgentTask(r.id); }, ASK_DELAY_MS());
else Shell.askAgentTask(r.id);
}
else if (r.back && !silent) Shell.brief(T().back(Store.agent(r.id).name), 'back');
}
for (const a of Store.get().agents) {
if (a.source !== 'bridge' || a.away || seen.has(a.session) || !Actions.ownAgent(a)) continue;
sessionEnded(a, silent);
Actions.sessionGone(a.id);
if (!silent) Shell.brief(T().seeYou(a.name), 'bye');
}
Actions.forgetIgnored(seen);
const changed = JSON.stringify(hidden) !== JSON.stringify(diag.hidden);
diag.hidden = hidden;
if (changed && typeof Agents !== 'undefined') Agents.render();
}
function allGone() { for (const a of Store.get().agents) if (a.source === 'bridge' && !a.away && Actions.ownAgent(a)) Actions.sessionGone(a.id); }
function badge() {
const b = Shell.el('span', { className: 'bridge' + (on ? ' on' : ''), textContent: 'Bridge: ' + (on ? 'on' : 'off'), title: Help.TEXT.tools.bridge.text });
return b;
}
// Pide al puente que termine una sesión de Claude Code. Devuelve { ok, ended, terminal, reason }.
async function end(session) {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 15000);
const r = await fetch(url().replace(/\/agents$/, '/end'), localInit({ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Tasky': '1' }, body: JSON.stringify({ session }), signal: ctrl.signal }));
clearTimeout(t);
const data = await r.json().catch(() => ({}));
return { ok: !!data.ok, ended: !!data.ended, terminal: data.terminal || 'none', reason: data.reason || (r.ok ? '' : 'error ' + r.status) };
} catch { return { ok: false, ended: false, terminal: 'none', reason: 'the bridge is not running' }; }
}
// Apaga el puente (botón "Turn off" del panel Agents). Devuelve true si el puente lo aceptó.
async function quit() {
let ok = false;
try {
const r = await fetch(url().replace(/\/agents$/, '/quit'), localInit({ method: 'POST', headers: { 'X-Tasky': '1' } }));
ok = r.ok;
} catch {}
// "Turn off" de verdad: se comprueba que el puente dejó de contestar (un puente viejo puede decir que sí y seguir).
if (ok) {
let still = false;
await new Promise((r) => setTimeout(r, 600));
try { const r = await fetch(url(), localInit({ cache: 'no-store', signal: AbortSignal.timeout(1200) })); still = r.ok; } catch {}
ok = !still;
}
if (ok) { on = false; diag.status = 'unreachable'; clearTimeout(timer); timer = setTimeout(poll, RETRY_MS); Agents.render(); }
return ok;
}
// Contexto al iniciar sesión (A3): la app le da al puente el texto de cada carpeta vinculada; si cambia, enseguida, y
// si no, cada 30 s (el puente lo da por vencido a los 2 minutos: si la app se cierra, deja de entregarse).
let pushedText = '', pushedAt = 0;
async function pushContext(force = false) {
if (!on) return false;
const folders = typeof Agents !== 'undefined' ? Agents.contexts() : [];
const extra = typeof Agents !== 'undefined' ? Agents.remindInfo() : {};
const text = JSON.stringify([folders, extra]);
// Sin carpetas vinculadas no hay texto de inicio ni recordatorio que dar: no se manda nada (solo una vez, para borrar
// lo que se hubiera mandado antes).
const empty = !folders.length && !(extra.links || []).length;
if (!force && empty && (pushedText === '' || pushedText === 'empty')) return true;
if (!force && text === pushedText && Date.now() - pushedAt < 30000) return true;
try {
const r = await fetch(url().replace(/\/agents$/, '/context'), localInit({ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Tasky': '1' }, body: JSON.stringify({ folders, ...extra }) }));
if (r.ok) { pushedText = empty ? 'empty' : text; pushedAt = Date.now(); }
return r.ok;
} catch { return false; }
}
// El texto exacto que el puente entregaría en esa carpeta (con su comando ya puesto); null si no responde.
async function contextFor(cwd) {
try { const r = await fetch(url().replace(/\/agents$/, '/context') + '?cwd=' + encodeURIComponent(cwd), localInit({ cache: 'no-store' })); if (!r.ok) return null; const d = await r.json(); return d.text || ''; } catch { return null; }
}
// A4: una acción de un agente; la respuesta vuelve al puente, que se la da al comando que la pidió.
async function runAction(a) {
let r;
try { r = await AgentCmd.run(a); } catch (e) { r = { ok: false, message: String((e && e.message) || e) }; }
try { await fetch(url().replace(/\/agents$/, '/result'), localInit({ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Tasky': '1' }, body: JSON.stringify({ id: a.id, ...r }) })); } catch {}
return r;
}
// Cualquier cambio (un vínculo, el interruptor, la plantilla, una tarea) llega al puente enseguida si cambia el texto.
let ctxTimer = null;
function start() { poll(); Store.subscribe(() => { clearTimeout(ctxTimer); ctxTimer = setTimeout(() => pushContext(), 300); }); }
return { start, poll, end, quit, badge, servedByBridge, pushContext, contextFor, runAction, fetchInit, isOn: () => on, diag: () => diag, port, seen, flushTime, promptGuessOf, confirmPrompt, dropPromptGuess: (id) => promptGuess.delete(id), promptLinked: (id) => promptLinked.has(id) };
})();
/* ==================== Zip ==================== */
// .zip sin compresión (método "store"): el respaldo del equipo guarda sus datos y cada versión de cada archivo, y se abre
// con el Explorador de Windows. Lee lo que escribe (y cualquier zip sin compresión); revisa el CRC de cada archivo.
const Zip = (() => {
const TABLE = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
function crc32(u8) { let c = 0xFFFFFFFF; for (let i = 0; i < u8.length; i++) c = TABLE[(c ^ u8[i]) & 0xFF] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; }
function make(entries) {
const parts = [], central = [];
let offset = 0;
const now = new Date();
const time = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1), date = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
for (const e of entries) {
const name = new TextEncoder().encode(e.name), data = e.data, crc = crc32(data);
const h = new DataView(new ArrayBuffer(30));
h.setUint32(0, 0x04034b50, true); h.setUint16(4, 20, true); h.setUint16(6, 0x0800, true); h.setUint16(10, time, true); h.setUint16(12, date, true);
h.setUint32(14, crc, true); h.setUint32(18, data.length, true); h.setUint32(22, data.length, true); h.setUint16(26, name.length, true);
parts.push(new Uint8Array(h.buffer), name, data);
const c = new DataView(new ArrayBuffer(46));
c.setUint32(0, 0x02014b50, true); c.setUint16(4, 20, true); c.setUint16(6, 20, true); c.setUint16(8, 0x0800, true); c.setUint16(12, time, true); c.setUint16(14, date, true);
c.setUint32(16, crc, true); c.setUint32(20, data.length, true); c.setUint32(24, data.length, true); c.setUint16(28, name.length, true); c.setUint32(42, offset, true);
central.push(new Uint8Array(c.buffer), name);
offset += 30 + name.length + data.length;
}
const size = central.reduce((a, x) => a + x.length, 0);
const end = new DataView(new ArrayBuffer(22));
end.setUint32(0, 0x06054b50, true); end.setUint16(8, entries.length, true); end.setUint16(10, entries.length, true); end.setUint32(12, size, true); end.setUint32(16, offset, true);
return new Blob([...parts, ...central, new Uint8Array(end.buffer)], { type: 'application/zip' });
}
function read(buffer) {
const u8 = new Uint8Array(buffer), dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength), out = new Map();
let e = u8.length - 22;
while (e >= 0 && dv.getUint32(e, true) !== 0x06054b50) e--;
if (e < 0) throw new Error('not a zip');
const count = dv.getUint16(e + 10, true);
let p = dv.getUint32(e + 16, true);
for (let i = 0; i < count; i++) {
if (dv.getUint32(p, true) !== 0x02014b50) throw new Error('damaged zip');
const method = dv.getUint16(p + 10, true), crc = dv.getUint32(p + 16, true), size = dv.getUint32(p + 20, true), nlen = dv.getUint16(p + 28, true), xlen = dv.getUint16(p + 30, true), clen = dv.getUint16(p + 32, true), off = dv.getUint32(p + 42, true);
const name = new TextDecoder().decode(u8.subarray(p + 46, p + 46 + nlen));
if (method !== 0) throw new Error('compressed zip entries are not supported');
const start = off + 30 + dv.getUint16(off + 26, true) + dv.getUint16(off + 28, true);
const data = u8.slice(start, start + size);
if (crc32(data) !== crc) throw new Error('damaged file in the zip: ' + name);
out.set(name, data);
p += 46 + nlen + xlen + clen;
}
return out;
}
return { make, read, crc32 };
})();
/* ==================== Team ==================== */
// Modo Team: los datos viven en Supabase, con cuentas y en vivo. Solo se activa en la carpeta web/ que arma
// equipo/preparar-web.mjs (meta tasky-mode = team, config.js y vendor/supabase.js). Abierto desde el disco
// (src/index.html) Tasky sigue en modo Personal y nada de esto corre.
// Es el adaptador "team" de la capa de datos: el Store y Actions funcionan igual; aquí se compara cada cambio con
// lo último que se sabe del servidor y se manda con la operación que corresponde al rol (la seguridad la pone el
// servidor: RLS y funciones de equipo/migraciones/). Los cambios de los demás llegan por tiempo real.
const Team = (() => {
const wanted = typeof document !== 'undefined' && !!document.querySelector('meta[name="tasky-mode"][content="team"]');
const cfg = (typeof window !== 'undefined' && window.TASKY_CONFIG) || {};
const on = () => wanted;
let contacts = new Map();
let sb = null, user = null, team = null, me = null, members = [], invitations = [], online = true, entered = false;
let dbChannel = null, shadow = null, queue = Promise.resolve(), saving = 0;
const T = () => Help.TEXT.team;
const PREFS_KEY = 'tasky:team:prefs', CACHE_KEY = 'tasky:team:cache';
const ls = () => { try { return localStorage; } catch { return null; } };
// E9: un visitante sin cuenta (enlace de Viewer) no tiene usuario de Supabase: su id es el de su visita.
let visitor = null; // { token, session, n, topic }
const myId = () => (visitor ? 'viewer:' + visitor.session : user ? user.id : null);
const myName = () => (me ? me.name : 'You');
const role = () => (me ? me.role : null);
const memberOf = (id) => members.find((m) => m.user_id === id) || null;
const nameOf = (id) => { if (typeof id === 'string' && id.startsWith('name:')) return id.slice(5); const m = memberOf(id); return m ? m.name : ''; }; // E9: el visitante solo sabe nombres de pila
// ---- Permisos: lo que la interfaz ofrece. El servidor revisa lo mismo por su cuenta. ----
const OWNER = ['import', 'editProjects', 'editTasks', 'invite', 'approve', 'deleteFiles', 'starting', 'restore', 'assignAny'];
// (take, submit, upload, comment, agents, history: Owner y Member)
// Prompt 021 (G4): los miembros ya no suben handoffs (lo que quieran añadir va como sugerencia).
const WRITER = ['status', 'take', 'submit', 'upload', 'comment', 'agents', 'history'];
const roleCan = (what) => !!me && (OWNER.includes(what) ? me.role === 'owner' : WRITER.includes(what) ? me.role === 'owner' || me.role === 'member' : true);
function can(what) {
if (!wanted) return true;
if (!me || !online) return false;
return roleCan(what);
}
// El porqué de un "no": el rol solo cuando de verdad es el rol; si no, que está sin conexión.
const deniedText = (what) => (me && !roleCan(what) ? T().denied(what) : online ? T().denied(what) : T().offlineNoEdit);
function deny(what) { Shell.notify(deniedText(what)); return null; }
// ---- Persistencia del Store en Team: preferencias propias y una copia para mirar sin conexión ----
const PREF_FIELDS = ['layoutId', 'helpSeen', 'projectId', 'inboxOpen', 'bridgePort', 'folderLinks', 'contextTemplate', 'universe'];
function prefs() { try { return JSON.parse(ls().getItem(PREFS_KEY) || '{}'); } catch { return {}; } }
let cacheTimer = null;
const persistence = {
load() {
const s = Store.blank(), p = prefs();
for (const k of PREF_FIELDS) if (k in p) s.settings[k] = p[k];
if (Array.isArray(p.layouts)) s.layouts = p.layouts;
return s;
},
save(s) {
const p = {}; for (const k of PREF_FIELDS) p[k] = s.settings[k];
p.layouts = s.layouts;
try { ls().setItem(PREFS_KEY, JSON.stringify(p)); } catch {}
if (!entered) return;
clearTimeout(cacheTimer);
cacheTimer = setTimeout(() => { try { ls().setItem(CACHE_KEY, JSON.stringify({ at: Date.now(), user: myId(), team, me, members, state: Store.get() })); } catch {} }, 400);
},
};
// ---- Filas del servidor ↔ objetos del Store ----
const iso = (ms) => (ms ? new Date(ms).toISOString() : null);
const ms = (t) => (t ? Date.parse(t) : 0);
const MAP = {
projects: {
key: (r) => r.id,
toRow: (p) => ({ id: p.id, name: p.name, status: p.status || 'active', done_when: p.doneWhen || '', notes: p.notes || '', color: p.color || null, archived: !!p.archived, source: p.source || null }),
fromRow: (r) => ({ id: r.id, name: r.name, status: r.status, doneWhen: r.done_when || '', notes: r.notes || '', color: r.color || Store.PALETTE[0], archived: !!r.archived, source: r.source || '', createdAt: String(r.created_at || '').slice(0, 10), updatedAt: String(r.updated_at || '').slice(0, 10) }),
rows: (s) => s.projects,
},
workflows: {
key: (r) => r.project_id + '|' + r.id,
toRow: (w, i) => ({ project_id: w.projectId, id: w.id, name: w.name, mission: w.mission || '', universe: w.universe || 'Main', after_wf: w.after || '', position: i }),
fromRow: (r) => ({ projectId: r.project_id, id: r.id, name: r.name, mission: r.mission || '', universe: r.universe || 'Main', ...(r.after_wf ? { after: r.after_wf } : {}), position: r.position }),
rows: (s) => s.workflows,
},
tasks: {
key: (r) => r.project_id + '|' + r.id,
toRow: (t, i) => ({ project_id: t.projectId, id: t.id, workflow: t.workflow, title: t.title, mission: t.mission || '', status: t.status, needs: t.needs || '', needs_since: iso(t.needsSince), deliverables: t.deliverables || [], files: t.files || [], touched: t.touched || [], assignee: t.assignee || null, review_note: t.reviewNote || '', back_comment: t.backComment || '', reward: Store.rewardOf(t), done_when: t.doneWhen || '', after_task: t.after || '', position: i }),
fromRow: (r) => ({ projectId: r.project_id, id: r.id, workflow: r.workflow, title: r.title, mission: r.mission || '', status: r.status, needs: r.needs || '', ...(r.needs_since ? { needsSince: ms(r.needs_since) } : {}), ...(r.status === 'review' ? { reviewSince: ms(r.updated_at) } : {}), deliverables: r.deliverables || [], files: r.files || [], touched: r.touched || [], assignee: r.assignee || null, reviewNote: r.review_note || '', backComment: r.back_comment || '', ...(r.reward ? { reward: r.reward } : {}), ...(r.done_when ? { doneWhen: r.done_when } : {}), ...(r.after_task ? { after: r.after_task } : {}), ...(r.ready ? { ready: true } : {}), ...(r.no_files ? { noFiles: true } : {}), position: r.position }),
rows: (s) => s.tasks,
},
task_details: {
key: (r) => r.project_id + '|' + r.task_id,
toRow: (t) => ({ project_id: t.projectId, task_id: t.id, details: Store.detailOf(t) }),
rows: (s) => (role() === 'owner' ? s.tasks.filter((t) => Store.hasDetail(t)) : []),
},
agents: {
key: (r) => r.id,
// activity (0005): lo que su dueño comparte de lo que hace el agente (a.shared, recortado otra vez por el servidor).
toRow: (a) => ({ id: a.id, owner: a.owner, name: a.name, icon: a.icon || null, color: a.color || null, status: a.status === 'working' ? 'working' : 'waiting', away: !!a.away, source: a.source || 'manual', session: a.session || null, project_id: a.projectId || null, task_id: a.taskId || null, ...(missingCols.has('agents.activity') ? {} : { activity: a.shared || {} }) }),
fromRow: (r) => ({ id: r.id, owner: r.owner, name: r.name, icon: r.icon || 'circle', color: r.color || '#5B9DFF', status: r.status, away: !!r.away, source: r.source, session: r.session || null, projectId: r.project_id || null, taskId: r.task_id || null, updatedAt: r.updated_at ? Date.parse(r.updated_at) : 0, activity: r.activity && Object.keys(r.activity).length ? Actions.cleanActivity(r.activity) : null, shared: r.activity || {} }),
rows: (s) => s.agents.filter((a) => a.owner === myId()),
},
files: {
key: (r) => r.id,
toRow: (f) => ({ id: f.id, project_id: f.projectId, task_id: f.taskId, name: f.name, version: f.version, kind: f.kind, path: f.path, size: f.size, mime: f.mime || '', note: f.note || '', deleted: !!f.deleted, ...(f.link ? { link: f.link } : {}) }),
fromRow: (r) => ({ id: r.id, projectId: r.project_id, taskId: r.task_id, name: r.name, version: r.version, kind: r.kind, path: r.path, size: Number(r.size) || 0, mime: r.mime || '', note: r.note || '', by: r.uploaded_by, at: ms(r.uploaded_at), deleted: !!r.deleted, ...(r.link ? { link: r.link } : {}) }),
rows: (s) => s.files,
},
messages: {
key: (r) => r.id,
toRow: (m) => ({ id: m.id, project_id: m.projectId, task_id: m.taskId || null, body: m.body, deleted: !!m.deleted, edited_at: iso(m.editedAt) }),
fromRow: (r) => ({ id: r.id, projectId: r.project_id, taskId: r.task_id || null, author: r.author, authorName: r.author_name || '', body: r.body, at: ms(r.created_at), editedAt: ms(r.edited_at) || 0, deleted: !!r.deleted }),
rows: (s) => s.messages.filter((m) => m.author === myId()),
},
// Papelera (0004): solo el dueño la ve y la cambia.
trash: {
key: (r) => r.id,
toRow: (e) => ({ id: e.id, kind: e.kind, project_id: e.projectId, task_id: e.taskId || null, name: String(e.name || '').slice(0, 300), data: e.data || {} }),
fromRow: (r) => ({ id: r.id, kind: r.kind, projectId: r.project_id, taskId: r.task_id || null, name: r.name || '', data: r.data || {}, deletedAt: ms(r.deleted_at), deletedByName: r.deleted_by_name || '' }),
rows: (s) => (role() === 'owner' ? s.trash || [] : []),
},
notifications: {
key: (r) => r.id,
toRow: (n) => ({ id: n.id, read_at: iso(n.readAt) }),
fromRow: (r) => ({ id: r.id, kind: r.kind, projectId: r.project_id, taskId: r.task_id, actor: r.actor, actorName: r.actor_name || '', body: r.body || '', at: ms(r.created_at), readAt: ms(r.read_at) || 0 }),
rows: (s) => s.notifications,
},
};
// Qué tablas existen en el servidor (las de T2 y T5 llegan con sus migraciones; si faltan, se saltan).
const missing = new Set();
// Columnas nuevas que el servidor todavía no tiene (una migración sin aplicar): no se mandan.
const missingCols = new Set();
// Quién: la persona real que puso el servidor (actor_name). Solo un handoff o un agente se muestran por su nombre.
const eventToEntry = (r) => { const d = r.data || {}; const byName = d.who === 'handoff' || d.agentAct || r.kind === 'agentIn' || r.kind === 'agentOut'; return { ...d, id: r.id, at: ms(r.at), kind: r.kind, projectId: r.project_id || undefined, taskId: r.task_id || undefined, actor: r.actor, actorName: r.actor_name || '', who: byName ? d.who : (r.actor_name || d.who || '') }; };
const entryToEvent = (e) => { const { id, at, kind, projectId, taskId, actor, ...data } = e; return { id, team_id: team.id, at: iso(at), kind, project_id: projectId || null, task_id: taskId || null, data }; };
function snapshot(s) {
const out = {};
for (const [t, m] of Object.entries(MAP)) {
out[t] = new Map();
if (missing.has(t)) continue;
const counters = {};
for (const o of m.rows(s)) {
const pid = o.projectId || '';
const i = (counters[pid] = (counters[pid] ?? -1) + 1);
const row = m.toRow(o, i);
for (const k of Object.keys(row)) if (missingCols.has(t + '.' + k)) delete row[k];
out[t].set(m.key(row), JSON.stringify(row));
}
}
out.events = new Set(s.history.map((e) => e.id));
return out;
}
// ---- Enviar: compara con lo último del servidor y manda lo que cambió, según el rol ----
function diff() {
if (!shadow || !entered) return [];
const s = Store.get(), now = snapshot(s), ops = [];
const order = ['projects', 'workflows', 'tasks', 'task_details', 'agents', 'files', 'messages', 'notifications', 'trash'];
for (const t of order) {
if (missing.has(t)) continue;
const a = shadow[t], b = now[t];
for (const [k, v] of b) if (!a.has(k)) ops.push({ t, op: 'insert', row: JSON.parse(v) });
for (const [k, v] of b) if (a.has(k) && a.get(k) !== v) { const was = JSON.parse(a.get(k)), row = JSON.parse(v); ops.push({ t, op: 'update', row, was, changed: Object.keys(row).filter((c) => JSON.stringify(row[c]) !== JSON.stringify(was[c])) }); }
}
for (const t of order.slice().reverse()) {
if (missing.has(t)) continue;
for (const [k, v] of shadow[t]) if (!now[t].has(k)) ops.push({ t, op: 'delete', row: JSON.parse(v) });
}
for (const e of s.history) if (!shadow.events.has(e.id)) ops.push({ t: 'events', op: 'insert', row: entryToEvent(e) });
shadow = now;
return ops;
}
const pk = { projects: ['id'], workflows: ['project_id', 'id'], tasks: ['project_id', 'id'], task_details: ['project_id', 'task_id'], agents: ['id'], files: ['id'], messages: ['id'], notifications: ['id'], trash: ['id'] };
function where(q, t, row) { q = q.eq('team_id', team.id); for (const c of pk[t]) q = q.eq(c, row[c]); return q; }
async function run(o) {
const isOwner = role() === 'owner';
if (o.rows) return sb.from(o.t).insert(o.t === 'events' ? o.rows : o.rows.map((r) => ({ team_id: team.id, ...r })));
// Archivos tocados (0006): siempre con su función, que suma sin repetir y revisa cuánto comparte quien los manda.
if (o.t === 'tasks' && o.op === 'update' && o.changed.includes('touched')) {
if (!missingCols.has('tasks.touched')) {
const r = await sb.rpc('add_task_touched', { p_team: team.id, p_project: o.row.project_id, p_task: o.row.id, p_files: o.row.touched || [] });
if (r.error && !/Could not find the function|does not exist/i.test(r.error.message)) return r;
}
o = { ...o, changed: o.changed.filter((c) => c !== 'touched') };
if (!o.changed.length) return {};
}
if (o.t === 'tasks' && !isOwner) {
// Un Member no edita tareas: cambia estados y se asigna con las funciones que lo revisan.
if (o.op !== 'update') return { error: { message: T().denied('editTasks') } };
const statusFields = ['status', 'needs', 'needs_since', 'review_note', 'back_comment'];
const rest = o.changed.filter((c) => !statusFields.includes(c) && c !== 'assignee');
if (rest.length) return { error: { message: T().denied('editTasks') } };
// Prompt 020 (E3): un Member cambia solo el estado de tareas suyas. Si el cambio la toma (a su nombre), primero la
// toma con take_it (que además la pone en curso); si la suelta, con give_back.
const args = { p_team: team.id, p_project: o.row.project_id, p_task: o.row.id };
if (o.changed.includes('assignee') && o.row.assignee === myId()) {
const r = await sb.rpc('take_it', args);
if (r.error && !/Could not find the function/i.test(r.error.message)) return r;
if (r.error) { const r2 = await sb.rpc('take_task', { ...args, p_take: true }); if (r2.error) return r2; }
if (o.row.status === 'doing') return {};
}
if (o.changed.includes('assignee') && !o.row.assignee) {
const r = await sb.rpc('give_back', args);
if (!r.error || !/Could not find the function/i.test(r.error.message)) return r;
return sb.rpc('take_task', { ...args, p_take: false });
}
if (o.changed.some((c) => statusFields.includes(c))) {
const r = await sb.rpc('set_task_status', { ...args, p_status: o.row.status, p_needs: o.row.needs || null, p_review_note: o.row.review_note || null });
if (r.error) return r;
}
return {};
}
const row = { team_id: team.id, ...o.row };
if (o.op === 'insert') return sb.from(o.t).insert(o.t === 'agents' ? row : row);
if (o.op === 'update') { const patch = {}; for (const c of o.changed) patch[c] = o.row[c]; if (!Object.keys(patch).length) return {}; return where(sb.from(o.t).update(patch), o.t, o.row); }
if (o.op === 'delete') return where(sb.from(o.t).delete(), o.t, o.row);
return {};
}
// Filas nuevas seguidas de la misma tabla van juntas (traer un Backup con 2000 entradas de historia = pocos pedidos).
function batch(ops) {
const out = [];
for (const o of ops) {
const last = out[out.length - 1];
const simple = o.op === 'insert' && (o.t === 'events' || role() === 'owner' || o.t !== 'tasks');
if (simple && last && last.op === 'insert' && last.t === o.t && last.rows && last.rows.length < 500) last.rows.push(o.row);
else out.push(simple ? { ...o, rows: [o.row] } : o);
}
return out;
}
function sync() {
const ops = batch(diff());
if (!ops.length) return;
saving++;
queue = queue.then(async () => {
for (const o of ops) {
let r;
try { r = await run(o); } catch (e) { r = { error: e, network: true }; }
if (r && r.error) {
const msg = String(r.error.message || r.error);
const net = r.network || /fetch|network|Failed to fetch|Load failed/i.test(msg);
// Un servidor sin una migración nueva (0005, 0006…) no tiene esa columna: se deja de mandar, sin molestar a nadie.
const col = /['"]([a-z_]+)['"] column/i.exec(msg) || /column ['"]?([a-z_]+)['"]? does not exist/i.exec(msg);
if (!net && col && /column/i.test(msg) && /does not exist|Could not find/i.test(msg)) { missingCols.add(o.t + '.' + col[1]); shadow = snapshot(Store.get()); await reload(); break; }
if (net) goOffline(); else { Shell.notify(T().notSaved(msg)); await reload(); }
break;
}
}
}).finally(() => { saving--; });
}
const pending = () => queue;
// ---- Recibir: aplicar al Store lo que cambió en el servidor, sin volver a mandarlo ----
function applyRemote(fn) {
Actions.applyRemote(fn);
shadow = snapshot(Store.get());
}
function sortByPosition(list) {
const order = new Map(Store.get().projects.map((p, i) => [p.id, i]));
return list.sort((a, b) => ((order.get(a.projectId) ?? 0) - (order.get(b.projectId) ?? 0)) || ((a.position ?? 0) - (b.position ?? 0)));
}
function onChange(p) {
const t = p.table, row = p.eventType === 'DELETE' ? p.old : p.new;
if (row && row.team_id && team && row.team_id !== team.id) return;
if (t === 'members') return onMember(p);
if (t === 'ledger' || t === 'cashouts' || t === 'budgets') { loadMoneySoon(); return; }
if (t === 'suggestions') { loadSuggestionsSoon(); return; }
if (t === 'chat') { if (p.eventType !== 'DELETE' && row) { const i = chat.findIndex((x) => x.id === row.id); if (i >= 0) chat[i] = row; else chat.push(row); } if (typeof Shell !== 'undefined') { Shell.render(); refreshPanel(); } return; }
if (t === 'announcements') { loadAnnouncements(); return; }
if (t === 'duels') { if (row && p.eventType !== 'DELETE') { const i = duels.findIndex((x) => x.id === row.id); if (i >= 0) duels[i] = row; else duels.unshift(row); } if (typeof Shell !== 'undefined') { Shell.render(); refreshPanel(); Shell.duelSeen(row); } return; }
if (t === 'bonus_spins') { loadSpins(); return; }
if (t === 'notifications' && p.eventType === 'INSERT' && row && row.user_id === myId() && typeof Shell !== 'undefined' && Shell.notifToast) setTimeout(() => Shell.notifToast(row.id), 50);
if (t === 'owner_log') { loadOwnerLogSoon(); return; }
if (t === 'agent_activity') { if (p.eventType === 'DELETE') delete agentLive[(p.old || {}).agent_id]; else if (row && row.agent_id) agentLive[row.agent_id] = row; if (typeof Shell !== 'undefined') Shell.render(); return; }
if (t === 'tasks' && role() !== 'viewer') loadMoneySoon(); // 022 (R3): la wallet sube en cuanto se aprueba, aunque el libro no hubiera cargado
if (t === 'tasks') { loadWaitingSoon(); if (p.eventType === 'UPDATE' && row && row.status === 'done') loadStatsSoon(); }
if (t === 'invitations') { if (p.eventType === 'DELETE') invitations = invitations.filter((i) => i.id !== p.old.id); else invitations = [...invitations.filter((i) => i.id !== p.new.id), p.new]; Shell.render(); refreshPanel(); return; }
if (t === 'events') {
if (Store.get().history.some((e) => e.id === row.id)) return;
applyRemote((s) => { s.history.push(eventToEntry(row)); s.history.sort((a, b) => a.at - b.at); if (s.history.length > 2000) s.history.splice(0, s.history.length - 2000); });
return;
}
if (t === 'task_details') {
applyRemote((s) => {
const x = s.tasks.find((y) => y.projectId === row.project_id && y.id === row.task_id);
if (!x) return;
if (p.eventType === 'DELETE') { delete x.details; delete x.checks; delete x.ownerChecks; } else detailRowTo(x, row);
});
return;
}
const m = MAP[t];
if (!m) return;
const listName = t;
applyRemote((s) => {
const list = s[listName];
const same = (o) => m.key(m.toRow(o, 0)) === m.key(row);
const i = list.findIndex(same);
if (p.eventType === 'DELETE') { if (i >= 0) list.splice(i, 1); }
else {
const o = m.fromRow(row);
if (t === 'agents' && o.owner === myId() && i >= 0) return; // los agentes propios los manda esta app
// 022: lo que la fila ya no trae se borra (Not ready, sin precio, sin «after»…); el detalle viene de otra tabla y queda.
if (i >= 0 && t === 'tasks') for (const k of ['ready', 'noFiles', 'reward', 'doneWhen', 'after', 'needsSince', 'reviewSince']) delete list[i][k];
if (i >= 0 && t === 'workflows') delete list[i].after;
if (i >= 0) list[i] = Object.assign(list[i], o); else list.push(o);
if (t === 'tasks' || t === 'workflows') sortByPosition(list);
}
if (t === 'projects' && !s.projects.some((x) => x.id === s.settings.projectId)) s.settings.projectId = (s.projects.find((x) => !x.archived) || {}).id || null;
});
if (typeof Team.onRemote === 'function') Team.onRemote(t, p);
}
function detailRowTo(x, r) {
const d = Store.cleanDetails(r.details || {});
if (Object.keys(d).length) x.details = d; else delete x.details;
const c = r.checks || {}, o = r.owner_checks || {};
if (Object.keys(c).length) x.checks = { steps: c.steps || [], tests: c.tests || [] }; else delete x.checks;
if (Object.keys(o).length) x.ownerChecks = { tests: o.tests || [] }; else delete x.ownerChecks;
}
function onMember(p) {
if (p.eventType === 'DELETE') {
members = members.filter((m) => m.user_id !== p.old.user_id);
if (p.old.user_id === myId()) { signOut(T().removed); return; }
} else {
members = [...members.filter((m) => m.user_id !== p.new.user_id), p.new];
if (p.new.user_id === myId()) { const was = me && me.role; me = p.new; if (was && was !== p.new.role) { roleChanged(); return; } }
}
Shell.render(); refreshPanel();
}
// ---- Cargar todo el equipo ----
async function fetchAll() {
if (role() === 'viewer') return fetchViewerAccount();
const q = (t, build) => build(sb.from(t).select('*').eq('team_id', team.id)).then((r) => {
if (r.error && /relation|does not exist|42P01|PGRST205|Could not find the table/.test(r.error.message + ' ' + r.error.code)) { missing.add(t); return []; }
if (r.error) throw r.error;
missing.delete(t);
return r.data;
});
const detailsP = role() === 'viewer' ? Promise.resolve([]) : q('task_details', (x) => x);
const [projects, workflows, tasks, events, agents, files, messages, notifications, mem, inv, trash] = await Promise.all([
q('projects', (x) => x.order('created_at')), q('workflows', (x) => x.order('position')), q('tasks', (x) => x.order('position')),
q('events', (x) => x.order('at', { ascending: false }).limit(2000)), q('agents', (x) => x), q('files', (x) => x.order('uploaded_at')),
q('messages', (x) => x.order('created_at', { ascending: false }).limit(2000)), q('notifications', (x) => x.eq('user_id', myId()).order('created_at', { ascending: false }).limit(300)),
q('members', (x) => x.order('joined_at')), role() === 'owner' ? q('invitations', (x) => x.order('created_at')) : Promise.resolve([]),
role() === 'owner' ? q('trash', (x) => x.order('deleted_at')) : Promise.resolve([]),
]);
members = mem; me = mem.find((m) => m.user_id === myId()) || me; invitations = inv;
// 021 (G5): correos y nombres completos: solo el líder (member_contacts); los demás no los reciben.
if (role() === 'owner' && !missing.has('member_contacts')) { const c = await sb.from('member_contacts').select('user_id,email,full_name').eq('team_id', team.id); if (isMissing(c.error)) missing.add('member_contacts'); else if (!c.error) contacts = new Map((c.data || []).map((x) => [x.user_id, x])); }
const cur = Store.get();
const next = { ...cur, settings: { ...cur.settings }, projects: projects.map(MAP.projects.fromRow), workflows: workflows.map(MAP.workflows.fromRow), tasks: tasks.map(MAP.tasks.fromRow),
history: events.map(eventToEntry).sort((a, b) => a.at - b.at), files: files.map(MAP.files.fromRow), messages: messages.map(MAP.messages.fromRow).sort((a, b) => a.at - b.at), notifications: notifications.map(MAP.notifications.fromRow), trash: trash.map(MAP.trash.fromRow),
// Los agentes propios del puente los vuelve a publicar esta app; los de los demás vienen del servidor.
agents: [...cur.agents.filter((a) => a.owner === myId() || (!a.owner && a.source === 'manual')).map((a) => ({ ...a, owner: myId() })), ...agents.filter((r) => r.owner !== myId()).map(MAP.agents.fromRow), ...agents.filter((r) => r.owner === myId() && !cur.agents.some((a) => a.id === r.id)).map(MAP.agents.fromRow)] };
next.tasks = next.tasks; next.workflows = next.workflows;
for (const r of await detailsP) { const x = next.tasks.find((y) => y.projectId === r.project_id && y.id === r.task_id); if (x) detailRowTo(x, r); }
if (!next.projects.some((p) => p.id === next.settings.projectId)) next.settings.projectId = (next.projects.find((p) => !p.archived) || {}).id || null;
for (const p of next.projects) Store.recordProgress(next, p.id);
Actions.replaceRemote(next);
const fromServer = Store.blank();
Object.assign(fromServer, { projects: next.projects, workflows: next.workflows, tasks: next.tasks, history: next.history, files: next.files, messages: next.messages, notifications: next.notifications, trash: next.trash, agents: agents.map(MAP.agents.fromRow) });
shadow = snapshot(fromServer);
if (role() !== 'viewer') await Promise.all([loadMoney(), loadSuggestions(), loadAgentLive(), loadOwnerLog()]);
if (!schemaChecked) checkSchema().catch(() => {});
if (!visitor) loadWaiting().catch(() => {});
if (!visitor && role() !== 'viewer') {
loadChat().catch(() => {}); loadAnnouncements().catch(() => {}); checkStale(); loadStats().catch(() => {});
loadSpins().catch(() => {}); loadDuels().then(() => { if (teamSettings.duels) sb.rpc('duel_week_close', { p_team: team.id }).then(() => loadSpins(), () => {}); }).catch(() => {}); syncClock();
}
if (role() === 'owner' && !viewerState) loadViewerState().catch(() => {});
}
// ---- Prompt 021 (G0): ¿la base tiene todas las migraciones que esta app necesita? ----
// tasky_schema (0022) lo dice. Si ni esa función existe, la app lo averigua con lecturas que no cambian nada (una
// columna, una tabla) y con funciones que rechazan a un equipo inventado antes de tocar nada.
const SCHEMA_NEEDED = 31;
let schemaGap = null, schemaChecked = false;
const fnMissing = (e) => !!e && (e.code === 'PGRST202' || /Could not find the function|schema cache/i.test(String(e.message || e)));
const tableMissing = (e) => !!e && (e.code === 'PGRST205' || e.code === '42P01' || e.code === '42703' || e.code === 'PGRST204' || /does not exist|Could not find the (table|'.*' column)|relation/i.test(String(e.message || e)));
async function probeSchema() {
const fake = '00000000-0000-0000-0000-000000000000';
const col = (t, c) => sb.from(t).select(c).limit(1).then((r) => !tableMissing(r.error), () => true);
const fn = (name, args) => sb.rpc(name, args).then((r) => !fnMissing(r.error), () => true);
const checks = [
['0009', () => col('tasks', 'reward')], ['0010', () => col('owner_log', 'id')], ['0011', () => fn('take_it', { p_team: fake, p_project: 'x', p_task: 'x' })],
['0012', () => col('ledger', 'id')], ['0013', () => fn('decide_suggestion', { p_team: fake, p_id: fake, p_decision: 'decline' })], ['0014', () => col('agent_activity', 'agent_id')],
['0015', () => col('viewer_links', 'token')], ['0016', () => col('members', 'guide_read')], ['0017', () => col('members', 'emails_on')],
['0019', () => col('task_details', 'task_id')], ['0020', () => col('files', 'link')], ['0021', () => col('app_time', 'day')],
['0024', () => col('tasks', 'ready')], ['0025', () => col('chat', 'id')], ['0026', () => col('member_contacts', 'user_id')], ['0027', () => col('viewer_links', 'on_signin')], ['0028', () => col('app_task_time', 'day')], ['0029', () => col('bonus_spins', 'id')],
];
const out = [];
for (const [n, check] of checks) if (!(await check())) out.push(n);
// tasky_schema no existe: 0022 falta seguro. 0018 solo cambia funciones y no se ve desde aquí: si falta alguna anterior,
// falta también (se aplican en orden).
if (out.some((n) => n < '0018')) out.push('0018');
out.push('0022', '0023', '0030', '0031'); // 0030 solo cambia funciones; sin tasky_schema, falta seguro
out.sort();
return out;
}
async function checkSchema() {
if (visitor || !sb || !team) return null;
let missing;
const r = await sb.rpc('tasky_schema');
if (r.error && fnMissing(r.error)) missing = await probeSchema();
else if (r.error) return null;
else { missing = (r.data && r.data.missing) || []; if (r.data && Number(r.data.version || 0) < 31 && !missing.includes('0031')) missing = [...missing, '0031']; } // 0031: la base dice su versión
schemaGap = missing.length ? missing : null;
schemaChecked = true;
if (typeof Shell !== 'undefined') Shell.showSchema(schemaText());
return schemaGap;
}
const listText = (xs) => (xs.length < 2 ? xs.join('') : xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1]);
// El texto para quien mira: al líder, qué aplicar; a los demás, que espere.
function schemaText(errMsg) {
const gap = schemaGap || (errMsg && fnMissing({ message: errMsg }) ? ['?'] : null);
if (!gap) return '';
if (role() === 'owner') return gap[0] === '?' ? 'Database update needed: some migrations are missing. See docs/PASOS-FELIPE-022.md' : 'Database update needed: apply migration' + (gap.length > 1 ? 's ' : ' ') + listText(gap) + '. See docs/PASOS-FELIPE-022.md';
return 'Tasky is being updated. Try again in a few minutes.';
}
async function reload() {
if (visitor) { try { await fetchVisitor(); } catch {} return; } try { await fetchAll(); } catch (e) { goOffline(); } }
// ---- Prompt 020 (E3): el ciclo de una tarea, por las funciones del servidor que revisan quién lo pide ----
// 020-B (A0): un doble clic o dos llamadas seguidas sobre lo mismo no se mandan dos veces: la segunda espera a la
// primera y no hace nada (el servidor igual rechazaría la segunda; así no hay mensajes de error confusos).
const inFlight = new Map();
function once(key, run) {
if (inFlight.has(key)) return inFlight.get(key).then(() => ({ error: 'busy', busy: true }), () => ({ error: 'busy', busy: true }));
const pr = Promise.resolve().then(run).finally(() => inFlight.delete(key));
inFlight.set(key, pr);
return pr;
}
const taskCall = (fn, projectId, taskId, extra = {}) => once('t|' + projectId + '|' + taskId, () => taskCallNow(fn, projectId, taskId, extra));
async function taskCallNow(fn, projectId, taskId, extra = {}) {
if (!entered || !online) { Shell.notify(T().offlineNoEdit); return { error: 'offline' }; }
const r = await sb.rpc(fn, { p_team: team.id, p_project: projectId, p_task: taskId, ...extra });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return { error: r.error.message }; }
await reload();
return { ok: true, data: r.data };
}
const takeIt = (pid, tid) => taskCall('take_it', pid, tid);
// 021 (G2): Ready. El servidor revisa que tenga lo necesario y dice qué falta.
async function setReady(pid, tid, ready, noFiles = false) {
if (!entered || !online) { Shell.notify(T().offlineNoEdit); return { error: 'offline' }; }
const r = await sb.rpc('set_task_ready', { p_team: team.id, p_project: pid, p_task: tid, p_ready: !!ready, p_no_files: !!noFiles });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return { error: r.error.message }; }
if (r.data && r.data.ok) await reload();
return r.data || {};
}
// 020-B (A4): tiempo con la app abierta y en uso (pestaña visible y algún movimiento en los últimos 2 minutos). Se
// suma cada 15 s y se manda cada minuto; el servidor no deja sumar más rápido que el reloj. Es una estimación.
let appAcc = 0, appLast = 0;
const ACTIVE_MS = 120000;
if (typeof addEventListener !== 'undefined') for (const ev of ['pointerdown', 'keydown', 'wheel', 'pointermove', 'touchstart']) addEventListener(ev, () => { appLast = Date.now(); }, { passive: true, capture: true });
function appTick(sec = 15) {
if (!entered || visitor || !online || !team || role() === 'viewer') return;
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
if (Date.now() - appLast > ACTIVE_MS) return;
appAcc += sec;
}
async function flushAppTime() {
if (!appAcc || !entered || visitor || !team || role() === 'viewer') return 0;
const n = Math.min(appAcc, 120); appAcc -= n;
const sel = typeof Shell !== 'undefined' && Shell.focusedTask ? Shell.focusedTask() : null;
const r = await sb.rpc('add_app_time', { p_team: team.id, p_seconds: n, ...(sel ? { p_project: sel.projectId, p_task: sel.taskId } : {}) });
if (r.error) { if (/Could not find the function/i.test(r.error.message)) appAcc = 0; return 0; }
return r.data;
}
if (typeof setInterval !== 'undefined') { setInterval(() => appTick(15), 15000); setInterval(() => { flushAppTime(); }, 60000); }
// El panel: solo el dueño (el servidor lo vuelve a revisar).
async function overview(weekFrom) {
const r = await sb.rpc('members_overview', { p_team: team.id, p_week: weekFrom ? new Date(weekFrom).toISOString() : null });
return r.error ? { error: r.error.message } : { ok: true, rows: r.data || [] };
}
async function memberReport(userId, from) {
const r = await sb.rpc('member_report', { p_team: team.id, p_user: userId, p_from: from ? new Date(from).toISOString() : null });
return r.error ? { error: r.error.message } : { ok: true, data: r.data };
}
// 020-B (A1): una casilla. Sin recargar todo: el cambio vuelve por tiempo real a todos (y a quien la marcó).
async function setCheck(pid, tid, list, item, on) {
if (!entered || !online) { Shell.notify(T().offlineNoEdit); return { error: 'offline' }; }
const r = await sb.rpc('set_task_check', { p_team: team.id, p_project: pid, p_task: tid, p_list: list, p_item: item, p_on: on });
if (r.error) return { error: r.error.message };
return { ok: true, data: r.data };
}
const giveBack = (pid, tid) => taskCall('give_back', pid, tid);
async function reviewTask(pid, tid, decision, note = '') {
const t = Store.task(pid, tid), who = t && t.assignee ? nameOf(t.assignee) || 'them' : '';
const r = await taskCall('review_task', pid, tid, { p_decision: decision, p_note: note });
if (r.ok && decision === 'approve' && r.data && r.data.paid) {
const b = r.data.bonus;
const M = Help.TEXT.money;
Shell.notify(M.approved(r.data.paid, who) + (b && b.material ? ' ' + M.bonusGot(who, b.material) : b && b.empty ? ' ' + M.poolEmpty : ''));
} else if (r.ok && decision === 'approve' && r.data && r.data.unpaid) Shell.notify(Help.TEXT.money.unpaid(r.data.unpaid, tid, who));
return r;
}
// ---- Prompt 020 (E4): la economía. La calcula y la hace cumplir el servidor (0012); aquí se lee y se pide. ----
// Cada uno lee su libro (el dueño, el de todos). La wallet es la suma del libro.
let money = { ledger: [], cashouts: [], budget: null, budgetPid: null, loaded: false };
const hasMoney = () => !!(entered && team && role() && role() !== 'viewer' && !missing.has('ledger'));
const isMissing = (e) => !!e && /relation|does not exist|42P01|PGRST205|PGRST202|Could not find/.test((e.message || '') + ' ' + (e.code || ''));
let moneyLoading = null;
async function loadMoney() {
if (!team || !sb || !role() || role() === 'viewer') return; // también antes de entrar: fetchAll lo llama
if (moneyLoading) return moneyLoading;
moneyLoading = (async () => {
const pid = Store.get().settings.projectId;
const [l, c, b] = await Promise.all([
sb.from('ledger').select('*').eq('team_id', team.id).order('at', { ascending: false }).limit(3000),
sb.from('cashouts').select('*').eq('team_id', team.id).order('asked_at', { ascending: false }).limit(300),
role() === 'owner' && pid ? sb.rpc('budget', { p_team: team.id, p_project: pid }) : Promise.resolve({ data: null }),
]);
if (isMissing(l.error)) { missing.add('ledger'); return; }
missing.delete('ledger');
money = { ledger: l.data || [], cashouts: c.data || [], budget: b && !b.error ? b.data : null, budgetPid: pid, loaded: true };
})().catch((e) => { console.warn('[Tasky] money: ' + (e && e.message || e)); }).finally(() => { moneyLoading = null; if (typeof Shell !== 'undefined') { Shell.render(); refreshPanel(); } });
return moneyLoading;
}
let moneyTimer = null;
const loadMoneySoon = () => { clearTimeout(moneyTimer); moneyTimer = setTimeout(loadMoney, 250); };
// La wallet de alguien, con lo que se ve del libro (un Member: solo la suya).
function walletOf(uid = myId()) {
const rows = money.ledger.filter((r) => r.user_id === uid);
const sum = (k, f = () => true) => rows.filter(f).reduce((a, r) => a + (r[k] || 0), 0);
const approved = rows.filter((r) => r.kind === 'pay').length;
const lastBonus = rows.find((r) => r.kind === 'bonus' || r.kind === 'bonus_empty'); // el libro llega del más nuevo al más viejo
return {
iron: sum('iron'), crystal: sum('crystal'), gold: sum('gold'), amount: sum('amount'), approved, toBonus: 4 - (approved % 4),
earned: sum('amount', (r) => r.kind === 'pay' || r.kind === 'bonus'), cashedOut: -sum('amount', (r) => r.kind === 'cashout'),
inReview: Store.get().tasks.filter((t) => t.assignee === uid && t.status === 'review').reduce((a, t) => a + Store.rewardOf(t), 0),
poolEmpty: !!lastBonus && lastBonus.kind === 'bonus_empty',
cashout: money.cashouts.find((c) => c.user_id === uid && c.status === 'asked') || null, rows,
};
}
const moneyCall = (fn, args) => once('m|' + fn + '|' + JSON.stringify(args), () => moneyCallNow(fn, args));
async function moneyCallNow(fn, args) {
if (!entered || !online) { Shell.notify(T().offlineNoEdit); return { error: 'offline' }; }
const r = await sb.rpc(fn, { p_team: team.id, ...args });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return { error: r.error.message }; }
await loadMoney();
return { ok: true, data: r.data };
}
const askCashout = () => moneyCall('ask_cashout', {});
const markCashoutPaid = (id) => moneyCall('mark_cashout_paid', { p_cashout: id });
const setBudget = (pid, total, pool, reserve) => moneyCall('set_budget', { p_project: pid, p_total: total, p_bonus_pool: pool, p_reserve: reserve });
// El estado de cuenta general (solo el dueño), en CSV. Una celda que empieza con = + - @ se protege (no es fórmula).
async function statementCsv() {
const r = await sb.rpc('ledger_statement', { p_team: team.id });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return null; }
const q = (v) => { let x = String(v == null ? '' : v); if (/^[=+\-@]/.test(x) && !/^-?\d+$/.test(x)) x = "'" + x; return /[",\n]/.test(x) ? '"' + x.replace(/"/g, '""') + '"' : x; };
return ['date,person,kind,project,task,iron,crystal,gold,amount,note', ...(r.data || []).map((x) => [new Date(x.at).toISOString(), x.person, x.kind, x.project, x.task, x.iron, x.crystal, x.gold, x.amount, x.note].map(q).join(','))].join('\n') + '\n';
}
// ---- Prompt 020 (E10): el historial del dueño (public.owner_log). Lo escribe el servidor; aquí solo se lee. ----
let ownerLog = [], ownerLogLoaded = false;
async function loadOwnerLog() {
if (!team || role() !== 'owner' || missing.has('owner_log')) return;
const r = await sb.from('owner_log').select('*').eq('team_id', team.id).order('at', { ascending: false }).limit(1000);
if (isMissing(r.error)) { missing.add('owner_log'); return; }
if (!r.error) { ownerLog = r.data || []; ownerLogLoaded = true; if (typeof Shell !== 'undefined') { Shell.render(); refreshPanel(); } tellFixes(); }
}
// 022 (R2): lo que arregló la base (0031) se le dice al líder una vez, con cuántas y cuáles.
function tellFixes() {
const fx = ownerLog.filter((x) => x.kind === 'fix'); if (!fx.length || typeof Shell === 'undefined') return;
let seen = ''; try { seen = ls().getItem('tasky.fixSeen.' + team.id) || ''; } catch {}
const fresh = fx.filter((x) => !seen || x.at > seen); if (!fresh.length) return;
try { ls().setItem('tasky.fixSeen.' + team.id, fx[0].at); } catch {}
Shell.notify(fresh.map((x) => x.text).join(' ') + ' See Check project data.');
}
let ownerLogTimer = null;
const loadOwnerLogSoon = () => { clearTimeout(ownerLogTimer); ownerLogTimer = setTimeout(loadOwnerLog, 400); };
// ---- Prompt 020 (E6): sugerencias. Cada uno ve las suyas; el dueño, todas (RLS de 0010). Decide el dueño (0013). ----
let suggestions = [];
async function loadSuggestions() {
if (!team || !sb || !role() || role() === 'viewer' || missing.has('suggestions')) return;
const r = await sb.from('suggestions').select('*').eq('team_id', team.id).order('created_at', { ascending: false }).limit(300);
if (isMissing(r.error)) { missing.add('suggestions'); return; }
if (!r.error) { suggestions = r.data || []; if (typeof Shell !== 'undefined') { Shell.render(); refreshPanel(); } }
}
let sugTimer = null;
// Prompt 021 (G0): "Waiting on you" lo arma el servidor (waiting_on_me): cada persona recibe solo lo suyo.
let waiting = [], waitTimer = null;
async function loadWaiting() {
if (visitor || !team || !sb) { waiting = []; return; }
const r = await sb.rpc('waiting_on_me', { p_team: team.id });
waiting = r.error ? [] : (r.data || []);
if (typeof Shell !== 'undefined') Shell.render();
}
const loadWaitingSoon = () => { clearTimeout(waitTimer); waitTimer = setTimeout(loadWaiting, 200); };
// ---- Prompt 021 (G10): la ruleta del bono, los duelos y sus ajustes ----
let spins = [], duels = [], teamSettings = { duels: true, prize: true }, clockOffset = 0;
async function loadSpins() {
if (!team || !sb || visitor || role() === 'viewer' || missing.has('bonus_spins')) return;
const r = await sb.from('bonus_spins').select('*').eq('team_id', team.id).order('created_at', { ascending: false }).limit(100);
if (isMissing(r.error)) { missing.add('bonus_spins'); return; }
if (!r.error) { spins = r.data || []; if (typeof Shell !== 'undefined') Shell.render(); }
}
async function spinBonus(id) {
const r = await sb.rpc('spin_bonus', { p_team: team.id, p_id: id });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return null; }
const i = spins.findIndex((x) => x.id === id); if (i >= 0) spins[i] = { ...spins[i], status: 'spun', spun_at: r.data.spunAt };
loadMoneySoon();
return r.data;
}
async function loadDuels() {
if (!team || !sb || visitor || role() === 'viewer' || missing.has('duels')) return;
const [d, st] = await Promise.all([sb.from('duels').select('*').eq('team_id', team.id).order('created_at', { ascending: false }).limit(200), sb.from('team_settings').select('*').eq('team_id', team.id)]);
if (isMissing(d.error)) { missing.add('duels'); return; }
if (!d.error) duels = d.data || [];
if (!st.error && st.data && st.data[0]) teamSettings = { duels: st.data[0].duels_on, prize: st.data[0].duel_prize_on };
if (typeof Shell !== 'undefined') { Shell.render(); refreshPanel(); }
}
// La hora del servidor: tres muestras y se queda con la de menos ida y vuelta (la más precisa).
async function syncClock() {
let best = null;
for (let i = 0; i < 3; i++) {
try { const t0 = Date.now(); const r = await sb.rpc('duel_clock'); const t1 = Date.now(); if (!r.error && (!best || t1 - t0 < best.rtt)) best = { rtt: t1 - t0, off: Date.parse(r.data) - (t0 + t1) / 2 }; } catch {}
}
if (best) clockOffset = best.off;
}
const duelCall = async (fn, args) => { const r = await sb.rpc(fn, { p_team: team.id, ...args }); if (r.error) { Shell.notify(T().notSaved(r.error.message)); return null; } const i = duels.findIndex((x) => x.id === r.data.id); if (i >= 0) duels[i] = r.data; else duels.unshift(r.data); if (typeof Shell !== 'undefined') { Shell.render(); refreshPanel(); } return r.data; };
async function duelWeek() { const r = await sb.rpc('duel_week', { p_team: team.id }); return r.error ? [] : r.data || []; }
async function setTeamSettings(duelsOn, prizeOn) {
const r = await sb.rpc('set_team_settings', { p_team: team.id, p_duels: duelsOn, p_prize: prizeOn });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return null; }
teamSettings = { duels: r.data.duels, prize: r.data.prize };
if (typeof Shell !== 'undefined') Shell.render();
return teamSettings;
}
// ---- Prompt 021 (G11): estadísticas del equipo (niveles y clasificación) ----
let stats = [], statsTimer = null;
async function loadStats() {
if (!team || !sb || visitor || !role() || role() === 'viewer' || missing.has('team_stats')) return;
const r = await sb.rpc('team_stats', { p_team: team.id });
if (r.error) { if (/Could not find the function/i.test(r.error.message)) missing.add('team_stats'); return; }
stats = r.data || [];
if (typeof Shell !== 'undefined') { Shell.levelCheck(); Shell.render(); refreshPanel(); }
}
const loadStatsSoon = () => { clearTimeout(statsTimer); statsTimer = setTimeout(loadStats, 400); };
async function notifyLevel(level) { try { await sb.rpc('notify_level', { p_team: team.id, p_level: level }); } catch {} }
// ---- Prompt 021 (G3): chat del equipo y anuncios ----
let chat = [], announcements = [], staleTimer = null;
async function loadChat() {
if (!team || !sb || !role() || role() === 'viewer' || visitor || missing.has('chat')) return;
const r = await sb.from('chat').select('*').eq('team_id', team.id).order('created_at', { ascending: false }).limit(1000);
if (isMissing(r.error)) { missing.add('chat'); return; }
if (!r.error) { chat = (r.data || []).reverse(); if (typeof Shell !== 'undefined') { Shell.render(); refreshPanel(); } }
}
async function loadAnnouncements() {
if (!team || !sb || !role() || role() === 'viewer' || visitor || missing.has('announcements')) return;
const r = await sb.from('announcements').select('*').eq('team_id', team.id).order('created_at', { ascending: false }).limit(200);
if (isMissing(r.error)) { missing.add('announcements'); return; }
if (!r.error) { announcements = r.data || []; if (typeof Shell !== 'undefined') { Shell.render(); refreshPanel(); } }
}
// Un mensaje: texto y/o archivos (mismos límites que los adjuntos de una tarea), al canal Team o a una persona.
async function sendChat(body, toUser, files = []) {
if (!entered || !online) { Shell.notify(T().offlineNoEdit); return { error: 'offline' }; }
const paths = [];
for (const file of Array.from(files || []).slice(0, 10)) {
const name = String(file.name || 'file').slice(0, 200);
if (file.size > maxMB() * 1024 * 1024) { Shell.notify(Media.kindOf({ name, mime: file.type || '' }) === 'video' ? T().videoTooBig(Math.ceil(file.size / 1048576), maxMB()) : T().tooBig(name, maxMB())); continue; }
const path = [team.id, 'chat', crypto.randomUUID() + '-' + safe(name)].join('/');
let up; try { up = await sb.storage.from(BUCKET).upload(path, await file.arrayBuffer(), { contentType: file.type || 'application/octet-stream', upsert: false }); } catch (e) { up = { error: e }; }
if (up.error) { Shell.notify(T().notSaved(String(up.error.message || up.error))); continue; }
paths.push(path);
}
const text = String(body || '').trim().slice(0, 4000);
if (!text && !paths.length) return { error: 'empty' };
const r = await sb.from('chat').insert({ team_id: team.id, to_user: toUser || null, body: text, files: paths }).select().single();
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return { error: r.error.message }; }
if (!chat.some((x) => x.id === r.data.id)) chat.push(r.data);
if (typeof Shell !== 'undefined') { Shell.render(); refreshPanel(); }
return { ok: true, data: r.data };
}
const chatFile = (path) => ({ path, name: String(path).replace(/^.*\/[0-9a-f-]{36}-/, ''), mime: '' });
async function chatFileUrl(path, download) {
const r = await sb.storage.from(BUCKET).createSignedUrl(path, 120, download ? { download: chatFile(path).name } : undefined);
return r.error ? null : r.data.signedUrl;
}
async function announce({ title = '', body = '', audience = null, pinned = false }) {
const r = await sb.from('announcements').insert({ team_id: team.id, title: String(title).trim().slice(0, 200), body: String(body).trim().slice(0, 4000), audience: audience && audience.length ? audience : null, pinned: !!pinned });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return { error: r.error.message }; }
await loadAnnouncements();
return { ok: true };
}
async function pinAnnouncement(id, pinned) { const r = await sb.from('announcements').update({ pinned: !!pinned }).eq('team_id', team.id).eq('id', id); if (r.error) Shell.notify(T().notSaved(r.error.message)); await loadAnnouncements(); return !r.error; }
async function deleteAnnouncement(id) { const r = await sb.from('announcements').delete().eq('team_id', team.id).eq('id', id); if (r.error) Shell.notify(T().notSaved(r.error.message)); await loadAnnouncements(); return !r.error; }
// Tareas quietas hace más de 3 días: el servidor avisa una vez (al líder y a quien la tiene). Al entrar y cada media hora.
async function checkStale() { if (!team || !sb || visitor || role() === 'viewer' || missing.has('chat')) return; try { await sb.rpc('check_stale', { p_team: team.id }); } catch {} }
if (typeof setInterval !== 'undefined') setInterval(() => { if (entered) checkStale(); }, 30 * 60000);
const loadSuggestionsSoon = () => { clearTimeout(sugTimer); sugTimer = setTimeout(loadSuggestions, 250); };
// Proponer: tipo, texto corto, workflow (si viene de su tarjeta) y archivos opcionales (al bucket del equipo).
async function suggest({ kind, text, workflow = '', projectId, files = [], aboutTask = '' }) {
if (!entered || !online) { Shell.notify(T().offlineNoEdit); return { error: 'offline' }; }
text = String(text || '').trim();
if (!text) return { error: T().sugEmpty };
const paths = [];
for (const file of Array.from(files || []).slice(0, 10)) {
if (file.size > maxMB() * 1024 * 1024) { Shell.notify(T().tooBig(file.name, maxMB())); continue; }
const path = [team.id, 'suggestions', crypto.randomUUID() + '-' + safe(String(file.name || 'file').slice(0, 120))].join('/');
let up; try { up = await sb.storage.from(BUCKET).upload(path, await file.arrayBuffer(), { contentType: file.type || 'application/octet-stream', upsert: false }); } catch (e) { up = { error: e }; }
if (!up.error) paths.push(path); else Shell.notify(T().notSaved(String(up.error.message || up.error)));
}
const title = text.split('\n')[0].slice(0, 120);
const r = await sb.from('suggestions').insert({ team_id: team.id, project_id: projectId || Store.get().settings.projectId || '', kind: ['task', 'design', 'idea', 'finding'].includes(kind) ? kind : 'idea', ...(kind === 'finding' && aboutTask ? { about_task: String(aboutTask).slice(0, 40) } : {}), title, body: text.slice(0, 2000), workflow: String(workflow || '').slice(0, 40), files: paths, source: 'app' });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return { error: r.error.message }; }
await loadSuggestions();
return { ok: true };
}
const decideSuggestion = (id, decision, opts = {}) => once('s|' + id, () => decideSuggestionNow(id, decision, opts));
async function decideSuggestionNow(id, decision, { workflow, reward, title } = {}) {
if (!entered || !online) { Shell.notify(T().offlineNoEdit); return { error: 'offline' }; }
const r = await sb.rpc('decide_suggestion', { p_team: team.id, p_id: id, p_decision: decision, p_workflow: workflow || null, p_reward: reward || 0, p_title: title || null });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return { error: r.error.message }; }
await Promise.all([loadSuggestions(), reload()]);
return { ok: true, data: r.data };
}
async function suggestionFileUrl(path) {
const r = await sb.storage.from(BUCKET).createSignedUrl(path, 120, { download: path.replace(/^.*\/[0-9a-f-]{36}-/, '') });
return r.error ? null : r.data.signedUrl;
}
// Cuántas tareas en curso tiene esta persona (un Member, como máximo 2).
const doingCount = (userId = myId()) => Store.get().tasks.filter((x) => x.assignee === userId && x.status === 'doing').length;
// El nombre del dueño del equipo (el de la maqueta, "Felipe", sale de aquí: no está escrito a mano).
const ownerName = () => ((members.find((m) => m.role === 'owner') || {}).name || 'the team lead');
// ---- Prompt 020 (E2): handoffs de miembros ----
// El handoff de un Member nunca reemplaza el proyecto: se fusiona. Este plan es lo mismo que hará el servidor
// (public.member_handoff), para mostrar el resumen antes de aplicar.
function planMerge(parsed) {
const name = String(parsed.project && parsed.project.name || '').trim().toLowerCase();
const proj = Store.get().projects.find((x) => !x.archived && x.name.trim().toLowerCase() === name);
if (!proj) return { error: Help.TEXT.merge.noProject(parsed.project ? parsed.project.name : '') };
const out = { projectId: proj.id, projectName: proj.name, updated: [], suggested: [], ignored: [], tasks: [] };
let doing = Store.get().tasks.filter((x) => x.assignee === myId() && x.status === 'doing').length;
const same = (a, b) => JSON.stringify(a || []) === JSON.stringify(b || []);
for (const x of parsed.tasks) {
const t = Store.task(proj.id, x.id);
const row = { id: x.id, workflow: x.workflow, title: x.title, mission: x.mission || '', status: x.status, needs: x.needs || '', deliverables: x.deliverables || [], files: x.files || [] };
out.tasks.push(row);
if (!t) { out.suggested.push(x.id + ' · ' + x.title); continue; }
const touches = x.status !== t.status || (x.needs || '') !== (t.needs || '') || !same(x.files, t.files) || !same(x.deliverables, t.deliverables);
if (t.assignee && t.assignee !== myId()) { if (touches) out.ignored.push({ id: t.id, why: 'notYours' }); continue; }
let st = x.status;
if (st === 'done' && t.status !== 'done') st = 'review';
if (t.status === 'done') st = 'done';
if (!t.assignee && ['doing', 'review', 'needs-you'].includes(st)) {
if (Store.lockOf(proj.id, t)) { out.ignored.push({ id: t.id, why: 'locked' }); continue; }
if (st === 'doing' && doing >= 2) { out.ignored.push({ id: t.id, why: 'two' }); continue; }
if (st === 'doing') doing++;
}
const changed = st !== t.status || (st === 'needs-you' && (x.needs || '') !== (t.needs || '')) || !same(x.files, t.files) || !same(x.deliverables, t.deliverables);
if (changed) out.updated.push(t.id);
}
return out;
}
async function memberHandoff(plan, file) {
const r = await sb.rpc('member_handoff', { p_team: team.id, p_project: plan.projectId, p_tasks: plan.tasks, p_file: String(file || '').slice(0, 80) });
if (r.error) return { error: r.error.message };
await reload();
return r.data || {};
}
// El dueño importa escribiendo las tablas; el resumen queda en su historial.
function logImport(r, file) {
if (!entered || !online || role() !== 'owner' || !r || !r.ok) return;
sb.rpc('log_import', { p_team: team.id, p_project: r.projectId, p_summary: (file ? file + ': ' : '') + r.name + ' ' + (r.created ? 'created' : 'updated') + ', ' + r.tasks + ' tasks' }).then(() => {}, () => {});
}
// ---- Tiempo real ----
function subscribe() {
if (dbChannel) sb.removeChannel(dbChannel);
if (role() === 'viewer') { dbChannel = null; return; } // 022 (R4): un Viewer no entra al canal del equipo
const tables = ['projects', 'workflows', 'tasks', 'events', 'agents', 'members', 'files', 'messages', ...(role() !== 'viewer' ? ['task_details'] : [])];
// Canal privado "team::db": el proyecto no acepta canales públicos (Realtime → «Allow public access» apagado,
// docs/DESPLIEGUE.md paso 4.3). La política de realtime.messages (0008) deja entrar solo a los miembros; cada
// cambio llega además filtrado por la RLS de su tabla.
let ch = sb.channel('team:' + team.id + ':db', { config: { private: true } });
for (const t of tables) {
if (missing.has(t)) continue;
ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: t, filter: 'team_id=eq.' + team.id }, onChange);
}
// Los DELETE no se pueden filtrar por equipo (llegan con la clave primaria, que incluye team_id).
for (const t of ['projects', 'workflows', 'tasks', 'agents', 'members', ...(role() !== 'viewer' && !missing.has('task_details') ? ['task_details'] : [])]) ch = ch.on('postgres_changes', { event: 'DELETE', schema: 'public', table: t }, (p) => { if (p.old && p.old.team_id === team.id) onChange(p); });
if (role() !== 'viewer' && !missing.has('ledger')) for (const t of ['ledger', 'cashouts']) ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: t, filter: 'team_id=eq.' + team.id }, onChange);
if (role() === 'owner' && !missing.has('ledger')) ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: 'budgets', filter: 'team_id=eq.' + team.id }, onChange);
if (role() === 'owner' && !missing.has('owner_log')) ch = ch.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'owner_log', filter: 'team_id=eq.' + team.id }, onChange);
if (role() !== 'viewer' && !missing.has('agent_activity')) ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: 'agent_activity', filter: 'team_id=eq.' + team.id }, onChange);
if (role() !== 'viewer' && !missing.has('suggestions')) ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: 'suggestions', filter: 'team_id=eq.' + team.id }, onChange);
if (role() !== 'viewer' && !missing.has('chat')) ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: 'chat', filter: 'team_id=eq.' + team.id }, onChange);
if (role() !== 'viewer' && !missing.has('duels')) ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: 'duels', filter: 'team_id=eq.' + team.id }, onChange);
if (role() !== 'viewer' && !missing.has('bonus_spins')) ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: 'bonus_spins', filter: 'team_id=eq.' + team.id }, onChange);
if (role() !== 'viewer' && !missing.has('announcements')) ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: 'announcements', filter: 'team_id=eq.' + team.id }, onChange)
.on('postgres_changes', { event: 'DELETE', schema: 'public', table: 'announcements' }, (p) => { if (p.old && p.old.team_id === team.id) onChange(p); });
if (!missing.has('notifications')) ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: 'notifications', filter: 'user_id=eq.' + myId() }, onChange);
if (role() === 'owner') ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: 'invitations', filter: 'team_id=eq.' + team.id }, onChange)
.on('postgres_changes', { event: 'DELETE', schema: 'public', table: 'invitations' }, onChange);
if (role() === 'owner' && !missing.has('trash')) ch = ch.on('postgres_changes', { event: '*', schema: 'public', table: 'trash', filter: 'team_id=eq.' + team.id }, onChange)
.on('postgres_changes', { event: 'DELETE', schema: 'public', table: 'trash' }, (p) => { if (p.old && p.old.team_id === team.id) onChange(p); });
dbChannel = ch; live = false;
// Realtime decide con el token de la sesión: se le entrega antes de entrar (como en la presencia).
Promise.resolve().then(() => sb.realtime.setAuth()).catch(() => {}).then(() => {
if (dbChannel !== ch) return;
ch.subscribe((status, err) => {
if (dbChannel !== ch) return;
if (status === 'SUBSCRIBED') { live = true; if (!online) goOnline(); }
else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') { live = false; if (entered) checkConnection(err); }
});
if (typeof Team.onSubscribed === 'function') Team.onSubscribed();
});
}
// ---- Sin conexión: se mira lo último; no se edita ----
// "Sin conexión" depende solo de Supabase. Si el canal en vivo falla pero la API contesta, se sigue conectado: se
// avisa en la consola y se trae lo nuevo cada 20 s hasta que el canal vuelva. El puente con Claude Code no cuenta
// nunca (si falla, solo queda "Bridge: off").
let live = false, livePoll = null;
async function checkConnection(err) {
if (err) console.warn('[Tasky] live updates: ' + (err.message || err));
let reachable = false;
try {
const r = await Promise.race([sb.from('members').select('user_id').eq('user_id', myId()).limit(1), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 8000))]);
reachable = !r.error || !/fetch|network|Load failed/i.test(r.error.message || '');
} catch {}
if (!reachable) { goOffline(); return; }
if (!online) await goOnline();
if (!livePoll) livePoll = setInterval(() => {
if (!entered || live) { clearInterval(livePoll); livePoll = null; return; }
if (online) reload();
}, 20000);
}
function goOffline() {
if (!online) return;
online = false;
Shell.render(); renderOffline();
}
async function goOnline() {
if (online) return;
try { await fetchAll(); online = true; renderOffline(); Shell.render(); persistence.save(Store.get()); } catch { /* sigue sin conexión */ }
}
function renderOffline() {
const n = document.getElementById('offline');
if (!n) return;
n.hidden = online;
n.textContent = online ? '' : T().offline;
}
// ---- Entrar ----
function prepare() {
if (!wanted) return;
Store.setPersistence(persistence);
Store.setWritable(() => !entered || (online && !!me && me.role !== 'viewer'));
Store.setAgentFilter((a) => !a.owner || a.owner === myId() || isOnlineUser(a.owner));
Store.setIdMaker((kind) => {
if (!entered) return null;
const rnd = (globalThis.crypto && crypto.randomUUID) ? crypto.randomUUID() : String(Math.random()).slice(2) + Date.now();
if (kind === 'event') return 'e_' + rnd;
if (kind === 'a_') return 'a_' + rnd.slice(0, 8);
if (kind === 'p_') return 'p_' + rnd.slice(0, 8);
return null;
});
Store.onBlocked(() => Shell.notify(!online ? T().offlineNoEdit : me && me.role === 'viewer' ? T().viewerNoEdit : T().signInFirst));
}
// Demo local (equipo/demo-local.mjs): un aviso fijo dice siempre que son datos de prueba.
function demoBanner() {
if (!cfg.demo || document.getElementById('demo-banner')) return;
const b = document.createElement('div');
b.id = 'demo-banner'; b.setAttribute('role', 'note');
b.textContent = T().demoBanner;
document.body.prepend(b);
document.body.classList.add('demo');
}
function demoAccounts() {
const { el, btn } = Shell;
const box = el('div', { className: 'demo-accounts' }, el('p', { className: 'si-hint', textContent: T().demoAccounts }));
for (const a of cfg.demoAccounts || []) box.append(btn(a.name + ' · ' + a.role + ' · ' + a.email, () => { const e = document.getElementById('si-email'), pw = document.getElementById('si-password'); if (e && pw) { e.value = a.email; pw.value = a.password; pw.focus(); } }, { className: 'linkish', data: { key: 'demo|' + a.email } }));
return box;
}
async function start() {
if (!wanted) return;
document.body.classList.add('team');
demoBanner();
// La app publicada se guarda en el navegador (sw.js) para abrirse sin conexión; los datos, aparte (copia local).
if ('serviceWorker' in navigator && /^https?:$/.test(location.protocol)) navigator.serviceWorker.register('sw.js').catch(() => {});
if (!cfg.supabaseUrl || !cfg.supabaseAnonKey || !window.supabase) { showSignIn(T().notConfigured); return; }
sb = window.supabase.createClient(cfg.supabaseUrl, cfg.supabaseAnonKey, { auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: false } });
addEventListener('offline', () => goOffline());
// Abrir un enlace de invitación con la pestaña ya abierta solo cambia el #: la pantalla de entrada se rehace.
addEventListener('hashchange', () => { if (!entered && /(invite|reset|view)=/.test(location.hash)) showSignIn(); });
addEventListener('online', () => { if (dbChannel) subscribe(); });
let sess = null;
try { sess = (await sb.auth.getSession()).data.session; } catch {}
// Un enlace para poner contraseña nueva siempre abre su pantalla (aunque este navegador tenga otra sesión).
if (!sess || /reset=/.test(location.hash)) { showSignIn(); return; }
user = sess.user;
await enter();
}
async function enter(code, name) {
// Sin red, la librería reintenta varias veces: si el navegador ya sabe que no hay conexión, o el servidor no
// contesta en 8 s, se abre la copia guardada.
if (typeof navigator !== 'undefined' && navigator.onLine === false && !code) return enterOffline();
let mine;
try { mine = await Promise.race([sb.from('members').select('*').eq('user_id', myId()), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 8000))]); }
catch { return enterOffline(); }
if (mine.error && /fetch|network/i.test(mine.error.message)) return enterOffline();
let row = mine.data && mine.data[0];
if (!row && code) {
const r = await sb.rpc('accept_invitation', { p_code: code.trim(), p_name: name || '' });
if (r.error) { showSignIn(T().badCode(r.error.message)); return; }
row = r.data;
}
if (!row) { await sb.auth.signOut(); user = null; showSignIn(T().notInvited); return; }
me = row;
const t = await sb.from('teams').select('*').eq('id', row.team_id).single();
team = t.data || { id: row.team_id, name: 'Team' };
try { await fetchAll(); } catch { return enterOffline(); }
entered = true; online = true;
if (!me.timezone) { const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; sb.rpc('update_profile', { p_team: team.id, p_timezone: tz }).then((r) => { if (r.data) me = r.data; }); }
subscribe();
joinPresence();
joinViewChannel();
hideSignIn();
Shell.render(); renderOffline();
persistence.save(Store.get()); // la copia para mirar sin conexión, desde ya
if (typeof Team.onEnter === 'function') Team.onEnter();
}
// Sin conexión al abrir: lo último que se guardó, para mirar.
function enterOffline() {
let c = null; try { c = JSON.parse(ls().getItem(CACHE_KEY) || 'null'); } catch {}
if (!c || c.user !== myId()) { showSignIn(T().offlineNoCache); return; }
team = c.team; me = c.me; members = c.members || [];
Actions.replaceRemote({ ...c.state, settings: { ...c.state.settings, ...Store.get().settings } });
shadow = snapshot(Store.get());
entered = true; online = false;
hideSignIn(); Shell.render(); renderOffline();
const retry = setInterval(async () => { try { const r = await sb.from('members').select('user_id').eq('user_id', myId()); if (!r.error) { clearInterval(retry); online = false; subscribe(); await goOnline(); } } catch {} }, 5000);
}
// ---- Prompt 020 (E9): el modo Viewer. El visitante lee por public.viewer_data (solo texto) con la clave pública y
// el token del enlace; no escribe nada. Su nave y las del equipo van por el canal "view:" (nombre de pila y
// posición). Con el enlace apagado o regenerado, deja de leer y vuelve a la pantalla de entrada.
const VIEW_COLORS = ['#E8E4FF', '#BFD4FF', '#FFD9C2', '#C8F5D8', '#FFE6A8', '#F9C6E4'];
let visitorPoll = null, viewCh = null, viewPresence = {};
const isVisitor = () => !!visitor;
async function viewerCheck(token) {
if (!sb || !token) return null;
try { const r = await sb.rpc('viewer_check', { p_token: token }); return r.error ? null : r.data; } catch { return null; }
}
async function enterVisitor(token, name) {
const r = await sb.rpc('viewer_join', { p_token: token, p_name: String(name || '').trim().slice(0, 40) });
if (r.error) { showSignIn(T().viewerOff); return false; }
visitor = { token, session: r.data.session, n: r.data.n, topic: r.data.topic };
let saved = {}; try { saved = JSON.parse(ls().getItem('tasky.viewerShip') || '{}') || {}; } catch {}
me = { user_id: myId(), name: 'Viewer ' + visitor.n, role: 'viewer', ship_shape: saved.shape || 'arrow', ship_color: /^#[0-9A-Fa-f]{6}$/.test(saved.color || '') ? saved.color : VIEW_COLORS[(visitor.n - 1) % VIEW_COLORS.length], visitor: true };
team = { id: 'view', name: r.data.team || 'Team' };
members = []; invitations = [];
try { await fetchVisitor(); } catch { visitor = null; me = null; team = null; showSignIn(T().viewerOff); return false; }
entered = true; online = true;
hideSignIn(); joinViewChannel(); Shell.render();
clearInterval(visitorPoll); visitorPoll = setInterval(() => { fetchVisitor().catch(() => {}); }, 5000);
return true;
}
async function fetchVisitor() {
if (!visitor) return;
const r = await sb.rpc('viewer_data', { p_token: visitor.token, p_session: visitor.session });
if (r.error) { if (/viewer link is off|visit ended/i.test(r.error.message || '')) { leaveVisitor(T().viewerOff); return; } throw r.error; }
applyViewerData(r.data || {});
}
// 022 (R4): el viewer recibe solo el universo: ids opacos, la galaxia de cada tarea y su estado (done, doing, todo).
// Nada de texto: las tarjetas, títulos, precios y personas no existen en su lado (los decide el servidor, 0031).
function applyViewerData(d) {
const cur = Store.get(), ps = d.projects || [];
const projects = ps.map((x, i) => MAP.projects.fromRow({ id: String(x.id), name: ps.length > 1 ? 'Project ' + (i + 1) : 'Project', status: 'active', archived: false }));
const workflows = (d.workflows || []).map((x) => MAP.workflows.fromRow({ project_id: String(x.project_id), id: String(x.id), name: '', mission: '', universe: 'Main', after_wf: '', position: Number(x.position) || 0 }));
const tasks = (d.tasks || []).map((x) => MAP.tasks.fromRow({ project_id: String(x.project_id), id: String(x.id), workflow: String(x.workflow), title: '', mission: '', status: x.status === 'done' || x.status === 'doing' ? x.status : 'todo', position: Number(x.position) || 0, assignee: null, after_task: '', reward: 0 }));
const next = { ...cur, settings: { ...cur.settings, layoutId: 'universe' }, projects, workflows, tasks, history: [], files: [], messages: [], notifications: [], trash: [], agents: [] };
if (!next.projects.some((p0) => p0.id === next.settings.projectId)) next.settings.projectId = (next.projects[0] || {}).id || null;
for (const p0 of next.projects) Store.recordProgress(next, p0.id);
Actions.replaceRemote(next);
}
// Una cuenta con rol Viewer: lo mismo, por viewer_view (0031). No lee las tablas del equipo ni entra a sus canales.
let viewerPoll = null;
async function fetchViewerAccount() {
members = me ? [me] : []; invitations = [];
const r = await sb.rpc('viewer_view', { p_team: team.id });
applyViewerData(r.error ? {} : r.data || {});
shadow = snapshot(Store.get());
clearInterval(viewerPoll); viewerPoll = setInterval(viewerTick, 3000);
}
// Sin canal del equipo, la cuenta de Viewer mira cada 3 s su universo y su propia fila (si el líder la cambió o la quitó).
async function viewerTick() {
if (!entered || visitor || !team || role() !== 'viewer') { clearInterval(viewerPoll); viewerPoll = null; return; }
try {
const m = await sb.from('members').select('*').eq('team_id', team.id).eq('user_id', myId());
if (!m.error && !(m.data || []).length) { clearInterval(viewerPoll); viewerPoll = null; signOut(T().removed); return; }
const row = m.data && m.data[0];
if (row && row.role !== 'viewer') { me = row; await roleChanged(); return; }
const x = await sb.rpc('viewer_view', { p_team: team.id });
if (!x.error) applyViewerData(x.data || {});
} catch {}
}
// Cambió el rol propio (Viewer ↔ Member): se vuelve a cargar todo con lo que ese rol puede ver, y los canales.
async function roleChanged() {
clearInterval(viewerPoll); viewerPoll = null;
try { await fetchAll(); } catch {}
subscribe(); joinPresence(); joinViewChannel();
Shell.render(); refreshPanel();
}
function leaveVisitor(message) {
const v = visitor;
clearInterval(visitorPoll); visitorPoll = null;
if (viewCh) { try { sb.removeChannel(viewCh); } catch {} viewCh = null; }
viewPresence = {};
if (v) sb.rpc('viewer_leave', { p_token: v.token, p_session: v.session }).then(() => {}, () => {});
visitor = null; entered = false; me = null; team = null;
showSignIn(message);
}
// Al cerrar la pestaña, la visita termina (el dueño ve cuánto se quedó).
if (typeof addEventListener !== 'undefined') addEventListener('pagehide', () => {
if (!visitor || !cfg.supabaseUrl) return;
try { fetch(cfg.supabaseUrl + '/rest/v1/rpc/viewer_leave', { method: 'POST', keepalive: true, headers: { apikey: cfg.supabaseAnonKey, Authorization: 'Bearer ' + cfg.supabaseAnonKey, 'Content-Type': 'application/json' }, body: JSON.stringify({ p_token: visitor.token, p_session: visitor.session }) }); } catch {}
});
// El canal de los visitantes: entran ellos y los miembros (si el dueño lo tiene encendido).
async function joinViewChannel() {
if (viewCh) { try { sb.removeChannel(viewCh); } catch {} viewCh = null; }
viewPresence = {};
let topic = visitor ? visitor.topic : null;
if (!visitor) { try { const r = await sb.rpc('viewer_topic', { p_team: team.id }); topic = r.error ? null : r.data; } catch {} }
if (!topic || !team) return;
loadViewKeys();
Promise.resolve().then(() => (visitor ? null : sb.realtime.setAuth())).catch(() => {}).then(() => {
const ch = sb.channel(topic, { config: { private: true, presence: { key: myId() } } });
viewCh = ch;
ch.on('presence', { event: 'sync' }, () => { if (viewCh !== ch) return; viewPresence = ch.presenceState(); if (typeof Team.onPresence === 'function') Team.onPresence(); })
.subscribe((st) => { if (st === 'SUBSCRIBED') trackView(); });
});
}
function trackView() {
if (!viewCh || !me) return;
// 022 (R4): solo la nave (forma y color) y adónde va, con el id opaco de ese planeta: ningún nombre, ningún id real.
const isV = !!visitor || role() === 'viewer';
viewCh.track({ k: isV ? 'v' : 'm', ...(visitor ? { n: visitor.n } : {}), shape: me.ship_shape || 'arrow', color: me.ship_color || '#FFD23F', focus: opaqueFocus(focusNow), at: Date.now() }).catch(() => {});
}
// El equipo traduce sus ids a los opacos del viewer (viewer_keys, 0031) y de vuelta, para ver las naves de los viewers.
let viewKeys = null, viewKeysAt = 0, viewInv = null, viewKeysTimer = null;
async function loadViewKeys() {
if (visitor || !team || !sb || role() === 'viewer') return;
// Como mucho una vez cada 10 s; si se pide antes (una tarea nueva), se hace al cumplirse el plazo.
const left = 10000 - (Date.now() - viewKeysAt);
if (left > 0) { if (!viewKeysTimer) viewKeysTimer = setTimeout(() => { viewKeysTimer = null; loadViewKeys(); }, left); return; }
viewKeysAt = Date.now();
try { const r = await sb.rpc('viewer_keys', { p_team: team.id }); viewKeys = r.error ? null : r.data; } catch { viewKeys = null; }
viewInv = null;
if (viewKeys) { viewInv = {}; for (const [kind, map] of Object.entries(viewKeys)) for (const [real, key] of Object.entries(map || {})) viewInv[key] = { kind, real }; }
trackView();
}
function opaqueFocus(f) {
if (!f) return null;
if (visitor || role() === 'viewer') return { project: f.project || null, kind: f.kind, id: f.kind === 'project' ? f.project || null : f.id || null };
const k = viewKeys;
if (!k) { loadViewKeys(); return null; }
const pid = f.project, id = f.kind === 'task' ? (k.tasks || {})[pid + '/' + f.id] : f.kind === 'workflow' ? (k.workflows || {})[pid + '/' + f.id] : (k.projects || {})[pid];
if (!id) { loadViewKeys(); return null; }
return { project: (k.projects || {})[pid] || null, kind: f.kind, id, universe: (k.universes || {})[pid + '/' + f.universe] || '' };
}
function realFocus(f) {
if (!f || visitor || role() === 'viewer') return f || null;
if (!viewInv) return null;
const pr = viewInv[f.project], it = f.kind === 'project' ? pr : viewInv[f.id], un = f.universe ? viewInv[f.universe] : null;
if (!pr || !it) return null;
const tail = (x) => x.real.split('/').slice(1).join('/');
return { project: pr.real, kind: f.kind, id: f.kind === 'project' ? pr.real : tail(it), universe: un ? tail(un) : 'Main' };
}
// Las naves del canal de visitantes: [{ userId, name, shape, color, focus, viewer }].
function viewPeople() {
const out = [];
for (const [key, metas] of Object.entries(viewPresence || {})) {
const m = (metas || []).slice().sort((a, b) => (b.at || 0) - (a.at || 0))[0];
if (m) out.push({ userId: key, name: m.k === 'v' ? 'Viewer' + (m.n ? ' ' + m.n : '') : '', shape: m.shape, color: m.color, focus: realFocus(m.focus), viewer: m.k === 'v', n: m.n || 0 });
}
return out;
}
const viewers = () => viewPeople().filter((x) => x.viewer && x.userId !== myId()).sort((a, b) => a.n - b.n);
// El visitante cambia su nave: se guarda en su navegador.
function setVisitorShip(shape, color) {
if (!visitor || !me) return false;
me = { ...me, ship_shape: shape || me.ship_shape, ship_color: color || me.ship_color };
try { ls().setItem('tasky.viewerShip', JSON.stringify({ shape: me.ship_shape, color: me.ship_color })); } catch {}
trackView(); Shell.render();
return true;
}
// El dueño: el enlace de Viewer (encender, apagar, regenerar).
// E11: la guía "Please read this". El servidor anota quién la abrió y quién la dio por leída (members.guide_read).
function guideLog(done) { if (!entered || !online || visitor || !team) return; sb.rpc('log_guide', { p_team: team.id, p_done: !!done }).then((r) => { if (!r.error && done && me) me = { ...me, guide_read: true }; if (typeof Shell !== 'undefined') Shell.render(); }, () => {}); }
const guideRead = () => !!(me && me.guide_read);
// E12: los correos. Si ya están configurados (lo marca la función del servidor) y lo que elige cada uno.
async function emailsStatus() { if (!entered || visitor || !team) return null; try { const r = await sb.rpc('emails_status', { p_team: team.id }); return r.error ? null : r.data; } catch { return null; } }
async function setEmailPrefs(on, hour) { const r = await sb.rpc('set_email_prefs', { p_team: team.id, p_on: on, p_hour: hour == null ? null : hour }); if (r.error) { Shell.notify(T().notSaved(r.error.message)); return null; } return r.data; }
async function viewerLink() { const r = await sb.rpc('viewer_link', { p_team: team.id }); return r.error ? null : r.data; }
// 021 (G8): "Viewers: on/off" del líder: el enlace y el botón de la entrada, juntos.
let viewerState = null;
async function loadViewerState() { if (role() !== 'owner' || !sb || !team) { viewerState = null; return null; } viewerState = await viewerLink(); if (typeof Shell !== 'undefined') Shell.render(); return viewerState; }
async function setViewersOn(on) {
const r = await sb.rpc('viewer_signin_set', { p_team: team.id, p_on: !!on });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return null; }
viewerState = r.data; joinViewChannel(); if (typeof Shell !== 'undefined') Shell.render();
return r.data;
}
async function viewerSigninLink() { if (!sb) return null; const r = await sb.rpc('viewer_signin_link'); return r.error ? null : r.data || null; }
async function setViewerLink(on) { const r = await sb.rpc('viewer_link_set', { p_team: team.id, p_enabled: !!on }); if (r.error) { Shell.notify(T().notSaved(r.error.message)); return null; } joinViewChannel(); return r.data; }
async function regenerateViewerLink() { const r = await sb.rpc('viewer_link_regenerate', { p_team: team.id }); if (r.error) { Shell.notify(T().notSaved(r.error.message)); return null; } joinViewChannel(); return r.data; }
async function signOut(msg) {
if (visitor) { leaveVisitor(msg); return; }
entered = false; me = null; team = null; members = []; invitations = []; shadow = null; schemaGap = null; schemaChecked = false; if (typeof Shell !== 'undefined' && Shell.showSchema) Shell.showSchema('');
try { if (dbChannel) await sb.removeChannel(dbChannel); } catch {}
try { if (presenceCh) await sb.removeChannel(presenceCh); } catch {}
presenceCh = null; presence = {};
if (typeof Team.onSignOut === 'function') Team.onSignOut();
dbChannel = null; live = false; clearInterval(livePoll); livePoll = null;
try { await sb.auth.signOut(); } catch {}
user = null;
try { ls().removeItem(CACHE_KEY); } catch {}
const keep = Store.get().settings, blank = Store.blank();
for (const k of PREF_FIELDS) if (k !== 'projectId') blank.settings[k] = keep[k];
Actions.replaceRemote(blank); // las preferencias de esta persona quedan; los datos del equipo, no
Shell.closePanel();
showSignIn(msg);
}
// ---- Pantalla de entrada: Sign in / Create account ----
function showSignIn(message) {
const box = document.getElementById('signin');
if (!box) return;
document.getElementById('app').hidden = true;
box.hidden = false;
box.textContent = '';
const { el, btn } = Shell;
const hash = new URLSearchParams((location.hash || '').replace(/^#/, ''));
// Modos: signin, create (enlace #invite=), reset (enlace #reset= que generó el dueño: contraseña nueva, un solo uso).
let mode = hash.get('reset') ? 'reset' : hash.get('invite') ? 'create' : 'signin';
const card = el('section', { className: 'signin-card', ariaLabel: 'Sign in' });
const draw = () => {
card.textContent = '';
const msg = el('p', { className: 'signin-msg', role: 'alert', textContent: message || '' });
msg.hidden = !message;
const email = el('input', { type: 'email', id: 'si-email', autocomplete: 'email', required: true, value: hash.get('email') || '', ariaLabel: 'Email' });
const pw = el('input', { type: 'password', id: 'si-password', autocomplete: mode === 'create' ? 'new-password' : 'current-password', required: true, minLength: 8, ariaLabel: 'Password' });
const nameIn = el('input', { type: 'text', id: 'si-name', autocomplete: 'name', maxLength: 60, ariaLabel: 'Your name' });
const code = el('input', { type: 'text', id: 'si-code', autocomplete: 'off', value: hash.get('invite') || '', ariaLabel: 'Invitation code' });
const field = (label, input) => el('label', { className: 'si-field' }, el('span', { textContent: label }), input);
const pw2 = el('input', { type: 'password', id: 'si-password2', autocomplete: 'new-password', minLength: 8, ariaLabel: 'Repeat the new password' });
if (mode === 'reset') { pw.autocomplete = 'new-password'; email.readOnly = !!email.value; }
const go = btn(mode === 'create' ? 'Create account' : mode === 'reset' ? 'Save the new password' : 'Sign in', null, { className: 'btn primary', type: 'submit', data: { key: 'si-go' } });
const form = el('form', { className: 'si-form' });
form.append(...(mode === 'create' ? [field('Your name', nameIn), field('Email', email), field('Password (8 or more characters)', pw), field('Invitation code', code)]
: mode === 'reset' ? [field('Email', email), field('New password (8 or more characters)', pw), field('Repeat the new password', pw2)] : [field('Email', email), field('Password', pw)]), go);
form.addEventListener('submit', async (e) => {
e.preventDefault();
go.disabled = true; message = '';
try {
if (mode === 'reset') {
if (pw.value.length < 8) { message = T().shortPassword; return; }
if (pw.value !== pw2.value) { message = T().passwordsDiffer; return; }
const r = await sb.rpc('redeem_password_reset', { p_token: hash.get('reset') || '', p_password: pw.value });
if (r.error) { message = r.error.message; return; }
const em = (r.data && r.data.email) || email.value.trim();
history.replaceState(null, '', location.pathname + location.search);
const si = await sb.auth.signInWithPassword({ email: em, password: pw.value });
if (si.error) { mode = 'signin'; message = T().passwordSaved; return; }
user = si.data.user;
await enter();
if (entered) Shell.notify(T().passwordSaved);
} else if (mode === 'create') {
if (pw.value.length < 8) { message = T().shortPassword; return; }
// El código viaja con la cuenta: el servidor (hook) no la crea sin el código correcto para ese correo.
const r = await sb.auth.signUp({ email: email.value.trim(), password: pw.value, options: { data: { invite_code: code.value.trim() } } });
if (r.error) { message = r.error.message; return; }
if (!r.data.session) { message = T().confirmOff; return; }
user = r.data.user;
history.replaceState(null, '', location.pathname + location.search);
await enter(code.value, nameIn.value);
} else {
const r = await sb.auth.signInWithPassword({ email: email.value.trim(), password: pw.value });
if (r.error) { message = r.error.message; return; }
user = r.data.user;
await enter();
}
} catch (err) { message = String(err && err.message || err); }
finally { go.disabled = false; if (message) draw(); }
});
const toggle = btn(mode === 'create' ? 'I already have an account: Sign in' : 'New here? Create your account with your invitation', () => { mode = mode === 'create' ? 'signin' : 'create'; message = ''; draw(); }, { className: 'linkish', data: { key: 'si-toggle' } });
card.append(brandMark(), el('h2', { textContent: mode === 'create' ? 'Create your account' : mode === 'reset' ? 'Choose a new password' : 'Sign in' }),
el('p', { className: 'si-hint', textContent: mode === 'create' ? T().createHint : mode === 'reset' ? T().resetHint : T().signInHint }), msg, form, mode === 'reset' ? null : toggle);
if (cfg.demo) card.append(demoAccounts());
// E9: con un enlace de Viewer encendido, también se puede mirar sin cuenta.
const vtoken = hash.get('view');
// 021 (G8): sin enlace, la entrada siempre ofrece mirar como viewer. Si el líder encendió «Viewers», se entra
// directo; si no (o si la base aún no tiene 0027), se pega el enlace de viewer que dio el líder, y se dice cómo
// conseguirlo. Quién entra lo sigue decidiendo el servidor (viewer_join exige un enlace encendido).
if (mode === 'signin' && !vtoken && sb) {
const slot = el('div', { className: 'si-viewer', data: { key: 'si-viewer-box' } });
card.append(slot);
const vname = el('input', { type: 'text', id: 'si-viewer-name', maxLength: 40, autocomplete: 'off', placeholder: T().viewerNamePh, ariaLabel: T().viewerNamePh, data: { key: 'si-viewer-name' } });
viewerSigninLink().then((tok) => {
if (!slot.isConnected) return;
if (tok) {
const look = btn(T().lookAround, async () => { look.disabled = true; await enterVisitor(tok, vname.value); look.disabled = false; }, { className: 'btn bigbtn go', data: { key: 'si-viewer-open' } });
slot.append(el('p', { className: 'si-hint', textContent: T().lookAroundHint }), vname, look);
return;
}
const paste = el('input', { type: 'text', id: 'si-viewer-link', autocomplete: 'off', spellcheck: false, placeholder: T().viewerPastePh, ariaLabel: T().viewerPastePh, data: { key: 'si-viewer-link' } });
const note = el('p', { className: 'si-small', role: 'status', data: { key: 'si-viewer-note' }, textContent: T().viewerAskLead });
const form = el('div', { className: 'si-vform', hidden: true }, paste, vname);
const go = async () => {
if (form.hidden) { form.hidden = false; look.textContent = T().viewerOpen; paste.focus(); return; }
const raw = paste.value.trim(), m = raw.match(/(?:^|[#&?])view=([A-Za-z0-9_-]+)/), tok2 = m ? m[1] : /^[A-Za-z0-9_-]{16,}$/.test(raw) ? raw : '';
if (!tok2) { note.textContent = T().viewerBadLink; paste.focus(); return; }
look.disabled = true; await enterVisitor(tok2, vname.value); look.disabled = false;
};
const look = btn(T().lookAround, go, { className: 'btn', data: { key: 'si-viewer-open' } });
paste.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } });
slot.append(el('p', { className: 'si-hint', textContent: T().lookAroundHint }), form, look, note);
});
}
if (mode === 'signin' && vtoken && sb) {
const slot = el('div', { className: 'si-viewer', data: { key: 'si-viewer-box' } });
card.append(slot);
viewerCheck(vtoken).then((ok) => {
if (!ok || !ok.ok || !slot.isConnected) return;
const vname = el('input', { type: 'text', id: 'si-viewer-name', maxLength: 40, autocomplete: 'off', placeholder: T().viewerNamePh, ariaLabel: T().viewerNamePh, data: { key: 'si-viewer-name' } });
const look = btn(T().lookAround, async () => { look.disabled = true; await enterVisitor(vtoken, vname.value); look.disabled = false; }, { className: 'btn', data: { key: 'si-viewer' } });
slot.append(el('p', { className: 'si-hint', textContent: T().lookHint(ok.team || '') }), vname, look, el('p', { className: 'si-small', textContent: T().viewerExplain }));
});
}
(mode === 'create' && !nameIn.value ? nameIn : email.value ? pw : email).focus();
};
draw();
box.append(card);
}
function hideSignIn() {
const box = document.getElementById('signin');
if (box) { box.hidden = true; box.textContent = ''; }
document.getElementById('app').hidden = false;
}
// ---- Cuenta y equipo (lo que no es el universo: va directo al servidor con sus funciones) ----
async function updateProfile(fields) {
const r = await sb.rpc('update_profile', { p_team: team.id, p_name: fields.name ?? null, p_ship_shape: fields.shipShape ?? null, p_ship_color: fields.shipColor ?? null, p_timezone: fields.timezone ?? null, p_onboarded: fields.onboarded ?? null });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return false; }
me = r.data; members = members.map((m) => (m.user_id === me.user_id ? me : m));
track();
Shell.render(); return true;
}
async function invite(email, roleName) {
const r = await sb.from('invitations').insert({ team_id: team.id, email: String(email).trim().toLowerCase(), role: roleName, invited_by: myId() }).select().single();
if (r.error) { Shell.notify(T().notSaved(/duplicate|unique/i.test(r.error.message) ? T().alreadyInvited : r.error.message)); return null; }
invitations = [...invitations.filter((i) => i.id !== r.data.id), r.data];
Actions.logTeam('invite', { email: r.data.email, role: roleName });
return r.data;
}
async function revoke(id) {
const inv = invitations.find((i) => i.id === id);
const r = await sb.from('invitations').delete().eq('id', id);
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return false; }
invitations = invitations.filter((i) => i.id !== id);
if (inv) Actions.logTeam('revoke', { email: inv.email });
return true;
}
async function setRole(userId, roleName) {
const r = await sb.rpc('set_member_role', { p_team: team.id, p_user: userId, p_role: roleName });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return false; }
members = members.map((m) => (m.user_id === userId ? { ...m, role: roleName } : m));
Actions.logTeam('role', { person: nameOf(userId), to: roleName });
return true;
}
async function remove(userId) {
const n = nameOf(userId);
const r = await sb.rpc('remove_member', { p_team: team.id, p_user: userId });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return false; }
members = members.filter((m) => m.user_id !== userId);
Actions.logTeam('remove', { person: n });
await reload();
return true;
}
const appBase = () => (cfg.appUrl || (location.origin + location.pathname)).replace(/#.*$/, '');
const inviteLink = (inv) => appBase() + '#invite=' + encodeURIComponent(inv.code) + '&email=' + encodeURIComponent(inv.email);
// ---- Agentes (0005): cuánto comparte cada persona, y la actividad publicada cada tanto ----
const sharing = () => (me && ['status', 'tools', 'files'].includes(me.agent_sharing) ? me.agent_sharing : 'tools');
async function setSharing(level) {
const r = await sb.rpc('set_agent_sharing', { p_team: team.id, p_level: level });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return false; }
me = r.data; members = members.map((m) => (m.user_id === me.user_id ? me : m));
lastPush.clear(); publishActivity(true);
return true;
}
// ---- Prompt 020 (E8): el detalle de los agentes (0014). El dueño lee el de todos; cada uno, el de los suyos. ----
let agentLive = {};
async function loadAgentLive() {
if (!team || !sb || !role() || role() === 'viewer' || missing.has('agent_activity')) return;
const r = await sb.from('agent_activity').select('*').eq('team_id', team.id);
if (isMissing(r.error)) { missing.add('agent_activity'); return; }
if (!r.error) { agentLive = Object.fromEntries((r.data || []).map((x) => [x.agent_id, x])); if (typeof Shell !== 'undefined') Shell.render(); }
}
const agentDetail = (id) => agentLive[id] || null;
// Lo propio, en detalle: la tarea, la nota, la última acción resumida y la actividad (el servidor la recorta otra vez).
const lastLive = new Map();
function publishLive(a, sh, beatAt) {
if (missing.has('agent_activity')) return;
const L = typeof Agents !== 'undefined' ? Agents.liveLine(a) : { text: '' };
const row = { team_id: team.id, agent_id: a.id, agent_name: a.name, project_id: a.projectId || null, task_id: a.taskId || null, note: a.note || '', note_at: a.noteAt ? new Date(a.noteAt).toISOString() : null, action: String(L.text || '').slice(0, 120), activity: sh };
const key = JSON.stringify(row), prev = lastLive.get(a.id);
if (prev && prev.key === key && Date.now() - prev.at < 50000) return;
lastLive.set(a.id, { key, at: Date.now() });
sb.from('agent_activity').upsert({ ...row, heartbeat_at: new Date(beatAt || Date.now()).toISOString() }, { onConflict: 'team_id,agent_id' }).then((r) => { if (isMissing(r.error)) missing.add('agent_activity'); }, () => {});
}
async function addAgentTime(agentId, session, projectId, taskId, seconds) {
if (!entered || !online || missing.has('agent_activity')) return;
await sb.rpc('add_agent_time', { p_team: team.id, p_agent: agentId, p_session: session, p_project: projectId, p_task: taskId, p_seconds: seconds });
}
// "Agent time · this week" y el tiempo por tarea (solo el dueño).
let agentTime = { week: null, tasks: {}, pid: null, at: 0 };
async function loadAgentTime(force = false) {
if (!team || role() !== 'owner' || missing.has('agent_activity')) return;
const pid = Store.get().settings.projectId;
if (!force && agentTime.pid === pid && Date.now() - agentTime.at < 30000) return;
agentTime.at = Date.now(); agentTime.pid = pid;
const [w, t] = await Promise.all([sb.rpc('agent_time_week', { p_team: team.id }), pid ? sb.rpc('agent_time_tasks', { p_team: team.id, p_project: pid }) : Promise.resolve({ data: {} })]);
if (isMissing(w.error)) { missing.add('agent_activity'); return; }
agentTime = { ...agentTime, week: w.error ? [] : (w.data || []), tasks: t.error ? {} : (t.data || {}) };
if (typeof Shell !== 'undefined') Shell.render();
}
// Lo propio se publica como mucho cada 10 s por agente; enseguida si empieza o deja de esperar a su dueño.
const lastPush = new Map();
function publishActivity(force = false) {
if (!entered || !online || !me || me.role === 'viewer' || missingCols.has('agents.activity')) return;
const level = sharing(), out = [];
for (const a of Store.get().agents) {
if (a.owner !== myId() || a.away) continue;
const sh = Actions.shareActivity(a.activity, level), prev = a.shared || {};
// E8: el latido (minuto de la última señal) renueva la fila pública mientras el agente da señales.
const beatAt = typeof Bridge !== 'undefined' && a.source === 'bridge' ? Bridge.seen(a.id) : 0;
if (beatAt) sh.beat = Math.floor(beatAt / 60000);
publishLive(a, sh, beatAt);
if (JSON.stringify(sh) === JSON.stringify(prev)) continue;
const urgent = force || sh.state !== prev.state || (sh.waiting || null) !== (prev.waiting || null);
if (!urgent && Date.now() - (lastPush.get(a.id) || 0) < 10000) continue;
lastPush.set(a.id, Date.now());
out.push([a.id, sh]);
}
if (out.length) Actions.publishActivity(out);
}
if (wanted && typeof setInterval !== 'undefined') setInterval(() => publishActivity(), 2500);
// ---- Contraseñas (0004): enlace de un solo uso que genera el dueño; cada uno cambia la suya en su cuenta ----
async function passwordLink(userId) {
const r = await sb.rpc('create_password_reset', { p_team: team.id, p_user: userId, p_hours: 24 });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return null; }
const m = memberOf(userId);
return { url: appBase() + '#reset=' + encodeURIComponent(r.data.token) + (m ? '&email=' + encodeURIComponent((contacts.get(m.user_id) || {}).email || m.email || '') : ''), expires: ms(r.data.expires_at) };
}
async function changePassword(current, next) {
if (String(next || '').length < 8) return T().shortPassword;
// Primero se confirma la contraseña de ahora (así la sesión es reciente y nadie la cambia con la pestaña abierta).
const chk = await sb.auth.signInWithPassword({ email: (user && user.email) || me.email, password: String(current || '') });
if (chk.error) return T().wrongPassword;
const r = await sb.auth.updateUser({ password: String(next) });
return r.error ? r.error.message : '';
}
// ---- Salida de un miembro (0004): reasignar sus tareas, retirar sus agentes y cerrar sus sesiones, en un paso ----
async function offboard(userId, reassign) {
const n = nameOf(userId);
const r = await sb.rpc('offboard_member', { p_team: team.id, p_user: userId, p_reassign: reassign || [] });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return null; }
members = members.filter((m) => m.user_id !== userId);
Actions.logTeam('remove', { person: n, reassigned: r.data.reassigned, agents: r.data.agents });
await reload();
return r.data;
}
// ---- Papelera: borrar para siempre lo hace el servidor (y devuelve los archivos que hay que quitar de Storage) ----
async function purgeTrash(e) {
const r = await sb.rpc('purge_trash', { p_team: team.id, p_id: e.id });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return false; }
const paths = (r.data || []).filter(Boolean);
for (let i = 0; i < paths.length; i += 100) await sb.storage.from(BUCKET).remove(paths.slice(i, i + 100));
applyRemote((s) => {
s.trash = s.trash.filter((x) => x.id !== e.id);
const hit = (x) => x.projectId === e.projectId && (e.kind === 'project' || x.taskId === e.taskId);
s.files = s.files.filter((x) => !hit(x)); s.messages = s.messages.filter((x) => !hit(x));
});
Actions.logTeam('purge', { what: e.kind, name: e.name });
return true;
}
// ---- Uso de almacenamiento (dueño): lo que ocupan las versiones de archivos frente al límite configurado ----
const storageLimitMB = () => (Number(cfg.storageLimitMB) > 0 ? Number(cfg.storageLimitMB) : 1024);
function storageUsage() {
const used = Store.get().files.filter((x) => !x.deleted).reduce((a, x) => a + (Number(x.size) || 0), 0);
const limit = storageLimitMB() * 1048576;
return { used, limit, pct: limit ? used / limit : 0, near: used >= 0.8 * limit };
}
// ---- Respaldo del equipo (0004): todo en un .zip (datos + cada versión de cada archivo) y su restauración ----
async function backupTeam(progress) {
const r = await sb.rpc('export_team', { p_team: team.id });
if (r.error) { Shell.notify(T().notSaved(r.error.message)); return null; }
const data = r.data, entries = [], missingFiles = [];
let i = 0;
for (const f of data.files || []) {
i++;
if (progress) progress(i, data.files.length);
if (f.deleted) continue;
const d = await sb.storage.from(BUCKET).download(f.path);
if (d.error || !d.data) { missingFiles.push(f.id); continue; }
f.zip = 'files/' + f.id + '-' + safe(f.name);
entries.push({ name: f.zip, data: new Uint8Array(await d.data.arrayBuffer()) });
}
data.missing_files = missingFiles;
entries.unshift({ name: 'tasky-team-backup.json', data: new TextEncoder().encode(JSON.stringify(data, null, 1)) });
return { blob: Zip.make(entries), counts: { projects: (data.projects || []).length, tasks: (data.tasks || []).length, events: (data.events || []).length, messages: (data.messages || []).length, files: entries.length - 1, members: (data.members || []).length } };
}
const isEmptyTeam = () => !Store.get().projects.length && !(Store.get().trash || []).length;
async function restoreTeam(buffer, progress) {
let files, data;
try { files = Zip.read(buffer); data = JSON.parse(new TextDecoder().decode(files.get('tasky-team-backup.json'))); } catch { return { error: T().notTeamBackup }; }
if (!data || data.format !== 'tasky-team-backup') return { error: T().notTeamBackup };
if (!isEmptyTeam()) return { error: T().restoreNotEmpty };
// Cada archivo se sube con una ruta de ESTE equipo (la primera carpeta es el equipo); después van las filas.
let i = 0;
for (const f of data.files || []) {
i++;
if (progress) progress(i, data.files.length);
const path = [team.id, safe(f.project_id), safe(f.task_id), crypto.randomUUID() + '-' + safe(f.name)].join('/');
const blob = f.zip ? files.get(f.zip) : null;
if (blob) {
const up = await sb.storage.from(BUCKET).upload(path, blob, { contentType: f.mime || 'application/octet-stream', upsert: false });
if (up.error) return { error: T().notSaved(up.error.message) };
}
f.path = path; delete f.zip;
}
const r = await sb.rpc('restore_team', { p_team: team.id, p_data: data });
if (r.error) return { error: T().notSaved(r.error.message) };
await reload();
try { const m = await sb.from('members').select('*').eq('team_id', team.id); if (!m.error) members = m.data; } catch {}
Shell.render();
return { ok: true, ...r.data };
}
// ---- Presencia: quién está conectado y qué mira (canal privado "team:", nada en la base de datos) ----
let presenceCh = null, presenceGen = 0, focusNow = null, presence = {};
function joinPresence() {
if (presenceCh) { try { sb.removeChannel(presenceCh); } catch {} presenceCh = null; }
if (role() === 'viewer') return;
const teamId = team.id, gen = ++presenceGen;
// Canal privado: Realtime decide quién entra con el token de la sesión. Se le entrega antes de entrar, como pide
// la documentación de Realtime Authorization; si no lo tuviera aún, Realtime probaría como anon y no dejaría entrar.
Promise.resolve().then(() => sb.realtime.setAuth()).catch(() => {}).then(() => {
if (gen !== presenceGen || !team || team.id !== teamId) return;
const ch = sb.channel('team:' + teamId, { config: { private: true, presence: { key: myId() } } });
presenceCh = ch;
ch.on('presence', { event: 'sync' }, () => { if (presenceCh !== ch) return; presence = ch.presenceState(); if (typeof Team.onPresence === 'function') Team.onPresence(); })
.subscribe((st, err) => {
if (st === 'SUBSCRIBED') track();
else if (st === 'CHANNEL_ERROR' && err) console.warn('[Tasky] presence: ' + (err.message || err));
});
});
}
function track() {
if (!presenceCh || !me) return;
presenceCh.track({ name: me.name, shape: me.ship_shape || 'arrow', color: me.ship_color || '#FFD23F', tz: me.timezone || '', focus: focusNow, at: Date.now() }).catch(() => {});
}
let focusTimer = null;
// Lo que esta persona mira: { project, kind: 'task' | 'workflow' | 'project', id }. Se publica un instante después.
function setFocus(target) {
const next = target ? { project: target.project || null, kind: target.kind || 'project', id: target.id || null, universe: target.universe || 'Main' } : null;
if (JSON.stringify(next) === JSON.stringify(focusNow)) return;
focusNow = next;
clearTimeout(focusTimer);
focusTimer = setTimeout(() => { track(); trackView(); }, 150);
}
// Personas conectadas: [{ userId, name, shape, color, tz, focus }] (sin repetir; la última conexión manda).
function people() {
if (visitor || role() === 'viewer') return viewPeople().filter((x) => x.userId !== myId());
const out = [];
for (const [key, metas] of Object.entries(presence || {})) {
const m = (metas || []).slice().sort((a, b) => (b.at || 0) - (a.at || 0))[0];
if (m) out.push({ userId: key, name: m.name, shape: m.shape, color: m.color, tz: m.tz, focus: m.focus || null });
}
return out;
}
const isOnlineUser = (id) => Object.prototype.hasOwnProperty.call(presence || {}, id) || (visitor && Object.prototype.hasOwnProperty.call(viewPresence || {}, id));
// ---- Archivos (bucket privado "tasky-files"): subir, enlace temporal, leer texto, quitar ----
const BUCKET = 'tasky-files';
const maxMB = () => Number(cfg.maxFileMB) > 0 ? Number(cfg.maxFileMB) : 25;
const safe = (x) => String(x).normalize('NFKD').replace(/[^\w.-]+/g, '_').replace(/^\.+/, '').slice(0, 100) || 'file';
async function uploadFiles(projectId, taskId, list, { kind = 'delivery', note = '', by = 'you' } = {}) {
const out = [];
for (const file of Array.from(list || [])) {
const name = String(file.name || 'file').slice(0, 200);
if (file.size > maxMB() * 1024 * 1024) { out.push({ name, error: Media.kindOf({ name, mime: file.type || '' }) === 'video' ? T().videoTooBig(Math.ceil(file.size / 1048576), maxMB()) : T().tooBig(name, maxMB()) }); continue; }
const prev = Store.get().files.filter((x) => x.projectId === projectId && x.taskId === taskId && x.kind === kind && x.name === name);
const version = prev.reduce((m, x) => Math.max(m, x.version), 0) + 1;
const id = crypto.randomUUID();
const path = [team.id, safe(projectId), safe(taskId), id + '-' + safe(name)].join('/');
let up;
try { up = await sb.storage.from(BUCKET).upload(path, await file.arrayBuffer(), { contentType: file.type || 'application/octet-stream', upsert: false }); }
catch (e) { up = { error: e }; }
if (up.error) { out.push({ name, error: String(up.error.message || up.error) }); continue; }
// La miniatura de lo que uno mismo sube sale del archivo local: el servidor la negaría hasta guardar su fila en files.
if (['image', 'video'].includes(Media.kindOf({ name, kind }))) mediaUrls.set(path, URL.createObjectURL(file));
if (Actions.addFile({ id, projectId, taskId, name, version, kind, path, size: file.size, mime: file.type || '', note: String(note || '').slice(0, 500) }, { by }) == null) { out.push({ name, error: T().denied(kind === 'starting' ? 'starting' : 'upload') }); continue; }
out.push({ name, version });
}
return out;
}
// 020-B (A2): un enlace (video largo, Loom, Drive…) como ficha. Va como un archivo sin contenido: misma lista, mismas
// versiones, mismos permisos (Reference = 'starting', del dueño; Delivery = 'delivery').
async function addLink(projectId, taskId, url, { kind = 'delivery', title = '', note = '', by = 'you' } = {}) {
url = String(url || '').trim();
if (!/^https?:\/\/[^\s]+$/i.test(url) || url.length > 2000) return { error: T().badLink };
let name = String(title || '').replace(/\s+/g, ' ').trim();
if (!name) { try { const u = new URL(url); name = u.hostname.replace(/^www\./, '') + (u.pathname.length > 1 ? u.pathname.replace(/\/$/, '') : ''); } catch { name = url; } }
name = name.slice(0, 200);
const prev = Store.get().files.filter((x) => x.projectId === projectId && x.taskId === taskId && x.kind === kind && x.name === name);
const version = prev.reduce((m, x) => Math.max(m, x.version), 0) + 1;
const id = crypto.randomUUID();
const path = [team.id, safe(projectId), safe(taskId), 'link-' + id].join('/');
if (Actions.addFile({ id, projectId, taskId, name, version, kind, path, size: 0, mime: 'text/uri-list', note: String(note || '').slice(0, 500), link: url }, { by }) == null) return { error: T().denied(kind === 'starting' ? 'starting' : 'upload') };
return { name, version };
}
// Para ver una imagen o un video dentro de la app: se baja con la sesión de quien mira (la RLS decide) y se muestra
// como blob: (la política de contenido de la app no deja cargar imágenes de otro origen). Solo png, jpg, webp, gif,
// mp4 y webm (Media.kindOf); el tipo lo pone la app, no el archivo. Las descargas siguen con enlaces firmados que caducan.
const mediaUrls = new Map(), mediaFailed = new Map();
// 021 (G2): los archivos de una tarea los lee el líder y quien la tomó (o quien los subió); a los demás no se les pide
// nada al servidor (lo negaría). Un intento fallido (p. ej., el archivo aún subiendo) se reintenta pasados 4 s, no antes.
const mayRead = (fl) => !fl.taskId || ['chat', 'suggestions'].includes(String(fl.path || '').split('/')[1]) || role() === 'owner' || fl.by === myId() || (Store.task(fl.projectId, fl.taskId) || {}).assignee === myId();
async function mediaUrl(fl) {
if (mediaUrls.has(fl.path)) return mediaUrls.get(fl.path);
const kind = Media.kindOf(fl);
if (kind !== 'image' && kind !== 'video') return null;
if (!mayRead(fl) || Date.now() - (mediaFailed.get(fl.path) || 0) < 4000) return null;
const r = await sb.storage.from(BUCKET).download(fl.path);
if (r.error || !r.data) { mediaFailed.set(fl.path, Date.now()); return null; }
const ext = (String(fl.name).match(/.([a-z0-9]+)$/i) || [])[1] || '';
const type = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', mp4: 'video/mp4', webm: 'video/webm' }[ext.toLowerCase()] || 'application/octet-stream';
const url = URL.createObjectURL(new Blob([await r.data.arrayBuffer()], { type }));
mediaUrls.set(fl.path, url);
return url;
}
const mediaUrlNow = (fl) => mediaUrls.get(fl.path) || null;
async function signedUrl(fl, asDownload) {
const r = await sb.storage.from(BUCKET).createSignedUrl(fl.path, 120, asDownload ? { download: fl.name } : undefined);
return r.error ? null : r.data.signedUrl;
}
async function download(fl) {
const url = await signedUrl(fl, true);
if (!url) { Shell.notify(T().fileGone(fl.name)); return false; }
const a = document.createElement('a');
a.href = url; a.download = fl.name; a.rel = 'noopener'; a.hidden = true;
document.body.append(a); a.click(); a.remove();
Actions.logFile('download', fl);
return true;
}
async function readText(fl) {
if (fl.size > 1024 * 1024) return null;
const r = await sb.storage.from(BUCKET).download(fl.path);
return r.error ? null : r.data.text();
}
async function deleteFileVersion(fl) {
if (Actions.deleteFile(fl.id) == null) return false;
await pending();
await sb.storage.from(BUCKET).remove([fl.path]);
return true;
}
// ---- Hilos leídos: hasta cuándo leyó esta persona cada hilo (en este navegador) ----
const READS_KEY = () => 'tasky:team:reads:' + (myId() || '');
const reads = () => { try { return JSON.parse(ls().getItem(READS_KEY()) || '{}'); } catch { return {}; } };
const threadKey = (projectId, taskId) => projectId + '|' + (taskId || '#chat');
function markRead(projectId, taskId) {
const r = reads(); r[threadKey(projectId, taskId)] = Date.now();
try { ls().setItem(READS_KEY(), JSON.stringify(r)); } catch {}
}
function unreadIn(projectId, taskId) {
const since = reads()[threadKey(projectId, taskId)] || 0;
return Store.get().messages.filter((m) => m.projectId === projectId && (m.taskId || null) === (taskId || null) && !m.deleted && m.author !== myId() && m.at > since).length;
}
let panelRefresh = null;
function refreshPanel() { if (panelRefresh && document.getElementById('panel')) panelRefresh(); }
function setPanelRefresh(fn) { panelRefresh = fn; }
return {
agentDetail, loadAgentLive, addAgentTime, loadAgentTime, agentTime: () => agentTime,
guideLog, guideRead, emailsStatus, setEmailPrefs,
ownerLog: () => ownerLog, ownerLogLoaded: () => ownerLogLoaded, loadOwnerLog,
isVisitor, viewerCheck, enterVisitor, leaveVisitor, viewers, setVisitorShip, viewerLink, setViewerLink, regenerateViewerLink,
suggestions: () => suggestions, suggest, decideSuggestion, suggestionFileUrl, loadSuggestions,
money: () => money, hasMoney, loadMoney, loadMoneySoon, walletOf, askCashout, markCashoutPaid, setBudget, statementCsv,
on, can, deny, deniedText, setReady, spins: () => spins, spinBonus, duels: () => duels, duelInvite: (uid) => duelCall('duel_invite', { p_other: uid }), duelAnswer: (id, yes) => duelCall('duel_answer', { p_id: id, p_accept: !!yes }), duelTap: (id) => duelCall('duel_tap', { p_id: id }), serverNow: () => Date.now() + clockOffset, duelWeek, teamSettings: () => teamSettings, setTeamSettings, loadSpins, loadDuels, stats: () => stats, statOf: (uid) => stats.find((x) => x.user === uid) || null, loadStats, notifyLevel, viewersOn: () => !!(viewerState && viewerState.enabled && viewerState.onSignin), setViewersOn, viewerSigninLink, loadViewerState, emailOf: (uid) => (contacts.get(uid) || {}).email || (members.find((m) => m.user_id === uid) || {}).email || '', myEmail: () => (user && user.email) || (me && me.email) || '', chat: () => chat, sendChat, chatFile, chatFileUrl, announcements: () => announcements, announce, pinAnnouncement, deleteAnnouncement, checkStale, loadChat, waiting: () => waiting, loadWaiting, schemaText, checkSchema, schemaGap: () => schemaGap, SCHEMA_NEEDED, planMerge, memberHandoff, logImport, takeIt, giveBack, reviewTask, setCheck, overview, memberReport, appTime: { tick: appTick, add: (n) => { appAcc += n; }, flush: flushAppTime }, doingCount, ownerName, prepare, start, enter, signOut, sync, pending, reload, fetchAll, updateProfile, invite, revoke, setRole, remove, inviteLink, appBase,
passwordLink, changePassword, offboard, sharing, setSharing, publishActivity, purgeTrash, storageUsage, storageLimitMB, backupTeam, restoreTeam, isEmptyTeam,
setPanelRefresh, refreshPanel, myId, myName, role, memberOf, nameOf, markRead, unreadIn, setFocus, people, isOnlineUser, uploadFiles, signedUrl, download, readText, deleteFileVersion, maxMB, addLink, mediaUrl, mediaUrlNow,
client: () => sb, team: () => team, me: () => me, members: () => members, invitations: () => invitations, isOnline: () => online, isEntered: () => entered,
goOffline, goOnline, missing: () => missing, config: () => cfg,
};
})();
/* ==================== Ships ==================== */
// Modo Team: cada persona tiene una nave (6 formas y un color). Los mismos trazos los dibujan la app y los layouts.
const Ships = (() => {
const PATHS = {
arrow: 'M12 2l6 18-6-4-6 4z',
saucer: 'M2 13.5c0-2 4.5-3.5 10-3.5s10 1.5 10 3.5-4.5 3.5-10 3.5S2 15.5 2 13.5zM7.5 10.5a4.5 4.5 0 0 1 9 0z',
rocket: 'M12 2c3.2 2.7 4.6 6.3 4.6 10.2l2.8 3.3V20l-4.2-1.6H8.8L4.6 20v-4.5l2.8-3.3C7.4 8.3 8.8 4.7 12 2z',
delta: 'M12 3l10 17-10-4-10 4z',
orb: 'M12 6.5a5.5 5.5 0 1 0 0 11a5.5 5.5 0 1 0 0-11zM1.5 12c0-1.4 4.7-2.4 10.5-2.4s10.5 1 10.5 2.4-4.7 2.4-10.5 2.4S1.5 13.4 1.5 12z',
wing: 'M1.5 15L12 5l10.5 10-4.5 1.2L12 12l-6 4.2z',
};
const SHAPES = Object.keys(PATHS);
const COLORS = ['#FFD23F', '#FF6B6B', '#2EC4B6', '#5B9DFF', '#B28DFF', '#F47FC5', '#FF9F1C', '#7ED957'];
const path = (shape) => PATHS[shape] || PATHS.arrow;
function icon(m, size = 22) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('width', size); svg.setAttribute('height', size); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('class', 'ship');
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
p.setAttribute('d', path(m && (m.ship_shape || m.shape))); p.setAttribute('fill', (m && (m.ship_color || m.color)) || '#FFD23F'); p.setAttribute('fill-rule', 'evenodd');
p.setAttribute('stroke', '#17142B'); p.setAttribute('stroke-width', '1');
svg.append(p);
return svg;
}
// Elegir forma y color (en la cuenta, y la primera vez que alguien entra).
function picker(m, onDone) {
const { el, btn } = Shell;
const box = el('section', { className: 'shippick', ariaLabel: 'Your ship' }, el('h3', { textContent: 'Your ship' }));
let shape = m.ship_shape || 'arrow', color = m.ship_color || COLORS[0];
const shapes = el('div', { className: 'apick', role: 'group', ariaLabel: 'Ship shape' }), colors = el('div', { className: 'apick', role: 'group', ariaLabel: 'Ship color' });
const draw = () => {
shapes.textContent = ''; colors.textContent = '';
for (const sh of SHAPES) { const b = btn('', () => { shape = sh; draw(); }, { className: 'aopt ship-opt', ariaLabel: 'Ship ' + sh, data: { key: 'ship|' + sh } }); b.append(icon({ shape: sh, color }, 26)); b.setAttribute('aria-pressed', String(shape === sh)); shapes.append(b); }
for (const c of COLORS) { const b = btn('', () => { color = c; draw(); }, { className: 'aopt swatch', ariaLabel: 'Color ' + c, data: { key: 'shipc|' + c } }); b.style.setProperty('--c', c); b.setAttribute('aria-pressed', String(color === c)); colors.append(b); }
};
draw();
box.append(shapes, colors, el('div', { className: 'pn-actions' }, btn('Save ship', async () => { if (await Team.updateProfile({ shipShape: shape, shipColor: color })) { Shell.notify(Help.TEXT.ships.saved); if (onDone) onDone(); } }, { className: 'primary', data: { key: 'ship-save' } })));
return box;
}
return { PATHS, SHAPES, COLORS, path, icon, picker };
})();
/* ==================== Help ==================== */
const Help = (() => {
// Todos los textos de explicación, en un solo objeto.
const TEXT = {
status: { todo: 'To do', doing: 'In progress', 'needs-you': 'Needs you', review: 'In review', blocked: 'Blocked', done: 'Done' },
chat: {
emptyThread: 'No messages yet. Ask, answer or leave a note for whoever works on this task.',
emptyChat: 'No messages yet. This is the chat of the whole project.',
placeholder: (names) => 'Write a message. Mention someone with @' + ([].concat(names)[0] || 'name') + '. Links open in a new tab.',
hint: 'Ctrl+Enter sends. Plain text only.',
deleted: 'This message was deleted.',
noNotifications: 'No notifications yet.',
},
ships: {
chooseTitle: 'Choose your ship',
chooseText: 'Your ship flies over the universe next to whatever you are looking at, so your team sees where you are. Pick a shape and a color; you can change them in your account.',
saved: 'Ship saved.',
},
people: {
noTask: 'No task assigned',
nothing: 'Nothing done yet',
last: (what, when) => 'Last: ' + what + ' (' + when + (when === 'just now' ? '' : ' ago') + ')',
following: (n) => 'Following ' + n,
followHint: 'Your camera follows their ship. Touch anything to stop.',
noActivity: (n) => n + ' has no activity yet.',
summary: (d, a, b) => d + (d === 1 ? ' delivery' : ' deliveries') + ' · ' + a + ' approved · ' + b + ' sent back',
},
review: {
submit: 'Submit for review',
submitTitle: 'Submit for review',
notePlaceholder: 'A note for the reviewer (optional): what you did, what to look at',
send: 'Send for review',
submitAnyway: 'Submit anyway',
missing: (list) => 'Not delivered yet: ' + [].concat(list).join(', ') + '. You can submit anyway.',
untested: (n, total) => n + ' of ' + total + (total === 1 ? ' test' : ' tests') + ' in How to test ' + (n === 1 ? 'is' : 'are') + ' not ticked yet. You can send it anyway.',
inReview: (n) => 'In review · sent by ' + n,
someone: 'someone',
noNote: 'No note.',
backPlaceholder: 'What needs to change? (required)',
needComment: 'Write what needs to change before sending it back.',
sentBackTitle: 'Sent back: what needs to change',
needTitle: 'Write a title.',
created: (id) => id + ' was created.',
deleted: (id) => id + ' was moved to the Trash. You can restore it for 30 days.',
},
needsTitle: 'What you need to do',
needsPlaceholder: 'Write what you have to do…',
unblock: 'Done, unblock',
path: {
whyWaiting: 'Waiting for the team lead', whyNotReady: 'Not ready yet: the team lead must set a price and mark it Ready',
whyLocked: (after) => 'Locked: unlocks after ' + after, whyTaken: (n) => 'Taken by ' + n, whyTwo: 'You already hold 2 tasks',
whyLeadNotReady: 'Not ready yet: members can take it once you set a price and mark it Ready', whyLeadPrice: 'Set a price',
whyMissing: (list) => 'Still missing: ' + [].concat(list).join(', ') + '.', markReadyBig: 'Mark Ready',
take: 'Take this task', takeAsk: (id) => 'Take ' + id + '? You can hold 2 tasks at a time.', takeYes: 'Yes, take it',
lost: (name) => name + ' just took this one.', takenBy: 'Taken by',
send: 'Send for review', sendAsk: (id, lead) => 'Send ' + id + ' for review? ' + lead + (lead === 'You' ? ' will check it.' : ' will check it.'), sendYes: 'Yes, send it',
notePlaceholder: 'A short note (optional): what you did, what to look at', drop: (mb) => 'Drop your files here, paste them, or choose them. Up to ' + mb + ' MB each.',
working: (who) => who.charAt(0).toUpperCase() + who.slice(1) + ' is working on it.', waitingFor: (lead) => 'Waiting for ' + lead,
approved: 'Approved', approvedPaid: (n) => 'Approved · $' + n + ' paid',
notReady: (lead) => 'Not ready yet: ' + lead + ' is still preparing it.', notReadyLead: 'Members can take it once you mark it ready.',
markReady: 'Mark ready', ready: 'Ready ✓ · members can take it', unready: 'Not ready', noFiles: 'No files needed',
missing: (list) => 'Before it is ready, add: ' + [].concat(list).join(', ') + '.',
filesLocked: (n) => n + (n === 1 ? ' file' : ' files') + ' · they open when you take the task',
filesOpen: (n) => n + (n === 1 ? ' file for this task' : ' files for this task'),
giveAsk: (id) => 'Give ' + id + ' back? It goes back to open for anyone.', giveYes: 'Yes, give it back',
},
restoredVersion: (name) => name + ' is back as it was before that import. The version you had is saved too.',
files: {
personal: 'In Team mode, files are uploaded and downloaded here, with versions.',
deliverablesTitle: 'deliverables, uploaded to Tasky for review',
plannedTitle: 'Planned files · code this task touches',
plannedTouched: 'Planned and touched',
plannedNotYet: 'Planned, not touched yet',
touchedUnplanned: 'Touched, not planned',
clash: (f, task, who) => '⚠ ' + f + ' is also in ' + task + ' (' + who + '). Agree who changes it first.',
clashesTitle: 'Same file in two tasks',
nobody: 'nobody yet',
none: 'Nothing delivered yet.',
noStarting: 'No starting files. Add some for whoever takes this task.',
notePlaceholder: 'Note for this upload (optional)',
drop: (mb) => 'Drop files or images here, paste them, or choose them. Up to ' + mb + ' MB each. The same name makes a new version.',
linkPlaceholder: 'Or paste a link: a long video, Loom, Drive…',
linked: (n) => 'Link added: ' + n + '.',
space: (here, used, limit, pct) => 'Files in this project: ' + here + ' · whole team: ' + used + ' of ' + limit + ' (' + pct + '%).',
spaceWarn: (pct, used, limit) => 'Files use ' + pct + '% of the space (' + used + ' of ' + limit + '). Delete old versions, or paste links for long videos.',
uploaded: (n, v) => 'Uploaded ' + n + ' (v' + v + ').',
previewNote: 'Shown as plain text: nothing in the file is run or interpreted.',
noPreview: 'This file cannot be previewed (too large or unavailable). Download it to open it.',
},
team: {
tooBig: (n, mb) => n + ' is larger than ' + mb + ' MB and was not uploaded.',
videoTooBig: (n, mb) => 'This video is ' + n + ' MB. The limit is ' + mb + ' MB. Paste a link instead.',
badLink: 'That is not a link. Paste an address that starts with https://',
fileGone: (n) => n + ' is not available anymore.',
notConfigured: 'Team mode is not configured yet: config.js needs the Supabase address and public key. See docs/DESPLIEGUE.md.',
signInHint: 'Sign in with the email and password of your Tasky account.',
createHint: 'You need an invitation from the team lead: use the email they invited and the code in your invitation link.',
shortPassword: 'Use a password with 8 or more characters.',
passwordsDiffer: 'The two passwords are not the same.',
demoBanner: 'DEMO · test data on this computer only · nothing here is real or saved when the demo stops',
demoAccounts: 'Demo accounts (click one to fill it in):',
wrongPassword: 'Your current password is not right.',
passwordSaved: 'Your new password is saved. Use it next time you sign in.',
resetHint: 'The team lead gave you this link. It works once and for a limited time. Choose a new password for your Tasky account.',
notTeamBackup: 'That file is not a Tasky team backup (.zip).',
restoreNotEmpty: 'A team backup can only be restored on an empty team: no projects and nothing in the Trash.',
confirmOff: 'Your account was created, but this project asks to confirm emails. Ask the team lead to turn off "Confirm email" (see docs/DESPLIEGUE.md), then sign in.',
notInvited: 'This account is not part of any team. Ask the team lead for an invitation.',
badCode: (m) => 'That invitation did not work: ' + m,
offline: 'You are offline. You can look at the last data Tasky had; editing is off until the connection comes back.',
sugEmpty: 'Write your suggestion first.',
offlineNoEdit: 'You are offline: Tasky shows the last data it had, but nothing can be changed until the connection comes back.',
offlineNoCache: 'Tasky cannot reach the server and has no saved copy on this computer yet. Check your connection and try again.',
viewerNoEdit: 'You are a Viewer: you can look at everything, but not change it.',
viewerOff: 'The viewer link is off or was replaced. Ask the team lead for a new one, or sign in.',
lookAround: 'Look around as a viewer', viewerOpen: 'Open as a viewer',
viewerPastePh: 'Paste the viewer link from the team lead',
viewerAskLead: 'Viewers need a link from the team lead (Team → Viewer link). The team lead can also turn on “Viewers” so this button opens right away.',
viewerBadLink: 'That does not look like a viewer link. It ends in #view= and a long code.',
lookAroundHint: 'No account? Look around: you see the universe and how far the work is, nothing to change. Only the team lead sees your name.', viewerNamePh: 'Your name (optional)',
lookHint: (t) => 'Sign in to take tasks and get paid. Or just look around' + (t ? ' ' + t : '') + '.',
viewerExplain: 'Viewers get a ship and see the universe only: galaxies, planets by color and the ships moving. No task text, names, prices or agent activity, and nothing to edit.',
signInFirst: 'Sign in first.',
denied: (what) => ({ import: 'Only the team lead can import handoffs.', editProjects: 'Only the team lead can change projects.', editTasks: 'Only the team lead can create, edit or delete tasks.', approve: 'Only the team lead can mark a task done. Submit it for review instead.', restore: 'Only the team lead can bring projects into the team.', invite: 'Only the team lead can invite people.', deleteFiles: 'Only the team lead can delete file versions.', starting: 'Only the team lead can add starting files.', ownAgent: 'Only the person who runs an agent can change it.', ownTask: 'Only the person who has this task can tick its boxes.', checkOwner: 'Only the team lead ticks the review list.', agents: 'A viewer cannot add agents.' }[what] || 'Your role cannot do that.'),
noHandoff: 'Only the team lead brings in handoffs. To add tasks or ideas, use Suggest something.',
notSaved: (m) => (typeof Team !== 'undefined' && Team.schemaText && Team.schemaText(m)) || 'That change was not saved: ' + m + '. Tasky reloaded the latest data.',
removed: 'You were removed from the team.',
welcomeTitle: 'Adds the welcome galaxy (first steps for a new person) to the project you are looking at, assigned to this person.',
welcomeAdded: (n, p) => 'Welcome galaxy added for ' + n + ' in ' + p + '.',
welcomeNoProject: 'Open a project first: the welcome galaxy goes into the project you are looking at.',
ownerOnly: 'Only the team lead can change the project.',
ownerTask: 'Tasks without a price belong to the team lead.',
paidDone: 'A task with a price is done when you approve it: it has to be sent for review first.',
twoTasks: 'Finish one of your two tasks in progress to take another.',
saved: 'Saved.',
roleText: { owner: 'can do everything', member: 'works on tasks and delivers files', viewer: 'can look, not change' },
roleChanged: (n, r) => n + ' is now ' + r + '.',
removeAsk: (n) => 'Remove ' + n + ' from the team? ',
offboardIntro: (n) => 'This removes ' + n + ' from the team in one step. Choose who takes each of their open tasks; the rest become free. ' + n + ' keeps their account, but sees nothing of this team any more.',
offboardNoTasks: 'No open tasks.',
offboardAgents: (k) => k ? k + (k === 1 ? ' agent leaves' : ' agents leave') + ' the team with them.' : 'No agents.',
offboardSessions: 'They are signed out everywhere and cannot come back in without a new invitation.',
offboarded: (n, r = {}) => n + ' was removed: ' + r.reassigned + ' reassigned, ' + r.unassigned + ' left free, ' + r.agents + (r.agents === 1 ? ' agent' : ' agents') + ' removed' + (r.sessions >= 0 ? ', signed out.' : '. Their open sessions end when they expire (within an hour).'),
passwordLink: (n, when) => 'Password link for ' + n + ' (works once, until ' + when + '). It was copied; send it to them:',
passwordLinkTitle: 'Make a one-time link so this person can choose a new password. Tasky sends no emails: you send the link yourself.',
passwordChanged: 'Your password was changed.',
storage: (used, limit, pct) => 'Files use ' + used + ' of ' + limit + ' (' + pct + '%).',
storageNear: 'Close to the limit: delete old versions or projects from the Trash, or raise the plan.',
backupMine: 'Download what you see of the team as a .json file (projects, tasks, history).',
backupHint: 'Everything in one .zip: projects, workflows, tasks, history, messages, members and roles, the Trash, and every version of every file. Keep it somewhere safe. Restoring needs an empty team.',
backupProgress: (i, n) => 'Downloading files: ' + i + ' of ' + n + '…',
backupDone: (f, c = {}) => 'Downloaded ' + f + ': ' + c.projects + ' projects, ' + c.tasks + ' tasks, ' + c.events + ' history entries, ' + c.messages + ' messages, ' + c.files + ' file versions, ' + c.members + ' people.',
restoreTitle: 'Only on an empty team (no projects, nothing in the Trash): for example a new Supabase project.',
restoreProgress: (i, n) => 'Uploading files: ' + i + ' of ' + n + '…',
restored: (r = {}) => 'Backup restored: ' + r.projects + ' projects, ' + r.tasks + ' tasks, ' + r.events + ' history entries, ' + r.messages + ' messages, ' + r.files + ' file versions, ' + r.members + ' people back.',
restoredInvites: 'These people need a new invitation link (send it to them):',
removed2: (n) => n + ' was removed.',
badEmail: 'Write a valid email.',
alreadyInvited: 'that email already has an invitation',
inviteHint: 'Tasky does not send emails: after inviting, copy the link and send it yourself (chat or email). Only that email can create an account with it.',
sendLink: (e) => 'Send this link to ' + e + ':',
personalHint: 'In Personal mode, open Backup → Download backup. Then choose that file here: its projects, workflows, tasks and history come into the team intact.',
imported: (p, t, e) => 'Brought ' + p + (p === 1 ? ' project' : ' projects') + ', ' + t + ' tasks and ' + e + ' history entries into the team.',
notBackup: 'That is not a Tasky backup.',
},
universes: {
hint: 'A universe groups workflows, from any project (for example Front and Back). Renaming one renames it everywhere.',
manageTitle: 'Rename universes. To move a workflow, open its card.',
renamed: (a, b) => 'The universe ' + a + ' is now ' + b + '.',
moved: (w, u) => w + ' moved to the universe ' + u + '.',
elsewhere: (list) => 'Nothing of this project is in the universe you are looking at. Its universes: ' + list + '.',
},
live: {
title: 'Live: what is happening right now with every agent, in the order it arrives.',
onIt: (n) => n + "'s agent is on it", needsOk: 'needs OK',
hint: 'What every agent is doing, in the order it happens. Each color is the ship of the person who runs it. Click a task to go there.',
empty: 'Nothing has happened yet. When an agent works, waits or pauses, it shows up here.',
noTask: 'no task',
you: 'you', owner: 'its owner',
working: 'Working', paused: 'Paused',
waitingFor: (n) => 'Waiting for ' + n,
permission: 'needs permission', question: 'has a question',
command: 'Running a command',
reading: (n) => 'Reading ' + n + ' files',
helpers: (n) => n + (n === 1 ? ' helper' : ' helpers'),
what: (k, f) => ({ edit: f ? 'editing ' + f : 'editing a file', write: f ? 'writing ' + f : 'writing a file', read: f ? 'reading ' + f : 'reading a file', search: 'searching the code', web: 'looking something up on the web', agent: 'starting a helper', plan: 'planning', mcp: 'using a connected tool', thinking: 'thinking', question: 'asking you something', other: '' })[k] || '',
needsPermission: (n) => (n || 'An agent') + ' needs your permission',
hasQuestion: (n) => (n || 'An agent') + ' has a question for you',
agentTag: 'agent',
toolCounts: (t = {}) => Object.entries(t || {}).sort((a, b) => b[1] - a[1]).map(([k, n]) => n + ' ' + ({ read: 'reads', edit: 'edits', write: 'new files', command: 'commands', search: 'searches', web: 'web lookups', agent: 'helpers', plan: 'plans', mcp: 'connected tools', question: 'questions', other: 'other' }[k] || k)).join(', '),
sharingHint: 'Others in the team see your agents while you have Tasky open. Choose how much: only whether they work, wait or pause; also which kind of tool they use; or also the file names. Prompts, answers, file contents and commands are never shared.',
sharingSaved: (l) => 'Saved: the team sees "' + l + '" of your agents.',
},
report2: {
title: 'A report of a period, made only of facts from the history, the deliverables and the agents. Download it as .md.',
hint: 'Only facts from the history, the deliverables and the agents\' activity; nothing is invented and what is not recorded is left out. In Team, it shows what you can see in the app.',
},
update: {
title: 'New version available',
text: 'Reload when you are ready. Nothing reloads by itself.',
unsent: 'You have text that is not sent or saved yet. Reload anyway?',
},
report: {
hint: 'Downloads a small file to send to whoever helps you: the version, the browser, the mode, the bridge and the last errors. It has no task, message or file content.',
done: (f) => 'Downloaded ' + f + '. Send it to whoever helps you; it has no task, message or file content.',
},
newProject: {
title: 'New project',
short: 'Start a new universe from a .tasky.md. A team backup is downloaded first; this project is archived, not deleted.',
steps: ['Download the team backup. Keep it: it is how you undo this.', 'Choose the .tasky.md of the new project. It always comes in as a new project, even with the same name.'],
keep: (cur) => 'Archiving ' + cur + ' deletes nothing: Project → Archived brings it back. Wallets, payments, ranks and history belong to the team, so they stay exactly as they are.',
first: 'Choose the .tasky.md of the new project. It always comes in as a new project.',
archive: (cur) => 'Archive ' + cur + ' (it leaves the map and the viewers; members keep their pay)',
backup: 'Download the team backup',
backupFailed: 'The backup could not be downloaded, so nothing changed. Try again.',
saved: (f) => 'Saved ' + f + '. Now choose the .tasky.md of the new project.',
firstBackup: 'Download the team backup first.',
choose: 'Choose the .tasky.md…',
template: 'Template for your agent',
done: (name, old) => name + ' is the new project.' + (old ? ' ' + old + ' is archived: Project → Archived brings it back.' : ''),
},
trash: {
title: 'Deleted projects and tasks. Restore them, or delete them forever after 30 days.',
hint: (d) => 'Deleted projects and tasks stay here at least ' + d + ' days, with their files, threads and history. Restore brings everything back. After ' + d + ' days you can delete them forever.',
empty: 'The Trash is empty.',
when: (when, who, left) => 'deleted ' + (when === 'just now' ? 'just now' : when + ' ago') + (who ? ' by ' + who : '') + (left > 0 ? ' · can be deleted forever in ' + left + (left === 1 ? ' day' : ' days') : ' · can be deleted forever now'),
restored: (n) => n + ' is back.',
cannot: { exists: 'Something with the same id exists now, so it cannot come back. Delete or rename that one first.', project: 'Its project is in the Trash: restore the project first.' },
purgeAsk: (n) => 'Delete “' + n + '” forever, with its files and threads? This cannot be undone. ',
purged: (n) => n + ' was deleted forever.',
purgeFailed: 'It could not be deleted forever. Try again.',
expired: (n) => n + (n === 1 ? ' item has' : ' items have') + ' been in the Trash for more than 30 days. Open Trash (under Projects) to delete them forever, or keep them.',
moved: (n) => n + ' was moved to the Trash. You can restore it for 30 days.',
},
search: {
placeholder: 'Search tasks and workflows',
label: 'Search tasks, workflows and projects by id, title or mission. Shortcut: / or Ctrl+K',
none: 'Nothing matches.',
project: 'Project',
},
filters: {
label: 'Filter the map',
title: 'Click to dim everything else on the map. Click again to remove it.',
clearTitle: 'Click to remove this filter.',
doing: 'In progress', needs: 'Needs you', agents: 'With agents', notdone: 'Not done',
all: 'All', open: 'Open to take', mine: 'Mine', review: 'In review', locked: 'Locked', done: 'Done', detail: 'Needs detail',
showing: (label, n, total) => 'Showing ' + label + ': ' + n + ' of ' + total,
clear: 'Clear',
},
tour: {
title: 'A walk through Universe: galaxy by galaxy, stopping a few seconds on every task that is not done. Any drag or key stops it.',
nothing: 'Every task is done: there is nothing to visit.',
finished: 'Tour finished.',
},
timeline: {
name: 'Timeline',
label: 'Timeline: move it to see the universe as it was at a past moment',
today: 'Today',
viewing: (when) => 'Viewing ' + when,
readOnly: 'This is the past: you can look, not edit.',
back: 'Back to today',
noEdit: 'You are viewing the past. Go back to today to change it.',
cardNote: (when) => 'As it was on ' + when + '. Go back to today to change it.',
},
list: { hint: 'The whole project as a simple list. Click a task to open its card; change its status there.' },
now: {
hint: 'Every project at a glance. Click a row to go to that task in Universe.',
recent: 'Recently done',
noAgent: 'no agent',
noDoing: 'Nothing in progress right now.',
noDone: 'Nothing finished in the last 7 days.',
yesterday: 'Yesterday',
yesterdayNone: 'Nothing recorded.',
yesterdayMore: (n) => '… and ' + n + ' earlier',
},
steps: {
title: 'Welcome to the team',
intro: 'Your work in four steps:',
list: [
['1. Take a task', 'Open a task in To do and press "I\'m on it".'],
['2. Download the files', 'In the task card, under Files, download what is there.'],
['3. Upload your version', 'Press "Upload files" in the same card. Each upload is a new version.'],
['4. Submit it for review', 'Press "Submit for review" with a short note. The team lead approves it or sends it back.'],
],
ok: 'Got it',
},
since: {
title: 'Since last time',
ago: (t) => 'You were here ' + t + ' ago',
ok: 'Close',
done: (n) => n + (n === 1 ? ' task done' : ' tasks done'),
needs: (n) => n + (n === 1 ? ' new task waits for you' : ' new tasks wait for you'),
came: (names) => (names.length === 1 ? names[0] + ' arrived' : names.length + ' agents arrived: ' + names.join(', ')),
left: (names) => (names.length === 1 ? names[0] + ' left' : names.length + ' agents left: ' + names.join(', ')),
},
history: {
hint: 'Every change, newest first. You means a click; Handoff means an imported file; otherwise, the agent. Click a line to go there.',
empty: 'Nothing here yet.',
gone: 'That project no longer exists.',
archived: (n) => n + ' is archived. Bring it back from “Archived” to see it.',
},
inbox: {
name: 'Waiting on you',
title: 'Tasks in every project that wait for something from you. Click one to go to it. Click the title to fold or unfold the list.',
empty: 'Nothing waits for you right now.',
noText: 'No note yet: open the task and write what is needed',
agent: (n) => n + ' is waiting',
review: (n) => 'ready for review' + (n ? ' · ' + n : ''),
reviewTitle: (title) => 'Review: ' + title,
open: (n, wf) => 'holds ' + n + ' open in ' + wf,
age: 'oldest first',
},
projectStatus: { active: 'Active', paused: 'Paused', done: 'Done' },
agentStatus: { working: 'Working', waiting: 'Waiting' },
touchTitle: 'Touch anything inside the map',
howItWorks: 'Click a task to read its mission, change its status or give it an agent. Click a planet or the center of a flower to see its workflow and its tasks. Each finished task raises its workflow on the right. Tasks that need you wait at the top left.',
projectTitle: 'Rename, archive, delete or see the handoff of this project.',
noMission: 'This workflow has no mission yet.',
noProjects: 'No projects yet. Drop a handoff to start.',
viewerHow: 'Tap a planet and your ship flies there. Each galaxy is a group of tasks; the colors say what is done, in progress or open; the other ships are people working right now.',
noWorkflows: 'Workflows appear here when a project is open.',
noHandoffYet: 'No handoff yet? Give the template to your agent and it will send one back.',
migrate: {
title: 'Bring your projects from the previous version',
text: 'Tasky now opens as an app at this address. Projects saved while you opened index.html from the disk live in that other place and do not appear here by themselves.',
steps: [
'Open src/index.html with a double click, as before.',
'Click Backup → Download backup. A tasky-backup-….json file is saved.',
'Come back to this window and click Restore backup below, then pick that file.',
],
button: 'Restore backup',
},
noProjectsToExport: 'There are no projects to export. Drop a handoff first.',
exportHint: 'One .tasky.md per project, with today’s state.',
personalStorage: (used, limit) => 'Saved in this browser: ' + used + ' of about ' + limit + '.',
personalStorageNear: 'Close to what the browser allows: download a backup and delete old projects from the Trash.',
backupHint: 'All of Tasky in one file: projects, tasks, progress history, agents and uploaded views. Use it to move to another browser, or from index.html opened from the disk to the Tasky app window.',
handoffHint: 'This is the project as a .tasky.md with today’s state, exactly as Export writes it.',
archiveHint: 'Archived projects leave the list but keep all their history.',
oldLayout: 'this view was made for the old contract (version 1) and cannot show workflows. Ask for a version 2 view; the example shows how.',
stalled: (name) => 'The view “' + name + '” did not answer. Pick another: ',
agentPhrase: 'When you finish, give me the project status as a .tasky.md file with this exact structure. Do not rename headings or columns; one file per project. Group tasks into workflows, each with a one-line mission. Use the statuses todo, doing, needs-you, blocked or done. Use needs-you when a task waits for something from me, and write in the needs column, in one line, what I have to do. In the files column, list for every task the code files it will touch (paths relative to the repository, comma-separated).',
layoutPhrase: 'Make me a view for Tasky: a single .tasky-layout.html file, no network and no libraries, that follows the attached example (meta tasky-layout content 2, a title, and the messages tasky:ready, task, select, hover and height). Each workflow should look like: ',
storage: {
empty: null,
migrated: 'Your saved data was upgraded to the new format: lanes became workflows and progress is now computed from tasks.',
corrupt: 'The saved data was damaged, so Tasky opened empty. A copy was kept under "tasky:v2:damaged".',
unavailable: 'This browser does not allow saving: Tasky opened empty and changes will not last.',
save: 'Changes could not be saved in this browser.',
},
toast: {
title: 'A new agent appeared',
started: 'Started just now on this computer',
question: 'What is this agent working on?',
later: 'Decide later',
ok: 'Save name',
noFolder: 'unknown folder',
pickProject: 'Pick a project:',
otherProjects: (name) => '“' + name + '” has no open tasks. These projects do:',
noOpenAnywhere: 'No project has open tasks right now. Just give this agent a name.',
linkQuestion: 'This folder belongs to:',
linkPick: 'Choose a project…',
linked: (p) => 'This folder belongs to ' + p + '. The agent got a short Tasky briefing when it started.',
linkedNow: (p) => 'Folder linked to ' + p + '. Next time a Claude Code session starts here, the agent gets a short Tasky briefing.',
guess: (id) => 'Looks like ' + id + ' — confirm?',
confirm: (id, title) => 'Yes, ' + id + ' · ' + title,
questionOther: 'Or another task:',
endedTitle: 'A session ended',
ended: (id) => 'Session ended on ' + id + ' — submit for review, keep in progress, or back to To do?',
endedReview: 'Submit for review',
endedKeep: 'Keep in progress',
endedTodo: 'Back to To do',
},
// Prompt 020 (E3): el ciclo de una tarea, con los textos de la maqueta.
cycle: {
status: { todo: 'Open', doing: 'In progress', review: 'In review', done: 'Done', 'needs-you': 'Needs the team lead', blocked: 'Blocked' },
yours: 'yours', takenBy: (n) => 'taken by ' + n, nobody: 'nobody has taken it',
finishOne: 'finish one of your two tasks to take it',
delivered: (who, waiting) => (who === 'you' ? 'you' : who) + ' delivered it · waiting for ' + waiting,
paid: 'approved and paid', finished: 'finished',
checkThis: (owner) => owner + ': check this before I approve',
checkTitle: (owner) => owner + ' asked you to check something',
noteFrom: (who) => 'Note from ' + (who === 'you' ? 'you' : who),
take: (r) => 'Take it · $' + r, takeMyself: 'Take it myself',
sendReview: 'Send for review', giveBack: 'Give it back',
approvePay: (r) => 'Approve and pay $' + r, approve: 'Approve',
noRewardAsk: 'This task has no reward. Approve without pay?', approveNoPay: 'Approve without pay',
check: 'Check this', checkSend: 'Send it back to check', checkPlaceholder: 'What should they check? (required)',
reject: 'Reject', markDone: 'Mark done', decided: 'I decided · open it',
},
sug: {
tool: 'Suggest something', title: 'Suggest something', kindLabel: 'What kind', kinds: { task: 'Task', design: 'Design', idea: 'Idea', finding: 'Finding' },
intro: (o) => 'A task, a design idea, a color, anything. ' + o + ' reviews it and may turn it into a paid task.',
placeholder: 'Say it in a few lines. The first line is the title.', textLabel: 'Your suggestion', wfLabel: 'Workflow', anyWf: 'Any workflow', filesLabel: 'Files (optional)',
send: 'Send suggestion', sent: (o) => 'Sent to ' + o, sentNote: (o) => 'Your suggestion is on its way to ' + o + '.', empty: 'Write your suggestion first.',
fromTitle: (n) => 'Suggestion from ' + (n || 'someone'), gone: 'This suggestion is gone.', fromHandoff: 'from a handoff', files: 'Files', fileGone: 'That file is no longer available.',
accepted: (id) => 'Now a task: ' + id, declined: 'Declined with thanks', waiting: (o) => 'Waiting for ' + o + ' to decide.',
makeTitle: 'Make it a task', taskTitle: 'Task title', price: 'Price', addTask: (r) => 'Make it a task · $' + r + ' from budget', addTaskFree: 'Make it a task (no price)',
notNow: 'Not now', thanks: 'Declined with thanks.', made: (id) => 'Now a task: ' + id + '. They were told.',
waitTitle: (who, kind) => 'Suggestion · ' + who + ' · ' + kind, became: 'Your suggestion became a task',
},
prompts: {
intro: 'Messages for your Claude agent. ', first: 'Always send the first one before anything else: it is what keeps the universe updated.',
items: [
{ name: '1 · First message to every agent', when: 'every time you start a Claude Code agent, before you give it any work. Without it the universe does not update.',
text: 'Before you do anything else in this session: this project is tracked in Tasky.\n1. When you start working on something, find the task it belongs to in the handoff (.tasky.md) and run: {{tasky}} take \n2. Each time you move to a new step, run: {{tasky}} note ""\n3. If you need a decision from a person, run: {{tasky}} needs "" and wait.\n4. When the "Done when" of the task is true, run: {{tasky}} review and write what you did and how to check it.\n5. If the work fits no task, tell me before you continue.\nNever put secrets or keys in these messages. Answer "Tasky on" in one line and wait for my instructions.' },
{ name: 'Change of task', when: 'you are moving the agent to different work in the same session.', text: 'We are changing work. If the task you were on is not finished, run tasky.mjs note on it saying where you stopped. Then find the task for the new work in the handoff and run tasky.mjs take on it before you start.' },
{ name: 'Before you close', when: 'you are about to close the terminal or end the day.', text: 'We are stopping here. If the "Done when" of your task is true, run tasky.mjs review on it. If not, run tasky.mjs note saying exactly where you stopped and what comes next, so the next session can continue.' },
{ name: 'Update the handoff', owner: true, when: 'the plan changed: new tasks appeared or some no longer make sense.', text: 'Update the .tasky.md handoff of this project: add the new tasks to their workflow with a title, a mission and a done_when, and do not delete or renumber the tasks that already exist. Leave reward empty: the team lead sets it.' },
{ name: 'Plan a project', owner: true, when: 'team lead only: you are creating the first handoff of a project.', text: 'Split this project into workflows and tasks using docs/PLANTILLA.tasky.md. For every task fill in: reward (5, 10 or 20), done_when (one sentence that can be checked), and after (the task or workflow that must be approved first, or empty). Then write its block in ## Task details: at least Steps, Expected outcomes and How to test, plus Context, Resources, Out of scope and Notes when they help, so a person or their agent can do it without asking.' },
],
},
viewerLink: {
title: 'Viewer link', text: 'Anyone with this link can look around without an account: tasks as text only, no prices, no wallets, no agents, nothing to edit. Turn it off or make a new one at any time; the old link stops working at once.',
on: 'On: people with the link can look around', off: 'Off: nobody can look around', copy: 'Copy link', copied: 'Link copied.', regen: 'Make a new link', signin: 'Viewers on: the sign-in screen also shows “Look around as a viewer” to anyone (text only, nothing to change)',
},
guide: { title: 'How Tasky works' },
helpTeam: {
sections: [
{ key: 'universe', title: 'The universe', items: [
['galaxy', 'A galaxy', 'One workflow: a group of tasks with one goal.'],
['planet', 'A planet', 'One task, with its price next to it.'],
['lock', 'A topaz lock', 'Someone took this task: their name is next to it.'],
['ship', 'A ship', 'One person, flying to what they are looking at.'],
['agent', 'A satellite', 'An agent working on that task right now.'],
] },
{ key: 'task', title: 'A task, step by step', steps: true, items: [
['read', '1 · Read', 'Read the mission, the steps and how to test it.'],
['take', '2 · Take', 'Take it: you can hold 2 tasks at a time.'],
['deliver', '3 · Deliver', 'Upload what you made, tick the tests, send it.'],
['review', '4 · In review', 'The team lead approves it, or asks you to check something.'],
] },
{ key: 'pay', title: 'Pay and bonuses', items: [
['coin', 'Approved = paid', 'The price goes to your wallet the moment it is approved.'],
['gift', 'Every 4 approved', 'A bonus gift appears: open it and spin the wheel.'],
['cash', 'Cash out', 'From $20 you ask for a cash out; the team lead pays it.'],
] },
{ key: 'agent', title: 'Your agent', items: [
['prompt', 'First message', 'Send it to every new agent: it keeps the universe up to date.'],
['agent', 'It tells Tasky', 'Your agent says what it takes, what it needs and when it is done.'],
['eye', 'You decide', 'Tasky asks you before linking an agent to a task it guessed.'],
] },
{ key: 'who', title: 'Who sees what', items: [
['crown', 'Team lead', 'Sees everything, prices tasks, approves and pays.'],
['person', 'Member', 'Sees the tasks, takes them, and sees only their own wallet and messages.'],
['eye', 'Viewer', 'Sees the universe and the task text, with no prices, files or changes.'],
] },
],
},
teamHelp: {
member: ['Tap a planet with a price and press “Take it”. Up to two tasks at a time; locked planets open when the task before is approved.', 'Do the work (you or your agent), then “Send for review”. The team lead checks it against “Done when”.', 'Approved: you are paid into your wallet in Crystal and Iron. Every 4 approved tasks roll a bonus.', 'From $20 you can cash out; the team lead pays you outside Tasky and marks it paid.', 'Have an idea? “Suggest something” (the bulb). Your agent: send the first message from “Prompts ▾”.'],
owner: ['Give tasks a price (5, 10 or 20), a “Done when” and, if they wait for another, an “after”. Set the budget on the budget card.', 'Deliveries wait for you in “Waiting on you”: “Approve and pay”, “Check this” (with a note) or “Reject”.', 'Cash outs and suggestions also wait there. History (only you) shows everything that happened.', 'Agent time shows how long the agents of each person worked this week. It does not change pay.', 'Turn the Viewer link on in Team to let anyone look around without an account.'],
},
emails: {
title: 'Emails', notYet: 'Emails are not set up yet. Tasky works the same; the team lead can turn them on later.', ready: 'Emails are on for this team.',
memberToggle: 'Email me when a task of mine is approved, checked or rejected, when a suggestion becomes a task, when a payout is sent and when I roll a bonus',
ownerToggle: 'Email me a summary every morning when something waits for me', hour: 'Morning summary at (your time)',
onSaved: 'Emails on.', offSaved: 'Emails off.', hourSaved: (h) => 'The summary comes at ' + String(h).padStart(2, '0') + ':00.',
},
ownerHistory: {
title: 'History · only you see this', all: 'See all', none: 'Nothing yet.', byPerson: 'Person', byKind: 'Type', everyone: 'Everyone', everything: 'Everything',
text: 'Written by the server as things happen; it only grows. Members and viewers never see it.',
},
agentCard: {
workingTitle: 'An agent is working on this now', waitingTitle: 'Agent is waiting for an OK',
yours: 'your agent', whose: (n) => n + "'s agent", onIt: (n) => n + "'s agent is on it",
time: (x) => x + ' on this task', timeOnly: (x) => 'Agent time on this task: ' + x,
weekTitle: 'Agent time · this week', loading: 'Loading…', none: 'No agent time this week yet.',
counts: (a, t) => a + (a === 1 ? ' agent' : ' agents') + ' · ' + t + (t === 1 ? ' task' : ' tasks'),
note: 'Active time between signals; pauses of more than 5 minutes do not count. It does not change pay.',
},
wait: {
pos: (i, n) => i + ' of ' + n,
check: (o) => o + ' asked you to check something', yours: (t) => 'Your task · ' + t, inProgress: 'In progress',
emptyOwner: 'Nothing waits for you right now.', emptyMember: 'Nothing waits for you. Pick an open planet to take a task.',
},
mywork: {
mine: 'My tasks', next: 'Next available', locked: 'Locked', doneWhen: 'Done when',
none: { mine: 'You have no tasks yet. Take one from “Next available”.', next: 'Nothing open to take right now.', locked: 'Nothing is locked.' },
teamOnly: 'My work is part of Team mode.',
},
money: {
walletTitle: 'Your wallet', walletOf: (n) => n + "'s wallet", budgetTitle: (p) => (p || 'Project') + ' budget',
line: (w) => 'In review $' + w.inReview + ' · Paid so far $' + w.earned + ' · ' + (w.toBonus === 1 ? 'next approval rolls a bonus' : 'bonus roll after ' + w.toBonus + ' more'),
bonusLine: (w) => (4 - w.toBonus) + ' of 4 approved · ' + (w.toBonus === 1 ? 'the next one rolls a bonus' : w.toBonus + ' more to roll a bonus'),
cashOut: (n) => 'Cash out $' + n, asked: (o) => 'Cash out asked · ' + o + ' pays you', minimum: 'Cash out from $20', minimumWhy: 'You can cash out once your wallet reaches $20.',
askedNote: (o) => o + ' was notified. You get a notice when it is paid.', cashNote: (o) => 'Minimum $20. ' + o + ' pays you directly, outside Tasky, and marks it paid.',
ready: 'Ready to cash out', inReview: 'In review', earned: 'Paid so far', cashedOut: 'Cashed out',
statement: 'Statement', noRows: 'Nothing yet. You earn when a task you did is approved.',
rowBonus: (m) => 'Bonus · ' + m, rowEmpty: 'No bonus · the bonus pool is empty', rowCashout: 'Cash out paid',
howPay: 'Iron is $1, Crystal $2, Gold $5. A $5 task pays 2 Crystal + 1 Iron; $10, 4 Crystal + 2 Iron; $20, 8 Crystal + 4 Iron. Every 4 approved tasks roll a bonus: Iron 60%, Crystal 30%, Gold 10%. Tasky keeps the account; the money moves outside it.',
poolEmpty: 'The bonus pool is empty: no more bonuses for now.', poolEmptyOwner: 'The bonus pool is empty: nobody gets bonuses until you raise it.',
approved: (n, who) => 'Approved · $' + n + ' paid to ' + who + '.',
unpaid: (why, id, who) => 'Approved ' + id + '. No payment: ' + (why === 'already_paid' ? (who || 'they') + ' was already paid for it once.' : why === 'no_person' ? 'nobody had it.' : 'it has no reward.'), bonusGot: (who, m) => { m = String(m || ''); return who + ' also rolled a bonus: 1 ' + m.charAt(0).toUpperCase() + m.slice(1) + '.'; },
noBudget: 'No budget set yet. Open it to set one.', cashWaiting: (n) => n + (n === 1 ? ' cash out waits' : ' cash outs wait') + ' for you',
freeNegative: 'Free is below zero: approvals are blocked until you raise the budget.',
setTitle: 'Set the budget', total: 'Total ($)', pool: 'Bonus pool ($)', reserve: 'Reserve ($)', save: 'Save budget',
payouts: 'Payouts to send', noPayouts: 'No cash outs waiting.', markPaid: 'Mark as paid', askedAgo: (a) => 'asked ' + a,
payOutside: 'You pay outside Tasky. Marking it paid takes it out of their wallet and notifies them.',
people: 'Wallets', short: (w, mats) => w.approved + ' approved' + (mats ? ' · ' + mats : ''), csv: 'Export the statement (CSV)',
cashTitle: (who, amt) => 'Cash out · ' + who + ' · ' + amt, cashSub: 'Pay outside Tasky, then mark it paid',
},
merge: {
title: (p) => 'Merge into ' + p,
noProject: (n) => 'There is no project called “' + n + '” in this team. Only the team lead can add a project.',
rules: 'Your handoff never replaces the project: it only updates the status, notes, deliverables and files of your tasks and of free ones. New tasks go to Felipe as suggestions. Nothing is deleted.',
updatedRow: (id) => id + ' · updated',
suggestedRow: (x) => x + ' · sent as a suggestion',
ignoredRow: (id, why) => id + ' · ignored: ' + ({ notYours: 'not your task', locked: 'locked until the task before it is approved', two: 'you already have two tasks in progress' }[why] || why),
confirm: 'Merge it',
cancel: 'Cancel',
done: (u, sg, ig) => 'Handoff merged: ' + u + ' updated, ' + sg + ' sent as suggestions, ' + ig + ' ignored.',
failed: (e) => 'The handoff was not merged: ' + e,
},
agents: {
none: 'No agents yet. They show up here when the bridge finds your Claude Code sessions.',
noneToAssign: 'No agents yet. An agent shows up here when the Tasky bridge sees a Claude Code session on this computer.',
bridgeControls: 'The bridge sets this status from its Claude Code conversation: Working while it answers, Waiting while it is open. When the conversation closes, the agent leaves and comes back when you resume it.',
manualControls: 'Click to change: Working ↔ Waiting.',
back: (n) => n + ' is back',
seeYou: (n) => 'See you later, ' + n,
seeYouTeam: (a, n) => 'See you later, ' + a + ' (' + n + ' closed Tasky)',
ownerOf: (n) => 'Runs on ' + n + "'s computer",
theirs: (n) => n + "'s agent. Only " + n + ' can change it.',
foldersTitle: 'Folders linked to a project. When a Claude Code session starts in one of them, the agent gets a short Tasky briefing.',
foldersHint: 'When a Claude Code session starts in a linked folder (or inside it), the agent gets a short briefing: the project, your open tasks and how to tell Tasky what it does. Turn it off per folder.',
briefingTitle: 'Turn the session briefing on or off for this folder.',
briefingHint: (cwd, p) => 'Folder: ' + cwd + (p ? ' · project: ' + p : '') + '. This is the text a new Claude Code session receives when it starts here. It needs the bridge running and Tasky open; otherwise the session starts as usual, with nothing.',
briefingTemplate: 'Placeholders: {{person}}, {{project}}, {{tasks}} (your open tasks), {{tasky}} (the bridge command) and {{rules}}. Keep it short: it must fit on one screen.',
briefingNone: 'Nothing: this folder is not linked to a project, or its briefing is off.',
briefingSaved: 'Template saved. New sessions get the new text.',
knownTitle: 'Agents Tasky remembers from Claude Code conversations that are closed now. They come back by themselves when you resume the conversation.',
knownHint: 'Closed conversations Tasky remembers. Rename them, or forget them: a forgotten one is treated as new if it comes back.',
forgot: (n) => n + ' was forgotten. If that conversation comes back, Tasky will ask its name again.',
deleteBridge: (n) => 'Delete “' + n + '”? Its Claude Code session can be ended too.',
ending: (n) => n + ' was deleted. Ending its Claude Code session…',
ended: (n) => n + ' was deleted and its Claude Code session was ended.',
endedClosed: (n) => n + ' was deleted, its Claude Code session was ended and its terminal window was closed.',
endedLeftOpen: (n) => n + ' was deleted and its Claude Code session was ended. Its terminal window stays open: Tasky could not be sure it was used only for Claude Code.',
endFailed: (n, why) => n + ' was deleted, but its Claude Code session could not be ended' + (why ? ' (' + why + ')' : '') + '. It will not come back here.',
deletedOnly: (n) => n + ' was deleted. Its Claude Code session keeps running and will not come back here.',
quitTitle: 'Turn off the Tasky bridge on this computer. You will be asked first.',
quitAsk: 'Turn off the bridge? Your Claude Code sessions stop showing up here until you open Tasky again with its shortcut.',
quitWindow: 'This window keeps working until you close it.',
quitDone: 'The bridge is off. Tasky is in manual mode; open Tasky with its shortcut to turn the bridge on again.',
quitFailed: 'The bridge is still running (it may be an older version). Run node puente/iniciar.mjs --bridge-only: it replaces it with the current one. Or close its node.exe in the Task Manager.',
diag: {
version: (v) => 'Bridge ' + v,
oldVersion: 'Bridge (older version)',
sessions: (n) => n + (n === 1 ? ' session' : ' sessions'),
lastHook: (t, ev) => 'last hook at ' + t + (ev ? ' (' + ev + ')' : ''),
noHook: 'no hook has arrived yet',
old: 'This bridge is an older version and may not show everything. Run node puente/iniciar.mjs --bridge-only: it replaces it with the current one.',
hidden: (name, cwd, why) => 'Not shown: ' + name + (cwd ? ' (' + cwd + ')' : '') + ' — ' + why,
why: { deleted: 'you deleted its agent; it comes back in a new conversation.', viewer: 'a Viewer cannot add agents.', offline: 'Tasky is offline; it shows up when the connection comes back.', notAdded: 'Tasky could not add it.' },
denied: 'Chrome is blocking Tasky from reaching the bridge on this computer. Click the icon left of the address → Site settings → “Apps on device” (older Chrome: “Local network access”) → Allow. Then reload the page.',
refused: (origin) => 'The bridge on this computer does not accept ' + origin + '. Start it again with node puente/iniciar.mjs --bridge-only (it accepts the team app address), or open Tasky at its usual address.',
},
},
tools: {
bridge: { name: 'Bridge', text: 'When the Tasky bridge runs on this computer (start it with “Iniciar Tasky.bat”), each Claude Code conversation appears here by itself: Working while it answers, Waiting while it is open. When it closes the agent leaves, and it comes back with its name and task when you resume the conversation. Off means manual mode; nothing breaks.' },
drop: { name: 'Drop handoff', text: 'Bring in a .tasky.md from your agent. Click to pick one or more files; you can also drop them anywhere on the window or paste the text with Ctrl+V.' },
check: { name: 'Check project data', text: 'What a handoff left half done: tasks in progress with nobody, without a reward, not Ready, not in English, without Steps or tests, or "after" a task that does not exist.' },
template: { name: 'Template for your agent', text: 'The blank .tasky.md your agent fills in, with an instruction ready to paste. Click to see, copy or download it.' },
export: { name: 'Export', text: 'Download your projects as .tasky.md files to give them back to your agent. With several projects you choose which.' },
backup: { name: 'Backup', text: 'Download all of Tasky in one .json file (projects, history, agents and views), or restore one.' },
help: { name: 'Help', text: 'The four steps of Tasky and everything you can touch.' },
team: { name: 'Team', text: 'Invite people, change their role or remove them, and bring your projects from Personal mode.' },
live: { name: 'Live', text: 'What every agent is doing right now, live, with the color of the person who runs it.' },
ship: { name: 'Edit my ship', text: 'Pick the shape and the color of your ship.' },
report: { name: 'Report', text: 'What got done in a period, by workflow and by task, ready to copy or download.' },
suggest: { name: 'Suggest something', text: 'A task, a design idea, a color, anything. The team lead reviews it and may turn it into a paid task.' },
chat: { name: 'Chat', text: 'Talk with the team: the Team channel and direct messages. Text, images and files.' },
ranks: { name: 'Leaderboard', text: 'Levels and the team leaderboard: tasks approved, by universe and by workflow, and approved at the first try.' },
news: { name: 'Announcements', text: 'News from the team lead. Only the team lead publishes.' },
history: { name: 'History', text: 'Every change, newest first: statuses, handoffs, names, archives, agents. Who did it, when, and Undo where it is safe.' },
archived: { name: 'Archived', text: 'Projects you archived. Click to see them and bring them back.' },
},
views: {
universe: 'Universe: one galaxy per workflow, one planet per task, in 3D. Drag to look around; click a star or a planet.',
orbit: 'Orbit: one solar system per workflow, with its tasks around a planet that shows how many are left.',
garden: 'Garden: one flower per workflow; every petal is a task.',
now: 'Now: every project at a glance. What waits for you, what is in progress and what got done this week.',
list: 'List: the whole project as a simple list, by workflow. For when you are in a hurry.',
own: 'A view you uploaded. Click to use it; the ✕ next to it deletes it.',
add: 'Upload your own .tasky-layout.html view, or drop it on the window. Start from the example.',
},
panel: {
title: 'Help',
intro: 'Tasky reads the handoffs your agents write and shows where each project stands.',
steps: [
['Give the template to your agent', 'Use “Template for your agent”: it sends back one .tasky.md per project, with workflows and tasks.'],
['Drop the handoff', 'Drag it onto the window, pick it with “Drop handoff” or paste it with Ctrl+V.'],
['Look and touch', 'Click a task to move it forward. Each workflow fills up by itself as its tasks get done.'],
['Save or send back', 'Export the updated .tasky.md for your agent, or make a backup of everything.'],
],
touchTitle: 'What you can touch',
touch: [
'A task: moves it forward (To do → In progress → Done).',
'The status buttons in the card: put a task in any status. Needs you means it waits for something from you; Blocked is a technical problem.',
'Waiting on you, at the top left: every task that needs you, in every project. Click one to go to it.',
'A workflow: shows its mission and its tasks.',
'The project name: rename, archive, delete or see its handoff.',
'Universe, Orbit and Garden: change the view at any time; your data stays the same.',
'In Universe a click selects a planet; change its status from this card.',
],
ownTitle: 'Your own view',
ok: 'Got it',
},
};
// Prompt 021 (G8): la ayuda del modo Team, visual como la guía: iconos grandes, una frase por idea, en cinco partes.
const HI = {
galaxy: '',
planet: '$',
ship: '',
agent: '',
lock: '',
read: '',
take: '',
deliver: '',
review: '',
coin: '$',
gift: '',
cash: '',
prompt: '',
eye: '',
crown: '',
person: '',
};
function teamHelp(root) {
const H = TEXT.helpTeam;
const icon = (k) => { const sv = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); sv.setAttribute('viewBox', '0 0 48 48'); sv.setAttribute('aria-hidden', 'true'); sv.innerHTML = HI[k]; return sv; };
for (const sec of H.sections) {
const grid = Shell.el('div', { className: 'hgrid' + (sec.steps ? ' hsteps' : '') });
sec.items.forEach(([k, title, line], i) => grid.append(Shell.el('div', { className: 'hcard' + (sec.steps ? ' st' + (i + 1) : ''), data: { key: 'help-item|' + k } }, icon(k), Shell.el('b', { textContent: title }), Shell.el('span', { textContent: line }))));
root.append(Shell.el('section', { className: 'hsec', data: { key: 'help-sec|' + sec.key } }, Shell.el('h3', { textContent: sec.title }), grid));
}
}
function open() {
const P = TEXT.panel;
const root = Shell.openPanel('help', P.title);
if (typeof Team !== 'undefined' && Team.on() && Team.role() !== 'viewer' && Team.isEntered()) {
root.classList.add('wide');
root.append(Shell.el('div', { className: 'pn-actions' }, Shell.btn('Please read this · How tasks and pay work', () => Shell.openGuide(), { className: 'primary', data: { key: 'help-guide' } })));
teamHelp(root);
const ok = Shell.btn(P.ok, () => Shell.closePanel(), { className: 'primary' });
root.append(Shell.el('h3', { textContent: 'Something wrong?' }), Shell.el('p', { textContent: TEXT.report.hint }),
Shell.el('div', { className: 'pn-actions' }, Shell.btn('Report a problem', () => Shell.reportProblem(), { data: { key: 'report-problem' } })), Shell.el('div', { className: 'pn-actions' }, ok));
ok.focus();
return;
}
// E11: en Team, la guía «Please read this» queda siempre aquí
if (typeof Team !== 'undefined' && Team.on() && Team.role() !== 'viewer') {
root.append(Shell.el('div', { className: 'pn-actions' }, Shell.btn('Please read this · How tasks and pay work', () => Shell.openGuide(), { className: 'primary', data: { key: 'help-guide' } })));
const tl = Shell.el('ul', { data: { key: 'help-team' } });
for (const line of TEXT.teamHelp[Team.role() === 'owner' ? 'owner' : 'member']) tl.append(Shell.el('li', { textContent: line }));
root.append(Shell.el('h3', { textContent: 'In this team' }), tl);
}
const steps = Shell.el('ol', { className: 'steps' });
P.steps.forEach(([title, body], i) => steps.append(Shell.el('li', {}, Shell.el('span', { className: 'n', textContent: i + 1, ariaHidden: 'true' }),
Shell.el('div', {}, Shell.el('strong', { textContent: title }), Shell.el('p', { textContent: body })))));
const touch = Shell.el('ul', {});
for (const line of P.touch) touch.append(Shell.el('li', { textContent: line }));
const legend = Shell.el('div', { className: 'pn-actions' });
for (const st of Store.statusList()) legend.append(Shell.el('span', { className: 'pn-item' }, Shell.ico(st, '#2EC4B6'), TEXT.status[st]));
const ok = Shell.btn(P.ok, () => Shell.closePanel(), { className: 'primary' });
root.append(Shell.el('p', { textContent: P.intro }), steps, Shell.el('h3', { textContent: P.touchTitle }), touch, legend,
Shell.el('h3', { textContent: P.ownTitle }), Shell.el('p', { className: 'phrase', textContent: TEXT.layoutPhrase + '…' }),
Shell.el('div', { className: 'pn-actions' }, Shell.btn('Download the example view', () => Shell.downloadExampleLayout()), Shell.btn('Copy the request', async () => Shell.copied(await Shell.copyText(TEXT.layoutPhrase), 'Request'))),
Shell.el('h3', { textContent: 'Something wrong?' }), Shell.el('p', { textContent: TEXT.report.hint }),
Shell.el('div', { className: 'pn-actions' }, Shell.btn('Report a problem', () => Shell.reportProblem(), { data: { key: 'report-problem' } })),
Shell.el('div', { className: 'pn-actions' }, ok));
ok.focus();
}
return { TEXT, open };
})();
if (typeof document !== 'undefined' && document.getElementById('app')) Shell.start();