Syncing Completed Things 3 Tasks into Obsidian Daily Notes
Every morning I want to see what I actually accomplished yesterday — not what I planned, but what I finished. My task manager (Things 3) knows this, but my daily notes don’t. So I built a small script to bridge the gap.
The Problem
Things and Obsidian don’t talk to each other. Tasks get completed in Things throughout the day, but my daily note — where I do my nightly review — has no record of them. I like having a log of what I got done in my daily note.
The Solution
A Node CLI script that runs once daily via macOS launchd. It pulls yesterday’s completed tasks from Things using things-cli (a Python tool that reads directly from the Things 3 SQLite database) and inserts them into yesterday’s daily note under a dedicated heading.
The whole thing is intentionally conservative: if anything looks wrong, it skips rather than corrupts.
How It Works
- Determine yesterday’s date and locate the corresponding daily note
- Check guards — skip if it already ran for that date, if it’s too early, or if the daily note doesn’t exist
- Query Things via
things-clifor the logbook in JSON format - Filter to only completed to-dos from yesterday
- Format each task as a checked markdown item
- Insert the block into the daily note under a specific anchor heading
- Record the successful date to a state file so it doesn’t run twice
What It Produces
The script finds an anchor heading in your daily note (I use my “Nightly Review” section) and inserts a block like this just before the next section:
### Things Actions
- [x] Ship feature flag for dashboard _(Project: App v2)_
- [x] Reply to landlord about lease _(Area: Personal)_
- [x] Read chapter 5 of Designing Data-Intensive ApplicationsEach task shows its title, and optionally the project and area it belonged to. If the section already exists, it skips — no duplicates.
Key Design Decisions
Skip, never corrupt. If the anchor heading doesn’t exist, or the section was already written, or the note is missing — it skips silently. No partial writes, no guessing.
Idempotent by default. A state file tracks the last successful sync date. Running the script multiple times on the same day does nothing after the first success.
Dry-run first. A --dry-run flag shows exactly what would happen without touching any files. I tested extensively with this before letting it write.
Automation
The script runs daily at 5:15am via a macOS LaunchAgent. RunAtLoad is set to true so it also fires after a reboot — you don’t miss a day. Logs go to ~/Library/Logs/ for debugging.
One thing worth knowing: LaunchAgents run in a minimal shell environment. You need to explicitly set PATH and tool paths in the plist — don’t count on your .zshrc being sourced.
Configuration
Everything is configurable via flags or environment variables:
| Setting | Flag | Env Var | Default |
|---|---|---|---|
| Vault path | --vault-path | OBSIDIAN_VAULT_PATH | (your vault root) |
| Earliest run hour | --min-hour | ODC_MIN_HOUR | 5 |
| Things CLI path | --things-cmd | THINGS_CMD | things-cli |
| State file | --state-file | ODC_STATE_FILE | ~/.local/state/... |
The Script
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
// --- Configuration defaults (override via flags or env vars) ---
const DEFAULT_VAULT_PATH = process.env.OBSIDIAN_VAULT_PATH || '';
const DEFAULT_MIN_HOUR = 5;
const NIGHTLY_REVIEW_HEADING = '## 🌘 Nightly Review';
const THINGS_ACTIONS_HEADING = '### Things Actions';
function parseArgs(argv) {
const args = {
dryRun: false,
force: false,
verbose: false,
vaultPath: DEFAULT_VAULT_PATH,
minHour: Number(process.env.ODC_MIN_HOUR || DEFAULT_MIN_HOUR),
thingsCmd: process.env.THINGS_CMD || 'things-cli',
stateFile: process.env.ODC_STATE_FILE ||
path.join(os.homedir(), '.local/state/obsidian-daily-catchup/last-successful-target-date.txt'),
};
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--dry-run' || arg === '-n') args.dryRun = true;
else if (arg === '--force') args.force = true;
else if (arg === '--verbose') args.verbose = true;
else if (arg === '--vault-path') { args.vaultPath = argv[++i]; }
else if (arg === '--min-hour') { args.minHour = Number(argv[++i]); }
else if (arg === '--things-cmd') { args.thingsCmd = argv[++i]; }
else if (arg === '--state-file') { args.stateFile = argv[++i]; }
else if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); }
else throw new Error(`Unknown argument: ${arg}`);
}
if (!args.vaultPath) throw new Error('--vault-path or OBSIDIAN_VAULT_PATH is required');
return args;
}
function printHelp() {
process.stdout.write(`Usage: obsidian-daily-catchup [options]
Adds yesterday's completed Things tasks into yesterday's Obsidian daily note.
Safe by default: when in doubt, it skips without changing files.
Options:
-n, --dry-run Print planned actions, do not write
--force Ignore the before-5am guard
--verbose Extra logging
--vault-path PATH Vault root (required, or set OBSIDIAN_VAULT_PATH)
--min-hour HOUR Earliest local hour to run (default: 5)
--things-cmd CMD Things CLI command (default: things-cli)
--state-file PATH State file path
-h, --help Show help
`);
}
function log(msg) { process.stdout.write(`${msg}\n`); }
function debug(enabled, msg) { if (enabled) log(`[debug] ${msg}`); }
// --- Date helpers ---
function toDateKeyLocal(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function monthFolder(date) {
const num = String(date.getMonth() + 1).padStart(2, '0');
const name = date.toLocaleString('en-US', { month: 'long' });
return `${num}-${name}`;
}
// Adjust this to match your daily note path structure
function getDailyNotePath(vaultPath, targetDate) {
return path.join(
vaultPath, 'Planner', 'Daily Notes',
String(targetDate.getFullYear()),
monthFolder(targetDate),
`${toDateKeyLocal(targetDate)}.md`
);
}
// --- State file (idempotency) ---
function readState(stateFile) {
try { return fs.readFileSync(stateFile, 'utf8').trim(); }
catch (_) { return ''; }
}
function writeState(stateFile, dateKey) {
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
fs.writeFileSync(stateFile, `${dateKey}\n`, 'utf8');
}
// --- Things CLI interaction ---
function runThingsCommands(thingsCmd, verbose) {
const candidates = [
['-j', 'logbook'], ['--json', 'logbook'],
['-j', 'completed'], ['--json', 'completed'],
['logbook', '--json'], ['completed', '--json'],
];
for (const candidate of candidates) {
const result = spawnSync(thingsCmd, candidate, {
encoding: 'utf8', maxBuffer: 256 * 1024 * 1024,
});
debug(verbose, `Tried: ${thingsCmd} ${candidate.join(' ')} -> exit ${result.status}`);
if (result.error || result.status !== 0) continue;
const stdout = (result.stdout || '').trim();
if (!stdout) continue;
try {
return { ok: true, data: JSON.parse(stdout), command: `${thingsCmd} ${candidate.join(' ')}` };
} catch (_) {
debug(verbose, `Non-JSON output from: ${thingsCmd} ${candidate.join(' ')}`);
}
}
return { ok: false, reason: 'Unable to retrieve JSON from Things CLI.' };
}
// --- Task extraction ---
function parseDateLike(value) {
if (!value || typeof value !== 'string') return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function extractCompletedDateKey(task) {
for (const key of ['completed', 'completed_at', 'completion_date', 'completedDate', 'stop_date']) {
const dt = parseDateLike(task[key]);
if (dt) return toDateKeyLocal(dt);
}
return '';
}
function normalizeTask(task) {
const title = String(task.title || task.name || '').trim();
if (!title) return null;
if (task.status && String(task.status).toLowerCase() !== 'completed') return null;
if (task.type && String(task.type).toLowerCase() !== 'to-do') return null;
return {
title,
project: String(task.project || task.project_title || task.list || '').trim(),
area: String(task.area_title || task.area || '').trim(),
completedDateKey: extractCompletedDateKey(task),
};
}
// Recursively walk the Things JSON (handles nested projects)
function walkAny(node, out) {
if (Array.isArray(node)) { for (const item of node) walkAny(item, out); return; }
if (!node || typeof node !== 'object') return;
const norm = normalizeTask(node);
if (norm) out.push(norm);
for (const value of Object.values(node)) {
if (value && typeof value === 'object') walkAny(value, out);
}
}
function collectTasksFromThings(data, targetDateKey) {
const raw = [];
walkAny(data, raw);
const unique = new Map();
for (const t of raw) {
if (t.completedDateKey !== targetDateKey) continue;
const key = `${t.title}|${t.project}|${t.area}`;
if (!unique.has(key)) unique.set(key, t);
}
return [...unique.values()].sort((a, b) => a.title.localeCompare(b.title));
}
// --- Note modification ---
function formatTaskLine(task) {
const safeTitle = task.title.replace(/\s+/g, ' ').trim();
const meta = [];
if (task.project) meta.push(`Project: ${task.project.replace(/\s+/g, ' ').trim()}`);
if (task.area) meta.push(`Area: ${task.area.replace(/\s+/g, ' ').trim()}`);
return meta.length === 0
? `- [x] ${safeTitle}`
: `- [x] ${safeTitle} _(${meta.join(', ')})_`;
}
function buildUpdatedNote(original, taskLines) {
const lines = original.split('\n');
if (lines.some((line) => line.trim() === THINGS_ACTIONS_HEADING)) {
return { ok: false, reason: `${THINGS_ACTIONS_HEADING} already exists; skipping.` };
}
const nightlyIdx = lines.findIndex((line) => line.trim() === NIGHTLY_REVIEW_HEADING);
if (nightlyIdx === -1) {
return { ok: false, reason: `Missing ${NIGHTLY_REVIEW_HEADING}; skipping.` };
}
const nextH2 = lines.findIndex((line, i) => i > nightlyIdx && /^##\s+/.test(line));
const insertAt = nextH2 === -1 ? lines.length : nextH2;
const block = ['', THINGS_ACTIONS_HEADING, ...taskLines, ''];
return {
ok: true,
text: [...lines.slice(0, insertAt), ...block, ...lines.slice(insertAt)].join('\n'),
};
}
// --- Main ---
function main() {
let args;
try { args = parseArgs(process.argv); }
catch (err) { process.stderr.write(`${err.message}\n`); printHelp(); process.exit(1); }
const now = new Date();
const targetDate = new Date(now);
targetDate.setDate(now.getDate() - 1);
const todayKey = toDateKeyLocal(now);
const targetDateKey = toDateKeyLocal(targetDate);
const notePath = getDailyNotePath(args.vaultPath, targetDate);
log(`Target date: ${targetDateKey} | Mode: ${args.dryRun ? 'dry-run' : 'apply'}`);
if (!args.force && now.getHours() < args.minHour) {
log(`Skip: too early (hour ${now.getHours()} < ${args.minHour}).`); return;
}
if (targetDateKey === todayKey) {
log('Skip: safety guard prevented targeting today.'); return;
}
if (readState(args.stateFile) === targetDateKey) {
log(`Skip: already synced ${targetDateKey}.`); return;
}
if (!fs.existsSync(notePath)) {
log(`Skip: no daily note for ${targetDateKey}.`); return;
}
const noteText = fs.readFileSync(notePath, 'utf8');
const thingsResult = runThingsCommands(args.thingsCmd, args.verbose);
if (!thingsResult.ok) { log(`Skip: ${thingsResult.reason}`); return; }
const tasks = collectTasksFromThings(thingsResult.data, targetDateKey);
if (tasks.length === 0) { log(`Skip: no tasks for ${targetDateKey}.`); return; }
log(`Found ${tasks.length} completed task(s).`);
const taskLines = tasks.map(formatTaskLine);
const update = buildUpdatedNote(noteText, taskLines);
if (!update.ok) { log(`Skip: ${update.reason}`); return; }
if (args.dryRun) {
log('[dry-run] Would insert:');
log(THINGS_ACTIONS_HEADING);
for (const line of taskLines) log(line);
return;
}
fs.writeFileSync(notePath, update.text, 'utf8');
writeState(args.stateFile, targetDateKey);
log('Done.');
}
main();Caveats
- macOS only. Uses
launchdfor scheduling andthings-clifor task data. - Daily note path is baked in. The script assumes a specific folder structure (
Planner/Daily Notes/YYYY/MM-Month/YYYY-MM-DD.md). ModifygetDailyNotePath()to match yours. - Anchor heading must exist. The script needs a heading in your daily note to know where to insert. Change the
NIGHTLY_REVIEW_HEADINGconstant to match your template. - Requires things-cli. Install via
pip install things-cli. It reads directly from the Things 3 SQLite database.
Related
Nightly Toggl Summary in Your Obsidian Daily Note Voice-Driven Time Tracking with Toggl and Claude