diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 0d8e7f6..c1e2ddd 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,5 +1,21 @@ import { useState } from 'react'; import { EventExplorerPage } from './pages/EventExplorerPage'; +import { DeliveryHeatmap } from './components/DeliveryHeatmap'; +import { ThemeToggle } from './components/ThemeToggle'; +import { useTheme } from './hooks/useTheme'; +import { useEventStore } from './store/eventStore'; + +export function App() { + const { theme, toggleTheme } = useTheme(); + const events = useEventStore((state) => state.events); + + return ( +
+
+ +
+ + import { TemplatePreviewDemoPage } from './pages/TemplatePreviewDemoPage'; type Page = 'events' | 'templates'; diff --git a/dashboard/src/components/DeliveryHeatmap.tsx b/dashboard/src/components/DeliveryHeatmap.tsx new file mode 100644 index 0000000..3f6efad --- /dev/null +++ b/dashboard/src/components/DeliveryHeatmap.tsx @@ -0,0 +1,292 @@ +import { memo, useCallback, useMemo, useRef, useState } from 'react'; +import type { BlockchainEvent } from '../types/event'; +import { + aggregateHeatmapData, + DAY_LABELS, + HOUR_LABELS, + intensityLevel, +} from '../utils/heatmapData'; + +interface HeatmapDateRange { + start: string; // yyyy-mm-dd + end: string; +} + +interface DeliveryHeatmapProps { + events: BlockchainEvent[]; +} + +/* ── Tooltip state ── */ +interface TooltipState { + day: number; + hour: number; + count: number; + x: number; + y: number; +} + +const CELL_SIZE = 38; +const CELL_GAP = 3; +const CELL_RADIUS = 6; + +/** Parses a yyyy-mm-dd string to a Date at start-of-day, or null if empty. */ +function parseDateInput(value: string): Date | null { + if (!value) return null; + const d = new Date(value + 'T00:00:00'); + return isNaN(d.getTime()) ? null : d; +} + +/** Parses end-of-day (23:59:59.999) so the full day is included. */ +function parseEndDate(value: string): Date | null { + if (!value) return null; + const d = new Date(value + 'T23:59:59.999'); + return isNaN(d.getTime()) ? null : d; +} + +/** Quick-range presets for the date filter. */ +const PRESETS = [ + { label: 'Last 24 h', days: 1 }, + { label: '7 days', days: 7 }, + { label: '30 days', days: 30 }, + { label: '90 days', days: 90 }, + { label: 'All time', days: 0 }, +] as const; + +function DeliveryHeatmapInner({ events }: DeliveryHeatmapProps) { + const [dateRange, setDateRange] = useState({ start: '', end: '' }); + const [tooltip, setTooltip] = useState(null); + const gridRef = useRef(null); + + /* ── Date range helpers ── */ + const startDate = useMemo(() => parseDateInput(dateRange.start), [dateRange.start]); + const endDate = useMemo(() => parseEndDate(dateRange.end), [dateRange.end]); + + const handleStartChange = useCallback( + (e: React.ChangeEvent) => + setDateRange((prev) => ({ ...prev, start: e.target.value })), + [] + ); + const handleEndChange = useCallback( + (e: React.ChangeEvent) => + setDateRange((prev) => ({ ...prev, end: e.target.value })), + [] + ); + + const handlePreset = useCallback((days: number) => { + if (days === 0) { + setDateRange({ start: '', end: '' }); + return; + } + const now = new Date(); + const start = new Date(now.getTime() - days * 86_400_000); + setDateRange({ + start: start.toISOString().slice(0, 10), + end: now.toISOString().slice(0, 10), + }); + }, []); + + const handleClearDates = useCallback(() => { + setDateRange({ start: '', end: '' }); + }, []); + + /* ── Heatmap data (memoised) ── */ + const heatmap = useMemo( + () => aggregateHeatmapData(events, startDate, endDate), + [events, startDate, endDate] + ); + + /* ── Tooltip handlers ── */ + const handleCellEnter = useCallback( + (day: number, hour: number, count: number, el: HTMLElement) => { + if (!gridRef.current) return; + const gridRect = gridRef.current.getBoundingClientRect(); + const cellRect = el.getBoundingClientRect(); + setTooltip({ + day, + hour, + count, + x: cellRect.left - gridRect.left + cellRect.width / 2, + y: cellRect.top - gridRect.top, + }); + }, + [] + ); + + const handleCellLeave = useCallback(() => setTooltip(null), []); + + /* ── Legend ── */ + const legendLevels = [0, 1, 2, 3, 4]; + + const hasFilters = dateRange.start !== '' || dateRange.end !== ''; + + return ( +
+ {/* ── Header ── */} +
+
+

Analytics

+

Delivery Activity Heatmap

+

+ Notification delivery patterns by day of week and hour of day +

+
+ +
+
+ + {heatmap.totalCount.toLocaleString()} + + + {hasFilters ? 'Filtered' : 'Total'} Events + +
+
+ + {heatmap.maxCount.toLocaleString()} + + Peak (per slot) +
+
+
+ + {/* ── Date-range filters ── */} +
+
+ {PRESETS.map((preset) => { + const isActive = + preset.days === 0 + ? !dateRange.start && !dateRange.end + : false; // full active-state logic lives in CSS via data attribute + return ( + + ); + })} +
+ +
+ + + {hasFilters && ( + + )} +
+
+ + {/* ── Heatmap grid ── */} +
+ {tooltip && ( +
+ {tooltip.count.toLocaleString()}{' '} + {tooltip.count === 1 ? 'event' : 'events'} +
+ + {DAY_LABELS[tooltip.day]}, {HOUR_LABELS[tooltip.hour]} + +
+ )} + + {/* Hour labels (top) */} +
+
+ {HOUR_LABELS.map((label, i) => ( +
+ {i % 3 === 0 ? label : ''} +
+ ))} +
+ + {/* Rows (one per day-of-week) */} + {heatmap.cells.map((hours, dayIdx) => ( +
+
{DAY_LABELS[dayIdx]}
+ {hours.map((cell) => ( +
+ handleCellEnter(cell.day, cell.hour, cell.count, e.currentTarget) + } + onMouseLeave={handleCellLeave} + onFocus={(e) => + handleCellEnter(cell.day, cell.hour, cell.count, e.currentTarget) + } + onBlur={handleCellLeave} + tabIndex={0} + role="gridcell" + /> + ))} +
+ ))} +
+ + {/* ── Legend ── */} +
+ Less + {legendLevels.map((level) => ( +
+ ))} + More +
+
+ ); +} + +export const DeliveryHeatmap = memo(DeliveryHeatmapInner); diff --git a/dashboard/src/components/ThemeToggle.tsx b/dashboard/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..9707c87 --- /dev/null +++ b/dashboard/src/components/ThemeToggle.tsx @@ -0,0 +1,56 @@ +import { useCallback } from 'react'; +import type { Theme } from '../hooks/useTheme'; + +interface ThemeToggleProps { + theme: Theme; + onToggle: () => void; +} + +/** + * A sleek toggle switch for dark / light theme. + * Renders a sun ☀ and moon 🌙 icon pair. + */ +export function ThemeToggle({ theme, onToggle }: ThemeToggleProps) { + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggle(); + } + }, + [onToggle] + ); + + return ( + + ); +} diff --git a/dashboard/src/hooks/useTheme.ts b/dashboard/src/hooks/useTheme.ts new file mode 100644 index 0000000..ef09429 --- /dev/null +++ b/dashboard/src/hooks/useTheme.ts @@ -0,0 +1,50 @@ +import { useState, useEffect, useCallback } from 'react'; + +export type Theme = 'dark' | 'light'; + +const STORAGE_KEY = 'notify-chain-theme'; + +function getInitialTheme(): Theme { + if (typeof window === 'undefined') return 'dark'; + + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'dark' || stored === 'light') return stored; + + // Respect OS preference + if (window.matchMedia?.('(prefers-color-scheme: light)').matches) { + return 'light'; + } + + return 'dark'; +} + +function applyTheme(theme: Theme): void { + document.documentElement.setAttribute('data-theme', theme); +} + +/** + * Manages the dark/light theme toggle. Persists choice to localStorage + * and applies a `data-theme` attribute to `` for CSS consumption. + */ +export function useTheme() { + const [theme, setThemeState] = useState(getInitialTheme); + + useEffect(() => { + applyTheme(theme); + }, [theme]); + + const toggleTheme = useCallback(() => { + setThemeState((prev) => { + const next = prev === 'dark' ? 'light' : 'dark'; + localStorage.setItem(STORAGE_KEY, next); + return next; + }); + }, []); + + const setTheme = useCallback((t: Theme) => { + localStorage.setItem(STORAGE_KEY, t); + setThemeState(t); + }, []); + + return { theme, toggleTheme, setTheme } as const; +} diff --git a/dashboard/src/index.css b/dashboard/src/index.css index d06e575..a59a356 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -5,6 +5,140 @@ font-weight: 400; color: #e8eaed; background: #0b0d12; + + /* ── Heatmap design tokens (dark) ── */ + --hm-bg: rgba(255, 255, 255, 0.02); + --hm-border: rgba(255, 255, 255, 0.08); + --hm-cell-empty: rgba(255, 255, 255, 0.045); + --hm-cell-l1: #0e4429; + --hm-cell-l2: #006d32; + --hm-cell-l3: #26a641; + --hm-cell-l4: #39d353; + --hm-cell-hover-ring: rgba(255, 255, 255, 0.25); + --hm-text-primary: #e8eaed; + --hm-text-secondary: #9aa0a6; + --hm-text-tertiary: #6b7280; + --hm-tooltip-bg: #1e2128; + --hm-tooltip-border: rgba(255, 255, 255, 0.14); + --hm-input-bg: #12151c; + --hm-input-border: rgba(255, 255, 255, 0.12); + --hm-preset-bg: rgba(255, 255, 255, 0.06); + --hm-preset-hover: rgba(255, 255, 255, 0.12); + --hm-preset-active-bg: rgba(96, 165, 250, 0.14); + --hm-preset-active-color: #93c5fd; + --hm-accent: #60a5fa; + --hm-stat-glow: rgba(96, 165, 250, 0.08); +} + +/* ── Light theme overrides ── */ +[data-theme="light"] { + color-scheme: light; + color: #1a1a2e; + background: #f5f5f9; + + --hm-bg: rgba(0, 0, 0, 0.02); + --hm-border: rgba(0, 0, 0, 0.1); + --hm-cell-empty: rgba(0, 0, 0, 0.06); + --hm-cell-l1: #c4b5fd; + --hm-cell-l2: #8b5cf6; + --hm-cell-l3: #7c3aed; + --hm-cell-l4: #6d28d9; + --hm-cell-hover-ring: rgba(0, 0, 0, 0.2); + --hm-text-primary: #1a1a2e; + --hm-text-secondary: #4b5563; + --hm-text-tertiary: #9ca3af; + --hm-tooltip-bg: #ffffff; + --hm-tooltip-border: rgba(0, 0, 0, 0.12); + --hm-input-bg: #ffffff; + --hm-input-border: rgba(0, 0, 0, 0.15); + --hm-preset-bg: rgba(0, 0, 0, 0.05); + --hm-preset-hover: rgba(0, 0, 0, 0.1); + --hm-preset-active-bg: rgba(124, 58, 237, 0.12); + --hm-preset-active-color: #7c3aed; + --hm-accent: #7c3aed; + --hm-stat-glow: rgba(124, 58, 237, 0.08); +} + +/* Light theme overrides for existing components */ +[data-theme="light"] body { + background: #f5f5f9; +} + +[data-theme="light"] .event-filters { + border-color: rgba(0, 0, 0, 0.1); + background: rgba(0, 0, 0, 0.02); +} + +[data-theme="light"] .event-filters input, +[data-theme="light"] .event-filters select { + border-color: rgba(0, 0, 0, 0.15); + background: #ffffff; + color: #1a1a2e; +} + +[data-theme="light"] .event-filters label { + color: #4b5563; +} + +[data-theme="light"] .event-explorer__eyebrow { + color: var(--hm-accent); +} + +[data-theme="light"] .event-explorer__lead { + color: #4b5563; +} + +[data-theme="light"] .event-explorer__table-wrapper { + border-color: rgba(0, 0, 0, 0.1); + background: rgba(255, 255, 255, 0.7); +} + +[data-theme="light"] .event-explorer__table-header { + color: #6b7280; + background: rgba(0, 0, 0, 0.03); +} + +[data-theme="light"] .event-explorer__row { + border-top-color: rgba(0, 0, 0, 0.08); +} + +[data-theme="light"] .event-explorer__summary, +[data-theme="light"] .event-explorer__loading-note, +[data-theme="light"] .event-explorer__status-row { + color: #6b7280; +} + +[data-theme="light"] .event-card { + border-color: rgba(0, 0, 0, 0.1); + background: rgba(255, 255, 255, 0.8); +} + +[data-theme="light"] .pagination-controls__button, +[data-theme="light"] .pagination-controls__select { + border-color: rgba(0, 0, 0, 0.15); + background: rgba(255, 255, 255, 0.8); + color: #1a1a2e; +} + +[data-theme="light"] .pagination-controls__summary, +[data-theme="light"] .pagination-controls__label { + color: #6b7280; +} + +[data-theme="light"] .wallet-connect__button { + border-color: rgba(0, 0, 0, 0.15); + background: rgba(0, 0, 0, 0.04); + color: #1a1a2e; +} + +[data-theme="light"] .wallet-connect__address { + color: #6b7280; +} + +[data-theme="light"] .event-explorer__empty-state { + border-color: rgba(0, 0, 0, 0.16); + background: rgba(0, 0, 0, 0.02); + color: #4b5563; } @@ -34,6 +168,13 @@ body { max-width: 1100px; margin: 0 auto; padding: 24px; + padding-bottom: 48px; +} + +.app__theme-bar { + display: flex; + justify-content: flex-end; + padding: 8px 0 0; } .app-nav { @@ -2976,3 +3117,421 @@ body { align-items: stretch; } } + +/* ═══════════════════════════════════════════════════════════════════════════ + Delivery Heatmap + ═══════════════════════════════════════════════════════════════════════════ */ + +.heatmap { + border: 1px solid var(--hm-border); + border-radius: 20px; + background: var(--hm-bg); + backdrop-filter: blur(12px); + padding: 28px 28px 22px; + display: grid; + gap: 22px; + animation: heatmap-fade-in 0.45s ease-out; +} + +@keyframes heatmap-fade-in { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Header */ +.heatmap__header { + display: flex; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; +} + +.heatmap__eyebrow { + margin: 0 0 6px; + font-size: 0.78rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--hm-accent); + font-weight: 700; +} + +.heatmap__title { + margin: 0 0 6px; + font-size: 1.35rem; + font-weight: 700; + color: var(--hm-text-primary); +} + +.heatmap__subtitle { + margin: 0; + font-size: 0.9rem; + color: var(--hm-text-secondary); + max-width: 480px; + line-height: 1.6; +} + +/* Stats */ +.heatmap__stats { + display: flex; + gap: 16px; + flex-shrink: 0; +} + +.heatmap__stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 14px 20px; + border-radius: 14px; + background: var(--hm-stat-glow); + border: 1px solid var(--hm-border); + min-width: 100px; +} + +.heatmap__stat-value { + font-size: 1.6rem; + font-weight: 800; + color: var(--hm-accent); + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.heatmap__stat-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--hm-text-tertiary); + font-weight: 600; +} + +/* ── Date filters ── */ +.heatmap__filters { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + flex-wrap: wrap; + padding: 14px 18px; + border-radius: 14px; + border: 1px solid var(--hm-border); + background: var(--hm-bg); +} + +.heatmap__presets { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.heatmap__preset-btn { + border: 1px solid var(--hm-input-border); + border-radius: 8px; + padding: 6px 14px; + background: var(--hm-preset-bg); + color: var(--hm-text-secondary); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: all 0.18s ease; + white-space: nowrap; +} + +.heatmap__preset-btn:hover { + background: var(--hm-preset-hover); + color: var(--hm-text-primary); + border-color: var(--hm-cell-hover-ring); +} + +.heatmap__preset-btn--active, +.heatmap__preset-btn:focus-visible { + background: var(--hm-preset-active-bg); + color: var(--hm-preset-active-color); + border-color: var(--hm-preset-active-color); + outline: none; +} + +.heatmap__date-inputs { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.heatmap__date-field { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.82rem; + color: var(--hm-text-secondary); +} + +.heatmap__date-field input[type="date"] { + border: 1px solid var(--hm-input-border); + border-radius: 8px; + padding: 6px 10px; + background: var(--hm-input-bg); + color: var(--hm-text-primary); + font-size: 0.82rem; + font-family: inherit; + transition: border-color 0.15s ease; +} + +.heatmap__date-field input[type="date"]:focus { + border-color: var(--hm-accent); + outline: none; +} + +.heatmap__clear-btn { + border: 1px solid var(--hm-input-border); + border-radius: 8px; + padding: 6px 12px; + background: transparent; + color: var(--hm-text-secondary); + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.heatmap__clear-btn:hover { + background: rgba(248, 113, 113, 0.12); + color: #f87171; + border-color: rgba(248, 113, 113, 0.3); +} + +/* ── Grid ── */ +.heatmap__grid-container { + position: relative; + overflow-x: auto; + padding: 4px 0; + -webkit-overflow-scrolling: touch; +} + +.heatmap__hour-labels { + display: flex; + padding-left: 2px; + margin-bottom: 4px; +} + +.heatmap__corner { + width: 44px; + flex-shrink: 0; +} + +.heatmap__hour-label { + font-size: 0.7rem; + color: var(--hm-text-tertiary); + text-align: center; + flex-shrink: 0; + user-select: none; +} + +.heatmap__row { + display: flex; + align-items: center; +} + +.heatmap__day-label { + width: 44px; + flex-shrink: 0; + font-size: 0.78rem; + font-weight: 600; + color: var(--hm-text-secondary); + user-select: none; +} + +/* ── Cells ── */ +.heatmap__cell { + flex-shrink: 0; + transition: transform 0.15s ease, box-shadow 0.15s ease; + cursor: default; + outline: none; +} + +.heatmap__cell:hover, +.heatmap__cell:focus-visible { + transform: scale(1.2); + box-shadow: 0 0 0 2px var(--hm-cell-hover-ring); + z-index: 2; +} + +.heatmap__cell--level-0 { background: var(--hm-cell-empty); } +.heatmap__cell--level-1 { background: var(--hm-cell-l1); } +.heatmap__cell--level-2 { background: var(--hm-cell-l2); } +.heatmap__cell--level-3 { background: var(--hm-cell-l3); } +.heatmap__cell--level-4 { background: var(--hm-cell-l4); } + +/* Cell entrance animation (staggered per row) */ +.heatmap__row:nth-child(2) .heatmap__cell { animation: hm-cell-pop 0.3s ease-out 0.05s both; } +.heatmap__row:nth-child(3) .heatmap__cell { animation: hm-cell-pop 0.3s ease-out 0.1s both; } +.heatmap__row:nth-child(4) .heatmap__cell { animation: hm-cell-pop 0.3s ease-out 0.15s both; } +.heatmap__row:nth-child(5) .heatmap__cell { animation: hm-cell-pop 0.3s ease-out 0.2s both; } +.heatmap__row:nth-child(6) .heatmap__cell { animation: hm-cell-pop 0.3s ease-out 0.25s both; } +.heatmap__row:nth-child(7) .heatmap__cell { animation: hm-cell-pop 0.3s ease-out 0.3s both; } +.heatmap__row:nth-child(8) .heatmap__cell { animation: hm-cell-pop 0.3s ease-out 0.35s both; } + +@keyframes hm-cell-pop { + from { opacity: 0; transform: scale(0.6); } + to { opacity: 1; transform: scale(1); } +} + +/* ── Tooltip ── */ +.heatmap__tooltip { + position: absolute; + transform: translate(-50%, -100%) translateY(-10px); + background: var(--hm-tooltip-bg); + border: 1px solid var(--hm-tooltip-border); + border-radius: 10px; + padding: 8px 14px; + font-size: 0.82rem; + color: var(--hm-text-primary); + white-space: nowrap; + pointer-events: none; + z-index: 10; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); + animation: hm-tooltip-in 0.15s ease-out; +} + +.heatmap__tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: var(--hm-tooltip-bg); +} + +@keyframes hm-tooltip-in { + from { opacity: 0; transform: translate(-50%, -100%) translateY(-6px); } + to { opacity: 1; transform: translate(-50%, -100%) translateY(-10px); } +} + +.heatmap__tooltip strong { + font-weight: 800; + color: var(--hm-accent); +} + +.heatmap__tooltip-sub { + font-size: 0.72rem; + color: var(--hm-text-tertiary); +} + +/* ── Legend ── */ +.heatmap__legend { + display: flex; + align-items: center; + gap: 5px; + justify-content: flex-end; +} + +.heatmap__legend-label { + font-size: 0.72rem; + color: var(--hm-text-tertiary); + margin: 0 4px; +} + +.heatmap__legend-cell { + transition: transform 0.15s ease; +} + +.heatmap__legend-cell:hover { + transform: scale(1.3); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Theme Toggle + ═══════════════════════════════════════════════════════════════════════════ */ + +.theme-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid var(--hm-input-border); + border-radius: 999px; + padding: 4px 6px; + background: var(--hm-preset-bg); + cursor: pointer; + transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; +} + +.theme-toggle:hover { + background: var(--hm-preset-hover); + border-color: var(--hm-cell-hover-ring); +} + +.theme-toggle:focus-visible { + outline: 2px solid var(--hm-accent); + outline-offset: 2px; +} + +.theme-toggle__icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + color: var(--hm-text-tertiary); + transition: all 0.22s ease; +} + +.theme-toggle__icon--active { + background: var(--hm-preset-active-bg); + color: var(--hm-accent); + box-shadow: 0 0 12px var(--hm-stat-glow); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Heatmap Responsive + ═══════════════════════════════════════════════════════════════════════════ */ + +@media (max-width: 900px) { + .heatmap { + padding: 20px 16px 18px; + border-radius: 16px; + } + + .heatmap__header { + flex-direction: column; + gap: 14px; + } + + .heatmap__stats { + order: -1; + align-self: flex-start; + } + + .heatmap__filters { + flex-direction: column; + align-items: stretch; + } + + .heatmap__presets { + overflow-x: auto; + flex-wrap: nowrap; + padding-bottom: 4px; + -webkit-overflow-scrolling: touch; + } + + .heatmap__date-inputs { + flex-wrap: wrap; + } +} + +@media (max-width: 680px) { + .heatmap__stat { + padding: 10px 14px; + min-width: 80px; + } + + .heatmap__stat-value { + font-size: 1.25rem; + } + + .heatmap__title { + font-size: 1.15rem; + } +} + diff --git a/dashboard/src/test/heatmapData.test.ts b/dashboard/src/test/heatmapData.test.ts new file mode 100644 index 0000000..76a74f7 --- /dev/null +++ b/dashboard/src/test/heatmapData.test.ts @@ -0,0 +1,145 @@ +import { + aggregateHeatmapData, + filterEventsByDateRange, + intensityLevel, +} from '../utils/heatmapData'; +import type { BlockchainEvent } from '../types/event'; + +function makeEvent(overrides: Partial = {}): BlockchainEvent { + return { + eventId: 'evt-1', + contractAddress: 'CABC', + eventName: 'TaskCreated', + ledger: 100, + type: 'contract', + topic: ['TaskCreated'], + value: '42', + txHash: 'tx-0001', + receivedAt: Date.now(), + ...overrides, + }; +} + +describe('filterEventsByDateRange', () => { + const events = [ + makeEvent({ eventId: 'e1', receivedAt: new Date('2026-01-10T12:00:00Z').getTime() }), + makeEvent({ eventId: 'e2', receivedAt: new Date('2026-02-15T08:00:00Z').getTime() }), + makeEvent({ eventId: 'e3', receivedAt: new Date('2026-03-20T20:00:00Z').getTime() }), + ]; + + it('returns all events when no range is provided', () => { + expect(filterEventsByDateRange(events, null, null)).toHaveLength(3); + }); + + it('filters events after start date', () => { + const start = new Date('2026-02-01T00:00:00Z'); + const result = filterEventsByDateRange(events, start, null); + expect(result).toHaveLength(2); + expect(result[0].eventId).toBe('e2'); + }); + + it('filters events before end date', () => { + const end = new Date('2026-02-28T23:59:59Z'); + const result = filterEventsByDateRange(events, null, end); + expect(result).toHaveLength(2); + expect(result[1].eventId).toBe('e2'); + }); + + it('filters events within start and end dates', () => { + const start = new Date('2026-02-01T00:00:00Z'); + const end = new Date('2026-02-28T23:59:59Z'); + const result = filterEventsByDateRange(events, start, end); + expect(result).toHaveLength(1); + expect(result[0].eventId).toBe('e2'); + }); +}); + +describe('aggregateHeatmapData', () => { + it('returns a 7×24 grid with all zeros for an empty array', () => { + const result = aggregateHeatmapData([]); + expect(result.cells).toHaveLength(7); + result.cells.forEach((row) => { + expect(row).toHaveLength(24); + row.forEach((cell) => expect(cell.count).toBe(0)); + }); + expect(result.maxCount).toBe(0); + expect(result.totalCount).toBe(0); + expect(result.dateRange).toBeNull(); + }); + + it('buckets events into the correct day/hour cells', () => { + // Wed, Jan 7 2026 14:30:00 UTC → getDay()=3 (Wed), getHours() depends on locale, + // so we use a fixed approach. + const date = new Date('2026-01-07T14:30:00'); + const day = date.getDay(); + const hour = date.getHours(); + + const events = [ + makeEvent({ eventId: 'e1', receivedAt: date.getTime() }), + makeEvent({ eventId: 'e2', receivedAt: date.getTime() + 60_000 }), + ]; + + const result = aggregateHeatmapData(events); + expect(result.cells[day][hour].count).toBe(2); + expect(result.maxCount).toBe(2); + expect(result.totalCount).toBe(2); + }); + + it('normalises intensities correctly', () => { + const baseDate = new Date('2026-03-02T10:00:00'); // Monday + const events = [ + makeEvent({ eventId: 'e1', receivedAt: baseDate.getTime() }), + makeEvent({ eventId: 'e2', receivedAt: baseDate.getTime() + 1000 }), + makeEvent({ eventId: 'e3', receivedAt: baseDate.getTime() + 2000 }), + makeEvent({ eventId: 'e4', receivedAt: baseDate.getTime() + 3000 }), + ]; + + const result = aggregateHeatmapData(events); + const day = baseDate.getDay(); + const hour = baseDate.getHours(); + + // All 4 events in the same cell → intensity = 1.0 + expect(result.cells[day][hour].intensity).toBe(1); + // An empty cell → intensity = 0 + expect(result.cells[(day + 1) % 7][hour].intensity).toBe(0); + }); + + it('respects date range filter', () => { + const events = [ + makeEvent({ eventId: 'e1', receivedAt: new Date('2026-01-05T08:00:00').getTime() }), + makeEvent({ eventId: 'e2', receivedAt: new Date('2026-06-15T14:00:00').getTime() }), + ]; + + const start = new Date('2026-06-01T00:00:00'); + const end = new Date('2026-06-30T23:59:59'); + const result = aggregateHeatmapData(events, start, end); + + expect(result.totalCount).toBe(1); + }); +}); + +describe('intensityLevel', () => { + it('returns 0 for zero intensity', () => { + expect(intensityLevel(0)).toBe(0); + }); + + it('returns 1 for low intensity', () => { + expect(intensityLevel(0.1)).toBe(1); + expect(intensityLevel(0.24)).toBe(1); + }); + + it('returns 2 for medium-low intensity', () => { + expect(intensityLevel(0.25)).toBe(2); + expect(intensityLevel(0.49)).toBe(2); + }); + + it('returns 3 for medium-high intensity', () => { + expect(intensityLevel(0.5)).toBe(3); + expect(intensityLevel(0.74)).toBe(3); + }); + + it('returns 4 for high intensity', () => { + expect(intensityLevel(0.75)).toBe(4); + expect(intensityLevel(1.0)).toBe(4); + }); +}); diff --git a/dashboard/src/utils/heatmapData.ts b/dashboard/src/utils/heatmapData.ts new file mode 100644 index 0000000..f9fecc5 --- /dev/null +++ b/dashboard/src/utils/heatmapData.ts @@ -0,0 +1,119 @@ +import type { BlockchainEvent } from '../types/event'; + +/** + * Represents a single cell in the heatmap grid. + * `day` is 0–6 (Sunday–Saturday), `hour` is 0–23. + */ +export interface HeatmapCell { + day: number; + hour: number; + count: number; + /** Normalised intensity in 0–1 range, used for colour interpolation. */ + intensity: number; +} + +/** + * Result of aggregating events into heatmap data. + */ +export interface HeatmapData { + cells: HeatmapCell[][]; + maxCount: number; + totalCount: number; + dateRange: { start: Date; end: Date } | null; +} + +export const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const; +export const HOUR_LABELS = Array.from({ length: 24 }, (_, i) => { + if (i === 0) return '12a'; + if (i < 12) return `${i}a`; + if (i === 12) return '12p'; + return `${i - 12}p`; +}); + +/** + * Filters events to those within the given date range. + * Returns all events when start/end are null. + */ +export function filterEventsByDateRange( + events: BlockchainEvent[], + start: Date | null, + end: Date | null +): BlockchainEvent[] { + if (!start && !end) return events; + + const startMs = start ? start.getTime() : -Infinity; + const endMs = end ? end.getTime() : Infinity; + + return events.filter((event) => { + const ts = event.receivedAt; + return ts >= startMs && ts <= endMs; + }); +} + +/** + * Aggregates an array of blockchain events into a 7×24 heatmap grid. + * + * Performance: iterates events once (O(n)), then normalises the 168 cells (O(1)). + */ +export function aggregateHeatmapData( + events: BlockchainEvent[], + startDate: Date | null = null, + endDate: Date | null = null +): HeatmapData { + const filtered = filterEventsByDateRange(events, startDate, endDate); + + // Build the 7×24 count grid + const counts: number[][] = Array.from({ length: 7 }, () => + Array.from({ length: 24 }, () => 0) + ); + + let maxCount = 0; + let minTs = Infinity; + let maxTs = -Infinity; + + for (const event of filtered) { + const date = new Date(event.receivedAt); + const day = date.getDay(); + const hour = date.getHours(); + + counts[day][hour]++; + + if (counts[day][hour] > maxCount) { + maxCount = counts[day][hour]; + } + if (event.receivedAt < minTs) minTs = event.receivedAt; + if (event.receivedAt > maxTs) maxTs = event.receivedAt; + } + + // Convert to HeatmapCell[][] + const cells: HeatmapCell[][] = counts.map((hourCounts, day) => + hourCounts.map((count, hour) => ({ + day, + hour, + count, + intensity: maxCount > 0 ? count / maxCount : 0, + })) + ); + + return { + cells, + maxCount, + totalCount: filtered.length, + dateRange: + minTs <= maxTs + ? { start: new Date(minTs), end: new Date(maxTs) } + : null, + }; +} + +/** + * Returns an intensity level (0-4) for colour-stepped rendering. + * Level 0 = no activity, Level 4 = peak activity. + */ +export function intensityLevel(intensity: number): number { + if (intensity === 0) return 0; + if (intensity < 0.25) return 1; + if (intensity < 0.5) return 2; + if (intensity < 0.75) return 3; + return 4; +}