feat: initial project setup

This commit is contained in:
Stijnvandenbroek
2026-03-06 12:25:07 +00:00
commit e1a67da3ce
33 changed files with 2069 additions and 0 deletions

14
frontend/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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"),
};

View 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}` : ""}
</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}` : ""}
</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
View 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>
);

View 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>
);
}

View 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>
);
}

View 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}` : ""}
</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>
);
}

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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
View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }]
}

11
frontend/vite.config.ts Normal file
View 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",
},
},
});