Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

# testing
/coverage
/playwright-report/
/test-results/

# next.js
/.next/
Expand Down
6 changes: 6 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,10 @@ export default defineConfig({
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});
45 changes: 34 additions & 11 deletions src/components/spatial/FleetGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,40 @@ interface FleetAsset {
resource: number;
}

const ASSETS: FleetAsset[] = Array.from({ length: 48 }, (_, i) => ({
id: `asset-${i}`,
name: `Meter-${String(i + 1).padStart(3, "0")}`,
gridId: `grid-${Math.floor(i / 6) + 1}`,
status: ((["online", "offline", "maintenance"] as const)[
Math.random() > 0.15 ? 0 : Math.random() > 0.5 ? 1 : 2
]),
uptime: 95 + Math.random() * 5,
lastPing: Date.now() - Math.floor(Math.random() * 60000),
resource: 0.3 + Math.random() * 0.7,
}));
// Seeded random number generator for consistent SSR/client rendering
function seededRandom(seed: number): () => number {
let current = seed;
return () => {
current = (current * 9301 + 49297) % 233280;
return current / 233280;
};
}

// Generate assets with seeded random for SSR safety
function generateAssets(): FleetAsset[] {
const random = seededRandom(42); // Fixed seed for consistency
return Array.from({ length: 48 }, (_, i) => {
const statusRand1 = random();
const statusRand2 = random();
const uptimeRand = random();
const pingRand = random();
const resourceRand = random();

return {
id: `asset-${i}`,
name: `Meter-${String(i + 1).padStart(3, "0")}`,
gridId: `grid-${Math.floor(i / 6) + 1}`,
status: ((["online", "offline", "maintenance"] as const)[
statusRand1 > 0.15 ? 0 : statusRand2 > 0.5 ? 1 : 2
]),
uptime: 95 + uptimeRand * 5,
lastPing: 1700000000000 - Math.floor(pingRand * 60000), // Fixed base timestamp
resource: 0.3 + resourceRand * 0.7,
};
});
}

const ASSETS: FleetAsset[] = generateAssets();

