Fix v2 analytics status matching; give Growth and Analytics real charts
Eight queries across the v2 modules counted only status = 'Finished' and so reported zero on live data, where the current ride pipeline writes 'completed': realtime revenue for today and yesterday, financial stats, settlements, driver scorecard, driver ranking, and both revenue queries. All now match either spelling. Growth and Advanced Analytics rendered through the generic shape-detecting renderer, which produced raw tables that said little. Both now have purpose- built views: - Growth: totals, 30-day joins, and a two-series daily chart. growth.php only returns days that had signups, so the series is expanded to a continuous 30-day axis with explicit zeros — plotting the returned rows directly would hide the gaps and make a quiet month look like steady growth. A caption states how many days actually had a signup. - Analytics: revenue summary tiles, a daily revenue trend, and the captain ranking, with a note explaining that platform share is what remains after the captain's cut. Null aggregates render as "—" rather than 0.00, and markers are drawn only on days with a value so a flat zero line stays readable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2135edcf43
commit
bc1b0129e8
@@ -17,7 +17,7 @@ try {
|
||||
SUM(r.price) as total_revenue
|
||||
FROM driver d
|
||||
JOIN ride r ON d.id = r.driver_id
|
||||
WHERE r.status = 'Finished'
|
||||
WHERE LOWER(r.status) IN ('finished','completed')
|
||||
GROUP BY d.id, d.first_name, d.last_name, d.phone
|
||||
ORDER BY completed_rides DESC
|
||||
LIMIT 10
|
||||
|
||||
@@ -17,7 +17,7 @@ try {
|
||||
SUM(price - price_for_driver) as company_profit,
|
||||
COUNT(*) as total_rides
|
||||
FROM ride
|
||||
WHERE status = 'Finished'
|
||||
WHERE LOWER(status) IN ('finished','completed')
|
||||
AND created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
@@ -32,7 +32,7 @@ try {
|
||||
SUM(price - price_for_driver) as total_profit_all,
|
||||
AVG(price) as avg_ride_price
|
||||
FROM ride
|
||||
WHERE status = 'Finished'
|
||||
WHERE LOWER(status) IN ('finished','completed')
|
||||
AND created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
");
|
||||
$stmt->execute();
|
||||
|
||||
@@ -17,7 +17,7 @@ try {
|
||||
SUM(r.price_for_driver) as total_earned,
|
||||
COUNT(r.id) as total_rides
|
||||
FROM driver d
|
||||
LEFT JOIN ride r ON d.id = r.driver_id AND r.status = 'Finished'
|
||||
LEFT JOIN ride r ON d.id = r.driver_id AND LOWER(r.status) IN ('finished','completed')
|
||||
GROUP BY d.id
|
||||
HAVING total_earned > 0
|
||||
ORDER BY total_earned DESC
|
||||
|
||||
@@ -18,7 +18,7 @@ try {
|
||||
0 as cash_payments,
|
||||
0 as digital_payments
|
||||
FROM ride
|
||||
WHERE status = 'Finished'
|
||||
WHERE LOWER(status) IN ('finished','completed')
|
||||
");
|
||||
$stmt->execute();
|
||||
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
@@ -40,7 +40,7 @@ try {
|
||||
$stmt = $con->prepare("
|
||||
SELECT
|
||||
COUNT(*) as total_rides,
|
||||
SUM(CASE WHEN status = 'Finished' THEN 1 ELSE 0 END) as completed_rides,
|
||||
SUM(CASE WHEN LOWER(status) IN ('finished','completed') THEN 1 ELSE 0 END) as completed_rides,
|
||||
SUM(CASE WHEN status = 'cancel' AND cancel_by = 'driver' THEN 1 ELSE 0 END) as driver_cancellations,
|
||||
SUM(CASE WHEN status = 'cancel' AND cancel_by = 'passenger' THEN 1 ELSE 0 END) as passenger_cancellations
|
||||
FROM ride
|
||||
|
||||
@@ -26,12 +26,12 @@ try {
|
||||
$online_drivers = $stmt->fetchColumn();
|
||||
|
||||
// 3. إيرادات اليوم
|
||||
$stmt = $con->prepare("SELECT IFNULL(SUM(price_for_passenger), 0) FROM ride WHERE status = 'Finished' AND DATE(created_at) = CURDATE()");
|
||||
$stmt = $con->prepare("SELECT IFNULL(SUM(price_for_passenger), 0) FROM ride WHERE LOWER(status) IN ('finished','completed') AND DATE(created_at) = CURDATE()");
|
||||
$stmt->execute();
|
||||
$revenue_today = $stmt->fetchColumn();
|
||||
|
||||
// إيرادات الأمس (للمقارنة)
|
||||
$stmt = $con->prepare("SELECT IFNULL(SUM(price_for_passenger), 0) FROM ride WHERE status = 'Finished' AND DATE(created_at) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)");
|
||||
$stmt = $con->prepare("SELECT IFNULL(SUM(price_for_passenger), 0) FROM ride WHERE LOWER(status) IN ('finished','completed') AND DATE(created_at) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)");
|
||||
$stmt->execute();
|
||||
$revenue_yesterday = $stmt->fetchColumn();
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<!-- ?v= must be bumped whenever css/main.css or js/app.js changes: the files
|
||||
are served straight off a bind mount, so without it browsers keep
|
||||
running the previously cached build after a deploy. -->
|
||||
<link rel="stylesheet" href="css/main.css?v=2026-07-25-7">
|
||||
<link rel="stylesheet" href="css/main.css?v=2026-07-25-8">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -609,6 +609,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/app.js?v=2026-07-25-7"></script>
|
||||
<script src="js/app.js?v=2026-07-25-8"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// Bump together with the ?v= query in index.html. Shown in the UI and in the
|
||||
// diagnostics report so "the deploy did nothing" can be answered with a fact
|
||||
// rather than a guess about caching.
|
||||
const BUILD = '2026-07-25-7';
|
||||
const BUILD = '2026-07-25-8';
|
||||
|
||||
// ── Localisation ─────────────────────────────────────────────────────────
|
||||
// Arabic is the operators' language; English is kept because several screens
|
||||
@@ -1024,17 +1024,13 @@
|
||||
},
|
||||
{
|
||||
id: 'growth', group: 'Realtime & Analytics', icon: 'ph-trend-up', title: 'Growth',
|
||||
subtitle: 'Daily signups for passengers and captains',
|
||||
panels: [{ title: 'Growth', path: '/Admin/v2/analytics/growth.php' }],
|
||||
subtitle: 'How many passengers and captains joined each day over the last 30 days',
|
||||
custom: renderGrowth,
|
||||
},
|
||||
{
|
||||
id: 'analyticsV2', group: 'Realtime & Analytics', icon: 'ph-chart-line', title: 'Advanced Analytics',
|
||||
subtitle: 'Revenue, ranking and dashboard aggregates from the v2 engine',
|
||||
panels: [
|
||||
{ title: 'Revenue', path: '/Admin/v2/analytics/revenue.php' },
|
||||
{ title: 'Driver ranking', path: '/Admin/v2/analytics/driver_ranking.php' },
|
||||
{ title: 'Dashboard data', path: '/Admin/v2/analytics/dashboard_data.php' },
|
||||
],
|
||||
custom: renderAnalytics,
|
||||
},
|
||||
{
|
||||
id: 'financeV2', group: 'Finance', icon: 'ph-bank', title: 'Financial V2',
|
||||
@@ -1289,6 +1285,145 @@
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Growth ───────────────────────────────────────────────────────────────
|
||||
// growth.php returns one row per day that had signups — days with none are
|
||||
// simply absent. Plotting those rows directly would compress gaps and make
|
||||
// a quiet week look like steady growth, so the series is expanded to a
|
||||
// continuous 30-day axis with explicit zeros.
|
||||
function densifyDays(rows, valueKey, days = 30) {
|
||||
const byDate = new Map();
|
||||
(rows || []).forEach((r) => byDate.set(String(r.date).slice(0, 10), Number(r[valueKey]) || 0));
|
||||
|
||||
const series = [];
|
||||
const today = new Date();
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(today.getDate() - i);
|
||||
const iso = d.toISOString().slice(0, 10);
|
||||
series.push({
|
||||
label: `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`,
|
||||
value: byDate.get(iso) || 0,
|
||||
});
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
const sumSeries = (series) => series.reduce((a, p) => a + p.value, 0);
|
||||
|
||||
async function renderGrowth(host) {
|
||||
host.innerHTML = '<div class="card"><div class="table-msg">Loading growth…</div></div>';
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = await api('/Admin/v2/analytics/growth.php');
|
||||
} catch (err) {
|
||||
if (handleApiError(err, 'growth')) return;
|
||||
host.innerHTML = `<div class="card"><div class="table-msg is-error">${esc(err.message)}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const passengers = densifyDays(payload?.passenger_daily, 'new_passengers');
|
||||
const drivers = densifyDays(payload?.driver_daily, 'new_drivers');
|
||||
const totals = payload?.totals || {};
|
||||
|
||||
const joinedP = sumSeries(passengers);
|
||||
const joinedD = sumSeries(drivers);
|
||||
const activeDays = passengers.filter((p, i) => p.value || drivers[i].value).length;
|
||||
|
||||
host.innerHTML = `
|
||||
<div class="stats-grid">
|
||||
${growthTile(fmtInt(totals.passengers), 'Passengers in total', 'ph-users', 'warning')}
|
||||
${growthTile(fmtInt(totals.drivers), 'Captains in total', 'ph-steering-wheel', 'info')}
|
||||
${growthTile(`+${fmtInt(joinedP)}`, 'Passengers joined · 30 days', 'ph-user-plus', 'primary')}
|
||||
${growthTile(`+${fmtInt(joinedD)}`, 'Captains joined · 30 days', 'ph-user-plus', 'success')}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Signups per day <span class="card-sub">last 30 days</span></h3>
|
||||
<div class="legend" style="margin:0;">
|
||||
<span class="legend-item"><i style="background:#6366f1"></i>Passengers</span>
|
||||
<span class="legend-item"><i style="background:#10b981"></i>Captains</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container"><canvas id="growthChart"></canvas></div>
|
||||
${activeDays === 0
|
||||
? '<div class="table-msg">Nobody signed up in the last 30 days.</div>'
|
||||
: `<div class="stamp" style="display:block;text-align:center;margin-top:0.5rem;">
|
||||
${activeDays} of 30 days had at least one signup
|
||||
</div>`}
|
||||
</div>`;
|
||||
|
||||
drawMultiLine('growthChart', [
|
||||
{ label: 'Passengers', color: '#6366f1', points: passengers },
|
||||
{ label: 'Captains', color: '#10b981', points: drivers },
|
||||
]);
|
||||
}
|
||||
|
||||
function growthTile(value, label, icon, tone) {
|
||||
return `
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<div>
|
||||
<div class="stat-value">${esc(value)}</div>
|
||||
<div class="stat-title">${esc(label)}</div>
|
||||
</div>
|
||||
<div class="stat-icon ${tone}"><i class="ph ${icon}"></i></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Advanced analytics ───────────────────────────────────────────────────
|
||||
async function renderAnalytics(host) {
|
||||
host.innerHTML = `
|
||||
<div class="card notice-card">
|
||||
<i class="ph-fill ph-info"></i>
|
||||
<span>Revenue is counted from completed rides only. <strong>Total</strong> is what passengers paid;
|
||||
<strong>platform share</strong> is what remains after the captain's cut.</span>
|
||||
</div>
|
||||
<div class="card" id="anRevenue"><div class="table-msg">Loading revenue…</div></div>
|
||||
<div class="card" id="anRanking"><div class="table-msg">Loading captain ranking…</div></div>`;
|
||||
|
||||
// Revenue
|
||||
try {
|
||||
const rev = await api('/Admin/v2/analytics/revenue.php');
|
||||
const daily = rev?.daily || [];
|
||||
const summary = rev?.summary || {};
|
||||
const series = densifyDays(daily, 'total_revenue');
|
||||
const rides = densifyDays(daily, 'total_rides');
|
||||
|
||||
$('anRevenue').innerHTML = `
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Revenue <span class="card-sub">last 30 days</span></h3>
|
||||
</div>
|
||||
<div class="kpi-tiles">
|
||||
<div class="kpi-tile"><div class="kpi-tile-value">${summary.total_revenue_all == null ? '—' : fmtMoney(summary.total_revenue_all)}</div><div class="kpi-tile-label">Total collected</div></div>
|
||||
<div class="kpi-tile"><div class="kpi-tile-value">${summary.total_profit_all == null ? '—' : fmtMoney(summary.total_profit_all)}</div><div class="kpi-tile-label">Platform share</div></div>
|
||||
<div class="kpi-tile"><div class="kpi-tile-value">${summary.avg_ride_price == null ? '—' : fmtMoney(summary.avg_ride_price)}</div><div class="kpi-tile-label">Average fare</div></div>
|
||||
<div class="kpi-tile"><div class="kpi-tile-value">${fmtInt(sumSeries(rides))}</div><div class="kpi-tile-label">Completed rides</div></div>
|
||||
</div>
|
||||
<div class="chart-container" style="height:220px;margin-top:1rem;"><canvas id="revenueTrend"></canvas></div>
|
||||
${sumSeries(series) === 0 ? '<div class="table-msg">No completed rides carried a fare in this period.</div>' : ''}`;
|
||||
|
||||
drawMultiLine('revenueTrend', [{ label: 'Revenue', color: '#06b6d4', points: series }]);
|
||||
} catch (err) {
|
||||
if (handleApiError(err, 'analytics-revenue')) return;
|
||||
$('anRevenue').innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
|
||||
}
|
||||
|
||||
// Captain ranking
|
||||
try {
|
||||
const ranking = await api('/Admin/v2/analytics/driver_ranking.php');
|
||||
const body = $('anRanking');
|
||||
body.innerHTML = '<div class="card-header"><h3 class="card-title">Captain ranking</h3></div><div class="panel-body"></div>';
|
||||
renderPayload(body.querySelector('.panel-body'), ranking);
|
||||
} catch (err) {
|
||||
if (handleApiError(err, 'analytics-ranking')) return;
|
||||
$('anRanking').innerHTML = `<div class="table-msg is-error">${esc(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blacklist & removal ──────────────────────────────────────────────────
|
||||
// Deletion here is a real DELETE against passengers/driver — the account and
|
||||
// its login are gone. The console therefore demands the phone number be
|
||||
@@ -2708,6 +2843,71 @@
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Multiple series on one axis, sharing a scale so the lines are comparable.
|
||||
function drawMultiLine(id, seriesList) {
|
||||
chartData.set(id, { type: 'multi', series: seriesList });
|
||||
const c = prepareCanvas(id);
|
||||
if (!c || !seriesList.length || !seriesList[0].points.length) return;
|
||||
const { ctx, w, h } = c;
|
||||
|
||||
const padL = 38, padR = 14, padTop = 14, padBottom = 26;
|
||||
const count = seriesList[0].points.length;
|
||||
const max = Math.max(1, ...seriesList.flatMap((s) => s.points.map((p) => p.value)));
|
||||
const stepX = count > 1 ? (w - padL - padR) / (count - 1) : 0;
|
||||
const y = (v) => h - padBottom - (v / max) * (h - padTop - padBottom);
|
||||
|
||||
// grid + scale
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
||||
ctx.fillStyle = '#64748b';
|
||||
ctx.font = '10px Inter, sans-serif';
|
||||
const ticks = Math.min(4, max);
|
||||
for (let i = 0; i <= ticks; i++) {
|
||||
const gy = padTop + i * (h - padTop - padBottom) / ticks;
|
||||
ctx.beginPath(); ctx.moveTo(padL, gy); ctx.lineTo(w - padR, gy); ctx.stroke();
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(String(Math.round(max - i * max / ticks)), padL - 6, gy + 3);
|
||||
}
|
||||
|
||||
seriesList.forEach((serie) => {
|
||||
const pts = serie.points.map((p, i) => ({ x: padL + i * stepX, y: y(p.value) }));
|
||||
|
||||
const grad = ctx.createLinearGradient(0, padTop, 0, h - padBottom);
|
||||
grad.addColorStop(0, serie.color + '55');
|
||||
grad.addColorStop(1, serie.color + '00');
|
||||
ctx.beginPath();
|
||||
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
|
||||
ctx.lineTo(pts[pts.length - 1].x, h - padBottom);
|
||||
ctx.lineTo(pts[0].x, h - padBottom);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
|
||||
ctx.strokeStyle = serie.color;
|
||||
ctx.lineWidth = 2.2;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
// mark only non-zero days: dots on a flat zero line are just noise
|
||||
serie.points.forEach((p, i) => {
|
||||
if (!p.value) return;
|
||||
ctx.beginPath();
|
||||
ctx.arc(pts[i].x, pts[i].y, 3.2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = serie.color;
|
||||
ctx.fill();
|
||||
});
|
||||
});
|
||||
|
||||
ctx.fillStyle = '#64748b';
|
||||
ctx.textAlign = 'center';
|
||||
const every = Math.ceil(count / 8);
|
||||
seriesList[0].points.forEach((p, i) => {
|
||||
if (i % every === 0 || i === count - 1) ctx.fillText(p.label, padL + i * stepX, h - 8);
|
||||
});
|
||||
}
|
||||
|
||||
function drawBarChart(id, series) {
|
||||
chartData.set(id, { type: 'bar', series });
|
||||
const c = prepareCanvas(id);
|
||||
@@ -2796,7 +2996,8 @@
|
||||
|
||||
function redrawCharts() {
|
||||
chartData.forEach((cfg, id) => {
|
||||
if (cfg.type === 'line') drawLineChart(id, cfg.series);
|
||||
if (cfg.type === 'multi') drawMultiLine(id, cfg.series);
|
||||
else if (cfg.type === 'line') drawLineChart(id, cfg.series);
|
||||
else if (cfg.type === 'bar') drawBarChart(id, cfg.series);
|
||||
else drawDonut(id, cfg.series, cfg.legendEl);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user