3 Commits

Author SHA1 Message Date
VPN SaaS Dev
f4be38f9b9 show vehicle facts in passport
Some checks failed
ci / test (push) Has been cancelled
2026-05-16 22:16:47 +09:00
VPN SaaS Dev
8ab296b675 compact vehicle card passport
Some checks failed
ci / test (push) Has been cancelled
2026-05-16 22:04:00 +09:00
VPN SaaS Dev
c98432ca7d docker-deploy-port-config
Some checks failed
ci / test (push) Has been cancelled
2026-05-16 21:30:19 +09:00
5 changed files with 309 additions and 27 deletions

View File

@@ -33,6 +33,13 @@ python -m scripts.bootstrap_admin
curl -fsS http://127.0.0.1:8000/ready curl -fsS http://127.0.0.1:8000/ready
``` ```
If port `8000` is already used on the host, set `APP_PORT` in `.env` and point the reverse proxy to that local port:
```bash
APP_PORT=8010
curl -fsS http://127.0.0.1:8010/ready
```
The default compose stack includes Postgres, Redis, API and bot services with health checks, restart policies and log rotation. The default compose stack includes Postgres, Redis, API and bot services with health checks, restart policies and log rotation.
Telegram notifications are the primary pilot notification channel. Browser push currently stores subscriptions and is treated as beta until server-side Web Push delivery is enabled. Telegram notifications are the primary pilot notification channel. Browser push currently stores subscriptions and is treated as beta until server-side Web Push delivery is enabled.

View File

@@ -59,7 +59,7 @@ services:
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
ports: ports:
- "127.0.0.1:8000:8000" - "127.0.0.1:${APP_PORT:-8000}:8000"
healthcheck: healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready', timeout=3).read()\""] test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready', timeout=3).read()\""]
interval: 10s interval: 10s

View File

@@ -78,23 +78,19 @@
<span>/100</span> <span>/100</span>
</div> </div>
</div> </div>
<div class="passport-vehicle-summary" id="passportVehicleSummary"></div>
<div class="passport-grid"> <div class="passport-grid">
<div class="passport-metric"> <div class="passport-metric">
<span>Profile quality</span> <span>Качество данных</span>
<strong id="scoreTitle">Старт</strong> <strong id="scoreTitle">Старт</strong>
</div> </div>
<div class="passport-metric"> <div class="passport-metric passport-metric-combo">
<span>Verified history</span> <span>История и ТО</span>
<strong id="verifiedHistoryStatus">Self-reported</strong> <strong id="verifiedHistoryStatus">Self-reported</strong>
</div> <small id="maintenanceStatus">Unknown</small>
<div class="passport-metric">
<span>Maintenance health</span>
<strong id="maintenanceStatus">Unknown</strong>
</div> </div>
</div> </div>
<div class="passport-actions" id="scoreActions"></div> <div class="passport-actions" id="scoreActions"></div>
<div class="achievement-strip" id="achievementList"></div>
<div class="vehicle-timeline" id="vehicleTimeline"></div>
</section> </section>
<div class="status-bar" id="statusBar" aria-live="polite">Готов к работе</div> <div class="status-bar" id="statusBar" aria-live="polite">Готов к работе</div>

View File

