diff --git a/docs/superpowers/plans/2026-05-25-preis-tracker.md b/docs/superpowers/plans/2026-05-25-preis-tracker.md
new file mode 100644
index 0000000..1e1c1c0
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-25-preis-tracker.md
@@ -0,0 +1,2783 @@
+# preis-tracker Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Eine Single-User-Webapp, die Produktpreise bei Amazon, Idealo und Geizhals täglich trackt, einen Preisverlauf anzeigt und bei konfigurierbaren Bedingungen Pushover-Notifications sendet.
+
+**Architecture:** Next.js 16 App Router auf Coolify, Shared PostgreSQL (DB `preistracker`), Playwright für Amazon + Cheerio für Idealo/Geizhals via Adapter-Pattern. Zitadel OIDC (manueller PKCE) sichert die App; ein Coolify Scheduled Task triggert daily um 06:00 die Scrape-API. Alerts (Zielpreis / Allzeit-Tief / Prozent-Drop) lösen Pushover-Pushes aus.
+
+**Tech Stack:** Next.js 16.2.2, React 19, TypeScript 5, Tailwind CSS 4, Drizzle 0.45 + postgres-js, Playwright 1.50, Cheerio 1.0, iron-session 8, jose 5 (JWKS), Recharts 3, Bun 1.3 (dev), Node 22 (Container), Vitest 2 (tests).
+
+**Spec:** `docs/superpowers/specs/2026-05-25-preis-tracker-design.md`
+
+**⚠ Next.js 16 Hinweis:** Diese Next-Version hat Breaking Changes gegenüber 14/15. **Vor dem Schreiben von App-Router-Code** in `node_modules/next/dist/docs/` (oder via context7 MCP) die aktuellen API-Signaturen für `route.ts`, `middleware.ts`, `params`-Async, `cookies()`, `headers()` prüfen. Trainingsdaten sind potentiell veraltet.
+
+---
+
+## File Map
+
+```
+preis-tracker/
+├── package.json
+├── tsconfig.json
+├── next.config.ts
+├── drizzle.config.ts
+├── postcss.config.mjs
+├── eslint.config.mjs
+├── Dockerfile
+├── .dockerignore
+├── .gitignore
+├── .env.example
+├── README.md
+├── vitest.config.ts
+├── drizzle/ # Auto-generated migrations
+├── tests/
+│ ├── fixtures/
+│ │ ├── amazon-ps5.html
+│ │ ├── idealo-headphones.html
+│ │ └── geizhals-gpu.html
+│ ├── scrapers/
+│ │ ├── geizhals.test.ts
+│ │ ├── idealo.test.ts
+│ │ └── amazon.test.ts
+│ ├── alerts/evaluate.test.ts
+│ └── pushover.test.ts
+└── src/
+ ├── middleware.ts # Auth gate
+ ├── app/
+ │ ├── layout.tsx
+ │ ├── globals.css
+ │ ├── page.tsx # Dashboard
+ │ ├── add/page.tsx # Add-Product Form
+ │ ├── products/[id]/page.tsx # Detail + Chart
+ │ └── api/
+ │ ├── auth/
+ │ │ ├── login/route.ts
+ │ │ ├── callback/route.ts
+ │ │ └── logout/route.ts
+ │ ├── products/
+ │ │ ├── route.ts # POST add, GET list
+ │ │ └── [id]/
+ │ │ ├── route.ts # GET detail, DELETE
+ │ │ └── scrape/route.ts # POST manual scrape
+ │ ├── alerts/
+ │ │ ├── route.ts # POST create
+ │ │ └── [id]/route.ts # DELETE
+ │ └── cron/scrape/route.ts # Daily scrape entry
+ ├── lib/
+ │ ├── db/
+ │ │ ├── index.ts # drizzle client
+ │ │ └── schema.ts # tables + types
+ │ ├── auth/
+ │ │ ├── session.ts # iron-session config
+ │ │ ├── pkce.ts # PKCE helpers
+ │ │ └── zitadel.ts # OIDC token exchange + JWKS
+ │ ├── scrapers/
+ │ │ ├── types.ts
+ │ │ ├── index.ts # registry + scrapeUrl()
+ │ │ ├── amazon.ts
+ │ │ ├── idealo.ts
+ │ │ └── geizhals.ts
+ │ ├── alerts/
+ │ │ └── evaluate.ts # pure alert-trigger logic
+ │ ├── pushover.ts
+ │ └── shops.ts # shop detection from URL
+ └── components/
+ ├── ProductCard.tsx
+ ├── Sparkline.tsx
+ ├── PriceChart.tsx
+ ├── AlertList.tsx
+ └── AlertForm.tsx
+```
+
+---
+
+## Task 1: Project Bootstrap
+
+**Files:**
+- Create: `package.json`
+- Create: `tsconfig.json`
+- Create: `next.config.ts`
+- Create: `postcss.config.mjs`
+- Create: `eslint.config.mjs`
+- Create: `.gitignore`
+- Create: `.env.example`
+- Create: `src/app/layout.tsx`
+- Create: `src/app/page.tsx`
+- Create: `src/app/globals.css`
+
+- [ ] **Step 1: Create `package.json`**
+
+```json
+{
+ "name": "preis-tracker",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "eslint",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "db:generate": "drizzle-kit generate",
+ "db:push": "drizzle-kit push",
+ "db:studio": "drizzle-kit studio"
+ },
+ "dependencies": {
+ "@types/cheerio": "^0.22.35",
+ "cheerio": "^1.0.0",
+ "drizzle-orm": "^0.45.2",
+ "iron-session": "^8.0.4",
+ "jose": "^5.9.6",
+ "next": "16.2.2",
+ "playwright": "^1.50.0",
+ "postgres": "^3.4.8",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
+ "recharts": "^3.8.1",
+ "zod": "^3.23.8"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "drizzle-kit": "^0.31.10",
+ "eslint": "^9",
+ "eslint-config-next": "16.2.2",
+ "tailwindcss": "^4",
+ "tsx": "^4.21.0",
+ "typescript": "^5",
+ "vitest": "^2.1.8"
+ }
+}
+```
+
+- [ ] **Step 2: Create `tsconfig.json`**
+
+```json
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": { "@/*": ["./src/*"] }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
+```
+
+- [ ] **Step 3: Create `next.config.ts`**
+
+```typescript
+import type { NextConfig } from 'next'
+
+const config: NextConfig = {
+ output: 'standalone',
+ experimental: {
+ serverActions: { allowedOrigins: ['preis.kuns.dev'] },
+ },
+ images: {
+ remotePatterns: [
+ { protocol: 'https', hostname: '**.amazon.com' },
+ { protocol: 'https', hostname: '**.amazon.de' },
+ { protocol: 'https', hostname: '**.media-amazon.com' },
+ { protocol: 'https', hostname: '**.idealo.com' },
+ { protocol: 'https', hostname: '**.geizhals.de' },
+ ],
+ },
+}
+
+export default config
+```
+
+- [ ] **Step 4: Create `postcss.config.mjs`**
+
+```javascript
+export default { plugins: { '@tailwindcss/postcss': {} } }
+```
+
+- [ ] **Step 5: Create `eslint.config.mjs`**
+
+```javascript
+import next from 'eslint-config-next'
+export default [...next, { rules: { '@typescript-eslint/no-explicit-any': 'warn' } }]
+```
+
+- [ ] **Step 6: Create `.gitignore`**
+
+```
+node_modules/
+.next/
+.env
+.env.local
+*.log
+.DS_Store
+out/
+build/
+.turbo/
+coverage/
+test-results/
+```
+
+- [ ] **Step 7: Create `.env.example`**
+
+```
+# Database
+DATABASE_URL=postgresql://preistracker:CHANGEME@localhost:5432/preistracker
+
+# Zitadel OIDC
+ZITADEL_ISSUER=https://auth.kuns.dev
+ZITADEL_CLIENT_ID=
+ALLOWED_USER_IDS=
+
+# Session (32+ chars, random)
+SESSION_PASSWORD=
+
+# Pushover
+PUSHOVER_TOKEN=
+PUSHOVER_USER=
+
+# Cron secret (any random string)
+CRON_SECRET=
+
+# Public URL (for OIDC redirects)
+NEXT_PUBLIC_BASE_URL=http://localhost:3000
+```
+
+- [ ] **Step 8: Create `src/app/globals.css`**
+
+```css
+@import "tailwindcss";
+
+:root {
+ --background: #0a0a0a;
+ --foreground: #ededed;
+}
+
+body {
+ background: var(--background);
+ color: var(--foreground);
+ font-family: ui-sans-serif, system-ui, sans-serif;
+}
+```
+
+- [ ] **Step 9: Create `src/app/layout.tsx`**
+
+```tsx
+import './globals.css'
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Preis-Tracker',
+ description: 'Tracking von Produktpreisen bei Amazon, Idealo, Geizhals',
+}
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
{children}
+
+ )
+}
+```
+
+- [ ] **Step 10: Create stub `src/app/page.tsx`**
+
+```tsx
+export default function Home() {
+ return Preis-Tracker
+}
+```
+
+- [ ] **Step 11: Install + verify boot**
+
+```bash
+cd ~/preis-tracker
+bun install
+bun run dev &
+sleep 5
+curl -sf http://localhost:3000 > /dev/null && echo OK
+kill %1 2>/dev/null
+```
+
+Expected: `OK`
+
+- [ ] **Step 12: Commit**
+
+```bash
+git add -A
+git commit -m "feat: bootstrap next.js + tailwind + deps"
+```
+
+---
+
+## Task 2: Database Schema + Migrations
+
+**Files:**
+- Create: `drizzle.config.ts`
+- Create: `src/lib/db/schema.ts`
+- Create: `src/lib/db/index.ts`
+
+- [ ] **Step 1: Provision DB in Shared PostgreSQL**
+
+```bash
+# As user mika on kuns.dev
+source ~/.secrets/coolify-tokens.env
+PGPASSWORD="$SHARED_POSTGRES_PASSWORD" psql -h localhost -p 54320 -U mika -d postgres \
+ -c "CREATE DATABASE preistracker;" \
+ -c "CREATE USER preistracker WITH PASSWORD 'GENERATE_AND_NOTE_DOWN';" \
+ -c "GRANT ALL PRIVILEGES ON DATABASE preistracker TO preistracker;"
+PGPASSWORD="$SHARED_POSTGRES_PASSWORD" psql -h localhost -p 54320 -U mika -d preistracker \
+ -c "GRANT ALL ON SCHEMA public TO preistracker;"
+```
+
+Update `.env` (NOT `.env.example`) with the generated password.
+
+- [ ] **Step 2: Create `drizzle.config.ts`**
+
+```typescript
+import 'dotenv/config'
+import { defineConfig } from 'drizzle-kit'
+
+export default defineConfig({
+ schema: './src/lib/db/schema.ts',
+ out: './drizzle',
+ dialect: 'postgresql',
+ dbCredentials: { url: process.env.DATABASE_URL! },
+})
+```
+
+- [ ] **Step 3: Create `src/lib/db/schema.ts`**
+
+```typescript
+import { pgTable, uuid, text, timestamp, boolean, integer, numeric, bigserial, jsonb, index, check } from 'drizzle-orm/pg-core'
+import { sql } from 'drizzle-orm'
+
+export const products = pgTable('products', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ url: text('url').notNull().unique(),
+ shop: text('shop').notNull(),
+ name: text('name').notNull(),
+ imageUrl: text('image_url'),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+ enabled: boolean('enabled').notNull().default(true),
+ lastScrapedAt: timestamp('last_scraped_at', { withTimezone: true }),
+ consecutiveFailures: integer('consecutive_failures').notNull().default(0),
+}, (t) => ({
+ shopCheck: check('shop_check', sql`${t.shop} in ('amazon','idealo','geizhals')`),
+}))
+
+export const priceSnapshots = pgTable('price_snapshots', {
+ id: bigserial('id', { mode: 'number' }).primaryKey(),
+ productId: uuid('product_id').notNull().references(() => products.id, { onDelete: 'cascade' }),
+ price: numeric('price', { precision: 10, scale: 2 }),
+ currency: text('currency').notNull().default('EUR'),
+ availability: text('availability'),
+ error: text('error'),
+ scrapedAt: timestamp('scraped_at', { withTimezone: true }).notNull().defaultNow(),
+}, (t) => ({
+ productScrapedIdx: index('snapshots_product_scraped_idx').on(t.productId, t.scrapedAt.desc()),
+}))
+
+export const alerts = pgTable('alerts', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ productId: uuid('product_id').notNull().references(() => products.id, { onDelete: 'cascade' }),
+ type: text('type').notNull(),
+ config: jsonb('config').notNull(),
+ enabled: boolean('enabled').notNull().default(true),
+ lastTriggeredAt: timestamp('last_triggered_at', { withTimezone: true }),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+}, (t) => ({
+ typeCheck: check('alert_type_check', sql`${t.type} in ('target_price','all_time_low','percent_drop')`),
+}))
+
+export type Product = typeof products.$inferSelect
+export type NewProduct = typeof products.$inferInsert
+export type PriceSnapshot = typeof priceSnapshots.$inferSelect
+export type Alert = typeof alerts.$inferSelect
+export type NewAlert = typeof alerts.$inferInsert
+```
+
+- [ ] **Step 4: Create `src/lib/db/index.ts`**
+
+```typescript
+import { drizzle } from 'drizzle-orm/postgres-js'
+import postgres from 'postgres'
+import * as schema from './schema'
+
+const connectionString = process.env.DATABASE_URL
+if (!connectionString) throw new Error('DATABASE_URL not set')
+
+const client = postgres(connectionString, { max: 5 })
+export const db = drizzle(client, { schema })
+export * from './schema'
+```
+
+- [ ] **Step 5: Generate + run migration**
+
+```bash
+bun run db:generate
+bun run db:push
+```
+
+Expected: `drizzle/` directory created with SQL, DB has 3 tables.
+
+- [ ] **Step 6: Verify tables exist**
+
+```bash
+PGPASSWORD="" psql -h localhost -p 54320 -U preistracker -d preistracker -c "\dt"
+```
+
+Expected: shows `products`, `price_snapshots`, `alerts`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A
+git commit -m "feat: drizzle schema + migrations for products/snapshots/alerts"
+```
+
+---
+
+## Task 3: Vitest Setup + Shop Detection
+
+**Files:**
+- Create: `vitest.config.ts`
+- Create: `src/lib/shops.ts`
+- Create: `tests/shops.test.ts`
+
+- [ ] **Step 1: Create `vitest.config.ts`**
+
+```typescript
+import { defineConfig } from 'vitest/config'
+import path from 'node:path'
+
+export default defineConfig({
+ test: {
+ environment: 'node',
+ include: ['tests/**/*.test.ts'],
+ },
+ resolve: { alias: { '@': path.resolve(__dirname, './src') } },
+})
+```
+
+- [ ] **Step 2: Write failing test `tests/shops.test.ts`**
+
+```typescript
+import { describe, it, expect } from 'vitest'
+import { detectShop } from '@/lib/shops'
+
+describe('detectShop', () => {
+ it.each([
+ ['https://www.amazon.de/dp/B0C5BMMJTL', 'amazon'],
+ ['https://amazon.com/gp/product/B0C5BMMJTL', 'amazon'],
+ ['https://www.idealo.de/preisvergleich/OffersOfProduct/123456_-foo.html', 'idealo'],
+ ['https://geizhals.de/sony-playstation-5-a2362829.html', 'geizhals'],
+ ['https://www.geizhals.eu/foo', 'geizhals'],
+ ])('detects %s as %s', (url, expected) => {
+ expect(detectShop(url)).toBe(expected)
+ })
+
+ it('returns null for unknown shop', () => {
+ expect(detectShop('https://example.com/foo')).toBeNull()
+ })
+
+ it('returns null for invalid url', () => {
+ expect(detectShop('not a url')).toBeNull()
+ })
+})
+```
+
+- [ ] **Step 3: Run test, expect failure**
+
+```bash
+bun run test
+```
+
+Expected: FAIL — `detectShop` is not exported.
+
+- [ ] **Step 4: Implement `src/lib/shops.ts`**
+
+```typescript
+export type Shop = 'amazon' | 'idealo' | 'geizhals'
+
+const PATTERNS: Array<{ shop: Shop; hostMatch: RegExp }> = [
+ { shop: 'amazon', hostMatch: /(^|\.)amazon\.(de|com|co\.uk|fr|it|es|nl)$/i },
+ { shop: 'idealo', hostMatch: /(^|\.)idealo\.(de|com|at|fr|it|es|co\.uk)$/i },
+ { shop: 'geizhals', hostMatch: /(^|\.)geizhals\.(de|at|eu)$/i },
+]
+
+export function detectShop(input: string): Shop | null {
+ let host: string
+ try {
+ host = new URL(input).hostname
+ } catch {
+ return null
+ }
+ return PATTERNS.find((p) => p.hostMatch.test(host))?.shop ?? null
+}
+```
+
+- [ ] **Step 5: Run test, expect pass**
+
+```bash
+bun run test
+```
+
+Expected: All tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A
+git commit -m "feat: shop detection from URL + vitest setup"
+```
+
+---
+
+## Task 4: Scraper Adapter Framework
+
+**Files:**
+- Create: `src/lib/scrapers/types.ts`
+- Create: `src/lib/scrapers/index.ts`
+- Create: `tests/scrapers/registry.test.ts`
+
+- [ ] **Step 1: Create `src/lib/scrapers/types.ts`**
+
+```typescript
+import type { Shop } from '@/lib/shops'
+
+export interface ScrapeResult {
+ price: number | null
+ currency: string
+ availability: 'in_stock' | 'out_of_stock' | 'unknown'
+ name?: string
+ imageUrl?: string
+ error?: string
+}
+
+export interface PriceScraper {
+ shop: Shop
+ scrape(url: string): Promise
+}
+```
+
+- [ ] **Step 2: Write failing test `tests/scrapers/registry.test.ts`**
+
+```typescript
+import { describe, it, expect, vi } from 'vitest'
+import { scrapeUrl, registerScraperForTest, resetScrapersForTest } from '@/lib/scrapers'
+import type { PriceScraper } from '@/lib/scrapers/types'
+
+describe('scrapeUrl', () => {
+ it('dispatches to matching shop scraper', async () => {
+ const fake: PriceScraper = {
+ shop: 'geizhals',
+ scrape: vi.fn().mockResolvedValue({
+ price: 42, currency: 'EUR', availability: 'in_stock', name: 'X',
+ }),
+ }
+ resetScrapersForTest()
+ registerScraperForTest(fake)
+ const result = await scrapeUrl('https://geizhals.de/foo')
+ expect(result.price).toBe(42)
+ expect(fake.scrape).toHaveBeenCalledWith('https://geizhals.de/foo')
+ })
+
+ it('throws on unknown shop', async () => {
+ resetScrapersForTest()
+ await expect(scrapeUrl('https://example.com')).rejects.toThrow(/unsupported/i)
+ })
+})
+```
+
+- [ ] **Step 3: Run test, expect failure**
+
+```bash
+bun run test tests/scrapers/registry.test.ts
+```
+
+Expected: FAIL — module missing.
+
+- [ ] **Step 4: Implement `src/lib/scrapers/index.ts`**
+
+```typescript
+import { detectShop, type Shop } from '@/lib/shops'
+import type { PriceScraper, ScrapeResult } from './types'
+
+const registry = new Map()
+
+export function registerScraper(scraper: PriceScraper) {
+ registry.set(scraper.shop, scraper)
+}
+
+export async function scrapeUrl(url: string): Promise {
+ const shop = detectShop(url)
+ if (!shop) throw new Error(`Unsupported URL: ${url}`)
+ const scraper = registry.get(shop)
+ if (!scraper) throw new Error(`No scraper registered for shop: ${shop}`)
+ return scraper.scrape(url)
+}
+
+export function registerScraperForTest(s: PriceScraper) { registry.set(s.shop, s) }
+export function resetScrapersForTest() { registry.clear() }
+
+// Real scrapers register themselves at module init when imported from a server entrypoint.
+// See src/lib/scrapers/{amazon,idealo,geizhals}.ts and src/lib/scrapers/register.ts in Task 8.
+```
+
+- [ ] **Step 5: Run test, expect pass**
+
+```bash
+bun run test tests/scrapers/registry.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A
+git commit -m "feat: scraper registry + adapter interface"
+```
+
+---
+
+## Task 5: Geizhals Scraper
+
+**Files:**
+- Create: `src/lib/scrapers/geizhals.ts`
+- Create: `tests/fixtures/geizhals-gpu.html`
+- Create: `tests/scrapers/geizhals.test.ts`
+
+- [ ] **Step 1: Capture fixture HTML**
+
+```bash
+curl -sL -A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' \
+ 'https://geizhals.de/sony-playstation-5-pro-a3163879.html' \
+ > tests/fixtures/geizhals-gpu.html
+```
+
+Verify file is >50KB and contains a price tag:
+
+```bash
+grep -oE 'class="[^"]*price[^"]*"' tests/fixtures/geizhals-gpu.html | head -3
+```
+
+- [ ] **Step 2: Write failing test `tests/scrapers/geizhals.test.ts`**
+
+```typescript
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { geizhalsScraper } from '@/lib/scrapers/geizhals'
+
+const fixture = readFileSync(join(__dirname, '../fixtures/geizhals-gpu.html'), 'utf-8')
+
+beforeEach(() => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ text: async () => fixture,
+ }) as unknown as typeof fetch
+})
+
+describe('geizhalsScraper', () => {
+ it('extracts price and name', async () => {
+ const r = await geizhalsScraper.scrape('https://geizhals.de/test')
+ expect(r.price).toBeGreaterThan(0)
+ expect(r.currency).toBe('EUR')
+ expect(r.name).toBeTruthy()
+ })
+
+ it('returns error on HTTP failure', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false, status: 503, text: async () => '',
+ }) as unknown as typeof fetch
+ const r = await geizhalsScraper.scrape('https://geizhals.de/test')
+ expect(r.price).toBeNull()
+ expect(r.error).toMatch(/HTTP 503/)
+ })
+})
+```
+
+- [ ] **Step 3: Run test, expect failure**
+
+```bash
+bun run test tests/scrapers/geizhals.test.ts
+```
+
+Expected: FAIL — module missing.
+
+- [ ] **Step 4: Implement `src/lib/scrapers/geizhals.ts`**
+
+```typescript
+import * as cheerio from 'cheerio'
+import type { PriceScraper, ScrapeResult } from './types'
+
+const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'
+
+export const geizhalsScraper: PriceScraper = {
+ shop: 'geizhals',
+ async scrape(url: string): Promise {
+ try {
+ const res = await fetch(url, {
+ headers: { 'User-Agent': UA, 'Accept-Language': 'de-DE,de;q=0.9' },
+ signal: AbortSignal.timeout(20_000),
+ })
+ if (!res.ok) {
+ return { price: null, currency: 'EUR', availability: 'unknown', error: `HTTP ${res.status}` }
+ }
+ const $ = cheerio.load(await res.text())
+
+ // Try multiple selectors (price layout varies)
+ const priceTexts = [
+ $('.gh_price').first().text(),
+ $('span.gh_price strong').first().text(),
+ $('[itemprop="price"]').attr('content'),
+ $('meta[itemprop="price"]').attr('content'),
+ ].filter(Boolean) as string[]
+
+ const price = parsePrice(priceTexts[0] ?? '')
+ const name = ($('h1[itemprop="name"]').text() || $('h1').first().text() || '').trim()
+ const imageUrl = $('img.product-gallery__image').first().attr('src')
+ || $('meta[property="og:image"]').attr('content')
+ || undefined
+
+ if (price === null) {
+ return { price: null, currency: 'EUR', availability: 'unknown', name, imageUrl, error: 'price-selector-missed' }
+ }
+
+ return { price, currency: 'EUR', availability: 'in_stock', name, imageUrl }
+ } catch (err) {
+ return { price: null, currency: 'EUR', availability: 'unknown', error: (err as Error).message }
+ }
+ },
+}
+
+function parsePrice(text: string): number | null {
+ // "€ 123,45" or "123,45 €" or "123.45"
+ const cleaned = text.replace(/[^\d.,]/g, '').replace(/\.(?=\d{3}(\D|$))/g, '').replace(',', '.')
+ if (!cleaned) return null
+ const n = parseFloat(cleaned)
+ return Number.isFinite(n) && n > 0 ? n : null
+}
+```
+
+- [ ] **Step 5: Run test, expect pass**
+
+```bash
+bun run test tests/scrapers/geizhals.test.ts
+```
+
+Expected: PASS. If price extraction fails, inspect the fixture, adjust selectors in the scraper.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A
+git commit -m "feat: geizhals scraper with cheerio + tests"
+```
+
+---
+
+## Task 6: Idealo Scraper
+
+**Files:**
+- Create: `src/lib/scrapers/idealo.ts`
+- Create: `tests/fixtures/idealo-headphones.html`
+- Create: `tests/scrapers/idealo.test.ts`
+
+- [ ] **Step 1: Capture fixture**
+
+```bash
+curl -sL -A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0' \
+ 'https://www.idealo.de/preisvergleich/OffersOfProduct/202740303_-wh-1000xm5-sony.html' \
+ > tests/fixtures/idealo-headphones.html
+ls -lh tests/fixtures/idealo-headphones.html
+```
+
+Verify size >20KB. If Cloudflare blocks (file <5KB), capture from a browser dev-tools "Save as HTML" instead.
+
+- [ ] **Step 2: Write failing test `tests/scrapers/idealo.test.ts`**
+
+```typescript
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { idealoScraper } from '@/lib/scrapers/idealo'
+
+const fixture = readFileSync(join(__dirname, '../fixtures/idealo-headphones.html'), 'utf-8')
+
+beforeEach(() => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true, status: 200, text: async () => fixture,
+ }) as unknown as typeof fetch
+})
+
+describe('idealoScraper', () => {
+ it('extracts price and name', async () => {
+ const r = await idealoScraper.scrape('https://www.idealo.de/foo')
+ expect(r.price).toBeGreaterThan(0)
+ expect(r.currency).toBe('EUR')
+ expect(r.name).toBeTruthy()
+ })
+
+ it('flags cloudflare challenge', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false, status: 403, text: async () => 'Cloudflare',
+ }) as unknown as typeof fetch
+ const r = await idealoScraper.scrape('https://www.idealo.de/foo')
+ expect(r.price).toBeNull()
+ expect(r.error).toMatch(/403|cloudflare/i)
+ })
+})
+```
+
+- [ ] **Step 3: Run test, expect failure**
+
+```bash
+bun run test tests/scrapers/idealo.test.ts
+```
+
+Expected: FAIL — module missing.
+
+- [ ] **Step 4: Implement `src/lib/scrapers/idealo.ts`**
+
+```typescript
+import * as cheerio from 'cheerio'
+import type { PriceScraper, ScrapeResult } from './types'
+
+const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'
+
+export const idealoScraper: PriceScraper = {
+ shop: 'idealo',
+ async scrape(url: string): Promise {
+ try {
+ const res = await fetch(url, {
+ headers: {
+ 'User-Agent': UA,
+ 'Accept': 'text/html,application/xhtml+xml',
+ 'Accept-Language': 'de-DE,de;q=0.9',
+ },
+ signal: AbortSignal.timeout(20_000),
+ })
+ if (!res.ok) {
+ return { price: null, currency: 'EUR', availability: 'unknown', error: `HTTP ${res.status}` }
+ }
+ const $ = cheerio.load(await res.text())
+
+ const priceTexts = [
+ $('[data-testid="detail-offer-price"]').first().text(),
+ $('meta[itemprop="price"]').attr('content'),
+ $('span.oopStage-conditionButton-price').first().text(),
+ $('strong.oopStage-price').first().text(),
+ ].filter(Boolean) as string[]
+
+ const price = parsePrice(priceTexts[0] ?? '')
+ const name = ($('h1[data-testid="product-title"]').text() || $('h1').first().text() || '').trim()
+ const imageUrl = $('meta[property="og:image"]').attr('content') || undefined
+
+ if (price === null) {
+ return { price: null, currency: 'EUR', availability: 'unknown', name, imageUrl, error: 'price-selector-missed' }
+ }
+ return { price, currency: 'EUR', availability: 'in_stock', name, imageUrl }
+ } catch (err) {
+ return { price: null, currency: 'EUR', availability: 'unknown', error: (err as Error).message }
+ }
+ },
+}
+
+function parsePrice(text: string): number | null {
+ const cleaned = text.replace(/[^\d.,]/g, '').replace(/\.(?=\d{3}(\D|$))/g, '').replace(',', '.')
+ if (!cleaned) return null
+ const n = parseFloat(cleaned)
+ return Number.isFinite(n) && n > 0 ? n : null
+}
+```
+
+- [ ] **Step 5: Run test, expect pass**
+
+```bash
+bun run test tests/scrapers/idealo.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A
+git commit -m "feat: idealo scraper"
+```
+
+---
+
+## Task 7: Amazon Scraper (Playwright)
+
+**Files:**
+- Create: `src/lib/scrapers/amazon.ts`
+- Create: `tests/fixtures/amazon-ps5.html`
+- Create: `tests/scrapers/amazon.test.ts`
+
+The Amazon scraper extracts via cheerio from rendered HTML; Playwright is only used to load the page (Amazon serves much content via JS). Tests use a saved fixture without running Playwright.
+
+- [ ] **Step 1: Capture fixture**
+
+```bash
+curl -sL -A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0' \
+ -H 'Accept-Language: de-DE,de;q=0.9' \
+ 'https://www.amazon.de/dp/B0C5BMMJTL' \
+ > tests/fixtures/amazon-ps5.html
+ls -lh tests/fixtures/amazon-ps5.html
+```
+
+If the file is <30KB it likely shows a robot check page; in that case open the URL in a real browser and "Save Page As → HTML Only" into the fixture file.
+
+- [ ] **Step 2: Write failing test `tests/scrapers/amazon.test.ts`**
+
+```typescript
+import { describe, it, expect } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { parseAmazonHtml } from '@/lib/scrapers/amazon'
+
+const fixture = readFileSync(join(__dirname, '../fixtures/amazon-ps5.html'), 'utf-8')
+
+describe('parseAmazonHtml', () => {
+ it('extracts price, name, image', () => {
+ const r = parseAmazonHtml(fixture)
+ expect(r.price).toBeGreaterThan(0)
+ expect(r.currency).toBe('EUR')
+ expect(r.name).toBeTruthy()
+ expect(r.imageUrl).toMatch(/^https?:\/\//)
+ })
+
+ it('detects captcha page', () => {
+ const captchaHtml = ''
+ const r = parseAmazonHtml(captchaHtml)
+ expect(r.price).toBeNull()
+ expect(r.error).toBe('captcha')
+ })
+})
+```
+
+- [ ] **Step 3: Run test, expect failure**
+
+```bash
+bun run test tests/scrapers/amazon.test.ts
+```
+
+Expected: FAIL — module missing.
+
+- [ ] **Step 4: Implement `src/lib/scrapers/amazon.ts`**
+
+```typescript
+import * as cheerio from 'cheerio'
+import type { PriceScraper, ScrapeResult } from './types'
+
+export function parseAmazonHtml(html: string): ScrapeResult {
+ if (/validateCaptcha|api-services-support@amazon/i.test(html)) {
+ return { price: null, currency: 'EUR', availability: 'unknown', error: 'captcha' }
+ }
+ const $ = cheerio.load(html)
+
+ const priceTexts = [
+ $('#corePrice_feature_div .a-offscreen').first().text(),
+ $('#corePriceDisplay_desktop_feature_div .a-offscreen').first().text(),
+ $('span.priceToPay .a-offscreen').first().text(),
+ $('#priceblock_ourprice').first().text(),
+ $('#priceblock_dealprice').first().text(),
+ $('.a-price .a-offscreen').first().text(),
+ ].filter(Boolean) as string[]
+
+ const price = parsePrice(priceTexts[0] ?? '')
+ const name = ($('#productTitle').text() || $('h1#title').text() || '').trim()
+ const imageUrl = $('#landingImage').attr('src')
+ || $('#imgBlkFront').attr('src')
+ || $('meta[property="og:image"]').attr('content')
+ || undefined
+
+ const outOfStock = /derzeit nicht verf|currently unavailable/i.test($('#availability').text())
+ const availability = outOfStock ? 'out_of_stock' : (price !== null ? 'in_stock' : 'unknown')
+
+ if (price === null) {
+ return { price: null, currency: 'EUR', availability, name, imageUrl, error: 'price-selector-missed' }
+ }
+ return { price, currency: 'EUR', availability, name, imageUrl }
+}
+
+function parsePrice(text: string): number | null {
+ const cleaned = text.replace(/[^\d.,]/g, '').replace(/\.(?=\d{3}(\D|$))/g, '').replace(',', '.')
+ if (!cleaned) return null
+ const n = parseFloat(cleaned)
+ return Number.isFinite(n) && n > 0 ? n : null
+}
+
+export const amazonScraper: PriceScraper = {
+ shop: 'amazon',
+ async scrape(url: string): Promise {
+ // Import lazily so test runs don't pull in playwright
+ const { chromium } = await import('playwright')
+ const browser = await chromium.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] })
+ try {
+ const ctx = await browser.newContext({
+ userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
+ locale: 'de-DE',
+ extraHTTPHeaders: { 'Accept-Language': 'de-DE,de;q=0.9' },
+ })
+ const page = await ctx.newPage()
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 })
+ const html = await page.content()
+ return parseAmazonHtml(html)
+ } catch (err) {
+ return { price: null, currency: 'EUR', availability: 'unknown', error: (err as Error).message }
+ } finally {
+ await browser.close()
+ }
+ },
+}
+```
+
+- [ ] **Step 5: Run test, expect pass**
+
+```bash
+bun run test tests/scrapers/amazon.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Install Playwright Chromium for dev runs**
+
+```bash
+bunx playwright install chromium
+```
+
+- [ ] **Step 7: Manual smoke test**
+
+```bash
+bun run -e "
+import { amazonScraper } from './src/lib/scrapers/amazon.ts'
+const r = await amazonScraper.scrape('https://www.amazon.de/dp/B0C5BMMJTL')
+console.log(r)
+"
+```
+
+If you hit captcha, that's expected on shared IPs — daily cron from the Coolify container should be fine. Log it and move on.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add -A
+git commit -m "feat: amazon scraper with playwright + html parser tests"
+```
+
+---
+
+## Task 8: Scraper Registration Entrypoint
+
+**Files:**
+- Create: `src/lib/scrapers/register.ts`
+
+- [ ] **Step 1: Create `src/lib/scrapers/register.ts`**
+
+```typescript
+import { registerScraper } from './index'
+import { amazonScraper } from './amazon'
+import { idealoScraper } from './idealo'
+import { geizhalsScraper } from './geizhals'
+
+let registered = false
+
+export function ensureScrapersRegistered() {
+ if (registered) return
+ registerScraper(amazonScraper)
+ registerScraper(idealoScraper)
+ registerScraper(geizhalsScraper)
+ registered = true
+}
+```
+
+Every API route that calls `scrapeUrl` MUST call `ensureScrapersRegistered()` at the top.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add -A
+git commit -m "feat: scraper registration entrypoint"
+```
+
+---
+
+## Task 9: Pushover Client
+
+**Files:**
+- Create: `src/lib/pushover.ts`
+- Create: `tests/pushover.test.ts`
+
+- [ ] **Step 1: Write failing test `tests/pushover.test.ts`**
+
+```typescript
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { sendPush } from '@/lib/pushover'
+
+beforeEach(() => {
+ process.env.PUSHOVER_TOKEN = 'tok'
+ process.env.PUSHOVER_USER = 'user'
+ global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ status: 1 }) }) as unknown as typeof fetch
+})
+
+describe('sendPush', () => {
+ it('posts to pushover with token/user/payload', async () => {
+ await sendPush({ title: 'T', message: 'M', url: 'https://x' })
+ const fetchMock = global.fetch as unknown as ReturnType
+ const [url, init] = fetchMock.mock.calls[0]
+ expect(url).toBe('https://api.pushover.net/1/messages.json')
+ const body = init.body as URLSearchParams
+ expect(body.get('token')).toBe('tok')
+ expect(body.get('user')).toBe('user')
+ expect(body.get('title')).toBe('T')
+ expect(body.get('message')).toBe('M')
+ expect(body.get('url')).toBe('https://x')
+ })
+
+ it('throws if env vars missing', async () => {
+ delete process.env.PUSHOVER_TOKEN
+ await expect(sendPush({ title: 'T', message: 'M' })).rejects.toThrow(/PUSHOVER/)
+ })
+})
+```
+
+- [ ] **Step 2: Run test, expect failure**
+
+```bash
+bun run test tests/pushover.test.ts
+```
+
+Expected: FAIL.
+
+- [ ] **Step 3: Implement `src/lib/pushover.ts`**
+
+```typescript
+export interface PushOpts {
+ title: string
+ message: string
+ url?: string
+ urlTitle?: string
+ priority?: -2 | -1 | 0 | 1 | 2
+}
+
+export async function sendPush(opts: PushOpts): Promise {
+ const token = process.env.PUSHOVER_TOKEN
+ const user = process.env.PUSHOVER_USER
+ if (!token || !user) throw new Error('PUSHOVER_TOKEN/PUSHOVER_USER not set')
+
+ const body = new URLSearchParams({
+ token,
+ user,
+ title: opts.title,
+ message: opts.message,
+ priority: String(opts.priority ?? 0),
+ })
+ if (opts.url) body.set('url', opts.url)
+ if (opts.urlTitle) body.set('url_title', opts.urlTitle)
+
+ const res = await fetch('https://api.pushover.net/1/messages.json', {
+ method: 'POST',
+ body,
+ signal: AbortSignal.timeout(10_000),
+ })
+ if (!res.ok) {
+ const txt = await res.text().catch(() => '')
+ throw new Error(`Pushover ${res.status}: ${txt}`)
+ }
+}
+```
+
+- [ ] **Step 4: Run test, expect pass**
+
+```bash
+bun run test tests/pushover.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -m "feat: pushover client"
+```
+
+---
+
+## Task 10: Alert Evaluation Logic
+
+**Files:**
+- Create: `src/lib/alerts/evaluate.ts`
+- Create: `tests/alerts/evaluate.test.ts`
+
+- [ ] **Step 1: Write failing test `tests/alerts/evaluate.test.ts`**
+
+```typescript
+import { describe, it, expect } from 'vitest'
+import { evaluateAlert, type SnapshotInput } from '@/lib/alerts/evaluate'
+
+function snap(price: number, daysAgo: number): SnapshotInput {
+ const d = new Date()
+ d.setDate(d.getDate() - daysAgo)
+ return { price, scrapedAt: d }
+}
+
+describe('evaluateAlert', () => {
+ describe('target_price', () => {
+ it('triggers when current price <= threshold', () => {
+ const r = evaluateAlert({
+ alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: null },
+ currentPrice: 99,
+ history: [],
+ })
+ expect(r.triggered).toBe(true)
+ })
+
+ it('does not trigger when price > threshold', () => {
+ const r = evaluateAlert({
+ alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: null },
+ currentPrice: 101,
+ history: [],
+ })
+ expect(r.triggered).toBe(false)
+ })
+ })
+
+ describe('all_time_low', () => {
+ it('triggers when current is below all previous successful prices', () => {
+ const r = evaluateAlert({
+ alert: { type: 'all_time_low', config: {}, lastTriggeredAt: null },
+ currentPrice: 50,
+ history: [snap(60, 1), snap(70, 5), snap(80, 10)],
+ })
+ expect(r.triggered).toBe(true)
+ expect(r.context.prevMin).toBe(60)
+ })
+
+ it('does not trigger when current equals previous min', () => {
+ const r = evaluateAlert({
+ alert: { type: 'all_time_low', config: {}, lastTriggeredAt: null },
+ currentPrice: 60,
+ history: [snap(60, 1)],
+ })
+ expect(r.triggered).toBe(false)
+ })
+
+ it('does not trigger with no history (first scrape)', () => {
+ const r = evaluateAlert({
+ alert: { type: 'all_time_low', config: {}, lastTriggeredAt: null },
+ currentPrice: 50,
+ history: [],
+ })
+ expect(r.triggered).toBe(false)
+ })
+ })
+
+ describe('percent_drop', () => {
+ it('triggers when price drops >= percent vs lookback avg', () => {
+ const r = evaluateAlert({
+ alert: { type: 'percent_drop', config: { lookback_days: 7, percent: 10 }, lastTriggeredAt: null },
+ currentPrice: 80,
+ history: [snap(100, 1), snap(100, 3), snap(100, 6), snap(50, 30)],
+ })
+ expect(r.triggered).toBe(true)
+ expect(r.context.percent).toBeGreaterThanOrEqual(10)
+ })
+
+ it('does not trigger when drop is smaller than threshold', () => {
+ const r = evaluateAlert({
+ alert: { type: 'percent_drop', config: { lookback_days: 7, percent: 10 }, lastTriggeredAt: null },
+ currentPrice: 95,
+ history: [snap(100, 1), snap(100, 3)],
+ })
+ expect(r.triggered).toBe(false)
+ })
+
+ it('does not trigger with no history in lookback window', () => {
+ const r = evaluateAlert({
+ alert: { type: 'percent_drop', config: { lookback_days: 7, percent: 10 }, lastTriggeredAt: null },
+ currentPrice: 50,
+ history: [snap(100, 30)],
+ })
+ expect(r.triggered).toBe(false)
+ })
+ })
+
+ describe('dedup', () => {
+ it('does not trigger if last triggered within 24h', () => {
+ const recent = new Date(Date.now() - 1000 * 60 * 60 * 12)
+ const r = evaluateAlert({
+ alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: recent },
+ currentPrice: 50,
+ history: [],
+ })
+ expect(r.triggered).toBe(false)
+ })
+
+ it('triggers if last triggered > 24h ago', () => {
+ const old = new Date(Date.now() - 1000 * 60 * 60 * 25)
+ const r = evaluateAlert({
+ alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: old },
+ currentPrice: 50,
+ history: [],
+ })
+ expect(r.triggered).toBe(true)
+ })
+ })
+})
+```
+
+- [ ] **Step 2: Run test, expect failure**
+
+```bash
+bun run test tests/alerts/evaluate.test.ts
+```
+
+Expected: FAIL — module missing.
+
+- [ ] **Step 3: Implement `src/lib/alerts/evaluate.ts`**
+
+```typescript
+export type AlertType = 'target_price' | 'all_time_low' | 'percent_drop'
+
+export interface AlertInput {
+ type: AlertType
+ config: Record
+ lastTriggeredAt: Date | null
+}
+
+export interface SnapshotInput {
+ price: number
+ scrapedAt: Date
+}
+
+export interface EvalInput {
+ alert: AlertInput
+ currentPrice: number
+ history: SnapshotInput[]
+}
+
+export interface EvalResult {
+ triggered: boolean
+ context: Record
+}
+
+const DEDUP_HOURS = 24
+
+export function evaluateAlert(input: EvalInput): EvalResult {
+ const { alert, currentPrice, history } = input
+
+ if (alert.lastTriggeredAt) {
+ const ageMs = Date.now() - alert.lastTriggeredAt.getTime()
+ if (ageMs < DEDUP_HOURS * 60 * 60 * 1000) {
+ return { triggered: false, context: { reason: 'dedup-cooldown' } }
+ }
+ }
+
+ switch (alert.type) {
+ case 'target_price': {
+ const threshold = Number(alert.config.threshold)
+ if (!Number.isFinite(threshold)) return { triggered: false, context: { reason: 'bad-config' } }
+ return { triggered: currentPrice <= threshold, context: { threshold, currentPrice } }
+ }
+ case 'all_time_low': {
+ if (history.length === 0) return { triggered: false, context: { reason: 'no-history' } }
+ const prevMin = Math.min(...history.map((s) => s.price))
+ return { triggered: currentPrice < prevMin, context: { prevMin, currentPrice } }
+ }
+ case 'percent_drop': {
+ const lookbackDays = Number(alert.config.lookback_days)
+ const percent = Number(alert.config.percent)
+ if (!Number.isFinite(lookbackDays) || !Number.isFinite(percent)) {
+ return { triggered: false, context: { reason: 'bad-config' } }
+ }
+ const cutoff = Date.now() - lookbackDays * 86400_000
+ const window = history.filter((s) => s.scrapedAt.getTime() >= cutoff)
+ if (window.length === 0) return { triggered: false, context: { reason: 'no-history-in-window' } }
+ const avg = window.reduce((s, x) => s + x.price, 0) / window.length
+ const dropPct = ((avg - currentPrice) / avg) * 100
+ return { triggered: dropPct >= percent, context: { avg, percent: Number(dropPct.toFixed(2)), currentPrice } }
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run test, expect pass**
+
+```bash
+bun run test tests/alerts/evaluate.test.ts
+```
+
+Expected: PASS — all cases green.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -m "feat: alert evaluation logic (target/atl/drop) with dedup"
+```
+
+---
+
+## Task 11: Zitadel Auth (PKCE + iron-session)
+
+**Files:**
+- Create: `src/lib/auth/pkce.ts`
+- Create: `src/lib/auth/session.ts`
+- Create: `src/lib/auth/zitadel.ts`
+- Create: `src/app/api/auth/login/route.ts`
+- Create: `src/app/api/auth/callback/route.ts`
+- Create: `src/app/api/auth/logout/route.ts`
+- Create: `src/middleware.ts`
+
+**Before coding:** Read `node_modules/next/dist/docs/` for Next.js 16 middleware + route-handler API. Confirm: `cookies()` async, `NextRequest.headers`, `Response.redirect` patterns.
+
+- [ ] **Step 1: Create `src/lib/auth/pkce.ts`**
+
+```typescript
+import { randomBytes, createHash } from 'node:crypto'
+
+export function generateVerifier(): string {
+ return randomBytes(64).toString('base64url')
+}
+
+export function challengeFromVerifier(verifier: string): string {
+ return createHash('sha256').update(verifier).digest('base64url')
+}
+
+export function generateState(): string {
+ return randomBytes(32).toString('base64url')
+}
+```
+
+- [ ] **Step 2: Create `src/lib/auth/session.ts`**
+
+```typescript
+import type { SessionOptions } from 'iron-session'
+import { getIronSession } from 'iron-session'
+import { cookies } from 'next/headers'
+
+export interface SessionData {
+ userId?: string
+ email?: string
+ name?: string
+ loginInProgress?: { state: string; codeVerifier: string }
+}
+
+const opts: SessionOptions = {
+ password: process.env.SESSION_PASSWORD || '',
+ cookieName: 'preis_tracker_session',
+ cookieOptions: {
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: process.env.NODE_ENV === 'production',
+ maxAge: 7 * 24 * 60 * 60,
+ },
+}
+
+export async function getSession() {
+ if (!opts.password || opts.password.length < 32) {
+ throw new Error('SESSION_PASSWORD must be set to a 32+ char value')
+ }
+ const c = await cookies()
+ return getIronSession(c, opts)
+}
+```
+
+- [ ] **Step 3: Create `src/lib/auth/zitadel.ts`**
+
+```typescript
+import { createRemoteJWKSet, jwtVerify } from 'jose'
+
+const issuer = process.env.ZITADEL_ISSUER!
+const clientId = process.env.ZITADEL_CLIENT_ID!
+
+let jwksCache: ReturnType | null = null
+function getJWKS() {
+ if (!jwksCache) {
+ jwksCache = createRemoteJWKSet(new URL(`${issuer}/oauth/v2/keys`))
+ }
+ return jwksCache
+}
+
+export interface TokenSet {
+ access_token: string
+ id_token: string
+ refresh_token?: string
+ expires_in: number
+ token_type: string
+}
+
+export async function exchangeCode(args: {
+ code: string
+ codeVerifier: string
+ redirectUri: string
+}): Promise {
+ const body = new URLSearchParams({
+ grant_type: 'authorization_code',
+ client_id: clientId,
+ code: args.code,
+ code_verifier: args.codeVerifier,
+ redirect_uri: args.redirectUri,
+ })
+ const res = await fetch(`${issuer}/oauth/v2/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body,
+ })
+ if (!res.ok) {
+ throw new Error(`Zitadel token exchange failed: ${res.status} ${await res.text()}`)
+ }
+ return res.json()
+}
+
+export interface IdTokenClaims {
+ sub: string
+ email?: string
+ name?: string
+ iss: string
+ aud: string | string[]
+ exp: number
+}
+
+export async function verifyIdToken(idToken: string): Promise {
+ const { payload } = await jwtVerify(idToken, getJWKS(), {
+ issuer,
+ audience: clientId,
+ })
+ return payload as unknown as IdTokenClaims
+}
+
+export function buildAuthorizeUrl(args: {
+ state: string
+ codeChallenge: string
+ redirectUri: string
+}): string {
+ const params = new URLSearchParams({
+ response_type: 'code',
+ client_id: clientId,
+ scope: 'openid email profile',
+ redirect_uri: args.redirectUri,
+ state: args.state,
+ code_challenge: args.codeChallenge,
+ code_challenge_method: 'S256',
+ })
+ return `${issuer}/oauth/v2/authorize?${params.toString()}`
+}
+
+export function buildEndSessionUrl(redirectTo: string): string {
+ const params = new URLSearchParams({ post_logout_redirect_uri: redirectTo })
+ return `${issuer}/oidc/v1/end_session?${params.toString()}`
+}
+
+export function isAllowedUser(sub: string): boolean {
+ const allowed = (process.env.ALLOWED_USER_IDS || '').split(',').map((s) => s.trim()).filter(Boolean)
+ return allowed.includes(sub)
+}
+```
+
+- [ ] **Step 4: Create `src/app/api/auth/login/route.ts`**
+
+```typescript
+import { NextResponse } from 'next/server'
+import { getSession } from '@/lib/auth/session'
+import { generateVerifier, challengeFromVerifier, generateState } from '@/lib/auth/pkce'
+import { buildAuthorizeUrl } from '@/lib/auth/zitadel'
+
+export async function GET() {
+ const verifier = generateVerifier()
+ const challenge = challengeFromVerifier(verifier)
+ const state = generateState()
+ const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`
+
+ const session = await getSession()
+ session.loginInProgress = { state, codeVerifier: verifier }
+ await session.save()
+
+ const url = buildAuthorizeUrl({ state, codeChallenge: challenge, redirectUri })
+ return NextResponse.redirect(url)
+}
+```
+
+- [ ] **Step 5: Create `src/app/api/auth/callback/route.ts`**
+
+```typescript
+import { NextRequest, NextResponse } from 'next/server'
+import { getSession } from '@/lib/auth/session'
+import { exchangeCode, verifyIdToken, isAllowedUser } from '@/lib/auth/zitadel'
+
+export async function GET(req: NextRequest) {
+ const url = new URL(req.url)
+ const code = url.searchParams.get('code')
+ const state = url.searchParams.get('state')
+ if (!code || !state) return NextResponse.json({ error: 'missing code/state' }, { status: 400 })
+
+ const session = await getSession()
+ const pending = session.loginInProgress
+ if (!pending || pending.state !== state) {
+ return NextResponse.json({ error: 'state mismatch' }, { status: 400 })
+ }
+
+ const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`
+ const tokens = await exchangeCode({ code, codeVerifier: pending.codeVerifier, redirectUri })
+ const claims = await verifyIdToken(tokens.id_token)
+
+ if (!isAllowedUser(claims.sub)) {
+ session.destroy()
+ return NextResponse.json({ error: 'user not allowed' }, { status: 403 })
+ }
+
+ session.userId = claims.sub
+ session.email = claims.email
+ session.name = claims.name
+ delete session.loginInProgress
+ await session.save()
+
+ return NextResponse.redirect(new URL('/', req.url))
+}
+```
+
+- [ ] **Step 6: Create `src/app/api/auth/logout/route.ts`**
+
+```typescript
+import { NextResponse } from 'next/server'
+import { getSession } from '@/lib/auth/session'
+import { buildEndSessionUrl } from '@/lib/auth/zitadel'
+
+export async function GET() {
+ const session = await getSession()
+ session.destroy()
+ return NextResponse.redirect(buildEndSessionUrl(`${process.env.NEXT_PUBLIC_BASE_URL}/`))
+}
+```
+
+- [ ] **Step 7: Create `src/middleware.ts`**
+
+```typescript
+import { NextRequest, NextResponse } from 'next/server'
+import { getIronSession } from 'iron-session'
+import type { SessionData } from '@/lib/auth/session'
+
+const PUBLIC_PREFIXES = ['/api/auth/', '/api/cron/', '/_next/', '/favicon']
+
+export async function middleware(req: NextRequest) {
+ const { pathname } = req.nextUrl
+ if (PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) return NextResponse.next()
+
+ const res = NextResponse.next()
+ const session = await getIronSession(req.cookies, res.cookies, {
+ password: process.env.SESSION_PASSWORD || '',
+ cookieName: 'preis_tracker_session',
+ })
+
+ if (!session.userId) {
+ if (pathname.startsWith('/api/')) {
+ return NextResponse.json({ error: 'unauthenticated' }, { status: 401 })
+ }
+ return NextResponse.redirect(new URL('/api/auth/login', req.url))
+ }
+ return res
+}
+
+export const config = {
+ matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
+}
+```
+
+- [ ] **Step 8: Zitadel app setup (manual, one-time)**
+
+In Zitadel UI (`https://auth.kuns.dev`):
+1. Project → Applications → New → Web → Name `preis-tracker`
+2. Auth Method: `PKCE`
+3. Redirect URIs: `https://preis.kuns.dev/api/auth/callback`, `http://localhost:3000/api/auth/callback`
+4. Post Logout URIs: `https://preis.kuns.dev/`, `http://localhost:3000/`
+5. Copy `Client ID` → put into `.env` as `ZITADEL_CLIENT_ID`
+6. Get your user-sub: User Profile → ID → put into `.env` as `ALLOWED_USER_IDS`
+
+Generate session password:
+
+```bash
+openssl rand -base64 48 | head -c 48
+```
+
+Put into `.env` as `SESSION_PASSWORD`.
+
+- [ ] **Step 9: Smoke test login flow locally**
+
+```bash
+bun run dev &
+sleep 5
+# Open browser: http://localhost:3000
+# Expect redirect to Zitadel → login → back to /
+# Verify cookie `preis_tracker_session` is set
+kill %1
+```
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add -A
+git commit -m "feat: zitadel oidc auth with pkce, iron-session, middleware"
+```
+
+---
+
+## Task 12: Products API
+
+**Files:**
+- Create: `src/app/api/products/route.ts`
+- Create: `src/app/api/products/[id]/route.ts`
+- Create: `src/app/api/products/[id]/scrape/route.ts`
+
+- [ ] **Step 1: Create `src/app/api/products/route.ts`**
+
+```typescript
+import { NextRequest, NextResponse } from 'next/server'
+import { z } from 'zod'
+import { eq, desc, sql } from 'drizzle-orm'
+import { db, products, priceSnapshots } from '@/lib/db'
+import { detectShop } from '@/lib/shops'
+import { scrapeUrl } from '@/lib/scrapers'
+import { ensureScrapersRegistered } from '@/lib/scrapers/register'
+
+const AddSchema = z.object({ url: z.string().url() })
+
+export async function GET() {
+ // List with latest snapshot
+ const rows = await db.execute<{
+ id: string; url: string; shop: string; name: string; image_url: string | null;
+ last_price: string | null; last_scraped_at: Date | null; min_price: string | null;
+ }>(sql`
+ select p.id, p.url, p.shop, p.name, p.image_url, p.last_scraped_at,
+ (select price from price_snapshots s where s.product_id = p.id and s.price is not null order by scraped_at desc limit 1) as last_price,
+ (select min(price) from price_snapshots s where s.product_id = p.id and s.price is not null) as min_price
+ from products p
+ where p.enabled = true
+ order by p.created_at desc
+ `)
+ return NextResponse.json(rows)
+}
+
+export async function POST(req: NextRequest) {
+ ensureScrapersRegistered()
+ const body = await req.json()
+ const parsed = AddSchema.safeParse(body)
+ if (!parsed.success) return NextResponse.json({ error: 'invalid url' }, { status: 400 })
+
+ const shop = detectShop(parsed.data.url)
+ if (!shop) return NextResponse.json({ error: 'unsupported shop' }, { status: 400 })
+
+ // Initial scrape (use minimal name/image to insert, then update)
+ const result = await scrapeUrl(parsed.data.url)
+ const name = result.name || parsed.data.url
+
+ const [inserted] = await db.insert(products).values({
+ url: parsed.data.url,
+ shop,
+ name,
+ imageUrl: result.imageUrl ?? null,
+ }).returning()
+
+ await db.insert(priceSnapshots).values({
+ productId: inserted.id,
+ price: result.price !== null ? String(result.price) : null,
+ currency: result.currency,
+ availability: result.availability,
+ error: result.error ?? null,
+ })
+
+ await db.update(products).set({
+ lastScrapedAt: new Date(),
+ consecutiveFailures: result.price === null ? 1 : 0,
+ }).where(eq(products.id, inserted.id))
+
+ return NextResponse.json({ id: inserted.id }, { status: 201 })
+}
+```
+
+- [ ] **Step 2: Create `src/app/api/products/[id]/route.ts`**
+
+```typescript
+import { NextRequest, NextResponse } from 'next/server'
+import { eq, desc } from 'drizzle-orm'
+import { db, products, priceSnapshots, alerts } from '@/lib/db'
+
+export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params
+ const [product] = await db.select().from(products).where(eq(products.id, id))
+ if (!product) return NextResponse.json({ error: 'not found' }, { status: 404 })
+
+ const snapshots = await db.select().from(priceSnapshots)
+ .where(eq(priceSnapshots.productId, id))
+ .orderBy(desc(priceSnapshots.scrapedAt))
+ .limit(1000)
+ const productAlerts = await db.select().from(alerts).where(eq(alerts.productId, id))
+
+ return NextResponse.json({ product, snapshots, alerts: productAlerts })
+}
+
+export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params
+ await db.delete(products).where(eq(products.id, id))
+ return NextResponse.json({ ok: true })
+}
+```
+
+- [ ] **Step 3: Create `src/app/api/products/[id]/scrape/route.ts`**
+
+```typescript
+import { NextRequest, NextResponse } from 'next/server'
+import { eq } from 'drizzle-orm'
+import { db, products, priceSnapshots } from '@/lib/db'
+import { scrapeUrl } from '@/lib/scrapers'
+import { ensureScrapersRegistered } from '@/lib/scrapers/register'
+
+export async function POST(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ ensureScrapersRegistered()
+ const { id } = await params
+ const [product] = await db.select().from(products).where(eq(products.id, id))
+ if (!product) return NextResponse.json({ error: 'not found' }, { status: 404 })
+
+ const result = await scrapeUrl(product.url)
+ await db.insert(priceSnapshots).values({
+ productId: id,
+ price: result.price !== null ? String(result.price) : null,
+ currency: result.currency,
+ availability: result.availability,
+ error: result.error ?? null,
+ })
+ await db.update(products).set({
+ lastScrapedAt: new Date(),
+ consecutiveFailures: result.price === null ? product.consecutiveFailures + 1 : 0,
+ }).where(eq(products.id, id))
+
+ return NextResponse.json(result)
+}
+```
+
+- [ ] **Step 4: Manual smoke test**
+
+```bash
+bun run dev &
+sleep 5
+# In another shell — get a session cookie first by visiting localhost:3000 in browser and copying it
+COOKIE='preis_tracker_session=...'
+curl -s -X POST -H "Cookie: $COOKIE" -H 'Content-Type: application/json' \
+ -d '{"url":"https://geizhals.de/sony-playstation-5-pro-a3163879.html"}' \
+ http://localhost:3000/api/products
+curl -s -H "Cookie: $COOKIE" http://localhost:3000/api/products | head -c 500
+kill %1
+```
+
+Expected: `{"id":"..."}` then list contains the entry.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -m "feat: products api (list/add/detail/delete/manual-scrape)"
+```
+
+---
+
+## Task 13: Alerts API
+
+**Files:**
+- Create: `src/app/api/alerts/route.ts`
+- Create: `src/app/api/alerts/[id]/route.ts`
+
+- [ ] **Step 1: Create `src/app/api/alerts/route.ts`**
+
+```typescript
+import { NextRequest, NextResponse } from 'next/server'
+import { z } from 'zod'
+import { db, alerts } from '@/lib/db'
+
+const CreateSchema = z.object({
+ productId: z.string().uuid(),
+ type: z.enum(['target_price', 'all_time_low', 'percent_drop']),
+ config: z.record(z.string(), z.unknown()),
+})
+
+export async function POST(req: NextRequest) {
+ const body = await req.json()
+ const parsed = CreateSchema.safeParse(body)
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.message }, { status: 400 })
+
+ // Per-type config validation
+ const cfg = parsed.data.config
+ switch (parsed.data.type) {
+ case 'target_price':
+ if (typeof cfg.threshold !== 'number' || cfg.threshold <= 0) {
+ return NextResponse.json({ error: 'threshold (number > 0) required' }, { status: 400 })
+ }
+ break
+ case 'percent_drop':
+ if (typeof cfg.lookback_days !== 'number' || typeof cfg.percent !== 'number') {
+ return NextResponse.json({ error: 'lookback_days + percent required' }, { status: 400 })
+ }
+ break
+ case 'all_time_low':
+ break
+ }
+
+ const [inserted] = await db.insert(alerts).values({
+ productId: parsed.data.productId,
+ type: parsed.data.type,
+ config: parsed.data.config,
+ }).returning()
+
+ return NextResponse.json(inserted, { status: 201 })
+}
+```
+
+- [ ] **Step 2: Create `src/app/api/alerts/[id]/route.ts`**
+
+```typescript
+import { NextRequest, NextResponse } from 'next/server'
+import { eq } from 'drizzle-orm'
+import { db, alerts } from '@/lib/db'
+
+export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params
+ await db.delete(alerts).where(eq(alerts.id, id))
+ return NextResponse.json({ ok: true })
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add -A
+git commit -m "feat: alerts api (create/delete)"
+```
+
+---
+
+## Task 14: Cron Scrape Endpoint
+
+**Files:**
+- Create: `src/app/api/cron/scrape/route.ts`
+
+- [ ] **Step 1: Create `src/app/api/cron/scrape/route.ts`**
+
+```typescript
+import { NextRequest, NextResponse } from 'next/server'
+import { eq, and } from 'drizzle-orm'
+import { db, products, priceSnapshots, alerts } from '@/lib/db'
+import { scrapeUrl } from '@/lib/scrapers'
+import { ensureScrapersRegistered } from '@/lib/scrapers/register'
+import { evaluateAlert, type SnapshotInput } from '@/lib/alerts/evaluate'
+import { sendPush } from '@/lib/pushover'
+
+export const maxDuration = 300 // 5 min
+
+const CONCURRENCY = 2
+const FAIL_WARN_THRESHOLD = 3
+
+export async function POST(req: NextRequest) {
+ const auth = req.headers.get('authorization')
+ if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
+ return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
+ }
+ ensureScrapersRegistered()
+
+ const productsToScrape = await db.select().from(products).where(eq(products.enabled, true))
+ console.log(`[cron] scraping ${productsToScrape.length} products`)
+
+ // Run with concurrency limit
+ const queue = [...productsToScrape]
+ const summary = { ok: 0, failed: 0, alertsTriggered: 0 }
+
+ async function worker() {
+ while (queue.length > 0) {
+ const p = queue.shift()
+ if (!p) break
+ try {
+ await processProduct(p, summary)
+ } catch (err) {
+ console.error(`[cron] product ${p.id} crashed`, err)
+ }
+ }
+ }
+ await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()))
+
+ return NextResponse.json(summary)
+}
+
+async function processProduct(p: typeof products.$inferSelect, summary: { ok: number; failed: number; alertsTriggered: number }) {
+ const result = await scrapeUrl(p.url)
+ await db.insert(priceSnapshots).values({
+ productId: p.id,
+ price: result.price !== null ? String(result.price) : null,
+ currency: result.currency,
+ availability: result.availability,
+ error: result.error ?? null,
+ })
+
+ if (result.price === null) {
+ const failures = p.consecutiveFailures + 1
+ await db.update(products).set({
+ lastScrapedAt: new Date(),
+ consecutiveFailures: failures,
+ }).where(eq(products.id, p.id))
+ summary.failed++
+ if (failures === FAIL_WARN_THRESHOLD) {
+ await sendPush({
+ title: `⚠ Scrape-Fehler`,
+ message: `${p.name} ist ${failures}× hintereinander fehlgeschlagen: ${result.error ?? 'unknown'}`,
+ url: p.url,
+ }).catch((e) => console.error('pushover failed', e))
+ }
+ return
+ }
+
+ summary.ok++
+ await db.update(products).set({ lastScrapedAt: new Date(), consecutiveFailures: 0 }).where(eq(products.id, p.id))
+
+ // Load history (price snapshots only) and product alerts
+ const allSnapshots = await db.select().from(priceSnapshots)
+ .where(and(eq(priceSnapshots.productId, p.id)))
+ const history: SnapshotInput[] = allSnapshots
+ .filter((s) => s.price !== null)
+ .filter((s) => s.id !== Math.max(...allSnapshots.map((x) => x.id))) // exclude the just-inserted one
+ .map((s) => ({ price: Number(s.price), scrapedAt: s.scrapedAt }))
+
+ const productAlerts = await db.select().from(alerts)
+ .where(and(eq(alerts.productId, p.id), eq(alerts.enabled, true)))
+
+ for (const alert of productAlerts) {
+ const r = evaluateAlert({
+ alert: {
+ type: alert.type as 'target_price' | 'all_time_low' | 'percent_drop',
+ config: alert.config as Record,
+ lastTriggeredAt: alert.lastTriggeredAt,
+ },
+ currentPrice: result.price,
+ history,
+ })
+ if (r.triggered) {
+ summary.alertsTriggered++
+ await sendPush({
+ title: alertTitle(alert.type as string, r.context, p.name),
+ message: alertMessage(alert.type as string, r.context, result.price),
+ url: p.url,
+ urlTitle: 'Zum Shop',
+ }).catch((e) => console.error('pushover failed', e))
+ await db.update(alerts).set({ lastTriggeredAt: new Date() }).where(eq(alerts.id, alert.id))
+ }
+ }
+}
+
+function alertTitle(type: string, ctx: Record, name: string): string {
+ switch (type) {
+ case 'target_price': return `📉 ${name} unter ${ctx.threshold}€`
+ case 'all_time_low': return `🎯 Allzeit-Tief: ${name}`
+ case 'percent_drop': return `⬇️ ${name} −${ctx.percent}%`
+ default: return name
+ }
+}
+
+function alertMessage(type: string, ctx: Record, price: number): string {
+ switch (type) {
+ case 'target_price': return `Jetzt ${price}€ (Ziel: ${ctx.threshold}€)`
+ case 'all_time_low': return `Jetzt ${price}€ (vorher min: ${ctx.prevMin}€)`
+ case 'percent_drop': return `Jetzt ${price}€ (vorher Ø ${ctx.avg}€ in ${ctx.percent ?? '?'}% Drop)`
+ default: return `Jetzt ${price}€`
+ }
+}
+```
+
+- [ ] **Step 2: Manual smoke test**
+
+```bash
+bun run dev &
+sleep 5
+curl -s -X POST -H "Authorization: Bearer $(grep CRON_SECRET .env | cut -d= -f2)" \
+ http://localhost:3000/api/cron/scrape
+kill %1
+```
+
+Expected: JSON `{ok, failed, alertsTriggered}`. New snapshots in DB.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add -A
+git commit -m "feat: daily cron scrape endpoint with concurrency + alert dispatch"
+```
+
+---
+
+## Task 15: UI — Dashboard
+
+**Files:**
+- Create: `src/components/Sparkline.tsx`
+- Create: `src/components/ProductCard.tsx`
+- Modify: `src/app/page.tsx`
+
+- [ ] **Step 1: Create `src/components/Sparkline.tsx`**
+
+```tsx
+'use client'
+import { LineChart, Line, ResponsiveContainer, YAxis } from 'recharts'
+
+export function Sparkline({ data }: { data: Array<{ price: number; t: string }> }) {
+ if (data.length === 0) return keine Daten
+ return (
+
+
+
+
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Create `src/components/ProductCard.tsx`**
+
+```tsx
+import Link from 'next/link'
+import { Sparkline } from './Sparkline'
+
+interface Props {
+ id: string
+ name: string
+ shop: string
+ imageUrl: string | null
+ lastPrice: string | null
+ minPrice: string | null
+ sparkline: Array<{ price: number; t: string }>
+}
+
+export function ProductCard(p: Props) {
+ const last = p.lastPrice ? Number(p.lastPrice) : null
+ const min = p.minPrice ? Number(p.minPrice) : null
+ const deltaFromMin = last !== null && min !== null ? (last - min).toFixed(2) : null
+
+ return (
+
+
+ {p.imageUrl &&

}
+
+
{p.shop}
+
{p.name}
+
+ {last !== null ? `${last.toFixed(2)} €` : '—'}
+ {deltaFromMin !== null && (
+ +{deltaFromMin} € vom Tief
+ )}
+
+
+
+
+
+ )
+}
+```
+
+- [ ] **Step 3: Replace `src/app/page.tsx`**
+
+```tsx
+import Link from 'next/link'
+import { desc, eq, sql } from 'drizzle-orm'
+import { db, products, priceSnapshots } from '@/lib/db'
+import { ProductCard } from '@/components/ProductCard'
+
+export const dynamic = 'force-dynamic'
+
+export default async function Home() {
+ const rows = await db.execute<{
+ id: string; url: string; shop: string; name: string; image_url: string | null;
+ last_price: string | null; min_price: string | null;
+ }>(sql`
+ select p.id, p.url, p.shop, p.name, p.image_url,
+ (select price from price_snapshots s where s.product_id = p.id and s.price is not null order by scraped_at desc limit 1) as last_price,
+ (select min(price) from price_snapshots s where s.product_id = p.id and s.price is not null) as min_price
+ from products p where p.enabled = true order by p.created_at desc
+ `)
+
+ const sparklines = new Map>()
+ for (const r of rows) {
+ const snaps = await db.select({ price: priceSnapshots.price, scrapedAt: priceSnapshots.scrapedAt })
+ .from(priceSnapshots)
+ .where(eq(priceSnapshots.productId, r.id))
+ .orderBy(desc(priceSnapshots.scrapedAt))
+ .limit(30)
+ sparklines.set(r.id, snaps
+ .filter((s) => s.price !== null)
+ .reverse()
+ .map((s) => ({ price: Number(s.price), t: s.scrapedAt.toISOString() })))
+ }
+
+ return (
+
+
+ Preis-Tracker
+
+ + Produkt
+ Logout
+
+
+ {rows.length === 0 ? (
+
+ Noch keine Produkte. Erstes hinzufügen.
+
+ ) : (
+
+ {rows.map((r) => (
+
+ ))}
+
+ )}
+
+ )
+}
+```
+
+- [ ] **Step 4: Smoke test in browser**
+
+```bash
+bun run dev
+# Open http://localhost:3000, login, verify dashboard renders (empty state ok)
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -m "feat: dashboard UI with product cards + sparklines"
+```
+
+---
+
+## Task 16: UI — Add Product
+
+**Files:**
+- Create: `src/app/add/page.tsx`
+
+- [ ] **Step 1: Create `src/app/add/page.tsx`**
+
+```tsx
+'use client'
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+
+export default function AddPage() {
+ const [url, setUrl] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+ const router = useRouter()
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault()
+ setSubmitting(true)
+ setError(null)
+ try {
+ const res = await fetch('/api/products', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url }),
+ })
+ if (!res.ok) {
+ const j = await res.json().catch(() => ({ error: 'unknown' }))
+ setError(j.error || `HTTP ${res.status}`)
+ return
+ }
+ const { id } = await res.json()
+ router.push(`/products/${id}`)
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+ Produkt hinzufügen
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Smoke test — add a Geizhals URL**
+
+```bash
+bun run dev
+# Browser: /add → paste Geizhals URL → submit → redirect to /products/
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add -A
+git commit -m "feat: add-product page"
+```
+
+---
+
+## Task 17: UI — Product Detail (Chart + Alerts)
+
+**Files:**
+- Create: `src/components/PriceChart.tsx`
+- Create: `src/components/AlertList.tsx`
+- Create: `src/components/AlertForm.tsx`
+- Create: `src/app/products/[id]/page.tsx`
+
+- [ ] **Step 1: Create `src/components/PriceChart.tsx`**
+
+```tsx
+'use client'
+import { useState } from 'react'
+import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts'
+
+interface Snap { price: number; t: string }
+
+export function PriceChart({ data }: { data: Snap[] }) {
+ const [range, setRange] = useState<'30d' | '90d' | 'all'>('30d')
+ const cutoff = range === 'all' ? 0 : Date.now() - (range === '30d' ? 30 : 90) * 86400_000
+ const filtered = data.filter((d) => new Date(d.t).getTime() >= cutoff)
+
+ return (
+
+
+ {(['30d', '90d', 'all'] as const).map((r) => (
+
+ ))}
+
+
+
+
+ new Date(v).toLocaleDateString('de-DE')} />
+ `${v}€`} domain={['dataMin', 'dataMax']} />
+ new Date(v).toLocaleString('de-DE')}
+ formatter={(v: number) => [`${v}€`, 'Preis']} />
+
+
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Create `src/components/AlertList.tsx`**
+
+```tsx
+'use client'
+import { useRouter } from 'next/navigation'
+
+interface Alert { id: string; type: string; config: Record; enabled: boolean }
+
+export function AlertList({ alerts }: { alerts: Alert[] }) {
+ const router = useRouter()
+ async function del(id: string) {
+ await fetch(`/api/alerts/${id}`, { method: 'DELETE' })
+ router.refresh()
+ }
+ if (alerts.length === 0) return Keine Alerts.
+ return (
+
+ {alerts.map((a) => (
+ -
+ {labelFor(a)}
+
+
+ ))}
+
+ )
+}
+
+function labelFor(a: Alert): string {
+ switch (a.type) {
+ case 'target_price': return `Zielpreis ≤ ${a.config.threshold}€`
+ case 'all_time_low': return `Allzeit-Tief`
+ case 'percent_drop': return `−${a.config.percent}% in ${a.config.lookback_days} Tagen`
+ default: return a.type
+ }
+}
+```
+
+- [ ] **Step 3: Create `src/components/AlertForm.tsx`**
+
+```tsx
+'use client'
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+
+export function AlertForm({ productId }: { productId: string }) {
+ const [type, setType] = useState<'target_price' | 'all_time_low' | 'percent_drop'>('target_price')
+ const [threshold, setThreshold] = useState('')
+ const [percent, setPercent] = useState('10')
+ const [lookback, setLookback] = useState('7')
+ const router = useRouter()
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault()
+ let config: Record = {}
+ if (type === 'target_price') config = { threshold: Number(threshold) }
+ if (type === 'percent_drop') config = { percent: Number(percent), lookback_days: Number(lookback) }
+ const res = await fetch('/api/alerts', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ productId, type, config }),
+ })
+ if (res.ok) {
+ setThreshold('')
+ router.refresh()
+ }
+ }
+
+ return (
+
+ )
+}
+```
+
+- [ ] **Step 4: Create `src/app/products/[id]/page.tsx`**
+
+```tsx
+import { notFound } from 'next/navigation'
+import { eq, desc } from 'drizzle-orm'
+import { db, products, priceSnapshots, alerts } from '@/lib/db'
+import { PriceChart } from '@/components/PriceChart'
+import { AlertList } from '@/components/AlertList'
+import { AlertForm } from '@/components/AlertForm'
+
+export const dynamic = 'force-dynamic'
+
+export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params
+ const [product] = await db.select().from(products).where(eq(products.id, id))
+ if (!product) notFound()
+
+ const snaps = await db.select().from(priceSnapshots)
+ .where(eq(priceSnapshots.productId, id))
+ .orderBy(desc(priceSnapshots.scrapedAt))
+ .limit(500)
+ const chartData = snaps
+ .filter((s) => s.price !== null)
+ .reverse()
+ .map((s) => ({ price: Number(s.price), t: s.scrapedAt.toISOString() }))
+
+ const productAlerts = await db.select().from(alerts).where(eq(alerts.productId, id))
+
+ return (
+
+ ← Zurück
+
+ {product.imageUrl &&

}
+
+
+
+
+
+
+ Alerts
+ ({ id: a.id, type: a.type, config: a.config as Record, enabled: a.enabled }))} />
+
+
+
+
+
+ )
+}
+```
+
+- [ ] **Step 5: Smoke test in browser**
+
+```bash
+bun run dev
+# /products/ → verify chart, alerts list, form, refresh button
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A
+git commit -m "feat: product detail page with chart, alert list, alert form"
+```
+
+---
+
+## Task 18: Dockerfile + .dockerignore
+
+**Files:**
+- Create: `Dockerfile`
+- Create: `.dockerignore`
+
+- [ ] **Step 1: Create `Dockerfile`**
+
+```dockerfile
+# syntax=docker/dockerfile:1.7
+
+FROM mcr.microsoft.com/playwright:v1.50.0-jammy AS base
+WORKDIR /app
+ENV NODE_ENV=production
+
+FROM base AS deps
+ENV NODE_ENV=development
+COPY package.json package-lock.json* bun.lockb* ./
+RUN npm install --frozen-lockfile || npm install
+
+FROM base AS build
+ENV NODE_ENV=development
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+RUN npm run build
+
+FROM base AS runner
+ENV PORT=3000
+COPY --from=build /app/.next/standalone ./
+COPY --from=build /app/.next/static ./.next/static
+COPY --from=build /app/public ./public 2>/dev/null || true
+COPY --from=build /app/drizzle ./drizzle
+COPY --from=build /app/drizzle.config.ts ./
+COPY --from=build /app/src/lib/db/schema.ts ./schema.ts
+EXPOSE 3000
+CMD ["node", "server.js"]
+```
+
+- [ ] **Step 2: Create `.dockerignore`**
+
+```
+node_modules
+.next
+.git
+.env
+.env.local
+*.log
+tests
+docs
+README.md
+```
+
+- [ ] **Step 3: Local build test**
+
+```bash
+docker build -t preis-tracker:local .
+docker run --rm -p 3001:3000 \
+ --env-file .env \
+ -e NEXT_PUBLIC_BASE_URL=http://localhost:3001 \
+ preis-tracker:local &
+sleep 8
+curl -sf -o /dev/null -w '%{http_code}\n' http://localhost:3001
+docker stop $(docker ps -q --filter ancestor=preis-tracker:local)
+```
+
+Expected: `307` (redirect to /api/auth/login). Image builds without errors.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A
+git commit -m "build: dockerfile based on playwright image, standalone next.js"
+```
+
+---
+
+## Task 19: Coolify Deployment
+
+**Files:**
+- Create: `README.md`
+
+- [ ] **Step 1: Push to Gitea**
+
+```bash
+cd ~/preis-tracker
+git remote add origin ssh://git@git.kuns.dev:22222/kuns/preis-tracker.git
+
+# In another shell — create the repo on Gitea first
+source ~/.secrets/coolify-tokens.env
+curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
+ -H 'Content-Type: application/json' \
+ -d '{"name":"preis-tracker","private":true,"default_branch":"main"}' \
+ https://git.kuns.dev/api/v1/user/repos
+
+git branch -M main
+git push -u origin main
+```
+
+- [ ] **Step 2: Create Coolify app via API**
+
+Follow the playbook in `memory/coolify-deploy.md`. Roughly:
+1. Coolify UI → New Resource → Application → Public Repository → `https://git.kuns.dev/kuns/preis-tracker`
+2. Build Pack: Dockerfile
+3. Domain: `https://preis.kuns.dev`
+4. Ports: `3000:3000`
+5. Env Vars (mark sensitive ones as Build/Runtime Secret):
+ - `DATABASE_URL=postgresql://preistracker:@l8kogcggsc80sgcgk8kswww4:5432/preistracker`
+ - `ZITADEL_ISSUER=https://auth.kuns.dev`
+ - `ZITADEL_CLIENT_ID=`
+ - `ALLOWED_USER_IDS=`
+ - `SESSION_PASSWORD=<32+ chars>`
+ - `PUSHOVER_TOKEN=`
+ - `PUSHOVER_USER=`
+ - `CRON_SECRET=`
+ - `NEXT_PUBLIC_BASE_URL=https://preis.kuns.dev`
+6. Webhook → enable, copy URL into Gitea repo settings → Webhooks
+7. Deploy
+
+- [ ] **Step 3: Add Scheduled Task in Coolify**
+
+In the app settings → Scheduled Tasks → New:
+- Name: `daily-scrape`
+- Command: `curl -fsS -X POST -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/scrape`
+- Schedule: `0 6 * * *`
+
+- [ ] **Step 4: Update Zitadel app config**
+
+In Zitadel UI: add redirect URI `https://preis.kuns.dev/api/auth/callback` and post-logout URI `https://preis.kuns.dev/`.
+
+- [ ] **Step 5: Verify deployment**
+
+```bash
+sleep 60 # wait for deploy
+curl -sI https://preis.kuns.dev | head -5
+# Expect 307 redirect to /api/auth/login
+
+# Trigger manual cron run to test
+curl -s -X POST -H "Authorization: Bearer " \
+ https://preis.kuns.dev/api/cron/scrape
+```
+
+- [ ] **Step 6: Create `README.md`**
+
+```markdown
+# preis-tracker
+
+Tracking von Produktpreisen bei Amazon, Idealo, Geizhals mit Pushover-Alerts.
+
+## Stack
+Next.js 16, TypeScript, Drizzle + PostgreSQL, Playwright + Cheerio, Zitadel OIDC, iron-session, Recharts.
+
+## Dev
+
+```bash
+cp .env.example .env # fill in
+bun install
+bunx playwright install chromium
+bun run db:push
+bun run dev
+```
+
+## Deploy
+Coolify-App `preis.kuns.dev`. Daily scrape via Scheduled Task `0 6 * * *`.
+
+Spec: `docs/superpowers/specs/2026-05-25-preis-tracker-design.md`
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add README.md
+git commit -m "docs: readme"
+git push
+```
+
+- [ ] **Step 8: Update memory**
+
+After successful deploy, write to `~/.claude/projects/-home-mika/memory/`:
+- Append to `MEMORY.md`: line for `preis-tracker` project (Coolify UUID, repo, domain, DB)
+- If learnings emerged (selector drift, Playwright in container quirks), add to `learnings.md`
+
+---
+
+## Self-Review Notes
+
+**Spec coverage:** All 16 sections of the spec map to tasks 1-19. Auth (§8) → Task 11. Scrapers (§6) → Tasks 4-8. Alerts (§4, §9) → Tasks 10, 13. Cron flow (§7) → Task 14. UI (§10) → Tasks 15-17. Deployment (§12) → Task 19.
+
+**Known fragility:** The HTML-fixture-based tests are only as good as the captured fixtures. If a fixture is from a captcha/cloudflare page, the scraper tests "pass" by extracting nothing. Step 1 of each scraper task instructs to verify fixture size — that's the guardrail.
+
+**Concurrency in cron:** The simple `worker()` pattern in Task 14 is fine for single-digit product counts. If list grows >50, consider proper batching with backpressure.
+
+**Type consistency:** `AlertType` defined in `evaluate.ts` (Task 10) is the authoritative source; API routes (Task 13) and Cron (Task 14) reference it indirectly via the literal union. Schema (Task 2) uses the same string values in the CHECK constraint.