AgentStack
SKILL verified MIT Self-run

Obsidian Heatmap

skill-lexbritvin-obsidian-skills-pack-obsidian-heatmap · by lexbritvin

Build activity heatmaps in Obsidian — GitHub-style year overview via the Heatmap Calendar plugin (Richardsl), and custom DataviewJS heatmaps (weeks × days, rows × cols matrices, project activity grids) when the plugin is not enough. Use whenever the user asks about heatmaps, calendar visualizations, streaks, "did I do X every day", year-in-pixels, contribution graphs, GitHub-style activity views,…

No reviews yet
0 installs
3 views
0.0% view→install

Install

$ agentstack add skill-lexbritvin-obsidian-skills-pack-obsidian-heatmap

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of Obsidian Heatmap? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Obsidian heatmaps

Two complementary tools cover almost every heatmap need in Obsidian:

  1. Heatmap Calendar plugin (Richardsl) — GitHub-style year overview, one call from a dataviewjs block. Handles 365-day grid, month labels, weekday rows, today border, color gradients, click/hover. Use this first when the question is "did I do X on each day of the year".
  1. Custom DataviewJS heatmaps — anything the plugin cannot draw: weeks × 7 days for the last N weeks, rows × cols matrix (e.g. metrics × weeks), rows × N days (projects × dates), custom score functions, multi-metric tooltips, streaks. The plugin draws a year; custom DOM draws whatever shape you need.

Pick the plugin first; drop to custom only when the year frame is wrong or you need a non-calendar shape.

Decision: which heatmap

| Need | Tool | |---|---| | One year, one metric, "did I do X today" | Plugin | | One year, two-three metrics overlaid by color | Plugin (multi-color entries) | | Last N weeks (e.g. 14), GitHub-contribution shape | Custom DataviewJS — weeks × 7 days | | Rows × cols matrix (metrics × weeks, projects × months) | Custom DataviewJS — grid | | Rows × days (projects × dates with sticky row labels) | Custom DataviewJS — activity grid | | Cell holds an emoji / number / link, not just intensity | Plugin (content field) or custom | | Multiple years on one screen | Two plugin blocks, one per year | | "Cell is a task you can click" | Custom DOM with click handlers |

If the user shows a GitHub contributions screenshot — that is the last 53 weeks scrolling shape, not a calendar year. The plugin always renders Jan 1 → Dec 31 of year. For a rolling-N-weeks contribution graph use the custom pattern.

Part A — Heatmap Calendar plugin (Richardsl)

Repo: Richardsl/heatmap-calendar-obsidian. Install via Community plugins in Obsidian. Exposes a single global function on plugin load.

API surface

renderHeatmapCalendar(container, calendarData)

container is the DOM element to render into — from a dataviewjs block use this.container. The function is on window, so just call renderHeatmapCalendar(...).

calendarData fields

| Field | Type | Default | Notes | |---|---|---|---| | entries | array | required | One object per day with data; missing days render as empty | | year | number | current | Plugin filters entries to this year (new Date(date).getFullYear() === year) | | colors | string \| object | "default" | Either name a palette saved in plugin settings, or pass an inline object | | showCurrentDayBorder | boolean | true | Outlines today's cell | | defaultEntryIntensity | number | 4 | Used for entries without intensity | | intensityScaleStart | number | min(intensities) | Lower bound mapped to color level 1 | | intensityScaleEnd | number | max(intensities) | Upper bound mapped to top color level | | separateMonths | boolean | false | Adds spacing between months for per-month styling |

Entry object

{
  date: "YYYY-MM-DD",   // required, must be ISO; daily-note filenames work directly
  intensity: 3,         // optional, falls back to defaultEntryIntensity
  content: "🏋️",       // optional, rendered inside the cell (emoji, text, dv.span(...))
  color: "green"        // optional, key into colors object; defaults to first key
}

Color palettes

Each palette is an array of HEX or rgba() strings — one per intensity level. More entries = finer gradient.

colors: {
  green: ["#c6e48b", "#7bc96f", "#49af5d", "#2e8840", "#196127"],   // GitHub default
  blue:  ["#8cb9ff", "#69a3ff", "#428bff", "#1872ff", "#0058e2"],
  red:   ["#ffb3b3", "#ff7373", "#ff3333", "#cc0000", "#800000"],
}

