From d102d2773172978f5363824dfd6c24d9b37bdf8b Mon Sep 17 00:00:00 2001 From: Kelly Date: Wed, 10 Dec 2025 23:50:47 -0700 Subject: [PATCH] feat(admin): Dispensary schedule page and UI cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DispensarySchedule page showing crawl history and upcoming schedule - Add /dispensaries/:state/:city/:slug/schedule route - Add API endpoint for store crawl history - Update View Schedule link to use dispensary-specific route - Remove colored badges from DispensaryDetail product table (plain text) - Make Details button ghost style in product table - Add "Sort by States" option to IntelligenceBrands - Remove status filter dropdown from Dispensaries page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/src/routes/markets.ts | 101 ++++++ cannaiq/src/App.tsx | 2 + cannaiq/src/lib/api.ts | 41 +++ cannaiq/src/pages/Dispensaries.tsx | 17 - cannaiq/src/pages/DispensaryDetail.tsx | 60 +--- cannaiq/src/pages/DispensarySchedule.tsx | 378 +++++++++++++++++++++++ cannaiq/src/pages/IntelligenceBrands.tsx | 5 +- 7 files changed, 543 insertions(+), 61 deletions(-) create mode 100644 cannaiq/src/pages/DispensarySchedule.tsx diff --git a/backend/src/routes/markets.ts b/backend/src/routes/markets.ts index 63fa2d59..1ff28e9c 100644 --- a/backend/src/routes/markets.ts +++ b/backend/src/routes/markets.ts @@ -291,6 +291,107 @@ router.get('/stores/:id/summary', async (req: Request, res: Response) => { } }); +/** + * GET /api/markets/stores/:id/crawl-history + * Get crawl history for a specific store + */ +router.get('/stores/:id/crawl-history', async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { limit = '50' } = req.query; + const dispensaryId = parseInt(id, 10); + const limitNum = Math.min(parseInt(limit as string, 10), 100); + + // Get crawl history from crawl_orchestration_traces + const { rows: historyRows } = await pool.query(` + SELECT + id, + run_id, + profile_key, + crawler_module, + state_at_start, + state_at_end, + total_steps, + duration_ms, + success, + error_message, + products_found, + started_at, + completed_at + FROM crawl_orchestration_traces + WHERE dispensary_id = $1 + ORDER BY started_at DESC + LIMIT $2 + `, [dispensaryId, limitNum]); + + // Get next scheduled crawl if available + const { rows: scheduleRows } = await pool.query(` + SELECT + js.id as schedule_id, + js.job_name, + js.enabled, + js.base_interval_minutes, + js.jitter_minutes, + js.next_run_at, + js.last_run_at, + js.last_status + FROM job_schedules js + WHERE js.enabled = true + AND js.job_config->>'dispensaryId' = $1::text + ORDER BY js.next_run_at + LIMIT 1 + `, [dispensaryId.toString()]); + + // Get dispensary info for slug + const { rows: dispRows } = await pool.query(` + SELECT + id, + name, + dba_name, + slug, + state, + city, + menu_type, + platform_dispensary_id, + last_menu_scrape + FROM dispensaries + WHERE id = $1 + `, [dispensaryId]); + + res.json({ + dispensary: dispRows[0] || null, + history: historyRows.map(row => ({ + id: row.id, + runId: row.run_id, + profileKey: row.profile_key, + crawlerModule: row.crawler_module, + stateAtStart: row.state_at_start, + stateAtEnd: row.state_at_end, + totalSteps: row.total_steps, + durationMs: row.duration_ms, + success: row.success, + errorMessage: row.error_message, + productsFound: row.products_found, + startedAt: row.started_at?.toISOString() || null, + completedAt: row.completed_at?.toISOString() || null, + })), + nextSchedule: scheduleRows[0] ? { + scheduleId: scheduleRows[0].schedule_id, + jobName: scheduleRows[0].job_name, + enabled: scheduleRows[0].enabled, + baseIntervalMinutes: scheduleRows[0].base_interval_minutes, + jitterMinutes: scheduleRows[0].jitter_minutes, + nextRunAt: scheduleRows[0].next_run_at?.toISOString() || null, + lastRunAt: scheduleRows[0].last_run_at?.toISOString() || null, + lastStatus: scheduleRows[0].last_status, + } : null, + }); + } catch (error: any) { + console.error('[Markets] Error fetching crawl history:', error.message); + res.status(500).json({ error: error.message }); + } +}); + /** * GET /api/markets/stores/:id/products * Get products for a store with filtering and pagination diff --git a/cannaiq/src/App.tsx b/cannaiq/src/App.tsx index d532bda4..323fff5f 100755 --- a/cannaiq/src/App.tsx +++ b/cannaiq/src/App.tsx @@ -8,6 +8,7 @@ import { ProductDetail } from './pages/ProductDetail'; import { Stores } from './pages/Stores'; import { Dispensaries } from './pages/Dispensaries'; import { DispensaryDetail } from './pages/DispensaryDetail'; +import { DispensarySchedule } from './pages/DispensarySchedule'; import { StoreDetail } from './pages/StoreDetail'; import { StoreBrands } from './pages/StoreBrands'; import { StoreSpecials } from './pages/StoreSpecials'; @@ -66,6 +67,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/cannaiq/src/lib/api.ts b/cannaiq/src/lib/api.ts index d8b7485c..cd88802a 100755 --- a/cannaiq/src/lib/api.ts +++ b/cannaiq/src/lib/api.ts @@ -983,6 +983,47 @@ class ApiClient { }>(`/api/markets/stores/${id}/categories`); } + async getStoreCrawlHistory(id: number, limit = 50) { + return this.request<{ + dispensary: { + id: number; + name: string; + dba_name: string | null; + slug: string; + state: string; + city: string; + menu_type: string | null; + platform_dispensary_id: string | null; + last_menu_scrape: string | null; + } | null; + history: Array<{ + id: number; + runId: string | null; + profileKey: string | null; + crawlerModule: string | null; + stateAtStart: string | null; + stateAtEnd: string | null; + totalSteps: number; + durationMs: number | null; + success: boolean; + errorMessage: string | null; + productsFound: number | null; + startedAt: string | null; + completedAt: string | null; + }>; + nextSchedule: { + scheduleId: number; + jobName: string; + enabled: boolean; + baseIntervalMinutes: number; + jitterMinutes: number; + nextRunAt: string | null; + lastRunAt: string | null; + lastStatus: string | null; + } | null; + }>(`/api/markets/stores/${id}/crawl-history?limit=${limit}`); + } + // Global Brands/Categories (from v_brands/v_categories views) async getMarketBrands(params?: { limit?: number; offset?: number }) { const searchParams = new URLSearchParams(); diff --git a/cannaiq/src/pages/Dispensaries.tsx b/cannaiq/src/pages/Dispensaries.tsx index 2b788c6e..595b3de7 100644 --- a/cannaiq/src/pages/Dispensaries.tsx +++ b/cannaiq/src/pages/Dispensaries.tsx @@ -161,23 +161,6 @@ export function Dispensaries() { ))} -
- - -
diff --git a/cannaiq/src/pages/DispensaryDetail.tsx b/cannaiq/src/pages/DispensaryDetail.tsx index 37ec1d88..d59d0bda 100644 --- a/cannaiq/src/pages/DispensaryDetail.tsx +++ b/cannaiq/src/pages/DispensaryDetail.tsx @@ -290,7 +290,7 @@ export function DispensaryDetail() { )} @@ -492,57 +492,31 @@ export function DispensaryDetail() { `$${product.regular_price}` ) : '-'} - - {product.quantity != null ? ( - 0 ? 'badge-info' : 'badge-error'}`}> - {product.quantity} - - ) : '-'} + + {product.quantity != null ? product.quantity : '-'} - - {product.thc_percentage ? ( - {product.thc_percentage}% - ) : '-'} + + {product.thc_percentage ? `${product.thc_percentage}%` : '-'} - - {product.cbd_percentage ? ( - {product.cbd_percentage}% - ) : '-'} + + {product.cbd_percentage ? `${product.cbd_percentage}%` : '-'} - - {product.strain_type ? ( - {product.strain_type} - ) : '-'} + + {product.strain_type || '-'} - - {product.in_stock ? ( - Yes - ) : product.in_stock === false ? ( - No - ) : '-'} + + {product.in_stock ? 'Yes' : product.in_stock === false ? 'No' : '-'} {product.updated_at ? formatDate(product.updated_at) : '-'} -
- {product.dutchie_url && ( - - Dutchie - - )} - -
+ ))} diff --git a/cannaiq/src/pages/DispensarySchedule.tsx b/cannaiq/src/pages/DispensarySchedule.tsx new file mode 100644 index 00000000..6dc18cd1 --- /dev/null +++ b/cannaiq/src/pages/DispensarySchedule.tsx @@ -0,0 +1,378 @@ +import { useEffect, useState } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { Layout } from '../components/Layout'; +import { api } from '../lib/api'; +import { + ArrowLeft, + Clock, + Calendar, + CheckCircle, + XCircle, + AlertCircle, + Package, + Timer, + Building2, +} from 'lucide-react'; + +interface CrawlHistoryItem { + id: number; + runId: string | null; + profileKey: string | null; + crawlerModule: string | null; + stateAtStart: string | null; + stateAtEnd: string | null; + totalSteps: number; + durationMs: number | null; + success: boolean; + errorMessage: string | null; + productsFound: number | null; + startedAt: string | null; + completedAt: string | null; +} + +interface NextSchedule { + scheduleId: number; + jobName: string; + enabled: boolean; + baseIntervalMinutes: number; + jitterMinutes: number; + nextRunAt: string | null; + lastRunAt: string | null; + lastStatus: string | null; +} + +interface Dispensary { + id: number; + name: string; + dba_name: string | null; + slug: string; + state: string; + city: string; + menu_type: string | null; + platform_dispensary_id: string | null; + last_menu_scrape: string | null; +} + +export function DispensarySchedule() { + const { state, city, slug } = useParams(); + const navigate = useNavigate(); + const [dispensary, setDispensary] = useState(null); + const [history, setHistory] = useState([]); + const [nextSchedule, setNextSchedule] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadScheduleData(); + }, [slug]); + + const loadScheduleData = async () => { + setLoading(true); + try { + // First get the dispensary to get the ID + const dispData = await api.getDispensary(slug!); + if (dispData?.id) { + const data = await api.getStoreCrawlHistory(dispData.id); + setDispensary(data.dispensary); + setHistory(data.history || []); + setNextSchedule(data.nextSchedule); + } + } catch (error) { + console.error('Failed to load schedule data:', error); + } finally { + setLoading(false); + } + }; + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return 'Never'; + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const formatTimeAgo = (dateStr: string | null) => { + if (!dateStr) return 'Never'; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMinutes < 1) return 'Just now'; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays} days ago`; + return date.toLocaleDateString(); + }; + + const formatTimeUntil = (dateStr: string | null) => { + if (!dateStr) return 'Not scheduled'; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = date.getTime() - now.getTime(); + + if (diffMs < 0) return 'Overdue'; + + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMinutes / 60); + + if (diffMinutes < 60) return `in ${diffMinutes}m`; + return `in ${diffHours}h ${diffMinutes % 60}m`; + }; + + const formatDuration = (ms: number | null) => { + if (!ms) return '-'; + if (ms < 1000) return `${ms}ms`; + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + if (minutes < 1) return `${seconds}s`; + return `${minutes}m ${seconds % 60}s`; + }; + + const formatInterval = (baseMinutes: number, jitterMinutes: number) => { + const hours = Math.floor(baseMinutes / 60); + const mins = baseMinutes % 60; + let base = hours > 0 ? `${hours}h` : ''; + if (mins > 0) base += `${mins}m`; + return `Every ${base} (+/- ${jitterMinutes}m jitter)`; + }; + + if (loading) { + return ( + +
+
+

Loading schedule...

+
+
+ ); + } + + if (!dispensary) { + return ( + +
+

Dispensary not found

+
+
+ ); + } + + // Stats from history + const successCount = history.filter(h => h.success).length; + const failureCount = history.filter(h => !h.success).length; + const lastSuccess = history.find(h => h.success); + const avgDuration = history.length > 0 + ? Math.round(history.reduce((sum, h) => sum + (h.durationMs || 0), 0) / history.length) + : 0; + + return ( + +
+ {/* Header */} +
+ +
+ + {/* Dispensary Info */} +
+
+
+ +
+
+

