From 26875e396cb9a11b8a006316208cdb6e714b60f4 Mon Sep 17 00:00:00 2001 From: VPN SaaS Dev Date: Tue, 12 May 2026 20:11:12 +0900 Subject: [PATCH] Add CarPass passport UI widgets --- web/index.html | 34 ++++++-- web/static/app.js | 140 +++++++++++++++++++++++++++---- web/static/styles.css | 189 +++++++++++++++++++++++++++++++++--------- 3 files changed, 302 insertions(+), 61 deletions(-) diff --git a/web/index.html b/web/index.html index 8773644..1197543 100644 --- a/web/index.html +++ b/web/index.html @@ -61,13 +61,35 @@
-
-
- Профиль учета - Старт +
+
+
+

CarPass

+

Цифровой паспорт

+ Добавь авто и первую запись, чтобы видеть точные отчеты +
+
+ 0 + /100 +
-
- Добавь авто и первую запись, чтобы видеть точные отчеты +
+
+ Profile quality + Старт +
+
+ Verified history + Self-reported +
+
+ Maintenance health + Unknown +
+
+
+
+
Готов к работе
diff --git a/web/static/app.js b/web/static/app.js index 9de46d8..0fda144 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -317,6 +317,9 @@ const state = { allStats: null, analytics: null, serviceCenters: [], + vehicleScore: null, + vehicleTimeline: [], + achievements: [], receiptFile: null, serviceWorkerRegistration: null, period: { @@ -889,21 +892,115 @@ function recordsForPeriod() { function updateScore() { const car = selectedCar(); - const fuelCount = state.latestFuel.length; - const serviceCount = state.latestService.length; - let score = 0; - if (car) score += 25; - if (fuelCount > 0) score += 25; - if (serviceCount > 0) score += 20; - if (state.latestStats?.distance_km > 0) score += 20; - if (fuelCount + serviceCount >= 6) score += 10; - const title = score >= 90 ? t("Профиль точный") : score >= 60 ? t("Хороший учет") : score >= 30 ? t("Набираем данные") : t("Старт"); - document.querySelector("#scoreTitle").textContent = title; - document.querySelector("#scoreBar").style.width = `${score}%`; - document.querySelector("#scoreHint").textContent = - score >= 90 - ? t("Отчеты уже достаточно надежны для решений по расходам") - : t("Чем регулярнее записи, тем точнее расход, цена километра и напоминания"); + const score = state.vehicleScore?.completeness_score || 0; + const title = scoreLabel(state.vehicleScore?.profile_quality, score); + const ring = document.querySelector("#scoreRing"); + document.querySelector("#scoreValue").textContent = score; + document.querySelector("#scoreTitle").textContent = car ? title : t("Старт"); + document.querySelector("#verifiedHistoryStatus").textContent = historyLabel(state.vehicleScore?.verified_history_status); + document.querySelector("#maintenanceStatus").textContent = healthLabel(state.vehicleScore?.maintenance_status); + if (ring) { + ring.style.background = `conic-gradient(#5ee0bd ${score * 3.6}deg, rgba(255,255,255,0.12) 0deg)`; + } + document.querySelector("#scoreHint").textContent = car + ? "Качество паспорта растет от подтвержденных данных, сервисной истории и точного пробега." + : t("Добавь авто и первую запись, чтобы видеть точные отчеты"); + renderScoreActions(state.vehicleScore?.missing_items || []); + renderAchievements(); + renderVehicleTimeline(); +} + +function scoreLabel(quality, score) { + if (quality === "high_confidence" || score >= 86) return "High-confidence passport"; + if (quality === "strong" || score >= 61) return "Strong profile"; + if (quality === "useful" || score >= 31) return "Useful profile"; + return "Basic profile"; +} + +function historyLabel(status) { + const labels = { + verified: "Verified maintenance history", + partially_verified: "Partially verified", + self_reported: "Self-reported", + }; + return labels[status] || "Self-reported"; +} + +function healthLabel(status) { + const labels = { + green: "Green", + yellow: "Attention soon", + red: "Overdue", + unknown: "Needs baseline", + }; + return labels[status] || "Needs baseline"; +} + +function renderScoreActions(items) { + const root = document.querySelector("#scoreActions"); + if (!root) return; + const visible = items.slice(0, 3); + if (!visible.length) { + root.innerHTML = `
Паспорт выглядит надежно. Следующий рост даст подтвержденная сервисная история.
`; + return; + } + root.innerHTML = visible + .map( + (item) => ` +
+ ${item.title} + ${item.description} +
+ `, + ) + .join(""); +} + +function renderAchievements() { + const root = document.querySelector("#achievementList"); + if (!root) return; + const car = selectedCar(); + const achievements = state.achievements.filter((item) => !item.vehicle_id || item.vehicle_id === car?.id).slice(0, 4); + if (!achievements.length) { + root.innerHTML = `
Evidence badgesПоявятся после первых качественных записей.
`; + return; + } + root.innerHTML = achievements + .map( + (item) => ` +
+ ${item.title} + ${item.description} +
+ `, + ) + .join(""); +} + +function renderVehicleTimeline() { + const root = document.querySelector("#vehicleTimeline"); + if (!root) return; + const items = state.vehicleTimeline.slice(0, 5); + if (!items.length) { + root.innerHTML = `
Timeline появится после заправок, сервиса и подтверждений.
`; + return; + } + root.innerHTML = ` +
Vehicle timeline
+ ${items + .map( + (item) => ` +
+ +
+ ${item.title} + ${String(item.date).slice(0, 10)}${item.status ? ` · ${item.status}` : ""} +
+
+ `, + ) + .join("")} + `; } function openReport(type = "summary") { @@ -1174,21 +1271,32 @@ async function loadSelectedCar() { state.latestStats = null; state.allStats = null; state.analytics = null; + state.vehicleScore = null; + state.vehicleTimeline = []; + state.achievements = []; renderStats(null); return; } - const [stats, allStats, fuel, service, analytics] = await Promise.all([ + const [stats, allStats, fuel, service, analytics, vehicleScore] = await Promise.all([ api(`/cars/${state.selectedCarId}/stats${periodQuery()}`), api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`), api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`), api(`/cars/${state.selectedCarId}/service${periodQuery()}`), api(`/cars/${state.selectedCarId}/analytics`), + api(`/my/vehicles/${state.selectedCarId}/score`), + ]); + const [timeline, achievements] = await Promise.all([ + api(`/my/vehicles/${state.selectedCarId}/timeline?limit=30`), + api("/me/achievements"), ]); state.latestStats = stats; state.allStats = allStats; state.latestFuel = fuel; state.latestService = service; state.analytics = analytics; + state.vehicleScore = vehicleScore; + state.vehicleTimeline = timeline; + state.achievements = achievements; renderStats(stats); drawCharts(fuel, service, stats); } diff --git a/web/static/styles.css b/web/static/styles.css index 7cc2b09..1b8e4d2 100644 --- a/web/static/styles.css +++ b/web/static/styles.css @@ -31,46 +31,160 @@ body.auth-required .shell { box-shadow: none; } -.progress-strip { +.passport-panel { display: grid; - grid-template-columns: 150px 1fr; - gap: 10px 14px; - align-items: center; - padding: 12px; + gap: 14px; + padding: 16px; margin-bottom: 14px; - border: 1px solid var(--line); + color: #f4fbf8; + border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 8px; - background: #f5faf8; + background: + linear-gradient(145deg, rgba(16, 26, 28, 0.98), rgba(27, 37, 43, 0.96)), + #101a1c; + box-shadow: 0 18px 50px rgba(10, 16, 18, 0.22); } -.progress-strip span, -.progress-strip small { - color: var(--muted); +.passport-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; } -.progress-strip strong { - display: block; - margin-top: 2px; +.passport-head h2 { + margin: 0; + color: #fff; + letter-spacing: 0; } -.progress-strip small { - grid-column: 2; +.passport-head small, +.passport-metric span, +.passport-action span, +.achievement-card span, +.timeline-item small, +.timeline-empty, +.passport-note { + color: rgba(244, 251, 248, 0.68); } -.progress-track { - height: 10px; - overflow: hidden; - border-radius: 999px; - background: #e3ebe7; +.score-ring { + display: grid; + place-items: center; + flex: 0 0 84px; + width: 84px; + height: 84px; + border-radius: 50%; + background: conic-gradient(#5ee0bd 0deg, rgba(255, 255, 255, 0.12) 0deg); + position: relative; } -.progress-track span { - display: block; - width: 0; - height: 100%; +.score-ring::after { + content: ""; + position: absolute; + inset: 8px; border-radius: inherit; - background: linear-gradient(90deg, var(--accent), var(--accent-2)); - transition: width 360ms ease; + background: #121d20; +} + +.score-ring strong, +.score-ring span { + position: relative; + z-index: 1; +} + +.score-ring strong { + font-size: 24px; +} + +.score-ring span { + margin-top: 28px; + font-size: 11px; + color: rgba(244, 251, 248, 0.62); + position: absolute; +} + +.passport-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.passport-metric, +.passport-action, +.achievement-card { + display: grid; + gap: 4px; + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: rgba(255, 255, 255, 0.055); +} + +.passport-metric strong { + color: #fff; + font-size: 14px; +} + +.passport-actions, +.achievement-strip { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.achievement-strip { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.achievement-card strong::before { + content: ""; + display: inline-block; + width: 7px; + height: 7px; + margin-right: 6px; + border-radius: 50%; + background: #5ee0bd; + box-shadow: 0 0 16px rgba(94, 224, 189, 0.7); +} + +.achievement-card.muted strong::before { + background: #91a39d; + box-shadow: none; +} + +.vehicle-timeline { + display: grid; + gap: 8px; +} + +.timeline-title { + color: #fff; + font-weight: 700; +} + +.timeline-item { + display: grid; + grid-template-columns: 16px 1fr; + gap: 8px; + align-items: start; +} + +.timeline-item > span { + width: 9px; + height: 9px; + margin-top: 5px; + border-radius: 50%; + background: #5ee0bd; +} + +.timeline-item.service > span, +.timeline-item.service_visit > span { + background: #8bb9ff; +} + +.timeline-item.achievement > span { + background: #f0c66a; } .stat { @@ -304,13 +418,15 @@ body.auth-required .shell { padding: 8px; } - .progress-strip, + .passport-grid, + .passport-actions, + .achievement-strip, .report-grid { grid-template-columns: 1fr; } - .progress-strip small { - grid-column: auto; + .passport-head { + align-items: flex-start; } } @@ -356,13 +472,15 @@ body.auth-required .shell { padding: 8px; } - .progress-strip, + .passport-grid, + .passport-actions, + .achievement-strip, .report-grid { grid-template-columns: 1fr; } - .progress-strip small { - grid-column: auto; + .passport-head { + align-items: flex-start; } } @@ -1030,13 +1148,6 @@ button.is-busy { display: none; } -.progress-strip { - border-radius: 8px; - background: - linear-gradient(90deg, rgba(18, 115, 95, 0.08), rgba(47, 111, 159, 0.08)), - #fff; -} - .status-bar { min-height: 36px; display: flex;