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
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -35,17 +35,11 @@ in
|
||||||
type = json.type;
|
type = json.type;
|
||||||
example = lib.literalExpression ''
|
example = lib.literalExpression ''
|
||||||
{
|
{
|
||||||
base_url = "http://192.168.50.124:2300";
|
|
||||||
devices = {
|
|
||||||
unknown = {
|
unknown = {
|
||||||
plugin = "calendar";
|
|
||||||
settings = {
|
|
||||||
urls = ["https://user.fm/calendar/....ics"];
|
urls = ["https://user.fm/calendar/....ics"];
|
||||||
};
|
|
||||||
refresh = 60;
|
refresh = 60;
|
||||||
model = "inkplate_10";
|
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'
|
| 'half_vertical'
|
||||||
| 'quadrant';
|
| 'quadrant';
|
||||||
export interface Renderable {
|
export interface Renderable {
|
||||||
hash: string;
|
|
||||||
nextEvent?: Date;
|
|
||||||
render(mode: RenderMode, model: DeviceModel): Promise<string>;
|
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>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
30
src/web.ts
30
src/web.ts
|
|
@ -1,21 +1,17 @@
|
||||||
import { SHA256 } from 'bun';
|
import { SHA256 } from 'bun';
|
||||||
import { devices } from './devices';
|
import { devices } from './devices';
|
||||||
import { createPlugin } from './plugins';
|
|
||||||
import { makeScreenshot } from './render';
|
import { makeScreenshot } from './render';
|
||||||
import { render } from './template';
|
import { render } from './template';
|
||||||
|
import { ICSRenderable } from './xlcalendar';
|
||||||
|
|
||||||
let renderMap = new Map();
|
let renderMap = new Map();
|
||||||
|
|
||||||
interface DeviceConfiguration {
|
interface Configuration {
|
||||||
plugin?: string;
|
[mac: string]: {
|
||||||
settings?: Record<string, any>;
|
urls: string[];
|
||||||
refresh?: number;
|
refresh?: number;
|
||||||
model: string;
|
model: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
interface Configuration {
|
|
||||||
base_url: string;
|
|
||||||
devices: Record<string, DeviceConfiguration>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: Configuration = JSON.parse(
|
const config: Configuration = JSON.parse(
|
||||||
|
|
@ -44,16 +40,15 @@ Bun.serve({
|
||||||
const id = req.headers.get('ID') ?? 'unknown';
|
const id = req.headers.get('ID') ?? 'unknown';
|
||||||
const newfriendly = id.replaceAll(':', '');
|
const newfriendly = id.replaceAll(':', '');
|
||||||
|
|
||||||
const device = config.devices[id] ?? {
|
const device = config[id] ?? {
|
||||||
plugin: 'calendar',
|
urls: [],
|
||||||
settings: {},
|
|
||||||
refresh: undefined,
|
refresh: undefined,
|
||||||
model: 'inkplate_10',
|
model: 'inkplate_10',
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`Rendering for ${id}..`);
|
console.log(`Rendering for ${id}..`);
|
||||||
|
|
||||||
let plugin = createPlugin(device.plugin ?? 'calendar', device.settings ?? {});
|
let plugin = new ICSRenderable(device.urls);
|
||||||
const rendered = makeScreenshot(
|
const rendered = makeScreenshot(
|
||||||
[[plugin, 'full']],
|
[[plugin, 'full']],
|
||||||
devices.find((a) => a.name === device.model)!
|
devices.find((a) => a.name === device.model)!
|
||||||
|
|
@ -79,7 +74,7 @@ Bun.serve({
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
status: 0,
|
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(),
|
refresh_rate: nextRefresh.toString(),
|
||||||
update_firmware: false,
|
update_firmware: false,
|
||||||
firmware_url: null,
|
firmware_url: null,
|
||||||
|
|
@ -92,14 +87,13 @@ Bun.serve({
|
||||||
const id = req.headers.get('ID') ?? 'unknown';
|
const id = req.headers.get('ID') ?? 'unknown';
|
||||||
const newfriendly = id.replaceAll(':', '');
|
const newfriendly = id.replaceAll(':', '');
|
||||||
|
|
||||||
const device = config.devices[id] ?? {
|
const device = config[id] ?? {
|
||||||
plugin: 'calendar',
|
urls: [],
|
||||||
settings: {},
|
|
||||||
refresh: undefined,
|
refresh: undefined,
|
||||||
model: 'inkplate_10',
|
model: 'inkplate_10',
|
||||||
};
|
};
|
||||||
|
|
||||||
let plugin = createPlugin(device.plugin ?? 'calendar', device.settings ?? {});
|
let plugin = new ICSRenderable(device.urls);
|
||||||
const rendered = await render(
|
const rendered = await render(
|
||||||
[[plugin, 'full']],
|
[[plugin, 'full']],
|
||||||
devices.find((a) => a.name === device.model)!
|
devices.find((a) => a.name === device.model)!
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue