Skip to content

Commit 154ef55

Browse files
committed
Auto-refresh
1 parent 11bf64b commit 154ef55

File tree

6 files changed

+149
-11
lines changed

6 files changed

+149
-11
lines changed

backend/app/main.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from fastapi import Depends, FastAPI, HTTPException, Query
99
from fastapi.middleware.cors import CORSMiddleware
1010
from httpx import HTTPError
11-
from sqlalchemy import select
11+
from sqlalchemy import func, select
1212
from sqlalchemy.orm import Session
1313

1414
from .db import get_db, init_db
@@ -26,6 +26,7 @@
2626
CompareResponse,
2727
GeocodeResultOut,
2828
ListingOut,
29+
ListingSummaryOut,
2930
ListingUpsert,
3031
ReverseGeocodeOut,
3132
TargetOut,
@@ -168,6 +169,26 @@ def list_listings(db: DbDep) -> list[Listing]:
168169
return list(db.scalars(select(Listing).order_by(Listing.captured_at.desc())))
169170

170171

172+
@app.get("/api/listings/summary", response_model=ListingSummaryOut)
173+
def listing_summary(db: DbDep) -> ListingSummaryOut:
174+
total = int(db.scalar(select(func.count(Listing.id))) or 0)
175+
row = db.execute(
176+
select(Listing.id, Listing.captured_at)
177+
.order_by(Listing.captured_at.desc())
178+
.limit(1)
179+
).first()
180+
latest_id: str | None = None
181+
latest_captured_at: datetime | None = None
182+
if row:
183+
latest_id = row[0]
184+
latest_captured_at = row[1]
185+
if isinstance(latest_captured_at, datetime) and latest_captured_at.tzinfo is None:
186+
latest_captured_at = latest_captured_at.replace(tzinfo=timezone.utc)
187+
return ListingSummaryOut(
188+
count=total, latest_id=latest_id, latest_captured_at=latest_captured_at
189+
)
190+
191+
171192
@app.delete("/api/listings/{listing_id}")
172193
def delete_listing(listing_id: str, db: DbDep) -> dict[str, bool]:
173194
listing = db.scalar(select(Listing).where(Listing.id == listing_id))

backend/app/schemas.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ class ListingOut(BaseModel):
5252
captured_at: datetime
5353

5454

55+
class ListingSummaryOut(BaseModel):
56+
count: int
57+
latest_id: str | None = None
58+
latest_captured_at: datetime | None = None
59+
60+
5561
class TargetUpsert(BaseModel):
5662
id: str | None = None
5763
name: str = Field(min_length=1, max_length=256)

backend/tests/test_targets_compare.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,30 @@ def fake_reverse_geocode(lat: float, lng: float, *, zoom: int = 10) -> ReverseGe
160160

161161
assert items[1]["metrics"]["distance_km"] is None
162162

163+
164+
def test_listings_summary_empty_then_one() -> None:
165+
with TestClient(main.app) as client:
166+
empty = client.get("/api/listings/summary")
167+
assert empty.status_code == 200, empty.text
168+
assert empty.json() == {"count": 0, "latest_id": None, "latest_captured_at": None}
169+
170+
created = client.post(
171+
"/api/listings",
172+
json={
173+
"source": "airbnb",
174+
"source_url": "https://www.airbnb.com/rooms/summary-test",
175+
"title": "Summary test",
176+
"currency": "USD",
177+
"price_period": "unknown",
178+
"captured_at": "2026-01-30T10:00:00Z",
179+
},
180+
)
181+
assert created.status_code == 200, created.text
182+
listing_id = created.json()["id"]
183+
184+
after = client.get("/api/listings/summary")
185+
assert after.status_code == 200, after.text
186+
data = after.json()
187+
assert data["count"] == 1
188+
assert data["latest_id"] == listing_id
189+
assert data["latest_captured_at"] == "2026-01-30T10:00:00Z"

