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>
This commit is contained in:
parent
ddcb03d3dd
commit
2e34246d14
12 changed files with 1665 additions and 11 deletions
152
src/sources/tana.ts
Normal file
152
src/sources/tana.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue