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.
This commit is contained in:
@@ -39,5 +39,116 @@ app.get('/api/bridge-days', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// /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}`));
|
||||
|
||||
Reference in New Issue
Block a user