#meta
The team wiki’s home page: the current sprint, open tasks per person and where our decisions stand, plus the open tasks shown at the top of every person page. The space opens in dark mode.
-- Dark by default, unless you picked a theme yourself with "Editor: Toggle Dark Mode"
if clientStore.get "darkMode" == nil and editor.getUiOption "darkMode" == nil then
editor.setUiOption("darkMode", true)
end
team = {}
-- Widget links open pages through editor.navigate, so they work wherever the space is served
local function go(ref)
return function()
editor.navigate(ref)
end
end
local function avatar(handle)
return dom.span {
class = "team-avatar team-" .. handle,
handle:sub(1, 1):upper(),
}
end
-- Whether a task is addressed to an identity, written as "@hana"
local function addressedTo(task, target)
return table.find(task.recipients, function(r)
return r == target
end) ~= nil
end
local MONTHS = { "January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December" }
-- "2026-10-09" as "9 October"
local function longDate(iso)
local _, m, d = tostring(iso):match "^(%d+)-(%d+)-(%d+)$"
if not m then
return tostring(iso)
end
return tonumber(d) .. " " .. MONTHS[tonumber(m)]
end
local function plural(n, word)
return n .. " " .. word .. (n == 1 and "" or "s")
end
-- A terminal-style status line: open tasks, the last weekly sync and decisions still waiting
function team.header()
local meetings = query[
from p = index.contentPages "meeting"
order by p.date desc
limit 1
select p.name
](<
from p = index.contentPages "meeting"
order by p.date desc
limit 1
select p.name
>)
local meeting = meetings[1]
local open = query[
from t = index.tasks()
where not t.done and t.recipients
select t
](<
from t = index.tasks()
where not t.done and t.recipients
select t
>)
local waiting = query[
from p = index.contentPages "adr"
where p.status == "proposed"
select p
](<
from p = index.contentPages "adr"
where p.status == "proposed"
select p
>)
return widget.htmlBlock(dom.div {
class = "team team-header",
dom.div {
class = "team-prompt",
dom.span { class = "team-host", "driftwood" },
dom.span { ":~$ status" },
dom.span { class = "team-cursor", "_" },
},
dom.div {
class = "team-pills",
dom.span {
class = "team-pill",
plural(#open, "open task"),
},
dom.a {
class = "team-pill",
onclick = go(meeting),
"last sync " .. meeting:match "%d+%-%d+%-%d+",
},
dom.a {
class = "team-pill team-pill-warn",
onclick = go "ADR",
plural(#waiting, "decision") .. " waiting",
},
},
})
end
function team.sprint(page)
local tasks = query[
from t = index.tasks()
where t.page == page
select t
](<
from t = index.tasks()
where t.page == page
select t
>)
local done = 0
local rows = {}
for _, t in ipairs(tasks) do
if t.done then
done = done + 1
end
table.insert(rows, dom.a {
class = t.done and "team-task team-done" or "team-task",
onclick = go(t.ref),
avatar(t.text:match "@(%w+)"),
dom.span { (t.text:gsub("@%w+ ", "")) },
})
end
local progress = #tasks > 0 and math.max(4, math.floor(100 * done / #tasks)) or 0
local ends = query[
from p = index.pages()
where p.name == page
select p.ends
](<
from p = index.pages()
where p.name == page
select p.ends
>)
ends = ends[1]
return widget.htmlBlock(dom.div {
class = "team team-sprint",
dom.div {
class = "team-sprint-head",
dom.a {
onclick = go(page),
page:match "[^/]+$",
},
dom.span { done .. " of " .. #tasks .. " done" .. (ends and (" · ends " .. longDate(ends)) or "") },
},
dom.div {
class = "team-bar",
dom.div { style = "width:" .. progress .. "%" },
},
dom.div {
class = "team-tasks",
table.unpack(rows),
},
})
end
-- Open tasks addressed to an identity, written as "@hana"
function team.openTasks(id)
local target = "@" .. id
return query[
from t = index.tasks()
where not t.done and addressedTo(t, target)
select templates.taskItem(t)
](<
from t = index.tasks()
where not t.done and addressedTo(t, target)
select templates.taskItem(t)
>)
end
-- Above every page with an `identity`: that person's open tasks, wherever they were assigned
view.define {
name = "team.openTasks",
title = "Open tasks",
dock = "page-top",
frame = "minimal",
supportedDocks = { "page-top", "page-bottom", "rhs" },
refreshOn = { "navigate", "index" },
content = function()
local id = (editor.getCurrentPageMeta() or {}).identity
if not id then
return nil
end
local tasks = team.openTasks(id)
if #tasks == 0 then
return nil
end
return "**Open tasks for @" .. id .. "**\n" .. table.concat(tasks)
end,
}
-- Open tasks per team member: pages tagged #team, matched to tasks by their `identity` (e.g. hana, addressed as @hana)
function team.people()
local open = query[
from t = index.tasks()
where not t.done and t.recipients
select t
](<
from t = index.tasks()
where not t.done and t.recipients
select t
>)
local members = query[
from p = index.contentPages "team"
order by p.name
select p
](<
from p = index.contentPages "team"
order by p.name
select p
>)
local cards = {}
for _, p in ipairs(members) do
local n = 0
for _, t in ipairs(open) do
if addressedTo(t, "@" .. p.identity) then
n = n + 1
end
end
table.insert(cards, dom.a {
class = "team-person",
onclick = go(p.name),
avatar(p.identity),
dom.span { p.name:match "[^/]+$" },
dom.span { class = "team-load", string.rep("■", n) },
dom.strong { tostring(n) },
})
end
return widget.htmlBlock(dom.div {
class = "team team-people",
table.unpack(cards),
})
end
function team.decisions()
local adrs = query[
from p = index.contentPages "adr"
order by p.date desc
select p
](<
from p = index.contentPages "adr"
order by p.date desc
select p
>)
local counts, waiting = {}, nil
for _, a in ipairs(adrs) do
counts[a.status] = (counts[a.status] or 0) + 1
if a.status == "proposed" then
waiting = a.name
end
end
local chips = {}
for _, status in ipairs { "accepted", "proposed", "superseded", "deprecated" } do
table.insert(chips, dom.span {
class = "team-chip team-chip-" .. status,
(counts[status] or 0) .. " " .. status,
})
end
return widget.htmlBlock(dom.div {
class = "team team-decisions",
dom.div {
class = "team-chips",
table.unpack(chips),
},
waiting and dom.p {
"Waiting on a decision: ",
dom.a {
onclick = go(waiting),
(waiting:gsub("^ADR/", "")),
},
} or nil,
})
end
/* The team’s accent colour; the code above opens the space in dark mode */
html:root {
--ui-accent-color: #2dd4bf;
}
/* Let the home widgets sit on the page without the usual widget frame */
.sb-lua-directive-block:has(.team) {
border: none !important;
background: none !important;
}
/* Flat panels with square corners, like a terminal */
.team-header,
.team-sprint,
.team-person {
border: 1px solid #1f3b3a;
background: #0b1514;
}
/* The status line */
.team-header {
padding: 14px 16px;
color: #ccfbf1;
}
.team-prompt {
font-size: 1.3em;
font-weight: 700;
}
.team-host {
color: #2dd4bf;
}
.team-cursor {
margin-left: 4px;
color: #2dd4bf;
animation: team-blink 1s steps(1) infinite;
}
@keyframes team-blink {
50% { opacity: 0; }
}
.team-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.team-pill {
padding: 2px 10px;
border: 1px solid #2dd4bf;
color: #5eead4 !important;
font-size: 0.8em;
text-decoration: none !important;
}
.team-pill::before {
content: "● ";
}
.team-pill-warn {
border-color: #fbbf24;
color: #fbbf24 !important;
}
/* The current sprint: progress bar and its tasks */
.team-sprint {
padding: 12px 16px;
}
.team-sprint-head {
display: flex;
justify-content: space-between;
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.team-sprint-head a {
font-weight: 700;
color: #5eead4 !important;
}
.team-sprint-head span {
color: #5f8f8a;
}
.team-bar {
height: 6px;
margin: 8px 0 10px;
background: #1f3b3a;
}
.team-bar div {
height: 100%;
background: #2dd4bf;
}
.team-tasks {
display: flex;
flex-direction: column;
gap: 6px;
}
.team-task {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.9em;
color: #ccfbf1 !important;
text-decoration: none !important;
}
.team-task:hover span {
color: #5eead4;
text-decoration: underline;
}
.team-task::before {
content: "[ ]";
flex: none;
color: #5f8f8a;
}
.team-done::before {
content: "[x]";
}
/* Avatars: one colour per person, square like everything else here */
.team-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 22px;
height: 22px;
color: #0b1514;
font-size: 0.75em;
font-weight: 700;
}
.team-sam {
background: #2dd4bf;
}
.team-pete {
background: #60a5fa;
}
.team-jo {
background: #f472b6;
}
.team-jane {
background: #a78bfa;
}
.team-mateo {
background: #fb923c;
}
.team-hana {
background: #a3e635;
}
/* Open tasks per person, as a load bar */
.team-people {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.team-person {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
color: #ccfbf1 !important;
text-decoration: none !important;
font-size: 0.9em;
}
.team-person span {
white-space: nowrap;
}
.team-load {
margin-left: auto;
color: #2dd4bf;
letter-spacing: 2px;
}
.team-person strong {
width: 1.5em;
text-align: right;
color: #5eead4;
}
/* Where decisions stand, and the one still waiting */
.team-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.team-chip {
padding: 2px 10px;
border: 1px solid currentColor;
font-size: 0.85em;
}
.team-chip-accepted {
color: #2dd4bf;
}
.team-chip-proposed {
color: #fbbf24;
}
.team-chip-superseded {
color: #94a3b8;
}
.team-chip-deprecated {
color: #f87171;
}
.team-decisions p {
margin: 10px 0 0;
font-size: 0.9em;
}
.team-decisions a {
color: #5eead4 !important;
}
/* Widget links navigate on click, so give them a pointer */
.team a {
cursor: pointer;
}