refactor: major architecture improvements

Security:
- Remove exposed NocoDB token from client code
- Add .env.example for environment variables

Shared Components:
- Carousel: touch/keyboard navigation, accessibility
- ChartCard: reusable chart container
- EmptyState: for no-data scenarios
- FilterControls: collapsible filter panel with reset button
- StatCard: metric display with change indicator
- ToggleSwitch: accessible radio-style toggle

Architecture:
- Create src/config/chartConfig.js for shared chart options
- Extract ChartJS registration to single location
- Reduce code duplication in Dashboard and Comparison

UX Improvements:
- Add empty state when filters return no data
- Add Reset Filters button to filter controls
- Add skeleton loader CSS utilities
- Improve focus states for accessibility
- Use shared components in Dashboard and Comparison
This commit is contained in:
fahed
2026-02-02 13:50:23 +03:00
parent 24fa601aec
commit 8a3b6a8d2e
13 changed files with 590 additions and 314 deletions

View File

@@ -1,19 +1,7 @@
import React, { useState, useMemo, useRef } from 'react';
import React, { useState, useMemo } from 'react';
import { Line, Doughnut, Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import { Carousel, EmptyState, FilterControls, StatCard } from './shared';
import { chartColors, createBaseOptions } from '../config/chartConfig';
import {
filterData,
calculateMetrics,
@@ -29,38 +17,24 @@ import {
getMuseumsForDistrict
} from '../services/dataService';
ChartJS.register(
CategoryScale, LinearScale, PointElement, LineElement,
BarElement, ArcElement, Title, Tooltip, Legend, Filler,
ChartDataLabels
);
const chartColors = {
primary: '#2563eb',
secondary: '#7c3aed',
tertiary: '#0891b2',
muted: '#cbd5e1',
grid: '#f1f5f9'
const defaultFilters = {
year: 'all',
district: 'all',
museum: 'all',
quarter: 'all'
};
function Dashboard({ data, showDataLabels }) {
const [filters, setFilters] = useState({
year: 'all',
district: 'all',
museum: 'all',
quarter: 'all'
});
const [filtersExpanded, setFiltersExpanded] = useState(true);
const [filters, setFilters] = useState(defaultFilters);
const [activeStatCard, setActiveStatCard] = useState(0);
const [activeChart, setActiveChart] = useState(0);
const [trendGranularity, setTrendGranularity] = useState('week');
// Touch handlers for carousels
const touchStartStat = useRef(null);
const touchStartChart = useRef(null);
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
const metrics = useMemo(() => calculateMetrics(filteredData), [filteredData]);
const hasData = filteredData.length > 0;
const resetFilters = () => setFilters(defaultFilters);
// Stat cards for carousel
const statCards = useMemo(() => [
@@ -70,38 +44,11 @@ function Dashboard({ data, showDataLabels }) {
{ title: 'Avg Rev/Visitor', value: formatCurrency(metrics.avgRevPerVisitor) }
], [metrics]);
const handleStatTouchStart = (e) => { touchStartStat.current = e.touches[0].clientX; };
const handleStatTouchEnd = (e) => {
if (!touchStartStat.current) return;
const diff = touchStartStat.current - e.changedTouches[0].clientX;
if (Math.abs(diff) > 50) {
if (diff > 0 && activeStatCard < statCards.length - 1) setActiveStatCard(activeStatCard + 1);
else if (diff < 0 && activeStatCard > 0) setActiveStatCard(activeStatCard - 1);
}
touchStartStat.current = null;
};
// Chart carousel - define charts array
const dashboardCharts = useMemo(() => [
{ id: 'revenue-trend', label: 'Revenue Trend' },
{ id: 'visitors-museum', label: 'Visitors' },
{ id: 'revenue-museum', label: 'Revenue' },
{ id: 'quarterly-yoy', label: 'Quarterly' },
{ id: 'district', label: 'District' },
{ id: 'capture-rate', label: 'Capture Rate' }
], []);
const handleChartTouchStart = (e) => { touchStartChart.current = e.touches[0].clientX; };
const handleChartTouchEnd = (e) => {
if (!touchStartChart.current) return;
const diff = touchStartChart.current - e.changedTouches[0].clientX;
const maxCharts = filters.museum === 'all' ? dashboardCharts.length : dashboardCharts.length - 2;
if (Math.abs(diff) > 50) {
if (diff > 0 && activeChart < maxCharts - 1) setActiveChart(activeChart + 1);
else if (diff < 0 && activeChart > 0) setActiveChart(activeChart - 1);
}
touchStartChart.current = null;
};
// Chart carousel labels
const chartLabels = useMemo(() => {
const labels = ['Revenue Trend', 'Visitors', 'Revenue', 'Quarterly', 'District', 'Capture Rate'];
return filters.museum === 'all' ? labels : labels.filter((_, i) => i !== 1 && i !== 2);
}, [filters.museum]);
// Dynamic lists from data
const years = useMemo(() => getUniqueYears(data), [data]);
@@ -306,7 +253,7 @@ function Dashboard({ data, showDataLabels }) {
}
]
};
}, [data, filters.district, filters.museum]);
}, [data, filters.district, filters.museum, showDataLabels]);
// Quarterly table
const quarterlyTable = useMemo(() => {
@@ -335,38 +282,7 @@ function Dashboard({ data, showDataLabels }) {
});
}, [data, filters.district, filters.museum]);
const dataLabelDefaults = {
display: showDataLabels,
color: '#1e293b',
font: { size: 10, weight: 600 },
anchor: 'end',
align: 'end',
offset: 4,
padding: 4,
backgroundColor: 'rgba(255, 255, 255, 0.85)',
borderRadius: 3,
formatter: (value) => {
if (value == null) return '';
if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
if (value >= 1000) return (value / 1000).toFixed(2) + 'K';
if (value < 100 && value > 0) return value.toFixed(2);
return Math.round(value).toLocaleString();
}
};
const baseOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { backgroundColor: '#1e293b', padding: 12, cornerRadius: 8, titleFont: { size: 12 }, bodyFont: { size: 11 } },
datalabels: dataLabelDefaults
},
scales: {
x: { grid: { display: false }, ticks: { font: { size: 10 }, color: '#94a3b8' } },
y: { grid: { color: chartColors.grid }, ticks: { font: { size: 10 }, color: '#94a3b8' }, border: { display: false } }
}
};
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
return (
<div className="dashboard">
@@ -375,114 +291,74 @@ function Dashboard({ data, showDataLabels }) {
<p>Real-time museum analytics from Google Sheets</p>
</div>
<div className={`controls ${filtersExpanded ? 'expanded' : 'collapsed'}`}>
<div className="controls-header" onClick={() => setFiltersExpanded(!filtersExpanded)}>
<h3>Filters</h3>
<button className="controls-toggle">{filtersExpanded ? '▲ Hide' : '▼ Show'}</button>
</div>
<div className="controls-body">
<div className="control-row">
<div className="control-group">
<label>Year</label>
<select value={filters.year} onChange={e => setFilters({...filters, year: e.target.value})}>
<option value="all">All Years</option>
{years.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
<div className="control-group">
<label>District</label>
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
<option value="all">All Districts</option>
{districts.map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
<div className="control-group">
<label>Museum</label>
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
<option value="all">All Museums</option>
{availableMuseums.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
<div className="control-group">
<label>Quarter</label>
<select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}>
<option value="all">All Quarters</option>
<option value="1">Q1</option>
<option value="2">Q2</option>
<option value="3">Q3</option>
<option value="4">Q4</option>
</select>
</div>
</div>
</div>
</div>
<FilterControls title="Filters" onReset={resetFilters}>
<FilterControls.Row>
<FilterControls.Group label="Year">
<select value={filters.year} onChange={e => setFilters({...filters, year: e.target.value})}>
<option value="all">All Years</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</FilterControls.Group>
<FilterControls.Group label="District">
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
<option value="all">All Districts</option>
{districts.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</FilterControls.Group>
<FilterControls.Group label="Museum">
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
<option value="all">All Museums</option>
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</FilterControls.Group>
<FilterControls.Group label="Quarter">
<select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}>
<option value="all">All Quarters</option>
<option value="1">Q1</option>
<option value="2">Q2</option>
<option value="3">Q3</option>
<option value="4">Q4</option>
</select>
</FilterControls.Group>
</FilterControls.Row>
</FilterControls>
{/* Desktop: Grid */}
<div className="stats-grid desktop-only">
<div className="stat-card">
<h3>Total Revenue</h3>
<div className="stat-value">{formatCurrency(metrics.revenue)}</div>
{yoyChange !== null && (
<div className={`stat-change ${yoyChange >= 0 ? 'positive' : 'negative'}`}>
{yoyChange >= 0 ? '↑' : '↓'} {Math.abs(yoyChange).toFixed(1)}% YoY
</div>
)}
</div>
<div className="stat-card">
<h3>Total Visitors</h3>
<div className="stat-value">{formatNumber(metrics.visitors)}</div>
</div>
<div className="stat-card">
<h3>Total Tickets</h3>
<div className="stat-value">{formatNumber(metrics.tickets)}</div>
</div>
<div className="stat-card">
<h3>Avg Revenue/Visitor</h3>
<div className="stat-value">{formatCurrency(metrics.avgRevPerVisitor)}</div>
</div>
<StatCard title="Total Revenue" value={formatCurrency(metrics.revenue)} change={yoyChange} />
<StatCard title="Total Visitors" value={formatNumber(metrics.visitors)} />
<StatCard title="Total Tickets" value={formatNumber(metrics.tickets)} />
<StatCard title="Avg Revenue/Visitor" value={formatCurrency(metrics.avgRevPerVisitor)} />
</div>
{/* Mobile: Stats Carousel */}
<div className="stats-carousel mobile-only">
<div className="carousel-container">
<div className="carousel-viewport">
<div
className="carousel-track"
style={{ transform: `translateX(-${activeStatCard * 100}%)` }}
onTouchStart={handleStatTouchStart}
onTouchEnd={handleStatTouchEnd}
>
{statCards.map((card, i) => (
<div className="carousel-slide" key={i}>
<div className="stat-card">
<h3>{card.title}</h3>
<div className="stat-value">{card.value}</div>
{card.hasYoy && yoyChange !== null && (
<div className={`stat-change ${yoyChange >= 0 ? 'positive' : 'negative'}`}>
{yoyChange >= 0 ? '↑' : '↓'} {Math.abs(yoyChange).toFixed(1)}% YoY
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
<div className="carousel-dots labeled">
<Carousel
activeIndex={activeStatCard}
setActiveIndex={setActiveStatCard}
labels={statCards.map(c => c.title.replace('Total ', '').replace('Avg ', ''))}
>
{statCards.map((card, i) => (
<button key={i} className={`carousel-dot ${activeStatCard === i ? 'active' : ''}`} onClick={() => setActiveStatCard(i)}>
<span className="dot-label">{card.title.replace('Total ', '').replace('Avg ', '')}</span>
</button>
<StatCard
key={i}
title={card.title}
value={card.value}
change={card.hasYoy ? yoyChange : null}
/>
))}
</div>
</Carousel>
</div>
{!hasData ? (
<EmptyState
icon="📊"
title="No data found"
message="No records match your current filters. Try adjusting your selection."
action={resetFilters}
actionLabel="Reset Filters"
/>
) : (
<>
<div className="chart-card full-width" style={{marginBottom: '16px'}}>
<h2>Quarterly Comparison: 2024 vs 2025</h2>
<div className="table-container">
@@ -619,8 +495,6 @@ function Dashboard({ data, showDataLabels }) {
<div
className="carousel-track"
style={{ transform: `translateX(-${activeChart * 100}%)` }}
onTouchStart={handleChartTouchStart}
onTouchEnd={handleChartTouchEnd}
>
<div className="carousel-slide">
<div className="chart-card">
@@ -722,17 +596,19 @@ function Dashboard({ data, showDataLabels }) {
</div>
<div className="carousel-dots">
{(filters.museum === 'all' ? dashboardCharts : dashboardCharts.filter(c => !['visitors-museum', 'revenue-museum'].includes(c.id))).map((chart, i) => (
{chartLabels.map((label, i) => (
<button
key={chart.id}
key={label}
className={`carousel-dot ${activeChart === i ? 'active' : ''}`}
onClick={() => setActiveChart(i)}
>
<span className="dot-label">{chart.label}</span>
<span className="dot-label">{label}</span>
</button>
))}
</div>
</div>
</>
)}
</div>
);
}