Add today-view plugin with Tana, ICS, and Donetick data sources
Implements an e-ink daily dashboard plugin ("today") with four sections:
date/focus/success header, agenda timeline, chores checklist, and
due/overdue task lists.
Data sources:
- Focus & success text: Tana daily note (src/sources/tana.ts)
- Due/overdue tasks: Tana task search (src/sources/tana.ts)
- Agenda events: ICS calendar feeds (src/sources/ics.ts)
- Chores: Donetick API (src/sources/donetick.ts)
All sources fetch in parallel and fall back gracefully on error.
Tests use mock HTTP servers with synthetic data — no real services needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ddcb03d3dd
commit
2e34246d14
12 changed files with 1665 additions and 11 deletions
50
CLAUDE.md
50
CLAUDE.md
|
|
@ -4,35 +4,63 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
## Project Overview
|
## 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.
|
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.
|
||||||
|
|
||||||
The UI is in Dutch (e.g., "VRIJ" = free, "BEZET" = busy, "VOLGENDE" = next, "DAARNA" = after that).
|
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
|
||||||
bun install
|
bun install
|
||||||
|
|
||||||
# Run the server (requires Firefox and ImageMagick in PATH)
|
# 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
|
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
|
## 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
|
## Deployment
|
||||||
|
|
||||||
|
|
|
||||||
26
config.sample.json
Normal file
26
config.sample.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -125,3 +125,98 @@ describe('calendar plugin rendering', () => {
|
||||||
expect(html).not.toContain('BEZET');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import type { Renderable } from './template';
|
import type { Renderable } from './template';
|
||||||
|
import { TodayRenderable, type TodayConfig } from './todayview';
|
||||||
import { ICSRenderable } from './xlcalendar';
|
import { ICSRenderable } from './xlcalendar';
|
||||||
|
|
||||||
type PluginFactory = (settings: Record<string, any>) => Renderable;
|
type PluginFactory = (settings: Record<string, any>) => Renderable;
|
||||||
|
|
||||||
const registry: Record<string, PluginFactory> = {
|
const registry: Record<string, PluginFactory> = {
|
||||||
calendar: (settings) => new ICSRenderable(settings.urls ?? []),
|
calendar: (settings) => new ICSRenderable(settings.urls ?? []),
|
||||||
|
today: (settings) =>
|
||||||
|
new TodayRenderable(settings as TodayConfig),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createPlugin = (
|
export const createPlugin = (
|
||||||
|
|
|
||||||
96
src/preview.ts
Normal file
96
src/preview.ts
Normal 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}`);
|
||||||
63
src/sources/donetick.test.ts
Normal file
63
src/sources/donetick.test.ts
Normal 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
51
src/sources/donetick.ts
Normal 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
101
src/sources/ics.test.ts
Normal 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
71
src/sources/ics.ts
Normal 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
199
src/sources/tana.test.ts
Normal 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
152
src/sources/tana.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import type { TodoItem, OverdueItem } from '../todayview';
|
||||||
|
|
||||||
|
export interface TanaConfig {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
workspace: string;
|
||||||
|
task_tag_id: string;
|
||||||
|
due_date_field_id: string;
|
||||||
|
focus_field?: string; // default: "Today's Focus"
|
||||||
|
success_field?: string; // default: "What would make today a success?"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TanaTask {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tags?: { id: string; name: string }[];
|
||||||
|
fields?: { fieldId: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseField(md: string, fieldName: string): string | undefined {
|
||||||
|
// Match **Field Name**: value <!-- or **Field Name**: value\n
|
||||||
|
const commentPattern = new RegExp(
|
||||||
|
`\\*\\*${fieldName}\\*\\*:\\s*(.*?)\\s*<!--`
|
||||||
|
);
|
||||||
|
const newlinePattern = new RegExp(
|
||||||
|
`\\*\\*${fieldName}\\*\\*:\\s*(.*?)\\s*\\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
let match = md.match(commentPattern) ?? md.match(newlinePattern);
|
||||||
|
if (!match?.[1]) return undefined;
|
||||||
|
|
||||||
|
let value = match[1];
|
||||||
|
// Strip Tana link markup: [text](tana:id) -> text
|
||||||
|
value = value.replace(/\[(.+?)\]\(tana:[^)]+\)\s*(\p{P})/gu, '$1$2');
|
||||||
|
value = value.replace(/\[(.+?)\]\(tana:[^)]+\)/g, '$1');
|
||||||
|
return value.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTanaFocus(
|
||||||
|
config: TanaConfig
|
||||||
|
): Promise<{ focus?: string; success?: string }> {
|
||||||
|
const workspace = config.workspace;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${config.token}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 1: get today's calendar node ID
|
||||||
|
const calResp = await fetch(
|
||||||
|
`${config.url}/workspaces/${workspace}/calendar/node?date=${today}&granularity=day`,
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
if (!calResp.ok) throw new Error(`Tana calendar fetch failed: ${calResp.status}`);
|
||||||
|
|
||||||
|
const calData = (await calResp.json()) as { nodeId?: string };
|
||||||
|
if (!calData.nodeId) throw new Error('Tana calendar response missing nodeId');
|
||||||
|
|
||||||
|
// Step 2: read the node content
|
||||||
|
const noteResp = await fetch(
|
||||||
|
`${config.url}/nodes/${calData.nodeId}?maxDepth=1`,
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
if (!noteResp.ok) throw new Error(`Tana note fetch failed: ${noteResp.status}`);
|
||||||
|
|
||||||
|
const noteData = (await noteResp.json()) as { markdown?: string };
|
||||||
|
if (!noteData.markdown) throw new Error('Tana note response missing markdown');
|
||||||
|
|
||||||
|
// Step 3: parse fields from markdown
|
||||||
|
return {
|
||||||
|
focus: parseField(
|
||||||
|
noteData.markdown,
|
||||||
|
escapeRegex(config.focus_field ?? "Today's Focus")
|
||||||
|
),
|
||||||
|
success: parseField(
|
||||||
|
noteData.markdown,
|
||||||
|
escapeRegex(config.success_field ?? 'What would make today a success?')
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTag(task: TanaTask, taskTagId: string): string {
|
||||||
|
const tag = task.tags?.find((t) => t.id !== taskTagId);
|
||||||
|
return tag?.name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDueDate(task: TanaTask, dueDateFieldId: string): string | undefined {
|
||||||
|
return task.fields?.find((f) => f.fieldId === dueDateFieldId)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeAge(dueDate: string): string {
|
||||||
|
const due = new Date(dueDate + 'T00:00:00');
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const days = Math.floor((today.getTime() - due.getTime()) / 86_400_000);
|
||||||
|
if (days <= 0) return '0d';
|
||||||
|
return `${days}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTanaTasks(
|
||||||
|
config: TanaConfig
|
||||||
|
): Promise<{ due: TodoItem[]; overdue: OverdueItem[] }> {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10);
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${config.token}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single query: due < tomorrow (i.e. due today or earlier), not done
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
'query[and][0][hasType]': config.task_tag_id,
|
||||||
|
'query[and][1][not][is]': 'done',
|
||||||
|
'query[and][2][compare][fieldId]': config.due_date_field_id,
|
||||||
|
'query[and][2][compare][operator]': 'lt',
|
||||||
|
'query[and][2][compare][value]': tomorrow,
|
||||||
|
'query[and][2][compare][type]': 'date',
|
||||||
|
limit: '100',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resp = await fetch(`${config.url}/nodes/search?${params}`, { headers });
|
||||||
|
if (!resp.ok) throw new Error(`Tana task search failed: ${resp.status}`);
|
||||||
|
const tasks = (await resp.json()) as TanaTask[];
|
||||||
|
|
||||||
|
// Split: due date == today → due today, due date < today → overdue
|
||||||
|
const due: TodoItem[] = [];
|
||||||
|
const overdue: OverdueItem[] = [];
|
||||||
|
|
||||||
|
for (const t of tasks) {
|
||||||
|
const dueDate = getDueDate(t, config.due_date_field_id);
|
||||||
|
if (dueDate && dueDate < today) {
|
||||||
|
overdue.push({
|
||||||
|
text: t.name,
|
||||||
|
tag: getTag(t, config.task_tag_id),
|
||||||
|
age: computeAge(dueDate),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
due.push({
|
||||||
|
text: t.name,
|
||||||
|
tag: getTag(t, config.task_tag_id),
|
||||||
|
est: '',
|
||||||
|
done: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { due, overdue };
|
||||||
|
}
|
||||||
769
src/todayview.tsx
Normal file
769
src/todayview.tsx
Normal 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)' }}>
|
||||||
|
· {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)} · 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} · {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,
|
||||||
|
}}>
|
||||||
|
· {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,
|
||||||
|
}}>
|
||||||
|
· {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',
|
||||||
|
}}>
|
||||||
|
· {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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue