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.
155 lines
5.4 KiB
JavaScript
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}`));
|