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:
Kate Meerburg 2026-05-24 22:25:06 +02:00
parent ddcb03d3dd
commit 2e34246d14
12 changed files with 1665 additions and 11 deletions

View file

@ -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

26
config.sample.json Normal file
View file

@ -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"
}
}
}

View file

@ -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 13', 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);
});
});

View file

@ -1,10 +1,13 @@
import type { Renderable } from './template';
import { TodayRenderable, type TodayConfig } from './todayview';
import { ICSRenderable } from './xlcalendar';
type PluginFactory = (settings: Record<string, any>) => Renderable;
const registry: Record<string, PluginFactory> = {
calendar: (settings) => new ICSRenderable(settings.urls ?? []),
today: (settings) =>
new TodayRenderable(settings as TodayConfig),
};
export const createPlugin = (

96
src/preview.ts Normal file
View file

@ -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 <path> Config file (default: config.json)
--device <id> Device ID from config (default: first device)
--output <path> Output PNG path (default: preview.png)
--firefox <path> Firefox executable path (or set FIREFOX env var)
--dither Apply e-ink dithering via ImageMagick
--html Also save the raw HTML to <output>.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}`);

View file

@ -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');
});
});

51
src/sources/donetick.ts Normal file
View file

@ -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<ChoreItem[]> {
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 };
});
}

101
src/sources/ics.test.ts Normal file
View file

@ -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);
});
});

71
src/sources/ics.ts Normal file
View file

@ -0,0 +1,71 @@
import * as ical from 'node-ical';
import type { AgendaEvent } from '../todayview';
export async function fetchICSAgenda(
urls: string[]
): Promise<AgendaEvent[]> {
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<RawAgendaEvent[]> {
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')}`;
}

199
src/sources/tana.test.ts Normal file
View file

@ -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 <!-- comment -->
**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();
});
});

152
src/sources/tana.ts Normal file
View 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 };
}

769
src/todayview.tsx Normal file
View file

