feat(F020): frontend infrastructure foundation

- src/lib/constants.ts: centralized route paths, labels, thresholds
- src/lib/utils.ts: formatting, color helpers, math utils
- src/hooks/: useProperties, useMatches, useFutureSignals, useNeeds
- src/components/scores/MatchScoreRing
- src/components/data-quality/DataQualityBar
- src/components/future-signals/SignalTypeBadge
- src/components/cards/SourceTypeBadge
- src/provider/AuthProvider (placeholder, swappable)
- main.tsx: AuthProvider + centralized stale time

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-15 10:50:26 +02:00
parent e20e0e9a2e
commit 3b45b0c121
17 changed files with 600 additions and 2 deletions
+113
View File
@@ -0,0 +1,113 @@
// Centralized app-wide constants — no magic strings anywhere else
export const APP_NAME = 'Property-Match'
export const APP_VERSION = '0.1.0'
// Organisation
export const DEFAULT_ORG_ID = 'org-wincasa'
// Pagination
export const DEFAULT_PAGE_SIZE = 25
export const MAX_COMPARE_ITEMS = 3
// Data quality thresholds
export const DQ_HIGH = 0.8
export const DQ_MEDIUM = 0.6
// Confidence thresholds
export const CONF_HIGH = 0.85
export const CONF_MEDIUM = 0.65
// Match score thresholds (0100)
export const SCORE_STRONG = 80
export const SCORE_MODERATE = 60
// Probability thresholds (future signals)
export const PROB_HIGH = 0.7
export const PROB_MEDIUM = 0.5
// Query stale times (ms)
export const STALE_PROPERTIES = 5 * 60 * 1000
export const STALE_MATCHES = 2 * 60 * 1000
export const STALE_SIGNALS = 5 * 60 * 1000
// Route paths — single source of truth
export const ROUTES = {
HOME: '/',
SUPPLY: {
DASHBOARD: '/supply/dashboard',
PROPERTIES: '/supply/properties',
MATCH_CENTER: '/supply/match-center',
FUTURE_AVAILABILITY: '/supply/future-availability',
DATA_QUALITY: '/supply/data-quality',
},
DEMAND: {
AI_SEARCH: '/demand/ai-search',
RESULTS: '/demand/results',
COMPARE: '/demand/compare',
SHORTLISTS: '/demand/shortlists',
},
OPS: {
REVIEW_QUEUE: '/ops/review-queue',
AI_MONITORING: '/ops/ai-monitoring',
GOVERNANCE: '/ops/governance',
},
} as const
// Asset type display labels
export const ASSET_TYPE_LABELS: Record<string, string> = {
OFFICE: 'Büro',
RETAIL: 'Retail',
GASTRO: 'Gastronomie',
LOGISTICS: 'Logistik',
PRODUCTION: 'Produktion',
MIXED: 'Gemischt',
}
// Result type display labels
export const RESULT_TYPE_LABELS: Record<string, string> = {
VERIFIED_PORTFOLIO: 'Verified Portfolio',
EXTERNAL_MARKET: 'Marktinserat',
FUTURE_AVAILABILITY: 'Zukunftssignal',
}
// Match strength display labels
export const MATCH_STRENGTH_LABELS: Record<string, string> = {
STRONG: 'Stark',
MODERATE: 'Mittel',
WEAK: 'Schwach',
}
// Availability status display labels
export const AVAILABILITY_LABELS: Record<string, string> = {
AVAILABLE_NOW: 'Verfügbar',
AVAILABLE_SOON: 'Bald verfügbar',
FUTURE_SIGNAL: 'Zukunftssignal',
OCCUPIED: 'Belegt',
UNKNOWN: 'Unbekannt',
}
// Risk level display labels
export const RISK_LABELS: Record<string, string> = {
LOW: 'Niedrig',
MEDIUM: 'Mittel',
HIGH: 'Hoch',
CRITICAL: 'Kritisch',
}
// Signal type display labels
export const SIGNAL_TYPE_LABELS: Record<string, string> = {
EXPANSION: 'Expansion',
POSSIBLE_MOVE_OUT: 'Möglicher Auszug',
CONSTRUCTION_PROJECT: 'Bauprojekt',
RESTRUCTURING: 'Umstrukturierung',
PROJECT_DEVELOPMENT: 'Projektentwicklung',
SPACE_CONSOLIDATION: 'Flächenkonsolidierung',
}
// Data freshness display labels
export const FRESHNESS_LABELS: Record<string, string> = {
FRESH: 'Aktuell',
STALE: 'Veraltet',
OUTDATED: 'Abgelaufen',
}
+99
View File
@@ -0,0 +1,99 @@
import {
DQ_HIGH, DQ_MEDIUM,
CONF_HIGH, CONF_MEDIUM,
SCORE_STRONG, SCORE_MODERATE,
PROB_HIGH, PROB_MEDIUM,
} from './constants'
// ── Formatting ────────────────────────────────────────────────────────────────
export function formatCHF(amount: number, decimals = 0): string {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF',
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(amount)
}
export function formatArea(sqm: number): string {
return `${new Intl.NumberFormat('de-CH').format(sqm)}`
}
export function formatPercent(value: number, decimals = 0): string {
return `${(value * 100).toFixed(decimals)} %`
}
export function formatDate(iso: string): string {
return new Intl.DateTimeFormat('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(new Date(iso))
}
export function formatRelativeDate(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const hours = Math.floor(diff / 3_600_000)
if (hours < 1) return 'Gerade eben'
if (hours < 24) return `vor ${hours} Stunde${hours === 1 ? '' : 'n'}`
const days = Math.floor(hours / 24)
if (days < 7) return `vor ${days} Tag${days === 1 ? '' : 'en'}`
return formatDate(iso)
}
// ── Color helpers (return MUI color token strings) ────────────────────────────
export function dataQualityColor(score: number): 'success' | 'warning' | 'error' {
if (score >= DQ_HIGH) return 'success'
if (score >= DQ_MEDIUM) return 'warning'
return 'error'
}
export function confidenceColor(score: number): 'success' | 'primary' | 'warning' {
if (score >= CONF_HIGH) return 'success'
if (score >= CONF_MEDIUM) return 'primary'
return 'warning'
}
export function matchScoreColor(score: number): 'success' | 'warning' | 'error' {
if (score >= SCORE_STRONG) return 'success'
if (score >= SCORE_MODERATE) return 'warning'
return 'error'
}
export function probabilityColor(prob: number): 'success' | 'warning' | 'error' {
if (prob >= PROB_HIGH) return 'success'
if (prob >= PROB_MEDIUM) return 'warning'
return 'error'
}
// Hex color variants for use in sx (when MUI color tokens aren't enough)
export function dataQualityHex(score: number): string {
if (score >= DQ_HIGH) return '#1a7a4a'
if (score >= DQ_MEDIUM) return '#d97706'
return '#c0392b'
}
export function confidenceHex(score: number): string {
if (score >= CONF_HIGH) return '#1a7a4a'
if (score >= CONF_MEDIUM) return '#1e3a5f'
return '#d97706'
}
export function probabilityHex(prob: number): string {
if (prob >= PROB_HIGH) return '#1a7a4a'
if (prob >= PROB_MEDIUM) return '#d97706'
return '#c0392b'
}
// ── Misc ──────────────────────────────────────────────────────────────────────
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
export function average(values: number[]): number {
if (values.length === 0) return 0
return values.reduce((a, b) => a + b, 0) / values.length
}
export function truncate(str: string, maxLen: number): string {
return str.length <= maxLen ? str : str.slice(0, maxLen - 1) + '…'
}