Map Your Things Deadlines on a More Intuitive Timeline

tech

Why Bother?

Things is brilliant for capturing tasks, but its default Deadline view isn’t intuitive enough. By piping your database into Grafana (or any D3-powered page) you can see deadlines laid out like a tube map or a compact grid layout—far easier on the brain and, crucially, kinder to an E-ink display.

Features

The “Tube Map” Layout

  • Looks like a simplified Underground diagram.
  • Dates are auto-generated; today is circled.
  • Items cross themselves off once marked complete.
  • Styling flips between “running early” and “overdue”.
  • Nodes flow above and below the centre line for visual balance.
  • E-ink friendly: when colour won’t pop, use line weight instead.

The Compact Grid Layout

  • Uses far less space by showing a row per task with a right-hand “date cell” pattern.
  • Each cell represents an opportunity to act on that task across the visible range of days.
  • If a task has a startDate, the symbol for that start day is filled solid.
  • The symbol for today gains a diagonal hatch to signal that the active window is closing (after 6pm).
  • Once a task is overdue, the pattern switches to exclamation marks to show the number of days past due.

Once you see your deadlines snapping into shape, you will rarely go back to a flat list.

Recipe

We’ll follow an ETV process—Extract, Transform, Visualise—rather than the typical ETL (Extract, Transform, Load).

  1. Expose the data

    • Export or mirror your Things data into a store that Grafana can query directly.
    • It’s a SQLite file on your Mac. The exact location is ~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/. You can find more information here.
    • A follow-up post will cover efficient data synchronisation in detail in the future.
  2. Query it

    Write a SQL query to prepare data. Below is an example

    SELECT
    	title,
    	status,
    	CASE
    		WHEN deadline != 0 THEN printf('%d-%02d-%02d', (deadline & 134152192) >> 16, (deadline & 61440) >> 12, (deadline & 3968) >> 7)
    	END AS deadline,
    	strftime('%Y-%m-%d', datetime(stopDate, 'unixepoch'), 'localtime') AS stopDate,
    	CASE
    		WHEN startDate != 0 THEN printf('%d-%02d-%02d', (startDate & 134152192) >> 16, (startDate & 61440) >> 12, (startDate & 3968) >> 7)
    	END AS startDate
    FROM
    	TMtask AS T
    WHERE
    	deadline IS NOT NULL
    	AND (
    		(
    			status = 0
    			AND deadline <= ((strftime('%Y', date('now', '+14 day', 'localtime')) << 16) | (strftime('%m', date('now', '+14 day', 'localtime')) << 12) | (strftime('%d', date('now', '+14 day', 'localtime')) << 7))
    		)
    		OR (
    			status = 3
    			AND deadline BETWEEN ((strftime('%Y', date('now', '-3 day', 'localtime')) << 16) | (strftime('%m', date('now', '-3 day', 'localtime')) << 12) | (strftime('%d', date('now', '-3 day', 'localtime')) << 7)) AND ((strftime('%Y', date('now', '+14 day', 'localtime')) << 16) | (strftime('%m', date('now', '+14 day', 'localtime')) << 12) | (strftime('%d', date('now', '+14 day', 'localtime')) << 7))
    		)
    	)
    ORDER BY
    	deadline,
    	creationDate
    ;

Transforming Columns

Things stores deadline dates as funky integers. For conversion, see the documentation.

  1. Visualise it

    • In Grafana, create a panel, paste the query along with the visualise script. The details are in the following text.

Integrate in Grafana

Business Text plugin works as a bridge to run D3.js scripts. There’s an official guide for your reference.

Steps

  1. Install the Business Text plugin.
  2. Create a panel and switch to Business Text type.
  3. Settings for Business Text:
    • Set Render template to All data.
    • Select Editors to display: include Default Content and Javascript Code After Content Ready
  4. In the code section of Content and Default Content, paste the code from the snippets section accordingly.
  5. In the code section of After Content Ready, paste the code from the snippets section accordingly.
  6. In the query section, choose your Things database and paste your SQL query (from previous section).
  7. Make necessary adjustment to fit your need.

[!TIP]

You can ask AI for help with code, but having some basic frontend knowledge will make collaboration and troubleshooting easier.

Snippets

For the “Tube Map” Layout

Content/Default Content:

<svg id="chart" width="1000" height="300"></svg>

After Content Ready:

import("https://esm.sh/d3@7").then((d3) => {

  // 1. Configuration
  // ──────────────────────────────────────────────────────────────
  const urlParams = new URLSearchParams(window.location.search);
  const isEink = urlParams.get('eink') === '1';

  const maxPerSide = 2;               // max tasks per side at each date
  const branchFirst = 'up';            // default branch: 'up' or 'down'
  const rowHeight = 20;
  const marginTop = 40;
  const marginBottom = 40;
  const marginLeft = 30;
  const marginRight = 30;
  const canvasWidth = 880;
  const canvasHeight = 300;

  // theme colours
  const tc = context.grafana.theme.colors;
  const priColor = tc.text.primary;
  const secColor = tc.text.secondary;
  const warningCol = isEink ? priColor : tc.warning.text;
  const successCol = tc.success.text;
  const dimOpacity = 0.6;

  // source data
  const data = context.data[0] || [];

  // 2. Parse dates on each task
  // ──────────────────────────────────────────────────────────────
  const parseDate = d3.timeParse("%Y-%m-%d");
  data.forEach(d => {
    d.deadline = parseDate(d.deadline);
    if (d.stopDate) d.stopDate = parseDate(d.stopDate);
    if (d.startDate) d.startDate = parseDate(d.startDate);
  });

  // 3. Calculate timeline range
  // ──────────────────────────────────────────────────────────────
  const now = new Date();
  const T = d3.timeDay.floor(now);           // today, no time component
  const start = d3.timeDay.offset(T, -3);        // 3 days before
  const end = d3.timeDay.offset(T, 14);        // 14 days after
  const days = d3.timeDay.range(start, d3.timeDay.offset(end, 1));

  // 4. Build labels for months and days
  // ──────────────────────────────────────────────────────────────
  const monthNames = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN",
    "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
  const labelObjs = [];

  // start with the first month label, then an ellipsis
  const startMonth = days[0].getMonth();
  labelObjs.push({ type: 'VM', label: monthNames[startMonth], month: startMonth });
  labelObjs.push({ type: 'VD', label: '…' });

  // add each day, with a new month label whenever the month changes
  let prevMonth = startMonth;
  days.forEach(day => {
    const m = day.getMonth();
    if (m !== prevMonth) {
      labelObjs.push({ type: 'VM', label: monthNames[m], month: m });
      prevMonth = m;
    }
    labelObjs.push({ type: 'day', label: d3.timeFormat('%-d')(day), date: day });
  });

  // if any task deadline exceeds end, append a trailing ellipsis
  if (data.some(d => d.deadline > end)) {
    labelObjs.push({ type: 'VD', label: '…' });
  }
  const N = labelObjs.length;

  // 5. Initialise SVG canvas
  // ──────────────────────────────────────────────────────────────
  const margin = {
    top: marginTop + maxPerSide * rowHeight,
    right: marginRight,
    bottom: marginBottom,
    left: marginLeft
  };
  const width = canvasWidth - margin.left - margin.right;
  const height = canvasHeight - margin.top - margin.bottom;

  // clear any existing drawing
  d3.select('#chart').selectAll('*').remove();

  const svg = d3.select('#chart')
    .attr('width', width + margin.left + margin.right)
    .attr('height', height + margin.top + margin.bottom);

  const container = svg.append('g')
    .attr('transform', `translate(${margin.left},${margin.top})`);

  // X scale for positioning by index
  const xScale = d3.scalePoint()
    .domain(d3.range(N))
    .range([0, width]);

  // 6. Render date axis (months & days)
  // ──────────────────────────────────────────────────────────────
  const axis = container.append('g').attr('class', 'axis');

  axis.selectAll('text')
    .data(labelObjs)
    .join('text')
    .attr('x', (d, i) => xScale(i))
    .attr('y', 0)
    .attr('text-anchor', 'middle')
    .style('fill', priColor)
    .text(d => d.label)
    .style('text-decoration', d => {
      // underline weekends
      if (d.type === 'day' && [0, 6].includes(d.date.getDay())) {
        return 'underline';
      }
      return null;
    });

  // cross out past dates/months
  axis.selectAll('text')
    .filter(d =>
      (d.type === 'day' && d.date < T) ||
      (d.type === 'VM' && d.month < T.getMonth())
    )
    .each(function () {
      const b = this.getBBox();
      container.append('line')
        .attr('x1', b.x)
        .attr('y1', b.y + b.height)
        .attr('x2', b.x + b.width)
        .attr('y2', b.y)
        .attr('stroke', secColor)
        .attr('stroke-width', 2)
        .attr('opacity', dimOpacity);
    });

  // highlight today with a circle
  const ti = labelObjs.findIndex(d =>
    d.type === 'day' && d3.timeDay.count(T, d.date) === 0
  );
  if (ti >= 0) {
    container.append('circle')
      .attr('class', 'link')
      .attr('cx', xScale(ti))
      .attr('cy', rowHeight * -0.2)
      .attr('r', rowHeight * 0.6)
      .attr('fill', 'none')
      .attr('stroke', secColor)
      .attr('stroke-width', 1)
      .attr('opacity', dimOpacity);
  }

  // 7. Prepare for task rendering
  // ──────────────────────────────────────────────────────────────
  const counts = {};
  const overflow = {};
  for (let i = 0; i < N; i++) {
    counts[i] = { up: 0, down: 0, lastDir: null };
    overflow[i] = { up: false, down: false };
  }

  // 8. Draw each task
  // ──────────────────────────────────────────────────────────────
  data.forEach(d => {
    // find the index for this task’s deadline
    let idx = labelObjs.findIndex(lo =>
      lo.type === 'day' && lo.date.getTime() === d.deadline.getTime()
    );
    if (d.deadline < start) {
      idx = labelObjs.findIndex(lo => lo.type === 'VD');
    }
    if (idx < 0) idx = N - 1;

    // decide which side (up/down) to place this task
    const prevIdx = idx - 1;
    let preferred = branchFirst;
    if (prevIdx >= 0 && (counts[prevIdx].up + counts[prevIdx].down > 0)) {
      const last = counts[prevIdx].lastDir || branchFirst;
      preferred = last === 'up' ? 'down' : 'up';
    }
    const alternate = preferred === 'up' ? 'down' : 'up';
    const dir = counts[idx][preferred] < maxPerSide ? preferred : alternate;
    counts[idx].lastDir = dir;

    const count = counts[idx][dir];
    const isOv = count >= maxPerSide;
    const render = !isOv || (!overflow[idx][dir] && dir === 'down');
    if (!render) return;

    // vertical offset for this task
    const dy = (count + 2) * rowHeight * (dir === 'up' ? -1 : 1);
    const x = xScale(idx);

    // draw the little connecting line
    const lineY = dir === 'up' ? -rowHeight : rowHeight;
    const lineY1 = dir === 'up' ? lineY : lineY * 0.5;
    const lineY2 = dir === 'up' ? lineY * 1.5 : lineY;
    container.append('line')
      .attr('class', 'link')
      .attr('x1', x).attr('y1', lineY1)
      .attr('x2', x).attr('y2', lineY2)
      .attr('stroke', secColor)
      .attr('stroke-width', 1)
      .attr('opacity', dimOpacity);

    // decide what text to show
    let txt = d.title;
    if (isOv && dir === 'down' && !overflow[idx][dir]) {
      txt = '…';
      overflow[idx][dir] = true;
    }

    // status flags
    const completed = d.status === 3;
    const overdue = (d.status === 0 && d.deadline < T)
      || (d.stopDate && d.stopDate > d.deadline);
    const early = d.stopDate && d.stopDate < d.deadline;

    // choose fill colour
    const fillColor = overdue ? warningCol : early ? successCol : priColor;

    // text decorations
    const decorations = [];
    if (completed) decorations.push('line-through');
    if (overdue && isEink) decorations.push('underline');

    // finally, draw the text label
    container.append('text')
      .attr('x', x)
      .attr('y', dy)
      .attr('text-anchor', 'middle')
      .style('fill', fillColor)
      .style('font-weight', (overdue && isEink) ? 'bold' : null)
      .style('text-decoration', decorations.join(' ') || null)
      .style('text-decoration-style', (overdue && isEink) ? 'double' : null)
      .text(txt);

    counts[idx][dir]++;
  });

});

For the Compact Grid Layout

Content/Default Content:

<svg id="deadline-chart"></svg>

After Content Ready:

import("https://esm.sh/d3@7").then(d3 => {

  // 1. CONFIGURATION
  // ─────────────────────────────────────────────────────
  // Layout
  const margin = { top: 5, left: 5, right: 5, bottom: 5 };
  const cellSize = 14;
  const cellGap = Math.round(cellSize * 0.7);
  const labelGap = 10;
  const strokeW = 1.2;
  const exclStrokeW = 2;
  const nonWorkdayCornerRadius = Math.round(cellSize / 7);

  // Behaviour
  const dayHourThreshold = 18; // hour from which today’s cell gets a hatch
  const overdueCap = 14;       // max exclamation marks before an ellipsis

  // Colours & opacity
  const tc = context.grafana.theme.colors;
  const priColor = tc.text.primary;
  const bgColor = tc.background.primary;
  const strokeCol = priColor;
  const dimOpacity = tc.mode === "light" ? 0.2 : 0.4;

  // 2. DATA PREPARATION
  // ─────────────────────────────────────────────────────
  const raw = context.data[0] || [];
  const tasks = raw
    .filter(d => d.status === 0)
    .map(d => ({
      title: d.title,
      deadline: new Date(d.deadline),
      startDate: d.startDate ? new Date(d.startDate) : null
    }))
    .sort((a, b) => a.deadline - b.deadline);

  // 3. DIMENSIONS CALCULATION
  // ─────────────────────────────────────────────────────
  // Today’s date (no time)
  const nowFull = new Date();
  const today = new Date(nowFull.getFullYear(), nowFull.getMonth(), nowFull.getDate());

  // How many days until each deadline (or overdue days)
  const counts = tasks.map(t => {
    const diffMs = t.deadline - today;
    const diffD = Math.floor(diffMs / (1000 * 60 * 60 * 24));
    return diffD >= 0 ? diffD + 1 : Math.abs(diffD);
  });
  const maxCells = d3.max(counts) || 0;

  // Measure widest task title to align the grid
  const temp = d3.select("body").append("svg")
    .attr("class", "temp-measure")
    .append("text")
    .attr("font-size", `${cellSize}px`)
    .attr("visibility", "hidden");

  let maxLabelWidth = 0;
  tasks.forEach(t => {
    temp.text(t.title);
    const w = temp.node().getBBox().width;
    if (w > maxLabelWidth) maxLabelWidth = w;
  });
  d3.select(".temp-measure").remove();

  // Compute offsets and overall SVG size
  const offsetX = margin.left + maxLabelWidth + labelGap;
  const width = offsetX + maxCells * (cellSize + cellGap) + margin.right;
  const height = margin.top + (tasks.length + 1) * (cellSize + cellGap) + margin.bottom;

  // 4. SET UP SVG & HATCH PATTERN
  // ─────────────────────────────────────────────────────
  d3.select("#deadline-chart").selectAll("*").remove();

  const svg = d3.select("#deadline-chart")
    .attr("width", width)
    .attr("height", height);

  const defs = svg.append("defs");
  defs.append("pattern")
    .attr("id", "diagonalHatch")
    .attr("patternUnits", "userSpaceOnUse")
    .attr("width", 4)
    .attr("height", 4)
    .append("path")
    .attr("d", "M0,4 l4,-4")
    .attr("stroke", priColor)
    .attr("stroke-width", 1);

  // Helpers
  const isWorkday = d => {
    const w = d.getDay();
    return w !== 0 && w !== 6;
  };
  const sameDay = (a, b) =>
    a.getFullYear() === b.getFullYear() &&
    a.getMonth() === b.getMonth() &&
    a.getDate() === b.getDate();

  // 5. DRAW TASK ROWS
  // ─────────────────────────────────────────────────────
  tasks.forEach((task, i) => {
    const y0 = margin.top + i * (cellSize + cellGap);

    // 5.1 Title (right‐aligned)
    svg.append("text")
      .attr("x", margin.left + maxLabelWidth)
      .attr("y", y0 + cellSize / 2)
      .attr("text-anchor", "end")
      .attr("dominant-baseline", "middle")
      .attr("fill", priColor)
      .attr("font-size", `${cellSize}px`)
      .text(task.title);

    // 5.2 Build “cells” array for this row
    const diffDays = Math.floor((task.deadline - today) / (1000 * 60 * 60 * 24));
    let cells = [];

    if (diffDays >= 0) {
      // Future or today: one square per day, circle on the deadline
      for (let k = 0; k <= diffDays; k++) {
        const d = new Date(today);
        d.setDate(d.getDate() + k);
        cells.push({
          type: k < diffDays ? "square" : "circle",
          date: d
        });
      }
    } else {
      // Overdue: exclamation marks, with ellipsis if very overdue
      const od = Math.abs(diffDays);
      if (od > overdueCap) {
        cells.push({ type: "ellipsis" });
        for (let k = 0; k < overdueCap; k++) cells.push({ type: "excl" });
      } else {
        for (let k = 0; k < od; k++) cells.push({ type: "excl" });
      }
    }

    // 5.3 Render each cell
    cells.forEach((c, j) => {
      const x0 = offsetX + j * (cellSize + cellGap);

      // Apply hatch if it’s today and past the threshold hour
      const todayShade = c.date && sameDay(c.date, today) && nowFull.getHours() >= dayHourThreshold;
      const fillCol = todayShade ? "url(#diagonalHatch)" : bgColor;

      if (c.type === "square") {
        const corner = isWorkday(c.date) ? 0 : nonWorkdayCornerRadius;
        let fill = fillCol;
        if (task.startDate && sameDay(c.date, task.startDate)) {
          fill = priColor;
        }
        svg.append("rect")
          .attr("x", x0)
          .attr("y", y0)
          .attr("width", cellSize)
          .attr("height", cellSize)
          .attr("rx", corner)
          .attr("ry", corner)
          .attr("fill", fill)
          .attr("stroke", strokeCol)
          .attr("stroke-width", strokeW);

      } else if (c.type === "circle") {
        svg.append("circle")
          .attr("cx", x0 + cellSize / 2)
          .attr("cy", y0 + cellSize / 2)
          .attr("r", cellSize / 2)
          .attr("fill", fillCol)
          .attr("stroke", strokeCol)
          .attr("stroke-width", strokeW);

      } else if (c.type === "excl") {
        const g = svg.append("g")
          .attr("transform", `translate(${x0},${y0})`);
        g.append("line")
          .attr("x1", cellSize / 2).attr("y1", cellSize * 0.1)
          .attr("x2", cellSize / 2).attr("y2", cellSize * 0.6)
          .attr("stroke", strokeCol)
          .attr("stroke-width", exclStrokeW);
        g.append("circle")
          .attr("cx", cellSize / 2).attr("cy", cellSize * 0.75)
          .attr("r", cellSize * 0.08)
          .attr("fill", strokeCol);

      } else if (c.type === "ellipsis") {
        const g = svg.append("g")
          .attr("transform", `translate(${x0},${y0})`);
        const r = cellSize * 0.08;
        const cy = cellSize / 2;
        const off = [-r * 2, 0, r * 2];
        off.forEach(dx => {
          g.append("circle")
            .attr("cx", cellSize / 2 + dx)
            .attr("cy", cy)
            .attr("r", r)
            .attr("fill", strokeCol);
        });
      }
    });
  });

  // 6. DRAW DATE LABELS BELOW
  // ─────────────────────────────────────────────────────
  if (tasks.length > 0) {
    const last = tasks[tasks.length - 1];
    const lastDiff = Math.floor((last.deadline - today) / (1000 * 60 * 60 * 24));

    if (lastDiff >= 0) {
      // Build array of dates from today to the last deadline
      const dateCount = lastDiff + 1;
      const dates = [];
      for (let k = 0; k < dateCount; k++) {
        const d = new Date(today);
        d.setDate(d.getDate() + k);
        dates.push(d);
      }

      // Y position just below the last row of cells
      const dateRowY = margin.top
        + tasks.length * (cellSize + cellGap)
        + cellGap / 10
        + 1;

      // Render day numbers, underlining weekends
      dates.forEach((dt, k) => {
        const x = offsetX + k * (cellSize + cellGap) + cellSize / 2;
        svg.append("text")
          .attr("x", x)
          .attr("y", dateRowY)
          .attr("text-anchor", "middle")
          .attr("dominant-baseline", "hanging")
          .attr("font-size", `${cellSize}px`)
          .attr("fill", priColor)
          .attr("opacity", dimOpacity)
          .text(dt.getDate())
          .style("text-decoration", isWorkday(dt) ? "none" : "underline");
      });
    }
  }

});

I hope this helps!