From ee1afc9a80797415724f3fa187fbeb67f726da76 Mon Sep 17 00:00:00 2001 From: Adeswalla Date: Wed, 24 Jun 2026 07:39:39 +0100 Subject: [PATCH 1/2] [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/2] [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); }