Skip to content

Commit 372ffda

Browse files
committed
feat: integrate field reports functionality into dashboard with caching and UI controls
1 parent d6aec4d commit 372ffda

14 files changed

Lines changed: 545 additions & 36 deletions

File tree

components/dashboard/Dashboard.tsx

Lines changed: 98 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,23 @@
22
* Main Dashboard component - orchestrates data fetching and child components
33
*/
44

5+
import { add, isAfter } from "date-fns";
56
import React from "react";
67
import useSWR from "swr";
7-
import { add, isAfter } from "date-fns";
88

99
import { GearIcon, PauseIcon, PlayIcon } from "@components/icons";
1010
import { NetworkGraph2d } from "@components/network-graph/network-graph-2d";
1111
import { NetworkGraph3d } from "@components/network-graph/network-graph-3d";
1212

13-
import { SettingsPanel, type ExpandedSections } from "./SettingsPanel";
13+
import { FieldReportsOverlay } from "./FieldReportsOverlay";
1414
import { InfoPanel, type SelectedInfo } from "./InfoPanel";
15-
import { TimelineBar } from "./TimelineBar";
1615
import type { TimelineBucket } from "./sections";
16+
import { SettingsPanel, type ExpandedSections } from "./SettingsPanel";
17+
import { TimelineBar } from "./TimelineBar";
1718

19+
import { useFieldReports } from "@/hooks/dashboard";
1820
import type { DataResponse } from "@/pages/api/data";
21+
import type { FieldReportsResponse } from "@/types";
1922
import type { Voucher } from "@/types/voucher";
2023

2124
// Fetcher function for SWR
@@ -29,10 +32,24 @@ const now = new Date();
2932

