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>
222 lines
6.9 KiB
TypeScript
222 lines
6.9 KiB
TypeScript
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);
|
||
});
|
||
});
|