feat(monitor/frontend): earthquake + outage panels (left + right column, USGS / CF Radar)
This commit is contained in:
parent
887dc217d2
commit
6f062bf49b
|
|
@ -10,7 +10,9 @@ export * from './useBgpHijackData'
|
|||
export * from './useCoinbasePrices'
|
||||
export * from './useCveData'
|
||||
export * from './useDShieldData'
|
||||
export * from './useEarthquakeData'
|
||||
export * from './useIssPosition'
|
||||
export * from './useKevData'
|
||||
export * from './useOutageData'
|
||||
export * from './useRansomwareData'
|
||||
export * from './useSpaceWeather'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
// ©AngelaMos | 2026
|
||||
// useEarthquakeData.ts
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { isValidEarthquakePayload, type EarthquakePayload } from '@/api/types'
|
||||
import { useSnapshot } from '@/api/snapshot'
|
||||
import { useEarthquakeStore } from '@/stores/earthquake'
|
||||
|
||||
interface EarthquakeData {
|
||||
items: EarthquakePayload[]
|
||||
}
|
||||
|
||||
export function useEarthquakeData(): EarthquakeData {
|
||||
const { data } = useSnapshot()
|
||||
const items = useEarthquakeStore((s) => s.items)
|
||||
const push = useEarthquakeStore((s) => s.push)
|
||||
|
||||
const raw = data?.earthquake
|
||||
const seed = isValidEarthquakePayload(raw) ? raw : undefined
|
||||
useEffect(() => {
|
||||
if (seed) push(seed)
|
||||
}, [seed, push])
|
||||
|
||||
return { items }
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// ©AngelaMos | 2026
|
||||
// useOutageData.ts
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { isValidInternetOutage, type InternetOutage } from '@/api/types'
|
||||
import { useSnapshot } from '@/api/snapshot'
|
||||
import { useOutageStore } from '@/stores/outage'
|
||||
|
||||
interface OutageData {
|
||||
items: InternetOutage[]
|
||||
}
|
||||
|
||||
export function useOutageData(): OutageData {
|
||||
const { data } = useSnapshot()
|
||||
const items = useOutageStore((s) => s.items)
|
||||
const push = useOutageStore((s) => s.push)
|
||||
|
||||
const raw = data?.internet_outage
|
||||
const seed = isValidInternetOutage(raw) ? raw : undefined
|
||||
useEffect(() => {
|
||||
if (seed) push(seed)
|
||||
}, [seed, push])
|
||||
|
||||
return { items }
|
||||
}
|
||||
|
|
@ -14,12 +14,25 @@ export const issPositionSchema = z.object({
|
|||
|
||||
export type IssPosition = z.infer<typeof issPositionSchema>
|
||||
|
||||
export const earthquakePropertiesSchema = z.object({
|
||||
mag: z.number().nullable().optional(),
|
||||
place: z.string().nullable().optional(),
|
||||
time: z.number().optional(),
|
||||
updated: z.number().optional(),
|
||||
alert: z.string().nullable().optional(),
|
||||
tsunami: z.number().optional(),
|
||||
url: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
})
|
||||
|
||||
export type EarthquakeProperties = z.infer<typeof earthquakePropertiesSchema>
|
||||
|
||||
export const earthquakePayloadSchema = z.object({
|
||||
id: z.string(),
|
||||
geometry: z
|
||||
.object({ coordinates: z.array(z.number()).optional() })
|
||||
.optional(),
|
||||
properties: z.record(z.string(), z.unknown()).optional(),
|
||||
properties: earthquakePropertiesSchema.optional(),
|
||||
})
|
||||
|
||||
export type EarthquakePayload = z.infer<typeof earthquakePayloadSchema>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import { BGPHijackPanel } from '@/pages/panels/BGPHijackPanel'
|
|||
import { BTCPanel } from '@/pages/panels/BTCPanel'
|
||||
import { CVEVelocityPanel } from '@/pages/panels/CVEVelocityPanel'
|
||||
import { DShieldPanel } from '@/pages/panels/DShieldPanel'
|
||||
import { EarthquakePanel } from '@/pages/panels/EarthquakePanel'
|
||||
import { ETHPanel } from '@/pages/panels/ETHPanel'
|
||||
import { ISSPanel } from '@/pages/panels/ISSPanel'
|
||||
import { KEVPanel } from '@/pages/panels/KEVPanel'
|
||||
import { OutagePanel } from '@/pages/panels/OutagePanel'
|
||||
import { RansomwarePanel } from '@/pages/panels/RansomwarePanel'
|
||||
import { SpaceWeatherPanel } from '@/pages/panels/SpaceWeatherPanel'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
|
|
@ -39,6 +41,7 @@ export function Dashboard(): React.ReactElement {
|
|||
<ETHPanel />
|
||||
<SpaceWeatherPanel />
|
||||
<ISSPanel />
|
||||
<EarthquakePanel />
|
||||
<CVEVelocityPanel />
|
||||
</aside>
|
||||
<section className={styles.center}>
|
||||
|
|
@ -46,6 +49,7 @@ export function Dashboard(): React.ReactElement {
|
|||
</section>
|
||||
<aside className={styles.right}>
|
||||
<BGPHijackPanel />
|
||||
<OutagePanel />
|
||||
<KEVPanel />
|
||||
<RansomwarePanel />
|
||||
<DShieldPanel />
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
isValidBgpHijack,
|
||||
isValidCoinbaseTick,
|
||||
isValidCveEvent,
|
||||
isValidDShieldData,
|
||||
isValidEarthquakePayload,
|
||||
isValidGdeltSpike,
|
||||
isValidInternetOutage,
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
isValidWikiItn,
|
||||
type BgpHijack,
|
||||
type CveEvent,
|
||||
type DShieldData,
|
||||
type EarthquakePayload,
|
||||
type GdeltSpike,
|
||||
type InternetOutage,
|
||||
|
|
@ -30,8 +32,10 @@ import { unlockOnFirstGesture } from '@/lib/audio'
|
|||
import { getCentroid } from '@/lib/countryCentroids'
|
||||
import { useBgpHijackStore } from '@/stores/bgpHijack'
|
||||
import { useCveStore } from '@/stores/cve'
|
||||
import { useEarthquakeStore } from '@/stores/earthquake'
|
||||
import { useGlobeEvents } from '@/stores/globeEvents'
|
||||
import { useKevStore } from '@/stores/kev'
|
||||
import { useOutageStore } from '@/stores/outage'
|
||||
import { usePrices } from '@/stores/prices'
|
||||
import { useRansomwareStore, victimKey } from '@/stores/ransomware'
|
||||
import { useTicker } from '@/stores/ticker'
|
||||
|
|
@ -101,10 +105,10 @@ export function useDashboardLifecycle(): void {
|
|||
}
|
||||
|
||||
function routeEvent(ev: WSEvent, queryClient: QueryClient): void {
|
||||
const data = ev.payload
|
||||
const data = ev.data
|
||||
if (data === undefined || data === null) return
|
||||
|
||||
switch (ev.topic) {
|
||||
switch (ev.ch) {
|
||||
case 'cve_new':
|
||||
if (isValidCveEvent(data)) handleCve(data)
|
||||
break
|
||||
|
|
@ -135,9 +139,11 @@ function routeEvent(ev: WSEvent, queryClient: QueryClient): void {
|
|||
case 'bgp_hijack':
|
||||
if (isValidBgpHijack(data)) handleHijack(data, queryClient)
|
||||
break
|
||||
case 'space_weather':
|
||||
case 'scan_firehose':
|
||||
mergeIntoSnapshot(queryClient, ev.topic, data)
|
||||
if (isValidDShieldData(data)) handleScanFirehose(data, queryClient)
|
||||
break
|
||||
case 'space_weather':
|
||||
mergeIntoSnapshot(queryClient, ev.ch, data)
|
||||
break
|
||||
default:
|
||||
break
|
||||
|
|
@ -172,6 +178,7 @@ function pushRansomwarePoint(p: RansomwareVictim): void {
|
|||
}
|
||||
|
||||
function handleOutage(p: InternetOutage, queryClient: QueryClient): void {
|
||||
useOutageStore.getState().push(p)
|
||||
pushOutagePoints(p)
|
||||
mergeIntoSnapshot(queryClient, 'internet_outage', p)
|
||||
}
|
||||
|
|
@ -218,6 +225,28 @@ function pushHijackPoint(p: BgpHijack): void {
|
|||
})
|
||||
}
|
||||
|
||||
function handleScanFirehose(p: DShieldData, queryClient: QueryClient): void {
|
||||
pushScanPoints(p)
|
||||
mergeIntoSnapshot(queryClient, 'scan_firehose', p)
|
||||
}
|
||||
|
||||
function pushScanPoints(p: DShieldData): void {
|
||||
const now = Date.now()
|
||||
for (const src of p.topips ?? []) {
|
||||
if (!src.country) continue
|
||||
const c = getCentroid(src.country)
|
||||
if (!c) continue
|
||||
useGlobeEvents.getState().pushPoint({
|
||||
id: `scan-${src.source}`,
|
||||
type: 'scan',
|
||||
lat: c.lat,
|
||||
lng: c.lng,
|
||||
emittedAt: now,
|
||||
meta: { source: src.source, reports: src.reports },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleCoinbase(p: { symbol: string; ts: string; price: string; volume_24h?: string }): void {
|
||||
usePrices.getState().pushTick({
|
||||
symbol: p.symbol,
|
||||
|
|
@ -228,6 +257,7 @@ function handleCoinbase(p: { symbol: string; ts: string; price: string; volume_2
|
|||
}
|
||||
|
||||
function handleEarthquake(p: EarthquakePayload): void {
|
||||
useEarthquakeStore.getState().push(p)
|
||||
const coords = p.geometry?.coordinates
|
||||
if (!Array.isArray(coords)) return
|
||||
const lng = coords[0]
|
||||
|
|
@ -296,6 +326,7 @@ function mergeIntoSnapshot(
|
|||
function seedGlobeFromSnapshot(snap: Snapshot): void {
|
||||
const eq = snap.earthquake
|
||||
if (isValidEarthquakePayload(eq)) {
|
||||
useEarthquakeStore.getState().push(eq)
|
||||
const coords = eq.geometry?.coordinates
|
||||
if (Array.isArray(coords)) {
|
||||
const lng = coords[0]
|
||||
|
|
@ -328,11 +359,17 @@ function seedGlobeFromSnapshot(snap: Snapshot): void {
|
|||
if (isValidRansomwareVictim(rw)) pushRansomwarePoint(rw)
|
||||
|
||||
const outage = snap.internet_outage
|
||||
if (isValidInternetOutage(outage)) pushOutagePoints(outage)
|
||||
if (isValidInternetOutage(outage)) {
|
||||
useOutageStore.getState().push(outage)
|
||||
pushOutagePoints(outage)
|
||||
}
|
||||
|
||||
const hijack = snap.bgp_hijack
|
||||
if (isValidBgpHijack(hijack)) {
|
||||
useBgpHijackStore.getState().push(hijack)
|
||||
pushHijackPoint(hijack)
|
||||
}
|
||||
|
||||
const scan = snap.scan_firehose
|
||||
if (isValidDShieldData(scan)) pushScanPoints(scan)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
// ©AngelaMos | 2026
|
||||
// EarthquakePanel.module.scss
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
font-size: var(--type-label);
|
||||
letter-spacing: var(--letter-spacing-label);
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
text-align: left;
|
||||
padding: var(--row-py) var(--row-px);
|
||||
border-bottom: 1px solid var(--fg-4);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.table tbody td {
|
||||
padding: var(--row-py) var(--row-px);
|
||||
font-size: var(--type-body);
|
||||
color: var(--fg-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: var(--bg-row-hover);
|
||||
}
|
||||
|
||||
.mag {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
width: 16%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.place {
|
||||
width: 64%;
|
||||
color: var(--fg-1);
|
||||
}
|
||||
|
||||
.ago {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--fg-3);
|
||||
width: 20%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.flash {
|
||||
animation: row-flash 600ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes row-flash {
|
||||
0% {
|
||||
background: var(--fg-4);
|
||||
}
|
||||
|
||||
100% {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
// ©AngelaMos | 2026
|
||||
// EarthquakePanel.tsx
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEarthquakeData } from '@/api/hooks'
|
||||
import { Panel } from './Panel'
|
||||
import styles from './EarthquakePanel.module.scss'
|
||||
|
||||
const QUAKE_ROW_LIMIT = 8
|
||||
const FLASH_DURATION_MS = 600
|
||||
const MS_PER_HOUR = 3_600_000
|
||||
const MS_PER_MINUTE = 60_000
|
||||
const HOURS_PER_DAY = 24
|
||||
|
||||
export function EarthquakePanel(): React.ReactElement {
|
||||
const { items } = useEarthquakeData()
|
||||
const seenIds = useRef<Set<string>>(new Set())
|
||||
const [flashIds, setFlashIds] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const isFirstSeed = seenIds.current.size === 0
|
||||
const newIds: string[] = []
|
||||
for (const q of items) {
|
||||
if (!seenIds.current.has(q.id)) {
|
||||
seenIds.current.add(q.id)
|
||||
if (!isFirstSeed) newIds.push(q.id)
|
||||
}
|
||||
}
|
||||
if (newIds.length > 0) setFlashIds(new Set(newIds))
|
||||
}, [items])
|
||||
|
||||
useEffect(() => {
|
||||
if (flashIds.size === 0) return
|
||||
const t = setTimeout(() => setFlashIds(new Set()), FLASH_DURATION_MS)
|
||||
return () => clearTimeout(t)
|
||||
}, [flashIds])
|
||||
|
||||
const recent = items.slice(0, QUAKE_ROW_LIMIT)
|
||||
const now = Date.now()
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="USGS"
|
||||
subtitle="QUAKES"
|
||||
rawHref="https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson"
|
||||
rawLabel="USGS feed"
|
||||
>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.mag}>Mag</th>
|
||||
<th className={styles.place}>Place</th>
|
||||
<th className={styles.ago}>Ago</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((q) => {
|
||||
const isFlashing = flashIds.has(q.id)
|
||||
return (
|
||||
<tr key={q.id} className={isFlashing ? styles.flash : undefined}>
|
||||
<td className={styles.mag}>{fmtMag(q.properties?.mag)}</td>
|
||||
<td
|
||||
className={styles.place}
|
||||
title={q.properties?.place ?? ''}
|
||||
>
|
||||
{q.properties?.place ?? '—'}
|
||||
</td>
|
||||
<td className={styles.ago}>
|
||||
{fmtAgo(q.properties?.time, now)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
EarthquakePanel.displayName = 'EarthquakePanel'
|
||||
|
||||
function fmtMag(mag: number | null | undefined): string {
|
||||
if (mag === null || mag === undefined) return '—'
|
||||
return mag.toFixed(1)
|
||||
}
|
||||
|
||||
function fmtAgo(ts: number | undefined, now: number): string {
|
||||
if (!ts) return '—'
|
||||
const diff = now - ts
|
||||
if (diff < 0) return '—'
|
||||
if (diff < MS_PER_HOUR) {
|
||||
return `${Math.max(Math.floor(diff / MS_PER_MINUTE), 0)}m`
|
||||
}
|
||||
if (diff < HOURS_PER_DAY * MS_PER_HOUR) {
|
||||
return `${Math.floor(diff / MS_PER_HOUR)}h`
|
||||
}
|
||||
return `${Math.floor(diff / (HOURS_PER_DAY * MS_PER_HOUR))}d`
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
// ©AngelaMos | 2026
|
||||
// OutagePanel.module.scss
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
font-size: var(--type-label);
|
||||
letter-spacing: var(--letter-spacing-label);
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
text-align: left;
|
||||
padding: var(--row-py) var(--row-px);
|
||||
border-bottom: 1px solid var(--fg-4);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.table tbody td {
|
||||
padding: var(--row-py) var(--row-px);
|
||||
font-size: var(--type-body);
|
||||
color: var(--fg-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: var(--bg-row-hover);
|
||||
}
|
||||
|
||||
.cc {
|
||||
width: 16%;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
.info {
|
||||
width: 38%;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.state {
|
||||
width: 18%;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.ago {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--fg-3);
|
||||
width: 28%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.flash {
|
||||
animation: row-flash 600ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes row-flash {
|
||||
0% {
|
||||
background: var(--fg-4);
|
||||
}
|
||||
|
||||
100% {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
// ©AngelaMos | 2026
|
||||
// OutagePanel.tsx
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useOutageData } from '@/api/hooks'
|
||||
import { Panel } from './Panel'
|
||||
import styles from './OutagePanel.module.scss'
|
||||
|
||||
const OUTAGE_ROW_LIMIT = 6
|
||||
const FLASH_DURATION_MS = 600
|
||||
const MS_PER_HOUR = 3_600_000
|
||||
const MS_PER_MINUTE = 60_000
|
||||
const HOURS_PER_DAY = 24
|
||||
|
||||
export function OutagePanel(): React.ReactElement {
|
||||
const { items } = useOutageData()
|
||||
const seenIds = useRef<Set<string>>(new Set())
|
||||
const [flashIds, setFlashIds] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const isFirstSeed = seenIds.current.size === 0
|
||||
const newIds: string[] = []
|
||||
for (const o of items) {
|
||||
if (!seenIds.current.has(o.id)) {
|
||||
seenIds.current.add(o.id)
|
||||
if (!isFirstSeed) newIds.push(o.id)
|
||||
}
|
||||
}
|
||||
if (newIds.length > 0) setFlashIds(new Set(newIds))
|
||||
}, [items])
|
||||
|
||||
useEffect(() => {
|
||||
if (flashIds.size === 0) return
|
||||
const t = setTimeout(() => setFlashIds(new Set()), FLASH_DURATION_MS)
|
||||
return () => clearTimeout(t)
|
||||
}, [flashIds])
|
||||
|
||||
const recent = items.slice(0, OUTAGE_ROW_LIMIT)
|
||||
const now = Date.now()
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="OUTAGES"
|
||||
subtitle="CF RADAR"
|
||||
rawHref="https://radar.cloudflare.com/outage-center"
|
||||
rawLabel="Cloudflare Radar Outage Center"
|
||||
>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.cc}>CC</th>
|
||||
<th className={styles.info}>Cause</th>
|
||||
<th className={styles.state}>State</th>
|
||||
<th className={styles.ago}>Started</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((o) => {
|
||||
const isFlashing = flashIds.has(o.id)
|
||||
return (
|
||||
<tr key={o.id} className={isFlashing ? styles.flash : undefined}>
|
||||
<td className={styles.cc}>{fmtCC(o.locations)}</td>
|
||||
<td
|
||||
className={styles.info}
|
||||
title={o.reason ?? o.outageType ?? ''}
|
||||
>
|
||||
{fmtCause(o.reason, o.outageType)}
|
||||
</td>
|
||||
<td className={styles.state}>{fmtState(o.endDate)}</td>
|
||||
<td className={styles.ago}>{fmtAgo(o.startDate, now)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
OutagePanel.displayName = 'OutagePanel'
|
||||
|
||||
function fmtCC(locations: string[] | undefined): string {
|
||||
if (!locations || locations.length === 0) return '—'
|
||||
const first = locations[0] ?? '—'
|
||||
if (locations.length === 1) return first
|
||||
return `${first} +${locations.length - 1}`
|
||||
}
|
||||
|
||||
function fmtCause(
|
||||
reason: string | undefined,
|
||||
outageType: string | undefined
|
||||
): string {
|
||||
if (reason && reason.trim()) return reason
|
||||
if (outageType && outageType.trim()) return outageType
|
||||
return '—'
|
||||
}
|
||||
|
||||
function fmtState(endDate: string | null | undefined): string {
|
||||
if (endDate === null || endDate === undefined) return 'active'
|
||||
const t = new Date(endDate).getTime()
|
||||
if (!Number.isFinite(t)) return 'ended'
|
||||
return 'ended'
|
||||
}
|
||||
|
||||
function fmtAgo(iso: string | undefined, now: number): string {
|
||||
if (!iso) return '—'
|
||||
const t = new Date(iso).getTime()
|
||||
if (!Number.isFinite(t)) return '—'
|
||||
const diff = now - t
|
||||
if (diff < 0) return '—'
|
||||
if (diff < MS_PER_HOUR) {
|
||||
return `${Math.max(Math.floor(diff / MS_PER_MINUTE), 0)}m`
|
||||
}
|
||||
if (diff < HOURS_PER_DAY * MS_PER_HOUR) {
|
||||
return `${Math.floor(diff / MS_PER_HOUR)}h`
|
||||
}
|
||||
return `${Math.floor(diff / (HOURS_PER_DAY * MS_PER_HOUR))}d`
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// ©AngelaMos | 2026
|
||||
// earthquake.ts
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { type EarthquakePayload } from '@/api/types'
|
||||
|
||||
interface EarthquakeStore {
|
||||
items: EarthquakePayload[]
|
||||
push: (item: EarthquakePayload) => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
const EARTHQUAKE_CAP = 100
|
||||
|
||||
export const useEarthquakeStore = create<EarthquakeStore>((set) => ({
|
||||
items: [],
|
||||
push: (item) =>
|
||||
set((s) => {
|
||||
const filtered = s.items.filter((i) => i.id !== item.id)
|
||||
return { items: [item, ...filtered].slice(0, EARTHQUAKE_CAP) }
|
||||
}),
|
||||
clear: () => set({ items: [] }),
|
||||
}))
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// ©AngelaMos | 2026
|
||||
// outage.ts
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { type InternetOutage } from '@/api/types'
|
||||
|
||||
interface OutageStore {
|
||||
items: InternetOutage[]
|
||||
push: (item: InternetOutage) => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
const OUTAGE_CAP = 50
|
||||
|
||||
export const useOutageStore = create<OutageStore>((set) => ({
|
||||
items: [],
|
||||
push: (item) =>
|
||||
set((s) => {
|
||||
const filtered = s.items.filter((i) => i.id !== item.id)
|
||||
return { items: [item, ...filtered].slice(0, OUTAGE_CAP) }
|
||||
}),
|
||||
clear: () => set({ items: [] }),
|
||||
}))
|
||||
Loading…
Reference in New Issue