Files
ridebus-status/server/widget.mjs
Zakhar Timoshenko cb1977b65f feat: add RideBus status widget integration
- Implemented StatusWidget component for embedding RideBus service status.
- Created widget-client.js for client-side interaction with the status widget.
- Developed install-widget-integrations script to automate integration into PWA, website, and admin projects.
- Introduced server-side widget rendering in widget.mjs for generating status widget HTML.
- Added comprehensive tests for widget functionality and integration.
2026-10-14 00:24:40 +03:00

60 lines
6.5 KiB
JavaScript

import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Icon24ArrowUpRightOutline } from '@vkontakte/icons';
import { combinedStatus, maintenanceEnd } from './maintenance.mjs';
import { overallStatus } from './snapshot.mjs';
const arrow = renderToStaticMarkup(createElement(Icon24ArrowUpRightOutline, { width: 16, height: 16 }));
const states = ['operational', 'degraded', 'down', 'unknown', 'maintenance'];
export const widgetCopy = {
ru: { title: 'Статус сервисов RideBus', operational: 'Сервисы работают', degraded: 'Есть перебои', down: 'Сервисы недоступны', unknown: 'Нет свежих данных', maintenance: 'Плановые работы' },
en: { title: 'RideBus service status', operational: 'Services operational', degraded: 'Service disruption', down: 'Services unavailable', unknown: 'No fresh status data', maintenance: 'Scheduled maintenance' },
};
export function widgetOptions(params, services, { embed = false } = {}) {
const allowed = embed ? ['services', 'lang', 'theme'] : ['services'];
for (const key of params.keys()) if (!allowed.includes(key) || params.getAll(key).length !== 1) throw new Error('Invalid widget option');
const raw = params.get('services');
const ids = raw === null ? services.map(service => service.id) : raw.split(',');
if (!ids.length || new Set(ids).size !== ids.length || ids.some(id => !services.some(service => service.id === id))) throw new Error('Unknown or duplicate widget service');
const locale = params.get('lang') || 'ru', theme = params.get('theme') || 'auto';
if (!['ru', 'en'].includes(locale) || !['light', 'dark', 'auto'].includes(theme)) throw new Error('Invalid widget locale or theme');
return { serviceIds: ids, locale, theme };
}
export function widgetSummary(store, config, serviceIds, now = Date.now()) {
const deadlines = [];
const services = config.services.filter(service => serviceIds.includes(service.id)).map(service => {
const state = store.state(service), scenario = store.scenario(service);
const availabilityUntil = state ? state.checked_at + config.staleAfterSeconds * 1000 + 1 : 0;
const scenarioUntil = scenario ? scenario.checked_at + config.scenarioIntervalSeconds * 3000 + 1 : 0;
if (availabilityUntil > now) deadlines.push(availabilityUntil);
if (scenarioUntil > now) deadlines.push(scenarioUntil);
const availability = availabilityUntil > now ? state.status : 'unknown';
const journey = service.scenario ? { status: scenarioUntil > now ? scenario.status : 'unknown' } : null;
return { id: service.id, status: combinedStatus(availability, journey, store.inMaintenance(service, now)) };
});
for (const window of store.maintenances()) {
if (window.cancelledAt !== null || window.serviceIds.length && !window.serviceIds.some(id => serviceIds.includes(id))) continue;
for (const boundary of [window.startsAt, maintenanceEnd(window)]) if (boundary > now) deadlines.push(boundary);
}
return { version: 1, generatedAt: now, expiresAt: Math.min(now + 90000, ...deadlines), overall: overallStatus(services), services };
}
export function widgetDocument(summary, { serviceIds, locale, theme }) {
const copy = widgetCopy[locale];
const payload = JSON.stringify({ summary, serviceIds, locale, copy, states }).replaceAll('<', '\\u003c');
return `<!doctype html><html lang="${locale}" data-theme="${theme}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${copy.title}</title><style>
@font-face{font-family:Manrope;font-style:normal;font-weight:200 800;font-display:swap;src:url('/widget/manrope-latin.woff2') format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:Manrope;font-style:normal;font-weight:200 800;font-display:swap;src:url('/widget/manrope-cyrillic.woff2') format('woff2');unicode-range:U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}
*{box-sizing:border-box}html,body{margin:0;background:transparent;color-scheme:light;font-family:Manrope,system-ui,sans-serif}
:root{--neutral-bg:#f0f1f5;--neutral:#636b80;--green-bg:#edf6e4;--green:#477545;--amber-bg:#fff1d8;--amber:#936b1a;--red-bg:#ffe9e6;--red:#b34a43;--blue-bg:#eeecfc;--blue:#6263aa}
html[data-theme=dark]{color-scheme:dark;--neutral-bg:#202636;--neutral:#bac2d5;--green-bg:#213425;--green:#abd296;--amber-bg:#40331f;--amber:#f1d18f;--red-bg:#402829;--red:#efa5a0;--blue-bg:#2a2d49;--blue:#c0bdf0}
@media(prefers-color-scheme:dark){html[data-theme=auto]{color-scheme:dark;--neutral-bg:#202636;--neutral:#bac2d5;--green-bg:#213425;--green:#abd296;--amber-bg:#40331f;--amber:#f1d18f;--red-bg:#402829;--red:#efa5a0;--blue-bg:#2a2d49;--blue:#c0bdf0}}
.widget{display:flex;align-items:center;gap:9px;width:100%;height:44px;padding:0 12px;border:1px solid transparent;border-radius:14px;background:var(--neutral-bg);color:var(--neutral);text-decoration:none;font-size:13px;font-weight:700;transition:background-color 160ms ease,color 160ms ease,box-shadow 160ms ease;animation:appear 160ms ease-out}
.widget[data-status=operational]{background:var(--green-bg);color:var(--green)}.widget[data-status=degraded]{background:var(--amber-bg);color:var(--amber)}.widget[data-status=down]{background:var(--red-bg);color:var(--red)}.widget[data-status=maintenance]{background:var(--blue-bg);color:var(--blue)}
.widget:hover{box-shadow:inset 0 0 0 1px currentColor}.widget:focus-visible{outline:2px solid currentColor;outline-offset:-3px}.dot{width:7px;height:7px;flex-shrink:0;border-radius:50%;background:currentColor;box-shadow:0 0 0 3px color-mix(in srgb,currentColor 12%,transparent)}.label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.widget svg{margin-left:auto;flex-shrink:0;transition:transform 160ms cubic-bezier(.2,.75,.25,1)}.widget:hover svg{transform:translate(1px,-1px)}
@keyframes appear{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:translateY(0)}}@media(prefers-reduced-motion:reduce){.widget,.widget svg{animation:none;transition:none}.widget:hover svg{transform:none}}
</style></head><body><a class="widget" data-status="unknown" href="/?lang=${locale}" target="_blank" rel="noopener noreferrer" aria-label="${copy.title}"><span class="dot" aria-hidden="true"></span><span class="label" role="status" aria-live="polite">${copy.unknown}</span>${arrow}</a><script type="application/json" id="widget-data">${payload}</script><script src="/widget-client.js" defer></script></body></html>`;
}