sentiment analysis visualization
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—
Daily scores
| date | score | label |
|---|---|---|
| 2026-10-01 | -0.5 | Negative |
| 2026-10-02 | 0 | Neutral |
| 2026-10-04 | 0.2 | Neutral |
| 2026-10-05 | 0.55 | Positive |
| 2026-10-08 | 0.85 | Very positive |
| 2026-10-11 | -0.1 | Slightly 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
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—
Daily scores
| date | score | label |
|---|
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
Monthly diary sentiment · 2026-11
-0.57Mean
5/8 (63%)Coverage
5Scored 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