From diary scores to a monthly view
The calendar below uses a fixed example dataset so the public demo contains no diary entries. The Lua renderer accepts daily scores from -1 to +1 and keeps the exact values available in an expandable table.
The report controller is a separate, manual step. In the notebook it reads scored diary metadata, calculates daily means, and creates or updates a saved monthly report. Its publishing tag is empty by default, so generated reports remain private.
Example sentiment calendar

Example month — synthetic scores

Sequential days of the month; blank cells have no scored entry.
1-0.50
2+0.00
3—
4+0.20
5+0.55
6—
7—
8+0.85
9—
10—
11-0.10
12—
13—
14—
15—
16—
17—
18—
19—
20—
21—
22—
23—
24—
25—
26—
27—
28—
29—
30—
31—
Negative · Slightly negative · Neutral · Positive · Very positive
Daily scores
datescorelabel
2026-10-01-0.5Negative
2026-10-020Neutral
2026-10-040.2Neutral
2026-10-050.55Positive
2026-10-080.85Very positive
2026-10-11-0.1Slightly negative
Show code
-- Example scores only; this demo never queries the journal.
ui.render("sentiment-calendar-heatmap-v2",{
 month="2026-10",monthLabel="Example month — synthetic scores",daysInMonth=31,
 data={["2026-10-01"]=-0.5,["2026-10-02"]=0,["2026-10-04"]=0.2,["2026-10-05"]=0.55,["2026-10-08"]=0.85,["2026-10-11"]=-0.1}
})
Sentiment Calendar Heatmap:renderer:

Monthly sentiment

Sequential days of the month; blank cells have no scored entry.
1—
2—
3—
4—
5—
6—
7—
8—
9—
10—
11—
12—
13—
14—
15—
16—
17—
18—
19—
20—
21—
22—
23—
24—
25—
26—
27—
28—
29—
30—
31—
Negative · Slightly negative · Neutral · Positive · Very positive
Daily scores
datescorelabel

No rows

Show code
-- Portable Lua version of the article's calendar heatmap.
-- Input: {data={["YYYY-MM-DD"]=score or {avg=score}},monthLabel=...,daysInMonth=...}
-- Values and labels remain available without mouse hover.
local payload = (target and target.metadata) or {}
local data = type(payload.data) == "table" and payload.data or {}
local days = math.max(28,math.min(31,math.floor(tonumber(payload.daysInMonth) or 31)))
local month = tostring(payload.month or ""):match("^(%d%d%d%d%-%d%d)$")
local dates = {}
for date in pairs(data) do
  if type(date) == "string" and date:match("^%d%d%d%d%-%d%d%-%d%d$") then dates[#dates+1]=date end
end
table.sort(dates)
month = month or (dates[1] and dates[1]:sub(1,7))
local by_day = {}
for _, date in ipairs(dates) do
  if date:sub(1,7) == month then
    local raw = data[date]
    if type(raw) == "table" then
      if raw.avg ~= nil then raw=raw.avg else raw=raw.score end
    end
    local score = tonumber(raw)
    local day = tonumber(date:sub(9,10))
    if score and score==score and score~=math.huge and score~=-math.huge and day>=1 and day<=days then
      by_day[day] = {date=date,score=math.max(-1,math.min(1,score))}
    end
  end
end
local function label(score)
  if score < -0.3 then return "Negative","danger" end
  if score < 0 then return "Slightly negative","warning" end
  if score < 0.3 then return "Neutral","neutral" end
  if score < 0.6 then return "Positive","success" end
  return "Very positive","accent"
end
local cells, values = {}, {}
for day=1,days do
  local entry=by_day[day]
  local text,tone="No data","neutral"
  if entry then text,tone=label(entry.score) end
  cells[#cells+1]=ui.box({
    ui.text(tostring(day)),
    ui.badge(entry and string.format("%+.2f",entry.score) or "—",{appearance={tone=tone}}),
  },{appearance={padding=1,density="compact"}})
  if entry then values[#values+1]={date=entry.date,score=entry.score,label=text} end
end
ui.column({
  ui.heading(tostring(payload.monthLabel or month or "Monthly sentiment"),3),
  ui.text("Sequential days of the month; blank cells have no scored entry."),
  ui.grid(cells,{columns=7,appearance={gap=1}}),
  ui.text("Negative · Slightly negative · Neutral · Positive · Very positive"),
  ui.collapsible("Daily scores",{open=false,children=ui.table(values,{columns={"date","score","label"}})}),
},{appearance={gap=2}})
Generate monthly diary sentiment:sentiment::analytics:

Monthly diary sentiment · 2026-11

-0.57Mean
5/8 (63%)Coverage
5Scored days
2026-11 was negative-leaning overall (mean -0.57), with sentiment more negative in the second half. Coverage was 63% across 5 scored days.

Daily mean sentiment (-1 to +1)

▁█▄▂▃
OUTPUT_TAG is empty, so the saved report remains private.
Show code
-- Monthly diary sentiment report controller.
-- Pin this block to journal/@end-of-month and click Run.

local OUTPUT_TAG = "" -- Exact published-page tag; empty keeps reports private.
local REPORT_PAGE = "reports/sentiment"
local TARGET_MONTH = "" -- Optional YYYY-MM override; empty uses the journal page/latest month.
local SUMMARY_MODE = "ai" -- "ai" summarizes aggregate scores only; "deterministic" stays local.
local FORCE_RESUMMARIZE = false

local SOURCE_TAG = "diary"
local SCORE_FIELD = "sentiment"
local REPORT_TAG = "sentiment/monthly"

local function push_unique(values, value)
  if value == nil or value == "" then
    return
  end
  for _, existing in ipairs(values) do
    if existing == value then
      return
    end
  end
  values[#values + 1] = value
end

local function rounded(value, places)
  local power = 10 ^ places
  if value >= 0 then
    return math.floor(value * power + 0.5) / power
  end
  return math.ceil(value * power - 0.5) / power
end

local function average_rows(rows, first_index, last_index)
  local sum = 0
  local count = 0
  for index = first_index, last_index do
    local row = rows[index]
    if row ~= nil then
      sum = sum + row.sentiment
      count = count + 1
    end
  end
  if count == 0 then
    return 0
  end
  return sum / count
end

local function deterministic_summary(month, monthly_mean, coverage, rows)
  local tone = "balanced"
  if monthly_mean >= 0.20 then
    tone = "positive-leaning"
  elseif monthly_mean <= -0.20 then
    tone = "negative-leaning"
  end

  local movement = "fairly steady"
  if #rows >= 4 then
    local midpoint = math.floor(#rows / 2)
    local first_half = average_rows(rows, 1, midpoint)
    local second_half = average_rows(rows, midpoint + 1, #rows)
    local change = second_half - first_half
    if change >= 0.15 then
      movement = "more positive in the second half"
    elseif change <= -0.15 then
      movement = "more negative in the second half"
    end
  end

  return string.format(
    "%s was %s overall (mean %+.2f), with sentiment %s. Coverage was %d%% across %d scored day%s.",
    month,
    tone,
    monthly_mean,
    movement,
    coverage,
    #rows,
    #rows == 1 and "" or "s"
  )
end

local function build_signature(month, total, scored, rows)
  local parts = {month, tostring(total), tostring(scored)}
  for _, row in ipairs(rows) do
    parts[#parts + 1] = string.format("%s:%+.6f:%d", row.date, row.sentiment, row.entries)
  end
  return table.concat(parts, "|")
end

local function build_aggregate_prompt(month, monthly_mean, coverage, total, scored, rows)
  local series = {}
  for _, row in ipairs(rows) do
    series[#series + 1] = string.format(
      "%s mean=%+.3f entries=%d",
      row.date,
      row.sentiment,
      row.entries
    )
  end

  return table.concat({
    "Summarize this one-month sentiment series in 2-3 concise sentences.",
    "Scores range from -1 (negative) to +1 (positive).",
    "Describe overall tone, direction, and variability. Mention incomplete coverage when relevant.",
    "Do not diagnose mental health, speculate about causes, or claim to have read journal text.",
    "Month: " .. month,
    string.format("Monthly mean: %+.3f", monthly_mean),
    string.format("Coverage: %d%% (%d of %d diary blocks scored)", coverage, scored, total),
    "Daily aggregates:",
    table.concat(series, "\n"),
  }, "\n")
end

local function build_frozen_report_code(month, summary, monthly_mean, coverage, total, scored, rows)
  local lines = {
    "local month = " .. string.format("%q", month),
    "local summary = " .. string.format("%q", summary),
    string.format("local monthly_mean = %.6f", monthly_mean),
    string.format("local coverage = %d", coverage),
    string.format("local total = %d", total),
    string.format("local scored = %d", scored),
    "local data = {",
  }

  for _, row in ipairs(rows) do
    lines[#lines + 1] = string.format(
      "  {day=%q, sentiment=%.6f, entries=%d},",
      row.day,
      row.sentiment,
      row.entries
    )
  end

  lines[#lines + 1] = "}"
  lines[#lines + 1] = [[
ui.column({
  ui.heading("Monthly diary sentiment · " .. month, 3),
  ui.row({
    ui.stat("Mean", string.format("%+.2f", monthly_mean)),
    ui.stat("Coverage", tostring(scored) .. "/" .. tostring(total) .. " (" .. tostring(coverage) .. "%)"),
    ui.stat("Scored days", tostring(#data)),
  }, {appearance = {gap = 2}}),
  ui.text(summary),
}, {appearance = {gap = 2}})

local chart = nb.chart(data, {
  x = "day",
  y = "sentiment",
  kind = "line",
  title = "Daily mean sentiment (-1 to +1)",
  height = 260,
})
ui.render(chart.renderer, chart)
ui.text("Generated from scored :diary: metadata only; no journal prose is embedded in this report.", {
  appearance = {tone = "neutral", density = "compact"},
})
]]

  return table.concat(lines, "\n")
end

local diary_blocks = nb.query_all({
  tags = {SOURCE_TAG},
  block_type = "note",
  limit = 10000,
})

local context_page = (context and context.page) or ""
local target_month = string.match(TARGET_MONTH, "^(%d%d%d%d%-%d%d)$")
  or string.match(context_page, "^journal/(%d%d%d%d%-%d%d)")
local latest_month = nil

for _, block in ipairs(diary_blocks) do
  local page_title = block.page_title or ""
  local month = string.match(page_title, "^journal/(%d%d%d%d%-%d%d)")
  if month and (latest_month == nil or month > latest_month) then
    latest_month = month
  end
end

target_month = target_month or latest_month

if target_month == nil then
  ui.alert("No :diary: blocks were found.", {
    variant = "info",
    title = "Monthly diary sentiment",
  })
else
  local days = {}
  local total = 0
  local scored = 0
  local score_sum = 0

  for _, block in ipairs(diary_blocks) do
    local page_title = block.page_title or ""
    local date = string.match(page_title, "^journal/(%d%d%d%d%-%d%d%-%d%d)")
    if date and string.sub(date, 1, 7) == target_month then
      total = total + 1
      local metadata = block.metadata or {}
      local properties = metadata.properties or {}
      local sentiment = tonumber(properties[SCORE_FIELD])
      if sentiment == nil then sentiment = tonumber(metadata[SCORE_FIELD]) end
      if sentiment ~= nil then
        local day = days[date] or {sum = 0, count = 0}
        day.sum = day.sum + sentiment
        day.count = day.count + 1
        days[date] = day
        scored = scored + 1
        score_sum = score_sum + sentiment
      end
    end
  end

  if scored == 0 then
    ui.alert(
      "No scored :diary: blocks for " .. target_month ..
        ". This view will fill automatically once diary scoring is enabled.",
      {
        variant = "info",
        title = "Monthly diary sentiment",
      }
    )
  else
    local dates = {}
    for date, _ in pairs(days) do
      dates[#dates + 1] = date
    end
    table.sort(dates)

    local rows = {}
    for _, date in ipairs(dates) do
      local item = days[date]
      rows[#rows + 1] = {
        date = date,
        day = string.sub(date, 9, 10),
        sentiment = rounded(item.sum / item.count, 6),
        entries = item.count,
      }
    end

    local monthly_mean = score_sum / scored
    local coverage = total > 0 and math.floor((scored / total) * 100 + 0.5) or 0
    local month_tag = "sentiment/month/" .. target_month
    local existing_reports = nb.query_all({
      block_type = "code",
      tags = {REPORT_TAG, month_tag},
      limit = 20,
    })
    local existing = existing_reports[1]
    local signature = build_signature(target_month, total, scored, rows)
    local fallback_summary = deterministic_summary(target_month, monthly_mean, coverage, rows)
    local summary = fallback_summary
    local summary_mode_used = "deterministic"

    local existing_metadata = existing and existing.metadata or {}
    local can_reuse_summary = not FORCE_RESUMMARIZE
      and existing_metadata.sentiment_report_signature == signature
      and existing_metadata.sentiment_report_requested_mode == SUMMARY_MODE
      and type(existing_metadata.sentiment_report_summary) == "string"
      and existing_metadata.sentiment_report_summary ~= ""

    if can_reuse_summary then
      summary = existing_metadata.sentiment_report_summary
      summary_mode_used = existing_metadata.sentiment_report_summary_mode or SUMMARY_MODE
    elseif SUMMARY_MODE == "ai" then
      local prompt = build_aggregate_prompt(
        target_month,
        monthly_mean,
        coverage,
        total,
        scored,
        rows
      )
      local ok, response = pcall(function()
        return nb.ai(prompt, {
          system = "You write careful, non-clinical summaries of numeric sentiment aggregates.",
          max_tokens = 220,
          schema = {summary = "str"},
        })
      end)
      if ok and type(response) == "table" and type(response.summary) == "string"
        and response.summary ~= "" then
        summary = response.summary
        summary_mode_used = "ai"
      end
    end

    ui.column({
      ui.heading("Monthly diary sentiment · " .. target_month, 3),
      ui.row({
        ui.stat("Mean", string.format("%+.2f", monthly_mean)),
        ui.stat("Coverage", tostring(scored) .. "/" .. tostring(total) .. " (" .. tostring(coverage) .. "%)"),
        ui.stat("Scored days", tostring(#rows)),
      }, {appearance = {gap = 2}}),
      ui.text(summary),
    }, {appearance = {gap = 2}})

    local chart = nb.chart(rows, {
      x = "day",
      y = "sentiment",
      kind = "line",
      title = "Daily mean sentiment (-1 to +1)",
      height = 260,
    })
    ui.render(chart.renderer, chart)

    local report_tags = {"sentiment", REPORT_TAG, month_tag}
    push_unique(report_tags, OUTPUT_TAG)
    local report_metadata = {
      auto_run = true,
      nb_ctx = true,
      collapsed = true,
      sentiment_report_month = target_month,
      sentiment_report_version = 1,
      sentiment_report_source_tag = SOURCE_TAG,
      sentiment_report_score_field = SCORE_FIELD,
      sentiment_report_total = total,
      sentiment_report_scored = scored,
      sentiment_report_coverage = coverage,
      sentiment_report_signature = signature,
      sentiment_report_requested_mode = SUMMARY_MODE,
      sentiment_report_summary_mode = summary_mode_used,
      sentiment_report_summary = summary,
      sentiment_report_publish_tag = OUTPUT_TAG,
    }
    local report_code = build_frozen_report_code(
      target_month,
      summary,
      monthly_mean,
      coverage,
      total,
      scored,
      rows
    )
    local report_title = "Monthly diary sentiment · " .. target_month

    nb.ensure_page(REPORT_PAGE)
    if existing ~= nil then
      nb.update(existing.id, {
        content = report_code,
        title = report_title,
        tags = report_tags,
        metadata = report_metadata,
      })
      ui.alert("Updated the saved report on " .. REPORT_PAGE .. ".", {
        variant = "success",
        title = report_title,
      })
    else
      nb.spawn(report_code, {
        page = REPORT_PAGE,
        block_type = "code",
        title = report_title,
        tags = report_tags,
        metadata = report_metadata,
      })
      ui.alert("Created the saved report on " .. REPORT_PAGE .. ".", {
        variant = "success",
        title = report_title,
      })
    end

    if OUTPUT_TAG == "" then
      ui.text("OUTPUT_TAG is empty, so the saved report remains private.", {
        appearance = {tone = "neutral", density = "compact"},
      })
    else
      ui.text("Saved report tagged :" .. OUTPUT_TAG .. ": for the published page with that title.", {
        appearance = {tone = "neutral", density = "compact"},
      })
    end
  end
end