Compare commits

..
5 Commits
Author SHA1 Message Date
ik 907ad537e2 fix: accept non-hex alembic revision ids
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-11 07:58:40 +07:00
ik 51728bf3f2 test: clean up concurrent cooldown processes 2026-09-11 07:56:01 +07:00
ik 8f76f70409 feat: add activity level legend to spot view 2026-09-11 07:54:16 +07:00
ik 8c94d40766 feat: add one-click coordinate copying 2026-09-11 07:52:07 +07:00
ik 1ffaf7478d fix: synchronize normalized records on import revisions 2026-09-11 07:50:31 +07:00
5 changed files with 45 additions and 21 deletions
+8
View File
@@ -206,6 +206,14 @@ def _import_records_locked(session: Session, *, url: str, region: str, category:
if old_val != new_val: if old_val != new_val:
changed_fields[field] = {"old": old_val, "new": new_val} changed_fields[field] = {"old": old_val, "new": new_val}
if changed_fields: if changed_fields:
# Keep the queryable normalized record in sync with its
# versioned source payload.
report.fish = fish
report.waterbody = waterbody
report.bait = bait
report.weight_g = raw.weight_g
report.player_name = raw.player
report.caught_at = caught
report.raw_payload = payload report.raw_payload = payload
report.source_url = url report.source_url = url
session.add(ImportRecordEvent( session.add(ImportRecordEvent(
+12 -1
View File
@@ -32,9 +32,10 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}> <Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
<a class="back" href="/">← Все активные точки</a> <a class="back" href="/">← Все активные точки</a>
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <> {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><CoordinateRadar x={spot.x} y={spot.y}/></section> <section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><button class="coordinate-copy" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><small class="copy-status" aria-live="polite"></small></div><CoordinateRadar x={spot.x} y={spot.y}/></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> <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>
<ActivityTimeline buckets={timeline}/> <ActivityTimeline buckets={timeline}/>
<div class="activity-legend" aria-label="Уровни активности"><span>Тихо</span><span>Есть сигналы</span><span>Горячо</span></div>
<section class="detail-grid"> <section class="detail-grid">
<div> <div>
<div class="section-heading"><h2>Последние уловы</h2></div> <div class="section-heading"><h2>Последние уловы</h2></div>
@@ -44,3 +45,13 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
</section> </section>
</>} </>}
</Layout> </Layout>
<script is:inline>
document.querySelectorAll<HTMLButtonElement>("[data-copy-coordinates]").forEach((button) => {
button.addEventListener("click", async () => {
const value = button.dataset.copyCoordinates || "";
const status = button.parentElement?.querySelector<HTMLElement>(".copy-status");
try { await navigator.clipboard.writeText(value); if (status) status.textContent = "Скопировано"; }
catch { if (status) status.textContent = value; }
});
});
</script>
+1
View File
@@ -20,6 +20,7 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
@media(max-width:480px){.filters{grid-template-columns:1fr 1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name{font-size:14px}.brand-name strong{font-size:17px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}} @media(max-width:480px){.filters{grid-template-columns:1fr 1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name{font-size:14px}.brand-name strong{font-size:17px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}}
@media(max-width:720px){.filter-advanced-fallback .filter-advanced-field{display:flex!important;align-items:center;gap:6px}.filter-advanced-fallback .filter-compact-hidden{display:flex!important}} @media(max-width:720px){.filter-advanced-fallback .filter-advanced-field{display:flex!important;align-items:center;gap:6px}.filter-advanced-fallback .filter-compact-hidden{display:flex!important}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}} @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
.activity-legend{display:flex;gap:10px;flex-wrap:wrap;margin:8px 0;color:#7d918b;font-size:11px}.activity-legend span:before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:4px;background:var(--lime)}.activity-legend span:nth-child(2):before{background:#e5b95c}.activity-legend span:nth-child(3):before{background:#ef795e}
/* RF4 field-guide iconography and float activity gauge */ /* RF4 field-guide iconography and float activity gauge */
.fish-icon{display:inline-block;flex:none;vertical-align:-.2em}.brand-mark .fish-icon{transform:rotate(-12deg)}.topbar nav a.active .fish-icon{color:#6b9229}.spot-meta span{display:flex;align-items:center;gap:4px}.bait-line>.fish-icon,.best-lure .fish-icon{color:#e19a43;transform:rotate(14deg)}.card-arrow{font-size:0}.detail-head>a{transition:background .2s ease,color .2s ease}.detail-head>a:hover{background:var(--lime);color:var(--deep)} .fish-icon{display:inline-block;flex:none;vertical-align:-.2em}.brand-mark .fish-icon{transform:rotate(-12deg)}.topbar nav a.active .fish-icon{color:#6b9229}.spot-meta span{display:flex;align-items:center;gap:4px}.bait-line>.fish-icon,.best-lure .fish-icon{color:#e19a43;transform:rotate(14deg)}.card-arrow{font-size:0}.detail-head>a{transition:background .2s ease,color .2s ease}.detail-head>a:hover{background:var(--lime);color:var(--deep)}
+3 -3
View File
@@ -49,7 +49,7 @@ if ! ALEMBIC_HEADS_OUTPUT=$($compose exec -T api alembic heads 2>/dev/null); the
exit 1 exit 1
fi fi
# Extract revision IDs (first field before space or '(head)'), handle multiple heads # Extract revision IDs (first field before space or '(head)'), handle multiple heads
ALEMBIC_HEAD=$(echo "$ALEMBIC_HEADS_OUTPUT" | grep -oE '^[a-f0-9]+' | head -1) ALEMBIC_HEAD=$(printf '%s\n' "$ALEMBIC_HEADS_OUTPUT" | awk 'NF {print $1; exit}')
DB_VERSION=$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version') DB_VERSION=$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')
if [ -z "$ALEMBIC_HEAD" ]; then if [ -z "$ALEMBIC_HEAD" ]; then
@@ -63,10 +63,10 @@ if [ -z "$DB_VERSION" ]; then
fi fi
# Handle multiple heads: check if DB version matches any head # Handle multiple heads: check if DB version matches any head
HEAD_COUNT=$(echo "$ALEMBIC_HEADS_OUTPUT" | grep -cE '^[a-f0-9]+') || HEAD_COUNT=0 HEAD_COUNT=$(printf '%s\n' "$ALEMBIC_HEADS_OUTPUT" | awk 'NF {count++} END {print count+0}')
if [ "$HEAD_COUNT" -gt 1 ]; then if [ "$HEAD_COUNT" -gt 1 ]; then
echo "WARNING: Multiple Alembic heads detected ($HEAD_COUNT), checking if DB version matches any..." echo "WARNING: Multiple Alembic heads detected ($HEAD_COUNT), checking if DB version matches any..."
if ! echo "$ALEMBIC_HEADS_OUTPUT" | grep -q "^$DB_VERSION"; then if ! printf '%s\n' "$ALEMBIC_HEADS_OUTPUT" | awk '{print $1}' | grep -Fxq "$DB_VERSION"; then
echo "ERROR: DB version $DB_VERSION does not match any head. Heads: $ALEMBIC_HEADS_OUTPUT" >&2 echo "ERROR: DB version $DB_VERSION does not match any head. Heads: $ALEMBIC_HEADS_OUTPUT" >&2
exit 1 exit 1
fi fi
+21 -17
View File
@@ -137,24 +137,28 @@ def test_check_and_reserve_atomic_under_concurrent_access(tmp_path: Path) -> Non
import multiprocessing import multiprocessing
state_file = tmp_path / "concurrent.json" state_file = tmp_path / "concurrent.json"
results = multiprocessing.Manager().list() # Explicitly shut down the manager and never leak a timed-out child.
with multiprocessing.Manager() as manager:
results = manager.list()
processes = []
for i in range(3):
args = (i, str(state_file), results)
p = multiprocessing.Process(target=_try_reserve_for_test, args=(args,))
processes.append(p)
for p in processes:
p.start()
for p in processes:
p.join(timeout=10)
for p in processes:
if p.is_alive():
p.terminate()
p.join(timeout=2)
# Launch 3 processes simultaneously # At most one should succeed
processes = [] ok_count = sum(1 for _, r in results if r == "ok")
for i in range(3): assert ok_count == 1, f"Expected exactly 1 ok, got {ok_count}: {results}"
args = (i, str(state_file), results) denied_count = sum(1 for _, r in results if "cooldown" in r)
p = multiprocessing.Process(target=_try_reserve_for_test, args=(args,)) assert denied_count == 2, f"Expected 2 denied, got {denied_count}: {results}"
processes.append(p)
for p in processes:
p.start()
for p in processes:
p.join(timeout=10)
# At most one should succeed
ok_count = sum(1 for _, r in results if r == "ok")
assert ok_count == 1, f"Expected exactly 1 ok, got {ok_count}: {results}"
denied_count = sum(1 for _, r in results if "cooldown" in r)
assert denied_count == 2, f"Expected 2 denied, got {denied_count}: {results}"
# A03: Manual redirect control tests # A03: Manual redirect control tests