Yassine Mhirsi commited on
Commit ·
78b3b2b
1
Parent(s): 21d3506
enhance analysis page
Browse files- src/app/components/analysis/UploadCsvModal.tsx +140 -0
- src/app/pages/AnalysisPage.tsx +126 -244
- src/app/utils/cache.utils.ts +33 -0
src/app/components/analysis/UploadCsvModal.tsx
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useMemo, useState } from 'react';
|
| 2 |
+
import { parseCsv } from '../../utils/index.ts';
|
| 3 |
+
|
| 4 |
+
import type { CsvRow } from '../../types/index.ts';
|
| 5 |
+
|
| 6 |
+
interface UploadCsvModalProps {
|
| 7 |
+
isOpen: boolean;
|
| 8 |
+
onClose: () => void;
|
| 9 |
+
onAnalyze: (file: File) => Promise<void>;
|
| 10 |
+
isAnalyzing: boolean;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const UploadCsvModal = ({
|
| 14 |
+
isOpen,
|
| 15 |
+
onClose,
|
| 16 |
+
onAnalyze,
|
| 17 |
+
isAnalyzing
|
| 18 |
+
}: UploadCsvModalProps) => {
|
| 19 |
+
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
| 20 |
+
const [rows, setRows] = useState<CsvRow[]>([]);
|
| 21 |
+
const [error, setError] = useState<string | null>(null);
|
| 22 |
+
|
| 23 |
+
const fileName = useMemo(
|
| 24 |
+
() => selectedFile?.name ?? 'Select a CSV file with arguments',
|
| 25 |
+
[selectedFile]
|
| 26 |
+
);
|
| 27 |
+
|
| 28 |
+
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 29 |
+
const file = event.target.files?.[0] ?? null;
|
| 30 |
+
setSelectedFile(file);
|
| 31 |
+
setError(null);
|
| 32 |
+
setRows([]);
|
| 33 |
+
|
| 34 |
+
if (!file) {
|
| 35 |
+
return;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// Parse CSV
|
| 39 |
+
try {
|
| 40 |
+
const content = await file.text();
|
| 41 |
+
const parsed = parseCsv(content);
|
| 42 |
+
|
| 43 |
+
setRows(parsed.rows);
|
| 44 |
+
|
| 45 |
+
if (parsed.rows.length === 0) {
|
| 46 |
+
setError('No data found in the CSV file.');
|
| 47 |
+
}
|
| 48 |
+
} catch (err) {
|
| 49 |
+
setError(
|
| 50 |
+
err instanceof Error ? err.message : 'Unable to read the CSV file.'
|
| 51 |
+
);
|
| 52 |
+
setRows([]);
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
const handleAnalyze = async () => {
|
| 57 |
+
if (!selectedFile) {
|
| 58 |
+
setError('Please choose a CSV file to analyze.');
|
| 59 |
+
return;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
if (rows.length === 0) {
|
| 63 |
+
setError('No data found in the CSV file.');
|
| 64 |
+
return;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
await onAnalyze(selectedFile);
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
if (!isOpen) return null;
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 dark:bg-black/70">
|
| 74 |
+
<div
|
| 75 |
+
className="relative w-full max-w-2xl max-h-[90vh] overflow-hidden rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-xl"
|
| 76 |
+
onClick={(e) => e.stopPropagation()}
|
| 77 |
+
>
|
| 78 |
+
<div className="flex items-center justify-between border-b border-slate-200 dark:border-zinc-700 px-6 py-4">
|
| 79 |
+
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">
|
| 80 |
+
Analyze New Arguments
|
| 81 |
+
</h2>
|
| 82 |
+
<button
|
| 83 |
+
onClick={onClose}
|
| 84 |
+
className="text-slate-500 dark:text-zinc-400 hover:text-slate-700 dark:hover:text-zinc-200 transition-colors"
|
| 85 |
+
aria-label="Close"
|
| 86 |
+
>
|
| 87 |
+
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
| 88 |
+
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
| 89 |
+
</svg>
|
| 90 |
+
</button>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-140px)]">
|
| 94 |
+
<div className="flex flex-col gap-3">
|
| 95 |
+
<label className="text-sm font-medium text-slate-700 dark:text-zinc-300">
|
| 96 |
+
Upload a CSV file
|
| 97 |
+
</label>
|
| 98 |
+
|
| 99 |
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
| 100 |
+
<label className="flex w-full max-w-xl cursor-pointer items-center justify-between rounded-full border border-slate-300 dark:border-zinc-600 bg-slate-50 dark:bg-zinc-800 px-5 py-3 transition hover:border-slate-400 dark:hover:border-zinc-500">
|
| 101 |
+
<span className="truncate text-sm font-semibold text-slate-500 dark:text-zinc-400">
|
| 102 |
+
{fileName}
|
| 103 |
+
</span>
|
| 104 |
+
<input
|
| 105 |
+
type="file"
|
| 106 |
+
accept=".csv,text/csv"
|
| 107 |
+
className="hidden"
|
| 108 |
+
onChange={handleFileChange}
|
| 109 |
+
/>
|
| 110 |
+
</label>
|
| 111 |
+
|
| 112 |
+
<div className="flex w-full justify-center">
|
| 113 |
+
<button
|
| 114 |
+
type="button"
|
| 115 |
+
onClick={handleAnalyze}
|
| 116 |
+
disabled={!selectedFile || rows.length === 0 || isAnalyzing}
|
| 117 |
+
className="rounded-md bg-blue-600 dark:bg-blue-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 dark:hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-60"
|
| 118 |
+
>
|
| 119 |
+
{isAnalyzing ? 'Analyzing...' : 'Analyze'}
|
| 120 |
+
</button>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
{isAnalyzing && (
|
| 127 |
+
<div className="flex items-center justify-center py-8">
|
| 128 |
+
<div className="flex flex-col items-center">
|
| 129 |
+
<div className="h-10 w-10 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"></div>
|
| 130 |
+
<p className="mt-3 text-sm text-slate-600 dark:text-zinc-400">Analyzing arguments...</p>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
)}
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
);
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
export default UploadCsvModal;
|
src/app/pages/AnalysisPage.tsx
CHANGED
|
@@ -1,40 +1,33 @@
|
|
| 1 |
import React, { useMemo, useState, useEffect } from 'react';
|
| 2 |
-
import {
|
| 3 |
-
import { CsvRow } from '../types/index.ts';
|
| 4 |
import { AnalysisResult } from '../types/analysis.types.ts';
|
| 5 |
import { analyzeArgumentsFromCsv, getAnalysisResults } from '../services/analysis.service.ts';
|
| 6 |
import {
|
| 7 |
calculateStanceStats,
|
| 8 |
calculateTopicFrequency,
|
| 9 |
-
calculateTimeStats,
|
| 10 |
getUniqueTopicsCount,
|
| 11 |
getAverageArgumentLength,
|
| 12 |
-
getMostRecentAnalysisDate,
|
| 13 |
-
getOldestAnalysisDate,
|
| 14 |
} from '../utils/analysis.utils.ts';
|
|
|
|
| 15 |
import StanceDistributionChart from '../components/analysis/StanceDistributionChart.tsx';
|
| 16 |
import TopicFrequencyChart from '../components/analysis/TopicFrequencyChart.tsx';
|
| 17 |
-
import TimeSeriesChart from '../components/analysis/TimeSeriesChart.tsx';
|
| 18 |
import Loading from '../components/common/Loading.tsx';
|
|
|
|
| 19 |
|
| 20 |
const AnalysisPage = () => {
|
| 21 |
-
const [selectedFile, setSelectedFile] = useState(null as File | null);
|
| 22 |
-
const [headers, setHeaders] = useState(['id', 'argument'] as string[]);
|
| 23 |
-
const [rows, setRows] = useState([] as CsvRow[]);
|
| 24 |
-
const [error, setError] = useState(null as string | null);
|
| 25 |
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
| 26 |
-
const [analysisResults, setAnalysisResults] = useState
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
// User's historical analysis data
|
| 29 |
const [userAnalysisData, setUserAnalysisData] = useState([] as AnalysisResult[]);
|
| 30 |
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
| 31 |
const [isRefreshingStats, setIsRefreshingStats] = useState(false);
|
| 32 |
const [statsError, setStatsError] = useState(null as string | null);
|
| 33 |
|
| 34 |
-
const fileName = useMemo(
|
| 35 |
-
() => selectedFile?.name ?? 'Select a CSV file with arguments',
|
| 36 |
-
[selectedFile]
|
| 37 |
-
);
|
| 38 |
|
| 39 |
// Fetch user's analysis data on mount with cache support
|
| 40 |
useEffect(() => {
|
|
@@ -71,6 +64,17 @@ const AnalysisPage = () => {
|
|
| 71 |
fetchUserAnalysis();
|
| 72 |
}, []);
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
// Calculate statistics from user's analysis data
|
| 75 |
const stanceStats = useMemo(
|
| 76 |
() => calculateStanceStats(userAnalysisData),
|
|
@@ -80,10 +84,6 @@ const AnalysisPage = () => {
|
|
| 80 |
() => calculateTopicFrequency(userAnalysisData, 10),
|
| 81 |
[userAnalysisData]
|
| 82 |
);
|
| 83 |
-
const timeStats = useMemo(
|
| 84 |
-
() => calculateTimeStats(userAnalysisData),
|
| 85 |
-
[userAnalysisData]
|
| 86 |
-
);
|
| 87 |
const uniqueTopicsCount = useMemo(
|
| 88 |
() => getUniqueTopicsCount(userAnalysisData),
|
| 89 |
[userAnalysisData]
|
|
@@ -92,69 +92,35 @@ const AnalysisPage = () => {
|
|
| 92 |
() => getAverageArgumentLength(userAnalysisData),
|
| 93 |
[userAnalysisData]
|
| 94 |
);
|
| 95 |
-
const mostRecentDate = useMemo(
|
| 96 |
-
() => getMostRecentAnalysisDate(userAnalysisData),
|
| 97 |
-
[userAnalysisData]
|
| 98 |
-
);
|
| 99 |
-
const oldestDate = useMemo(
|
| 100 |
-
() => getOldestAnalysisDate(userAnalysisData),
|
| 101 |
-
[userAnalysisData]
|
| 102 |
-
);
|
| 103 |
-
|
| 104 |
-
const handleFileChange = async (event: any) => {
|
| 105 |
-
const file = event.target.files?.[0] ?? null;
|
| 106 |
-
setSelectedFile(file);
|
| 107 |
-
setError(null);
|
| 108 |
-
setAnalysisResults([]);
|
| 109 |
-
|
| 110 |
-
if (!file) {
|
| 111 |
-
setHeaders(['id', 'argument']);
|
| 112 |
-
setRows([]);
|
| 113 |
-
return;
|
| 114 |
-
}
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
const content = await file.text();
|
| 119 |
-
const parsed = parseCsv(content);
|
| 120 |
-
|
| 121 |
-
setHeaders(parsed.headers.length > 0 ? parsed.headers : ['id', 'argument']);
|
| 122 |
-
setRows(parsed.rows);
|
| 123 |
-
|
| 124 |
-
if (parsed.rows.length === 0) {
|
| 125 |
-
setError('No data found in the CSV file.');
|
| 126 |
-
}
|
| 127 |
-
} catch (err) {
|
| 128 |
-
setError(
|
| 129 |
-
err instanceof Error ? err.message : 'Unable to read the CSV file.'
|
| 130 |
-
);
|
| 131 |
-
setHeaders(['id', 'argument']);
|
| 132 |
-
setRows([]);
|
| 133 |
-
}
|
| 134 |
};
|
| 135 |
|
| 136 |
-
const
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
return;
|
| 140 |
-
}
|
| 141 |
|
|
|
|
| 142 |
setIsAnalyzing(true);
|
| 143 |
-
setError(null);
|
| 144 |
-
setAnalysisResults([]);
|
| 145 |
|
| 146 |
try {
|
| 147 |
-
const response = await analyzeArgumentsFromCsv(
|
| 148 |
setAnalysisResults(response.results);
|
|
|
|
|
|
|
| 149 |
// Refresh user analysis data after successful analysis
|
| 150 |
const updatedResponse = await getAnalysisResults(1000, 0);
|
| 151 |
setUserAnalysisData(updatedResponse.results);
|
| 152 |
// Cache the updated data
|
| 153 |
cacheAnalysisData(updatedResponse.results);
|
| 154 |
} catch (err) {
|
| 155 |
-
|
|
|
|
|
|
|
| 156 |
} finally {
|
| 157 |
setIsAnalyzing(false);
|
|
|
|
| 158 |
}
|
| 159 |
};
|
| 160 |
|
|
@@ -173,12 +139,20 @@ const AnalysisPage = () => {
|
|
| 173 |
Overview of all your analyzed arguments
|
| 174 |
</p>
|
| 175 |
</div>
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
<div className="
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
</div>
|
| 183 |
</div>
|
| 184 |
|
|
@@ -227,7 +201,7 @@ const AnalysisPage = () => {
|
|
| 227 |
</div>
|
| 228 |
|
| 229 |
{/* Charts Grid */}
|
| 230 |
-
<div className="grid grid-cols-1
|
| 231 |
{/* Stance Distribution */}
|
| 232 |
<div className="rounded-lg border border-slate-200 dark:border-zinc-700 p-4">
|
| 233 |
<h3 className="text-sm font-semibold text-slate-700 dark:text-zinc-300 mb-4">
|
|
@@ -247,21 +221,6 @@ const AnalysisPage = () => {
|
|
| 247 |
</div>
|
| 248 |
</div>
|
| 249 |
</div>
|
| 250 |
-
|
| 251 |
-
{/* Time Series */}
|
| 252 |
-
{timeStats.length > 0 && (
|
| 253 |
-
<div className="rounded-lg border border-slate-200 dark:border-zinc-700 p-4">
|
| 254 |
-
<h3 className="text-sm font-semibold text-slate-700 dark:text-zinc-300 mb-4">
|
| 255 |
-
Analysis Timeline
|
| 256 |
-
</h3>
|
| 257 |
-
<TimeSeriesChart data={timeStats} />
|
| 258 |
-
{oldestDate && mostRecentDate && (
|
| 259 |
-
<div className="mt-4 text-center text-xs text-slate-500 dark:text-zinc-400">
|
| 260 |
-
From {oldestDate} to {mostRecentDate}
|
| 261 |
-
</div>
|
| 262 |
-
)}
|
| 263 |
-
</div>
|
| 264 |
-
)}
|
| 265 |
</div>
|
| 266 |
|
| 267 |
{/* Topic Frequency */}
|
|
@@ -273,167 +232,90 @@ const AnalysisPage = () => {
|
|
| 273 |
<TopicFrequencyChart data={topicFrequency} />
|
| 274 |
</div>
|
| 275 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
</div>
|
| 277 |
)}
|
| 278 |
</div>
|
| 279 |
|
| 280 |
{/* CSV Upload and Analysis Section */}
|
| 281 |
-
<div className="rounded-lg border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-sm">
|
| 282 |
-
<div className="border-b border-slate-200 dark:border-zinc-700 px-6 py-4">
|
| 283 |
-
<h1 className="text-lg font-semibold text-slate-800 dark:text-white">
|
| 284 |
-
Analyze New Arguments
|
| 285 |
-
</h1>
|
| 286 |
-
<p className="text-sm text-slate-500 dark:text-zinc-400">
|
| 287 |
-
Upload a CSV file containing arguments to analyze them.
|
| 288 |
-
</p>
|
| 289 |
-
</div>
|
| 290 |
-
|
| 291 |
-
<div className="space-y-6 p-6">
|
| 292 |
-
<div className="flex flex-col gap-3">
|
| 293 |
-
<label className="text-sm font-medium text-slate-700 dark:text-zinc-300">
|
| 294 |
-
Upload a CSV file
|
| 295 |
-
</label>
|
| 296 |
-
|
| 297 |
-
<div className="flex flex-wrap items-center gap-3">
|
| 298 |
-
<label className="flex w-full max-w-xl cursor-pointer items-center justify-between rounded-full border border-slate-300 dark:border-zinc-600 bg-slate-50 dark:bg-zinc-800 px-5 py-3 transition hover:border-slate-400 dark:hover:border-zinc-500">
|
| 299 |
-
<span className="truncate text-sm font-semibold text-slate-500 dark:text-zinc-400">
|
| 300 |
-
{fileName}
|
| 301 |
-
</span>
|
| 302 |
-
<input
|
| 303 |
-
type="file"
|
| 304 |
-
accept=".csv,text/csv"
|
| 305 |
-
className="hidden"
|
| 306 |
-
onChange={handleFileChange}
|
| 307 |
-
/>
|
| 308 |
-
</label>
|
| 309 |
-
|
| 310 |
-
<button
|
| 311 |
-
type="button"
|
| 312 |
-
onClick={handleAnalyze}
|
| 313 |
-
disabled={!selectedFile || rows.length === 0 || isAnalyzing}
|
| 314 |
-
className="rounded-md bg-blue-600 dark:bg-blue-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 dark:hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-60"
|
| 315 |
-
>
|
| 316 |
-
{isAnalyzing ? 'Analyzing...' : 'Analyze'}
|
| 317 |
-
</button>
|
| 318 |
-
</div>
|
| 319 |
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
</div>
|
| 330 |
-
<table className="min-w-full divide-y divide-slate-200 dark:divide-zinc-700">
|
| 331 |
-
<thead className="bg-slate-50 dark:bg-zinc-800">
|
| 332 |
-
<tr>
|
| 333 |
-
{headers.map((header) => (
|
| 334 |
-
<th
|
| 335 |
-
key={header}
|
| 336 |
-
scope="col"
|
| 337 |
-
className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600 dark:text-zinc-300"
|
| 338 |
-
>
|
| 339 |
-
{header}
|
| 340 |
-
</th>
|
| 341 |
-
))}
|
| 342 |
-
</tr>
|
| 343 |
-
</thead>
|
| 344 |
-
<tbody className="divide-y divide-slate-200 dark:divide-zinc-700 bg-white dark:bg-zinc-900">
|
| 345 |
-
{rows.map((row, index) => (
|
| 346 |
-
<tr key={row.id ?? `row-${index}`} className="hover:bg-slate-50 dark:hover:bg-zinc-800">
|
| 347 |
-
{headers.map((header) => (
|
| 348 |
-
<td
|
| 349 |
-
key={`${header}-${index}`}
|
| 350 |
-
className="whitespace-pre-wrap px-4 py-3 text-sm text-slate-700 dark:text-zinc-300"
|
| 351 |
-
>
|
| 352 |
-
{row[header] ?? ''}
|
| 353 |
-
</td>
|
| 354 |
-
))}
|
| 355 |
-
</tr>
|
| 356 |
-
))}
|
| 357 |
-
</tbody>
|
| 358 |
-
</table>
|
| 359 |
-
</div>
|
| 360 |
-
)}
|
| 361 |
-
|
| 362 |
-
{isAnalyzing && (
|
| 363 |
-
<div className="flex items-center justify-center py-12">
|
| 364 |
-
<Loading text="Analyzing arguments..." />
|
| 365 |
-
</div>
|
| 366 |
-
)}
|
| 367 |
-
|
| 368 |
-
{!isAnalyzing && analysisResults.length > 0 && (
|
| 369 |
-
<div className="overflow-hidden rounded-lg border border-slate-200 dark:border-zinc-700">
|
| 370 |
-
<div className="bg-green-50 dark:bg-green-900/20 px-4 py-2 border-b border-slate-200 dark:border-zinc-700">
|
| 371 |
-
<h2 className="text-sm font-semibold text-green-800 dark:text-green-400">
|
| 372 |
-
Analysis Results ({analysisResults.length} results)
|
| 373 |
-
</h2>
|
| 374 |
-
</div>
|
| 375 |
-
<div className="overflow-x-auto">
|
| 376 |
-
<table className="min-w-full divide-y divide-slate-200 dark:divide-zinc-700">
|
| 377 |
-
<thead className="bg-slate-50 dark:bg-zinc-800">
|
| 378 |
-
<tr>
|
| 379 |
-
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600 dark:text-zinc-300">
|
| 380 |
-
Argument
|
| 381 |
-
</th>
|
| 382 |
-
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600 dark:text-zinc-300">
|
| 383 |
-
Topic
|
| 384 |
-
</th>
|
| 385 |
-
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600 dark:text-zinc-300">
|
| 386 |
-
Stance
|
| 387 |
-
</th>
|
| 388 |
-
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600 dark:text-zinc-300">
|
| 389 |
-
Confidence
|
| 390 |
-
</th>
|
| 391 |
-
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600 dark:text-zinc-300">
|
| 392 |
-
PRO
|
| 393 |
-
</th>
|
| 394 |
-
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600 dark:text-zinc-300">
|
| 395 |
-
CON
|
| 396 |
-
</th>
|
| 397 |
-
</tr>
|
| 398 |
-
</thead>
|
| 399 |
-
<tbody className="divide-y divide-slate-200 dark:divide-zinc-700 bg-white dark:bg-zinc-900">
|
| 400 |
-
{analysisResults.map((result) => (
|
| 401 |
-
<tr key={result.id} className="hover:bg-slate-50 dark:hover:bg-zinc-800">
|
| 402 |
-
<td className="whitespace-pre-wrap px-4 py-3 text-sm text-slate-700 dark:text-zinc-300 max-w-md">
|
| 403 |
-
{result.argument}
|
| 404 |
-
</td>
|
| 405 |
-
<td className="px-4 py-3 text-sm text-slate-700 dark:text-zinc-300">
|
| 406 |
-
{result.topic}
|
| 407 |
-
</td>
|
| 408 |
-
<td className="px-4 py-3 text-sm">
|
| 409 |
-
<span
|
| 410 |
-
className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${
|
| 411 |
-
result.predicted_stance === 'PRO'
|
| 412 |
-
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400'
|
| 413 |
-
: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400'
|
| 414 |
-
}`}
|
| 415 |
-
>
|
| 416 |
-
{result.predicted_stance}
|
| 417 |
-
</span>
|
| 418 |
-
</td>
|
| 419 |
-
<td className="px-4 py-3 text-sm text-slate-700 dark:text-zinc-300">
|
| 420 |
-
{(result.confidence * 100).toFixed(1)}%
|
| 421 |
-
</td>
|
| 422 |
-
<td className="px-4 py-3 text-sm text-slate-700 dark:text-zinc-300">
|
| 423 |
-
{(result.probability_pro * 100).toFixed(1)}%
|
| 424 |
-
</td>
|
| 425 |
-
<td className="px-4 py-3 text-sm text-slate-700 dark:text-zinc-300">
|
| 426 |
-
{(result.probability_con * 100).toFixed(1)}%
|
| 427 |
-
</td>
|
| 428 |
-
</tr>
|
| 429 |
-
))}
|
| 430 |
-
</tbody>
|
| 431 |
-
</table>
|
| 432 |
-
</div>
|
| 433 |
-
</div>
|
| 434 |
-
)}
|
| 435 |
-
</div>
|
| 436 |
-
</div>
|
| 437 |
</div>
|
| 438 |
</div>
|
| 439 |
);
|
|
|
|
| 1 |
import React, { useMemo, useState, useEffect } from 'react';
|
| 2 |
+
import { formatError, getCachedAnalysisData, cacheAnalysisData } from '../utils/index.ts';
|
|
|
|
| 3 |
import { AnalysisResult } from '../types/analysis.types.ts';
|
| 4 |
import { analyzeArgumentsFromCsv, getAnalysisResults } from '../services/analysis.service.ts';
|
| 5 |
import {
|
| 6 |
calculateStanceStats,
|
| 7 |
calculateTopicFrequency,
|
|
|
|
| 8 |
getUniqueTopicsCount,
|
| 9 |
getAverageArgumentLength,
|
|
|
|
|
|
|
| 10 |
} from '../utils/analysis.utils.ts';
|
| 11 |
+
import { getCachedRecentAnalysisData, cacheRecentAnalysisData } from '../utils/cache.utils.ts';
|
| 12 |
import StanceDistributionChart from '../components/analysis/StanceDistributionChart.tsx';
|
| 13 |
import TopicFrequencyChart from '../components/analysis/TopicFrequencyChart.tsx';
|
|
|
|
| 14 |
import Loading from '../components/common/Loading.tsx';
|
| 15 |
+
import UploadCsvModal from '../components/analysis/UploadCsvModal.tsx';
|
| 16 |
|
| 17 |
const AnalysisPage = () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
| 19 |
+
const [analysisResults, setAnalysisResults] = useState<AnalysisResult[]>(() => {
|
| 20 |
+
const cachedResults = getCachedRecentAnalysisData();
|
| 21 |
+
return cachedResults || [];
|
| 22 |
+
});
|
| 23 |
+
const [showUploadModal, setShowUploadModal] = useState(false);
|
| 24 |
+
|
| 25 |
// User's historical analysis data
|
| 26 |
const [userAnalysisData, setUserAnalysisData] = useState([] as AnalysisResult[]);
|
| 27 |
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
| 28 |
const [isRefreshingStats, setIsRefreshingStats] = useState(false);
|
| 29 |
const [statsError, setStatsError] = useState(null as string | null);
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
// Fetch user's analysis data on mount with cache support
|
| 33 |
useEffect(() => {
|
|
|
|
| 64 |
fetchUserAnalysis();
|
| 65 |
}, []);
|
| 66 |
|
| 67 |
+
// Update analysisResults when userAnalysisData changes
|
| 68 |
+
useEffect(() => {
|
| 69 |
+
if (userAnalysisData.length > 0) {
|
| 70 |
+
// Set analysisResults to the most recent items (e.g., last 10 items)
|
| 71 |
+
const recentResults = userAnalysisData.slice(0, 10);
|
| 72 |
+
setAnalysisResults(recentResults);
|
| 73 |
+
// Also cache these to localStorage
|
| 74 |
+
cacheRecentAnalysisData(recentResults);
|
| 75 |
+
}
|
| 76 |
+
}, [userAnalysisData]);
|
| 77 |
+
|
| 78 |
// Calculate statistics from user's analysis data
|
| 79 |
const stanceStats = useMemo(
|
| 80 |
() => calculateStanceStats(userAnalysisData),
|
|
|
|
| 84 |
() => calculateTopicFrequency(userAnalysisData, 10),
|
| 85 |
[userAnalysisData]
|
| 86 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
const uniqueTopicsCount = useMemo(
|
| 88 |
() => getUniqueTopicsCount(userAnalysisData),
|
| 89 |
[userAnalysisData]
|
|
|
|
| 92 |
() => getAverageArgumentLength(userAnalysisData),
|
| 93 |
[userAnalysisData]
|
| 94 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
+
const handleOpenUploadModal = () => {
|
| 97 |
+
setShowUploadModal(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
};
|
| 99 |
|
| 100 |
+
const handleCloseUploadModal = () => {
|
| 101 |
+
setShowUploadModal(false);
|
| 102 |
+
};
|
|
|
|
|
|
|
| 103 |
|
| 104 |
+
const handleAnalyzeFromModal = async (file: File) => {
|
| 105 |
setIsAnalyzing(true);
|
|
|
|
|
|
|
| 106 |
|
| 107 |
try {
|
| 108 |
+
const response = await analyzeArgumentsFromCsv(file);
|
| 109 |
setAnalysisResults(response.results);
|
| 110 |
+
// Cache the recent analysis results
|
| 111 |
+
cacheRecentAnalysisData(response.results);
|
| 112 |
// Refresh user analysis data after successful analysis
|
| 113 |
const updatedResponse = await getAnalysisResults(1000, 0);
|
| 114 |
setUserAnalysisData(updatedResponse.results);
|
| 115 |
// Cache the updated data
|
| 116 |
cacheAnalysisData(updatedResponse.results);
|
| 117 |
} catch (err) {
|
| 118 |
+
// For now, just log the error since we don't have a state to store it
|
| 119 |
+
// In a production app, we might want to use a toast notification system
|
| 120 |
+
console.error('Analysis error:', formatError(err));
|
| 121 |
} finally {
|
| 122 |
setIsAnalyzing(false);
|
| 123 |
+
setShowUploadModal(false);
|
| 124 |
}
|
| 125 |
};
|
| 126 |
|
|
|
|
| 139 |
Overview of all your analyzed arguments
|
| 140 |
</p>
|
| 141 |
</div>
|
| 142 |
+
<div className="flex items-center gap-2">
|
| 143 |
+
{isRefreshingStats && (
|
| 144 |
+
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-zinc-400">
|
| 145 |
+
<div className="h-3 w-3 animate-spin rounded-full border-2 border-slate-300 dark:border-zinc-600 border-t-blue-600 dark:border-t-blue-500"></div>
|
| 146 |
+
<span>Updating...</span>
|
| 147 |
+
</div>
|
| 148 |
+
)}
|
| 149 |
+
<button
|
| 150 |
+
onClick={handleOpenUploadModal}
|
| 151 |
+
className="rounded-md bg-blue-600 dark:bg-blue-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 dark:hover:bg-blue-600"
|
| 152 |
+
>
|
| 153 |
+
Upload CSV
|
| 154 |
+
</button>
|
| 155 |
+
</div>
|
| 156 |
</div>
|
| 157 |
</div>
|
| 158 |
|
|
|
|
| 201 |
</div>
|
| 202 |
|
| 203 |
{/* Charts Grid */}
|
| 204 |
+
<div className="grid grid-cols-1 gap-6">
|
| 205 |
{/* Stance Distribution */}
|
| 206 |
<div className="rounded-lg border border-slate-200 dark:border-zinc-700 p-4">
|
| 207 |
<h3 className="text-sm font-semibold text-slate-700 dark:text-zinc-300 mb-4">
|
|
|
|
| 221 |
</div>
|
| 222 |
</div>
|
| 223 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
</div>
|
| 225 |
|
| 226 |
{/* Topic Frequency */}
|
|
|
|
| 232 |
<TopicFrequencyChart data={topicFrequency} />
|
| 233 |
</div>
|
| 234 |
)}
|
| 235 |
+
|
| 236 |
+
{/* Analysis Results Table */}
|
| 237 |
+
{analysisResults.length > 0 && (
|
| 238 |
+
<div className="rounded-lg border border-slate-200 dark:border-zinc-700">
|
| 239 |
+
<div className="bg-green-50 dark:bg-green-900/20 px-4 py-2 border-b border-slate-200 dark:border-zinc-700">
|
| 240 |
+
<h3 className="text-sm font-semibold text-green-800 dark:text-green-400">
|
| 241 |
+
Recent Analysis Results ({analysisResults.length} results)
|
| 242 |
+
</h3>
|
| 243 |
+
</div>
|
| 244 |
+
<div className="overflow-x-auto">
|
| 245 |
+
<table className="min-w-full divide-y divide-slate-200 dark:divide-zinc-700">
|
| 246 |
+
<thead className="bg-slate-50 dark:bg-zinc-800">
|
| 247 |
+
<tr>
|
| 248 |
+
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600 dark:text-zinc-300">
|
| 249 |
+
Argument
|
| 250 |
+
</th>
|
| 251 |
+
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600 dark:text-zinc-300">
|
| 252 |
+
Topic
|
| 253 |
+
</th>
|
| 254 |
+
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600 dark:text-zinc-300">
|
| 255 |
+
Stance
|
| 256 |
+
</th>
|
| 257 |
+
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600 dark:text-zinc-300">
|
| 258 |
+
Confidence
|
| 259 |
+
</th>
|
| 260 |
+
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600 dark:text-zinc-300">
|
| 261 |
+
PRO
|
| 262 |
+
</th>
|
| 263 |
+
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600 dark:text-zinc-300">
|
| 264 |
+
CON
|
| 265 |
+
</th>
|
| 266 |
+
</tr>
|
| 267 |
+
</thead>
|
| 268 |
+
<tbody className="divide-y divide-slate-200 dark:divide-zinc-700 bg-white dark:bg-zinc-900">
|
| 269 |
+
{analysisResults.map((result) => (
|
| 270 |
+
<tr key={result.id} className="hover:bg-slate-50 dark:hover:bg-zinc-800">
|
| 271 |
+
<td className="whitespace-pre-wrap px-4 py-3 text-sm text-slate-700 dark:text-zinc-300 max-w-md">
|
| 272 |
+
{result.argument}
|
| 273 |
+
</td>
|
| 274 |
+
<td className="px-4 py-3 text-sm text-slate-700 dark:text-zinc-300">
|
| 275 |
+
{result.topic}
|
| 276 |
+
</td>
|
| 277 |
+
<td className="px-4 py-3 text-sm">
|
| 278 |
+
<span
|
| 279 |
+
className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${
|
| 280 |
+
result.predicted_stance === 'PRO'
|
| 281 |
+
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400'
|
| 282 |
+
: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400'
|
| 283 |
+
}`}
|
| 284 |
+
>
|
| 285 |
+
{result.predicted_stance}
|
| 286 |
+
</span>
|
| 287 |
+
</td>
|
| 288 |
+
<td className="px-4 py-3 text-sm text-slate-700 dark:text-zinc-300">
|
| 289 |
+
{(result.confidence * 100).toFixed(1)}%
|
| 290 |
+
</td>
|
| 291 |
+
<td className="px-4 py-3 text-sm text-slate-700 dark:text-zinc-300">
|
| 292 |
+
{(result.probability_pro * 100).toFixed(1)}%
|
| 293 |
+
</td>
|
| 294 |
+
<td className="px-4 py-3 text-sm text-slate-700 dark:text-zinc-300">
|
| 295 |
+
{(result.probability_con * 100).toFixed(1)}%
|
| 296 |
+
</td>
|
| 297 |
+
</tr>
|
| 298 |
+
))}
|
| 299 |
+
</tbody>
|
| 300 |
+
</table>
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
)}
|
| 304 |
</div>
|
| 305 |
)}
|
| 306 |
</div>
|
| 307 |
|
| 308 |
{/* CSV Upload and Analysis Section */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
+
{/* Upload CSV Modal */}
|
| 311 |
+
{showUploadModal && (
|
| 312 |
+
<UploadCsvModal
|
| 313 |
+
isOpen={showUploadModal}
|
| 314 |
+
onClose={handleCloseUploadModal}
|
| 315 |
+
onAnalyze={handleAnalyzeFromModal}
|
| 316 |
+
isAnalyzing={isAnalyzing}
|
| 317 |
+
/>
|
| 318 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
</div>
|
| 320 |
</div>
|
| 321 |
);
|
src/app/utils/cache.utils.ts
CHANGED
|
@@ -117,3 +117,36 @@ export function clearMcpToolsCache(): void {
|
|
| 117 |
}
|
| 118 |
}
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
}
|
| 118 |
}
|
| 119 |
|
| 120 |
+
/**
|
| 121 |
+
* Cache recent analysis results to localStorage
|
| 122 |
+
*/
|
| 123 |
+
export function cacheRecentAnalysisData(results: AnalysisResult[]): void {
|
| 124 |
+
try {
|
| 125 |
+
const cacheData: CachedAnalysisData = {
|
| 126 |
+
results,
|
| 127 |
+
timestamp: Date.now(),
|
| 128 |
+
};
|
| 129 |
+
localStorage.setItem('recent_analysis_data_cache', JSON.stringify(cacheData));
|
| 130 |
+
} catch (error) {
|
| 131 |
+
// Silently fail if localStorage is not available or quota exceeded
|
| 132 |
+
console.warn('Failed to cache recent analysis data:', error);
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/**
|
| 137 |
+
* Get cached recent analysis results from localStorage
|
| 138 |
+
*/
|
| 139 |
+
export function getCachedRecentAnalysisData(): AnalysisResult[] | null {
|
| 140 |
+
try {
|
| 141 |
+
const cached = localStorage.getItem('recent_analysis_data_cache');
|
| 142 |
+
if (!cached) return null;
|
| 143 |
+
|
| 144 |
+
const cacheData: CachedAnalysisData = JSON.parse(cached);
|
| 145 |
+
return cacheData.results;
|
| 146 |
+
} catch (error) {
|
| 147 |
+
// Silently fail if localStorage is not available or data is corrupted
|
| 148 |
+
console.warn('Failed to retrieve cached recent analysis data:', error);
|
| 149 |
+
return null;
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|