@ -0,0 +1,769 @@
import { renderToString } from 'react-dom/server';
import type { Renderable, RenderMode } from './template';
import type { DeviceModel } from './devices';
import { fetchDonetickChores } from './sources/donetick';
import { fetchICSAgenda } from './sources/ics';
import { fetchTanaFocus, fetchTanaTasks } from './sources/tana';
/* ---------- Types ---------- */
export interface AgendaEvent {
start: string; // "HH:MM"
end: string;
title: string;
where: string;
kind: string;
}
export interface ChoreItem {
text: string;
label?: string;
overdue_days?: number;
}
export interface TodoItem {
text: string;
tag: string;
est: string;
done: boolean;
}
export interface OverdueItem {
text: string;
tag: string;
age: string;
}
export interface TodayData {
focus?: string;
success?: string;
agenda?: AgendaEvent[];
chores?: ChoreItem[];
todos_due?: TodoItem[];
todos_overdue?: OverdueItem[];
}
export interface TodayConfig extends TodayData {
calendar_urls?: string[];
tana?: {
url: string;
token: string;
workspace?: string;
};
donetick?: {
url?: string;
token: string;
user_id: number;
};
max_chores?: number; // default: 8
}
/* ---------- Style constants ---------- */
const FONT_BODY = '"Geist", system-ui, sans-serif';
const FONT_MONO = '"Geist Mono", ui-monospace, monospace';
const STYLES = `
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap");
html, body { margin: 0; background: #fff; }
.today-panel {
--paper: #ffffff;
--ink: #1a1a1a;
--ink-2: #3a3a3a;
--ink-3: #6a675e;
--rule: rgba(26,26,26,0.18);
width: 100%; height: 100vh;
background: var(--paper);
color: var(--ink);
display: grid;
grid-template-rows: auto 1fr;
font-family: ${FONT_BODY};
box-sizing: border-box;
}
`;
/* ---------- Atoms ---------- */
const Check = ({ on }: { on: boolean }) => (
<span
aria-hidden
style={{
display: 'inline-block',
width: 12,
height: 12,
border: '1.5px solid var(--ink)',
background: on ? 'var(--ink)' : 'transparent',
position: 'relative',
verticalAlign: 'middle',
flex: '0 0 12px',
}}>
{on && (
<svg
viewBox="0 0 12 12"
width="12"
height="12"
style={{ position: 'absolute', inset: -1.5 }}>
<polyline
points="2.5,6.5 5,9 9.5,3.5"
fill="none"
stroke="var(--paper)"
strokeWidth="2"
strokeLinecap="square"
strokeLinejoin="miter"
/>
</svg>
)}
</span>
);
const SectionTitle = ({
children,
count,
right,
}: {
children: string;
count?: number | null;
right?: string;
}) => (
<div
style={{
display: 'flex',
alignItems: 'baseline',
gap: 8,
paddingBottom: 4,
borderBottom: '1px solid var(--ink)',
marginBottom: 8,
}}>
<span
style={{
fontFamily: FONT_MONO,
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.14em',
textTransform: 'uppercase',
}}>
{children}
</span>
{count != null && (
<span style={{ fontFamily: FONT_MONO, fontSize: 10, color: 'var(--ink-3)' }}>
&middot; {count}
</span>
)}
<span style={{ flex: 1 }} />
{right && (
<span
style={{
fontFamily: FONT_MONO,
fontSize: 9.5,
color: 'var(--ink-3)',
letterSpacing: '0.08em',
textTransform: 'uppercase',
}}>
{right}
</span>
)}
</div>
);
/* ---------- Top bar ---------- */
const DOW = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const getWeekNumber = (d: Date): number => {
const start = new Date(d.getFullYear(), 0, 1);
const diff = d.getTime() - start.getTime() + (start.getTimezoneOffset() - d.getTimezoneOffset()) * 60000;
return Math.ceil((diff / 86400000 + start.getDay() + 1) / 7);
};
const getDayOfYear = (d: Date): number => {
const start = new Date(d.getFullYear(), 0, 0);
const diff = d.getTime() - start.getTime();
return Math.floor(diff / 86400000);
};
const FocusLine = ({ label, text }: { label: string; text: string }) => (
<div style={{ display: 'grid', gridTemplateColumns: '62px 1fr', gap: 10, alignItems: 'baseline' }}>
<div
style={{
fontFamily: FONT_MONO,
fontSize: 9.5,
fontWeight: 600,
letterSpacing: '0.16em',
color: 'var(--ink-3)',
paddingTop: 2,
}}>
{label}
</div>
<div
style={{
fontFamily: FONT_BODY,
fontSize: 15.5,
lineHeight: 1.25,
fontWeight: 450,
letterSpacing: '-0.005em',
}}>
{text}
</div>
</div>
);
const TopBar = ({ data }: { data: TodayData }) => {
const d = new Date();
const big = String(d.getDate()).padStart(2, '0');
return (
<header
style={{
display: 'grid',
gridTemplateColumns: 'auto 1px 1fr',
gap: 16,
alignItems: 'stretch',
padding: '10px 18px 12px',
borderBottom: '1.5px solid var(--ink)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 200 }}>
<div
style={{
fontFamily: FONT_BODY,
fontSize: 56,
lineHeight: 0.9,
fontWeight: 500,
letterSpacing: '-0.04em',
fontVariantNumeric: 'lining-nums tabular-nums',
}}>
{big}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, paddingTop: 4 }}>
<div style={{ fontFamily: FONT_BODY, fontSize: 17, lineHeight: 1, fontWeight: 500, letterSpacing: '-0.01em' }}>
{DOW[d.getDay()]}
</div>
<div
style={{
fontFamily: FONT_MONO,
fontSize: 10,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: 'var(--ink-2)',
}}>
{MONTHS[d.getMonth()]} {d.getFullYear()}
</div>
<div
style={{
fontFamily: FONT_MONO,
fontSize: 9,
letterSpacing: '0.18em',
textTransform: 'uppercase',
color: 'var(--ink-3)',
marginTop: 2,
}}>
Week {getWeekNumber(d)} &middot; Day {getDayOfYear(d)}
</div>
</div>
</div>
<div style={{ width: 1, background: 'var(--ink)', opacity: 0.85 }} />
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 6, minWidth: 0 }}>
<FocusLine label="FOCUS" text={data.focus} />
<FocusLine label="SUCCESS" text={data.success} />
</div>
</header>
);
};
/* ---------- Agenda ---------- */
const Agenda = ({ events }: { events: AgendaEvent[] }) => {
const now = new Date();
const nowStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
const nowMin = now.getHours() * 60 + now.getMinutes();
// Derive timeline range from events
const startMin = events.length
? parseInt(events[0]!.start.split(':')[0]!) * 60
: 8 * 60;
const endMin = events.length
? parseInt(events[events.length - 1]!.end.split(':')[0]!) * 60 + parseInt(events[events.length - 1]!.end.split(':')[1]!)
: 17 * 60;
const rangeMin = Math.max(endMin - startMin, 1);
const pct = Math.min(Math.max((nowMin - startMin) / rangeMin, 0), 1);
// Hour ticks at even hours within range
const ticks: number[] = [];
for (let h = Math.ceil(startMin / 120) * 2; h * 60 <= endMin; h += 2) {
ticks.push(h);
}
const startLabel = events[0]?.start ?? '08:00';
const endLabel = events[events.length - 1]?.end ?? '17:00';
return (
<section style={{ padding: '10px 14px 10px 18px', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<SectionTitle right={`${startLabel} \u2014 ${endLabel}`} count={events.length}>
Agenda
</SectionTitle>
<div style={{ display: 'flex', gap: 10, flex: 1, minHeight: 0 }}>
{/* Timeline rail */}
<div style={{ width: 14, position: 'relative', borderRight: '1px dashed var(--rule)', flex: '0 0 14px' }}>
{ticks.map((h) => {
const p = (h * 60 - startMin) / rangeMin;
return (
<div key={h} style={{ position: 'absolute', top: `${p * 100}%`, left: 0, right: -1, borderTop: '1px solid var(--rule)' }}>
<span style={{ position: 'absolute', left: -2, top: -6, fontFamily: FONT_MONO, fontSize: 8, color: 'var(--ink-3)' }}>
{String(h).padStart(2, '0')}
</span>
</div>
);
})}
{/* Now marker */}
<div style={{ position: 'absolute', left: -2, right: -3, top: `${pct * 100}%`, display: 'flex', alignItems: 'center' }}>
<div style={{ width: 6, height: 6, background: 'var(--ink)', transform: 'rotate(45deg)' }} />
<div style={{ flex: 1, height: 0, borderTop: '1.5px solid var(--ink)' }} />
</div>
</div>
{/* Event list */}
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 7, flex: 1, minWidth: 0 }}>
{events.map((e, i) => {
const past = e.end <= nowStr;
const current = e.start <= nowStr && e.end > nowStr;
return (
<li
key={i}
style={{
display: 'grid',
gridTemplateColumns: '54px 1fr',
columnGap: 10,
opacity: past ? 0.42 : 1,
}}>
<div
style={{
fontFamily: FONT_MONO,
fontSize: 10,
lineHeight: 1.2,
fontWeight: 500,
paddingTop: 2,
fontVariantNumeric: 'tabular-nums',
}}>
<div>{e.start}</div>
<div style={{ color: 'var(--ink-3)' }}>{e.end}</div>
</div>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, minWidth: 0 }}>
{current && (
<span
aria-hidden
style={{
width: 5,
height: 5,
background: 'var(--ink)',
display: 'inline-block',
flex: '0 0 5px',
transform: 'translateY(-1px)',
}}
/>
)}
<span
style={{
fontFamily: FONT_BODY,
fontSize: 13.5,
fontWeight: current ? 600 : 500,
lineHeight: 1.15,
letterSpacing: '-0.005em',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textDecoration: past ? 'line-through' : 'none',
textDecorationColor: 'var(--ink-3)',
}}>
{e.title}
</span>
</div>
<div
style={{
fontFamily: FONT_MONO,
fontSize: 9.5,
color: 'var(--ink-3)',
letterSpacing: '0.04em',
marginTop: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{e.where} &middot; {e.kind}
</div>
</div>
</li>
);
})}
</ul>
</div>
</section>
);
};
/* ---------- Chores ---------- */
const OverdueBadge = ({ days }: { days: number }) => (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 16,
height: 16,
border: '1.5px solid var(--ink)',
fontFamily: FONT_MONO,
fontSize: 9,
fontWeight: 600,
lineHeight: 1,
flex: '0 0 16px',
background: days > 0 ? 'var(--ink)' : 'transparent',
color: days > 0 ? 'var(--paper)' : 'var(--ink)',
}}>
{days}
</span>
);
const Chores = ({ items }: { items: ChoreItem[] }) => (
<section style={{ padding: '10px 14px 12px 18px', display: 'flex', flexDirection: 'column', borderTop: '1px solid var(--rule)' }}>
<SectionTitle>Chores</SectionTitle>
<ul
style={{
listStyle: 'none',
margin: 0,
padding: 0,
display: 'grid',
gridTemplateColumns: '1fr 1fr',
columnGap: 16,
rowGap: 6,
}}>
{items.map((it, i) => (
<li key={i}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 9,
width: '100%',
fontFamily: FONT_BODY,
fontSize: 13,
lineHeight: 1.2,
fontWeight: 450,
color: 'var(--ink)',
}}>
<OverdueBadge days={it.overdue_days ?? 0} />
<span>
{it.text}
{it.label && (
<span
style={{
fontFamily: FONT_MONO,
fontSize: 9,
color: 'var(--ink-3)',
letterSpacing: '0.08em',
textTransform: 'uppercase',
marginLeft: 4,
}}>
&middot; {it.label}
</span>
)}
</span>
</div>
</li>
))}
</ul>
</section>
);
/* ---------- Tasks (right column) ---------- */
const Tasks = ({
due,
overdue,
}: {
due: TodoItem[];
overdue: OverdueItem[];
}) => {
const doneCount = due.filter((d) => d.done).length;
return (
<aside
style={{
padding: '10px 18px 12px 14px',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
borderLeft: '1.5px solid var(--ink)',
gap: 10,
}}>
<div>
<SectionTitle right={`${doneCount}/${due.length}`}>Due today</SectionTitle>
<ul
style={{
listStyle: 'none',
margin: 0,
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: 6,
}}>
{due.map((t, i) => (
<li
key={i}
style={{
display: 'grid',
gridTemplateColumns: '12px 1fr auto',
columnGap: 9,
alignItems: 'baseline',
color: t.done ? 'var(--ink-3)' : 'var(--ink)',
}}>
<span style={{ transform: 'translateY(2px)' }}>
<Check on={t.done} />
</span>
<span
style={{
fontFamily: FONT_BODY,
fontSize: 13,
fontWeight: 450,
lineHeight: 1.2,
textDecoration: t.done ? 'line-through' : 'none',
}}>
{t.text}
{t.tag && (
<>
{' '}
<span
style={{
fontFamily: FONT_MONO,
fontSize: 9,
color: 'var(--ink-3)',
letterSpacing: '0.08em',
textTransform: 'uppercase',
marginLeft: 2,
}}>
&middot; {t.tag}
</span>
</>
)}
</span>
{t.est && (
<span
style={{
fontFamily: FONT_MONO,
fontSize: 10,
color: 'var(--ink-2)',
fontVariantNumeric: 'tabular-nums',
}}>
{t.est}
</span>
)}
</li>
))}
</ul>
</div>
{overdue.length > 0 && <div style={{ borderTop: '1px dashed var(--rule)', paddingTop: 8 }}>
<SectionTitle right={`${overdue.length}`}>Overdue</SectionTitle>
<ul
style={{
listStyle: 'none',
margin: 0,
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: 6,
}}>
{overdue.map((t, i) => (
<li
key={i}
style={{
display: 'grid',
gridTemplateColumns: '12px 1fr auto',
columnGap: 9,
alignItems: 'baseline',
}}>
<span
aria-hidden
style={{
fontFamily: FONT_MONO,
fontWeight: 700,
fontSize: 14,
lineHeight: 1,
transform: 'translateY(2px)',
}}>
!
</span>
<span
style={{
fontFamily: FONT_BODY,
fontSize: 13,
fontWeight: 450,
lineHeight: 1.2,
textDecoration: 'underline',
textDecorationStyle: 'dotted',
textUnderlineOffset: 3,
textDecorationColor: 'var(--ink-3)',
}}>
{t.text}
{t.tag && (
<>
{' '}
<span
style={{
fontFamily: FONT_MONO,
fontSize: 9,
color: 'var(--ink-3)',
letterSpacing: '0.08em',
textTransform: 'uppercase',
marginLeft: 2,
textDecoration: 'none',
}}>
&middot; {t.tag}
</span>
</>
)}
</span>
{t.age && (
<span
style={{
fontFamily: FONT_MONO,
fontSize: 10,
color: 'var(--ink)',
fontVariantNumeric: 'tabular-nums',
padding: '1px 4px',
border: '1px solid var(--ink)',
}}>
{t.age}
</span>
)}
</li>
))}
</ul>
</div>}
</aside>
);
};
/* ---------- Main layout ---------- */
const TodayPanel = ({ data }: { data: TodayData }) => (
<div className="today-panel">
<TopBar data={data} />
<main style={{ display: 'grid', gridTemplateColumns: '1.55fr 1fr', minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateRows: '1fr auto', minHeight: 0 }}>
<Agenda events={data.agenda} />
<Chores items={data.chores} />
</div>
<Tasks due={data.todos_due} overdue={data.todos_overdue} />
</main>
</div>
);
/* ---------- Renderable ---------- */
export class TodayRenderable implements Renderable {
public hash: string = '';
public nextEvent?: Date;
constructor(private config: TodayConfig) {}
private async resolveData(): Promise<TodayData> {
const data: TodayData = { ...this.config };
const fetches: Promise<void>[] = [];
if (this.config.tana) {
fetches.push(
Promise.all([
fetchTanaFocus(this.config.tana),
fetchTanaTasks(this.config.tana),
])
.then(([focus, tasks]) => {
if (focus.focus) data.focus = focus.focus;
if (focus.success) data.success = focus.success;
if (tasks.due.length) data.todos_due = tasks.due;
if (tasks.overdue.length) data.todos_overdue = tasks.overdue;
})
.catch((e) => console.error('Tana fetch failed:', e))
);
}
if (this.config.calendar_urls?.length) {
fetches.push(
fetchICSAgenda(this.config.calendar_urls)
.then((events) => {
if (events.length) data.agenda = events;
})
.catch((e) => console.error('ICS fetch failed:', e))
);
}
if (this.config.donetick) {
fetches.push(
fetchDonetickChores(this.config.donetick)
.then((chores) => {
if (chores.length) data.chores = chores;
})
.catch((e) => console.error('Donetick fetch failed:', e))
);
}
await Promise.all(fetches);
// Limit chores
const maxChores = this.config.max_chores ?? 8;
if (data.chores) {
data.chores = data.chores.slice(0, maxChores);
}
return data;
}
async render(_mode: RenderMode): Promise<string> {
const data = await this.resolveData();
const agenda = data.agenda ?? [];
const chores = data.chores ?? [];
const todosDue = data.todos_due ?? [];
const now = new Date();
const nowStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
this.hash = Bun.hash
.xxHash64(
JSON.stringify([
now.toDateString(),
nowStr.substring(0, 4), // hash changes each 10 min block
data.focus,
agenda.length,
chores.length,
todosDue.filter((t) => t.done).length,
]),
2n
)
.toString();
// Find the next agenda event to schedule a refresh
for (const event of agenda) {
const [h, m] = event.start.split(':').map(Number) as [number, number];
const eventTime = new Date(now);
eventTime.setHours(h, m, 0, 0);
if (eventTime > now) {
this.nextEvent = eventTime;
break;
}
}
const resolved: TodayData = {
focus: data.focus ?? '',
success: data.success ?? '',
agenda,
chores,
todos_due: todosDue,
todos_overdue: data.todos_overdue ?? [],
};
const html = renderToString(<TodayPanel data={resolved} />);
return `<style>${STYLES}</style><div class="view view--full">${html}</div>`;
}
}