Files
bgrollemanandClaude Sonnet 5 74284ada97 Initial commit: year overview app for Google Calendar ICS feeds
Local Node app that fetches a Google Calendar ICS URL server-side (to avoid
browser CORS restrictions) and renders a two-year overview with each day
split into morning/afternoon/evening segments, colored by emoji found at
the start of event titles (configured in tags.json).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 11:26:39 +02:00

88 lines
2.5 KiB
JavaScript

'use strict';
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { buildOverview } = require('./days.js');
const PORT = process.env.PORT || 3000;
const TAGS_PATH = path.join(__dirname, 'tags.json');
const INDEX_PATH = path.join(__dirname, 'public', 'index.html');
function readTags() {
return JSON.parse(fs.readFileSync(TAGS_PATH, 'utf8'));
}
function sendJSON(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
async function handleOverview(res, query) {
const icsUrl = query.get('url');
if (!icsUrl) return sendJSON(res, 400, { error: 'missing url parameter' });
let parsedUrl;
try {
parsedUrl = new URL(icsUrl);
} catch {
return sendJSON(res, 400, { error: 'invalid url' });
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
return sendJSON(res, 400, { error: 'url must be http or https' });
}
let icsText;
try {
const fetchRes = await fetch(icsUrl);
if (!fetchRes.ok) throw new Error(`HTTP ${fetchRes.status}`);
icsText = await fetchRes.text();
} catch (err) {
return sendJSON(res, 502, { error: `could not fetch calendar: ${err.message}` });
}
const now = new Date();
const rangeStart = `${now.getFullYear()}-01-01`;
const rangeEnd = `${now.getFullYear() + 1}-12-31`;
let overview;
try {
overview = buildOverview(icsText, readTags(), rangeStart, rangeEnd);
} catch (err) {
return sendJSON(res, 500, { error: `could not parse calendar: ${err.message}` });
}
sendJSON(res, 200, { rangeStart, rangeEnd, days: overview });
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
if (req.method === 'GET' && url.pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(INDEX_PATH));
return;
}
if (req.method === 'GET' && url.pathname === '/tags.json') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(fs.readFileSync(TAGS_PATH));
return;
}
if (req.method === 'GET' && url.pathname === '/api/default-url') {
return sendJSON(res, 200, { url: process.env.CALURL || null });
}
if (req.method === 'GET' && url.pathname === '/api/overview') {
await handleOverview(res, url.searchParams);
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found');
});
server.listen(PORT, () => {
console.log(`http://localhost:${PORT}`);
});