diff --git a/.gitignore b/.gitignore index 2567214..a14702c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,3 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store - -# Config files -config.json diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ab7ea51..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,71 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -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 -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). See `config.sample.json` for the config format. - -## Architecture - -### Plugin system - -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()`. - -Available plugins: - -- **`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`. - -### Data sources (`src/sources/`) - -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 - -Deployed as a NixOS module via `flake.nix` / `default.nix`. The `default.nix` defines a systemd service with `DynamicUser=true`. The Nix derivation copies source directly (no build step — runs with `bun run` at runtime). - -## Formatting - -Uses Prettier: single quotes, trailing commas (es5), 2-space indent, bracket same line. diff --git a/config.sample.json b/config.sample.json deleted file mode 100644 index 8b615b6..0000000 --- a/config.sample.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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" - }, - "location": { - "latitude": 48.8566, - "longitude": 2.3522 - }, - "donetick": { - "token": "your-donetick-access-token", - "user_id": 1 - } - }, - "refresh": 300, - "model": "inkplate_10" - } - } -} diff --git a/default.nix b/default.nix index 1c77b23..e9b0e26 100644 --- a/default.nix +++ b/default.nix @@ -35,16 +35,10 @@ in type = json.type; example = lib.literalExpression '' { - base_url = "http://192.168.50.124:2300"; - devices = { - unknown = { - plugin = "calendar"; - settings = { - urls = ["https://user.fm/calendar/....ics"]; - }; - refresh = 60; - model = "inkplate_10"; - }; + unknown = { + urls = ["https://user.fm/calendar/....ics"]; + refresh = 60; + model = "inkplate_10"; }; } ''; diff --git a/src/plugins.test.ts b/src/plugins.test.ts deleted file mode 100644 index 16f6b21..0000000 --- a/src/plugins.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; -import type { Server } from 'bun'; -import { createPlugin } from './plugins'; - -// --- Synthetic 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 currentStart = new Date(now.getTime() - 30 * 60_000); -const currentEnd = new Date(now.getTime() + 30 * 60_000); -const nextStart = new Date(now.getTime() + 60 * 60_000); -const nextEnd = new Date(now.getTime() + 120 * 60_000); -const laterStart = new Date(now.getTime() + 180 * 60_000); -const laterEnd = new Date(now.getTime() + 240 * 60_000); - -const ics = `BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Test//Test//EN -BEGIN:VEVENT -DTSTART:${icsDate(currentStart)} -DTEND:${icsDate(currentEnd)} -SUMMARY:Current Meeting -UID:test-current@test -END:VEVENT -BEGIN:VEVENT -DTSTART:${icsDate(nextStart)} -DTEND:${icsDate(nextEnd)} -SUMMARY:Next Meeting -UID:test-next@test -END:VEVENT -BEGIN:VEVENT -DTSTART:${icsDate(laterStart)} -DTEND:${icsDate(laterEnd)} -SUMMARY:Later Meeting -UID:test-later@test -END:VEVENT -END:VCALENDAR`; - -const fakeModel = { - width: 800, - height: 480, - css: { classes: { device: 'device', size: 'size', density: 'density' } }, -} as any; - -// --- ICS server --- - -let icsServer: Server; -let icsUrl: string; - -beforeAll(() => { - icsServer = 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:${icsServer.port}/cal.ics`; -}); - -afterAll(() => { - icsServer.stop(); -}); - -// --- Tests --- - -describe('plugin registry', () => { - test('unknown plugin throws', () => { - expect(() => createPlugin('nonexistent', {})).toThrow( - 'Unknown plugin: nonexistent' - ); - }); - - test('calendar plugin creates an ICSRenderable', () => { - const plugin = createPlugin('calendar', { urls: [] }); - expect(plugin.hash).toBe(''); - expect(plugin.nextEvent).toBeUndefined(); - }); -}); - -describe('calendar plugin rendering', () => { - test('renders events from ICS feed', async () => { - const plugin = createPlugin('calendar', { urls: [icsUrl] }); - const html = await plugin.render('full', fakeModel); - - expect(html).toContain('Current Meeting'); - expect(html).toContain('Next Meeting'); - expect(html).toContain('Later Meeting'); - }); - - test('shows BEZET when there is a current event', async () => { - const plugin = createPlugin('calendar', { urls: [icsUrl] }); - const html = await plugin.render('full', fakeModel); - - expect(html).toContain('BEZET'); - }); - - test('computes hash after render', async () => { - const plugin = createPlugin('calendar', { urls: [icsUrl] }); - await plugin.render('full', fakeModel); - - expect(plugin.hash).toBeTruthy(); - }); - - test('sets nextEvent to upcoming event start', async () => { - const plugin = createPlugin('calendar', { urls: [icsUrl] }); - await plugin.render('full', fakeModel); - - expect(plugin.nextEvent).toBeInstanceOf(Date); - const diff = Math.abs(plugin.nextEvent!.getTime() - nextStart.getTime()); - expect(diff).toBeLessThan(120_000); - }); - - test('renders VRIJ with no events', async () => { - const plugin = createPlugin('calendar', { urls: [] }); - const html = await plugin.render('full', fakeModel); - - expect(html).toContain('VRIJ'); - 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 deleted file mode 100644 index aac91a2..0000000 --- a/src/plugins.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 = ( - name: string, - settings: Record -): Renderable => { - const factory = registry[name]; - if (!factory) throw new Error(`Unknown plugin: ${name}`); - return factory(settings); -}; diff --git a/src/preview.ts b/src/preview.ts deleted file mode 100644 index 766050c..0000000 --- a/src/preview.ts +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index 336e3df..0000000 --- a/src/sources/donetick.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index d63d8a9..0000000 --- a/src/sources/donetick.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 9004ae0..0000000 --- a/src/sources/ics.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -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 deleted file mode 100644 index 57e73d2..0000000 --- a/src/sources/ics.ts +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 31203f9..0000000 --- a/src/sources/tana.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -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 deleted file mode 100644 index 90e033d..0000000 --- a/src/sources/tana.ts +++ /dev/null @@ -1,152 +0,0 @@ -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