-
Профиль учета
-
Старт
+
+
+
+
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;