style: run prettifier plugin on entire frontend

This commit is contained in:
2026-03-25 19:30:21 +00:00
parent 8730af146d
commit aae10c4d9d
20 changed files with 1381 additions and 868 deletions

View File

@@ -3,7 +3,7 @@ import axios from "axios";
import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { Outlet, useLocation, useNavigate } from "react-router-dom";
import StatsStyling from "../styles/stats_styling"; import StatsStyling from "../styles/stats_styling";
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL const API_BASE_URL = import.meta.env.VITE_BACKEND_URL;
type ProfileResponse = { type ProfileResponse = {
user?: Record<string, unknown>; user?: Record<string, unknown>;
@@ -33,7 +33,10 @@ const AppLayout = () => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [isSignedIn, setIsSignedIn] = useState(false); const [isSignedIn, setIsSignedIn] = useState(false);
const [currentUser, setCurrentUser] = useState<Record<string, unknown> | null>(null); const [currentUser, setCurrentUser] = useState<Record<
string,
unknown
> | null>(null);
const syncAuthState = useCallback(async () => { const syncAuthState = useCallback(async () => {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
@@ -48,7 +51,9 @@ const AppLayout = () => {
axios.defaults.headers.common.Authorization = `Bearer ${token}`; axios.defaults.headers.common.Authorization = `Bearer ${token}`;
try { try {
const response = await axios.get<ProfileResponse>(`${API_BASE_URL}/profile`); const response = await axios.get<ProfileResponse>(
`${API_BASE_URL}/profile`,
);
setIsSignedIn(true); setIsSignedIn(true);
setCurrentUser(response.data.user ?? null); setCurrentUser(response.data.user ?? null);
} catch { } catch {
@@ -81,27 +86,35 @@ const AppLayout = () => {
<div style={{ ...styles.container, ...styles.appHeaderWrap }}> <div style={{ ...styles.container, ...styles.appHeaderWrap }}>
<div style={{ ...styles.card, ...styles.headerBar }}> <div style={{ ...styles.card, ...styles.headerBar }}>
<div style={styles.appHeaderBrandRow}> <div style={styles.appHeaderBrandRow}>
<span style={styles.appTitle}> <span style={styles.appTitle}>CrossPost Analysis Engine</span>
CrossPost Analysis Engine
</span>
<span <span
style={{ style={{
...styles.authStatusBadge, ...styles.authStatusBadge,
...(isSignedIn ? styles.authStatusSignedIn : styles.authStatusSignedOut), ...(isSignedIn
? styles.authStatusSignedIn
: styles.authStatusSignedOut),
}} }}
> >
{isSignedIn ? `Signed in: ${getUserLabel(currentUser)}` : "Not signed in"} {isSignedIn
? `Signed in: ${getUserLabel(currentUser)}`
: "Not signed in"}
</span> </span>
</div> </div>
<div style={styles.controlsWrapped}> <div style={styles.controlsWrapped}>
{isSignedIn && <button {isSignedIn && (
type="button" <button
style={location.pathname === "/datasets" ? styles.buttonPrimary : styles.buttonSecondary} type="button"
onClick={() => navigate("/datasets")} style={
> location.pathname === "/datasets"
My datasets ? styles.buttonPrimary
</button>} : styles.buttonSecondary
}
onClick={() => navigate("/datasets")}
>
My datasets
</button>
)}
<button <button
type="button" type="button"

View File

@@ -8,20 +8,20 @@ const Card = (props: {
value: string | number; value: string | number;
sublabel?: string; sublabel?: string;
rightSlot?: React.ReactNode; rightSlot?: React.ReactNode;
style?: CSSProperties style?: CSSProperties;
}) => { }) => {
return ( return (
<div style={{ ...styles.cardBase, ...props.style }}> <div style={{ ...styles.cardBase, ...props.style }}>
<div style={styles.cardTopRow}> <div style={styles.cardTopRow}>
<div style={styles.cardLabel}> <div style={styles.cardLabel}>{props.label}</div>
{props.label}
</div>
{props.rightSlot ? <div>{props.rightSlot}</div> : null} {props.rightSlot ? <div>{props.rightSlot}</div> : null}
</div> </div>
<div style={styles.cardValue}>{props.value}</div> <div style={styles.cardValue}>{props.value}</div>
{props.sublabel ? <div style={styles.cardSubLabel}>{props.sublabel}</div> : null} {props.sublabel ? (
<div style={styles.cardSubLabel}>{props.sublabel}</div>
) : null}
</div> </div>
); );
} };
export default Card; export default Card;

View File

@@ -34,10 +34,20 @@ export default function ConfirmationModal({
<p style={styles.sectionSubtitle}>{message}</p> <p style={styles.sectionSubtitle}>{message}</p>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}> <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<button type="button" onClick={onCancel} style={styles.buttonSecondary} disabled={loading}> <button
type="button"
onClick={onCancel}
style={styles.buttonSecondary}
disabled={loading}
>
{cancelLabel} {cancelLabel}
</button> </button>
<button type="button" onClick={onConfirm} style={styles.buttonDanger} disabled={loading}> <button
type="button"
onClick={onConfirm}
style={styles.buttonDanger}
disabled={loading}
>
{loading ? "Deleting..." : confirmLabel} {loading ? "Deleting..." : confirmLabel}
</button> </button>
</div> </div>

View File

@@ -14,15 +14,17 @@ const CulturalStats = ({ data }: CulturalStatsProps) => {
const inGroupWords = identity?.in_group_usage ?? 0; const inGroupWords = identity?.in_group_usage ?? 0;
const outGroupWords = identity?.out_group_usage ?? 0; const outGroupWords = identity?.out_group_usage ?? 0;
const totalGroupWords = inGroupWords + outGroupWords; const totalGroupWords = inGroupWords + outGroupWords;
const inGroupWordRate = typeof identity?.in_group_ratio === "number" const inGroupWordRate =
? identity.in_group_ratio * 100 typeof identity?.in_group_ratio === "number"
: null; ? identity.in_group_ratio * 100
const outGroupWordRate = typeof identity?.out_group_ratio === "number" : null;
? identity.out_group_ratio * 100 const outGroupWordRate =
: null; typeof identity?.out_group_ratio === "number"
? identity.out_group_ratio * 100
: null;
const rawEntities = data.avg_emotion_per_entity?.entity_emotion_avg ?? {}; const rawEntities = data.avg_emotion_per_entity?.entity_emotion_avg ?? {};
const entities = Object.entries(rawEntities) const entities = Object.entries(rawEntities)
.sort((a, b) => (b[1].post_count - a[1].post_count)) .sort((a, b) => b[1].post_count - a[1].post_count)
.slice(0, 20); .slice(0, 20);
const topEmotion = (emotionAvg: Record<string, number> | undefined) => { const topEmotion = (emotionAvg: Record<string, number> | undefined) => {
@@ -42,7 +44,10 @@ const CulturalStats = ({ data }: CulturalStatsProps) => {
<div style={{ ...styles.container, ...styles.grid }}> <div style={{ ...styles.container, ...styles.grid }}>
<div style={{ ...styles.card, gridColumn: "span 12" }}> <div style={{ ...styles.card, gridColumn: "span 12" }}>
<h2 style={styles.sectionTitle}>Community Framing Overview</h2> <h2 style={styles.sectionTitle}>Community Framing Overview</h2>
<p style={styles.sectionSubtitle}>Simple view of how often people use "us" words vs "them" words, and the tone around that language.</p> <p style={styles.sectionSubtitle}>
Simple view of how often people use "us" words vs "them" words, and
the tone around that language.
</p>
</div> </div>
<Card <Card
@@ -84,13 +89,17 @@ const CulturalStats = ({ data }: CulturalStatsProps) => {
/> />
<Card <Card
label="In-Group Share" label="In-Group Share"
value={inGroupWordRate === null ? "—" : `${inGroupWordRate.toFixed(2)}%`} value={
inGroupWordRate === null ? "—" : `${inGroupWordRate.toFixed(2)}%`
}
sublabel="Share of all words" sublabel="Share of all words"
style={{ gridColumn: "span 3" }} style={{ gridColumn: "span 3" }}
/> />
<Card <Card
label="Out-Group Share" label="Out-Group Share"
value={outGroupWordRate === null ? "—" : `${outGroupWordRate.toFixed(2)}%`} value={
outGroupWordRate === null ? "—" : `${outGroupWordRate.toFixed(2)}%`
}
sublabel="Share of all words" sublabel="Share of all words"
style={{ gridColumn: "span 3" }} style={{ gridColumn: "span 3" }}
/> />
@@ -98,52 +107,87 @@ const CulturalStats = ({ data }: CulturalStatsProps) => {
<Card <Card
label="Hedging Words" label="Hedging Words"
value={stance?.hedge_total?.toLocaleString() ?? "—"} value={stance?.hedge_total?.toLocaleString() ?? "—"}
sublabel={typeof stance?.hedge_per_1k_tokens === "number" ? `${stance.hedge_per_1k_tokens.toFixed(1)} per 1k words` : "Word frequency"} sublabel={
typeof stance?.hedge_per_1k_tokens === "number"
? `${stance.hedge_per_1k_tokens.toFixed(1)} per 1k words`
: "Word frequency"
}
style={{ gridColumn: "span 3" }} style={{ gridColumn: "span 3" }}
/> />
<Card <Card
label="Certainty Words" label="Certainty Words"
value={stance?.certainty_total?.toLocaleString() ?? "—"} value={stance?.certainty_total?.toLocaleString() ?? "—"}
sublabel={typeof stance?.certainty_per_1k_tokens === "number" ? `${stance.certainty_per_1k_tokens.toFixed(1)} per 1k words` : "Word frequency"} sublabel={
typeof stance?.certainty_per_1k_tokens === "number"
? `${stance.certainty_per_1k_tokens.toFixed(1)} per 1k words`
: "Word frequency"
}
style={{ gridColumn: "span 3" }} style={{ gridColumn: "span 3" }}
/> />
<Card <Card
label="Need/Should Words" label="Need/Should Words"
value={stance?.deontic_total?.toLocaleString() ?? "—"} value={stance?.deontic_total?.toLocaleString() ?? "—"}
sublabel={typeof stance?.deontic_per_1k_tokens === "number" ? `${stance.deontic_per_1k_tokens.toFixed(1)} per 1k words` : "Word frequency"} sublabel={
typeof stance?.deontic_per_1k_tokens === "number"
? `${stance.deontic_per_1k_tokens.toFixed(1)} per 1k words`
: "Word frequency"
}
style={{ gridColumn: "span 3" }} style={{ gridColumn: "span 3" }}
/> />
<Card <Card
label="Permission Words" label="Permission Words"
value={stance?.permission_total?.toLocaleString() ?? "—"} value={stance?.permission_total?.toLocaleString() ?? "—"}
sublabel={typeof stance?.permission_per_1k_tokens === "number" ? `${stance.permission_per_1k_tokens.toFixed(1)} per 1k words` : "Word frequency"} sublabel={
typeof stance?.permission_per_1k_tokens === "number"
? `${stance.permission_per_1k_tokens.toFixed(1)} per 1k words`
: "Word frequency"
}
style={{ gridColumn: "span 3" }} style={{ gridColumn: "span 3" }}
/> />
<div style={{ ...styles.card, gridColumn: "span 6" }}> <div style={{ ...styles.card, gridColumn: "span 6" }}>
<h2 style={styles.sectionTitle}>Mood in "Us" Posts</h2> <h2 style={styles.sectionTitle}>Mood in "Us" Posts</h2>
<p style={styles.sectionSubtitle}>Most likely emotion when in-group wording is stronger.</p> <p style={styles.sectionSubtitle}>
<div style={styles.topUserName}>{topEmotion(identity?.in_group_emotion_avg)}</div> Most likely emotion when in-group wording is stronger.
</p>
<div style={styles.topUserName}>
{topEmotion(identity?.in_group_emotion_avg)}
</div>
</div> </div>
<div style={{ ...styles.card, gridColumn: "span 6" }}> <div style={{ ...styles.card, gridColumn: "span 6" }}>
<h2 style={styles.sectionTitle}>Mood in "Them" Posts</h2> <h2 style={styles.sectionTitle}>Mood in "Them" Posts</h2>
<p style={styles.sectionSubtitle}>Most likely emotion when out-group wording is stronger.</p> <p style={styles.sectionSubtitle}>
<div style={styles.topUserName}>{topEmotion(identity?.out_group_emotion_avg)}</div> Most likely emotion when out-group wording is stronger.
</p>
<div style={styles.topUserName}>
{topEmotion(identity?.out_group_emotion_avg)}
</div>
</div> </div>
<div style={{ ...styles.card, gridColumn: "span 12" }}> <div style={{ ...styles.card, gridColumn: "span 12" }}>
<h2 style={styles.sectionTitle}>Entity Mood Snapshot</h2> <h2 style={styles.sectionTitle}>Entity Mood Snapshot</h2>
<p style={styles.sectionSubtitle}>Most mentioned entities and the mood that appears most with each.</p> <p style={styles.sectionSubtitle}>
Most mentioned entities and the mood that appears most with each.
</p>
{!entities.length ? ( {!entities.length ? (
<div style={styles.topUserMeta}>No entity-level cultural data available.</div> <div style={styles.topUserMeta}>
No entity-level cultural data available.
</div>
) : ( ) : (
<div style={{ ...styles.topUsersList, maxHeight: 420, overflowY: "auto" }}> <div
style={{
...styles.topUsersList,
maxHeight: 420,
overflowY: "auto",
}}
>
{entities.map(([entity, aggregate]) => ( {entities.map(([entity, aggregate]) => (
<div key={entity} style={styles.topUserItem}> <div key={entity} style={styles.topUserItem}>
<div style={styles.topUserName}>{entity}</div> <div style={styles.topUserName}>{entity}</div>
<div style={styles.topUserMeta}> <div style={styles.topUserMeta}>
{aggregate.post_count.toLocaleString()} posts Likely mood: {topEmotion(aggregate.emotion_avg)} {aggregate.post_count.toLocaleString()} posts Likely mood:{" "}
{topEmotion(aggregate.emotion_avg)}
</div> </div>
</div> </div>
))} ))}

View File

@@ -1,16 +1,17 @@
import type { ContentAnalysisResponse } from "../types/ApiTypes" import type { ContentAnalysisResponse } from "../types/ApiTypes";
import StatsStyling from "../styles/stats_styling"; import StatsStyling from "../styles/stats_styling";
const styles = StatsStyling; const styles = StatsStyling;
type EmotionalStatsProps = { type EmotionalStatsProps = {
contentData: ContentAnalysisResponse; contentData: ContentAnalysisResponse;
} };
const EmotionalStats = ({contentData}: EmotionalStatsProps) => { const EmotionalStats = ({ contentData }: EmotionalStatsProps) => {
const rows = contentData.average_emotion_by_topic ?? []; const rows = contentData.average_emotion_by_topic ?? [];
const overallEmotionAverage = contentData.overall_emotion_average ?? []; const overallEmotionAverage = contentData.overall_emotion_average ?? [];
const dominantEmotionDistribution = contentData.dominant_emotion_distribution ?? []; const dominantEmotionDistribution =
contentData.dominant_emotion_distribution ?? [];
const emotionBySource = contentData.emotion_by_source ?? []; const emotionBySource = contentData.emotion_by_source ?? [];
const lowSampleThreshold = 20; const lowSampleThreshold = 20;
const stableSampleThreshold = 50; const stableSampleThreshold = 50;
@@ -34,7 +35,7 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
topic: String(row.topic), topic: String(row.topic),
count: Number(row.n ?? 0), count: Number(row.n ?? 0),
emotion: maxKey.replace("emotion_", "") || "unknown", emotion: maxKey.replace("emotion_", "") || "unknown",
value: maxValue > Number.NEGATIVE_INFINITY ? maxValue : 0 value: maxValue > Number.NEGATIVE_INFINITY ? maxValue : 0,
}; };
}); });
@@ -48,8 +49,12 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
.filter((count) => Number.isFinite(count) && count > 0) .filter((count) => Number.isFinite(count) && count > 0)
.sort((a, b) => a - b); .sort((a, b) => a - b);
const lowSampleTopics = strongestPerTopic.filter((topic) => topic.count < lowSampleThreshold).length; const lowSampleTopics = strongestPerTopic.filter(
const stableSampleTopics = strongestPerTopic.filter((topic) => topic.count >= stableSampleThreshold).length; (topic) => topic.count < lowSampleThreshold,
).length;
const stableSampleTopics = strongestPerTopic.filter(
(topic) => topic.count >= stableSampleThreshold,
).length;
const medianSampleSize = sampleSizes.length const medianSampleSize = sampleSizes.length
? sampleSizes[Math.floor(sampleSizes.length / 2)] ? sampleSizes[Math.floor(sampleSizes.length / 2)]
@@ -68,15 +73,37 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
<div style={styles.page}> <div style={styles.page}>
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}> <div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
<h2 style={styles.sectionTitle}>Topic Mood Overview</h2> <h2 style={styles.sectionTitle}>Topic Mood Overview</h2>
<p style={styles.sectionSubtitle}>Use the strength score together with post count. Topics with fewer than {lowSampleThreshold} events are often noisy.</p> <p style={styles.sectionSubtitle}>
Use the strength score together with post count. Topics with fewer
than {lowSampleThreshold} events are often noisy.
</p>
<div style={styles.emotionalSummaryRow}> <div style={styles.emotionalSummaryRow}>
<span><strong style={{ color: "#24292f" }}>Topics:</strong> {strongestPerTopic.length}</span> <span>
<span><strong style={{ color: "#24292f" }}>Median Posts:</strong> {medianSampleSize}</span> <strong style={{ color: "#24292f" }}>Topics:</strong>{" "}
<span><strong style={{ color: "#24292f" }}>Small Topics (&lt;{lowSampleThreshold}):</strong> {lowSampleTopics}</span> {strongestPerTopic.length}
<span><strong style={{ color: "#24292f" }}>Stable Topics ({stableSampleThreshold}+):</strong> {stableSampleTopics}</span> </span>
<span>
<strong style={{ color: "#24292f" }}>Median Posts:</strong>{" "}
{medianSampleSize}
</span>
<span>
<strong style={{ color: "#24292f" }}>
Small Topics (&lt;{lowSampleThreshold}):
</strong>{" "}
{lowSampleTopics}
</span>
<span>
<strong style={{ color: "#24292f" }}>
Stable Topics ({stableSampleThreshold}+):
</strong>{" "}
{stableSampleTopics}
</span>
</div> </div>
<p style={{ ...styles.sectionSubtitle, marginTop: 10, marginBottom: 0 }}> <p
Strength means how far the top emotion is ahead in that topic. It does not mean model accuracy. style={{ ...styles.sectionSubtitle, marginTop: 10, marginBottom: 0 }}
>
Strength means how far the top emotion is ahead in that topic. It does
not mean model accuracy.
</p> </p>
</div> </div>
@@ -85,14 +112,24 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
<h2 style={styles.sectionTitle}>Mood Averages</h2> <h2 style={styles.sectionTitle}>Mood Averages</h2>
<p style={styles.sectionSubtitle}>Average score for each emotion.</p> <p style={styles.sectionSubtitle}>Average score for each emotion.</p>
{!overallEmotionAverage.length ? ( {!overallEmotionAverage.length ? (
<div style={styles.topUserMeta}>No overall emotion averages available.</div> <div style={styles.topUserMeta}>
No overall emotion averages available.
</div>
) : ( ) : (
<div style={{ ...styles.topUsersList, maxHeight: 260, overflowY: "auto" }}> <div
style={{
...styles.topUsersList,
maxHeight: 260,
overflowY: "auto",
}}
>
{[...overallEmotionAverage] {[...overallEmotionAverage]
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
.map((row) => ( .map((row) => (
<div key={row.emotion} style={styles.topUserItem}> <div key={row.emotion} style={styles.topUserItem}>
<div style={styles.topUserName}>{formatEmotion(row.emotion)}</div> <div style={styles.topUserName}>
{formatEmotion(row.emotion)}
</div>
<div style={styles.topUserMeta}>{row.score.toFixed(3)}</div> <div style={styles.topUserMeta}>{row.score.toFixed(3)}</div>
</div> </div>
))} ))}
@@ -102,17 +139,32 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
<div style={{ ...styles.card, gridColumn: "span 4" }}> <div style={{ ...styles.card, gridColumn: "span 4" }}>
<h2 style={styles.sectionTitle}>Mood Split</h2> <h2 style={styles.sectionTitle}>Mood Split</h2>
<p style={styles.sectionSubtitle}>How often each emotion is dominant.</p> <p style={styles.sectionSubtitle}>
How often each emotion is dominant.
</p>
{!dominantEmotionDistribution.length ? ( {!dominantEmotionDistribution.length ? (
<div style={styles.topUserMeta}>No dominant-emotion split available.</div> <div style={styles.topUserMeta}>
No dominant-emotion split available.
</div>
) : ( ) : (
<div style={{ ...styles.topUsersList, maxHeight: 260, overflowY: "auto" }}> <div
style={{
...styles.topUsersList,
maxHeight: 260,
overflowY: "auto",
}}
>
{[...dominantEmotionDistribution] {[...dominantEmotionDistribution]
.sort((a, b) => b.ratio - a.ratio) .sort((a, b) => b.ratio - a.ratio)
.map((row) => ( .map((row) => (
<div key={row.emotion} style={styles.topUserItem}> <div key={row.emotion} style={styles.topUserItem}>
<div style={styles.topUserName}>{formatEmotion(row.emotion)}</div> <div style={styles.topUserName}>
<div style={styles.topUserMeta}>{(row.ratio * 100).toFixed(1)}% {row.count.toLocaleString()} events</div> {formatEmotion(row.emotion)}
</div>
<div style={styles.topUserMeta}>
{(row.ratio * 100).toFixed(1)}% {" "}
{row.count.toLocaleString()} events
</div>
</div> </div>
))} ))}
</div> </div>
@@ -123,16 +175,26 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
<h2 style={styles.sectionTitle}>Mood by Source</h2> <h2 style={styles.sectionTitle}>Mood by Source</h2>
<p style={styles.sectionSubtitle}>Leading emotion in each source.</p> <p style={styles.sectionSubtitle}>Leading emotion in each source.</p>
{!emotionBySource.length ? ( {!emotionBySource.length ? (
<div style={styles.topUserMeta}>No source emotion profile available.</div> <div style={styles.topUserMeta}>
No source emotion profile available.
</div>
) : ( ) : (
<div style={{ ...styles.topUsersList, maxHeight: 260, overflowY: "auto" }}> <div
style={{
...styles.topUsersList,
maxHeight: 260,
overflowY: "auto",
}}
>
{[...emotionBySource] {[...emotionBySource]
.sort((a, b) => b.event_count - a.event_count) .sort((a, b) => b.event_count - a.event_count)
.map((row) => ( .map((row) => (
<div key={row.source} style={styles.topUserItem}> <div key={row.source} style={styles.topUserItem}>
<div style={styles.topUserName}>{row.source}</div> <div style={styles.topUserName}>{row.source}</div>
<div style={styles.topUserMeta}> <div style={styles.topUserMeta}>
{formatEmotion(row.dominant_emotion)} {row.dominant_score.toFixed(3)} {row.event_count.toLocaleString()} events {formatEmotion(row.dominant_emotion)} {" "}
{row.dominant_score.toFixed(3)} {" "}
{row.event_count.toLocaleString()} events
</div> </div>
</div> </div>
))} ))}
@@ -142,20 +204,27 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
<div style={{ ...styles.card, gridColumn: "span 12" }}> <div style={{ ...styles.card, gridColumn: "span 12" }}>
<h2 style={styles.sectionTitle}>Topic Snapshots</h2> <h2 style={styles.sectionTitle}>Topic Snapshots</h2>
<p style={styles.sectionSubtitle}>Per-topic mood with strength and post count.</p> <p style={styles.sectionSubtitle}>
Per-topic mood with strength and post count.
</p>
<div style={{ ...styles.grid, marginTop: 10 }}> <div style={{ ...styles.grid, marginTop: 10 }}>
{strongestPerTopic.map((topic) => ( {strongestPerTopic.map((topic) => (
<div key={topic.topic} style={{ ...styles.cardBase, gridColumn: "span 4" }}> <div
<h3 style={{ ...styles.sectionTitle, marginBottom: 6 }}>{topic.topic}</h3> key={topic.topic}
<div style={styles.emotionalTopicLabel}> style={{ ...styles.cardBase, gridColumn: "span 4" }}
Likely Mood >
</div> <h3 style={{ ...styles.sectionTitle, marginBottom: 6 }}>
{topic.topic}
</h3>
<div style={styles.emotionalTopicLabel}>Likely Mood</div>
<div style={styles.emotionalTopicValue}> <div style={styles.emotionalTopicValue}>
{formatEmotion(topic.emotion)} {formatEmotion(topic.emotion)}
</div> </div>
<div style={styles.emotionalMetricRow}> <div style={styles.emotionalMetricRow}>
<span>Strength</span> <span>Strength</span>
<span style={styles.emotionalMetricValue}>{topic.value.toFixed(3)}</span> <span style={styles.emotionalMetricValue}>
{topic.value.toFixed(3)}
</span>
</div> </div>
<div style={styles.emotionalMetricRowCompact}> <div style={styles.emotionalMetricRowCompact}>
<span>Posts in Topic</span> <span>Posts in Topic</span>
@@ -168,6 +237,6 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
</div> </div>
</div> </div>
); );
} };
export default EmotionalStats; export default EmotionalStats;

View File

@@ -24,25 +24,32 @@ type InteractionalStatsProps = {
const InteractionalStats = ({ data }: InteractionalStatsProps) => { const InteractionalStats = ({ data }: InteractionalStatsProps) => {
const graph = data.interaction_graph ?? {}; const graph = data.interaction_graph ?? {};
const userCount = Object.keys(graph).length; const userCount = Object.keys(graph).length;
const edges = Object.values(graph).flatMap((targets) => Object.values(targets)); const edges = Object.values(graph).flatMap((targets) =>
Object.values(targets),
);
const edgeCount = edges.length; const edgeCount = edges.length;
const interactionVolume = edges.reduce((sum, value) => sum + value, 0); const interactionVolume = edges.reduce((sum, value) => sum + value, 0);
const concentration = data.conversation_concentration; const concentration = data.conversation_concentration;
const topTenCommentShare = typeof concentration?.top_10pct_comment_share === "number" const topTenCommentShare =
? concentration?.top_10pct_comment_share typeof concentration?.top_10pct_comment_share === "number"
: null; ? concentration?.top_10pct_comment_share
const topTenAuthorCount = typeof concentration?.top_10pct_author_count === "number" : null;
? concentration.top_10pct_author_count const topTenAuthorCount =
: null; typeof concentration?.top_10pct_author_count === "number"
const totalCommentingAuthors = typeof concentration?.total_commenting_authors === "number" ? concentration.top_10pct_author_count
? concentration.total_commenting_authors : null;
: null; const totalCommentingAuthors =
const singleCommentAuthorRatio = typeof concentration?.single_comment_author_ratio === "number" typeof concentration?.total_commenting_authors === "number"
? concentration.single_comment_author_ratio ? concentration.total_commenting_authors
: null; : null;
const singleCommentAuthors = typeof concentration?.single_comment_authors === "number" const singleCommentAuthorRatio =
? concentration.single_comment_authors typeof concentration?.single_comment_author_ratio === "number"
: null; ? concentration.single_comment_author_ratio
: null;
const singleCommentAuthors =
typeof concentration?.single_comment_authors === "number"
? concentration.single_comment_authors
: null;
const topPairs = (data.top_interaction_pairs ?? []) const topPairs = (data.top_interaction_pairs ?? [])
.filter((item): item is [[string, string], number] => { .filter((item): item is [[string, string], number] => {
@@ -53,26 +60,28 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => {
const pair = item[0]; const pair = item[0];
const count = item[1]; const count = item[1];
return Array.isArray(pair) return (
&& pair.length === 2 Array.isArray(pair) &&
&& typeof pair[0] === "string" pair.length === 2 &&
&& typeof pair[1] === "string" typeof pair[0] === "string" &&
&& typeof count === "number"; typeof pair[1] === "string" &&
typeof count === "number"
);
}) })
.slice(0, 20); .slice(0, 20);
const topPairChartData = topPairs.slice(0, 8).map(([[source, target], value], index) => ({ const topPairChartData = topPairs
pair: `${source} -> ${target}`, .slice(0, 8)
replies: value, .map(([[source, target], value], index) => ({
rank: index + 1, pair: `${source} -> ${target}`,
})); replies: value,
rank: index + 1,
}));
const topTenSharePercent = topTenCommentShare === null const topTenSharePercent =
? null topTenCommentShare === null ? null : topTenCommentShare * 100;
: topTenCommentShare * 100; const nonTopTenSharePercent =
const nonTopTenSharePercent = topTenSharePercent === null topTenSharePercent === null ? null : Math.max(0, 100 - topTenSharePercent);
? null
: Math.max(0, 100 - topTenSharePercent);
let concentrationPieData: { name: string; value: number }[] = []; let concentrationPieData: { name: string; value: number }[] = [];
if (topTenSharePercent !== null && nonTopTenSharePercent !== null) { if (topTenSharePercent !== null && nonTopTenSharePercent !== null) {
@@ -89,12 +98,18 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => {
<div style={{ ...styles.container, ...styles.grid }}> <div style={{ ...styles.container, ...styles.grid }}>
<div style={{ ...styles.card, gridColumn: "span 12" }}> <div style={{ ...styles.card, gridColumn: "span 12" }}>
<h2 style={styles.sectionTitle}>Conversation Overview</h2> <h2 style={styles.sectionTitle}>Conversation Overview</h2>
<p style={styles.sectionSubtitle}>Who talks to who, and how concentrated the replies are.</p> <p style={styles.sectionSubtitle}>
Who talks to who, and how concentrated the replies are.
</p>
</div> </div>
<Card <Card
label="Average Reply Depth" label="Average Reply Depth"
value={typeof data.average_thread_depth === "number" ? data.average_thread_depth.toFixed(2) : "—"} value={
typeof data.average_thread_depth === "number"
? data.average_thread_depth.toFixed(2)
: "—"
}
sublabel="How deep reply chains usually go" sublabel="How deep reply chains usually go"
style={{ gridColumn: "span 3" }} style={{ gridColumn: "span 3" }}
/> />
@@ -118,31 +133,51 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => {
/> />
<Card <Card
label="Concentrated Replies" label="Concentrated Replies"
value={topTenSharePercent === null ? "-" : `${topTenSharePercent.toFixed(1)}%`} value={
sublabel={topTenAuthorCount === null || totalCommentingAuthors === null topTenSharePercent === null
? "Reply share from the top 10% commenters" ? "-"
: `${topTenAuthorCount.toLocaleString()} of ${totalCommentingAuthors.toLocaleString()} authors`} : `${topTenSharePercent.toFixed(1)}%`
}
sublabel={
topTenAuthorCount === null || totalCommentingAuthors === null
? "Reply share from the top 10% commenters"
: `${topTenAuthorCount.toLocaleString()} of ${totalCommentingAuthors.toLocaleString()} authors`
}
style={{ gridColumn: "span 6" }} style={{ gridColumn: "span 6" }}
/> />
<Card <Card
label="Single-Comment Authors" label="Single-Comment Authors"
value={singleCommentAuthorRatio === null ? "-" : `${(singleCommentAuthorRatio * 100).toFixed(1)}%`} value={
sublabel={singleCommentAuthors === null singleCommentAuthorRatio === null
? "Authors who commented exactly once" ? "-"
: `${singleCommentAuthors.toLocaleString()} authors commented exactly once`} : `${(singleCommentAuthorRatio * 100).toFixed(1)}%`
}
sublabel={
singleCommentAuthors === null
? "Authors who commented exactly once"
: `${singleCommentAuthors.toLocaleString()} authors commented exactly once`
}
style={{ gridColumn: "span 6" }} style={{ gridColumn: "span 6" }}
/> />
<div style={{ ...styles.card, gridColumn: "span 12" }}> <div style={{ ...styles.card, gridColumn: "span 12" }}>
<h2 style={styles.sectionTitle}>Conversation Visuals</h2> <h2 style={styles.sectionTitle}>Conversation Visuals</h2>
<p style={styles.sectionSubtitle}>Main reply links and concentration split.</p> <p style={styles.sectionSubtitle}>
Main reply links and concentration split.
</p>
<div style={{ ...styles.grid, marginTop: 12 }}> <div style={{ ...styles.grid, marginTop: 12 }}>
<div style={{ ...styles.cardBase, gridColumn: "span 6" }}> <div style={{ ...styles.cardBase, gridColumn: "span 6" }}>
<h3 style={{ ...styles.sectionTitle, fontSize: "1rem" }}>Top Interaction Pairs</h3> <h3 style={{ ...styles.sectionTitle, fontSize: "1rem" }}>
Top Interaction Pairs
</h3>
<div style={{ width: "100%", height: 300 }}> <div style={{ width: "100%", height: 300 }}>
<ResponsiveContainer> <ResponsiveContainer>
<BarChart data={topPairChartData} layout="vertical" margin={{ top: 8, right: 16, left: 16, bottom: 8 }}> <BarChart
data={topPairChartData}
layout="vertical"
margin={{ top: 8, right: 16, left: 16, bottom: 8 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#d9e2ec" /> <CartesianGrid strokeDasharray="3 3" stroke="#d9e2ec" />
<XAxis type="number" allowDecimals={false} /> <XAxis type="number" allowDecimals={false} />
<YAxis <YAxis
@@ -152,14 +187,20 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => {
width={36} width={36}
/> />
<Tooltip /> <Tooltip />
<Bar dataKey="replies" fill="#2b6777" radius={[0, 6, 6, 0]} /> <Bar
dataKey="replies"
fill="#2b6777"
radius={[0, 6, 6, 0]}
/>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div> </div>
<div style={{ ...styles.cardBase, gridColumn: "span 6" }}> <div style={{ ...styles.cardBase, gridColumn: "span 6" }}>
<h3 style={{ ...styles.sectionTitle, fontSize: "1rem" }}>Top 10% vs Other Comment Share</h3> <h3 style={{ ...styles.sectionTitle, fontSize: "1rem" }}>
Top 10% vs Other Comment Share
</h3>
<div style={{ width: "100%", height: 300 }}> <div style={{ width: "100%", height: 300 }}>
<ResponsiveContainer> <ResponsiveContainer>
<PieChart> <PieChart>
@@ -172,7 +213,10 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => {
paddingAngle={2} paddingAngle={2}
> >
{concentrationPieData.map((entry, index) => ( {concentrationPieData.map((entry, index) => (
<Cell key={`${entry.name}-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} /> <Cell
key={`${entry.name}-${index}`}
fill={PIE_COLORS[index % PIE_COLORS.length]}
/>
))} ))}
</Pie> </Pie>
<Tooltip /> <Tooltip />
@@ -186,15 +230,32 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => {
<div style={{ ...styles.card, gridColumn: "span 12" }}> <div style={{ ...styles.card, gridColumn: "span 12" }}>
<h2 style={styles.sectionTitle}>Frequent Reply Paths</h2> <h2 style={styles.sectionTitle}>Frequent Reply Paths</h2>
<p style={styles.sectionSubtitle}>Most common user-to-user reply paths.</p> <p style={styles.sectionSubtitle}>
Most common user-to-user reply paths.
</p>
{!topPairs.length ? ( {!topPairs.length ? (
<div style={styles.topUserMeta}>No interaction pair data available.</div> <div style={styles.topUserMeta}>
No interaction pair data available.
</div>
) : ( ) : (
<div style={{ ...styles.topUsersList, maxHeight: 420, overflowY: "auto" }}> <div
style={{
...styles.topUsersList,
maxHeight: 420,
overflowY: "auto",
}}
>
{topPairs.map(([[source, target], value], index) => ( {topPairs.map(([[source, target], value], index) => (
<div key={`${source}->${target}-${index}`} style={styles.topUserItem}> <div
<div style={styles.topUserName}>{source} -&gt; {target}</div> key={`${source}->${target}-${index}`}
<div style={styles.topUserMeta}>{value.toLocaleString()} replies</div> style={styles.topUserItem}
>
<div style={styles.topUserName}>
{source} -&gt; {target}
</div>
<div style={styles.topUserMeta}>
{value.toLocaleString()} replies
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -23,7 +23,9 @@ const LinguisticStats = ({ data }: LinguisticStatsProps) => {
<div style={{ ...styles.container, ...styles.grid }}> <div style={{ ...styles.container, ...styles.grid }}>
<div style={{ ...styles.card, gridColumn: "span 12" }}> <div style={{ ...styles.card, gridColumn: "span 12" }}>
<h2 style={styles.sectionTitle}>Language Overview</h2> <h2 style={styles.sectionTitle}>Language Overview</h2>
<p style={styles.sectionSubtitle}>Quick read on how broad and repetitive the wording is.</p> <p style={styles.sectionSubtitle}>
Quick read on how broad and repetitive the wording is.
</p>
</div> </div>
<Card <Card
@@ -40,7 +42,9 @@ const LinguisticStats = ({ data }: LinguisticStatsProps) => {
/> />
<Card <Card
label="Vocabulary Variety" label="Vocabulary Variety"
value={typeof lexical?.ttr === "number" ? lexical.ttr.toFixed(4) : "—"} value={
typeof lexical?.ttr === "number" ? lexical.ttr.toFixed(4) : "—"
}
sublabel="Higher means less repetition" sublabel="Higher means less repetition"
style={{ gridColumn: "span 4" }} style={{ gridColumn: "span 4" }}
/> />
@@ -48,11 +52,19 @@ const LinguisticStats = ({ data }: LinguisticStatsProps) => {
<div style={{ ...styles.card, gridColumn: "span 4" }}> <div style={{ ...styles.card, gridColumn: "span 4" }}>
<h2 style={styles.sectionTitle}>Top Words</h2> <h2 style={styles.sectionTitle}>Top Words</h2>
<p style={styles.sectionSubtitle}>Most used single words.</p> <p style={styles.sectionSubtitle}>Most used single words.</p>
<div style={{ ...styles.topUsersList, maxHeight: 360, overflowY: "auto" }}> <div
style={{
...styles.topUsersList,
maxHeight: 360,
overflowY: "auto",
}}
>
{topWords.map((item) => ( {topWords.map((item) => (
<div key={item.word} style={styles.topUserItem}> <div key={item.word} style={styles.topUserItem}>
<div style={styles.topUserName}>{item.word}</div> <div style={styles.topUserName}>{item.word}</div>
<div style={styles.topUserMeta}>{item.count.toLocaleString()} uses</div> <div style={styles.topUserMeta}>
{item.count.toLocaleString()} uses
</div>
</div> </div>
))} ))}
</div> </div>
@@ -61,11 +73,19 @@ const LinguisticStats = ({ data }: LinguisticStatsProps) => {
<div style={{ ...styles.card, gridColumn: "span 4" }}> <div style={{ ...styles.card, gridColumn: "span 4" }}>
<h2 style={styles.sectionTitle}>Top Bigrams</h2> <h2 style={styles.sectionTitle}>Top Bigrams</h2>
<p style={styles.sectionSubtitle}>Most used 2-word phrases.</p> <p style={styles.sectionSubtitle}>Most used 2-word phrases.</p>
<div style={{ ...styles.topUsersList, maxHeight: 360, overflowY: "auto" }}> <div
style={{
...styles.topUsersList,
maxHeight: 360,
overflowY: "auto",
}}
>
{topBigrams.map((item) => ( {topBigrams.map((item) => (
<div key={item.ngram} style={styles.topUserItem}> <div key={item.ngram} style={styles.topUserItem}>
<div style={styles.topUserName}>{item.ngram}</div> <div style={styles.topUserName}>{item.ngram}</div>
<div style={styles.topUserMeta}>{item.count.toLocaleString()} uses</div> <div style={styles.topUserMeta}>
{item.count.toLocaleString()} uses
</div>
</div> </div>
))} ))}
</div> </div>
@@ -74,11 +94,19 @@ const LinguisticStats = ({ data }: LinguisticStatsProps) => {
<div style={{ ...styles.card, gridColumn: "span 4" }}> <div style={{ ...styles.card, gridColumn: "span 4" }}>
<h2 style={styles.sectionTitle}>Top Trigrams</h2> <h2 style={styles.sectionTitle}>Top Trigrams</h2>
<p style={styles.sectionSubtitle}>Most used 3-word phrases.</p> <p style={styles.sectionSubtitle}>Most used 3-word phrases.</p>
<div style={{ ...styles.topUsersList, maxHeight: 360, overflowY: "auto" }}> <div
style={{
...styles.topUsersList,
maxHeight: 360,
overflowY: "auto",
}}
>
{topTrigrams.map((item) => ( {topTrigrams.map((item) => (
<div key={item.ngram} style={styles.topUserItem}> <div key={item.ngram} style={styles.topUserItem}>
<div style={styles.topUserName}>{item.ngram}</div> <div style={styles.topUserName}>{item.ngram}</div>
<div style={styles.topUserMeta}>{item.count.toLocaleString()} uses</div> <div style={styles.topUserMeta}>
{item.count.toLocaleString()} uses
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -6,32 +6,32 @@ import {
YAxis, YAxis,
Tooltip, Tooltip,
CartesianGrid, CartesianGrid,
ResponsiveContainer ResponsiveContainer,
} from "recharts"; } from "recharts";
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"; import Card from "../components/Card";
import UserModal from "../components/UserModal"; import UserModal from "../components/UserModal";
import { import {
type SummaryResponse, type SummaryResponse,
type FrequencyWord, type FrequencyWord,
type UserAnalysisResponse, type UserAnalysisResponse,
type TimeAnalysisResponse, type TimeAnalysisResponse,
type ContentAnalysisResponse, type ContentAnalysisResponse,
type User type User,
} from '../types/ApiTypes' } from "../types/ApiTypes";
const styles = StatsStyling; const styles = StatsStyling;
type SummaryStatsProps = { type SummaryStatsProps = {
userData: UserAnalysisResponse | null; userData: UserAnalysisResponse | null;
timeData: TimeAnalysisResponse | null; timeData: TimeAnalysisResponse | null;
contentData: ContentAnalysisResponse | null; contentData: ContentAnalysisResponse | null;
summary: SummaryResponse | null; summary: SummaryResponse | null;
} };
function formatDateRange(startUnix: number, endUnix: number) { function formatDateRange(startUnix: number, endUnix: number) {
const start = new Date(startUnix * 1000); const start = new Date(startUnix * 1000);
@@ -48,168 +48,180 @@ function formatDateRange(startUnix: number, endUnix: number) {
} }
function convertFrequencyData(data: FrequencyWord[]) { function convertFrequencyData(data: FrequencyWord[]) {
return data.map((d: FrequencyWord) => ({ return data.map((d: FrequencyWord) => ({
text: d.word, text: d.word,
value: d.count, value: d.count,
})) }));
} }
const SummaryStats = ({userData, timeData, contentData, summary}: SummaryStatsProps) => { const SummaryStats = ({
const [selectedUser, setSelectedUser] = useState<string | null>(null); userData,
const selectedUserData: User | null = userData?.users.find((u) => u.author === selectedUser) ?? null; timeData,
contentData,
summary,
}: SummaryStatsProps) => {
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const selectedUserData: User | null =
userData?.users.find((u) => u.author === selectedUser) ?? null;
return ( return (
<div style={styles.page}> <div style={styles.page}>
{/* main grid*/}
<div style={{ ...styles.container, ...styles.grid }}>
<Card
label="Total Activity"
value={summary?.total_events ?? "—"}
sublabel="Posts + comments"
style={{
gridColumn: "span 4",
}}
/>
<Card
label="Active People"
value={summary?.unique_users ?? "—"}
sublabel="Distinct users"
style={{
gridColumn: "span 4",
}}
/>
<Card
label="Posts vs Comments"
value={
summary ? `${summary.total_posts} / ${summary.total_comments}` : "—"
}
sublabel={`Comments per post: ${summary?.comments_per_post ?? "—"}`}
style={{
gridColumn: "span 4",
}}
/>
{/* main grid*/} <Card
<div style={{ ...styles.container, ...styles.grid}}> label="Time Range"
<Card value={
label="Total Activity" summary?.time_range
value={summary?.total_events ?? "—"} ? formatDateRange(
sublabel="Posts + comments" summary.time_range.start,
style={{ summary.time_range.end,
gridColumn: "span 4" )
}} : "—"
/> }
<Card sublabel="Based on dataset timestamps"
label="Active People" style={{
value={summary?.unique_users ?? "—"} gridColumn: "span 4",
sublabel="Distinct users" }}
style={{ />
gridColumn: "span 4"
}}
/>
<Card
label="Posts vs Comments"
value={
summary
? `${summary.total_posts} / ${summary.total_comments}`
: "—"
}
sublabel={`Comments per post: ${summary?.comments_per_post ?? "—"}`}
style={{
gridColumn: "span 4"
}}
/>
<Card <Card
label="Time Range" label="One-Time Users"
value={ value={
summary?.time_range typeof summary?.lurker_ratio === "number"
? formatDateRange(summary.time_range.start, summary.time_range.end) ? `${Math.round(summary.lurker_ratio * 100)}%`
: "—" : "—"
} }
sublabel="Based on dataset timestamps" sublabel="Users with only one event"
style={{ style={{
gridColumn: "span 4" gridColumn: "span 4",
}} }}
/> />
<Card <Card
label="One-Time Users" label="Sources"
value={ value={summary?.sources?.length ?? "—"}
typeof summary?.lurker_ratio === "number" sublabel={
? `${Math.round(summary.lurker_ratio * 100)}%` summary?.sources?.length
: "—" ? summary.sources.slice(0, 3).join(", ") +
} (summary.sources.length > 3 ? "…" : "")
sublabel="Users with only one event" : "—"
style={{ }
gridColumn: "span 4" 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}>Activity Over Time</h2> <h2 style={styles.sectionTitle}>Activity Over Time</h2>
<p style={styles.sectionSubtitle}>How much posting happened each day.</p> <p style={styles.sectionSubtitle}>
How much posting happened each day.
</p>
<div style={styles.chartWrapper}> <div style={styles.chartWrapper}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={timeData?.events_per_day ?? []}> <LineChart data={timeData?.events_per_day ?? []}>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" /> <XAxis dataKey="date" />
<YAxis /> <YAxis />
<Tooltip /> <Tooltip />
<Line type="monotone" dataKey="count" name="Events" /> <Line type="monotone" dataKey="count" name="Events" />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div> </div>
{/* Word Cloud */} {/* Word Cloud */}
<div style={{ ...styles.card, gridColumn: "span 4" }}> <div style={{ ...styles.card, gridColumn: "span 4" }}>
<h2 style={styles.sectionTitle}>Common Words</h2> <h2 style={styles.sectionTitle}>Common Words</h2>
<p style={styles.sectionSubtitle}>Frequently used words across the dataset.</p> <p style={styles.sectionSubtitle}>
Frequently used words across the dataset.
</p>
<div style={styles.chartWrapper}> <div style={styles.chartWrapper}>
<ReactWordcloud <ReactWordcloud
words={convertFrequencyData(contentData?.word_frequencies ?? [])} words={convertFrequencyData(contentData?.word_frequencies ?? [])}
options={{ options={{
rotations: 2, rotations: 2,
rotationAngles: [0, 90], rotationAngles: [0, 90],
fontSizes: [14, 60], fontSizes: [14, 60],
enableTooltip: true, enableTooltip: true,
}} }}
/> />
</div> </div>
</div> </div>
{/* Top Users */} {/* Top Users */}
<div style={{...styles.card, ...styles.scrollArea, gridColumn: "span 3", <div
}} style={{ ...styles.card, ...styles.scrollArea, gridColumn: "span 3" }}
> >
<h2 style={styles.sectionTitle}>Most Active Users</h2> <h2 style={styles.sectionTitle}>Most Active Users</h2>
<p style={styles.sectionSubtitle}>Who posted the most events.</p> <p style={styles.sectionSubtitle}>Who posted the most events.</p>
<div style={styles.topUsersList}> <div style={styles.topUsersList}>
{userData?.top_users.slice(0, 100).map((item) => ( {userData?.top_users.slice(0, 100).map((item) => (
<div <div
key={`${item.author}-${item.source}`} key={`${item.author}-${item.source}`}
style={{ ...styles.topUserItem, cursor: "pointer" }} style={{ ...styles.topUserItem, cursor: "pointer" }}
onClick={() => setSelectedUser(item.author)} onClick={() => setSelectedUser(item.author)}
> >
<div style={styles.topUserName}>{item.author}</div> <div style={styles.topUserName}>{item.author}</div>
<div style={styles.topUserMeta}> <div style={styles.topUserMeta}>
{item.source} {item.count} events {item.source} {item.count} events
</div>
</div> </div>
</div>
))} ))}
</div> </div>
</div> </div>
{/* Heatmap */} {/* Heatmap */}
<div style={{ ...styles.card, gridColumn: "span 12" }}> <div style={{ ...styles.card, gridColumn: "span 12" }}>
<h2 style={styles.sectionTitle}>Weekly Activity Pattern</h2> <h2 style={styles.sectionTitle}>Weekly Activity Pattern</h2>
<p style={styles.sectionSubtitle}>When activity tends to happen by weekday and hour.</p> <p style={styles.sectionSubtitle}>
When activity tends to happen by weekday and hour.
</p>
<div style={styles.heatmapWrapper}> <div style={styles.heatmapWrapper}>
<ActivityHeatmap data={timeData?.weekday_hour_heatmap ?? []} /> <ActivityHeatmap data={timeData?.weekday_hour_heatmap ?? []} />
</div> </div>
</div>
</div> </div>
</div>
<UserModal <UserModal
open={!!selectedUser} open={!!selectedUser}
onClose={() => setSelectedUser(null)} onClose={() => setSelectedUser(null)}
username={selectedUser ?? ""} username={selectedUser ?? ""}
userData={selectedUserData} userData={selectedUserData}
/> />
</div> </div>
); );
} };
export default SummaryStats; export default SummaryStats;

View File

@@ -11,9 +11,15 @@ type Props = {
username: string; username: string;
}; };
export default function UserModal({ open, onClose, userData, username }: Props) { export default function UserModal({
const dominantEmotionEntry = Object.entries(userData?.avg_emotions ?? {}) open,
.sort((a, b) => b[1] - a[1])[0]; onClose,
userData,
username,
}: Props) {
const dominantEmotionEntry = Object.entries(
userData?.avg_emotions ?? {},
).sort((a, b) => b[1] - a[1])[0];
return ( return (
<Dialog open={open} onClose={onClose} style={styles.modalRoot}> <Dialog open={open} onClose={onClose} style={styles.modalRoot}>
@@ -36,7 +42,9 @@ export default function UserModal({ open, onClose, userData, username }: Props)
<p style={styles.sectionSubtitle}>No data for this user.</p> <p style={styles.sectionSubtitle}>No data for this user.</p>
) : ( ) : (
<div style={styles.topUsersList}> <div style={styles.topUsersList}>
<div style={{...styles.topUserName, fontSize: 20}}>{userData.author}</div> <div style={{ ...styles.topUserName, fontSize: 20 }}>
{userData.author}
</div>
<div style={styles.topUserItem}> <div style={styles.topUserItem}>
<div style={styles.topUserName}>Posts</div> <div style={styles.topUserName}>Posts</div>
<div style={styles.topUserMeta}>{userData.post}</div> <div style={styles.topUserMeta}>{userData.post}</div>
@@ -65,7 +73,8 @@ export default function UserModal({ open, onClose, userData, username }: Props)
<div style={styles.topUserItem}> <div style={styles.topUserItem}>
<div style={styles.topUserName}>Vocab Richness</div> <div style={styles.topUserName}>Vocab Richness</div>
<div style={styles.topUserMeta}> <div style={styles.topUserMeta}>
{userData.vocab.vocab_richness} (avg {userData.vocab.avg_words_per_event} words/event) {userData.vocab.vocab_richness} (avg{" "}
{userData.vocab.avg_words_per_event} words/event)
</div> </div>
</div> </div>
) : null} ) : null}
@@ -74,7 +83,8 @@ export default function UserModal({ open, onClose, userData, username }: Props)
<div style={styles.topUserItem}> <div style={styles.topUserItem}>
<div style={styles.topUserName}>Dominant Avg Emotion</div> <div style={styles.topUserName}>Dominant Avg Emotion</div>
<div style={styles.topUserMeta}> <div style={styles.topUserMeta}>
{dominantEmotionEntry[0].replace("emotion_", "")} ({dominantEmotionEntry[1].toFixed(3)}) {dominantEmotionEntry[0].replace("emotion_", "")} (
{dominantEmotionEntry[1].toFixed(3)})
</div> </div>
</div> </div>
) : null} ) : null}

View File

@@ -2,9 +2,9 @@ import { useEffect, useMemo, useRef, useState } from "react";
import ForceGraph3D from "react-force-graph-3d"; import ForceGraph3D from "react-force-graph-3d";
import { import {
type UserAnalysisResponse, type UserAnalysisResponse,
type InteractionGraph type InteractionGraph,
} from '../types/ApiTypes'; } from "../types/ApiTypes";
import StatsStyling from "../styles/stats_styling"; import StatsStyling from "../styles/stats_styling";
import Card from "./Card"; import Card from "./Card";
@@ -12,38 +12,43 @@ import Card from "./Card";
const styles = StatsStyling; const styles = StatsStyling;
type GraphLink = { type GraphLink = {
source: string; source: string;
target: string; target: string;
value: number; value: number;
}; };
function ApiToGraphData(apiData: InteractionGraph) { function ApiToGraphData(apiData: InteractionGraph) {
const nodes = Object.keys(apiData).map(username => ({ id: username })); const nodes = Object.keys(apiData).map((username) => ({ id: username }));
const links: GraphLink[] = []; const links: GraphLink[] = [];
for (const [source, targets] of Object.entries(apiData)) { for (const [source, targets] of Object.entries(apiData)) {
for (const [target, count] of Object.entries(targets)) { for (const [target, count] of Object.entries(targets)) {
links.push({ source, target, value: count }); links.push({ source, target, value: count });
}
} }
}
// drop low-value and deleted interactions to reduce clutter
const filteredLinks = links.filter(link =>
link.value >= 2 &&
link.source !== "[deleted]" &&
link.target !== "[deleted]"
);
// also filter out nodes that are no longer connected after link filtering // drop low-value and deleted interactions to reduce clutter
const connectedNodeIds = new Set(filteredLinks.flatMap(link => [link.source, link.target])); const filteredLinks = links.filter(
const filteredNodes = nodes.filter(node => connectedNodeIds.has(node.id)); (link) =>
link.value >= 2 &&
link.source !== "[deleted]" &&
link.target !== "[deleted]",
);
return { nodes: filteredNodes, links: filteredLinks}; // also filter out nodes that are no longer connected after link filtering
const connectedNodeIds = new Set(
filteredLinks.flatMap((link) => [link.source, link.target]),
);
const filteredNodes = nodes.filter((node) => connectedNodeIds.has(node.id));
return { nodes: filteredNodes, links: filteredLinks };
} }
const UserStats = (props: { data: UserAnalysisResponse }) => { const UserStats = (props: { data: UserAnalysisResponse }) => {
const graphData = useMemo(() => ApiToGraphData(props.data.interaction_graph), [props.data.interaction_graph]); const graphData = useMemo(
() => ApiToGraphData(props.data.interaction_graph),
[props.data.interaction_graph],
);
const graphContainerRef = useRef<HTMLDivElement | null>(null); const graphContainerRef = useRef<HTMLDivElement | null>(null);
const [graphSize, setGraphSize] = useState({ width: 720, height: 540 }); const [graphSize, setGraphSize] = useState({ width: 720, height: 540 });
@@ -63,86 +68,113 @@ const UserStats = (props: { data: UserAnalysisResponse }) => {
const totalUsers = props.data.users.length; const totalUsers = props.data.users.length;
const connectedUsers = graphData.nodes.length; const connectedUsers = graphData.nodes.length;
const totalInteractions = graphData.links.reduce((sum, link) => sum + link.value, 0); const totalInteractions = graphData.links.reduce(
const avgInteractionsPerConnectedUser = connectedUsers ? totalInteractions / connectedUsers : 0; (sum, link) => sum + link.value,
0,
);
const avgInteractionsPerConnectedUser = connectedUsers
? totalInteractions / connectedUsers
: 0;
const strongestLink = graphData.links.reduce<GraphLink | null>((best, current) => { const strongestLink = graphData.links.reduce<GraphLink | null>(
if (!best || current.value > best.value) { (best, current) => {
return current; if (!best || current.value > best.value) {
} return current;
return best; }
}, null); return best;
},
null,
);
const highlyInteractiveUser = [...props.data.users].sort((a, b) => b.comment_share - a.comment_share)[0]; const highlyInteractiveUser = [...props.data.users].sort(
(a, b) => b.comment_share - a.comment_share,
)[0];
const mostActiveUser = props.data.top_users.find(u => u.author !== "[deleted]"); const mostActiveUser = props.data.top_users.find(
(u) => u.author !== "[deleted]",
);
return ( return (
<div style={styles.page}> <div style={styles.page}>
<div style={{ ...styles.container, ...styles.grid }}> <div style={{ ...styles.container, ...styles.grid }}>
<Card <Card
label="Users" label="Users"
value={totalUsers.toLocaleString()} value={totalUsers.toLocaleString()}
sublabel={`${connectedUsers.toLocaleString()} users in filtered graph`} sublabel={`${connectedUsers.toLocaleString()} users in filtered graph`}
style={{ gridColumn: "span 3" }} style={{ gridColumn: "span 3" }}
/> />
<Card <Card
label="Replies" label="Replies"
value={totalInteractions.toLocaleString()} value={totalInteractions.toLocaleString()}
sublabel="Links with at least 2 replies" sublabel="Links with at least 2 replies"
style={{ gridColumn: "span 3" }} style={{ gridColumn: "span 3" }}
/> />
<Card <Card
label="Replies per Connected User" label="Replies per Connected User"
value={avgInteractionsPerConnectedUser.toFixed(1)} value={avgInteractionsPerConnectedUser.toFixed(1)}
sublabel="Average from visible graph links" sublabel="Average from visible graph links"
style={{ gridColumn: "span 3" }} style={{ gridColumn: "span 3" }}
/> />
<Card <Card
label="Most Active User" label="Most Active User"
value={mostActiveUser?.author ?? "—"} value={mostActiveUser?.author ?? "—"}
sublabel={mostActiveUser ? `${mostActiveUser.count.toLocaleString()} events` : "No user activity found"} sublabel={
style={{ gridColumn: "span 3" }} mostActiveUser
/> ? `${mostActiveUser.count.toLocaleString()} events`
: "No user activity found"
}
style={{ gridColumn: "span 3" }}
/>
<Card <Card
label="Strongest User Link" label="Strongest User Link"
value={strongestLink ? `${strongestLink.source} -> ${strongestLink.target}` : "—"} value={
sublabel={strongestLink ? `${strongestLink.value.toLocaleString()} replies` : "No graph links after filtering"} strongestLink
style={{ gridColumn: "span 6" }} ? `${strongestLink.source} -> ${strongestLink.target}`
/> : "—"
<Card }
label="Most Comment-Heavy User" sublabel={
value={highlyInteractiveUser?.author ?? "—"} strongestLink
sublabel={ ? `${strongestLink.value.toLocaleString()} replies`
highlyInteractiveUser : "No graph links after filtering"
? `${Math.round(highlyInteractiveUser.comment_share * 100)}% comments` }
: "No user distribution available" style={{ gridColumn: "span 6" }}
} />
style={{ gridColumn: "span 6" }} <Card
/> label="Most Comment-Heavy User"
value={highlyInteractiveUser?.author ?? "—"}
sublabel={
highlyInteractiveUser
? `${Math.round(highlyInteractiveUser.comment_share * 100)}% comments`
: "No user distribution available"
}
style={{ gridColumn: "span 6" }}
/>
<div style={{ ...styles.card, gridColumn: "span 12" }}> <div style={{ ...styles.card, gridColumn: "span 12" }}>
<h2 style={styles.sectionTitle}>User Interaction Graph</h2> <h2 style={styles.sectionTitle}>User Interaction Graph</h2>
<p style={styles.sectionSubtitle}> <p style={styles.sectionSubtitle}>
Each node is a user, and each link shows replies between them. Each node is a user, and each link shows replies between them.
</p> </p>
<div ref={graphContainerRef} style={{ width: "100%", height: graphSize.height }}> <div
<ForceGraph3D ref={graphContainerRef}
width={graphSize.width} style={{ width: "100%", height: graphSize.height }}
height={graphSize.height} >
graphData={graphData} <ForceGraph3D
nodeAutoColorBy="id" width={graphSize.width}
linkDirectionalParticles={1} height={graphSize.height}
linkDirectionalParticleSpeed={0.004} graphData={graphData}
linkWidth={(link) => Math.sqrt(Number(link.value))} nodeAutoColorBy="id"
nodeLabel={(node) => `${node.id}`} linkDirectionalParticles={1}
/> linkDirectionalParticleSpeed={0.004}
</div> linkWidth={(link) => Math.sqrt(Number(link.value))}
nodeLabel={(node) => `${node.id}`}
/>
</div> </div>
</div> </div>
</div>
</div> </div>
); );
} };
export default UserStats; export default UserStats;

View File

@@ -58,8 +58,8 @@ const AutoScrapePage = () => {
if (axios.isAxiosError(requestError)) { if (axios.isAxiosError(requestError)) {
setReturnMessage( setReturnMessage(
`Failed to load available sources: ${String( `Failed to load available sources: ${String(
requestError.response?.data?.error || requestError.message requestError.response?.data?.error || requestError.message,
)}` )}`,
); );
} else { } else {
setReturnMessage("Failed to load available sources."); setReturnMessage("Failed to load available sources.");
@@ -70,15 +70,19 @@ const AutoScrapePage = () => {
}); });
}, []); }, []);
const updateSourceConfig = (index: number, field: keyof SourceConfig, value: string) => { const updateSourceConfig = (
index: number,
field: keyof SourceConfig,
value: string,
) => {
setSourceConfigs((previous) => setSourceConfigs((previous) =>
previous.map((config, configIndex) => previous.map((config, configIndex) =>
configIndex === index configIndex === index
? field === "sourceName" ? field === "sourceName"
? { ...config, sourceName: value, search: "", category: "" } ? { ...config, sourceName: value, search: "", category: "" }
: { ...config, [field]: value } : { ...config, [field]: value }
: config : config,
) ),
); );
}; };
@@ -93,7 +97,9 @@ const AutoScrapePage = () => {
}; };
const removeSourceConfig = (index: number) => { const removeSourceConfig = (index: number) => {
setSourceConfigs((previous) => previous.filter((_, configIndex) => configIndex !== index)); setSourceConfigs((previous) =>
previous.filter((_, configIndex) => configIndex !== index),
);
}; };
const autoScrape = async () => { const autoScrape = async () => {
@@ -123,7 +129,9 @@ const AutoScrapePage = () => {
return { return {
name: source.sourceName, name: source.sourceName,
limit: Number(source.limit || 100), limit: Number(source.limit || 100),
search: supportsSearch(sourceOption) ? source.search.trim() || undefined : undefined, search: supportsSearch(sourceOption)
? source.search.trim() || undefined
: undefined,
category: supportsCategories(sourceOption) category: supportsCategories(sourceOption)
? source.category.trim() || undefined ? source.category.trim() || undefined
: undefined, : undefined,
@@ -131,12 +139,15 @@ const AutoScrapePage = () => {
}); });
const invalidSource = normalizedSources.find( const invalidSource = normalizedSources.find(
(source) => !source.name || !Number.isFinite(source.limit) || source.limit <= 0 (source) =>
!source.name || !Number.isFinite(source.limit) || source.limit <= 0,
); );
if (invalidSource) { if (invalidSource) {
setHasError(true); setHasError(true);
setReturnMessage("Every source needs a name and a limit greater than zero."); setReturnMessage(
"Every source needs a name and a limit greater than zero.",
);
return; return;
} }
@@ -155,13 +166,13 @@ const AutoScrapePage = () => {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
} },
); );
const datasetId = Number(response.data.dataset_id); const datasetId = Number(response.data.dataset_id);
setReturnMessage( setReturnMessage(
`Auto scrape queued successfully (dataset #${datasetId}). Redirecting to processing status...` `Auto scrape queued successfully (dataset #${datasetId}). Redirecting to processing status...`,
); );
setTimeout(() => { setTimeout(() => {
@@ -171,7 +182,9 @@ const AutoScrapePage = () => {
setHasError(true); setHasError(true);
if (axios.isAxiosError(requestError)) { if (axios.isAxiosError(requestError)) {
const message = String( const message = String(
requestError.response?.data?.error || requestError.message || "Auto scrape failed." requestError.response?.data?.error ||
requestError.message ||
"Auto scrape failed.",
); );
setReturnMessage(`Auto scrape failed: ${message}`); setReturnMessage(`Auto scrape failed: ${message}`);
} else { } else {
@@ -189,15 +202,26 @@ const AutoScrapePage = () => {
<div> <div>
<h1 style={styles.sectionHeaderTitle}>Auto Scrape Dataset</h1> <h1 style={styles.sectionHeaderTitle}>Auto Scrape Dataset</h1>
<p style={styles.sectionHeaderSubtitle}> <p style={styles.sectionHeaderSubtitle}>
Select sources and scrape settings, then queue processing automatically. Select sources and scrape settings, then queue processing
automatically.
</p> </p>
<p style={{ ...styles.subtleBodyText, marginTop: 6, color: "#9a6700" }}> <p
Warning: Scraping more than 250 posts from any single site can take hours due to rate limits. style={{
...styles.subtleBodyText,
marginTop: 6,
color: "#9a6700",
}}
>
Warning: Scraping more than 250 posts from any single site can
take hours due to rate limits.
</p> </p>
</div> </div>
<button <button
type="button" type="button"
style={{ ...styles.buttonPrimary, opacity: isSubmitting || isLoadingSources ? 0.75 : 1 }} style={{
...styles.buttonPrimary,
opacity: isSubmitting || isLoadingSources ? 0.75 : 1,
}}
onClick={autoScrape} onClick={autoScrape}
disabled={isSubmitting || isLoadingSources} disabled={isSubmitting || isLoadingSources}
> >
@@ -213,8 +237,12 @@ const AutoScrapePage = () => {
}} }}
> >
<div style={{ ...styles.card, gridColumn: "auto" }}> <div style={{ ...styles.card, gridColumn: "auto" }}>
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>Dataset Name</h2> <h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>
<p style={styles.sectionSubtitle}>Use a clear label so you can identify this run later.</p> Dataset Name
</h2>
<p style={styles.sectionSubtitle}>
Use a clear label so you can identify this run later.
</p>
<input <input
style={{ ...styles.input, ...styles.inputFullWidth }} style={{ ...styles.input, ...styles.inputFullWidth }}
type="text" type="text"
@@ -225,97 +253,129 @@ const AutoScrapePage = () => {
</div> </div>
<div style={{ ...styles.card, gridColumn: "auto" }}> <div style={{ ...styles.card, gridColumn: "auto" }}>
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>Sources</h2> <h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>
Sources
</h2>
<p style={styles.sectionSubtitle}> <p style={styles.sectionSubtitle}>
Configure source, limit, optional search, and optional category. Configure source, limit, optional search, and optional category.
</p> </p>
{isLoadingSources && <p style={styles.subtleBodyText}>Loading sources...</p>} {isLoadingSources && (
<p style={styles.subtleBodyText}>Loading sources...</p>
)}
{!isLoadingSources && sourceOptions.length === 0 && ( {!isLoadingSources && sourceOptions.length === 0 && (
<p style={styles.subtleBodyText}>No source connectors are currently available.</p> <p style={styles.subtleBodyText}>
No source connectors are currently available.
</p>
)} )}
{!isLoadingSources && sourceOptions.length > 0 && ( {!isLoadingSources && sourceOptions.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div
style={{ display: "flex", flexDirection: "column", gap: 10 }}
>
{sourceConfigs.map((source, index) => { {sourceConfigs.map((source, index) => {
const sourceOption = getSourceOption(source.sourceName); const sourceOption = getSourceOption(source.sourceName);
const searchEnabled = supportsSearch(sourceOption); const searchEnabled = supportsSearch(sourceOption);
const categoriesEnabled = supportsCategories(sourceOption); const categoriesEnabled = supportsCategories(sourceOption);
return ( return (
<div <div
key={`source-${index}`} key={`source-${index}`}
style={{ style={{
border: "1px solid #d0d7de", border: "1px solid #d0d7de",
borderRadius: 8, borderRadius: 8,
padding: 12, padding: 12,
background: "#f6f8fa", background: "#f6f8fa",
display: "grid", display: "grid",
gap: 8, gap: 8,
}} }}
>
<select
value={source.sourceName}
style={{ ...styles.input, ...styles.inputFullWidth }}
onChange={(event) => updateSourceConfig(index, "sourceName", event.target.value)}
> >
{sourceOptions.map((option) => ( <select
<option key={option.id} value={option.id}> value={source.sourceName}
{option.label} style={{ ...styles.input, ...styles.inputFullWidth }}
</option> onChange={(event) =>
))} updateSourceConfig(
</select> index,
"sourceName",
<input event.target.value,
type="number" )
min={1} }
value={source.limit}
placeholder="Limit"
style={{ ...styles.input, ...styles.inputFullWidth }}
onChange={(event) => updateSourceConfig(index, "limit", event.target.value)}
/>
<input
type="text"
value={source.search}
placeholder={
searchEnabled
? "Search term (optional)"
: "Search not supported for this source"
}
style={{ ...styles.input, ...styles.inputFullWidth }}
disabled={!searchEnabled}
onChange={(event) => updateSourceConfig(index, "search", event.target.value)}
/>
<input
type="text"
value={source.category}
placeholder={
categoriesEnabled
? "Category (optional)"
: "Categories not supported for this source"
}
style={{ ...styles.input, ...styles.inputFullWidth }}
disabled={!categoriesEnabled}
onChange={(event) => updateSourceConfig(index, "category", event.target.value)}
/>
{sourceConfigs.length > 1 && (
<button
type="button"
style={styles.buttonSecondary}
onClick={() => removeSourceConfig(index)}
> >
Remove source {sourceOptions.map((option) => (
</button> <option key={option.id} value={option.id}>
)} {option.label}
</div> </option>
))}
</select>
<input
type="number"
min={1}
value={source.limit}
placeholder="Limit"
style={{ ...styles.input, ...styles.inputFullWidth }}
onChange={(event) =>
updateSourceConfig(index, "limit", event.target.value)
}
/>
<input
type="text"
value={source.search}
placeholder={
searchEnabled
? "Search term (optional)"
: "Search not supported for this source"
}
style={{ ...styles.input, ...styles.inputFullWidth }}
disabled={!searchEnabled}
onChange={(event) =>
updateSourceConfig(
index,
"search",
event.target.value,
)
}
/>
<input
type="text"
value={source.category}
placeholder={
categoriesEnabled
? "Category (optional)"
: "Categories not supported for this source"
}
style={{ ...styles.input, ...styles.inputFullWidth }}
disabled={!categoriesEnabled}
onChange={(event) =>
updateSourceConfig(
index,
"category",
event.target.value,
)
}
/>
{sourceConfigs.length > 1 && (
<button
type="button"
style={styles.buttonSecondary}
onClick={() => removeSourceConfig(index)}
>
Remove source
</button>
)}
</div>
); );
})} })}
<button type="button" style={styles.buttonSecondary} onClick={addSourceConfig}> <button
type="button"
style={styles.buttonSecondary}
onClick={addSourceConfig}
>
Add another source Add another source
</button> </button>
</div> </div>

View File

@@ -51,7 +51,9 @@ const DatasetEditPage = () => {
.catch((error: unknown) => { .catch((error: unknown) => {
setHasError(true); setHasError(true);
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
setStatusMessage(String(error.response?.data?.error || error.message)); setStatusMessage(
String(error.response?.data?.error || error.message),
);
} else { } else {
setStatusMessage("Could not get dataset info."); setStatusMessage("Could not get dataset info.");
} }
@@ -61,7 +63,6 @@ const DatasetEditPage = () => {
}); });
}, [parsedDatasetId]); }, [parsedDatasetId]);
const saveDatasetName = async (event: FormEvent<HTMLFormElement>) => { const saveDatasetName = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -87,14 +88,18 @@ const DatasetEditPage = () => {
await axios.patch( await axios.patch(
`${API_BASE_URL}/dataset/${parsedDatasetId}`, `${API_BASE_URL}/dataset/${parsedDatasetId}`,
{ name: trimmedName }, { name: trimmedName },
{ headers: { Authorization: `Bearer ${token}` } } { headers: { Authorization: `Bearer ${token}` } },
); );
navigate("/datasets", { replace: true }); navigate("/datasets", { replace: true });
} catch (error: unknown) { } catch (error: unknown) {
setHasError(true); setHasError(true);
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
setStatusMessage(String(error.response?.data?.error || error.message || "Save failed.")); setStatusMessage(
String(
error.response?.data?.error || error.message || "Save failed.",
),
);
} else { } else {
setStatusMessage("Save failed due to an unexpected error."); setStatusMessage("Save failed due to an unexpected error.");
} }
@@ -117,17 +122,20 @@ const DatasetEditPage = () => {
setHasError(false); setHasError(false);
setStatusMessage(""); setStatusMessage("");
await axios.delete( await axios.delete(`${API_BASE_URL}/dataset/${parsedDatasetId}`, {
`${API_BASE_URL}/dataset/${parsedDatasetId}`, headers: { Authorization: `Bearer ${deleteToken}` },
{ headers: { Authorization: `Bearer ${deleteToken}` } } });
);
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
navigate("/datasets", { replace: true }); navigate("/datasets", { replace: true });
} catch (error: unknown) { } catch (error: unknown) {
setHasError(true); setHasError(true);
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
setStatusMessage(String(error.response?.data?.error || error.message || "Delete failed.")); setStatusMessage(
String(
error.response?.data?.error || error.message || "Delete failed.",
),
);
} else { } else {
setStatusMessage("Delete failed due to an unexpected error."); setStatusMessage("Delete failed due to an unexpected error.");
} }
@@ -142,7 +150,9 @@ const DatasetEditPage = () => {
<div style={{ ...styles.card, ...styles.headerBar }}> <div style={{ ...styles.card, ...styles.headerBar }}>
<div> <div>
<h1 style={styles.sectionHeaderTitle}>Edit Dataset</h1> <h1 style={styles.sectionHeaderTitle}>Edit Dataset</h1>
<p style={styles.sectionHeaderSubtitle}>Update the dataset name shown in your datasets list.</p> <p style={styles.sectionHeaderSubtitle}>
Update the dataset name shown in your datasets list.
</p>
</div> </div>
</div> </div>
@@ -173,8 +183,8 @@ const DatasetEditPage = () => {
style={styles.buttonDanger} style={styles.buttonDanger}
onClick={() => setIsDeleteModalOpen(true)} onClick={() => setIsDeleteModalOpen(true)}
disabled={isSaving || isDeleting} disabled={isSaving || isDeleting}
> >
Delete Dataset Delete Dataset
</button> </button>
<button <button
@@ -187,15 +197,16 @@ const DatasetEditPage = () => {
</button> </button>
<button <button
type="submit" type="submit"
style={{ ...styles.buttonPrimary, opacity: loading || isSaving ? 0.75 : 1 }} style={{
...styles.buttonPrimary,
opacity: loading || isSaving ? 0.75 : 1,
}}
disabled={loading || isSaving || isDeleting} disabled={loading || isSaving || isDeleting}
> >
{isSaving ? "Saving..." : "Save"} {isSaving ? "Saving..." : "Save"}
</button> </button>
{loading {loading ? "Loading dataset details..." : statusMessage}
? "Loading dataset details..."
: statusMessage}
</div> </div>
</form> </form>

View File

@@ -3,7 +3,7 @@ import axios from "axios";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import StatsStyling from "../styles/stats_styling"; import StatsStyling from "../styles/stats_styling";
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL const API_BASE_URL = import.meta.env.VITE_BACKEND_URL;
type DatasetStatusResponse = { type DatasetStatusResponse = {
status?: "fetching" | "processing" | "complete" | "error"; status?: "fetching" | "processing" | "complete" | "error";
@@ -17,7 +17,8 @@ const DatasetStatusPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { datasetId } = useParams<{ datasetId: string }>(); const { datasetId } = useParams<{ datasetId: string }>();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<DatasetStatusResponse["status"]>("processing"); const [status, setStatus] =
useState<DatasetStatusResponse["status"]>("processing");
const [statusMessage, setStatusMessage] = useState(""); const [statusMessage, setStatusMessage] = useState("");
const parsedDatasetId = useMemo(() => Number(datasetId), [datasetId]); const parsedDatasetId = useMemo(() => Number(datasetId), [datasetId]);
@@ -34,7 +35,7 @@ const DatasetStatusPage = () => {
const pollStatus = async () => { const pollStatus = async () => {
try { try {
const response = await axios.get<DatasetStatusResponse>( const response = await axios.get<DatasetStatusResponse>(
`${API_BASE_URL}/dataset/${parsedDatasetId}/status` `${API_BASE_URL}/dataset/${parsedDatasetId}/status`,
); );
const nextStatus = response.data.status ?? "processing"; const nextStatus = response.data.status ?? "processing";
@@ -51,7 +52,9 @@ const DatasetStatusPage = () => {
setLoading(false); setLoading(false);
setStatus("error"); setStatus("error");
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const message = String(error.response?.data?.error || error.message || "Request failed"); const message = String(
error.response?.data?.error || error.message || "Request failed",
);
setStatusMessage(message); setStatusMessage(message);
} else { } else {
setStatusMessage("Unable to fetch dataset status."); setStatusMessage("Unable to fetch dataset status.");
@@ -73,7 +76,8 @@ const DatasetStatusPage = () => {
}; };
}, [navigate, parsedDatasetId, status]); }, [navigate, parsedDatasetId, status]);
const isProcessing = loading || status === "fetching" || status === "processing"; const isProcessing =
loading || status === "fetching" || status === "processing";
const isError = status === "error"; const isError = status === "error";
return ( return (
@@ -81,26 +85,37 @@ const DatasetStatusPage = () => {
<div style={styles.containerNarrow}> <div style={styles.containerNarrow}>
<div style={{ ...styles.card, marginTop: 28 }}> <div style={{ ...styles.card, marginTop: 28 }}>
<h1 style={styles.sectionHeaderTitle}> <h1 style={styles.sectionHeaderTitle}>
{isProcessing ? "Processing dataset..." : isError ? "Dataset processing failed" : "Dataset ready"} {isProcessing
? "Processing dataset..."
: isError
? "Dataset processing failed"
: "Dataset ready"}
</h1> </h1>
<p style={{ ...styles.sectionSubtitle, marginTop: 10 }}> <p style={{ ...styles.sectionSubtitle, marginTop: 10 }}>
{isProcessing && {isProcessing &&
"Your dataset is being analyzed. This page will redirect to stats automatically once complete."} "Your dataset is being analyzed. This page will redirect to stats automatically once complete."}
{isError && "There was an issue while processing your dataset. Please review the error details."} {isError &&
{status === "complete" && "Processing complete. Redirecting to your stats now..."} "There was an issue while processing your dataset. Please review the error details."}
{status === "complete" &&
"Processing complete. Redirecting to your stats now..."}
</p> </p>
<div <div
style={{ style={{
...styles.card, ...styles.card,
...styles.statusMessageCard, ...styles.statusMessageCard,
borderColor: isError ? "rgba(185, 28, 28, 0.28)" : "rgba(0,0,0,0.06)", borderColor: isError
? "rgba(185, 28, 28, 0.28)"
: "rgba(0,0,0,0.06)",
background: isError ? "#fff5f5" : "#ffffff", background: isError ? "#fff5f5" : "#ffffff",
color: isError ? "#991b1b" : "#374151", color: isError ? "#991b1b" : "#374151",
}} }}
> >
{statusMessage || (isProcessing ? "Waiting for updates from the worker queue..." : "No details provided.")} {statusMessage ||
(isProcessing
? "Waiting for updates from the worker queue..."
: "No details provided.")}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -39,7 +39,9 @@ const DatasetsPage = () => {
}) })
.catch((requestError: unknown) => { .catch((requestError: unknown) => {
if (axios.isAxiosError(requestError)) { if (axios.isAxiosError(requestError)) {
setError(String(requestError.response?.data?.error || requestError.message)); setError(
String(requestError.response?.data?.error || requestError.message),
);
} else { } else {
setError("Failed to load datasets."); setError("Failed to load datasets.");
} }
@@ -61,13 +63,28 @@ const DatasetsPage = () => {
</div> </div>
<div style={styles.loadingSkeleton}> <div style={styles.loadingSkeleton}>
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineLong }} /> <div
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineMed }} /> style={{
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineShort }} /> ...styles.loadingSkeletonLine,
...styles.loadingSkeletonLineLong,
}}
/>
<div
style={{
...styles.loadingSkeletonLine,
...styles.loadingSkeletonLineMed,
}}
/>
<div
style={{
...styles.loadingSkeletonLine,
...styles.loadingSkeletonLineShort,
}}
/>
</div> </div>
</div> </div>
</div> </div>
) );
} }
return ( return (
@@ -81,7 +98,11 @@ const DatasetsPage = () => {
</p> </p>
</div> </div>
<div style={styles.controlsWrapped}> <div style={styles.controlsWrapped}>
<button type="button" style={styles.buttonPrimary} onClick={() => navigate("/upload")}> <button
type="button"
style={styles.buttonPrimary}
onClick={() => navigate("/upload")}
>
Upload New Dataset Upload New Dataset
</button> </button>
<button <button
@@ -116,20 +137,25 @@ const DatasetsPage = () => {
)} )}
{!error && datasets.length > 0 && ( {!error && datasets.length > 0 && (
<div style={{ ...styles.card, marginTop: 14, padding: 0, overflow: "hidden" }}> <div
style={{
...styles.card,
marginTop: 14,
padding: 0,
overflow: "hidden",
}}
>
<ul style={styles.listNoBullets}> <ul style={styles.listNoBullets}>
{datasets.map((dataset) => { {datasets.map((dataset) => {
const isComplete = dataset.status === "complete" || dataset.status === "error"; const isComplete =
dataset.status === "complete" || dataset.status === "error";
const editPath = `/dataset/${dataset.id}/edit`; const editPath = `/dataset/${dataset.id}/edit`;
const targetPath = isComplete const targetPath = isComplete
? `/dataset/${dataset.id}/stats` ? `/dataset/${dataset.id}/stats`
: `/dataset/${dataset.id}/status`; : `/dataset/${dataset.id}/status`;
return ( return (
<li <li key={dataset.id} style={styles.datasetListItem}>
key={dataset.id}
style={styles.datasetListItem}
>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={styles.datasetName}> <div style={styles.datasetName}>
{dataset.name || `Dataset #${dataset.id}`} {dataset.name || `Dataset #${dataset.id}`}
@@ -145,19 +171,23 @@ const DatasetsPage = () => {
</div> </div>
<div> <div>
{ isComplete && {isComplete && (
<button <button
type="button" type="button"
style={{...styles.buttonSecondary, "margin": "5px"}} style={{ ...styles.buttonSecondary, margin: "5px" }}
onClick={() => navigate(editPath)} onClick={() => navigate(editPath)}
> >
Edit Dataset Edit Dataset
</button> </button>
} )}
<button <button
type="button" type="button"
style={isComplete ? styles.buttonPrimary : styles.buttonSecondary} style={
isComplete
? styles.buttonPrimary
: styles.buttonSecondary
}
onClick={() => navigate(targetPath)} onClick={() => navigate(targetPath)}
> >
{isComplete ? "Open stats" : "View status"} {isComplete ? "Open stats" : "View status"}

View File

@@ -3,7 +3,7 @@ import axios from "axios";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import StatsStyling from "../styles/stats_styling"; import StatsStyling from "../styles/stats_styling";
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL const API_BASE_URL = import.meta.env.VITE_BACKEND_URL;
const styles = StatsStyling; const styles = StatsStyling;
@@ -44,13 +44,17 @@ const LoginPage = () => {
try { try {
if (isRegisterMode) { if (isRegisterMode) {
await axios.post(`${API_BASE_URL}/register`, { username, email, password }); await axios.post(`${API_BASE_URL}/register`, {
username,
email,
password,
});
setInfo("Account created. You can now sign in."); setInfo("Account created. You can now sign in.");
setIsRegisterMode(false); setIsRegisterMode(false);
} else { } else {
const response = await axios.post<{ access_token: string }>( const response = await axios.post<{ access_token: string }>(
`${API_BASE_URL}/login`, `${API_BASE_URL}/login`,
{ username, password } { username, password },
); );
const token = response.data.access_token; const token = response.data.access_token;
@@ -61,7 +65,11 @@ const LoginPage = () => {
} catch (requestError: unknown) { } catch (requestError: unknown) {
if (axios.isAxiosError(requestError)) { if (axios.isAxiosError(requestError)) {
setError( setError(
String(requestError.response?.data?.error || requestError.message || "Request failed") String(
requestError.response?.data?.error ||
requestError.message ||
"Request failed",
),
); );
} else { } else {
setError("Unexpected error occurred."); setError("Unexpected error occurred.");
@@ -73,90 +81,86 @@ const LoginPage = () => {
return ( return (
<div style={styles.containerAuth}> <div style={styles.containerAuth}>
<div style={{ ...styles.card, ...styles.authCard }}> <div style={{ ...styles.card, ...styles.authCard }}>
<div style={styles.headingBlock}> <div style={styles.headingBlock}>
<h1 style={styles.headingXl}> <h1 style={styles.headingXl}>
{isRegisterMode ? "Create your account" : "Welcome back"} {isRegisterMode ? "Create your account" : "Welcome back"}
</h1> </h1>
<p style={styles.mutedText}> <p style={styles.mutedText}>
{isRegisterMode {isRegisterMode
? "Register to start uploading and exploring your dataset insights." ? "Register to start uploading and exploring your dataset insights."
: "Sign in to continue to your analytics workspace."} : "Sign in to continue to your analytics workspace."}
</p> </p>
</div>
<form onSubmit={handleSubmit} style={styles.authForm}>
<input
type="text"
placeholder="Username"
style={{ ...styles.input, ...styles.authControl }}
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
{isRegisterMode && (
<input
type="email"
placeholder="Email"
style={{ ...styles.input, ...styles.authControl }}
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
)}
<input
type="password"
placeholder="Password"
style={{ ...styles.input, ...styles.authControl }}
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
<button
type="submit"
style={{ ...styles.buttonPrimary, ...styles.authControl, marginTop: 2 }}
disabled={loading}
>
{loading
? "Please wait..."
: isRegisterMode
? "Create account"
: "Sign in"}
</button>
</form>
{error && (
<p style={styles.authErrorText}>
{error}
</p>
)}
{info && (
<p style={styles.authInfoText}>
{info}
</p>
)}
<div style={styles.authSwitchRow}>
<span style={styles.authSwitchLabel}>
{isRegisterMode ? "Already have an account?" : "New here?"}
</span>
<button
type="button"
style={styles.authSwitchButton}
onClick={() => {
setError("");
setInfo("");
setIsRegisterMode((value) => !value);
}}
>
{isRegisterMode ? "Switch to sign in" : "Create account"}
</button>
</div>
</div> </div>
<form onSubmit={handleSubmit} style={styles.authForm}>
<input
type="text"
placeholder="Username"
style={{ ...styles.input, ...styles.authControl }}
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
{isRegisterMode && (
<input
type="email"
placeholder="Email"
style={{ ...styles.input, ...styles.authControl }}
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
)}
<input
type="password"
placeholder="Password"
style={{ ...styles.input, ...styles.authControl }}
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
<button
type="submit"
style={{
...styles.buttonPrimary,
...styles.authControl,
marginTop: 2,
}}
disabled={loading}
>
{loading
? "Please wait..."
: isRegisterMode
? "Create account"
: "Sign in"}
</button>
</form>
{error && <p style={styles.authErrorText}>{error}</p>}
{info && <p style={styles.authInfoText}>{info}</p>}
<div style={styles.authSwitchRow}>
<span style={styles.authSwitchLabel}>
{isRegisterMode ? "Already have an account?" : "New here?"}
</span>
<button
type="button"
style={styles.authSwitchButton}
onClick={() => {
setError("");
setInfo("");
setIsRegisterMode((value) => !value);
}}
>
{isRegisterMode ? "Switch to sign in" : "Create account"}
</button>
</div>
</div>
</div> </div>
); );
}; };

View File

@@ -9,47 +9,59 @@ import LinguisticStats from "../components/LinguisticStats";
import InteractionalStats from "../components/InteractionalStats"; import InteractionalStats from "../components/InteractionalStats";
import CulturalStats from "../components/CulturalStats"; import CulturalStats from "../components/CulturalStats";
import { import {
type SummaryResponse, type SummaryResponse,
type UserAnalysisResponse, type UserAnalysisResponse,
type TimeAnalysisResponse, type TimeAnalysisResponse,
type ContentAnalysisResponse, type ContentAnalysisResponse,
type UserEndpointResponse, type UserEndpointResponse,
type LinguisticAnalysisResponse, type LinguisticAnalysisResponse,
type EmotionalAnalysisResponse, type EmotionalAnalysisResponse,
type InteractionAnalysisResponse, type InteractionAnalysisResponse,
type CulturalAnalysisResponse type CulturalAnalysisResponse,
} from '../types/ApiTypes' } from "../types/ApiTypes";
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL const API_BASE_URL = import.meta.env.VITE_BACKEND_URL;
const styles = StatsStyling; const styles = StatsStyling;
const DELETED_USERS = ["[deleted]"]; const DELETED_USERS = ["[deleted]"];
const isDeletedUser = (value: string | null | undefined) => ( const isDeletedUser = (value: string | null | undefined) =>
DELETED_USERS.includes((value ?? "").trim().toLowerCase()) DELETED_USERS.includes((value ?? "").trim().toLowerCase());
);
const StatPage = () => { const StatPage = () => {
const { datasetId: routeDatasetId } = useParams<{ datasetId: string }>(); const { datasetId: routeDatasetId } = useParams<{ datasetId: string }>();
const [error, setError] = useState(''); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [activeView, setActiveView] = useState<"summary" | "emotional" | "user" | "linguistic" | "interactional" | "cultural">("summary"); const [activeView, setActiveView] = useState<
| "summary"
| "emotional"
| "user"
| "linguistic"
| "interactional"
| "cultural"
>("summary");
const [userData, setUserData] = useState<UserAnalysisResponse | null>(null); const [userData, setUserData] = useState<UserAnalysisResponse | null>(null);
const [timeData, setTimeData] = useState<TimeAnalysisResponse | null>(null); const [timeData, setTimeData] = useState<TimeAnalysisResponse | null>(null);
const [contentData, setContentData] = useState<ContentAnalysisResponse | null>(null); const [contentData, setContentData] =
const [linguisticData, setLinguisticData] = useState<LinguisticAnalysisResponse | null>(null); useState<ContentAnalysisResponse | null>(null);
const [interactionData, setInteractionData] = useState<InteractionAnalysisResponse | null>(null); const [linguisticData, setLinguisticData] =
const [culturalData, setCulturalData] = useState<CulturalAnalysisResponse | null>(null); useState<LinguisticAnalysisResponse | null>(null);
const [interactionData, setInteractionData] =
useState<InteractionAnalysisResponse | null>(null);
const [culturalData, setCulturalData] =
useState<CulturalAnalysisResponse | null>(null);
const [summary, setSummary] = useState<SummaryResponse | null>(null); 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);
const afterDateRef = useRef<HTMLInputElement>(null); const afterDateRef = useRef<HTMLInputElement>(null);
const parsedDatasetId = Number(routeDatasetId ?? ""); const parsedDatasetId = Number(routeDatasetId ?? "");
const datasetId = Number.isInteger(parsedDatasetId) && parsedDatasetId > 0 ? parsedDatasetId : null; const datasetId =
Number.isInteger(parsedDatasetId) && parsedDatasetId > 0
? parsedDatasetId
: null;
const getFilterParams = () => { const getFilterParams = () => {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
@@ -99,112 +111,147 @@ const StatPage = () => {
setLoading(true); setLoading(true);
Promise.all([ Promise.all([
axios.get<TimeAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/temporal`, { axios.get<TimeAnalysisResponse>(
params, `${API_BASE_URL}/dataset/${datasetId}/temporal`,
headers: authHeaders, {
}), params,
axios.get<UserEndpointResponse>(`${API_BASE_URL}/dataset/${datasetId}/user`, { headers: authHeaders,
params, },
headers: authHeaders, ),
}), axios.get<UserEndpointResponse>(
axios.get<LinguisticAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/linguistic`, { `${API_BASE_URL}/dataset/${datasetId}/user`,
params, {
headers: authHeaders, params,
}), headers: authHeaders,
axios.get<EmotionalAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/emotional`, { },
params, ),
headers: authHeaders, axios.get<LinguisticAnalysisResponse>(
}), `${API_BASE_URL}/dataset/${datasetId}/linguistic`,
axios.get<InteractionAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/interactional`, { {
params, params,
headers: authHeaders, headers: authHeaders,
}), },
axios.get<SummaryResponse>(`${API_BASE_URL}/dataset/${datasetId}/summary`, { ),
params, axios.get<EmotionalAnalysisResponse>(
headers: authHeaders, `${API_BASE_URL}/dataset/${datasetId}/emotional`,
}), {
axios.get<CulturalAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/cultural`, { params,
params, headers: authHeaders,
headers: authHeaders, },
}), ),
]) axios.get<InteractionAnalysisResponse>(
.then(([timeRes, userRes, linguisticRes, emotionalRes, interactionRes, summaryRes, culturalRes]) => { `${API_BASE_URL}/dataset/${datasetId}/interactional`,
const usersList = userRes.data.users ?? []; {
const topUsersList = userRes.data.top_users ?? []; params,
const interactionGraphRaw = interactionRes.data?.interaction_graph ?? {}; headers: authHeaders,
const topPairsRaw = interactionRes.data?.top_interaction_pairs ?? []; },
),
axios.get<SummaryResponse>(
`${API_BASE_URL}/dataset/${datasetId}/summary`,
{
params,
headers: authHeaders,
},
),
axios.get<CulturalAnalysisResponse>(
`${API_BASE_URL}/dataset/${datasetId}/cultural`,
{
params,
headers: authHeaders,
},
),
])
.then(
([
timeRes,
userRes,
linguisticRes,
emotionalRes,
interactionRes,
summaryRes,
culturalRes,
]) => {
const usersList = userRes.data.users ?? [];
const topUsersList = userRes.data.top_users ?? [];
const interactionGraphRaw =
interactionRes.data?.interaction_graph ?? {};
const topPairsRaw = interactionRes.data?.top_interaction_pairs ?? [];
const filteredUsers: typeof usersList = []; const filteredUsers: typeof usersList = [];
for (const user of usersList) { for (const user of usersList) {
if (isDeletedUser(user.author)) continue; if (isDeletedUser(user.author)) continue;
filteredUsers.push(user); filteredUsers.push(user);
}
const filteredTopUsers: typeof topUsersList = [];
for (const user of topUsersList) {
if (isDeletedUser(user.author)) continue;
filteredTopUsers.push(user);
}
const filteredInteractionGraph: Record<string, Record<string, number>> = {};
for (const [source, targets] of Object.entries(interactionGraphRaw)) {
if (isDeletedUser(source)) {
continue;
} }
const nextTargets: Record<string, number> = {}; const filteredTopUsers: typeof topUsersList = [];
for (const [target, count] of Object.entries(targets)) { for (const user of topUsersList) {
if (isDeletedUser(target)) { if (isDeletedUser(user.author)) continue;
filteredTopUsers.push(user);
}
const filteredInteractionGraph: Record<
string,
Record<string, number>
> = {};
for (const [source, targets] of Object.entries(interactionGraphRaw)) {
if (isDeletedUser(source)) {
continue; continue;
} }
nextTargets[target] = count;
const nextTargets: Record<string, number> = {};
for (const [target, count] of Object.entries(targets)) {
if (isDeletedUser(target)) {
continue;
}
nextTargets[target] = count;
}
filteredInteractionGraph[source] = nextTargets;
} }
filteredInteractionGraph[source] = nextTargets; const filteredTopInteractionPairs: typeof topPairsRaw = [];
} for (const pairEntry of topPairsRaw) {
const pair = pairEntry[0];
const filteredTopInteractionPairs: typeof topPairsRaw = []; const source = pair[0];
for (const pairEntry of topPairsRaw) { const target = pair[1];
const pair = pairEntry[0]; if (isDeletedUser(source) || isDeletedUser(target)) {
const source = pair[0]; continue;
const target = pair[1]; }
if (isDeletedUser(source) || isDeletedUser(target)) { filteredTopInteractionPairs.push(pairEntry);
continue;
} }
filteredTopInteractionPairs.push(pairEntry);
}
const combinedUserData: UserAnalysisResponse = { const combinedUserData: UserAnalysisResponse = {
...userRes.data, ...userRes.data,
users: filteredUsers, users: filteredUsers,
top_users: filteredTopUsers, top_users: filteredTopUsers,
interaction_graph: filteredInteractionGraph, interaction_graph: filteredInteractionGraph,
}; };
const combinedContentData: ContentAnalysisResponse = { const combinedContentData: ContentAnalysisResponse = {
...linguisticRes.data, ...linguisticRes.data,
...emotionalRes.data, ...emotionalRes.data,
}; };
const filteredInteractionData: InteractionAnalysisResponse = { const filteredInteractionData: InteractionAnalysisResponse = {
...interactionRes.data, ...interactionRes.data,
interaction_graph: filteredInteractionGraph, interaction_graph: filteredInteractionGraph,
top_interaction_pairs: filteredTopInteractionPairs, top_interaction_pairs: filteredTopInteractionPairs,
}; };
const filteredSummary: SummaryResponse = { const filteredSummary: SummaryResponse = {
...summaryRes.data, ...summaryRes.data,
unique_users: filteredUsers.length, unique_users: filteredUsers.length,
}; };
setUserData(combinedUserData); setUserData(combinedUserData);
setTimeData(timeRes.data || null); setTimeData(timeRes.data || null);
setContentData(combinedContentData); setContentData(combinedContentData);
setLinguisticData(linguisticRes.data || null); setLinguisticData(linguisticRes.data || null);
setInteractionData(filteredInteractionData || null); setInteractionData(filteredInteractionData || null);
setCulturalData(culturalRes.data || null); setCulturalData(culturalRes.data || null);
setSummary(filteredSummary || null); setSummary(filteredSummary || 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));
}; };
@@ -233,7 +280,7 @@ const StatPage = () => {
return; return;
} }
getStats(); getStats();
}, [datasetId]) }, [datasetId]);
if (loading) { if (loading) {
return ( return (
@@ -243,155 +290,199 @@ const StatPage = () => {
<div style={styles.loadingSpinner} /> <div style={styles.loadingSpinner} />
<div> <div>
<h2 style={styles.loadingTitle}>Loading analytics</h2> <h2 style={styles.loadingTitle}>Loading analytics</h2>
<p style={styles.loadingSubtitle}>Fetching summary, timeline, user, and content insights.</p> <p style={styles.loadingSubtitle}>
Fetching summary, timeline, user, and content insights.
</p>
</div> </div>
</div> </div>
<div style={styles.loadingSkeleton}> <div style={styles.loadingSkeleton}>
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineLong }} /> <div
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineMed }} /> style={{
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineShort }} /> ...styles.loadingSkeletonLine,
...styles.loadingSkeletonLineLong,
}}
/>
<div
style={{
...styles.loadingSkeletonLine,
...styles.loadingSkeletonLineMed,
}}
/>
<div
style={{
...styles.loadingSkeletonLine,
...styles.loadingSkeletonLineShort,
}}
/>
</div> </div>
</div> </div>
</div> </div>
); );
} }
if (error) return <p style={{...styles.page}}>{error}</p>; if (error) return <p style={{ ...styles.page }}>{error}</p>;
return ( return (
<div style={styles.page}> <div style={styles.page}>
<div style={{ ...styles.container, ...styles.card, ...styles.headerBar }}> <div style={{ ...styles.container, ...styles.card, ...styles.headerBar }}>
<div style={styles.controls}> <div style={styles.controls}>
<input <input
type="text" type="text"
id="query" id="query"
ref={searchInputRef} ref={searchInputRef}
placeholder="Search events..." placeholder="Search events..."
style={styles.input} style={styles.input}
/> />
<input <input
type="date" type="date"
ref={beforeDateRef} ref={beforeDateRef}
placeholder="Search before date" placeholder="Search before date"
style={styles.input} style={styles.input}
/> />
<input <input
type="date" type="date"
ref={afterDateRef} ref={afterDateRef}
placeholder="Search before date" placeholder="Search before date"
style={styles.input} style={styles.input}
/> />
<button onClick={onSubmitFilters} style={styles.buttonPrimary}> <button onClick={onSubmitFilters} style={styles.buttonPrimary}>
Search Search
</button> </button>
<button onClick={resetFilters} style={styles.buttonSecondary}> <button onClick={resetFilters} style={styles.buttonSecondary}>
Reset Reset
</button> </button>
</div>
<div style={styles.dashboardMeta}>Analytics Dashboard</div>
<div style={styles.dashboardMeta}>Dataset #{datasetId ?? "-"}</div>
</div> </div>
<div style={{ ...styles.container, ...styles.tabsRow, justifyContent: "center" }}> <div style={styles.dashboardMeta}>Analytics Dashboard</div>
<button <div style={styles.dashboardMeta}>Dataset #{datasetId ?? "-"}</div>
onClick={() => setActiveView("summary")} </div>
style={activeView === "summary" ? styles.buttonPrimary : styles.buttonSecondary}
>
Summary
</button>
<button
onClick={() => setActiveView("emotional")}
style={activeView === "emotional" ? styles.buttonPrimary : styles.buttonSecondary}
>
Emotional
</button>
<button <div
onClick={() => setActiveView("user")} style={{
style={activeView === "user" ? styles.buttonPrimary : styles.buttonSecondary} ...styles.container,
...styles.tabsRow,
justifyContent: "center",
}}
> >
Users <button
</button> onClick={() => setActiveView("summary")}
<button style={
onClick={() => setActiveView("linguistic")} activeView === "summary"
style={activeView === "linguistic" ? styles.buttonPrimary : styles.buttonSecondary} ? styles.buttonPrimary
> : styles.buttonSecondary
Linguistic }
</button> >
<button Summary
onClick={() => setActiveView("interactional")} </button>
style={activeView === "interactional" ? styles.buttonPrimary : styles.buttonSecondary} <button
> onClick={() => setActiveView("emotional")}
Interactional style={
</button> activeView === "emotional"
<button ? styles.buttonPrimary
onClick={() => setActiveView("cultural")} : styles.buttonSecondary
style={activeView === "cultural" ? styles.buttonPrimary : styles.buttonSecondary} }
> >
Cultural Emotional
</button> </button>
<button
onClick={() => setActiveView("user")}
style={
activeView === "user"
? styles.buttonPrimary
: styles.buttonSecondary
}
>
Users
</button>
<button
onClick={() => setActiveView("linguistic")}
style={
activeView === "linguistic"
? styles.buttonPrimary
: styles.buttonSecondary
}
>
Linguistic
</button>
<button
onClick={() => setActiveView("interactional")}
style={
activeView === "interactional"
? styles.buttonPrimary
: styles.buttonSecondary
}
>
Interactional
</button>
<button
onClick={() => setActiveView("cultural")}
style={
activeView === "cultural"
? styles.buttonPrimary
: styles.buttonSecondary
}
>
Cultural
</button>
</div>
{activeView === "summary" && (
<SummaryStats
userData={userData}
timeData={timeData}
contentData={contentData}
summary={summary}
/>
)}
{activeView === "emotional" && contentData && (
<EmotionalStats contentData={contentData} />
)}
{activeView === "emotional" && !contentData && (
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
No emotional data available.
</div>
)}
{activeView === "user" && userData && <UserStats data={userData} />}
{activeView === "linguistic" && linguisticData && (
<LinguisticStats data={linguisticData} />
)}
{activeView === "linguistic" && !linguisticData && (
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
No linguistic data available.
</div>
)}
{activeView === "interactional" && interactionData && (
<InteractionalStats data={interactionData} />
)}
{activeView === "interactional" && !interactionData && (
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
No interactional data available.
</div>
)}
{activeView === "cultural" && culturalData && (
<CulturalStats data={culturalData} />
)}
{activeView === "cultural" && !culturalData && (
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
No cultural data available.
</div>
)}
</div> </div>
);
{activeView === "summary" && ( };
<SummaryStats
userData={userData}
timeData={timeData}
contentData={contentData}
summary={summary}
/>
)}
{activeView === "emotional" && contentData && (
<EmotionalStats contentData={contentData} />
)}
{activeView === "emotional" && !contentData && (
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
No emotional data available.
</div>
)}
{activeView === "user" && userData && (
<UserStats data={userData} />
)}
{activeView === "linguistic" && linguisticData && (
<LinguisticStats data={linguisticData} />
)}
{activeView === "linguistic" && !linguisticData && (
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
No linguistic data available.
</div>
)}
{activeView === "interactional" && interactionData && (
<InteractionalStats data={interactionData} />
)}
{activeView === "interactional" && !interactionData && (
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
No interactional data available.
</div>
)}
{activeView === "cultural" && culturalData && (
<CulturalStats data={culturalData} />
)}
{activeView === "cultural" && !culturalData && (
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
No cultural data available.
</div>
)}
</div>
);
}
export default StatPage; export default StatPage;

View File

@@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom";
import StatsStyling from "../styles/stats_styling"; import StatsStyling from "../styles/stats_styling";
const styles = StatsStyling; const styles = StatsStyling;
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL const API_BASE_URL = import.meta.env.VITE_BACKEND_URL;
const UploadPage = () => { const UploadPage = () => {
const [datasetName, setDatasetName] = useState(""); const [datasetName, setDatasetName] = useState("");
@@ -40,16 +40,20 @@ const UploadPage = () => {
setHasError(false); setHasError(false);
setReturnMessage(""); setReturnMessage("");
const response = await axios.post(`${API_BASE_URL}/datasets/upload`, formData, { const response = await axios.post(
headers: { `${API_BASE_URL}/datasets/upload`,
"Content-Type": "multipart/form-data", formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
}, },
}); );
const datasetId = Number(response.data.dataset_id); const datasetId = Number(response.data.dataset_id);
setReturnMessage( setReturnMessage(
`Upload queued successfully (dataset #${datasetId}). Redirecting to processing status...` `Upload queued successfully (dataset #${datasetId}). Redirecting to processing status...`,
); );
setTimeout(() => { setTimeout(() => {
@@ -58,7 +62,9 @@ const UploadPage = () => {
} catch (error: unknown) { } catch (error: unknown) {
setHasError(true); setHasError(true);
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const message = String(error.response?.data?.error || error.message || "Upload failed."); const message = String(
error.response?.data?.error || error.message || "Upload failed.",
);
setReturnMessage(`Upload failed: ${message}`); setReturnMessage(`Upload failed: ${message}`);
} else { } else {
setReturnMessage("Upload failed due to an unexpected error."); setReturnMessage("Upload failed due to an unexpected error.");
@@ -75,12 +81,16 @@ const UploadPage = () => {
<div> <div>
<h1 style={styles.sectionHeaderTitle}>Upload Dataset</h1> <h1 style={styles.sectionHeaderTitle}>Upload Dataset</h1>
<p style={styles.sectionHeaderSubtitle}> <p style={styles.sectionHeaderSubtitle}>
Name your dataset, then upload posts and topic map files to generate analytics. Name your dataset, then upload posts and topic map files to
generate analytics.
</p> </p>
</div> </div>
<button <button
type="button" type="button"
style={{ ...styles.buttonPrimary, opacity: isSubmitting ? 0.75 : 1 }} style={{
...styles.buttonPrimary,
opacity: isSubmitting ? 0.75 : 1,
}}
onClick={uploadFiles} onClick={uploadFiles}
disabled={isSubmitting} disabled={isSubmitting}
> >
@@ -96,8 +106,12 @@ const UploadPage = () => {
}} }}
> >
<div style={{ ...styles.card, gridColumn: "auto" }}> <div style={{ ...styles.card, gridColumn: "auto" }}>
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>Dataset Name</h2> <h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>
<p style={styles.sectionSubtitle}>Use a clear label so you can identify this upload later.</p> Dataset Name
</h2>
<p style={styles.sectionSubtitle}>
Use a clear label so you can identify this upload later.
</p>
<input <input
style={{ ...styles.input, ...styles.inputFullWidth }} style={{ ...styles.input, ...styles.inputFullWidth }}
type="text" type="text"
@@ -108,8 +122,12 @@ const UploadPage = () => {
</div> </div>
<div style={{ ...styles.card, gridColumn: "auto" }}> <div style={{ ...styles.card, gridColumn: "auto" }}>
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>Posts File (.jsonl)</h2> <h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>
<p style={styles.sectionSubtitle}>Upload the raw post records export.</p> Posts File (.jsonl)
</h2>
<p style={styles.sectionSubtitle}>
Upload the raw post records export.
</p>
<input <input
style={{ ...styles.input, ...styles.inputFullWidth }} style={{ ...styles.input, ...styles.inputFullWidth }}
type="file" type="file"
@@ -122,16 +140,24 @@ const UploadPage = () => {
</div> </div>
<div style={{ ...styles.card, gridColumn: "auto" }}> <div style={{ ...styles.card, gridColumn: "auto" }}>
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>Topics File (.json)</h2> <h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>
<p style={styles.sectionSubtitle}>Upload your topic bucket mapping file.</p> Topics File (.json)
</h2>
<p style={styles.sectionSubtitle}>
Upload your topic bucket mapping file.
</p>
<input <input
style={{ ...styles.input, ...styles.inputFullWidth }} style={{ ...styles.input, ...styles.inputFullWidth }}
type="file" type="file"
accept=".json" accept=".json"
onChange={(event) => setTopicBucketFile(event.target.files?.[0] ?? null)} onChange={(event) =>
setTopicBucketFile(event.target.files?.[0] ?? null)
}
/> />
<p style={styles.subtleBodyText}> <p style={styles.subtleBodyText}>
{topicBucketFile ? `Selected: ${topicBucketFile.name}` : "No file selected"} {topicBucketFile
? `Selected: ${topicBucketFile.name}`
: "No file selected"}
</p> </p>
</div> </div>
</div> </div>
@@ -143,7 +169,8 @@ const UploadPage = () => {
...(hasError ? styles.alertCardError : styles.alertCardInfo), ...(hasError ? styles.alertCardError : styles.alertCardInfo),
}} }}
> >
{returnMessage || "After upload, your dataset is queued for processing and you'll land on stats."} {returnMessage ||
"After upload, your dataset is queued for processing and you'll land on stats."}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -25,8 +25,7 @@ const DAYS = [
"Sunday", "Sunday",
]; ];
const hourLabel = (h: number) => const hourLabel = (h: number) => `${h.toString().padStart(2, "0")}:00`;
`${h.toString().padStart(2, "0")}:00`;
const convertWeeklyData = (dataset: ApiRow[]): ChartSeries[] => { const convertWeeklyData = (dataset: ApiRow[]): ChartSeries[] => {
return dataset.map((dayData, index) => ({ return dataset.map((dayData, index) => ({
@@ -40,32 +39,29 @@ const convertWeeklyData = (dataset: ApiRow[]): ChartSeries[] => {
})); }));
}; };
const ActivityHeatmap = ({ data }: ActivityHeatmapProps) => { const ActivityHeatmap = ({ data }: ActivityHeatmapProps) => {
const convertedData = convertWeeklyData(data); const convertedData = convertWeeklyData(data);
const maxValue = Math.max( const maxValue = Math.max(
...convertedData.flatMap(day => ...convertedData.flatMap((day) => day.data.map((point) => point.y)),
day.data.map(point => point.y)
)
); );
return ( return (
<ResponsiveHeatMap <ResponsiveHeatMap
data={convertedData} data={convertedData}
valueFormat=">-.2s" valueFormat=">-.2s"
axisTop={{ tickRotation: -90 }} axisTop={{ tickRotation: -90 }}
axisRight={{ legend: 'Weekday', legendOffset: 70 }} axisRight={{ legend: "Weekday", legendOffset: 70 }}
axisLeft={{ legend: 'Weekday', legendOffset: -72 }} axisLeft={{ legend: "Weekday", legendOffset: -72 }}
colors={{ colors={{
type: 'diverging', type: "diverging",
scheme: 'red_yellow_blue', scheme: "red_yellow_blue",
divergeAt: 0.3, divergeAt: 0.3,
minValue: 0, minValue: 0,
maxValue: maxValue maxValue: maxValue,
}} }}
/> />
) );
} };
export default ActivityHeatmap; export default ActivityHeatmap;

View File

@@ -17,7 +17,7 @@ type Emotion = {
emotion_sadness: number; emotion_sadness: number;
}; };
// User // User
type TopUser = { type TopUser = {
author: string; author: string;
source: string; source: string;
@@ -57,7 +57,7 @@ type UserAnalysisResponse = {
interaction_graph: InteractionGraph; interaction_graph: InteractionGraph;
}; };
// Time // Time
type EventsPerDay = { type EventsPerDay = {
date: Date; date: Date;
count: number; count: number;
@@ -125,7 +125,7 @@ type EmotionalAnalysisResponse = {
emotion_by_source?: EmotionBySource[]; emotion_by_source?: EmotionBySource[];
}; };
// Interactional // Interactional
type ConversationConcentration = { type ConversationConcentration = {
total_commenting_authors: number; total_commenting_authors: number;
top_10pct_author_count: number; top_10pct_author_count: number;
@@ -180,7 +180,7 @@ type CulturalAnalysisResponse = {
avg_emotion_per_entity?: AverageEmotionPerEntity; avg_emotion_per_entity?: AverageEmotionPerEntity;
}; };
// Summary // Summary
type SummaryResponse = { type SummaryResponse = {
total_events: number; total_events: number;
total_posts: number; total_posts: number;
@@ -195,7 +195,7 @@ type SummaryResponse = {
sources: string[]; sources: string[];
}; };
// Filter // Filter
type FilterResponse = { type FilterResponse = {
rows: number; rows: number;
data: any; data: any;

View File

@@ -13,7 +13,7 @@ export const getDocumentTitle = (pathname: string) => {
} }
if (pathname.includes("stats")) { if (pathname.includes("stats")) {
return "Ethnography Analysis" return "Ethnography Analysis";
} }
return STATIC_TITLES[pathname] ?? DEFAULT_TITLE; return STATIC_TITLES[pathname] ?? DEFAULT_TITLE;