Compare commits

..

3 Commits

Author SHA1 Message Date
b6815c490a feat: add loading page for when dataset is loading
Originally there was a simple "Loading" text, however this looked bad and might lead a user to think that the page had frozen.

There is now a more comprehensive loading animation which users might be happy to sit on for a few minutes.
2026-03-04 18:39:20 +00:00
29c90ddfff feat: update name on topbar
Crosspost Analysis Engine sounds far cooler than "Ethnograph View"
2026-03-04 18:37:48 +00:00
3fe08b9c67 fix(backend): buggy reply_time_by_emotion metric
This metric was never stastically significant and held no real value. It also so happened to hold accidental NaN values in the dataframe which broke the frontend.

Happy to remove.
2026-03-04 18:37:11 +00:00
6 changed files with 129 additions and 11 deletions

View File

@@ -82,7 +82,7 @@ const AppLayout = () => {
<div style={{ ...styles.card, ...styles.headerBar }}>
<div style={styles.appHeaderBrandRow}>
<span style={styles.appTitle}>
Ethnograph View
CrossPost Analysis Engine
</span>
<span
style={{

View File

@@ -42,3 +42,24 @@ textarea:focus {
box-shadow: 0 0 0 3px var(--focus-ring);
outline: none;
}
@keyframes stats-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes stats-pulse {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}

View File

@@ -136,7 +136,27 @@ const StatPage = () => {
getStats();
}, [datasetId])
if (loading) return <p style={{...styles.page, minWidth: "100vh", minHeight: "100vh"}}>Loading insights</p>;
if (loading) {
return (
<div style={styles.loadingPage}>
<div style={styles.loadingCard}>
<div style={styles.loadingHeader}>
<div style={styles.loadingSpinner} />
<div>
<h2 style={styles.loadingTitle}>Loading analytics</h2>
<p style={styles.loadingSubtitle}>Fetching summary, timeline, user, and content insights.</p>
</div>
</div>
<div style={styles.loadingSkeleton}>
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineLong }} />
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineMed }} />
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineShort }} />
</div>
</div>
</div>
);
}
if (error) return <p style={{...styles.page}}>{error}</p>;
return (

View File

@@ -2,6 +2,78 @@ import { palette } from "./palette";
import type { StyleMap } from "./types";
export const feedbackStyles: StyleMap = {
loadingPage: {
width: "100%",
minHeight: "100vh",
padding: 20,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
loadingCard: {
width: "min(560px, 92vw)",
background: palette.surface,
border: `1px solid ${palette.borderDefault}`,
borderRadius: 8,
boxShadow: `0 1px 0 ${palette.shadowSubtle}`,
padding: 20,
},
loadingHeader: {
display: "flex",
alignItems: "center",
gap: 12,
},
loadingSpinner: {
width: 18,
height: 18,
borderRadius: "50%",
border: `2px solid ${palette.borderDefault}`,
borderTopColor: palette.brandGreen,
animation: "stats-spin 0.9s linear infinite",
flexShrink: 0,
},
loadingTitle: {
margin: 0,
fontSize: 16,
fontWeight: 600,
color: palette.textPrimary,
},
loadingSubtitle: {
margin: "6px 0 0",
fontSize: 13,
color: palette.textSecondary,
},
loadingSkeleton: {
marginTop: 16,
display: "grid",
gap: 8,
},
loadingSkeletonLine: {
height: 9,
borderRadius: 999,
background: palette.canvas,
animation: "stats-pulse 1.25s ease-in-out infinite",
},
loadingSkeletonLineLong: {
width: "100%",
},
loadingSkeletonLineMed: {
width: "78%",
},
loadingSkeletonLineShort: {
width: "62%",
},
alertCardError: {
borderColor: palette.alertErrorBorder,
background: palette.alertErrorBg,

View File

@@ -55,16 +55,24 @@ type TimeAnalysisResponse = {
}
// Content Analysis
type Emotion = {
emotion_anger: number;
emotion_disgust: number;
emotion_fear: number;
emotion_joy: number;
emotion_sadness: number;
};
type NGram = {
count: number;
ngram: string;
}
type AverageEmotionByTopic = {
topic: string;
type AverageEmotionByTopic = Emotion & {
n: number;
[emotion: string]: string | number;
}
topic: string;
};
type ContentAnalysisResponse = {
word_frequencies: FrequencyWord[];

View File

@@ -96,10 +96,7 @@ class StatGen:
"common_three_phrases": self.linguistic_analysis.ngrams(filtered_df, n=3),
"average_emotion_by_topic": self.emotional_analysis.avg_emotion_by_topic(
filtered_df
),
"reply_time_by_emotion": self.temporal_analysis.avg_reply_time_per_emotion(
filtered_df
),
)
}
def get_user_analysis(self, df: pd.DataFrame, filters: dict | None = None) -> dict: