Build Dockerized MVP scaffold and records importer

This commit is contained in:
ik
2026-09-02 20:29:02 +07:00
parent a6f91a1329
commit d3a45248ef
39 changed files with 6638 additions and 13 deletions
@@ -0,0 +1,13 @@
---
import type { Activity } from "../lib/api";
import { ago, kg } from "../lib/api";
const { item } = Astro.props as { item: Activity };
---
<article class="card">
<div class="card-top"><div><span class="eyebrow">{item.waterbody}</span><h2>{item.fish}</h2></div><div class="score" aria-label={`Активность ${item.activity_score} из 100`}><strong>{item.activity_score}</strong><span>активность</span></div></div>
<a class="coordinates" href={`/spots/${item.spot_id}`}>Точка {item.x}:{item.y} <span>→</span></a>
<div class="bait"><span>Рабочая приманка</span><strong>{item.best_bait ?? "не указана"}</strong></div>
<dl class="stats"><div><dt>Уловов</dt><dd>{item.catches}</dd></div><div><dt>Игроков</dt><dd>{item.unique_players}</dd></div><div><dt>Средний</dt><dd>{kg(item.average_weight_g)}</dd></div><div><dt>Максимум</dt><dd>{kg(item.max_weight_g)}</dd></div></dl>
<p class="explanation">{item.explanation}</p>
<div class="fresh"><span class="pulse"></span> Обновлено {ago(item.last_confirmed_at)} · уверенность {item.confidence_score}/100</div>
</article>
+13
View File
@@ -0,0 +1,13 @@
---
import "../styles/global.css";
const { title = "RF4 Spotter" } = Astro.props;
---
<!doctype html>
<html lang="ru">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width" /><meta name="description" content="Свежие точки и статистика клёва Russian Fishing 4" /><title>{title}</title></head>
<body>
<header class="site-header"><a href="/" class="brand"><span>RF4</span> Spotter</a><nav><a href="/">Активность</a><a href="/records">Рекорды</a></nav><p>Свежие точки без догадок</p></header>
<main><slot /></main>
<footer>Неофициальный проект. Данные демонстрационные.</footer>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
export type Activity = {
spot_id: string; waterbody_slug: string; waterbody: string; fish_slug: string;
fish: string; x: number; y: number; best_bait: string | null; catches: number;
unique_players: number; average_weight_g: number; max_weight_g: number;
last_confirmed_at: string; activity_score: number; confidence_score: number;
explanation: string;
};
export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[] };
export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; retrieve_method: string | null; retrieve_speed: number | null };
export type DictionaryItem = { id: string; slug: string; name_ru: string };
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null };
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
const base = import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
export async function api<T>(path: string): Promise<T> {
const response = await fetch(`${base}${path}`);
if (!response.ok) throw new Error(`API ${response.status}`);
return response.json() as Promise<T>;
}
export function kg(grams: number) { return `${(grams / 1000).toFixed(2)} кг`; }
export function ago(value: string) {
const minutes = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 60000));
return minutes < 60 ? `${minutes} мин назад` : `${Math.floor(minutes / 60)} ч назад`;
}
+31
View File
@@ -0,0 +1,31 @@
---
import Layout from "../layouts/Layout.astro";
import ActivityCard from "../components/ActivityCard.astro";
import { api, type Activity, type DictionaryItem } from "../lib/api";
const params = Astro.url.searchParams;
const hours = params.get("hours") ?? "24";
const waterbody = params.get("waterbody") ?? "";
const fish = params.get("fish") ?? "";
const sort = params.get("sort") ?? "activity";
let items: Activity[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
let unavailable = false;
try {
[items, fishes, waterbodies] = await Promise.all([
api<Activity[]>(`/api/v1/activity?hours=${hours}&waterbody=${waterbody}&fish=${fish}&sort=${sort}`),
api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")
]);
} catch { unavailable = true; }
---
<Layout title="Что клюёт сейчас — RF4 Spotter">
<section class="hero"><div><span class="eyebrow">Сводка активности</span><h1>Что клюёт<br/><em>прямо сейчас</em></h1></div><p>Свежие подтверждения, рабочие приманки и честная оценка надёжности данных.</p></section>
<form class="filters" method="get">
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label>
<label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(x => <option value={x.slug} selected={fish === x.slug}>{x.name_ru}</option>)}</select></label>
<label>Период<select name="hours"><option value="6" selected={hours === "6"}>6 часов</option><option value="12" selected={hours === "12"}>12 часов</option><option value="24" selected={hours === "24"}>24 часа</option><option value="72" selected={hours === "72"}>72 часа</option></select></label>
<label>Сначала<select name="sort"><option value="activity" selected={sort === "activity"}>Активные</option><option value="confidence" selected={sort === "confidence"}>Надёжные</option><option value="freshness" selected={sort === "freshness"}>Свежие</option></select></label>
<button>Показать</button>
</form>
<div class="section-heading"><h2>Активные точки</h2><span>{items.length} комбинации</span></div>
{unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте обновить страницу позже.</p></div> : items.length ? <div class="grid">{items.map(item => <ActivityCard item={item} />)}</div> : <div class="state"><h2>За этот период данных нет</h2><p>Измените фильтры или выберите более длинный период.</p></div>}
</Layout>
+17
View File
@@ -0,0 +1,17 @@
---
import Layout from "../layouts/Layout.astro";
import { api, kg, type ImportRun, type OfficialRecord } from "../lib/api";
const params = Astro.url.searchParams;
const fish = params.get("fish") ?? "";
const waterbody = params.get("waterbody") ?? "";
let records: OfficialRecord[] = [], runs: ImportRun[] = [], unavailable = false;
try { [records, runs] = await Promise.all([api<OfficialRecord[]>(`/api/v1/records?fish=${fish}&waterbody=${waterbody}`), api<ImportRun[]>("/api/v1/imports?limit=1")]); } catch { unavailable = true; }
const last = runs[0];
---
<Layout title="Официальные рекорды — RF4 Spotter">
<section class="records-hero"><div><span class="eyebrow">Публичные данные RF4</span><h1>Официальные<br/><em>рекорды</em></h1></div><div class="source-status"><span class:list={["status-dot", last?.status]}></span><strong>{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}</strong>{last?.finished_at && <small>{new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк</small>}</div></section>
<form class="record-filters" method="get"><label>Slug рыбы<input name="fish" value={fish} placeholder="pike" /></label><label>Slug водоёма<input name="waterbody" value={waterbody} placeholder="vyunok" /></label><button>Фильтровать</button></form>
<div class="section-heading"><h2>Последние записи</h2><span>{records.length} показано</span></div>
{unavailable ? <div class="state"><h2>Источник временно недоступен</h2></div> : records.length ? <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата</span></div>{records.map(record => <article class="record-row"><strong>{record.fish}</strong><strong>{kg(record.weight_g)}</strong><span>{record.waterbody}</span><span>{record.bait ?? "—"}</span><span>{record.player_name ?? "—"}</span><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time></article>)}</div> : <div class="state"><h2>Рекорды ещё не импортированы</h2><p>Запустите <code>python -m app.cli import-records</code>. Пустой результат не подменяется демоданными.</p></div>}
<p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
</Layout>
+15
View File
@@ -0,0 +1,15 @@
---
import Layout from "../../layouts/Layout.astro";
import { api, kg, type Catch, type Spot } from "../../lib/api";
const { id } = Astro.params;
let spot: Spot | null = null, catches: Catch[] = [], unavailable = false;
try { [spot, catches] = await Promise.all([api<Spot>(`/api/v1/spots/${id}`), api<Catch[]>(`/api/v1/spots/${id}/catches`)]); } catch { unavailable = true; }
---
<Layout title={spot ? `${spot.waterbody} ${spot.x}:${spot.y} — RF4 Spotter` : "Точка — RF4 Spotter"}>
<a class="back" href="/">← Все активные точки</a>
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><div class="pin">{spot.x}<span>:</span>{spot.y}</div></section>
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
<section class="detail-grid"><div><div class="section-heading"><h2>Последние уловы</h2></div><div class="catch-list">{catches.map(item => <article><div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span></div><div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span></div></article>)}</div></div><aside><span class="eyebrow">Лучшие приманки</span><ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol><p class="note">Статистика построена только по одобренным демо-наблюдениям.</p></aside></section>
</>}
</Layout>
+22
View File
@@ -0,0 +1,22 @@
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Unbounded:wght@600;700&display=swap');
:root { color-scheme: dark; --bg:#07110f; --panel:#0d1c18; --line:#20332d; --ink:#f2f4e9; --muted:#93a69f; --accent:#d7f45b; --orange:#ff985a; font-family:Manrope,system-ui,sans-serif; }
* { box-sizing:border-box; }
body { margin:0; background:radial-gradient(circle at 15% -10%,#173b30 0,transparent 30rem),var(--bg); color:var(--ink); min-height:100vh; }
a { color:inherit; }
.site-header, main, footer { width:min(1180px,calc(100% - 40px)); margin:auto; }
.site-header { height:86px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }
.brand { text-decoration:none; font-family:Unbounded,sans-serif; font-weight:700; font-size:19px; }.brand span{color:var(--accent)}
.site-header nav{display:flex;gap:24px}.site-header nav a{font-size:13px;color:#bdcbc6;text-decoration:none}.site-header nav a:hover{color:var(--accent)}
.site-header p, footer { color:var(--muted); font-size:13px; } footer{padding:45px 0 30px}
.hero { padding:78px 0 55px; display:grid; grid-template-columns:1.4fr .6fr; align-items:end; gap:30px; }
.hero h1,.spot-hero h1 { font:700 clamp(42px,7vw,86px)/.98 Unbounded,sans-serif; letter-spacing:-.055em; margin:14px 0 0; }.hero h1 em{color:var(--accent);font-style:normal}.hero>p{font-size:18px;line-height:1.65;color:#b9c6c1;max-width:420px}
.eyebrow { color:var(--accent); font-size:11px; font-weight:800; letter-spacing:.14em; text-transform:uppercase; }
.filters { padding:18px; background:#0b1915cc; border:1px solid var(--line); display:grid; grid-template-columns:repeat(4,1fr) auto; gap:12px; border-radius:16px; position:sticky; top:10px; z-index:2; backdrop-filter:blur(16px); }
label { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.08em; } select { display:block; width:100%; margin-top:7px; border:0; color:var(--ink); background:#14251f; padding:12px; border-radius:8px; font:600 14px Manrope; } button{align-self:end;border:0;border-radius:8px;background:var(--accent);color:#102015;font-weight:800;padding:13px 24px;cursor:pointer}
.section-heading { display:flex; align-items:center; justify-content:space-between; margin:50px 0 20px; }.section-heading h2{font:600 22px Unbounded;margin:0}.section-heading span{color:var(--muted);font-size:13px}
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:18px; }.card{padding:28px;background:linear-gradient(145deg,#10231dcf,#0a1714);border:1px solid var(--line);border-radius:18px}.card-top{display:flex;justify-content:space-between;gap:20px}.card h2{font:700 30px Unbounded;margin:8px 0}.score{text-align:center;background:#182b22;border-radius:50%;width:82px;height:82px;display:flex;flex-direction:column;justify-content:center;flex:none}.score strong{font:700 27px Unbounded;color:var(--accent)}.score span{font-size:8px;text-transform:uppercase;color:var(--muted)}
.coordinates{display:flex;justify-content:space-between;background:var(--accent);color:#0b1713;padding:14px 17px;border-radius:9px;text-decoration:none;font-weight:800;margin:20px 0}.bait{display:flex;flex-direction:column;gap:5px}.bait span,.fresh{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;border-block:1px solid var(--line);padding:18px 0;margin:20px 0}.stats div{display:flex;flex-direction:column-reverse}.stats dt{font-size:10px;color:var(--muted)}.stats dd{font-weight:700;margin:0 0 3px}.explanation{min-height:48px;color:#bac8c3;font-size:13px;line-height:1.6}.pulse{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent);margin-right:5px}.state{padding:50px;border:1px dashed #385148;text-align:center;border-radius:16px;color:var(--muted)}.state h1,.state h2{color:var(--ink)}
.back{display:inline-block;margin:45px 0 25px;color:var(--muted);text-decoration:none}.spot-hero{display:flex;justify-content:space-between;align-items:center;padding:40px;background:linear-gradient(130deg,#142c24,#0b1714);border:1px solid var(--line);border-radius:20px}.spot-hero h1{font-size:clamp(36px,6vw,72px)}.spot-hero p{color:var(--muted)}.pin{font:700 40px Unbounded;color:var(--accent);border:1px solid #38523e;border-radius:50%;width:170px;height:170px;display:grid;place-content:center}.pin span{color:var(--orange)}.periods{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--line);border:1px solid var(--line);margin:20px 0;border-radius:14px;overflow:hidden}.periods div{background:var(--panel);padding:25px;text-align:center}.periods strong{display:block;font:700 32px Unbounded;color:var(--accent)}.periods span{font-size:12px;color:var(--muted)}.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:22px}.catch-list article{display:flex;justify-content:space-between;border-bottom:1px solid var(--line);padding:16px 2px}.catch-list article>div{display:flex;flex-direction:column}.catch-list article>div:last-child{text-align:right}.catch-list span{color:var(--muted);font-size:12px;margin-top:4px}aside{background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:25px;margin-top:50px}aside li{padding:11px 0;border-bottom:1px solid var(--line)}.note{font-size:12px;line-height:1.6;color:var(--muted)}
@media(max-width:800px){.site-header p{display:none}.hero{grid-template-columns:1fr;padding-top:50px}.filters{position:static;grid-template-columns:1fr 1fr}.filters button{grid-column:1/-1}.grid,.detail-grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr 1fr}.spot-hero{padding:25px}.pin{display:none}.periods div{padding:18px 8px}.periods strong{font-size:24px}}
@media(max-width:480px){.site-header,main,footer{width:min(100% - 24px,1180px)}.filters{grid-template-columns:1fr}.card{padding:20px}.hero h1{font-size:40px}.periods span{font-size:10px}}
.records-hero{padding:70px 0 45px;display:flex;align-items:end;justify-content:space-between;gap:30px}.records-hero h1{font:700 clamp(42px,7vw,78px)/1 Unbounded;margin:15px 0 0;letter-spacing:-.05em}.records-hero h1 em{color:var(--accent);font-style:normal}.source-status{display:grid;grid-template-columns:auto 1fr;gap:4px 9px;align-items:center;color:#c6d1cd}.source-status small{grid-column:2;color:var(--muted)}.status-dot{width:9px;height:9px;border-radius:50%;background:#66736e}.status-dot.success{background:var(--accent)}.status-dot.failed{background:#ff6f61}.record-filters{display:flex;gap:12px;padding:18px;border:1px solid var(--line);background:var(--panel);border-radius:14px}.record-filters label{flex:1}.record-filters input{display:block;width:100%;margin-top:7px;padding:12px;border:0;border-radius:8px;background:#14251f;color:var(--ink)}.record-table{border:1px solid var(--line);border-radius:14px;overflow:hidden}.record-row{display:grid;grid-template-columns:1.2fr .65fr 1.1fr 1.5fr 1fr .75fr;gap:14px;padding:16px 18px;border-bottom:1px solid var(--line);align-items:center}.record-row:last-child{border:0}.record-row span,.record-row time{font-size:13px;color:#aebdb7}.record-head{background:#14251f;text-transform:uppercase;letter-spacing:.07em}.record-head span{font-size:10px;color:var(--muted)}.official-note{color:var(--muted);font-size:12px;margin-top:20px}.official-note a{color:#bccf61}@media(max-width:800px){.site-header nav{gap:12px}.records-hero{display:block}.source-status{margin-top:30px}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}}