Forecasting app: hybrid port to HNF stack

Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-04 18:49:34 +00:00
commit 75d2c1fa9d
103 changed files with 70316 additions and 0 deletions

34
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,34 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import AuthGate from './components/AuthGate'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Forecasts from './pages/Forecasts'
import History from './pages/History'
import Bookability from './pages/Bookability'
import CompetitorRates from './pages/CompetitorRates'
import Accuracy from './pages/Accuracy'
import Settings from './pages/Settings'
export default function App() {
return (
<AuthGate>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/forecasts" element={<Forecasts />} />
<Route path="/forecasts/:page" element={<Forecasts />} />
<Route path="/history" element={<History />} />
<Route path="/history/:report" element={<History />} />
<Route path="/bookability" element={<Bookability />} />
<Route path="/competitor-rates" element={<CompetitorRates />} />
<Route path="/accuracy" element={<Accuracy />} />
<Route path="/accuracy/:tab" element={<Accuracy />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/:tab" element={<Settings />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Layout>
</AuthGate>
)
}

20
frontend/src/api.ts Normal file
View file

@ -0,0 +1,20 @@
import axios from 'axios'
const BASE = '/forecasting/api'
const api = axios.create({
baseURL: BASE,
withCredentials: true,
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname)
}
return Promise.reject(error)
}
)
export default api

View file

@ -0,0 +1,47 @@
import { createContext, useContext, useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import type { User } from '../types'
interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(null)
export function useAuth() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useAuth outside AuthGate')
return ctx
}
export default function AuthGate({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [checking, setChecking] = useState(true)
useEffect(() => {
fetch('/forecasting/api/auth/verify?app=forecasting', { credentials: 'include' })
.then(r => {
if (!r.ok) throw new Error('unauth')
return r.json()
})
.then(data => setUser({
email: data.email || data.sub || '',
name: data.name || data.display_name || '',
is_admin: data.is_admin ?? false,
caps: data.caps ?? [],
}))
.catch(() => {
window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname)
})
.finally(() => setChecking(false))
}, [])
if (checking) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--navy-dark)' }}>
<div className="spinner" style={{ width: 32, height: 32 }} />
</div>
)
}
if (!user) return null
return <Ctx.Provider value={{ user }}>{children}</Ctx.Provider>
}

View file

@ -0,0 +1,59 @@
import { NavLink } from 'react-router-dom'
import {
TrendingUp, BarChart2, Calendar, Target, Globe, History,
Settings, Bot,
} from 'lucide-react'
import { useAuth } from './AuthGate'
import { can } from '../types'
import type { ReactNode } from 'react'
const ICON = { size: 16, strokeWidth: 1.75 }
const NAV = [
{ to: '/dashboard', label: 'Dashboard', icon: Bot, cap: 'view' },
{ to: '/forecasts', label: 'Forecasts', icon: TrendingUp, cap: 'view' },
{ to: '/history', label: 'History', icon: History, cap: 'view' },
{ to: '/bookability', label: 'Bookability', icon: Calendar, cap: 'view_bookability' },
{ to: '/competitor-rates', label: 'Competitors', icon: Globe, cap: 'view_competitor_rates' },
{ to: '/accuracy', label: 'Accuracy', icon: Target, cap: 'view_accuracy' },
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' },
]
export default function Layout({ children }: { children: ReactNode }) {
const { user } = useAuth()
const items = NAV.filter(n => can(user, n.cap))
return (
<div className="app-shell">
<aside className="sidebar">
<div className="sidebar-logo">
<BarChart2 size={18} strokeWidth={1.75} />
Forecasting
</div>
<nav className="sidebar-nav">
{items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
<Icon {...ICON} />
{label}
</NavLink>
))}
</nav>
<div className="sidebar-user">{user.name || user.email}</div>
</aside>
<header className="top-bar">
<BarChart2 size={18} strokeWidth={1.75} color="var(--gold)" />
<span className="top-bar-title">Forecasting</span>
<nav className="top-bar-nav">
{items.map(({ to, label }) => (
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
{label}
</NavLink>
))}
</nav>
</header>
<main className="page-content">{children}</main>
</div>
)
}

361
frontend/src/index.css Normal file
View file

