From 364af721cce102da0afd29f2e35a22ff49f9e80a Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Sun, 15 Mar 2026 10:38:44 +0200 Subject: [PATCH 01/11] =?UTF-8?q?Added=20day=20overview=20for=20the=20serv?= =?UTF-8?q?ice=E2=80=99s=20bar=20chart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ServiceDayTooltip.ts | 237 ++++++++++++++++++++++++++++ src/components/ServiceRow.ts | 98 ++++++++---- src/models/Notice.ts | 19 ++- 3 files changed, 324 insertions(+), 30 deletions(-) create mode 100644 src/components/ServiceDayTooltip.ts diff --git a/src/components/ServiceDayTooltip.ts b/src/components/ServiceDayTooltip.ts new file mode 100644 index 0000000..cc0cc27 --- /dev/null +++ b/src/components/ServiceDayTooltip.ts @@ -0,0 +1,237 @@ +import { html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { styleMap } from "lit/directives/style-map.js"; +import { Component } from "./Component"; +import { Notice } from "../models/Notice"; +import { ServiceRow } from "./ServiceRow"; +import { NoticeStatus } from "../models/NoticeStatus"; +import { ServiceStatus } from "../models/ServiceStatus"; + +@customElement("service-day-tooltip") +export class ServiceDayTooltip extends Component { + @property({ type: Array }) + public notices: Notice[]; + + @property({ type: Object }) + public day: Date; + + @state() + public started: Date | null; + + private static readonly STATUS_NAMES: Record = { + [NoticeStatus.INCIDENT_IDENTIFIED]: "Identified", + [NoticeStatus.INCIDENT_INVESTIGATING]: "Investigating", + [NoticeStatus.INCIDENT_MONITORING]: "Monitoring", + [NoticeStatus.INCIDENT_RESOLVED]: "Resolved", + [NoticeStatus.MAINTENANCE_NOT_STARTED_YET]: "Not started yet", + [NoticeStatus.MAINTENANCE_IN_PROGRESS]: "In progress", + [NoticeStatus.MAINTENANCE_COMPLETED]: "Completed", + }; + + public constructor(notices: Notice[], day: Date, started: Date | null) { + super(); + this.notices = notices.sort((a, b) => + a.started.getTime() - b.started.getTime() + ); + this.day = day; + this.started = started; + } + + private static duration(ms: number): { iso: string; human: string } { + const totalSeconds = Math.floor(ms / 1000); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + + const iso = "P" + + (days ? `${days}D` : "") + + (hours || minutes ? "T" : "") + + (hours ? `${hours}H` : "") + + (minutes ? `${minutes}M` : ""); + + const parts = [ + days && `${days} ${days === 1 ? "day" : "days"}`, + hours && `${hours} ${hours === 1 ? "hour" : "hours"}`, + minutes && `${minutes} ${minutes === 1 ? "minute" : "minutes"}`, + ].filter(Boolean) as string[]; + + const human = parts.length > 1 + ? parts.slice(0, -1).join(", ") + " and " + parts.at(-1) + : parts[0] ?? "0 minutes"; + + return { iso, human }; + } + + public override render() { + const now = new Date(); + const days = (n: number) => + new Date(now.getTime() - n * 86400000).toISOString().split("T")[0]; + const tomorrow = new Date(this.day); + tomorrow.setDate(tomorrow.getDate() + 1); + + return html` +
+
+ + + + + + + +
+ ${this.notices.length > 0 + ? html` +
    + ${this.notices.map((n) => { + const style = ServiceRow.STATUS_STYLES[n.impact]; + const duration = ServiceDayTooltip.duration(n.duration()); + return html` +
  • +
    + + ${style.label} +
    + ${n + .name} + +
    +

    ${n.ended === null + ? ServiceDayTooltip.STATUS_NAMES[n.status] + : html` + Resolved after + `}

    +
  • + `; + })} +
+ ` + : html` +
+ +

${this.started === null || + this.started.getTime() < tomorrow.getTime() + ? ServiceRow.STATUS_STYLES[ServiceStatus.OPERATIONAL].label + : "Not monitored"}

+
+ `} +
+ +
+
+ `; + } +} diff --git a/src/components/ServiceRow.ts b/src/components/ServiceRow.ts index 6a64527..0472b11 100644 --- a/src/components/ServiceRow.ts +++ b/src/components/ServiceRow.ts @@ -6,12 +6,13 @@ import { Component } from "./Component"; import { Service } from "../models/Service"; import { ServiceStatus } from "../models/ServiceStatus"; import { Notice } from "../models/Notice"; +import { ServiceDayTooltip } from "./ServiceDayTooltip"; @customElement("service-row") export class ServiceRow extends Component { private static readonly MD = markdownit(); - protected static readonly STATUS_STYLES: Record< + public static readonly STATUS_STYLES: Record< ServiceStatus, { color: string; bar: string; label: string; icon: string } > = { @@ -25,28 +26,28 @@ export class ServiceRow extends Component { [ServiceStatus.UNDER_MAINTENANCE]: { color: "fill-indigo-400", bar: "bg-blue-400", - label: "Under Maintenance", + label: "Under maintenance", icon: ``, }, [ServiceStatus.DEGRADED_PERFORMANCE]: { color: "fill-amber-400", bar: "bg-amber-400", - label: "Degraded Performance", + label: "Degraded performance", icon: ``, }, [ServiceStatus.PARTIAL_OUTAGE]: { color: "fill-orange-400", bar: "bg-orange-400", - label: "Partial Outage", + label: "Partial outage", icon: ``, }, [ServiceStatus.MAJOR_OUTAGE]: { color: "fill-red-400", bar: "bg-red-400", - label: "Major Outage", + label: "Major outage", icon: ``, }, @@ -72,6 +73,63 @@ export class ServiceRow extends Component { this.notices = notices; } + private static bar( + day: Date, + notices: Notice[], + started: Date | null, + ): TemplateResult { + const tomorrow = new Date(day); + tomorrow.setDate(tomorrow.getDate() + 1); + + if (started !== null && started.getTime() > tomorrow.getTime()) { + return html` +
+
+
+ ${new ServiceDayTooltip( + notices, + day, + started, + )} +
+ `; + } + + if (notices.length === 0) { + return html` +
+
+
+ ${new ServiceDayTooltip(notices, day, started)} +
+ `; + } + + const worst = notices.reduce((w, n) => n.impact > w.impact ? n : w); + return html` +
+
+
+ ${new ServiceDayTooltip(notices, day, started)} +
+ `; + } + public uptime(days: number): number { const now = Date.now(); const periodStart = now - days * 86400000; @@ -110,11 +168,11 @@ export class ServiceRow extends Component { protected noticesForDay(day: Date): Notice[] { const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0); - const dayEnd = new Date(day); - dayEnd.setHours(23, 59, 59, 999); + const nextDayStart = new Date(dayStart); + nextDayStart.setDate(dayStart.getDate() + 1); return this.notices.filter((n) => - n.started.getTime() <= dayEnd.getTime() && + n.started.getTime() < nextDayStart.getTime() && (n.ended === null || n.ended.getTime() >= dayStart.getTime()) ); } @@ -132,7 +190,7 @@ export class ServiceRow extends Component { > ${style.label} `; @@ -160,7 +218,7 @@ export class ServiceRow extends Component {
${unsafeHTML( ServiceRow.MD.render(this.service.description), @@ -198,27 +256,13 @@ export class ServiceRow extends Component { const now = new Date(); const bars = Array.from({ length: 90 }, (_, i) => { const day = new Date(now.getTime() - (89 - i) * 86400000); - if ((this.service.started?.getTime() ?? 0) > day.getTime()) { - return html` -
- `; - } - const notices = this.noticesForDay(day); - if (notices.length === 0) { - return html` -
- `; - } - const worst = notices.reduce((w, n) => n.impact > w.impact ? n : w); - return html` -
- `; + day.setHours(0, 0, 0, 0); + return ServiceRow.bar(day, this.noticesForDay(day), this.service.started); }); return html`
${bars}
diff --git a/src/models/Notice.ts b/src/models/Notice.ts index 2ac6148..094889a 100644 --- a/src/models/Notice.ts +++ b/src/models/Notice.ts @@ -13,7 +13,7 @@ export abstract class Notice { public readonly ended: Date | null; public readonly impact: ServiceStatus; - public constructor( + protected constructor( id: string, name: string, components: BaseComponent[], @@ -35,7 +35,20 @@ export abstract class Notice { this.impact = impact; } - public get duration(): number { - return (this.ended?.getTime() ?? Date.now()) - this.started.getTime(); + public duration(day?: Date): number { + if (day === undefined) { + return (this.ended?.getTime() ?? Date.now()) - this.started.getTime(); + } + + const dayStart = new Date(day); + dayStart.setHours(0, 0, 0, 0); + const nextDayStart = new Date(dayStart); + nextDayStart.setDate(dayStart.getDate() + 1); + + return Math.max( + 0, + Math.min(this.ended?.getTime() ?? Date.now(), nextDayStart.getTime()) - + Math.max(this.started.getTime(), dayStart.getTime()), + ); } } From b5c98ef7613308592015c8bf9ab0caa4bb868be7 Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Sun, 15 Mar 2026 15:06:22 +0200 Subject: [PATCH 02/11] use plural for notices URL namespace --- src/components/ServiceDayTooltip.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/ServiceDayTooltip.ts b/src/components/ServiceDayTooltip.ts index cc0cc27..7d62780 100644 --- a/src/components/ServiceDayTooltip.ts +++ b/src/components/ServiceDayTooltip.ts @@ -121,7 +121,8 @@ export class ServiceDayTooltip extends Component { class="absolute top-full left-0 z-50 mt-1 block w-max rounded-lg bg-neutral-800 px-2 py-1 text-sm leading-normal font-medium text-white shadow-lg ring-1 ring-white/10 ring-inset group-[:not(:hover)]/indicator:sr-only lg:-top-1 lg:-left-1 lg:mt-0 lg:-translate-x-full" >${style.label}
- ${n + ${n .name} From 90899daf0f7e986cfd67b5745717fe211d750cc0 Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Sun, 15 Mar 2026 15:06:48 +0200 Subject: [PATCH 03/11] handle notices having direct untranslated `name` --- src/api/Notice.ts | 2 +- src/components/pages/HomePage.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api/Notice.ts b/src/api/Notice.ts index 7224454..feef04f 100644 --- a/src/api/Notice.ts +++ b/src/api/Notice.ts @@ -3,7 +3,7 @@ import { BaseComponent } from "./BaseComponent"; export interface Notice { id: string; - name: { default: string }; + name: { default: string } | string; status: string; components: BaseComponent[]; updates: NoticeUpdate[]; diff --git a/src/components/pages/HomePage.ts b/src/components/pages/HomePage.ts index b9aded2..acc261a 100644 --- a/src/components/pages/HomePage.ts +++ b/src/components/pages/HomePage.ts @@ -68,7 +68,7 @@ export class HomePage extends Page { for (const i of incidents) { const incident = new Incident( i.id, - i.name.default, + typeof i.name === "string" ? i.name : i.name.default, i.components, i.updates.map((u) => new NoticeUpdate( @@ -96,7 +96,7 @@ export class HomePage extends Page { for (const m of maintenances) { const maintenance = new Maintenance( m.id, - m.name.default, + typeof m.name === "string" ? m.name : m.name.default, m.components, m.updates.map((u) => new NoticeUpdate( From 1ef35779a6cd312d3bc46dcd678f5693ea23bc55 Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Sun, 15 Mar 2026 15:07:41 +0200 Subject: [PATCH 04/11] use maintenance duration as estimation of resolution time when not avail --- src/components/pages/HomePage.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/pages/HomePage.ts b/src/components/pages/HomePage.ts index acc261a..7c4f4b7 100644 --- a/src/components/pages/HomePage.ts +++ b/src/components/pages/HomePage.ts @@ -108,7 +108,11 @@ export class HomePage extends Page { ), Maintenance.parseStatus(m.status), new Date(m.start), - new Date(m.resolved), + new Date( + m.resolved === null + ? new Date(m.start).getTime() + (m.duration * 60000) + : m.resolved, + ), ); this.notices.push(maintenance); From 21ac940540c9bb587a98b2f477b265b76472d4e6 Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Sun, 15 Mar 2026 15:07:59 +0200 Subject: [PATCH 05/11] prevent reporting as OPERATIONAL past the current time --- src/components/ServiceDayTooltip.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/ServiceDayTooltip.ts b/src/components/ServiceDayTooltip.ts index 7d62780..4c590a7 100644 --- a/src/components/ServiceDayTooltip.ts +++ b/src/components/ServiceDayTooltip.ts @@ -196,7 +196,10 @@ export class ServiceDayTooltip extends Component { let cursor = monitorStart; for (const n of clampedNotices) { if (cursor < n.start) { - greenSegments.push({ start: cursor, end: n.start }); + greenSegments.push({ + start: cursor, + end: Math.min(n.start, monitorEnd), + }); } cursor = Math.max(cursor, n.end); } From 56d765c66987cc495a692ff96a92d611df167b9c Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Sun, 15 Mar 2026 15:09:21 +0200 Subject: [PATCH 06/11] improved rendering of planned maintenance --- src/components/ServiceDayTooltip.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/ServiceDayTooltip.ts b/src/components/ServiceDayTooltip.ts index 4c590a7..b43a2e3 100644 --- a/src/components/ServiceDayTooltip.ts +++ b/src/components/ServiceDayTooltip.ts @@ -23,7 +23,7 @@ export class ServiceDayTooltip extends Component { [NoticeStatus.INCIDENT_INVESTIGATING]: "Investigating", [NoticeStatus.INCIDENT_MONITORING]: "Monitoring", [NoticeStatus.INCIDENT_RESOLVED]: "Resolved", - [NoticeStatus.MAINTENANCE_NOT_STARTED_YET]: "Not started yet", + [NoticeStatus.MAINTENANCE_NOT_STARTED_YET]: "Planned", [NoticeStatus.MAINTENANCE_IN_PROGRESS]: "In progress", [NoticeStatus.MAINTENANCE_COMPLETED]: "Completed", }; @@ -128,7 +128,8 @@ export class ServiceDayTooltip extends Component { >
-

${n.ended === null +

${n.ended === null || + n.started.getTime() > now.getTime() ? ServiceDayTooltip.STATUS_NAMES[n.status] : html` Resolved after

Date: Sun, 15 Mar 2026 15:40:26 +0200 Subject: [PATCH 08/11] change maintenance colours fully to blue --- src/components/ServiceRow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ServiceRow.ts b/src/components/ServiceRow.ts index 128c87d..5b4cb3d 100644 --- a/src/components/ServiceRow.ts +++ b/src/components/ServiceRow.ts @@ -24,7 +24,7 @@ export class ServiceRow extends Component { ``, }, [ServiceStatus.UNDER_MAINTENANCE]: { - color: "fill-indigo-400", + color: "fill-blue-400", bar: "bg-blue-400", label: "Under maintenance", icon: From 479fbab4a04c41cb7c1cedabda6d6f5f435953ce Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Sun, 15 Mar 2026 16:44:01 +0200 Subject: [PATCH 09/11] permit `style` attribute in CSP --- vercel.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vercel.json b/vercel.json index 2136ba2..837af16 100644 --- a/vercel.json +++ b/vercel.json @@ -11,7 +11,7 @@ "headers": [ { "key": "Content-Security-Policy", - "value": "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https://wsrv.nl; connect-src 'self' https://api.instatus.com;" + "value": "default-src 'none'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' https://wsrv.nl; connect-src 'self' https://api.instatus.com;" }, { "key": "Cache-Control", From 3567cd08ba46d53edae18e7728a0559b29c5964a Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Sun, 15 Mar 2026 16:54:27 +0200 Subject: [PATCH 10/11] fix bars rounding on smaller screens --- src/components/ServiceRow.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/ServiceRow.ts b/src/components/ServiceRow.ts index 5b4cb3d..d89a070 100644 --- a/src/components/ServiceRow.ts +++ b/src/components/ServiceRow.ts @@ -91,7 +91,7 @@ export class ServiceRow extends Component { class="group/bar flex" >
${new ServiceDayTooltip( @@ -111,7 +111,7 @@ export class ServiceRow extends Component {
${new ServiceDayTooltip(notices, day, started)} @@ -126,7 +126,7 @@ export class ServiceRow extends Component { >
${new ServiceDayTooltip(notices, day, started)} From 4daa140f345c7b77ff55434321774973b0f4b164 Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Sun, 15 Mar 2026 16:56:24 +0200 Subject: [PATCH 11/11] use more concentrated shadow (md instead of lg) --- src/components/AppHeader.ts | 2 +- src/components/ServiceDayTooltip.ts | 4 ++-- src/components/ServiceRow.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/AppHeader.ts b/src/components/AppHeader.ts index 11a3501..752973d 100644 --- a/src/components/AppHeader.ts +++ b/src/components/AppHeader.ts @@ -70,7 +70,7 @@ export class AppHeader extends Component { Menu