+
+ {/* ── 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;
+}