Files
ulve a18e2a2455 Add /shouldibeworking endpoint, container timezone, and PowerShell CLI tracker
Ports work-time rules to the server for external polling, sets the
container TZ so schedule math matches Europe/Stockholm, and adds a
terminal-based progress tracker mirroring the web app.
2026-09-13 10:48:48 +02:00

155 lines
5.4 KiB
JavaScript

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}`));