-
{t('nav.labels')}
-
-
-
+
+
+
{t('nav.vat') || 'VAT'}
+
+
+
+
+
+
+
{t('nav.labels')}
+
+
+
+
diff --git a/src/components/Dashboard.js b/src/components/Dashboard.js
index 85d2db7..dacdbc5 100644
--- a/src/components/Dashboard.js
+++ b/src/components/Dashboard.js
@@ -29,7 +29,7 @@ const defaultFilters = {
const filterKeys = ['year', 'district', 'museum', 'quarter'];
-function Dashboard({ data, showDataLabels, setShowDataLabels }) {
+function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }) {
const { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams();
@@ -62,7 +62,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
const [trendGranularity, setTrendGranularity] = useState('week');
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
- const metrics = useMemo(() => calculateMetrics(filteredData), [filteredData]);
+ const metrics = useMemo(() => calculateMetrics(filteredData, includeVAT), [filteredData, includeVAT]);
const hasData = filteredData.length > 0;
const resetFilters = () => setFilters(defaultFilters);
@@ -92,12 +92,13 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
const prevYear = String(parseInt(filters.year) - 1);
const prevData = data.filter(row => row.year === prevYear);
if (prevData.length === 0) return null;
- const prevMetrics = calculateMetrics(prevData);
+ const prevMetrics = calculateMetrics(prevData, includeVAT);
return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null;
- }, [data, filters.year, metrics.revenue]);
+ }, [data, filters.year, metrics.revenue, includeVAT]);
// Revenue trend data (weekly or daily)
const trendData = useMemo(() => {
+ const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const formatLabel = (dateStr) => {
if (!dateStr) return '';
const [year, month, day] = dateStr.split('-').map(Number);
@@ -106,12 +107,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
};
if (trendGranularity === 'week') {
- const grouped = groupByWeek(filteredData);
+ const grouped = groupByWeek(filteredData, includeVAT);
const weeks = Object.keys(grouped).filter(w => w).sort();
return {
labels: weeks.map(formatLabel),
datasets: [{
- label: 'Revenue',
+ label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
data: weeks.map(w => grouped[w].revenue),
borderColor: chartColors.primary,
backgroundColor: chartColors.primary + '10',
@@ -128,13 +129,13 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
filteredData.forEach(row => {
const date = row.date;
if (!dailyData[date]) dailyData[date] = 0;
- dailyData[date] += parseFloat(row.revenue_incl_tax || 0);
+ dailyData[date] += parseFloat(row[revenueField] || row.revenue_incl_tax || 0);
});
const days = Object.keys(dailyData).sort();
return {
labels: days.map(formatLabel),
datasets: [{
- label: 'Revenue',
+ label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
data: days.map(d => dailyData[d]),
borderColor: chartColors.primary,
backgroundColor: chartColors.primary + '10',
@@ -146,11 +147,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
}]
};
}
- }, [filteredData, trendGranularity]);
+ }, [filteredData, trendGranularity, includeVAT]);
// Museum data
const museumData = useMemo(() => {
- const grouped = groupByMuseum(filteredData);
+ const grouped = groupByMuseum(filteredData, includeVAT);
const museums = Object.keys(grouped);
return {
visitors: {
@@ -170,11 +171,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
}]
}
};
- }, [filteredData]);
+ }, [filteredData, includeVAT]);
// District data
const districtData = useMemo(() => {
- const grouped = groupByDistrict(filteredData);
+ const grouped = groupByDistrict(filteredData, includeVAT);
const districts = Object.keys(grouped);
return {
labels: districts,
@@ -184,10 +185,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
borderRadius: 4
}]
};
- }, [filteredData]);
+ }, [filteredData, includeVAT]);
// Quarterly YoY
const quarterlyYoYData = useMemo(() => {
+ const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const d2024 = data.filter(row => row.year === '2024');
const d2025 = data.filter(row => row.year === '2025');
const quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
@@ -196,19 +198,19 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
datasets: [
{
label: '2024',
- data: quarters.map(q => d2024.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0)),
+ data: quarters.map(q => d2024.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0)),
backgroundColor: chartColors.muted,
borderRadius: 4
},
{
label: '2025',
- data: quarters.map(q => d2025.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0)),
+ data: quarters.map(q => d2025.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0)),
backgroundColor: chartColors.primary,
borderRadius: 4
}
]
};
- }, [data]);
+ }, [data, includeVAT]);
// Capture rate
const captureRateData = useMemo(() => {
@@ -288,6 +290,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
// Quarterly table
const quarterlyTable = useMemo(() => {
+ const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const d2024 = data.filter(row => row.year === '2024');
const d2025 = data.filter(row => row.year === '2025');
return [1, 2, 3, 4].map(q => {
@@ -301,8 +304,8 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
q2024 = q2024.filter(r => r.museum_name === filters.museum);
q2025 = q2025.filter(r => r.museum_name === filters.museum);
}
- const rev24 = q2024.reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0);
- const rev25 = q2025.reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0);
+ const rev24 = q2024.reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0);
+ const rev25 = q2025.reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0);
const vis24 = q2024.reduce((s, r) => s + parseInt(r.visits || 0), 0);
const vis25 = q2025.reduce((s, r) => s + parseInt(r.visits || 0), 0);
const revChg = rev24 > 0 ? ((rev25 - rev24) / rev24 * 100) : 0;
@@ -311,7 +314,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
const cap25 = umrahData[2025][q] ? (vis25 / umrahData[2025][q] * 100) : null;
return { q, rev24, rev25, revChg, vis24, vis25, visChg, cap24, cap25 };
});
- }, [data, filters.district, filters.museum]);
+ }, [data, filters.district, filters.museum, includeVAT]);
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
@@ -322,11 +325,20 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
{t('dashboard.title')}
{t('dashboard.subtitle')}
-
-
{t('nav.labels')}
-
-
-
+
+
+
{t('nav.vat') || 'VAT'}
+
+
+
+
+
+
+
{t('nav.labels')}
+
+
+
+
diff --git a/src/locales/ar.json b/src/locales/ar.json
index 2fc8df0..9c7ea69 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -3,7 +3,9 @@
"name": "هاي هلا داتا",
"loading": "جارٍ تحميل البيانات...",
"error": "تعذر تحميل البيانات",
- "retry": "إعادة المحاولة"
+ "retry": "إعادة المحاولة",
+ "offline": "غير متصل",
+ "refresh": "تحديث البيانات"
},
"nav": {
"dashboard": "لوحة التحكم",
@@ -13,11 +15,14 @@
"labels": "التسميات",
"labelsOn": "التسميات مفعّلة",
"labelsOff": "التسميات معطّلة",
- "labelsTooltip": "عرض القيم على الرسوم البيانية"
+ "labelsTooltip": "عرض القيم على الرسوم البيانية",
+ "vat": "الضريبة"
},
"toggle": {
"on": "تشغيل",
- "off": "إيقاف"
+ "off": "إيقاف",
+ "incl": "شامل",
+ "excl": "بدون"
},
"dataSources": {
"museums": "المتاحف",
diff --git a/src/locales/en.json b/src/locales/en.json
index b31cbc9..69f785d 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -3,7 +3,9 @@
"name": "HiHala Data",
"loading": "Loading data...",
"error": "Unable to load data",
- "retry": "Retry"
+ "retry": "Retry",
+ "offline": "Offline",
+ "refresh": "Refresh data"
},
"nav": {
"dashboard": "Dashboard",
@@ -13,11 +15,14 @@
"labels": "Labels",
"labelsOn": "Labels On",
"labelsOff": "Labels Off",
- "labelsTooltip": "Show values on charts"
+ "labelsTooltip": "Show values on charts",
+ "vat": "VAT"
},
"toggle": {
"on": "On",
- "off": "Off"
+ "off": "Off",
+ "incl": "Incl",
+ "excl": "Excl"
},
"dataSources": {
"museums": "Museums",
diff --git a/src/services/dataService.js b/src/services/dataService.js
index 6c1e10f..b32fc06 100644
--- a/src/services/dataService.js
+++ b/src/services/dataService.js
@@ -1,101 +1,96 @@
-// Data source configuration - all from environment variables
-// Set these in .env.local (never commit .env.local to git)
+// Data source: NocoDB only
+// Offline mode: caches data to localStorage for resilience
-// NocoDB (primary/default)
const NOCODB_URL = process.env.REACT_APP_NOCODB_URL || '';
const NOCODB_TOKEN = process.env.REACT_APP_NOCODB_TOKEN || '';
-// Google Sheets (fallback)
-const SPREADSHEET_ID = process.env.REACT_APP_SHEETS_ID || '';
-const SHEET_NAME = process.env.REACT_APP_SHEETS_NAME || 'Consolidated Data';
-const SHEET_URL = SPREADSHEET_ID
- ? `https://docs.google.com/spreadsheets/d/${SPREADSHEET_ID}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(SHEET_NAME)}`
- : '';
-
-// Table IDs (not sensitive - just identifiers)
+// Table IDs
const NOCODB_TABLES = {
districts: 'm8cup7lesbet0sa',
museums: 'm1c7od7mdirffvu',
dailyStats: 'mc7qhbdh3mjjwl8'
};
+// Cache keys
+const CACHE_KEY = 'hihala_data_cache';
+const CACHE_TIMESTAMP_KEY = 'hihala_data_cache_timestamp';
+const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
+
export const umrahData = {
2024: { 1: 11574494, 2: 10521465, 3: 3364627, 4: 7435625 },
2025: { 1: 15222497, 2: 5443393, 3: null, 4: null }
};
-// Convert Excel serial date to YYYY-MM-DD
-function excelDateToYMD(serial) {
- const num = parseInt(serial);
- if (isNaN(num) || num < 1) return null;
-
- // Excel epoch is Dec 30, 1899
- const utcDays = Math.floor(num - 25569); // 25569 = days from 1899-12-30 to 1970-01-01
- const date = new Date(utcDays * 86400 * 1000);
-
- const y = date.getUTCFullYear();
- const m = String(date.getUTCMonth() + 1).padStart(2, '0');
- const d = String(date.getUTCDate()).padStart(2, '0');
-
- return `${y}-${m}-${d}`;
-}
+// ============================================
+// Offline Cache Functions
+// ============================================
-function parseCSV(text) {
- const normalizedText = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
- const lines = normalizedText.trim().split('\n');
- const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
-
- return lines.slice(1).map(line => {
- const values = [];
- let current = '';
- let inQuotes = false;
-
- for (let char of line) {
- if (char === '"') {
- inQuotes = !inQuotes;
- } else if (char === ',' && !inQuotes) {
- values.push(current.trim().replace(/^"|"$/g, ''));
- current = '';
- } else {
- current += char;
- }
- }
- values.push(current.trim().replace(/^"|"$/g, ''));
-
- const obj = {};
- headers.forEach((header, i) => {
- let val = values[i] || '';
- // Convert date serial to YYYY-MM-DD
- if (header === 'date' && /^\d+$/.test(val)) {
- val = excelDateToYMD(val);
- }
- obj[header] = val;
- });
- return obj;
- }).filter(row => row.date);
-}
-
-export async function fetchSheetData() {
+function saveToCache(data) {
try {
- console.log('Fetching from Google Sheets...');
- const response = await fetch(SHEET_URL);
-
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
-
- const text = await response.text();
- if (text.includes(' CACHE_MAX_AGE_MS;
+
+ console.log(`Loaded ${data.length} rows from cache (age: ${Math.round(age / 1000 / 60)} min, stale: ${isStale})`);
+
+ return { data, isStale, timestamp: parseInt(timestamp) };
+ } catch (err) {
+ console.warn('Failed to load from cache:', err.message);
+ return null;
+ }
+}
+
+function getCacheAge() {
+ const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
+ if (!timestamp) return null;
+ return Date.now() - parseInt(timestamp);
+}
+
+export function getCacheStatus() {
+ const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
+ const cached = localStorage.getItem(CACHE_KEY);
+
+ if (!cached || !timestamp) {
+ return { available: false, timestamp: null, age: null, rows: 0 };
+ }
+
+ const ts = parseInt(timestamp);
+ const data = JSON.parse(cached);
+
+ return {
+ available: true,
+ timestamp: new Date(ts).toISOString(),
+ age: Date.now() - ts,
+ rows: data.length,
+ isStale: (Date.now() - ts) > CACHE_MAX_AGE_MS
+ };
+}
+
+export function clearCache() {
+ localStorage.removeItem(CACHE_KEY);
+ localStorage.removeItem(CACHE_TIMESTAMP_KEY);
+ console.log('Cache cleared');
+}
+
+// ============================================
+// NocoDB Data Fetching
+// ============================================
+
async function fetchNocoDBTable(tableId, limit = 1000) {
let allRecords = [];
let offset = 0;
@@ -119,78 +114,113 @@ async function fetchNocoDBTable(tableId, limit = 1000) {
return allRecords;
}
-export async function fetchNocoDBData() {
+async function fetchFromNocoDB() {
+ console.log('Fetching from NocoDB...');
+
+ // Fetch all three tables in parallel
+ const [districts, museums, dailyStats] = await Promise.all([
+ fetchNocoDBTable(NOCODB_TABLES.districts),
+ fetchNocoDBTable(NOCODB_TABLES.museums),
+ fetchNocoDBTable(NOCODB_TABLES.dailyStats)
+ ]);
+
+ // Build lookup maps
+ const districtMap = {};
+ districts.forEach(d => { districtMap[d.Id] = d.Name; });
+
+ const museumMap = {};
+ museums.forEach(m => {
+ museumMap[m.Id] = {
+ code: m.Code,
+ name: m.Name,
+ district: districtMap[m['nc_epk____Districts_id']] || 'Unknown'
+ };
+ });
+
+ // Join data into flat structure
+ const data = dailyStats.map(row => {
+ const museum = museumMap[row['nc_epk____Museums_id']] || {};
+ const date = row.Date;
+ const year = date ? date.substring(0, 4) : '';
+ const month = date ? parseInt(date.substring(5, 7)) : 0;
+ const quarter = month <= 3 ? '1' : month <= 6 ? '2' : month <= 9 ? '3' : '4';
+
+ // GrossRevenue = including VAT, NetRevenue = excluding VAT
+ const grossRevenue = row.GrossRevenue || 0;
+ const netRevenue = row.NetRevenue || (grossRevenue / 1.15);
+
+ return {
+ date: date,
+ museum_code: museum.code,
+ museum_name: museum.name,
+ district: museum.district,
+ visits: row.Visits,
+ tickets: row.Tickets,
+ revenue_gross: grossRevenue,
+ revenue_net: netRevenue,
+ revenue_incl_tax: grossRevenue, // Legacy compatibility
+ year: year,
+ quarter: quarter
+ };
+ }).filter(r => r.date && r.museum_name);
+
+ console.log(`Loaded ${data.length} rows from NocoDB`);
+ return data;
+}
+
+// ============================================
+// Main Data Fetcher (with offline fallback)
+// ============================================
+
+export async function fetchData() {
+ // Check if NocoDB is configured
+ if (!NOCODB_URL || !NOCODB_TOKEN) {
+ // Try cache
+ const cached = loadFromCache();
+ if (cached) {
+ console.warn('NocoDB not configured, using cached data');
+ return cached.data;
+ }
+ throw new Error('NocoDB not configured and no cached data available. Set REACT_APP_NOCODB_URL and REACT_APP_NOCODB_TOKEN in .env.local');
+ }
+
try {
- console.log('Fetching from NocoDB (normalized)...');
+ // Try to fetch fresh data
+ const data = await fetchFromNocoDB();
- // Fetch all three tables in parallel
- const [districts, museums, dailyStats] = await Promise.all([
- fetchNocoDBTable(NOCODB_TABLES.districts),
- fetchNocoDBTable(NOCODB_TABLES.museums),
- fetchNocoDBTable(NOCODB_TABLES.dailyStats)
- ]);
+ // Save to cache on success
+ saveToCache(data);
- // Build lookup maps
- const districtMap = {};
- districts.forEach(d => { districtMap[d.Id] = d.Name; });
-
- const museumMap = {};
- museums.forEach(m => {
- museumMap[m.Id] = {
- code: m.Code,
- name: m.Name,
- district: districtMap[m['nc_epk____Districts_id']] || 'Unknown'
- };
- });
-
- // Join data into flat structure for dashboard
- const data = dailyStats.map(row => {
- const museum = museumMap[row['nc_epk____Museums_id']] || {};
- const date = row.Date;
- const year = date ? date.substring(0, 4) : '';
- const month = date ? parseInt(date.substring(5, 7)) : 0;
- const quarter = month <= 3 ? '1' : month <= 6 ? '2' : month <= 9 ? '3' : '4';
-
- return {
- date: date,
- museum_code: museum.code,
- museum_name: museum.name,
- district: museum.district,
- visits: row.Visits,
- tickets: row.Tickets,
- revenue_incl_tax: row.Revenue,
- year: year,
- quarter: quarter
- };
- }).filter(r => r.date && r.museum_name);
-
- console.log(`Loaded ${data.length} rows from NocoDB (joined from ${districts.length} districts, ${museums.length} museums, ${dailyStats.length} stats)`);
return data;
} catch (err) {
- console.error('NocoDB fetch error:', err);
- throw new Error(`Failed to load from NocoDB: ${err.message}`);
+ console.error('NocoDB fetch failed:', err.message);
+
+ // Try to load from cache
+ const cached = loadFromCache();
+ if (cached) {
+ console.warn(`Using cached data from ${new Date(cached.timestamp).toLocaleString()} (offline mode)`);
+ return cached.data;
+ }
+
+ throw new Error(`Database unavailable and no cached data: ${err.message}`);
}
}
-// Main data fetcher - tries NocoDB first, falls back to Sheets
-export async function fetchData() {
- // Try NocoDB if configured
- if (NOCODB_URL && NOCODB_TOKEN) {
- try {
- return await fetchNocoDBData();
- } catch (err) {
- console.warn('NocoDB failed, trying Google Sheets fallback...', err.message);
- }
+// Force refresh (bypass cache read, but still write to cache)
+export async function refreshData() {
+ if (!NOCODB_URL || !NOCODB_TOKEN) {
+ throw new Error('NocoDB not configured');
}
- // Fallback to Google Sheets if configured
- if (SHEET_URL) {
- return await fetchSheetData();
- }
-
- throw new Error('No data source configured. Set REACT_APP_NOCODB_URL + REACT_APP_NOCODB_TOKEN, or REACT_APP_SHEETS_ID in .env.local');
+ const data = await fetchFromNocoDB();
+ saveToCache(data);
+ return data;
}
+// ============================================
+// Data Filtering & Metrics
+// ============================================
+
export function filterData(data, filters) {
return data.filter(row => {
if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false;
@@ -211,14 +241,19 @@ export function filterDataByDateRange(data, startDate, endDate, filters = {}) {
});
}
-export function calculateMetrics(data) {
- const revenue = data.reduce((sum, row) => sum + parseFloat(row.revenue_incl_tax || 0), 0);
+export function calculateMetrics(data, includeVAT = true) {
+ const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
+ const revenue = data.reduce((sum, row) => sum + parseFloat(row[revenueField] || row.revenue_incl_tax || 0), 0);
const visitors = data.reduce((sum, row) => sum + parseInt(row.visits || 0), 0);
const tickets = data.reduce((sum, row) => sum + parseInt(row.tickets || 0), 0);
const avgRevPerVisitor = visitors > 0 ? revenue / visitors : 0;
return { revenue, visitors, tickets, avgRevPerVisitor };
}
+// ============================================
+// Formatting Functions
+// ============================================
+
export function formatCurrency(num) {
if (isNaN(num)) return 'SAR 0';
return new Intl.NumberFormat('en-US', {
@@ -249,6 +284,10 @@ export function formatCompactCurrency(num) {
return formatCurrency(num);
}
+// ============================================
+// Grouping Functions
+// ============================================
+
export function getWeekStart(dateStr) {
if (!dateStr || !dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) return null;
@@ -265,45 +304,51 @@ export function getWeekStart(dateStr) {
return `${y}-${m}-${d}`;
}
-export function groupByWeek(data) {
+export function groupByWeek(data, includeVAT = true) {
+ const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const grouped = {};
data.forEach(row => {
if (!row.date) return;
const weekStart = getWeekStart(row.date);
if (!weekStart) return;
if (!grouped[weekStart]) grouped[weekStart] = { revenue: 0, visitors: 0, tickets: 0 };
- grouped[weekStart].revenue += parseFloat(row.revenue_incl_tax || 0);
+ grouped[weekStart].revenue += parseFloat(row[revenueField] || row.revenue_incl_tax || 0);
grouped[weekStart].visitors += parseInt(row.visits || 0);
grouped[weekStart].tickets += parseInt(row.tickets || 0);
});
return grouped;
}
-export function groupByMuseum(data) {
+export function groupByMuseum(data, includeVAT = true) {
+ const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const grouped = {};
data.forEach(row => {
if (!row.museum_name) return;
if (!grouped[row.museum_name]) grouped[row.museum_name] = { revenue: 0, visitors: 0, tickets: 0 };
- grouped[row.museum_name].revenue += parseFloat(row.revenue_incl_tax || 0);
+ grouped[row.museum_name].revenue += parseFloat(row[revenueField] || row.revenue_incl_tax || 0);
grouped[row.museum_name].visitors += parseInt(row.visits || 0);
grouped[row.museum_name].tickets += parseInt(row.tickets || 0);
});
return grouped;
}
-export function groupByDistrict(data) {
+export function groupByDistrict(data, includeVAT = true) {
+ const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const grouped = {};
data.forEach(row => {
if (!row.district) return;
if (!grouped[row.district]) grouped[row.district] = { revenue: 0, visitors: 0, tickets: 0 };
- grouped[row.district].revenue += parseFloat(row.revenue_incl_tax || 0);
+ grouped[row.district].revenue += parseFloat(row[revenueField] || row.revenue_incl_tax || 0);
grouped[row.district].visitors += parseInt(row.visits || 0);
grouped[row.district].tickets += parseInt(row.tickets || 0);
});
return grouped;
}
-// Dynamic data extraction helpers
+// ============================================
+// Data Extraction Helpers
+// ============================================
+
export function getUniqueYears(data) {
const years = [...new Set(data.map(r => r.year).filter(Boolean))];
return years.sort((a, b) => parseInt(a) - parseInt(b));
@@ -320,7 +365,6 @@ export function getDistrictMuseumMap(data) {
if (!map[row.district]) map[row.district] = new Set();
map[row.district].add(row.museum_name);
});
- // Convert sets to sorted arrays
Object.keys(map).forEach(d => {
map[d] = [...map[d]].sort();
});
diff --git a/src/services/dataService.legacy.js b/src/services/dataService.legacy.js
new file mode 100644
index 0000000..b68d25d
--- /dev/null
+++ b/src/services/dataService.legacy.js
@@ -0,0 +1,81 @@
+// ============================================
+// ARCHIVED - Google Sheets Data Fetching
+// ============================================
+// Kept for reference only - NOT used in the app
+// The app now uses NocoDB exclusively with offline caching
+
+const SPREADSHEET_ID = process.env.REACT_APP_SHEETS_ID || '';
+const SHEET_NAME = process.env.REACT_APP_SHEETS_NAME || 'Consolidated Data';
+const SHEET_URL = SPREADSHEET_ID
+ ? `https://docs.google.com/spreadsheets/d/${SPREADSHEET_ID}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(SHEET_NAME)}`
+ : '';
+
+// Convert Excel serial date to YYYY-MM-DD
+function excelDateToYMD(serial) {
+ const num = parseInt(serial);
+ if (isNaN(num) || num < 1) return null;
+
+ const utcDays = Math.floor(num - 25569);
+ const date = new Date(utcDays * 86400 * 1000);
+
+ const y = date.getUTCFullYear();
+ const m = String(date.getUTCMonth() + 1).padStart(2, '0');
+ const d = String(date.getUTCDate()).padStart(2, '0');
+
+ return `${y}-${m}-${d}`;
+}
+
+function parseCSV(text) {
+ const normalizedText = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+ const lines = normalizedText.trim().split('\n');
+ const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
+
+ return lines.slice(1).map(line => {
+ const values = [];
+ let current = '';
+ let inQuotes = false;
+
+ for (let char of line) {
+ if (char === '"') {
+ inQuotes = !inQuotes;
+ } else if (char === ',' && !inQuotes) {
+ values.push(current.trim().replace(/^"|"$/g, ''));
+ current = '';
+ } else {
+ current += char;
+ }
+ }
+ values.push(current.trim().replace(/^"|"$/g, ''));
+
+ const obj = {};
+ headers.forEach((header, i) => {
+ let val = values[i] || '';
+ if (header === 'date' && /^\d+$/.test(val)) {
+ val = excelDateToYMD(val);
+ }
+ obj[header] = val;
+ });
+ return obj;
+ }).filter(row => row.date);
+}
+
+export async function fetchSheetData() {
+ try {
+ console.log('Fetching from Google Sheets...');
+ const response = await fetch(SHEET_URL);
+
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+ const text = await response.text();
+ if (text.includes('