From 2e34246d14210da088fd5cce95e4b33c6aa8bb1b Mon Sep 17 00:00:00 2001 From: Kate Meerburg Date: Sun, 24 May 2026 22:25:06 +0200 Subject: [PATCH] Add today-view plugin with Tana, ICS, and Donetick data sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 50 ++- config.sample.json | 26 ++ src/plugins.test.ts | 95 +++++ src/plugins.ts | 3 + src/preview.ts | 96 +++++ src/sources/donetick.test.ts | 63 +++ src/sources/donetick.ts | 51 +++ src/sources/ics.test.ts | 101 +++++ src/sources/ics.ts | 71 ++++ src/sources/tana.test.ts | 199 +++++++++ src/sources/tana.ts | 152 +++++++ src/todayview.tsx | 769 +++++++++++++++++++++++++++++++++++ 12 files changed, 1665 insertions(+), 11 deletions(-) create mode 100644 config.sample.json create mode 100644 src/preview.ts create mode 100644 src/sources/donetick.test.ts create mode 100644 src/sources/donetick.ts create mode 100644 src/sources/ics.test.ts create mode 100644 src/sources/ics.ts create mode 100644 src/sources/tana.test.ts create mode 100644 src/sources/tana.ts create mode 100644 src/todayview.tsx diff --git a/CLAUDE.md b/CLAUDE.md index c3a05d7..ab7ea51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,35 +4,63 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -TRMNLc is a self-hosted server that serves rendered calendar displays to [TRMNL](https://usetrmnl.com/) e-ink devices. It fetches ICS calendar feeds, renders them as HTML using React SSR, screenshots the HTML with Puppeteer (Firefox), dithers the image with ImageMagick for e-ink display, and serves it via the TRMNL device API. - -The UI is in Dutch (e.g., "VRIJ" = free, "BEZET" = busy, "VOLGENDE" = next, "DAARNA" = after that). +TRMNLc is a self-hosted server that serves rendered displays to [TRMNL](https://usetrmnl.com/) e-ink devices. It renders plugin UIs as HTML using React SSR, screenshots with Puppeteer (Firefox), dithers with ImageMagick for e-ink, and serves via the TRMNL device API. ## Running ```bash -# Install dependencies bun install # Run the server (requires Firefox and ImageMagick in PATH) FIREFOX=/path/to/firefox CONFIG_FILE=config.json COLORMAP=./colormap.png bun run src/web.ts + +# Run tests +bun test ``` -The server runs on port 2300 by default (`BUN_PORT` env var). No test suite exists. +The server runs on port 2300 by default (`BUN_PORT` env var). See `config.sample.json` for the config format. ## Architecture -The request flow for `/api/display`: +### Plugin system -1. **`src/web.ts`** — Bun HTTP server. Reads `CONFIG_FILE` JSON mapping device MAC addresses to ICS URLs + device model. Handles `/api/setup`, `/api/display` (returns rendered PNG), `/api/display/html` (returns raw HTML), and `/api/render/:id/:ignore` (serves cached PNGs). +Each device is configured with a `plugin` name and `settings` object. The plugin registry (`src/plugins.ts`) maps names to factory functions that return a `Renderable`. The `Renderable` interface (`src/template.ts`) requires `hash`, `nextEvent?`, and `async render()`. -2. **`src/xlcalendar.tsx`** — `ICSRenderable` class. Fetches ICS feeds via `node-ical`, expands recurring events, processes them into current/next/secondary slots, and renders a React component to HTML via `renderToString`. Computes a content hash and calculates the next refresh time based on upcoming events. +Available plugins: -3. **`src/template.ts`** — Wraps plugin HTML output in a full HTML page with TRMNL's CSS/JS framework. Defines the `Renderable` interface and `RenderMode` type (`full`, `half_horizontal`, `half_vertical`, `quadrant`). +- **`calendar`** — Full-screen ICS calendar view with current/next/later event slots. UI is in Dutch ("VRIJ", "BEZET", "VOLGENDE"). Implementation: `src/xlcalendar.tsx`. +- **`today`** — Daily dashboard with date header, focus/success lines, agenda timeline, chores checklist, and due/overdue tasks. Implementation: `src/todayview.tsx`. -4. **`src/render.ts`** — Uses Puppeteer (Firefox) to screenshot the rendered HTML, then pipes through ImageMagick (`magick`) to dither to a 2-bit grayscale PNG using the colormap. +### Data sources (`src/sources/`) -5. **`src/devices.ts`** — Fetches device model definitions (screen dimensions, CSS classes) from `trmnl.com/api/models` at startup. +The today plugin fetches from external services during `render()`, all in parallel: + +- **`tana.ts`** — Fetches daily focus/success text and due/overdue tasks from a Tana server. Focus: calendar node → markdown field parsing. Tasks: single search query (`lt` tomorrow), split client-side by due date. +- **`ics.ts`** — Fetches today's non-full-day events from ICS feeds, returns `AgendaEvent[]`. Note: `location` lives on `instance.event.location`, not on the expanded instance directly. +- **`donetick.ts`** — Fetches chores from a Donetick instance (defaults to `app.donetick.com`). Filters by `user_id` and `isActive`. Done status: `nextDueDate > today` means completed. + +### Config structure + +```json +{ + "base_url": "http://host:2300", + "devices": { + "DEVICE_MAC": { + "plugin": "today", + "settings": { ... }, + "refresh": 300, + "model": "inkplate_10" + } + } +} +``` + +### Render pipeline + +1. **`src/web.ts`** — Bun HTTP server. Routes: `/api/setup`, `/api/display` (PNG), `/api/display/html` (raw HTML), `/api/render/:id/:ignore` (cached PNGs). +2. **`src/template.ts`** — Wraps plugin HTML in a full page with TRMNL's CSS/JS framework. +3. **`src/render.ts`** — Puppeteer (Firefox) screenshots the HTML, then ImageMagick dithers to 2-bit grayscale PNG. +4. **`src/devices.ts`** — Fetches device model definitions (screen dimensions, CSS classes) from `trmnl.com/api/models` at startup. ## Deployment diff --git a/config.sample.json b/config.sample.json new file mode 100644 index 0000000..95de38d --- /dev/null +++ b/config.sample.json @@ -0,0 +1,26 @@ +{ + "base_url": "http://192.168.1.100:2300", + "devices": { + "AA:BB:CC:DD:EE:FF": { + "plugin": "today", + "settings": { + "calendar_urls": [ + "https://calendar.example.com/feed.ics" + ], + "tana": { + "url": "http://192.168.1.100:8262", + "token": "your-tana-api-token", + "workspace": "your-workspace-id", + "task_tag_id": "your-task-tag-node-id", + "due_date_field_id": "your-due-date-field-id" + }, + "donetick": { + "token": "your-donetick-access-token", + "user_id": 1 + } + }, + "refresh": 300, + "model": "inkplate_10" + } + } +} diff --git a/src/plugins.test.ts b/src/plugins.test.ts index 97b6e5d..16f6b21 100644 --- a/src/plugins.test.ts +++ b/src/plugins.test.ts @@ -125,3 +125,98 @@ describe('calendar plugin rendering', () => { expect(html).not.toContain('BEZET'); }); }); + +// --- Today plugin test data --- + +import type { TodayData } from './todayview'; + +const SAMPLE_TODAY: TodayData = { + focus: 'Ship the Q2 retrospective deck — clarity over completeness.', + success: 'Three deep-work blocks. One real conversation. Inbox under 10.', + agenda: [ + { start: '08:30', end: '09:00', title: 'Morning planning', where: 'Office', kind: 'solo' }, + { start: '09:30', end: '10:15', title: 'Design review', where: 'Meet', kind: 'call' }, + { start: '11:00', end: '12:00', title: '1:1 with Priya', where: 'Walk', kind: 'in-person' }, + { start: '13:30', end: '15:00', title: 'Deep work — deck', where: 'Library', kind: 'focus block' }, + { start: '16:00', end: '16:30', title: 'Eng standup', where: 'Zoom', kind: 'call' }, + ], + chores: [ + { text: 'Make the bed' }, + { text: '20 min reading' }, + { text: 'Water the plants' }, + { text: 'Walk · 30 min' }, + { text: 'Journal · 3 lines' }, + { text: 'Lights out by 23:00' }, + ], + todos_due: [ + { text: 'Review Atlas spec comments', tag: 'Atlas', est: '30m', done: false }, + { text: 'Draft retro deck — sections 1–3', tag: 'Deck', est: '1h', done: false }, + { text: 'Reply to Marcus re: contract', tag: 'Ops', est: '10m', done: false }, + { text: 'Submit June expenses', tag: 'Admin', est: '15m', done: false }, + ], + todos_overdue: [ + { text: 'File Q1 receipts', tag: 'Admin', age: '3d' }, + { text: 'Renew domain · seva.dev', tag: 'Ops', age: '1d' }, + { text: 'Book dentist', tag: 'Health', age: '9d' }, + ], +}; + +describe('today plugin', () => { + test('renders all sections', async () => { + const plugin = createPlugin('today', SAMPLE_TODAY); + const html = await plugin.render('full', fakeModel); + + // Top bar + expect(html).toContain('FOCUS'); + expect(html).toContain('SUCCESS'); + expect(html).toContain('retrospective deck'); + + // Agenda + expect(html).toContain('Agenda'); + expect(html).toContain('Morning planning'); + expect(html).toContain('Eng standup'); + + // Chores + expect(html).toContain('Chores'); + expect(html).toContain('Make the bed'); + expect(html).toContain('Water the plants'); + + // Tasks + expect(html).toContain('Due today'); + expect(html).toContain('Overdue'); + expect(html).toContain('Review Atlas spec comments'); + expect(html).toContain('File Q1 receipts'); + }); + + test('renders with minimal data', async () => { + const plugin = createPlugin('today', { + focus: 'Custom focus text', + success: 'Custom success text', + agenda: [ + { start: '09:00', end: '10:00', title: 'Custom Event', where: 'Room', kind: 'call' }, + ], + chores: [{ text: 'Custom chore' }], + todos_due: [{ text: 'Custom task', tag: 'Test', est: '5m', done: false }], + todos_overdue: [], + }); + const html = await plugin.render('full', fakeModel); + + expect(html).toContain('Custom focus text'); + expect(html).toContain('Custom Event'); + expect(html).toContain('Custom chore'); + expect(html).toContain('Custom task'); + }); + + test('computes hash after render', async () => { + const plugin = createPlugin('today', SAMPLE_TODAY); + await plugin.render('full', fakeModel); + expect(plugin.hash).toBeTruthy(); + }); + + test('renders today date', async () => { + const plugin = createPlugin('today', SAMPLE_TODAY); + const html = await plugin.render('full', fakeModel); + const dayStr = String(new Date().getDate()).padStart(2, '0'); + expect(html).toContain(dayStr); + }); +}); diff --git a/src/plugins.ts b/src/plugins.ts index 1c206dc..aac91a2 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -1,10 +1,13 @@ import type { Renderable } from './template'; +import { TodayRenderable, type TodayConfig } from './todayview'; import { ICSRenderable } from './xlcalendar'; type PluginFactory = (settings: Record) => Renderable; const registry: Record = { calendar: (settings) => new ICSRenderable(settings.urls ?? []), + today: (settings) => + new TodayRenderable(settings as TodayConfig), }; export const createPlugin = ( diff --git a/src/preview.ts b/src/preview.ts new file mode 100644 index 0000000..766050c --- /dev/null +++ b/src/preview.ts @@ -0,0 +1,96 @@ +import puppeteer from 'puppeteer-core'; +import { devices } from './devices'; +import { createPlugin } from './plugins'; +import { render } from './template'; + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: bun run src/preview.ts [options] + +Render a plugin to a local PNG file for testing. + +Options: + --config Config file (default: config.json) + --device Device ID from config (default: first device) + --output Output PNG path (default: preview.png) + --firefox Firefox executable path (or set FIREFOX env var) + --dither Apply e-ink dithering via ImageMagick + --html Also save the raw HTML to .html + -h, --help Show this help`); + process.exit(0); +} + +function getArg(flag: string, fallback: string): string { + const idx = args.indexOf(flag); + return idx !== -1 && args[idx + 1] ? args[idx + 1]! : fallback; +} + +const configPath = getArg('--config', Bun.env['CONFIG_FILE'] ?? 'config.json'); +const outputPath = getArg('--output', 'preview.png'); +const firefoxPath = getArg('--firefox', Bun.env['FIREFOX'] ?? 'firefox'); +const dither = args.includes('--dither'); +const saveHtml = args.includes('--html'); + +const config = JSON.parse(await Bun.file(configPath).text()); +const deviceEntries = Object.entries(config.devices ?? {}); + +if (deviceEntries.length === 0) { + console.error('No devices found in config.'); + process.exit(1); +} + +const deviceIdArg = getArg('--device', ''); +const [deviceId, deviceConfig] = deviceIdArg + ? [deviceIdArg, config.devices[deviceIdArg]] + : (deviceEntries[0] as [string, any]); + +if (!deviceConfig) { + console.error(`Device "${deviceIdArg}" not found. Available: ${deviceEntries.map(([k]) => k).join(', ')}`); + process.exit(1); +} + +const model = devices.find((a) => a.name === (deviceConfig.model ?? 'inkplate_10')); +if (!model) { + console.error(`Unknown model: ${deviceConfig.model}`); + process.exit(1); +} + +console.log(`Rendering plugin "${deviceConfig.plugin ?? 'calendar'}" for device "${deviceId}" (${model.width}x${model.height})...`); + +const plugin = createPlugin(deviceConfig.plugin ?? 'calendar', deviceConfig.settings ?? {}); +const html = await render([[plugin, 'full']], model); + +if (saveHtml) { + const htmlPath = outputPath.replace(/\.png$/, '.html'); + await Bun.write(htmlPath, html); + console.log(`HTML saved to ${htmlPath}`); +} + +const browser = await puppeteer.launch({ + browser: 'firefox', + args: ['--new-instance'], + executablePath: firefoxPath, +}); + +const page = await browser.newPage(); +await page.setViewport({ width: model.width, height: model.height, deviceScaleFactor: 1 }); +await page.setContent(html, { waitUntil: 'networkidle2' }); +await Bun.sleep(3000); +const screenshot = await page.screenshot({ encoding: 'binary' }); +await page.close(); +await browser.close(); + +if (dither) { + const colormap = Bun.env['COLORMAP'] ?? './colormap.png'; + const processed = await Bun.spawn( + ['magick', '-', '-dither', 'FloydSteinberg', '-remap', colormap, + '-define', 'png:bit-depth=2', '-define', 'png:color-type=0', '-strip', 'png:-'], + { stdin: screenshot } + ).stdout.bytes(); + await Bun.write(outputPath, processed); +} else { + await Bun.write(outputPath, screenshot); +} + +console.log(`Preview saved to ${outputPath}`); diff --git a/src/sources/donetick.test.ts b/src/sources/donetick.test.ts new file mode 100644 index 0000000..336e3df --- /dev/null +++ b/src/sources/donetick.test.ts @@ -0,0 +1,63 @@ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import type { Server } from 'bun'; +import { fetchDonetickChores } from './donetick'; + +const today = new Date().toISOString().slice(0, 10); +const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10); +const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + +const CHORES = [ + { id: 1, name: 'Make the bed', assignedTo: 1, nextDueDate: `${tomorrow}T00:00:00Z`, isActive: true }, + { id: 2, name: 'Water the plants', assignedTo: 1, nextDueDate: `${today}T00:00:00Z`, isActive: true }, + { id: 3, name: 'Walk the dog', assignedTo: 2, nextDueDate: `${today}T00:00:00Z`, isActive: true }, + { id: 4, name: 'Inactive chore', assignedTo: 1, nextDueDate: `${today}T00:00:00Z`, isActive: false }, + { id: 5, name: 'Shared chore', assignedTo: 2, nextDueDate: `${yesterday}T00:00:00Z`, isActive: true, assignees: [{ userId: 1 }, { userId: 2 }] }, +]; + +let server: Server; +let baseUrl: string; + +beforeAll(() => { + server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + routes: { + '/eapi/v1/chore': (req) => { + if (req.headers.get('secretkey') !== 'test-token') + return new Response('Unauthorized', { status: 401 }); + return Response.json(CHORES); + }, + }, + }); + baseUrl = `http://127.0.0.1:${server.port}`; +}); + +afterAll(() => { + server.stop(); +}); + +describe('donetick source', () => { + test('fetches chores for the configured user', async () => { + const chores = await fetchDonetickChores({ + url: baseUrl, + token: 'test-token', + user_id: 1, + }); + + // Should include: Water the plants (due today), Shared chore (overdue, in assignees) + // Should exclude: Make the bed (done, nextDueDate=tomorrow), Walk the dog (different user), Inactive chore + expect(chores).toHaveLength(2); + expect(chores.map((c) => c.text)).toContain('Water the plants'); + expect(chores.map((c) => c.text)).toContain('Shared chore'); + expect(chores.map((c) => c.text)).not.toContain('Make the bed'); + expect(chores.map((c) => c.text)).not.toContain('Walk the dog'); + expect(chores.map((c) => c.text)).not.toContain('Inactive chore'); + + }); + + test('throws on auth failure', async () => { + await expect( + fetchDonetickChores({ url: baseUrl, token: 'wrong', user_id: 1 }) + ).rejects.toThrow('401'); + }); +}); diff --git a/src/sources/donetick.ts b/src/sources/donetick.ts new file mode 100644 index 0000000..d63d8a9 --- /dev/null +++ b/src/sources/donetick.ts @@ -0,0 +1,51 @@ +import type { ChoreItem } from '../todayview'; + +export interface DonetickConfig { + url?: string; + token: string; + user_id: number; +} + +interface DonetickChore { + id: number; + name: string; + assignedTo: number; + nextDueDate: string; + isActive: boolean; + assignees?: { userId: number }[]; + labelsV2?: { name: string }[]; +} + +export async function fetchDonetickChores( + config: DonetickConfig +): Promise { + const baseUrl = config.url ?? 'https://api.donetick.com'; + const resp = await fetch(`${baseUrl}/eapi/v1/chore`, { + headers: { secretkey: config.token }, + }); + if (!resp.ok) + throw new Error(`Donetick fetch failed: ${resp.status}`); + + const chores = (await resp.json()) as DonetickChore[]; + + const today = new Date().toISOString().slice(0, 10); + + return chores + .filter( + (c) => + c.isActive && + (c.assignedTo === config.user_id || + c.assignees?.some((a) => a.userId === config.user_id)) && + // Only include chores due today or overdue + c.nextDueDate?.slice(0, 10) <= today + ) + .map((c) => { + const dueDate = new Date(c.nextDueDate?.slice(0, 10) + 'T00:00:00'); + const todayDate = new Date(today + 'T00:00:00'); + const overdue_days = Math.floor( + (todayDate.getTime() - dueDate.getTime()) / 86_400_000 + ); + const label = c.labelsV2?.[0]?.name; + return { text: c.name, label, overdue_days }; + }); +} diff --git a/src/sources/ics.test.ts b/src/sources/ics.test.ts new file mode 100644 index 0000000..9004ae0 --- /dev/null +++ b/src/sources/ics.test.ts @@ -0,0 +1,101 @@ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import type { Server } from 'bun'; +import { fetchICSAgenda } from './ics'; + +const pad = (n: number) => n.toString().padStart(2, '0'); +const icsDate = (d: Date) => + `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}00`; + +const now = new Date(); +const event1Start = new Date(now); +event1Start.setHours(9, 0, 0, 0); +const event1End = new Date(now); +event1End.setHours(10, 0, 0, 0); +const event2Start = new Date(now); +event2Start.setHours(14, 30, 0, 0); +const event2End = new Date(now); +event2End.setHours(15, 15, 0, 0); + +const ics = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +DTSTART:${icsDate(event1Start)} +DTEND:${icsDate(event1End)} +SUMMARY:Morning standup +LOCATION:Zoom +UID:test-1@test +END:VEVENT +BEGIN:VEVENT +DTSTART:${icsDate(event2Start)} +DTEND:${icsDate(event2End)} +SUMMARY:Design review +LOCATION:Room 4B +UID:test-2@test +END:VEVENT +BEGIN:VEVENT +DTSTART;VALUE=DATE:${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())} +DTEND;VALUE=DATE:${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate() + 1)} +SUMMARY:All-day event +UID:test-allday@test +END:VEVENT +END:VCALENDAR`; + +let server: Server; +let icsUrl: string; + +beforeAll(() => { + server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + routes: { + '/cal.ics': () => + new Response(ics, { + headers: { 'Content-Type': 'text/calendar' }, + }), + }, + }); + icsUrl = `http://127.0.0.1:${server.port}/cal.ics`; +}); + +afterAll(() => { + server.stop(); +}); + +describe('ics agenda source', () => { + test('fetches and formats today events', async () => { + const events = await fetchICSAgenda([icsUrl]); + + expect(events.length).toBeGreaterThanOrEqual(2); + + const standup = events.find((e) => e.title === 'Morning standup'); + expect(standup).toBeDefined(); + expect(standup!.start).toBe('09:00'); + expect(standup!.end).toBe('10:00'); + expect(standup!.where).toBe('Zoom'); + + const review = events.find((e) => e.title === 'Design review'); + expect(review).toBeDefined(); + expect(review!.start).toBe('14:30'); + expect(review!.end).toBe('15:15'); + expect(review!.where).toBe('Room 4B'); + }); + + test('filters out all-day events', async () => { + const events = await fetchICSAgenda([icsUrl]); + const allDay = events.find((e) => e.title === 'All-day event'); + expect(allDay).toBeUndefined(); + }); + + test('returns sorted by start time', async () => { + const events = await fetchICSAgenda([icsUrl]); + for (let i = 1; i < events.length; i++) { + expect(events[i]!.start >= events[i - 1]!.start).toBe(true); + } + }); + + test('handles empty url list', async () => { + const events = await fetchICSAgenda([]); + expect(events).toHaveLength(0); + }); +}); diff --git a/src/sources/ics.ts b/src/sources/ics.ts new file mode 100644 index 0000000..57e73d2 --- /dev/null +++ b/src/sources/ics.ts @@ -0,0 +1,71 @@ +import * as ical from 'node-ical'; +import type { AgendaEvent } from '../todayview'; + +export async function fetchICSAgenda( + urls: string[] +): Promise { + const now = new Date(); + const startOfDay = new Date(now); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(now); + endOfDay.setHours(23, 59, 59, 999); + + const allEvents = ( + await Promise.all(urls.map((url) => fetchOne(url, startOfDay, endOfDay))) + ).flat(); + + allEvents.sort((a, b) => a.sortKey - b.sortKey); + + return allEvents.map(({ sortKey, ...event }) => event); +} + +interface RawAgendaEvent extends AgendaEvent { + sortKey: number; +} + +async function fetchOne( + url: string, + start: Date, + end: Date +): Promise { + const resp = await fetch(url, { + headers: [['User-Agent', 'private TRMNL fetcher']], + }); + const data = await resp.text(); + const parsed = Object.values(await ical.async.parseICS(data)).filter( + (a) => a?.type === 'VEVENT' + ); + + const events: RawAgendaEvent[] = []; + + for (const event of parsed) { + for (const instance of ical.expandRecurringEvent(event, { + from: start, + to: end, + excludeExdates: true, + expandOngoing: true, + includeOverrides: true, + })) { + if (instance.isFullDay) continue; + + const startTime = formatTime(instance.start); + const endTime = formatTime(instance.end); + const location = (instance.event as any).location ?? ''; + + events.push({ + start: startTime, + end: endTime, + title: (instance.summary as string) ?? '', + where: location, + kind: '', + sortKey: instance.start.valueOf(), + }); + } + } + + return events; +} + +function formatTime(d: Date): string { + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} diff --git a/src/sources/tana.test.ts b/src/sources/tana.test.ts new file mode 100644 index 0000000..31203f9 --- /dev/null +++ b/src/sources/tana.test.ts @@ -0,0 +1,199 @@ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import type { Server } from 'bun'; +import { fetchTanaFocus, fetchTanaTasks } from './tana'; + +const NODE_ID = 'test-node-123'; +const MARKDOWN = `**Today's Focus**: Ship the retrospective deck +**What would make today a success?**: Three deep-work blocks. One real conversation. +`; + +let server: Server; +let baseUrl: string; + +beforeAll(() => { + server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + routes: { + '/workspaces/:ws/calendar/node': (req) => { + const auth = req.headers.get('Authorization'); + if (auth !== 'Bearer test-token') + return new Response('Unauthorized', { status: 401 }); + return Response.json({ nodeId: NODE_ID }); + }, + '/nodes/:id': (req) => { + const auth = req.headers.get('Authorization'); + if (auth !== 'Bearer test-token') + return new Response('Unauthorized', { status: 401 }); + if (req.params.id !== NODE_ID) + return new Response('Not found', { status: 404 }); + return Response.json({ markdown: MARKDOWN }); + }, + }, + }); + baseUrl = `http://127.0.0.1:${server.port}`; +}); + +afterAll(() => { + server.stop(); +}); + +describe('tana source', () => { + test('fetches focus and success', async () => { + const result = await fetchTanaFocus({ + url: baseUrl, + token: 'test-token', + workspace: 'test-ws', + task_tag_id: 'x', + due_date_field_id: 'x', + }); + + expect(result.focus).toBe('Ship the retrospective deck'); + expect(result.success).toBe( + 'Three deep-work blocks. One real conversation.' + ); + }); + + test('strips tana link markup', async () => { + const linkServer = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + routes: { + '/workspaces/:ws/calendar/node': () => + Response.json({ nodeId: 'n1' }), + '/nodes/:id': () => + Response.json({ + markdown: + "**Today's Focus**: Review [Atlas](tana:abc123) spec\n**What would make today a success?**: Ship [the deck](tana:xyz789).\n", + }), + }, + }); + + const result = await fetchTanaFocus({ + url: `http://127.0.0.1:${linkServer.port}`, + token: 'test-token', + workspace: 'w', + task_tag_id: 'x', + due_date_field_id: 'x', + }); + + expect(result.focus).toBe('Review Atlas spec'); + expect(result.success).toBe('Ship the deck.'); + linkServer.stop(); + }); + + test('throws on auth failure', async () => { + await expect( + fetchTanaFocus({ url: baseUrl, token: 'wrong', workspace: 'w', task_tag_id: 'x', due_date_field_id: 'x' }) + ).rejects.toThrow('401'); + }); +}); + +describe('tana tasks', () => { + const today = new Date().toISOString().slice(0, 10); + const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + const threeDaysAgo = new Date(Date.now() - 3 * 86_400_000) + .toISOString() + .slice(0, 10); + + let taskServer: Server; + let taskUrl: string; + + beforeAll(() => { + taskServer = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + routes: { + '/nodes/search': () => { + // Single query returns all non-done tasks due < tomorrow + return Response.json([ + { + id: 't1', + name: 'Review spec comments', + tags: [ + { id: '81eGeJlDYzOD', name: 'task' }, + { id: 'tag1', name: 'Atlas' }, + ], + fields: [{ fieldId: 'T3ApdSKU70Y8', value: today }], + }, + { + id: 't2', + name: 'Submit expenses', + tags: [{ id: '81eGeJlDYzOD', name: 'task' }], + fields: [{ fieldId: 'T3ApdSKU70Y8', value: today }], + }, + { + id: 'o1', + name: 'File Q1 receipts', + tags: [ + { id: '81eGeJlDYzOD', name: 'task' }, + { id: 'tag2', name: 'Admin' }, + ], + fields: [{ fieldId: 'T3ApdSKU70Y8', value: threeDaysAgo }], + }, + { + id: 'o2', + name: 'Book dentist', + tags: [ + { id: '81eGeJlDYzOD', name: 'task' }, + { id: 'tag3', name: 'Health' }, + ], + fields: [{ fieldId: 'T3ApdSKU70Y8', value: yesterday }], + }, + ]); + }, + }, + }); + taskUrl = `http://127.0.0.1:${taskServer.port}`; + }); + + afterAll(() => { + taskServer.stop(); + }); + + test('fetches due-today and overdue tasks', async () => { + const result = await fetchTanaTasks({ + url: taskUrl, + token: 'test-token', + workspace: 'w', + task_tag_id: '81eGeJlDYzOD', + due_date_field_id: 'T3ApdSKU70Y8', + }); + + expect(result.due).toHaveLength(2); + expect(result.due[0]!.text).toBe('Review spec comments'); + expect(result.due[0]!.tag).toBe('Atlas'); + expect(result.due[1]!.text).toBe('Submit expenses'); + expect(result.due[1]!.tag).toBe(''); + + expect(result.overdue).toHaveLength(2); + expect(result.overdue[0]!.text).toBe('File Q1 receipts'); + expect(result.overdue[0]!.tag).toBe('Admin'); + expect(result.overdue[0]!.age).toBe('3d'); + expect(result.overdue[1]!.text).toBe('Book dentist'); + expect(result.overdue[1]!.tag).toBe('Health'); + expect(result.overdue[1]!.age).toBe('1d'); + }); + + test('handles empty results', async () => { + const emptyServer = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + routes: { + '/nodes/search': () => Response.json([]), + }, + }); + + const result = await fetchTanaTasks({ + url: `http://127.0.0.1:${emptyServer.port}`, + token: 'test-token', + workspace: 'w', + task_tag_id: 'x', + due_date_field_id: 'x', + }); + + expect(result.due).toHaveLength(0); + expect(result.overdue).toHaveLength(0); + emptyServer.stop(); + }); +}); diff --git a/src/sources/tana.ts b/src/sources/tana.ts new file mode 100644 index 0000000..90e033d --- /dev/null +++ b/src/sources/tana.ts @@ -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