Mine
Everyone
Tasks
Done
New chore
Who's using this phone?
Stays on this device, so the app opens straight to your chores.
Close
Settings
Board name
Family
Add person
Sync
Checking…
Connect a free Supabase board
Project URL
Anon public key
Board code
Everyone in the family enters the same three values.
Disconnect
Connect
Personal links
Send each person their own link. Opening it tells that phone who it
belongs to, so their name sticks even if the browser forgets everything else.
Notifications
Tell me when someone finishes a chore
Done
]*>)[\s\S]*?(<\/script>)/i,
function (m, open, close) { return open + json + close; }
);
}
/* ---------- relay: the board lives on our own origin ---------- */
/* A brand new phone has no board yet, so the name in the link matches nobody
on the first try. Keep it and retry once the board arrives. */
var pendingIdentity = null;
function applyPendingIdentity() {
if (!pendingIdentity || D.me) return false;
var claimed = matchMember(pendingIdentity);
if (!claimed) return false;
pendingIdentity = null;
D.me = claimed.id;
saveDevice();
render();
toast("Signed in as " + esc(claimed.name));
return true;
}
function relayCfg() {
var c = window.CHOREWALL_CONFIG;
if (!c || !c.relay || !c.board) return null;
return { base: String(c.relay).replace(/\/+$/, ""), board: String(c.board) };
}
function relayPull() {
var c = relayCfg();
return fetch(c.base + "/board?board=" + encodeURIComponent(c.board), {
headers: { "Accept": "application/json" }
}).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
}).then(function (d) { return d && d.data ? d.data : null; });
}
function relayPush(board) {
var c = relayCfg();
return fetch(c.base + "/board", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ board: c.board, data: board })
}).then(function (r) {
if (!r.ok) return r.text().then(function (t) { throw new Error(t || ("HTTP " + r.status)); });
});
}
function supaHeaders() {
return {
"apikey": D.supa.key,
"Authorization": "Bearer " + D.supa.key,
"Content-Type": "application/json"
};
}
function supaPull() {
var c = D.supa;
var url = c.url.replace(/\/+$/, "") + "/rest/v1/boards?id=eq." +
encodeURIComponent(c.board) + "&select=data";
return fetch(url, { headers: supaHeaders() }).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
}).then(function (rows) {
return rows && rows[0] ? rows[0].data : null;
});
}
function supaPush(board) {
var c = D.supa;
var url = c.url.replace(/\/+$/, "") + "/rest/v1/boards";
var h = supaHeaders();
h["Prefer"] = "resolution=merge-duplicates,return=minimal";
return fetch(url, {
method: "POST",
headers: h,
body: JSON.stringify({ id: c.board, data: board, updated_at: nowISO() })
}).then(function (r) {
if (!r.ok) return r.text().then(function (t) { throw new Error(t || ("HTTP " + r.status)); });
});
}
function setSync(state, message) {
Sync.state = state;
Sync.message = message;
paintSync();
}
/* Pull remote, merge into ours, re-render if anything moved. */
function pull() {
if (Sync.mode !== "supabase" && Sync.mode !== "relay") return Promise.resolve();
var fetchRemote = Sync.mode === "relay" ? relayPull : supaPull;
return fetchRemote().then(function (remote) {
if (!remote) return;
var before = JSON.stringify(S);
S = mergeBoards(S, remote);
writeLocal(LS_BOARD, S);
if (JSON.stringify(S) !== before) { render(); announceNew(); }
applyPendingIdentity();
setSync("ok", "Synced with the family");
}).catch(function (err) {
setSync("error", "Can't reach the board — " + (err.message || "offline"));
});
}
/* Persist locally now, share it a beat later. */
function commit() {
S.v = SCHEMA;
writeLocal(LS_BOARD, S);
render();
if (Sync.timer) clearTimeout(Sync.timer);
if (Sync.mode === "local") return;
Sync.timer = setTimeout(push, Sync.mode === "artifact" ? 900 : 500);
}
function push() {
if (Sync.mode === "relay") {
setSync("syncing", "Saving…");
return relayPull().then(function (remote) {
S = mergeBoards(S, remote);
writeLocal(LS_BOARD, S);
return relayPush(S);
}).then(function () {
setSync("ok", "Synced with the family");
render();
}).catch(function (err) {
setSync("error", "Saved here, not shared yet — " + (err.message || "offline"));
});
}
if (Sync.mode === "supabase") {
setSync("syncing", "Saving…");
return supaPull().then(function (remote) {
S = mergeBoards(S, remote);
writeLocal(LS_BOARD, S);
return supaPush(S);
}).then(function () {
setSync("ok", "Synced with the family");
render();
}).catch(function (err) {
setSync("error", "Saved here, not shared yet — " + (err.message || "offline"));
});
}
if (Sync.mode === "artifact" && Sync.artifact) {
setSync("syncing", "Saving to the shared board…");
return Sync.artifact.publish(buildDoc(S)).then(function () {
setSync("ok", "Shared with everyone on the link");
}).catch(function (err) {
var code = (err && (err.code || err.name)) || "";
if (code === "conflict") return; // someone published first; every view reloads
if (code === "not_granted" || code === "not_writer") {
setSync("readonly", "View only — ask the board owner for edit access");
} else {
setSync("error", "Saved here, not shared yet");
}
});
}
return Promise.resolve();
}
/* ---------- rendering ---------- */
function avHTML(m, extra) {
var cls = "av" + (extra ? " " + extra : "");
if (!m) return '· ';
return '' + esc(initials(m.name)) + " ";
}
function icon(id, style) {
return ' ';
}
function pointsThisWeek() {
var start = weekStart().toISOString();
var totals = Object.create(null);
live(S.log).forEach(function (e) {
if (e.at >= start && e.by && e.kind !== "passed") {
totals[e.by] = (totals[e.by] || 0) + (e.points || 0);
}
});
return totals;
}
/* Who has done the most this week. With every chore worth the same, this is
simply a count of tasks, which is what people actually argue about. */
function tasksThisWeek() {
var start = weekStart().toISOString();
var n = Object.create(null);
live(S.log).forEach(function (e) {
if (e.at >= start && e.by && e.kind !== "passed") n[e.by] = (n[e.by] || 0) + 1;
});
return n;
}
function paintStrip() {
var strip = $("#scoreStrip");
var mem = members();
if (!mem.length) { strip.innerHTML = ""; return; }
var done = tasksThisWeek();
var ranked = mem.slice().sort(function (a, b) {
var byCount = (done[b.id] || 0) - (done[a.id] || 0);
return byCount !== 0 ? byCount : (a.order || 0) - (b.order || 0);
});
var top = done[ranked[0].id] || 0;
strip.innerHTML = ranked.map(function (m, i) {
var n = done[m.id] || 0;
var lead = (top > 0 && n === top ? " leader" : "") + (m.id === D.me ? " me" : "");
return '' +
'' + (i + 1) + " " +
avHTML(m) +
'' + esc(firstName(m)) + " " +
'' + n + "
";
}).join("");
}
var DAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function chosenDays() {
return $$("#fDays button").filter(function (b) {
return b.getAttribute("aria-pressed") === "true";
}).map(function (b) { return +b.dataset.d; });
}
function paintDays(days, repeat) {
var set = days || [];
$$("#fDays button").forEach(function (b) {
b.setAttribute("aria-pressed", String(set.indexOf(+b.dataset.d) >= 0));
});
$("#fDaysField").hidden = (repeat !== "weekly");
}
/* The soonest date on or after `from` that falls on one of `days`. */
function firstMatchingDay(days, from) {
var d = new Date(from);
for (var i = 0; i < 14; i++) {
if (days.indexOf(d.getDay()) >= 0) return keyOf(d);
d.setDate(d.getDate() + 1);
}
return keyOf(from);
}
function daysLabel(days) {
if (!days || !days.length) return null;
if (days.length === 7) return "every day";
var order = [1, 2, 3, 4, 5, 6, 0];
return order.filter(function (d) { return days.indexOf(d) >= 0; })
.map(function (d) { return DAY_SHORT[d]; }).join(", ");
}
/* A chore may belong to several people. Older records carry a single
`assignee`, so read through this and both shapes work. */
function assigneesOf(c) {
if (c.assignees && c.assignees.length) return c.assignees;
return c.assignee ? [c.assignee] : [];
}
function isAssignedTo(c, memberId) {
return !!memberId && assigneesOf(c).indexOf(memberId) >= 0;
}
function setAssignees(c, ids) {
c.assignees = ids.slice();
c.assignee = ids.length ? ids[0] : null; // keeps older readers working
}
var REPEAT_LABEL = {
daily: "daily", weekly: "weekly",
fortnightly: "every 2 weeks", monthly: "monthly"
};
/* Chips use a short name. That is the first word for "Matthew Tan", but not
every name is first-plus-surname, so a member may carry an explicit short
form and it wins. */
function firstName(m) {
if (!m) return "";
if (m.short) return String(m.short).trim();
return String(m.name || "").trim().split(/\s+/)[0] || "Unnamed";
}
/* Notes are written one step per line and shown as bullets, so a chore can
say what actually needs doing rather than just naming itself. */
function noteHTML(note) {
var lines = String(note || "").split(/\r?\n/)
.map(function (s) { return s.replace(/^\s*[-*\u2022]\s*/, "").trim(); })
.filter(Boolean);
if (!lines.length) return "";
if (lines.length === 1) return '' + esc(lines[0]) + "
";
return '' +
lines.map(function (l) { return "" + esc(l) + " "; }).join("") +
" ";
}
function choreHTML(c, hideAssignee, hideDue) {
var m = c.assignee ? memberById(c.assignee) : null;
var due = dueInfo(c.due);
var meta = [];
// Under a person heading the name is already obvious, so leave it out.
if (!hideAssignee) {
var who = assigneesOf(c).map(memberById).filter(Boolean);
if (!who.length) {
meta.push('' + avHTML(null) + "Anyone ");
} else {
who.forEach(function (p) {
meta.push('' + avHTML(p) + esc(firstName(p)) + " ");
});
}
}
if (due && !hideDue) meta.push('' + esc(due.text) + " ");
if (c.repeat && c.repeat !== "none") {
meta.push('' + icon("i-repeat") +
esc(daysLabel(c.days) || REPEAT_LABEL[c.repeat] || c.repeat) + " ");
}
if (c.points > 1) { // every chore counts as one
meta.push('' + c.points + " pts ");
}
if (c.passedBy) {
meta.push('Passed on by ' +
esc(firstName(memberById(c.passedBy)) || "someone") + " ");
}
// One tap to hand a chore back, one tap for anyone else to pick it up.
if (isAssignedTo(c, D.me)) {
meta.push('Can’t do it ');
} else if (D.me) {
meta.push('I’ll take it ');
}
var cls = "chore";
if (due && due.late) cls += " is-late";
if (c.passedBy) cls += " is-open";
return '' +
'' +
icon("i-check") +
" " +
'' +
'
' + esc(c.title) + "
" +
noteHTML(c.note) +
'
' + meta.join("") + "
" +
"
" +
'' +
icon("i-pencil") +
" " +
" ";
}
function groupHTML(label, items, late) {
if (!items.length) return "";
return '' +
'' + esc(label) +
'
' +
items.map(choreHTML).join("") +
" ";
}
function emptyHTML(title, body) {
return '' + icon("i-board") +
"
" + esc(title) + " " + esc(body) + "
";
}
/* Under a person, break their chores into one heading per date. The weekday
always shows, so the block reads as Monday / Tuesday / Wednesday. */
function dayHeading(key) {
if (key === "none") return "No date";
var d = parseKey(key);
var stamp = d.getDate() + " " + MONTHS[d.getMonth()];
var diff = daysApart(key, todayKey());
if (diff < 0) return "Late \u00b7 " + DAY_SHORT[d.getDay()] + " " + stamp;
if (diff === 0) return "Today \u00b7 " + DAY_SHORT[d.getDay()] + " " + stamp;
if (diff === 1) return "Tomorrow \u00b7 " + DAY_SHORT[d.getDay()] + " " + stamp;
return WEEKDAYS[d.getDay()] + " \u00b7 " + stamp;
}
function daySubgroups(rows, hideAssignee) {
var order = [], seen = Object.create(null);
rows.forEach(function (c) {
var k = c.due || "none";
if (!seen[k]) { seen[k] = []; order.push(k); }
seen[k].push(c);
});
return order.map(function (k) {
var cls = "day-sub";
if (k !== "none" && k < todayKey()) cls += " late";
else if (k === todayKey()) cls += " today";
return '' + esc(dayHeading(k)) + "
" +
seen[k].map(function (c) { return choreHTML(c, hideAssignee !== false, true); }).join("");
}).join("");
}
/* The Everyone tab reads as a rota: one block per person, you first. */
function personGroupHTML(m, rows, pts, isMe) {
return '' +
'' +
avHTML(m) +
'' + esc(m ? m.name : "Up for grabs") + " " +
(isMe ? 'you ' : "") +
' ' +
(m ? '' + pts + " pts " : "") +
"
" +
(rows.length
? daySubgroups(rows)
: 'Nothing left this week
') +
" ";
}
function renderByPerson(list, items) {
var totals = pointsThisWeek();
var ordered = members().slice().sort(function (a, b) {
if (a.id === D.me) return -1; // whoever's phone this is comes first
if (b.id === D.me) return 1;
return (a.order || 0) - (b.order || 0);
});
var html = ordered.map(function (m) {
var theirs = items.filter(function (c) { return isAssignedTo(c, m.id); }).sort(bySort);
return personGroupHTML(m, theirs, totals[m.id] || 0, m.id === D.me);
}).join("");
var loose = items.filter(function (c) { return !assigneesOf(c).length; }).sort(bySort);
if (loose.length) html += personGroupHTML(null, loose, 0, false);
list.innerHTML = html;
}
/* The Tasks tab is the whole week's work in one list, whoever it belongs to. */
function tasksHeaderHTML() {
var all = chores();
var free = all.filter(function (c) { return !c.assignee; }).length;
var taken = all.length - free;
var bits = [all.length + (all.length === 1 ? " task" : " tasks")];
if (free) bits.push(free + " still unclaimed");
if (taken) bits.push(taken + " assigned");
return '' +
'
All tasks
' +
'
' + bits.join(" \u00b7 ") + "
";
}
function mineHeaderHTML() {
var m = me();
if (!m) {
return '' + avHTML(null, "lg") +
'
Who are you?
' +
'
Tap your name at the top so your chores count for you.
';
}
var all = chores();
var mine = all.filter(function (c) { return c.assignee === m.id; }).length;
var free = all.filter(function (c) { return !c.assignee; }).length;
var bits = [mine + (mine === 1 ? " chore is yours" : " chores are yours")];
if (free) bits.push(free + " unclaimed in Tasks");
return '' + avHTML(m, "lg") +
'
' + esc(m.name) + "
" +
'
' + bits.join(" \u00b7 ") + "
" +
'
you ';
}
/* "Mine" is strictly the chores with your name on them, so it matches your
block on the Everyone tab exactly. Unclaimed work lives on the Tasks tab. */
function visibleChores() {
var all = chores();
if (D.tab === "mine") {
return all.filter(function (c) { return isAssignedTo(c, D.me); });
}
return all;
}
function bySort(a, b) {
if (!a.due && b.due) return 1;
if (a.due && !b.due) return -1;
if (a.due !== b.due) return a.due < b.due ? -1 : 1;
return a.title.localeCompare(b.title);
}
function renderList() {
var list = $("#list");
if (D.tab === "done") {
var entries = live(S.log).filter(function (e) { return e.kind !== "passed"; }).sort(function (a, b) { return a.at < b.at ? 1 : -1; }).slice(0, 60);
if (!entries.length) {
list.innerHTML = emptyHTML("Nothing ticked off yet", "Finished chores land here, with who did them and when.");
return;
}
var buckets = [], seen = Object.create(null);
entries.forEach(function (e) {
var k = keyOf(new Date(e.at));
if (!seen[k]) { seen[k] = []; buckets.push({ key: k, rows: seen[k] }); }
seen[k].push(e);
});
list.innerHTML = buckets.map(function (b) {
var diff = daysApart(b.key, todayKey());
var label = diff === 0 ? "Today" : diff === -1 ? "Yesterday" :
parseKey(b.key).getDate() + " " + MONTHS[parseKey(b.key).getMonth()];
return '' + esc(label) +
'
' + b.rows.map(function (e) {
var m = memberById(e.by);
var when = new Date(e.at);
return '' + avHTML(m) +
'' + esc(e.title) + " " +
'' + pad(when.getHours()) + ":" + pad(when.getMinutes()) + " " +
'Undo
';
}).join("") + " ";
}).join("");
return;
}
var items = visibleChores();
if (!members().length && !items.length) {
list.innerHTML = emptyHTML("Add your family first",
"Open settings and add everyone who'll be doing chores, then start assigning.");
return;
}
if (!items.length) {
if (D.tab === "mine") {
list.innerHTML = mineHeaderHTML() +
emptyHTML("Nothing assigned to you",
"Open Tasks to pick something up, or ask whoever runs the board.");
} else if (D.tab === "tasks") {
list.innerHTML = emptyHTML("No tasks yet", "Tap New chore to put the first job up.");
} else {
list.innerHTML = emptyHTML("The board is empty", "Tap New chore to put the first job up.");
}
return;
}
if (D.tab === "all" && members().length) {
renderByPerson(list, items);
return;
}
if (D.tab === "mine" || D.tab === "tasks") {
var byDay = items.slice().sort(bySort);
list.innerHTML =
(D.tab === "mine" ? mineHeaderHTML() : tasksHeaderHTML()) +
'' +
daySubgroups(byDay, D.tab === "mine") + // Tasks keeps the name visible
" ";
return;
}
var t = todayKey();
var wk = weekendRange();
var overdue = [], today = [], weekend = [], later = [], someday = [];
items.forEach(function (c) {
if (!c.due) someday.push(c);
else if (c.due < t) overdue.push(c);
else if (c.due === t) today.push(c);
else if (c.due === wk.sat || c.due === wk.sun) weekend.push(c);
else later.push(c);
});
[overdue, today, weekend, later, someday].forEach(function (g) { g.sort(bySort); });
list.innerHTML =
(D.tab === "mine" ? mineHeaderHTML() : "") +
(D.tab === "tasks" ? tasksHeaderHTML() : "") +
groupHTML("Overdue", overdue, true) +
groupHTML("Today", today) +
groupHTML("This weekend", weekend) +
groupHTML("Coming up", later) +
groupHTML("No date", someday);
}
function paintSync() {
var dot = $("#syncDot"), text = $("#syncText");
if (!dot) return;
dot.className = "dot " + (Sync.state === "ok" ? "on" : Sync.state === "error" ? "err" : "");
text.textContent = Sync.message;
}
function render() {
var d = new Date();
$("#familyName").textContent = S.name || "Chore Wall";
$("#todayLine").textContent = WEEKDAYS[d.getDay()] + " " + d.getDate() + " " + MONTHS[d.getMonth()];
var m = me();
$("#whoAv").outerHTML = avHTML(m).replace('class="av', 'id="whoAv" class="av');
$("#whoName").textContent = m ? firstName(m) : "Who are you?";
$("#whoBtn").classList.toggle("identified", !!m);
var all = chores(), t = todayKey();
var mineDue = all.filter(function (c) {
return c.assignee === D.me && (!c.due || c.due <= t);
}).length;
var unclaimed = all.filter(function (c) { return !c.assignee; }).length;
$("#cMine").textContent = mineDue || "";
$("#cAll").textContent = all.length || "";
$("#cAll").title = unclaimed + " unclaimed";
$$("#tabs button").forEach(function (b) {
b.setAttribute("aria-selected", String(b.dataset.tab === D.tab));
});
paintStrip();
renderList();
paintSync();
}
/* ---------- toast ---------- */
var toastTimer = null;
function toast(html) {
var el = $("#toast");
el.innerHTML = html;
el.classList.add("show");
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () { el.classList.remove("show"); }, 2600);
}
/* ---------- chore actions ---------- */
function findChore(id) {
var c = (S.chores || []).filter(function (x) { return x.id === id; });
return c[0] || null;
}
function completeChore(id) {
var c = findChore(id);
if (!c || c.deleted) return;
var actor = D.me || c.assignee || null;
var stamp = nowISO();
S.log.push({
id: uid(), choreId: c.id, title: c.title, by: actor,
kind: "done", points: c.points || 0, at: stamp, updatedAt: stamp,
prevDue: c.due || null, wasRepeat: c.repeat || "none"
});
if (c.repeat && c.repeat !== "none") c.due = nextDue(c);
else c.deleted = true;
c.updatedAt = stamp;
var who = memberById(actor);
toast(esc(c.title) + " done" + (c.points ? ' +' + c.points + " " : "") +
(who ? " · " + esc(firstName(who)) : ""));
broadcast((firstName(who) || "Someone") + " finished " + c.title,
c.points ? "+" + c.points + " points" : "");
commit();
}
/* Hand a chore back to the family. It becomes unassigned, so it shows up
in everyone else's Mine tab until someone takes it. */
function passChore(id) {
var c = findChore(id);
if (!c || c.deleted) return;
var stamp = nowISO();
S.log.push({
id: uid(), choreId: c.id, title: c.title, by: D.me || null,
kind: "passed", points: 0, at: stamp, updatedAt: stamp
});
var left = assigneesOf(c).filter(function (id) { return id !== D.me; });
setAssignees(c, left);
c.passedBy = left.length ? null : (D.me || null); // only "up for grabs" if nobody is left
c.passedAt = stamp;
c.updatedAt = stamp;
toast(esc(c.title) + " is up for grabs");
broadcast((firstName(me()) || "Someone") + " can't do " + c.title, "It's up for grabs");
commit();
}
function claimChore(id) {
var c = findChore(id);
if (!c || c.deleted) return;
if (!D.me) { openWho(); return; }
var now = assigneesOf(c);
if (now.indexOf(D.me) < 0) now = now.concat([D.me]);
setAssignees(c, now);
c.passedBy = null;
c.passedAt = null;
c.updatedAt = nowISO();
toast("You've taken " + esc(c.title));
commit();
}
function undoLog(logId) {
var e = (S.log || []).filter(function (x) { return x.id === logId; })[0];
if (!e) return;
e.deleted = true;
e.updatedAt = nowISO();
var c = findChore(e.choreId);
if (c) {
c.deleted = false;
c.due = e.prevDue || null;
c.updatedAt = nowISO();
}
toast("Put " + esc(e.title) + " back on the board");
commit();
}
/* ---------- chore dialog ---------- */
/* A chore shared by several people who do it on different days is stored as
one record per person, linked by groupId. Every view then treats them as
ordinary single-owner chores, and only the editor knows they belong together. */
function groupKey(c) { return c.groupId || c.id; }
function groupMembers(c) {
var key = groupKey(c);
return chores().filter(function (x) { return groupKey(x) === key; });
}
function perDayRowHTML(m, days) {
var ORDER = [1, 2, 3, 4, 5, 6, 0];
return '' +
'
' + avHTML(m) + '' + esc(m.name) + "
" +
'
' + ORDER.map(function (d) {
return '' + DAY_SHORT[d].charAt(0) + " ";
}).join("") + "
";
}
function paintPerDays(chosen, existing) {
var wrap = $("#fPerDays");
wrap.innerHTML = chosen.map(function (id) {
var m = memberById(id);
if (!m) return "";
var days = (existing && existing[id]) || [];
return perDayRowHTML(m, days);
}).join("");
}
function readPerDays() {
var out = {};
$$("#fPerDays .per-day").forEach(function (row) {
out[row.dataset.who] = $$("button[data-d]", row)
.filter(function (b) { return b.getAttribute("aria-pressed") === "true"; })
.map(function (b) { return +b.dataset.d; });
});
return out;
}
/* Show the shared picker for one owner, the split picker for several. */
function syncAssignUI() {
var chosen = $$("#fAssignee button[aria-pressed='true']")
.map(function (b) { return b.dataset.id; })
.filter(function (id) { return !!id; });
var repeat = ($("#fRepeat button[aria-pressed='true']") || { dataset: {} }).dataset.v || "none";
var many = chosen.length > 1 && repeat === "weekly";
$("#fDaysField").hidden = (repeat !== "weekly") || many;
$("#fPerDaysField").hidden = !many;
if (many) paintPerDays(chosen, editingPerDays);
return chosen;
}
var editingId = null;
var editingPerDays = {}; // memberId -> weekdays, while the editor is open
var pendingDone = null; // a tick waiting on "who are you?"
function openChore(id) {
editingId = id || null;
var c = id ? findChore(id) : null;
var mem = members();
$("#choreDlgTitle").textContent = c ? "Edit chore" : "New chore";
$("#fTitle").value = c ? c.title : "";
$("#fNote").value = c ? (c.note || "") : "";
$("#fDue").value = c ? (c.due || "") : defaultDue();
// points are fixed at 1; the leaderboard counts tasks, not weights
$("#choreDelete").hidden = !c;
var pickRepeat = c ? (c.repeat || "none") : "weekly";
$$("#fRepeat button").forEach(function (b) {
b.setAttribute("aria-pressed", String(b.dataset.v === pickRepeat));
});
// default to the weekday the chore already falls on
var startDays = c && c.days && c.days.length ? c.days
: [parseKey($("#fDue").value || todayKey()).getDay()];
paintDays(startDays, pickRepeat);
syncAssignUI();
var group = c ? groupMembers(c) : [];
editingPerDays = {};
var picked = [];
group.forEach(function (g) {
assigneesOf(g).forEach(function (id) {
if (picked.indexOf(id) < 0) picked.push(id);
editingPerDays[id] = g.days || [];
});
});
if (!c && D.me) picked = [D.me];
$("#fAssignee").innerHTML =
'Anyone ' +
mem.map(function (m) {
return '' + avHTML(m) + esc(m.name) + " ";
}).join("");
$("#choreDlg").showModal();
setTimeout(function () { $("#fTitle").focus(); }, 60);
}
/* Write the chore. With two or more owners on different days it becomes one
record each, linked by groupId, so every other view stays simple. */
function saveGroup(base, people, perDays, sharedDays) {
var stamp = nowISO();
var existing = editingId ? groupMembers(findChore(editingId)) : [];
var key = existing.length ? groupKey(existing[0]) : uid();
function shape(rec, who, days) {
rec.groupId = key;
rec.title = base.title;
rec.note = base.note;
rec.repeat = base.repeat;
rec.points = 1;
rec.days = days && days.length ? days : null;
setAssignees(rec, who ? [who] : []);
rec.deleted = false;
rec.updatedAt = stamp;
if (rec.days && rec.days.length) {
// the soonest chosen day from today, so picking Wednesday on a Monday
// lands this Wednesday rather than jumping a week
rec.due = firstMatchingDay(rec.days, midnight());
} else if (!rec.due || !editingId) {
rec.due = base.due;
}
return rec;
}
var wanted = people.length ? people : [null];
var used = [];
wanted.forEach(function (who) {
var days = who && perDays ? perDays[who] : sharedDays;
var rec = existing.filter(function (x) {
return used.indexOf(x.id) < 0 && (who ? isAssignedTo(x, who) : !assigneesOf(x).length);
})[0];
if (!rec) {
rec = existing.filter(function (x) { return used.indexOf(x.id) < 0; })[0];
}
if (!rec) {
rec = { id: uid(), createdAt: stamp, due: base.due };
S.chores.push(rec);
}
used.push(rec.id);
shape(rec, who, days);
});
// anyone dropped from the chore loses their copy
existing.forEach(function (x) {
if (used.indexOf(x.id) < 0) { x.deleted = true; x.updatedAt = stamp; }
});
}
function saveChore() {
var title = $("#fTitle").value.trim();
if (!title) { $("#fTitle").focus(); return false; }
var chosen = $$("#fAssignee button[aria-pressed='true']")
.map(function (b) { return b.dataset.id; })
.filter(function (id) { return !!id; });
var repeat = ($("#fRepeat button[aria-pressed='true']") || { dataset: {} }).dataset.v || "none";
var base = {
title: title,
note: $("#fNote").value.trim(),
repeat: repeat,
due: $("#fDue").value || null
};
var split = chosen.length > 1 && repeat === "weekly";
var perDays = split ? readPerDays() : null;
var sharedDays = (repeat === "weekly" && !split) ? chosenDays() : null;
saveGroup(base, chosen, perDays, sharedDays);
commit();
return true;
}
function deleteChore() {
var c = editingId ? findChore(editingId) : null;
if (!c) return;
c.deleted = true;
c.updatedAt = nowISO();
toast("Removed " + esc(c.title));
commit();
}
/* ---------- "someone finished a chore" ---------- */
/* Completions reach this phone two ways: a Supabase pull, or the whole page
reloading after another view publishes. Both end up here, so both announce. */
function newestLogAt() {
return live(S.log).reduce(function (a, e) { return e.at > a ? e.at : a; }, "");
}
function markSeen() {
var newest = newestLogAt();
if (newest && newest !== D.lastSeenAt) { D.lastSeenAt = newest; saveDevice(); }
}
function notify(title, body) {
toast(esc(title) + (body ? ' ' + esc(body) + " " : ""));
if (!("Notification" in window) || Notification.permission !== "granted") return;
var opts = { body: body || "", icon: "icon-192.png", badge: "icon-192.png", tag: "chorewall" };
if (navigator.serviceWorker && navigator.serviceWorker.ready) {
navigator.serviceWorker.ready.then(function (reg) {
reg.showNotification(title, opts);
}).catch(function () { /* no worker in this build */ });
} else {
try { new Notification(title, opts); } catch (e) { /* blocked */ }
}
}
function announceNew() {
var since = D.lastSeenAt || "";
var fresh = live(S.log).filter(function (e) {
return e.at > since && e.by && e.by !== D.me;
}).sort(function (a, b) { return a.at < b.at ? -1 : 1; });
if (!D.notify || !fresh.length) { markSeen(); return; }
if (fresh.length === 1) {
var e = fresh[0], who = firstName(memberById(e.by)) || "Someone";
if (e.kind === "passed") {
notify(who + " can't do " + e.title, "It's up for grabs");
} else {
notify(who + " finished " + e.title, e.points ? "+" + e.points + " points" : "");
}
} else {
var passes = fresh.filter(function (x) { return x.kind === "passed"; }).length;
var dones = fresh.length - passes;
var bits = [];
if (dones) bits.push(dones + (dones === 1 ? " chore done" : " chores done"));
if (passes) bits.push(passes + " up for grabs");
notify(bits.join(", "), fresh.slice(-3).map(function (x) { return x.title; }).join(", "));
}
markSeen();
}
function updateNotifyUI() {
var btn = $("#notifyToggle"), hint = $("#notifyHint");
if (!btn) return;
btn.setAttribute("aria-pressed", String(!!D.notify));
if (!D.notify) {
hint.textContent = "Off. You'll still see everything in the Done tab.";
return;
}
if (!("Notification" in window)) {
hint.textContent = "Shown inside the app. Add it to your home screen for alerts when it's closed.";
return;
}
if (Notification.permission === "denied") {
hint.textContent = "Your phone is blocking alerts for this app. Turn them back on in iPhone Settings > Notifications.";
return;
}
if (Notification.permission === "default") {
hint.textContent = "Allow notifications when your phone asks, so alerts arrive with the app closed.";
return;
}
hint.textContent = pushReady()
? "On. Alerts arrive even with the app closed."
: "On, while the app is open. Set up a shared board and a push URL for alerts when it's closed.";
}
function toggleNotify() {
if (D.notify) {
D.notify = false;
saveDevice();
updateNotifyUI();
disablePush();
return;
}
D.notify = true;
saveDevice();
markSeen();
updateNotifyUI();
// Must be inside the click for iOS to accept it.
if ("Notification" in window && Notification.permission === "default") {
try {
Notification.requestPermission().then(function (p) {
updateNotifyUI();
if (p === "granted") enablePush().then(updateNotifyUI);
}, function () {});
} catch (e) { /* older Safari: callback form */ }
} else if ("Notification" in window && Notification.permission === "granted") {
enablePush().then(updateNotifyUI);
}
}
/* ---------- push (alerts while the app is closed) ---------- */
function pushBase() {
var relay = relayCfg();
if (relay) return relay.base;
var cfg = window.CHOREWALL_CONFIG;
if (!cfg || !cfg.push) return null;
return String(cfg.push).replace(/\/+$/, "");
}
/* Push is per shared board, so it needs the same board code the sync uses. */
function pushBoard() {
var relay = relayCfg();
if (relay) return relay.board;
return D.supa && D.supa.board ? D.supa.board : null;
}
function pushReady() {
return !!(pushBase() && pushBoard() &&
"serviceWorker" in navigator && "PushManager" in window);
}
function keyToBytes(b64) {
var pad = b64.replace(/-/g, "+").replace(/_/g, "/");
var padded = pad + "=".repeat((4 - (pad.length % 4)) % 4);
var raw = atob(padded);
var out = new Uint8Array(raw.length);
for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
function enablePush() {
if (!pushReady()) return Promise.resolve(false);
var base = pushBase();
return navigator.serviceWorker.ready.then(function (reg) {
return reg.pushManager.getSubscription().then(function (existing) {
if (existing) return existing;
return fetch(base + "/key")
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.key) throw new Error("no server key");
return reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: keyToBytes(d.key)
});
});
});
}).then(function (sub) {
var mine = me();
return fetch(base + "/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
board: pushBoard(),
member: D.me || "",
name: mine ? firstName(mine) : "",
sub: sub.toJSON ? sub.toJSON() : sub
})
});
}).then(function () { return true; })
.catch(function () { return false; });
}
function disablePush() {
if (!pushBase() || !("serviceWorker" in navigator)) return Promise.resolve();
var base = pushBase(), board = pushBoard();
return navigator.serviceWorker.ready.then(function (reg) {
return reg.pushManager.getSubscription();
}).then(function (sub) {
if (!sub) return;
var endpoint = sub.endpoint;
return sub.unsubscribe().then(function () {
if (!board) return;
return fetch(base + "/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ board: board, endpoint: endpoint })
});
});
}).catch(function () { /* already gone */ });
}
/* Tell the other phones what just happened here. Fire and forget: the board
itself is already saved, and a failed buzz must never block a tick. */
function broadcast(title, body) {
if (!pushReady()) return;
fetch(pushBase() + "/notify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
board: pushBoard(),
from: D.me || "",
title: title,
body: body || ""
})
}).catch(function () { /* offline: the in-app sync still catches up */ });
}
/* ---------- family + settings ---------- */
function nextColor() {
var used = members().map(function (m) { return m.color; });
for (var i = 0; i < PALETTE.length; i++) {
if (used.indexOf(PALETTE[i]) === -1) return PALETTE[i];
}
return PALETTE[used.length % PALETTE.length];
}
function renderMembers() {
$("#memberList").innerHTML = members().map(function (m) {
return '' + avHTML(m) +
' ' +
'' + PALETTE.map(function (col) {
return ' ';
}).join("") + " " +
'' +
icon("i-trash") + "
";
}).join("");
}
function addMember() {
S.members.push({
id: uid(), name: "", color: nextColor(),
order: members().length, updatedAt: nowISO()
});
commit();
renderMembers();
renderLinks();
var inputs = $("#memberList input");
if (inputs.length) inputs[inputs.length - 1].focus();
}
function openSettings() {
$("#sFamily").value = S.name || "";
renderMembers();
renderLinks();
updateNotifyUI();
if (D.supa) {
$("#sUrl").value = D.supa.url;
$("#sKey").value = D.supa.key;
$("#sBoard").value = D.supa.board;
$("#supaAdv").open = true;
}
$("#supaSummary").textContent = D.supa
? "Supabase board settings"
: "Connect a free Supabase board";
paintSync();
$("#setDlg").showModal();
}
function openWho() {
var mem = members();
$("#whoChips").innerHTML = mem.length
? mem.map(function (m) {
return '' + avHTML(m) + esc(m.name || "Unnamed") + " ";
}).join("")
: 'Add people in settings first.
';
$("#whoDlg").showModal();
}
function saveDevice() { writeLocal(LS_DEVICE, D); }
/* ---------- who this phone belongs to ---------- */
/* A personal link like .../board#me=Sam names its owner in the URL, so the
phone knows who it is even where local storage is blocked. */
function readMeFromURL() {
var src = String(location.hash || "") + "&" + String(location.search || "");
var m = /[#&?]me=([^]+)/.exec(src);
if (!m) return null;
try { return decodeURIComponent(m[1]); } catch (e) { return m[1]; }
}
function matchMember(token) {
var want = String(token || "").trim().toLowerCase();
if (!want) return null;
var list = members(), i, m;
for (i = 0; i < list.length; i++) if (list[i].id === token) return list[i];
for (i = 0; i < list.length; i++) {
m = list[i];
if (String(m.name || "").trim().toLowerCase() === want) return m;
}
for (i = 0; i < list.length; i++) {
m = list[i];
if (firstName(m).toLowerCase() === want) return m;
}
// a link built from just the first word still finds them
for (i = 0; i < list.length; i++) {
m = list[i];
if (String(m.name || "").trim().split(/s+/)[0].toLowerCase() === want) return m;
}
return null;
}
function personalLink(m) {
return location.origin + location.pathname + "#me=" + encodeURIComponent(firstName(m));
}
function selectLink(btn) {
var code = btn.parentNode.querySelector("code");
try {
var r = document.createRange();
r.selectNodeContents(code);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(r);
flashCopy(btn, "Selected");
} catch (e) { flashCopy(btn, "Copy it"); }
}
function flashCopy(btn, word) {
btn.textContent = word;
setTimeout(function () { btn.textContent = "Copy"; }, 1600);
}
function renderLinks() {
var wrap = $("#linkList");
if (!wrap) return;
var list = members().filter(function (m) { return String(m.name || "").trim(); });
if (!list.length) {
wrap.innerHTML = 'Name everyone above and their links appear here.
';
return;
}
wrap.innerHTML = list.map(function (m) {
return '' + avHTML(m) +
'' + esc(personalLink(m)) + "" +
'Copy
';
}).join("");
}
/* ---------- sync setup ---------- */
function startRelay() {
Sync.mode = "relay";
setSync("syncing", "Connecting…");
if (Sync.poll) clearInterval(Sync.poll);
Sync.poll = setInterval(pull, 12000);
return pull();
}
function startSupabase() {
Sync.mode = "supabase";
setSync("syncing", "Connecting…");
if (Sync.poll) clearInterval(Sync.poll);
Sync.poll = setInterval(pull, 12000);
return pull();
}
function connectSupabase() {
var url = $("#sUrl").value.trim().replace(/\/+$/, "");
var key = $("#sKey").value.trim();
var board = $("#sBoard").value.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-");
if (!/^https:\/\/.+/.test(url) || !key || !board) {
setSync("error", "Fill in all three fields — the URL must start with https://");
return;
}
D.supa = { url: url, key: key, board: board };
saveDevice();
$("#sBoard").value = board;
startSupabase().then(function () {
if (Sync.state === "ok") return push();
});
}
function disconnectSupabase() {
D.supa = null;
saveDevice();
if (Sync.poll) { clearInterval(Sync.poll); Sync.poll = null; }
Sync.mode = Sync.artifact ? "artifact" : "local";
setSync("idle", Sync.mode === "artifact"
? "Shared with everyone on the link"
: "Saved on this phone only");
$("#sUrl").value = ""; $("#sKey").value = ""; $("#sBoard").value = "";
$("#supaSummary").textContent = "Connect a free Supabase board";
}
/* ---------- events ---------- */
function pressOne(group, btn) {
$$("button", group).forEach(function (b) {
b.setAttribute("aria-pressed", String(b === btn));
});
}
function wire() {
$("#list").addEventListener("click", function (ev) {
var btn = ev.target.closest("[data-act]");
if (!btn) {
// tapping anywhere on the row opens it for editing
var row = ev.target.closest(".chore");
if (row) openChore(row.dataset.id);
return;
}
var act = btn.dataset.act;
if (act === "undo") {
var row = btn.closest("[data-log]");
if (row) undoLog(row.dataset.log);
return;
}
var card = btn.closest(".chore");
if (!card) return;
if (act === "edit") { openChore(card.dataset.id); return; }
if (act === "pass") { passChore(card.dataset.id); return; }
if (act === "claim") { claimChore(card.dataset.id); return; }
if (act === "done") {
if (!D.me) {
pendingDone = card.dataset.id;
if (!members().length) { openSettings(); } else { openWho(); }
return;
}
btn.setAttribute("data-done", "true");
card.classList.add("going");
var id = card.dataset.id;
setTimeout(function () { completeChore(id); }, 170);
}
});
$("#tabs").addEventListener("click", function (ev) {
var b = ev.target.closest("button[data-tab]");
if (!b) return;
D.tab = b.dataset.tab;
saveDevice();
render();
});
$("#addBtn").addEventListener("click", function () { openChore(null); });
$("#setBtn").addEventListener("click", openSettings);
$("#whoBtn").addEventListener("click", openWho);
$("#whoChips").addEventListener("click", function (ev) {
var b = ev.target.closest("button[data-id]");
if (!b) return;
D.me = b.dataset.id;
saveDevice();
$("#whoDlg").close();
render();
var picked = me();
if (picked && !pendingDone) toast("Signed in as " + esc(picked.name));
if (pendingDone) {
var resume = pendingDone;
pendingDone = null;
completeChore(resume);
}
});
$("#whoCancel").addEventListener("click", function () { $("#whoDlg").close(); });
$("#fAssignee").addEventListener("click", function (ev) {
var b = ev.target.closest("button[data-id]");
if (!b) return;
var anyone = $("#fAssignee button[data-id='']");
if (!b.dataset.id) { // "Anyone" clears everybody
$$("#fAssignee button[data-id]").forEach(function (x) {
x.setAttribute("aria-pressed", String(x === b));
});
syncAssignUI();
return;
}
b.setAttribute("aria-pressed", String(b.getAttribute("aria-pressed") !== "true"));
var names = $$("#fAssignee button[data-id]").filter(function (x) {
return x.dataset.id && x.getAttribute("aria-pressed") === "true";
});
if (anyone) anyone.setAttribute("aria-pressed", String(names.length === 0));
editingPerDays = readPerDays(); // keep what has been set so far
syncAssignUI();
});
$("#fPerDays").addEventListener("click", function (ev) {
var b = ev.target.closest("button[data-d]");
if (!b) return;
b.setAttribute("aria-pressed", String(b.getAttribute("aria-pressed") !== "true"));
});
$("#fRepeat").addEventListener("click", function (ev) {
var b = ev.target.closest("button[data-v]");
if (!b) return;
pressOne(this, b);
syncAssignUI();
});
$("#fDays").addEventListener("click", function (ev) {
var b = ev.target.closest("button[data-d]");
if (!b) return;
var on = b.getAttribute("aria-pressed") === "true";
// never leave every day switched off
if (on && chosenDays().length === 1) return;
b.setAttribute("aria-pressed", String(!on));
});
$("#choreForm").addEventListener("submit", function (ev) {
ev.preventDefault();
if (saveChore()) $("#choreDlg").close();
});
$("#choreCancel").addEventListener("click", function () { $("#choreDlg").close(); });
$("#choreDelete").addEventListener("click", function () {
deleteChore();
$("#choreDlg").close();
});
$("#sFamily").addEventListener("input", function () {
S.name = this.value.trim() || "Chore Wall";
S.nameAt = nowISO();
commit();
});
$("#memberList").addEventListener("input", function (ev) {
var row = ev.target.closest(".member-row");
if (!row || ev.target.tagName !== "INPUT") return;
var m = memberById(row.dataset.id);
if (!m) return;
m.name = ev.target.value;
m.updatedAt = nowISO();
commit();
renderLinks();
});
$("#memberList").addEventListener("click", function (ev) {
var row = ev.target.closest(".member-row");
if (!row) return;
var m = memberById(row.dataset.id);
if (!m) return;
var sw = ev.target.closest(".sw");
if (sw) {
m.color = sw.dataset.color;
m.updatedAt = nowISO();
commit();
renderMembers();
return;
}
if (ev.target.closest(".rm")) {
var label = String(m.name || "").trim() || "this person";
if (!window.confirm("Remove " + label + " from the family? Their chores go back to Anyone, and everyone else sees this too.")) return;
m.deleted = true;
m.updatedAt = nowISO();
chores().forEach(function (c) {
if (isAssignedTo(c, m.id)) {
setAssignees(c, assigneesOf(c).filter(function (id) { return id !== m.id; }));
c.updatedAt = nowISO();
}
});
if (D.me === m.id) { D.me = null; saveDevice(); }
commit();
renderMembers();
}
});
$("#linkList").addEventListener("click", function (ev) {
var btn = ev.target.closest(".copy");
if (!btn) return;
var link = btn.dataset.link;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(link).then(function () {
flashCopy(btn, "Copied");
}, function () { selectLink(btn); });
} else {
selectLink(btn);
}
});
$("#notifyToggle").addEventListener("click", toggleNotify);
$("#addMember").addEventListener("click", addMember);
$("#supaSave").addEventListener("click", connectSupabase);
$("#supaClear").addEventListener("click", disconnectSupabase);
$("#setClose").addEventListener("click", function () { $("#setDlg").close(); });
$("#setX").addEventListener("click", function () { $("#setDlg").close(); });
$("#choreX").addEventListener("click", function () { $("#choreDlg").close(); });
$("#whoX").addEventListener("click", function () { $("#whoDlg").close(); });
document.addEventListener("visibilitychange", function () {
if (!document.hidden) pull();
});
}
/* ---------- first run ---------- */
function seed() {
var wr = weekendRange();
var t0 = todayKey();
var sat = wr.sat >= t0 ? wr.sat : t0; // never lands in the past
var sun = wr.sun >= t0 ? wr.sun : t0;
var starter = [
{ title: "Load the dishwasher", due: todayKey(), repeat: "daily", points: 3 },
{ title: "Take the bins out", due: todayKey(), repeat: "weekly", points: 5 },
{ title: "Hoover through", due: sat, repeat: "weekly", points: 8 },
{ title: "Clean the bathroom", due: sat, repeat: "weekly", points: 8 },
{ title: "Tidy the kitchen", due: sun, repeat: "weekly", points: 5 },
{ title: "Change the bed sheets", due: sun, repeat: "weekly", points: 6 }
];
starter.forEach(function (c, i) {
S.chores.push({
id: uid(), title: c.title, note: c.note || "", assignee: null,
due: c.due, repeat: c.repeat, points: c.points,
createdAt: nowISO(), updatedAt: nowISO()
});
});
}
/* ---------- boot ---------- */
function boot() {
var embedded = readEmbedded();
var local = readLocal(LS_BOARD);
var dev = readLocal(LS_DEVICE);
if (dev) {
D.me = dev.me || null;
D.tab = dev.tab || "mine";
D.supa = dev.supa || null;
D.notify = !!dev.notify;
D.lastSeenAt = dev.lastSeenAt || "";
}
S = mergeBoards(local || emptyBoard(), embedded);
S.members = S.members || [];
S.chores = S.chores || [];
S.log = S.log || [];
var urlMe = readMeFromURL();
var greet = false;
if (urlMe) {
var claimed = matchMember(urlMe);
if (claimed && claimed.id !== D.me) { D.me = claimed.id; saveDevice(); greet = true; }
else if (claimed) { D.me = claimed.id; }
else { pendingIdentity = urlMe; } // board not here yet; retry after the pull
}
// A phone joining a shared board must not invent starter chores: they would
// merge straight into everyone else's list. Let the first pull fill it instead.
var joiningSharedBoard = !!relayCfg() ||
!!(window.CHOREWALL_CONFIG && window.CHOREWALL_CONFIG.url && window.CHOREWALL_CONFIG.key) ||
!!D.supa;
var brandNew = !embedded && !local && !joiningSharedBoard;
if (brandNew) seed();
wire();
render();
if (greet) {
var greeted = me();
if (greeted) toast("Signed in as " + esc(greeted.name));
}
announceNew();
if (brandNew) {
writeLocal(LS_BOARD, S);
setTimeout(openSettings, 400);
}
if (relayCfg()) {
startRelay();
} else {
if (!D.supa && window.CHOREWALL_CONFIG) {
var cfg = window.CHOREWALL_CONFIG;
if (cfg.url && cfg.key && cfg.board) {
D.supa = { url: String(cfg.url).replace(/\/+$/, ""), key: cfg.key, board: cfg.board };
saveDevice();
}
}
if (D.supa) {
startSupabase();
} else {
setSync("idle", "Saved on this phone only");
}
}
// The artifact capability resolves after first paint, or never.
if (window.claude && typeof window.claude.use === "function") {
window.claude.use("artifact").then(function (a) {
if (!a) return;
Sync.artifact = a;
if (Sync.mode !== "supabase" && Sync.mode !== "relay") {
Sync.mode = "artifact";
setSync("ok", "Shared with everyone on the link");
}
}).catch(function () { /* not available in this view */ });
}
}
// Test seam: internals are reachable only with ?debug=1 in the URL.
if (/[?&]debug=1/.test(location.search)) {
window.__cw = {
mergeBoards: mergeBoards, mergeLists: mergeLists,
nextDue: nextDue, dueInfo: dueInfo,
weekendRange: weekendRange, defaultDue: defaultDue,
board: function () { return S; }
};
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();