Sentiment analysis visualisation - matplotlib version
fig done
Output
Show code
from datetime import datetime
import calendar
from collections import defaultdict
import json
import matplotlib.pyplot as plt
import matplotlib.patches as patches
from matplotlib.colors import LinearSegmentedColormap


def get_theme_colors(nb):
    """Fetch current CSS theme colors from settings."""
    # FIX 1: query_all() takes a dict, not keyword args
    blocks = nb.query_all(block_type="note",
        metadata = {'css_enabled': True}
    )
    if blocks:
        theme = blocks[0]['metadata']['variables']
        return theme
    return None


def sentiment_color(score, has_data=True, theme=None):
    """Map sentiment score using theme colors."""
    if theme is None:
        theme = {
            '--bg-hover': '#f2e9e1',
            '--hue-error': '#b4637a',
            '--hue-success': '#286983',
        }

    if not has_data:
        return theme['--bg-hover']

    score = max(-1, min(1, score))

    colors = [theme['--hue-error'], theme['--bg-hover'], theme['--hue-success']]
    positions = [0, 0.5, 1]
    cmap = LinearSegmentedColormap.from_list('sentiment', list(zip(positions, colors)))

    normalized = (score + 1) / 2
    rgba = cmap(normalized)
    return '#{:02x}{:02x}{:02x}'.format(int(rgba[0]*255), int(rgba[1]*255), int(rgba[2]*255))


def generate_month(blocks, year, month, nb, figsize=(8, 7)):
    """Generate sentiment calendar using theme colors."""

    theme = get_theme_colors(nb)
    if theme is None:
        theme = {
            '--bg-app': '#faf4ed',
            '--bg-hover': '#f2e9e1',
            '--text-body': '#575279',
            '--text-muted': '#797593',
            '--hue-error': '#b4637a',
            '--hue-success': '#286983',
        }

    # Group scores by day
    daily_scores = defaultdict(list)

    for block in blocks:
        meta = block.get("metadata") or {}
        if "sentiment" not in meta:
            continue

        page_title = block.get("page_title", "")
        if page_title.startswith("journal/"):
            try:
                day = int(page_title.split("-")[-1])
                score = meta["sentiment"]
                if isinstance(score, str):
                    score = float(score)
                daily_scores[day].append(score)
            except (ValueError, IndexError):
                continue

    daily_avg = {day: sum(s)/len(s) for day, s in daily_scores.items()}

    # Build calendar grid
    cal = calendar.Calendar(firstweekday=6)
    month_name = calendar.month_name[month]

    fig, ax = plt.subplots(figsize=figsize, facecolor=theme['--bg-app'])
    ax.set_facecolor(theme['--bg-app'])
    ax.set_xlim(0, 7)
    ax.set_ylim(-0.8, 7)
    ax.set_aspect('equal')
    ax.axis('off')

    # Title
    ax.text(3.5, 6.5, f"{month_name} {year}", ha='center', va='center',
            fontsize=22, color=theme['--text-body'], fontweight='bold')

    # Day headers
    days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
    for i, day in enumerate(days):
        ax.text(i + 0.5, 5.75, day, ha='center', va='center',
                fontsize=10, color=theme['--text-muted'], fontweight='medium')

    # Calendar cells
    row = 5
    col = 0

    for date in cal.itermonthdates(year, month):
        if date.month != month:
            col += 1
            if col == 7:
                col = 0
                row -= 1
            continue

        has_data = date.day in daily_avg
        score = daily_avg.get(date.day)

        color = sentiment_color(score if score else 0, has_data, theme)

        # Cell
        rect = patches.FancyBboxPatch(
            (col + 0.05, row - 0.95), 0.9, 0.9,
            boxstyle="round,pad=0.02,rounding_size=0.08",
            facecolor=color, edgecolor='none'
        )
        ax.add_patch(rect)

        # Text color based on background
        txt_color = theme['--text-body'] if has_data else theme['--text-muted']

        # Day number
        ax.text(col + 0.5, row - 0.38, str(date.day),
                ha='center', va='center',
                fontsize=13, color=txt_color, fontweight='bold')

        # Score
        if has_data:
            ax.text(col + 0.5, row - 0.68, f"{score:+.1f}",
                    ha='center', va='center',
                    fontsize=8, color=txt_color, alpha=0.7)

        col += 1
        if col == 7:
            col = 0
            row -= 1

    # Legend
    legend_items = [
        ('Negative', theme['--hue-error']),
        ('Neutral', theme['--bg-hover']),
        ('Positive', theme['--hue-success'])
    ]
    for i, (label, c) in enumerate(legend_items):
        x = 1.5 + i * 2
        rect = patches.FancyBboxPatch(
            (x, -0.45), 0.3, 0.3,
            boxstyle="round,pad=0.02,rounding_size=0.05",
            facecolor=c, edgecolor='none'
        )
        ax.add_patch(rect)
        ax.text(x + 0.45, -0.3, label, va='center', fontsize=9, color=theme['--text-muted'])

    plt.tight_layout()
    return fig, ax


# --- Main execution ---
date = datetime.strptime("2025-12-01", "%Y-%m-%d")

first_day = date.replace(day=1)
last_day = date.replace(day=calendar.monthrange(date.year, date.month)[1])

blocks = nb.query_all({
    'metadata': {'source': 'dayone'},
    'date_from': first_day.strftime("%Y-%m-%d"),
    'date_to': last_day.strftime("%Y-%m-%d"),
})

fig, ax = generate_month(blocks, date.year, date.month, nb)
print("fig done")
plt.show()
Sentiment data query
February 2026
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
Negative
Slightly negative
Neutral
Positive
Very positive
Hide code
-- Query all blocks that have sentiment metadata and aggregate by date
local current_page = nb.current_page
local blocks

-- Check if we're on a journal page and extract date range
if current_page and current_page:match("^journal/") then
  local date_str = current_page:match("[^/]+$")  -- extract "2025-10-31"
  local year, month, day = date_str:match("(%d%d%d%d)%-(%d%d)%-(%d%d)")
  if year and month then
    year, month = tonumber(year), tonumber(month)
    local first_day = string.format("%04d-%02d-01", year, month)

    -- Compute last day of month
    local last_day = string.format("%04d-%02d-%02d", year, month, 31)

    blocks = nb.query_all({
      tags = {"dayone"},
      date_from = first_day,
      date_to = last_day
    })
  end
else
  -- Not on journal page, query February 2026
  blocks = nb.query_all({
    tags = {"dayone"},
    date_from = "2026-03-01",
    date_to = "2026-03-31"
  })
end

local by_date = {}
local counts = {}

for _, b in ipairs(blocks) do
  local d = b.metadata.date or b.date
  local s = tonumber(b.metadata.sentiment)
  if d and s then
    local key = string.sub(d, 1, 10)
    by_date[key] = (by_date[key] or 0) + s
    counts[key] = (counts[key] or 0) + 1
  end
end

local result = {}
for date, total in pairs(by_date) do
  result[date] = total / counts[date]
end

-- Extract month label and days in month from current context
local month_label = "Monthly View"
local days_in_month = 31

if current_page and current_page:match("^journal/") then
  local date_str = current_page:match("[^/]+$")
  local year, month = date_str:match("(%d%d%d%d)%-(%d%d)")
  if year and month then
    local month_names = {"January", "February", "March", "April", "May", "June",
                         "July", "August", "September", "October", "November", "December"}
    month_label = month_names[tonumber(month)] .. " " .. year
    
    -- Calculate days in month
    local days = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
    local y = tonumber(year)
    if tonumber(month) == 2 and (y % 4 == 0 and (y % 100 ~= 0 or y % 400 == 0)) then
      days_in_month = 29
    else
      days_in_month = days[tonumber(month)]
    end
  end
end

ui.render("sentiment-calendar-heatmap-v2", {
  data = result,
  monthLabel = month_label,
  daysInMonth = days_in_month
})