frontend/src/MapView.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type RouteSummary = {
1414

1515
type Props = {
1616
target: Target | null
17+
initialCenter: { lat: number; lng: number } | null
1718
items: CompareItem[]
1819
selectedListingId: string | null
1920
onSelectListingId: (id: string) => void
@@ -89,6 +90,7 @@ function markerIcon(kind: 'target' | 'listing', opts?: { selected?: boolean }) {
8990

9091
export default function MapView({
9192
target,
93+
initialCenter,
9294
items,
9395
selectedListingId,
9496
onSelectListingId,
@@ -152,10 +154,10 @@ export default function MapView({
152154
}
153155
await loadGoogleMaps()
154156
if (cancelled) return
155-
const startCenter = target ? { lat: target.lat, lng: target.lng } : US_CENTER
157+
const startCenter = initialCenter ?? (target ? { lat: target.lat, lng: target.lng } : US_CENTER)
156158
const map = new google.maps.Map(containerRef.current!, {
157159
center: startCenter,
158-
zoom: target ? 11 : 4,
160+
zoom: target || initialCenter ? 11 : 4,
159161
mapTypeControl: false,
160162
streetViewControl: false,
161163
fullscreenControl: false,

frontend/src/api.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ export type CompareResponse = {
3333
items: CompareItem[]
3434
}
3535

36+
export type ListingSummary = {
37+
count: number
38+
latest_id: string | null
39+
latest_captured_at: string | null
40+
}
41+
3642
async function parseJsonOrThrow(res: Response): Promise<unknown> {
3743
const text = await res.text()
3844
if (!res.ok) {
@@ -55,6 +61,11 @@ export async function deleteListing(id: string): Promise<void> {
5561
await parseJsonOrThrow(res)
5662
}
5763

64+
export async function fetchListingsSummary(): Promise<ListingSummary> {
65+
const res = await fetch(apiUrl('/api/listings/summary'), { method: 'GET' })
66+
return (await parseJsonOrThrow(res)) as ListingSummary
67+
}
68+
5869
export async function upsertTarget(payload: {
5970
id?: string
6071
name: string

frontend/src/pages/ComparePage.tsx

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import '../App.css'
2-
import { useCallback, useEffect, useMemo, useState } from 'react'
2+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
33
import { Link } from 'react-router-dom'
44

55
import MapView from '../MapView'
6-
import type { CompareItem } from '../api'
7-
import { deleteListing, fetchCompare, reverseGeocode, upsertTarget } from '../api'
6+
import type { CompareItem, ListingSummary } from '../api'
7+
import {
8+
deleteListing,
9+
fetchCompare,
10+
fetchListingsSummary,
11+
reverseGeocode,
12+
upsertTarget,
13+
} from '../api'
814

915
type SortKey = 'distance' | 'price'
1016
type TargetLocationMode = 'address' | 'coords'
@@ -108,6 +114,9 @@ function App() {
108114
const [loading, setLoading] = useState(false)
109115
const [error, setError] = useState<string | null>(null)
110116

117+
const lastListingSummaryRef = useRef<ListingSummary | null>(null)
118+
const isAutoRefreshingRef = useRef(false)
119+
111120
useEffect(() => {
112121
localStorage.setItem('easyrelocate_target_location_mode', targetLocationMode)
113122
}, [targetLocationMode])
@@ -152,19 +161,22 @@ function App() {
152161
}, [selectedItem])
153162

154163
const refresh = useCallback(
155-
async (opts?: { nextTargetId?: string | null }) => {
164+
async (opts?: { nextTargetId?: string | null; silent?: boolean }) => {
156165
const id = opts?.nextTargetId ?? targetId
157166
if (!id) return
158-
setLoading(true)
159-
setError(null)
167+
const silent = opts?.silent ?? false
168+
if (!silent) {
169+
setLoading(true)
170+
setError(null)
171+
}
160172
try {
161173
const res = await fetchCompare(id)
162174
setTarget(res.target)
163175
setCompareItems(res.items)
164176
} catch (e) {
165-
setError(e instanceof Error ? e.message : String(e))
177+
if (!silent) setError(e instanceof Error ? e.message : String(e))
166178
} finally {
167-
setLoading(false)
179+
if (!silent) setLoading(false)
168180
}
169181
},
170182
[targetId],
@@ -175,6 +187,56 @@ function App() {
175187
void refresh()
176188
}, [refresh, targetId])
177189

190+
useEffect(() => {
191+
if (!targetId) return
192+
let cancelled = false
193+
194+
const checkForNewListings = async (opts?: { force?: boolean }) => {
195+
if (cancelled) return
196+
if (!opts?.force && document.hidden) return
197+
try {
198+
const summary = await fetchListingsSummary()
199+
if (cancelled) return
200+
201+
const prev = lastListingSummaryRef.current
202+
lastListingSummaryRef.current = summary
203+
const changed =
204+
prev != null &&
205+
(prev.count !== summary.count ||
206+
prev.latest_id !== summary.latest_id ||
207+
prev.latest_captured_at !== summary.latest_captured_at)
208+
209+
if (changed && !isAutoRefreshingRef.current) {
210+
isAutoRefreshingRef.current = true
211+
try {
212+
await refresh({ nextTargetId: targetId, silent: true })
213+
} finally {
214+
isAutoRefreshingRef.current = false
215+
}
216+
}
217+
} catch {
218+
// ignore (offline / backend down)
219+
}
220+
}
221+
222+
void checkForNewListings({ force: true })
223+
224+
const interval = window.setInterval(() => {
225+
void checkForNewListings()
226+
}, 7000)
227+
228+
const onVisibilityChange = () => {
229+
if (!document.hidden) void checkForNewListings({ force: true })
230+
}
231+
document.addEventListener('visibilitychange', onVisibilityChange)
232+
233+
return () => {
234+
cancelled = true
235+
window.clearInterval(interval)
236+
document.removeEventListener('visibilitychange', onVisibilityChange)
237+
}
238+
}, [refresh, targetId])
239+
178240
const onSaveTarget = async () => {
179241
const address = targetAddress.trim()
180242
const lat = parseNumberOrNull(targetLat)
@@ -315,6 +377,14 @@ function App() {
315377
}, [filteredAndSorted])
316378

317379
const fitKey = target ? `${target.id}:${target.updated_at}` : 'no-target'
380+
const initialCenter = useMemo(() => {
381+
if (target) return { lat: target.lat, lng: target.lng }
382+
const lat = parseNumberOrNull(targetLat)
383+
const lng = parseNumberOrNull(targetLng)
384+
if (lat == null || lng == null) return null
385+
if (!isWithinUsBounds(lat, lng)) return null
386+
return { lat, lng }
387+
}, [target, targetLat, targetLng])
318388

319389
return (
320390
<div className="app">
@@ -665,6 +735,7 @@ function App() {
665735
<main className="mapWrap">
666736
<MapView
667737
target={target}
738+
initialCenter={initialCenter}
668739
items={filteredAndSorted}
669740
selectedListingId={selectedListingId}
670741
onSelectListingId={setSelectedListingId}

0 commit comments

Comments
 (0)