Obsidian Integration: Two-Way Habit Sync
The check-in is the moment that matters. You did the thing. Now you need to tell due.box before the deadline passes.
Manual check-ins take seconds. But if you are already tracking habits and tasks in Obsidian, those seconds add up. And the real risk is not the time. It is forgetting to confirm something you actually did.
This guide covers an Obsidian plugin that syncs your habits both ways. Check a habit checkbox or start a timer, and due.box gets the confirmation automatically. Mark something done in due.box directly, and your daily note updates to match.
What the plugin does
Push: Obsidian to due.box. Check a habit checkbox, and the plugin fires POST /api/commitments/:id/done. Uncheck it, and it fires /undo. For field-based habits (like a journal), writing content into an inline field triggers the check-in automatically. No checkbox needed. Start a timer on a task with a commitmentId, and that counts as a check-in too. Same for marking a task as done.
Pull: due.box to Obsidian. On startup and whenever you open a daily note, the plugin fetches the current state of each commitment from the API. If you checked in through the due.box dashboard or another device, the plugin updates your daily note checkboxes to match.
How habits work
Each habit is a note in a Habits/ folder. There are two kinds:
Checkbox-based: the template renders checkboxes that you tick off:
---
tags:
- habit
commitmentId: 019cd590-bdbf-7d15-b542-6848408662c0
frequency: 3
---
Field-based: the habit is tracked by an inline field in the daily note instead of a checkbox. Useful when the act of filling in the field is the habit (e.g., writing a journal entry):
---
tags:
- habit
commitmentId: 019cc11c-a03b-74b8-a492-fc2e83a94022
frequency: 1
field: journal
---
When field is set, the template skips rendering checkboxes for that habit. Instead, the plugin watches the corresponding Dataview inline field (e.g., journal::) in the daily note. When you write content after the ::, the plugin detects it and fires the check-in. Clear the content and it fires an undo.
The daily note template reads habit files and renders checkboxes automatically, skipping field-based ones. If you use Templater, add this to your daily note template:
## Habits
<%*
const habitFiles = app.vault.getMarkdownFiles()
.filter(f => f.path.startsWith("Habits/"))
.filter(f => {
const cache = app.metadataCache.getFileCache(f);
return cache?.frontmatter?.tags?.includes("habit");
})
.sort((a, b) => a.basename.localeCompare(b.basename));
for (const f of habitFiles) {
const fm = app.metadataCache.getFileCache(f)?.frontmatter;
if (fm?.field) continue; // field-based habits don't need checkboxes
const freq = fm?.frequency || 1;
tR += `### ${f.basename}\n`;
for (let i = 0; i < freq; i++) {
tR += `- [ ] \n`;
}
tR += `\n`;
}
-%>
This produces a section like:
### Vitamins
- [ ]
- [ ]
- [ ]
Field-based habits like Journal do not appear here. They are tracked via their inline field elsewhere in the daily note (e.g., journal:: in the Journal section).
Adding a new habit is just creating a new note. No config to update, no template to edit. Habits without a commitmentId still get checkboxes. They just do not fire the API.
The plugin matches checkboxes to habits by section header name. ### Vitamins maps to Habits/Vitamins.md. The daily notes folder is expected at Daily Notes/. Adjust getTodayPath() in the plugin if yours is different.
The two-device problem
Obsidian runs on desktop and mobile, synced via iCloud. If you check a vitamin box on your phone, the file syncs to your Mac. Both instances see the change. Both could fire the API.
For a commitment with multiple daily occurrences, a duplicate call marks an extra occurrence as done. That is not a harmless repeat. It is wrong data.
The fix: the plugin writes a marker to the daily note's frontmatter before making the API call.
- You check a checkbox
- The plugin writes a marker to frontmatter
- The plugin fires the API
- If the call fails, the marker is rolled back
Since the marker and the checkbox live in the same file, they sync together. When the second device picks up the change, it sees the marker is already there and skips the call.
---
duebox-fired:
- 019cd590-bdbf-7d15-b542-6848408662c0:0
- 019cd590-bdbf-7d15-b542-6848408662c0:1
---
The only race condition requires both devices to detect the change before iCloud finishes syncing. The window is tiny, and the worst case is one extra check-in.
Pull sync
The push side handles Obsidian-initiated check-ins. But what if you mark something done in the due.box dashboard, or from another device that is not running Obsidian?
The plugin pulls commitment state from the API using GET /api/commitments/:id?for_datetime=.... The for_datetime parameter lets it query the completion count for any specific date, not just today.
When it syncs:
- On plugin startup: syncs today and yesterday
- When you open a daily note: syncs for that note's date (throttled to avoid rapid-fire calls)
- On manual trigger: command palette or settings button
What it does: For each daily habit, the plugin compares the API's completed_count with the number of local markers. If they disagree, it checks or unchecks checkboxes to match, and updates the markers so the push side does not re-fire.
Pull sync only reconciles daily habits (FREQ=DAILY). Weekly or monthly commitments have completion counts that span multiple days and do not map cleanly to a single day's checkboxes.
Setup
1. Install the plugin. Copy the duebox-sync folder into .obsidian/plugins/ and enable it in Obsidian settings.
2. Add your API token. Go to the plugin settings and paste your due.box API token. You can create one from the API page.
3. Create a habit note. Add a file in Habits/ with the habit tag, commitmentId, and frequency in frontmatter. For field-based habits, also add field with the name of the inline field to watch (e.g., field: journal). You can find the commitment ID in the due.box dashboard. Open a commitment and copy the ID from the URL or detail view.
4. Wire a task (optional). Add commitmentId to any task's frontmatter. The plugin will check in when you start a timer or mark it done.
New daily notes will render the checkboxes automatically. Check one and you should see a confirmation notice.
Implementation notes
The plugin is vanilla JavaScript. No build step needed.
Checkbox detection uses vault.on("modify") rather than Obsidian's metadata cache, because the cache does not track checkbox state. The plugin parses the habits section of the daily note, diffs against the previous state, and acts on changes. Field-based habits use the same event. The plugin parses inline fields (e.g., journal:: some text) and compares against the previous state to detect when content is added or removed.
HTTP requests use Obsidian's built-in requestUrl instead of fetch. Standard fetch runs into CORS issues inside the Obsidian runtime.
Self-triggered edits are handled naturally. When the plugin writes a marker to frontmatter, it triggers another file modification event. But since only the frontmatter changed, not the checkboxes, the diff is empty and processing skips. During pull sync, a syncing flag prevents the push side from reacting to reconciliation changes.
The API
The plugin uses three endpoints from the due.box REST API:
GET /api/commitments/:id?for_datetime=2026-03-11T12:00:00Z
POST /api/commitments/:id/done
POST /api/commitments/:id/undo
The GET endpoint returns the commitment state including completed_count, target_count, and rrule. The for_datetime parameter controls which period the completion count is calculated for. Pass noon on the date you care about.
The POST endpoints take a Bearer token and return the commitment state including the occurrence_id. The plugin does not need to track occurrences. It just fires and lets the server handle sequencing.
The full plugin
Two files. Drop them into .obsidian/plugins/duebox-sync/, enable the plugin, and add your token.
manifest.json
{
"id": "duebox-sync",
"name": "Due Box Sync",
"version": "1.0.0",
"minAppVersion": "1.0.0",
"description": "Two-way sync between Obsidian habits and due.box commitments.",
"author": "due.box",
"isDesktopOnly": false
}
main.js
const { Plugin, PluginSettingTab, Setting, Notice, requestUrl } = require("obsidian");
const DEFAULT_SETTINGS = {
apiUrl: "https://due.box",
apiToken: "",
};
module.exports = class DueBoxSyncPlugin extends Plugin {
prevCheckboxes = null;
prevFields = {}; // fieldName → boolean (has content)
firedTimers = new Set();
debounceTimer = null;
processing = false;
syncing = false;
lastSyncTime = 0;
commitmentStates = new Map(); // "commitmentId:YYYY-MM-DD" → API attributes
async onload() {
await this.loadSettings();
this.addSettingTab(new DueBoxSettingTab(this.app, this));
this.addCommand({
id: "sync-commitments",
name: "Sync commitments from due.box",
callback: () => this.syncAllCommitments(true),
});
this.app.workspace.onLayoutReady(async () => {
await this.initialize();
await this.syncAllCommitments(false);
this.registerEvent(
this.app.vault.on("modify", (file) => this.onFileModify(file))
);
this.registerEvent(
this.app.metadataCache.on("changed", (file) =>
this.onMetadataChanged(file)
)
);
this.registerEvent(
this.app.workspace.on("file-open", (file) => this.onFileOpen(file))
);
});
}
onunload() {
clearTimeout(this.debounceTimer);
}
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData()
);
}
async saveSettings() {
await this.saveData(this.settings);
}
getTodayStr() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
getTodayPath() {
return `Daily Notes/${this.getTodayStr()}.md`;
}
// Build map of habit name (lowercase) → { commitmentId, frequency, field }
getHabitMap() {
const map = {};
for (const f of this.app.vault.getMarkdownFiles()) {
if (!f.path.startsWith("Habits/")) continue;
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter;
if (!fm?.commitmentId) continue;
const tags = fm.tags || [];
if (!tags.includes("habit")) continue;
map[f.basename.toLowerCase()] = {
commitmentId: fm.commitmentId,
frequency: fm.frequency || 1,
field: fm.field || null,
};
}
return map;
}
// Parse ## Habits section for ### SubSection checkboxes
parseHabitCheckboxes(content) {
const result = {};
const habitsIdx = content.indexOf("\n## Habits");
if (habitsIdx === -1 && !content.startsWith("## Habits")) return result;
const startIdx = content.startsWith("## Habits") ? 0 : habitsIdx + 1;
const afterHabits = content.slice(startIdx);
const nextSection = afterHabits.match(/\n## (?!#)/);
const habitsSection = nextSection
? afterHabits.slice(0, nextSection.index)
: afterHabits;
const parts = habitsSection.split(/\n### /);
for (let i = 1; i < parts.length; i++) {
const lines = parts[i].split("\n");
const name = lines[0].trim().toLowerCase();
const checkboxes = [];
for (let j = 1; j < lines.length; j++) {
const m = lines[j].match(/^- \[([ xX])\]/);
if (m) {
checkboxes.push(m[1] !== " ");
}
}
if (checkboxes.length > 0) {
result[name] = checkboxes;
}
}
return result;
}
// Parse inline fields (e.g. "journal:: some text") → { fieldName: boolean }
parseInlineFields(content, fieldNames) {
const result = {};
for (const name of fieldNames) {
const re = new RegExp(`^${name}::(.*)$`, "m");
const m = content.match(re);
result[name] = m ? m[1].trim().length > 0 : false;
}
return result;
}
async initialize() {
const todayPath = this.getTodayPath();
const file = this.app.vault.getAbstractFileByPath(todayPath);
if (file) {
const content = await this.app.vault.read(file);
this.prevCheckboxes = this.parseHabitCheckboxes(content);
// Init field-based habit state
const fieldNames = Object.values(this.getHabitMap())
.filter((h) => h.field)
.map((h) => h.field);
this.prevFields = this.parseInlineFields(content, fieldNames);
}
// Record all existing timer starts for today so we don't re-fire on load
const today = this.getTodayStr();
for (const f of this.app.vault.getMarkdownFiles()) {
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter;
if (!fm?.commitmentId || !fm?.timeEntries) continue;
for (const entry of fm.timeEntries) {
if (entry.startTime?.toString().startsWith(today)) {
this.firedTimers.add(entry.startTime.toString());
}
}
}
}
async onFileOpen(file) {
if (!file || !file.path.startsWith("Daily Notes/")) return;
if (!this.settings.apiToken) return;
// Throttle: skip if synced in last 30s
if (Date.now() - this.lastSyncTime < 30000) return;
await this.syncAndReconcile(false, [file.path]);
}
// ─── Push: Obsidian → due.box ──────────────────────────────────
onFileModify(file) {
if (file.path !== this.getTodayPath()) return;
if (!this.settings.apiToken) return;
if (this.syncing) return;
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(
() => this.processCheckboxChanges(file),
300
);
}
async processCheckboxChanges(file) {
if (this.processing || this.syncing) return;
this.processing = true;
try {
const content = await this.app.vault.read(file);
const current = this.parseHabitCheckboxes(content);
const prev = this.prevCheckboxes || {};
const habitMap = this.getHabitMap();
const fm = this.app.metadataCache.getFileCache(file)?.frontmatter;
const fired = fm?.["duebox-fired"] || [];
for (const [name, boxes] of Object.entries(current)) {
const habit = habitMap[name];
if (!habit) continue;
const prevBoxes = prev[name] || [];
for (let i = 0; i < boxes.length; i++) {
const wasChecked = prevBoxes[i] || false;
const isChecked = boxes[i];
const marker = `${habit.commitmentId}:${i}`;
if (isChecked && !wasChecked) {
if (fired.includes(marker)) continue;
await this.writeMarkerAndFire(
file,
habit.commitmentId,
marker,
"done"
);
} else if (!isChecked && wasChecked) {
if (!fired.includes(marker)) continue;
await this.writeMarkerAndFire(
file,
habit.commitmentId,
marker,
"undo"
);
}
}
}
// Field-based habits (e.g. journal:: → fires when content is added/removed)
const fieldHabits = Object.values(habitMap).filter((h) => h.field);
if (fieldHabits.length > 0) {
const fieldNames = fieldHabits.map((h) => h.field);
const currentFields = this.parseInlineFields(content, fieldNames);
// Re-read fired markers (may have changed above)
const fmNow = this.app.metadataCache.getFileCache(file)?.frontmatter;
const firedNow = fmNow?.["duebox-fired"] || [];
for (const habit of fieldHabits) {
const wasFilled = this.prevFields[habit.field] || false;
const isFilled = currentFields[habit.field] || false;
const marker = `${habit.commitmentId}:field`;
if (isFilled && !wasFilled) {
if (firedNow.includes(marker)) continue;
await this.writeMarkerAndFire(file, habit.commitmentId, marker, "done");
} else if (!isFilled && wasFilled) {
if (!firedNow.includes(marker)) continue;
await this.writeMarkerAndFire(file, habit.commitmentId, marker, "undo");
}
}
this.prevFields = currentFields;
}
// Re-read final state after any frontmatter writes
const finalContent = await this.app.vault.read(file);
this.prevCheckboxes = this.parseHabitCheckboxes(finalContent);
} finally {
this.processing = false;
}
}
async onMetadataChanged(file) {
if (this.processing || this.syncing) return;
if (!this.settings.apiToken) return;
const fm = this.app.metadataCache.getFileCache(file)?.frontmatter;
if (!fm?.commitmentId) return;
const today = this.getTodayStr();
const firedMarkers = fm["duebox-fired"] || [];
// Detect new timer start today
if (Array.isArray(fm.timeEntries)) {
for (const entry of fm.timeEntries) {
const st = entry.startTime?.toString();
if (!st?.startsWith(today)) continue;
if (this.firedTimers.has(st)) continue;
if (entry.endTime) continue;
this.firedTimers.add(st);
const marker = `timer:${st}`;
if (firedMarkers.includes(marker)) continue;
this.processing = true;
try {
await this.writeMarkerAndFire(file, fm.commitmentId, marker, "done");
} finally {
this.processing = false;
}
}
}
// Detect status changed to done (TaskNotes aliases: done, completed, finished)
const doneStatuses = ["done", "completed", "finished"];
if (doneStatuses.includes(fm.status)) {
const marker = "status:done";
if (firedMarkers.includes(marker)) return;
this.processing = true;
try {
await this.writeMarkerAndFire(file, fm.commitmentId, marker, "done");
} finally {
this.processing = false;
}
}
}
async writeMarkerAndFire(file, commitmentId, marker, action) {
// Step 1: write marker to frontmatter
await this.app.fileManager.processFrontMatter(file, (fm) => {
if (!fm["duebox-fired"]) fm["duebox-fired"] = [];
if (action === "done") {
if (!fm["duebox-fired"].includes(marker)) {
fm["duebox-fired"].push(marker);
}
} else {
fm["duebox-fired"] = fm["duebox-fired"].filter((m) => m !== marker);
}
});
// Step 2: fire API
try {
const url = `${this.settings.apiUrl}/api/commitments/${commitmentId}/${action}`;
const res = await requestUrl({
url,
method: "POST",
headers: {
Authorization: `Bearer ${this.settings.apiToken}`,
Accept: "application/vnd.api+json",
},
throw: false,
});
if (res.status < 200 || res.status >= 300) {
throw new Error(`${res.status}: ${res.text}`);
}
new Notice(
`Due Box: ${action === "done" ? "confirmed" : "undone"}`
);
} catch (err) {
// Rollback marker
await this.app.fileManager.processFrontMatter(file, (fm) => {
if (!fm["duebox-fired"]) fm["duebox-fired"] = [];
if (action === "done") {
fm["duebox-fired"] = fm["duebox-fired"].filter((m) => m !== marker);
} else {
if (!fm["duebox-fired"].includes(marker)) {
fm["duebox-fired"].push(marker);
}
}
});
new Notice(`Due Box: Failed: ${err.message}`);
}
}
// Pull: due.box to Obsidian
dateFromPath(path) {
return path.match(/(\d{4}-\d{2}-\d{2})/)?.[1];
}
yesterdayStr() {
const d = new Date();
d.setDate(d.getDate() - 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
async syncAllCommitments(manual) {
const paths = [
this.getTodayPath(),
`Daily Notes/${this.yesterdayStr()}.md`,
];
await this.syncAndReconcile(manual, paths);
}
async syncAndReconcile(manual, dailyPaths) {
if (!this.settings.apiToken) return;
if (this.syncing) return;
this.syncing = true;
try {
// Collect unique commitmentIds
const commitmentIds = new Set();
for (const f of this.app.vault.getMarkdownFiles()) {
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter;
if (fm?.commitmentId) commitmentIds.add(fm.commitmentId);
}
const commitments = [...commitmentIds];
// Dedupe dates from paths
const dates = [...new Set(dailyPaths.map((p) => this.dateFromPath(p)).filter(Boolean))];
// Fetch state for each commitment × date
for (const id of commitments) {
for (const date of dates) {
await this.syncCommitment(id, date);
}
}
this.lastSyncTime = Date.now();
for (const path of dailyPaths) {
await this.reconcileDailyNote(path);
}
if (manual) {
new Notice(`Due Box: Synced ${commitments.length} commitment${commitments.length !== 1 ? "s" : ""}`);
}
} catch (err) {
console.error("DueBox sync error:", err);
if (manual) new Notice(`Due Box: Sync failed: ${err.message}`);
} finally {
this.syncing = false;
}
}
async syncCommitment(commitmentId, dateStr) {
try {
const forDatetime = new Date(`${dateStr}T12:00:00`).toISOString();
const url = `${this.settings.apiUrl}/api/commitments/${commitmentId}?for_datetime=${encodeURIComponent(forDatetime)}`;
const res = await requestUrl({
url,
method: "GET",
headers: {
Authorization: `Bearer ${this.settings.apiToken}`,
Accept: "application/vnd.api+json",
},
throw: false,
});
if (res.status < 200 || res.status >= 300) {
console.error(`DueBox: GET ${commitmentId} for ${dateStr} → ${res.status}`);
return;
}
const attrs = res.json?.data?.attributes;
if (!attrs) return;
this.commitmentStates.set(`${commitmentId}:${dateStr}`, attrs);
} catch (err) {
console.error(`DueBox: sync error for ${commitmentId}:`, err);
}
}
async reconcileDailyNote(notePath) {
const file = this.app.vault.getAbstractFileByPath(notePath);
if (!file) return;
const dateStr = this.dateFromPath(notePath);
if (!dateStr) return;
const habitMap = this.getHabitMap();
let content = await this.app.vault.read(file);
const current = this.parseHabitCheckboxes(content);
const fm = this.app.metadataCache.getFileCache(file)?.frontmatter;
const existingMarkers = fm?.["duebox-fired"] || [];
const markersToAdd = [];
const markersToRemove = [];
let modified = false;
for (const [name, boxes] of Object.entries(current)) {
const habit = habitMap[name];
if (!habit) continue;
const state = this.commitmentStates.get(`${habit.commitmentId}:${dateStr}`);
if (!state) continue;
// Only reconcile daily commitments. Weekly/monthly counts don't map to daily checkboxes
if (state.rrule && !state.rrule.includes("FREQ=DAILY")) continue;
const apiCount = state.completed_count ?? 0;
// Count local markers for this commitment on this note
const localMarkers = existingMarkers.filter(
(m) => m.startsWith(habit.commitmentId + ":") && /:\d+$/.test(m)
);
const localCount = localMarkers.length;
if (apiCount === localCount) continue;
content = this.alignCheckboxes(
content, name, boxes, apiCount,
habit.commitmentId, markersToAdd, markersToRemove
);
modified = true;
}
if (!modified) return;
// Write updated content + markers
this.processing = true;
try {
await this.app.vault.modify(file, content);
await this.app.fileManager.processFrontMatter(file, (fm) => {
if (!fm["duebox-fired"]) fm["duebox-fired"] = [];
for (const m of markersToAdd) {
if (!fm["duebox-fired"].includes(m)) fm["duebox-fired"].push(m);
}
fm["duebox-fired"] = fm["duebox-fired"].filter(
(m) => !markersToRemove.includes(m)
);
});
// Only update prevCheckboxes when reconciling today. It tracks today's push state
if (notePath === this.getTodayPath()) {
const finalContent = await this.app.vault.read(file);
this.prevCheckboxes = this.parseHabitCheckboxes(finalContent);
}
} finally {
this.processing = false;
}
}
alignCheckboxes(content, habitName, currentBoxes, targetChecked, commitmentId, markersToAdd, markersToRemove) {
const lines = content.split("\n");
let inHabits = false;
let inTarget = false;
let cbIdx = 0;
for (let i = 0; i < lines.length; i++) {
if (lines[i].match(/^## Habits\s*$/)) { inHabits = true; continue; }
if (inHabits && lines[i].match(/^## (?!#)/)) { inHabits = false; break; }
if (inHabits && lines[i].match(/^### /)) {
inTarget = lines[i].replace(/^### /, "").trim().toLowerCase() === habitName;
cbIdx = 0;
continue;
}
if (!inTarget) continue;
const m = lines[i].match(/^- \[([ xX])\]/);
if (!m) continue;
const shouldCheck = cbIdx < targetChecked;
const isChecked = m[1] !== " ";
const marker = `${commitmentId}:${cbIdx}`;
if (shouldCheck && !isChecked) {
lines[i] = lines[i].replace("- [ ]", "- [x]");
markersToAdd.push(marker);
} else if (!shouldCheck && isChecked) {
lines[i] = lines[i].replace(/- \[[xX]\]/, "- [ ]");
markersToRemove.push(marker);
}
cbIdx++;
}
return lines.join("\n");
}
};
class DueBoxSettingTab extends PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "Due Box Sync" });
new Setting(containerEl)
.setName("API URL")
.setDesc("Due Box API base URL")
.addText((text) =>
text
.setPlaceholder("https://due.box")
.setValue(this.plugin.settings.apiUrl)
.onChange(async (val) => {
this.plugin.settings.apiUrl = val.replace(/\/+$/, "");
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("API Token")
.setDesc("Bearer token for authentication")
.addText((text) => {
text.inputEl.type = "password";
text
.setPlaceholder("your-api-token")
.setValue(this.plugin.settings.apiToken)
.onChange(async (val) => {
this.plugin.settings.apiToken = val;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("Sync now")
.setDesc("Pull latest commitment state from due.box")
.addButton((btn) =>
btn.setButtonText("Sync").onClick(() => this.plugin.syncAllCommitments(true))
);
// Connected habits
containerEl.createEl("h3", { text: "Connected Habits" });
const habitFiles = this.app.vault.getMarkdownFiles().filter((f) => {
if (!f.path.startsWith("Habits/")) return false;
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter;
return fm?.tags?.includes("habit");
});
if (habitFiles.length === 0) {
containerEl.createEl("p", {
text: 'No habits found. Create notes in Habits/ folder with #habit tag and commitmentId.',
});
} else {
const list = containerEl.createEl("ul");
for (const f of habitFiles) {
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter;
let info = `${f.basename} - freq: ${fm?.frequency || 1}/day`;
if (!fm?.commitmentId) {
info += " - no commitmentId";
} else {
const state = this.plugin.commitmentStates.get(`${fm.commitmentId}:${this.plugin.getTodayStr()}`);
if (state) {
info += ` - ${state.completed_count ?? 0}/${state.target_count} done`;
} else {
info += " - linked";
}
}
list.createEl("li", { text: info });
}
}
// Connected tasks
containerEl.createEl("h3", { text: "Connected Tasks" });
const taskFiles = this.app.vault.getMarkdownFiles().filter((f) => {
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter;
return fm?.commitmentId && !f.path.startsWith("Habits/");
});
if (taskFiles.length === 0) {
containerEl.createEl("p", {
text: "No tasks with commitmentId found.",
});
} else {
const list = containerEl.createEl("ul");
for (const f of taskFiles) {
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter;
const state = this.plugin.commitmentStates.get(`${fm.commitmentId}:${this.plugin.getTodayStr()}`);
let info = `${f.basename} - ${fm?.commitmentId}`;
if (state) {
info += ` - ${state.completed_count ?? 0}/${state.target_count} done`;
}
list.createEl("li", { text: info });
}
}
}
}
If you want to build a similar integration for a different tool, the pattern is the same: detect the action, write a deduplication marker, fire the API, roll back on failure. For the pull side, reconcile against the API state and use the same markers to prevent the push side from re-firing.
Want to use it in your vault?
Install the plugin files, add an API token, and keep Obsidian and due.box in sync.
Get started