Compare commits

...

5 Commits

Author SHA1 Message Date
6948891677 Merge remote-tracking branch 'origin/main' into feat/editable-datasets 2026-03-04 21:30:13 +00:00
e20d0689e8 fix(celery): adjust try-catch logic to improve error handling
Capturing the instantiation of the database and dataset manager objects inside the try-catch will cause errors if something else fails.

If an exception occurs and the dataset_manager is not initialised, the code inside the catch block will fail.
2026-03-04 21:18:59 +00:00
fcdac6f3bb Merge pull request 'Fix the frontend API calls and implement logins on frontend' (#7) from feat/update-frontend-api-calls into main
Reviewed-on: #7
2026-03-04 20:20:50 +00:00
5fc1f1532f feat(user stats): updated styling and stats in user page
Interaction graph was taking up too much space and was the only thing on the screen. Further statistics were added however these may be removed in favour of more informative statistics
2026-03-04 20:20:34 +00:00
24277e0104 fix(frontend): move loading card higher up
Looks weird lower down on the screen
2026-03-04 20:09:55 +00:00
3 changed files with 113 additions and 28 deletions

View File

@@ -1,3 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import ForceGraph3D from "react-force-graph-3d"; import ForceGraph3D from "react-force-graph-3d";
import { import {
@@ -6,12 +7,19 @@ import {
} from '../types/ApiTypes'; } from '../types/ApiTypes';
import StatsStyling from "../styles/stats_styling"; import StatsStyling from "../styles/stats_styling";
import Card from "./Card";
const styles = StatsStyling; const styles = StatsStyling;
type GraphLink = {
source: string;
target: string;
value: number;
};
function ApiToGraphData(apiData: InteractionGraph) { function ApiToGraphData(apiData: InteractionGraph) {
const nodes = Object.keys(apiData).map(username => ({ id: username })); const nodes = Object.keys(apiData).map(username => ({ id: username }));
const links = []; const links: GraphLink[] = [];
for (const [source, targets] of Object.entries(apiData)) { for (const [source, targets] of Object.entries(apiData)) {
for (const [target, count] of Object.entries(targets)) { for (const [target, count] of Object.entries(targets)) {
@@ -35,27 +43,106 @@ function ApiToGraphData(apiData: InteractionGraph) {
const UserStats = (props: { data: UserAnalysisResponse }) => { const UserStats = (props: { data: UserAnalysisResponse }) => {
const graphData = ApiToGraphData(props.data.interaction_graph); const graphData = useMemo(() => ApiToGraphData(props.data.interaction_graph), [props.data.interaction_graph]);
const graphContainerRef = useRef<HTMLDivElement | null>(null);
const [graphSize, setGraphSize] = useState({ width: 720, height: 540 });
useEffect(() => {
const updateGraphSize = () => {
const containerWidth = graphContainerRef.current?.clientWidth ?? 720;
const nextWidth = Math.max(320, Math.floor(containerWidth));
const nextHeight = nextWidth < 700 ? 300 : 540;
setGraphSize({ width: nextWidth, height: nextHeight });
};
updateGraphSize();
window.addEventListener("resize", updateGraphSize);
return () => window.removeEventListener("resize", updateGraphSize);
}, []);
const totalUsers = props.data.users.length;
const connectedUsers = graphData.nodes.length;
const totalInteractions = graphData.links.reduce((sum, link) => sum + link.value, 0);
const avgInteractionsPerConnectedUser = connectedUsers ? totalInteractions / connectedUsers : 0;
const strongestLink = graphData.links.reduce<GraphLink | null>((best, current) => {
if (!best || current.value > best.value) {
return current;
}
return best;
}, null);
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]");
return ( return (
<div style={styles.page}> <div style={styles.page}>
<h2 style={styles.sectionTitle}>User Interaction Graph</h2> <div style={{ ...styles.container, ...styles.grid }}>
<p style={styles.sectionSubtitle}> <Card
This graph visualizes interactions between users based on comments and replies. label="Users"
Nodes represent users, and edges represent interactions (e.g., comments or replies) between them. value={totalUsers.toLocaleString()}
</p> sublabel={`${connectedUsers.toLocaleString()} users in filtered graph`}
<div> style={{ gridColumn: "span 3" }}
<ForceGraph3D />
<Card
label="Interactions"
value={totalInteractions.toLocaleString()}
sublabel="Filtered links (2+ interactions)"
style={{ gridColumn: "span 3" }}
/>
<Card
label="Average Intensity"
value={avgInteractionsPerConnectedUser.toFixed(1)}
sublabel="Interactions per connected user"
style={{ gridColumn: "span 3" }}
/>
<Card
label="Most Active User"
value={mostActiveUser?.author ?? "—"}
sublabel={mostActiveUser ? `${mostActiveUser.count.toLocaleString()} events` : "No user activity found"}
style={{ gridColumn: "span 3" }}
/>
<Card
label="Strongest Connection"
value={strongestLink ? `${strongestLink.source} -> ${strongestLink.target}` : "—"}
sublabel={strongestLink ? `${strongestLink.value.toLocaleString()} interactions` : "No graph edges after filtering"}
style={{ gridColumn: "span 6" }}
/>
<Card
label="Most Reply-Driven User"
value={highlyInteractiveUser?.author ?? "—"}
sublabel={
highlyInteractiveUser
? `${Math.round(highlyInteractiveUser.comment_share * 100)}% comments`
: "No user distribution available"
}
style={{ gridColumn: "span 6" }}
/>
<div style={{ ...styles.card, gridColumn: "span 12" }}>
<h2 style={styles.sectionTitle}>User Interaction Graph</h2>
<p style={styles.sectionSubtitle}>
Nodes represent users and links represent conversation interactions.
</p>
<div ref={graphContainerRef} style={{ width: "100%", height: graphSize.height }}>
<ForceGraph3D
width={graphSize.width}
height={graphSize.height}
graphData={graphData} graphData={graphData}
nodeAutoColorBy="id" nodeAutoColorBy="id"
linkDirectionalParticles={2} linkDirectionalParticles={1}
linkDirectionalParticleSpeed={0.005} linkDirectionalParticleSpeed={0.004}
linkWidth={(link) => Math.sqrt(link.value)} linkWidth={(link) => Math.sqrt(Number(link.value))}
nodeLabel={(node) => `${node.id}`} nodeLabel={(node) => `${node.id}`}
/> />
</div>
</div>
</div> </div>
</div> </div>
); );
} }
export default UserStats; export default UserStats;

View File

@@ -4,7 +4,7 @@ import { useParams } from "react-router-dom";
import StatsStyling from "../styles/stats_styling"; import StatsStyling from "../styles/stats_styling";
import SummaryStats from "../components/SummaryStats"; import SummaryStats from "../components/SummaryStats";
import EmotionalStats from "../components/EmotionalStats"; import EmotionalStats from "../components/EmotionalStats";
import InteractionStats from "../components/UserStats"; import UserStats from "../components/UserStats";
import { import {
type SummaryResponse, type SummaryResponse,
@@ -20,7 +20,7 @@ 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" | "interaction">("summary"); const [activeView, setActiveView] = useState<"summary" | "emotional" | "user">("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);
@@ -139,7 +139,7 @@ const StatPage = () => {
if (loading) { if (loading) {
return ( return (
<div style={styles.loadingPage}> <div style={styles.loadingPage}>
<div style={styles.loadingCard}> <div style={{ ...styles.loadingCard, transform: "translateY(-100px)" }}>
<div style={styles.loadingHeader}> <div style={styles.loadingHeader}>
<div style={styles.loadingSpinner} /> <div style={styles.loadingSpinner} />
<div> <div>
@@ -213,10 +213,10 @@ return (
</button> </button>
<button <button
onClick={() => setActiveView("interaction")} onClick={() => setActiveView("user")}
style={activeView === "interaction" ? styles.buttonPrimary : styles.buttonSecondary} style={activeView === "user" ? styles.buttonPrimary : styles.buttonSecondary}
> >
Interaction Users
</button> </button>
</div> </div>
@@ -239,8 +239,8 @@ return (
</div> </div>
)} )}
{activeView === "interaction" && userData && ( {activeView === "user" && userData && (
<InteractionStats data={userData} /> <UserStats data={userData} />
)} )}
</div> </div>

View File

@@ -2,17 +2,15 @@ import pandas as pd
from server.queue.celery_app import celery from server.queue.celery_app import celery
from server.analysis.enrichment import DatasetEnrichment from server.analysis.enrichment import DatasetEnrichment
from server.db.database import PostgresConnector
from server.core.datasets import DatasetManager
@celery.task(bind=True, max_retries=3) @celery.task(bind=True, max_retries=3)
def process_dataset(self, dataset_id: int, posts: list, topics: dict): def process_dataset(self, dataset_id: int, posts: list, topics: dict):
db = PostgresConnector()
dataset_manager = DatasetManager(db)
try: try:
from server.db.database import PostgresConnector
from server.core.datasets import DatasetManager
db = PostgresConnector()
dataset_manager = DatasetManager(db)
df = pd.DataFrame(posts) df = pd.DataFrame(posts)
processor = DatasetEnrichment(df, topics) processor = DatasetEnrichment(df, topics)