Migrate frontend from Streamlit to Next.js with shadcn/ui

Replace the Streamlit dashboard with a modern Next.js 15 frontend using
TypeScript, Tailwind CSS, shadcn/ui components, Recharts, and TanStack
Query. All four pages (Overview, Price Battle, Product History, Basket
Compare) are fully reimplemented with responsive layouts, collapsible
sidebar navigation, and proper data fetching with caching. Adds Docker
Compose setup for db + api + frontend and removes streamlit/plotly deps.
This commit is contained in:
authentik Default Admin 2026-02-11 18:12:19 +00:00
parent 82430864f7
commit 20f7c76cdf
69 changed files with 15380 additions and 1329 deletions

5
.gitignore vendored
View file

@ -16,3 +16,8 @@ venv/
.coverage
htmlcov/
.mypy_cache/
# Frontend
frontend/node_modules/
frontend/.next/
frontend/out/

45
docker-compose.yml Normal file
View file

@ -0,0 +1,45 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: smartcart
POSTGRES_PASSWORD: smartcart
POSTGRES_DB: smartcart
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U smartcart"]
interval: 5s
timeout: 5s
retries: 5
api:
build: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql+asyncpg://smartcart:smartcart@db:5432/smartcart
DATABASE_URL_SYNC: postgresql://smartcart:smartcart@db:5432/smartcart
API_HOST: "0.0.0.0"
API_PORT: "8000"
ports:
- "8000:8000"
command: python -m uvicorn src.api.main:app --host 0.0.0.0 --port 8000
frontend:
build: ./frontend
restart: unless-stopped
depends_on:
- api
environment:
NEXT_PUBLIC_API_URL: http://api:8000
ports:
- "3000:3000"
volumes:
pgdata:

5
frontend/.dockerignore Normal file
View file

@ -0,0 +1,5 @@
node_modules
.next
.git
.env*
README.md

41
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

37
frontend/Dockerfile Normal file
View file

@ -0,0 +1,37 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Build the application
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Production image, copy only necessary files
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Standalone output
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

36
frontend/README.md Normal file
View file

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

23
frontend/components.json Normal file
View file

@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

20
frontend/next.config.ts Normal file
View file

