Skip to content

Commit 25e6936

Browse files
authored
Show active and pending notices (#25)
2 parents e2d504c + 7f03be8 commit 25e6936

10 files changed

Lines changed: 485 additions & 123 deletions

File tree

src/EnumMappings.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { ServiceStatus } from "./models/ServiceStatus";
2+
import { NoticeStatus } from "./models/NoticeStatus";
3+
4+
export namespace EnumMappings {
5+
export const SERVICE_STATUS_STYLES: Record<
6+
ServiceStatus,
7+
{ color: string; bar: string; label: string; icon: string }
8+
> = {
9+
[ServiceStatus.OPERATIONAL]: {
10+
color: "fill-emerald-400",
11+
bar: "bg-emerald-500",
12+
label: "Operational",
13+
icon:
14+
`<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>`,
15+
},
16+
[ServiceStatus.UNDER_MAINTENANCE]: {
17+
color: "fill-blue-400",
18+
bar: "bg-blue-400",
19+
label: "Under maintenance",
20+
icon:
21+
`<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" />`,
22+
},
23+
[ServiceStatus.DEGRADED_PERFORMANCE]: {
24+
color: "fill-amber-400",
25+
bar: "bg-amber-400",
26+
label: "Degraded performance",
27+
icon:
28+
`<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>`,
29+
},
30+
[ServiceStatus.PARTIAL_OUTAGE]: {
31+
color: "fill-orange-400",
32+
bar: "bg-orange-400",
33+
label: "Partial outage",
34+
icon:
35+
`<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>`,
36+
},
37+
[ServiceStatus.MAJOR_OUTAGE]: {
38+
color: "fill-red-400",
39+
bar: "bg-red-400",
40+
label: "Major outage",
41+
icon:
42+
`<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>`,
43+
},
44+
};
45+
46+
export const NOTICE_STATUS_NAMES: Record<NoticeStatus, string> = {
47+
[NoticeStatus.INCIDENT_IDENTIFIED]: "Identified",
48+
[NoticeStatus.INCIDENT_INVESTIGATING]: "Investigating",
49+
[NoticeStatus.INCIDENT_MONITORING]: "Monitoring",
50+
[NoticeStatus.INCIDENT_RESOLVED]: "Resolved",
51+
[NoticeStatus.MAINTENANCE_NOT_STARTED_YET]: "Planned",
52+
[NoticeStatus.MAINTENANCE_IN_PROGRESS]: "In progress",
53+
[NoticeStatus.MAINTENANCE_COMPLETED]: "Completed",
54+
};
55+
}

src/Time.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
export namespace Time {
2+
export class Duration {
3+
public readonly ms: number;
4+
5+
public constructor(ms: number) {
6+
this.ms = ms;
7+
}
8+
9+
private getParts() {
10+
const totalSeconds = Math.floor(this.ms / 1000);
11+
return {
12+
days: Math.floor(totalSeconds / 86400),
13+
hours: Math.floor((totalSeconds % 86400) / 3600),
14+
minutes: Math.floor((totalSeconds % 3600) / 60),
15+
};
16+
}
17+
18+
public toISOString() {
19+
const { days, hours, minutes } = this.getParts();
20+
21+
return "P" +
22+
(days ? `${days}D` : "") +
23+
(hours || minutes ? "T" : "") +
24+
(hours ? `${hours}H` : "") +
25+
(minutes ? `${minutes}M` : "");
26+
}
27+
28+
public toString() {
29+
const { days, hours, minutes } = this.getParts();
30+
31+
const parts = [
32+
days && `${days} ${days === 1 ? "day" : "days"}`,
33+
hours && `${hours} ${hours === 1 ? "hour" : "hours"}`,
34+
minutes && `${minutes} ${minutes === 1 ? "minute" : "minutes"}`,
35+
].filter(Boolean) as string[];
36+
37+
return parts.length > 1
38+
? parts.slice(0, -1).join(", ") + " and " + parts.at(-1)
39+
: parts[0] ?? "0 minutes";
40+
}
41+
}
42+
43+
export class Day {
44+
public readonly date: Date;
45+
46+
public constructor(date: Date) {
47+
this.date = new Date(date.getTime());
48+
this.date.setHours(0, 0, 0, 0);
49+
}
50+
51+
public static today() {
52+
return new Day(new Date());
53+
}
54+
55+
public is(date: Date): boolean;
56+
public is(day: Day): boolean;
57+
public is(d: Date | Day): boolean {
58+
return d instanceof Day
59+
? d.date.getTime() === this.date.getTime()
60+
: new Day(d).date.getTime() === this.date.getTime();
61+
}
62+
63+
public toISOString() {
64+
const year = this.date.getFullYear();
65+
const month = String(this.date.getMonth() + 1).padStart(2, "0");
66+
const day = String(this.date.getDate()).padStart(2, "0");
67+
68+
return `${year}-${month}-${day}`;
69+
}
70+
71+
public toString() {
72+
return this.date.toLocaleString(undefined, {
73+
month: "long",
74+
day: "numeric",
75+
year: "numeric",
76+
});
77+
}
78+
79+
public add(days: number) {
80+
const newDate = new Date(this.date.getTime());
81+
newDate.setDate(this.date.getDate() + days);
82+
return new Day(newDate);
83+
}
84+
85+
public subtract(days: number) {
86+
const newDate = new Date(this.date.getTime());
87+
newDate.setDate(this.date.getDate() - days);
88+
return new Day(newDate);
89+
}
90+
91+
public next() {
92+
return this.add(1);
93+
}
94+
95+
public previous() {
96+
return this.subtract(1);
97+
}
98+
99+
public getTime() {
100+
return this.date.getTime();
101+
}
102+
}
103+
104+
export class DateTime {
105+
public readonly date: Date;
106+
107+
public constructor(date: Date) {
108+
this.date = new Date(date.getTime());
109+
}
110+
111+
public static now() {
112+
return new DateTime(new Date());
113+
}
114+
115+
public getDay() {
116+
return new Day(this.date);
117+
}
118+
119+
public toISOString() {
120+
return this.date.toISOString();
121+
}
122+
123+
public toString() {
124+
return this.getDay().toString() + " at " + this.toTimeString();
125+
}
126+
127+
public toTimeString() {
128+
return this.date.toLocaleTimeString(undefined, {
129+
hour: "numeric",
130+
minute: "numeric",
131+
});
132+
}
133+
}
134+
}

src/api/NoticeUpdate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ export interface NoticeUpdate {
22
id: string;
33
started: string;
44
status: string;
5-
message: { default: string };
5+
message: { default: string } | string;
66
attachments: string[];
77
}

src/components/ActiveNotices.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { customElement, property } from "lit/decorators.js";
2+
import { Component } from "./Component";
3+
import { Notice } from "../models/Notice";
4+
import { Maintenance } from "../models/Maintenance";
5+
import { html, nothing } from "lit";
6+
import { EnumMappings } from "../EnumMappings";
7+
import { ServiceStatus } from "../models/ServiceStatus";
8+
import { NoticeOverview } from "./NoticeOverview";
9+
10+
@customElement("active-notices")
11+
export class ActiveNotices extends Component {
12+
private static readonly MAINTENANCE_COLLAPSE_DAYS = 3;
13+
14+
@property({ type: Array })
15+
public readonly notices: Notice[];
16+
17+
public constructor(notices: Notice[]) {
18+
super();
19+
this.notices = notices;
20+
}
21+
22+
public maintenances(): Maintenance[] {
23+
const threshold = new Date(
24+
Date.now() + ActiveNotices.MAINTENANCE_COLLAPSE_DAYS * 86400000,
25+
);
26+
return this.notices.filter((n) =>
27+
n instanceof Maintenance && n.started > threshold
28+
) as Maintenance[];
29+
}
30+
31+
public active(): Notice[] {
32+
const threshold = new Date(
33+
Date.now() + ActiveNotices.MAINTENANCE_COLLAPSE_DAYS * 86400000,
34+
);
35+
return this.notices.filter((n) =>
36+
n.ended === null || (n.ended > new Date() && n.started <= threshold)
37+
);
38+
}
39+
40+
public override render() {
41+
const active = this.active();
42+
const maintenances = this.maintenances();
43+
return html`
44+
${active.length === 0 ? nothing : html`
45+
<ul class="flex flex-col space-y-12 mb-6">
46+
${active.map((n) =>
47+
html`
48+
<li>${new NoticeOverview(n)}</li>
49+
`
50+
)}
51+
</ul>
52+
`} ${maintenances.length === 0 ? nothing : html`
53+
<details
54+
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"
55+
>
56+
<summary
57+
class="flex cursor-pointer items-center gap-3 rounded-md outline-offset-2 outline-blue-400 focus-visible:outline-2"
58+
>
59+
<span class="group/indicator relative">
60+
<svg
61+
xmlns="http://www.w3.org/2000/svg"
62+
class="size-5 ${EnumMappings
63+
.SERVICE_STATUS_STYLES[ServiceStatus.UNDER_MAINTENANCE]
64+
.color}"
65+
viewBox="0 0 256 256"
66+
aria-hidden="true"
67+
.innerHTML="${EnumMappings
68+
.SERVICE_STATUS_STYLES[ServiceStatus.UNDER_MAINTENANCE].icon}"
69+
>
70+
</svg>
71+
<span
72+
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"
73+
>
74+
${EnumMappings
75+
.SERVICE_STATUS_STYLES[ServiceStatus.UNDER_MAINTENANCE]
76+
.label}
77+
</span>
78+
</span>
79+
<span class="font-medium text-white">
80+
${maintenances.length === 1
81+
? "One maintenance is"
82+
: `${maintenances.length} maintenance periods are`} scheduled
83+
</span>
84+
</summary>
85+
<ul class="flex flex-col mt-4 space-y-4">
86+
${maintenances.map((n) =>
87+
html`
88+
<li>${new NoticeOverview(n)}</li>
89+
`
90+
)}
91+
</ul>
92+
</details>
93+
`}
94+
`;
95+
}
96+
}

src/components/NoticeOverview.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { html, nothing } from "lit";
2+
import { customElement, state } from "lit/decorators.js";
3+
import { Component } from "./Component";
4+
import { Notice } from "../models/Notice";
5+
import { EnumMappings } from "../EnumMappings";
6+
import { Time } from "../Time";
7+
import { UpdatesFeed } from "./UpdatesFeed";
8+
import { Maintenance } from "../models/Maintenance";
9+
10+
@customElement("notice-overview")
11+
export class NoticeOverview extends Component {
12+
@state()
13+
private notice: Notice;
14+
15+
public constructor(notice: Notice) {
16+
super();
17+
this.notice = notice;
18+
}
19+
20+
public override render() {
21+
const start = new Time.DateTime(this.notice.started);
22+
const end = this.notice.ended === null
23+
? null
24+
: new Time.DateTime(this.notice.ended);
25+
const duration = new Time.Duration(this.notice.duration());
26+
27+
return html`
28+
<div class="relative">
29+
<div class="flex items-center justify-between mb-2">
30+
<div>
31+
<div class="flex flex-row-reverse items-center justify-end gap-3">
32+
<a
33+
href="/notices/${this.notice.id}"
34+
class="text-lg font-medium text-white"
35+
>
36+
${this.notice.name}<span class="absolute inset-0"></span>
37+
</a>
38+
<span class="group/indicator relative">
39+
<svg
40+
xmlns="http://www.w3.org/2000/svg"
41+
class="size-5 ${EnumMappings
42+
.SERVICE_STATUS_STYLES[
43+
this.notice.impact
44+
]
45+
.color}"
46+
viewBox="0 0 256 256"
47+
aria-hidden="true"
48+
.innerHTML="${EnumMappings
49+
.SERVICE_STATUS_STYLES[
50+
this.notice.impact
51+
]
52+
.icon}"
53+
>
54+
</svg>
55+
<span
56+
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"
57+
>
58+
${EnumMappings
59+
.SERVICE_STATUS_STYLES[
60+
this.notice.impact
61+
]
62+
.label}
63+
</span>
64+
</span>
65+
</div>
66+
${this.notice instanceof Maintenance && end !== null
67+
? html`
68+
<p class="text-sm leading-loose text-neutral-400">
69+
Scheduled for
70+
<time datetime="${start.toISOString()}">${start
71+
.toString()}
72+
</time>
73+
&nbsp;–&nbsp;<time
74+
datetime="${end.toISOString()}"
75+
>${end.getDay().is(start.getDay())
76+
? end.toTimeString()
77+
: end.toString()}
78+
</time>
79+
<span
80+
class="rounded-full bg-white/10 px-2 py-0.5 ring-1 ring-white/10 ring-inset"
81+
><time datetime="${duration.toISOString()}">${duration
82+
.toString()}</time></span>
83+
</p>
84+
`
85+
: nothing}
86+
</div>
87+
<button
88+
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"
89+
>
90+
Subscribe
91+
</button>
92+
</div>
93+
${new UpdatesFeed(this.notice.updates)}
94+
</div>
95+
`;
96+
}
97+
}

0 commit comments

Comments
 (0)