Wave 2: Migrate scraper to StructuredLogger, add crash detection & topic tags
- Task #2: Migrate scraper_clean.py to use StructuredLogger with categories (37 log calls with metrics across browser/scraper/network/system) - Task #4: Add crash_reports table schema and database methods (save_crash_report, get_crash_report, get_crash_stats) - Task #9: Implement crash detection wrapper with metrics sampling (get_chrome_memory, get_dom_node_count, classify_crash) - Task #17: Add topic tags to frontend ReviewAnalytics (topic filter UI, tags on cards, topics in modal) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ interface ReviewWithNew extends Review {
|
||||
is_new?: boolean;
|
||||
owner_response?: OwnerResponse | null;
|
||||
photo_urls?: string[] | null;
|
||||
topics?: string[];
|
||||
}
|
||||
|
||||
interface ReviewTopic {
|
||||
@@ -47,6 +48,7 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
|
||||
const [selectedReview, setSelectedReview] = useState<ReviewWithNew | null>(null);
|
||||
const [showOnlyNew, setShowOnlyNew] = useState(false);
|
||||
const [brushRange, setBrushRange] = useState<{ startIndex: number; endIndex: number } | null>(null);
|
||||
const [selectedTopics, setSelectedTopics] = useState<string[]>([]);
|
||||
|
||||
// Check if we have comparison data
|
||||
const hasComparisonData = reviews.some(r => r.is_new !== undefined);
|
||||
@@ -67,6 +69,19 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
|
||||
// Calculate timeline data for chart
|
||||
const timelineData = useMemo(() => calculateTimelineData(dateFilteredReviews), [dateFilteredReviews]);
|
||||
|
||||
// Calculate available topics with counts
|
||||
const availableTopics = useMemo(() => {
|
||||
const topicCounts = new Map<string, number>();
|
||||
reviews.forEach(r => {
|
||||
r.topics?.forEach(t => {
|
||||
topicCounts.set(t, (topicCounts.get(t) || 0) + 1);
|
||||
});
|
||||
});
|
||||
return Array.from(topicCounts.entries())
|
||||
.map(([topic, count]) => ({ topic, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [reviews]);
|
||||
|
||||
// Check if brush covers the full range (no filtering needed)
|
||||
const isFullRange = useMemo(() => {
|
||||
if (!brushRange || timelineData.length === 0) return true;
|
||||
@@ -155,7 +170,7 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
|
||||
return 'all';
|
||||
}, [brushRange, timelineData]);
|
||||
|
||||
// Filter reviews by selected ratings, sentiments, response status, new status, and brush range (for table)
|
||||
// Filter reviews by selected ratings, sentiments, response status, new status, topics, and brush range (for table)
|
||||
const filteredReviews = useMemo(() => {
|
||||
return dateFilteredReviews.filter(r => {
|
||||
const matchesRating = selectedRatings.includes(r.rating);
|
||||
@@ -174,6 +189,10 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
|
||||
(hasResponse && selectedResponseStatus.includes('answered')) ||
|
||||
(!hasResponse && selectedResponseStatus.includes('not_answered'));
|
||||
|
||||
// Filter by selected topics
|
||||
const matchesTopics = selectedTopics.length === 0 ||
|
||||
r.topics?.some(t => selectedTopics.includes(t));
|
||||
|
||||
// Filter by brush date range if active
|
||||
let matchesBrush = true;
|
||||
if (brushDateRange && r.centerDate) {
|
||||
@@ -189,9 +208,9 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
|
||||
matchesBrush = r.centerDate >= startDate && r.centerDate < endDate;
|
||||
}
|
||||
|
||||
return matchesRating && matchesSentiment && matchesSearch && matchesNew && matchesResponseStatus && matchesBrush;
|
||||
return matchesRating && matchesSentiment && matchesSearch && matchesNew && matchesResponseStatus && matchesTopics && matchesBrush;
|
||||
});
|
||||
}, [dateFilteredReviews, selectedRatings, selectedSentiments, selectedResponseStatus, globalFilter, showOnlyNew, brushDateRange]);
|
||||
}, [dateFilteredReviews, selectedRatings, selectedSentiments, selectedResponseStatus, globalFilter, showOnlyNew, selectedTopics, brushDateRange]);
|
||||
|
||||
const toggleRating = (rating: number) => {
|
||||
setSelectedRatings(prev =>
|
||||
@@ -211,6 +230,14 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
|
||||
);
|
||||
};
|
||||
|
||||
const toggleTopicFilter = (topic: string) => {
|
||||
setSelectedTopics(prev =>
|
||||
prev.includes(topic)
|
||||
? prev.filter(t => t !== topic)
|
||||
: [...prev, topic]
|
||||
);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setDateRange('all');
|
||||
setSelectedRatings([1, 2, 3, 4, 5]);
|
||||
@@ -219,6 +246,7 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
|
||||
setGlobalFilter('');
|
||||
setShowOnlyNew(false);
|
||||
setBrushRange(null);
|
||||
setSelectedTopics([]);
|
||||
};
|
||||
|
||||
const hasActiveFilters = dateRange !== 'all' ||
|
||||
@@ -227,7 +255,8 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
|
||||
selectedSentiments.length < 3 ||
|
||||
selectedResponseStatus.length < 2 ||
|
||||
globalFilter !== '' ||
|
||||
showOnlyNew;
|
||||
showOnlyNew ||
|
||||
selectedTopics.length > 0;
|
||||
|
||||
const exportFilteredData = () => {
|
||||
const dataStr = JSON.stringify(filteredReviews, null, 2);
|
||||
@@ -364,6 +393,8 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
|
||||
const hasResponse = !!ownerResponse?.text;
|
||||
const photoUrls = row.original.photo_urls;
|
||||
const hasPhotos = photoUrls && photoUrls.length > 0;
|
||||
const topics = row.original.topics;
|
||||
const hasTopics = topics && topics.length > 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
@@ -387,6 +418,19 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
|
||||
<p className={`text-gray-800 ${!expanded && 'line-clamp-2'}`}>
|
||||
{text}
|
||||
</p>
|
||||
{hasTopics && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{topics.map(topic => (
|
||||
<span
|
||||
key={topic}
|
||||
className="px-2 py-0.5 bg-indigo-100 text-indigo-700 text-xs rounded-full cursor-pointer hover:bg-indigo-200 transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); toggleTopicFilter(topic); }}
|
||||
>
|
||||
{topic}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{text.length > 100 && (
|
||||
<button
|
||||
@@ -458,7 +502,7 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
[toggleTopicFilter]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -603,6 +647,35 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Topic Filter */}
|
||||
{availableTopics.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<MessageSquare className="w-5 h-5 text-gray-700" />
|
||||
<span className="font-semibold text-gray-900">Topics:</span>
|
||||
{availableTopics.slice(0, 10).map(({topic, count}) => (
|
||||
<button
|
||||
key={topic}
|
||||
onClick={() => toggleTopicFilter(topic)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-semibold transition-all border ${
|
||||
selectedTopics.includes(topic)
|
||||
? 'bg-indigo-600 text-white border-indigo-700'
|
||||
: 'bg-indigo-100 text-indigo-700 border-indigo-200 hover:bg-indigo-200'
|
||||
}`}
|
||||
>
|
||||
{topic} ({count})
|
||||
</button>
|
||||
))}
|
||||
{selectedTopics.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectedTopics([])}
|
||||
className="px-2 py-1 text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Clear topics
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Summary */}
|
||||
<div className="flex items-center justify-between pt-2 border-t-2 border-gray-200">
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
@@ -1535,6 +1608,37 @@ export default function ReviewAnalytics({ reviews, businessName, businessUrl, ne
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review Topics */}
|
||||
{selectedReview.topics && selectedReview.topics.length > 0 && (
|
||||
<div className="bg-indigo-50 rounded-xl p-4 border-2 border-indigo-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MessageSquare className="w-5 h-5 text-indigo-700" />
|
||||
<span className="font-semibold text-indigo-900">Topics</span>
|
||||
<span className="px-2 py-0.5 bg-indigo-200 text-indigo-800 text-xs font-bold rounded-full">
|
||||
{selectedReview.topics.length} topic{selectedReview.topics.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedReview.topics.map(topic => (
|
||||
<button
|
||||
key={topic}
|
||||
onClick={() => {
|
||||
toggleTopicFilter(topic);
|
||||
setSelectedReview(null);
|
||||
}}
|
||||
className={`px-3 py-1 rounded-full text-sm font-semibold transition-all border ${
|
||||
selectedTopics.includes(topic)
|
||||
? 'bg-indigo-600 text-white border-indigo-700'
|
||||
: 'bg-white text-indigo-700 border-indigo-300 hover:bg-indigo-100'
|
||||
}`}
|
||||
>
|
||||
{topic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Owner Response */}
|
||||
{selectedReview.owner_response?.text ? (
|
||||
<div className="bg-emerald-50 rounded-xl p-4 border-2 border-emerald-200">
|
||||
|
||||
Reference in New Issue
Block a user