Add today-view plugin with Tana, ICS, and Donetick data sources
Implements an e-ink daily dashboard plugin ("today") with four sections:
date/focus/success header, agenda timeline, chores checklist, and
due/overdue task lists.
Data sources:
- Focus & success text: Tana daily note (src/sources/tana.ts)
- Due/overdue tasks: Tana task search (src/sources/tana.ts)
- Agenda events: ICS calendar feeds (src/sources/ics.ts)
- Chores: Donetick API (src/sources/donetick.ts)
All sources fetch in parallel and fall back gracefully on error.
Tests use mock HTTP servers with synthetic data — no real services needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ddcb03d3dd
commit
2e34246d14
12 changed files with 1665 additions and 11 deletions
50
CLAUDE.md
50
CLAUDE.md
|
|
@ -4,35 +4,63 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
## Project Overview
|
||||
|
||||
TRMNLc is a self-hosted server that serves rendered calendar displays to [TRMNL](https://usetrmnl.com/) e-ink devices. It fetches ICS calendar feeds, renders them as HTML using React SSR, screenshots the HTML with Puppeteer (Firefox), dithers the image with ImageMagick for e-ink display, and serves it via the TRMNL device API.
|
||||
|
||||
The UI is in Dutch (e.g., "VRIJ" = free, "BEZET" = busy, "VOLGENDE" = next, "DAARNA" = after that).
|
||||
TRMNLc is a self-hosted server that serves rendered displays to [TRMNL](https://usetrmnl.com/) e-ink devices. It renders plugin UIs as HTML using React SSR, screenshots with Puppeteer (Firefox), dithers with ImageMagick for e-ink, and serves via the TRMNL device API.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Run the server (requires Firefox and ImageMagick in PATH)
|
||||
FIREFOX=/path/to/firefox CONFIG_FILE=config.json COLORMAP=./colormap.png bun run src/web.ts
|
||||
|
||||
# Run tests
|
||||
bun test
|
||||
```
|
||||
|
||||
The server runs on port 2300 by default (`BUN_PORT` env var). No test suite exists.
|
||||
The server runs on port 2300 by default (`BUN_PORT` env var). See `config.sample.json` for the config format.
|
||||
|
||||
## Architecture
|
||||
|
||||
The request flow for `/api/display`:
|
||||
### Plugin system
|
||||
|
||||
1. **`src/web.ts`** — Bun HTTP server. Reads `CONFIG_FILE` JSON mapping device MAC addresses to ICS URLs + device model. Handles `/api/setup`, `/api/display` (returns rendered PNG), `/api/display/html` (returns raw HTML), and `/api/render/:id/:ignore` (serves cached PNGs).
|
||||
Each device is configured with a `plugin` name and `settings` object. The plugin registry (`src/plugins.ts`) maps names to factory functions that return a `Renderable`. The `Renderable` interface (`src/template.ts`) requires `hash`, `nextEvent?`, and `async render()`.
|
||||
|
||||
2. **`src/xlcalendar.tsx`** — `ICSRenderable` class. Fetches ICS feeds via `node-ical`, expands recurring events, processes them into current/next/secondary slots, and renders a React component to HTML via `renderToString`. Computes a content hash and calculates the next refresh time based on upcoming events.
|
||||
Available plugins:
|
||||
|
||||
3. **`src/template.ts`** — Wraps plugin HTML output in a full HTML page with TRMNL's CSS/JS framework. Defines the `Renderable` interface and `RenderMode` type (`full`, `half_horizontal`, `half_vertical`, `quadrant`).
|
||||
- **`calendar`** — Full-screen ICS calendar view with current/next/later event slots. UI is in Dutch ("VRIJ", "BEZET", "VOLGENDE"). Implementation: `src/xlcalendar.tsx`.
|
||||
- **`today`** — Daily dashboard with date header, focus/success lines, agenda timeline, chores checklist, and due/overdue tasks. Implementation: `src/todayview.tsx`.
|
||||
|
||||
4. **`src/render.ts`** — Uses Puppeteer (Firefox) to screenshot the rendered HTML, then pipes through ImageMagick (`magick`) to dither to a 2-bit grayscale PNG using the colormap.
|
||||
### Data sources (`src/sources/`)
|
||||
|
||||
5. **`src/devices.ts`** — Fetches device model definitions (screen dimensions, CSS classes) from `trmnl.com/api/models` at startup.
|
||||
The today plugin fetches from external services during `render()`, all in parallel:
|
||||
|
||||
- **`tana.ts`** — Fetches daily focus/success text and due/overdue tasks from a Tana server. Focus: calendar node → markdown field parsing. Tasks: single search query (`lt` tomorrow), split client-side by due date.
|
||||
- **`ics.ts`** — Fetches today's non-full-day events from ICS feeds, returns `AgendaEvent[]`. Note: `location` lives on `instance.event.location`, not on the expanded instance directly.
|
||||
- **`donetick.ts`** — Fetches chores from a Donetick instance (defaults to `app.donetick.com`). Filters by `user_id` and `isActive`. Done status: `nextDueDate > today` means completed.
|
||||
|
||||
### Config structure
|
||||
|
||||
```json
|
||||
{
|
||||
"base_url": "http://host:2300",
|
||||
"devices": {
|
||||
"DEVICE_MAC": {
|
||||
"plugin": "today",
|
||||
"settings": { ... },
|
||||
"refresh": 300,
|
||||
"model": "inkplate_10"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Render pipeline
|
||||
|
||||
1. **`src/web.ts`** — Bun HTTP server. Routes: `/api/setup`, `/api/display` (PNG), `/api/display/html` (raw HTML), `/api/render/:id/:ignore` (cached PNGs).
|
||||
2. **`src/template.ts`** — Wraps plugin HTML in a full page with TRMNL's CSS/JS framework.
|
||||
3. **`src/render.ts`** — Puppeteer (Firefox) screenshots the HTML, then ImageMagick dithers to 2-bit grayscale PNG.
|
||||
4. **`src/devices.ts`** — Fetches device model definitions (screen dimensions, CSS classes) from `trmnl.com/api/models` at startup.
|
||||
|
||||
## Deployment
|
||||
|
||||
|
|
|
|||
26
config.sample.json
Normal file
26
config.sample.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"base_url": "http://192.168.1.100:2300",
|
||||
"devices": {
|
||||
"AA:BB:CC:DD:EE:FF": {
|
||||
"plugin": "today",
|
||||
"settings": {
|
||||
"calendar_urls": [
|
||||
"https://calendar.example.com/feed.ics"
|
||||
],
|
||||
"tana": {
|
||||
"url": "http://192.168.1.100:8262",
|
||||
"token": "your-tana-api-token",
|
||||
"workspace": "your-workspace-id",
|
||||
"task_tag_id": "your-task-tag-node-id",
|
||||
"due_date_field_id": "your-due-date-field-id"
|
||||
},
|
||||
"donetick": {
|
||||
"token": "your-donetick-access-token",
|
||||
"user_id": 1
|
||||
}
|
||||
},
|
||||
"refresh": 300,
|
||||
"model": "inkplate_10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -125,3 +125,98 @@ describe('calendar plugin rendering', () => {
|
|||
expect(html).not.toContain('BEZET');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Today plugin test data ---
|
||||
|
||||
import type { TodayData } from './todayview';
|
||||
|
||||
const SAMPLE_TODAY: TodayData = {
|
||||
focus: 'Ship the Q2 retrospective deck — clarity over completeness.',
|
||||
success: 'Three deep-work blocks. One real conversation. Inbox under 10.',
|
||||
agenda: [
|
||||
{ start: '08:30', end: '09:00', title: 'Morning planning', where: 'Office', kind: 'solo' },
|
||||
{ start: '09:30', end: '10:15', title: 'Design review', where: 'Meet', kind: 'call' },
|
||||
{ start: '11:00', end: '12:00', title: '1:1 with Priya', where: 'Walk', kind: 'in-person' },
|
||||
{ start: '13:30', end: '15:00', title: 'Deep work — deck', where: 'Library', kind: 'focus block' },
|
||||
{ start: '16:00', end: '16:30', title: 'Eng standup', where: 'Zoom', kind: 'call' },
|
||||
],
|
||||
chores: [
|
||||
{ text: 'Make the bed' },
|
||||
{ text: '20 min reading' },
|
||||
{ text: 'Water the plants' },
|
||||
{ text: 'Walk · 30 min' },
|
||||
{ text: 'Journal · 3 lines' },
|
||||
{ text: 'Lights out by 23:00' },
|
||||
],
|
||||
todos_due: [
|
||||
{ text: 'Review Atlas spec comments', tag: 'Atlas', est: '30m', done: false },
|
||||
{ text: 'Draft retro deck — sections 1–3', tag: 'Deck', est: '1h', done: false },
|
||||
{ text: 'Reply to Marcus re: contract', tag: 'Ops', est: '10m', done: false },
|
||||
{ text: 'Submit June expenses', tag: 'Admin', est: '15m', done: false },
|
||||
],
|
||||
todos_overdue: [
|
||||
{ text: 'File Q1 receipts', tag: 'Admin', age: '3d' },
|
||||
{ text: 'Renew domain · seva.dev', tag: 'Ops', age: '1d' },
|
||||
{ text: 'Book dentist', tag: 'Health', age: '9d' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('today plugin', () => {
|
||||
test('renders all sections', async () => {
|
||||
const plugin = createPlugin('today', SAMPLE_TODAY);
|
||||
const html = await plugin.render('full', fakeModel);
|
||||
|
||||
// Top bar
|
||||
expect(html).toContain('FOCUS');
|
||||
expect(html).toContain('SUCCESS');
|
||||
expect(html).toContain('retrospective deck');
|
||||
|
||||
// Agenda
|
||||
expect(html).toContain('Agenda');
|
||||
expect(html).toContain('Morning planning');
|
||||
expect(html).toContain('Eng standup');
|
||||
|
||||
// Chores
|
||||
expect(html).toContain('Chores');
|
||||
expect(html).toContain('Make the bed');
|
||||
expect(html).toContain('Water the plants');
|
||||
|
||||
// Tasks
|
||||
expect(html).toContain('Due today');
|
||||
expect(html).toContain('Overdue');
|
||||
expect(html).toContain('Review Atlas spec comments');
|
||||
expect(html).toContain('File Q1 receipts');
|
||||
});
|
||||
|
||||
test('renders with minimal data', async () => {
|
||||
const plugin = createPlugin('today', {
|
||||
focus: 'Custom focus text',
|
||||
success: 'Custom success text',
|
||||
agenda: [
|
||||
{ start: '09:00', end: '10:00', title: 'Custom Event', where: 'Room', kind: 'call' },
|
||||
],
|
||||
chores: [{ text: 'Custom chore' }],
|
||||
todos_due: [{ text: 'Custom task', tag: 'Test', est: '5m', done: false }],
|
||||
todos_overdue: [],
|
||||
});
|
||||
const html = await plugin.render('full', fakeModel);
|
||||
|
||||
expect(html).toContain('Custom focus text');
|
||||
expect(html).toContain('Custom Event');
|
||||
expect(html).toContain('Custom chore');
|
||||
expect(html).toContain('Custom task');
|
||||
});
|
||||
|
||||
test('computes hash after render', async () => {
|
||||
const plugin = createPlugin('today', SAMPLE_TODAY);
|
||||
await plugin.render('full', fakeModel);
|
||||
expect(plugin.hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test('renders today date', async () => {
|
||||
const plugin = createPlugin('today', SAMPLE_TODAY);
|
||||
const html = await plugin.render('full', fakeModel);
|
||||
const dayStr = String(new Date().getDate()).padStart(2, '0');
|
||||
expect(html).toContain(dayStr);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import type { Renderable } from './template';
|
||||
import { TodayRenderable, type TodayConfig } from './todayview';
|
||||
import { ICSRenderable } from './xlcalendar';
|
||||
|
||||
type PluginFactory = (settings: Record<string, any>) => Renderable;
|
||||
|
||||
const registry: Record<string, PluginFactory> = {
|
||||
calendar: (settings) => new ICSRenderable(settings.urls ?? []),
|
||||
today: (settings) =>
|
||||
new TodayRenderable(settings as TodayConfig),
|
||||
};
|
||||
|
||||
export const createPlugin = (
|
||||
|
|
|
|||
96
src/preview.ts
Normal file
96
src/preview.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import puppeteer from 'puppeteer-core';
|
||||
import { devices } from './devices';
|
||||
import { createPlugin } from './plugins';
|
||||
import { render } from './template';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: bun run src/preview.ts [options]
|
||||
|
||||
Render a plugin to a local PNG file for testing.
|
||||
|
||||
Options:
|
||||
--config <path> Config file (default: config.json)
|
||||
--device <id> Device ID from config (default: first device)
|
||||
--output <path> Output PNG path (default: preview.png)
|
||||
--firefox <path> Firefox executable path (or set FIREFOX env var)
|
||||
--dither Apply e-ink dithering via ImageMagick
|
||||
--html Also save the raw HTML to <output>.html
|
||||
-h, --help Show this help`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function getArg(flag: string, fallback: string): string {
|
||||
const idx = args.indexOf(flag);
|
||||
return idx !== -1 && args[idx + 1] ? args[idx + 1]! : fallback;
|
||||
}
|
||||
|
||||
const configPath = getArg('--config', Bun.env['CONFIG_FILE'] ?? 'config.json');
|
||||
const outputPath = getArg('--output', 'preview.png');
|
||||
const firefoxPath = getArg('--firefox', Bun.env['FIREFOX'] ?? 'firefox');
|
||||
const dither = args.includes('--dither');
|
||||
const saveHtml = args.includes('--html');
|
||||
|
||||
const config = JSON.parse(await Bun.file(configPath).text());
|
||||
const deviceEntries = Object.entries(config.devices ?? {});
|
||||
|
||||
if (deviceEntries.length === 0) {
|
||||
console.error('No devices found in config.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const deviceIdArg = getArg('--device', '');
|
||||
const [deviceId, deviceConfig] = deviceIdArg
|
||||
? [deviceIdArg, config.devices[deviceIdArg]]
|
||||
: (deviceEntries[0] as [string, any]);
|
||||
|
||||
if (!deviceConfig) {
|
||||
console.error(`Device "${deviceIdArg}" not found. Available: ${deviceEntries.map(([k]) => k).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const model = devices.find((a) => a.name === (deviceConfig.model ?? 'inkplate_10'));
|
||||
if (!model) {
|
||||
console.error(`Unknown model: ${deviceConfig.model}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Rendering plugin "${deviceConfig.plugin ?? 'calendar'}" for device "${deviceId}" (${model.width}x${model.height})...`);
|
||||
|
||||
const plugin = createPlugin(deviceConfig.plugin ?? 'calendar', deviceConfig.settings ?? {});
|
||||
const html = await render([[plugin, 'full']], model);
|
||||
|
||||
if (saveHtml) {
|
||||
const htmlPath = outputPath.replace(/\.png$/, '.html');
|
||||
await Bun.write(htmlPath, html);
|
||||
console.log(`HTML saved to ${htmlPath}`);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
browser: 'firefox',
|
||||
args: ['--new-instance'],
|
||||
executablePath: firefoxPath,
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: model.width, height: model.height, deviceScaleFactor: 1 });
|
||||
await page.setContent(html, { waitUntil: 'networkidle2' });
|
||||
await Bun.sleep(3000);
|
||||
const screenshot = await page.screenshot({ encoding: 'binary' });
|
||||
await page.close();
|
||||
await browser.close();
|
||||
|
||||
if (dither) {
|
||||
const colormap = Bun.env['COLORMAP'] ?? './colormap.png';
|
||||
const processed = await Bun.spawn(
|
||||
['magick', '-', '-dither', 'FloydSteinberg', '-remap', colormap,
|
||||
'-define', 'png:bit-depth=2', '-define', 'png:color-type=0', '-strip', 'png:-'],
|
||||
{ stdin: screenshot }
|
||||
).stdout.bytes();
|
||||
await Bun.write(outputPath, processed);
|
||||
} else {
|
||||
await Bun.write(outputPath, screenshot);
|
||||
}
|
||||
|
||||
console.log(`Preview saved to ${outputPath}`);
|
||||
63
src/sources/donetick.test.ts
Normal file
63
src/sources/donetick.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import type { Server } from 'bun';
|
||||
import { fetchDonetickChores } from './donetick';
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
||||
|
||||
const CHORES = [
|
||||
{ id: 1, name: 'Make the bed', assignedTo: 1, nextDueDate: `${tomorrow}T00:00:00Z`, isActive: true },
|
||||
{ id: 2, name: 'Water the plants', assignedTo: 1, nextDueDate: `${today}T00:00:00Z`, isActive: true },
|
||||
{ id: 3, name: 'Walk the dog', assignedTo: 2, nextDueDate: `${today}T00:00:00Z`, isActive: true },
|
||||
{ id: 4, name: 'Inactive chore', assignedTo: 1, nextDueDate: `${today}T00:00:00Z`, isActive: false },
|
||||
{ id: 5, name: 'Shared chore', assignedTo: 2, nextDueDate: `${yesterday}T00:00:00Z`, isActive: true, assignees: [{ userId: 1 }, { userId: 2 }] },
|
||||
];
|
||||
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
routes: {
|
||||
'/eapi/v1/chore': (req) => {
|
||||
if (req.headers.get('secretkey') !== 'test-token')
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
return Response.json(CHORES);
|
||||
},
|
||||
},
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.stop();
|
||||
});
|
||||
|
||||
describe('donetick source', () => {
|
||||
test('fetches chores for the configured user', async () => {
|
||||
const chores = await fetchDonetickChores({
|
||||
url: baseUrl,
|
||||
token: 'test-token',
|
||||
user_id: 1,
|
||||
});
|
||||
|
||||
// Should include: Water the plants (due today), Shared chore (overdue, in assignees)
|
||||
// Should exclude: Make the bed (done, nextDueDate=tomorrow), Walk the dog (different user), Inactive chore
|
||||
expect(chores).toHaveLength(2);
|
||||
expect(chores.map((c) => c.text)).toContain('Water the plants');
|
||||
expect(chores.map((c) => c.text)).toContain('Shared chore');
|
||||
expect(chores.map((c) => c.text)).not.toContain('Make the bed');
|
||||
expect(chores.map((c) => c.text)).not.toContain('Walk the dog');
|
||||
expect(chores.map((c) => c.text)).not.toContain('Inactive chore');
|
||||
|
||||
});
|
||||
|
||||
test('throws on auth failure', async () => {
|
||||
await expect(
|
||||
fetchDonetickChores({ url: baseUrl, token: 'wrong', user_id: 1 })
|
||||
).rejects.toThrow('401');
|
||||
});
|
||||
});
|
||||
51
src/sources/donetick.ts
Normal file
51
src/sources/donetick.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { ChoreItem } from '../todayview';
|
||||
|
||||
export interface DonetickConfig {
|
||||
url?: string;
|
||||
token: string;
|
||||
user_id: number;
|
||||
}
|
||||
|
||||
interface DonetickChore {
|
||||
id: number;
|
||||
name: string;
|
||||
assignedTo: number;
|
||||
nextDueDate: string;
|
||||
isActive: boolean;
|
||||
assignees?: { userId: number }[];
|
||||
labelsV2?: { name: string }[];
|
||||
}
|
||||
|
||||
export async function fetchDonetickChores(
|
||||
config: DonetickConfig
|
||||
): Promise<ChoreItem[]> {
|
||||
const baseUrl = config.url ?? 'https://api.donetick.com';
|
||||
const resp = await fetch(`${baseUrl}/eapi/v1/chore`, {
|
||||
headers: { secretkey: config.token },
|
||||
});
|
||||
if (!resp.ok)
|
||||
throw new Error(`Donetick fetch failed: ${resp.status}`);
|
||||
|
||||
const chores = (await resp.json()) as DonetickChore[];
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
return chores
|
||||
.filter(
|
||||
(c) =>
|
||||
c.isActive &&
|
||||
(c.assignedTo === config.user_id ||
|
||||
c.assignees?.some((a) => a.userId === config.user_id)) &&
|
||||
// Only include chores due today or overdue
|
||||
c.nextDueDate?.slice(0, 10) <= today
|
||||
)
|
||||
.map((c) => {
|
||||
const dueDate = new Date(c.nextDueDate?.slice(0, 10) + 'T00:00:00');
|
||||
const todayDate = new Date(today + 'T00:00:00');
|
||||
const overdue_days = Math.floor(
|
||||
(todayDate.getTime() - dueDate.getTime()) / 86_400_000
|
||||
);
|
||||
const label = c.labelsV2?.[0]?.name;
|
||||
return { text: c.name, label, overdue_days };
|
||||
});
|
||||
}
|
||||
101
src/sources/ics.test.ts
Normal file
101
src/sources/ics.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import type { Server } from 'bun';
|
||||
import { fetchICSAgenda } from './ics';
|
||||
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
const icsDate = (d: Date) =>
|
||||
`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}00`;
|
||||
|
||||
const now = new Date();
|
||||
const event1Start = new Date(now);
|
||||
event1Start.setHours(9, 0, 0, 0);
|
||||
const event1End = new Date(now);
|
||||
event1End.setHours(10, 0, 0, 0);
|
||||
const event2Start = new Date(now);
|
||||
event2Start.setHours(14, 30, 0, 0);
|
||||
const event2End = new Date(now);
|
||||
event2End.setHours(15, 15, 0, 0);
|
||||
|
||||
const ics = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test//EN
|
||||
BEGIN:VEVENT
|
||||
DTSTART:${icsDate(event1Start)}
|
||||
DTEND:${icsDate(event1End)}
|
||||
SUMMARY:Morning standup
|
||||
LOCATION:Zoom
|
||||
UID:test-1@test
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:${icsDate(event2Start)}
|
||||
DTEND:${icsDate(event2End)}
|
||||
SUMMARY:Design review
|
||||
LOCATION:Room 4B
|
||||
UID:test-2@test
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}
|
||||
DTEND;VALUE=DATE:${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate() + 1)}
|
||||
SUMMARY:All-day event
|
||||
UID:test-allday@test
|
||||
END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
|
||||
let server: Server;
|
||||
let icsUrl: string;
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
routes: {
|
||||
'/cal.ics': () =>
|
||||
new Response(ics, {
|
||||
headers: { 'Content-Type': 'text/calendar' },
|
||||
}),
|
||||
},
|
||||
});
|
||||
icsUrl = `http://127.0.0.1:${server.port}/cal.ics`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.stop();
|
||||
});
|
||||
|
||||
describe('ics agenda source', () => {
|
||||
test('fetches and formats today events', async () => {
|
||||
const events = await fetchICSAgenda([icsUrl]);
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const standup = events.find((e) => e.title === 'Morning standup');
|
||||
expect(standup).toBeDefined();
|
||||
expect(standup!.start).toBe('09:00');
|
||||
expect(standup!.end).toBe('10:00');
|
||||
expect(standup!.where).toBe('Zoom');
|
||||
|
||||
const review = events.find((e) => e.title === 'Design review');
|
||||
expect(review).toBeDefined();
|
||||
expect(review!.start).toBe('14:30');
|
||||
expect(review!.end).toBe('15:15');
|
||||
expect(review!.where).toBe('Room 4B');
|
||||
});
|
||||
|
||||
test('filters out all-day events', async () => {
|
||||
const events = await fetchICSAgenda([icsUrl]);
|
||||
const allDay = events.find((e) => e.title === 'All-day event');
|
||||
expect(allDay).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns sorted by start time', async () => {
|
||||
const events = await fetchICSAgenda([icsUrl]);
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
expect(events[i]!.start >= events[i - 1]!.start).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('handles empty url list', async () => {
|
||||
const events = await fetchICSAgenda([]);
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
71
src/sources/ics.ts
Normal file
71
src/sources/ics.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import * as ical from 'node-ical';
|
||||
import type { AgendaEvent } from '../todayview';
|
||||
|
||||
export async function fetchICSAgenda(
|
||||
urls: string[]
|
||||
): Promise<AgendaEvent[]> {
|
||||
const now = new Date();
|
||||
const startOfDay = new Date(now);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(now);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const allEvents = (
|
||||
await Promise.all(urls.map((url) => fetchOne(url, startOfDay, endOfDay)))
|
||||
).flat();
|
||||
|
||||
allEvents.sort((a, b) => a.sortKey - b.sortKey);
|
||||
|
||||
return allEvents.map(({ sortKey, ...event }) => event);
|
||||
}
|
||||
|
||||
interface RawAgendaEvent extends AgendaEvent {
|
||||
sortKey: number;
|
||||
}
|
||||
|
||||
async function fetchOne(
|
||||
url: string,
|
||||
start: Date,
|
||||
end: Date
|
||||
): Promise<RawAgendaEvent[]> {
|
||||
const resp = await fetch(url, {
|
||||
headers: [['User-Agent', 'private TRMNL fetcher']],
|
||||
});
|
||||
const data = await resp.text();
|
||||
const parsed = Object.values(await ical.async.parseICS(data)).filter(
|
||||
(a) => a?.type === 'VEVENT'
|
||||
);
|
||||
|
||||
const events: RawAgendaEvent[] = [];
|
||||
|
||||
for (const event of parsed) {
|
||||
for (const instance of ical.expandRecurringEvent(event, {
|
||||
from: start,
|
||||
to: end,
|
||||
excludeExdates: true,
|
||||
expandOngoing: true,
|
||||
includeOverrides: true,
|
||||
})) {
|
||||
if (instance.isFullDay) continue;
|
||||
|
||||
const startTime = formatTime(instance.start);
|
||||
const endTime = formatTime(instance.end);
|
||||
const location = (instance.event as any).location ?? '';
|
||||
|
||||
events.push({
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
title: (instance.summary as string) ?? '',
|
||||
where: location,
|
||||
kind: '',
|
||||
sortKey: instance.start.valueOf(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function formatTime(d: Date): string {
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
199
src/sources/tana.test.ts
Normal file
199
src/sources/tana.test.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import type { Server } from 'bun';
|
||||
import { fetchTanaFocus, fetchTanaTasks } from './tana';
|
||||
|
||||
const NODE_ID = 'test-node-123';
|
||||
const MARKDOWN = `**Today's Focus**: Ship the retrospective deck <!-- comment -->
|
||||
**What would make today a success?**: Three deep-work blocks. One real conversation.
|
||||
`;
|
||||
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
routes: {
|
||||
'/workspaces/:ws/calendar/node': (req) => {
|
||||
const auth = req.headers.get('Authorization');
|
||||
if (auth !== 'Bearer test-token')
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
return Response.json({ nodeId: NODE_ID });
|
||||
},
|
||||
'/nodes/:id': (req) => {
|
||||
const auth = req.headers.get('Authorization');
|
||||
if (auth !== 'Bearer test-token')
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
if (req.params.id !== NODE_ID)
|
||||
return new Response('Not found', { status: 404 });
|
||||
return Response.json({ markdown: MARKDOWN });
|
||||
},
|
||||
},
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.stop();
|
||||
});
|
||||
|
||||
describe('tana source', () => {
|
||||
test('fetches focus and success', async () => {
|
||||
const result = await fetchTanaFocus({
|
||||
url: baseUrl,
|
||||
token: 'test-token',
|
||||
workspace: 'test-ws',
|
||||
task_tag_id: 'x',
|
||||
due_date_field_id: 'x',
|
||||
});
|
||||
|
||||
expect(result.focus).toBe('Ship the retrospective deck');
|
||||
expect(result.success).toBe(
|
||||
'Three deep-work blocks. One real conversation.'
|
||||
);
|
||||
});
|
||||
|
||||
test('strips tana link markup', async () => {
|
||||
const linkServer = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
routes: {
|
||||
'/workspaces/:ws/calendar/node': () =>
|
||||
Response.json({ nodeId: 'n1' }),
|
||||
'/nodes/:id': () =>
|
||||
Response.json({
|
||||
markdown:
|
||||
"**Today's Focus**: Review [Atlas](tana:abc123) spec\n**What would make today a success?**: Ship [the deck](tana:xyz789).\n",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fetchTanaFocus({
|
||||
url: `http://127.0.0.1:${linkServer.port}`,
|
||||
token: 'test-token',
|
||||
workspace: 'w',
|
||||
task_tag_id: 'x',
|
||||
due_date_field_id: 'x',
|
||||
});
|
||||
|
||||
expect(result.focus).toBe('Review Atlas spec');
|
||||
expect(result.success).toBe('Ship the deck.');
|
||||
linkServer.stop();
|
||||
});
|
||||
|
||||
test('throws on auth failure', async () => {
|
||||
await expect(
|
||||
fetchTanaFocus({ url: baseUrl, token: 'wrong', workspace: 'w', task_tag_id: 'x', due_date_field_id: 'x' })
|
||||
).rejects.toThrow('401');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tana tasks', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
||||
const threeDaysAgo = new Date(Date.now() - 3 * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
let taskServer: Server;
|
||||
let taskUrl: string;
|
||||
|
||||
beforeAll(() => {
|
||||
taskServer = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
routes: {
|
||||
'/nodes/search': () => {
|
||||
// Single query returns all non-done tasks due < tomorrow
|
||||
return Response.json([
|
||||
{
|
||||
id: 't1',
|
||||
name: 'Review spec comments',
|
||||
tags: [
|
||||
{ id: '81eGeJlDYzOD', name: 'task' },
|
||||
{ id: 'tag1', name: 'Atlas' },
|
||||
],
|
||||
fields: [{ fieldId: 'T3ApdSKU70Y8', value: today }],
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
name: 'Submit expenses',
|
||||
tags: [{ id: '81eGeJlDYzOD', name: 'task' }],
|
||||
fields: [{ fieldId: 'T3ApdSKU70Y8', value: today }],
|
||||
},
|
||||
{
|
||||
id: 'o1',
|
||||
name: 'File Q1 receipts',
|
||||
tags: [
|
||||
{ id: '81eGeJlDYzOD', name: 'task' },
|
||||
{ id: 'tag2', name: 'Admin' },
|
||||
],
|
||||
fields: [{ fieldId: 'T3ApdSKU70Y8', value: threeDaysAgo }],
|
||||
},
|
||||
{
|
||||
id: 'o2',
|
||||
name: 'Book dentist',
|
||||
tags: [
|
||||
{ id: '81eGeJlDYzOD', name: 'task' },
|
||||
{ id: 'tag3', name: 'Health' },
|
||||
],
|
||||
fields: [{ fieldId: 'T3ApdSKU70Y8', value: yesterday }],
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
});
|
||||
taskUrl = `http://127.0.0.1:${taskServer.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
taskServer.stop();
|
||||
});
|
||||
|
||||
test('fetches due-today and overdue tasks', async () => {
|
||||
const result = await fetchTanaTasks({
|
||||
url: taskUrl,
|
||||
token: 'test-token',
|
||||
workspace: 'w',
|
||||
task_tag_id: '81eGeJlDYzOD',
|
||||
due_date_field_id: 'T3ApdSKU70Y8',
|
||||
});
|
||||
|
||||
expect(result.due).toHaveLength(2);
|
||||
expect(result.due[0]!.text).toBe('Review spec comments');
|
||||
expect(result.due[0]!.tag).toBe('Atlas');
|
||||
expect(result.due[1]!.text).toBe('Submit expenses');
|
||||
expect(result.due[1]!.tag).toBe('');
|
||||
|
||||
expect(result.overdue).toHaveLength(2);
|
||||
expect(result.overdue[0]!.text).toBe('File Q1 receipts');
|
||||
expect(result.overdue[0]!.tag).toBe('Admin');
|
||||
expect(result.overdue[0]!.age).toBe('3d');
|
||||
expect(result.overdue[1]!.text).toBe('Book dentist');
|
||||
expect(result.overdue[1]!.tag).toBe('Health');
|
||||
expect(result.overdue[1]!.age).toBe('1d');
|
||||
});
|
||||
|
||||
test('handles empty results', async () => {
|
||||
const emptyServer = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
routes: {
|
||||
'/nodes/search': () => Response.json([]),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fetchTanaTasks({
|
||||
url: `http://127.0.0.1:${emptyServer.port}`,
|
||||
token: 'test-token',
|
||||
workspace: 'w',
|
||||
task_tag_id: 'x',
|
||||
due_date_field_id: 'x',
|
||||
});
|
||||
|
||||
expect(result.due).toHaveLength(0);
|
||||
expect(result.overdue).toHaveLength(0);
|
||||
emptyServer.stop();
|
||||
});
|
||||
});
|
||||
152
src/sources/tana.ts
Normal file
152
src/sources/tana.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import type { TodoItem, OverdueItem } from '../todayview';
|
||||
|
||||
export interface TanaConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
workspace: string;
|
||||
task_tag_id: string;
|
||||
due_date_field_id: string;
|
||||
focus_field?: string; // default: "Today's Focus"
|
||||
success_field?: string; // default: "What would make today a success?"
|
||||
}
|
||||
|
||||
interface TanaTask {
|
||||
id: string;
|
||||
name: string;
|
||||
tags?: { id: string; name: string }[];
|
||||
fields?: { fieldId: string; value: string }[];
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function parseField(md: string, fieldName: string): string | undefined {
|
||||
// Match **Field Name**: value <!-- or **Field Name**: value\n
|
||||
const commentPattern = new RegExp(
|
||||
`\\*\\*${fieldName}\\*\\*:\\s*(.*?)\\s*<!--`
|
||||
);
|
||||
const newlinePattern = new RegExp(
|
||||
`\\*\\*${fieldName}\\*\\*:\\s*(.*?)\\s*\\n`
|
||||
);
|
||||
|
||||
let match = md.match(commentPattern) ?? md.match(newlinePattern);
|
||||
if (!match?.[1]) return undefined;
|
||||
|
||||
let value = match[1];
|
||||
// Strip Tana link markup: [text](tana:id) -> text
|
||||
value = value.replace(/\[(.+?)\]\(tana:[^)]+\)\s*(\p{P})/gu, '$1$2');
|
||||
value = value.replace(/\[(.+?)\]\(tana:[^)]+\)/g, '$1');
|
||||
return value.trim() || undefined;
|
||||
}
|
||||
|
||||
export async function fetchTanaFocus(
|
||||
config: TanaConfig
|
||||
): Promise<{ focus?: string; success?: string }> {
|
||||
const workspace = config.workspace;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const headers = {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
// Step 1: get today's calendar node ID
|
||||
const calResp = await fetch(
|
||||
`${config.url}/workspaces/${workspace}/calendar/node?date=${today}&granularity=day`,
|
||||
{ headers }
|
||||
);
|
||||
if (!calResp.ok) throw new Error(`Tana calendar fetch failed: ${calResp.status}`);
|
||||
|
||||
const calData = (await calResp.json()) as { nodeId?: string };
|
||||
if (!calData.nodeId) throw new Error('Tana calendar response missing nodeId');
|
||||
|
||||
// Step 2: read the node content
|
||||
const noteResp = await fetch(
|
||||
`${config.url}/nodes/${calData.nodeId}?maxDepth=1`,
|
||||
{ headers }
|
||||
);
|
||||
if (!noteResp.ok) throw new Error(`Tana note fetch failed: ${noteResp.status}`);
|
||||
|
||||
const noteData = (await noteResp.json()) as { markdown?: string };
|
||||
if (!noteData.markdown) throw new Error('Tana note response missing markdown');
|
||||
|
||||
// Step 3: parse fields from markdown
|
||||
return {
|
||||
focus: parseField(
|
||||
noteData.markdown,
|
||||
escapeRegex(config.focus_field ?? "Today's Focus")
|
||||
),
|
||||
success: parseField(
|
||||
noteData.markdown,
|
||||
escapeRegex(config.success_field ?? 'What would make today a success?')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getTag(task: TanaTask, taskTagId: string): string {
|
||||
const tag = task.tags?.find((t) => t.id !== taskTagId);
|
||||
return tag?.name ?? '';
|
||||
}
|
||||
|
||||
function getDueDate(task: TanaTask, dueDateFieldId: string): string | undefined {
|
||||
return task.fields?.find((f) => f.fieldId === dueDateFieldId)?.value;
|
||||
}
|
||||
|
||||
function computeAge(dueDate: string): string {
|
||||
const due = new Date(dueDate + 'T00:00:00');
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const days = Math.floor((today.getTime() - due.getTime()) / 86_400_000);
|
||||
if (days <= 0) return '0d';
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
export async function fetchTanaTasks(
|
||||
config: TanaConfig
|
||||
): Promise<{ due: TodoItem[]; overdue: OverdueItem[] }> {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10);
|
||||
const headers = {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
// Single query: due < tomorrow (i.e. due today or earlier), not done
|
||||
const params = new URLSearchParams({
|
||||
'query[and][0][hasType]': config.task_tag_id,
|
||||
'query[and][1][not][is]': 'done',
|
||||
'query[and][2][compare][fieldId]': config.due_date_field_id,
|
||||
'query[and][2][compare][operator]': 'lt',
|
||||
'query[and][2][compare][value]': tomorrow,
|
||||
'query[and][2][compare][type]': 'date',
|
||||
limit: '100',
|
||||
});
|
||||
|
||||
const resp = await fetch(`${config.url}/nodes/search?${params}`, { headers });
|
||||
if (!resp.ok) throw new Error(`Tana task search failed: ${resp.status}`);
|
||||
const tasks = (await resp.json()) as TanaTask[];
|
||||
|
||||
// Split: due date == today → due today, due date < today → overdue
|
||||
const due: TodoItem[] = [];
|
||||
const overdue: OverdueItem[] = [];
|
||||
|
||||
for (const t of tasks) {
|
||||
const dueDate = getDueDate(t, config.due_date_field_id);
|
||||
if (dueDate && dueDate < today) {
|
||||
overdue.push({
|
||||
text: t.name,
|
||||
tag: getTag(t, config.task_tag_id),
|
||||
age: computeAge(dueDate),
|
||||
});
|
||||
} else {
|
||||
due.push({
|
||||
text: t.name,
|
||||
tag: getTag(t, config.task_tag_id),
|
||||
est: '',
|
||||
done: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { due, overdue };
|
||||
}
|
||||
769
src/todayview.tsx
Normal file
769
src/todayview.tsx
Normal file
|
|
@ -0,0 +1,769 @@
|
|||
import { renderToString } from 'react-dom/server';
|
||||
import type { Renderable, RenderMode } from './template';
|
||||
import type { DeviceModel } from './devices';
|
||||
import { fetchDonetickChores } from './sources/donetick';
|
||||
import { fetchICSAgenda } from './sources/ics';
|
||||
import { fetchTanaFocus, fetchTanaTasks } from './sources/tana';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
export interface AgendaEvent {
|
||||
start: string; // "HH:MM"
|
||||
end: string;
|
||||
title: string;
|
||||
where: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export interface ChoreItem {
|
||||
text: string;
|
||||
label?: string;
|
||||
overdue_days?: number;
|
||||
}
|
||||
|
||||
export interface TodoItem {
|
||||
text: string;
|
||||
tag: string;
|
||||
est: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface OverdueItem {
|
||||
text: string;
|
||||
tag: string;
|
||||
age: string;
|
||||
}
|
||||
|
||||
export interface TodayData {
|
||||
focus?: string;
|
||||
success?: string;
|
||||
agenda?: AgendaEvent[];
|
||||
chores?: ChoreItem[];
|
||||
todos_due?: TodoItem[];
|
||||
todos_overdue?: OverdueItem[];
|
||||
}
|
||||
|
||||
export interface TodayConfig extends TodayData {
|
||||
calendar_urls?: string[];
|
||||
tana?: {
|
||||
url: string;
|
||||
token: string;
|
||||
workspace?: string;
|
||||
};
|
||||
donetick?: {
|
||||
url?: string;
|
||||
token: string;
|
||||
user_id: number;
|
||||
};
|
||||
max_chores?: number; // default: 8
|
||||
}
|
||||
|
||||
/* ---------- Style constants ---------- */
|
||||
|
||||
const FONT_BODY = '"Geist", system-ui, sans-serif';
|
||||
const FONT_MONO = '"Geist Mono", ui-monospace, monospace';
|
||||
|
||||
const STYLES = `
|
||||
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap");
|
||||
html, body { margin: 0; background: #fff; }
|
||||
.today-panel {
|
||||
--paper: #ffffff;
|
||||
--ink: #1a1a1a;
|
||||
--ink-2: #3a3a3a;
|
||||
--ink-3: #6a675e;
|
||||
--rule: rgba(26,26,26,0.18);
|
||||
width: 100%; height: 100vh;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
font-family: ${FONT_BODY};
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`;
|
||||
|
||||
/* ---------- Atoms ---------- */
|
||||
|
||||
const Check = ({ on }: { on: boolean }) => (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 12,
|
||||
height: 12,
|
||||
border: '1.5px solid var(--ink)',
|
||||
background: on ? 'var(--ink)' : 'transparent',
|
||||
position: 'relative',
|
||||
verticalAlign: 'middle',
|
||||
flex: '0 0 12px',
|
||||
}}>
|
||||
{on && (
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
width="12"
|
||||
height="12"
|
||||
style={{ position: 'absolute', inset: -1.5 }}>
|
||||
<polyline
|
||||
points="2.5,6.5 5,9 9.5,3.5"
|
||||
fill="none"
|
||||
stroke="var(--paper)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="square"
|
||||
strokeLinejoin="miter"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const SectionTitle = ({
|
||||
children,
|
||||
count,
|
||||
right,
|
||||
}: {
|
||||
children: string;
|
||||
count?: number | null;
|
||||
right?: string;
|
||||
}) => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 8,
|
||||
paddingBottom: 4,
|
||||
borderBottom: '1px solid var(--ink)',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.14em',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{children}
|
||||
</span>
|
||||
{count != null && (
|
||||
<span style={{ fontFamily: FONT_MONO, fontSize: 10, color: 'var(--ink-3)' }}>
|
||||
· {count}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
{right && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 9.5,
|
||||
color: 'var(--ink-3)',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{right}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Top bar ---------- */
|
||||
|
||||
const DOW = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
const getWeekNumber = (d: Date): number => {
|
||||
const start = new Date(d.getFullYear(), 0, 1);
|
||||
const diff = d.getTime() - start.getTime() + (start.getTimezoneOffset() - d.getTimezoneOffset()) * 60000;
|
||||
return Math.ceil((diff / 86400000 + start.getDay() + 1) / 7);
|
||||
};
|
||||
|
||||
const getDayOfYear = (d: Date): number => {
|
||||
const start = new Date(d.getFullYear(), 0, 0);
|
||||
const diff = d.getTime() - start.getTime();
|
||||
return Math.floor(diff / 86400000);
|
||||
};
|
||||
|
||||
const FocusLine = ({ label, text }: { label: string; text: string }) => (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '62px 1fr', gap: 10, alignItems: 'baseline' }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 9.5,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.16em',
|
||||
color: 'var(--ink-3)',
|
||||
paddingTop: 2,
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT_BODY,
|
||||
fontSize: 15.5,
|
||||
lineHeight: 1.25,
|
||||
fontWeight: 450,
|
||||
letterSpacing: '-0.005em',
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TopBar = ({ data }: { data: TodayData }) => {
|
||||
const d = new Date();
|
||||
const big = String(d.getDate()).padStart(2, '0');
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1px 1fr',
|
||||
gap: 16,
|
||||
alignItems: 'stretch',
|
||||
padding: '10px 18px 12px',
|
||||
borderBottom: '1.5px solid var(--ink)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 200 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT_BODY,
|
||||
fontSize: 56,
|
||||
lineHeight: 0.9,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '-0.04em',
|
||||
fontVariantNumeric: 'lining-nums tabular-nums',
|
||||
}}>
|
||||
{big}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, paddingTop: 4 }}>
|
||||
<div style={{ fontFamily: FONT_BODY, fontSize: 17, lineHeight: 1, fontWeight: 500, letterSpacing: '-0.01em' }}>
|
||||
{DOW[d.getDay()]}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 10,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--ink-2)',
|
||||
}}>
|
||||
{MONTHS[d.getMonth()]} {d.getFullYear()}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 9,
|
||||
letterSpacing: '0.18em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--ink-3)',
|
||||
marginTop: 2,
|
||||
}}>
|
||||
Week {getWeekNumber(d)} · Day {getDayOfYear(d)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 1, background: 'var(--ink)', opacity: 0.85 }} />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 6, minWidth: 0 }}>
|
||||
<FocusLine label="FOCUS" text={data.focus} />
|
||||
<FocusLine label="SUCCESS" text={data.success} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Agenda ---------- */
|
||||
|
||||
const Agenda = ({ events }: { events: AgendaEvent[] }) => {
|
||||
const now = new Date();
|
||||
const nowStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
const nowMin = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
// Derive timeline range from events
|
||||
const startMin = events.length
|
||||
? parseInt(events[0]!.start.split(':')[0]!) * 60
|
||||
: 8 * 60;
|
||||
const endMin = events.length
|
||||
? parseInt(events[events.length - 1]!.end.split(':')[0]!) * 60 + parseInt(events[events.length - 1]!.end.split(':')[1]!)
|
||||
: 17 * 60;
|
||||
const rangeMin = Math.max(endMin - startMin, 1);
|
||||
const pct = Math.min(Math.max((nowMin - startMin) / rangeMin, 0), 1);
|
||||
|
||||
// Hour ticks at even hours within range
|
||||
const ticks: number[] = [];
|
||||
for (let h = Math.ceil(startMin / 120) * 2; h * 60 <= endMin; h += 2) {
|
||||
ticks.push(h);
|
||||
}
|
||||
|
||||
const startLabel = events[0]?.start ?? '08:00';
|
||||
const endLabel = events[events.length - 1]?.end ?? '17:00';
|
||||
|
||||
return (
|
||||
<section style={{ padding: '10px 14px 10px 18px', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<SectionTitle right={`${startLabel} \u2014 ${endLabel}`} count={events.length}>
|
||||
Agenda
|
||||
</SectionTitle>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, flex: 1, minHeight: 0 }}>
|
||||
{/* Timeline rail */}
|
||||
<div style={{ width: 14, position: 'relative', borderRight: '1px dashed var(--rule)', flex: '0 0 14px' }}>
|
||||
{ticks.map((h) => {
|
||||
const p = (h * 60 - startMin) / rangeMin;
|
||||
return (
|
||||
<div key={h} style={{ position: 'absolute', top: `${p * 100}%`, left: 0, right: -1, borderTop: '1px solid var(--rule)' }}>
|
||||
<span style={{ position: 'absolute', left: -2, top: -6, fontFamily: FONT_MONO, fontSize: 8, color: 'var(--ink-3)' }}>
|
||||
{String(h).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Now marker */}
|
||||
<div style={{ position: 'absolute', left: -2, right: -3, top: `${pct * 100}%`, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ width: 6, height: 6, background: 'var(--ink)', transform: 'rotate(45deg)' }} />
|
||||
<div style={{ flex: 1, height: 0, borderTop: '1.5px solid var(--ink)' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event list */}
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 7, flex: 1, minWidth: 0 }}>
|
||||
{events.map((e, i) => {
|
||||
const past = e.end <= nowStr;
|
||||
const current = e.start <= nowStr && e.end > nowStr;
|
||||
return (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '54px 1fr',
|
||||
columnGap: 10,
|
||||
opacity: past ? 0.42 : 1,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 10,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 500,
|
||||
paddingTop: 2,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
<div>{e.start}</div>
|
||||
<div style={{ color: 'var(--ink-3)' }}>{e.end}</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, minWidth: 0 }}>
|
||||
{current && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 5,
|
||||
height: 5,
|
||||
background: 'var(--ink)',
|
||||
display: 'inline-block',
|
||||
flex: '0 0 5px',
|
||||
transform: 'translateY(-1px)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT_BODY,
|
||||
fontSize: 13.5,
|
||||
fontWeight: current ? 600 : 500,
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: '-0.005em',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
textDecoration: past ? 'line-through' : 'none',
|
||||
textDecorationColor: 'var(--ink-3)',
|
||||
}}>
|
||||
{e.title}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 9.5,
|
||||
color: 'var(--ink-3)',
|
||||
letterSpacing: '0.04em',
|
||||
marginTop: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{e.where} · {e.kind}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Chores ---------- */
|
||||
|
||||
const OverdueBadge = ({ days }: { days: number }) => (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 16,
|
||||
height: 16,
|
||||
border: '1.5px solid var(--ink)',
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
flex: '0 0 16px',
|
||||
background: days > 0 ? 'var(--ink)' : 'transparent',
|
||||
color: days > 0 ? 'var(--paper)' : 'var(--ink)',
|
||||
}}>
|
||||
{days}
|
||||
</span>
|
||||
);
|
||||
|
||||
const Chores = ({ items }: { items: ChoreItem[] }) => (
|
||||
<section style={{ padding: '10px 14px 12px 18px', display: 'flex', flexDirection: 'column', borderTop: '1px solid var(--rule)' }}>
|
||||
<SectionTitle>Chores</SectionTitle>
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
columnGap: 16,
|
||||
rowGap: 6,
|
||||
}}>
|
||||
{items.map((it, i) => (
|
||||
<li key={i}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 9,
|
||||
width: '100%',
|
||||
fontFamily: FONT_BODY,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 450,
|
||||
color: 'var(--ink)',
|
||||
}}>
|
||||
<OverdueBadge days={it.overdue_days ?? 0} />
|
||||
<span>
|
||||
{it.text}
|
||||
{it.label && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 9,
|
||||
color: 'var(--ink-3)',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
marginLeft: 4,
|
||||
}}>
|
||||
· {it.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
|
||||
/* ---------- Tasks (right column) ---------- */
|
||||
|
||||
const Tasks = ({
|
||||
due,
|
||||
overdue,
|
||||
}: {
|
||||
due: TodoItem[];
|
||||
overdue: OverdueItem[];
|
||||
}) => {
|
||||
const doneCount = due.filter((d) => d.done).length;
|
||||
return (
|
||||
<aside
|
||||
style={{
|
||||
padding: '10px 18px 12px 14px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
borderLeft: '1.5px solid var(--ink)',
|
||||
gap: 10,
|
||||
}}>
|
||||
<div>
|
||||
<SectionTitle right={`${doneCount}/${due.length}`}>Due today</SectionTitle>
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}>
|
||||
{due.map((t, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '12px 1fr auto',
|
||||
columnGap: 9,
|
||||
alignItems: 'baseline',
|
||||
color: t.done ? 'var(--ink-3)' : 'var(--ink)',
|
||||
}}>
|
||||
<span style={{ transform: 'translateY(2px)' }}>
|
||||
<Check on={t.done} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT_BODY,
|
||||
fontSize: 13,
|
||||
fontWeight: 450,
|
||||
lineHeight: 1.2,
|
||||
textDecoration: t.done ? 'line-through' : 'none',
|
||||
}}>
|
||||
{t.text}
|
||||
{t.tag && (
|
||||
<>
|
||||
{' '}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 9,
|
||||
color: 'var(--ink-3)',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
marginLeft: 2,
|
||||
}}>
|
||||
· {t.tag}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{t.est && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 10,
|
||||
color: 'var(--ink-2)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{t.est}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{overdue.length > 0 && <div style={{ borderTop: '1px dashed var(--rule)', paddingTop: 8 }}>
|
||||
<SectionTitle right={`${overdue.length}`}>Overdue</SectionTitle>
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}>
|
||||
{overdue.map((t, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '12px 1fr auto',
|
||||
columnGap: 9,
|
||||
alignItems: 'baseline',
|
||||
}}>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
lineHeight: 1,
|
||||
transform: 'translateY(2px)',
|
||||
}}>
|
||||
!
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT_BODY,
|
||||
fontSize: 13,
|
||||
fontWeight: 450,
|
||||
lineHeight: 1.2,
|
||||
textDecoration: 'underline',
|
||||
textDecorationStyle: 'dotted',
|
||||
textUnderlineOffset: 3,
|
||||
textDecorationColor: 'var(--ink-3)',
|
||||
}}>
|
||||
{t.text}
|
||||
{t.tag && (
|
||||
<>
|
||||
{' '}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 9,
|
||||
color: 'var(--ink-3)',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
marginLeft: 2,
|
||||
textDecoration: 'none',
|
||||
}}>
|
||||
· {t.tag}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{t.age && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 10,
|
||||
color: 'var(--ink)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
padding: '1px 4px',
|
||||
border: '1px solid var(--ink)',
|
||||
}}>
|
||||
{t.age}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Main layout ---------- */
|
||||
|
||||
const TodayPanel = ({ data }: { data: TodayData }) => (
|
||||
<div className="today-panel">
|
||||
<TopBar data={data} />
|
||||
<main style={{ display: 'grid', gridTemplateColumns: '1.55fr 1fr', minHeight: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateRows: '1fr auto', minHeight: 0 }}>
|
||||
<Agenda events={data.agenda} />
|
||||
<Chores items={data.chores} />
|
||||
</div>
|
||||
<Tasks due={data.todos_due} overdue={data.todos_overdue} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Renderable ---------- */
|
||||
|
||||
export class TodayRenderable implements Renderable {
|
||||
public hash: string = '';
|
||||
public nextEvent?: Date;
|
||||
|
||||
constructor(private config: TodayConfig) {}
|
||||
|
||||
private async resolveData(): Promise<TodayData> {
|
||||
const data: TodayData = { ...this.config };
|
||||
|
||||
const fetches: Promise<void>[] = [];
|
||||
|
||||
if (this.config.tana) {
|
||||
fetches.push(
|
||||
Promise.all([
|
||||
fetchTanaFocus(this.config.tana),
|
||||
fetchTanaTasks(this.config.tana),
|
||||
])
|
||||
.then(([focus, tasks]) => {
|
||||
if (focus.focus) data.focus = focus.focus;
|
||||
if (focus.success) data.success = focus.success;
|
||||
if (tasks.due.length) data.todos_due = tasks.due;
|
||||
if (tasks.overdue.length) data.todos_overdue = tasks.overdue;
|
||||
})
|
||||
.catch((e) => console.error('Tana fetch failed:', e))
|
||||
);
|
||||
}
|
||||
|
||||
if (this.config.calendar_urls?.length) {
|
||||
fetches.push(
|
||||
fetchICSAgenda(this.config.calendar_urls)
|
||||
.then((events) => {
|
||||
if (events.length) data.agenda = events;
|
||||
})
|
||||
.catch((e) => console.error('ICS fetch failed:', e))
|
||||
);
|
||||
}
|
||||
|
||||
if (this.config.donetick) {
|
||||
fetches.push(
|
||||
fetchDonetickChores(this.config.donetick)
|
||||
.then((chores) => {
|
||||
if (chores.length) data.chores = chores;
|
||||
})
|
||||
.catch((e) => console.error('Donetick fetch failed:', e))
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(fetches);
|
||||
|
||||
// Limit chores
|
||||
const maxChores = this.config.max_chores ?? 8;
|
||||
if (data.chores) {
|
||||
data.chores = data.chores.slice(0, maxChores);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async render(_mode: RenderMode): Promise<string> {
|
||||
const data = await this.resolveData();
|
||||
const agenda = data.agenda ?? [];
|
||||
const chores = data.chores ?? [];
|
||||
const todosDue = data.todos_due ?? [];
|
||||
|
||||
const now = new Date();
|
||||
const nowStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
|
||||
this.hash = Bun.hash
|
||||
.xxHash64(
|
||||
JSON.stringify([
|
||||
now.toDateString(),
|
||||
nowStr.substring(0, 4), // hash changes each 10 min block
|
||||
data.focus,
|
||||
agenda.length,
|
||||
chores.length,
|
||||
todosDue.filter((t) => t.done).length,
|
||||
]),
|
||||
2n
|
||||
)
|
||||
.toString();
|
||||
|
||||
// Find the next agenda event to schedule a refresh
|
||||
for (const event of agenda) {
|
||||
const [h, m] = event.start.split(':').map(Number) as [number, number];
|
||||
const eventTime = new Date(now);
|
||||
eventTime.setHours(h, m, 0, 0);
|
||||
if (eventTime > now) {
|
||||
this.nextEvent = eventTime;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const resolved: TodayData = {
|
||||
focus: data.focus ?? '',
|
||||
success: data.success ?? '',
|
||||
agenda,
|
||||
chores,
|
||||
todos_due: todosDue,
|
||||
todos_overdue: data.todos_overdue ?? [],
|
||||
};
|
||||
|
||||
const html = renderToString(<TodayPanel data={resolved} />);
|
||||
return `<style>${STYLES}</style><div class="view view--full">${html}</div>`;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue