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>
152 lines
4.7 KiB
TypeScript
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 };
|
|
}
|