@@ -313,6 +313,9 @@ const state = {
selectedCarId: null, selectedCarId: null,
latestFuel: [], latestFuel: [],
latestService: [], latestService: [],
latestFuelAllTime: [],
latestServiceAllTime: [],
selectedCarHighlights: null,
latestExpenses: [], latestExpenses: [],
latestStats: null, latestStats: null,
allStats: null, allStats: null,
@@ -329,7 +332,6 @@ const state = {
connectedServices: [], connectedServices: [],
adminPendingServices: [], adminPendingServices: [],
vehicleScore: null, vehicleScore: null,
vehicleTimeline: [],
achievements: [], achievements: [],
receiptFile: null, receiptFile: null,
serviceWorkerRegistration: null, serviceWorkerRegistration: null,
@@ -901,10 +903,11 @@ function renderCars() {
.map( .map(
(car) => ` (car) => `
<button class="car-item ${car.id === state.selectedCarId ? "active" : ""}" data-car="${car.id}"> <button class="car-item ${car.id === state.selectedCarId ? "active" : ""}" data-car="${car.id}">
<span class="car-badge">${(car.make || car.name).slice(0, 2).toUpperCase()}</span> <span class="car-badge">${escapeHtml((car.make || car.name).slice(0, 2).toUpperCase())}</span>
<span class="car-copy"> <span class="car-copy">
<strong>${car.name}</strong> <strong>${escapeHtml(car.name)}</strong>
<small>${[car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей")}</small> <small>${escapeHtml([car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей"))}</small>
${car.id === state.selectedCarId ? renderSelectedCarHighlights(car) : ""}
</span> </span>
</button> </button>
`, `,
@@ -917,6 +920,84 @@ function renderCars() {
}); });
} }
function renderSelectedCarHighlights(car) {
const highlights = state.selectedCarHighlights?.carId === car.id ? state.selectedCarHighlights : buildCarHighlights(car, [], []);
const rows = [
["Одометр", highlights.odometer],
["Заправка", highlights.lastFuel],
["Масло", highlights.lastOil],
["До масла", highlights.oilRemaining],
];
return `
<span class="car-key-params">
${rows
.map(
([label, value]) => `
<span class="car-key-param">
<span>${label}</span>
<strong>${escapeHtml(value)}</strong>
</span>
`,
)
.join("")}
</span>
`;
}
function buildCarHighlights(car, fuelEntries, serviceEntries) {
const currentOdometer = Number(car?.current_odometer || 0);
const lastFuel = fuelEntries[0] || null;
const lastOil = serviceEntries.find(isOilService) || null;
const oilDueOdometer = oilDueKm(car, lastOil);
const oilRemainingKm = oilDueOdometer != null && car?.current_odometer != null
? oilDueOdometer - currentOdometer
: null;
return {
carId: car?.id,
odometer: car?.current_odometer != null ? `${formatKm(car.current_odometer)}` : "-",
lastFuel: lastFuel ? `${formatShortDate(lastFuel.entry_date)} · ${formatLiters(lastFuel.liters)}` : "-",
lastOil: lastOil ? formatShortDate(lastOil.entry_date) : "-",
oilRemaining: formatOilRemaining(oilRemainingKm),
};
}
function isOilService(item) {
const text = [item?.title, item?.category, item?.notes, item?.service_type].filter(Boolean).join(" ").toLowerCase();
return /масл|oil|engine_oil/.test(text);
}
function oilDueKm(car, latestOil) {
if (latestOil?.next_due_odometer != null) return Number(latestOil.next_due_odometer);
const interval = Number(car?.oil_change_interval_km || 0);
if (!interval) return null;
if (latestOil?.odometer != null) return Number(latestOil.odometer) + interval;
if (car?.current_odometer != null) return Number(car.current_odometer) + interval;
return null;
}
function formatKm(value) {
if (value == null || value === "") return "-";
return `${Number(value).toLocaleString("ru-RU")} км`;
}
function formatLiters(value) {
if (value == null || value === "") return "-";
return `${Number(value).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} л`;
}
function formatShortDate(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);
return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit" });
}
function formatOilRemaining(value) {
if (value == null || Number.isNaN(value)) return "-";
if (value < 0) return `просрочено ${formatKm(Math.abs(value))}`;
return formatKm(value);
}
function setInputValue(form, name, value) { function setInputValue(form, name, value) {
if (!form?.elements[name]) return; if (!form?.elements[name]) return;
const input = form.elements[name]; const input = form.elements[name];
@@ -1902,11 +1983,60 @@ function updateScore() {
ring.style.background = `conic-gradient(#5ee0bd ${score * 3.6}deg, rgba(255,255,255,0.12) 0deg)`; ring.style.background = `conic-gradient(#5ee0bd ${score * 3.6}deg, rgba(255,255,255,0.12) 0deg)`;
} }
document.querySelector("#scoreHint").textContent = car document.querySelector("#scoreHint").textContent = car
? "Качество паспорта растет от подтвержденных данных, сервисной истории и точного пробега." ? "Короткая сводка по полноте данных и готовности к обслуживанию."
: t("Добавь авто и первую запись, чтобы видеть точные отчеты"); : t("Добавь авто и первую запись, чтобы видеть точные отчеты");
renderPassportVehicleSummary(car);
renderScoreActions(state.vehicleScore?.missing_items || []); renderScoreActions(state.vehicleScore?.missing_items || []);
renderAchievements(); }
renderVehicleTimeline();
function renderPassportVehicleSummary(car) {
const root = document.querySelector("#passportVehicleSummary");
if (!root) return;
if (!car) {
root.innerHTML = `
<div class="passport-vehicle-empty">
<strong>Выберите автомобиль</strong>
<span>Здесь появятся номер, VIN, пробег, заправка и ближайшее ТО.</span>
</div>
`;
return;
}
const highlights = state.selectedCarHighlights?.carId === car.id ? state.selectedCarHighlights : buildCarHighlights(car, [], []);
const identity = [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || "Паспорт без деталей";
const plate = car.license_plate_display || car.plate_number || "номер не указан";
const vin = car.vin || "VIN не указан";
const oilSpec = [car.engine_oil_type, car.engine_oil_volume_l ? `${Number(car.engine_oil_volume_l).toLocaleString("ru-RU")} л` : ""]
.filter(Boolean)
.join(" · ");
const fuelAndOil = [car.fuel_type || "топливо не указано", oilSpec || "масло не указано"].join(" · ");
const rows = [
["Одометр", highlights.odometer],
["Последняя заправка", highlights.lastFuel],
["Замена масла", highlights.lastOil],
["До следующей", highlights.oilRemaining],
];
root.innerHTML = `
<div class="passport-vehicle-main">
<div>
<strong>${escapeHtml(car.name)}</strong>
<span>${escapeHtml(identity)}</span>
</div>
<a class="passport-edit-link" href="/car_profile.html?car_id=${car.id}">Паспорт</a>
</div>
<div class="passport-vehicle-id">
<span>${escapeHtml(plate)}</span>
<span>${escapeHtml(vin)}</span>
<span>${escapeHtml(fuelAndOil)}</span>
</div>
<div class="passport-vehicle-facts">
${rows.map(([label, value]) => `
<div>
<span>${label}</span>
<strong>${escapeHtml(value)}</strong>
</div>
`).join("")}
</div>
`;
} }
function scoreLabel(quality, score) { function scoreLabel(quality, score) {
@@ -1938,9 +2068,9 @@ function healthLabel(status) {
function renderScoreActions(items) { function renderScoreActions(items) {
const root = document.querySelector("#scoreActions"); const root = document.querySelector("#scoreActions");
if (!root) return; if (!root) return;
const visible = items.slice(0, 3); const visible = items.slice(0, 1);
if (!visible.length) { if (!visible.length) {
root.innerHTML = `<div class="passport-note">Паспорт выглядит надежно. Следующий рост даст подтвержденная сервисная история.</div>`; root.innerHTML = `<div class="passport-note">Паспорт готов: ключевые данные, история и ТО собраны достаточно для пилота.</div>`;
return; return;
} }
root.innerHTML = visible root.innerHTML = visible
@@ -2339,6 +2469,7 @@ async function applyInitialRoute() {
async function selectCar(carId) { async function selectCar(carId) {
state.selectedCarId = carId; state.selectedCarId = carId;
state.selectedCarHighlights = null;
renderCars(); renderCars();
fillCarProfileForm(); fillCarProfileForm();
await loadSelectedCar(); await loadSelectedCar();
@@ -2348,17 +2479,19 @@ async function loadSelectedCar() {
if (!state.selectedCarId) { if (!state.selectedCarId) {
state.latestFuel = []; state.latestFuel = [];
state.latestService = []; state.latestService = [];
state.latestFuelAllTime = [];
state.latestServiceAllTime = [];
state.selectedCarHighlights = null;
state.latestExpenses = []; state.latestExpenses = [];
state.latestStats = null; state.latestStats = null;
state.allStats = null; state.allStats = null;
state.analytics = null; state.analytics = null;
state.vehicleScore = null; state.vehicleScore = null;
state.vehicleTimeline = [];
state.achievements = []; state.achievements = [];
renderStats(null); renderStats(null);
return; return;
} }
const [stats, allStats, fuel, service, expenses, analytics, vehicleScore] = await Promise.all([ const [stats, allStats, fuel, service, expenses, analytics, vehicleScore, allFuel, allService] = await Promise.all([
api(`/cars/${state.selectedCarId}/stats${periodQuery()}`), api(`/cars/${state.selectedCarId}/stats${periodQuery()}`),
api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`), api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`),
api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`), api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`),
@@ -2366,20 +2499,20 @@ async function loadSelectedCar() {
api(`/cars/${state.selectedCarId}/expenses${periodQuery()}`), api(`/cars/${state.selectedCarId}/expenses${periodQuery()}`),
api(`/cars/${state.selectedCarId}/analytics`), api(`/cars/${state.selectedCarId}/analytics`),
api(`/my/vehicles/${state.selectedCarId}/score`), api(`/my/vehicles/${state.selectedCarId}/score`),
]); api(`/cars/${state.selectedCarId}/fuel?limit=1`),
const [timeline, achievements] = await Promise.all([ api(`/cars/${state.selectedCarId}/service?limit=100`),
api(`/my/vehicles/${state.selectedCarId}/timeline?limit=30`),
api("/me/achievements"),
]); ]);
state.latestStats = stats; state.latestStats = stats;
state.allStats = allStats; state.allStats = allStats;
state.latestFuel = fuel; state.latestFuel = fuel;
state.latestService = service; state.latestService = service;
state.latestFuelAllTime = allFuel;
state.latestServiceAllTime = allService;
state.latestExpenses = expenses; state.latestExpenses = expenses;
state.analytics = analytics; state.analytics = analytics;
state.vehicleScore = vehicleScore; state.vehicleScore = vehicleScore;
state.vehicleTimeline = timeline; state.selectedCarHighlights = buildCarHighlights(selectedCar(), allFuel, allService);
state.achievements = achievements; renderCars();
renderStats(stats); renderStats(stats);
drawCharts(fuel, service, stats); drawCharts(fuel, service, stats);
} }

View File

@@ -70,8 +70,13 @@ body.auth-required .shell {
} }
.passport-head small, .passport-head small,
.passport-metric small,
.passport-metric span, .passport-metric span,
.passport-action span, .passport-action span,
.passport-vehicle-empty span,
.passport-vehicle-main span,
.passport-vehicle-id,
.passport-vehicle-facts span,
.achievement-card span, .achievement-card span,
.timeline-item small, .timeline-item small,
.timeline-empty, .timeline-empty,
@@ -134,6 +139,103 @@ body.auth-required .shell {
font-size: 14px; font-size: 14px;
} }
.passport-metric-combo small {
display: block;
margin-top: 2px;
}
.passport-vehicle-summary {
display: grid;
gap: 9px;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.085), rgba(255, 255, 255, 0.035));
}
.passport-vehicle-empty,
.passport-vehicle-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.passport-vehicle-empty {
align-items: start;
flex-direction: column;
gap: 3px;
}
.passport-vehicle-main strong,
.passport-vehicle-empty strong {
display: block;
color: #fff;
font-size: 16px;
}
.passport-vehicle-main span {
display: block;
margin-top: 2px;
font-size: 12px;
}
.passport-edit-link {
flex: 0 0 auto;
padding: 7px 10px;
border: 1px solid rgba(94, 224, 189, 0.3);
border-radius: 8px;
color: #b9f7e7;
font-size: 12px;
font-weight: 700;
text-decoration: none;
background: rgba(94, 224, 189, 0.08);
}
.passport-vehicle-id {
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 12px;
}
.passport-vehicle-id span {
min-width: 0;
max-width: 100%;
padding: 4px 7px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
overflow-wrap: anywhere;
}
.passport-vehicle-facts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
}
.passport-vehicle-facts div {
display: grid;
gap: 3px;
min-width: 0;
padding: 8px;
border-radius: 8px;
background: rgba(12, 20, 22, 0.34);
}
.passport-vehicle-facts span {
font-size: 11px;
line-height: 1.2;
}
.passport-vehicle-facts strong {
color: #fff;
font-size: 13px;
line-height: 1.25;
overflow-wrap: anywhere;
}
.passport-grid, .passport-grid,
.passport-actions, .passport-actions,
.achievement-strip { .achievement-strip {
@@ -816,6 +918,36 @@ select:disabled {
color: var(--muted); color: var(--muted);
} }
.car-key-params {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin-top: 9px;
}
.car-key-param {
display: grid;
gap: 2px;
min-width: 0;
padding: 7px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.78);
}
.car-key-param span {
color: var(--muted);
font-size: 11px;
line-height: 1.2;
}
.car-key-param strong {
color: var(--text);
font-size: 12px;
line-height: 1.25;
overflow-wrap: anywhere;
}
.car-item.active { .car-item.active {
border-color: rgba(22, 128, 106, 0.48); border-color: rgba(22, 128, 106, 0.48);
background: #e5f4ef; background: #e5f4ef;
@@ -2159,11 +2291,25 @@ select {
white-space: nowrap; white-space: nowrap;
} }
.car-key-param strong {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.passport-head { .passport-head {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
} }
.passport-vehicle-main {
align-items: start;
}
.passport-vehicle-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.score-ring { .score-ring {
width: 72px; width: 72px;
height: 72px; height: 72px;