Two ways to pass:

  • Inline — pass the object directly in calendarData.colors. Self-contained, lives in the note.
  • Named — define the palette once in Settings → Heatmap Calendar, then pass colors: "myPalette" (string). Reusable across notes; survives moving the block.

How intensity → color level works: plugin scales each entry's intensity from [intensityScaleStart, intensityScaleEnd] to [1, palette.length], rounds, and picks the corresponding HEX. So a palette of 5 colors gives 5 visible levels regardless of raw values.

CSS hooks

The plugin renders a known DOM tree you can style via Settings → Appearance → CSS snippets:

| Selector | What | |---|---| | .heatmap-calendar-graph | The whole container | | .heatmap-calendar-year | Two-digit year label (top-left) | | .heatmap-calendar-months | Month axis (Jan…Dec) | | .heatmap-calendar-days | Weekday axis (Mon…Sun) | | .heatmap-calendar-boxes | The 7 × 53 cell grid | | .heatmap-calendar-boxes li.today | Current day's cell | | .heatmap-calendar-boxes li.hasData | Cells with entries | | .heatmap-calendar-boxes li.isEmpty | Cells without entries | | .heatmap-calendar-boxes li.month-jan.month-dec | Per-month styling | | .heatmap-calendar-content | The content text/emoji span inside a cell | | [data-date="YYYY-MM-DD"] | Pick a specific cell by date |

Cell background-color is inlined — to override, use !important in your snippet.

Minimal example

const data = {
  year: 2026,
  entries: dv.pages('"Daily"')
    .where(p => p.exercise)
    .map(p => ({
      date: p.file.name,           // daily-note name = "YYYY-MM-DD"
      intensity: p.exercise,
      content: "🏋️",
    }))
    .array(),
};
renderHeatmapCalendar(this.container, data);

Multi-color overlay

Two metrics on the same year — entries with the same date can both render (last one wins per cell, so prefer disjoint days or accept overwrite):

const data = {
  year: 2026,
  colors: {
    green: ["#c6e48b", "#7bc96f", "#49af5d", "#2e8840", "#196127"],
    blue:  ["#8cb9ff", "#69a3ff", "#428bff", "#1872ff", "#0058e2"],
  },
  intensityScaleStart: 1,
  intensityScaleEnd: 60,
  entries: [],
};

for (const p of dv.pages('"Daily"').where(p => p.exercise)) {
  data.entries.push({ date: p.file.name, intensity: p.exercise, content: "🏋️", color: "green" });
}
for (const p of dv.pages('"Daily"').where(p => p.meditation)) {
  data.entries.push({ date: p.file.name, intensity: p.meditation, content: "🧘", color: "blue" });
}

renderHeatmapCalendar(this.container, data);

When two metrics matter equally per day, don't overlay — split into two heatmap blocks (one per metric) stacked vertically. Overlay is for highlighting outliers, not comparison.

Pick a data source first

The plugin doesn't care where data comes from — but the choice of source shapes everything else (tracking friction, edit ergonomics, what queries are cheap). Pick deliberately:

| Data you have | Best source | Reasoning | |---|---|---| | One scalar metric per day, you keep daily notes anyway | frontmatter on daily note (workout: 3) | Cell click jumps to the note; rich context per day | | Multiple metrics per day, no daily note overhead | inline-fields in monthly log (- YYYY-MM-DD [pages:: 32] [minutes:: 45]) | One file per month, all metrics on one row, low-friction edit | | Already tracking via Tasks plugin | task ✅ YYYY-MM-DD markers | Reuses existing task discipline; intensity = count of completions | | Pure binary habit ("did X today?") | tag presence on daily note (#workout in 2026-04-25.md) | Lightweight; one tag per day per habit; no field schema | | "How many things of kind X happened on day Y?" | count of pages tagged #X with cday on Y | Works without daily notes; aggregates any tagged content |

These are not exclusive — you can run two heatmap blocks side by side, one driven by frontmatter and another by tags. Don't pick more than one source for the same metric in the same heatmap; mixed sources produce inconsistent results when the same date is recorded twice.

Pattern — entries from frontmatter

Daily-note frontmatter is the cleanest source: one note per day, named YYYY-MM-DD.md, frontmatter has exercise: 3 etc.

renderHeatmapCalendar(this.container, {
  year: dv.date("today").year,
  entries: dv.pages('"Daily"')
    .where(p => p.workout != null)
    .map(p => ({ date: p.file.name, intensity: p.workout, content: "🏋️" }))
    .array(),
});

Pattern — entries from inline-fields in monthly logs

When you don't keep a daily note per day but log inside a monthly file using flat bullets — for example, a reading log:

- 2026-04-25 [pages:: 32] [minutes:: 45]
- 2026-04-26 [pages:: 18] [minutes:: 25]
- 2026-04-27 [pages:: 60] [minutes:: 90]

Dataview parses these into list items with date in text and inline-fields as keys. Score them and render:

// Pages is the goal; minutes adds a small bonus so a long careful read still registers
const score = e => (e.pages ?? 0) + (e.minutes ?? 0) * 0.3;

const entries = dv.pages('"Logs/Monthly"')
  .file.lists
  .where(l => /^\d{4}-\d{2}-\d{2}/.test(l.text))
  .map(l => {
    const date = l.text.match(/^\d{4}-\d{2}-\d{2}/)[0];
    return { date, intensity: score(l), content: "📖" };
  })
  .array();

renderHeatmapCalendar(this.container, { year: dv.date("today").year, entries });

Adapt the schema to whatever you track — running (km, minutes), writing (words, sessions), language study (new_words, minutes). The score function is where you decide what the cell intensity means: pure output, time-weighted, or composite.

Pattern — entries from task counts

Cell intensity = number of tasks completed on that day, from ✅ YYYY-MM-DD markers in any task:

const byDate = new Map();
for (const t of dv.pages().file.tasks.where(t => t.completed && t.completion)) {
  const d = t.completion.toString().slice(0, 10);
  byDate.set(d, (byDate.get(d) ?? 0) + 1);
}
const entries = Array.from(byDate, ([date, n]) => ({ date, intensity: n, content: "" }));
renderHeatmapCalendar(this.container, { year: dv.date("today").year, entries });

Pattern — entries from tag presence

Two flavors, depending on what "presence" means.

Binary habit on daily notes — daily note named YYYY-MM-DD.md has tag #workout → that day is "done":

const TAG = "#workout";
const entries = dv.pages('"Daily"')
  .where(p => p.file.tags.includes(TAG))
  .map(p => ({ date: p.file.name, intensity: 1, content: "🏋️" }))
  .array();
renderHeatmapCalendar(this.container, {
  year: dv.date("today").year,
  entries,
  intensityScaleStart: 0,
  intensityScaleEnd: 1,    // binary — every "done" day uses the top color
});

p.file.tags includes nested tags too (#workout/morning matches #workout). For exact-match only, compare manually: p.file.tags.some(t => t === TAG).

Count of tagged pages per day — intensity = number of pages with #read whose creation date falls on day Y. Works without per-day notes:

const TAG = "#read";
const byDate = new Map();
for (const p of dv.pages(TAG)) {
  const d = p.file.cday.toFormat("yyyy-MM-dd");
  byDate.set(d, (byDate.get(d) ?? 0) + 1);
}
const entries = Array.from(byDate, ([date, n]) => ({ date, intensity: n, content: "📖" }));
renderHeatmapCalendar(this.container, { year: dv.date("today").year, entries });

p.file.cday is creation day; p.file.mday is last-modified day. For "what did I read today" use cday; for "what did I touch today" use mday. If your note has an explicit date: frontmatter, prefer that — file timestamps reflect filesystem operations, not the event the note describes.

Multi-tag overlay — one heatmap, many tags, color per tag:

const TAGS = [
  { tag: "#workout",    color: "orange", emoji: "🏋️" },
  { tag: "#meditation", color: "blue",   emoji: "🧘" },
  { tag: "#read",       color: "green",  emoji: "📖" },
];
const entries = [];
for (const { tag, color, emoji } of TAGS) {
  for (const p of dv.pages('"Daily"').where(p => p.file.tags.includes(tag))) {
    entries.push({ date: p.file.name, intensity: 1, content: emoji, color });
  }
}
renderHeatmapCalendar(this.container, {
  year: dv.date("today").year,
  colors: {
    orange: ["#ffa244", "#fd7f00"],
    blue:   ["#8cb9ff", "#428bff"],
    green:  ["#7bc96f", "#196127"],
  },
  entries,
});

Same caveat as multi-color overlay: when two tags hit the same day, the last entry in the array wins the cell. Order TAGS so the more important tag goes last, or split into separate heatmaps.

Caveats with the plugin

  • One year per call. year filters entries; cross-year data renders as empty in the wrong year. Stack two blocks for two years.
  • renderHeatmapCalendar may be undefined on first paint if the plugin loads after the dataview block. Guard with if (typeof renderHeatmapCalendar === 'function') {...} and reload the note. Persistent issue → set "Show ribbon icon" off and on, or restart Obsidian.
  • Daily-note name must be YYYY-MM-DD. If your daily notes use a different format, derive the date string explicitly (p.date from frontmatter, or dv.func.dateformat(p.file.day, "yyyy-MM-dd")).
  • Entries with the same date overwrite. Last entry in array wins. If you build entries in a loop, latest insert is what renders.
  • content is a string, not HTML. For interactive content (links to the daily note), pass content: await dv.span(\[[\${p.file.name}]]\) and the dataview span renders inline.
  • Hover preview needs Page Preview enabled. Core plugin → Page Preview → toggle "Source mode" / "Reading view" depending on where the heatmap renders.
  • Color array length determines resolution. 3 colors → 3 visible levels; 5 → 5; 10 → smooth gradient. Don't pass 100 — eyes can't tell.
  • intensityScaleStart === intensityScaleEnd (or all entries have the same intensity) makes every cell the top color. Set explicit start/end if your data is bimodal.
  • separateMonths: true breaks the visual rhythm of the GitHub layout — only enable if you want per-month dividers for styling.

When to override colors via CSS instead of colors field

Use colors field when each block has its own palette. Use a CSS snippet when:

  • You want the same palette across the entire vault and tire of repeating it.
  • You want CSS variables driven by the active theme (light/dark).
  • You want to tweak .isEmpty background (the inline background-color on .hasData overrides palette, but .isEmpty has only the CSS class, easily themable).
/* CSS snippet — empty cells barely visible in dark theme */
.theme-dark .heatmap-calendar-boxes li.isEmpty {
  background-color: rgba(148, 163, 184, 0.06);
}
.heatmap-calendar-boxes li.today {
  outline: 1.5px solid var(--interactive-accent) !important;
}

Part B — Custom DataviewJS heatmaps

When the year-frame doesn't fit. Three reusable shapes cover most needs.

Pattern B1 — weeks × 7 days, last N weeks (GitHub-contribution shape)

This is what most "GitHub-style" requests actually want: rolling N weeks, scroll cuts off naturally, today on the right.

const WEEKS = 14;
const MAX = 60;                          // 60 pages saturates the color
const score = e => e.pages ?? 0;

// Source: flat bullets `- YYYY-MM-DD [pages:: N] [minutes:: M]`
const entries = [];
for (const l of dv.pages('"Logs/Monthly"').file.lists) {
  const m = l.text.match(/^(\d{4}-\d{2}-\d{2})/);
  if (!m) continue;
  entries.push({ date: m[1], pages: l.pages ?? 0, minutes: l.minutes ?? 0 });
}
const byDate = new Map(entries.map(e => [e.date, e]));

const todayDt = dv.date("today");
const start = todayDt.startOf("week").minus({ weeks: WEEKS - 1 });
const wrap = this.container.createEl("div", { attr: { style: "overflow-x: auto; padding: 8px 0;" } });
const flex = wrap.createEl("div", { attr: { style: "display: flex; gap: 3px; align-items: flex-start;" } });

// Day-of-week label column (Mon, Wed, Fri)
const dayCol = flex.createEl("div", { attr: { style: "display: flex; flex-direction: column; gap: 3px; padding

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [lexbritvin](https://github.com/lexbritvin)
- **Source:** [lexbritvin/obsidian-skills-pack](https://github.com/lexbritvin/obsidian-skills-pack)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.