feat: add summary cards in Stat page
This commit is contained in:
52
frontend/src/components/Card.tsx
Normal file
52
frontend/src/components/Card.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
const Card = (props: {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
sublabel?: string;
|
||||||
|
rightSlot?: React.ReactNode;
|
||||||
|
style?: CSSProperties
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: "rgba(255,255,255,0.85)",
|
||||||
|
border: "1px solid rgba(15,23,42,0.08)",
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 14,
|
||||||
|
boxShadow: "0 12px 30px rgba(15,23,42,0.06)",
|
||||||
|
minHeight: 88,
|
||||||
|
...props.style
|
||||||
|
}}>
|
||||||
|
<div style={ {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "rgba(15, 23, 42, 0.65)",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
textTransform: "uppercase"
|
||||||
|
}}>
|
||||||
|
{props.label}
|
||||||
|
</div>
|
||||||
|
{props.rightSlot ? <div>{props.rightSlot}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 850,
|
||||||
|
marginTop: 6,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
}}>{props.value}</div>
|
||||||
|
{props.sublabel ? <div style={{
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "rgba(15, 23, 42, 0.55)",
|
||||||
|
}}>{props.sublabel}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Card;
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import ActivityHeatmap from "../stats/ActivityHeatmap";
|
import ActivityHeatmap from "../stats/ActivityHeatmap";
|
||||||
import { ReactWordcloud } from '@cp949/react-wordcloud';
|
import { ReactWordcloud } from '@cp949/react-wordcloud';
|
||||||
import StatsStyling from "../styles/stats_styling";
|
import StatsStyling from "../styles/stats_styling";
|
||||||
|
import Card from "../components/Card";
|
||||||
|
|
||||||
type BackendWord = {
|
type BackendWord = {
|
||||||
word: string;
|
word: string;
|
||||||
@@ -25,8 +26,36 @@ type TopUser = {
|
|||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SummaryResponse = {
|
||||||
|
total_events: number;
|
||||||
|
total_posts: number;
|
||||||
|
total_comments: number;
|
||||||
|
unique_users: number;
|
||||||
|
comments_per_post: number;
|
||||||
|
lurker_ratio: number;
|
||||||
|
time_range: {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
sources: string[];
|
||||||
|
};
|
||||||
|
|
||||||
const styles = StatsStyling;
|
const styles = StatsStyling;
|
||||||
|
|
||||||
|
function formatDateRange(startUnix: number, endUnix: number) {
|
||||||
|
const start = new Date(startUnix * 1000);
|
||||||
|
const end = new Date(endUnix * 1000);
|
||||||
|
|
||||||
|
const fmt = (d: Date) =>
|
||||||
|
d.toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${fmt(start)} → ${fmt(end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
const StatPage = () => {
|
const StatPage = () => {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -35,6 +64,8 @@ const StatPage = () => {
|
|||||||
const [heatmapData, setHeatmapData] = useState([]);
|
const [heatmapData, setHeatmapData] = useState([]);
|
||||||
const [topUserData, setTopUserData] = useState<TopUser[]>([]);
|
const [topUserData, setTopUserData] = useState<TopUser[]>([]);
|
||||||
const [wordFrequencyData, setWordFrequencyData] = useState([]);
|
const [wordFrequencyData, setWordFrequencyData] = useState([]);
|
||||||
|
const [summary, setSummary] = useState<SummaryResponse | null>(null);
|
||||||
|
|
||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const beforeDateRef = useRef<HTMLInputElement>(null);
|
const beforeDateRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -48,8 +79,9 @@ const StatPage = () => {
|
|||||||
axios.get("http://localhost:5000/stats/time"),
|
axios.get("http://localhost:5000/stats/time"),
|
||||||
axios.get("http://localhost:5000/stats/user"),
|
axios.get("http://localhost:5000/stats/user"),
|
||||||
axios.get("http://localhost:5000/stats/content"),
|
axios.get("http://localhost:5000/stats/content"),
|
||||||
|
axios.get<SummaryResponse>(`http://localhost:5000/stats/summary`),
|
||||||
])
|
])
|
||||||
.then(([timeRes, userRes, wordsRes]) => {
|
.then(([timeRes, userRes, wordsRes, summaryRes]) => {
|
||||||
const eventsPerDay = Array.isArray(timeRes.data?.events_per_day)
|
const eventsPerDay = Array.isArray(timeRes.data?.events_per_day)
|
||||||
? timeRes.data.events_per_day.filter((d: any) => new Date(d.date) >= new Date("2026-01-10"))
|
? timeRes.data.events_per_day.filter((d: any) => new Date(d.date) >= new Date("2026-01-10"))
|
||||||
: [];
|
: [];
|
||||||
@@ -78,6 +110,7 @@ const StatPage = () => {
|
|||||||
value: d.count,
|
value: d.count,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
setSummary(summaryRes.data ?? null);
|
||||||
})
|
})
|
||||||
.catch((e) => setError("Failed to load statistics: " + String(e)))
|
.catch((e) => setError("Failed to load statistics: " + String(e)))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -162,7 +195,47 @@ return (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* main grid*/}
|
{/* main grid*/}
|
||||||
<div style={{ ...styles.container, ...styles.grid }}>
|
<div style={{ ...styles.container, ...styles.grid}}>
|
||||||
|
<Card
|
||||||
|
label="Time Range"
|
||||||
|
value={
|
||||||
|
summary?.time_range
|
||||||
|
? formatDateRange(summary.time_range.start, summary.time_range.end)
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
sublabel="Based on dataset timestamps"
|
||||||
|
style={{
|
||||||
|
gridColumn: "span 4"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
label="Lurker Ratio"
|
||||||
|
value={
|
||||||
|
typeof summary?.lurker_ratio === "number"
|
||||||
|
? `${Math.round(summary.lurker_ratio * 100)}%`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
sublabel="Users with only 1 event"
|
||||||
|
style={{
|
||||||
|
gridColumn: "span 4"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
label="Sources"
|
||||||
|
value={summary?.sources?.length ?? "—"}
|
||||||
|
sublabel={
|
||||||
|
summary?.sources?.length
|
||||||
|
? summary.sources.slice(0, 3).join(", ") +
|
||||||
|
(summary.sources.length > 3 ? "…" : "")
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
gridColumn: "span 4"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* events per day */}
|
{/* events per day */}
|
||||||
<div style={{ ...styles.card, gridColumn: "span 5" }}>
|
<div style={{ ...styles.card, gridColumn: "span 5" }}>
|
||||||
<h2 style={styles.sectionTitle}>Events per Day</h2>
|
<h2 style={styles.sectionTitle}>Events per Day</h2>
|
||||||
@@ -200,12 +273,7 @@ return (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Users */}
|
{/* Top Users */}
|
||||||
{topUserData?.length > 0 && (
|
<div style={{...styles.card, ...styles.scrollArea, gridColumn: "span 3",
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
...styles.card,
|
|
||||||
...styles.scrollArea,
|
|
||||||
gridColumn: "span 3",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={styles.sectionTitle}>Top Users</h2>
|
<h2 style={styles.sectionTitle}>Top Users</h2>
|
||||||
@@ -225,7 +293,6 @@ return (
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Heatmap */}
|
{/* Heatmap */}
|
||||||
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user