Heatmap TrackerCustom statistics

Custom statistics for your Obsidian heatmap

The grid shows you when. An insight answers how much, how often and how long — in the Statistics tab, next to the built-in totals. Twelve you can copy, plus the rules for writing your own.

What an insight is

A name and a function. The function gets that year's entries and returns a string; the plugin prints it in the Statistics tab under the heatmap. That is the whole API — there is no reporting language to learn, because it is just JavaScript over an array.

Insights live in a dataviewjs block rather than the heatmap-tracker codeblock, for the boring reason that YAML cannot hold a function. Everything else about the heatmap works the same way:

any note
```dataviewjs
const trackerData = {
  entries: [],
  insights: [
    {
      name: "Days logged",
      calculate: ({ yearEntries }) => yearEntries.length.toString(),
    },
  ],
};

for (let page of dv.pages('"daily notes"').where((p) => p.steps)) {
  trackerData.entries.push({
    date: page.file.name,
    filePath: page.file.path,
    intensity: page.steps,
  });
}

renderHeatmapTracker(this.container, trackerData);
```

Each entry in yearEntries carries date (YYYY-MM-DD), value (the number read from your note), intensity (the level it was mapped to on the color scale) and filePath. Every recipe below is one object you drop into that insights array — several at once is fine, each becomes its own line.

Totals and averages

The numbers you'd reach for a calculator to get.

🧮 Total for the year

Total hours slept, pages read, kilometres run — one line, any unit.

insights: [ … ]
{
  name: "Total",
  calculate: ({ yearEntries }) =>
    yearEntries
      .reduce((sum, e) => sum + (e.value || 0), 0)
      .toString(),
}

📊 Average per logged day

The mean across days that have an entry — blanks don't drag it down.

insights: [ … ]
{
  name: "Average per day",
  calculate: ({ yearEntries }) => {
    if (!yearEntries.length) return "No data";
    const total = yearEntries.reduce((s, e) => s + (e.value || 0), 0);
    return (total / yearEntries.length).toFixed(2);
  },
}

🏆 Best day of the year

The single date you scored highest — the one worth remembering.

insights: [ … ]
{
  name: "Best day",
  calculate: ({ yearEntries }) => {
    if (!yearEntries.length) return "No data";
    const best = yearEntries.reduce((max, e) =>
      (e.value || 0) > (max.value || 0) ? e : max);
    return `${best.value} on ${best.date}`;
  },
}

Streaks and goals

Did you keep it up, and how often did you clear the bar.

🔥 Longest streak above a goal

Consecutive logged days that met your target. Change the number, change the goal.

insights: [ … ]
{
  name: "Longest streak",
  calculate: ({ yearEntries }) => {
    const goal = 8000;
    let streak = 0, max = 0;
    yearEntries.forEach((e) => {
      streak = e.value >= goal ? streak + 1 : 0;
      max = Math.max(max, streak);
    });
    return max.toString();
  },
}

🎯 Days you hit the goal

How many days cleared the bar — the number most people actually want.

insights: [ … ]
{
  name: "Days at goal",
  calculate: ({ yearEntries }) =>
    yearEntries.filter((e) => e.value >= 8000).length.toString(),
}

🕳️ Longest gap

The longest run of days with nothing logged — where the habit broke.

insights: [ … ]
{
  name: "Longest gap",
  calculate: ({ yearEntries }) => {
    const dates = yearEntries
      .map((e) => new Date(e.date).getTime())
      .sort((a, b) => a - b);
    let max = 0;
    for (let i = 1; i < dates.length; i++) {
      const gap = (dates[i] - dates[i - 1]) / 86400000 - 1;
      max = Math.max(max, gap);
    }
    return `${max} days`;
  },
}

Patterns in time

The grid hints at these; an insight states them.

📅 Most active weekday

Which day of the week you log most often. Usually not the one you'd guess.

insights: [ … ]
{
  name: "Most active weekday",
  calculate: ({ yearEntries }) => {
    const counts = {};
    if (!yearEntries.length) return "No data";
    yearEntries.forEach((e) => {
      const day = new Date(e.date)
        .toLocaleDateString("en-US", { weekday: "long" });
      counts[day] = (counts[day] || 0) + 1;
    });
    return Object.entries(counts)
      .reduce((a, b) => (b[1] > a[1] ? b : a))[0];
  },
}

🥇 Most active month

The month you showed up most — seasonality you can't see in the grid alone.

insights: [ … ]
{
  name: "Most active month",
  calculate: ({ yearEntries }) => {
    const months = {};
    if (!yearEntries.length) return "No data";
    yearEntries.forEach((e) => {
      const m = new Date(e.date)
        .toLocaleDateString("en-US", { month: "long" });
      months[m] = (months[m] || 0) + (e.value || 0);
    });
    return Object.entries(months)
      .reduce((a, b) => (b[1] > a[1] ? b : a))[0];
  },
}

🏖️ Weekend vs weekday

Whether your habit survives Saturday. Averages, side by side.

insights: [ … ]
{
  name: "Weekend vs weekday",
  calculate: ({ yearEntries }) => {
    const avg = (list) =>
      list.length
        ? list.reduce((s, e) => s + (e.value || 0), 0) / list.length
        : 0;
    const isWeekend = (e) => [0, 6].includes(new Date(e.date).getDay());
    const we = avg(yearEntries.filter(isWeekend));
    const wd = avg(yearEntries.filter((e) => !isWeekend(e)));
    return `${wd.toFixed(1)} weekday · ${we.toFixed(1)} weekend`;
  },
}

Progress and consistency

Whether this year is going anywhere.

📈 Second half vs first

Are you trending up or coasting? Compares the two halves of the year.

insights: [ … ]
{
  name: "Trend",
  calculate: ({ yearEntries }) => {
    const mid = new Date(yearEntries[0]?.date).getFullYear() + "-07-01";
    const avg = (list) =>
      list.length
        ? list.reduce((s, e) => s + (e.value || 0), 0) / list.length
        : 0;
    const h1 = avg(yearEntries.filter((e) => e.date < mid));
    const h2 = avg(yearEntries.filter((e) => e.date >= mid));
    const diff = h1 ? ((h2 - h1) / h1) * 100 : 0;
    return `${diff >= 0 ? "+" : ""}${diff.toFixed(0)}%`;
  },
}

🗓️ Coverage of the year

What share of days you actually logged — the honest adherence number.

insights: [ … ]
{
  name: "Days logged",
  calculate: ({ yearEntries }) => {
    if (!yearEntries.length) return "No data";
    const year = new Date(yearEntries[0].date).getFullYear();
    const days = new Date(year, 1, 29).getDate() === 29 ? 366 : 365;
    const pct = (yearEntries.length / days) * 100;
    return `${yearEntries.length} / ${days} (${pct.toFixed(0)}%)`;
  },
}

📉 Intensity distribution

How many days land on each color of your scale, as one line.

insights: [ … ]
{
  name: "Distribution",
  calculate: ({ yearEntries }) => {
    const dist = {};
    yearEntries.forEach((e) => {
      const i = Number(e.intensity) || 0;
      dist[i] = (dist[i] || 0) + 1;
    });
    return Object.entries(dist)
      .map(([i, count]) => `L${i}: ${count}`)
      .join(" · ");
  },
}

Rules worth knowing

  • Return a string. A number renders as nothing. End with .toString(), .toFixed(1) or a template literal.
  • Handle the empty year. Switch to a year with no data and yearEntries is []reduce without an initial value throws there. Return "No data" early.
  • value for totals, intensity for buckets. value is your raw number; intensity is the color level it landed on.
  • Only the displayed year. Paging to another year recalculates every insight against that year's entries — the numbers follow the grid.
  • Missing days are absent, not zero. Entries exist only for days you logged, so an average is over logged days unless you divide by the calendar yourself.
  • Keep it cheap. It runs on every render. A pass or two over a few hundred entries is nothing; anything heavier is felt.

FAQ

What is an insight in Heatmap Tracker?

An insight is a named function you add to trackerData.insights. It receives that year's entries and returns a string, which the plugin renders in the Statistics tab next to the built-in totals and streaks.

Do I need Dataview to use insights?

You need a dataviewjs block, because an insight is a JavaScript function and a heatmap-tracker codeblock can only hold YAML values. The heatmap itself works without any script.

What data does an insight get?

One argument, { yearEntries }: the entries for the currently displayed year. Each entry has date (YYYY-MM-DD), value (the number read from your note), intensity (the mapped color level) and filePath.

Why does my insight show nothing or break the heatmap?

An insight must return a string. Returning a number or undefined shows nothing, and a throw during calculation stops that insight rendering — guard the empty case, for example by returning "No data" when yearEntries is empty, and call toString() or toFixed() on numbers.

Can an insight use value or does it have to use intensity?

Both are available. value is the raw number from your note, intensity is the level it was mapped to on the color scale, so totals and averages normally use value while distribution-style insights use intensity.

How many insights can one heatmap have?

There is no limit; insights is an array and each entry becomes its own line in the Statistics tab. They are recalculated when the heatmap renders and when you switch years.

Get the plugin

Heatmap Tracker is a free, open-source Obsidian community plugin. Install it from Settings → Community plugins → Browse, or open the plugin page directly. Every insight above also ships as a note in the example vault.

Install Heatmap Tracker Parameter reference