feat: initial project setup
This commit is contained in:
14
frontend/Dockerfile
Normal file
14
frontend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
# -- Build stage --
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# -- Serve stage --
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
20
frontend/index.html
Normal file
20
frontend/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>House ELO Ranking</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in { animation: fadeIn 0.3s ease-out; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-50 text-slate-800 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
17
frontend/nginx.conf
Normal file
17
frontend/nginx.conf
Normal file
@@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# API requests → backend
|
||||
location /api/ {
|
||||
proxy_pass http://elo-backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "house-elo-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"recharts": "^2.13.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
116
frontend/src/api.ts
Normal file
116
frontend/src/api.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/** API client for the House ELO Ranking backend. */
|
||||
|
||||
const BASE = "/api";
|
||||
|
||||
export interface Listing {
|
||||
global_id: string;
|
||||
tiny_id: string | null;
|
||||
url: string | null;
|
||||
title: string | null;
|
||||
city: string | null;
|
||||
postcode: string | null;
|
||||
province: string | null;
|
||||
neighbourhood: string | null;
|
||||
municipality: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
object_type: string | null;
|
||||
house_type: string | null;
|
||||
offering_type: string | null;
|
||||
construction_type: string | null;
|
||||
construction_year: string | null;
|
||||
energy_label: string | null;
|
||||
living_area: number | null;
|
||||
plot_area: number | null;
|
||||
bedrooms: number | null;
|
||||
rooms: number | null;
|
||||
has_garden: boolean | null;
|
||||
has_balcony: boolean | null;
|
||||
has_solar_panels: boolean | null;
|
||||
has_heat_pump: boolean | null;
|
||||
has_roof_terrace: boolean | null;
|
||||
is_energy_efficient: boolean | null;
|
||||
is_monument: boolean | null;
|
||||
current_price: number | null;
|
||||
status: string | null;
|
||||
price_per_sqm: number | null;
|
||||
publication_date: string | null;
|
||||
elo_rating: number;
|
||||
comparison_count: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
}
|
||||
|
||||
export interface Matchup {
|
||||
listing_a: Listing;
|
||||
listing_b: Listing;
|
||||
}
|
||||
|
||||
export interface CompareResult {
|
||||
winner_id: string;
|
||||
loser_id: string;
|
||||
elo_change: number;
|
||||
new_winner_elo: number;
|
||||
new_loser_elo: number;
|
||||
}
|
||||
|
||||
export interface RankedListing {
|
||||
rank: number;
|
||||
listing: Listing;
|
||||
}
|
||||
|
||||
export interface ComparisonHistory {
|
||||
id: number;
|
||||
listing_a_title: string | null;
|
||||
listing_b_title: string | null;
|
||||
winner_title: string | null;
|
||||
listing_a_id: string;
|
||||
listing_b_id: string;
|
||||
winner_id: string;
|
||||
elo_a_before: number;
|
||||
elo_b_before: number;
|
||||
elo_a_after: number;
|
||||
elo_b_after: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
total_comparisons: number;
|
||||
total_rated_listings: number;
|
||||
total_listings: number;
|
||||
avg_elo: number | null;
|
||||
max_elo: number | null;
|
||||
min_elo: number | null;
|
||||
elo_distribution: { bucket: string; count: number }[];
|
||||
recent_comparisons: ComparisonHistory[];
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, init);
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`API ${res.status}: ${body}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getMatchup: () => request<Matchup>("/matchup"),
|
||||
|
||||
submitComparison: (winner_id: string, loser_id: string) =>
|
||||
request<CompareResult>("/compare", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ winner_id, loser_id }),
|
||||
}),
|
||||
|
||||
getRankings: (limit = 100, offset = 0) =>
|
||||
request<RankedListing[]>(`/rankings?limit=${limit}&offset=${offset}`),
|
||||
|
||||
getHistory: (limit = 50) =>
|
||||
request<ComparisonHistory[]>(`/history?limit=${limit}`),
|
||||
|
||||
getStats: () => request<Stats>("/stats"),
|
||||
|
||||
getListings: () => request<Listing[]>("/listings"),
|
||||
};
|
||||
139
frontend/src/components/ListingCard.tsx
Normal file
139
frontend/src/components/ListingCard.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { Listing } from "../api";
|
||||
|
||||
interface Props {
|
||||
listing: Listing;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
function formatPrice(price: number | null): string {
|
||||
if (price == null) return "–";
|
||||
return `€${price.toLocaleString("nl-NL")}`;
|
||||
}
|
||||
|
||||
function Badge({ label, value }: { label: string; value: boolean | null }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<span className="inline-block text-xs bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ListingCard({
|
||||
listing,
|
||||
onClick,
|
||||
disabled,
|
||||
highlight,
|
||||
}: Props) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-full text-left bg-white rounded-xl border-2 p-5 transition-all ${
|
||||
highlight
|
||||
? "border-green-400 ring-2 ring-green-200"
|
||||
: "border-slate-200 hover:border-indigo-400 hover:shadow-md"
|
||||
} ${disabled ? "opacity-70 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{/* Title & link */}
|
||||
<div className="mb-3">
|
||||
<h3 className="font-bold text-lg text-slate-800 leading-tight">
|
||||
{listing.title || listing.global_id}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 mt-0.5">
|
||||
{[listing.city, listing.neighbourhood].filter(Boolean).join(" · ") ||
|
||||
"–"}
|
||||
</p>
|
||||
{listing.url && (
|
||||
<a
|
||||
href={listing.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs text-indigo-500 hover:underline"
|
||||
>
|
||||
View on Funda ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Key stats */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm mb-3">
|
||||
<div>
|
||||
<span className="text-slate-400">Price</span>{" "}
|
||||
<span className="font-semibold text-slate-700">
|
||||
{formatPrice(listing.current_price)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">Area</span>{" "}
|
||||
<span className="font-semibold text-slate-700">
|
||||
{listing.living_area ? `${listing.living_area} m²` : "–"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">€/m²</span>{" "}
|
||||
<span className="font-semibold text-slate-700">
|
||||
{listing.price_per_sqm
|
||||
? `€${Math.round(listing.price_per_sqm).toLocaleString("nl-NL")}`
|
||||
: "–"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">Rooms</span>{" "}
|
||||
<span className="font-semibold text-slate-700">
|
||||
{listing.rooms ?? "–"}
|
||||
{listing.bedrooms != null && ` (${listing.bedrooms} bed)`}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">Type</span>{" "}
|
||||
<span className="font-semibold text-slate-700">
|
||||
{listing.house_type || listing.object_type || "–"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">Year</span>{" "}
|
||||
<span className="font-semibold text-slate-700">
|
||||
{listing.construction_year || "–"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">Energy</span>{" "}
|
||||
<span className="font-semibold text-slate-700">
|
||||
{listing.energy_label || "–"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">Plot</span>{" "}
|
||||
<span className="font-semibold text-slate-700">
|
||||
{listing.plot_area ? `${listing.plot_area} m²` : "–"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature badges */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
<Badge label="Garden" value={listing.has_garden} />
|
||||
<Badge label="Balcony" value={listing.has_balcony} />
|
||||
<Badge label="Solar" value={listing.has_solar_panels} />
|
||||
<Badge label="Heat pump" value={listing.has_heat_pump} />
|
||||
<Badge label="Roof terrace" value={listing.has_roof_terrace} />
|
||||
<Badge label="Energy eff." value={listing.is_energy_efficient} />
|
||||
</div>
|
||||
|
||||
{/* ELO bar */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
|
||||
<span className="text-xs text-slate-400">
|
||||
ELO {listing.elo_rating.toFixed(0)} · {listing.wins}W–
|
||||
{listing.losses}L
|
||||
</span>
|
||||
<span className="text-xs font-medium text-indigo-600">
|
||||
{listing.comparison_count} comparisons
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
60
frontend/src/main.tsx
Normal file
60
frontend/src/main.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
|
||||
import CompareView from "./views/CompareView";
|
||||
import RankingsView from "./views/RankingsView";
|
||||
import HistoryView from "./views/HistoryView";
|
||||
import StatsView from "./views/StatsView";
|
||||
|
||||
function Nav() {
|
||||
const link = (to: string, label: string) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? "bg-indigo-600 text-white"
|
||||
: "text-slate-600 hover:bg-slate-200"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="bg-white border-b border-slate-200 sticky top-0 z-50">
|
||||
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center gap-2">
|
||||
<h1 className="text-lg font-bold text-indigo-700 mr-6">
|
||||
House ELO
|
||||
</h1>
|
||||
{link("/", "Compare")}
|
||||
{link("/rankings", "Rankings")}
|
||||
{link("/history", "History")}
|
||||
{link("/stats", "Stats")}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Nav />
|
||||
<main className="max-w-6xl mx-auto px-4 py-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<CompareView />} />
|
||||
<Route path="/rankings" element={<RankingsView />} />
|
||||
<Route path="/history" element={<HistoryView />} />
|
||||
<Route path="/stats" element={<StatsView />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
126
frontend/src/views/CompareView.tsx
Normal file
126
frontend/src/views/CompareView.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, type Listing, type Matchup, type CompareResult } from "../api";
|
||||
import ListingCard from "../components/ListingCard";
|
||||
|
||||
export default function CompareView() {
|
||||
const [matchup, setMatchup] = useState<Matchup | null>(null);
|
||||
const [result, setResult] = useState<CompareResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [comparisonCount, setComparisonCount] = useState(0);
|
||||
|
||||
const fetchMatchup = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
try {
|
||||
const m = await api.getMatchup();
|
||||
setMatchup(m);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load matchup");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMatchup();
|
||||
}, [fetchMatchup]);
|
||||
|
||||
const handlePick = async (winner: Listing, loser: Listing) => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await api.submitComparison(winner.global_id, loser.global_id);
|
||||
setResult(res);
|
||||
setComparisonCount((c) => c + 1);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to submit comparison");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 text-red-700 p-4 rounded-lg">
|
||||
<p className="font-medium">Error</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
<button
|
||||
onClick={fetchMatchup}
|
||||
className="mt-3 px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!matchup) return null;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-slate-800">
|
||||
Which house do you prefer?
|
||||
</h2>
|
||||
<p className="text-slate-500 text-sm mt-1">
|
||||
Session comparisons: {comparisonCount}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4 text-center animate-fade-in">
|
||||
<p className="text-green-800 font-medium">
|
||||
ELO change: +{result.elo_change.toFixed(1)} / -{result.elo_change.toFixed(1)}
|
||||
</p>
|
||||
<p className="text-green-600 text-sm">
|
||||
Winner: {result.new_winner_elo.toFixed(0)} · Loser:{" "}
|
||||
{result.new_loser_elo.toFixed(0)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<ListingCard
|
||||
listing={matchup.listing_a}
|
||||
onClick={() => handlePick(matchup.listing_a, matchup.listing_b)}
|
||||
disabled={submitting}
|
||||
highlight={result?.winner_id === matchup.listing_a.global_id}
|
||||
/>
|
||||
<ListingCard
|
||||
listing={matchup.listing_b}
|
||||
onClick={() => handlePick(matchup.listing_b, matchup.listing_a)}
|
||||
disabled={submitting}
|
||||
highlight={result?.winner_id === matchup.listing_b.global_id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4 mt-6">
|
||||
<button
|
||||
onClick={fetchMatchup}
|
||||
className="px-6 py-2 bg-slate-200 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-300 transition-colors"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
{result && (
|
||||
<button
|
||||
onClick={fetchMatchup}
|
||||
className="px-6 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Next pair →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
frontend/src/views/HistoryView.tsx
Normal file
80
frontend/src/views/HistoryView.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type ComparisonHistory } from "../api";
|
||||
|
||||
export default function HistoryView() {
|
||||
const [history, setHistory] = useState<ComparisonHistory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.getHistory(100).then(setHistory).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<h2 className="text-2xl font-bold text-slate-800 mb-4">
|
||||
Comparison History
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{history.map((h) => {
|
||||
const aWon = h.winner_id === h.listing_a_id;
|
||||
const eloChangeA = h.elo_a_after - h.elo_a_before;
|
||||
const eloChangeB = h.elo_b_after - h.elo_b_before;
|
||||
return (
|
||||
<div
|
||||
key={h.id}
|
||||
className="bg-white rounded-lg border border-slate-200 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`text-sm font-medium px-2 py-0.5 rounded ${
|
||||
aWon
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{h.listing_a_title || h.listing_a_id}
|
||||
<span className="ml-1 text-xs">
|
||||
({eloChangeA >= 0 ? "+" : ""}
|
||||
{eloChangeA.toFixed(1)})
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-slate-400 text-xs">vs</span>
|
||||
<span
|
||||
className={`text-sm font-medium px-2 py-0.5 rounded ${
|
||||
!aWon
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{h.listing_b_title || h.listing_b_id}
|
||||
<span className="ml-1 text-xs">
|
||||
({eloChangeB >= 0 ? "+" : ""}
|
||||
{eloChangeB.toFixed(1)})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">
|
||||
{new Date(h.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{history.length === 0 && (
|
||||
<p className="text-center text-slate-400 py-8">
|
||||
No comparisons yet. Start comparing!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/views/RankingsView.tsx
Normal file
88
frontend/src/views/RankingsView.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type RankedListing } from "../api";
|
||||
|
||||
function formatPrice(price: number | null): string {
|
||||
if (price == null) return "–";
|
||||
return `€${price.toLocaleString("nl-NL")}`;
|
||||
}
|
||||
|
||||
export default function RankingsView() {
|
||||
const [rankings, setRankings] = useState<RankedListing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.getRankings(100).then(setRankings).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<h2 className="text-2xl font-bold text-slate-800 mb-4">Rankings</h2>
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600 text-left">
|
||||
<tr>
|
||||
<th className="px-4 py-3 w-12">#</th>
|
||||
<th className="px-4 py-3">Listing</th>
|
||||
<th className="px-4 py-3">City</th>
|
||||
<th className="px-4 py-3 text-right">Price</th>
|
||||
<th className="px-4 py-3 text-right">Area</th>
|
||||
<th className="px-4 py-3 text-right">ELO</th>
|
||||
<th className="px-4 py-3 text-right">W/L</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rankings.map((r) => (
|
||||
<tr key={r.listing.global_id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3 font-mono text-slate-400">{r.rank}</td>
|
||||
<td className="px-4 py-3">
|
||||
{r.listing.url ? (
|
||||
<a
|
||||
href={r.listing.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-indigo-600 hover:underline font-medium"
|
||||
>
|
||||
{r.listing.title || r.listing.global_id}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-medium">
|
||||
{r.listing.title || r.listing.global_id}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600">{r.listing.city || "–"}</td>
|
||||
<td className="px-4 py-3 text-right text-slate-700">
|
||||
{formatPrice(r.listing.current_price)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-slate-600">
|
||||
{r.listing.living_area ? `${r.listing.living_area} m²` : "–"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-bold text-indigo-700">
|
||||
{r.listing.elo_rating.toFixed(0)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-slate-500">
|
||||
{r.listing.wins}–{r.listing.losses}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rankings.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-slate-400">
|
||||
No rated listings yet. Start comparing!
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
frontend/src/views/StatsView.tsx
Normal file
80
frontend/src/views/StatsView.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type Stats } from "../api";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
|
||||
export default function StatsView() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.getStats().then(setStats).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const cards = [
|
||||
{ label: "Comparisons", value: stats.total_comparisons },
|
||||
{ label: "Rated Listings", value: stats.total_rated_listings },
|
||||
{ label: "Total Listings", value: stats.total_listings },
|
||||
{ label: "Avg ELO", value: stats.avg_elo?.toFixed(0) ?? "–" },
|
||||
{ label: "Max ELO", value: stats.max_elo?.toFixed(0) ?? "–" },
|
||||
{ label: "Min ELO", value: stats.min_elo?.toFixed(0) ?? "–" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<h2 className="text-2xl font-bold text-slate-800">Statistics</h2>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{cards.map((c) => (
|
||||
<div
|
||||
key={c.label}
|
||||
className="bg-white rounded-lg border border-slate-200 p-4 text-center"
|
||||
>
|
||||
<p className="text-2xl font-bold text-indigo-700">{c.value}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{c.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{stats.elo_distribution.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<h3 className="font-semibold text-slate-700 mb-3">
|
||||
ELO Distribution
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={stats.elo_distribution}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis
|
||||
dataKey="bucket"
|
||||
tick={{ fontSize: 11 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#4f46e5" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
frontend/tsconfig.app.json
Normal file
20
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
4
frontend/tsconfig.json
Normal file
4
frontend/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }]
|
||||
}
|
||||
11
frontend/vite.config.ts
Normal file
11
frontend/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:8000",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user