import express from 'express'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const app = express(); const cache = new Map(); const TTL = 60 * 60 * 1000; // 1 hour async function cachedFetch(url) { const entry = cache.get(url); if (entry && Date.now() - entry.ts < TTL) return entry.data; const response = await fetch(url); const data = await response.json(); cache.set(url, { data, ts: Date.now() }); return data; } app.use(express.static(join(__dirname, 'public'))); app.get('/api/holidays', async (req, res) => { const { year } = req.query; try { const data = await cachedFetch(`https://api.dagsmart.se/holidays?year=${year}&weekends=false`); res.json(data); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.get('/api/bridge-days', async (req, res) => { const { year } = req.query; try { const data = await cachedFetch(`https://api.dagsmart.se/bridge-days?year=${year}`); res.json(data); } catch (err) { res.status(500).json({ error: String(err) }); } }); // ───────────────────────────────────────────────────────────────────────────── // /shouldibeworking — same work-time rules as public/app.js (see isWorkDay / // isCurrentlyWorkTime there), ported so it can be evaluated server-side. // ───────────────────────────────────────────────────────────────────────────── const WORK_START = 8 * 60; // 08:00 const LUNCH_START = 12 * 60; // 12:00 const LUNCH_END = 13 * 60; // 13:00 const WORK_END = 17 * 60; // 17:00 function formatDate(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } function isWeekend(date) { const d = date.getDay(); return d === 0 || d === 6; } // Matches app.js: all of July counts as non-work (Swedish summer holidays). function isJuly(date) { return date.getMonth() === 6; } async function isWorkDay(date, includeBridgeDays) { if (isWeekend(date)) return false; if (isJuly(date)) return false; const year = date.getFullYear(); const ds = formatDate(date); const holidays = await cachedFetch(`https://api.dagsmart.se/holidays?year=${year}&weekends=false`); if (holidays.some(h => h.date === ds)) return false; if (includeBridgeDays) { const bridgeDays = await cachedFetch(`https://api.dagsmart.se/bridge-days?year=${year}`); if (bridgeDays.some(h => h.date === ds)) return false; } return true; } async function isCurrentlyWorkTime(date, includeBridgeDays) { if (!(await isWorkDay(date, includeBridgeDays))) return false; const mins = date.getHours() * 60 + date.getMinutes(); if (mins < WORK_START || mins >= WORK_END) return false; if (mins >= LUNCH_START && mins < LUNCH_END) return false; return true; } // Minutes from `now` until the working/not-working state next flips — // the next of WORK_START/LUNCH_START/LUNCH_END/WORK_END today, or WORK_START // on the next work day if none of those are left today. Rounded up, so it // never reads 0 while a change is still seconds away. async function minutesUntilNextShift(now, includeBridgeDays) { const nowMins = now.getHours() * 60 + now.getMinutes() + now.getSeconds() / 60; if (await isWorkDay(now, includeBridgeDays)) { for (const boundary of [WORK_START, LUNCH_START, LUNCH_END, WORK_END]) { if (boundary > nowMins) return Math.ceil(boundary - nowMins); } } const cursor = new Date(now); cursor.setHours(0, 0, 0, 0); cursor.setDate(cursor.getDate() + 1); for (let i = 0; i < 60; i++) { if (await isWorkDay(cursor, includeBridgeDays)) { const target = new Date(cursor); target.setMinutes(WORK_START); return Math.ceil((target - now) / 60000); } cursor.setDate(cursor.getDate() + 1); } return null; // no work day found within 60 days — shouldn't happen } // Fixed-window rate limiter: N requests per IP per window, no dependencies. function rateLimit({ windowMs, max }) { const hits = new Map(); // ip -> { count, resetAt } return (req, res, next) => { const now = Date.now(); const entry = hits.get(req.ip); if (!entry || now > entry.resetAt) { hits.set(req.ip, { count: 1, resetAt: now + windowMs }); return next(); } if (entry.count >= max) { res.set('Retry-After', String(Math.ceil((entry.resetAt - now) / 1000))); return res.status(429).json({ error: 'rate limited' }); } entry.count++; next(); }; } app.get( '/shouldibeworking', rateLimit({ windowMs: 60 * 1000, max: 20 }), async (req, res) => { try { const includeBridgeDays = req.query.bridgeDays === 'true'; const now = new Date(); const working = await isCurrentlyWorkTime(now, includeBridgeDays); const minutesUntilChange = await minutesUntilNextShift(now, includeBridgeDays); res.json({ working, includeBridgeDays, minutesUntilChange }); } catch (err) { res.status(500).json({ error: String(err) }); } } ); const PORT = process.env.PORT || 3000; app.listen(PORT, () => console.log(`Aftersummer → http://localhost:${PORT}`));