From 1664e1b3d5314b18bcbe16e6116c60f7f32ac3cb Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 23:27:19 -0400 Subject: [PATCH] =?UTF-8?q?Wave=202B:=20Offseason=20never-dark=20hub=20?= =?UTF-8?q?=E2=80=94=20NewsWire=20+=20FuturesBoard=20on=20/explore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend /explore (ExploreHub) into the 365-day never-dark hub. Two new self-hiding sections feed REAL always-available data into the offseason: - NewsWire (components/vyndr/NewsWire.tsx): real ESPN headlines from /api/news/:sport (newest-first, mono timestamps, type chips, player/team links) + real injuries from /api/schedule/:sport/injuries (OUT/GTD chips, token colors). Reuses the retired TerminalTemplates INJURY_WIRE layout but never routes its sample constants. Self-hides when both feeds are empty. - FuturesBoard (components/vyndr/FuturesBoard.tsx): real futures from /api/futures/:sport — championship/win-total/award markets, mono tabular prices + movement colored by the contract (shortening=green / drifting=amber / flat=dim, NEVER red; move shown ONLY when the backend supplies one). Carries the honest "TRACKED · NOT GRADED" label — no fabricated grades on futures. Self-hides when markets:[]. - ExploreHub is offseason-aware (via emptyState OFF_SEASON month check): the hub LEADS with futures + wire when the board is dark, COMPLEMENTS the live board in-season. Sport selector kept; each section self-hides independently. - Testable pure helpers: lib/futuresMove.js (move→color, never red) + lib/newsFormat.js (timeAgo mono-stamp, ESPN type labels). Contracts consumed (Wave 2A owns the proxy/service files); code self-hides on fetch failure if a proxy isn't present yet. Tests: tests/unit/newsWire.test.js + futuresBoard.test.js (26 new). Full suite 259 suites / 3144 green; web build exit 0. vyndrParityQA stays green (mono data, no glitch on data surfaces). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/futuresBoard.test.js | 80 +++++++++++ tests/unit/newsWire.test.js | 85 +++++++++++ web/src/components/ExploreHub.tsx | 24 ++++ web/src/components/vyndr/FuturesBoard.tsx | 107 ++++++++++++++ web/src/components/vyndr/NewsWire.tsx | 163 ++++++++++++++++++++++ web/src/components/vyndr/index.ts | 6 + web/src/lib/futuresMove.js | 40 ++++++ web/src/lib/newsFormat.js | 53 +++++++ 8 files changed, 558 insertions(+) create mode 100644 tests/unit/futuresBoard.test.js create mode 100644 tests/unit/newsWire.test.js create mode 100644 web/src/components/vyndr/FuturesBoard.tsx create mode 100644 web/src/components/vyndr/NewsWire.tsx create mode 100644 web/src/lib/futuresMove.js create mode 100644 web/src/lib/newsFormat.js diff --git a/tests/unit/futuresBoard.test.js b/tests/unit/futuresBoard.test.js new file mode 100644 index 0000000..989c84d --- /dev/null +++ b/tests/unit/futuresBoard.test.js @@ -0,0 +1,80 @@ +// Wave 2B — OFFSEASON HUB · FuturesBoard. Source + helper asserts. Futures are +// TRACKED, NOT GRADED — the board carries that label, never renders a grade, +// self-hides on an empty feed, and colors movement per the contract +// (shortening→green / drifting→amber / flat→dim) — NEVER red for a drift. + +const fs = require('fs'); +const path = require('path'); +const WEB = path.join(__dirname, '..', '..', 'web', 'src'); +const src = fs.readFileSync(path.join(WEB, 'components', 'vyndr', 'FuturesBoard.tsx'), 'utf8'); +const { moveColor, moveLabel, MOVE_COLOR } = require('../../web/src/lib/futuresMove'); + +describe('futuresMove — the color contract (never red)', () => { + it('shortening → green (toward the selection)', () => { + expect(moveColor('shortening')).toBe('var(--g-a)'); + }); + it('drifting → amber (caution, NOT a miss)', () => { + expect(moveColor('drifting')).toBe('var(--amber)'); + }); + it('flat / absent / unknown → dim (say less)', () => { + expect(moveColor('flat')).toBe('var(--text-2)'); + expect(moveColor(undefined)).toBe('var(--text-2)'); + expect(moveColor('sideways')).toBe('var(--text-2)'); + }); + it('NO move ever renders red (--miss reserved for settled-negative)', () => { + Object.values(MOVE_COLOR).forEach((v) => expect(v).not.toMatch(/miss/)); + ['shortening', 'drifting', 'flat', undefined, 'x'].forEach((m) => + expect(moveColor(m)).not.toMatch(/miss/) + ); + }); + it('moveLabel is empty when there is no move (never fabricated)', () => { + expect(moveLabel(undefined)).toBe(''); + expect(moveLabel('shortening')).toMatch(/SHORTENING/); + }); +}); + +describe('FuturesBoard.tsx source', () => { + it('fetches the REAL futures endpoint', () => { + expect(src).toMatch(/\/api\/futures\//); + }); + + it('carries the honest "TRACKED · NOT GRADED" label', () => { + expect(src).toContain('TRACKED · NOT GRADED'); + }); + + it('NEVER renders a grade on a future (no GradeBadge)', () => { + expect(src).not.toContain('GradeBadge'); + expect(src).not.toMatch(/import.*GradeBadge/); + }); + + it('SELF-HIDES when there are no markets (returns null)', () => { + expect(src).toMatch(/return null/); + expect(src).toMatch(/list\.length === 0/); + }); + + it('renders real markets + selections with mono tabular prices', () => { + expect(src).toMatch(/list\.map/); + expect(src).toMatch(/selections/); + expect(src).toContain('className="mono"'); + expect(src).toContain('tabular-nums'); + }); + + it('shows a move ONLY when the backend supplied one, via the contract color', () => { + expect(src).toContain('moveColor'); + expect(src).toContain('moveLabel'); + // movement is gated on a truthy label (never fabricated) + expect(src).toMatch(/label &&/); + }); + + it('data surface carries NO glitch classes', () => { + expect(src).not.toMatch(/wm-tear|glitch-shift|head-tear|glitch-hover/); + }); +}); + +describe('ExploreHub mounts FuturesBoard', () => { + const hub = fs.readFileSync(path.join(WEB, 'components', 'ExploreHub.tsx'), 'utf8'); + it('imports + mounts { + const now = Date.parse('2026-07-13T12:00:00Z'); + it('absent / invalid → empty string (never a fabricated stamp)', () => { + expect(timeAgo(null, now)).toBe(''); + expect(timeAgo('', now)).toBe(''); + expect(timeAgo('not-a-date', now)).toBe(''); + }); + it('minutes / hours / days ago', () => { + expect(timeAgo(now - 5 * 60000, now)).toBe('5m ago'); + expect(timeAgo(now - 3 * 3600000, now)).toBe('3h ago'); + expect(timeAgo(now - 2 * 86400000, now)).toBe('2d ago'); + }); + it('sub-minute → now', () => { + expect(timeAgo(now - 10000, now)).toBe('now'); + }); +}); + +describe('newsFormat.typeLabel', () => { + it('maps known ESPN types', () => { + expect(typeLabel('Recap')).toBe('RECAP'); + expect(typeLabel('HeadlineNews')).toBe('NEWS'); + }); + it('falls back to a title-cased split for unknown types (never dropped)', () => { + expect(typeLabel('Story')).toBe('STORY'); + expect(typeLabel('')).toBe(''); + }); +}); + +describe('NewsWire.tsx source', () => { + it('fetches the REAL news + injuries endpoints (no sample data)', () => { + expect(src).toMatch(/\/api\/news\//); + expect(src).toMatch(/\/api\/schedule\/.*\/injuries/); + expect(src).not.toMatch(/import[^\n]*TerminalTemplates/); // never routes sample data + expect(src).not.toContain('Jamal Murray'); // no sample constants + }); + + it('SELF-HIDES when both feeds are empty (returns null)', () => { + expect(src).toMatch(/return null/); + // the guard combines news + injuries emptiness + expect(src).toMatch(/news\.length === 0 && inj\.length === 0/); + }); + + it('renders real items newest-first and maps them', () => { + expect(src).toMatch(/news\.map/); + expect(src).toMatch(/\.sort\(/); + expect(src).toContain('headline'); + }); + + it('timestamps + statuses render in mono (data-is-mono rule)', () => { + expect(src).toContain('className="mono"'); + expect(src).toContain('timeAgo('); + }); + + it('links player/team via the canonical helpers', () => { + expect(src).toContain('playerHref'); + expect(src).toMatch(/\/team\//); + }); + + it('data surface carries NO glitch classes', () => { + expect(src).not.toMatch(/wm-tear|glitch-shift|head-tear|glitch-hover/); + }); +}); + +describe('ExploreHub mounts NewsWire', () => { + const hub = fs.readFileSync(path.join(WEB, 'components', 'ExploreHub.tsx'), 'utf8'); + it('imports + mounts { + expect(hub).toContain('OFF_SEASON'); + expect(hub).toMatch(/isOffseason/); + }); +}); diff --git a/web/src/components/ExploreHub.tsx b/web/src/components/ExploreHub.tsx index 48f0821..0baf0ad 100644 --- a/web/src/components/ExploreHub.tsx +++ b/web/src/components/ExploreHub.tsx @@ -13,8 +13,11 @@ import { playerHref } from '@/lib/playerHref'; */ import StreaksPanel from '@/components/StreaksPanel'; import HotListPanel from '@/components/HotListPanel'; +import NewsWire from '@/components/vyndr/NewsWire'; +import FuturesBoard from '@/components/vyndr/FuturesBoard'; import { useAuth } from '@/contexts/AuthContext'; import { nextRunLabelET } from '@/lib/pipelineSchedule'; +import { OFF_SEASON } from '@/lib/emptyState'; interface Leader { player: string; @@ -54,6 +57,21 @@ export default function ExploreHub() { return q ? leaders.filter((l) => (l.player || '').toLowerCase().includes(q)) : leaders; }, [leaders, query]); + // Wave 2B — never-dark hub. When the selected sport is in its OFF-SEASON, the + // hub LEADS with futures + the wire (real, always-available data) instead of + // a dark leaderboard; in-season those sections COMPLEMENT the live board + // below. Each self-hides independently on an empty feed — no empty boxes. + const off = OFF_SEASON[sport as keyof typeof OFF_SEASON]; + const isOffseason = !!(off && off.months.includes(new Date().getMonth())); + + // Both sections self-hide (return null) when their feeds are empty. + const hubSections = ( + <> + + + + ); + return (
@@ -78,6 +96,9 @@ export default function ExploreHub() {
+ {/* OFF-SEASON LEAD — futures + wire come FIRST when the board is dark. */} + {isOffseason && hubSections} + {/* FILTER BAR */}
@@ -135,6 +156,9 @@ export default function ExploreHub() {
+ + {/* IN-SEASON — futures + wire COMPLEMENT the live board below it. */} + {!isOffseason && hubSections}
); } diff --git a/web/src/components/vyndr/FuturesBoard.tsx b/web/src/components/vyndr/FuturesBoard.tsx new file mode 100644 index 0000000..5adc076 --- /dev/null +++ b/web/src/components/vyndr/FuturesBoard.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import SportBadge from '@/components/vyndr/SportBadge'; +import SectionHead from '@/components/vyndr/SectionHead'; +import { moveColor, moveLabel } from '@/lib/futuresMove'; + +/* ============================================================ + FuturesBoard (Wave 2B — Offseason Hub). The never-dark FUTURES board: REAL + championship / win-total / award markets from `/api/futures/:sport`. + + HONESTY (Wave 3 line): futures are TRACKED, NOT GRADED — season-long + grading is a different model that does not exist yet. So this board SHOWS + prices + movement and carries an explicit "TRACKED · NOT GRADED" label. It + NEVER renders a grade on a future, and a price MOVE is shown ONLY when the + backend supplies one (never fabricated). Movement colors follow the + contract (shortening→green / drifting→amber / flat→dim) and NEVER red. + + SELF-HIDES when there are no markets (markets:[]). Prices are mono + + tabular (data-is-mono brand rule). + ============================================================ */ + +interface Selection { + name: string; + price: number | string; + prevPrice?: number | string; + move?: 'shortening' | 'drifting' | 'flat'; +} +interface FuturesMarket { + key: string; + title: string; + selections: Selection[]; +} + +export interface FuturesBoardProps { + sport: string; +} + +const card: React.CSSProperties = { background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16 }; + +// American-odds display: a signed integer keeps its sign; anything else prints +// as-is (absent beats a fabricated/mis-converted number). +function fmtPrice(price: number | string): string { + if (price == null || price === '') return '—'; + if (typeof price === 'number' && Number.isFinite(price)) { + return price > 0 ? `+${price}` : String(price); + } + const s = String(price); + return /^-?\d+$/.test(s) && Number(s) > 0 ? `+${s}` : s; +} + +export default function FuturesBoard({ sport }: FuturesBoardProps) { + const [markets, setMarkets] = useState(null); + + useEffect(() => { + let cancelled = false; + setMarkets(null); + fetch(`/api/futures/${encodeURIComponent(sport)}`) + .then((r) => (r.ok ? r.json() : { markets: [] })) + .then((d) => { if (!cancelled) setMarkets(Array.isArray(d?.markets) ? d.markets : []); }) + .catch(() => { if (!cancelled) setMarkets([]); }); + return () => { cancelled = true; }; + }, [sport]); + + const list = markets || []; + // SELF-HIDE: no markets (empty feed or still loading). + if (list.length === 0) return null; + + return ( +
+
+ FUTURES BOARD + + {/* The honesty line — futures are shown/tracked, not model-graded. */} + + TRACKED · NOT GRADED + +
+

+ Live book prices, tracked over time. VYNDR does not grade futures — season-long grading is a separate model. +

+ +
+ {list.map((m) => ( +
+
{m.title}
+
+ {(Array.isArray(m.selections) ? m.selections : []).map((sel, i) => { + const label = moveLabel(sel.move); + return ( +
+ {sel.name} + {/* Movement — ONLY when the backend supplied a `move`. */} + {label && ( + {label} + )} + {fmtPrice(sel.price)} +
+ ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/web/src/components/vyndr/NewsWire.tsx b/web/src/components/vyndr/NewsWire.tsx new file mode 100644 index 0000000..0351806 --- /dev/null +++ b/web/src/components/vyndr/NewsWire.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import SportBadge from '@/components/vyndr/SportBadge'; +import SectionHead from '@/components/vyndr/SectionHead'; +import { playerHref } from '@/lib/playerHref'; +import { timeAgo, typeLabel } from '@/lib/newsFormat'; + +/* ============================================================ + NewsWire (Wave 2B — Offseason Hub). The never-dark LEAGUE WIRE: REAL ESPN + headlines (`/api/news/:sport`) + REAL injuries (`/api/schedule/:sport/ + injuries`), fed into the retired TerminalTemplates INJURY_WIRE layout. + + HONESTY: everything real or absent. No sample data ever reaches here. The + whole component SELF-HIDES when both feeds are empty (news items:[] AND no + injuries) — the offseason hub shows what is genuinely available or nothing. + Timestamps + statuses are mono (the data-is-mono brand rule). + ============================================================ */ + +interface Athlete { name: string; key?: string } +interface NewsItem { + id: string | number; + headline: string; + description?: string; + published?: string | number; + type?: string; + athlete?: Athlete | null; + team?: string | null; + href?: string | null; +} + +export interface NewsWireProps { + sport: string; +} + +// Injury status → color token. OUT is a genuine availability negative (red); +// GTD/questionable is caution (amber); probable/other reads neutral. Tokens +// only — no raw hex (QA parity rule). +function statusColor(s: string): string { + const v = String(s || '').toUpperCase(); + if (v === 'OUT') return 'var(--miss)'; + if (v === 'GTD' || v === 'QUESTIONABLE' || v === 'DTD') return 'var(--amber)'; + return 'var(--text-1)'; +} + +// nameKey ("aaron judge") → display ("Aaron Judge"). Real data, just cased. +function titleCase(key: string): string { + return String(key || '') + .split(/\s+/) + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + +const card: React.CSSProperties = { background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16 }; + +export default function NewsWire({ sport }: NewsWireProps) { + const [items, setItems] = useState(null); + const [injuries, setInjuries] = useState | null>(null); + + useEffect(() => { + let cancelled = false; + setItems(null); + setInjuries(null); + // News feed (Wave 2A contract). Self-hide on any failure — never invent. + fetch(`/api/news/${encodeURIComponent(sport)}`) + .then((r) => (r.ok ? r.json() : { items: [] })) + .then((d) => { if (!cancelled) setItems(Array.isArray(d?.items) ? d.items : []); }) + .catch(() => { if (!cancelled) setItems([]); }); + // Injury wire (already-live contract: { byPlayer: { nameKey -> STATUS } }). + fetch(`/api/schedule/${encodeURIComponent(sport)}/injuries`) + .then((r) => (r.ok ? r.json() : { byPlayer: {} })) + .then((d) => { + if (cancelled) return; + const bp = d && typeof d.byPlayer === 'object' && d.byPlayer ? d.byPlayer : {}; + setInjuries(Object.entries(bp).map(([k, v]) => [k, String(v)] as [string, string])); + }) + .catch(() => { if (!cancelled) setInjuries([]); }); + return () => { cancelled = true; }; + }, [sport]); + + // Newest-first (defensive — the backend already sorts, but never trust order). + const news = useMemo(() => { + const list = Array.isArray(items) ? [...items] : []; + return list.sort((a, b) => { + const ta = a.published ? Date.parse(String(a.published)) : 0; + const tb = b.published ? Date.parse(String(b.published)) : 0; + return (Number.isFinite(tb) ? tb : 0) - (Number.isFinite(ta) ? ta : 0); + }); + }, [items]); + + const inj = injuries || []; + + // SELF-HIDE: both feeds empty (still loading = null on both → also hidden). + if (news.length === 0 && inj.length === 0) return null; + + return ( +
+
+ LEAGUE WIRE · NEWS + INJURIES + +
+ + {/* INJURY WIRE — real chips (self-hides its own block when empty). */} + {inj.length > 0 && ( +
+
INJURY REPORT · {inj.length} FLAGGED
+
+ {inj.map(([key, status]) => ( + + {titleCase(key)} + {String(status).toUpperCase()} + + ))} +
+
+ )} + + {/* NEWS HEADLINES — real ESPN feed, newest first (self-hides when empty). */} + {news.length > 0 && ( +
+ {news.map((n) => { + const stamp = timeAgo(n.published); + const chip = typeLabel(n.type); + const HeadlineTag = n.href ? 'a' : 'div'; + return ( +
+
+ {chip && {chip}} + {stamp && {stamp}} +
+ + {n.headline} + + {n.description && ( +

{n.description}

+ )} + {(n.athlete?.name || n.team) && ( +
+ {n.athlete?.name && ( + ↳ {n.athlete.name} + )} + {n.team && ( + {n.team} + )} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index 656ecde..9d1a35b 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -10,6 +10,12 @@ export { default as Sparkline } from './Sparkline'; export { default as Ticker } from './Ticker'; export { default as EmptyState } from './EmptyState'; export { default as MarketBreadth } from './MarketBreadth'; + +/* Wave 2B — Offseason / never-dark hub */ +export { default as NewsWire } from './NewsWire'; +export type { NewsWireProps } from './NewsWire'; +export { default as FuturesBoard } from './FuturesBoard'; +export type { FuturesBoardProps } from './FuturesBoard'; export type { EmptyStateProps, EmptyStateAction } from './EmptyState'; export { default as GradeResultCard } from './GradeResultCard'; export type { GradeResultData } from './GradeResultCard'; diff --git a/web/src/lib/futuresMove.js b/web/src/lib/futuresMove.js new file mode 100644 index 0000000..99d6d0b --- /dev/null +++ b/web/src/lib/futuresMove.js @@ -0,0 +1,40 @@ +/* ============================================================ + VYNDR — FUTURES MOVEMENT color/label (Wave 2B, Offseason Hub). + + The color contract for a futures selection's price movement. HONESTY RULE: + futures are TRACKED, not model-graded — a movement is only ever shown when + the backend actually supplies a `move` (never fabricated). The color space + is deliberately narrow and NEVER red: + • shortening → the market moved TOWARD the selection → green (value/steam) + • drifting → the market moved AWAY → amber (caution) + • flat / absent → no movement to report → dim (say less) + Red is reserved system-wide for settled-negative outcomes; a futures drift + is NOT a miss, so it must never render red. + + CommonJS so the .tsx surface imports it (allowJs) AND Jest exercises it. + ============================================================ */ + +// Canonical move → CSS custom-property token. No branch returns the red +// (--miss) token — a normal drift is caution (amber), never a miss. +const MOVE_COLOR = { + shortening: 'var(--g-a)', // green — toward the selection (value) + drifting: 'var(--amber)', // amber — away from the selection (caution) + flat: 'var(--text-2)', // dim — no movement +}; + +/** moveColor(move) — the token for a move, defaulting to dim for flat/absent. */ +function moveColor(move) { + const m = String(move || '').toLowerCase(); + return MOVE_COLOR[m] || MOVE_COLOR.flat; +} + +/** moveLabel(move) — a short mono glyph+word, or '' when there is no move. */ +function moveLabel(move) { + const m = String(move || '').toLowerCase(); + if (m === 'shortening') return '▼ SHORTENING'; + if (m === 'drifting') return '▲ DRIFTING'; + if (m === 'flat') return '— FLAT'; + return ''; +} + +module.exports = { MOVE_COLOR, moveColor, moveLabel }; diff --git a/web/src/lib/newsFormat.js b/web/src/lib/newsFormat.js new file mode 100644 index 0000000..e40b9bd --- /dev/null +++ b/web/src/lib/newsFormat.js @@ -0,0 +1,53 @@ +/* ============================================================ + VYNDR — NEWS WIRE formatting (Wave 2B, Offseason Hub). + + Small pure helpers for the real-ESPN news wire. Timestamps are rendered in + mono (the brand rule: all data is mono); an unparseable/absent published + time yields '' (absent beats a fabricated "just now"). Type chips map the + raw ESPN feed type to a short human label without inventing categories. + + CommonJS so the .tsx surface imports it (allowJs) AND Jest exercises it. + ============================================================ */ + +/** + * timeAgo(published, now) — compact relative time for a mono timestamp. + * Invalid / missing input → '' (never a fabricated stamp). `published` is an + * ISO string or epoch ms; `now` defaults to Date.now(). + */ +function timeAgo(published, now = Date.now()) { + if (published == null || published === '') return ''; + const t = typeof published === 'number' ? published : Date.parse(String(published)); + if (!Number.isFinite(t)) return ''; + const diff = Number(now) - t; + if (!Number.isFinite(diff)) return ''; + if (diff < 0) return 'now'; + const min = Math.floor(diff / 60000); + if (min < 1) return 'now'; + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h ago`; + const day = Math.floor(hr / 24); + return `${day}d ago`; +} + +// Raw ESPN feed types → short display labels. Unknown types fall back to a +// title-cased version of the raw string (never dropped, never invented). +const TYPE_LABEL = { + Recap: 'RECAP', + HeadlineNews: 'NEWS', + Story: 'STORY', + Preview: 'PREVIEW', + Notebook: 'NOTEBOOK', + Media: 'MEDIA', +}; + +/** typeLabel(type) — short uppercase chip label, or '' when absent. */ +function typeLabel(type) { + if (!type) return ''; + const raw = String(type); + if (TYPE_LABEL[raw]) return TYPE_LABEL[raw]; + // split camelCase / snake and uppercase + return raw.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').trim().toUpperCase(); +} + +module.exports = { timeAgo, typeLabel, TYPE_LABEL };