feat: add summary cards in Stat page

This commit is contained in:
2026-02-03 10:28:24 +00:00
parent 471fea39c8
commit 30b11ce66e
2 changed files with 144 additions and 25 deletions

View 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;

View File

@@ -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" }}>