@ -0,0 +1,20 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/:path*`,
},
];
},
images: {
remotePatterns: [
{ protocol: "https", hostname: "**" },
],
},
};
export default nextConfig;

12185
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
frontend/package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"shadcn": "^3.8.4",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
frontend/public/file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

1
frontend/public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View file

@ -0,0 +1,216 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { fetchProducts, compareBasket } from "@/lib/api";
import { queryKeys, staleTimes } from "@/lib/query-keys";
import { useDebounce } from "@/hooks/use-debounce";
import { PageHeader } from "@/components/layout/page-header";
import { ProductSearchInput } from "@/components/products/product-search-input";
import { ProductSelect } from "@/components/products/product-select";
import { BasketTable, type BasketItem } from "@/components/basket/basket-table";
import { StoreTotalCard } from "@/components/basket/store-total-card";
import { BasketComparisonBarChart } from "@/components/charts/basket-comparison-bar-chart";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { ShoppingCart, Trash2 } from "lucide-react";
import type { BasketCompareOut } from "@/lib/types";
const STORAGE_KEY = "smartcart-basket";
function loadBasket(): BasketItem[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveBasket(items: BasketItem[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}
export default function BasketComparePage() {
const [basket, setBasket] = useState<BasketItem[]>([]);
const [search, setSearch] = useState("");
const [selectedProductId, setSelectedProductId] = useState<string>();
const [quantity, setQuantity] = useState(1);
const debouncedSearch = useDebounce(search);
// Load basket from localStorage on mount
useEffect(() => {
setBasket(loadBasket());
}, []);
// Persist basket changes
const updateBasket = useCallback((items: BasketItem[]) => {
setBasket(items);
saveBasket(items);
}, []);
// Search products
const { data: productsData } = useQuery({
queryKey: queryKeys.products({ search: debouncedSearch, limit: 30 }),
queryFn: () => fetchProducts({ search: debouncedSearch, limit: 30 }),
staleTime: staleTimes.products,
enabled: debouncedSearch.length >= 2,
});
const products = productsData?.items ?? [];
// Compare mutation
const compareMutation = useMutation({
mutationFn: () =>
compareBasket({
name: "My Basket",
items: basket.map((item) => ({
product_id: item.product_id,
quantity: item.quantity,
})),
}),
});
const handleAdd = () => {
if (!selectedProductId) return;
const product = products.find((p) => String(p.id) === selectedProductId);
if (!product) return;
const newBasket = [
...basket,
{
product_id: product.id,
product_name: product.name,
quantity,
},
];
updateBasket(newBasket);
setSelectedProductId(undefined);
setQuantity(1);
};
const handleRemove = (index: number) => {
const newBasket = basket.filter((_, i) => i !== index);
updateBasket(newBasket);
};
const handleClear = () => {
updateBasket([]);
compareMutation.reset();
};
const result: BasketCompareOut | undefined = compareMutation.data;
const activeStores = (result?.stores ?? []).filter(
(s) => s.items_found > 0
);
const sortedStores = [...activeStores].sort(
(a, b) => Number(a.total) - Number(b.total)
);
const cheapestTotal =
sortedStores.length > 0 ? Number(sortedStores[0].total) : 0;
return (
<div>
<PageHeader
title="Basket Compare"
subtitle="Build a shopping list, then compare the total cost at each store."
/>
{/* Add Items */}
<h2 className="text-lg font-semibold mb-3">Add Items to Basket</h2>
<div className="grid grid-cols-1 sm:grid-cols-[1fr_200px_80px_auto] gap-3 items-end mb-6">
<ProductSearchInput
value={search}
onChange={setSearch}
placeholder="e.g. milk, bread, chicken ..."
/>
{products.length > 0 && (
<ProductSelect
products={products}
value={selectedProductId}
onChange={setSelectedProductId}
/>
)}
<div>
<label className="text-xs text-muted-foreground">Qty</label>
<Input
type="number"
min={1}
max={99}
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value) || 1)}
/>
</div>
<Button onClick={handleAdd} disabled={!selectedProductId}>
Add to basket
</Button>
</div>
<Separator className="mb-6" />
{/* Basket */}
<h2 className="text-lg font-semibold mb-3">Your Basket</h2>
<BasketTable items={basket} onRemove={handleRemove} />
{basket.length > 0 && (
<div className="flex gap-3 mt-4">
<Button
onClick={() => compareMutation.mutate()}
disabled={compareMutation.isPending}
className="flex-1"
>
<ShoppingCart className="h-4 w-4 mr-2" />
{compareMutation.isPending ? "Comparing..." : "Compare Basket"}
</Button>
<Button variant="outline" onClick={handleClear}>
<Trash2 className="h-4 w-4 mr-2" />
Clear Basket
</Button>
</div>
)}
{/* Error */}
{compareMutation.isError && (
<p className="text-destructive mt-4">
Could not compare your basket. Make sure the API is running.
</p>
)}
{/* Results */}
{result && sortedStores.length > 0 && (
<>
<Separator className="my-6" />
<h2 className="text-lg font-semibold mb-3">Comparison Results</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 mb-6">
{sortedStores.map((st) => (
<StoreTotalCard
key={st.store.id}
storeTotal={st}
cheapestTotal={cheapestTotal}
isCheapest={Number(st.total) === cheapestTotal}
/>
))}
</div>
<BasketComparisonBarChart stores={sortedStores} />
</>
)}
{result && sortedStores.length === 0 && (
<p className="text-muted-foreground mt-6">
None of the stores carry these products.
</p>
)}
{compareMutation.isPending && (
<div className="mt-6">
<Skeleton className="h-48" />
</div>
)}
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,126 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Sidebar } from "@/components/layout/sidebar";
import { QueryProvider } from "@/providers/query-provider";
import { TooltipProvider } from "@/components/ui/tooltip";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "SmartCart",
description: "Irish Grocery Price Tracker",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<QueryProvider>
<TooltipProvider>
<Sidebar />
<main className="lg:pl-64">
<div className="p-6 pt-16 lg:pt-6">{children}</div>
</main>
</TooltipProvider>
</QueryProvider>
</body>
</html>
);
}

175
frontend/src/app/page.tsx Normal file
View file

@ -0,0 +1,175 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchStats, fetchBattle, fetchProducts } from "@/lib/api";
import { queryKeys, staleTimes } from "@/lib/query-keys";
import { useDebounce } from "@/hooks/use-debounce";
import { formatCurrency, formatNumber } from "@/lib/utils";
import { PageHeader } from "@/components/layout/page-header";
import { KpiCard } from "@/components/layout/kpi-card";
import { BattlePieChart } from "@/components/charts/battle-pie-chart";
import { ProductSearchInput } from "@/components/products/product-search-input";
import { ProductTable } from "@/components/products/product-table";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Package, Store, Database } from "lucide-react";
import { getStoreColor } from "@/lib/store-colors";
const PAGE_SIZE = 25;
export default function OverviewPage() {
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const debouncedSearch = useDebounce(search);
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: queryKeys.stats,
queryFn: fetchStats,
staleTime: staleTimes.stats,
});
const { data: battle } = useQuery({
queryKey: queryKeys.battle(),
queryFn: () => fetchBattle(),
staleTime: staleTimes.battle,
});
const { data: products, isLoading: productsLoading } = useQuery({
queryKey: queryKeys.products({ page, limit: PAGE_SIZE, search: debouncedSearch }),
queryFn: () =>
fetchProducts({ page, limit: PAGE_SIZE, search: debouncedSearch || undefined }),
staleTime: staleTimes.products,
});
const handleSearchChange = (value: string) => {
setSearch(value);
setPage(1);
};
const battleResults = battle?.results ?? [];
const storesWithWins = battleResults.filter((r) => r.wins > 0);
return (
<div>
<PageHeader
title="Overview"
subtitle="Key performance indicators and product catalogue."
/>
{/* KPI Cards */}
{statsLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-28" />
))}
</div>
) : stats ? (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<KpiCard
title="Products Tracked"
value={formatNumber(stats.total_products)}
icon={<Package className="h-4 w-4" />}
/>
<KpiCard
title="Stores"
value={formatNumber(stats.total_stores)}
icon={<Store className="h-4 w-4" />}
/>
<KpiCard
title="Price Records"
value={formatNumber(stats.total_price_records)}
icon={<Database className="h-4 w-4" />}
/>
</div>
) : (
<p className="text-destructive mb-6">
Unable to reach the SmartCart API. Please ensure the backend is running.
</p>
)}
{/* Average Price by Store */}
{stats && stats.avg_prices_by_store.length > 0 && (
<>
<h2 className="text-lg font-semibold mb-3">Average Price by Store</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
{stats.avg_prices_by_store.map((entry) => (
<div
key={entry.store.id}
className="border-l-4 rounded-lg"
style={{ borderLeftColor: getStoreColor(entry.store.name) }}
>
<KpiCard
title={entry.store.name}
value={formatCurrency(entry.avg_price)}
/>
</div>
))}
</div>
<Separator className="mb-6" />
</>
)}
{/* Cheapest Store Breakdown */}
{storesWithWins.length > 0 && (
<>
<h2 className="text-lg font-semibold mb-3">
Cheapest Store Breakdown
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<BattlePieChart results={battleResults} />
<div className="space-y-2">
{battleResults
.filter((r) => r.wins > 0 || Number(r.avg_price) > 0)
.map((r) => (
<div
key={r.store.id}
className="flex items-center gap-2 text-sm"
>
<div
className="h-3 w-3 rounded-full shrink-0"
style={{ backgroundColor: getStoreColor(r.store.name) }}
/>
<span className="font-medium">{r.store.name}:</span>
<span>
{r.wins} wins ({r.cheapest_pct.toFixed(1)}%) | avg{" "}
{formatCurrency(r.avg_price)}
</span>
</div>
))}
</div>
</div>
<Separator className="mb-6" />
</>
)}
{/* Product Catalogue */}
<h2 className="text-lg font-semibold mb-3">Product Catalogue</h2>
<div className="mb-4 max-w-md">
<ProductSearchInput
value={search}
onChange={handleSearchChange}
placeholder="e.g. milk, bread, chicken ..."
/>
</div>
{productsLoading ? (
<Skeleton className="h-64" />
) : products && products.items.length > 0 ? (
<ProductTable
products={products.items}
total={products.total}
page={page}
pageSize={PAGE_SIZE}
onPageChange={setPage}
/>
) : (
<p className="text-muted-foreground">
{search
? "No products found for your search."
: "No products in the database yet. Run a scraper first!"}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchBattle, searchPrices } from "@/lib/api";
import { queryKeys, staleTimes } from "@/lib/query-keys";
import { useDebounce } from "@/hooks/use-debounce";
import { formatCurrency } from "@/lib/utils";
import { PageHeader } from "@/components/layout/page-header";
import { KpiCard } from "@/components/layout/kpi-card";
import { ProductSearchInput } from "@/components/products/product-search-input";
import { PopularSearchGrid } from "@/components/battle/popular-search-grid";
import { PriceResultsTable } from "@/components/battle/price-results-table";
import { BestDealsList } from "@/components/battle/best-deals-list";
import { SearchAvgBarChart } from "@/components/charts/search-avg-bar-chart";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { getStoreColor } from "@/lib/store-colors";
export default function PriceBattlePage() {
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search);
const { data: battle } = useQuery({
queryKey: queryKeys.battle(),
queryFn: () => fetchBattle(),
staleTime: staleTimes.battle,
});
const { data: searchResults, isLoading: searchLoading } = useQuery({
queryKey: queryKeys.searchPrices(debouncedSearch, 60),
queryFn: () => searchPrices(debouncedSearch, 60),
staleTime: staleTimes.searchPrices,
enabled: debouncedSearch.length >= 2,
});
const storesWithData = (battle?.results ?? []).filter(
(r) => Number(r.avg_price) > 0
);
// Store counts for caption
const storeCounts = searchResults
? searchResults.reduce<Record<string, number>>((acc, r) => {
acc[r.store] = (acc[r.store] || 0) + 1;
return acc;
}, {})
: {};
return (
<div>
<PageHeader
title="Price Battle"
subtitle="Compare real product prices across Irish supermarkets."
/>
{/* Store Overview */}
{storesWithData.length > 0 && (
<>
<h2 className="text-lg font-semibold mb-3">Store Overview</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
{storesWithData.map((r) => (
<div
key={r.store.id}
className="border-l-4 rounded-lg"
style={{ borderLeftColor: getStoreColor(r.store.name) }}
>
<KpiCard
title={r.store.name}
value={`${formatCurrency(r.avg_price)} avg`}
/>
</div>
))}
</div>
<Separator className="mb-6" />
</>
)}
{/* Compare Products */}
<h2 className="text-lg font-semibold mb-3">Compare Products</h2>
<div className="space-y-4 mb-6">
<PopularSearchGrid onSelect={setSearch} />
<div className="max-w-md">
<ProductSearchInput
value={search}
onChange={setSearch}
placeholder="e.g. milk, bread, chicken ..."
/>
</div>
</div>
{/* Search Results */}
{debouncedSearch.length >= 2 && (
<>
{searchLoading ? (
<Skeleton className="h-64" />
) : searchResults && searchResults.length > 0 ? (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Found {searchResults.length} products matching &quot;{debouncedSearch}&quot;:{" "}
{Object.entries(storeCounts)
.map(([store, count]) => `${store} (${count})`)
.join(", ")}
</p>
<PriceResultsTable results={searchResults} />
<h3 className="text-lg font-semibold">
Average price for &quot;{debouncedSearch}&quot; by store
</h3>
<SearchAvgBarChart results={searchResults} />
<BestDealsList results={searchResults} />
</div>
) : (
<p className="text-muted-foreground">
No products found for &quot;{debouncedSearch}&quot;.
</p>
)}
</>
)}
{!debouncedSearch && (
<p className="text-muted-foreground">
Search for a product above or click a popular category to compare
prices across stores.
</p>
)}
</div>
);
}

View file

@ -0,0 +1,268 @@
"use client";
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
fetchProducts,
fetchPriceHistory,
fetchComparison,
searchPrices,
} from "@/lib/api";
import { queryKeys, staleTimes } from "@/lib/query-keys";
import { useDebounce } from "@/hooks/use-debounce";
import { formatCurrency } from "@/lib/utils";
import { PageHeader } from "@/components/layout/page-header";
import { ProductSearchInput } from "@/components/products/product-search-input";
import { ProductSelect } from "@/components/products/product-select";
import { PriceHistoryLineChart } from "@/components/charts/price-history-line-chart";
import { StoreComparisonBarChart } from "@/components/charts/store-comparison-bar-chart";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getStoreColor } from "@/lib/store-colors";
export default function ProductHistoryPage() {
const [search, setSearch] = useState("");
const [selectedProductId, setSelectedProductId] = useState<string>();
const [days, setDays] = useState(90);
const debouncedSearch = useDebounce(search);
// Search for products
const { data: productsData } = useQuery({
queryKey: queryKeys.products({ search: debouncedSearch, limit: 50 }),
queryFn: () => fetchProducts({ search: debouncedSearch, limit: 50 }),
staleTime: staleTimes.products,
enabled: debouncedSearch.length >= 2,
});
const products = productsData?.items ?? [];
// Auto-select first product when search results change
const productId = selectedProductId ? Number(selectedProductId) : undefined;
// Price history
const { data: history, isLoading: historyLoading } = useQuery({
queryKey: queryKeys.priceHistory(productId!, days),
queryFn: () => fetchPriceHistory(productId!, days),
staleTime: staleTimes.priceHistory,
enabled: !!productId,
});
// Comparison
const { data: comparison } = useQuery({
queryKey: queryKeys.comparison(productId!),
queryFn: () => fetchComparison(productId!),
staleTime: staleTimes.comparison,
enabled: !!productId,
});
// Similar products
const { data: similarResults } = useQuery({
queryKey: queryKeys.searchPrices(debouncedSearch, 100),
queryFn: () => searchPrices(debouncedSearch, 100),
staleTime: staleTimes.searchPrices,
enabled: debouncedSearch.length >= 2,
});
// Build bar chart data from comparison
const barData = useMemo(() => {
if (!comparison?.stores) return [];
return comparison.stores
.filter((sp) => sp.latest_price != null || sp.promo_price != null)
.map((sp) => ({
store_name: sp.store.name,
price: Number(sp.promo_price ?? sp.latest_price),
}));
}, [comparison]);
const sortedSimilar = useMemo(
() =>
[...(similarResults ?? [])].sort(
(a, b) => a.effective_price - b.effective_price
),
[similarResults]
);
return (
<div>
<PageHeader
title="Product History"
subtitle="Search for a product and explore its price history across stores."
/>
{/* Search & Select */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<ProductSearchInput
value={search}
onChange={(v) => {
setSearch(v);
setSelectedProductId(undefined);
}}
placeholder="Search for a product..."
/>
{products.length > 0 && (
<ProductSelect
products={products}
value={selectedProductId}
onChange={setSelectedProductId}
/>
)}
</div>
{/* Date range control */}
{productId && (
<div className="flex items-center gap-2 mb-6">
<label className="text-sm text-muted-foreground">History days:</label>
<Input
type="number"
min={1}
max={365}
value={days}
onChange={(e) => setDays(Number(e.target.value) || 90)}
className="w-24"
/>
</div>
)}
{/* Empty states */}
{!debouncedSearch && (
<p className="text-muted-foreground">
Enter a search term above to find products.
</p>
)}
{debouncedSearch && products.length === 0 && (
<p className="text-muted-foreground">
No products found. Try a different search term.
</p>
)}
{/* Price History Chart */}
{productId && (
<>
<h2 className="text-lg font-semibold mb-3">Price History</h2>
{historyLoading ? (
<Skeleton className="h-80 mb-6" />
) : history && history.length > 0 ? (
<div className="mb-6">
<PriceHistoryLineChart history={history} />
</div>
) : (
<p className="text-muted-foreground mb-6">
No price history available for this product.
</p>
)}
<Separator className="mb-6" />
{/* Current Prices Across Stores */}
<h2 className="text-lg font-semibold mb-3">
Current Prices Across Stores
</h2>
{comparison?.stores && comparison.stores.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Store</TableHead>
<TableHead className="text-right">Price</TableHead>
<TableHead>Promo</TableHead>
<TableHead className="text-right">Promo Price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{comparison.stores.map((sp) => (
<TableRow
key={sp.store.id}
style={{
borderLeft: `4px solid ${getStoreColor(sp.store.name)}`,
}}
>
<TableCell className="font-medium">
{sp.store.name}
</TableCell>
<TableCell className="text-right">
{sp.latest_price != null
? formatCurrency(sp.latest_price)
: "—"}
</TableCell>
<TableCell className="text-muted-foreground">
{sp.promo_label || "—"}
</TableCell>
<TableCell className="text-right">
{sp.promo_price != null
? formatCurrency(sp.promo_price)
: "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{barData.length > 0 && (
<StoreComparisonBarChart data={barData} />
)}
</div>
) : (
<p className="text-muted-foreground mb-6">
No comparison data available for this product.
</p>
)}
<Separator className="mb-6" />
</>
)}
{/* Similar Products */}
{debouncedSearch && sortedSimilar.length > 0 && (
<>
<h2 className="text-lg font-semibold mb-1">
Similar Products Across Stores
</h2>
<p className="text-sm text-muted-foreground mb-3">
Other products matching &quot;{debouncedSearch}&quot; across all stores.
</p>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Store</TableHead>
<TableHead>Product</TableHead>
<TableHead className="text-right">Price</TableHead>
<TableHead className="text-right">Effective</TableHead>
<TableHead>Promo</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedSimilar.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="font-medium">{item.store}</TableCell>
<TableCell>{item.product_name}</TableCell>
<TableCell className="text-right">
{formatCurrency(item.price)}
</TableCell>
<TableCell className="text-right font-semibold">
{formatCurrency(item.effective_price)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{item.promo_label || ""}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,65 @@
"use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
export interface BasketItem {
product_id: number;
product_name: string;
quantity: number;
}
interface BasketTableProps {
items: BasketItem[];
onRemove: (index: number) => void;
}
export function BasketTable({ items, onRemove }: BasketTableProps) {
if (items.length === 0) {
return (
<p className="text-muted-foreground">
Your basket is empty. Search and add products above.
</p>
);
}
return (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead className="w-20 text-center">Qty</TableHead>
<TableHead className="w-16" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => (
<TableRow key={`${item.product_id}-${idx}`}>
<TableCell className="font-medium">{item.product_name}</TableCell>
<TableCell className="text-center">{item.quantity}</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(idx)}
className="h-8 w-8 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View file

@ -0,0 +1,55 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { formatCurrency } from "@/lib/utils";
import { getStoreColor } from "@/lib/store-colors";
import type { BasketStoreTotal } from "@/lib/types";
interface StoreTotalCardProps {
storeTotal: BasketStoreTotal;
cheapestTotal: number;
isCheapest: boolean;
}
export function StoreTotalCard({
storeTotal,
cheapestTotal,
isCheapest,
}: StoreTotalCardProps) {
const delta = Number(storeTotal.total) - cheapestTotal;
const storeColor = getStoreColor(storeTotal.store.name);
return (
<Card
className={isCheapest ? "ring-2 ring-yellow-400" : ""}
style={{ borderTop: `4px solid ${storeColor}` }}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{storeTotal.store.name}</CardTitle>
{isCheapest && (
<Badge className="bg-yellow-400 text-yellow-900 hover:bg-yellow-400">
Cheapest
</Badge>
)}
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(storeTotal.total)}
</div>
{delta > 0 ? (
<p className="text-sm text-destructive mt-1">
+{formatCurrency(delta)} more
</p>
) : (
<p className="text-sm text-green-600 mt-1">Best price</p>
)}
<p className="text-xs text-muted-foreground mt-2">
{storeTotal.items_found} found, {storeTotal.items_missing} missing
</p>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,50 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getStoreColor } from "@/lib/store-colors";
import { formatCurrency } from "@/lib/utils";
import type { SearchPriceResult } from "@/lib/types";
interface BestDealsListProps {
results: SearchPriceResult[];
limit?: number;
}
export function BestDealsList({ results, limit = 5 }: BestDealsListProps) {
const sorted = [...results]
.sort((a, b) => a.effective_price - b.effective_price)
.slice(0, limit);
if (sorted.length === 0) return null;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Best Deals</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{sorted.map((item, idx) => (
<div key={idx} className="flex items-center gap-3">
<div
className="h-2 w-2 rounded-full shrink-0"
style={{ backgroundColor: getStoreColor(item.store) }}
/>
<div className="text-sm">
<span className="font-bold">
{formatCurrency(item.effective_price)}
</span>
{" - "}
{item.product_name} @ {item.store}
{item.promo_label && (
<span className="text-muted-foreground">
{" "}
({item.promo_label})
</span>
)}
</div>
</div>
))}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,34 @@
"use client";
import { Button } from "@/components/ui/button";
const POPULAR_SEARCHES = [
"milk", "bread", "chicken", "rice", "butter", "cheese",
"eggs", "pasta", "sugar", "tea", "coffee", "water",
"beef", "salmon", "yoghurt", "cereal", "oil", "flour",
];
interface PopularSearchGridProps {
onSelect: (term: string) => void;
}
export function PopularSearchGrid({ onSelect }: PopularSearchGridProps) {
return (
<div>
<p className="text-sm text-muted-foreground mb-2">Popular searches:</p>
<div className="grid grid-cols-3 sm:grid-cols-6 lg:grid-cols-9 gap-2">
{POPULAR_SEARCHES.map((term) => (
<Button
key={term}
variant="outline"
size="sm"
className="capitalize"
onClick={() => onSelect(term)}
>
{term}
</Button>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getStoreColor } from "@/lib/store-colors";
import { formatCurrency } from "@/lib/utils";
import type { SearchPriceResult } from "@/lib/types";
interface PriceResultsTableProps {
results: SearchPriceResult[];
}
export function PriceResultsTable({ results }: PriceResultsTableProps) {
const sorted = [...results].sort(
(a, b) => a.effective_price - b.effective_price
);
return (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Store</TableHead>
<TableHead>Product</TableHead>
<TableHead className="text-right">Price</TableHead>
<TableHead className="text-right">Effective</TableHead>
<TableHead>Promo</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((item, idx) => (
<TableRow
key={`${item.store}-${item.product_name}-${idx}`}
style={{ borderLeft: `4px solid ${getStoreColor(item.store)}` }}
>
<TableCell className="font-medium">{item.store}</TableCell>
<TableCell>{item.product_name}</TableCell>
<TableCell className="text-right">
{formatCurrency(item.price)}
</TableCell>
<TableCell className="text-right font-semibold">
{formatCurrency(item.effective_price)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{item.promo_label || ""}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View file

@ -0,0 +1,67 @@
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
LabelList,
} from "recharts";
import { getStoreColor } from "@/lib/store-colors";
import { formatCurrency } from "@/lib/utils";
import type { BasketStoreTotal } from "@/lib/types";
interface BasketComparisonBarChartProps {
stores: BasketStoreTotal[];
}
export function BasketComparisonBarChart({
stores,
}: BasketComparisonBarChartProps) {
const sorted = [...stores].sort(
(a, b) => Number(a.total) - Number(b.total)
);
const minTotal = sorted.length > 0 ? Number(sorted[0].total) : 0;
const data = sorted.map((s) => ({
store: s.store.name,
total: Number(s.total),
isCheapest: Number(s.total) === minTotal,
}));
if (data.length === 0) return null;
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data}>
<XAxis dataKey="store" tick={{ fontSize: 12 }} />
<YAxis
tickFormatter={(v) => `${Number(v).toFixed(2)}`}
tick={{ fontSize: 11 }}
/>
<Tooltip
formatter={(value) => [formatCurrency(Number(value)), "Total"]}
/>
<Bar dataKey="total" radius={[4, 4, 0, 0]}>
<LabelList
dataKey="total"
position="top"
formatter={(v) => formatCurrency(Number(v))}
style={{ fontSize: 11 }}
/>
{data.map((entry) => (
<Cell
key={entry.store}
fill={getStoreColor(entry.store)}
stroke={entry.isCheapest ? "gold" : "transparent"}
strokeWidth={entry.isCheapest ? 3 : 0}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}

View file

@ -0,0 +1,66 @@
"use client";
import {
PieChart,
Pie,
Cell,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { getStoreColor } from "@/lib/store-colors";
import { formatCurrency } from "@/lib/utils";
import type { BattleResult } from "@/lib/types";
interface BattlePieChartProps {
results: BattleResult[];
}
export function BattlePieChart({ results }: BattlePieChartProps) {
const data = results
.filter((r) => r.wins > 0)
.map((r) => ({
name: r.store.name,
value: r.wins,
pct: r.cheapest_pct,
avg: r.avg_price,
}));
if (data.length === 0) return null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const renderLabel = (props: any) => {
const { name, pct } = props;
return `${name} ${Number(pct).toFixed(0)}%`;
};
return (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
dataKey="value"
nameKey="name"
label={renderLabel}
labelLine={false}
>
{data.map((entry) => (
<Cell key={entry.name} fill={getStoreColor(entry.name)} />
))}
</Pie>
<Tooltip
formatter={(value, name, props) => [
`${value} wins (avg ${formatCurrency((props.payload as { avg: number }).avg)})`,
name,
]}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
);
}

View file

@ -0,0 +1,115 @@
"use client";
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
Legend,
ResponsiveContainer,
ReferenceDot,
} from "recharts";
import { getStoreColor } from "@/lib/store-colors";
import { formatCurrency, formatDate } from "@/lib/utils";
import type { PriceHistoryOut } from "@/lib/types";
interface PriceHistoryLineChartProps {
history: PriceHistoryOut[];
}
interface ChartPoint {
date: string;
timestamp: number;
[store: string]: string | number | boolean;
}
interface PromoMarker {
timestamp: number;
store: string;
price: number;
}
export function PriceHistoryLineChart({ history }: PriceHistoryLineChartProps) {
const dateMap = new Map<string, Record<string, number>>();
const promoMarkers: PromoMarker[] = [];
for (const entry of history) {
const storeName = entry.store.name;
for (const pr of entry.prices) {
const dateKey = pr.scraped_at.split("T")[0];
const effective = pr.promo_price != null ? Number(pr.promo_price) : Number(pr.price);
if (!dateMap.has(dateKey)) {
dateMap.set(dateKey, {});
}
dateMap.get(dateKey)![storeName] = effective;
if (pr.promo_label != null) {
promoMarkers.push({
timestamp: new Date(dateKey).getTime(),
store: storeName,
price: effective,
});
}
}
}
const storeNames = [...new Set(history.map((h) => h.store.name))].sort();
const data: ChartPoint[] = Array.from(dateMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, stores]) => ({
date,
timestamp: new Date(date).getTime(),
...stores,
}));
if (data.length === 0) return null;
return (
<ResponsiveContainer width="100%" height={350}>
<LineChart data={data}>
<XAxis
dataKey="date"
tick={{ fontSize: 11 }}
tickFormatter={(d) => formatDate(String(d))}
/>
<YAxis
tickFormatter={(v) => `${Number(v).toFixed(2)}`}
tick={{ fontSize: 11 }}
/>
<Tooltip
labelFormatter={(d) => formatDate(String(d))}
formatter={(value, name) => [
formatCurrency(Number(value)),
String(name),
]}
/>
<Legend />
{storeNames.map((store) => (
<Line
key={store}
type="monotone"
dataKey={store}
stroke={getStoreColor(store)}
strokeWidth={2}
dot={{ r: 3 }}
connectNulls
/>
))}
{promoMarkers.map((m, idx) => (
<ReferenceDot
key={idx}
x={data.find((d) => d.timestamp === m.timestamp)?.date ?? ""}
y={m.price}
r={6}
fill="gold"
stroke={getStoreColor(m.store)}
strokeWidth={2}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}

View file

@ -0,0 +1,64 @@
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
LabelList,
} from "recharts";
import { getStoreColor } from "@/lib/store-colors";
import { formatCurrency } from "@/lib/utils";
import type { SearchPriceResult } from "@/lib/types";
interface SearchAvgBarChartProps {
results: SearchPriceResult[];
}
export function SearchAvgBarChart({ results }: SearchAvgBarChartProps) {
const storeMap = new Map<string, { total: number; count: number }>();
for (const r of results) {
const entry = storeMap.get(r.store) || { total: 0, count: 0 };
entry.total += r.effective_price;
entry.count += 1;
storeMap.set(r.store, entry);
}
const data = Array.from(storeMap.entries())
.map(([store, { total, count }]) => ({
store,
avg: total / count,
}))
.sort((a, b) => a.avg - b.avg);
if (data.length === 0) return null;
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data}>
<XAxis dataKey="store" tick={{ fontSize: 12 }} />
<YAxis
tickFormatter={(v) => `${Number(v).toFixed(2)}`}
tick={{ fontSize: 12 }}
/>
<Tooltip
formatter={(value) => [formatCurrency(Number(value)), "Avg Price"]}
/>
<Bar dataKey="avg" radius={[4, 4, 0, 0]}>
<LabelList
dataKey="avg"
position="top"
formatter={(v) => formatCurrency(Number(v))}
style={{ fontSize: 11 }}
/>
{data.map((entry) => (
<Cell key={entry.store} fill={getStoreColor(entry.store)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}

View file

@ -0,0 +1,58 @@
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
LabelList,
} from "recharts";
import { getStoreColor } from "@/lib/store-colors";
import { formatCurrency } from "@/lib/utils";
interface StoreComparisonBarChartProps {
data: { store_name: string; price: number }[];
}
export function StoreComparisonBarChart({
data,
}: StoreComparisonBarChartProps) {
const sorted = [...data].sort((a, b) => a.price - b.price);
if (sorted.length === 0) return null;
return (
<ResponsiveContainer width="100%" height={250}>
<BarChart data={sorted} layout="vertical">
<XAxis
type="number"
tickFormatter={(v) => `${Number(v).toFixed(2)}`}
tick={{ fontSize: 11 }}
/>
<YAxis
type="category"
dataKey="store_name"
width={100}
tick={{ fontSize: 12 }}
/>
<Tooltip
formatter={(value) => [formatCurrency(Number(value)), "Price"]}
/>
<Bar dataKey="price" radius={[0, 4, 4, 0]}>
<LabelList
dataKey="price"
position="right"
formatter={(v) => formatCurrency(Number(v))}
style={{ fontSize: 11 }}
/>
{sorted.map((entry) => (
<Cell key={entry.store_name} fill={getStoreColor(entry.store_name)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}

View file

@ -0,0 +1,35 @@
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
interface KpiCardProps {
title: string;
value: string;
subtitle?: string;
icon?: ReactNode;
className?: string;
}
export function KpiCard({ title, value, subtitle, icon, className }: KpiCardProps) {
return (
<Card className={cn("", className)}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
{icon && <div className="text-muted-foreground">{icon}</div>}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{subtitle && (
<p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,32 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
interface NavLinkProps {
href: string;
icon: ReactNode;
children: ReactNode;
}
export function NavLink({ href, icon, children }: NavLinkProps) {
const pathname = usePathname();
const isActive = pathname === href;
return (
<Link
href={href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
{icon}
{children}
</Link>
);
}

View file

@ -0,0 +1,15 @@
interface PageHeaderProps {
title: string;
subtitle?: string;
}
export function PageHeader({ title, subtitle }: PageHeaderProps) {
return (
<div className="mb-6">
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
{subtitle && (
<p className="text-muted-foreground mt-1">{subtitle}</p>
)}
</div>
);
}

View file

@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
import { NavLink } from "./nav-link";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
BarChart3,
Swords,
TrendingUp,
ShoppingCart,
Menu,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ href: "/", icon: <BarChart3 className="h-4 w-4" />, label: "Overview" },
{ href: "/price-battle", icon: <Swords className="h-4 w-4" />, label: "Price Battle" },
{ href: "/product-history", icon: <TrendingUp className="h-4 w-4" />, label: "Product History" },
{ href: "/basket-compare", icon: <ShoppingCart className="h-4 w-4" />, label: "Basket Compare" },
];
export function Sidebar() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<>
{/* Mobile toggle */}
<Button
variant="ghost"
size="icon"
className="fixed top-3 left-3 z-50 lg:hidden"
onClick={() => setMobileOpen(!mobileOpen)}
>
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
{/* Overlay */}
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={cn(
"fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r bg-background transition-transform duration-200 lg:translate-x-0",
mobileOpen ? "translate-x-0" : "-translate-x-full"
)}
>
{/* Branding */}
<div className="flex flex-col items-center gap-1 px-4 py-6">
<ShoppingCart className="h-10 w-10 text-primary" />
<h1 className="text-xl font-bold">SmartCart</h1>
<p className="text-xs text-muted-foreground">
Irish Grocery Price Tracker
</p>
</div>
<Separator />
{/* Navigation */}
<nav className="flex flex-col gap-1 p-4">
{NAV_ITEMS.map((item) => (
<NavLink key={item.href} href={item.href} icon={item.icon}>
{item.label}
</NavLink>
))}
</nav>
</aside>
</>
);
}

View file

@ -0,0 +1,29 @@
"use client";
import { Input } from "@/components/ui/input";
import { Search } from "lucide-react";
interface ProductSearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function ProductSearchInput({
value,
onChange,
placeholder = "Search products...",
}: ProductSearchInputProps) {
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="pl-9"
/>
</div>
);
}

View file

@ -0,0 +1,37 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ProductOut } from "@/lib/types";
interface ProductSelectProps {
products: ProductOut[];
value: string | undefined;
onChange: (productId: string) => void;
}
export function ProductSelect({
products,
value,
onChange,
}: ProductSelectProps) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a product" />
</SelectTrigger>
<SelectContent>
{products.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View file

@ -0,0 +1,107 @@
"use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import type { ProductOut } from "@/lib/types";
interface ProductTableProps {
products: ProductOut[];
total: number;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
}
export function ProductTable({
products,
total,
page,
pageSize,
onPageChange,
}: ProductTableProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Brand</TableHead>
<TableHead>Category</TableHead>
<TableHead>Unit</TableHead>
<TableHead className="w-16">Image</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-mono text-xs">{p.id}</TableCell>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell className="text-muted-foreground">
{p.brand || "—"}
</TableCell>
<TableCell className="text-muted-foreground">
{p.category?.name || "—"}
</TableCell>
<TableCell className="text-muted-foreground">
{p.unit_size && p.unit
? `${p.unit_size} ${p.unit}`
: "—"}
</TableCell>
<TableCell>
{p.image_url ? (
<img
src={p.image_url}
alt={p.name}
className="h-8 w-8 rounded object-cover"
/>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
Showing {products.length} of {total} products (page {page}/
{totalPages})
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View file

@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View file

@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View file

@ -0,0 +1,12 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay = 300): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}

89
frontend/src/lib/api.ts Normal file
View file

@ -0,0 +1,89 @@
import type {
BasketCompareOut,
BasketIn,
BattleOut,
CategoryOut,
ComparisonOut,
PriceHistoryOut,
ProductListOut,
SearchPriceResult,
StatsOut,
StoreOut,
} from "./types";
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
async function fetchApi<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, init);
if (!res.ok) {
throw new Error(`API error ${res.status}: ${res.statusText}`);
}
return res.json() as Promise<T>;
}
// ──────────────────────────── Stores / Categories ────────────────────────────
export function fetchStores() {
return fetchApi<StoreOut[]>("/api/stores");
}
export function fetchCategories() {
return fetchApi<CategoryOut[]>("/api/categories");
}
// ──────────────────────────── Products ───────────────────────────────────────
export function fetchProducts(params: {
page?: number;
limit?: number;
search?: string;
category_id?: number;
store_id?: number;
}) {
const sp = new URLSearchParams();
if (params.page) sp.set("page", String(params.page));
if (params.limit) sp.set("limit", String(params.limit));
if (params.search) sp.set("search", params.search);
if (params.category_id) sp.set("category_id", String(params.category_id));
if (params.store_id) sp.set("store_id", String(params.store_id));
return fetchApi<ProductListOut>(`/api/products?${sp.toString()}`);
}
// ──────────────────────────── Prices ─────────────────────────────────────────
export function fetchPriceHistory(productId: number, days = 30) {
return fetchApi<PriceHistoryOut[]>(
`/api/products/${productId}/prices?days=${days}`
);
}
export function searchPrices(q: string, limit = 60) {
return fetchApi<SearchPriceResult[]>(
`/api/search-prices?q=${encodeURIComponent(q)}&limit=${limit}`
);
}
export function fetchStats() {
return fetchApi<StatsOut>("/api/stats");
}
// ──────────────────────────── Comparison ─────────────────────────────────────
export function fetchComparison(productId: number) {
return fetchApi<ComparisonOut>(`/api/products/${productId}/compare`);
}
export function fetchBattle(categoryId?: number) {
const params = categoryId ? `?category_id=${categoryId}` : "";
return fetchApi<BattleOut>(`/api/battle${params}`);
}
// ──────────────────────────── Baskets ────────────────────────────────────────
export function compareBasket(basket: BasketIn) {
return fetchApi<BasketCompareOut>("/api/baskets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(basket),
});
}

View file

@ -0,0 +1,29 @@
export const queryKeys = {
stats: ["stats"] as const,
stores: ["stores"] as const,
categories: ["categories"] as const,
battle: (categoryId?: number) =>
categoryId ? (["battle", categoryId] as const) : (["battle"] as const),
products: (params: {
page?: number;
limit?: number;
search?: string;
}) => ["products", params] as const,
searchPrices: (q: string, limit?: number) =>
["search-prices", q, limit] as const,
priceHistory: (productId: number, days?: number) =>
["price-history", productId, days] as const,
comparison: (productId: number) => ["comparison", productId] as const,
};
// Stale time config (mirrors Streamlit TTLs)
export const staleTimes = {
stats: 2 * 60 * 1000, // 2 min
battle: 2 * 60 * 1000, // 2 min
products: 2 * 60 * 1000, // 2 min
searchPrices: 1 * 60 * 1000, // 1 min
priceHistory: 1 * 60 * 1000, // 1 min
comparison: 1 * 60 * 1000, // 1 min
stores: 5 * 60 * 1000, // 5 min
categories: 5 * 60 * 1000, // 5 min
};

View file

@ -0,0 +1,18 @@
export const STORE_COLORS: Record<string, string> = {
Tesco: "#00539F",
Dunnes: "#6B2D5B",
SuperValu: "#E31837",
Aldi: "#00205B",
Lidl: "#0050AA",
};
const DEFAULT_COLOR = "#888888";
export function getStoreColor(storeName: string): string {
for (const [key, color] of Object.entries(STORE_COLORS)) {
if (storeName.toLowerCase().includes(key.toLowerCase())) {
return color;
}
}
return DEFAULT_COLOR;
}

136
frontend/src/lib/types.ts Normal file
View file

@ -0,0 +1,136 @@
// TypeScript interfaces mirroring src/api/schemas.py
// Decimal → number, datetime → string (ISO)
// ──────────────────────────── Stores / Categories ────────────────────────────
export interface StoreOut {
id: number;
name: string;
slug: string;
base_url: string;
logo_url: string | null;
}
export interface CategoryOut {
id: number;
name: string;
slug: string;
}
// ──────────────────────────── Products ───────────────────────────────────────
export interface ProductOut {
id: number;
name: string;
brand: string | null;
ean: string | null;
category: CategoryOut | null;
unit: string | null;
unit_size: number | null;
image_url: string | null;
}
export interface ProductListOut {
items: ProductOut[];
total: number;
}
// ──────────────────────────── Store Products & Prices ────────────────────────
export interface StoreProductOut {
store: StoreOut;
store_name: string;
store_url: string | null;
latest_price: number | null;
promo_price: number | null;
promo_label: string | null;
}
export interface PriceRecordOut {
price: number;
promo_price: number | null;
promo_label: string | null;
unit_price: number | null;
in_stock: boolean;
scraped_at: string;
}
export interface PriceHistoryOut {
store: StoreOut;
prices: PriceRecordOut[];
}
// ──────────────────────────── Comparison ─────────────────────────────────────
export interface ComparisonOut {
product: ProductOut;
stores: StoreProductOut[];
}
// ──────────────────────────── Store Battle ───────────────────────────────────
export interface BattleResult {
store: StoreOut;
wins: number;
avg_price: number;
cheapest_pct: number;
}
export interface BattleOut {
category: string | null;
results: BattleResult[];
}
// ──────────────────────────── Baskets ────────────────────────────────────────
export interface BasketItemIn {
product_id: number;
quantity: number;
}
export interface BasketIn {
name: string;
items: BasketItemIn[];
}
export interface BasketStoreTotal {
store: StoreOut;
total: number;
items_found: number;
items_missing: number;
}
export interface BasketCompareOut {
basket_name: string;
stores: BasketStoreTotal[];
}
// ──────────────────────────── Stats / KPIs ───────────────────────────────────
export interface AvgPriceByStore {
store: StoreOut;
avg_price: number;
}
export interface StatsOut {
total_products: number;
total_stores: number;
total_price_records: number;
last_scrape: string | null;
avg_prices_by_store: AvgPriceByStore[];
}
// ──────────────────────────── Search Prices ──────────────────────────────────
export interface SearchPriceResult {
product_name: string;
store: string;
store_slug: string;
price: number;
promo_price: number | null;
promo_label: string | null;
effective_price: number;
unit_price: number | null;
image_url: string | null;
product_url: string | null;
}

25
frontend/src/lib/utils.ts Normal file
View file

@ -0,0 +1,25 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatCurrency(value: number | string | null | undefined): string {
if (value == null) return "—";
const num = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(num)) return "—";
return `${num.toFixed(2)}`;
}
export function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString("en-IE", {
day: "numeric",
month: "short",
year: "numeric",
});
}
export function formatNumber(value: number): string {
return value.toLocaleString("en-IE");
}

View file

@ -0,0 +1,22 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
export function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

34
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

View file

@ -16,8 +16,6 @@ dependencies = [
"beautifulsoup4>=4.12.0",
"rapidfuzz>=3.10.0",
"apscheduler>=3.10.0",
"streamlit>=1.40.0",
"plotly>=5.24.0",
"python-dotenv>=1.0.0",
]

View file

@ -1,59 +0,0 @@
"""SmartCart Streamlit Dashboard - Main Application Entry Point."""
import streamlit as st
st.set_page_config(
page_title="SmartCart",
page_icon="\U0001f6d2",
layout="wide",
initial_sidebar_state="expanded",
)
# ---------------------------------------------------------------------------
# Sidebar branding
# ---------------------------------------------------------------------------
with st.sidebar:
st.markdown(
"""
<div style="text-align:center;padding:1rem 0 0.5rem 0;">
<span style="font-size:2.4rem;">\U0001f6d2</span>
<h2 style="margin:0;padding:0;">SmartCart</h2>
<p style="color:grey;margin:0;font-size:0.85rem;">
Irish Grocery Price Tracker
</p>
</div>
<hr style="margin:0.5rem 0 1rem 0;">
""",
unsafe_allow_html=True,
)
# ---------------------------------------------------------------------------
# Multi-page navigation (Streamlit >= 1.36 st.navigation API)
# ---------------------------------------------------------------------------
overview_page = st.Page(
"pages/overview.py",
title="Overview",
icon="\U0001f4ca",
default=True,
)
battle_page = st.Page(
"pages/price_battle.py",
title="Price Battle",
icon="\u2694\ufe0f",
)
history_page = st.Page(
"pages/product_history.py",
title="Product History",
icon="\U0001f4c8",
)
basket_page = st.Page(
"pages/basket_compare.py",
title="Basket Compare",
icon="\U0001f6d2",
)
pg = st.navigation(
[overview_page, battle_page, history_page, basket_page],
)
pg.run()

View file

@ -1,287 +0,0 @@
"""Reusable Plotly chart helpers for the SmartCart dashboard."""
from __future__ import annotations
from typing import Any
import plotly.express as px
import plotly.graph_objects as go
# ---------------------------------------------------------------------------
# Consistent colour palette keyed by store name
# ---------------------------------------------------------------------------
STORE_COLOURS: dict[str, str] = {
"Tesco": "#00539F",
"Dunnes": "#6B2D5B",
"SuperValu": "#E31837",
"Aldi": "#00205B",
"Lidl": "#0050AA",
}
_DEFAULT_COLOUR_SEQUENCE = list(STORE_COLOURS.values())
def _colour_map(stores: list[str]) -> dict[str, str]:
"""Return a colour mapping, using partial matching and falling back to the palette."""
palette_iter = iter(_DEFAULT_COLOUR_SEQUENCE)
mapping: dict[str, str] = {}
for s in stores:
matched = False
for key, val in STORE_COLOURS.items():
if key.lower() in s.lower():
mapping[s] = val
matched = True
break
if not matched:
mapping[s] = next(palette_iter, "#888888")
return mapping
# ---------------------------------------------------------------------------
# 1. Price history line chart
# ---------------------------------------------------------------------------
def price_history_chart(data: list[dict[str, Any]]) -> go.Figure:
"""Line chart showing price over time, one line per store.
*data* is expected to be a list of dicts with keys:
``date``, ``price``, ``store_name``, and optionally ``is_promo``.
"""
if not data:
fig = go.Figure()
fig.update_layout(title="No price history data available")
return fig
import pandas as pd
df = pd.DataFrame(data)
df["date"] = pd.to_datetime(df["date"])
stores = sorted(df["store_name"].unique())
colour_map = _colour_map(stores)
fig = go.Figure()
for store in stores:
sdf = df[df["store_name"] == store].sort_values("date")
fig.add_trace(
go.Scatter(
x=sdf["date"],
y=sdf["price"],
mode="lines+markers",
name=store,
line=dict(color=colour_map[store], width=2),
marker=dict(size=5),
hovertemplate=(
"<b>%{fullData.name}</b><br>"
"Date: %{x|%d %b %Y}<br>"
"Price: \u20ac%{y:.2f}<extra></extra>"
),
)
)
# Overlay promo markers if the field exists
if "is_promo" in sdf.columns:
promo = sdf[sdf["is_promo"] == True] # noqa: E712
if not promo.empty:
fig.add_trace(
go.Scatter(
x=promo["date"],
y=promo["price"],
mode="markers",
name=f"{store} (promo)",
marker=dict(
symbol="star",
size=12,
color=colour_map[store],
line=dict(width=1, color="gold"),
),
hovertemplate=(
"<b>%{fullData.name}</b><br>"
"Date: %{x|%d %b %Y}<br>"
"Promo price: \u20ac%{y:.2f}<extra></extra>"
),
showlegend=False,
)
)
fig.update_layout(
title="Price History",
xaxis_title="Date",
yaxis_title="Price (\u20ac)",
yaxis_tickprefix="\u20ac",
hovermode="x unified",
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
margin=dict(l=40, r=20, t=60, b=40),
template="plotly_white",
)
return fig
# ---------------------------------------------------------------------------
# 2. Store comparison horizontal bar chart
# ---------------------------------------------------------------------------
def store_comparison_bar(data: list[dict[str, Any]]) -> go.Figure:
"""Horizontal bar chart comparing stores for a single product.
*data* is expected to be a list of dicts with keys:
``store_name`` and ``price``.
"""
if not data:
fig = go.Figure()
fig.update_layout(title="No comparison data available")
return fig
import pandas as pd
df = pd.DataFrame(data).sort_values("price", ascending=True)
stores = df["store_name"].tolist()
colour_map = _colour_map(stores)
colours = [colour_map.get(s, "#888888") for s in stores]
fig = go.Figure(
go.Bar(
y=df["store_name"],
x=df["price"],
orientation="h",
marker_color=colours,
text=df["price"].apply(lambda p: f"\u20ac{p:.2f}"),
textposition="outside",
hovertemplate="<b>%{y}</b>: \u20ac%{x:.2f}<extra></extra>",
)
)
fig.update_layout(
title="Store Price Comparison",
xaxis_title="Price (\u20ac)",
xaxis_tickprefix="\u20ac",
yaxis_title="",
margin=dict(l=100, r=40, t=60, b=40),
template="plotly_white",
)
return fig
# ---------------------------------------------------------------------------
# 3. Battle pie chart
# ---------------------------------------------------------------------------
def battle_pie_chart(data: dict[str, int]) -> go.Figure:
"""Pie chart showing % of times each store is cheapest.
*data* is expected to be a dict mapping store name -> win count.
"""
if not data:
fig = go.Figure()
fig.update_layout(title="No battle data available")
return fig
stores = list(data.keys())
counts = list(data.values())
colour_map = _colour_map(stores)
colours = [colour_map.get(s, "#888888") for s in stores]
fig = go.Figure(
go.Pie(
labels=stores,
values=counts,
marker=dict(colors=colours),
textinfo="label+percent",
hovertemplate="<b>%{label}</b><br>Wins: %{value}<br>%{percent}<extra></extra>",
hole=0.35,
)
)
fig.update_layout(
title="Cheapest Store Breakdown",
margin=dict(l=20, r=20, t=60, b=20),
template="plotly_white",
showlegend=True,
legend=dict(orientation="h", yanchor="bottom", y=-0.15, xanchor="center", x=0.5),
)
return fig
# ---------------------------------------------------------------------------
# 4. Basket comparison grouped bar chart
# ---------------------------------------------------------------------------
def basket_comparison_bar(data: list[dict[str, Any]]) -> go.Figure:
"""Grouped bar chart comparing basket cost per store.
*data* is expected to be a list of dicts with keys:
``store_name`` and ``total``.
"""
if not data:
fig = go.Figure()
fig.update_layout(title="No basket data available")
return fig
import pandas as pd
df = pd.DataFrame(data).sort_values("total", ascending=True)
stores = df["store_name"].tolist()
colour_map = _colour_map(stores)
colours = [colour_map.get(s, "#888888") for s in stores]
min_total = df["total"].min()
fig = go.Figure(
go.Bar(
x=df["store_name"],
y=df["total"],
marker_color=colours,
text=df["total"].apply(lambda t: f"\u20ac{t:.2f}"),
textposition="outside",
hovertemplate="<b>%{x}</b>: \u20ac%{y:.2f}<extra></extra>",
)
)
# Highlight the cheapest bar with a border
bar_line_widths = [3 if t == min_total else 0 for t in df["total"]]
fig.update_traces(
marker_line_width=bar_line_widths,
marker_line_color="gold",
)
fig.update_layout(
title="Basket Total by Store",
yaxis_title="Total Cost (\u20ac)",
yaxis_tickprefix="\u20ac",
xaxis_title="",
margin=dict(l=40, r=20, t=60, b=40),
template="plotly_white",
)
return fig
# ---------------------------------------------------------------------------
# 5. Price trend sparkline
# ---------------------------------------------------------------------------
def price_trend_sparkline(prices: list[float], width: int = 150, height: int = 40) -> go.Figure:
"""Tiny sparkline for inline use.
*prices* is a simple list of price floats in chronological order.
"""
if not prices:
fig = go.Figure()
fig.update_layout(width=width, height=height, margin=dict(l=0, r=0, t=0, b=0))
return fig
colour = "#00539F"
if len(prices) >= 2:
colour = "#2ecc71" if prices[-1] <= prices[0] else "#e74c3c"
fig = go.Figure(
go.Scatter(
y=prices,
mode="lines",
line=dict(color=colour, width=1.5),
fill="tozeroy",
fillcolor=f"rgba({int(colour[1:3],16)},{int(colour[3:5],16)},{int(colour[5:7],16)},0.1)",
hoverinfo="skip",
)
)
fig.update_layout(
width=width,
height=height,
margin=dict(l=0, r=0, t=0, b=0),
xaxis=dict(visible=False),
yaxis=dict(visible=False),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
)
return fig

View file

@ -1,133 +0,0 @@
"""Reusable Streamlit filter / input components for the SmartCart dashboard."""
from __future__ import annotations
import datetime
from typing import Any
import httpx
import streamlit as st
from src.core.config import settings
API = settings.api_base_url
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
@st.cache_data(ttl=300, show_spinner=False)
def _fetch_stores() -> list[dict[str, Any]]:
"""Fetch the list of stores from the API (cached 5 min)."""
try:
resp = httpx.get(f"{API}/api/stores", timeout=10)
resp.raise_for_status()
return resp.json()
except httpx.HTTPError:
return []
@st.cache_data(ttl=300, show_spinner=False)
def _fetch_categories() -> list[dict[str, Any]]:
"""Fetch the list of categories from the API (cached 5 min)."""
try:
resp = httpx.get(f"{API}/api/categories", timeout=10)
resp.raise_for_status()
return resp.json()
except httpx.HTTPError:
return []
# ---------------------------------------------------------------------------
# Public filter widgets
# ---------------------------------------------------------------------------
def store_filter(stores: list[dict[str, Any]] | None = None, key: str = "store_filter") -> list[int]:
"""Render a multiselect widget for stores.
Returns a list of selected store IDs. If *stores* is ``None`` the list is
fetched from the API automatically.
"""
if stores is None:
stores = _fetch_stores()
if not stores:
st.warning("Could not load stores from the API.")
return []
options = {s["name"]: s["id"] for s in stores}
selected_names: list[str] = st.multiselect(
"Stores",
options=list(options.keys()),
default=list(options.keys()),
key=key,
)
return [options[n] for n in selected_names]
def category_filter(
categories: list[dict[str, Any]] | None = None,
key: str = "category_filter",
include_all: bool = True,
) -> int | None:
"""Render a selectbox for category.
Returns the selected category ID, or ``None`` when *All Categories* is
chosen.
"""
if categories is None:
categories = _fetch_categories()
if not categories:
st.warning("Could not load categories from the API.")
return None
labels: list[str] = []
id_map: dict[str, int | None] = {}
if include_all:
labels.append("All Categories")
id_map["All Categories"] = None
for cat in categories:
labels.append(cat["name"])
id_map[cat["name"]] = cat["id"]
selected = st.selectbox("Category", options=labels, key=key)
return id_map.get(selected)
def date_range_filter(
key: str = "date_range_filter",
default_days: int = 30,
) -> tuple[datetime.date, datetime.date]:
"""Render a date-range picker.
Returns ``(start_date, end_date)``. Defaults to the last
*default_days* days.
"""
today = datetime.date.today()
start_default = today - datetime.timedelta(days=default_days)
col1, col2 = st.columns(2)
with col1:
start = st.date_input("From", value=start_default, key=f"{key}_start")
with col2:
end = st.date_input("To", value=today, key=f"{key}_end")
if start > end:
st.error("Start date must be before end date.")
start = end
return start, end
def search_filter(
label: str = "Search products",
key: str = "search_filter",
placeholder: str = "e.g. milk, bread, chicken ...",
) -> str:
"""Render a text input for product search.
Returns the current search string (may be empty).
"""
return st.text_input(label, key=key, placeholder=placeholder)

View file

@ -1,219 +0,0 @@
"""SmartCart Dashboard -- Basket Compare page."""
from __future__ import annotations
from typing import Any
import httpx
import pandas as pd
import streamlit as st
from src.core.config import settings
from src.dashboard.components.charts import basket_comparison_bar
API = settings.api_base_url
# ---------------------------------------------------------------------------
# Data fetching
# ---------------------------------------------------------------------------
@st.cache_data(ttl=60, show_spinner=False)
def _search_products(query: str) -> list[dict[str, Any]]:
if not query:
return []
try:
resp = httpx.get(
f"{API}/api/products",
params={"search": query, "limit": 30},
timeout=10,
)
resp.raise_for_status()
payload = resp.json()
if isinstance(payload, list):
return payload
return payload.get("items", payload.get("results", []))
except httpx.HTTPError:
return []
def _compare_basket(items: list[dict[str, Any]]) -> dict[str, Any]:
"""POST the basket to the API and return comparison results."""
try:
resp = httpx.post(
f"{API}/api/baskets",
json={"name": "My Basket", "items": items},
timeout=15,
)
resp.raise_for_status()
return resp.json()
except httpx.HTTPError as exc:
st.error(f"API error: {exc}")
return {}
# ---------------------------------------------------------------------------
# Session state initialisation
# ---------------------------------------------------------------------------
if "basket_items" not in st.session_state:
st.session_state.basket_items: list[dict[str, Any]] = []
# ---------------------------------------------------------------------------
# Page content
# ---------------------------------------------------------------------------
st.title("Basket Compare")
st.caption(
"Build a shopping list, then compare the total cost at each store."
)
# ---- Add items section ----------------------------------------------------
st.subheader("Add Items to Basket")
add_col1, add_col2, add_col3 = st.columns([3, 1, 1])
with add_col1:
search_query = st.text_input(
"Search for a product",
key="basket_search",
placeholder="e.g. milk, bread, chicken ...",
)
products = _search_products(search_query)
if search_query and not products:
st.warning("No products found for your search.")
if products:
product_map = {p.get("name", f"Product {p['id']}"): p for p in products}
with add_col2:
selected_product_name = st.selectbox(
"Product",
options=list(product_map.keys()),
key="basket_product_select",
)
with add_col3:
quantity = st.number_input(
"Qty",
min_value=1,
max_value=99,
value=1,
key="basket_qty",
)
if st.button("Add to basket", type="primary"):
product = product_map[selected_product_name]
st.session_state.basket_items.append(
{
"product_id": product["id"],
"product_name": product.get("name", f"Product {product['id']}"),
"quantity": quantity,
}
)
st.rerun()
st.divider()
# ---- Shopping list display ------------------------------------------------
st.subheader("Your Basket")
if not st.session_state.basket_items:
st.info("Your basket is empty. Search and add products above.")
else:
# Show basket as a table
basket_df = pd.DataFrame(
[
{
"Product": item["product_name"],
"Quantity": item["quantity"],
}
for item in st.session_state.basket_items
]
)
st.dataframe(basket_df, use_container_width=True, hide_index=True)
# Remove buttons
remove_cols = st.columns(min(len(st.session_state.basket_items), 6))
for idx, item in enumerate(st.session_state.basket_items):
col = remove_cols[idx % len(remove_cols)]
if col.button(
f"Remove {item['product_name'][:20]}",
key=f"remove_{idx}",
):
st.session_state.basket_items.pop(idx)
st.rerun()
action_col1, action_col2 = st.columns(2)
with action_col1:
compare_clicked = st.button(
"Compare Basket",
type="primary",
use_container_width=True,
)
with action_col2:
if st.button("Clear Basket", use_container_width=True):
st.session_state.basket_items = []
st.rerun()
# ---- Comparison results -----------------------------------------------
if compare_clicked:
payload_items = [
{"product_id": item["product_id"], "quantity": item["quantity"]}
for item in st.session_state.basket_items
]
with st.spinner("Comparing prices across stores..."):
result = _compare_basket(payload_items)
if not result:
st.error(
"Could not compare your basket. Make sure the API is running "
f"at **{API}**."
)
st.stop()
st.divider()
st.subheader("Comparison Results")
# ---- Totals per store (from BasketCompareOut.stores) ------
store_totals: list[dict[str, Any]] = result.get("stores", [])
if store_totals:
# Filter out stores with 0 items found
active_stores = [s for s in store_totals if s.get("items_found", 0) > 0]
if not active_stores:
st.warning("None of the stores carry these products.")
else:
# Sort cheapest first
active_sorted = sorted(active_stores, key=lambda s: float(s.get("total", 99999)))
# Metrics row
metric_cols = st.columns(len(active_sorted))
cheapest_total = float(active_sorted[0]["total"]) if active_sorted else 0
for idx, st_total in enumerate(active_sorted):
store_info = st_total.get("store", {})
name = store_info.get("name", "Unknown")
total = float(st_total.get("total", 0))
found = st_total.get("items_found", 0)
missing = st_total.get("items_missing", 0)
delta = total - cheapest_total
metric_cols[idx].metric(
label=name,
value=f"\u20ac{total:.2f}",
delta=f"+\u20ac{delta:.2f}" if delta > 0 else "Cheapest",
delta_color="inverse" if delta > 0 else "off",
)
metric_cols[idx].caption(f"{found} found, {missing} missing")
# Bar chart
chart_data = [
{
"store_name": s["store"]["name"],
"total": float(s["total"]),
}
for s in active_sorted
]
fig = basket_comparison_bar(chart_data)
st.plotly_chart(fig, use_container_width=True)
else:
st.info("No store comparison data available.")

View file

@ -1,198 +0,0 @@
"""SmartCart Dashboard -- Overview / KPI page."""
from __future__ import annotations
from typing import Any
import httpx
import pandas as pd
import streamlit as st
from src.core.config import settings
API = settings.api_base_url
# ---------------------------------------------------------------------------
# Data fetching
# ---------------------------------------------------------------------------
@st.cache_data(ttl=120, show_spinner=False)
def _fetch_stats() -> dict[str, Any]:
try:
resp = httpx.get(f"{API}/api/stats", timeout=10)
resp.raise_for_status()
return resp.json()
except httpx.HTTPError:
return {}
@st.cache_data(ttl=120, show_spinner=False)
def _fetch_products(page: int = 1, limit: int = 50, search: str = "") -> dict[str, Any]:
params: dict[str, Any] = {"page": page, "limit": limit}
if search:
params["search"] = search
try:
resp = httpx.get(f"{API}/api/products", params=params, timeout=10)
resp.raise_for_status()
return resp.json()
except httpx.HTTPError:
return {"items": [], "total": 0}
@st.cache_data(ttl=120, show_spinner=False)
def _fetch_battle() -> dict[str, Any]:
try:
resp = httpx.get(f"{API}/api/battle", timeout=10)
resp.raise_for_status()
return resp.json()
except httpx.HTTPError:
return {}
# ---------------------------------------------------------------------------
# Page content
# ---------------------------------------------------------------------------
st.title("Overview")
st.caption("Key performance indicators and product catalogue.")
stats = _fetch_stats()
if not stats:
st.error(
"Unable to reach the SmartCart API. Please ensure the backend is running "
f"at **{API}**."
)
st.stop()
# ---- KPI cards -----------------------------------------------------------
kpi1, kpi2, kpi3 = st.columns(3)
kpi1.metric(
label="Products Tracked",
value=f"{stats.get('total_products', 0):,}",
)
kpi2.metric(
label="Stores",
value=f"{stats.get('total_stores', 0):,}",
)
kpi3.metric(
label="Price Records",
value=f"{stats.get('total_price_records', 0):,}",
)
st.divider()
# ---- Average Price by Store ----------------------------------------------
avg_by_store = stats.get("avg_prices_by_store", [])
if avg_by_store:
st.subheader("Average Price by Store")
store_cols = st.columns(len(avg_by_store))
for idx, entry in enumerate(avg_by_store):
store_info = entry.get("store", {})
store_name = store_info.get("name", "Unknown")
avg_price = entry.get("avg_price", "0")
store_cols[idx].metric(
label=store_name,
value=f"\u20ac{float(avg_price):.2f}",
)
st.divider()
# ---- Battle summary (if multiple stores) ---------------------------------
battle = _fetch_battle()
battle_results = battle.get("results", [])
stores_with_wins = [r for r in battle_results if r.get("wins", 0) > 0]
if stores_with_wins:
from src.dashboard.components.charts import battle_pie_chart
st.subheader("Cheapest Store Breakdown")
wins_dict = {r["store"]["name"]: r["wins"] for r in stores_with_wins}
col_chart, col_stats = st.columns(2)
with col_chart:
fig = battle_pie_chart(wins_dict)
st.plotly_chart(fig, use_container_width=True)
with col_stats:
for r in battle_results:
store_name = r["store"]["name"]
wins = r.get("wins", 0)
avg = r.get("avg_price", 0)
pct = r.get("cheapest_pct", 0)
if wins > 0 or float(avg) > 0:
st.markdown(
f"**{store_name}**: {wins} wins ({pct}%) "
f"| avg \u20ac{float(avg):.2f}"
)
st.divider()
# ---- Product catalogue table ---------------------------------------------
st.subheader("Product Catalogue")
# Search bar
search_query = st.text_input(
"Search products",
placeholder="e.g. milk, bread, chicken ...",
key="overview_search",
)
# Pagination
if "overview_page" not in st.session_state:
st.session_state.overview_page = 1
PAGE_SIZE = 25
data = _fetch_products(
page=st.session_state.overview_page, limit=PAGE_SIZE, search=search_query
)
items = data.get("items", [])
total = data.get("total", 0)
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
if items:
rows = []
for p in items:
cat = p.get("category")
rows.append({
"ID": p.get("id"),
"Name": p.get("name", ""),
"Brand": p.get("brand") or "\u2014",
"Category": cat.get("name", "") if cat else "\u2014",
"Unit": f"{p['unit_size']} {p['unit']}" if p.get("unit_size") and p.get("unit") else "\u2014",
"Image": p.get("image_url") or "",
})
df = pd.DataFrame(rows)
# Show image column if available
has_images = any(r["Image"] for r in rows)
if has_images:
st.dataframe(
df,
use_container_width=True,
hide_index=True,
column_config={
"Image": st.column_config.ImageColumn("Image", width="small"),
"ID": st.column_config.NumberColumn("ID", width="small"),
},
height=min(len(rows) * 40 + 50, 700),
)
else:
display_df = df.drop(columns=["Image"])
st.dataframe(display_df, use_container_width=True, hide_index=True)
# Pagination controls
st.caption(f"Showing {len(items)} of {total} products (page {st.session_state.overview_page}/{total_pages})")
nav_cols = st.columns([1, 1, 4])
with nav_cols[0]:
if st.button("Previous", disabled=st.session_state.overview_page <= 1):
st.session_state.overview_page -= 1
st.rerun()
with nav_cols[1]:
if st.button("Next", disabled=st.session_state.overview_page >= total_pages):
st.session_state.overview_page += 1
st.rerun()
else:
if search_query:
st.warning("No products found for your search.")
else:
st.info("No products in the database yet. Run a scraper first!")

View file

@ -1,208 +0,0 @@
"""SmartCart Dashboard -- Price Battle page."""
from __future__ import annotations
from typing import Any
import httpx
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from src.core.config import settings
from src.dashboard.components.charts import STORE_COLOURS, battle_pie_chart
from src.dashboard.components.filters import category_filter
API = settings.api_base_url
POPULAR_SEARCHES = [
"milk", "bread", "chicken", "rice", "butter", "cheese",
"eggs", "pasta", "sugar", "tea", "coffee", "water",
"beef", "salmon", "yoghurt", "cereal", "oil", "flour",
]
# ---------------------------------------------------------------------------
# Data fetching
# ---------------------------------------------------------------------------
@st.cache_data(ttl=120, show_spinner=False)
def _fetch_battle(category_id: int | None = None) -> dict[str, Any]:
params: dict[str, Any] = {}
if category_id is not None:
params["category_id"] = category_id
try:
resp = httpx.get(f"{API}/api/battle", params=params, timeout=10)
resp.raise_for_status()
return resp.json()
except httpx.HTTPError:
return {}
@st.cache_data(ttl=60, show_spinner=False)
def _search_prices(query: str) -> list[dict[str, Any]]:
if not query:
return []
try:
resp = httpx.get(
f"{API}/api/search-prices",
params={"q": query, "limit": 60},
timeout=10,
)
resp.raise_for_status()
return resp.json()
except httpx.HTTPError:
return []
# ---------------------------------------------------------------------------
# Page content
# ---------------------------------------------------------------------------
st.title("Price Battle")
st.caption("Compare real product prices across Irish supermarkets.")
# ---- Store Rankings (compact) --------------------------------------------
battle = _fetch_battle()
results = battle.get("results", [])
stores_with_data = [r for r in results if float(r.get("avg_price", 0)) > 0]
if stores_with_data:
st.subheader("Store Overview")
metric_cols = st.columns(len(stores_with_data))
for idx, r in enumerate(stores_with_data):
store_name = r["store"]["name"]
avg_price = float(r.get("avg_price", 0))
product_count = r.get("wins", 0)
metric_cols[idx].metric(
label=store_name,
value=f"\u20ac{avg_price:.2f} avg",
)
st.divider()
# ---- Product Price Comparison --------------------------------------------
st.subheader("Compare Products")
# Popular search buttons
st.caption("Popular searches:")
button_cols = st.columns(9)
for idx, term in enumerate(POPULAR_SEARCHES[:9]):
with button_cols[idx]:
if st.button(term.capitalize(), key=f"pop_{term}", use_container_width=True):
st.session_state.battle_search_input = term
st.rerun()
# Second row of popular searches
button_cols2 = st.columns(9)
for idx, term in enumerate(POPULAR_SEARCHES[9:18]):
with button_cols2[idx]:
if st.button(term.capitalize(), key=f"pop_{term}", use_container_width=True):
st.session_state.battle_search_input = term
st.rerun()
# Search input
actual_query = st.text_input(
"Search for a product to compare prices",
placeholder="e.g. milk, bread, chicken ...",
key="battle_search_input",
)
if actual_query:
results_data = _search_prices(actual_query)
if not results_data:
st.warning(f"No products found for '{actual_query}'.")
else:
# Build comparison table
rows = []
for item in results_data:
price = item["price"]
promo = item.get("promo_price")
effective = item["effective_price"]
row = {
"Store": item["store"],
"Product": item["product_name"],
"Price": price,
"Effective": effective,
"Promo": item.get("promo_label") or "",
}
rows.append(row)
df = pd.DataFrame(rows)
# Sort by effective price
df = df.sort_values("Effective")
# Show count per store
store_counts = df["Store"].value_counts()
st.caption(
f"Found {len(df)} products matching '{actual_query}': "
+ ", ".join(f"{store} ({count})" for store, count in store_counts.items())
)
# Format for display
display_df = df.copy()
display_df["Price"] = display_df["Price"].apply(lambda p: f"\u20ac{p:.2f}")
display_df["Effective"] = display_df["Effective"].apply(lambda p: f"\u20ac{p:.2f}")
# Color-code by store
def _style_store(row: pd.Series) -> list[str]:
store = row.get("Store", "")
color = STORE_COLOURS.get(store, "")
# Match partial store names
for key, val in STORE_COLOURS.items():
if key.lower() in store.lower():
color = val
break
if color:
return [f"border-left: 4px solid {color}"] + [""] * (len(row) - 1)
return [""] * len(row)
styled = display_df.style.apply(_style_store, axis=1)
st.dataframe(
styled,
use_container_width=True,
hide_index=True,
height=min(len(display_df) * 38 + 50, 600),
)
# Average price chart per store for this search
st.subheader(f"Average price for '{actual_query}' by store")
avg_by_store = df.groupby("Store")["Effective"].mean().sort_values()
colors = []
for store in avg_by_store.index:
color = "#888888"
for key, val in STORE_COLOURS.items():
if key.lower() in store.lower():
color = val
break
colors.append(color)
fig = go.Figure(
go.Bar(
x=avg_by_store.index,
y=avg_by_store.values,
marker_color=colors,
text=[f"\u20ac{v:.2f}" for v in avg_by_store.values],
textposition="outside",
)
)
fig.update_layout(
yaxis_title="Average Price (\u20ac)",
yaxis_tickprefix="\u20ac",
margin=dict(l=40, r=20, t=20, b=40),
template="plotly_white",
height=350,
)
st.plotly_chart(fig, use_container_width=True)
# Cheapest finds
st.subheader("Best Deals")
cheapest = df.nsmallest(5, "Effective")
for _, row in cheapest.iterrows():
promo_text = f" ({row['Promo']})" if row["Promo"] else ""
st.markdown(
f"**\u20ac{row['Effective']:.2f}** - {row['Product']} @ {row['Store']}{promo_text}"
)
else:
st.info("Search for a product above or click a popular category to compare prices across stores.")

View file

@ -1,223 +0,0 @@
"""SmartCart Dashboard -- Product History page."""
from __future__ import annotations
import datetime
from typing import Any
import httpx
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from src.core.config import settings
from src.dashboard.components.charts import STORE_COLOURS, price_history_chart, store_comparison_bar
from src.dashboard.components.filters import date_range_filter, search_filter
API = settings.api_base_url
# ---------------------------------------------------------------------------
# Data fetching
# ---------------------------------------------------------------------------
@st.cache_data(ttl=60, show_spinner=False)
def _search_products(query: str) -> list[dict[str, Any]]:
if not query:
return []
try:
resp = httpx.get(
f"{API}/api/products",
params={"search": query, "limit": 50},
timeout=10,
)
resp.raise_for_status()
payload = resp.json()
if isinstance(payload, list):
return payload
return payload.get("items", payload.get("results", []))
except httpx.HTTPError:
return []
@st.cache_data(ttl=60, show_spinner=False)
def _fetch_price_history(product_id: int, days: int = 90) -> list[dict[str, Any]]:
try:
resp = httpx.get(
f"{API}/api/products/{product_id}/prices",
params={"days": days},
timeout=10,
)
resp.raise_for_status()
return resp.json()
except httpx.HTTPError:
return []
@st.cache_data(ttl=60, show_spinner=False)
def _fetch_comparison(product_id: int) -> dict[str, Any]:
try:
resp = httpx.get(f"{API}/api/products/{product_id}/compare", timeout=10)
resp.raise_for_status()
return resp.json()
except httpx.HTTPError:
return {}
@st.cache_data(ttl=60, show_spinner=False)
def _search_prices(query: str) -> list[dict[str, Any]]:
if not query:
return []
try:
resp = httpx.get(
f"{API}/api/search-prices",
params={"q": query, "limit": 100},
timeout=10,
)
resp.raise_for_status()
return resp.json()
except httpx.HTTPError:
return []
# ---------------------------------------------------------------------------
# Page content
# ---------------------------------------------------------------------------
st.title("Product History")
st.caption("Search for a product and explore its price history across stores.")
# ---- Sidebar filters ------------------------------------------------------
with st.sidebar:
st.subheader("Filters")
start_date, end_date = date_range_filter(key="history_date", default_days=90)
# ---- Search & select product ---------------------------------------------
query = search_filter(key="product_history_search")
products = _search_products(query)
if query and not products:
st.warning("No products found. Try a different search term.")
st.stop()
if not products:
st.info("Enter a search term above to find products.")
st.stop()
product_options = {p.get("name", f"Product {p['id']}"): p["id"] for p in products}
selected_name = st.selectbox(
"Select a product",
options=list(product_options.keys()),
key="product_selector",
)
if not selected_name:
st.stop()
product_id: int = product_options[selected_name]
# ---- Calculate days from date range --------------------------------------
days = (end_date - start_date).days
if days < 1:
days = 90
# ---- Price history time series chart -------------------------------------
st.subheader("Price History")
history = _fetch_price_history(product_id, days=days)
if history:
# The API returns list of {store: {...}, prices: [{price, promo_price, scraped_at, ...}]}
chart_data: list[dict[str, Any]] = []
for entry in history:
store_info = entry.get("store", {})
store_name = store_info.get("name", "Unknown")
prices = entry.get("prices", [])
for pr in prices:
scraped_at = pr.get("scraped_at", "")
price = float(pr.get("price", 0))
promo = pr.get("promo_price")
effective = float(promo) if promo else price
chart_data.append({
"date": scraped_at,
"price": effective,
"store_name": store_name,
"is_promo": pr.get("promo_label") is not None,
})
if chart_data:
fig = price_history_chart(chart_data)
st.plotly_chart(fig, use_container_width=True)
else:
st.info("No price data in the selected date range.")
else:
st.info("No price history available for this product.")
st.divider()
# ---- Current prices across stores ----------------------------------------
st.subheader("Current Prices Across Stores")
comparison = _fetch_comparison(product_id)
if comparison:
stores_list = comparison.get("stores", [])
if stores_list:
rows = []
bar_data = []
for sp in stores_list:
store_info = sp.get("store", {})
store_name = store_info.get("name", "Unknown")
price = sp.get("latest_price")
promo_price = sp.get("promo_price")
promo_label = sp.get("promo_label")
effective_price = promo_price if promo_price is not None else price
row = {
"Store": store_name,
"Price": f"\u20ac{float(price):.2f}" if price is not None else "\u2014",
"Promo": promo_label or "\u2014",
}
if promo_price is not None:
row["Promo Price"] = f"\u20ac{float(promo_price):.2f}"
rows.append(row)
if effective_price is not None:
bar_data.append({
"store_name": store_name,
"price": float(effective_price),
})
df = pd.DataFrame(rows)
st.dataframe(df, use_container_width=True, hide_index=True)
if bar_data:
fig2 = store_comparison_bar(bar_data)
st.plotly_chart(fig2, use_container_width=True)
else:
st.info("This product is not available in any store currently.")
else:
st.info("No comparison data available for this product.")
st.divider()
# ---- Similar products across stores (using search) -----------------------
st.subheader("Similar Products Across Stores")
st.caption(f"Other products matching '{query}' across all stores.")
similar = _search_prices(query) if query else []
if similar:
sim_rows = []
for item in similar:
price = item["price"]
effective = item["effective_price"]
sim_rows.append({
"Store": item["store"],
"Product": item["product_name"],
"Price": f"\u20ac{price:.2f}",
"Effective": f"\u20ac{effective:.2f}",
"Promo": item.get("promo_label") or "",
})
sim_df = pd.DataFrame(sim_rows).sort_values("Effective")
st.dataframe(sim_df, use_container_width=True, hide_index=True, height=min(len(sim_df) * 38 + 50, 400))
else:
if query:
st.info("No similar products found across stores.")