feat(dashboard): add data labels toggle, dual-axis capture rate chart, mobile bottom nav

- Global data labels toggle in header (works on Dashboard & Comparison pages)
- Labels show formatted values (K/M suffix, max 2 decimals) with white pill background
- Capture Rate chart now shows pilgrims as curved line on right Y-axis
- Revenue Trends toggle moved to top-right corner of chart container
- Mobile: bottom navigation bar with Dashboard, Compare, Labels toggle
- Mobile: top nav simplified to brand only, bottom nav is thumb-friendly
This commit is contained in:
fahed
2026-02-02 13:39:56 +03:00
commit 24fa601aec
17 changed files with 20716 additions and 0 deletions
+324
View File
@@ -0,0 +1,324 @@
// Google Sheets configuration
const SPREADSHEET_ID = '1rdK1e7jmfu-es4Ql0YwDYNBY2OvVihBjYaXTM-MHHqg';
const SHEET_NAME = 'Consolidated Data';
const SHEET_URL = `https://docs.google.com/spreadsheets/d/${SPREADSHEET_ID}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(SHEET_NAME)}`;
// NocoDB configuration
// Use relative URL for dev proxy, full URL for production
const NOCODB_URL = process.env.NODE_ENV === 'production' ? 'http://localhost:8090' : '';
const NOCODB_TOKEN = 'By-wCdkUm6N9JdfmNpGH2jd6LqEejwOXER7FMkgr';
// Old flat table (for backwards compatibility)
const NOCODB_TABLE_ID = 'mzcz8ktjybcjc79';
// New normalized tables (Samaya Museums Statistics base)
const NOCODB_TABLES = {
districts: 'm8cup7lesbet0sa',
museums: 'm1c7od7mdirffvu',
dailyStats: 'mc7qhbdh3mjjwl8'
};
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}`;
}
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() {
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('<!DOCTYPE') || text.includes('<html')) {
throw new Error('Sheet is not public');
}
const data = parseCSV(text);
console.log(`Loaded ${data.length} rows from Google Sheets`);
return data;
} catch (err) {
console.error('Fetch error:', err);
throw new Error(`Failed to load data: ${err.message}`);
}
}
async function fetchNocoDBTable(tableId, limit = 1000) {
let allRecords = [];
let offset = 0;
while (true) {
const response = await fetch(
`${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`,
{ headers: { 'xc-token': NOCODB_TOKEN } }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
const records = json.list || [];
allRecords = allRecords.concat(records);
if (records.length < limit) break;
offset += limit;
}
return allRecords;
}
export async function fetchNocoDBData() {
try {
console.log('Fetching from NocoDB (normalized)...');
// 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 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}`);
}
}
export async function fetchData(source = 'sheets') {
return source === 'nocodb' ? fetchNocoDBData() : fetchSheetData();
}
export function filterData(data, filters) {
return data.filter(row => {
if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false;
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false;
return true;
});
}
export function filterDataByDateRange(data, startDate, endDate, filters = {}) {
return data.filter(row => {
if (!row.date) return false;
if (row.date < startDate || row.date > endDate) return false;
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
return true;
});
}
export function calculateMetrics(data) {
const revenue = data.reduce((sum, row) => sum + parseFloat(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 };
}
export function formatCurrency(num) {
if (isNaN(num)) return 'SAR 0';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'SAR',
maximumFractionDigits: 0
}).format(num);
}
export function formatNumber(num) {
if (isNaN(num)) return '0';
return new Intl.NumberFormat('en-US').format(Math.round(num));
}
export function formatCompact(num) {
if (isNaN(num)) return '0';
const absNum = Math.abs(num);
if (absNum >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (absNum >= 1000) return (num / 1000).toFixed(0) + 'K';
return formatNumber(num);
}
export function formatCompactCurrency(num) {
if (isNaN(num)) return 'SAR 0';
const absNum = Math.abs(num);
if (absNum >= 1000000) return 'SAR ' + (num / 1000000).toFixed(1) + 'M';
if (absNum >= 1000) return 'SAR ' + (num / 1000).toFixed(0) + 'K';
return formatCurrency(num);
}
export function getWeekStart(dateStr) {
if (!dateStr || !dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) return null;
const [year, month, day] = dateStr.split('-').map(Number);
const date = new Date(year, month - 1, day);
const dayOfWeek = date.getDay();
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const monday = new Date(year, month - 1, day + diff);
const y = monday.getFullYear();
const m = String(monday.getMonth() + 1).padStart(2, '0');
const d = String(monday.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export function groupByWeek(data) {
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].visitors += parseInt(row.visits || 0);
grouped[weekStart].tickets += parseInt(row.tickets || 0);
});
return grouped;
}
export function groupByMuseum(data) {
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].visitors += parseInt(row.visits || 0);
grouped[row.museum_name].tickets += parseInt(row.tickets || 0);
});
return grouped;
}
export function groupByDistrict(data) {
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].visitors += parseInt(row.visits || 0);
grouped[row.district].tickets += parseInt(row.tickets || 0);
});
return grouped;
}
// Dynamic 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));
}
export function getUniqueDistricts(data) {
return [...new Set(data.map(r => r.district).filter(Boolean))].sort();
}
export function getDistrictMuseumMap(data) {
const map = {};
data.forEach(row => {
if (!row.district || !row.museum_name) return;
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();
});
return map;
}
export function getMuseumsForDistrict(districtMuseumMap, district) {
if (district === 'all') {
return Object.values(districtMuseumMap).flat().sort();
}
return districtMuseumMap[district] || [];
}
export function getLatestYear(data) {
const years = getUniqueYears(data);
return years.length > 0 ? years[years.length - 1] : '2025';
}