Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/EnumMappings.ts
Original file line number Diff line number Diff line change
@@ -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:
`<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm45.66,85.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z"></path>`,
},
[ServiceStatus.UNDER_MAINTENANCE]: {
color: "fill-blue-400",
bar: "bg-blue-400",
label: "Under maintenance",
icon:
`<path d="M128 24a104 104 0 1 0 104 104A104.13 104.13 0 0 0 128 24m14.052 54.734a34.2 34.2 0 0 1 9.427 1.006 3.79 3.79 0 0 1 1.865 6.25l-17.76 19.265 2.682 12.485 12.484 2.677 19.266-17.782a3.79 3.79 0 0 1 6.25 1.865 34.4 34.4 0 0 1 1.02 8.333 34.122 34.122 0 0 1-47.833 31.282l-24.672 28.536a4 4 0 0 1-.187.203 15.168 15.168 0 0 1-21.448-21.453q.098-.095.203-.182l28.542-24.667a34.155 34.155 0 0 1 30.161-47.818" />`,
},
[ServiceStatus.DEGRADED_PERFORMANCE]: {
color: "fill-amber-400",
bar: "bg-amber-400",
label: "Degraded performance",
icon:
`<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm-8,56a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm8,104a12,12,0,1,1,12-12A12,12,0,0,1,128,184Z"></path>`,
},
[ServiceStatus.PARTIAL_OUTAGE]: {
color: "fill-orange-400",
bar: "bg-orange-400",
label: "Partial outage",
icon:
`<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm-8,56a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm8,104a12,12,0,1,1,12-12A12,12,0,0,1,128,184Z"></path>`,
},
[ServiceStatus.MAJOR_OUTAGE]: {
color: "fill-red-400",
bar: "bg-red-400",
label: "Major outage",
icon:
`<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm37.66,130.34a8,8,0,0,1-11.32,11.32L128,139.31l-26.34,26.35a8,8,0,0,1-11.32-11.32L116.69,128,90.34,101.66a8,8,0,0,1,11.32-11.32L128,116.69l26.34-26.35a8,8,0,0,1,11.32,11.32L139.31,128Z"></path>`,
},
};

export const NOTICE_STATUS_NAMES: Record<NoticeStatus, string> = {
[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",
};
}
134 changes: 134 additions & 0 deletions src/Time.ts
Original file line number Diff line number Diff line change
@@ -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",
});
}
}
}
2 changes: 1 addition & 1 deletion src/api/NoticeUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ export interface NoticeUpdate {
id: string;
started: string;
status: string;
message: { default: string };
message: { default: string } | string;
attachments: string[];
}
96 changes: 96 additions & 0 deletions src/components/ActiveNotices.ts
Original file line number Diff line number Diff line change
@@ -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`
<ul class="flex flex-col space-y-12 mb-6">
${active.map((n) =>
html`
<li>${new NoticeOverview(n)}</li>
`
)}
</ul>
`} ${maintenances.length === 0 ? nothing : html`
<details
class="group/upcoming -mx-3 mb-6 rounded-xl p-3 ring-white/5 ring-inset open:ring-1 md:-mx-4 md:p-4"
>
<summary
class="flex cursor-pointer items-center gap-3 rounded-md outline-offset-2 outline-blue-400 focus-visible:outline-2"
>
<span class="group/indicator relative">
<svg
xmlns="http://www.w3.org/2000/svg"
class="size-5 ${EnumMappings
.SERVICE_STATUS_STYLES[ServiceStatus.UNDER_MAINTENANCE]
.color}"
viewBox="0 0 256 256"
aria-hidden="true"
.innerHTML="${EnumMappings
.SERVICE_STATUS_STYLES[ServiceStatus.UNDER_MAINTENANCE].icon}"
>
</svg>
<span
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-md ring-1 ring-white/10 ring-inset not-group-hover/indicator:sr-only lg:-top-1 lg:-left-1 lg:mt-0 lg:-translate-x-full"
>
${EnumMappings
.SERVICE_STATUS_STYLES[ServiceStatus.UNDER_MAINTENANCE]
.label}
</span>
</span>
<span class="font-medium text-white">
${maintenances.length === 1
? "One maintenance is"
: `${maintenances.length} maintenance periods are`} scheduled
</span>
</summary>
<ul class="flex flex-col mt-4 space-y-4">
${maintenances.map((n) =>
html`
<li>${new NoticeOverview(n)}</li>
`
)}
</ul>
</details>
`}
`;
}
}
97 changes: 97 additions & 0 deletions src/components/NoticeOverview.ts
Original file line number Diff line number Diff line change
@@ -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`
<div class="relative">
<div class="flex items-center justify-between mb-2">
<div>
<div class="flex flex-row-reverse items-center justify-end gap-3">
<a
href="/notices/${this.notice.id}"
class="text-lg font-medium text-white"
>
${this.notice.name}<span class="absolute inset-0"></span>
</a>
<span class="group/indicator relative">
<svg
xmlns="http://www.w3.org/2000/svg"
class="size-5 ${EnumMappings
.SERVICE_STATUS_STYLES[
this.notice.impact
]
.color}"
viewBox="0 0 256 256"
aria-hidden="true"
.innerHTML="${EnumMappings
.SERVICE_STATUS_STYLES[
this.notice.impact
]
.icon}"
>
</svg>
<span
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-md ring-1 ring-white/10 ring-inset not-group-hover/indicator:sr-only lg:-top-1 lg:-left-1 lg:mt-0 lg:-translate-x-full"
>
${EnumMappings
.SERVICE_STATUS_STYLES[
this.notice.impact
]
.label}
</span>
</span>
</div>
${this.notice instanceof Maintenance && end !== null
? html`
<p class="text-sm leading-loose text-neutral-400">
Scheduled for
<time datetime="${start.toISOString()}">${start
.toString()}
</time>
&nbsp;–&nbsp;<time
datetime="${end.toISOString()}"
>${end.getDay().is(start.getDay())
? end.toTimeString()
: end.toString()}
</time>
<span
class="rounded-full bg-white/10 px-2 py-0.5 ring-1 ring-white/10 ring-inset"
><time datetime="${duration.toISOString()}">${duration
.toString()}</time></span>
</p>
`
: nothing}
</div>
<button
class="relative rounded-lg bg-white/5 px-3 py-2 text-sm font-medium text-white ring-1 ring-white/10 outline-offset-2 outline-blue-400 transition-colors select-none ring-inset hover:bg-white/15 focus-visible:outline-2"
>
Subscribe
</button>
</div>
${new UpdatesFeed(this.notice.updates)}
</div>
`;
}
}
Loading