3033
export function Dashboard() {
3134
// Data fetching with SWR
32-
const { data, error, isLoading } = useSWR<DataResponse>("/api/data", fetcher, {
33-
refreshInterval: 5 * 60 * 1000,
34-
revalidateOnFocus: false,
35-
});
35+
const { data, error, isLoading } = useSWR<DataResponse>(
36+
"/api/data",
37+
fetcher,
38+
{
39+
refreshInterval: 5 * 60 * 1000,
40+
revalidateOnFocus: false,
41+
}
42+
);
43+
44+
// Fetch field reports
45+
const { data: reportsData } = useSWR<FieldReportsResponse>(
46+
"/api/reports",
47+
fetcher,
48+
{
49+
refreshInterval: 5 * 60 * 1000,
50+
revalidateOnFocus: false,
51+
}
52+
);
3653

3754
// Panel states
3855
const [optionsOpen, setOptionsOpen] = React.useState(false);
@@ -41,7 +58,9 @@ export function Dashboard() {
4158

4259
// Token filtering
4360
const [selectedTokens, setSelectedTokens] = React.useState<Voucher[]>([]);
44-
const [filteredByToken, setFilteredByToken] = React.useState<DataResponse["graphData"]>({
61+
const [filteredByToken, setFilteredByToken] = React.useState<
62+
DataResponse["graphData"]
63+
>({
4564
nodes: [],
4665
links: [],
4766
});
@@ -53,6 +72,7 @@ export function Dashboard() {
5372

5473
// Display options
5574
const [showRecentOnly, setShowRecentOnly] = React.useState(true);
75+
const [showReports, setShowReports] = React.useState(false);
5676

5777
// Physics settings - input values (immediate UI feedback)
5878
const [chargeStrengthInput, setChargeStrengthInput] = React.useState(-8);
@@ -69,12 +89,13 @@ export function Dashboard() {
6989
const [copiedField, setCopiedField] = React.useState<string | null>(null);
7090

7191
// Collapsible sections state
72-
const [expandedSections, setExpandedSections] = React.useState<ExpandedSections>({
73-
vouchers: true,
74-
animation: false,
75-
display: false,
76-
physics: false,
77-
});
92+
const [expandedSections, setExpandedSections] =
93+
React.useState<ExpandedSections>({
94+
vouchers: true,
95+
animation: false,
96+
display: false,
97+
physics: false,
98+
});
7899

79100
// Debounce physics updates
80101
React.useEffect(() => {
@@ -101,12 +122,15 @@ export function Dashboard() {
101122
const newGraphData = {
102123
nodes: data.graphData.nodes.filter((node) =>
103124
selectedTokens.some((selectedToken) =>
104-
Object.keys(node.usedVouchers).includes(selectedToken.contract_address)
125+
Object.keys(node.usedVouchers).includes(
126+
selectedToken.contract_address
127+
)
105128
)
106129
),
107130
links: data.graphData.links.filter((link) =>
108131
selectedTokens.some(
109-
(selectedToken) => selectedToken.contract_address === link.contract_address
132+
(selectedToken) =>
133+
selectedToken.contract_address === link.contract_address
110134
)
111135
),
112136
};
@@ -186,7 +210,10 @@ export function Dashboard() {
186210
React.useEffect(() => {
187211
const handleKeyDown = (e: KeyboardEvent) => {
188212
// Ignore if user is typing in an input
189-
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
213+
if (
214+
e.target instanceof HTMLInputElement ||
215+
e.target instanceof HTMLTextAreaElement
216+
) {
190217
return;
191218
}
192219
if (e.code === "Space") {
@@ -238,15 +265,39 @@ export function Dashboard() {
238265
nodes: filteredNodes,
239266
links: activeLinks,
240267
};
241-
}, [filteredByToken.links, filteredByToken.nodes, availableNodeIds, date, showRecentOnly]);
268+
}, [
269+
filteredByToken.links,
270+
filteredByToken.nodes,
271+
availableNodeIds,
272+
date,
273+
showRecentOnly,
274+
]);
275+
276+
// Selected voucher addresses for filtering
277+
const selectedVoucherAddresses = React.useMemo(
278+
() => new Set(selectedTokens.map((t) => t.contract_address)),
279+
[selectedTokens]
280+
);
281+
282+
// Field reports filtering
283+
const { visibleReports, dismissReport, resetDismissed } = useFieldReports({
284+
reports: reportsData?.reports ?? [],
285+
currentDate: date,
286+
selectedVoucherAddresses,
287+
maxVisible: 3,
288+
});
289+
290+
// Reset dismissed reports when animation restarts from beginning
291+
React.useEffect(() => {
292+
if (date === dateRange.start) {
293+
resetDismissed();
294+
}
295+
}, [date, dateRange.start, resetDismissed]);
242296

243297
// Callbacks
244-
const toggleSection = React.useCallback(
245-
(section: keyof ExpandedSections) => {
246-
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
247-
},
248-
[]
249-
);
298+
const toggleSection = React.useCallback((section: keyof ExpandedSections) => {
299+
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
300+
}, []);
250301

251302
const handleNodeClick = React.useCallback((node: any) => {
252303
setSelectedInfo({
@@ -260,8 +311,10 @@ export function Dashboard() {
260311
}, []);
261312

262313
const handleLinkClick = React.useCallback((link: any) => {
263-
const sourceId = typeof link.source === "object" ? link.source.id : link.source;
264-
const targetId = typeof link.target === "object" ? link.target.id : link.target;
314+
const sourceId =
315+
typeof link.source === "object" ? link.source.id : link.source;
316+
const targetId =
317+
typeof link.target === "object" ? link.target.id : link.target;
265318
setSelectedInfo({
266319
type: "link",
267320
data: {
@@ -335,13 +388,15 @@ export function Dashboard() {
335388
{animate ? (
336389
<PauseIcon onClick={() => setAnimate(false)} />
337390
) : (
338-
<PlayIcon onClick={() => {
339-
// If at or past end, reset to start before playing
340-
if (date >= dateRange.end) {
341-
setDate(dateRange.start);
342-
}
343-
setAnimate(true);
344-
}} />
391+
<PlayIcon
392+
onClick={() => {
393+
// If at or past end, reset to start before playing
394+
if (date >= dateRange.end) {
395+
setDate(dateRange.start);
396+
}
397+
setAnimate(true);
398+
}}
399+
/>
345400
)}
346401
<GearIcon onClick={() => setOptionsOpen((prev) => !prev)} />
347402
</div>
@@ -372,6 +427,8 @@ export function Dashboard() {
372427
setShowRecentOnly={setShowRecentOnly}
373428
showTimelineBar={showTimelineBar}
374429
setShowTimelineBar={setShowTimelineBar}
430+
showReports={showReports}
431+
setShowReports={setShowReports}
375432
physicsInputs={{
376433
chargeStrengthInput,
377434
linkDistanceInput,
@@ -408,6 +465,14 @@ export function Dashboard() {
408465
/>
409466
)}
410467

468+
{/* Field Reports Overlay */}
469+
{showReports && (
470+
<FieldReportsOverlay
471+
visibleReports={visibleReports}
472+
onDismiss={dismissReport}
473+
/>
474+
)}
475+
411476
{/* Bottom Timeline Bar */}
412477
{showTimelineBar && (
413478
<TimelineBar
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Individual field report card with animations
3+
*/
4+
5+
import React from "react";
6+
import { CloseIcon } from "@components/icons";
7+
import type { VisibleReport } from "@/types";
8+
9+
export interface FieldReportCardProps {
10+
report: VisibleReport;
11+
onDismiss: () => void;
12+
}
13+
14+
export function FieldReportCard({ report, onDismiss }: FieldReportCardProps) {
15+
const handleDismiss = (e: React.MouseEvent) => {
16+
e.preventDefault();
17+
e.stopPropagation();
18+
onDismiss();
19+
};
20+
21+
return (
22+
<a
23+
href={`https://sarafu.network/reports/${report.id}`}
24+
target="_blank"
25+
rel="noopener noreferrer"
26+
className="block w-72 max-w-[calc(100vw-2rem)] bg-white/95 backdrop-blur-sm rounded-lg shadow-xl border border-gray-200 overflow-hidden hover:shadow-2xl hover:scale-[1.02] transition-all cursor-pointer"
27+
>
28+
{/* Image */}
29+
{report.image_url && (
30+
<div className="relative h-32 w-full">
31+
<img
32+
src={report.image_url}
33+
alt={report.title}
34+
className="w-full h-full object-cover"
35+
/>
36+
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
37+
<button
38+
onClick={handleDismiss}
39+
className="absolute top-2 right-2 p-1 bg-black/50 rounded-full hover:bg-black/70 transition-colors"
40+
>
41+
<CloseIcon
42+
onClick={onDismiss}
43+
className="w-4 h-4 text-white"
44+
/>
45+
</button>
46+
</div>
47+
)}
48+
49+
{/* Content */}
50+
<div className="p-3 space-y-2">
51+
{/* Title with dismiss if no image */}
52+
<div className="flex items-start justify-between gap-2">
53+
<h3 className="text-sm font-semibold text-gray-800 line-clamp-2 flex-1">
54+
{report.title}
55+
</h3>
56+
{!report.image_url && (
57+
<button
58+
onClick={handleDismiss}
59+
className="p-0.5 hover:bg-gray-100 rounded transition-colors"
60+
>
61+
<CloseIcon
62+
onClick={onDismiss}
63+
className="w-4 h-4 text-gray-400 hover:text-gray-600 transition-colors flex-shrink-0"
64+
/>
65+
</button>
66+
)}
67+
</div>
68+
69+
{/* Tags */}
70+
{report.tags && report.tags.length > 0 && (
71+
<div className="flex flex-wrap gap-1">
72+
{report.tags.slice(0, 4).map((tag, index) => (
73+
<span
74+
key={index}
75+
className="px-2 py-0.5 bg-emerald-50 text-emerald-700 text-xs rounded-full"
76+
>
77+
{tag}
78+
</span>
79+
))}
80+
{report.tags.length > 4 && (
81+
<span className="px-2 py-0.5 bg-gray-100 text-gray-500 text-xs rounded-full">
82+
+{report.tags.length - 4}
83+
</span>
84+
)}
85+
</div>
86+
)}
87+
</div>
88+
89+
{/* Bottom accent bar */}
90+
<div className="h-1 bg-gradient-to-r from-emerald-500 to-green-600" />
91+
</a>
92+
);
93+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Overlay container for field report cards with animations
3+
*/
4+
5+
import React from "react";
6+
import { AnimatePresence, motion } from "framer-motion";
7+
import { FieldReportCard } from "./FieldReportCard";
8+
import type { VisibleReport } from "@/types";
9+
10+
export interface FieldReportsOverlayProps {
11+
visibleReports: VisibleReport[];
12+
onDismiss: (reportId: number) => void;
13+
}
14+
15+
const cardVariants = {
16+
initial: {
17+
opacity: 0,
18+
y: 20,
19+
scale: 0.95,
20+
},
21+
animate: {
22+
opacity: 1,
23+
y: 0,
24+
scale: 1,
25+
transition: {
26+
duration: 0.4,
27+
ease: "easeOut",
28+
},
29+
},
30+
exit: {
31+
opacity: 0,
32+
y: -10,
33+
scale: 0.95,
34+
transition: {
35+
duration: 0.3,
36+
ease: "easeIn",
37+
},
38+
},
39+
};
40+
41+
export function FieldReportsOverlay({
42+
visibleReports,
43+
onDismiss,
44+
}: FieldReportsOverlayProps) {
45+
if (visibleReports.length === 0) {
46+
return null;
47+
}
48+
49+
return (
50+
<div className="absolute bottom-20 left-0 right-0 z-10 flex justify-center gap-3 px-4 overflow-x-auto pointer-events-none overflow-y-hidden">
51+
<AnimatePresence mode="popLayout">
52+
{visibleReports.map((report) => (
53+
<motion.div
54+
key={report.id}
55+
layout
56+
variants={cardVariants}
57+
initial="initial"
58+
animate="animate"
59+
exit="exit"
60+
className="pointer-events-auto flex-shrink-0"
61+
>
62+
<FieldReportCard
63+
report={report}
64+
onDismiss={() => onDismiss(report.id)}
65+
/>
66+
</motion.div>
67+
))}
68+
</AnimatePresence>
69+
</div>
70+
);
71+
}

0 commit comments

Comments
 (0)