From 7f03be885bc57a7db899a07735f94bf23eae7d11 Mon Sep 17 00:00:00 2001
From: Zefir Kirilov
Date: Tue, 17 Mar 2026 20:35:28 +0200
Subject: [PATCH] Show active and pending notices
---
src/EnumMappings.ts | 55 ++++++++++++
src/Time.ts | 134 ++++++++++++++++++++++++++++
src/api/NoticeUpdate.ts | 2 +-
src/components/ActiveNotices.ts | 96 ++++++++++++++++++++
src/components/NoticeOverview.ts | 97 ++++++++++++++++++++
src/components/ServiceDayTooltip.ts | 81 +++++------------
src/components/ServiceRow.ts | 74 +++------------
src/components/UpdatesFeed.ts | 58 ++++++++++++
src/components/pages/HomePage.ts | 8 +-
src/models/Maintenance.ts | 3 +
10 files changed, 485 insertions(+), 123 deletions(-)
create mode 100644 src/EnumMappings.ts
create mode 100644 src/Time.ts
create mode 100644 src/components/ActiveNotices.ts
create mode 100644 src/components/NoticeOverview.ts
create mode 100644 src/components/UpdatesFeed.ts
diff --git a/src/EnumMappings.ts b/src/EnumMappings.ts
new file mode 100644
index 0000000..dcc28ff
--- /dev/null
+++ b/src/EnumMappings.ts
@@ -0,0 +1,55 @@
+import { ServiceStatus } from "./models/ServiceStatus";
+import { NoticeStatus } from "./models/NoticeStatus";
+
+export namespace EnumMappings {
+ export const SERVICE_STATUS_STYLES: Record<
+ ServiceStatus,
+ { color: string; bar: string; label: string; icon: string }
+ > = {
+ [ServiceStatus.OPERATIONAL]: {
+ color: "fill-emerald-400",
+ bar: "bg-emerald-500",
+ label: "Operational",
+ icon:
+ ``,
+ },
+ [ServiceStatus.UNDER_MAINTENANCE]: {
+ color: "fill-blue-400",
+ bar: "bg-blue-400",
+ label: "Under maintenance",
+ icon:
+ ``,
+ },
+ [ServiceStatus.DEGRADED_PERFORMANCE]: {
+ color: "fill-amber-400",
+ bar: "bg-amber-400",
+ label: "Degraded performance",
+ icon:
+ ``,
+ },
+ [ServiceStatus.PARTIAL_OUTAGE]: {
+ color: "fill-orange-400",
+ bar: "bg-orange-400",
+ label: "Partial outage",
+ icon:
+ ``,
+ },
+ [ServiceStatus.MAJOR_OUTAGE]: {
+ color: "fill-red-400",
+ bar: "bg-red-400",
+ label: "Major outage",
+ icon:
+ ``,
+ },
+ };
+
+ export const NOTICE_STATUS_NAMES: Record = {
+ [NoticeStatus.INCIDENT_IDENTIFIED]: "Identified",
+ [NoticeStatus.INCIDENT_INVESTIGATING]: "Investigating",
+ [NoticeStatus.INCIDENT_MONITORING]: "Monitoring",
+ [NoticeStatus.INCIDENT_RESOLVED]: "Resolved",
+ [NoticeStatus.MAINTENANCE_NOT_STARTED_YET]: "Planned",
+ [NoticeStatus.MAINTENANCE_IN_PROGRESS]: "In progress",
+ [NoticeStatus.MAINTENANCE_COMPLETED]: "Completed",
+ };
+}
diff --git a/src/Time.ts b/src/Time.ts
new file mode 100644
index 0000000..8654637
--- /dev/null
+++ b/src/Time.ts
@@ -0,0 +1,134 @@
+export namespace Time {
+ export class Duration {
+ public readonly ms: number;
+
+ public constructor(ms: number) {
+ this.ms = ms;
+ }
+
+ private getParts() {
+ const totalSeconds = Math.floor(this.ms / 1000);
+ return {
+ days: Math.floor(totalSeconds / 86400),
+ hours: Math.floor((totalSeconds % 86400) / 3600),
+ minutes: Math.floor((totalSeconds % 3600) / 60),
+ };
+ }
+
+ public toISOString() {
+ const { days, hours, minutes } = this.getParts();
+
+ return "P" +
+ (days ? `${days}D` : "") +
+ (hours || minutes ? "T" : "") +
+ (hours ? `${hours}H` : "") +
+ (minutes ? `${minutes}M` : "");
+ }
+
+ public toString() {
+ const { days, hours, minutes } = this.getParts();
+
+ const parts = [
+ days && `${days} ${days === 1 ? "day" : "days"}`,
+ hours && `${hours} ${hours === 1 ? "hour" : "hours"}`,
+ minutes && `${minutes} ${minutes === 1 ? "minute" : "minutes"}`,
+ ].filter(Boolean) as string[];
+
+ return parts.length > 1
+ ? parts.slice(0, -1).join(", ") + " and " + parts.at(-1)
+ : parts[0] ?? "0 minutes";
+ }
+ }
+
+ export class Day {
+ public readonly date: Date;
+
+ public constructor(date: Date) {
+ this.date = new Date(date.getTime());
+ this.date.setHours(0, 0, 0, 0);
+ }
+
+ public static today() {
+ return new Day(new Date());
+ }
+
+ public is(date: Date): boolean;
+ public is(day: Day): boolean;
+ public is(d: Date | Day): boolean {
+ return d instanceof Day
+ ? d.date.getTime() === this.date.getTime()
+ : new Day(d).date.getTime() === this.date.getTime();
+ }
+
+ public toISOString() {
+ const year = this.date.getFullYear();
+ const month = String(this.date.getMonth() + 1).padStart(2, "0");
+ const day = String(this.date.getDate()).padStart(2, "0");
+
+ return `${year}-${month}-${day}`;
+ }
+
+ public toString() {
+ return this.date.toLocaleString(undefined, {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
+ }
+
+ public add(days: number) {
+ const newDate = new Date(this.date.getTime());
+ newDate.setDate(this.date.getDate() + days);
+ return new Day(newDate);
+ }
+
+ public subtract(days: number) {
+ const newDate = new Date(this.date.getTime());
+ newDate.setDate(this.date.getDate() - days);
+ return new Day(newDate);
+ }
+
+ public next() {
+ return this.add(1);
+ }
+
+ public previous() {
+ return this.subtract(1);
+ }
+
+ public getTime() {
+ return this.date.getTime();
+ }
+ }
+
+ export class DateTime {
+ public readonly date: Date;
+
+ public constructor(date: Date) {
+ this.date = new Date(date.getTime());
+ }
+
+ public static now() {
+ return new DateTime(new Date());
+ }
+
+ public getDay() {
+ return new Day(this.date);
+ }
+
+ public toISOString() {
+ return this.date.toISOString();
+ }
+
+ public toString() {
+ return this.getDay().toString() + " at " + this.toTimeString();
+ }
+
+ public toTimeString() {
+ return this.date.toLocaleTimeString(undefined, {
+ hour: "numeric",
+ minute: "numeric",
+ });
+ }
+ }
+}
diff --git a/src/api/NoticeUpdate.ts b/src/api/NoticeUpdate.ts
index 58c5a41..0936359 100644
--- a/src/api/NoticeUpdate.ts
+++ b/src/api/NoticeUpdate.ts
@@ -2,6 +2,6 @@ export interface NoticeUpdate {
id: string;
started: string;
status: string;
- message: { default: string };
+ message: { default: string } | string;
attachments: string[];
}
diff --git a/src/components/ActiveNotices.ts b/src/components/ActiveNotices.ts
new file mode 100644
index 0000000..c27683f
--- /dev/null
+++ b/src/components/ActiveNotices.ts
@@ -0,0 +1,96 @@
+import { customElement, property } from "lit/decorators.js";
+import { Component } from "./Component";
+import { Notice } from "../models/Notice";
+import { Maintenance } from "../models/Maintenance";
+import { html, nothing } from "lit";
+import { EnumMappings } from "../EnumMappings";
+import { ServiceStatus } from "../models/ServiceStatus";
+import { NoticeOverview } from "./NoticeOverview";
+
+@customElement("active-notices")
+export class ActiveNotices extends Component {
+ private static readonly MAINTENANCE_COLLAPSE_DAYS = 3;
+
+ @property({ type: Array })
+ public readonly notices: Notice[];
+
+ public constructor(notices: Notice[]) {
+ super();
+ this.notices = notices;
+ }
+
+ public maintenances(): Maintenance[] {
+ const threshold = new Date(
+ Date.now() + ActiveNotices.MAINTENANCE_COLLAPSE_DAYS * 86400000,
+ );
+ return this.notices.filter((n) =>
+ n instanceof Maintenance && n.started > threshold
+ ) as Maintenance[];
+ }
+
+ public active(): Notice[] {
+ const threshold = new Date(
+ Date.now() + ActiveNotices.MAINTENANCE_COLLAPSE_DAYS * 86400000,
+ );
+ return this.notices.filter((n) =>
+ n.ended === null || (n.ended > new Date() && n.started <= threshold)
+ );
+ }
+
+ public override render() {
+ const active = this.active();
+ const maintenances = this.maintenances();
+ return html`
+ ${active.length === 0 ? nothing : html`
+
+ ${active.map((n) =>
+ html`
+ - ${new NoticeOverview(n)}
+ `
+ )}
+
+ `} ${maintenances.length === 0 ? nothing : html`
+
+
+
+
+
+ ${EnumMappings
+ .SERVICE_STATUS_STYLES[ServiceStatus.UNDER_MAINTENANCE]
+ .label}
+
+
+
+ ${maintenances.length === 1
+ ? "One maintenance is"
+ : `${maintenances.length} maintenance periods are`} scheduled
+
+
+
+ ${maintenances.map((n) =>
+ html`
+ - ${new NoticeOverview(n)}
+ `
+ )}
+
+
+ `}
+ `;
+ }
+}
diff --git a/src/components/NoticeOverview.ts b/src/components/NoticeOverview.ts
new file mode 100644
index 0000000..6aad771
--- /dev/null
+++ b/src/components/NoticeOverview.ts
@@ -0,0 +1,97 @@
+import { html, nothing } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { Component } from "./Component";
+import { Notice } from "../models/Notice";
+import { EnumMappings } from "../EnumMappings";
+import { Time } from "../Time";
+import { UpdatesFeed } from "./UpdatesFeed";
+import { Maintenance } from "../models/Maintenance";
+
+@customElement("notice-overview")
+export class NoticeOverview extends Component {
+ @state()
+ private notice: Notice;
+
+ public constructor(notice: Notice) {
+ super();
+ this.notice = notice;
+ }
+
+ public override render() {
+ const start = new Time.DateTime(this.notice.started);
+ const end = this.notice.ended === null
+ ? null
+ : new Time.DateTime(this.notice.ended);
+ const duration = new Time.Duration(this.notice.duration());
+
+ return html`
+
+
+
+
+
+ ${this.notice.name}
+
+
+
+
+ ${EnumMappings
+ .SERVICE_STATUS_STYLES[
+ this.notice.impact
+ ]
+ .label}
+
+
+
+ ${this.notice instanceof Maintenance && end !== null
+ ? html`
+
+ Scheduled for
+
+ –
+
+
+ `
+ : nothing}
+
+
+
+ ${new UpdatesFeed(this.notice.updates)}
+
+ `;
+ }
+}
diff --git a/src/components/ServiceDayTooltip.ts b/src/components/ServiceDayTooltip.ts
index f2b6c39..1e2ecde 100644
--- a/src/components/ServiceDayTooltip.ts
+++ b/src/components/ServiceDayTooltip.ts
@@ -3,9 +3,9 @@ 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";
+import { Time } from "../Time";
+import { EnumMappings } from "../EnumMappings";
@customElement("service-day-tooltip")
export class ServiceDayTooltip extends Component {
@@ -13,22 +13,12 @@ export class ServiceDayTooltip extends Component {
public notices: Notice[];
@property({ type: Object })
- public day: Date;
+ public day: Time.Day;
@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]: "Planned",
- [NoticeStatus.MAINTENANCE_IN_PROGRESS]: "In progress",
- [NoticeStatus.MAINTENANCE_COMPLETED]: "Completed",
- };
-
- public constructor(notices: Notice[], day: Date, started: Date | null) {
+ public constructor(notices: Notice[], day: Time.Day, started: Date | null) {
super();
this.notices = notices.sort((a, b) =>
a.started.getTime() - b.started.getTime()
@@ -37,37 +27,11 @@ export class ServiceDayTooltip extends Component {
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);
+ const tomorrow = this.day.next();
return html`
${this.day.toLocaleString(undefined, {
- month: "long",
- day: "numeric",
- year: "numeric",
- })}
+ >${this.day.toString()}
`}
@@ -195,7 +158,7 @@ export class ServiceDayTooltip extends Component {
monitorStart,
),
end: Math.max(n.started.getTime(), this.day.getTime()) +
- n.duration(this.day),
+ n.duration(this.day.date),
impact: n.impact,
}))
.filter((n) => n.start < n.end);
@@ -218,8 +181,8 @@ export class ServiceDayTooltip extends Component {
return html`
${greenSegments.map((s) =>
html`
-
`
diff --git a/src/components/ServiceRow.ts b/src/components/ServiceRow.ts
index 72c2218..ea30fdf 100644
--- a/src/components/ServiceRow.ts
+++ b/src/components/ServiceRow.ts
@@ -7,52 +7,13 @@ import { Service } from "../models/Service";
import { ServiceStatus } from "../models/ServiceStatus";
import { Notice } from "../models/Notice";
import { ServiceDayTooltip } from "./ServiceDayTooltip";
+import { Time } from "../Time";
+import { EnumMappings } from "../EnumMappings";
@customElement("service-row")
export class ServiceRow extends Component {
private static readonly MD = markdownit();
- public static readonly STATUS_STYLES: Record<
- ServiceStatus,
- { color: string; bar: string; label: string; icon: string }
- > = {
- [ServiceStatus.OPERATIONAL]: {
- color: "fill-emerald-400",
- bar: "bg-emerald-500",
- label: "Operational",
- icon:
- ``,
- },
- [ServiceStatus.UNDER_MAINTENANCE]: {
- color: "fill-blue-400",
- bar: "bg-blue-400",
- label: "Under maintenance",
- icon:
- ``,
- },
- [ServiceStatus.DEGRADED_PERFORMANCE]: {
- color: "fill-amber-400",
- bar: "bg-amber-400",
- label: "Degraded performance",
- icon:
- ``,
- },
- [ServiceStatus.PARTIAL_OUTAGE]: {
- color: "fill-orange-400",
- bar: "bg-orange-400",
- label: "Partial outage",
- icon:
- ``,
- },
- [ServiceStatus.MAJOR_OUTAGE]: {
- color: "fill-red-400",
- bar: "bg-red-400",
- label: "Major outage",
- icon:
- ``,
- },
- };
-
private static readonly WEIGHTS: Record = {
[ServiceStatus.OPERATIONAL]: 0,
[ServiceStatus.UNDER_MAINTENANCE]: 0,
@@ -74,12 +35,11 @@ export class ServiceRow extends Component {
}
private static bar(
- day: Date,
+ day: Time.Day,
notices: Notice[],
started: Date | null,
): TemplateResult {
- const tomorrow = new Date(day);
- tomorrow.setDate(tomorrow.getDate() + 1);
+ const tomorrow = day.next();
const presentNotices = notices.filter((n) =>
n.started.getTime() < Date.now()
@@ -109,8 +69,8 @@ export class ServiceRow extends Component {
class="group/bar flex"
>
@@ -125,7 +85,7 @@ export class ServiceRow extends Component {
class="group/bar flex outline-offset-2 outline-blue-400 has-focus-visible:z-10 has-focus-visible:outline-2"
>
@@ -169,20 +129,15 @@ export class ServiceRow extends Component {
return 1 - downtime / totalMs;
}
- protected noticesForDay(day: Date): Notice[] {
- const dayStart = new Date(day);
- dayStart.setHours(0, 0, 0, 0);
- const nextDayStart = new Date(dayStart);
- nextDayStart.setDate(dayStart.getDate() + 1);
-
+ protected noticesForDay(day: Time.Day): Notice[] {
return this.notices.filter((n) =>
- n.started.getTime() < nextDayStart.getTime() &&
- (n.ended === null || n.ended.getTime() >= dayStart.getTime())
+ n.started.getTime() < day.next().getTime() &&
+ (n.ended === null || n.ended.getTime() >= day.getTime())
);
}
protected renderIcon(): TemplateResult {
- const style = ServiceRow.STATUS_STYLES[this.service.status];
+ const style = EnumMappings.SERVICE_STATUS_STYLES[this.service.status];
return html`
${this.service.description === null ? nothing : html`
-
+