Sentiment Calendar Heatmap:renderer:
Show code
function SentimentCalendarHeatmap({ data, monthLabel = 'Monthly View', daysInMonth = 31 }) {
  const [hoveredDay, setHoveredDay] = useState(null);

  if (!data || Object.keys(data).length === 0) {
    return React.createElement('div', {
      style: { padding: 20, color: css.textMuted, textAlign: 'center' }
    }, 'No sentiment data available');
  }

  // Build a map of day-of-month -> { score, date string }
  const dayMap = {};
  Object.entries(data).forEach(([dateStr, value]) => {
    const parts = dateStr.split('-');
    const day = parseInt(parts[2], 10);
    if (!isNaN(day) && day >= 1 && day <= daysInMonth) {
      dayMap[day] = {
        score: typeof value === 'object' ? value.avg || value.score || 0 : value,
        dateStr: dateStr
      };
    }
  });

  const getColor = (score) => {
    if (score === null || score === undefined) return css.bgSecondary;
    const s = Math.max(-1, Math.min(1, score));
    if (s < -0.3) return css.error;
    if (s < 0) return css.warning;
    if (s < 0.3) return css.bgSecondary;
    if (s < 0.6) return css.success;
    return css.primary;
  };

  const getLabel = (score) => {
    if (score === null || score === undefined) return 'No data';
    const s = Math.max(-1, Math.min(1, score));
    if (s < -0.3) return 'Negative';
    if (s < 0) return 'Slightly negative';
    if (s < 0.3) return 'Neutral';
    if (s < 0.6) return 'Positive';
    return 'Very positive';
  };

  const days = [];
  for (let d = 1; d <= daysInMonth; d++) {
    days.push(d);
  }

  const rows = [];
  const totalCells = Math.ceil(daysInMonth / 7) * 7;
  const numRows = Math.ceil(daysInMonth / 7);
  
  for (let r = 0; r < numRows; r++) {
    const cells = [];
    for (let c = 0; c < 7; c++) {
      const dayNum = r * 7 + c + 1;
      if (dayNum > daysInMonth) {
        cells.push(React.createElement('div', {
          key: 'empty-' + r + '-' + c,
          style: { width: 40, height: 40 }
        }));
      } else {
        const entry = dayMap[dayNum];
        const score = entry ? entry.score : null;
        const color = getColor(score);
        const isHovered = hoveredDay === dayNum;
        cells.push(React.createElement('div', {
          key: 'day-' + dayNum,
          onMouseEnter: () => setHoveredDay(dayNum),
          onMouseLeave: () => setHoveredDay(null),
          style: {
            width: 40,
            height: 40,
            borderRadius: 6,
            backgroundColor: color,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            cursor: 'default',
            border: isHovered ? '2px solid ' + css.text : '2px solid transparent',
            transition: 'border-color 0.15s',
            position: 'relative'
          }
        },
          React.createElement('span', {
            style: { fontSize: 12, color: css.text, fontWeight: entry ? 600 : 400 }
          }, dayNum),
          isHovered && entry ? React.createElement('div', {
            style: {
              position: 'absolute',
              bottom: 46,
              left: '50%',
              transform: 'translateX(-50%)',
              backgroundColor: css.bgPanel,
              border: '1px solid ' + css.border,
              borderRadius: 6,
              padding: '6px 10px',
              whiteSpace: 'nowrap',
              zIndex: 10,
              fontSize: 12,
              color: css.text,
              boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
            }
          },
            React.createElement('div', { style: { fontWeight: 600 } }, entry.dateStr),
            React.createElement('div', null, getLabel(score) + ' (' + score.toFixed(2) + ')')
          ) : null
        ));
      }
    }
    rows.push(React.createElement('div', {
      key: 'row-' + r,
      style: { display: 'flex', gap: 4 }
    }, ...cells));
  }

  const legendItems = [
    { label: 'Negative', color: css.error },
    { label: 'Slightly negative', color: css.warning },
    { label: 'Neutral', color: css.bgSecondary },
    { label: 'Positive', color: css.success },
    { label: 'Very positive', color: css.primary }
  ];

  return React.createElement('div', {
    style: { padding: 16, fontFamily: 'inherit' }
  },
    React.createElement('div', {
      style: {
        textAlign: 'center',
        marginBottom: 12,
        fontSize: 16,
        fontWeight: 600,
        color: css.textHeading
      }
    }, monthLabel),
    React.createElement('div', {
      style: { display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'center' }
    }, ...rows),
    React.createElement('div', {
      style: {
        display: 'flex',
        gap: 12,
        justifyContent: 'center',
        marginTop: 14,
        flexWrap: 'wrap'
      }
    }, ...legendItems.map(item =>
      React.createElement('div', {
        key: item.label,
        style: { display: 'flex', alignItems: 'center', gap: 4 }
      },
        React.createElement('div', {
          style: {
            width: 14,
            height: 14,
            borderRadius: 3,
            backgroundColor: item.color
          }
        }),
        React.createElement('span', {
          style: { fontSize: 11, color: css.textMuted }
        }, item.label)
      )
    ))
  );
}
Sentiment analysis visualisation - matplotlib version:blog/lifelab/what is lifelab data scientist edition:
fig done
Output
findfont: Failed to find font weight medium, now using 400.
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()
Generate monthly diary sentiment:sentiment::analytics::code/sentiment-analysis-visualization:

Monthly diary sentiment · 2026-09

-0.15Mean
6/6 (100%)Coverage
6Scored days
runtime error: UI contract at $.props.appearance.gap must be one of [0,1,2,3,4,6,8]
stack traceback:
	[C]: in field 'row'
	lifelab-max/src/runtime/lua.rs:652:289: in main chunk
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 = 12}}),
  ui.text(summary),
}, {appearance = {gap = 8}})

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 sentiment = tonumber(metadata[SCORE_FIELD])
      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 = 12}}),
      ui.text(summary),
    }, {appearance = {gap = 8}})

    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,
        language = "lua",
        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",
        language = "lua",
        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