Overview
S-Parking’s analytics system captures hourly snapshots of parking occupancy and displays historical trends using Chart.js . The dashboard shows global and per-zone occupancy patterns, helping administrators optimize capacity and identify peak hours.
Hourly Snapshot System
A Cloud Function runs every hour (via Cloud Scheduler) to capture occupancy data:
// gcp-functions/save-hourly-snapshot/index.js:5-11
function buildHourKey ( date = new Date ()) {
const y = date . getUTCFullYear ();
const m = String ( date . getUTCMonth () + 1 ). padStart ( 2 , '0' );
const d = String ( date . getUTCDate ()). padStart ( 2 , '0' );
const h = String ( date . getUTCHours ()). padStart ( 2 , '0' );
return ` ${ y } - ${ m } - ${ d } - ${ h } ` ; // UTC for consistency
}
Hour Key Format : 2026-03-05-14 represents 2:00 PM UTC on March 5, 2026. Using UTC ensures consistent time handling across timezones.
Snapshot Data Structure
// gcp-functions/save-hourly-snapshot/index.js:159-186
const docData = {
hour_key: hourKey ,
timestamp: targetDate . toISOString (),
ts: targetDate . getTime (), // numeric timestamp for efficient queries
global: {
free: globalCounts . free || 0 ,
occupied: globalCounts . occupied || 0 ,
reserved: globalCounts . reserved || 0 ,
total: globalCounts . total || 0 ,
occupancyPct
},
zones: {},
created_at: targetDate
};
Object . keys ( zonesMap ). forEach ( zoneId => {
const z = zonesMap [ zoneId ];
const occPlusRes = ( z . occupied || 0 ) + ( z . reserved || 0 );
const pct = z . total ? Math . round (( occPlusRes / z . total ) * 100 ) : 0 ;
docData . zones [ zoneId ] = {
free: z . free || 0 ,
occupied: z . occupied || 0 ,
reserved: z . reserved || 0 ,
total: z . total || 0 ,
occupancyPct: pct
};
});
Global Stats Total occupancy across all parking spots
Zone Stats Per-zone breakdown with individual percentages
Data Retention
Snapshots are automatically cleaned up after 30 days:
// gcp-functions/save-hourly-snapshot/index.js:192-210
const cutoffMs = Date . now () - 30 * 24 * 60 * 60 * 1000 ;
const oldQuery = await firestore
. collection ( 'occupancy_history' )
. where ( 'ts' , '<' , cutoffMs )
. get ();
let deleted = 0 ;
if ( ! oldQuery . empty ) {
const batch = firestore . batch ();
oldQuery . docs . forEach ( doc => {
batch . delete ( doc . ref );
deleted += 1 ;
});
await batch . commit ();
console . log ( `🧹 Retention applied. Documents deleted: ${ deleted } ` );
}
Cost Optimization : 30-day retention balances historical insights with Firestore storage costs. Adjust based on your needs.
Fetching Historical Data
The client fetches historical data with configurable time ranges:
// js/api/history.js:17-49
export async function fetchOccupancyHistory ( days = 1 , zoneId = null ) {
const now = Date . now ();
const cacheKey = ` ${ days } _ ${ zoneId || 'all' } ` ;
// Return cache if valid
if ( cachedHistory && cachedHistory . key === cacheKey &&
cacheTimestamp && ( now - cacheTimestamp < CACHE_DURATION )) {
console . log ( '📦 Using cached history' );
return cachedHistory . data ;
}
try {
const params = new URLSearchParams ({ days: days . toString () });
if ( zoneId ) params . append ( 'zoneId' , zoneId );
const url = ` ${ HISTORY_API_URL } ? ${ params . toString () } ` ;
console . log ( `📥 Fetching history: ${ days } days ${ zoneId ? ' zone=' + zoneId : '' } ` );
const response = await fetch ( url );
if ( ! response . ok ) throw new Error ( `HTTP ${ response . status } ` );
const data = await response . json ();
// Cache result
cachedHistory = { key: cacheKey , data };
cacheTimestamp = now ;
console . log ( `✅ History fetched: ${ data . count } samples` );
return data ;
} catch ( error ) {
console . error ( '❌ Error fetching history:' , error );
return { success: false , days , count: 0 , samples: [] };
}
}
History data is cached for 10 minutes to reduce API costs. Since hourly snapshots only update once per hour, frequent fetching is unnecessary.
Chart.js Visualization
S-Parking uses Chart.js to render sparklines and detailed graphs:
Main Dashboard Chart
// js/ui/charts.js:56-102
hourlyChart = new Chart ( ctx , {
type: 'line' ,
data: {
labels: dataToShow . map (( _ , i ) => i ),
datasets: [{
data: dataToShow ,
borderColor: '#3b82f6' ,
backgroundColor: 'transparent' ,
borderWidth: 1.5 ,
fill: false ,
tension: 0.4 ,
pointRadius: 0 ,
pointHoverRadius: 0 ,
}]
},
options: {
responsive: true ,
maintainAspectRatio: false ,
layout: {
padding: {
top: 5 ,
bottom: 5 ,
left: 0 ,
right: 0
}
},
plugins: {
legend: { display: false },
tooltip: { enabled: false }
},
scales: {
y: {
beginAtZero: true ,
grace: '5%' ,
display: false ,
grid: { display: false }
},
x: {
display: false ,
grid: { display: false }
}
},
interaction: {
mode: 'none'
}
}
});
Minimalist Design : The main chart hides axes and tooltips for a clean sparkline appearance. Detailed charts show full controls.
Zone Sparklines
Each zone card displays a mini sparkline:
// js/ui/charts.js:196-258
export function createZoneSparkline ( ctx , data , color = '#3b82f6' ) {
if ( ! ctx || ! data || data . length === 0 ) return null ;
// If only 1 point, duplicate it so Chart.js can draw
const chartData = data . length === 1 ? [ data [ 0 ], data [ 0 ]] : data ;
return new Chart ( ctx , {
type: 'line' ,
data: {
labels: chartData . map (( _ , i ) => i ),
datasets: [{
data: chartData ,
borderColor: color ,
backgroundColor: 'transparent' ,
borderWidth: 1.5 ,
fill: false ,
tension: 0.4 ,
pointRadius: data . length <= 3 ? 3 : 0 ,
pointHoverRadius: data . length <= 3 ? 4 : 0 ,
pointBackgroundColor: color ,
pointBorderColor: '#fff' ,
pointBorderWidth: 1
}]
},
options: {
responsive: true ,
maintainAspectRatio: false ,
plugins: {
legend: { display: false },
tooltip: {
enabled: data . length <= 10 ,
callbacks: {
label : ( context ) => ` ${ context . parsed . y } % occupancy`
}
}
},
scales: {
y: {
beginAtZero: true ,
grace: '5%' ,
display: false ,
grid: { display: false }
},
x: {
display: false ,
grid: { display: false }
}
},
interaction: { mode: data . length <= 10 ? 'index' : 'none' },
animation: {
duration: 400
}
}
});
}
Expanded Chart Modal
Clicking the main chart opens a detailed view with interactive tooltips:
// js/ui/charts.js:377-467
export function initExpandedChart ( ctx , historyData , samples ) {
const chart = new Chart ( ctx , {
type: 'line' ,
data: {
labels: samples . map ( s => new Date ( s . ts )),
datasets: [{
label: 'Occupancy' ,
data: historyData ,
borderColor: '#3b82f6' ,
backgroundColor: 'rgba(59, 130, 246, 0.1)' ,
borderWidth: 2 ,
fill: true ,
tension: 0.4 ,
pointRadius: 0
}]
},
options: {
responsive: true ,
maintainAspectRatio: false ,
interaction: {
mode: 'index' ,
intersect: false ,
axis: 'x'
},
scales: {
y: {
beginAtZero: true ,
suggestedMax: 105 ,
grid: {
color: 'rgba(148, 163, 184, 0.1)'
},
ticks: {
callback : ( value ) => value <= 100 ? ` ${ value } %` : '' ,
color: '#64748b' ,
font: { size: 11 },
stepSize: 20
}
},
x: {
type: 'time' ,
time: {
unit: 'hour' ,
displayFormats: {
hour: 'HH:mm'
},
tooltipFormat: 'DD MMM YYYY, HH:mm'
},
grid: {
color: 'rgba(148, 163, 184, 0.1)'
},
ticks: {
color: '#64748b' ,
maxRotation: 0 ,
autoSkip: true ,
maxTicksLimit: 12
}
}
}
}
});
}
Time-Series X-Axis Uses Chart.js time scale for proper date/time rendering
Filled Area Light blue fill shows occupancy range clearly
Trend Calculation
The dashboard calculates occupancy trends:
// js/api/history.js:123-129
export function calculateTrend ( data ) {
if ( ! data || data . length < 2 ) return 0 ;
const first = data [ 0 ];
const last = data [ data . length - 1 ];
if ( first === 0 ) return last > 0 ? 100 : 0 ;
return Math . round ((( last - first ) / first ) * 100 );
}
Trend indicators update in real-time:
// js/main.js:341-365
if ( globalOccupancy . length >= 2 ) {
const current = Math . round ( globalOccupancy [ globalOccupancy . length - 1 ]);
const previous = Math . round ( globalOccupancy [ globalOccupancy . length - 2 ]);
const diff = current - previous ;
const trendEl = document . getElementById ( 'occupancy-trend' );
const trendIcon = trendEl ?. previousElementSibling ;
if ( trendEl ) {
trendEl . textContent = ` ${ diff > 0 ? '+' : '' }${ diff } %` ;
if ( diff > 0 ) {
trendIcon . className = 'fa-solid fa-arrow-up text-red-600' ;
trendEl . className = 'text-red-700 font-bold' ;
trendEl . parentElement . className = 'flex items-center gap-1 px-2 py-1 bg-red-100 rounded-full' ;
} else if ( diff < 0 ) {
trendIcon . className = 'fa-solid fa-arrow-down text-green-600' ;
trendEl . className = 'text-green-700 font-bold' ;
trendEl . parentElement . className = 'flex items-center gap-1 px-2 py-1 bg-green-100 rounded-full' ;
} else {
trendIcon . className = 'fa-solid fa-minus text-slate-600' ;
trendEl . className = 'text-slate-700 font-bold' ;
trendEl . parentElement . className = 'flex items-center gap-1 px-2 py-1 bg-slate-100 rounded-full' ;
}
}
}
Upward Trend
Downward Trend
Stable
🔴 Red arrow + badge: Occupancy increasing (more congestion)
🟢 Green arrow + badge: Occupancy decreasing (freeing up)
⚪ Gray minus + badge: No significant change
Peak Hour Detection
The analytics modal identifies peak hours automatically:
// js/main.js:733-749
const samplesWithTime = historyData . samples . map (( s , i ) => ({
time: new Date ( s . ts ). toLocaleString ( 'es-CL' , {
weekday: 'short' ,
hour: '2-digit' ,
minute: '2-digit'
}),
value: s . global ?. occupancyPct || 0 ,
index: i
}));
const topPeaks = [ ... samplesWithTime ]
. sort (( a , b ) => b . value - a . value )
. slice ( 0 , 3 );
peakHoursEl . innerHTML = topPeaks . map ( peak => `
<div class="flex justify-between items-center py-2 border-b">
<span class="font-semibold"> ${ peak . time } </span>
<span class="text-rose-600 font-bold"> ${ Math . round ( peak . value ) } %</span>
</div>
` ). join ( '' );
Recommendations Engine
The system analyzes patterns and provides actionable recommendations:
// js/main.js:752-826 (simplified)
const avgOccupancy = globalOccupancy . reduce (( a , b ) => a + b , 0 ) / globalOccupancy . length ;
const variance = globalOccupancy . reduce (( sum , val ) =>
sum + Math . pow ( val - avgOccupancy , 2 ), 0 ) / globalOccupancy . length ;
const stdDev = Math . sqrt ( variance );
const variability = stdDev / avgOccupancy * 100 ;
const recommendations = [];
if ( criticalPercent > 30 ) {
recommendations . push ({
icon: 'fa-triangle-exclamation' ,
color: 'text-rose-600' ,
text: 'High saturation detected. Consider expanding capacity.'
});
}
if ( variability > 30 ) {
recommendations . push ({
icon: 'fa-chart-line' ,
color: 'text-blue-600' ,
text: `Irregular demand (CV ${ Math . round ( variability ) } %). Implement dynamic pricing.`
});
}
if ( avgAvailability < 20 ) {
recommendations . push ({
icon: 'fa-users' ,
color: 'text-amber-600' ,
text: `Low average availability ( ${ Math . round ( avgAvailability ) } %). Review policies.`
});
}
Triggered when : Occupancy > 80% for more than 30% of the periodRecommendation : Expand capacity, add overflow areas, or implement waitlists
Triggered when : Coefficient of variation > 30%Recommendation : Implement dynamic pricing, improve signage, or adjust marketing
Triggered when : Average availability < 20%Recommendation : Review maximum stay policies, increase turnover, or add spots
The history API provides utilities to extract specific data slices:
// js/api/history.js:56-86
export function extractGlobalOccupancy ( samples ) {
if ( ! samples || samples . length === 0 ) return [];
return samples . map ( s => s . global ?. occupancyPct || 0 );
}
export function extractZoneOccupancy ( samples , zoneId ) {
if ( ! samples || samples . length === 0 ) return [];
return samples . map (( s ) => {
// If sample has direct zone data
if ( s . zone ) return s . zone . occupancyPct || 0 ;
// If it has zones_summary
if ( s . zones_summary && Array . isArray ( s . zones_summary )) {
const zone = s . zones_summary . find ( z => z . id === zoneId );
if ( zone ) return zone . occupancyPct || 0 ;
}
return 0 ;
});
}
export function aggregateGlobalFromZones ( samples ) {
if ( ! samples || samples . length === 0 ) return [];
return samples . map (( s ) => {
if ( s . zones_summary && Array . isArray ( s . zones_summary )) {
const zones = s . zones_summary . filter ( z =>
z && z . id !== 'SinZona' && z . total > 0
);
if ( zones . length > 0 ) {
const weightedSum = zones . reduce (( sum , z ) => {
const occupiedPct = z . occupancyPct ??
(( z . occupied + z . reserved ) / z . total ) * 100 ;
return sum + ( occupiedPct * z . total );
}, 0 );
const totalCapacity = zones . reduce (( t , z ) => t + z . total , 0 );
return Math . round ( weightedSum / totalCapacity );
}
}
return s . global ?. occupancyPct ?? 0 ;
});
}
Chart Reuse Existing Chart.js instances are destroyed before creating new ones to prevent memory leaks
Data Limiting Main chart shows maximum 48 data points to prevent UI slowdown
Lazy Rendering Charts only render when visible (expanded modal uses setTimeout)
10-Minute Cache History data cached to reduce Firestore reads
Best Practices
Choosing Snapshot Frequency
The default 1-hour interval balances cost and granularity:
15-minute snapshots : 4x cost, better for high-turnover lots
1-hour snapshots (default): Ideal for most use cases
6-hour snapshots : Minimal cost, sufficient for low-traffic lots
Configure via Cloud Scheduler trigger frequency.
Context matters when reading trends:
Time of day : Morning increases are normal for commuter lots
Day of week : Weekend patterns differ significantly
Special events : Concerts, games, etc. create anomalies
Seasonal changes : Academic calendars affect university parking
Use the expanded chart’s date picker to compare similar time periods.
For external analysis, export historical data:
Query Firestore directly: firestore.collection('occupancy_history')
Use Cloud Functions to generate CSV exports
Connect BI tools (Tableau, Looker) to Firestore
Set up BigQuery sync for advanced analytics
Real-Time Monitoring How live data feeds into historical snapshots
Zone Management Per-zone analytics and sparklines