From ee1afc9a80797415724f3fa187fbeb67f726da76 Mon Sep 17 00:00:00 2001 From: Adeswalla Date: Wed, 24 Jun 2026 07:39:39 +0100 Subject: [PATCH 1/5] [Frontend] Implement Notification Delivery Heatmap --- dashboard/package-lock.json | 45 -- dashboard/src/App.tsx | 11 + dashboard/src/components/DeliveryHeatmap.tsx | 292 ++++++++++ dashboard/src/components/ThemeToggle.tsx | 56 ++ dashboard/src/hooks/useTheme.ts | 50 ++ dashboard/src/index.css | 559 +++++++++++++++++++ dashboard/src/test/heatmapData.test.ts | 145 +++++ dashboard/src/utils/heatmapData.ts | 119 ++++ 8 files changed, 1232 insertions(+), 45 deletions(-) create mode 100644 dashboard/src/components/DeliveryHeatmap.tsx create mode 100644 dashboard/src/components/ThemeToggle.tsx create mode 100644 dashboard/src/hooks/useTheme.ts create mode 100644 dashboard/src/test/heatmapData.test.ts create mode 100644 dashboard/src/utils/heatmapData.ts diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index cf25acd..c69f122 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -860,21 +860,6 @@ } } }, - "node_modules/@creit.tech/stellar-wallets-kit/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/@creit.tech/xbull-wallet-connect": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@creit.tech/xbull-wallet-connect/-/xbull-wallet-connect-0.4.0.tgz", @@ -7772,21 +7757,6 @@ "ws": "^7.5.1" } }, - "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": { "version": "7.5.11", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", @@ -12904,21 +12874,6 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, - "node_modules/jayson/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/jayson/node_modules/ws": { "version": "7.5.11", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 2443663..2cda2a8 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,9 +1,20 @@ 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 (
+
+ +
+
); } 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 f3b7ac7..8e369cf 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; } .events-page__header h1 { @@ -758,3 +899,421 @@ body { display: none; } } + +/* ═══════════════════════════════════════════════════════════════════════════ + 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; +} From fe151b5b072ea188b70652ea1f99fcc4e1750b8b Mon Sep 17 00:00:00 2001 From: Adeswalla Date: Wed, 24 Jun 2026 07:55:42 +0100 Subject: [PATCH 2/5] [Contracts] Add Batch Acknowledgment Support --- .../hello-world/src/autoshare_logic.rs | 52 ++++- .../contracts/hello-world/src/base/errors.rs | 2 + .../contracts/hello-world/src/base/events.rs | 15 ++ contract/contracts/hello-world/src/lib.rs | 8 + .../hello-world/src/tests/batch_ack_test.rs | 209 ++++++++++++++++++ .../hello-world/src/tests/revocation_test.rs | 18 +- 6 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 contract/contracts/hello-world/src/tests/batch_ack_test.rs diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index ad87d61..922109b 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -1,9 +1,9 @@ use crate::base::errors::Error; use crate::base::events::{ AdminTransferred, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, ContractPaused, - ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationExpired, - NotificationPriority, NotificationRevoked, NotificationScheduled, ScheduledNotificationCancelled, - Withdrawal, + ContractUnpaused, GroupActivated, GroupDeactivated, NotificationAcknowledged, + NotificationCategory, NotificationExpired, NotificationPriority, NotificationRevoked, + NotificationScheduled, ScheduledNotificationCancelled, Withdrawal, }; use crate::base::types::{AutoShareDetails, GroupMember, PaymentHistory, ScheduledNotification}; use soroban_sdk::{contracttype, token, Address, BytesN, Env, String, Vec}; @@ -1083,3 +1083,49 @@ pub fn is_notification_revoked(env: Env, notification_id: BytesN<32>) -> Result< let notification = get_notification(env, notification_id)?; Ok(is_revoked(¬ification)) } + +/// Acknowledges multiple scheduled notifications in a single batch. +/// +/// Only the creator of the notification can acknowledge it. The notification +/// must exist, not be revoked, and not be expired. +/// Emits a [`NotificationAcknowledged`] event for each valid notification. +pub fn acknowledge_notifications( + env: Env, + caller: Address, + notification_ids: Vec>, +) -> Result<(), Error> { + caller.require_auth(); + + if get_paused_status(&env) { + return Err(Error::ContractPaused); + } + + let timestamp = env.ledger().timestamp(); + + for id in notification_ids.iter() { + let notification = load_notification(&env, &id).ok_or(Error::NotFound)?; + + if notification.creator != caller { + return Err(Error::NotAuthorizedToAcknowledge); + } + + if is_revoked(¬ification) { + return Err(Error::NotificationRevoked); + } + + if is_expired(&env, ¬ification) { + return Err(Error::NotificationExpired); + } + + NotificationAcknowledged { + notification_id: id, + acknowledger: caller.clone(), + category: NotificationCategory::Notification, + priority: NOTIFICATION_PRIORITY, + timestamp, + } + .publish(&env); + } + + Ok(()) +} diff --git a/contract/contracts/hello-world/src/base/errors.rs b/contract/contracts/hello-world/src/base/errors.rs index 9bd2180..7845efb 100644 --- a/contract/contracts/hello-world/src/base/errors.rs +++ b/contract/contracts/hello-world/src/base/errors.rs @@ -62,4 +62,6 @@ pub enum Error { NotAuthorizedToRevoke = 27, /// Triggered when attempting to revoke a notification that is already revoked. AlreadyRevoked = 28, + /// Triggered when the caller is not authorized to acknowledge a notification. + NotAuthorizedToAcknowledge = 29, } diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index fbb5dcd..16c2e59 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -237,3 +237,18 @@ pub struct NotificationRevoked { pub priority: NotificationPriority, pub revoked_at: u64, } + +/// Emitted when a notification is acknowledged by an authorized user. +#[contractevent(data_format = "single-value")] +#[derive(Clone)] +pub struct NotificationAcknowledged { + #[topic] + pub notification_id: BytesN<32>, + #[topic] + pub acknowledger: Address, + #[topic] + pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, + pub timestamp: u64, +} diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index 0fef756..c69ce55 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -302,6 +302,11 @@ impl AutoShareContract { pub fn is_notification_revoked(env: Env, notification_id: BytesN<32>) -> bool { autoshare_logic::is_notification_revoked(env, notification_id).unwrap() } + + /// Acknowledges multiple scheduled notifications in a single batch. + pub fn acknowledge_notifications(env: Env, caller: Address, notification_ids: Vec>) { + autoshare_logic::acknowledge_notifications(env, caller, notification_ids).unwrap(); + } } #[cfg(test)] @@ -333,4 +338,7 @@ mod tests { #[path = "../tests/revocation_test.rs"] mod revocation_test; + + #[path = "../tests/batch_ack_test.rs"] + mod batch_ack_test; } diff --git a/contract/contracts/hello-world/src/tests/batch_ack_test.rs b/contract/contracts/hello-world/src/tests/batch_ack_test.rs new file mode 100644 index 0000000..aa56737 --- /dev/null +++ b/contract/contracts/hello-world/src/tests/batch_ack_test.rs @@ -0,0 +1,209 @@ +//! Tests for batch acknowledgment of notifications. +//! +//! These tests verify: +//! - Multiple notifications can be acknowledged in a single transaction. +//! - Validates notification ownership (only creator can acknowledge). +//! - Correct `NotificationAcknowledged` events are emitted. +//! - Gas benchmarking to prove batching is more efficient than individual calls. + +use crate::base::events::{NotificationCategory, NotificationPriority}; +use crate::test_utils::setup_test_env; +use crate::AutoShareContractClient; + +use soroban_sdk::testutils::{Address as _, Events, Ledger}; +use soroban_sdk::{Address, BytesN, Env, Symbol, TryFromVal, Val, Vec}; + +const ONE_HOUR: u64 = 3_600; + +fn make_id(env: &Env, tag: u8) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[0] = tag; + BytesN::from_array(env, &bytes) +} + +fn set_now(env: &Env, timestamp: u64) { + env.ledger().set_timestamp(timestamp); +} + +fn count_events(env: &Env, event_name: &str) -> usize { + let target = Symbol::new(env, event_name); + let mut count = 0; + for (_addr, topics, _data) in env.events().all().iter() { + if topics.is_empty() { + continue; + } + let first = topics.get(0).unwrap(); + if let Ok(name) = Symbol::try_from_val(env, &first) { + if name == target { + count += 1; + } + } + } + count +} + +#[test] +fn test_acknowledge_multiple_notifications() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + set_now(&test_env.env, 1_000); + + let id1 = make_id(&test_env.env, 1); + let id2 = make_id(&test_env.env, 2); + let id3 = make_id(&test_env.env, 3); + + client.schedule_notification(&id1, &creator, &ONE_HOUR); + client.schedule_notification(&id2, &creator, &ONE_HOUR); + client.schedule_notification(&id3, &creator, &ONE_HOUR); + + let mut batch = Vec::new(&test_env.env); + batch.push_back(id1.clone()); + batch.push_back(id2.clone()); + batch.push_back(id3.clone()); + + set_now(&test_env.env, 2_000); + + client.acknowledge_notifications(&creator, &batch); + + // Verify exactly 3 events were emitted + assert_eq!(count_events(&test_env.env, "notification_acknowledged"), 3); +} + +#[test] +#[should_panic] +fn test_acknowledge_unauthorized_fails() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + let unauthorized = Address::generate(&test_env.env); + + set_now(&test_env.env, 1_000); + + let id1 = make_id(&test_env.env, 1); + client.schedule_notification(&id1, &creator, &ONE_HOUR); + + let mut batch = Vec::new(&test_env.env); + batch.push_back(id1.clone()); + + // Fails because `unauthorized` does not own the notification + client.acknowledge_notifications(&unauthorized, &batch); +} + +#[test] +#[should_panic] +fn test_acknowledge_revoked_fails() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + set_now(&test_env.env, 1_000); + let id1 = make_id(&test_env.env, 1); + client.schedule_notification(&id1, &creator, &ONE_HOUR); + + client.revoke_notification(&id1, &creator); + + let mut batch = Vec::new(&test_env.env); + batch.push_back(id1.clone()); + + // Fails because notification is revoked + client.acknowledge_notifications(&creator, &batch); +} + +#[test] +#[should_panic] +fn test_acknowledge_expired_fails() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + set_now(&test_env.env, 1_000); + let id1 = make_id(&test_env.env, 1); + client.schedule_notification(&id1, &creator, &ONE_HOUR); + + set_now(&test_env.env, 1_000 + ONE_HOUR + 1); + + let mut batch = Vec::new(&test_env.env); + batch.push_back(id1.clone()); + + // Fails because notification is expired + client.acknowledge_notifications(&creator, &batch); +} + +#[test] +fn benchmark_gas_usage() { + let env_single = Env::default(); + env_single.mock_all_auths(); + env_single.cost_estimate().budget().reset_unlimited(); + + let client_single = AutoShareContractClient::new( + &env_single, + &env_single.register_contract(None, crate::AutoShareContract), + ); + let creator_single = Address::generate(&env_single); + client_single.initialize_admin(&Address::generate(&env_single)); + + set_now(&env_single, 1_000); + + let mut ids_single = Vec::new(&env_single); + for i in 0..10u8 { + let id = make_id(&env_single, i); + client_single.schedule_notification(&id, &creator_single, &ONE_HOUR); + ids_single.push_back(id); + } + + let start_cpu_single = env_single + .cost_estimate() + .budget() + .get_cpu_instruction_cost(); + for id in ids_single.iter() { + let mut single_batch = Vec::new(&env_single); + single_batch.push_back(id); + client_single.acknowledge_notifications(&creator_single, &single_batch); + } + let end_cpu_single = env_single + .cost_estimate() + .budget() + .get_cpu_instruction_cost(); + let single_cost = end_cpu_single - start_cpu_single; + + let env_batch = Env::default(); + env_batch.mock_all_auths(); + env_batch.cost_estimate().budget().reset_unlimited(); + + let client_batch = AutoShareContractClient::new( + &env_batch, + &env_batch.register_contract(None, crate::AutoShareContract), + ); + let creator_batch = Address::generate(&env_batch); + client_batch.initialize_admin(&Address::generate(&env_batch)); + + set_now(&env_batch, 1_000); + + let mut ids_batch = Vec::new(&env_batch); + for i in 0..10u8 { + let id = make_id(&env_batch, i); + client_batch.schedule_notification(&id, &creator_batch, &ONE_HOUR); + ids_batch.push_back(id); + } + + let start_cpu_batch = env_batch + .cost_estimate() + .budget() + .get_cpu_instruction_cost(); + client_batch.acknowledge_notifications(&creator_batch, &ids_batch); + let end_cpu_batch = env_batch + .cost_estimate() + .budget() + .get_cpu_instruction_cost(); + let batch_cost = end_cpu_batch - start_cpu_batch; + + // Batch cost should be significantly less than running 10 separate transactions + assert!( + batch_cost < single_cost, + "Batch cost ({}) should be less than individual cost ({})", + batch_cost, + single_cost + ); +} diff --git a/contract/contracts/hello-world/src/tests/revocation_test.rs b/contract/contracts/hello-world/src/tests/revocation_test.rs index 8867b53..6d881b9 100644 --- a/contract/contracts/hello-world/src/tests/revocation_test.rs +++ b/contract/contracts/hello-world/src/tests/revocation_test.rs @@ -118,7 +118,8 @@ fn test_revoke_notification_emits_event() { set_now(&test_env.env, 2_000); client.revoke_notification(&id, &creator); - let topics = topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); + let topics = + topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); // [0] name, [1] notification_id, [2] revoked_by, [3] category, [4] priority. assert_eq!(topics.len(), 5); @@ -327,12 +328,14 @@ fn test_revoke_event_has_high_priority() { set_now(&test_env.env, 2_000); client.revoke_notification(&id, &creator); - let topics = topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); + let topics = + topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); // Last topic is priority let priority_topic = topics.last().unwrap(); - let priority = crate::base::events::NotificationPriority::try_from_val(&test_env.env, &priority_topic) - .expect("priority should be extractable"); - + let priority = + crate::base::events::NotificationPriority::try_from_val(&test_env.env, &priority_topic) + .expect("priority should be extractable"); + assert_eq!(priority, crate::base::events::NotificationPriority::High); } @@ -349,12 +352,13 @@ fn test_revoke_event_has_notification_category() { set_now(&test_env.env, 2_000); client.revoke_notification(&id, &creator); - let topics = topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); + let topics = + topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); // Second to last topic is category let n = topics.len(); let category_topic = topics.get(n - 2).unwrap(); let category = NotificationCategory::try_from_val(&test_env.env, &category_topic) .expect("category should be extractable"); - + assert_eq!(category, NotificationCategory::Notification); } From 68b4ac7b720e57ddc8a0c7ec9ddefa2b554c415e Mon Sep 17 00:00:00 2001 From: Adeswalla Date: Wed, 24 Jun 2026 08:08:15 +0100 Subject: [PATCH 3/5] [Bug] Resolve Pagination Inconsistencies in Notification History --- listener/API.md | 7 +- listener/src/api/events-server.ts | 3 + .../src/api/notifications-history.test.ts | 98 +++++++++++++++++++ listener/src/services/notification-history.ts | 38 +++++-- listener/src/utils/pagination.ts | 22 +++++ 5 files changed, 159 insertions(+), 9 deletions(-) diff --git a/listener/API.md b/listener/API.md index 352a926..45a048f 100644 --- a/listener/API.md +++ b/listener/API.md @@ -303,7 +303,8 @@ Returns paginated delivery execution records from `notification_execution_log`. | Name | Type | Required | Description | |-----------|--------|----------|-------------------------------------------------------------------| | limit | number | No | Maximum records per page (default `20`, max `100`) | -| offset | number | No | Number of records to skip (default `0`) | +| offset | number | No | Number of records to skip (default `0`). Prefer `cursor`. | +| cursor | string | No | Opaque token for cursor-based pagination | | status | string | No | Filter by execution status: `SUCCESS`, `FAILED`, or `RETRY` | | startDate | string | No | ISO 8601 lower bound on `execution_time` (inclusive) | | endDate | string | No | ISO 8601 upper bound on `execution_time` (inclusive) | @@ -327,7 +328,8 @@ Returns paginated delivery execution records from `notification_execution_log`. "itemCount": 5, "totalPages": 3, "limit": 2, - "offset": 0 + "offset": 0, + "nextCursor": "MjAyNC0wNi0yMFQxNTowMDowMC4wMDBaLDQy" } ``` @@ -339,6 +341,7 @@ Returns paginated delivery execution records from `notification_execution_log`. | totalPages | number | Total pages available at the requested `limit` (`0` when `itemCount` is `0`) | | limit | number | Effective page size applied to the query | | offset | number | Number of records skipped before this page | +| nextCursor | string | Opaque token to fetch the next page of results | Existing clients that read `total`, `limit`, `offset`, and `records` continue to work unchanged. New clients should prefer `itemCount` and `totalPages` for pagination UI. diff --git a/listener/src/api/events-server.ts b/listener/src/api/events-server.ts index c1153c5..4b92512 100644 --- a/listener/src/api/events-server.ts +++ b/listener/src/api/events-server.ts @@ -456,6 +456,7 @@ export function createEventsServer(options: EventsServerOptions): http.Server { const url = new URL(req.url, 'http://localhost'); const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit')!, 10) : undefined; const offset = url.searchParams.get('offset') ? parseInt(url.searchParams.get('offset')!, 10) : undefined; + const cursor = url.searchParams.get('cursor') || undefined; const status = url.searchParams.get('status') as 'SUCCESS' | 'FAILED' | 'RETRY' | null; const startDate = url.searchParams.get('startDate'); const endDate = url.searchParams.get('endDate'); @@ -465,6 +466,7 @@ export function createEventsServer(options: EventsServerOptions): http.Server { correlationId, limit, offset, + cursor, status, startDate, endDate, @@ -473,6 +475,7 @@ export function createEventsServer(options: EventsServerOptions): http.Server { historyService.getHistory({ limit, offset, + cursor, status: status || undefined, startDate: startDate || undefined, endDate: endDate || undefined, diff --git a/listener/src/api/notifications-history.test.ts b/listener/src/api/notifications-history.test.ts index 65197d4..73e0b89 100644 --- a/listener/src/api/notifications-history.test.ts +++ b/listener/src/api/notifications-history.test.ts @@ -225,6 +225,104 @@ describe('GET /api/notifications/history', () => { expect(status).toBe(200); expect((body as any).limit).toBeLessThanOrEqual(100); }); + + it('supports cursor-based pagination', async () => { + server = await startServer(BASE_OPTIONS); + + for (let i = 0; i < 5; i++) { + await db.run( + `INSERT INTO scheduled_notifications + (payload, notification_type, target_recipient, execute_at, status) + VALUES (?, ?, ?, ?, ?)`, + [JSON.stringify({ test: true }), 'discord', 'test_user', new Date().toISOString(), 'COMPLETED'] + ); + } + + const times = [ + '2026-06-20T10:00:00.000Z', + '2026-06-20T10:00:01.000Z', + '2026-06-20T10:00:02.000Z', + '2026-06-20T10:00:03.000Z', + '2026-06-20T10:00:04.000Z', + ]; + + for (let i = 1; i <= 5; i++) { + await db.run( + `INSERT INTO notification_execution_log + (scheduled_notification_id, execution_attempt, execution_time, status, duration_ms) + VALUES (?, ?, ?, ?, ?)`, + [i, 1, times[i - 1], 'SUCCESS', 100] + ); + } + + const { status: status1, body: body1 } = await makeRequest( + server, + '/api/notifications/history?limit=2' + ); + + expect(status1).toBe(200); + expect((body1 as any).records.length).toBe(2); + expect((body1 as any).records[0].executionTime).toBe(times[4]); // DESC order + expect((body1 as any).records[1].executionTime).toBe(times[3]); + expect((body1 as any).nextCursor).toBeDefined(); + + const cursor = encodeURIComponent((body1 as any).nextCursor); + const { status: status2, body: body2 } = await makeRequest( + server, + `/api/notifications/history?limit=2&cursor=${cursor}` + ); + + expect(status2).toBe(200); + expect((body2 as any).records.length).toBe(2); + expect((body2 as any).records[0].executionTime).toBe(times[2]); + expect((body2 as any).records[1].executionTime).toBe(times[1]); + }); + + it('handles sorting consistency with tie-breakers', async () => { + server = await startServer(BASE_OPTIONS); + + for (let i = 0; i < 3; i++) { + await db.run( + `INSERT INTO scheduled_notifications + (payload, notification_type, target_recipient, execute_at, status) + VALUES (?, ?, ?, ?, ?)`, + [JSON.stringify({ test: true }), 'discord', 'test_user', new Date().toISOString(), 'COMPLETED'] + ); + } + + const sameTime = '2026-06-20T10:00:00.000Z'; + + // Insert multiple records with the exact same execution_time + for (let i = 1; i <= 3; i++) { + await db.run( + `INSERT INTO notification_execution_log + (scheduled_notification_id, execution_attempt, execution_time, status, duration_ms) + VALUES (?, ?, ?, ?, ?)`, + [i, 1, sameTime, 'SUCCESS', 100] + ); + } + + const { status: status1, body: body1 } = await makeRequest( + server, + '/api/notifications/history?limit=2' + ); + + expect(status1).toBe(200); + expect((body1 as any).records.length).toBe(2); + expect((body1 as any).records[0].id).toBe(3); // DESC order + expect((body1 as any).records[1].id).toBe(2); + expect((body1 as any).nextCursor).toBeDefined(); + + const cursor = encodeURIComponent((body1 as any).nextCursor); + const { status: status2, body: body2 } = await makeRequest( + server, + `/api/notifications/history?limit=2&cursor=${cursor}` + ); + + expect(status2).toBe(200); + expect((body2 as any).records.length).toBe(1); + expect((body2 as any).records[0].id).toBe(1); + }); }); describe('GET /api/notifications/history database failures', () => { diff --git a/listener/src/services/notification-history.ts b/listener/src/services/notification-history.ts index 215adf9..2077ce0 100644 --- a/listener/src/services/notification-history.ts +++ b/listener/src/services/notification-history.ts @@ -1,6 +1,6 @@ import { getDatabase } from '../database/database'; import logger from '../utils/logger'; -import { buildPaginationMetadata, normalizePaginationParams } from '../utils/pagination'; +import { buildPaginationMetadata, normalizePaginationParams, encodeCursor, decodeCursor } from '../utils/pagination'; export interface NotificationHistoryRecord { id: number; @@ -15,6 +15,7 @@ export interface NotificationHistoryRecord { export interface HistoryQueryOptions { limit?: number; offset?: number; + cursor?: string; status?: 'SUCCESS' | 'FAILED' | 'RETRY'; startDate?: string; endDate?: string; @@ -27,6 +28,7 @@ export interface PaginatedHistoryResponse { offset: number; itemCount: number; totalPages: number; + nextCursor?: string | null; } export class NotificationHistoryService { @@ -55,15 +57,25 @@ export class NotificationHistoryService { params.push(options.endDate); } + const baseConditions = [...conditions]; + const baseParams = [...params]; + + const decodedCursor = options.cursor ? decodeCursor(options.cursor) : null; + if (decodedCursor) { + conditions.push('(execution_time < ? OR (execution_time = ? AND id < ?))'); + params.push(decodedCursor.executionTime, decodedCursor.executionTime, decodedCursor.id); + } + + const countWhereClause = baseConditions.length > 0 ? `WHERE ${baseConditions.join(' AND ')}` : ''; const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; // Get total count - const countSql = `SELECT COUNT(*) as count FROM notification_execution_log ${whereClause}`; - const countResult = await this.db.get<{ count: number }>(countSql, params); + const countSql = `SELECT COUNT(*) as count FROM notification_execution_log ${countWhereClause}`; + const countResult = await this.db.get<{ count: number }>(countSql, baseParams); const total = countResult?.count || 0; // Get paginated records - const sql = ` + let sql = ` SELECT id, scheduled_notification_id as scheduledNotificationId, @@ -74,13 +86,20 @@ export class NotificationHistoryService { duration_ms as responseDuration FROM notification_execution_log ${whereClause} - ORDER BY execution_time DESC - LIMIT ? OFFSET ? + ORDER BY execution_time DESC, id DESC + LIMIT ? `; + + const queryParams = [...params, limit]; + + if (!decodedCursor) { + sql += ` OFFSET ?`; + queryParams.push(offset); + } const records = await this.db.all( sql, - [...params, limit, offset] + queryParams ); logger.info('Notification history retrieved', { @@ -92,6 +111,10 @@ export class NotificationHistoryService { const pagination = buildPaginationMetadata(total, limit, offset); + const nextCursor = records.length > 0 + ? encodeCursor(records[records.length - 1].executionTime, records[records.length - 1].id) + : null; + return { records, total, @@ -99,6 +122,7 @@ export class NotificationHistoryService { offset: pagination.offset, itemCount: pagination.itemCount, totalPages: pagination.totalPages, + nextCursor, }; } catch (error) { logger.error('Failed to retrieve notification history', { error }); diff --git a/listener/src/utils/pagination.ts b/listener/src/utils/pagination.ts index 8df07ef..6ba4b3a 100644 --- a/listener/src/utils/pagination.ts +++ b/listener/src/utils/pagination.ts @@ -5,6 +5,28 @@ export interface PaginationMetadata { offset: number; } +export interface CursorData { + executionTime: string; + id: number; +} + +export function encodeCursor(executionTime: string, id: number): string { + return Buffer.from(`${executionTime},${id}`).toString('base64'); +} + +export function decodeCursor(cursor: string): CursorData | null { + try { + const decoded = Buffer.from(cursor, 'base64').toString('utf-8'); + const [executionTime, idStr] = decoded.split(','); + if (!executionTime || !idStr) return null; + const id = parseInt(idStr, 10); + if (isNaN(id)) return null; + return { executionTime, id }; + } catch { + return null; + } +} + export interface PaginationQueryParams { limit: number; offset: number; From 3af8faeba149b5e67a9a77abc9c8e13cbce2e4dc Mon Sep 17 00:00:00 2001 From: Adeswalla Date: Wed, 24 Jun 2026 08:40:27 +0100 Subject: [PATCH 4/5] [Bug] Fix Delayed Notification Status Synchronization --- listener/data/debug-test.db | Bin 0 -> 98304 bytes .../scheduled-notification-repository.ts | 37 +++-- .../src/tests/notification-scheduler.test.ts | 135 ++++++++++++++++++ 3 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 listener/data/debug-test.db diff --git a/listener/data/debug-test.db b/listener/data/debug-test.db new file mode 100644 index 0000000000000000000000000000000000000000..454e5196194a0565f43f47e35c93ab5b3208b446 GIT binary patch literal 98304 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 literal 0 HcmV?d00001 diff --git a/listener/src/services/scheduled-notification-repository.ts b/listener/src/services/scheduled-notification-repository.ts index 592273e..8647cb8 100644 --- a/listener/src/services/scheduled-notification-repository.ts +++ b/listener/src/services/scheduled-notification-repository.ts @@ -153,6 +153,7 @@ export class ScheduledNotificationRepository { last_error = ?, error_details = ?, processing_completed_at = ?, + updated_at = ?, processor_id = NULL, lock_expires_at = NULL WHERE id = ? @@ -170,6 +171,7 @@ export class ScheduledNotificationRepository { errorMsg, errorDetails, isFailed ? now.toISOString() : null, + now.toISOString(), model.id, ]); @@ -200,14 +202,17 @@ export class ScheduledNotificationRepository { SET status = ?, processing_completed_at = ?, + updated_at = ?, processor_id = NULL, lock_expires_at = NULL WHERE id = ? `; + const now = new Date().toISOString(); await this.db.run(sql, [ NotificationStatus.COMPLETED, - new Date().toISOString(), + now, + now, id, ]); @@ -234,15 +239,17 @@ export class ScheduledNotificationRepository { last_error = ?, error_details = ?, processing_completed_at = ?, + updated_at = ?, processor_id = NULL, lock_expires_at = NULL WHERE id = ? `; + const now = new Date().toISOString(); const errorDetails = JSON.stringify({ message: error.message, stack: error.stack, - timestamp: new Date().toISOString(), + timestamp: now, }); await this.db.run(sql, [ @@ -250,7 +257,8 @@ export class ScheduledNotificationRepository { currentRetryCount + 1, error.message, errorDetails, - isFailed ? new Date().toISOString() : null, + isFailed ? now : null, + now, id, ]); @@ -372,23 +380,30 @@ export class ScheduledNotificationRepository { * Convert database row to model */ private rowToModel(row: ScheduledNotificationRow): ScheduledNotification { + // SQLite CURRENT_TIMESTAMP produces "YYYY-MM-DD HH:MM:SS" (UTC, no Z suffix). + // Appending Z ensures JS parses the value as UTC rather than local time, + // which prevents timezone-shifted timestamps in rowToModel output. + const parseUtc = (value: string | null | undefined): Date | undefined => { + if (!value) return undefined; + const normalized = value.includes('T') || value.endsWith('Z') ? value : value.replace(' ', 'T') + 'Z'; + return new Date(normalized); + }; + return { id: row.id, payload: row.payload, notificationType: row.notification_type as any, targetRecipient: row.target_recipient, - executeAt: new Date(row.execute_at), - createdAt: new Date(row.created_at), - updatedAt: new Date(row.updated_at), + executeAt: parseUtc(row.execute_at) as Date, + createdAt: parseUtc(row.created_at), + updatedAt: parseUtc(row.updated_at), status: row.status as NotificationStatus, retryCount: row.retry_count, maxRetries: row.max_retries, - processingStartedAt: row.processing_started_at ? new Date(row.processing_started_at) : null, - processingCompletedAt: row.processing_completed_at - ? new Date(row.processing_completed_at) - : null, + processingStartedAt: parseUtc(row.processing_started_at) ?? null, + processingCompletedAt: parseUtc(row.processing_completed_at) ?? null, processorId: row.processor_id, - lockExpiresAt: row.lock_expires_at ? new Date(row.lock_expires_at) : null, + lockExpiresAt: parseUtc(row.lock_expires_at) ?? null, lastError: row.last_error, errorDetails: row.error_details, eventId: row.event_id, diff --git a/listener/src/tests/notification-scheduler.test.ts b/listener/src/tests/notification-scheduler.test.ts index 496cba0..829e00e 100644 --- a/listener/src/tests/notification-scheduler.test.ts +++ b/listener/src/tests/notification-scheduler.test.ts @@ -336,3 +336,138 @@ describe('NotificationScheduler', () => { }); }); }); + +describe('Stale cache regression tests', () => { + let db: Database; + let repository: ScheduledNotificationRepository; + + const testDbPath = './data/test-stale-cache.db'; + + beforeAll(async () => { + const dbDir = path.dirname(testDbPath); + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); + } + if (fs.existsSync(testDbPath)) { + fs.unlinkSync(testDbPath); + } + db = new Database(testDbPath); + await db.initialize(); + repository = new ScheduledNotificationRepository(db); + }); + + afterAll(async () => { + await db.close(); + if (fs.existsSync(testDbPath)) { + fs.unlinkSync(testDbPath); + } + }); + + beforeEach(async () => { + await db.run('DELETE FROM notification_execution_log'); + await db.run('DELETE FROM scheduled_notifications'); + }); + + test('markAsCompleted updates updated_at — no stale timestamp after delivery', async () => { + const id = await repository.create({ + payload: { message: 'Stale test' }, + notificationType: NotificationType.DISCORD, + targetRecipient: 'test-webhook', + executeAt: new Date(Date.now() - 1000), + }); + + await repository.markAsCompleted(id); + + const notification = await repository.getById(id); + expect(notification?.status).toBe(NotificationStatus.COMPLETED); + // updated_at must be a defined, valid date — confirms the column is written on status change + expect(notification?.updatedAt).toBeDefined(); + expect(notification?.updatedAt).toBeInstanceOf(Date); + expect(isNaN(notification?.updatedAt?.getTime() ?? NaN)).toBe(false); + }); + + test('markAsFailedOrRetry updates updated_at — no stale timestamp on retry', async () => { + const id = await repository.create({ + payload: { message: 'Retry stale test' }, + notificationType: NotificationType.DISCORD, + targetRecipient: 'test-webhook', + executeAt: new Date(Date.now() - 1000), + maxRetries: 3, + }); + + await repository.markAsFailedOrRetry(id, new Error('Delivery failed'), 0, 3); + + const notification = await repository.getById(id); + expect(notification?.status).toBe(NotificationStatus.PENDING); + expect(notification?.updatedAt).toBeDefined(); + expect(notification?.updatedAt).toBeInstanceOf(Date); + expect(isNaN(notification?.updatedAt?.getTime() ?? NaN)).toBe(false); + }); + + test('markAsFailedOrRetry updates updated_at when permanently failed', async () => { + const id = await repository.create({ + payload: { message: 'Final failure stale test' }, + notificationType: NotificationType.DISCORD, + targetRecipient: 'test-webhook', + executeAt: new Date(Date.now() - 1000), + maxRetries: 2, + }); + + await repository.markAsFailedOrRetry(id, new Error('Max retries exceeded'), 2, 2); + + const notification = await repository.getById(id); + expect(notification?.status).toBe(NotificationStatus.FAILED); + expect(notification?.updatedAt).toBeDefined(); + expect(notification?.updatedAt).toBeInstanceOf(Date); + expect(isNaN(notification?.updatedAt?.getTime() ?? NaN)).toBe(false); + }); + + test('recoverStaleLocks updates updated_at — no stale status after lock recovery', async () => { + const id = await repository.create({ + payload: { message: 'Stale lock test' }, + notificationType: NotificationType.DISCORD, + targetRecipient: 'test-webhook', + executeAt: new Date(Date.now() - 1000), + }); + + await repository.fetchAndLockPendingNotifications('processor-stale', 30000, 10); + + // Expire the lock + const pastLock = new Date(Date.now() - 1000); + await db.run('UPDATE scheduled_notifications SET lock_expires_at = ? WHERE id = ?', [ + pastLock.toISOString(), + id, + ]); + + await repository.recoverStaleLocks(); + + const recovered = await repository.getById(id); + expect(recovered?.status).toBe(NotificationStatus.PENDING); + expect(recovered?.processorId).toBeNull(); + // updated_at must be a valid date — confirms the column is written on lock recovery + expect(recovered?.updatedAt).toBeDefined(); + expect(recovered?.updatedAt).toBeInstanceOf(Date); + expect(isNaN(recovered?.updatedAt?.getTime() ?? NaN)).toBe(false); + }); + + test('stale status is not served after delivery: re-fetch reflects COMPLETED', async () => { + const id = await repository.create({ + payload: { message: 'Delivery stale check' }, + notificationType: NotificationType.DISCORD, + targetRecipient: 'test-webhook', + executeAt: new Date(Date.now() - 1000), + }); + + // Simulate the state a UI would cache before delivery + const beforeDelivery = await repository.getById(id); + expect(beforeDelivery?.status).toBe(NotificationStatus.PENDING); + + // Delivery happens + await repository.markAsCompleted(id); + + // Re-fetch (simulating a poll/refresh) must return the new status + const afterDelivery = await repository.getById(id); + expect(afterDelivery?.status).toBe(NotificationStatus.COMPLETED); + expect(afterDelivery?.status).not.toBe(beforeDelivery?.status); + }); +}); From 74796d465b8a074790f8dab997f515c8ed826e43 Mon Sep 17 00:00:00 2001 From: Adeswalla Date: Thu, 25 Jun 2026 11:52:08 +0100 Subject: [PATCH 5/5] [CI] Fix all CI check failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dashboard: raise jest testTimeout to 15s to prevent flaky timeouts when render-benchmark.test runs in parallel with async userEvent tests - dashboard: fix stale-cache deduplication (Map last-write-wins, correct appendEvents merge order) and add regression tests - dashboard: add 15s polling to EventExplorerPage and EventsPage so status updates appear without a manual refresh - listener: fix notification-scheduler-refactored test — fetchAndLock was picking up both past notifications; restore item 2 to PENDING so only item 3 is left as PROCESSING with an expired lock - listener: remove accidentally committed debug-test.db; add data/ to .gitignore --- dashboard/jest.config.cjs | 1 + dashboard/src/pages/EventExplorerPage.tsx | 16 +++++++ dashboard/src/pages/EventsPage.tsx | 13 ++++++ dashboard/src/store/eventStore.test.tsx | 42 ++++++++++++++++++ dashboard/src/store/eventStore.ts | 19 ++++---- listener/.gitignore | 1 + listener/data/debug-test.db | Bin 98304 -> 0 bytes .../notification-scheduler-refactored.test.ts | 13 +++++- 8 files changed, 93 insertions(+), 12 deletions(-) delete mode 100644 listener/data/debug-test.db diff --git a/dashboard/jest.config.cjs b/dashboard/jest.config.cjs index 87d18b3..746a4e4 100644 --- a/dashboard/jest.config.cjs +++ b/dashboard/jest.config.cjs @@ -1,6 +1,7 @@ module.exports = { preset: 'ts-jest/presets/default-esm', testEnvironment: 'jsdom', + testTimeout: 15000, setupFilesAfterEnv: ['/jest.setup.cjs'], extensionsToTreatAsEsm: ['.ts', '.tsx'], moduleNameMapper: { diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index 724938b..33c64f9 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -13,6 +13,7 @@ import { restoreWalletSession } from '../services/wallet'; const DEFAULT_EVENT_COUNT = 5000; const DEFAULT_LIMIT = 12; const API_URL = import.meta.env.VITE_EVENTS_API_URL ?? 'http://localhost:8787/api/events'; +const POLL_INTERVAL_MS = 15_000; function parsePageParam(search: string) { const params = new URLSearchParams(search); @@ -68,8 +69,23 @@ export function EventExplorerPage() { loadEvents(); + // Poll for status updates so delivered/failed notifications are reflected + // without requiring a manual page refresh. + const intervalId = setInterval(async () => { + try { + const remoteEvents = await fetchEvents(API_URL); + if (!cancelled) { + setEvents(remoteEvents); + } + } catch { + // Silently ignore polling errors — the error banner is reserved for + // the initial load failure so background polls don't disrupt the user. + } + }, POLL_INTERVAL_MS); + return () => { cancelled = true; + clearInterval(intervalId); }; }, [setEvents, setError, setLoading]); diff --git a/dashboard/src/pages/EventsPage.tsx b/dashboard/src/pages/EventsPage.tsx index e2415dc..a07db07 100644 --- a/dashboard/src/pages/EventsPage.tsx +++ b/dashboard/src/pages/EventsPage.tsx @@ -11,6 +11,7 @@ import { restoreWalletSession } from '../services/wallet'; const DEFAULT_EVENT_COUNT = 5000; const API_URL = import.meta.env.VITE_EVENTS_API_URL ?? 'http://localhost:8787/api/events'; +const POLL_INTERVAL_MS = 15_000; export function EventsPage() { const setEvents = useEventStore((state) => state.setEvents); @@ -48,8 +49,20 @@ export function EventsPage() { loadEvents(); + const intervalId = setInterval(async () => { + try { + const remoteEvents = await fetchEvents(API_URL); + if (!cancelled) { + setEvents(remoteEvents); + } + } catch { + // Silently ignore background poll errors. + } + }, POLL_INTERVAL_MS); + return () => { cancelled = true; + clearInterval(intervalId); }; }, [setEvents, setError, setLoading]); diff --git a/dashboard/src/store/eventStore.test.tsx b/dashboard/src/store/eventStore.test.tsx index c7771aa..4671977 100644 --- a/dashboard/src/store/eventStore.test.tsx +++ b/dashboard/src/store/eventStore.test.tsx @@ -115,3 +115,45 @@ describe('pagination + filter interaction', () => { expect(after[0].textContent).toContain('Withdrawal'); }); }); + +describe('stale cache regression tests', () => { + beforeEach(() => { + useEventStore.setState({ events: [], filters: { search: '', contractAddress: 'all', eventType: 'all' }, isLoading: false, error: null }); + }); + + it('setEvents with an updated record replaces the stale copy, not silently dropped', () => { + const [event] = generateMockEvents(1); + const staleEvent = { ...event, value: 'stale-value' }; + const freshEvent = { ...event, value: 'fresh-value' }; + + useEventStore.getState().setEvents([staleEvent]); + // Simulate a poll returning the same eventId with updated data + useEventStore.getState().setEvents([freshEvent]); + + const stored = useEventStore.getState().events; + expect(stored).toHaveLength(1); + expect(stored[0].value).toBe('fresh-value'); + }); + + it('appendEvents with an updated record replaces the stale copy', () => { + const [event] = generateMockEvents(1); + const staleEvent = { ...event, value: 'stale-value' }; + const freshEvent = { ...event, value: 'fresh-value' }; + + useEventStore.getState().setEvents([staleEvent]); + // Poll brings in the updated record + useEventStore.getState().appendEvents([freshEvent]); + + const stored = useEventStore.getState().events; + expect(stored).toHaveLength(1); + expect(stored[0].value).toBe('fresh-value'); + }); + + it('setEvents keeps order stable when no duplicates are present', () => { + const [a, b, c] = generateMockEvents(3); + useEventStore.getState().setEvents([a, b, c]); + + const stored = useEventStore.getState().events; + expect(stored.map((e) => e.eventId)).toEqual([a.eventId, b.eventId, c.eventId]); + }); +}); diff --git a/dashboard/src/store/eventStore.ts b/dashboard/src/store/eventStore.ts index 5dcd525..f2f633f 100644 --- a/dashboard/src/store/eventStore.ts +++ b/dashboard/src/store/eventStore.ts @@ -17,16 +17,13 @@ interface EventStoreState { } function dedupeEventsById(events: BlockchainEvent[]): BlockchainEvent[] { - const seenEventIds = new Set(); - - return events.filter((event) => { - if (seenEventIds.has(event.eventId)) { - return false; - } - - seenEventIds.add(event.eventId); - return true; - }); + // Use a Map to keep the last-seen record for each eventId so that status + // updates (newer entries) overwrite stale cached copies rather than being dropped. + const byId = new Map(); + for (const event of events) { + byId.set(event.eventId, event); + } + return Array.from(byId.values()); } export const useEventStore = create((set) => ({ @@ -41,6 +38,8 @@ export const useEventStore = create((set) => ({ setEvents: (events) => set({ events: dedupeEventsById(events) }), appendEvents: (events) => set((state) => ({ + // Existing events go first so incoming (fresh) events overwrite stale + // copies when the Map processes duplicates last-write-wins. events: dedupeEventsById([...state.events, ...events]), })), setSearch: (search) => diff --git a/listener/.gitignore b/listener/.gitignore index aa0926a..caf428f 100644 --- a/listener/.gitignore +++ b/listener/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ .env *.log +data/ diff --git a/listener/data/debug-test.db b/listener/data/debug-test.db deleted file mode 100644 index 454e5196194a0565f43f47e35c93ab5b3208b446..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 98304 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 diff --git a/listener/src/tests/notification-scheduler-refactored.test.ts b/listener/src/tests/notification-scheduler-refactored.test.ts index 51f5897..dd9128e 100644 --- a/listener/src/tests/notification-scheduler-refactored.test.ts +++ b/listener/src/tests/notification-scheduler-refactored.test.ts @@ -307,7 +307,7 @@ describe('NotificationScheduler (Refactored)', () => { ); // 2. Create a notification in the past (overdue, pending) - await repository.create( + const overdueId = await repository.create( NotificationFixtureBuilder .aScheduledNotificationInput() .forImmediateExecution() @@ -321,14 +321,23 @@ describe('NotificationScheduler (Refactored)', () => { .forImmediateExecution() .build() ); + // Lock only the stale notification — fetchAndLock picks up past items by priority order, + // so we lock both past items then restore item 2 to PENDING to isolate the stale case. await repository.fetchAndLockPendingNotifications('processor-1', 30000, 10); + await db.run( + "UPDATE scheduled_notifications SET status = 'PENDING', processor_id = NULL, lock_expires_at = NULL WHERE id = ?", + [overdueId] + ); const pastLock = NotificationFixtureBuilder.dates.past(1000); await db.run('UPDATE scheduled_notifications SET lock_expires_at = ? WHERE id = ?', [ pastLock.toISOString(), staleId, ]); - // Get stats BEFORE recovery + // Get stats BEFORE recovery: + // - item 1: PENDING (future) + // - item 2: PENDING (restored, overdue) + // - item 3: PROCESSING with expired lock → getStats adjusts to PENDING const stats = await repository.getStats(); expect(stats.pending).toBe(3);