diff --git a/backend/api/ai_insights.py b/backend/api/ai_insights.py index 9a532a8..9db2bc6 100644 --- a/backend/api/ai_insights.py +++ b/backend/api/ai_insights.py @@ -28,7 +28,7 @@ async def get_latest_insight( """Get the most recent AI insight for the dashboard card.""" result = await db.execute( text(""" - SELECT id, generated_at, insight_type, headline, content, model, + SELECT id, generated_at, insight_type, content, model, input_tokens, output_tokens, triggered_by FROM ai_insights ORDER BY generated_at DESC @@ -43,7 +43,6 @@ async def get_latest_insight( "id": row.id, "generated_at": row.generated_at.isoformat(), "insight_type": row.insight_type, - "headline": row.headline, "content": row.content, "model": row.model, "input_tokens": row.input_tokens, @@ -62,7 +61,7 @@ async def get_insight_history( """Get historical AI insights with pagination.""" result = await db.execute( text(""" - SELECT id, generated_at, insight_type, headline, content, model, + SELECT id, generated_at, insight_type, content, model, input_tokens, output_tokens, triggered_by FROM ai_insights ORDER BY generated_at DESC @@ -80,7 +79,6 @@ async def get_insight_history( "id": row.id, "generated_at": row.generated_at.isoformat(), "insight_type": row.insight_type, - "headline": row.headline, "content": row.content, "model": row.model, "input_tokens": row.input_tokens, @@ -168,7 +166,6 @@ async def generate_insight_manual( return { "success": True, "content": result["content"], - "headline": result.get("headline"), "input_tokens": result["input_tokens"], "output_tokens": result["output_tokens"], "model": result["model"], diff --git a/backend/api/config.py b/backend/api/config.py index df9bd09..4dccd78 100644 --- a/backend/api/config.py +++ b/backend/api/config.py @@ -1223,7 +1223,7 @@ class AIInsightsSettingsResponse(BaseModel): api_key_set: bool = False model: str = "claude-haiku-4-5-20251001" schedule_time: str = "07:15" - daily_token_budget: int = 12000 + daily_token_budget: int = 5000 class AIInsightsSettingsUpdate(BaseModel): @@ -1258,7 +1258,7 @@ async def get_ai_insights_settings( api_key_set=config.get('_api_key_set', False), model=config.get('ai_insights_model', 'claude-haiku-4-5-20251001'), schedule_time=config.get('ai_insights_schedule_time', '07:15'), - daily_token_budget=int(config.get('ai_insights_daily_token_budget', '12000')), + daily_token_budget=int(config.get('ai_insights_daily_token_budget', '5000')), ) diff --git a/backend/jobs/ai_insights.py b/backend/jobs/ai_insights.py index 6b4b8d4..5d1b2b7 100644 --- a/backend/jobs/ai_insights.py +++ b/backend/jobs/ai_insights.py @@ -1,18 +1,18 @@ """ AI Daily Insights Generation Job -Gathers Pickup-V2 forecast data, booking pace, competitor rates, rate parity, -UK bank holidays, and the previous insight, then sends a compact prompt to -Anthropic's Haiku model to generate a daily briefing for hotel revenue staff. +Gathers Pickup-V2 forecast data, booking pace, competitor rates, and rate parity +information, then sends a compact prompt to Anthropic's Haiku model to generate +a daily briefing for hotel revenue staff. Schedule: Daily at 7:15 AM (after all forecasts and accuracy calc complete) +Cost: ~$0.05/month at 1 run/day with Haiku """ import json import logging from datetime import date, timedelta, datetime, timezone from typing import Dict, List, Any, Optional -import holidays from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession @@ -21,10 +21,8 @@ from database import AsyncSessionLocal logger = logging.getLogger(__name__) DEFAULT_MODEL = "claude-haiku-4-5-20251001" -DEFAULT_DAILY_TOKEN_BUDGET = 12000 -MAX_OUTPUT_TOKENS = 550 -FORECAST_HORIZON_DAYS = 30 -RATE_MOVEMENT_THRESHOLD = 3.0 # GBP — minimum delta to surface as a market move +DEFAULT_DAILY_TOKEN_BUDGET = 5000 +MAX_OUTPUT_TOKENS = 400 async def get_config(db: AsyncSession) -> Dict[str, str]: @@ -63,28 +61,7 @@ async def check_daily_budget(db: AsyncSession, budget: int) -> tuple[bool, int]: return used < budget, used -async def get_previous_insight(db: AsyncSession) -> Optional[Dict[str, Any]]: - """Fetch the most recent insight so the new one can compare against it.""" - result = await db.execute( - text(""" - SELECT headline, content, generated_at, data_snapshot - FROM ai_insights - ORDER BY generated_at DESC - LIMIT 1 - """) - ) - row = result.fetchone() - if not row: - return None - return { - "headline": row.headline, - "content": row.content, - "generated_at": row.generated_at, - "data_snapshot": row.data_snapshot or {}, - } - - -async def gather_occupancy_data(db: AsyncSession, days: int = FORECAST_HORIZON_DAYS) -> List[Dict]: +async def gather_occupancy_data(db: AsyncSession, days: int = 14) -> List[Dict]: """Gather Pickup-V2 occupancy forecast data for the next N days.""" from services.forecasting.pickup_v2_model import run_pickup_v2_forecast @@ -101,7 +78,7 @@ async def gather_occupancy_data(db: AsyncSession, days: int = FORECAST_HORIZON_D return [] -async def gather_revenue_data(db: AsyncSession, days: int = FORECAST_HORIZON_DAYS) -> List[Dict]: +async def gather_revenue_data(db: AsyncSession, days: int = 14) -> List[Dict]: """Gather Pickup-V2 revenue forecast data for the next N days.""" from services.forecasting.pickup_v2_model import run_pickup_v2_forecast @@ -118,7 +95,7 @@ async def gather_revenue_data(db: AsyncSession, days: int = FORECAST_HORIZON_DAY return [] -async def gather_budget_data(db: AsyncSession, days: int = FORECAST_HORIZON_DAYS) -> Dict[str, float]: +async def gather_budget_data(db: AsyncSession, days: int = 14) -> Dict[str, float]: """Gather budget values for forecast comparison.""" today = date.today() end = today + timedelta(days=days - 1) @@ -140,7 +117,7 @@ async def gather_budget_data(db: AsyncSession, days: int = FORECAST_HORIZON_DAYS return budgets -async def gather_competitor_data(db: AsyncSession, days: int = FORECAST_HORIZON_DAYS) -> Dict[str, Any]: +async def gather_competitor_data(db: AsyncSession, days: int = 14) -> Dict[str, Any]: """Gather competitor rate data from Booking.com scraper.""" today = date.today() end = today + timedelta(days=days - 1) @@ -174,7 +151,7 @@ async def gather_competitor_data(db: AsyncSession, days: int = FORECAST_HORIZON_ h.name as comp_name FROM booking_com_rates r JOIN booking_com_hotels h ON r.hotel_id = h.id - WHERE h.tier IN ('competitor', 'market') + WHERE h.tier IN ('primary', 'secondary') AND r.rate_date BETWEEN :start AND :end AND r.availability_status = 'available' AND r.rate_gross IS NOT NULL @@ -209,21 +186,6 @@ async def gather_competitor_data(db: AsyncSession, days: int = FORECAST_HORIZON_ } -def get_uk_bank_holidays(start: date, end: date) -> Dict[str, str]: - """UK (England) bank holidays within the given range, keyed by YYYY-MM-DD.""" - years = list(range(start.year, end.year + 1)) - try: - uk_holidays = holidays.UK(subdiv='England', years=years) - except Exception as e: - logger.warning(f"Failed to load UK bank holidays: {e}") - return {} - - return { - str(d): name for d, name in uk_holidays.items() - if start <= d <= end - } - - def _fmt_date(d: str) -> str: """Convert YYYY-MM-DD to DD/MM/YYYY for UK display.""" try: @@ -235,116 +197,28 @@ def _fmt_date(d: str) -> str: return str(d) -def build_market_movement_section( - current: Dict[str, Any], - previous: Optional[Dict[str, Any]], - threshold: float = RATE_MOVEMENT_THRESHOLD -) -> List[str]: - """ - Diff competitor/own rates against the previous insight's snapshot. - Only same-channel deltas are compared (B.com vs B.com, Rack vs Rack) so - a direct rack rate is never held up against a competitor's B.com rate. - """ - lines = ["## Market Movement Since Last Insight"] - - if not previous: - lines.append("No previous insight to compare against (first run).") - lines.append("") - return lines - - prev_competitors = previous.get('competitors', {}) or {} - prev_own_booking = previous.get('own_booking', {}) or {} - prev_rack = previous.get('rack', {}) or {} - - cur_competitors = current.get('competitors', {}) or {} - cur_own_booking = current.get('own_booking', {}) or {} - cur_rack = current.get('rack', {}) or {} - - moves = [] - - for d, cur in cur_competitors.items(): - prev = prev_competitors.get(d) - if not prev or not cur.get('rate') or not prev.get('rate'): - continue - delta = cur['rate'] - prev['rate'] - if abs(delta) >= threshold: - moves.append( - f"{_fmt_date(d)} | Cheapest competitor ({cur.get('name', '?')}, B.com): " - f"£{prev['rate']:.0f} -> £{cur['rate']:.0f} ({delta:+.0f})" - ) - - for d, cur in cur_own_booking.items(): - prev = prev_own_booking.get(d) - if not prev or not cur.get('rate') or not prev.get('rate'): - continue - delta = cur['rate'] - prev['rate'] - if abs(delta) >= threshold: - moves.append( - f"{_fmt_date(d)} | Own rate (B.com): " - f"£{prev['rate']:.0f} -> £{cur['rate']:.0f} ({delta:+.0f})" - ) - - for d, cur_rate in cur_rack.items(): - prev_rate = prev_rack.get(d) - if not prev_rate or not cur_rate: - continue - delta = cur_rate - prev_rate - if abs(delta) >= threshold: - moves.append( - f"{_fmt_date(d)} | Own rate (Rack/Newbook): " - f"£{prev_rate:.0f} -> £{cur_rate:.0f} ({delta:+.0f})" - ) - - if moves: - moves.sort() - lines.extend(moves) - else: - lines.append(f"No competitor or own-rate movements of £{threshold:.0f}+ since last insight.") - - lines.append("") - return lines - - def build_prompt( occupancy: List[Dict], revenue: List[Dict], budgets: Dict[str, float], - competitor: Dict[str, Any], - bank_holidays: Dict[str, str], - previous_insight: Optional[Dict[str, Any]] = None, + competitor: Dict[str, Any] ) -> tuple[str, str]: """Build system and user prompts from gathered data. Returns (system_msg, user_msg).""" system_msg = ( "You are an AI assistant for a hotel revenue manager in the UK. Analyze the data below and provide " - "a daily briefing. Use UK date format (DD/MM/YYYY) and GBP (£) for all monetary values. " - "Be specific with numbers and dates. Keep it actionable — no fluff or generic advice.\n\n" - "Output format: first line must be `HEADLINE: `, " - "then a blank line, then 4-6 bullet points covering: occupancy/pace, pricing opportunities, " - "notable competitor rate movements (only if the Market Movement section has any), UK bank holidays " - "in the window if they affect pace, and anything unusual requiring attention.\n\n" - "A 'Previous Insight' section may be included below — compare against it explicitly: call out what's " - "changed, what's resolved, and what's still an open issue. Don't just repeat it verbatim.\n\n" - "Never compare a direct/rack rate to a competitor's Booking.com rate as if they were the same channel — " - "only compare rates within the same channel (B.com vs B.com, rack vs rack) when discussing parity or " - "pricing moves." + "a concise daily briefing (3-5 bullet points). Focus on: occupancy trends, pace vs prior " + "year, pricing opportunities, and anything unusual requiring attention. " + "Use UK date format (DD/MM/YYYY) and GBP (£) for all monetary values. " + "Be specific with numbers and dates. Keep it actionable — no fluff or generic advice." ) lines = [] - if previous_insight: - gen_at = previous_insight.get('generated_at') - gen_at_str = gen_at.strftime('%d/%m/%Y %H:%M') if gen_at else 'unknown time' - lines.append(f"## Previous Insight ({gen_at_str})") - if previous_insight.get('headline'): - lines.append(f"Headline: {previous_insight['headline']}") - lines.append(previous_insight.get('content', '')) - lines.append("") - # Occupancy section if occupancy: - lines.append(f"## Occupancy Forecast - Pickup-V2 (next {FORECAST_HORIZON_DAYS} days)") - lines.append("Date | DoW | OTB | Forecast | PY Final | Pace vs LY | Budget | Notes") + lines.append("## Occupancy Forecast - Pickup-V2 (next 14 days)") + lines.append("Date | DoW | OTB | Forecast | PY Final | Pace vs LY | Budget") for fc in occupancy: d = fc.get('date', '') dow = fc.get('day_of_week', '') @@ -360,50 +234,20 @@ def build_prompt( py_str = f"{py_final:.0f}%" if py_final is not None else "-" pace_str = f"{pace:+.0f}%" if pace is not None else "-" bud_str = f"{budget_val:.0f}%" if budget_val is not None else "-" - holiday_note = bank_holidays.get(str(d), "") - lines.append( - f"{_fmt_date(d)} | {dow} | {otb_str} | {fc_str} | {py_str} | {pace_str} | {bud_str} | {holiday_note}" - ) + lines.append(f"{_fmt_date(d)} | {dow} | {otb_str} | {fc_str} | {py_str} | {pace_str} | {bud_str}") lines.append("") - # Revenue section — per-day + aggregate + # Revenue summary if revenue: - lines.append(f"## Revenue Forecast - Pickup-V2 (next {FORECAST_HORIZON_DAYS} days)") - lines.append("Date | DoW | OTB Rev | Forecast Rev | PY Rev | Pace vs LY | Budget Rev | Opportunity") - for fc in revenue: - d = fc.get('date', '') - dow = fc.get('day_of_week', '') - otb_rev = fc.get('current_otb_rev') - forecast_rev = fc.get('forecast') - py_rev = fc.get('prior_year_final_rev') - pace = fc.get('pace_vs_prior_pct') - budget_val = budgets.get(f"{d}_net_accom") - lost = fc.get('lost_potential') or 0 - - otb_str = f"£{otb_rev:,.0f}" if otb_rev is not None else "-" - fc_str = f"£{forecast_rev:,.0f}" if forecast_rev is not None else "-" - py_str = f"£{py_rev:,.0f}" if py_rev is not None else "-" - pace_str = f"{pace:+.0f}%" if pace is not None else "-" - bud_str = f"£{budget_val:,.0f}" if budget_val is not None else "-" - opp_str = f"£{lost:,.0f} left on table" if fc.get('has_pricing_opportunity') else "-" - - lines.append( - f"{_fmt_date(d)} | {dow} | {otb_str} | {fc_str} | {py_str} | {pace_str} | {bud_str} | {opp_str}" - ) - lines.append("") - total_forecast = sum(fc.get('forecast', 0) or 0 for fc in revenue) total_otb = sum(fc.get('current_otb_rev', 0) or 0 for fc in revenue) total_py = sum(fc.get('prior_year_final_rev', 0) or 0 for fc in revenue) opportunity_days = sum(1 for fc in revenue if fc.get('has_pricing_opportunity')) total_lost = sum(fc.get('lost_potential', 0) or 0 for fc in revenue) - lines.append("## Revenue Signals (aggregate)") - lines.append( - f"{FORECAST_HORIZON_DAYS}-day forecast: £{total_forecast:,.0f} | " - f"OTB: £{total_otb:,.0f} | PY: £{total_py:,.0f}" - ) + lines.append("## Revenue Signals") + lines.append(f"14-day forecast: £{total_forecast:,.0f} | OTB: £{total_otb:,.0f} | PY: £{total_py:,.0f}") if opportunity_days > 0: lines.append(f"Pricing opportunity days: {opportunity_days} | Total lost potential: £{total_lost:,.0f}") lines.append("") @@ -414,8 +258,8 @@ def build_prompt( rack_rates = competitor.get('rack', {}) if own_booking or comp_rates: - lines.append(f"## Competitor Rates (next {FORECAST_HORIZON_DAYS} days)") - lines.append("Date | Own Rack | Own B.com | Cheapest Competitor (B.com) | Competitor Name") + lines.append("## Competitor Rates (next 14 days)") + lines.append("Date | Own Rack | Own B.com | Cheapest Competitor | Competitor Name") all_dates = sorted(set(list(own_booking.keys()) + list(comp_rates.keys()) + list(rack_rates.keys()))) for d in all_dates: @@ -435,9 +279,6 @@ def build_prompt( lines.append(f"{_fmt_date(d)} | {rack_str} | {own_str} | {comp_str} | {comp_name}{note}") lines.append("") - # Market movement since last insight (same-channel deltas only) - lines.extend(build_market_movement_section(competitor, (previous_insight or {}).get('data_snapshot', {}).get('competitor_rates'))) - user_msg = "\n".join(lines) return system_msg, user_msg @@ -468,20 +309,9 @@ async def call_llm(api_key: str, system_msg: str, user_msg: str, model: str) -> await client.close() -def parse_headline(raw_content: str) -> tuple[Optional[str], str]: - """Split a `HEADLINE: ...` first line off the model's response. Returns (headline, remaining_content).""" - lines = raw_content.split("\n") - if lines and lines[0].strip().upper().startswith("HEADLINE:"): - headline = lines[0].split(":", 1)[1].strip() - remaining = "\n".join(lines[1:]).strip() - return headline, remaining - return None, raw_content - - async def save_insight( db: AsyncSession, content: str, - headline: Optional[str], model: str, input_tokens: int, output_tokens: int, @@ -492,13 +322,12 @@ async def save_insight( await db.execute( text(""" INSERT INTO ai_insights - (content, headline, model, input_tokens, output_tokens, data_snapshot, triggered_by) - VALUES (:content, :headline, :model, :input_tokens, :output_tokens, + (content, model, input_tokens, output_tokens, data_snapshot, triggered_by) + VALUES (:content, :model, :input_tokens, :output_tokens, CAST(:data_snapshot AS jsonb), :triggered_by) """), { "content": content, - "headline": headline, "model": model, "input_tokens": input_tokens, "output_tokens": output_tokens, @@ -548,30 +377,22 @@ async def generate_insight(db: AsyncSession, triggered_by: str = "scheduler") -> # Gather data logger.info("Gathering data for AI insight...") - previous_insight = await get_previous_insight(db) occupancy = await gather_occupancy_data(db) revenue = await gather_revenue_data(db) budgets = await gather_budget_data(db) competitor = await gather_competitor_data(db) - today = date.today() - end = today + timedelta(days=FORECAST_HORIZON_DAYS - 1) - bank_holidays = get_uk_bank_holidays(today, end) - if not occupancy and not revenue: return {"success": False, "error": "No forecast data available"} # Build prompt - system_msg, user_msg = build_prompt( - occupancy, revenue, budgets, competitor, bank_holidays, previous_insight - ) + system_msg, user_msg = build_prompt(occupancy, revenue, budgets, competitor) - # Store data snapshot — full competitor rates so the NEXT insight can diff against it + # Store data snapshot for debugging data_snapshot = { "occupancy_days": len(occupancy), "revenue_days": len(revenue), "competitor_dates": len(competitor.get('own_booking', {})), - "competitor_rates": competitor, "prompt_preview": user_msg[:500], } @@ -583,13 +404,10 @@ async def generate_insight(db: AsyncSession, triggered_by: str = "scheduler") -> logger.error(f"LLM call failed: {e}") return {"success": False, "error": f"LLM call failed: {str(e)}"} - headline, content = parse_headline(result["content"]) - # Save await save_insight( db, - content=content, - headline=headline, + content=result["content"], model=result["model"], input_tokens=result["input_tokens"], output_tokens=result["output_tokens"], @@ -610,8 +428,7 @@ async def generate_insight(db: AsyncSession, triggered_by: str = "scheduler") -> return { "success": True, - "content": content, - "headline": headline, + "content": result["content"], "input_tokens": result["input_tokens"], "output_tokens": result["output_tokens"], "model": result["model"], diff --git a/backend/main.py b/backend/main.py index 604208b..0227698 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,8 +4,6 @@ Auth is handled by the central HNF stack cookie (hnf_session). """ import logging import sys -import os -import time from contextlib import asynccontextmanager logging.basicConfig( @@ -107,9 +105,6 @@ async def lifespan(app: FastAPI): CREATE INDEX IF NOT EXISTS idx_ai_insights_generated ON ai_insights(generated_at DESC) """)) - db.execute(text(""" - ALTER TABLE ai_insights ADD COLUMN IF NOT EXISTS headline TEXT - """)) db.commit() finally: db.close() @@ -127,7 +122,6 @@ app = FastAPI( version="2.0.0", lifespan=lifespan, ) -STARTED_AT = str(int(time.time() * 1000)) app.add_middleware( CORSMiddleware, @@ -161,4 +155,4 @@ app.include_router(ai_insights.router, prefix="/ai-insights", tags=[ @app.get("/health") async def health_check(): - return {"status": "healthy", "service": "forecasting-api", "version": os.environ.get("BUILD_VERSION", STARTED_AT)} + return {"status": "healthy", "service": "forecasting-api"} diff --git a/backend/requirements.txt b/backend/requirements.txt index da34be5..e6a4a77 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -22,4 +22,3 @@ python-dotenv==1.0.0 python-dateutil==2.8.2 playwright>=1.40.0 anthropic>=0.42.0 -holidays>=0.47 diff --git a/frontend/src/components/AIInsightHistory.tsx b/frontend/src/components/AIInsightHistory.tsx deleted file mode 100644 index fd07f41..0000000 --- a/frontend/src/components/AIInsightHistory.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useState } from 'react' -import { useQuery } from '@tanstack/react-query' -import { History, ChevronDown, ChevronRight, Clock } from 'lucide-react' -import api from '../api' -import { AIInsight, formatAge, renderContent } from '../utils/aiInsight' - -interface HistoryResponse { - insights: AIInsight[] - total: number -} - -export default function AIInsightHistory() { - const [expandedId, setExpandedId] = useState(null) - - const { data, isLoading } = useQuery({ - queryKey: ['ai-insights-history'], - queryFn: () => api.get('/ai-insights/history', { params: { limit: 20 } }).then(r => r.data), - }) - - const insights = data?.insights ?? [] - - return ( -
-
- - - Insight History - -
-
- {isLoading && ( -
-
- Loading history… -
- )} - {!isLoading && insights.length === 0 && ( -
-

No past insights yet.

-
- )} - {insights.map((item, i) => { - const isOpen = expandedId === item.id - return ( -
- - {isOpen && ( -
- {renderContent(item.content)} -
- {item.input_tokens}↑ / {item.output_tokens}↓ tokens · {item.triggered_by} -
-
- )} -
- ) - })} -
-
- ) -} diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx index c13ebe7..7f0e793 100644 --- a/frontend/src/components/AuthGate.tsx +++ b/frontend/src/components/AuthGate.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useRef, useState } from 'react' +import { createContext, useContext, useEffect, useState } from 'react' import type { ReactNode } from 'react' import type { User } from '../types' @@ -10,26 +10,6 @@ function getInactivityMs(): number | null { return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000 } -// Only bounce to the central login when actually embedded in the portal shell. -// A standalone PWA or a directly-opened browser tab must never navigate away -// from its own start_url/scope — otherwise it loses its installed-app context. -function isEmbedded() { - return window.top !== window -} - -function verify(): Promise { - return fetch('/forecasting/api/auth/verify?app=forecasting', { credentials: 'include' }) - .then(r => { - if (!r.ok) throw new Error('unauth') - return r.json() - }) - .then(data => ({ - email: data.email || data.sub || '', - name: data.name || data.display_name || '', - is_admin: data.is_admin ?? false, - caps: data.caps ?? [], - })) -} interface AuthCtx { user: User } const Ctx = createContext(null) @@ -41,72 +21,30 @@ export function useAuth() { } export default function AuthGate({ children }: { children: ReactNode }) { - const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking') const [user, setUser] = useState(null) - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState('') - const [loading, setLoading] = useState(false) - const timerRef = useRef | null>(null) + const [checking, setChecking] = useState(true) useEffect(() => { - verify() - .then(data => { setUser(data); setState('authed') }) - .catch(() => { - if (isEmbedded()) window.top!.location.href = `/login?from=${encodeURIComponent('/app/forecasting')}` - else setState('login') + 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(() => { + // Redirect the top-level window, not the iframe, so the portal + // navigates to login rather than loading inside itself (EmbeddedFallback). + ;(window.top ?? window).location.href = '/login' + }) + .finally(() => setChecking(false)) }, []) - // Inactivity auto-logout — disabled for installed PWAs; configurable per - // device (Admin Settings → Device) for shared/front-desk browser sessions. - useEffect(() => { - const ms = getInactivityMs() - if (state !== 'authed' || !ms) return - const timeoutMs: number = ms - - async function forceLogout() { - await fetch('/forecasting/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}) - setUser(null) - setState('login') - } - - function reset() { - if (timerRef.current) clearTimeout(timerRef.current) - timerRef.current = setTimeout(forceLogout, timeoutMs) - } - - const events = ['mousemove', 'keydown', 'click', 'touchstart'] as const - events.forEach(e => window.addEventListener(e, reset, { passive: true })) - reset() - - return () => { - if (timerRef.current) clearTimeout(timerRef.current) - events.forEach(e => window.removeEventListener(e, reset)) - } - }, [state]) - - async function login(e: React.FormEvent) { - e.preventDefault() - setLoading(true) - setError('') - try { - const res = await fetch('/forecasting/api/auth/login', { - method: 'POST', credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }) - if (!res.ok) { setError('Invalid email or password'); return } - setUser(await verify()) - setState('authed') - } catch { - setError('Connection error — please try again') - } finally { - setLoading(false) - } - } - - if (state === 'checking') { + if (checking) { return (
@@ -114,46 +52,7 @@ export default function AuthGate({ children }: { children: ReactNode }) { ) } - if (state === 'login') { - return ( -
-
-

- Forecasting -

-
- setEmail(e.target.value)} - placeholder="Email" required autoComplete="email" style={inputStyle} /> - setPassword(e.target.value)} - placeholder="Password" required autoComplete="current-password" style={inputStyle} /> - {error &&

{error}

} - -
-
-
- ) - } + if (!user) return null - return {children} -} - -const inputStyle: React.CSSProperties = { - background: 'var(--navy-dark)', border: '1px solid var(--surface-2)', - borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem', - fontSize: '1rem', width: '100%', outline: 'none', + return {children} } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f9e3dfe..3bcc934 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -2,8 +2,54 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { RefreshCw, Bot, Clock } from 'lucide-react' import api from '../api' -import { AIInsight, formatAge, renderContent } from '../utils/aiInsight' -import AIInsightHistory from '../components/AIInsightHistory' + +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 escHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') +} + +function renderContent(text: string) { + return text.split('\n').map((line, i) => { + const safe = escHtml(line) + const processed = safe.replace(/\*\*(.+?)\*\*/g, '$1') + if (line.startsWith('- ') || line.startsWith('* ')) { + return ( +
+ + +
+ ) + } + if (line.startsWith('## ') || line.startsWith('# ')) { + const txt = line.replace(/^#+\s*/, '') + return

{txt}

+ } + if (line.trim() === '') return
+ return

+ }) +} export default function Dashboard() { const qc = useQueryClient() @@ -20,7 +66,6 @@ export default function Dashboard() { onSuccess: () => { setGenError(null) qc.invalidateQueries({ queryKey: ['ai-insights-latest'] }) - qc.invalidateQueries({ queryKey: ['ai-insights-history'] }) }, onError: (err: any) => { setGenError(err.response?.data?.detail || 'Failed to generate insight') @@ -80,11 +125,6 @@ export default function Dashboard() { )} {insight && ( <> - {insight.headline && ( -

- {insight.headline} -

- )}
{renderContent(insight.content)}
{(insight.input_tokens || insight.output_tokens) && (
@@ -96,8 +136,6 @@ export default function Dashboard() { )}
- -
) } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index ffc1cfb..06d261f 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -6156,7 +6156,6 @@ const AIInsightsPage: React.FC = () => { setGenerateMessage(`Generated! ${data.input_tokens} in / ${data.output_tokens} out tokens`) queryClient.invalidateQueries({ queryKey: ['ai-insights-usage'] }) queryClient.invalidateQueries({ queryKey: ['ai-insights-latest'] }) - queryClient.invalidateQueries({ queryKey: ['ai-insights-history'] }) } else { setGenerateStatus('error') setGenerateMessage(data.detail || 'Generation failed') diff --git a/frontend/src/utils/aiInsight.tsx b/frontend/src/utils/aiInsight.tsx deleted file mode 100644 index 4cc6e3f..0000000 --- a/frontend/src/utils/aiInsight.tsx +++ /dev/null @@ -1,49 +0,0 @@ -export interface AIInsight { - id: number - generated_at: string - insight_type: string - headline: string | null - content: string - model: string - input_tokens: number - output_tokens: number - triggered_by: string -} - -export 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 escHtml(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') -} - -export function renderContent(text: string) { - return text.split('\n').map((line, i) => { - const safe = escHtml(line) - const processed = safe.replace(/\*\*(.+?)\*\*/g, '$1') - if (line.startsWith('- ') || line.startsWith('* ')) { - return ( -
- - -
- ) - } - if (line.startsWith('## ') || line.startsWith('# ')) { - const txt = line.replace(/^#+\s*/, '') - return

{txt}

- } - if (line.trim() === '') return
- return

- }) -}