Tasks and tags

Hi,

I'd like to ask if you're using tags with your tasks like:

  • Task1 #task #project1

Can SB manage nested tasks like: #project/todo #project/mails, does anybody use them?.

Regards.

Yes it can -- I use them!

Great!, nice page, is there any kind of template/plugin?...

Thanks for helping.

Functions:

Renders the body of the [[tagCloud|Tag Cloud]] virtual page (implemented in [[Library/Personal/VirtualPages/TagCloud]]):

```
${tagCloud.render()}
```

Two parts. On top, a **weighted cloud** of every tag in the space, alphabetical, with type size scaled to how often the tag is used (six log-spaced steps, so `#news` at 146 doesn't flatten everything below 10 into the same size). Underneath, a **card per namespace** โ€” the top-level segment of slash tags like `shopping/grocery` โ€” listing its tags with counts, biggest first, heaviest namespace first. A namespace holding only one tag (`#thoughts`, or a lone `#coffee/espresso`) doesn't earn a card; those collect into a single trailing "Ungrouped" card.

Cards with more than a dozen tags start collapsed; every card is a `<details>`, so the open/closed state is the browser's business, not ours.

Counts are index records, one per page ร— tag ร— context โ€” the unit `tag.count()` has always reported, so the numbers match what the space showed before. A tag that appears both in a page's frontmatter and in a list item on that same page therefore counts twice. Everything comes from **one** query, counted in Lua; the previous implementation ran a `tag.count()` query per tag (~140 queries per render).

```space-lua
tagCloud = tagCloud or {}

-- Tags under this prefix are internal plumbing (#meta, #meta/repository)
-- and stay out of the cloud โ€” same filter tag.list() has always used.
local HIDDEN_PREFIX = "meta"

-- Cards above this many tags start collapsed.
local COLLAPSE_OVER = 12

-- Number of type sizes in the cloud; sb-tc-s1 (rarest) .. sb-tc-s6.
local SIZE_STEPS = 6

-- Emoji per top-level namespace. Unmapped namespaces fall back to ๐Ÿท๏ธ,
-- so this only needs entries for tags that actually exist.
local ICONS = {
  booking = "๐ŸŽซ",
  coffee = "โ˜•",
  contact = "๐Ÿ“‡",
  cooking = "๐Ÿณ",
  docker = "๐Ÿณ",
  espresso = "โ˜•",
  finance = "๐Ÿ’ฐ",
  flights = "โœˆ๏ธ",
  food = "๐Ÿฝ๏ธ",
  health = "๐Ÿฉบ",
  home = "๐Ÿ ",
  homelab = "๐Ÿ–ฅ๏ธ",
  immigration = "๐Ÿ›‚",
  important = "โญ",
  inbox = "๐Ÿ“ฅ",
  journal = "๐Ÿ““",
  location = "๐Ÿ“",
  marriage = "๐Ÿ’",
  media = "๐ŸŽž๏ธ",
  medical = "๐Ÿฉบ",
  music = "๐ŸŽต",
  news = "๐Ÿ“ฐ",
  NixOS = "โ„๏ธ",
  parts = "๐Ÿ”ฉ",
  pto = "๐Ÿ“…",
  ["self-hosting"] = "๐Ÿ–ฅ๏ธ",
  shopping = "๐Ÿ›’",
  silverbullet = "๐Ÿฅˆ",
  tasks = "๐Ÿ“‹",
  thoughts = "๐Ÿ’ญ",
  travel = "๐Ÿงณ",
  want = "๐ŸŽฏ",
  work = "๐Ÿ’ผ",
}

local function plural(n, word)
  if n == 1 then return n .. " " .. word end
  return n .. " " .. word .. "s"
end

-- Mirrors encodePageURI (lib/ref.ts): percent-encode, keep slashes.
-- Only the characters a tag name can actually contain need handling โ€”
-- the parser already rules out whitespace and most punctuation.
local function tagHref(name)
  local ref = "tag:" .. name
  local encoded = ref:gsub("%%", "%%25")
  encoded = encoded:gsub(":", "%%3A")
  return "/" .. encoded
end

-- The same anchor the markdown renderer emits for a `#hashtag`
-- (markdown_renderer/markdown_render.ts): `data-tag-name` is what every
-- rule in [[Library/Personal/Styles/Tags]] keys off for colour, and
-- `data-ref` is what the widget click handler turns into a local
-- navigate. Built by hand rather than passed in as a markdown string so
-- that tag names containing `_` or `*` don't come out italicised.
local function chip(name, count)
  return dom.a {
    class = "hashtag sb-hashtag",
    ["data-tag-name"] = name,
    ["data-ref"] = "tag:" .. name,
    href = tagHref(name),
    title = "#" .. name .. " ยท " .. count,
    __rawText = "#" .. name,
  }
end

-- One record per page ร— tag ร— context, folded into one entry per tag.
local function collectTags()
  local records = query[[
    from t = index.tag "tag"
    where not t.name:startsWith(HIDDEN_PREFIX)
  ]]

  local byName, tags, total = {}, {}, 0
  for _, rec in ipairs(records) do
    local entry = byName[rec.name]
    if not entry then
      entry = { name = rec.name, count = 0, sort = rec.name:lower() }
      byName[rec.name] = entry
      table.insert(tags, entry)
    end
    entry.count = entry.count + 1
    total = total + 1
  end
  return tags, total
end

-- Group by first path segment. A namespace holding a single tag isn't a
-- hierarchy worth a card of its own โ€” bare `#thoughts`, but also a lone
-- `#coffee/espresso` โ€” so those collect into one trailing "Ungrouped"
-- card instead of dozens of cards holding one row each.
local function groupTags(tags)
  local byNs, order = {}, {}
  for _, t in ipairs(tags) do
    local slash = t.name:find("/")
    local ns = slash and t.name:sub(1, slash - 1) or t.name
    local g = byNs[ns]
    if not g then
      g = { ns = ns, tags = {}, total = 0 }
      byNs[ns] = g
      table.insert(order, g)
    end
    table.insert(g.tags, t)
    g.total = g.total + t.count
  end

  local sections = {}
  local flat = { ns = "Ungrouped", icon = "๐Ÿท๏ธ", tags = {}, total = 0 }
  for _, g in ipairs(order) do
    if #g.tags > 1 then
      g.icon = ICONS[g.ns] or "๐Ÿท๏ธ"
      table.insert(sections, g)
    else
      for _, t in ipairs(g.tags) do
        table.insert(flat.tags, t)
        flat.total = flat.total + t.count
      end
    end
  end

  local function byWeight(a, b)
    if a.count ~= b.count then return a.count > b.count end
    return a.sort < b.sort
  end
  table.sort(sections, function(a, b)
    if a.total ~= b.total then return a.total > b.total end
    return a.ns:lower() < b.ns:lower()
  end)
  for _, s in ipairs(sections) do
    table.sort(s.tags, byWeight)
  end
  table.sort(flat.tags, byWeight)

  -- The leftovers card always comes last, however heavy it is, and
  -- doesn't count towards the namespace tally in the stats line.
  local namespaceCount = #sections
  if #flat.tags > 0 then
    table.insert(sections, flat)
  end
  return sections, namespaceCount
end

-- Log-spaced so a handful of huge tags don't flatten the long tail:
-- counts of 1, 5, 20 and 130 land in visibly different steps.
local function sizeClass(count, lowest, highest)
  if highest <= lowest then return "sb-tc-s3" end
  local span = math.log(highest) - math.log(lowest)
  local pos = (math.log(count) - math.log(lowest)) / span
  local step = math.floor(pos * (SIZE_STEPS - 1) + 0.5) + 1
  if step < 1 then step = 1 end
  if step > SIZE_STEPS then step = SIZE_STEPS end
  return "sb-tc-s" .. step
end

local function buildCloud(tags)
  local lowest, highest = tags[1].count, tags[1].count
  for _, t in ipairs(tags) do
    if t.count < lowest then lowest = t.count end
    if t.count > highest then highest = t.count end
  end

  local alpha = {}
  for _, t in ipairs(tags) do table.insert(alpha, t) end
  table.sort(alpha, function(a, b) return a.sort < b.sort end)

  local cloud = { class = "sb-tagcloud" }
  for _, t in ipairs(alpha) do
    -- Size lives on a wrapper, not the anchor: the built-in
    -- `#sb-main .cm-editor .sb-hashtag` rule sets font-size at a
    -- specificity a space-style class selector can't outrank, but the
    -- anchor's 0.9em happily scales against an inherited em.
    table.insert(cloud, dom.span {
      class = "sb-tc-item " .. sizeClass(t.count, lowest, highest),
      chip(t.name, t.count),
    })
  end
  return dom.div(cloud)
end

local function buildCard(section)
  local body = { class = "sb-tagns-body" }
  for _, t in ipairs(section.tags) do
    table.insert(body, dom.div {
      class = "sb-tagns-row",
      chip(t.name, t.count),
      dom.span { class = "sb-tagns-count", __rawText = tostring(t.count) },
    })
  end

  local card = {
    class = "sb-tagns",
    dom.summary {
      class = "sb-tagns-head",
      dom.span { class = "sb-tagns-icon", __rawText = section.icon },
      dom.span { class = "sb-tagns-name", __rawText = section.ns },
      dom.span {
        class = "sb-tagns-meta",
        __rawText = plural(#section.tags, "tag") .. " ยท " .. section.total,
      },
    },
    dom.div(body),
  }
  if #section.tags <= COLLAPSE_OVER then
    card.open = ""
  end
  return dom.details(card)
end

local function buildStats(tags, uses, namespaces, busiest)
  local singles = 0
  for _, t in ipairs(tags) do
    if t.count == 1 then singles = singles + 1 end
  end

  local parts = {
    plural(#tags, "tag"),
    plural(uses, "use"),
    plural(namespaces, "namespace"),
  }
  if singles > 0 then
    table.insert(parts, singles .. " used once")
  end

  local line = {
    class = "sb-tagcloud-stats",
    dom.span { __rawText = table.concat(parts, " ยท ") },
    dom.span { class = "sb-tagcloud-sep", __rawText = "ยท" },
    dom.span { __rawText = "busiest" },
    chip(busiest.name, busiest.count),
  }
  return dom.div(line)
end

function tagCloud.render()
  local tags, uses = collectTags()
  if #tags == 0 then
    return widget.new {
      html = dom.div { class = "sb-tagcloud-empty", __rawText = "No tags in this space yet." },
      display = "block",
    }
  end

  local busiest = tags[1]
  for _, t in ipairs(tags) do
    if t.count > busiest.count then busiest = t end
  end
  local sections, namespaces = groupTags(tags)

  local groups = { class = "sb-tagcloud-groups" }
  for _, section in ipairs(sections) do
    table.insert(groups, buildCard(section))
  end

  return widget.new {
    html = dom.div {
      buildStats(tags, uses, namespaces, busiest),
      buildCloud(tags),
      dom.div {
        class = "sb-tagcloud-legend",
        __rawText = "Bigger means used more often โ€” click a tag for everything it's on.",
      },
      dom.div(groups),
    },
    display = "block",
  }
end
```

```space-style
/* ---------- Tag cloud: stats line ---------- */
.sb-tagcloud-stats {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: center;
  gap: 2px 6px;
  font-size: 0.85em;
  opacity: 0.7;
  margin: 4px 0 2px;
}

.sb-tagcloud-sep {
  opacity: 0.5;
}

.sb-tagcloud-legend {
  text-align: center;
  font-size: 0.78em;
  opacity: 0.45;
  margin: 2px 0 4px;
}

/* ---------- Tag cloud: the cloud itself ---------- */
.sb-tagcloud {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: center;
  gap: 3px 7px;
  line-height: 1.85;
  padding: 14px 12px;
  margin: 8px 0 2px;
  border-radius: 10px;
  background: color-mix(in srgb, var(--editor-widget-background-color) 50%, transparent);
}

.sb-tc-item {
  display: inline-flex;
  transition: transform 0.1s ease-out;
}

.sb-tc-item:hover {
  transform: translateY(-1px);
}

/* Rarer tags recede, common ones lead โ€” size does most of the work,
   opacity keeps the small end from turning into visual noise. */
.sb-tc-s1 { font-size: 0.8em; opacity: 0.6; }
.sb-tc-s2 { font-size: 0.95em; opacity: 0.75; }
.sb-tc-s3 { font-size: 1.15em; opacity: 0.85; }
.sb-tc-s4 { font-size: 1.4em; }
.sb-tc-s5 { font-size: 1.7em; }
.sb-tc-s6 { font-size: 2.05em; }

/* ---------- Tag cloud: namespace cards ---------- */
.sb-tagcloud-groups {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  align-items: start;
  gap: 10px;
  margin-top: 14px;
}

.sb-tagns {
  background: var(--editor-widget-background-color);
  border-radius: 8px;
  padding: 7px 10px 4px;
}

.sb-tagns-head {
  display: flex;
  align-items: baseline;
  gap: 6px;
  cursor: pointer;
  list-style: none;
}

/* Swap the native disclosure triangle for one that lines up with the
   emoji column and turns as the card opens. */
.sb-tagns-head::-webkit-details-marker {
  display: none;
}

.sb-tagns-head::before {
  content: "โ–ธ";
  opacity: 0.45;
  font-size: 0.8em;
  transition: transform 0.12s ease-out;
}

.sb-tagns[open] > .sb-tagns-head::before {
  transform: rotate(90deg);
}

.sb-tagns-name {
  font-weight: 600;
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.sb-tagns-meta {
  font-size: 0.78em;
  opacity: 0.55;
  white-space: nowrap;
}

.sb-tagns-body {
  margin: 6px 0 4px;
}

.sb-tagns-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  padding: 1px 0;
}

/* Long tags (media/game/political-thriller) truncate rather than
   pushing the count off the card; the full name is in the tooltip. */
.sb-tagns-row .sb-hashtag {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.sb-tagns-count {
  font-size: 0.8em;
  font-variant-numeric: tabular-nums;
  opacity: 0.5;
}

.sb-tagns-row:hover .sb-tagns-count {
  opacity: 0.85;
}

.sb-tagcloud-empty {
  opacity: 0.6;
  font-style: italic;
  margin: 10px 0;
}

Virtual page:

---
description: Implements the TagCloud virtual page
tags: meta
---

Implements a virtual page for the Tag Cloud.

The body is `tagCloud.render()` from [[Library/Personal/Scripts/Widgets/TagCloud]] โ€” a size-weighted cloud of every tag in the space plus a card per namespace. It runs a single index query, so the page costs one query however many tags there are.

# Implementation
```space-lua
-- priority: 10
virtualPage.define {
  pattern = "tagCloud",
  run = function()
    return "---\npageDecoration:\n  disableTOC: true\n---\n"
      .. "# ๐Ÿท๏ธ Tag Cloud\n\n${tagCloud.render()}\n"
  end
}

There are more theming items that aren't present here -- like the tag colors. This is just a scaffold for the tag browser :slight_smile:

Thanks a lot!, now I have a lot of stuff to study (and understand) :smiling_face: