trmnlc/src/sources/tana.ts
Kate Meerburg 2e34246d14 Add today-view plugin with Tana, ICS, and Donetick data sources
Implements an e-ink daily dashboard plugin ("today") with four sections:
date/focus/success header, agenda timeline, chores checklist, and
due/overdue task lists.

Data sources:
- Focus & success text: Tana daily note (src/sources/tana.ts)
- Due/overdue tasks: Tana task search (src/sources/tana.ts)
- Agenda events: ICS calendar feeds (src/sources/ics.ts)
- Chores: Donetick API (src/sources/donetick.ts)

All sources fetch in parallel and fall back gracefully on error.
Tests use mock HTTP servers with synthetic data — no real services needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 23:18:13 +02:00

152 lines
4.7 KiB
TypeScript

import type { TodoItem, OverdueItem } from '../todayview';
export interface TanaConfig {
url: string;
token: string;
workspace: string;
task_tag_id: string;
due_date_field_id: string;
focus_field?: string; // default: "Today's Focus"
success_field?: string; // default: "What would make today a success?"
}
interface TanaTask {
id: string;
name: string;
tags?: { id: string; name: string }[];
fields?: { fieldId: string; value: string }[];
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function parseField(md: string, fieldName: string): string | undefined {
// Match **Field Name**: value <!-- or **Field Name**: value\n
const commentPattern = new RegExp(
`\\*\\*${fieldName}\\*\\*:\\s*(.*?)\\s*<!--`
);
const newlinePattern = new RegExp(
`\\*\\*${fieldName}\\*\\*:\\s*(.*?)\\s*\\n`
);
let match = md.match(commentPattern) ?? md.match(newlinePattern);
if (!match?.[1]) return undefined;
let value = match[1];
// Strip Tana link markup: [text](tana:id) -> text
value = value.replace(/\[(.+?)\]\(tana:[^)]+\)\s*(\p{P})/gu, '$1$2');
value = value.replace(/\[(.+?)\]\(tana:[^)]+\)/g, '$1');
return value.trim() || undefined;
}
export async function fetchTanaFocus(
config: TanaConfig
): Promise<{ focus?: string; success?: string }> {
const workspace = config.workspace;
const today = new Date().toISOString().slice(0, 10);
const headers = {
Authorization: `Bearer ${config.token}`,
Accept: 'application/json',
};
// Step 1: get today's calendar node ID
const calResp = await fetch(
`${config.url}/workspaces/${workspace}/calendar/node?date=${today}&granularity=day`,
{ headers }
);
if (!calResp.ok) throw new Error(`Tana calendar fetch failed: ${calResp.status}`);
const calData = (await calResp.json()) as { nodeId?: string };
if (!calData.nodeId) throw new Error('Tana calendar response missing nodeId');
// Step 2: read the node content
const noteResp = await fetch(
`${config.url}/nodes/${calData.nodeId}?maxDepth=1`,
{ headers }
);
if (!noteResp.ok) throw new Error(`Tana note fetch failed: ${noteResp.status}`);
const noteData = (await noteResp.json()) as { markdown?: string };
if (!noteData.markdown) throw new Error('Tana note response missing markdown');
// Step 3: parse fields from markdown
return {
focus: parseField(
noteData.markdown,
escapeRegex(config.focus_field ?? "Today's Focus")
),
success: parseField(
noteData.markdown,
escapeRegex(config.success_field ?? 'What would make today a success?')
),
};
}
function getTag(task: TanaTask, taskTagId: string): string {
const tag = task.tags?.find((t) => t.id !== taskTagId);
return tag?.name ?? '';
}
function getDueDate(task: TanaTask, dueDateFieldId: string): string | undefined {
return task.fields?.find((f) => f.fieldId === dueDateFieldId)?.value;
}
function computeAge(dueDate: string): string {
const due = new Date(dueDate + 'T00:00:00');
const today = new Date();
today.setHours(0, 0, 0, 0);
const days = Math.floor((today.getTime() - due.getTime()) / 86_400_000);
if (days <= 0) return '0d';
return `${days}d`;
}
export async function fetchTanaTasks(
config: TanaConfig
): Promise<{ due: TodoItem[]; overdue: OverdueItem[] }> {
const today = new Date().toISOString().slice(0, 10);
const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10);
const headers = {
Authorization: `Bearer ${config.token}`,
Accept: 'application/json',
};
// Single query: due < tomorrow (i.e. due today or earlier), not done
const params = new URLSearchParams({
'query[and][0][hasType]': config.task_tag_id,
'query[and][1][not][is]': 'done',
'query[and][2][compare][fieldId]': config.due_date_field_id,
'query[and][2][compare][operator]': 'lt',
'query[and][2][compare][value]': tomorrow,
'query[and][2][compare][type]': 'date',
limit: '100',
});
const resp = await fetch(`${config.url}/nodes/search?${params}`, { headers });
if (!resp.ok) throw new Error(`Tana task search failed: ${resp.status}`);
const tasks = (await resp.json()) as TanaTask[];
// Split: due date == today → due today, due date < today → overdue
const due: TodoItem[] = [];
const overdue: OverdueItem[] = [];
for (const t of tasks) {
const dueDate = getDueDate(t, config.due_date_field_id);
if (dueDate && dueDate < today) {
overdue.push({
text: t.name,
tag: getTag(t, config.task_tag_id),
age: computeAge(dueDate),
});
} else {
due.push({
text: t.name,
tag: getTag(t, config.task_tag_id),
est: '',
done: false,
});
}
}
return { due, overdue };
}