+ {dispensary.dba_name || dispensary.name} +

+

+ {dispensary.city}, {dispensary.state} - Crawl Schedule & History +

+
+ Slug: {dispensary.slug} + {dispensary.menu_type && ( + + {dispensary.menu_type} + + )} +
+
+
+
+ + {/* Next Scheduled Crawl */} + {nextSchedule && ( +
+

+ + Upcoming Schedule +

+
+
+

Next Run

+

+ {formatTimeUntil(nextSchedule.nextRunAt)} +

+

+ {formatDate(nextSchedule.nextRunAt)} +

+
+
+

Interval

+

+ {formatInterval(nextSchedule.baseIntervalMinutes, nextSchedule.jitterMinutes)} +

+
+
+

Last Run

+

+ {formatTimeAgo(nextSchedule.lastRunAt)} +

+
+
+

Last Status

+

+ {nextSchedule.lastStatus || '-'} +

+
+
+
+ )} + + {/* Stats Summary */} +
+
+
+ +
+

Successful Runs

+

{successCount}

+
+
+
+
+
+ +
+

Failed Runs

+

{failureCount}

+
+
+
+
+
+ +
+

Avg Duration

+

{formatDuration(avgDuration)}

+
+
+
+
+
+ +
+

Last Products Found

+

+ {lastSuccess?.productsFound?.toLocaleString() || '-'} +

+
+
+
+
+ + {/* Crawl History Table */} +
+
+

+ + Crawl History +

+
+
+ + + + + + + + + + + + + {history.length === 0 ? ( + + + + ) : ( + history.map((item) => ( + + + + + + + + + )) + )} + +
StatusStartedDurationProductsStateError
+ No crawl history available +
+ + {item.success ? ( + + ) : ( + + )} + {item.success ? 'Success' : 'Failed'} + + +
{formatDate(item.startedAt)}
+
{formatTimeAgo(item.startedAt)}
+
+ {formatDuration(item.durationMs)} + + {item.productsFound?.toLocaleString() || '-'} + + {item.stateAtEnd || item.stateAtStart || '-'} + + {item.errorMessage ? ( + + {item.errorMessage.substring(0, 50)}... + + ) : '-'} +
+
+
+
+
+ ); +} + +export default DispensarySchedule; diff --git a/cannaiq/src/pages/IntelligenceBrands.tsx b/cannaiq/src/pages/IntelligenceBrands.tsx index d9e255ad..ab4d751f 100644 --- a/cannaiq/src/pages/IntelligenceBrands.tsx +++ b/cannaiq/src/pages/IntelligenceBrands.tsx @@ -31,7 +31,7 @@ export function IntelligenceBrands() { const [brands, setBrands] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); - const [sortBy, setSortBy] = useState<'stores' | 'skus' | 'name'>('stores'); + const [sortBy, setSortBy] = useState<'stores' | 'skus' | 'name' | 'states'>('stores'); useEffect(() => { loadBrands(); @@ -68,6 +68,8 @@ export function IntelligenceBrands() { return b.skuCount - a.skuCount; case 'name': return a.brandName.localeCompare(b.brandName); + case 'states': + return b.states.length - a.states.length; default: return 0; } @@ -252,6 +254,7 @@ export function IntelligenceBrands() { > +