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 && (
<button
type="button" type="button"
style={location.pathname === "/datasets" ? styles.buttonPrimary : styles.buttonSecondary} style={
location.pathname === "/datasets"
? styles.buttonPrimary
: styles.buttonSecondary
}
onClick={() => navigate("/datasets")} onClick={() => navigate("/datasets")}
> >
My datasets My datasets
</button>} </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 =
typeof identity?.in_group_ratio === "number"
? identity.in_group_ratio * 100 ? identity.in_group_ratio * 100
: null; : null;
const outGroupWordRate = typeof identity?.out_group_ratio === "number" const outGroupWordRate =
typeof identity?.out_group_ratio === "number"
? identity.out_group_ratio * 100 ? identity.out_group_ratio * 100
: null; : 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,23 +24,30 @@ 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 =
typeof concentration?.top_10pct_comment_share === "number"
? concentration?.top_10pct_comment_share ? concentration?.top_10pct_comment_share
: null; : null;
const topTenAuthorCount = typeof concentration?.top_10pct_author_count === "number" const topTenAuthorCount =
typeof concentration?.top_10pct_author_count === "number"
? concentration.top_10pct_author_count ? concentration.top_10pct_author_count
: null; : null;
const totalCommentingAuthors = typeof concentration?.total_commenting_authors === "number" const totalCommentingAuthors =
typeof concentration?.total_commenting_authors === "number"
? concentration.total_commenting_authors ? concentration.total_commenting_authors
: null; : null;
const singleCommentAuthorRatio = typeof concentration?.single_comment_author_ratio === "number" const singleCommentAuthorRatio =
typeof concentration?.single_comment_author_ratio === "number"
? concentration.single_comment_author_ratio ? concentration.single_comment_author_ratio
: null; : null;
const singleCommentAuthors = typeof concentration?.single_comment_authors === "number" const singleCommentAuthors =
typeof concentration?.single_comment_authors === "number"
? concentration.single_comment_authors ? concentration.single_comment_authors
: null; : null;
@@ -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
.slice(0, 8)
.map(([[source, target], value], index) => ({
pair: `${source} -> ${target}`, pair: `${source} -> ${target}`,
replies: value, replies: value,
rank: index + 1, 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
? "-"
: `${topTenSharePercent.toFixed(1)}%`
}
sublabel={
topTenAuthorCount === null || totalCommentingAuthors === null
? "Reply share from the top 10% commenters" ? "Reply share from the top 10% commenters"
: `${topTenAuthorCount.toLocaleString()} of ${totalCommentingAuthors.toLocaleString()} authors`} : `${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
? "-"
: `${(singleCommentAuthorRatio * 100).toFixed(1)}%`
}
sublabel={
singleCommentAuthors === null
? "Authors who commented exactly once" ? "Authors who commented exactly once"
: `${singleCommentAuthors.toLocaleString()} authors 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,11 +6,11 @@ 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";
@@ -21,8 +21,8 @@ import {
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;
@@ -31,7 +31,7 @@ type SummaryStatsProps = {
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);
@@ -51,24 +51,29 @@ 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 = ({
userData,
timeData,
contentData,
summary,
}: SummaryStatsProps) => {
const [selectedUser, setSelectedUser] = useState<string | null>(null); const [selectedUser, setSelectedUser] = useState<string | null>(null);
const selectedUserData: User | null = userData?.users.find((u) => u.author === selectedUser) ?? 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*/} {/* main grid*/}
<div style={{ ...styles.container, ...styles.grid}}> <div style={{ ...styles.container, ...styles.grid }}>
<Card <Card
label="Total Activity" label="Total Activity"
value={summary?.total_events ?? "—"} value={summary?.total_events ?? "—"}
sublabel="Posts + comments" sublabel="Posts + comments"
style={{ style={{
gridColumn: "span 4" gridColumn: "span 4",
}} }}
/> />
<Card <Card
@@ -76,19 +81,17 @@ const SummaryStats = ({userData, timeData, contentData, summary}: SummaryStatsPr
value={summary?.unique_users ?? "—"} value={summary?.unique_users ?? "—"}
sublabel="Distinct users" sublabel="Distinct users"
style={{ style={{
gridColumn: "span 4" gridColumn: "span 4",
}} }}
/> />
<Card <Card
label="Posts vs Comments" label="Posts vs Comments"
value={ value={
summary summary ? `${summary.total_posts} / ${summary.total_comments}` : "—"
? `${summary.total_posts} / ${summary.total_comments}`
: "—"
} }
sublabel={`Comments per post: ${summary?.comments_per_post ?? "—"}`} sublabel={`Comments per post: ${summary?.comments_per_post ?? "—"}`}
style={{ style={{
gridColumn: "span 4" gridColumn: "span 4",
}} }}
/> />
@@ -96,12 +99,15 @@ const SummaryStats = ({userData, timeData, contentData, summary}: SummaryStatsPr
label="Time Range" label="Time Range"
value={ value={
summary?.time_range summary?.time_range
? formatDateRange(summary.time_range.start, summary.time_range.end) ? formatDateRange(
summary.time_range.start,
summary.time_range.end,
)
: "—" : "—"
} }
sublabel="Based on dataset timestamps" sublabel="Based on dataset timestamps"
style={{ style={{
gridColumn: "span 4" gridColumn: "span 4",
}} }}
/> />
@@ -114,7 +120,7 @@ const SummaryStats = ({userData, timeData, contentData, summary}: SummaryStatsPr
} }
sublabel="Users with only one event" sublabel="Users with only one event"
style={{ style={{
gridColumn: "span 4" gridColumn: "span 4",
}} }}
/> />
@@ -128,14 +134,16 @@ const SummaryStats = ({userData, timeData, contentData, summary}: SummaryStatsPr
: "—" : "—"
} }
style={{ style={{
gridColumn: "span 4" 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%">
@@ -153,7 +161,9 @@ const SummaryStats = ({userData, timeData, contentData, summary}: SummaryStatsPr
{/* 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
@@ -169,8 +179,8 @@ const SummaryStats = ({userData, timeData, contentData, summary}: SummaryStatsPr
</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>
@@ -194,7 +204,9 @@ const SummaryStats = ({userData, timeData, contentData, summary}: SummaryStatsPr
{/* 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 ?? []} />
@@ -210,6 +222,6 @@ const SummaryStats = ({userData, timeData, contentData, summary}: SummaryStatsPr
/> />
</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

@@ -3,8 +3,8 @@ 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";
@@ -18,7 +18,7 @@ type GraphLink = {
}; };
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)) {
@@ -28,22 +28,27 @@ function ApiToGraphData(apiData: InteractionGraph) {
} }
// drop low-value and deleted interactions to reduce clutter // drop low-value and deleted interactions to reduce clutter
const filteredLinks = links.filter(link => const filteredLinks = links.filter(
(link) =>
link.value >= 2 && link.value >= 2 &&
link.source !== "[deleted]" && link.source !== "[deleted]" &&
link.target !== "[deleted]" link.target !== "[deleted]",
); );
// also filter out nodes that are no longer connected after link filtering // also filter out nodes that are no longer connected after link filtering
const connectedNodeIds = new Set(filteredLinks.flatMap(link => [link.source, link.target])); const connectedNodeIds = new Set(
const filteredNodes = nodes.filter(node => connectedNodeIds.has(node.id)); filteredLinks.flatMap((link) => [link.source, link.target]),
);
const filteredNodes = nodes.filter((node) => connectedNodeIds.has(node.id));
return { nodes: filteredNodes, links: filteredLinks}; 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,19 +68,31 @@ 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>(
(best, current) => {
if (!best || current.value > best.value) { if (!best || current.value > best.value) {
return current; return current;
} }
return best; return best;
}, null); },
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}>
@@ -101,14 +118,26 @@ const UserStats = (props: { data: UserAnalysisResponse }) => {
<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={
mostActiveUser
? `${mostActiveUser.count.toLocaleString()} events`
: "No user activity found"
}
style={{ gridColumn: "span 3" }} 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
? `${strongestLink.source} -> ${strongestLink.target}`
: "—"
}
sublabel={
strongestLink
? `${strongestLink.value.toLocaleString()} replies`
: "No graph links after filtering"
}
style={{ gridColumn: "span 6" }} style={{ gridColumn: "span 6" }}
/> />
<Card <Card
@@ -127,7 +156,10 @@ const UserStats = (props: { data: UserAnalysisResponse }) => {
<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
ref={graphContainerRef}
style={{ width: "100%", height: graphSize.height }}
>
<ForceGraph3D <ForceGraph3D
width={graphSize.width} width={graphSize.width}
height={graphSize.height} height={graphSize.height}
@@ -143,6 +175,6 @@ const UserStats = (props: { data: UserAnalysisResponse }) => {
</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,19 +253,27 @@ 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);
@@ -258,7 +294,13 @@ const AutoScrapePage = () => {
<select <select
value={source.sourceName} value={source.sourceName}
style={{ ...styles.input, ...styles.inputFullWidth }} style={{ ...styles.input, ...styles.inputFullWidth }}
onChange={(event) => updateSourceConfig(index, "sourceName", event.target.value)} onChange={(event) =>
updateSourceConfig(
index,
"sourceName",
event.target.value,
)
}
> >
{sourceOptions.map((option) => ( {sourceOptions.map((option) => (
<option key={option.id} value={option.id}> <option key={option.id} value={option.id}>
@@ -273,7 +315,9 @@ const AutoScrapePage = () => {
value={source.limit} value={source.limit}
placeholder="Limit" placeholder="Limit"
style={{ ...styles.input, ...styles.inputFullWidth }} style={{ ...styles.input, ...styles.inputFullWidth }}
onChange={(event) => updateSourceConfig(index, "limit", event.target.value)} onChange={(event) =>
updateSourceConfig(index, "limit", event.target.value)
}
/> />
<input <input
@@ -286,7 +330,13 @@ const AutoScrapePage = () => {
} }
style={{ ...styles.input, ...styles.inputFullWidth }} style={{ ...styles.input, ...styles.inputFullWidth }}
disabled={!searchEnabled} disabled={!searchEnabled}
onChange={(event) => updateSourceConfig(index, "search", event.target.value)} onChange={(event) =>
updateSourceConfig(
index,
"search",
event.target.value,
)
}
/> />
<input <input
@@ -299,7 +349,13 @@ const AutoScrapePage = () => {
} }
style={{ ...styles.input, ...styles.inputFullWidth }} style={{ ...styles.input, ...styles.inputFullWidth }}
disabled={!categoriesEnabled} disabled={!categoriesEnabled}
onChange={(event) => updateSourceConfig(index, "category", event.target.value)} onChange={(event) =>
updateSourceConfig(
index,
"category",
event.target.value,
)
}
/> />
{sourceConfigs.length > 1 && ( {sourceConfigs.length > 1 && (
@@ -315,7 +371,11 @@ const AutoScrapePage = () => {
); );
})} })}
<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>
@@ -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.");
@@ -117,7 +125,11 @@ const LoginPage = () => {
<button <button
type="submit" type="submit"
style={{ ...styles.buttonPrimary, ...styles.authControl, marginTop: 2 }} style={{
...styles.buttonPrimary,
...styles.authControl,
marginTop: 2,
}}
disabled={loading} disabled={loading}
> >
{loading {loading
@@ -128,17 +140,9 @@ const LoginPage = () => {
</button> </button>
</form> </form>
{error && ( {error && <p style={styles.authErrorText}>{error}</p>}
<p style={styles.authErrorText}>
{error}
</p>
)}
{info && ( {info && <p style={styles.authInfoText}>{info}</p>}
<p style={styles.authInfoText}>
{info}
</p>
)}
<div style={styles.authSwitchRow}> <div style={styles.authSwitchRow}>
<span style={styles.authSwitchLabel}> <span style={styles.authSwitchLabel}>

View File

@@ -18,38 +18,50 @@ import {
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,39 +111,70 @@ const StatPage = () => {
setLoading(true); setLoading(true);
Promise.all([ Promise.all([
axios.get<TimeAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/temporal`, { axios.get<TimeAnalysisResponse>(
`${API_BASE_URL}/dataset/${datasetId}/temporal`,
{
params, params,
headers: authHeaders, headers: authHeaders,
}), },
axios.get<UserEndpointResponse>(`${API_BASE_URL}/dataset/${datasetId}/user`, { ),
axios.get<UserEndpointResponse>(
`${API_BASE_URL}/dataset/${datasetId}/user`,
{
params, params,
headers: authHeaders, headers: authHeaders,
}), },
axios.get<LinguisticAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/linguistic`, { ),
axios.get<LinguisticAnalysisResponse>(
`${API_BASE_URL}/dataset/${datasetId}/linguistic`,
{
params, params,
headers: authHeaders, headers: authHeaders,
}), },
axios.get<EmotionalAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/emotional`, { ),
axios.get<EmotionalAnalysisResponse>(
`${API_BASE_URL}/dataset/${datasetId}/emotional`,
{
params, params,
headers: authHeaders, headers: authHeaders,
}), },
axios.get<InteractionAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/interactional`, { ),
axios.get<InteractionAnalysisResponse>(
`${API_BASE_URL}/dataset/${datasetId}/interactional`,
{
params, params,
headers: authHeaders, headers: authHeaders,
}), },
axios.get<SummaryResponse>(`${API_BASE_URL}/dataset/${datasetId}/summary`, { ),
axios.get<SummaryResponse>(
`${API_BASE_URL}/dataset/${datasetId}/summary`,
{
params, params,
headers: authHeaders, headers: authHeaders,
}), },
axios.get<CulturalAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/cultural`, { ),
axios.get<CulturalAnalysisResponse>(
`${API_BASE_URL}/dataset/${datasetId}/cultural`,
{
params, params,
headers: authHeaders, headers: authHeaders,
}), },
),
]) ])
.then(([timeRes, userRes, linguisticRes, emotionalRes, interactionRes, summaryRes, culturalRes]) => { .then(
([
timeRes,
userRes,
linguisticRes,
emotionalRes,
interactionRes,
summaryRes,
culturalRes,
]) => {
const usersList = userRes.data.users ?? []; const usersList = userRes.data.users ?? [];
const topUsersList = userRes.data.top_users ?? []; const topUsersList = userRes.data.top_users ?? [];
const interactionGraphRaw = interactionRes.data?.interaction_graph ?? {}; const interactionGraphRaw =
interactionRes.data?.interaction_graph ?? {};
const topPairsRaw = interactionRes.data?.top_interaction_pairs ?? []; const topPairsRaw = interactionRes.data?.top_interaction_pairs ?? [];
const filteredUsers: typeof usersList = []; const filteredUsers: typeof usersList = [];
@@ -146,7 +189,10 @@ const StatPage = () => {
filteredTopUsers.push(user); filteredTopUsers.push(user);
} }
const filteredInteractionGraph: Record<string, Record<string, number>> = {}; const filteredInteractionGraph: Record<
string,
Record<string, number>
> = {};
for (const [source, targets] of Object.entries(interactionGraphRaw)) { for (const [source, targets] of Object.entries(interactionGraphRaw)) {
if (isDeletedUser(source)) { if (isDeletedUser(source)) {
continue; continue;
@@ -204,7 +250,8 @@ const StatPage = () => {
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,22 +290,39 @@ 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}>
@@ -297,41 +361,71 @@ return (
<div style={styles.dashboardMeta}>Dataset #{datasetId ?? "-"}</div> <div style={styles.dashboardMeta}>Dataset #{datasetId ?? "-"}</div>
</div> </div>
<div style={{ ...styles.container, ...styles.tabsRow, justifyContent: "center" }}> <div
style={{
...styles.container,
...styles.tabsRow,
justifyContent: "center",
}}
>
<button <button
onClick={() => setActiveView("summary")} onClick={() => setActiveView("summary")}
style={activeView === "summary" ? styles.buttonPrimary : styles.buttonSecondary} style={
activeView === "summary"
? styles.buttonPrimary
: styles.buttonSecondary
}
> >
Summary Summary
</button> </button>
<button <button
onClick={() => setActiveView("emotional")} onClick={() => setActiveView("emotional")}
style={activeView === "emotional" ? styles.buttonPrimary : styles.buttonSecondary} style={
activeView === "emotional"
? styles.buttonPrimary
: styles.buttonSecondary
}
> >
Emotional Emotional
</button> </button>
<button <button
onClick={() => setActiveView("user")} onClick={() => setActiveView("user")}
style={activeView === "user" ? styles.buttonPrimary : styles.buttonSecondary} style={
activeView === "user"
? styles.buttonPrimary
: styles.buttonSecondary
}
> >
Users Users
</button> </button>
<button <button
onClick={() => setActiveView("linguistic")} onClick={() => setActiveView("linguistic")}
style={activeView === "linguistic" ? styles.buttonPrimary : styles.buttonSecondary} style={
activeView === "linguistic"
? styles.buttonPrimary
: styles.buttonSecondary
}
> >
Linguistic Linguistic
</button> </button>
<button <button
onClick={() => setActiveView("interactional")} onClick={() => setActiveView("interactional")}
style={activeView === "interactional" ? styles.buttonPrimary : styles.buttonSecondary} style={
activeView === "interactional"
? styles.buttonPrimary
: styles.buttonSecondary
}
> >
Interactional Interactional
</button> </button>
<button <button
onClick={() => setActiveView("cultural")} onClick={() => setActiveView("cultural")}
style={activeView === "cultural" ? styles.buttonPrimary : styles.buttonSecondary} style={
activeView === "cultural"
? styles.buttonPrimary
: styles.buttonSecondary
}
> >
Cultural Cultural
</button> </button>
@@ -356,9 +450,7 @@ return (
</div> </div>
)} )}
{activeView === "user" && userData && ( {activeView === "user" && userData && <UserStats data={userData} />}
<UserStats data={userData} />
)}
{activeView === "linguistic" && linguisticData && ( {activeView === "linguistic" && linguisticData && (
<LinguisticStats data={linguisticData} /> <LinguisticStats data={linguisticData} />
@@ -389,9 +481,8 @@ return (
No cultural data available. No cultural data available.
</div> </div>
)} )}
</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(
`${API_BASE_URL}/datasets/upload`,
formData,
{
headers: { headers: {
"Content-Type": "multipart/form-data", "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,14 +39,11 @@ 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 (
@@ -55,17 +51,17 @@ const ActivityHeatmap = ({ data }: ActivityHeatmapProps) => {
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

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