Map Your Things Deadlines on a More Intuitive Timeline
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).
-
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.
-
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.
-
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
- Install the Business Text plugin.
- Create a panel and switch to Business Text type.
- Settings for Business Text:
- Set
Render template
toAll data
. - Select Editors to display: include
Default Content
andJavascript Code After Content Ready
- Set
- In the code section of
Content
andDefault Content
, paste the code from the snippets section accordingly. - In the code section of
After Content Ready
, paste the code from the snippets section accordingly. - In the query section, choose your Things database and paste your SQL query (from previous section).
- 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!