const STATUS_COLORS: Record<FleetAsset["status"], string> = {
online: "bg-green-500",
Expand Down
24 changes: 20 additions & 4 deletions src/components/spatial/GridMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,17 @@ interface GridNode {
status: "active" | "idle" | "fault";
}

// Seeded random number generator for consistent rendering
function seededRandom(seed: number): () => number {
let current = seed;
return () => {
current = (current * 9301 + 49297) % 233280;
return current / 233280;
};
}

function generateNodes(count: number, width: number, height: number): GridNode[] {
const random = seededRandom(123); // Fixed seed for consistency
const nodes: GridNode[] = [];
const cols = Math.ceil(Math.sqrt(count));
const rows = Math.ceil(count / cols);
Expand All @@ -20,12 +30,18 @@ function generateNodes(count: number, width: number, height: number): GridNode[]
for (let i = 0; i < count; i++) {
const col = i % cols;
const row = Math.floor(i / cols);
const rand1 = random();
const rand2 = random();
const rand3 = random();
const rand4 = random();
const rand5 = random();

nodes.push({
id: `node-${i}`,
x: col * cellW + cellW / 2 + (Math.random() - 0.5) * cellW * 0.4,
y: row * cellH + cellH / 2 + (Math.random() - 0.5) * cellH * 0.4,
resource: 0.2 + Math.random() * 0.8,
status: Math.random() > 0.15 ? "active" : Math.random() > 0.5 ? "idle" : "fault",
x: col * cellW + cellW / 2 + (rand1 - 0.5) * cellW * 0.4,
y: row * cellH + cellH / 2 + (rand2 - 0.5) * cellH * 0.4,
resource: 0.2 + rand3 * 0.8,
status: rand4 > 0.15 ? "active" : rand5 > 0.5 ? "idle" : "fault",
});
}
return nodes;
Expand Down
200 changes: 151 additions & 49 deletions src/hooks/useRetryQueue.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,86 @@
"use client";

import { useState, useCallback, useRef, useEffect } from "react";
import type { QueuedTransaction, UseRetryQueueReturn } from "@/types/retry-queue";

interface QueuedTransaction {
id: string;
txHash: string | null;
status: "pending" | "submitted" | "confirmed" | "failed";
retryCount: number;
maxRetries: number;
error: string | null;
createdAt: number;
}
const RETRY_DELAYS = [1000, 5000, 15000, 30000, 60000];
const STORAGE_KEY = "utility-retry-queue";

interface UseRetryQueueReturn {
queue: QueuedTransaction[];
enqueue: (id: string, maxRetries?: number) => void;
updateTxHash: (id: string, txHash: string) => void;
markConfirmed: (id: string) => void;
markFailed: (id: string, error: string) => void;
retry: (id: string) => Promise<void>;
purge: () => void;
pendingCount: number;
/**
* Serialize queue for storage, excluding in-progress timers
*/
function serializeQueue(queue: QueuedTransaction[]): string {
return JSON.stringify(queue);
}

const RETRY_DELAYS = [1000, 5000, 15000, 30000, 60000];
/**
* Deserialize queue from storage
*/
function deserializeQueue(data: string): QueuedTransaction[] {
try {
return JSON.parse(data);
} catch {
return [];
}
}

export function useRetryQueue(): UseRetryQueueReturn {
const [queue, setQueue] = useState<QueuedTransaction[]>([]);
const [isHydrated, setIsHydrated] = useState(false);
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());

// Initialize from localStorage
useEffect(() => {
if (typeof window === "undefined") return;

try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const deserializedQueue = deserializeQueue(stored);
setQueue(deserializedQueue);

// Reschedule retries for transactions that were awaiting retry
deserializedQueue.forEach((tx) => {
if (tx.status === "retrying" && tx.nextRetryAt) {
const delay = Math.max(0, tx.nextRetryAt - Date.now());
const timer = setTimeout(() => {
setQueue((prev) =>
prev.map((t) =>
t.id === tx.id
? {
...t,
retryCount: t.retryCount + 1,
status: "pending" as const,
error: null,
nextRetryAt: undefined,
}
: t
)
);
}, delay);
timersRef.current.set(tx.id, timer);
}
});
}
} catch (err) {
console.error("Failed to restore retry queue from localStorage:", err);
localStorage.removeItem(STORAGE_KEY);
}

setIsHydrated(true);
}, []);

// Persist queue to localStorage whenever it changes
useEffect(() => {
if (!isHydrated || typeof window === "undefined") return;

try {
localStorage.setItem(STORAGE_KEY, serializeQueue(queue));
} catch (err) {
console.error("Failed to persist retry queue to localStorage:", err);
}
}, [queue, isHydrated]);

const cleanupTimer = useCallback((id: string) => {
const timer = timersRef.current.get(id);
if (timer) {
Expand Down Expand Up @@ -68,7 +120,7 @@ export function useRetryQueue(): UseRetryQueueReturn {
cleanupTimer(id);
setQueue((prev) =>
prev.map((t) =>
t.id === id ? { ...t, status: "confirmed" as const } : t
t.id === id ? { ...t, status: "confirmed" as const, error: null, nextRetryAt: undefined } : t
)
);
},
Expand All @@ -77,49 +129,97 @@ export function useRetryQueue(): UseRetryQueueReturn {

const markFailed = useCallback(
(id: string, error: string) => {
const tx = queue.find((t) => t.id === id);
if (!tx) return;
if (tx.retryCount < tx.maxRetries) {
const delay =
RETRY_DELAYS[Math.min(tx.retryCount, RETRY_DELAYS.length - 1)];
const timer = setTimeout(() => {
setQueue((prev) =>
prev.map((t) =>
t.id === id
? { ...t, retryCount: t.retryCount + 1, status: "pending" as const, error: null }
: t
)
setQueue((prev) => {
const tx = prev.find((t) => t.id === id);
if (!tx) return prev;

// If we have retries remaining, schedule the next retry
if (tx.retryCount < tx.maxRetries) {
const delayIndex = Math.min(tx.retryCount, RETRY_DELAYS.length - 1);
const delay = RETRY_DELAYS[delayIndex];
const nextRetryAt = Date.now() + delay;

const timer = setTimeout(() => {
setQueue((innerPrev) =>
innerPrev.map((t) =>
t.id === id
? {
...t,
retryCount: t.retryCount + 1,
status: "pending" as const,
error: null,
nextRetryAt: undefined,
}
: t
)
);
}, delay);

timersRef.current.set(id, timer);

// Update status to "retrying" with scheduled next retry
return prev.map((t) =>
t.id === id
? {
...t,
status: "retrying" as const,
error,
nextRetryAt,
}
: t
);
}, delay);
timersRef.current.set(id, timer);
}
}

// No retries remaining - mark as permanently failed
cleanupTimer(id);
return prev.map((t) =>
t.id === id
? {
...t,
status: "failed" as const,
error,
nextRetryAt: undefined,
}
: t
);
});
},
[cleanupTimer]
);

const retry = useCallback(
async (id: string) => {
cleanupTimer(id);
setQueue((prev) =>
prev.map((t) => (t.id === id ? { ...t, status: "failed" as const, error } : t))
prev.map((t) =>
t.id === id
? {
...t,
status: "pending" as const,
retryCount: t.retryCount + 1,
error: null,
nextRetryAt: undefined,
}
: t
)
);
},
[queue, cleanupTimer]
[cleanupTimer]
);

const retry = useCallback(async (id: string) => {
cleanupTimer(id);
setQueue((prev) =>
prev.map((t) =>
t.id === id
? { ...t, status: "pending" as const, retryCount: t.retryCount + 1, error: null }
: t
)
);
}, [cleanupTimer]);

const purge = useCallback(() => {
timersRef.current.forEach((timer) => clearTimeout(timer));
timersRef.current.clear();
setQueue([]);
if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
}
}, []);

useEffect(() => {
return () => {
timersRef.current.forEach((timer) => clearTimeout(timer));
timersRef.current.clear();
};
}, []);

Expand All @@ -131,6 +231,8 @@ export function useRetryQueue(): UseRetryQueueReturn {
markFailed,
retry,
purge,
pendingCount: queue.filter((t) => t.status === "pending" || t.status === "submitted").length,
pendingCount: queue.filter(
(t) => t.status === "pending" || t.status === "submitted"
).length,
};
}
Loading
Loading