Compare commits
No commits in common. "8a1fd31e10017ede39ab815585d8ab6ecbf446f3" and "cb66e87204b0993306de63aedbacbee38985a501" have entirely different histories.
8a1fd31e10
...
cb66e87204
18 changed files with 17 additions and 2129 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -32,6 +32,3 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
# Config files
|
||||
config.json
|
||||
|
|
|
|||
71
CLAUDE.md
71
CLAUDE.md
|
|
@ -1,71 +0,0 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
TRMNLc is a self-hosted server that serves rendered displays to [TRMNL](https://usetrmnl.com/) e-ink devices. It renders plugin UIs as HTML using React SSR, screenshots with Puppeteer (Firefox), dithers with ImageMagick for e-ink, and serves via the TRMNL device API.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
bun install
|
||||
|
||||
# Run the server (requires Firefox and ImageMagick in PATH)
|
||||
FIREFOX=/path/to/firefox CONFIG_FILE=config.json COLORMAP=./colormap.png bun run src/web.ts
|
||||
|
||||
# Run tests
|
||||
bun test
|
||||
```
|
||||
|
||||
The server runs on port 2300 by default (`BUN_PORT` env var). See `config.sample.json` for the config format.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Plugin system
|
||||
|
||||
Each device is configured with a `plugin` name and `settings` object. The plugin registry (`src/plugins.ts`) maps names to factory functions that return a `Renderable`. The `Renderable` interface (`src/template.ts`) requires `hash`, `nextEvent?`, and `async render()`.
|
||||
|
||||
Available plugins:
|
||||
|
||||
- **`calendar`** — Full-screen ICS calendar view with current/next/later event slots. UI is in Dutch ("VRIJ", "BEZET", "VOLGENDE"). Implementation: `src/xlcalendar.tsx`.
|
||||
- **`today`** — Daily dashboard with date header, focus/success lines, agenda timeline, chores checklist, and due/overdue tasks. Implementation: `src/todayview.tsx`.
|
||||
|
||||
### Data sources (`src/sources/`)
|
||||
|
||||
The today plugin fetches from external services during `render()`, all in parallel:
|
||||
|
||||
- **`tana.ts`** — Fetches daily focus/success text and due/overdue tasks from a Tana server. Focus: calendar node → markdown field parsing. Tasks: single search query (`lt` tomorrow), split client-side by due date.
|
||||
- **`ics.ts`** — Fetches today's non-full-day events from ICS feeds, returns `AgendaEvent[]`. Note: `location` lives on `instance.event.location`, not on the expanded instance directly.
|
||||
- **`donetick.ts`** — Fetches chores from a Donetick instance (defaults to `app.donetick.com`). Filters by `user_id` and `isActive`. Done status: `nextDueDate > today` means completed.
|
||||
|
||||
### Config structure
|
||||
|
||||
```json
|
||||
{
|
||||
"base_url": "http://host:2300",
|
||||
"devices": {
|
||||
"DEVICE_MAC": {
|
||||
"plugin": "today",
|
||||
"settings": { ... },
|
||||
"refresh": 300,
|
||||
"model": "inkplate_10"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Render pipeline
|
||||
|
||||
1. **`src/web.ts`** — Bun HTTP server. Routes: `/api/setup`, `/api/display` (PNG), `/api/display/html` (raw HTML), `/api/render/:id/:ignore` (cached PNGs).
|
||||
2. **`src/template.ts`** — Wraps plugin HTML in a full page with TRMNL's CSS/JS framework.
|
||||
3. **`src/render.ts`** — Puppeteer (Firefox) screenshots the HTML, then ImageMagick dithers to 2-bit grayscale PNG.
|
||||
4. **`src/devices.ts`** — Fetches device model definitions (screen dimensions, CSS classes) from `trmnl.com/api/models` at startup.
|
||||
|
||||
## Deployment
|
||||
|
||||
Deployed as a NixOS module via `flake.nix` / `default.nix`. The `default.nix` defines a systemd service with `DynamicUser=true`. The Nix derivation copies source directly (no build step — runs with `bun run` at runtime).
|
||||
|
||||
## Formatting
|
||||
|
||||
Uses Prettier: single quotes, trailing commas (es5), 2-space indent, bracket same line.
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
{
|
||||
"base_url": "http://192.168.1.100:2300",
|
||||
"devices": {
|
||||
"AA:BB:CC:DD:EE:FF": {
|
||||
"plugin": "today",
|
||||
"settings": {
|
||||
"calendar_urls": [
|
||||
"https://calendar.example.com/feed.ics"
|
||||
],
|
||||
"tana": {
|
||||
"url": "http://192.168.1.100:8262",
|
||||
"token": "your-tana-api-token",
|
||||
"workspace": "your-workspace-id",
|
||||
"task_tag_id": "your-task-tag-node-id",
|
||||
"due_date_field_id": "your-due-date-field-id"
|
||||
},
|
||||
"location": {
|
||||
"latitude": 48.8566,
|
||||
"longitude": 2.3522
|
||||
},
|
||||
"donetick": {
|
||||
"token": "your-donetick-access-token",
|
||||
"user_id": 1
|
||||
}
|
||||
},
|
||||
"refresh": 300,
|
||||
"model": "inkplate_10"
|
||||
}
|
||||
}
|
||||
}
|
||||
14
default.nix
14
default.nix
|
|
@ -35,16 +35,10 @@ in
|
|||
type = json.type;
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
base_url = "http://192.168.50.124:2300";
|
||||
devices = {
|
||||
unknown = {
|
||||
plugin = "calendar";
|
||||
settings = {
|
||||
urls = ["https://user.fm/calendar/....ics"];
|
||||
};
|
||||
refresh = 60;
|
||||
model = "inkplate_10";
|
||||
};
|
||||
unknown = {
|
||||
urls = ["https://user.fm/calendar/....ics"];
|
||||
refresh = 60;
|
||||
model = "inkplate_10";
|
||||
};
|
||||
}
|
||||
'';
|
||||
|
|
|
|||
|
|
@ -1,222 +0,0 @@
|
|||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import type { Server } from 'bun';
|
||||
import { createPlugin } from './plugins';
|
||||
|
||||
// --- Synthetic ICS ---
|
||||
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
const icsDate = (d: Date) =>
|
||||
`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}00`;
|
||||
|
||||
const now = new Date();
|
||||
const currentStart = new Date(now.getTime() - 30 * 60_000);
|
||||
const currentEnd = new Date(now.getTime() + 30 * 60_000);
|
||||
const nextStart = new Date(now.getTime() + 60 * 60_000);
|
||||
const nextEnd = new Date(now.getTime() + 120 * 60_000);
|
||||
const laterStart = new Date(now.getTime() + 180 * 60_000);
|
||||
const laterEnd = new Date(now.getTime() + 240 * 60_000);
|
||||
|
||||
const ics = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test//EN
|
||||
BEGIN:VEVENT
|
||||
DTSTART:${icsDate(currentStart)}
|
||||
DTEND:${icsDate(currentEnd)}
|
||||
SUMMARY:Current Meeting
|
||||
UID:test-current@test
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:${icsDate(nextStart)}
|
||||
DTEND:${icsDate(nextEnd)}
|
||||
SUMMARY:Next Meeting
|
||||
UID:test-next@test
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:${icsDate(laterStart)}
|
||||
DTEND:${icsDate(laterEnd)}
|
||||
SUMMARY:Later Meeting
|
||||
UID:test-later@test
|
||||
END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
|
||||
const fakeModel = {
|
||||
width: 800,
|
||||
height: 480,
|
||||
css: { classes: { device: 'device', size: 'size', density: 'density' } },
|
||||
} as any;
|
||||
|
||||
// --- ICS server ---
|
||||
|
||||
let icsServer: Server;
|
||||
let icsUrl: string;
|
||||
|
||||
beforeAll(() => {
|
||||
icsServer = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
routes: {
|
||||
'/cal.ics': () =>
|
||||
new Response(ics, {
|
||||
headers: { 'Content-Type': 'text/calendar' },
|
||||
}),
|
||||
},
|
||||
});
|
||||
icsUrl = `http://127.0.0.1:${icsServer.port}/cal.ics`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
icsServer.stop();
|
||||
});
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('plugin registry', () => {
|
||||
test('unknown plugin throws', () => {
|
||||
expect(() => createPlugin('nonexistent', {})).toThrow(
|
||||
'Unknown plugin: nonexistent'
|
||||
);
|
||||
});
|
||||
|
||||
test('calendar plugin creates an ICSRenderable', () => {
|
||||
const plugin = createPlugin('calendar', { urls: [] });
|
||||
expect(plugin.hash).toBe('');
|
||||
expect(plugin.nextEvent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar plugin rendering', () => {
|
||||
test('renders events from ICS feed', async () => {
|
||||
const plugin = createPlugin('calendar', { urls: [icsUrl] });
|
||||
const html = await plugin.render('full', fakeModel);
|
||||
|
||||
expect(html).toContain('Current Meeting');
|
||||
expect(html).toContain('Next Meeting');
|
||||
expect(html).toContain('Later Meeting');
|
||||
});
|
||||
|
||||
test('shows BEZET when there is a current event', async () => {
|
||||
const plugin = createPlugin('calendar', { urls: [icsUrl] });
|
||||
const html = await plugin.render('full', fakeModel);
|
||||
|
||||
expect(html).toContain('BEZET');
|
||||
});
|
||||
|
||||
test('computes hash after render', async () => {
|
||||
const plugin = createPlugin('calendar', { urls: [icsUrl] });
|
||||
await plugin.render('full', fakeModel);
|
||||
|
||||
expect(plugin.hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test('sets nextEvent to upcoming event start', async () => {
|
||||
const plugin = createPlugin('calendar', { urls: [icsUrl] });
|
||||
await plugin.render('full', fakeModel);
|
||||
|
||||
expect(plugin.nextEvent).toBeInstanceOf(Date);
|
||||
const diff = Math.abs(plugin.nextEvent!.getTime() - nextStart.getTime());
|
||||
expect(diff).toBeLessThan(120_000);
|
||||
});
|
||||
|
||||
test('renders VRIJ with no events', async () => {
|
||||
const plugin = createPlugin('calendar', { urls: [] });
|
||||
const html = await plugin.render('full', fakeModel);
|
||||
|
||||
expect(html).toContain('VRIJ');
|
||||
expect(html).not.toContain('BEZET');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Today plugin test data ---
|
||||
|
||||
import type { TodayData } from './todayview';
|
||||
|
||||
const SAMPLE_TODAY: TodayData = {
|
||||
focus: 'Ship the Q2 retrospective deck — clarity over completeness.',
|
||||
success: 'Three deep-work blocks. One real conversation. Inbox under 10.',
|
||||
agenda: [
|
||||
{ start: '08:30', end: '09:00', title: 'Morning planning', where: 'Office', kind: 'solo' },
|
||||
{ start: '09:30', end: '10:15', title: 'Design review', where: 'Meet', kind: 'call' },
|
||||
{ start: '11:00', end: '12:00', title: '1:1 with Priya', where: 'Walk', kind: 'in-person' },
|
||||
{ start: '13:30', end: '15:00', title: 'Deep work — deck', where: 'Library', kind: 'focus block' },
|
||||
{ start: '16:00', end: '16:30', title: 'Eng standup', where: 'Zoom', kind: 'call' },
|
||||
],
|
||||
chores: [
|
||||
{ text: 'Make the bed' },
|
||||
{ text: '20 min reading' },
|
||||
{ text: 'Water the plants' },
|
||||
{ text: 'Walk · 30 min' },
|
||||
{ text: 'Journal · 3 lines' },
|
||||
{ text: 'Lights out by 23:00' },
|
||||
],
|
||||
todos_due: [
|
||||
{ text: 'Review Atlas spec comments', tag: 'Atlas', est: '30m', done: false },
|
||||
{ text: 'Draft retro deck — sections 1–3', tag: 'Deck', est: '1h', done: false },
|
||||
{ text: 'Reply to Marcus re: contract', tag: 'Ops', est: '10m', done: false },
|
||||
{ text: 'Submit June expenses', tag: 'Admin', est: '15m', done: false },
|
||||
],
|
||||
todos_overdue: [
|
||||
{ text: 'File Q1 receipts', tag: 'Admin', age: '3d' },
|
||||
{ text: 'Renew domain · seva.dev', tag: 'Ops', age: '1d' },
|
||||
{ text: 'Book dentist', tag: 'Health', age: '9d' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('today plugin', () => {
|
||||
test('renders all sections', async () => {
|
||||
const plugin = createPlugin('today', SAMPLE_TODAY);
|
||||
const html = await plugin.render('full', fakeModel);
|
||||
|
||||
// Top bar
|
||||
expect(html).toContain('FOCUS');
|
||||
expect(html).toContain('SUCCESS');
|
||||
expect(html).toContain('retrospective deck');
|
||||
|
||||
// Agenda
|
||||
expect(html).toContain('Agenda');
|
||||
expect(html).toContain('Morning planning');
|
||||
expect(html).toContain('Eng standup');
|
||||
|
||||
// Chores
|
||||
expect(html).toContain('Chores');
|
||||
expect(html).toContain('Make the bed');
|
||||
expect(html).toContain('Water the plants');
|
||||
|
||||
// Tasks
|
||||
expect(html).toContain('Due today');
|
||||
expect(html).toContain('Overdue');
|
||||
expect(html).toContain('Review Atlas spec comments');
|
||||
expect(html).toContain('File Q1 receipts');
|
||||
});
|
||||
|
||||
test('renders with minimal data', async () => {
|
||||
const plugin = createPlugin('today', {
|
||||
focus: 'Custom focus text',
|
||||
success: 'Custom success text',
|
||||
agenda: [
|
||||
{ start: '09:00', end: '10:00', title: 'Custom Event', where: 'Room', kind: 'call' },
|
||||
],
|
||||
chores: [{ text: 'Custom chore' }],
|
||||
todos_due: [{ text: 'Custom task', tag: 'Test', est: '5m', done: false }],
|
||||
todos_overdue: [],
|
||||
});
|
||||
const html = await plugin.render('full', fakeModel);
|
||||
|
||||
expect(html).toContain('Custom focus text');
|
||||
expect(html).toContain('Custom Event');
|
||||
expect(html).toContain('Custom chore');
|
||||
expect(html).toContain('Custom task');
|
||||
});
|
||||
|
||||
test('computes hash after render', async () => {
|
||||
const plugin = createPlugin('today', SAMPLE_TODAY);
|
||||
await plugin.render('full', fakeModel);
|
||||
expect(plugin.hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test('renders today date', async () => {
|
||||
const plugin = createPlugin('today', SAMPLE_TODAY);
|
||||
const html = await plugin.render('full', fakeModel);
|
||||
const dayStr = String(new Date().getDate()).padStart(2, '0');
|
||||
expect(html).toContain(dayStr);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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 = (
|
||||
name: string,
|
||||
settings: Record<string, any>
|
||||
): Renderable => {
|
||||
const factory = registry[name];
|
||||
if (!factory) throw new Error(`Unknown plugin: ${name}`);
|
||||
return factory(settings);
|
||||
};
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
import puppeteer from 'puppeteer-core';
|
||||
import { devices } from './devices';
|
||||
import { createPlugin } from './plugins';
|
||||
import { render } from './template';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: bun run src/preview.ts [options]
|
||||
|
||||
Render a plugin to a local PNG file for testing.
|
||||
|
||||
Options:
|
||||
--config <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}`);
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import type { Server } from 'bun';
|
||||
import { fetchDonetickChores } from './donetick';
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
||||
|
||||
const CHORES = [
|
||||
{ id: 1, name: 'Make the bed', assignedTo: 1, nextDueDate: `${tomorrow}T00:00:00Z`, isActive: true },
|
||||
{ id: 2, name: 'Water the plants', assignedTo: 1, nextDueDate: `${today}T00:00:00Z`, isActive: true },
|
||||
{ id: 3, name: 'Walk the dog', assignedTo: 2, nextDueDate: `${today}T00:00:00Z`, isActive: true },
|
||||
{ id: 4, name: 'Inactive chore', assignedTo: 1, nextDueDate: `${today}T00:00:00Z`, isActive: false },
|
||||
{ id: 5, name: 'Shared chore', assignedTo: 2, nextDueDate: `${yesterday}T00:00:00Z`, isActive: true, assignees: [{ userId: 1 }, { userId: 2 }] },
|
||||
];
|
||||
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
routes: {
|
||||
'/eapi/v1/chore': (req) => {
|
||||
if (req.headers.get('secretkey') !== 'test-token')
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
return Response.json(CHORES);
|
||||
},
|
||||
},
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.stop();
|
||||
});
|
||||
|
||||
describe('donetick source', () => {
|
||||
test('fetches chores for the configured user', async () => {
|
||||
const chores = await fetchDonetickChores({
|
||||
url: baseUrl,
|
||||
token: 'test-token',
|
||||
user_id: 1,
|
||||
});
|
||||
|
||||
// Should include: Water the plants (due today), Shared chore (overdue, in assignees)
|
||||
// Should exclude: Make the bed (done, nextDueDate=tomorrow), Walk the dog (different user), Inactive chore
|
||||
expect(chores).toHaveLength(2);
|
||||
expect(chores.map((c) => c.text)).toContain('Water the plants');
|
||||
expect(chores.map((c) => c.text)).toContain('Shared chore');
|
||||
expect(chores.map((c) => c.text)).not.toContain('Make the bed');
|
||||
expect(chores.map((c) => c.text)).not.toContain('Walk the dog');
|
||||
expect(chores.map((c) => c.text)).not.toContain('Inactive chore');
|
||||
|
||||
});
|
||||
|
||||
test('throws on auth failure', async () => {
|
||||
await expect(
|
||||
fetchDonetickChores({ url: baseUrl, token: 'wrong', user_id: 1 })
|
||||
).rejects.toThrow('401');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import type { ChoreItem } from '../todayview';
|
||||
|
||||
export interface DonetickConfig {
|
||||
url?: string;
|
||||
token: string;
|
||||
user_id: number;
|
||||
}
|
||||
|
||||
interface DonetickChore {
|
||||
id: number;
|
||||
name: string;
|
||||
assignedTo: number;
|
||||
nextDueDate: string;
|
||||
isActive: boolean;
|
||||
assignees?: { userId: number }[];
|
||||
labelsV2?: { name: string }[];
|
||||
}
|
||||
|
||||
export async function fetchDonetickChores(
|
||||
config: DonetickConfig
|
||||
): Promise<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 };
|
||||
});
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import type { Server } from 'bun';
|
||||
import { fetchICSAgenda } from './ics';
|
||||
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
const icsDate = (d: Date) =>
|
||||
`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}00`;
|
||||
|
||||
const now = new Date();
|
||||
const event1Start = new Date(now);
|
||||
event1Start.setHours(9, 0, 0, 0);
|
||||
const event1End = new Date(now);
|
||||
event1End.setHours(10, 0, 0, 0);
|
||||
const event2Start = new Date(now);
|
||||
event2Start.setHours(14, 30, 0, 0);
|
||||
const event2End = new Date(now);
|
||||
event2End.setHours(15, 15, 0, 0);
|
||||
|
||||
const ics = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test//EN
|
||||
BEGIN:VEVENT
|
||||
DTSTART:${icsDate(event1Start)}
|
||||
DTEND:${icsDate(event1End)}
|
||||
SUMMARY:Morning standup
|
||||
LOCATION:Zoom
|
||||
UID:test-1@test
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:${icsDate(event2Start)}
|
||||
DTEND:${icsDate(event2End)}
|
||||
SUMMARY:Design review
|
||||
LOCATION:Room 4B
|
||||
UID:test-2@test
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}
|
||||
DTEND;VALUE=DATE:${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate() + 1)}
|
||||
SUMMARY:All-day event
|
||||
UID:test-allday@test
|
||||
END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
|
||||
let server: Server;
|
||||
let icsUrl: string;
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
routes: {
|
||||
'/cal.ics': () =>
|
||||
new Response(ics, {
|
||||
headers: { 'Content-Type': 'text/calendar' },
|
||||
}),
|
||||
},
|
||||
});
|
||||
icsUrl = `http://127.0.0.1:${server.port}/cal.ics`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.stop();
|
||||
});
|
||||
|
||||
describe('ics agenda source', () => {
|
||||
test('fetches and formats today events', async () => {
|
||||
const events = await fetchICSAgenda([icsUrl]);
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const standup = events.find((e) => e.title === 'Morning standup');
|
||||
expect(standup).toBeDefined();
|
||||
expect(standup!.start).toBe('09:00');
|
||||
expect(standup!.end).toBe('10:00');
|
||||
expect(standup!.where).toBe('Zoom');
|
||||
|
||||
const review = events.find((e) => e.title === 'Design review');
|
||||
expect(review).toBeDefined();
|
||||
expect(review!.start).toBe('14:30');
|
||||
expect(review!.end).toBe('15:15');
|
||||
expect(review!.where).toBe('Room 4B');
|
||||
});
|
||||
|
||||
test('filters out all-day events', async () => {
|
||||
const events = await fetchICSAgenda([icsUrl]);
|
||||
const allDay = events.find((e) => e.title === 'All-day event');
|
||||
expect(allDay).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns sorted by start time', async () => {
|
||||
const events = await fetchICSAgenda([icsUrl]);
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
expect(events[i]!.start >= events[i - 1]!.start).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('handles empty url list', async () => {
|
||||
const events = await fetchICSAgenda([]);
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
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')}`;
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import type { Server } from 'bun';
|
||||
import { fetchTanaFocus, fetchTanaTasks } from './tana';
|
||||
|
||||
const NODE_ID = 'test-node-123';
|
||||
const MARKDOWN = `**Today's Focus**: Ship the retrospective deck <!-- 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import type { TodoItem, OverdueItem } from '../todayview';
|
||||
|
||||
export interface TanaConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
workspace: string;
|
||||
task_tag_id: string;
|
||||
due_date_field_id: string;
|
||||
focus_field?: string; // default: "Today's Focus"
|
||||
success_field?: string; // default: "What would make today a success?"
|
||||
}
|
||||
|
||||
interface TanaTask {
|
||||
id: string;
|
||||
name: string;
|
||||
tags?: { id: string; name: string }[];
|
||||
fields?: { fieldId: string; value: string }[];
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function parseField(md: string, fieldName: string): string | undefined {
|
||||
// Match **Field Name**: value <!-- 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 };
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import type { Server } from 'bun';
|
||||
import { fetchWeather } from './weather';
|
||||
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
const MOCK_RESPONSE = {
|
||||
daily: {
|
||||
temperature_2m_max: [22],
|
||||
temperature_2m_min: [14],
|
||||
weather_code: [2],
|
||||
},
|
||||
hourly: {
|
||||
time: [
|
||||
'2026-05-24T08:00', '2026-05-24T10:00',
|
||||
'2026-05-24T12:00', '2026-05-24T14:00',
|
||||
'2026-05-24T16:00',
|
||||
],
|
||||
temperature_2m: [15.2, 17.8, 20.1, 21.6, 19.3],
|
||||
},
|
||||
};
|
||||
|
||||
// Patch global fetch to intercept open-meteo calls
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
describe('weather source', () => {
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
routes: {
|
||||
'/v1/forecast': () => Response.json(MOCK_RESPONSE),
|
||||
},
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${server.port}`;
|
||||
|
||||
// Override fetch to redirect open-meteo to our mock
|
||||
globalThis.fetch = (input, init?) => {
|
||||
const url = typeof input === 'string' ? input : (input as Request).url;
|
||||
if (url.startsWith('https://api.open-meteo.com')) {
|
||||
const replaced = url.replace('https://api.open-meteo.com', baseUrl);
|
||||
return originalFetch(replaced, init);
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
server.stop();
|
||||
});
|
||||
|
||||
test('fetches and parses weather data', async () => {
|
||||
const weather = await fetchWeather({ latitude: 52.37, longitude: 4.89 });
|
||||
|
||||
expect(weather.high).toBe(22);
|
||||
expect(weather.low).toBe(14);
|
||||
expect(weather.description).toBe('Partly cloudy');
|
||||
expect(weather.icon).toBe('wi-day-cloudy');
|
||||
});
|
||||
|
||||
test('provides hourly temperatures', async () => {
|
||||
const weather = await fetchWeather({ latitude: 52.37, longitude: 4.89 });
|
||||
|
||||
expect(weather.hourly.get(8)).toBe(15);
|
||||
expect(weather.hourly.get(12)).toBe(20);
|
||||
expect(weather.hourly.get(14)).toBe(22);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
export interface WeatherConfig {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface WeatherData {
|
||||
description: string;
|
||||
icon: string;
|
||||
high: number;
|
||||
low: number;
|
||||
hourly: Map<number, number>; // hour (0-23) → temperature in °C
|
||||
}
|
||||
|
||||
// WMO Weather interpretation codes → [description, Weather Icons class]
|
||||
// See https://erikflowers.github.io/weather-icons/
|
||||
const WMO_CODES: Record<number, [string, string]> = {
|
||||
0: ['Clear', 'wi-day-sunny'],
|
||||
1: ['Mostly clear', 'wi-day-sunny-overcast'],
|
||||
2: ['Partly cloudy', 'wi-day-cloudy'],
|
||||
3: ['Overcast', 'wi-cloudy'],
|
||||
45: ['Fog', 'wi-fog'],
|
||||
48: ['Fog', 'wi-fog'],
|
||||
51: ['Light drizzle', 'wi-sprinkle'],
|
||||
53: ['Drizzle', 'wi-sprinkle'],
|
||||
55: ['Heavy drizzle', 'wi-rain'],
|
||||
56: ['Freezing drizzle', 'wi-rain-mix'],
|
||||
57: ['Freezing drizzle', 'wi-rain-mix'],
|
||||
61: ['Light rain', 'wi-showers'],
|
||||
63: ['Rain', 'wi-rain'],
|
||||
65: ['Heavy rain', 'wi-rain-wind'],
|
||||
66: ['Freezing rain', 'wi-rain-mix'],
|
||||
67: ['Freezing rain', 'wi-rain-mix'],
|
||||
71: ['Light snow', 'wi-snow'],
|
||||
73: ['Snow', 'wi-snow'],
|
||||
75: ['Heavy snow', 'wi-snow-wind'],
|
||||
77: ['Snow grains', 'wi-snow'],
|
||||
80: ['Light showers', 'wi-showers'],
|
||||
81: ['Showers', 'wi-rain'],
|
||||
82: ['Heavy showers', 'wi-rain-wind'],
|
||||
85: ['Snow showers', 'wi-snow'],
|
||||
86: ['Snow showers', 'wi-snow-wind'],
|
||||
95: ['Thunderstorm', 'wi-thunderstorm'],
|
||||
96: ['Thunderstorm + hail', 'wi-storm-showers'],
|
||||
99: ['Thunderstorm + hail', 'wi-storm-showers'],
|
||||
};
|
||||
|
||||
interface OpenMeteoResponse {
|
||||
daily: {
|
||||
temperature_2m_max: number[];
|
||||
temperature_2m_min: number[];
|
||||
weather_code: number[];
|
||||
};
|
||||
hourly: {
|
||||
time: string[];
|
||||
temperature_2m: number[];
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchWeather(
|
||||
config: WeatherConfig
|
||||
): Promise<WeatherData> {
|
||||
const params = new URLSearchParams({
|
||||
latitude: config.latitude.toString(),
|
||||
longitude: config.longitude.toString(),
|
||||
daily: 'temperature_2m_max,temperature_2m_min,weather_code',
|
||||
hourly: 'temperature_2m',
|
||||
timezone: 'auto',
|
||||
forecast_days: '1',
|
||||
});
|
||||
|
||||
const resp = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?${params}`
|
||||
);
|
||||
if (!resp.ok) throw new Error(`Weather fetch failed: ${resp.status}`);
|
||||
|
||||
const data = (await resp.json()) as OpenMeteoResponse;
|
||||
|
||||
const code = data.daily.weather_code[0] ?? 0;
|
||||
const [description, icon] = WMO_CODES[code] ?? ['Unknown', '?'];
|
||||
|
||||
const hourly = new Map<number, number>();
|
||||
for (let i = 0; i < data.hourly.time.length; i++) {
|
||||
const hour = new Date(data.hourly.time[i]!).getHours();
|
||||
hourly.set(hour, Math.round(data.hourly.temperature_2m[i]!));
|
||||
}
|
||||
|
||||
return {
|
||||
description,
|
||||
icon,
|
||||
high: Math.round(data.daily.temperature_2m_max[0]!),
|
||||
low: Math.round(data.daily.temperature_2m_min[0]!),
|
||||
hourly,
|
||||
};
|
||||
}
|
||||
|
|
@ -22,8 +22,6 @@ export type RenderMode =
|
|||
| 'half_vertical'
|
||||
| 'quadrant';
|
||||
export interface Renderable {
|
||||
hash: string;
|
||||
nextEvent?: Date;
|
||||
render(mode: RenderMode, model: DeviceModel): Promise<string>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,855 +0,0 @@
|
|||
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';
|
||||
import { fetchWeather, type WeatherData } from './sources/weather';
|
||||
|
||||
/* ---------- 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[];
|
||||
weather?: WeatherData;
|
||||
}
|
||||
|
||||
export interface TodayConfig extends TodayData {
|
||||
calendar_urls?: string[];
|
||||
tana?: {
|
||||
url: string;
|
||||
token: string;
|
||||
workspace?: string;
|
||||
};
|
||||
donetick?: {
|
||||
url?: string;
|
||||
token: string;
|
||||
user_id: number;
|
||||
};
|
||||
location?: {
|
||||
latitude: number;
|
||||
longitude: 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");
|
||||
@import url("https://cdnjs.cloudflare.com/ajax/libs/weather-icons/2.0.12/css/weather-icons.min.css");
|
||||
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?: React.ReactNode;
|
||||
}) => (
|
||||
<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 parseTime = (t: string): number => {
|
||||
const [h, m] = t.split(':').map(Number) as [number, number];
|
||||
return h * 60 + m;
|
||||
};
|
||||
|
||||
const Agenda = ({ events, weather }: { events: AgendaEvent[]; weather?: WeatherData }) => {
|
||||
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();
|
||||
|
||||
// Anchor timeline: start ~1h before now (rounded to even hour), end at last event or +8h
|
||||
const lastEventEnd = events.length
|
||||
? parseTime(events[events.length - 1]!.end)
|
||||
: 17 * 60;
|
||||
const firstEventStart = events.length
|
||||
? parseTime(events[0]!.start)
|
||||
: 8 * 60;
|
||||
|
||||
const startMin = Math.min(
|
||||
firstEventStart,
|
||||
Math.floor((nowMin - 60) / 120) * 120
|
||||
);
|
||||
const endMin = Math.min(
|
||||
24 * 60,
|
||||
Math.max(lastEventEnd, nowMin + 4 * 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';
|
||||
|
||||
// Event spans for thick bars on the timeline rail
|
||||
const eventSpans = events.map((e) => ({
|
||||
top: Math.max(0, (parseTime(e.start) - startMin) / rangeMin),
|
||||
bottom: Math.min(1, (parseTime(e.end) - startMin) / rangeMin),
|
||||
past: e.end <= nowStr,
|
||||
}));
|
||||
|
||||
return (
|
||||
<section style={{ padding: '10px 14px 10px 18px', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<SectionTitle
|
||||
right={weather
|
||||
? <><i className={`wi ${weather.icon}`} /> {weather.description} {weather.low}°–{weather.high}°</>
|
||||
: `${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' }}>
|
||||
{/* Event span bars */}
|
||||
{eventSpans.map((span, i) => (
|
||||
<div
|
||||
key={`span-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${span.top * 100}%`,
|
||||
height: `${(span.bottom - span.top) * 100}%`,
|
||||
right: -1,
|
||||
width: 3,
|
||||
background: 'var(--ink)',
|
||||
opacity: span.past ? 0.25 : 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* Hour ticks */}
|
||||
{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>
|
||||
|
||||
{/* Temperature column */}
|
||||
{weather && (
|
||||
<div style={{ width: 24, position: 'relative', flex: '0 0 24px' }}>
|
||||
{ticks.map((h) => {
|
||||
const p = (h * 60 - startMin) / rangeMin;
|
||||
const temp = weather.hourly.get(h);
|
||||
if (temp == null) return null;
|
||||
return (
|
||||
<span
|
||||
key={h}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${p * 100}%`,
|
||||
right: 0,
|
||||
transform: 'translateY(-50%)',
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 8,
|
||||
color: 'var(--ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{temp}°
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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} weather={data.weather} />
|
||||
<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))
|
||||
);
|
||||
}
|
||||
|
||||
if (this.config.location) {
|
||||
fetches.push(
|
||||
fetchWeather(this.config.location)
|
||||
.then((weather) => {
|
||||
data.weather = weather;
|
||||
})
|
||||
.catch((e) => console.error('Weather 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 ?? [],
|
||||
weather: data.weather,
|
||||
};
|
||||
|
||||
const html = renderToString(<TodayPanel data={resolved} />);
|
||||
return `<style>${STYLES}</style><div class="view view--full">${html}</div>`;
|
||||
}
|
||||
}
|
||||
32
src/web.ts
32
src/web.ts
|
|
@ -1,21 +1,17 @@
|
|||
import { SHA256 } from 'bun';
|
||||
import { devices } from './devices';
|
||||
import { createPlugin } from './plugins';
|
||||
import { makeScreenshot } from './render';
|
||||
import { render } from './template';
|
||||
import { ICSRenderable } from './xlcalendar';
|
||||
|
||||
let renderMap = new Map();
|
||||
|
||||
interface DeviceConfiguration {
|
||||
plugin?: string;
|
||||
settings?: Record<string, any>;
|
||||
refresh?: number;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface Configuration {
|
||||
base_url: string;
|
||||
devices: Record<string, DeviceConfiguration>;
|
||||
[mac: string]: {
|
||||
urls: string[];
|
||||
refresh?: number;
|
||||
model: string;
|
||||
};
|
||||
}
|
||||
|
||||
const config: Configuration = JSON.parse(
|
||||
|
|
@ -44,16 +40,15 @@ Bun.serve({
|
|||
const id = req.headers.get('ID') ?? 'unknown';
|
||||
const newfriendly = id.replaceAll(':', '');
|
||||
|
||||
const device = config.devices[id] ?? {
|
||||
plugin: 'calendar',
|
||||
settings: {},
|
||||
const device = config[id] ?? {
|
||||
urls: [],
|
||||
refresh: undefined,
|
||||
model: 'inkplate_10',
|
||||
};
|
||||
|
||||
console.log(`Rendering for ${id}..`);
|
||||
|
||||
let plugin = createPlugin(device.plugin ?? 'calendar', device.settings ?? {});
|
||||
let plugin = new ICSRenderable(device.urls);
|
||||
const rendered = makeScreenshot(
|
||||
[[plugin, 'full']],
|
||||
devices.find((a) => a.name === device.model)!
|
||||
|
|
@ -79,7 +74,7 @@ Bun.serve({
|
|||
|
||||
return Response.json({
|
||||
status: 0,
|
||||
image_url: `${config.base_url}/api/render/${id.toLowerCase()}/${plugin.hash ?? Math.random()}.png`,
|
||||
image_url: `http://192.168.50.124:2300/api/render/${id.toLowerCase()}/${plugin.hash ?? Math.random()}.png`,
|
||||
refresh_rate: nextRefresh.toString(),
|
||||
update_firmware: false,
|
||||
firmware_url: null,
|
||||
|
|
@ -92,14 +87,13 @@ Bun.serve({
|
|||
const id = req.headers.get('ID') ?? 'unknown';
|
||||
const newfriendly = id.replaceAll(':', '');
|
||||
|
||||
const device = config.devices[id] ?? {
|
||||
plugin: 'calendar',
|
||||
settings: {},
|
||||
const device = config[id] ?? {
|
||||
urls: [],
|
||||
refresh: undefined,
|
||||
model: 'inkplate_10',
|
||||
};
|
||||
|
||||
let plugin = createPlugin(device.plugin ?? 'calendar', device.settings ?? {});
|
||||
let plugin = new ICSRenderable(device.urls);
|
||||
const rendered = await render(
|
||||
[[plugin, 'full']],
|
||||
devices.find((a) => a.name === device.model)!
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue