Expand AI insight: fix broken competitor query, add continuity, 30-day horizon, holidays

- Fix gather_competitor_data() tier filter: it queried tier IN ('primary','secondary'),
  values that never exist (real values are 'own'/'competitor'/'market'), so the
  cheapest-competitor comparison has always silently returned nothing.
- Feed the previous insight back into the prompt so the model can note what's
  changed/resolved instead of repeating itself.
- Extend forecast horizon from 14 to 30 days; add a per-day revenue table
  alongside the existing occupancy table.
- Annotate the occupancy table with UK (England) bank holidays.
- Add a same-channel market-movement section (B.com vs B.com, rack vs rack)
  diffing rates against the last insight's snapshot, threshold £3.
- Add a parsed headline field + insight history list on the Dashboard,
  collapsed to headline/age and expandable to full content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-24 18:03:31 +00:00
parent 2f5349bd1c
commit 2a7ee1d6b8
9 changed files with 381 additions and 83 deletions

View file

@ -0,0 +1,93 @@
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<number | null>(null)
const { data, isLoading } = useQuery<HistoryResponse>({
queryKey: ['ai-insights-history'],
queryFn: () => api.get('/ai-insights/history', { params: { limit: 20 } }).then(r => r.data),
})
const insights = data?.insights ?? []
return (
<div className="card" style={{ marginTop: 16 }}>
<div className="card-header">
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<History size={16} strokeWidth={1.75} color="var(--gold)" />
Insight History
</span>
</div>
<div className="card-body" style={{ padding: 0 }}>
{isLoading && (
<div className="loading-state" style={{ padding: 16 }}>
<div className="spinner" />
Loading history
</div>
)}
{!isLoading && insights.length === 0 && (
<div className="empty-state" style={{ padding: 16 }}>
<p>No past insights yet.</p>
</div>
)}
{insights.map((item, i) => {
const isOpen = expandedId === item.id
return (
<div
key={item.id}
style={{
borderTop: i === 0 ? 'none' : '1px solid var(--card-border)',
}}
>
<button
onClick={() => setExpandedId(isOpen ? null : item.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
width: '100%',
padding: '10px 16px',
background: 'none',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
font: 'inherit',
}}
>
{isOpen ? (
<ChevronDown size={14} strokeWidth={1.75} color="var(--text-mid)" style={{ flexShrink: 0 }} />
) : (
<ChevronRight size={14} strokeWidth={1.75} color="var(--text-mid)" style={{ flexShrink: 0 }} />
)}
<span style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-dark)' }}>
{item.headline || 'Daily briefing'}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-mid)', flexShrink: 0 }}>
<Clock size={11} strokeWidth={1.75} />
{formatAge(item.generated_at)}
</span>
</button>
{isOpen && (
<div style={{ padding: '0 16px 16px 40px', fontSize: 13.5, lineHeight: 1.65, color: 'var(--text-dark)' }}>
{renderContent(item.content)}
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 12 }}>
{item.input_tokens} / {item.output_tokens} tokens · {item.triggered_by}
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View file

@ -2,54 +2,8 @@ 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 escHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
function renderContent(text: string) {
return text.split('\n').map((line, i) => {
const safe = escHtml(line)
const processed = safe.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 }} />
})
}
import { AIInsight, formatAge, renderContent } from '../utils/aiInsight'
import AIInsightHistory from '../components/AIInsightHistory'
export default function Dashboard() {
const qc = useQueryClient()
@ -66,6 +20,7 @@ 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')
@ -125,6 +80,11 @@ export default function Dashboard() {
)}
{insight && (
<>
{insight.headline && (
<p style={{ fontWeight: 700, fontSize: 15, marginBottom: 12, color: 'var(--text-dark)' }}>
{insight.headline}
</p>
)}
<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)' }}>
@ -136,6 +96,8 @@ export default function Dashboard() {
)}
</div>
</div>
<AIInsightHistory />
</div>
)
}

View file

@ -6156,6 +6156,7 @@ 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')

View file

@ -0,0 +1,49 @@
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
export function renderContent(text: string) {
return text.split('\n').map((line, i) => {
const safe = escHtml(line)
const processed = safe.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 }} />
})
}