@ -0,0 +1,361 @@
:root {
--navy: #1a1a2e;
--navy-dark: #0f0f20;
--gold: #c9a84c;
--gold-light: #e8c96d;
--surface: rgba(255,255,255,0.07);
--surface-2: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.88);
--text-muted: rgba(255,255,255,0.48);
--body-bg: #f4f5f7;
--card-bg: #ffffff;
--card-border: #e4e8ee;
--text-dark: #1e293b;
--text-mid: #64748b;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--danger: #dc2626;
--success: #16a34a;
--warning: #d97706;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--body-bg);
color: var(--text-dark);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
}
/* App shell layout */
.app-shell {
display: grid;
grid-template-columns: 220px 1fr;
grid-template-rows: auto 1fr;
min-height: 100vh;
}
.sidebar {
grid-column: 1;
grid-row: 1 / -1;
background: var(--navy);
display: flex;
flex-direction: column;
padding: 0;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 16px 16px;
font-size: 15px;
font-weight: 600;
color: var(--text);
border-bottom: 1px solid var(--surface);
}
.sidebar-nav {
flex: 1;
padding: 12px 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.sidebar-nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border-radius: 8px;
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
transition: background 0.15s, color 0.15s;
}
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
.sidebar-nav a.active { background: rgba(201,168,76,0.15); color: var(--gold); }
.sidebar-user {
padding: 12px 16px;
font-size: 12px;
color: var(--text-muted);
border-top: 1px solid var(--surface);
}
/* Top bar — mobile/collapsed fallback */
.top-bar {
display: none;
}
.page-content {
grid-column: 2;
grid-row: 1 / -1;
min-width: 0;
padding: 24px;
background: var(--body-bg);
}
/* Cards */
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--card-border);
font-size: 14px;
font-weight: 600;
color: var(--text-dark);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-body { padding: 20px; }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.15s, opacity 0.15s;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--gold);
color: var(--navy);
}
.btn-primary:hover:not(:disabled) { background: var(--gold-light); }
.btn-secondary {
background: var(--navy);
color: var(--text);
}
.btn-secondary:hover:not(:disabled) { background: var(--navy-dark); }
.btn-outline {
background: transparent;
color: var(--text-dark);
border: 1px solid var(--card-border);
}
.btn-outline:hover:not(:disabled) { background: var(--body-bg); }
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover:not(:disabled) { opacity: 0.85; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
/* Form elements */
input, select, textarea {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 7px;
padding: 7px 10px;
font-size: 13px;
color: var(--text-dark);
width: 100%;
outline: none;
transition: border-color 0.15s;
}
input:focus, select:focus, textarea:focus { border-color: var(--gold); }
/* Badges */
.badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-warning { background: #fef3c7; color: #d97706; }
.badge-danger { background: #fee2e2; color: #dc2626; }
.badge-info { background: #dbeafe; color: #2563eb; }
.badge-neutral { background: #f1f5f9; color: #64748b; }
/* Tables */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
text-align: left;
padding: 8px 12px;
background: #f8fafc;
border-bottom: 1px solid var(--card-border);
font-weight: 600;
color: var(--text-mid);
white-space: nowrap;
}
td {
padding: 8px 12px;
border-bottom: 1px solid #f1f5f9;
color: var(--text-dark);
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f8fafc; }
/* Page header */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
gap: 16px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: var(--text-dark);
}
.page-subtitle { font-size: 13px; color: var(--text-mid); margin-top: 2px; }
/* Sub-nav tabs */
.sub-nav {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--card-border);
margin-bottom: 24px;
overflow-x: auto;
}
.sub-nav-item {
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
color: var(--text-mid);
cursor: pointer;
border-bottom: 2px solid transparent;
white-space: nowrap;
background: none;
border-left: none;
border-right: none;
border-top: none;
transition: color 0.15s, border-color 0.15s;
}
.sub-nav-item:hover { color: var(--text-dark); }
.sub-nav-item.active { color: var(--gold); border-bottom-color: var(--gold); }
/* Status dot */
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.green { background: var(--success); }
.status-dot.yellow { background: var(--warning); }
.status-dot.red { background: var(--danger); }
.status-dot.grey { background: #94a3b8; }
/* Spinner */
.spinner {
width: 20px; height: 20px;
border: 2px solid var(--card-border);
border-top-color: var(--gold);
border-radius: 50%;
animation: spin 0.7s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Loading / empty states */
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 60px 20px;
color: var(--text-mid);
font-size: 13px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-mid);
font-size: 13px;
}
/* Grid helpers */
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
/* Stat card */
.stat-card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
padding: 16px 20px;
box-shadow: var(--shadow-sm);
}
.stat-label { font-size: 12px; color: var(--text-mid); margin-bottom: 4px; }
.stat-value { font-size: 24px; font-weight: 700; color: var(--text-dark); }
.stat-delta { font-size: 12px; margin-top: 4px; }
.stat-delta.positive { color: var(--success); }
.stat-delta.negative { color: var(--danger); }
.stat-delta.neutral { color: var(--text-mid); }
/* Responsive — at narrow widths hide sidebar, show top-bar */
@media (max-width: 900px) {
.app-shell {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.sidebar { display: none; }
.top-bar {
display: flex;
align-items: center;
gap: 12px;
background: var(--navy);
padding: 0 16px;
height: 52px;
grid-column: 1;
overflow-x: auto;
}
.top-bar-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
margin-right: 8px;
}
.top-bar-nav { display: flex; gap: 4px; }
.top-bar-nav a {
color: var(--text-muted);
text-decoration: none;
font-size: 12.5px;
padding: 6px 10px;
border-radius: 6px;
white-space: nowrap;
}
.top-bar-nav a:hover { color: var(--text); background: var(--surface); }
.top-bar-nav a.active { color: var(--gold); }
.page-content {
grid-column: 1;
padding: 16px;
}
}

20
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,20 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const qc = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter basename="/forecasting">
<QueryClientProvider client={qc}>
<App />
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>,
)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,133 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { RefreshCw, Bot, Clock } from 'lucide-react'
import api from '../api'
interface AIInsight {
id: number
generated_at: string
content: string
model: string
input_tokens: number
output_tokens: number
triggered_by: string
}
function formatAge(iso: string): string {
const ms = Date.now() - new Date(iso).getTime()
const mins = Math.floor(ms / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.floor(hours / 24)}d ago`
}
function renderContent(text: string) {
return text.split('\n').map((line, i) => {
const processed = line.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
if (line.startsWith('- ') || line.startsWith('* ')) {
return (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
<span style={{ color: 'var(--gold)', flexShrink: 0 }}></span>
<span dangerouslySetInnerHTML={{ __html: processed.slice(2) }} />
</div>
)
}
if (line.startsWith('## ') || line.startsWith('# ')) {
const txt = line.replace(/^#+\s*/, '')
return <p key={i} style={{ fontWeight: 600, marginTop: 12, marginBottom: 6, color: 'var(--text-dark)' }}>{txt}</p>
}
if (line.trim() === '') return <div key={i} style={{ height: 8 }} />
return <p key={i} style={{ marginBottom: 4 }} dangerouslySetInnerHTML={{ __html: processed }} />
})
}
export default function Dashboard() {
const qc = useQueryClient()
const [genError, setGenError] = useState<string | null>(null)
const { data: insight, isLoading } = useQuery<AIInsight | null>({
queryKey: ['ai-insights-latest'],
queryFn: () => api.get('/ai-insights/latest').then(r => r.data),
refetchInterval: 5 * 60_000,
})
const generate = useMutation({
mutationFn: () => api.post('/ai-insights/generate').then(r => r.data),
onSuccess: () => {
setGenError(null)
qc.invalidateQueries({ queryKey: ['ai-insights-latest'] })
},
onError: (err: any) => {
setGenError(err.response?.data?.detail || 'Failed to generate insight')
},
})
return (
<div>
<div className="page-header">
<div>
<div className="page-title">Dashboard</div>
<div className="page-subtitle">Daily AI-generated forecast summary</div>
</div>
<button
className="btn btn-primary"
onClick={() => generate.mutate()}
disabled={generate.isPending}
>
<RefreshCw size={14} strokeWidth={1.75} />
{generate.isPending ? 'Generating…' : 'Generate Now'}
</button>
</div>
{genError && (
<div style={{ background: '#fee2e2', border: '1px solid #fca5a5', borderRadius: 8, padding: '10px 14px', marginBottom: 16, color: '#dc2626', fontSize: 13 }}>
{genError}
</div>
)}
<div className="card">
<div className="card-header">
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Bot size={16} strokeWidth={1.75} color="var(--gold)" />
AI Insight
</span>
{insight && (
<span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'var(--text-mid)', fontWeight: 400 }}>
<Clock size={12} strokeWidth={1.75} />
{formatAge(insight.generated_at)}
{insight.model && <span style={{ marginLeft: 6, background: '#f1f5f9', borderRadius: 4, padding: '1px 6px' }}>{insight.model}</span>}
</span>
)}
</div>
<div className="card-body" style={{ fontSize: 13.5, lineHeight: 1.65, color: 'var(--text-dark)' }}>
{isLoading && (
<div className="loading-state">
<div className="spinner" />
Loading insight
</div>
)}
{!isLoading && !insight && (
<div className="empty-state">
<Bot size={32} strokeWidth={1.75} color="var(--text-mid)" style={{ margin: '0 auto 12px' }} />
<p>No insight generated yet.</p>
<p style={{ marginTop: 6 }}>Click <strong>Generate Now</strong> to produce a daily summary.</p>
</div>
)}
{insight && (
<>
<div style={{ marginBottom: 16 }}>{renderContent(insight.content)}</div>
{(insight.input_tokens || insight.output_tokens) && (
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--card-border)' }}>
{insight.input_tokens} / {insight.output_tokens} tokens
· {insight.triggered_by}
</div>
)}
</>
)}
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

11
frontend/src/types.ts Normal file
View file

@ -0,0 +1,11 @@
export interface User {
email: string
name: string
is_admin: boolean
caps: string[]
}
export function can(user: User | null, cap: string): boolean {
if (!user) return false
return user.is_admin || user.caps.includes(cap)
}

9
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_HOTEL_NAME: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}