sync
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-08 09:31:41 +07:00
parent 8b2d7e2e1c
commit ddb6909c4d
21 changed files with 255 additions and 58 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ const limited = item.catches < 3;
<FishSilhouette name={item.fish}/>
<div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div>
<p class="data-note">{item.explanation}</p>
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} completeness={limited ? 75 : 100} confidence={item.confidence_score}/>
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} confidence={item.confidence_score}/>
<div class="bait-line"><FishingIcon name="lure" size={25}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div>
</div>
<div class="spot-stats"><div><strong>{item.catches}</strong><span>{plural(item.catches, ["улов", "улова", "уловов"])}</span></div><div><strong>{item.unique_players}</strong><span>{plural(item.unique_players, ["игрок", "игрока", "игроков"])}</span></div><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{item.confidence_score}%</strong><span>уверенность</span></div></div><span class="card-arrow"><FishingIcon name="arrow" size={22}/></span>
+4 -13
View File
@@ -1,19 +1,10 @@
---
import type { Catch } from "../lib/api";
const { catches } = Astro.props as { catches: Catch[] };
const now = Date.now();
const step = 12 * 60 * 60 * 1000;
const buckets = Array.from({ length: 6 }, (_, index) => ({ index, count: 0 }));
for (const item of catches) {
const timestamp = new Date(item.caught_at ?? item.reported_at).getTime();
const age = Math.floor((now - timestamp) / step);
if (age >= 0 && age < buckets.length) buckets[buckets.length - 1 - age].count += 1;
}
const { buckets } = Astro.props as { buckets: { start: string; end: string; count: number }[] };
const max = Math.max(1, ...buckets.map(bucket => bucket.count));
---
<section class="activity-timeline" aria-labelledby="timeline-title">
<header><div><span class="overline">Последние 72 часа</span><h2 id="timeline-title">Леска активности</h2></div><p>По показанным ниже уловам · шаг 12 часов</p></header>
<div class="timeline-chart" role="img" aria-label={`Распределение ${catches.length} показанных уловов за последние 72 часа`}>
{buckets.map((bucket, index) => <div class="timeline-slot"><span class="timeline-line" style={`--height:${Math.max(8, bucket.count / max * 100)}%`}><i></i></span><strong>{bucket.count}</strong><small>{index === 0 ? "72 ч" : index === 5 ? "сейчас" : `${(5-index)*12} ч`}</small></div>)}
<header><div><span class="overline">Последние 72 часа</span><h2 id="timeline-title">Леска активности</h2></div><p>Все одобренные записи · по времени поступления · шаг 12 часов</p></header>
<div class="timeline-chart">
{buckets.map((bucket, index) => <div class="timeline-slot"><span aria-hidden="true" class="timeline-line" style={`--height:${bucket.count / max * 100}%`}>{bucket.count > 0 && <i></i>}</span><strong>{bucket.count}</strong><small>{`${(6-index)*12}${(5-index)*12} ч назад`}</small></div>)}
</div>
</section>
+1 -1
View File
@@ -8,7 +8,7 @@ const { catches } = Astro.props as { catches: Catch[] };
const timestamp = item.caught_at ?? item.reported_at;
return <article>
<div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span><SourceBadge source={item.source_system} href={item.source_url}/></div>
<div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span><time datetime={timestamp} title={new Date(timestamp).toLocaleString("ru-RU")}>{ago(timestamp)}</time></div>
<div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span><span>{item.caught_at ? "Время улова" : "Получено · время улова неизвестно"}</span><time datetime={timestamp}>{ago(timestamp)} · {new Date(timestamp).toLocaleString("ru-RU", { timeZone: "UTC" })} UTC</time></div>
</article>;
})}
</div>
+3 -3
View File
@@ -1,10 +1,10 @@
---
import SourceBadge from "./SourceBadge.astro";
import { ago } from "../lib/api";
type Props = { sources: string[]; sourceUrl?: string | null; observedAt: string; completeness: number; confidence?: number | null; status?: "verified" | "unverified" | "incomplete" };
const { sources, sourceUrl, observedAt, completeness, confidence = null, status = "verified" } = Astro.props;
type Props = { sources: string[]; sourceUrl?: string | null; observedAt: string; completeness?: number | null; confidence?: number | null; status?: "verified" | "unverified" | "incomplete" };
const { sources, sourceUrl, observedAt, completeness = null, confidence = null, status = "verified" } = Astro.props;
const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные" };
const completenessLabel = completeness >= 100 ? "Полные" : `${Math.max(0, completeness)}% полей`;
const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`;
---
<section class="data-passport" aria-label="Паспорт данных">
<header><span>Паспорт данных</span><strong data-passport-status={status}>{statusLabels[status]}</strong></header>
+2 -1
View File
@@ -3,7 +3,8 @@ import SourceBadge from "./SourceBadge.astro";
import { ago, kg, type PublicObservation } from "../lib/api";
const { signals } = Astro.props as { signals: PublicObservation[] };
const groups = [...signals.reduce((map, signal) => {
const key = [signal.fish_name, signal.waterbody_name, signal.x, signal.y, signal.weight_g].join("|").toLocaleLowerCase("ru");
// Similar fields do not establish that two sources describe the same catch.
const key = JSON.stringify([signal.source_system, signal.id]);
const current = map.get(key);
if (!current) map.set(key, { ...signal, observations: [signal] });
else {