From 9044ab7da3aa9448d23caa42d825df020a8e9310 Mon Sep 17 00:00:00 2001 From: fahed Date: Wed, 4 Feb 2026 11:47:42 +0300 Subject: [PATCH] feat: VAT toggle + offline mode - Rename Revenue to GrossRevenue, add NetRevenue (excl. VAT) - Add VAT toggle (Incl/Excl) on Dashboard and Comparison pages - Add offline mode with localStorage caching (24h validity) - Add refresh button and offline indicator in nav - Remove Google Sheets fallback (archived to dataService.legacy.js) - Add AR/EN translations for new UI elements --- src/App.css | 139 ++++++------ src/App.js | 80 +++++-- src/components/Comparison.js | 37 ++-- src/components/Dashboard.js | 60 +++-- src/locales/ar.json | 11 +- src/locales/en.json | 11 +- src/services/dataService.js | 338 ++++++++++++++++------------- src/services/dataService.legacy.js | 81 +++++++ 8 files changed, 477 insertions(+), 280 deletions(-) create mode 100644 src/services/dataService.legacy.js diff --git a/src/App.css b/src/App.css index 04f79cb..1a9a48c 100644 --- a/src/App.css +++ b/src/App.css @@ -271,38 +271,6 @@ html[dir="rtl"] { border-color: var(--primary); } -.nav-label-toggle { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 14px; - border-radius: 8px; - font-size: 0.75rem; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - background: var(--bg); - border: 1px solid var(--border); - color: var(--text-muted); - margin-left: 8px; -} - -.nav-label-toggle:hover { - background: var(--surface); - color: var(--text-primary); - border-color: var(--primary); -} - -.nav-label-toggle.active { - background: #10b981; - color: white; - border-color: #10b981; -} - -.nav-label-toggle svg { - opacity: 0.8; -} - .nav-link.active svg { opacity: 1; } @@ -341,6 +309,58 @@ html[dir="rtl"] .nav-lang-toggle { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } +/* Offline Badge */ +.offline-badge { + display: flex; + align-items: center; + gap: 5px; + padding: 6px 12px; + background: #fef3c7; + color: #92400e; + border-radius: 6px; + font-size: 0.75rem; + font-weight: 600; + border: 1px solid #fcd34d; +} + +.offline-badge svg { + opacity: 0.8; +} + +/* Refresh Button */ +.nav-refresh-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); +} + +.nav-refresh-btn:hover { + background: var(--surface); + color: var(--text-primary); + border-color: var(--primary); +} + +.nav-refresh-btn:active { + transform: scale(0.96); +} + +.nav-refresh-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.nav-refresh-btn.refreshing svg { + animation: spin 1s linear infinite; +} + /* Main Content */ .dashboard, .comparison { @@ -394,6 +414,18 @@ html[dir="rtl"] .nav-lang-toggle { color: var(--text-secondary); } +/* Header Toggles Container (for multiple toggles side by side) */ +.header-toggles { + display: flex; + align-items: center; + gap: 24px; + margin-top: 6px; +} + +.header-toggles .toggle-with-label { + margin-top: 0; +} + /* Filters - now uses .controls for consistency */ /* Stats Grid */ @@ -870,33 +902,6 @@ table tbody tr:hover { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); } -/* Data Labels Toggle */ -.label-toggle { - display: flex; - align-items: center; - gap: 4px; - border: 2px solid var(--border); - background: var(--surface); - color: var(--text-muted); - padding: 8px 14px; - border-radius: 10px; - font-size: 0.8rem; - font-weight: 500; - cursor: pointer; - transition: all 0.25s ease; -} - -.label-toggle:hover { - border-color: var(--primary); - color: var(--text-primary); -} - -.label-toggle.active { - background: var(--primary); - border-color: var(--primary); - color: white; -} - .chart-metric-selector button { border: none; background: transparent; @@ -926,13 +931,6 @@ table tbody tr:hover { margin-bottom: 32px; } -.chart-selectors-bar { - display: flex; - gap: 8px; - margin-bottom: 16px; - flex-wrap: wrap; -} - /* Inline selectors inside chart cards (mobile) */ .chart-selectors-inline { display: flex; @@ -1305,11 +1303,6 @@ table tbody tr:hover { flex-wrap: wrap; } - /* Chart selectors bar */ - .chart-selectors-bar { - justify-content: center; - } - /* Carousel - overlay arrows on mobile */ .carousel-arrow { width: 28px; @@ -1484,10 +1477,6 @@ table tbody tr:hover { .carousel-dot .dot-label { font-size: 0.5625rem; } - - .chart-selectors-bar { - gap: 4px; - } } /* ========== Slides Builder ========== */ diff --git a/src/App.js b/src/App.js index 91c2fb8..c901271 100644 --- a/src/App.js +++ b/src/App.js @@ -1,9 +1,9 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'; import Dashboard from './components/Dashboard'; import Comparison from './components/Comparison'; import Slides from './components/Slides'; -import { fetchData } from './services/dataService'; +import { fetchData, getCacheStatus, refreshData } from './services/dataService'; import { useLanguage } from './contexts/LanguageContext'; import './App.css'; @@ -21,8 +21,12 @@ function App() { const { t, dir, switchLanguage } = useLanguage(); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); + const [isOffline, setIsOffline] = useState(false); + const [cacheInfo, setCacheInfo] = useState(null); const [showDataLabels, setShowDataLabels] = useState(false); + const [includeVAT, setIncludeVAT] = useState(true); const [dataSource, setDataSource] = useState('museums'); const dataSources = [ @@ -31,22 +35,42 @@ function App() { { id: 'ecommerce', labelKey: 'dataSources.ecommerce', enabled: false } ]; - useEffect(() => { - async function loadData() { - try { - setLoading(true); - const result = await fetchData(); - setData(result); - setError(null); - } catch (err) { + const loadData = useCallback(async (forceRefresh = false) => { + try { + setLoading(!forceRefresh); + setRefreshing(forceRefresh); + + const result = forceRefresh ? await refreshData() : await fetchData(); + setData(result); + setError(null); + setIsOffline(false); + + // Update cache info + const status = getCacheStatus(); + setCacheInfo(status); + } catch (err) { + // Check if we got data from cache despite the error + const status = getCacheStatus(); + if (status.available && data.length > 0) { + setIsOffline(true); + setCacheInfo(status); + } else { setError(err.message); - console.error(err); - } finally { - setLoading(false); } + console.error(err); + } finally { + setLoading(false); + setRefreshing(false); } + }, [data.length]); + + useEffect(() => { loadData(); }, []); + + const handleRefresh = () => { + loadData(true); + }; if (loading) { return ( @@ -114,6 +138,32 @@ function App() { {t('nav.comparison')} + {isOffline && ( + + + + + + + + + + + {t('app.offline') || 'Offline'} + + )} + - +
+
+ {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('