M A N D A L I V I A
Obsidian Lab All Notes

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

  1. Determine yesterday’s date and locate the corresponding daily note
  2. Check guards — skip if it already ran for that date, if it’s too early, or if the daily note doesn’t exist
  3. Query Things via things-cli for the logbook in JSON format
  4. Filter to only completed to-dos from yesterday
  5. Format each task as a checked markdown item
  6. Insert the block into the daily note under a specific anchor heading
  7. 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 Applications

Each 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:

SettingFlagEnv VarDefault
Vault path--vault-pathOBSIDIAN_VAULT_PATH(your vault root)
Earliest run hour--min-hourODC_MIN_HOUR5
Things CLI path--things-cmdTHINGS_CMDthings-cli
State file--state-fileODC_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 launchd for scheduling and things-cli for 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). Modify getDailyNotePath() 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_HEADING constant to match your template.
  • Requires things-cli. Install via pip install things-cli. It reads directly from the Things 3 SQLite database.

Nightly Toggl Summary in Your Obsidian Daily Note Voice-Driven Time Tracking with Toggl and Claude

Keep Exploring