Initial scaffold: NixOS + SvelteKit dashboard + Python renderer

This commit is contained in:
Ruben Hensen
2026-06-07 18:15:06 +02:00
commit 50256fa384
25 changed files with 4491 additions and 0 deletions
+1965
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "eink-dashboard",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.5.0",
"vite": "^6.0.0"
},
"dependencies": {
"tsdav": "^2.1.0",
"ical.js": "^2.1.0"
}
}
+34
View File
@@ -0,0 +1,34 @@
/* E-paper-friendly base styles.
Constraints:
- Hard 1-bit palette: pure black on pure white. No grays (they dither badly).
- No thin lines below 2px — Floyd-Steinberg dither breaks them up.
- No anti-aliased small fonts. Stick to bold weights at >= 18px.
- No soft shadows; use crisp 2-4px solid borders for separation. */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 1304px;
height: 984px;
background: #ffffff;
color: #000000;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 20px;
font-weight: 500;
-webkit-font-smoothing: none;
-moz-osx-font-smoothing: unset;
text-rendering: geometricPrecision;
}
h1, h2, h3 {
font-weight: 800;
letter-spacing: -0.01em;
}
.border-thick {
border: 3px solid #000000;
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=1304, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}
+112
View File
@@ -0,0 +1,112 @@
import { readFileSync } from 'node:fs';
import { createDAVClient } from 'tsdav';
import ICAL from 'ical.js';
import type { PageServerLoad } from './$types';
const CALDAV_URL = process.env.CALDAV_URL ?? 'https://mail.rubenhensen.nl/dav/';
const CALDAV_USER = process.env.CALDAV_USER ?? 'ruben@rubenhensen.nl';
// systemd LoadCredential drops the password at $CREDENTIALS_DIRECTORY/caldav-password.
// Falls back to env for dev.
function readCalDavPassword(): string {
const dir = process.env.CREDENTIALS_DIRECTORY;
if (dir) {
try {
return readFileSync(`${dir}/caldav-password`, 'utf-8').trim();
} catch {}
}
return process.env.CALDAV_PASSWORD ?? '';
}
interface Event {
start: Date;
end: Date;
summary: string;
allDay: boolean;
}
async function fetchTodayEvents(): Promise<Event[]> {
const password = readCalDavPassword();
if (!password) return [];
const client = await createDAVClient({
serverUrl: CALDAV_URL,
credentials: { username: CALDAV_USER, password },
authMethod: 'Basic',
defaultAccountType: 'caldav'
});
const calendars = await client.fetchCalendars();
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
// Stalwart returns "207 Multi-Status / 404 No resources found" for empty
// time-range queries, which tsdav rejects as an error. Fetch everything
// and filter client-side — the calendar is small enough that this is fine.
const windowStart = ICAL.Time.fromJSDate(start, true);
const windowEnd = ICAL.Time.fromJSDate(end, true);
const events: Event[] = [];
for (const cal of calendars) {
const objects = await client.fetchCalendarObjects({ calendar: cal });
for (const obj of objects) {
if (!obj.data) continue;
const jcal = ICAL.parse(obj.data);
const comp = new ICAL.Component(jcal);
for (const vevent of comp.getAllSubcomponents('vevent')) {
const ev = new ICAL.Event(vevent);
if (ev.isRecurrenceException()) continue;
const push = (s: ICAL.Time, e: ICAL.Time) => {
events.push({
start: s.toJSDate(),
end: e.toJSDate(),
summary: ev.summary,
allDay: s.isDate
});
};
if (!ev.isRecurring()) {
if (ev.endDate.compare(windowStart) <= 0) continue;
if (ev.startDate.compare(windowEnd) >= 0) continue;
push(ev.startDate, ev.endDate);
continue;
}
const iter = ev.iterator();
let next: ICAL.Time | null;
while ((next = iter.next())) {
if (next.compare(windowEnd) >= 0) break;
const occ = ev.getOccurrenceDetails(next);
if (occ.endDate.compare(windowStart) <= 0) continue;
push(occ.startDate, occ.endDate);
}
}
}
}
return events.sort((a, b) => a.start.getTime() - b.start.getTime());
}
export const load: PageServerLoad = async () => {
let events: Event[] = [];
let error: string | null = null;
try {
events = await fetchTodayEvents();
} catch (e) {
if (e instanceof Error) {
error = e.message;
} else if (typeof e === 'object' && e !== null) {
try { error = JSON.stringify(e); } catch { error = String(e); }
} else {
error = String(e);
}
}
return {
events,
error,
renderedAt: new Date().toISOString()
};
};
+107
View File
@@ -0,0 +1,107 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function fmtTime(d: Date | string): string {
const date = typeof d === 'string' ? new Date(d) : d;
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
}
function fmtDate(d: Date | string): string {
const date = typeof d === 'string' ? new Date(d) : d;
return date.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long'
});
}
const today = new Date();
</script>
<main class="dashboard">
<header class="header">
<h1>{fmtDate(today)}</h1>
<span class="rendered">refreshed {fmtTime(data.renderedAt)}</span>
</header>
<section class="events">
<h2>Today</h2>
{#if data.error}
<p class="error">CalDAV error: {data.error}</p>
{:else if data.events.length === 0}
<p class="empty">Nothing scheduled.</p>
{:else}
<ul>
{#each data.events as ev (`${ev.start}-${ev.summary}`)}
<li class="event">
<span class="time">
{#if ev.allDay}all day{:else}{fmtTime(ev.start)}{fmtTime(ev.end)}{/if}
</span>
<span class="summary">{ev.summary}</span>
</li>
{/each}
</ul>
{/if}
</section>
</main>
<style>
.dashboard {
width: 1304px;
height: 984px;
padding: 40px 56px;
display: flex;
flex-direction: column;
gap: 32px;
}
.header {
display: flex;
justify-content: space-between;
align-items: baseline;
border-bottom: 4px solid #000;
padding-bottom: 16px;
}
.header h1 {
font-size: 56px;
}
.rendered {
font-size: 18px;
font-weight: 600;
}
.events h2 {
font-size: 36px;
margin-bottom: 16px;
}
.events ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
}
.event {
display: grid;
grid-template-columns: 220px 1fr;
gap: 24px;
padding: 12px 0;
border-bottom: 2px solid #000;
font-size: 28px;
}
.time {
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.empty, .error {
font-size: 28px;
padding: 16px 0;
}
</style>
+10
View File
@@ -0,0 +1,10 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter()
}
};
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+6
View File
@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});