blog/lifelab/what is lifelab data scientist edition
code/sentiment analysis visualization
renderer
Sentiment Calendar Heatmap
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 data query
Monthly View
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
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
})
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()