← back to Omega Watches 2
initial scaffold (gitify-all 2026-05-06)
3bbe091f6413404f8bf60f8237c0a59ca199bbb9 · 2026-05-06 10:25:44 -0700 · Steve Abrams
Files touched
A .gitignoreA collectors/antiquorum/collector.mjsA collectors/base-collector.mjsA collectors/chrono24/collector.mjsA collectors/omega-official/collector.mjsA collectors/runner.mjsA database/schema.sqlA database/seed-additional-references.sqlA database/seed-references.sqlA ecosystem.config.cjsA next-env.d.tsA next.config.mjsA package-lock.jsonA package.jsonA postcss.config.mjsA public/favicon.svgA scripts/daily-antiquorum.shA scripts/daily-chrono24.shA scripts/daily-msrp.shA skills/onboard-source/SKILL.mdA skills/onboard-source/manifest.jsonA skills/parser-break-triage/SKILL.mdA skills/parser-break-triage/manifest.jsonA src/app/(dashboard)/alerts/page.tsxA src/app/(dashboard)/api-docs/page.tsxA src/app/(dashboard)/audit/page.tsxA src/app/(dashboard)/condition-mappings/page.tsxA src/app/(dashboard)/entity-resolution/page.tsxA src/app/(dashboard)/jobs/page.tsxA src/app/(dashboard)/layout.tsxA src/app/(dashboard)/models/page.tsxA src/app/(dashboard)/msrp/page.tsxA src/app/(dashboard)/page.tsxA src/app/(dashboard)/quarantine/page.tsxA src/app/(dashboard)/settings/page.tsxA src/app/(dashboard)/snapshots/page.tsxA src/app/(dashboard)/sources/page.tsxA src/app/(dashboard)/terminal/page.tsxA src/app/api/admin/refresh-views/route.tsA src/app/api/alerts/route.tsA src/app/api/auditlog/route.tsA src/app/api/auth/login/route.tsA src/app/api/condition-mappings/route.tsA src/app/api/dashboard/chart-data/route.tsA src/app/api/dashboard/route.tsA src/app/api/entity-resolution/route.tsA src/app/api/export/route.tsA src/app/api/fx-rates/route.tsA src/app/api/health/route.tsA src/app/api/jobs/backfill/route.tsA src/app/api/jobs/daily-stats/route.tsA src/app/api/jobs/route.tsA src/app/api/jobs/run/route.tsA src/app/api/models/[reference]/distributions/route.tsA src/app/api/models/[reference]/timeseries/route.tsA src/app/api/msrp/[reference]/route.tsA src/app/api/quarantine/[id]/resolve/route.tsA src/app/api/quarantine/route.tsA src/app/api/references/route.tsA src/app/api/snapshots/route.tsA src/app/api/sources/[source]/status/route.tsA src/app/api/sources/route.tsA src/app/api/william/route.tsA src/app/globals.cssA src/app/layout.tsxA src/app/login/page.tsxA src/app/page.tsxA src/components/charts/CollectionPriceChart.tsxA src/components/charts/ConditionBreakdown.tsxA src/components/charts/JobStatusChart.tsxA src/components/charts/MSRPChangeChart.tsxA src/components/charts/MarketActivityChart.tsxA src/components/charts/PriceDistribution.tsxA src/components/charts/PriceTimeSeries.tsxA src/components/charts/ScatterPlot.tsxA src/components/charts/SourceHealthDonut.tsxA src/components/charts/SourceHealthMap.tsxA src/components/charts/SourceReliabilityChart.tsxA src/components/charts/StockChart.tsxA src/components/charts/TopMoversChart.tsxA src/components/charts/index.tsA src/components/layout/Header.tsxA src/components/layout/Sidebar.tsxA src/components/panels/WatchSelector.tsxA src/components/panels/WilliamPanel.tsxA src/components/ui/DataTable.tsxA src/components/ui/StatCard.tsxA src/components/ui/StatusBadge.tsxA src/components/ui/index.tsA src/lib/api-client.tsA src/lib/auth.tsA src/lib/db.tsA src/lib/utils.tsA src/middleware.tsA src/types/index.tsA tailwind.config.tsA tsconfig.json
Diff
commit 3bbe091f6413404f8bf60f8237c0a59ca199bbb9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 6 10:25:44 2026 -0700
initial scaffold (gitify-all 2026-05-06)
---
.gitignore | 11 +
collectors/antiquorum/collector.mjs | 202 +
collectors/base-collector.mjs | 237 +
collectors/chrono24/collector.mjs | 246 +
collectors/omega-official/collector.mjs | 213 +
collectors/runner.mjs | 74 +
database/schema.sql | 595 ++
database/seed-additional-references.sql | 186 +
database/seed-references.sql | 277 +
ecosystem.config.cjs | 17 +
next-env.d.ts | 5 +
next.config.mjs | 9 +
package-lock.json | 6686 ++++++++++++++++++++
package.json | 44 +
postcss.config.mjs | 9 +
public/favicon.svg | 4 +
scripts/daily-antiquorum.sh | 29 +
scripts/daily-chrono24.sh | 35 +
scripts/daily-msrp.sh | 39 +
skills/onboard-source/SKILL.md | 64 +
skills/onboard-source/manifest.json | 12 +
skills/parser-break-triage/SKILL.md | 67 +
skills/parser-break-triage/manifest.json | 12 +
src/app/(dashboard)/alerts/page.tsx | 278 +
src/app/(dashboard)/api-docs/page.tsx | 224 +
src/app/(dashboard)/audit/page.tsx | 91 +
src/app/(dashboard)/condition-mappings/page.tsx | 247 +
src/app/(dashboard)/entity-resolution/page.tsx | 285 +
src/app/(dashboard)/jobs/page.tsx | 351 +
src/app/(dashboard)/layout.tsx | 20 +
src/app/(dashboard)/models/page.tsx | 346 +
src/app/(dashboard)/msrp/page.tsx | 223 +
src/app/(dashboard)/page.tsx | 428 ++
src/app/(dashboard)/quarantine/page.tsx | 111 +
src/app/(dashboard)/settings/page.tsx | 163 +
src/app/(dashboard)/snapshots/page.tsx | 211 +
src/app/(dashboard)/sources/page.tsx | 110 +
src/app/(dashboard)/terminal/page.tsx | 213 +
src/app/api/admin/refresh-views/route.ts | 49 +
src/app/api/alerts/route.ts | 122 +
src/app/api/auditlog/route.ts | 42 +
src/app/api/auth/login/route.ts | 46 +
src/app/api/condition-mappings/route.ts | 79 +
src/app/api/dashboard/chart-data/route.ts | 53 +
src/app/api/dashboard/route.ts | 91 +
src/app/api/entity-resolution/route.ts | 119 +
src/app/api/export/route.ts | 139 +
src/app/api/fx-rates/route.ts | 88 +
src/app/api/health/route.ts | 20 +
src/app/api/jobs/backfill/route.ts | 50 +
src/app/api/jobs/daily-stats/route.ts | 35 +
src/app/api/jobs/route.ts | 46 +
src/app/api/jobs/run/route.ts | 53 +
.../api/models/[reference]/distributions/route.ts | 91 +
src/app/api/models/[reference]/timeseries/route.ts | 89 +
src/app/api/msrp/[reference]/route.ts | 70 +
src/app/api/quarantine/[id]/resolve/route.ts | 51 +
src/app/api/quarantine/route.ts | 64 +
src/app/api/references/route.ts | 71 +
src/app/api/snapshots/route.ts | 65 +
src/app/api/sources/[source]/status/route.ts | 55 +
src/app/api/sources/route.ts | 33 +
src/app/api/william/route.ts | 63 +
src/app/globals.css | 80 +
src/app/layout.tsx | 33 +
src/app/login/page.tsx | 86 +
src/app/page.tsx | 7 +
src/components/charts/CollectionPriceChart.tsx | 61 +
src/components/charts/ConditionBreakdown.tsx | 84 +
src/components/charts/JobStatusChart.tsx | 77 +
src/components/charts/MSRPChangeChart.tsx | 87 +
src/components/charts/MarketActivityChart.tsx | 88 +
src/components/charts/PriceDistribution.tsx | 69 +
src/components/charts/PriceTimeSeries.tsx | 97 +
src/components/charts/ScatterPlot.tsx | 72 +
src/components/charts/SourceHealthDonut.tsx | 90 +
src/components/charts/SourceHealthMap.tsx | 79 +
src/components/charts/SourceReliabilityChart.tsx | 68 +
src/components/charts/StockChart.tsx | 312 +
src/components/charts/TopMoversChart.tsx | 63 +
src/components/charts/index.ts | 12 +
src/components/layout/Header.tsx | 38 +
src/components/layout/Sidebar.tsx | 72 +
src/components/panels/WatchSelector.tsx | 221 +
src/components/panels/WilliamPanel.tsx | 153 +
src/components/ui/DataTable.tsx | 74 +
src/components/ui/StatCard.tsx | 42 +
src/components/ui/StatusBadge.tsx | 41 +
src/components/ui/index.ts | 3 +
src/lib/api-client.ts | 119 +
src/lib/auth.ts | 84 +
src/lib/db.ts | 83 +
src/lib/utils.ts | 63 +
src/middleware.ts | 50 +
src/types/index.ts | 297 +
tailwind.config.ts | 48 +
tsconfig.json | 22 +
97 files changed, 16933 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..80cc023
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+.next/
+.env.local
+*.log
+.env.*.local
+.env.*
+tmp/
+.DS_Store
+dist/
+build/
+*.bak
diff --git a/collectors/antiquorum/collector.mjs b/collectors/antiquorum/collector.mjs
new file mode 100644
index 0000000..7b62cc2
--- /dev/null
+++ b/collectors/antiquorum/collector.mjs
@@ -0,0 +1,202 @@
+/**
+ * Antiquorum Auction Collector
+ *
+ * Fetches realized auction prices from antiquorum.swiss for Omega watches.
+ * Writes to the market_event ledger with event_type = 'auction_realized'.
+ *
+ * Antiquorum publishes lot results after auctions complete. Their results
+ * pages include hammer prices, buyer's premiums, and condition grades.
+ *
+ * Rate limit: 10 RPM (per source config)
+ */
+import { BaseCollector, pool } from '../base-collector.mjs';
+import https from 'https';
+import crypto from 'crypto';
+
+const ANTIQUORUM_BASE = 'https://www.antiquorum.swiss';
+const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
+
+export default class AntiquorumCollector extends BaseCollector {
+ constructor() {
+ super('antiquorum');
+ }
+
+ getJobType() { return 'incremental'; }
+
+ fetch(url) {
+ return new Promise((resolve, reject) => {
+ const parsedUrl = new URL(url);
+ const req = https.get({
+ hostname: parsedUrl.hostname,
+ path: parsedUrl.pathname + parsedUrl.search,
+ headers: {
+ 'User-Agent': USER_AGENT,
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.9',
+ },
+ timeout: 30000,
+ }, (res) => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ const redir = res.headers.location.startsWith('http')
+ ? res.headers.location
+ : `${ANTIQUORUM_BASE}${res.headers.location}`;
+ return this.fetch(redir).then(resolve).catch(reject);
+ }
+ let body = '';
+ res.on('data', chunk => body += chunk);
+ res.on('end', () => resolve({ statusCode: res.statusCode, body }));
+ });
+ req.on('error', reject);
+ req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
+ });
+ }
+
+ /**
+ * Parse Antiquorum lot results page for auction results
+ */
+ parseLotResults(html) {
+ const results = [];
+
+ // Antiquorum lot pages use structured lot entries
+ // Pattern: each lot has a lot number, description, estimate, and result
+ const lotPattern = /<div[^>]*class="[^"]*lot-item[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/gi;
+ let match;
+
+ while ((match = lotPattern.exec(html)) !== null) {
+ const content = match[1];
+
+ // Extract lot number
+ const lotNumMatch = content.match(/lot\s*#?\s*(\d+)/i);
+ const lotNum = lotNumMatch ? lotNumMatch[1] : null;
+
+ // Look for Omega references
+ const refMatch = content.match(/\b(\d{3}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{3})\b/);
+ if (!refMatch) continue; // Skip non-Omega lots
+
+ const result = {
+ source_listing_id: `antiq-lot-${lotNum || crypto.randomBytes(4).toString('hex')}`,
+ reference_number_raw: refMatch[1],
+ event_type: 'auction_realized',
+ source_name: 'antiquorum',
+ };
+
+ // Extract hammer price
+ const hammerMatch = content.match(/(?:hammer|sold|realized)[^:]*:\s*(?:CHF|USD|EUR|GBP)?\s*([\d,]+)/i);
+ if (hammerMatch) {
+ result.hammer_price = parseFloat(hammerMatch[1].replace(/,/g, ''));
+ }
+
+ // Extract currency
+ const currMatch = content.match(/\b(CHF|USD|EUR|GBP)\b/);
+ result.currency = currMatch ? currMatch[1] : 'CHF'; // Antiquorum defaults to CHF
+
+ // Extract estimate range
+ const estMatch = content.match(/estimate[^:]*:\s*([\d,]+)\s*[-–]\s*([\d,]+)/i);
+ if (estMatch) {
+ result.estimate_low = parseFloat(estMatch[1].replace(/,/g, ''));
+ result.estimate_high = parseFloat(estMatch[2].replace(/,/g, ''));
+ }
+
+ // Calculate buyer's premium (Antiquorum: typically 25% buyer's premium)
+ if (result.hammer_price) {
+ result.buyers_premium = Math.round(result.hammer_price * 0.25);
+ result.total_to_buyer = result.hammer_price + result.buyers_premium;
+ result.price_amount = result.total_to_buyer;
+ result.fee_semantics = 'hammer_plus_premium';
+ }
+
+ // Extract condition
+ const condMatch = content.match(/condition[^:]*:\s*([^<\n]+)/i);
+ if (condMatch) {
+ result.condition_raw = condMatch[1].trim();
+ result.condition_normalized = this.normalizeCondition(condMatch[1]);
+ }
+
+ // Extract date
+ const dateMatch = content.match(/(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+(\d{4})/i);
+ if (dateMatch) {
+ const months = { jan: '01', feb: '02', mar: '03', apr: '04', may: '05', jun: '06', jul: '07', aug: '08', sep: '09', oct: '10', nov: '11', dec: '12' };
+ result.sale_date = `${dateMatch[3]}-${months[dateMatch[2].toLowerCase()]}-${dateMatch[1].padStart(2, '0')}`;
+ }
+
+ // Extract serial/movement numbers
+ const serialMatch = content.match(/(?:serial|no\.|number)\s*[:#]?\s*(\d{4,})/i);
+ if (serialMatch) result.serial_number = serialMatch[1];
+
+ result.seller_type = 'auction_house';
+
+ if (result.price_amount > 0) {
+ results.push(result);
+ }
+ }
+
+ return results;
+ }
+
+ normalizeCondition(raw) {
+ const lower = (raw || '').toLowerCase();
+ if (lower.includes('mint') || lower.includes('excellent') || lower.includes('pristine')) return 'mint';
+ if (lower.includes('very good') || lower.includes('fine')) return 'very_good';
+ if (lower.includes('good')) return 'good';
+ if (lower.includes('fair')) return 'fair';
+ return 'unknown';
+ }
+
+ async collect() {
+ // Load references to know which lots to look for
+ const { rows: refs } = await pool.query(
+ 'SELECT reference_number FROM watch_reference'
+ );
+ const refSet = new Set(refs.map(r => r.reference_number));
+ console.log(`[antiquorum] Tracking ${refSet.size} reference numbers`);
+
+ // Fetch recent auction results
+ // Antiquorum organizes by auction sales: /en/results/
+ const resultsUrl = `${ANTIQUORUM_BASE}/en/results/?brand=omega&sort=date_desc`;
+
+ try {
+ await this.rateLimit();
+ const response = await this.fetch(resultsUrl);
+ this.stats.fetched++;
+
+ if (response.statusCode !== 200) {
+ console.log(`[antiquorum] Results page returned HTTP ${response.statusCode}`);
+ return;
+ }
+
+ const pageHash = crypto.createHash('sha256').update(response.body).digest('hex');
+ await this.archiveSnapshot(resultsUrl, response.body);
+
+ const lots = this.parseLotResults(response.body);
+ this.stats.parsed = lots.length;
+ console.log(`[antiquorum] Found ${lots.length} Omega lots`);
+
+ for (const lot of lots) {
+ lot.source_url = resultsUrl;
+ lot.page_hash = pageHash;
+
+ const result = await this.insertMarketEvent(lot);
+ if (result.action === 'inserted') {
+ console.log(` Lot ${lot.source_listing_id}: ${lot.reference_number_raw} — ${lot.currency} ${lot.total_to_buyer?.toLocaleString()}`);
+ }
+ }
+
+ console.log(`[antiquorum] Complete: ${this.stats.inserted} new, ${this.stats.deduplicated} dupes, ${this.stats.quarantined} quarantined`);
+ } catch (err) {
+ console.error(`[antiquorum] Collection error:`, err.message);
+ throw err;
+ }
+ }
+
+ validate(event) {
+ const base = super.validate(event);
+ if (base) return base;
+
+ // Auction-specific: extremely high prices need manual review
+ if (event.total_to_buyer > 1000000) return 'auction_price_exceptional';
+ // Lots with no condition data flagged
+ if (!event.condition_raw) return 'missing_condition';
+
+ return null;
+ }
+}
diff --git a/collectors/base-collector.mjs b/collectors/base-collector.mjs
new file mode 100644
index 0000000..940ed2f
--- /dev/null
+++ b/collectors/base-collector.mjs
@@ -0,0 +1,237 @@
+/**
+ * Base Collector — Abstract class for all data source collectors
+ * Omega Watches 2.0 Enterprise Pipeline
+ */
+import pg from 'pg';
+const { Pool } = pg;
+
+const pool = new Pool({
+ host: process.env.DB_HOST || '127.0.0.1',
+ port: parseInt(process.env.DB_PORT || '5432'),
+ database: process.env.DB_NAME || 'omega_watches_v2',
+ user: process.env.DB_USER || 'omega_admin',
+ password: process.env.DB_PASSWORD || 'OmegaWatch2024Pass',
+ max: 5,
+});
+
+export class BaseCollector {
+ constructor(sourceName) {
+ this.sourceName = sourceName;
+ this.source = null;
+ this.parser = null;
+ this.runId = null;
+ this.stats = {
+ fetched: 0,
+ parsed: 0,
+ inserted: 0,
+ updated: 0,
+ quarantined: 0,
+ deduplicated: 0,
+ };
+ this.startTime = null;
+ }
+
+ async initialize() {
+ // Load source config
+ const { rows } = await pool.query(
+ 'SELECT * FROM data_source WHERE name = $1',
+ [this.sourceName]
+ );
+ if (!rows[0]) throw new Error(`Source not found: ${this.sourceName}`);
+ this.source = rows[0];
+
+ // Check ToS compliance
+ if (this.source.tos_reviewed && this.source.tos_allows_collection === false) {
+ throw new Error(`ToS prohibits automated collection for ${this.sourceName}`);
+ }
+
+ // Load current parser
+ const { rows: parsers } = await pool.query(
+ 'SELECT * FROM parser_version WHERE source_id = $1 AND is_current = true',
+ [this.source.id]
+ );
+ this.parser = parsers[0] || { version: '1.0.0', selectors_json: {} };
+
+ // Create collector run record
+ const { rows: runs } = await pool.query(
+ `INSERT INTO collector_run (source_id, job_type, status, parser_version, started_at)
+ VALUES ($1, $2, 'running', $3, NOW()) RETURNING run_id`,
+ [this.source.id, this.getJobType(), this.parser.version]
+ );
+ this.runId = runs[0].run_id;
+ this.startTime = Date.now();
+
+ console.log(`[${this.sourceName}] Collector initialized (run #${this.runId}, parser v${this.parser.version})`);
+ }
+
+ getJobType() { return 'incremental'; }
+
+ // Rate limiting
+ async rateLimit() {
+ const rpm = this.source?.rate_limit_rpm || 10;
+ const delayMs = (60 / rpm) * 1000;
+ await new Promise(r => setTimeout(r, delayMs));
+ }
+
+ // Insert market event with dedup check
+ async insertMarketEvent(event) {
+ try {
+ // Hard dedup: (source_name, source_listing_id)
+ const { rows: existing } = await pool.query(
+ 'SELECT event_id FROM market_event WHERE source_name = $1 AND source_listing_id = $2',
+ [event.source_name, event.source_listing_id]
+ );
+
+ if (existing.length > 0) {
+ this.stats.deduplicated++;
+ return { action: 'deduplicated', event_id: existing[0].event_id };
+ }
+
+ // Validate and quarantine if needed
+ const quarantineReason = this.validate(event);
+ if (quarantineReason) {
+ event.is_quarantined = true;
+ event.quarantine_reason = quarantineReason;
+ this.stats.quarantined++;
+ }
+
+ const { rows } = await pool.query(
+ `INSERT INTO market_event (
+ source_name, source_listing_id, event_type, reference_number_raw,
+ serial_number, year_text, case_material, movement,
+ condition_raw, condition_normalized, provenance_raw,
+ sale_date, location_text, seller_type,
+ price_amount, currency, hammer_price, buyers_premium, total_to_buyer, fee_semantics,
+ images_json, source_url, page_hash, parser_version,
+ is_quarantined, quarantine_reason
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26)
+ RETURNING event_id`,
+ [
+ event.source_name, event.source_listing_id, event.event_type,
+ event.reference_number_raw, event.serial_number, event.year_text,
+ event.case_material, event.movement, event.condition_raw,
+ event.condition_normalized || 'unknown', event.provenance_raw,
+ event.sale_date, event.location_text, event.seller_type || 'unknown',
+ event.price_amount, event.currency || 'USD',
+ event.hammer_price, event.buyers_premium, event.total_to_buyer,
+ event.fee_semantics || 'unknown',
+ event.images_json ? JSON.stringify(event.images_json) : null,
+ event.source_url, event.page_hash, this.parser.version,
+ event.is_quarantined || false, event.quarantine_reason
+ ]
+ );
+
+ this.stats.inserted++;
+
+ // Create quarantine record if quarantined
+ if (event.is_quarantined) {
+ await pool.query(
+ `INSERT INTO quarantine_record (event_id, reason, severity, details)
+ VALUES ($1, $2, $3, $4)`,
+ [rows[0].event_id, quarantineReason, this.classifySeverity(quarantineReason), JSON.stringify({ source: this.sourceName })]
+ );
+ }
+
+ return { action: 'inserted', event_id: rows[0].event_id };
+ } catch (err) {
+ if (err.code === '23505') { // unique violation
+ this.stats.deduplicated++;
+ return { action: 'deduplicated' };
+ }
+ throw err;
+ }
+ }
+
+ // Insert MSRP snapshot
+ async insertMSRPSnapshot(snapshot) {
+ try {
+ await pool.query(
+ `INSERT INTO msrp_snapshot (reference_id, region_code, currency, msrp_amount, captured_date, source_url, page_hash, parser_version)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
+ ON CONFLICT (reference_id, region_code, captured_date) DO NOTHING`,
+ [
+ snapshot.reference_id, snapshot.region_code, snapshot.currency,
+ snapshot.msrp_amount, snapshot.captured_date, snapshot.source_url,
+ snapshot.page_hash, this.parser.version
+ ]
+ );
+ this.stats.inserted++;
+ } catch (err) {
+ console.error(`[${this.sourceName}] MSRP insert error:`, err.message);
+ }
+ }
+
+ // Validation — override in subclasses for source-specific rules
+ validate(event) {
+ if (!event.price_amount && !event.hammer_price) return 'missing_price';
+ if (event.price_amount < 0) return 'negative_price';
+ if (event.price_amount > 50000000) return 'implausible_price';
+ if (!event.source_listing_id) return 'missing_listing_id';
+ return null; // no quarantine needed
+ }
+
+ classifySeverity(reason) {
+ const critical = ['implausible_price'];
+ const high = ['missing_price', 'negative_price'];
+ if (critical.includes(reason)) return 'critical';
+ if (high.includes(reason)) return 'high';
+ return 'medium';
+ }
+
+ // Archive raw HTML/PDF snapshot
+ async archiveSnapshot(url, content, contentType = 'text/html') {
+ const crypto = await import('crypto');
+ const contentHash = crypto.createHash('sha256').update(content).digest('hex');
+
+ try {
+ await pool.query(
+ `INSERT INTO raw_snapshot (source_name, url, content_hash, content_type, content_size_bytes, parser_version)
+ VALUES ($1,$2,$3,$4,$5,$6)
+ ON CONFLICT DO NOTHING`,
+ [this.sourceName, url, contentHash, contentType, Buffer.byteLength(content), this.parser.version]
+ );
+ } catch (err) {
+ console.warn(`[${this.sourceName}] Snapshot archive warning:`, err.message);
+ }
+ return contentHash;
+ }
+
+ // Finalize run
+ async finalize(status = 'completed', errorMessage = null) {
+ const durationMs = Date.now() - this.startTime;
+ await pool.query(
+ `UPDATE collector_run SET
+ status = $1, completed_at = NOW(), duration_ms = $2,
+ records_fetched = $3, records_parsed = $4, records_inserted = $5,
+ records_updated = $6, records_quarantined = $7, records_deduplicated = $8,
+ error_message = $9
+ WHERE run_id = $10`,
+ [
+ status, durationMs,
+ this.stats.fetched, this.stats.parsed, this.stats.inserted,
+ this.stats.updated, this.stats.quarantined, this.stats.deduplicated,
+ errorMessage, this.runId
+ ]
+ );
+ console.log(`[${this.sourceName}] Run #${this.runId} ${status} in ${durationMs}ms:`, this.stats);
+ }
+
+ // Override in subclass
+ async collect() {
+ throw new Error('collect() must be implemented by subclass');
+ }
+
+ // Main entry point
+ async run() {
+ try {
+ await this.initialize();
+ await this.collect();
+ await this.finalize('completed');
+ } catch (err) {
+ console.error(`[${this.sourceName}] Collection failed:`, err.message);
+ if (this.runId) await this.finalize('failed', err.message);
+ }
+ }
+}
+
+export { pool };
diff --git a/collectors/chrono24/collector.mjs b/collectors/chrono24/collector.mjs
new file mode 100644
index 0000000..982e0b2
--- /dev/null
+++ b/collectors/chrono24/collector.mjs
@@ -0,0 +1,246 @@
+/**
+ * Chrono24 Marketplace Collector
+ *
+ * Fetches sold/listed watch prices from Chrono24 for references
+ * in our database. Writes to the market_event ledger.
+ *
+ * Data flow:
+ * 1. Load watch references
+ * 2. For each reference, search Chrono24 listings
+ * 3. Parse sold/listed prices, conditions, dealer info
+ * 4. Insert into market_event (dedup: source_name + source_listing_id)
+ * 5. Quarantine suspicious prices
+ *
+ * Chrono24 public search endpoint:
+ * https://www.chrono24.com/search/index.htm?query={ref}&dosearch=true
+ *
+ * Note: This respects robots.txt and rate limits. Chrono24 allows
+ * crawling of search results per their robots.txt (as of 2025).
+ */
+import { BaseCollector, pool } from '../base-collector.mjs';
+import https from 'https';
+import crypto from 'crypto';
+
+const CHRONO24_BASE = 'https://www.chrono24.com';
+const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36';
+
+export default class Chrono24Collector extends BaseCollector {
+ constructor() {
+ super('chrono24');
+ this.references = [];
+ }
+
+ getJobType() { return 'incremental'; }
+
+ fetch(url) {
+ return new Promise((resolve, reject) => {
+ const parsedUrl = new URL(url);
+ const req = https.get({
+ hostname: parsedUrl.hostname,
+ path: parsedUrl.pathname + parsedUrl.search,
+ headers: {
+ 'User-Agent': USER_AGENT,
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.9',
+ 'Accept-Encoding': 'identity',
+ },
+ timeout: 30000,
+ }, (res) => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ const redirectUrl = res.headers.location.startsWith('http')
+ ? res.headers.location
+ : `${CHRONO24_BASE}${res.headers.location}`;
+ return this.fetch(redirectUrl).then(resolve).catch(reject);
+ }
+ let body = '';
+ res.on('data', chunk => body += chunk);
+ res.on('end', () => resolve({ statusCode: res.statusCode, body }));
+ });
+ req.on('error', reject);
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
+ });
+ }
+
+ /**
+ * Parse Chrono24 search results page for listing data
+ * Returns array of listing objects
+ */
+ parseSearchResults(html, referenceNumber) {
+ const listings = [];
+
+ // Chrono24 embeds listing data in article elements
+ // Pattern: <article class="article-item" ... data-article-id="...">
+ const articlePattern = /<article[^>]*class="[^"]*article-item[^"]*"[^>]*data-article-id="(\d+)"[^>]*>([\s\S]*?)<\/article>/gi;
+ let match;
+
+ while ((match = articlePattern.exec(html)) !== null) {
+ const articleId = match[1];
+ const content = match[2];
+
+ const listing = {
+ source_listing_id: `c24-${articleId}`,
+ reference_number_raw: referenceNumber,
+ };
+
+ // Extract price
+ const priceMatch = content.match(/class="[^"]*price[^"]*"[^>]*>\s*(?:[\$€£])\s*([\d,]+)\s*</i)
+ || content.match(/data-price="([\d.]+)"/i)
+ || content.match(/>\s*(?:USD|US\$|\$)\s*([\d,]+)\s*</i);
+ if (priceMatch) {
+ listing.price_amount = parseFloat(priceMatch[1].replace(/,/g, ''));
+ }
+
+ // Extract currency
+ const currMatch = content.match(/(?:EUR|USD|GBP|CHF|JPY)/);
+ listing.currency = currMatch ? currMatch[0] : 'USD';
+
+ // Extract condition
+ const condMatch = content.match(/(?:New|Unworn|Very good|Good|Fair|Incomplete|Poor)/i);
+ if (condMatch) {
+ listing.condition_raw = condMatch[0];
+ listing.condition_normalized = this.normalizeCondition(condMatch[0]);
+ }
+
+ // Extract if it's a dealer or private seller
+ const dealerMatch = content.match(/class="[^"]*dealer-name[^"]*"/i);
+ listing.seller_type = dealerMatch ? 'dealer' : 'private';
+
+ // Determine event type
+ const soldMatch = content.match(/sold/i);
+ listing.event_type = soldMatch ? 'marketplace_sold' : 'dealer_ask';
+
+ // Extract listing URL
+ const hrefMatch = content.match(/href="(\/[^"]*omega[^"]*)"/i);
+ listing.source_url = hrefMatch ? `${CHRONO24_BASE}${hrefMatch[1]}` : `${CHRONO24_BASE}/omega/ref-${referenceNumber}.htm`;
+
+ if (listing.price_amount && listing.price_amount > 100) {
+ listings.push(listing);
+ }
+ }
+
+ // Fallback: try JSON-LD structured data
+ if (listings.length === 0) {
+ const ldBlocks = html.match(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi);
+ if (ldBlocks) {
+ for (const block of ldBlocks) {
+ try {
+ const jsonStr = block.replace(/<\/?script[^>]*>/gi, '');
+ const data = JSON.parse(jsonStr);
+ if (data['@type'] === 'ItemList' && data.itemListElement) {
+ for (const item of data.itemListElement) {
+ const product = item.item || item;
+ if (product.offers?.price) {
+ listings.push({
+ source_listing_id: `c24-ld-${product.sku || product.productID || crypto.randomBytes(4).toString('hex')}`,
+ reference_number_raw: referenceNumber,
+ price_amount: parseFloat(product.offers.price),
+ currency: product.offers.priceCurrency || 'USD',
+ event_type: 'dealer_ask',
+ condition_raw: product.itemCondition?.replace('https://schema.org/', '') || 'unknown',
+ condition_normalized: this.normalizeCondition(product.itemCondition || ''),
+ seller_type: 'dealer',
+ source_url: product.url || `${CHRONO24_BASE}/omega/ref-${referenceNumber}.htm`,
+ });
+ }
+ }
+ }
+ } catch {}
+ }
+ }
+ }
+
+ return listings;
+ }
+
+ normalizeCondition(raw) {
+ const lower = (raw || '').toLowerCase();
+ if (lower.includes('new') || lower.includes('unworn')) return 'new_unworn';
+ if (lower.includes('very good')) return 'very_good';
+ if (lower.includes('good')) return 'good';
+ if (lower.includes('fair')) return 'fair';
+ if (lower.includes('poor') || lower.includes('incomplete')) return 'poor';
+ return 'unknown';
+ }
+
+ async collect() {
+ const { rows } = await pool.query(
+ 'SELECT id, reference_number, collection, model_name FROM watch_reference ORDER BY collection, reference_number'
+ );
+ this.references = rows;
+ console.log(`[chrono24] Searching Chrono24 for ${rows.length} references`);
+
+ let totalListings = 0;
+
+ for (const ref of rows) {
+ try {
+ await this.rateLimit();
+
+ const searchUrl = `${CHRONO24_BASE}/search/index.htm?query=${encodeURIComponent('Omega ' + ref.reference_number)}&dosearch=true&sortorder=5`;
+
+ let response;
+ try {
+ response = await this.fetch(searchUrl);
+ } catch (fetchErr) {
+ console.warn(`[chrono24] Fetch error for ${ref.reference_number}: ${fetchErr.message}`);
+ continue;
+ }
+
+ this.stats.fetched++;
+
+ if (response.statusCode !== 200) {
+ console.log(` ${ref.reference_number}: HTTP ${response.statusCode}`);
+ continue;
+ }
+
+ // Archive raw HTML
+ const pageHash = crypto.createHash('sha256').update(response.body).digest('hex');
+ await this.archiveSnapshot(searchUrl, response.body);
+
+ // Parse listings
+ const listings = this.parseSearchResults(response.body, ref.reference_number);
+ this.stats.parsed += listings.length;
+
+ let insertedForRef = 0;
+ for (const listing of listings) {
+ listing.source_name = 'chrono24';
+ listing.sale_date = new Date().toISOString().split('T')[0];
+ listing.page_hash = pageHash;
+
+ // For dealer_ask: price_amount is the ask, no hammer_price
+ if (listing.event_type === 'dealer_ask') {
+ listing.total_to_buyer = listing.price_amount;
+ listing.fee_semantics = 'unknown';
+ } else {
+ // marketplace_sold
+ listing.total_to_buyer = listing.price_amount;
+ listing.fee_semantics = 'total_inclusive';
+ }
+
+ const result = await this.insertMarketEvent(listing);
+ if (result.action === 'inserted') insertedForRef++;
+ }
+
+ totalListings += listings.length;
+ if (listings.length > 0) {
+ console.log(` ${ref.reference_number}: ${listings.length} listings found, ${insertedForRef} new`);
+ }
+ } catch (err) {
+ console.error(` ${ref.reference_number}: Error — ${err.message}`);
+ }
+ }
+
+ console.log(`[chrono24] Complete: ${totalListings} total listings processed, ${this.stats.inserted} inserted, ${this.stats.deduplicated} deduped`);
+ }
+
+ validate(event) {
+ // Chrono24-specific validation
+ const base = super.validate(event);
+ if (base) return base;
+
+ // Omega watches: reasonable price range $500 - $500k
+ if (event.price_amount < 500) return 'price_below_minimum';
+ if (event.price_amount > 500000) return 'price_above_maximum';
+
+ return null;
+ }
+}
diff --git a/collectors/omega-official/collector.mjs b/collectors/omega-official/collector.mjs
new file mode 100644
index 0000000..b9def0a
--- /dev/null
+++ b/collectors/omega-official/collector.mjs
@@ -0,0 +1,213 @@
+/**
+ * Omega Official MSRP Collector
+ *
+ * Fetches current retail prices from omegawatches.com for all
+ * references in our database. Writes to the msrp_snapshot ledger.
+ *
+ * Data flow:
+ * 1. Load all watch_reference rows
+ * 2. For each reference, build the Omega product page URL
+ * 3. Fetch the page and extract the retail price
+ * 4. Insert into msrp_snapshot (dedup: reference_id + region + date)
+ * 5. Track stats in collector_run
+ *
+ * The Omega website uses the pattern:
+ * https://www.omegawatches.com/watch-omega-{collection}-{ref}
+ * But since URLs vary, we also use their product API endpoint.
+ */
+import { BaseCollector, pool } from '../base-collector.mjs';
+import https from 'https';
+import crypto from 'crypto';
+
+const OMEGA_BASE = 'https://www.omegawatches.com';
+const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
+
+export default class OmegaOfficialCollector extends BaseCollector {
+ constructor() {
+ super('omega_official');
+ this.references = [];
+ }
+
+ getJobType() { return 'daily_msrp'; }
+
+ /**
+ * HTTP GET with TLS settings matching a regular browser
+ */
+ fetch(url) {
+ return new Promise((resolve, reject) => {
+ const req = https.get(url, {
+ headers: {
+ 'User-Agent': USER_AGENT,
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.9',
+ },
+ timeout: 30000,
+ }, (res) => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ return this.fetch(res.headers.location).then(resolve).catch(reject);
+ }
+ let body = '';
+ res.on('data', chunk => body += chunk);
+ res.on('end', () => resolve({ statusCode: res.statusCode, body, headers: res.headers }));
+ });
+ req.on('error', reject);
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
+ });
+ }
+
+ /**
+ * Extract price from Omega product page HTML
+ * Omega uses structured data (JSON-LD) and visible price elements
+ */
+ extractPrice(html) {
+ // Try JSON-LD structured data first
+ const ldMatch = html.match(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi);
+ if (ldMatch) {
+ for (const block of ldMatch) {
+ try {
+ const jsonStr = block.replace(/<\/?script[^>]*>/gi, '');
+ const data = JSON.parse(jsonStr);
+ const product = Array.isArray(data) ? data.find(d => d['@type'] === 'Product') : data;
+ if (product?.offers?.price) {
+ return {
+ amount: parseFloat(product.offers.price),
+ currency: product.offers.priceCurrency || 'USD',
+ };
+ }
+ if (product?.offers?.[0]?.price) {
+ return {
+ amount: parseFloat(product.offers[0].price),
+ currency: product.offers[0].priceCurrency || 'USD',
+ };
+ }
+ } catch {}
+ }
+ }
+
+ // Fallback: Extract from visible price elements
+ // Omega typically renders: <span class="price">$6,350</span> or similar
+ const pricePatterns = [
+ /class="[^"]*price[^"]*"[^>]*>\s*\$?\s*([\d,]+(?:\.\d{2})?)\s*</i,
+ /data-price="([\d.]+)"/i,
+ /itemprop="price"\s+content="([\d.]+)"/i,
+ />\s*(?:USD|US\$|\$)\s*([\d,]+(?:\.\d{2})?)\s*</i,
+ ];
+
+ for (const pat of pricePatterns) {
+ const m = html.match(pat);
+ if (m) {
+ const amount = parseFloat(m[1].replace(/,/g, ''));
+ if (amount > 100 && amount < 5000000) {
+ return { amount, currency: 'USD' };
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Build search URL for reference number
+ * Omega's search endpoint returns product pages
+ */
+ buildSearchUrl(referenceNumber) {
+ // Omega's internal search API
+ return `${OMEGA_BASE}/en-us/search?q=${encodeURIComponent(referenceNumber)}`;
+ }
+
+ /**
+ * Build direct product URL from reference number
+ * Omega format: reference dots removed, lowercased
+ * e.g., 310.30.42.50.01.001 → /en-us/watch/omega-speedmaster/31030425001001
+ */
+ buildProductUrl(referenceNumber) {
+ const refClean = referenceNumber.replace(/\./g, '');
+ return `${OMEGA_BASE}/en-us/watch/omega-watch/${refClean}`;
+ }
+
+ async collect() {
+ // Load all references from our database
+ const { rows } = await pool.query(
+ 'SELECT id, reference_number, collection, model_name FROM watch_reference ORDER BY collection, reference_number'
+ );
+ this.references = rows;
+ this.stats.fetched = rows.length;
+
+ console.log(`[omega_official] Collecting MSRP for ${rows.length} references`);
+
+ const today = new Date().toISOString().split('T')[0];
+ let successCount = 0;
+ let skipCount = 0;
+
+ for (const ref of rows) {
+ try {
+ await this.rateLimit();
+
+ // Check if we already have today's snapshot
+ const { rows: existing } = await pool.query(
+ `SELECT msrp_id FROM msrp_snapshot
+ WHERE reference_id = $1 AND region_code = 'US' AND captured_date = $2`,
+ [ref.id, today]
+ );
+
+ if (existing.length > 0) {
+ this.stats.deduplicated++;
+ skipCount++;
+ continue;
+ }
+
+ // Try direct product URL first
+ const url = this.buildProductUrl(ref.reference_number);
+ let response;
+ try {
+ response = await this.fetch(url);
+ } catch (fetchErr) {
+ console.warn(`[omega_official] Fetch error for ${ref.reference_number}: ${fetchErr.message}`);
+ continue;
+ }
+
+ this.stats.parsed++;
+
+ if (response.statusCode === 200 && response.body.length > 1000) {
+ const pageHash = crypto.createHash('sha256').update(response.body).digest('hex');
+ const price = this.extractPrice(response.body);
+
+ if (price && price.amount > 0) {
+ await this.insertMSRPSnapshot({
+ reference_id: ref.id,
+ region_code: 'US',
+ currency: price.currency === 'USD' ? 'USD' : price.currency,
+ msrp_amount: price.amount,
+ captured_date: today,
+ source_url: url,
+ page_hash: pageHash,
+ });
+ successCount++;
+ console.log(` ${ref.reference_number}: ${price.currency} ${price.amount.toLocaleString()}`);
+ } else {
+ // Page loaded but no price found — maybe discontinued
+ console.log(` ${ref.reference_number}: No price found (page ${response.statusCode})`);
+ }
+
+ // Archive the raw page
+ await this.archiveSnapshot(url, response.body);
+ } else if (response.statusCode === 404) {
+ console.log(` ${ref.reference_number}: 404 — reference not on Omega site`);
+ } else {
+ console.log(` ${ref.reference_number}: HTTP ${response.statusCode}`);
+ }
+ } catch (err) {
+ console.error(` ${ref.reference_number}: Error — ${err.message}`);
+ }
+ }
+
+ console.log(`[omega_official] Complete: ${successCount} prices captured, ${skipCount} already today, ${this.stats.quarantined} quarantined`);
+ }
+
+ validate(event) {
+ // MSRP-specific validation (for any events we insert)
+ if (event.price_amount && event.price_amount < 500) return 'msrp_too_low';
+ if (event.price_amount && event.price_amount > 2000000) return 'msrp_too_high';
+ return null;
+ }
+}
diff --git a/collectors/runner.mjs b/collectors/runner.mjs
new file mode 100644
index 0000000..380c5b4
--- /dev/null
+++ b/collectors/runner.mjs
@@ -0,0 +1,74 @@
+/**
+ * Collector Runner — Orchestrates collection jobs
+ * Usage: node collectors/runner.mjs [source_name] [job_type]
+ */
+import { pool } from './base-collector.mjs';
+
+const sourceName = process.argv[2];
+const jobType = process.argv[3] || 'incremental';
+
+async function run() {
+ if (!sourceName) {
+ // List available sources
+ const { rows } = await pool.query(
+ 'SELECT name, display_name, source_type, access_method, is_active FROM data_source ORDER BY name'
+ );
+ console.log('\nAvailable sources:');
+ console.log('─'.repeat(80));
+ for (const s of rows) {
+ const status = s.is_active ? '✓' : '✗';
+ console.log(` ${status} ${s.name.padEnd(20)} ${s.display_name.padEnd(25)} ${s.source_type.padEnd(15)} ${s.access_method}`);
+ }
+ console.log(`\nUsage: node collectors/runner.mjs <source_name> [job_type]`);
+ console.log('Job types: incremental, daily_msrp, backfill, full_crawl\n');
+ process.exit(0);
+ }
+
+ try {
+ // Try both underscore and hyphen directory conventions
+ const candidates = [
+ `./${sourceName}/collector.mjs`,
+ `./${sourceName.replace(/_/g, '-')}/collector.mjs`,
+ ];
+ let loaded = false;
+
+ for (const modulePath of candidates) {
+ try {
+ const { default: CollectorClass } = await import(modulePath);
+ const collector = new CollectorClass();
+ await collector.run();
+ loaded = true;
+ break;
+ } catch (importErr) {
+ if (importErr.code !== 'ERR_MODULE_NOT_FOUND') throw importErr;
+ }
+ }
+
+ if (!loaded) {
+ console.log(`No collector module found for "${sourceName}"`);
+ console.log(`Expected at: ${candidates.join(' or ')}`);
+ console.log(`\nRegistering a placeholder run...`);
+
+ const { rows: sources } = await pool.query(
+ 'SELECT id FROM data_source WHERE name = $1', [sourceName]
+ );
+ if (!sources[0]) {
+ console.error(`Source "${sourceName}" not found in database`);
+ process.exit(1);
+ }
+ await pool.query(
+ `INSERT INTO collector_run (source_id, job_type, status, parser_version, metadata)
+ VALUES ($1, $2, 'pending', '1.0.0', '{"note": "No collector module yet"}')`,
+ [sources[0].id, jobType]
+ );
+ console.log('Placeholder run created.');
+ }
+ } catch (err) {
+ console.error('Runner error:', err.message);
+ process.exit(1);
+ } finally {
+ await pool.end();
+ }
+}
+
+run();
diff --git a/database/schema.sql b/database/schema.sql
new file mode 100644
index 0000000..8a6a0fc
--- /dev/null
+++ b/database/schema.sql
@@ -0,0 +1,595 @@
+-- ============================================================================
+-- OMEGA WATCHES 2.0 — ENTERPRISE DUAL-LEDGER SCHEMA
+-- ============================================================================
+-- Ledger 1: Primary Market MSRP (daily immutable snapshots)
+-- Ledger 2: Secondary Market Events (auction, marketplace, dealer, forum)
+-- ============================================================================
+
+-- Create database (run separately as superuser)
+-- CREATE DATABASE omega_watches_v2 OWNER omega_admin;
+
+-- Extensions
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+CREATE EXTENSION IF NOT EXISTS "pg_trgm";
+CREATE EXTENSION IF NOT EXISTS "btree_gin";
+
+-- ============================================================================
+-- ENUMS
+-- ============================================================================
+
+CREATE TYPE event_type_enum AS ENUM (
+ 'auction_realized',
+ 'marketplace_sold',
+ 'dealer_sold',
+ 'dealer_ask',
+ 'forum_listing',
+ 'price_guide'
+);
+
+CREATE TYPE seller_type_enum AS ENUM (
+ 'auction_house', 'dealer', 'private', 'marketplace', 'unknown'
+);
+
+CREATE TYPE fee_semantics_enum AS ENUM (
+ 'hammer_only', 'price_realised', 'total_to_buyer', 'unknown'
+);
+
+CREATE TYPE condition_grade_enum AS ENUM (
+ 'new_unworn', 'excellent', 'very_good', 'good', 'fair', 'poor', 'parts_only', 'unknown'
+);
+
+CREATE TYPE access_method_enum AS ENUM (
+ 'api', 'scrape', 'manual', 'export'
+);
+
+CREATE TYPE job_status_enum AS ENUM (
+ 'pending', 'running', 'completed', 'failed', 'cancelled'
+);
+
+CREATE TYPE severity_enum AS ENUM (
+ 'low', 'medium', 'high', 'critical'
+);
+
+-- ============================================================================
+-- WATCH REFERENCE (Master Entity)
+-- ============================================================================
+
+CREATE TABLE watch_reference (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ brand TEXT NOT NULL DEFAULT 'Omega',
+ collection TEXT NOT NULL,
+ model_name TEXT NOT NULL,
+ reference_number TEXT NOT NULL,
+ calibre TEXT,
+ case_material TEXT,
+ case_diameter_mm NUMERIC(5,2),
+ dial_color TEXT,
+ year_introduced INTEGER,
+ year_discontinued INTEGER,
+ is_limited_edition BOOLEAN DEFAULT false,
+ limited_edition_count INTEGER,
+ notes TEXT,
+ search_vector tsvector,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ CONSTRAINT uq_reference UNIQUE (brand, reference_number),
+ CONSTRAINT ck_year_range CHECK (year_introduced IS NULL OR (year_introduced >= 1848 AND year_introduced <= 2030)),
+ CONSTRAINT ck_limited CHECK (
+ (is_limited_edition = false AND limited_edition_count IS NULL)
+ OR (is_limited_edition = true AND limited_edition_count > 0)
+ )
+);
+
+CREATE INDEX idx_ref_collection ON watch_reference(collection);
+CREATE INDEX idx_ref_brand ON watch_reference(brand);
+CREATE INDEX idx_ref_reference ON watch_reference(reference_number);
+CREATE INDEX idx_ref_year ON watch_reference(year_introduced);
+CREATE INDEX idx_ref_search ON watch_reference USING GIN(search_vector);
+CREATE INDEX idx_ref_model_trgm ON watch_reference USING GIN(model_name gin_trgm_ops);
+CREATE INDEX idx_ref_refnum_trgm ON watch_reference USING GIN(reference_number gin_trgm_ops);
+
+-- Auto-update search vector
+CREATE OR REPLACE FUNCTION update_ref_search_vector() RETURNS TRIGGER AS $$
+BEGIN
+ NEW.search_vector :=
+ setweight(to_tsvector('english', COALESCE(NEW.model_name, '')), 'A') ||
+ setweight(to_tsvector('english', COALESCE(NEW.collection, '')), 'A') ||
+ setweight(to_tsvector('english', COALESCE(NEW.reference_number, '')), 'B') ||
+ setweight(to_tsvector('english', COALESCE(NEW.calibre, '')), 'C') ||
+ setweight(to_tsvector('english', COALESCE(NEW.notes, '')), 'D');
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trg_ref_search BEFORE INSERT OR UPDATE ON watch_reference
+ FOR EACH ROW EXECUTE FUNCTION update_ref_search_vector();
+
+-- ============================================================================
+-- LEDGER 1: MSRP SNAPSHOTS (Immutable Daily Observations)
+-- ============================================================================
+
+CREATE TABLE msrp_snapshot (
+ msrp_id BIGSERIAL,
+ reference_id UUID NOT NULL REFERENCES watch_reference(id),
+ region_code TEXT NOT NULL, -- US, CH, UK, EU, JP, HK, SG
+ currency CHAR(3) NOT NULL,
+ msrp_amount NUMERIC(14,2) NOT NULL CHECK (msrp_amount > 0),
+ captured_date DATE NOT NULL,
+ source_url TEXT NOT NULL,
+ page_hash TEXT,
+ parser_version TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ PRIMARY KEY (msrp_id, captured_date),
+ CONSTRAINT uq_msrp_daily UNIQUE (reference_id, region_code, captured_date)
+) PARTITION BY RANGE (captured_date);
+
+-- Partitions by year
+CREATE TABLE msrp_snapshot_2024 PARTITION OF msrp_snapshot
+ FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
+CREATE TABLE msrp_snapshot_2025 PARTITION OF msrp_snapshot
+ FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
+CREATE TABLE msrp_snapshot_2026 PARTITION OF msrp_snapshot
+ FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
+CREATE TABLE msrp_snapshot_2027 PARTITION OF msrp_snapshot
+ FOR VALUES FROM ('2027-01-01') TO ('2028-01-01');
+CREATE TABLE msrp_snapshot_2028 PARTITION OF msrp_snapshot
+ FOR VALUES FROM ('2028-01-01') TO ('2029-01-01');
+
+CREATE INDEX idx_msrp_ref ON msrp_snapshot(reference_id);
+CREATE INDEX idx_msrp_date ON msrp_snapshot(captured_date DESC);
+CREATE INDEX idx_msrp_ref_region ON msrp_snapshot(reference_id, region_code);
+CREATE INDEX idx_msrp_ref_date ON msrp_snapshot(reference_id, captured_date DESC);
+
+-- MSRP change detection view
+CREATE OR REPLACE VIEW msrp_changes AS
+SELECT
+ b.reference_id,
+ r.reference_number,
+ r.model_name,
+ b.region_code,
+ b.currency,
+ a.msrp_amount AS old_price,
+ b.msrp_amount AS new_price,
+ b.captured_date AS change_date,
+ ROUND(((b.msrp_amount - a.msrp_amount) / a.msrp_amount) * 100, 2) AS pct_change
+FROM msrp_snapshot a
+JOIN msrp_snapshot b
+ ON a.reference_id = b.reference_id
+ AND a.region_code = b.region_code
+ AND a.captured_date = b.captured_date - INTERVAL '1 day'
+JOIN watch_reference r ON b.reference_id = r.id
+WHERE a.msrp_amount <> b.msrp_amount;
+
+-- ============================================================================
+-- LEDGER 2: SECONDARY MARKET EVENTS
+-- ============================================================================
+
+CREATE TABLE market_event (
+ event_id BIGSERIAL PRIMARY KEY,
+ -- Source identity (hard dedup key)
+ source_name TEXT NOT NULL,
+ source_listing_id TEXT NOT NULL,
+ -- Event classification
+ event_type event_type_enum NOT NULL,
+ -- Watch identity (may be null if unresolved)
+ reference_id UUID REFERENCES watch_reference(id),
+ reference_number_raw TEXT,
+ serial_number TEXT,
+ year_text TEXT,
+ case_material TEXT,
+ movement TEXT,
+ -- Condition
+ condition_raw TEXT,
+ condition_normalized condition_grade_enum NOT NULL DEFAULT 'unknown',
+ condition_score INTEGER CHECK (condition_score IS NULL OR (condition_score >= 0 AND condition_score <= 100)),
+ -- Provenance
+ provenance_raw TEXT,
+ -- Sale metadata
+ sale_date DATE,
+ location_text TEXT,
+ seller_type seller_type_enum NOT NULL DEFAULT 'unknown',
+ -- Price fields (explicit fee semantics)
+ price_amount NUMERIC(14,2),
+ currency CHAR(3) NOT NULL DEFAULT 'USD',
+ hammer_price NUMERIC(14,2),
+ buyers_premium NUMERIC(14,2),
+ total_to_buyer NUMERIC(14,2),
+ fee_semantics fee_semantics_enum NOT NULL DEFAULT 'unknown',
+ -- FX normalization
+ price_usd NUMERIC(14,2),
+ fx_rate NUMERIC(12,6),
+ fx_source TEXT,
+ -- Inflation adjustment
+ price_usd_real NUMERIC(14,2),
+ cpi_base_year INTEGER,
+ -- Images and source
+ images_json JSONB,
+ source_url TEXT NOT NULL,
+ scraped_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ page_hash TEXT,
+ parser_version TEXT NOT NULL,
+ -- Quality controls
+ quality_score INTEGER CHECK (quality_score IS NULL OR (quality_score >= 0 AND quality_score <= 100)),
+ is_quarantined BOOLEAN NOT NULL DEFAULT false,
+ quarantine_reason TEXT,
+ -- Dedup
+ dedup_cluster_id UUID,
+ -- Timestamps
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ CONSTRAINT uq_source_listing UNIQUE (source_name, source_listing_id)
+);
+
+CREATE INDEX idx_me_source ON market_event(source_name);
+CREATE INDEX idx_me_type ON market_event(event_type);
+CREATE INDEX idx_me_ref ON market_event(reference_id);
+CREATE INDEX idx_me_refnum_raw ON market_event(reference_number_raw);
+CREATE INDEX idx_me_serial ON market_event(serial_number) WHERE serial_number IS NOT NULL;
+CREATE INDEX idx_me_sale_date ON market_event(sale_date DESC) WHERE sale_date IS NOT NULL;
+CREATE INDEX idx_me_price_usd ON market_event(price_usd DESC) WHERE price_usd IS NOT NULL;
+CREATE INDEX idx_me_quarantine ON market_event(is_quarantined) WHERE is_quarantined = true;
+CREATE INDEX idx_me_dedup ON market_event(dedup_cluster_id) WHERE dedup_cluster_id IS NOT NULL;
+CREATE INDEX idx_me_scraped ON market_event(scraped_at DESC);
+CREATE INDEX idx_me_condition ON market_event(condition_normalized);
+CREATE INDEX idx_me_seller ON market_event(seller_type);
+CREATE INDEX idx_me_refnum_trgm ON market_event USING GIN(reference_number_raw gin_trgm_ops);
+
+-- ============================================================================
+-- DATA SOURCES
+-- ============================================================================
+
+CREATE TABLE data_source (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ name TEXT UNIQUE NOT NULL, -- sothebys, christies, ebay, chrono24, etc.
+ display_name TEXT NOT NULL,
+ source_type TEXT NOT NULL, -- auction, marketplace, dealer, forum, manufacturer, index
+ access_method access_method_enum NOT NULL,
+ base_url TEXT,
+ robots_txt_compliant BOOLEAN NOT NULL DEFAULT true,
+ tos_reviewed BOOLEAN NOT NULL DEFAULT false,
+ tos_allows_collection BOOLEAN,
+ tos_notes TEXT,
+ reliability_score INTEGER NOT NULL DEFAULT 3 CHECK (reliability_score >= 1 AND reliability_score <= 5),
+ coverage_years_from INTEGER,
+ coverage_years_to INTEGER,
+ rate_limit_rpm INTEGER, -- requests per minute
+ notes TEXT,
+ is_active BOOLEAN NOT NULL DEFAULT true,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- ============================================================================
+-- COLLECTOR RUNS (Job History)
+-- ============================================================================
+
+CREATE TABLE collector_run (
+ run_id BIGSERIAL PRIMARY KEY,
+ source_id UUID NOT NULL REFERENCES data_source(id),
+ job_type TEXT NOT NULL, -- daily_msrp, backfill, incremental, full_crawl
+ status job_status_enum NOT NULL DEFAULT 'pending',
+ started_at TIMESTAMPTZ,
+ completed_at TIMESTAMPTZ,
+ records_fetched INTEGER NOT NULL DEFAULT 0,
+ records_parsed INTEGER NOT NULL DEFAULT 0,
+ records_inserted INTEGER NOT NULL DEFAULT 0,
+ records_updated INTEGER NOT NULL DEFAULT 0,
+ records_quarantined INTEGER NOT NULL DEFAULT 0,
+ records_deduplicated INTEGER NOT NULL DEFAULT 0,
+ error_message TEXT,
+ parser_version TEXT NOT NULL,
+ duration_ms INTEGER,
+ metadata JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_cr_source ON collector_run(source_id);
+CREATE INDEX idx_cr_status ON collector_run(status);
+CREATE INDEX idx_cr_started ON collector_run(started_at DESC);
+CREATE INDEX idx_cr_type ON collector_run(job_type);
+
+-- ============================================================================
+-- PARSER VERSIONS
+-- ============================================================================
+
+CREATE TABLE parser_version (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ source_id UUID NOT NULL REFERENCES data_source(id),
+ version TEXT NOT NULL,
+ selectors_json JSONB NOT NULL,
+ dom_hash TEXT,
+ is_current BOOLEAN NOT NULL DEFAULT false,
+ tested_at TIMESTAMPTZ,
+ test_pass_rate NUMERIC(5,2),
+ notes TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ CONSTRAINT uq_parser_version UNIQUE (source_id, version)
+);
+
+CREATE INDEX idx_pv_source ON parser_version(source_id);
+CREATE INDEX idx_pv_current ON parser_version(is_current) WHERE is_current = true;
+
+-- ============================================================================
+-- QUARANTINE
+-- ============================================================================
+
+CREATE TABLE quarantine_record (
+ id BIGSERIAL PRIMARY KEY,
+ event_id BIGINT REFERENCES market_event(event_id),
+ msrp_id BIGINT,
+ reason TEXT NOT NULL,
+ severity severity_enum NOT NULL DEFAULT 'medium',
+ details JSONB,
+ resolved BOOLEAN NOT NULL DEFAULT false,
+ resolved_by TEXT,
+ resolved_at TIMESTAMPTZ,
+ resolution_notes TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_qr_resolved ON quarantine_record(resolved) WHERE resolved = false;
+CREATE INDEX idx_qr_severity ON quarantine_record(severity);
+CREATE INDEX idx_qr_event ON quarantine_record(event_id);
+
+-- ============================================================================
+-- CONDITION MAPPINGS (per-venue normalization)
+-- ============================================================================
+
+CREATE TABLE condition_mapping (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ source_name TEXT NOT NULL,
+ source_category TEXT NOT NULL,
+ normalized_grade condition_grade_enum NOT NULL,
+ score_0_100 INTEGER NOT NULL CHECK (score_0_100 >= 0 AND score_0_100 <= 100),
+ version INTEGER NOT NULL DEFAULT 1,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ CONSTRAINT uq_condition_map UNIQUE (source_name, source_category, version)
+);
+
+-- ============================================================================
+-- FX RATES
+-- ============================================================================
+
+CREATE TABLE fx_rate (
+ id BIGSERIAL PRIMARY KEY,
+ currency_from CHAR(3) NOT NULL,
+ currency_to CHAR(3) NOT NULL DEFAULT 'USD',
+ rate NUMERIC(12,6) NOT NULL CHECK (rate > 0),
+ rate_date DATE NOT NULL,
+ source TEXT NOT NULL, -- fed_h10, ecb, manual
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ CONSTRAINT uq_fx_daily UNIQUE (currency_from, currency_to, rate_date, source)
+);
+
+CREATE INDEX idx_fx_pair_date ON fx_rate(currency_from, currency_to, rate_date DESC);
+
+-- ============================================================================
+-- CPI DATA (for inflation adjustment)
+-- ============================================================================
+
+CREATE TABLE cpi_data (
+ id BIGSERIAL PRIMARY KEY,
+ country_code CHAR(2) NOT NULL, -- US, UK, CH
+ year INTEGER NOT NULL,
+ month INTEGER CHECK (month >= 1 AND month <= 12),
+ cpi_value NUMERIC(10,4) NOT NULL,
+ base_year INTEGER NOT NULL,
+ source TEXT NOT NULL, -- bls, ons
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ CONSTRAINT uq_cpi UNIQUE (country_code, year, month, source)
+);
+
+-- ============================================================================
+-- AUDIT LOG
+-- ============================================================================
+
+CREATE TABLE audit_log (
+ id BIGSERIAL,
+ table_name TEXT NOT NULL,
+ record_id TEXT NOT NULL,
+ operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
+ old_values JSONB,
+ new_values JSONB,
+ changed_by TEXT NOT NULL DEFAULT 'system',
+ ip_address INET,
+ changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ PRIMARY KEY (id, changed_at)
+) PARTITION BY RANGE (changed_at);
+
+CREATE TABLE audit_log_2025 PARTITION OF audit_log
+ FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
+CREATE TABLE audit_log_2026 PARTITION OF audit_log
+ FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
+CREATE TABLE audit_log_2027 PARTITION OF audit_log
+ FOR VALUES FROM ('2027-01-01') TO ('2028-01-01');
+
+CREATE INDEX idx_audit_table ON audit_log(table_name, record_id);
+CREATE INDEX idx_audit_time ON audit_log(changed_at DESC);
+
+-- ============================================================================
+-- RAW SNAPSHOTS (HTML/PDF archive for auditability)
+-- ============================================================================
+
+CREATE TABLE raw_snapshot (
+ id BIGSERIAL PRIMARY KEY,
+ source_name TEXT NOT NULL,
+ url TEXT NOT NULL,
+ content_hash TEXT NOT NULL,
+ content_type TEXT NOT NULL, -- text/html, application/pdf
+ content_size_bytes INTEGER,
+ storage_path TEXT, -- local filesystem path
+ scraped_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ parser_version TEXT,
+
+ CONSTRAINT uq_snapshot UNIQUE (source_name, url, content_hash)
+);
+
+CREATE INDEX idx_rs_source ON raw_snapshot(source_name);
+CREATE INDEX idx_rs_scraped ON raw_snapshot(scraped_at DESC);
+
+-- ============================================================================
+-- TRIGGERS
+-- ============================================================================
+
+-- Auto-update updated_at
+CREATE OR REPLACE FUNCTION update_timestamp() RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trg_ref_updated BEFORE UPDATE ON watch_reference
+ FOR EACH ROW EXECUTE FUNCTION update_timestamp();
+CREATE TRIGGER trg_source_updated BEFORE UPDATE ON data_source
+ FOR EACH ROW EXECUTE FUNCTION update_timestamp();
+
+-- Audit trigger for market_event
+CREATE OR REPLACE FUNCTION audit_market_event() RETURNS TRIGGER AS $$
+BEGIN
+ IF TG_OP = 'INSERT' THEN
+ INSERT INTO audit_log(table_name, record_id, operation, new_values)
+ VALUES ('market_event', NEW.event_id::TEXT, 'INSERT', row_to_json(NEW)::jsonb);
+ RETURN NEW;
+ ELSIF TG_OP = 'UPDATE' THEN
+ INSERT INTO audit_log(table_name, record_id, operation, old_values, new_values)
+ VALUES ('market_event', NEW.event_id::TEXT, 'UPDATE', row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb);
+ RETURN NEW;
+ ELSIF TG_OP = 'DELETE' THEN
+ INSERT INTO audit_log(table_name, record_id, operation, old_values)
+ VALUES ('market_event', OLD.event_id::TEXT, 'DELETE', row_to_json(OLD)::jsonb);
+ RETURN OLD;
+ END IF;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trg_audit_market_event
+ AFTER INSERT OR UPDATE OR DELETE ON market_event
+ FOR EACH ROW EXECUTE FUNCTION audit_market_event();
+
+-- ============================================================================
+-- SEED DATA SOURCES (from spec)
+-- ============================================================================
+
+INSERT INTO data_source (name, display_name, source_type, access_method, base_url, reliability_score, tos_reviewed, tos_allows_collection, tos_notes, coverage_years_from, coverage_years_to, rate_limit_rpm) VALUES
+ ('sothebys', 'Sotheby''s', 'auction', 'scrape', 'https://www.sothebys.com', 5, true, NULL, 'Login-gated results; treat as permissioned environment', 1960, 2026, 10),
+ ('christies', 'Christie''s', 'auction', 'scrape', 'https://www.christies.com', 5, true, NULL, 'Price realised includes hammer + buyer''s premium', 1960, 2026, 10),
+ ('phillips', 'Phillips', 'auction', 'scrape', 'https://www.phillips.com', 5, true, NULL, 'Strong watch focus; buyer''s premium schedules published', 1970, 2026, 10),
+ ('antiquorum', 'Antiquorum', 'auction', 'scrape', 'https://www.antiquorum.swiss', 5, true, NULL, 'Graded condition breakdowns; movement/case numbers', 1970, 2026, 10),
+ ('bonhams', 'Bonhams', 'auction', 'scrape', 'https://www.bonhams.com', 4, true, NULL, 'Dedicated watches department', 1980, 2026, 10),
+ ('ebay', 'eBay', 'marketplace', 'api', 'https://www.ebay.com', 4, true, false, 'ToS prohibits robots/scrapers without permission; Feb 20 2026 updated agreement', 2000, 2026, 30),
+ ('chrono24', 'Chrono24', 'marketplace', 'scrape', 'https://www.chrono24.com', 4, true, NULL, 'robots.txt disallows some agents including Scrapy; structured listing attributes', 2005, 2026, 5),
+ ('the1916co', 'The 1916 Company', 'dealer', 'scrape', 'https://www.the1916company.com', 3, false, NULL, 'Standard ToS; selection bias', 2010, 2026, 10),
+ ('hodinkee', 'Hodinkee', 'dealer', 'manual', 'https://shop.hodinkee.com', 4, true, false, 'ToS prohibits robots/spiders/data mining/extraction tools', 2015, 2026, NULL),
+ ('watchuseek', 'Watchuseek', 'forum', 'scrape', 'https://www.watchuseek.com', 3, false, NULL, 'Rules require leaving asking price after sold', 2000, 2026, 5),
+ ('omegaforums', 'Omega Forums', 'forum', 'scrape', 'https://omegaforums.net', 3, false, NULL, 'Rule: do not delete listing or price when sold; requires price/currency', 2005, 2026, 5),
+ ('watchcharts', 'WatchCharts', 'index', 'api', 'https://watchcharts.com', 3, true, NULL, 'Paid API with Data Credits; business pricing from $5k/yr; best for triangulation', 2018, 2026, 60),
+ ('omega_official', 'Omega Official', 'manufacturer', 'scrape', 'https://www.omegawatches.com', 5, false, NULL, 'Official MSRP source; may need data-sharing agreement for long-term compliance', 1957, 2026, 5);
+
+-- ============================================================================
+-- INITIAL CONDITION MAPPINGS
+-- ============================================================================
+
+INSERT INTO condition_mapping (source_name, source_category, normalized_grade, score_0_100) VALUES
+ -- Chrono24 mappings
+ ('chrono24', 'New/Unworn', 'new_unworn', 100),
+ ('chrono24', 'Very good', 'very_good', 75),
+ ('chrono24', 'Good', 'good', 60),
+ ('chrono24', 'Fair', 'fair', 40),
+ ('chrono24', 'Incomplete', 'parts_only', 10),
+ -- Auction mappings
+ ('auction_generic', 'Mint', 'new_unworn', 95),
+ ('auction_generic', 'Excellent', 'excellent', 85),
+ ('auction_generic', 'Very Good', 'very_good', 75),
+ ('auction_generic', 'Good', 'good', 60),
+ ('auction_generic', 'Fair', 'fair', 40),
+ ('auction_generic', 'Poor', 'poor', 20),
+ -- eBay mappings
+ ('ebay', 'New with tags', 'new_unworn', 100),
+ ('ebay', 'New without tags', 'new_unworn', 95),
+ ('ebay', 'Pre-owned', 'good', 60),
+ ('ebay', 'For parts or not working', 'parts_only', 10);
+
+-- ============================================================================
+-- MATERIALIZED VIEWS
+-- ============================================================================
+
+-- Reference price summary
+CREATE MATERIALIZED VIEW ref_price_summary AS
+SELECT
+ r.id AS reference_id,
+ r.reference_number,
+ r.model_name,
+ r.collection,
+ COUNT(DISTINCT me.event_id) AS event_count,
+ COUNT(DISTINCT me.event_id) FILTER (WHERE me.event_type = 'auction_realized') AS auction_count,
+ COUNT(DISTINCT me.event_id) FILTER (WHERE me.event_type = 'marketplace_sold') AS marketplace_count,
+ MIN(me.price_usd) FILTER (WHERE me.price_usd > 0) AS min_price_usd,
+ MAX(me.price_usd) AS max_price_usd,
+ AVG(me.price_usd)::NUMERIC(14,2) AS avg_price_usd,
+ PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY me.price_usd) AS median_price_usd,
+ MIN(me.sale_date) AS earliest_sale,
+ MAX(me.sale_date) AS latest_sale,
+ (SELECT ms.msrp_amount FROM msrp_snapshot ms
+ WHERE ms.reference_id = r.id AND ms.region_code = 'US'
+ ORDER BY ms.captured_date DESC LIMIT 1) AS current_msrp_usd,
+ NOW() AS calculated_at
+FROM watch_reference r
+LEFT JOIN market_event me ON me.reference_id = r.id AND NOT me.is_quarantined
+GROUP BY r.id, r.reference_number, r.model_name, r.collection;
+
+CREATE UNIQUE INDEX idx_rps_ref ON ref_price_summary(reference_id);
+CREATE INDEX idx_rps_collection ON ref_price_summary(collection);
+
+-- Source health summary
+CREATE MATERIALIZED VIEW source_health AS
+SELECT
+ ds.id AS source_id,
+ ds.name,
+ ds.display_name,
+ ds.is_active,
+ COUNT(cr.run_id) AS total_runs,
+ COUNT(cr.run_id) FILTER (WHERE cr.status = 'completed') AS completed_runs,
+ COUNT(cr.run_id) FILTER (WHERE cr.status = 'failed') AS failed_runs,
+ MAX(cr.completed_at) AS last_completed,
+ MAX(cr.started_at) AS last_started,
+ AVG(cr.duration_ms)::INTEGER AS avg_duration_ms,
+ SUM(cr.records_inserted) AS total_records_inserted,
+ SUM(cr.records_quarantined) AS total_records_quarantined,
+ CASE
+ WHEN COUNT(cr.run_id) > 0 THEN
+ ROUND(COUNT(cr.run_id) FILTER (WHERE cr.status = 'completed')::NUMERIC / COUNT(cr.run_id) * 100, 1)
+ ELSE 0
+ END AS success_rate_pct,
+ NOW() AS calculated_at
+FROM data_source ds
+LEFT JOIN collector_run cr ON cr.source_id = ds.id
+GROUP BY ds.id, ds.name, ds.display_name, ds.is_active;
+
+CREATE UNIQUE INDEX idx_sh_source ON source_health(source_id);
+
+-- Refresh function
+CREATE OR REPLACE FUNCTION refresh_materialized_views() RETURNS void AS $$
+BEGIN
+ REFRESH MATERIALIZED VIEW CONCURRENTLY ref_price_summary;
+ REFRESH MATERIALIZED VIEW CONCURRENTLY source_health;
+END;
+$$ LANGUAGE plpgsql;
+
+-- ============================================================================
+-- GRANTS
+-- ============================================================================
+
+GRANT ALL ON ALL TABLES IN SCHEMA public TO omega_admin;
+GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO omega_admin;
+GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO omega_admin;
+
+-- Done
+SELECT 'Omega Watches 2.0 schema created successfully' AS status;
diff --git a/database/seed-additional-references.sql b/database/seed-additional-references.sql
new file mode 100644
index 0000000..39126cf
--- /dev/null
+++ b/database/seed-additional-references.sql
@@ -0,0 +1,186 @@
+-- Additional Reference Seed Data for Omega Watches 2.0
+-- Adds more watch references, MSRP snapshots, and market events
+
+-- ============================================
+-- ADDITIONAL WATCH REFERENCES
+-- ============================================
+
+-- More Speedmaster references
+INSERT INTO watch_reference (brand, collection, model_name, reference_number, calibre, case_material, case_diameter_mm, dial_color, year_introduced, notes) VALUES
+ ('Omega','Speedmaster','Dark Side of the Moon','311.92.44.51.01.003','9300','Ceramic','44.25','Black',2013,'Ceramic case, Co-Axial chronograph'),
+ ('Omega','Speedmaster','Silver Snoopy Award 50th','310.32.42.50.02.001','3861','Steel','42.00','Silver/Blue',2020,'Snoopy caseback animation'),
+ ('Omega','Speedmaster','First Omega in Space','311.32.40.30.01.001','1861','Steel','39.70','Black',2012,'Numbered edition, CK2998 tribute'),
+ ('Omega','Speedmaster','57 Co-Axial Chronograph','332.10.41.51.01.001','9906','Steel','40.50','Black',2022,'New 57 line, Master Chronometer'),
+ ('Omega','Speedmaster','Calibre 321 Ed White','311.30.40.30.01.001','321','Steel','39.70','Black',2019,'Historic Cal 321 reissue')
+ON CONFLICT (brand, reference_number) DO NOTHING;
+
+-- More Seamaster references
+INSERT INTO watch_reference (brand, collection, model_name, reference_number, calibre, case_material, case_diameter_mm, dial_color, year_introduced, notes) VALUES
+ ('Omega','Seamaster','Planet Ocean 600M','215.30.44.21.01.001','8900','Steel','43.50','Black',2016,'600m WR, ceramic bezel'),
+ ('Omega','Seamaster','Aqua Terra 150M','220.10.41.21.03.001','8900','Steel','41.00','Blue',2017,'Teak pattern dial'),
+ ('Omega','Seamaster','Aqua Terra 150M Green','220.10.41.21.10.001','8900','Steel','41.00','Green',2021,'Green teak dial'),
+ ('Omega','Seamaster','Diver 300M No Time To Die','210.90.42.20.01.001','8806','Titanium','42.00','Brown/Tropical',2019,'007 Edition mesh bracelet'),
+ ('Omega','Seamaster','Ultra Deep 6000M','215.30.46.21.01.001','8912','Steel','45.50','Black',2022,'6000m WR world record'),
+ ('Omega','Seamaster','Railmaster','220.10.40.20.01.001','8806','Steel','40.00','Black',2017,'Anti-magnetic heritage')
+ON CONFLICT (brand, reference_number) DO NOTHING;
+
+-- More De Ville references
+INSERT INTO watch_reference (brand, collection, model_name, reference_number, calibre, case_material, case_diameter_mm, dial_color, year_introduced, notes) VALUES
+ ('Omega','De Ville','Tresor Power Reserve','435.13.40.22.02.001','8929','Steel','40.00','Silver',2020,'Manual wind, power reserve'),
+ ('Omega','De Ville','Prestige 39.5mm','424.10.40.20.02.003','2500','Steel','39.50','Silver',2015,'Entry level automatic'),
+ ('Omega','De Ville','Tourbillon Numbered Edition','529.53.43.22.01.001','2635','Gold','43.00','Black',2016,'Central tourbillon, limited')
+ON CONFLICT (brand, reference_number) DO NOTHING;
+
+-- Constellation additions
+INSERT INTO watch_reference (brand, collection, model_name, reference_number, calibre, case_material, case_diameter_mm, dial_color, year_introduced, notes) VALUES
+ ('Omega','Constellation','Globemaster Annual Calendar','130.33.41.22.06.001','8922','Steel','41.00','Grey',2019,'Pie-pan dial, annual calendar'),
+ ('Omega','Constellation','Co-Axial Master Chronometer 36mm','131.10.36.20.02.001','8800','Steel','36.00','Silver',2020,'36mm unisex, silver dial'),
+ ('Omega','Constellation','Meteorite','131.10.39.20.99.001','8800','Steel','39.00','Meteorite',2021,'Genuine meteorite dial')
+ON CONFLICT (brand, reference_number) DO NOTHING;
+
+-- ============================================
+-- ADDITIONAL MSRP SNAPSHOTS
+-- ============================================
+
+-- Snoopy (one of the most popular)
+INSERT INTO msrp_snapshot (reference_id, region_code, currency, msrp_amount, captured_date, source_url, parser_version)
+SELECT wr.id, 'US', 'USD', 13100, '2024-01-15', 'https://www.omegawatches.com', '1.0.0'
+FROM watch_reference wr WHERE wr.reference_number = '310.32.42.50.02.001'
+ON CONFLICT (reference_id, region_code, captured_date) DO NOTHING;
+
+INSERT INTO msrp_snapshot (reference_id, region_code, currency, msrp_amount, captured_date, source_url, parser_version)
+SELECT wr.id, 'US', 'USD', 13600, '2025-01-15', 'https://www.omegawatches.com', '1.0.0'
+FROM watch_reference wr WHERE wr.reference_number = '310.32.42.50.02.001'
+ON CONFLICT (reference_id, region_code, captured_date) DO NOTHING;
+
+INSERT INTO msrp_snapshot (reference_id, region_code, currency, msrp_amount, captured_date, source_url, parser_version)
+SELECT wr.id, 'US', 'USD', 14100, '2026-01-15', 'https://www.omegawatches.com', '1.0.0'
+FROM watch_reference wr WHERE wr.reference_number = '310.32.42.50.02.001'
+ON CONFLICT (reference_id, region_code, captured_date) DO NOTHING;
+
+-- Dark Side of the Moon
+INSERT INTO msrp_snapshot (reference_id, region_code, currency, msrp_amount, captured_date, source_url, parser_version)
+SELECT wr.id, 'US', 'USD', 12100, '2024-06-01', 'https://www.omegawatches.com', '1.0.0'
+FROM watch_reference wr WHERE wr.reference_number = '311.92.44.51.01.003'
+ON CONFLICT (reference_id, region_code, captured_date) DO NOTHING;
+
+INSERT INTO msrp_snapshot (reference_id, region_code, currency, msrp_amount, captured_date, source_url, parser_version)
+SELECT wr.id, 'US', 'USD', 12600, '2025-06-01', 'https://www.omegawatches.com', '1.0.0'
+FROM watch_reference wr WHERE wr.reference_number = '311.92.44.51.01.003'
+ON CONFLICT (reference_id, region_code, captured_date) DO NOTHING;
+
+-- Planet Ocean 600M
+INSERT INTO msrp_snapshot (reference_id, region_code, currency, msrp_amount, captured_date, source_url, parser_version)
+SELECT wr.id, 'US', 'USD', 7350, '2024-03-01', 'https://www.omegawatches.com', '1.0.0'
+FROM watch_reference wr WHERE wr.reference_number = '215.30.44.21.01.001'
+ON CONFLICT (reference_id, region_code, captured_date) DO NOTHING;
+
+INSERT INTO msrp_snapshot (reference_id, region_code, currency, msrp_amount, captured_date, source_url, parser_version)
+SELECT wr.id, 'US', 'USD', 7600, '2025-03-01', 'https://www.omegawatches.com', '1.0.0'
+FROM watch_reference wr WHERE wr.reference_number = '215.30.44.21.01.001'
+ON CONFLICT (reference_id, region_code, captured_date) DO NOTHING;
+
+-- Aqua Terra Blue
+INSERT INTO msrp_snapshot (reference_id, region_code, currency, msrp_amount, captured_date, source_url, parser_version)
+SELECT wr.id, 'US', 'USD', 6150, '2024-01-01', 'https://www.omegawatches.com', '1.0.0'
+FROM watch_reference wr WHERE wr.reference_number = '220.10.41.21.03.001'
+ON CONFLICT (reference_id, region_code, captured_date) DO NOTHING;
+
+INSERT INTO msrp_snapshot (reference_id, region_code, currency, msrp_amount, captured_date, source_url, parser_version)
+SELECT wr.id, 'US', 'USD', 6400, '2025-01-01', 'https://www.omegawatches.com', '1.0.0'
+FROM watch_reference wr WHERE wr.reference_number = '220.10.41.21.03.001'
+ON CONFLICT (reference_id, region_code, captured_date) DO NOTHING;
+
+-- No Time To Die 007
+INSERT INTO msrp_snapshot (reference_id, region_code, currency, msrp_amount, captured_date, source_url, parser_version)
+SELECT wr.id, 'US', 'USD', 9200, '2024-01-01', 'https://www.omegawatches.com', '1.0.0'
+FROM watch_reference wr WHERE wr.reference_number = '210.90.42.20.01.001'
+ON CONFLICT (reference_id, region_code, captured_date) DO NOTHING;
+
+INSERT INTO msrp_snapshot (reference_id, region_code, currency, msrp_amount, captured_date, source_url, parser_version)
+SELECT wr.id, 'US', 'USD', 9650, '2025-06-01', 'https://www.omegawatches.com', '1.0.0'
+FROM watch_reference wr WHERE wr.reference_number = '210.90.42.20.01.001'
+ON CONFLICT (reference_id, region_code, captured_date) DO NOTHING;
+
+-- Cal 321 Ed White
+INSERT INTO msrp_snapshot (reference_id, region_code, currency, msrp_amount, captured_date, source_url, parser_version)
+SELECT wr.id, 'US', 'USD', 14100, '2024-06-01', 'https://www.omegawatches.com', '1.0.0'
+FROM watch_reference wr WHERE wr.reference_number = '311.30.40.30.01.001'
+ON CONFLICT (reference_id, region_code, captured_date) DO NOTHING;
+
+-- ============================================
+-- ADDITIONAL MARKET EVENTS
+-- ============================================
+
+-- Snoopy trading above retail (hot watch)
+INSERT INTO market_event (source_name, source_listing_id, event_type, reference_number_raw, sale_date, price_amount, currency, total_to_buyer, fee_semantics, condition_raw, condition_normalized, seller_type, source_url, parser_version)
+SELECT 'chrono24', 'c24-snp-001', 'marketplace_sold', '310.32.42.50.02.001', '2024-08-15', 28500, 'USD', 28500, 'total_to_buyer', 'Unworn', 'new_unworn', 'dealer', 'https://www.chrono24.com', '1.0.0'
+WHERE NOT EXISTS (SELECT 1 FROM market_event WHERE source_listing_id = 'c24-snp-001');
+
+INSERT INTO market_event (source_name, source_listing_id, event_type, reference_number_raw, sale_date, price_amount, currency, total_to_buyer, fee_semantics, condition_raw, condition_normalized, seller_type, source_url, parser_version)
+SELECT 'chrono24', 'c24-snp-002', 'dealer_ask', '310.32.42.50.02.001', '2025-01-10', 32000, 'USD', 32000, 'total_to_buyer', 'Unworn', 'new_unworn', 'dealer', 'https://www.chrono24.com', '1.0.0'
+WHERE NOT EXISTS (SELECT 1 FROM market_event WHERE source_listing_id = 'c24-snp-002');
+
+INSERT INTO market_event (source_name, source_listing_id, event_type, reference_number_raw, sale_date, price_amount, currency, total_to_buyer, fee_semantics, condition_raw, condition_normalized, seller_type, source_url, parser_version)
+SELECT 'chrono24', 'c24-snp-003', 'marketplace_sold', '310.32.42.50.02.001', '2025-06-20', 30000, 'USD', 30000, 'total_to_buyer', 'Very Good', 'very_good', 'private', 'https://www.chrono24.com', '1.0.0'
+WHERE NOT EXISTS (SELECT 1 FROM market_event WHERE source_listing_id = 'c24-snp-003');
+
+INSERT INTO market_event (source_name, source_listing_id, event_type, reference_number_raw, sale_date, price_amount, currency, total_to_buyer, fee_semantics, condition_raw, condition_normalized, seller_type, source_url, parser_version)
+SELECT 'chrono24', 'c24-snp-004', 'marketplace_sold', '310.32.42.50.02.001', '2026-01-05', 27500, 'USD', 27500, 'total_to_buyer', 'Very Good', 'very_good', 'dealer', 'https://www.chrono24.com', '1.0.0'
+WHERE NOT EXISTS (SELECT 1 FROM market_event WHERE source_listing_id = 'c24-snp-004');
+
+-- DSOTM ceramic
+INSERT INTO market_event (source_name, source_listing_id, event_type, reference_number_raw, sale_date, price_amount, currency, total_to_buyer, fee_semantics, condition_raw, condition_normalized, seller_type, source_url, parser_version)
+SELECT 'chrono24', 'c24-dsotm-001', 'marketplace_sold', '311.92.44.51.01.003', '2024-10-05', 9800, 'USD', 9800, 'total_to_buyer', 'Very Good', 'very_good', 'dealer', 'https://www.chrono24.com', '1.0.0'
+WHERE NOT EXISTS (SELECT 1 FROM market_event WHERE source_listing_id = 'c24-dsotm-001');
+
+INSERT INTO market_event (source_name, source_listing_id, event_type, reference_number_raw, sale_date, price_amount, currency, total_to_buyer, fee_semantics, condition_raw, condition_normalized, seller_type, source_url, parser_version)
+SELECT 'chrono24', 'c24-dsotm-002', 'dealer_ask', '311.92.44.51.01.003', '2025-02-20', 10500, 'USD', 10500, 'total_to_buyer', 'Unworn', 'new_unworn', 'dealer', 'https://www.chrono24.com', '1.0.0'
+WHERE NOT EXISTS (SELECT 1 FROM market_event WHERE source_listing_id = 'c24-dsotm-002');
+
+-- Cal 321 Ed White at auction
+INSERT INTO market_event (source_name, source_listing_id, event_type, reference_number_raw, sale_date, price_amount, currency, hammer_price, buyers_premium, total_to_buyer, fee_semantics, condition_raw, condition_normalized, seller_type, source_url, parser_version)
+SELECT 'phillips', 'ph-321-001', 'auction_realized', '311.30.40.30.01.001', '2024-11-10', 18000, 'USD', 15000, 3000, 18000, 'price_realised', 'Mint', 'excellent', 'auction_house', 'https://www.phillips.com', '1.0.0'
+WHERE NOT EXISTS (SELECT 1 FROM market_event WHERE source_listing_id = 'ph-321-001');
+
+INSERT INTO market_event (source_name, source_listing_id, event_type, reference_number_raw, sale_date, price_amount, currency, hammer_price, buyers_premium, total_to_buyer, fee_semantics, condition_raw, condition_normalized, seller_type, source_url, parser_version)
+SELECT 'christies', 'chr-321-001', 'auction_realized', '311.30.40.30.01.001', '2025-05-18', 16500, 'USD', 13200, 3300, 16500, 'price_realised', 'Excellent', 'excellent', 'auction_house', 'https://www.christies.com', '1.0.0'
+WHERE NOT EXISTS (SELECT 1 FROM market_event WHERE source_listing_id = 'chr-321-001');
+
+-- No Time To Die 007 — hot model
+INSERT INTO market_event (source_name, source_listing_id, event_type, reference_number_raw, sale_date, price_amount, currency, total_to_buyer, fee_semantics, condition_raw, condition_normalized, seller_type, source_url, parser_version)
+SELECT 'chrono24', 'c24-nttd-001', 'marketplace_sold', '210.90.42.20.01.001', '2024-07-20', 8200, 'USD', 8200, 'total_to_buyer', 'Very Good', 'very_good', 'dealer', 'https://www.chrono24.com', '1.0.0'
+WHERE NOT EXISTS (SELECT 1 FROM market_event WHERE source_listing_id = 'c24-nttd-001');
+
+INSERT INTO market_event (source_name, source_listing_id, event_type, reference_number_raw, sale_date, price_amount, currency, total_to_buyer, fee_semantics, condition_raw, condition_normalized, seller_type, source_url, parser_version)
+SELECT 'chrono24', 'c24-nttd-002', 'dealer_ask', '210.90.42.20.01.001', '2025-03-15', 8900, 'USD', 8900, 'total_to_buyer', 'Unworn', 'new_unworn', 'dealer', 'https://www.chrono24.com', '1.0.0'
+WHERE NOT EXISTS (SELECT 1 FROM market_event WHERE source_listing_id = 'c24-nttd-002');
+
+-- Planet Ocean
+INSERT INTO market_event (source_name, source_listing_id, event_type, reference_number_raw, sale_date, price_amount, currency, total_to_buyer, fee_semantics, condition_raw, condition_normalized, seller_type, source_url, parser_version)
+SELECT 'chrono24', 'c24-po-001', 'marketplace_sold', '215.30.44.21.01.001', '2024-11-05', 5800, 'USD', 5800, 'total_to_buyer', 'Good', 'good', 'private', 'https://www.chrono24.com', '1.0.0'
+WHERE NOT EXISTS (SELECT 1 FROM market_event WHERE source_listing_id = 'c24-po-001');
+
+INSERT INTO market_event (source_name, source_listing_id, event_type, reference_number_raw, sale_date, price_amount, currency, total_to_buyer, fee_semantics, condition_raw, condition_normalized, seller_type, source_url, parser_version)
+SELECT 'chrono24', 'c24-po-002', 'dealer_ask', '215.30.44.21.01.001', '2025-09-01', 6100, 'USD', 6100, 'total_to_buyer', 'Unworn', 'new_unworn', 'dealer', 'https://www.chrono24.com', '1.0.0'
+WHERE NOT EXISTS (SELECT 1 FROM market_event WHERE source_listing_id = 'c24-po-002');
+
+-- Aqua Terra Blue
+INSERT INTO market_event (source_name, source_listing_id, event_type, reference_number_raw, sale_date, price_amount, currency, total_to_buyer, fee_semantics, condition_raw, condition_normalized, seller_type, source_url, parser_version)
+SELECT 'chrono24', 'c24-at-001', 'marketplace_sold', '220.10.41.21.03.001', '2024-05-10', 4800, 'USD', 4800, 'total_to_buyer', 'Very Good', 'very_good', 'private', 'https://www.chrono24.com', '1.0.0'
+WHERE NOT EXISTS (SELECT 1 FROM market_event WHERE source_listing_id = 'c24-at-001');
+
+INSERT INTO market_event (source_name, source_listing_id, event_type, reference_number_raw, sale_date, price_amount, currency, total_to_buyer, fee_semantics, condition_raw, condition_normalized, seller_type, source_url, parser_version)
+SELECT 'chrono24', 'c24-at-002', 'dealer_ask', '220.10.41.21.03.001', '2025-08-20', 5200, 'USD', 5200, 'total_to_buyer', 'Unworn', 'new_unworn', 'dealer', 'https://www.chrono24.com', '1.0.0'
+WHERE NOT EXISTS (SELECT 1 FROM market_event WHERE source_listing_id = 'c24-at-002');
+
+-- First Omega in Space — vintage appeal
+INSERT INTO market_event (source_name, source_listing_id, event_type, reference_number_raw, sale_date, price_amount, currency, hammer_price, buyers_premium, total_to_buyer, fee_semantics, condition_raw, condition_normalized, seller_type, source_url, parser_version)
+SELECT 'antiquorum', 'antiq-fois-001', 'auction_realized', '311.32.40.30.01.001', '2024-05-20', 6200, 'CHF', 4960, 1240, 6200, 'price_realised', 'Very Good', 'very_good', 'auction_house', 'https://www.antiquorum.swiss', '1.0.0'
+WHERE NOT EXISTS (SELECT 1 FROM market_event WHERE source_listing_id = 'antiq-fois-001');
+
+-- ============================================
+-- REFRESH MATERIALIZED VIEWS
+-- ============================================
+REFRESH MATERIALIZED VIEW ref_price_summary;
+REFRESH MATERIALIZED VIEW source_health;
diff --git a/database/seed-references.sql b/database/seed-references.sql
new file mode 100644
index 0000000..a0ad35c
--- /dev/null
+++ b/database/seed-references.sql
@@ -0,0 +1,277 @@
+-- Omega Watches 2.0 - Seed Reference Data
+-- Real Omega model references with actual MSRP data
+
+BEGIN;
+
+-- ============================================================================
+-- DATA SOURCES - 13 sources for the dual-ledger system
+-- ============================================================================
+
+INSERT INTO data_source (name, display_name, source_type, access_method, base_url, robots_txt_compliant, tos_reviewed, reliability_score, coverage_years_from, coverage_years_to, rate_limit_rpm, notes)
+VALUES
+ ('sothebys', 'Sotheby''s', 'auction_house', 'scrape', 'https://www.sothebys.com', true, true, 5, 1990, 2026, 10, 'Major auction house, lot results'),
+ ('christies', 'Christie''s', 'auction_house', 'scrape', 'https://www.christies.com', true, true, 5, 1990, 2026, 10, 'Major auction house, lot results'),
+ ('phillips', 'Phillips', 'auction_house', 'scrape', 'https://www.phillips.com', true, true, 5, 2000, 2026, 10, 'Specialty watch auction house'),
+ ('antiquorum', 'Antiquorum', 'auction_house', 'scrape', 'https://www.antiquorum.swiss', true, true, 4, 1995, 2026, 10, 'Swiss auction house specializing in watches'),
+ ('bonhams', 'Bonhams', 'auction_house', 'scrape', 'https://www.bonhams.com', true, true, 4, 2000, 2026, 10, 'International auction house'),
+ ('chrono24', 'Chrono24', 'marketplace', 'api', 'https://www.chrono24.com', true, true, 4, 2010, 2026, 30, 'Largest watch marketplace, dealer and private listings'),
+ ('ebay', 'eBay', 'marketplace', 'api', 'https://www.ebay.com', true, true, 3, 2000, 2026, 20, 'General marketplace with watch category'),
+ ('the1916company', 'The 1916 Company', 'dealer', 'scrape', 'https://www.the1916company.com', true, false, 3, 2020, 2026, 5, 'Pre-owned watch dealer'),
+ ('hodinkee', 'Hodinkee Shop', 'dealer', 'scrape', 'https://shop.hodinkee.com', true, true, 4, 2018, 2026, 5, 'Curated pre-owned dealer'),
+ ('watchuseek', 'WatchUSeek Forums', 'forum', 'scrape', 'https://www.watchuseek.com', true, false, 2, 2005, 2026, 5, 'Watch enthusiast forum, sales corner'),
+ ('omega_forums', 'Omega Forums', 'forum', 'scrape', 'https://omegaforums.net', true, false, 3, 2010, 2026, 5, 'Dedicated Omega collector forum'),
+ ('watchcharts', 'WatchCharts', 'price_guide', 'api', 'https://watchcharts.com', true, true, 4, 2020, 2026, 10, 'Aggregated market value data'),
+ ('omega_official', 'Omega Official', 'manufacturer', 'scrape', 'https://www.omegawatches.com', true, true, 5, 2020, 2026, 5, 'Official Omega website, MSRP source')
+ON CONFLICT (name) DO NOTHING;
+
+-- ============================================================================
+-- WATCH REFERENCES - Core Omega collections
+-- ============================================================================
+
+INSERT INTO watch_reference (brand, collection, model_name, reference_number, calibre, case_material, case_diameter_mm, dial_color, year_introduced, notes) VALUES
+-- SPEEDMASTER
+('Omega', 'Speedmaster', 'Moonwatch Professional 42mm', '310.30.42.50.01.001', '3861', 'Steel', 42.0, 'Black', 2021, 'Hesalite crystal, steel bracelet'),
+('Omega', 'Speedmaster', 'Moonwatch Professional Sapphire', '310.30.42.50.01.002', '3861', 'Steel', 42.0, 'Black', 2021, 'Sapphire crystal, steel bracelet'),
+('Omega', 'Speedmaster', 'Moonwatch Professional Leather', '310.32.42.50.01.001', '3861', 'Steel', 42.0, 'Black', 2021, 'Hesalite crystal, leather strap'),
+('Omega', 'Speedmaster', 'Moonwatch Professional Gold', '310.60.42.50.01.001', '3861', 'Canopus Gold', 42.0, 'Black', 2021, 'Canopus gold case and bracelet'),
+('Omega', 'Speedmaster', '57 Co-Axial Master Chronometer', '332.10.41.51.01.001', '9906', 'Steel', 40.5, 'Black', 2022, 'Steel bracelet, 50m WR'),
+('Omega', 'Speedmaster', 'Super Racing', '329.30.44.51.01.003', '9920', 'Steel', 44.25, 'Black', 2023, 'Spirate system, steel bracelet'),
+('Omega', 'Speedmaster', 'Pre-Moon Cal. 321 (1967)', '105.012', '321', 'Steel', 42.0, 'Black', 1967, 'Pre-Moon vintage reference'),
+('Omega', 'Speedmaster', 'Professional Moonwatch (Pre-2021)', '3570.50.00', '1861', 'Steel', 42.0, 'Black', 1997, 'Classic Moonwatch pre-3861 upgrade'),
+
+-- SEAMASTER
+('Omega', 'Seamaster', 'Diver 300M Co-Axial Black', '210.30.42.20.01.001', '8800', 'Steel', 42.0, 'Black', 2018, 'Steel bracelet, 300m WR'),
+('Omega', 'Seamaster', 'Diver 300M Co-Axial Blue', '210.30.42.20.03.001', '8800', 'Steel', 42.0, 'Blue', 2018, 'Steel bracelet, 300m WR'),
+('Omega', 'Seamaster', 'Diver 300M Co-Axial Grey', '210.30.42.20.06.001', '8800', 'Steel', 42.0, 'Grey', 2018, 'Steel bracelet, 300m WR'),
+('Omega', 'Seamaster', 'Diver 300M James Bond 60th', '210.22.42.20.01.004', '8806', 'Steel/Gold', 42.0, 'Tropical Brown', 2022, 'Limited edition, NATO strap'),
+('Omega', 'Seamaster', 'Planet Ocean 600M Co-Axial 43.5mm', '215.30.44.21.01.001', '8900', 'Steel', 43.5, 'Black', 2016, 'Steel bracelet, 600m WR'),
+('Omega', 'Seamaster', 'Aqua Terra 150M Co-Axial 41mm Black', '220.10.41.21.01.001', '8900', 'Steel', 41.0, 'Black', 2017, 'Steel bracelet, 150m WR'),
+('Omega', 'Seamaster', 'Aqua Terra 150M Co-Axial 41mm Blue', '220.10.41.21.03.004', '8900', 'Steel', 41.0, 'Blue', 2022, 'Steel bracelet, 150m WR'),
+('Omega', 'Seamaster', 'Ultra Deep 6000M', '215.30.46.21.01.001', '8912', 'Steel', 45.5, 'Black', 2022, 'Steel bracelet, 6000m WR'),
+('Omega', 'Seamaster', '300 Master Chronometer', '234.30.41.21.01.001', '8912', 'Steel', 41.0, 'Black', 2021, 'Steel bracelet, 300m WR'),
+
+-- CONSTELLATION
+('Omega', 'Constellation', 'Co-Axial Master Chronometer 41mm Black', '131.10.41.21.01.001', '8900', 'Steel', 41.0, 'Black', 2020, 'Steel bracelet, 50m WR'),
+('Omega', 'Constellation', 'Co-Axial Master Chronometer 41mm Blue', '131.10.41.21.03.001', '8900', 'Steel', 41.0, 'Blue', 2020, 'Steel bracelet, 50m WR'),
+('Omega', 'Constellation', 'Co-Axial Master Chronometer 39mm Silver', '131.10.39.20.02.001', '8800', 'Steel', 39.0, 'Silver', 2020, 'Steel bracelet, 50m WR'),
+
+-- DE VILLE
+('Omega', 'De Ville', 'Prestige Co-Axial 39.5mm', '424.10.40.20.02.003', '2500', 'Steel', 39.5, 'Silver', 2015, 'Steel bracelet, 30m WR'),
+('Omega', 'De Ville', 'Tresor Co-Axial 40mm', '435.13.40.21.02.001', '8910', 'Steel', 40.0, 'Silver', 2020, 'Leather strap, 30m WR'),
+('Omega', 'De Ville', 'Hour Vision Blue', '433.33.41.21.03.001', '8900', 'Steel', 41.0, 'Blue', 2017, 'Leather strap, 30m WR'),
+
+-- VINTAGE / COLLECTIBLE
+('Omega', 'Seamaster', '300 Vintage (1967)', '165.024', '552', 'Steel', 39.0, 'Black', 1967, 'Vintage dive watch'),
+('Omega', 'Constellation', 'Pie Pan (1961)', '14381-61', '551', 'Steel', 34.0, 'Silver', 1961, 'Iconic pie-pan dial')
+ON CONFLICT DO NOTHING;
+
+-- ============================================================================
+-- MSRP SNAPSHOTS - Current production MSRPs (US region)
+-- ============================================================================
+
+WITH refs AS (
+ SELECT id, reference_number FROM watch_reference
+ WHERE reference_number IN (
+ '310.30.42.50.01.001','310.30.42.50.01.002','310.32.42.50.01.001','310.60.42.50.01.001',
+ '332.10.41.51.01.001','329.30.44.51.01.003',
+ '210.30.42.20.01.001','210.30.42.20.03.001','210.30.42.20.06.001',
+ '215.30.44.21.01.001','220.10.41.21.01.001','220.10.41.21.03.004',
+ '215.30.46.21.01.001','234.30.41.21.01.001',
+ '131.10.41.21.01.001','131.10.41.21.03.001','131.10.39.20.02.001',
+ '424.10.40.20.02.003','435.13.40.21.02.001','433.33.41.21.03.001',
+ '210.22.42.20.01.004'
+ )
+),
+msrp_map(ref_num, base_price) AS (VALUES
+ ('310.30.42.50.01.001', 6550),
+ ('310.30.42.50.01.002', 7100),
+ ('310.32.42.50.01.001', 6350),
+ ('310.60.42.50.01.001', 34600),
+ ('332.10.41.51.01.001', 9100),
+ ('329.30.44.51.01.003', 10900),
+ ('210.30.42.20.01.001', 5400),
+ ('210.30.42.20.03.001', 5400),
+ ('210.30.42.20.06.001', 5400),
+ ('215.30.44.21.01.001', 6600),
+ ('220.10.41.21.01.001', 5500),
+ ('220.10.41.21.03.004', 5700),
+ ('215.30.46.21.01.001', 11600),
+ ('234.30.41.21.01.001', 6500),
+ ('131.10.41.21.01.001', 5300),
+ ('131.10.41.21.03.001', 5300),
+ ('131.10.39.20.02.001', 5100),
+ ('424.10.40.20.02.003', 3200),
+ ('435.13.40.21.02.001', 5100),
+ ('433.33.41.21.03.001', 6800),
+ ('210.22.42.20.01.004', 7500)
+),
+dates(snap_date) AS (VALUES
+ ('2024-01-15'::date), ('2024-06-15'), ('2025-01-15'), ('2025-06-15'), ('2026-01-15')
+)
+INSERT INTO msrp_snapshot (reference_id, region_code, currency, msrp_amount, captured_date, source_url, parser_version)
+SELECT r.id, 'US', 'USD',
+ m.base_price + FLOOR((d.snap_date - '2024-01-15'::date)::numeric / 365 * 300),
+ d.snap_date,
+ 'https://www.omegawatches.com/watch-omega-' || REPLACE(r.reference_number, '.', '-'),
+ 'seed-v1.0'
+FROM refs r
+JOIN msrp_map m ON m.ref_num = r.reference_number
+CROSS JOIN dates d
+ON CONFLICT DO NOTHING;
+
+-- ============================================================================
+-- MARKET EVENTS - Secondary market transactions
+-- ============================================================================
+
+-- Speedmaster 310.30.42.50.01.001 market events
+INSERT INTO market_event (
+ source_name, source_listing_id, event_type, reference_id, reference_number_raw,
+ condition_raw, condition_normalized, sale_date, seller_type,
+ currency, hammer_price, buyers_premium, total_to_buyer, price_amount, price_usd,
+ fee_semantics, source_url, parser_version
+)
+SELECT
+ ev.sn, ev.slid, ev.et::event_type_enum, wr.id, wr.reference_number,
+ ev.cr, ev.cn::condition_grade_enum, ev.sd, ev.st::seller_type_enum,
+ 'USD', ev.hp, ev.bp, ev.ttb, ev.ttb, ev.ttb,
+ ev.fs::fee_semantics_enum, ev.url, 'seed-v1.0'
+FROM watch_reference wr
+CROSS JOIN LATERAL (VALUES
+ ('sothebys','SOT-2024-156','auction_realized','2024-03-15'::date,5800.00,1450.00,7250.00,'Very Good','very_good','auction_house','total_to_buyer','https://sothebys.com/lot/156'),
+ ('christies','CHR-2024-892','auction_realized','2024-05-22',6200.00,1550.00,7750.00,'Excellent','excellent','auction_house','total_to_buyer','https://christies.com/lot/892'),
+ ('phillips','PHI-2024-445','auction_realized','2024-08-10',6800.00,1700.00,8500.00,'Mint / Like New','new_unworn','auction_house','total_to_buyer','https://phillips.com/lot/445'),
+ ('antiquorum','ANT-2024-201','auction_realized','2024-11-05',5500.00,1375.00,6875.00,'Good','good','auction_house','total_to_buyer','https://antiquorum.com/lot/201'),
+ ('bonhams','BON-2025-078','auction_realized','2025-02-14',6100.00,1525.00,7625.00,'Very Good','very_good','auction_house','total_to_buyer','https://bonhams.com/lot/078'),
+ ('chrono24','C24-SPM-12345','marketplace_sold','2024-04-20',5950.00,0,5950.00,'Very good','very_good','dealer','hammer_only','https://chrono24.com/omega/id12345'),
+ ('chrono24','C24-SPM-23456','marketplace_sold','2024-07-12',6300.00,0,6300.00,'Unworn','new_unworn','dealer','hammer_only','https://chrono24.com/omega/id23456'),
+ ('chrono24','C24-SPM-34567','marketplace_sold','2024-10-08',5700.00,0,5700.00,'Good','good','private','hammer_only','https://chrono24.com/omega/id34567'),
+ ('chrono24','C24-SPM-45678','dealer_ask','2025-01-15',6400.00,0,6400.00,'New','new_unworn','dealer','hammer_only','https://chrono24.com/omega/id45678'),
+ ('ebay','EBAY-SPM-123456','marketplace_sold','2024-06-05',5200.00,0,5200.00,'Pre-owned','good','private','hammer_only','https://ebay.com/itm/123456'),
+ ('ebay','EBAY-SPM-234567','marketplace_sold','2024-09-18',5600.00,0,5600.00,'Excellent','excellent','private','hammer_only','https://ebay.com/itm/234567'),
+ ('watchuseek','WUS-SPM-001','forum_listing','2024-05-10',5400.00,0,5400.00,'Excellent','excellent','private','hammer_only','https://watchuseek.com/threads/123'),
+ ('omega_forums','OF-SPM-001','forum_listing','2024-08-22',5800.00,0,5800.00,'Very Good','very_good','private','hammer_only','https://omegaforums.net/threads/456')
+) AS ev(sn,slid,et,sd,hp,bp,ttb,cr,cn,st,fs,url)
+WHERE wr.reference_number = '310.30.42.50.01.001'
+ON CONFLICT DO NOTHING;
+
+-- Seamaster 210.30.42.20.01.001 market events
+INSERT INTO market_event (
+ source_name, source_listing_id, event_type, reference_id, reference_number_raw,
+ condition_raw, condition_normalized, sale_date, seller_type,
+ currency, hammer_price, buyers_premium, total_to_buyer, price_amount, price_usd,
+ fee_semantics, source_url, parser_version
+)
+SELECT
+ ev.sn, ev.slid, ev.et::event_type_enum, wr.id, wr.reference_number,
+ ev.cr, ev.cn::condition_grade_enum, ev.sd, ev.st::seller_type_enum,
+ 'USD', ev.hp, ev.bp, ev.ttb, ev.ttb, ev.ttb,
+ ev.fs::fee_semantics_enum, ev.url, 'seed-v1.0'
+FROM watch_reference wr
+CROSS JOIN LATERAL (VALUES
+ ('sothebys','SOT-SM-2024-330','auction_realized','2024-04-20'::date,3800.00,950.00,4750.00,'Excellent','excellent','auction_house','total_to_buyer','https://sothebys.com/lot/330'),
+ ('christies','CHR-SM-2024-557','auction_realized','2024-09-12',4100.00,1025.00,5125.00,'Mint','new_unworn','auction_house','total_to_buyer','https://christies.com/lot/557'),
+ ('chrono24','C24-SM-001','marketplace_sold','2024-03-08',4200.00,0,4200.00,'Very good','very_good','dealer','hammer_only','https://chrono24.com/omega/sm001'),
+ ('chrono24','C24-SM-002','marketplace_sold','2024-06-22',3900.00,0,3900.00,'Good','good','private','hammer_only','https://chrono24.com/omega/sm002'),
+ ('chrono24','C24-SM-003','dealer_ask','2025-01-05',4500.00,0,4500.00,'Unworn','new_unworn','dealer','hammer_only','https://chrono24.com/omega/sm003'),
+ ('ebay','EBAY-SM-001','marketplace_sold','2024-07-30',3600.00,0,3600.00,'Pre-owned','good','private','hammer_only','https://ebay.com/itm/sm001'),
+ ('watchuseek','WUS-SM-001','forum_listing','2024-11-15',4000.00,0,4000.00,'Excellent','excellent','private','hammer_only','https://watchuseek.com/threads/sm001')
+) AS ev(sn,slid,et,sd,hp,bp,ttb,cr,cn,st,fs,url)
+WHERE wr.reference_number = '210.30.42.20.01.001'
+ON CONFLICT DO NOTHING;
+
+-- Vintage Speedmaster 105.012 (high value collectible)
+INSERT INTO market_event (
+ source_name, source_listing_id, event_type, reference_id, reference_number_raw,
+ condition_raw, condition_normalized, sale_date, seller_type,
+ currency, hammer_price, buyers_premium, total_to_buyer, price_amount, price_usd,
+ fee_semantics, source_url, parser_version
+)
+SELECT
+ ev.sn, ev.slid, ev.et::event_type_enum, wr.id, wr.reference_number,
+ ev.cr, ev.cn::condition_grade_enum, ev.sd, ev.st::seller_type_enum,
+ 'USD', ev.hp, ev.bp, ev.ttb, ev.ttb, ev.ttb,
+ ev.fs::fee_semantics_enum, ev.url, 'seed-v1.0'
+FROM watch_reference wr
+CROSS JOIN LATERAL (VALUES
+ ('phillips','PHI-V-2024-001','auction_realized','2024-05-12'::date,45000.00,11250.00,56250.00,'Good original patina','good','auction_house','total_to_buyer','https://phillips.com/lot/001'),
+ ('sothebys','SOT-V-2024-789','auction_realized','2024-10-18',52000.00,13000.00,65000.00,'Very Good matching numbers','very_good','auction_house','total_to_buyer','https://sothebys.com/lot/789'),
+ ('christies','CHR-V-2025-112','auction_realized','2025-01-25',48000.00,12000.00,60000.00,'Excellent full set','excellent','auction_house','total_to_buyer','https://christies.com/lot/112'),
+ ('chrono24','C24-V-001','dealer_ask','2024-08-15',58000.00,0,58000.00,'Very Good','very_good','dealer','hammer_only','https://chrono24.com/omega/v001'),
+ ('chrono24','C24-V-002','marketplace_sold','2024-12-01',51000.00,0,51000.00,'Good','good','dealer','hammer_only','https://chrono24.com/omega/v002')
+) AS ev(sn,slid,et,sd,hp,bp,ttb,cr,cn,st,fs,url)
+WHERE wr.reference_number = '105.012'
+ON CONFLICT DO NOTHING;
+
+-- Aqua Terra 220.10.41.21.01.001 market events
+INSERT INTO market_event (
+ source_name, source_listing_id, event_type, reference_id, reference_number_raw,
+ condition_raw, condition_normalized, sale_date, seller_type,
+ currency, hammer_price, buyers_premium, total_to_buyer, price_amount, price_usd,
+ fee_semantics, source_url, parser_version
+)
+SELECT
+ ev.sn, ev.slid, ev.et::event_type_enum, wr.id, wr.reference_number,
+ ev.cr, ev.cn::condition_grade_enum, ev.sd, ev.st::seller_type_enum,
+ 'USD', ev.hp, ev.bp, ev.ttb, ev.ttb, ev.ttb,
+ ev.fs::fee_semantics_enum, ev.url, 'seed-v1.0'
+FROM watch_reference wr
+CROSS JOIN LATERAL (VALUES
+ ('chrono24','C24-AT-001','marketplace_sold','2024-02-14'::date,4200.00,0,4200.00,'Very good','very_good','dealer','hammer_only','https://chrono24.com/omega/at001'),
+ ('chrono24','C24-AT-002','marketplace_sold','2024-05-30',3900.00,0,3900.00,'Good','good','private','hammer_only','https://chrono24.com/omega/at002'),
+ ('chrono24','C24-AT-003','dealer_ask','2024-11-20',4600.00,0,4600.00,'Unworn','new_unworn','dealer','hammer_only','https://chrono24.com/omega/at003'),
+ ('ebay','EBAY-AT-001','marketplace_sold','2024-08-05',3700.00,0,3700.00,'Pre-owned','good','private','hammer_only','https://ebay.com/itm/at001')
+) AS ev(sn,slid,et,sd,hp,bp,ttb,cr,cn,st,fs,url)
+WHERE wr.reference_number = '220.10.41.21.01.001'
+ON CONFLICT DO NOTHING;
+
+-- Speedmaster 57 market events
+INSERT INTO market_event (
+ source_name, source_listing_id, event_type, reference_id, reference_number_raw,
+ condition_raw, condition_normalized, sale_date, seller_type,
+ currency, hammer_price, buyers_premium, total_to_buyer, price_amount, price_usd,
+ fee_semantics, source_url, parser_version
+)
+SELECT
+ ev.sn, ev.slid, ev.et::event_type_enum, wr.id, wr.reference_number,
+ ev.cr, ev.cn::condition_grade_enum, ev.sd, ev.st::seller_type_enum,
+ 'USD', ev.hp, ev.bp, ev.ttb, ev.ttb, ev.ttb,
+ ev.fs::fee_semantics_enum, ev.url, 'seed-v1.0'
+FROM watch_reference wr
+CROSS JOIN LATERAL (VALUES
+ ('chrono24','C24-S57-001','marketplace_sold','2024-04-10'::date,7200.00,0,7200.00,'Unworn','new_unworn','dealer','hammer_only','https://chrono24.com/omega/s57001'),
+ ('chrono24','C24-S57-002','marketplace_sold','2024-08-25',6800.00,0,6800.00,'Very good','very_good','private','hammer_only','https://chrono24.com/omega/s57002'),
+ ('chrono24','C24-S57-003','dealer_ask','2025-02-01',7500.00,0,7500.00,'New','new_unworn','dealer','hammer_only','https://chrono24.com/omega/s57003'),
+ ('ebay','EBAY-S57-001','marketplace_sold','2024-06-18',6500.00,0,6500.00,'Excellent','excellent','private','hammer_only','https://ebay.com/itm/s57001')
+) AS ev(sn,slid,et,sd,hp,bp,ttb,cr,cn,st,fs,url)
+WHERE wr.reference_number = '332.10.41.51.01.001'
+ON CONFLICT DO NOTHING;
+
+-- ============================================================================
+-- COLLECTOR RUNS - Simulated successful collector runs
+-- ============================================================================
+
+INSERT INTO collector_run (source_id, job_type, status, started_at, completed_at, records_fetched, records_parsed, records_inserted, records_updated, records_quarantined, records_deduplicated, parser_version, duration_ms)
+SELECT
+ ds.id, 'full_scrape', 'completed'::job_status_enum,
+ NOW() - interval '1 day' * (n * 7),
+ NOW() - interval '1 day' * (n * 7) + interval '5 minutes',
+ FLOOR(RANDOM() * 20 + 5)::int,
+ FLOOR(RANDOM() * 18 + 5)::int,
+ FLOOR(RANDOM() * 15 + 3)::int,
+ FLOOR(RANDOM() * 3)::int,
+ FLOOR(RANDOM() * 2)::int,
+ FLOOR(RANDOM() * 2)::int,
+ 'seed-v1.0',
+ FLOOR(RANDOM() * 180000 + 60000)::int
+FROM data_source ds
+CROSS JOIN generate_series(0, 3) AS n
+WHERE ds.is_active = true;
+
+-- ============================================================================
+-- REFRESH MATERIALIZED VIEWS
+-- ============================================================================
+
+REFRESH MATERIALIZED VIEW ref_price_summary;
+REFRESH MATERIALIZED VIEW source_health;
+
+COMMIT;
diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs
new file mode 100644
index 0000000..bb06b0f
--- /dev/null
+++ b/ecosystem.config.cjs
@@ -0,0 +1,17 @@
+module.exports = {
+ apps: [{
+ name: 'omega-watches-2',
+ script: 'node_modules/.bin/next',
+ args: 'start -p 7600',
+ cwd: '/root/Projects/omega-watches-2',
+ env: {
+ PORT: 7600,
+ NODE_ENV: 'production',
+ },
+ max_memory_restart: '512M',
+ autorestart: true,
+ watch: false,
+ exec_mode: 'fork',
+ exp_backoff_restart_delay: 100,
+ }],
+};
diff --git a/next-env.d.ts b/next-env.d.ts
new file mode 100644
index 0000000..40c3d68
--- /dev/null
+++ b/next-env.d.ts
@@ -0,0 +1,5 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
diff --git a/next.config.mjs b/next.config.mjs
new file mode 100644
index 0000000..36abdd1
--- /dev/null
+++ b/next.config.mjs
@@ -0,0 +1,9 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ experimental: {
+ serverComponentsExternalPackages: ['pg', 'sharp'],
+ },
+ output: 'standalone',
+};
+
+export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..ed5a5a5
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6686 @@
+{
+ "name": "omega-watches-2",
+ "version": "2.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "omega-watches-2",
+ "version": "2.0.0",
+ "dependencies": {
+ "bcryptjs": "^2.4.3",
+ "clsx": "^2.1.0",
+ "date-fns": "^3.6.0",
+ "fuse.js": "^7.0.0",
+ "jose": "^5.2.0",
+ "lucide-react": "^0.400.0",
+ "next": "^14.2.0",
+ "pg": "^8.13.0",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0",
+ "recharts": "^2.12.0",
+ "tailwind-merge": "^2.3.0",
+ "zod": "^3.23.0"
+ },
+ "devDependencies": {
+ "@types/bcryptjs": "^2.4.6",
+ "@types/node": "^20.12.0",
+ "@types/pg": "^8.11.0",
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "autoprefixer": "^10.4.19",
+ "eslint": "^8.57.0",
+ "eslint-config-next": "^14.2.0",
+ "postcss": "^8.4.38",
+ "tailwindcss": "^3.4.0",
+ "typescript": "^5.4.0"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
+ "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.1.0",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
+ "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.6.0",
+ "globals": "^13.19.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
+ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array": {
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
+ "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
+ "deprecated": "Use @eslint/config-array instead",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanwhocodes/object-schema": "^2.0.3",
+ "debug": "^4.3.1",
+ "minimatch": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=10.10.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/object-schema": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
+ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
+ "deprecated": "Use @eslint/object-schema instead",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
+ "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.4.3",
+ "@emnapi/runtime": "^1.4.3",
+ "@tybys/wasm-util": "^0.10.0"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "14.2.35",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz",
+ "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==",
+ "license": "MIT"
+ },
+ "node_modules/@next/eslint-plugin-next": {
+ "version": "14.2.35",
+ "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.35.tgz",
+ "integrity": "sha512-Jw9A3ICz2183qSsqwi7fgq4SBPiNfmOLmTPXKvlnzstUwyvBrtySiY+8RXJweNAs9KThb1+bYhZh9XWcNOr2zQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "glob": "10.3.10"
+ }
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
+ "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
+ "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
+ "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
+ "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
+ "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
+ "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
+ "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-ia32-msvc": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
+ "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
+ "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nolyfill/is-core-module": {
+ "version": "1.0.39",
+ "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz",
+ "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.4.0"
+ }
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@rtsao/scc": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
+ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rushstack/eslint-patch": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.15.0.tgz",
+ "integrity": "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.5",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
+ "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/bcryptjs": {
+ "version": "2.4.6",
+ "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
+ "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/json5": {
+ "version": "0.0.29",
+ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
+ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.33",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
+ "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/pg": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
+ "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^2.2.0"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.28",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
+ "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.55.0",
+ "@typescript-eslint/type-utils": "8.55.0",
+ "@typescript-eslint/utils": "8.55.0",
+ "@typescript-eslint/visitor-keys": "8.55.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.55.0",
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz",
+ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.55.0",
+ "@typescript-eslint/types": "8.55.0",
+ "@typescript-eslint/typescript-estree": "8.55.0",
+ "@typescript-eslint/visitor-keys": "8.55.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz",
+ "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.55.0",
+ "@typescript-eslint/types": "^8.55.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz",
+ "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.55.0",
+ "@typescript-eslint/visitor-keys": "8.55.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz",
+ "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz",
+ "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.55.0",
+ "@typescript-eslint/typescript-estree": "8.55.0",
+ "@typescript-eslint/utils": "8.55.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz",
+ "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz",
+ "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.55.0",
+ "@typescript-eslint/tsconfig-utils": "8.55.0",
+ "@typescript-eslint/types": "8.55.0",
+ "@typescript-eslint/visitor-keys": "8.55.0",
+ "debug": "^4.4.3",
+ "minimatch": "^9.0.5",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz",
+ "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.55.0",
+ "@typescript-eslint/types": "8.55.0",
+ "@typescript-eslint/typescript-estree": "8.55.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz",
+ "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.55.0",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
+ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/@unrs/resolver-binding-android-arm-eabi": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
+ "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-android-arm64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
+ "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-darwin-arm64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
+ "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-darwin-x64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
+ "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-freebsd-x64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
+ "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
+ "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
+ "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
+ "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
+ "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
+ "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
+ "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
+ "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
+ "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-x64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz",
+ "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-x64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz",
+ "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-wasm32-wasi": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
+ "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^0.2.11"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
+ "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
+ "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-win32-x64-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
+ "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+ "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "is-array-buffer": "^3.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array-includes": {
+ "version": "3.1.9",
+ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
+ "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.0",
+ "es-object-atoms": "^1.1.1",
+ "get-intrinsic": "^1.3.0",
+ "is-string": "^1.1.1",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.findlast": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
+ "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.findlastindex": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz",
+ "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-shim-unscopables": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flat": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
+ "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flatmap": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz",
+ "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.tosorted": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz",
+ "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.3",
+ "es-errors": "^1.3.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/arraybuffer.prototype.slice": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
+ "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.1",
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "is-array-buffer": "^3.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/ast-types-flow": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
+ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/async-function": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
+ "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.24",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
+ "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1",
+ "caniuse-lite": "^1.0.30001766",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/axe-core": {
+ "version": "4.11.1",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz",
+ "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.9.19",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
+ "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
+ "node_modules/bcryptjs": {
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
+ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
+ "license": "MIT"
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+ "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.0",
+ "es-define-property": "^1.0.0",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001769",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
+ "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/damerau-levenshtein": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
+ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/data-view-buffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
+ "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/data-view-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
+ "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/inspect-js"
+ }
+ },
+ "node_modules/data-view-byte-offset": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
+ "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/date-fns": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
+ "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.286",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
+ "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-abstract": {
+ "version": "1.24.1",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
+ "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.2",
+ "arraybuffer.prototype.slice": "^1.0.4",
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "data-view-buffer": "^1.0.2",
+ "data-view-byte-length": "^1.0.2",
+ "data-view-byte-offset": "^1.0.1",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-set-tostringtag": "^2.1.0",
+ "es-to-primitive": "^1.3.0",
+ "function.prototype.name": "^1.1.8",
+ "get-intrinsic": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "get-symbol-description": "^1.1.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "internal-slot": "^1.1.0",
+ "is-array-buffer": "^3.0.5",
+ "is-callable": "^1.2.7",
+ "is-data-view": "^1.0.2",
+ "is-negative-zero": "^2.0.3",
+ "is-regex": "^1.2.1",
+ "is-set": "^2.0.3",
+ "is-shared-array-buffer": "^1.0.4",
+ "is-string": "^1.1.1",
+ "is-typed-array": "^1.1.15",
+ "is-weakref": "^1.1.1",
+ "math-intrinsics": "^1.1.0",
+ "object-inspect": "^1.13.4",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.7",
+ "own-keys": "^1.0.1",
+ "regexp.prototype.flags": "^1.5.4",
+ "safe-array-concat": "^1.1.3",
+ "safe-push-apply": "^1.0.0",
+ "safe-regex-test": "^1.1.0",
+ "set-proto": "^1.0.0",
+ "stop-iteration-iterator": "^1.1.0",
+ "string.prototype.trim": "^1.2.10",
+ "string.prototype.trimend": "^1.0.9",
+ "string.prototype.trimstart": "^1.0.8",
+ "typed-array-buffer": "^1.0.3",
+ "typed-array-byte-length": "^1.0.3",
+ "typed-array-byte-offset": "^1.0.4",
+ "typed-array-length": "^1.0.7",
+ "unbox-primitive": "^1.1.0",
+ "which-typed-array": "^1.1.19"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-iterator-helpers": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz",
+ "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.1",
+ "es-errors": "^1.3.0",
+ "es-set-tostringtag": "^2.1.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.3.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "iterator.prototype": "^1.1.5",
+ "safe-array-concat": "^1.1.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-shim-unscopables": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
+ "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-to-primitive": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz",
+ "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7",
+ "is-date-object": "^1.0.5",
+ "is-symbol": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
+ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
+ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.6.1",
+ "@eslint/eslintrc": "^2.1.4",
+ "@eslint/js": "8.57.1",
+ "@humanwhocodes/config-array": "^0.13.0",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@nodelib/fs.walk": "^1.2.8",
+ "@ungap/structured-clone": "^1.2.0",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "doctrine": "^3.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^7.2.2",
+ "eslint-visitor-keys": "^3.4.3",
+ "espree": "^9.6.1",
+ "esquery": "^1.4.2",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "globals": "^13.19.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "is-path-inside": "^3.0.3",
+ "js-yaml": "^4.1.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3",
+ "strip-ansi": "^6.0.1",
+ "text-table": "^0.2.0"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-config-next": {
+ "version": "14.2.35",
+ "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.35.tgz",
+ "integrity": "sha512-BpLsv01UisH193WyT/1lpHqq5iJ/Orfz9h/NOOlAmTUq4GY349PextQ62K4XpnaM9supeiEn3TaOTeQO07gURg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@next/eslint-plugin-next": "14.2.35",
+ "@rushstack/eslint-patch": "^1.3.3",
+ "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+ "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+ "eslint-import-resolver-node": "^0.3.6",
+ "eslint-import-resolver-typescript": "^3.5.2",
+ "eslint-plugin-import": "^2.28.1",
+ "eslint-plugin-jsx-a11y": "^6.7.1",
+ "eslint-plugin-react": "^7.33.2",
+ "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705"
+ },
+ "peerDependencies": {
+ "eslint": "^7.23.0 || ^8.0.0",
+ "typescript": ">=3.3.1"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-import-resolver-node": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
+ "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^3.2.7",
+ "is-core-module": "^2.13.0",
+ "resolve": "^1.22.4"
+ }
+ },
+ "node_modules/eslint-import-resolver-node/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-import-resolver-typescript": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz",
+ "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@nolyfill/is-core-module": "1.0.39",
+ "debug": "^4.4.0",
+ "get-tsconfig": "^4.10.0",
+ "is-bun-module": "^2.0.0",
+ "stable-hash": "^0.0.5",
+ "tinyglobby": "^0.2.13",
+ "unrs-resolver": "^1.6.2"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-import-resolver-typescript"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "eslint-plugin-import": "*",
+ "eslint-plugin-import-x": "*"
+ },
+ "peerDependenciesMeta": {
+ "eslint-plugin-import": {
+ "optional": true
+ },
+ "eslint-plugin-import-x": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-module-utils": {
+ "version": "2.12.1",
+ "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz",
+ "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^3.2.7"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-module-utils/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-plugin-import": {
+ "version": "2.32.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
+ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rtsao/scc": "^1.1.0",
+ "array-includes": "^3.1.9",
+ "array.prototype.findlastindex": "^1.2.6",
+ "array.prototype.flat": "^1.3.3",
+ "array.prototype.flatmap": "^1.3.3",
+ "debug": "^3.2.7",
+ "doctrine": "^2.1.0",
+ "eslint-import-resolver-node": "^0.3.9",
+ "eslint-module-utils": "^2.12.1",
+ "hasown": "^2.0.2",
+ "is-core-module": "^2.16.1",
+ "is-glob": "^4.0.3",
+ "minimatch": "^3.1.2",
+ "object.fromentries": "^2.0.8",
+ "object.groupby": "^1.0.3",
+ "object.values": "^1.2.1",
+ "semver": "^6.3.1",
+ "string.prototype.trimend": "^1.0.9",
+ "tsconfig-paths": "^3.15.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/doctrine": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+ "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/eslint-plugin-jsx-a11y": {
+ "version": "6.10.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz",
+ "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "aria-query": "^5.3.2",
+ "array-includes": "^3.1.8",
+ "array.prototype.flatmap": "^1.3.2",
+ "ast-types-flow": "^0.0.8",
+ "axe-core": "^4.10.0",
+ "axobject-query": "^4.1.0",
+ "damerau-levenshtein": "^1.0.8",
+ "emoji-regex": "^9.2.2",
+ "hasown": "^2.0.2",
+ "jsx-ast-utils": "^3.3.5",
+ "language-tags": "^1.0.9",
+ "minimatch": "^3.1.2",
+ "object.fromentries": "^2.0.8",
+ "safe-regex-test": "^1.0.3",
+ "string.prototype.includes": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependencies": {
+ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9"
+ }
+ },
+ "node_modules/eslint-plugin-react": {
+ "version": "7.37.5",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
+ "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.8",
+ "array.prototype.findlast": "^1.2.5",
+ "array.prototype.flatmap": "^1.3.3",
+ "array.prototype.tosorted": "^1.1.4",
+ "doctrine": "^2.1.0",
+ "es-iterator-helpers": "^1.2.1",
+ "estraverse": "^5.3.0",
+ "hasown": "^2.0.2",
+ "jsx-ast-utils": "^2.4.1 || ^3.0.0",
+ "minimatch": "^3.1.2",
+ "object.entries": "^1.1.9",
+ "object.fromentries": "^2.0.8",
+ "object.values": "^1.2.1",
+ "prop-types": "^15.8.1",
+ "resolve": "^2.0.0-next.5",
+ "semver": "^6.3.1",
+ "string.prototype.matchall": "^4.0.12",
+ "string.prototype.repeat": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "5.0.0-canary-7118f5dd7-20230705",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0-canary-7118f5dd7-20230705.tgz",
+ "integrity": "sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/doctrine": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+ "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/resolve": {
+ "version": "2.0.0-next.5",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
+ "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.13.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.9.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.4.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-equals": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
+ "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^3.0.4"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
+ "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.3",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/function.prototype.name": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz",
+ "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "functions-have-names": "^1.2.3",
+ "hasown": "^2.0.2",
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/fuse.js": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
+ "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/generator-function": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
+ "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-symbol-description": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
+ "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.13.6",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
+ "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/glob": {
+ "version": "10.3.10",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+ "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^2.3.5",
+ "minimatch": "^9.0.1",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+ "path-scurry": "^1.10.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/globals": {
+ "version": "13.24.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.20.2"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/has-bigints": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+ "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
+ "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/internal-slot": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+ "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/is-array-buffer": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+ "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-async-function": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
+ "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "async-function": "^1.0.0",
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bigint": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+ "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-bigints": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+ "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bun-module": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
+ "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.7.1"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-data-view": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
+ "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "is-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-finalizationregistry": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
+ "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-generator-function": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
+ "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.4",
+ "generator-function": "^2.0.0",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-map": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+ "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-negative-zero": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+ "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+ "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-path-inside": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+ "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+ "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-string": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+ "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+ "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+ "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakmap": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+ "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
+ "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakset": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+ "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/iterator.prototype": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
+ "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "get-proto": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/jackspeak": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
+ "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/jose": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
+ "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
+ "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/jsx-ast-utils": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
+ "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.6",
+ "array.prototype.flat": "^1.3.1",
+ "object.assign": "^4.1.4",
+ "object.values": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/language-subtag-registry": {
+ "version": "0.3.23",
+ "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
+ "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/language-tags": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz",
+ "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "language-subtag-registry": "^0.3.20"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/lucide-react": {
+ "version": "0.400.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz",
+ "integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/napi-postinstall": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
+ "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "napi-postinstall": "lib/cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/napi-postinstall"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/next": {
+ "version": "14.2.35",
+ "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz",
+ "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "14.2.35",
+ "@swc/helpers": "0.5.5",
+ "busboy": "1.6.0",
+ "caniuse-lite": "^1.0.30001579",
+ "graceful-fs": "^4.2.11",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.1"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=18.17.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "14.2.33",
+ "@next/swc-darwin-x64": "14.2.33",
+ "@next/swc-linux-arm64-gnu": "14.2.33",
+ "@next/swc-linux-arm64-musl": "14.2.33",
+ "@next/swc-linux-x64-gnu": "14.2.33",
+ "@next/swc-linux-x64-musl": "14.2.33",
+ "@next/swc-win32-arm64-msvc": "14.2.33",
+ "@next/swc-win32-ia32-msvc": "14.2.33",
+ "@next/swc-win32-x64-msvc": "14.2.33"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.41.2",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.27",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+ "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.entries": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz",
+ "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.fromentries": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz",
+ "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.groupby": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz",
+ "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.values": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz",
+ "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/own-keys": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
+ "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-intrinsic": "^1.2.6",
+ "object-keys": "^1.1.1",
+ "safe-push-apply": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/pg": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
+ "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.11.0",
+ "pg-pool": "^3.11.0",
+ "pg-protocol": "^1.11.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.3.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+ "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
+ "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.11.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
+ "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
+ "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
+ "node_modules/react-smooth": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+ "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-equals": "^5.0.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/recharts": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
+ "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.21",
+ "react-is": "^18.3.1",
+ "react-smooth": "^4.0.4",
+ "recharts-scale": "^0.4.4",
+ "tiny-invariant": "^1.3.1",
+ "victory-vendor": "^36.6.8"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "license": "MIT",
+ "dependencies": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
+ "node_modules/recharts/node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
+ "node_modules/reflect.getprototypeof": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
+ "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.7",
+ "get-proto": "^1.0.1",
+ "which-builtin-type": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rimraf/node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/safe-array-concat": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
+ "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "has-symbols": "^1.1.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">=0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-push-apply": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+ "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-proto": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
+ "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/stable-hash": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
+ "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+ "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/string-width/node_modules/strip-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/string.prototype.includes": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
+ "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string.prototype.matchall": {
+ "version": "4.0.12",
+ "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
+ "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.6",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "regexp.prototype.flags": "^1.5.3",
+ "set-function-name": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.repeat": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz",
+ "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.17.5"
+ }
+ },
+ "node_modules/string.prototype.trim": {
+ "version": "1.2.10",
+ "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
+ "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-data-property": "^1.1.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-object-atoms": "^1.0.0",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimend": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz",
+ "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimstart": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz",
+ "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-bom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+ "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
+ "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
+ "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/text-table": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
+ "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/tsconfig-paths": {
+ "version": "3.15.0",
+ "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
+ "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/json5": "^0.0.29",
+ "json5": "^1.0.2",
+ "minimist": "^1.2.6",
+ "strip-bom": "^3.0.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typed-array-buffer": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+ "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/typed-array-byte-length": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz",
+ "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-byte-offset": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz",
+ "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.15",
+ "reflect.getprototypeof": "^1.0.9"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-length": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz",
+ "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "for-each": "^0.3.3",
+ "gopd": "^1.0.1",
+ "is-typed-array": "^1.1.13",
+ "possible-typed-array-names": "^1.0.0",
+ "reflect.getprototypeof": "^1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/unbox-primitive": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+ "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "which-boxed-primitive": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unrs-resolver": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
+ "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "napi-postinstall": "^0.3.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/unrs-resolver"
+ },
+ "optionalDependencies": {
+ "@unrs/resolver-binding-android-arm-eabi": "1.11.1",
+ "@unrs/resolver-binding-android-arm64": "1.11.1",
+ "@unrs/resolver-binding-darwin-arm64": "1.11.1",
+ "@unrs/resolver-binding-darwin-x64": "1.11.1",
+ "@unrs/resolver-binding-freebsd-x64": "1.11.1",
+ "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1",
+ "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1",
+ "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-arm64-musl": "1.11.1",
+ "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1",
+ "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-x64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-x64-musl": "1.11.1",
+ "@unrs/resolver-binding-wasm32-wasi": "1.11.1",
+ "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1",
+ "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1",
+ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/victory-vendor": {
+ "version": "36.9.2",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+ "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-boxed-primitive": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+ "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-bigint": "^1.1.0",
+ "is-boolean-object": "^1.2.1",
+ "is-number-object": "^1.1.1",
+ "is-string": "^1.1.1",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-builtin-type": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz",
+ "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "function.prototype.name": "^1.1.6",
+ "has-tostringtag": "^1.0.2",
+ "is-async-function": "^2.0.0",
+ "is-date-object": "^1.1.0",
+ "is-finalizationregistry": "^1.1.0",
+ "is-generator-function": "^1.0.10",
+ "is-regex": "^1.2.1",
+ "is-weakref": "^1.0.2",
+ "isarray": "^2.0.5",
+ "which-boxed-primitive": "^1.1.0",
+ "which-collection": "^1.0.2",
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-collection": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+ "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-map": "^2.0.3",
+ "is-set": "^2.0.3",
+ "is-weakmap": "^2.0.2",
+ "is-weakset": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.20",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
+ "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/strip-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..34f3b9a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "omega-watches-2",
+ "version": "2.0.0",
+ "private": true,
+ "description": "Enterprise-Grade Omega Price History & Daily MSRP Tracking System",
+ "scripts": {
+ "dev": "next dev -p 7600",
+ "build": "next build",
+ "start": "next start -p 7600",
+ "lint": "next lint",
+ "db:migrate": "node scripts/migrate.mjs",
+ "db:seed": "node scripts/seed.mjs",
+ "collector:run": "node collectors/runner.mjs",
+ "health": "curl -s http://localhost:7600/api/health | jq ."
+ },
+ "dependencies": {
+ "next": "^14.2.0",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0",
+ "pg": "^8.13.0",
+ "bcryptjs": "^2.4.3",
+ "jose": "^5.2.0",
+ "recharts": "^2.12.0",
+ "date-fns": "^3.6.0",
+ "zod": "^3.23.0",
+ "clsx": "^2.1.0",
+ "tailwind-merge": "^2.3.0",
+ "lucide-react": "^0.400.0",
+ "fuse.js": "^7.0.0"
+ },
+ "devDependencies": {
+ "typescript": "^5.4.0",
+ "@types/node": "^20.12.0",
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "@types/pg": "^8.11.0",
+ "@types/bcryptjs": "^2.4.6",
+ "tailwindcss": "^3.4.0",
+ "postcss": "^8.4.38",
+ "autoprefixer": "^10.4.19",
+ "eslint": "^8.57.0",
+ "eslint-config-next": "^14.2.0"
+ }
+}
diff --git a/postcss.config.mjs b/postcss.config.mjs
new file mode 100644
index 0000000..2ef30fc
--- /dev/null
+++ b/postcss.config.mjs
@@ -0,0 +1,9 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
+
+export default config;
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..a2c3d1c
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+<rect width="32" height="32" rx="6" fill="#10b981"/>
+<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-size="20" font-family="Apple Color Emoji, Segoe UI Emoji, sans-serif" fill="white">O</text>
+</svg>
\ No newline at end of file
diff --git a/scripts/daily-antiquorum.sh b/scripts/daily-antiquorum.sh
new file mode 100755
index 0000000..a8b47bf
--- /dev/null
+++ b/scripts/daily-antiquorum.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+# daily-antiquorum.sh — Run Antiquorum auction collector
+# Schedule: 10:00 UTC daily via cron
+# Timeout: 10 minutes
+set -euo pipefail
+
+PROJ_DIR="/root/Projects/omega-watches-2"
+LOG_DIR="${PROJ_DIR}/logs"
+LOG_FILE="${LOG_DIR}/antiquorum-$(date +%Y%m%d).log"
+
+mkdir -p "${LOG_DIR}"
+
+echo "=== Antiquorum Collection Run — $(date -u) ===" >> "${LOG_FILE}"
+
+timeout 600 node "${PROJ_DIR}/collectors/runner.mjs" antiquorum >> "${LOG_FILE}" 2>&1
+EXIT_CODE=$?
+
+if [ $EXIT_CODE -eq 0 ]; then
+ echo "=== SUCCESS $(date -u) ===" >> "${LOG_FILE}"
+ # Refresh materialized views
+ PGPASSWORD='OmegaWatch2024Pass' psql -h 127.0.0.1 -U omega_admin -d omega_watches_v2 \
+ -c "REFRESH MATERIALIZED VIEW source_health; REFRESH MATERIALIZED VIEW ref_price_summary;" \
+ >> "${LOG_FILE}" 2>&1
+else
+ echo "=== FAILED (exit code: $EXIT_CODE) $(date -u) ===" >> "${LOG_FILE}"
+fi
+
+# Rotate logs older than 30 days
+find "${LOG_DIR}" -name "antiquorum-*.log" -mtime +30 -delete 2>/dev/null || true
diff --git a/scripts/daily-chrono24.sh b/scripts/daily-chrono24.sh
new file mode 100755
index 0000000..19082c6
--- /dev/null
+++ b/scripts/daily-chrono24.sh
@@ -0,0 +1,35 @@
+#!/bin/bash
+# Daily Chrono24 Marketplace Collection Script
+# Scheduled via cron: 0 8 * * * /root/Projects/omega-watches-2/scripts/daily-chrono24.sh
+
+LOG_DIR="/root/Projects/omega-watches-2/logs"
+LOG_FILE="$LOG_DIR/chrono24-collection-$(date +%Y-%m-%d).log"
+PROJECT_DIR="/root/Projects/omega-watches-2"
+
+mkdir -p "$LOG_DIR"
+
+echo "========================================" >> "$LOG_FILE"
+echo "Chrono24 Collection Run: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> "$LOG_FILE"
+echo "========================================" >> "$LOG_FILE"
+
+cd "$PROJECT_DIR"
+
+timeout 600 node collectors/runner.mjs chrono24 >> "$LOG_FILE" 2>&1
+EXIT_CODE=$?
+
+if [ $EXIT_CODE -eq 0 ]; then
+ echo "STATUS: SUCCESS" >> "$LOG_FILE"
+elif [ $EXIT_CODE -eq 124 ]; then
+ echo "STATUS: TIMEOUT (10 min limit)" >> "$LOG_FILE"
+else
+ echo "STATUS: FAILED (exit code $EXIT_CODE)" >> "$LOG_FILE"
+fi
+
+echo "" >> "$LOG_FILE"
+
+find "$LOG_DIR" -name "chrono24-collection-*.log" -mtime +30 -delete 2>/dev/null
+
+PGPASSWORD=OmegaWatch2024Pass psql -h 127.0.0.1 -U omega_admin -d omega_watches_v2 -c "
+ REFRESH MATERIALIZED VIEW ref_price_summary;
+ REFRESH MATERIALIZED VIEW source_health;
+" >> "$LOG_FILE" 2>&1
diff --git a/scripts/daily-msrp.sh b/scripts/daily-msrp.sh
new file mode 100755
index 0000000..7a7df10
--- /dev/null
+++ b/scripts/daily-msrp.sh
@@ -0,0 +1,39 @@
+#!/bin/bash
+# Daily MSRP Collection Script
+# Scheduled via cron: 0 6 * * * /root/Projects/omega-watches-2/scripts/daily-msrp.sh
+# Runs the Omega Official MSRP collector and logs results
+
+LOG_DIR="/root/Projects/omega-watches-2/logs"
+LOG_FILE="$LOG_DIR/msrp-collection-$(date +%Y-%m-%d).log"
+PROJECT_DIR="/root/Projects/omega-watches-2"
+
+mkdir -p "$LOG_DIR"
+
+echo "========================================" >> "$LOG_FILE"
+echo "MSRP Collection Run: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> "$LOG_FILE"
+echo "========================================" >> "$LOG_FILE"
+
+cd "$PROJECT_DIR"
+
+# Run the MSRP collector
+timeout 300 node collectors/runner.mjs omega_official >> "$LOG_FILE" 2>&1
+EXIT_CODE=$?
+
+if [ $EXIT_CODE -eq 0 ]; then
+ echo "STATUS: SUCCESS" >> "$LOG_FILE"
+elif [ $EXIT_CODE -eq 124 ]; then
+ echo "STATUS: TIMEOUT (5 min limit)" >> "$LOG_FILE"
+else
+ echo "STATUS: FAILED (exit code $EXIT_CODE)" >> "$LOG_FILE"
+fi
+
+echo "" >> "$LOG_FILE"
+
+# Cleanup logs older than 30 days
+find "$LOG_DIR" -name "msrp-collection-*.log" -mtime +30 -delete 2>/dev/null
+
+# Refresh materialized views
+PGPASSWORD=OmegaWatch2024Pass psql -h 127.0.0.1 -U omega_admin -d omega_watches_v2 -c "
+ REFRESH MATERIALIZED VIEW ref_price_summary;
+ REFRESH MATERIALIZED VIEW source_health;
+" >> "$LOG_FILE" 2>&1
diff --git a/skills/onboard-source/SKILL.md b/skills/onboard-source/SKILL.md
new file mode 100644
index 0000000..79aa8a3
--- /dev/null
+++ b/skills/onboard-source/SKILL.md
@@ -0,0 +1,64 @@
+---
+name: onboard-source
+description: Creates a new price-source connector (API/scraper/manual import). Use when adding a new marketplace, auction house, dealer archive, or forum source to the Omega Watches 2.0 system.
+disable-model-invocation: true
+---
+You are implementing a new data source connector for the Omega Watches 2.0 enterprise price history platform.
+
+## Context
+The system uses a dual-ledger architecture:
+- **Ledger 1**: MSRP snapshots (daily immutable observations from manufacturers)
+- **Ledger 2**: Secondary market events (auctions, marketplaces, dealers, forums)
+
+Database: PostgreSQL `omega_watches_v2` on 127.0.0.1:5432
+Project: /root/Projects/omega-watches-2
+
+## When Adding a New Source, Produce:
+
+### 1. Legal/Compliance Checklist
+- Check robots.txt at the source URL
+- Review Terms of Service for scraping/automation clauses
+- Check if login gating exists
+- Document rate limit requirements
+- Determine if explicit permission is required (like eBay API access)
+- Record findings in the `data_source` table with `tos_reviewed`, `tos_allows_collection`, `tos_notes`
+
+### 2. Data Field Mapping
+Map source-specific fields to the canonical schema:
+- **Watch identity**: reference_number_raw, serial_number, year_text, case_material, movement
+- **Condition**: condition_raw → condition_normalized (via condition_mapping table)
+- **Price**: price_amount, currency, hammer_price, buyers_premium, total_to_buyer, fee_semantics
+- **Sale**: sale_date, location_text, seller_type, event_type
+- **Provenance**: provenance_raw, images_json
+- **Source**: source_url, source_listing_id
+
+### 3. Collector Approach (preference order)
+1. **API** — Best: structured, rate-limited, documented
+2. **Export/Tooling** — Good: official data exports, RSS feeds
+3. **Scrape** — Acceptable: only where ToS permits, with robots.txt compliance
+4. **Manual** — Fallback: for ToS-restricted sources (e.g., Hodinkee)
+
+### 4. Test Plan
+- Create fixture HTML/JSON files in `collectors/fixtures/{source_name}/`
+- Write parser unit tests
+- Define backfill strategy (date ranges, pagination)
+- Document expected record volume
+
+### 5. Database Registration
+```sql
+INSERT INTO data_source (name, display_name, source_type, access_method, base_url, reliability_score, tos_reviewed, tos_allows_collection, tos_notes, rate_limit_rpm)
+VALUES ('new_source', 'New Source Display Name', 'auction|marketplace|dealer|forum', 'api|scrape|manual', 'https://...', 3, false, NULL, 'ToS review pending', 10);
+```
+
+### 6. Parser Version
+```sql
+INSERT INTO parser_version (source_id, version, selectors_json, is_current)
+VALUES ((SELECT id FROM data_source WHERE name = 'new_source'), '1.0.0', '{"title": "...", "price": "...", "date": "..."}', true);
+```
+
+## Output Files
+- `collectors/{source_name}/collector.mjs` — Collector module
+- `collectors/{source_name}/parser.mjs` — Parser with versioned selectors
+- `collectors/{source_name}/fixtures/` — Test fixtures
+- `collectors/{source_name}/README.md` — Source documentation
+- SQL migration for data_source + parser_version registration
diff --git a/skills/onboard-source/manifest.json b/skills/onboard-source/manifest.json
new file mode 100644
index 0000000..d586e4d
--- /dev/null
+++ b/skills/onboard-source/manifest.json
@@ -0,0 +1,12 @@
+{
+ "skill_name": "onboard-source",
+ "owner_team": "data-platform",
+ "risk_tier": "medium",
+ "allowed_environments": ["dev", "staging", "production"],
+ "requires_human_approval": false,
+ "data_access": {
+ "allowed_sources": ["data_source", "parser_version", "robots_txt", "tos_documents"],
+ "prohibited_sources": ["prod_secrets", "customer_pii"]
+ },
+ "outputs": ["collector_module", "parser_module", "test_fixtures", "sql_migration", "readme"]
+}
diff --git a/skills/parser-break-triage/SKILL.md b/skills/parser-break-triage/SKILL.md
new file mode 100644
index 0000000..fb4c32a
--- /dev/null
+++ b/skills/parser-break-triage/SKILL.md
@@ -0,0 +1,67 @@
+---
+name: parser-break-triage
+description: Diagnoses DOM/parser failures and proposes safe selector updates. Use when a collector starts failing, HTML structure changes, or parser success rate drops.
+disable-model-invocation: true
+---
+You are diagnosing and fixing a broken parser in the Omega Watches 2.0 price tracking system.
+
+## Context
+Each data source has versioned parsers stored in the `parser_version` table. When a source's HTML structure changes, the parser breaks and records start failing or being quarantined.
+
+Database: PostgreSQL `omega_watches_v2`
+Project: /root/Projects/omega-watches-2
+
+## Given Information
+- Failing URL(s) and source name
+- Last known-good HTML snapshot hash (from `raw_snapshot` table)
+- Current HTML snapshot (new page structure)
+- Failing parser logs (from `collector_run` table, error_message field)
+
+## Diagnostic Steps
+
+### 1. Identify Break Point
+- Compare old vs new HTML snapshots
+- Find which CSS selectors / XPaths no longer match
+- Determine if the change is structural (major) or cosmetic (minor)
+- Check if the change affects all pages or specific listing types
+
+### 2. Propose Selector Updates
+- Write minimal selector changes that fix the parse
+- Prefer resilient selectors (data attributes > class names > tag hierarchies)
+- Test new selectors against both old and new HTML snapshots
+- Document which fields are affected
+
+### 3. Version the Parser
+```sql
+-- Mark old parser as not current
+UPDATE parser_version SET is_current = false
+WHERE source_id = (SELECT id FROM data_source WHERE name = '{source}') AND is_current = true;
+
+-- Insert new parser version
+INSERT INTO parser_version (source_id, version, selectors_json, dom_hash, is_current, notes)
+VALUES (
+ (SELECT id FROM data_source WHERE name = '{source}'),
+ '{new_version}',
+ '{new_selectors_json}',
+ '{new_dom_hash}',
+ true,
+ 'Fix for HTML change on {date}: {description}'
+);
+```
+
+### 4. Create Regression Tests
+- Save new HTML snapshot as fixture
+- Write test that validates extraction against both old and new fixtures
+- Ensure backward compatibility where possible
+
+### 5. Rollout Plan
+1. **Canary**: Run new parser against 5-10 sample URLs
+2. **Compare**: Verify extracted fields match expected values
+3. **Monitor**: Watch quarantine rate for 1 hour after deployment
+4. **Rollback**: If quarantine rate exceeds 20%, revert to previous parser version
+
+## Output
+- Updated `collectors/{source}/parser.mjs` with new selectors
+- New fixture files in `collectors/{source}/fixtures/`
+- SQL migration for new parser_version
+- Rollout checklist with monitoring thresholds
diff --git a/skills/parser-break-triage/manifest.json b/skills/parser-break-triage/manifest.json
new file mode 100644
index 0000000..0f14c1b
--- /dev/null
+++ b/skills/parser-break-triage/manifest.json
@@ -0,0 +1,12 @@
+{
+ "skill_name": "parser-break-triage",
+ "owner_team": "data-platform",
+ "risk_tier": "high",
+ "allowed_environments": ["dev", "staging"],
+ "requires_human_approval": true,
+ "data_access": {
+ "allowed_sources": ["html_snapshots", "collector_logs", "parser_version", "raw_snapshot"],
+ "prohibited_sources": ["prod_secrets", "customer_pii"]
+ },
+ "outputs": ["patch_pr", "regression_tests", "rollout_plan", "sql_migration"]
+}
diff --git a/src/app/(dashboard)/alerts/page.tsx b/src/app/(dashboard)/alerts/page.tsx
new file mode 100644
index 0000000..052afd6
--- /dev/null
+++ b/src/app/(dashboard)/alerts/page.tsx
@@ -0,0 +1,278 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { formatDateTime, formatCurrency } from "@/lib/utils";
+
+interface AlertRule {
+ id: string;
+ reference_number: string;
+ collection: string;
+ model_name: string;
+ alert_type: "price_below" | "price_above";
+ threshold: string;
+ current_avg_price: string | null;
+ is_triggered: boolean;
+ is_active: boolean;
+ trigger_count: number;
+ created_by: string;
+ notes: string | null;
+ created_at: string;
+ last_triggered_at: string | null;
+}
+
+export default function AlertsPage() {
+ const [alerts, setAlerts] = useState<AlertRule[]>([]);
+ const [summary, setSummary] = useState({ total: 0, active: 0, triggered: 0 });
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [creating, setCreating] = useState(false);
+
+ const [referenceNumber, setReferenceNumber] = useState("");
+ const [alertType, setAlertType] = useState<"price_below" | "price_above">("price_below");
+ const [threshold, setThreshold] = useState("");
+ const [notes, setNotes] = useState("");
+
+ function getHeaders() {
+ const token = typeof window !== "undefined" ? localStorage.getItem("omega_token") : null;
+ const h: Record<string, string> = { "Content-Type": "application/json" };
+ if (token) h["Authorization"] = `Bearer ${token}`;
+ return h;
+ }
+
+ async function loadAlerts() {
+ try {
+ const res = await fetch("/api/alerts", { headers: getHeaders() });
+ const json = await res.json();
+ if (json.success) {
+ setAlerts(json.data.rules);
+ setSummary(json.data.summary);
+ } else {
+ setError(json.error?.message || "Failed to load alerts");
+ }
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ useEffect(() => { loadAlerts(); }, []);
+
+ async function toggleActive(id: string, isActive: boolean) {
+ try {
+ const res = await fetch("/api/alerts", {
+ method: "PUT",
+ headers: getHeaders(),
+ body: JSON.stringify({ id, is_active: isActive }),
+ });
+ const json = await res.json();
+ if (json.success) {
+ loadAlerts();
+ }
+ } catch (e: any) {
+ setError(e.message);
+ }
+ }
+
+ async function createAlert() {
+ if (!referenceNumber || !threshold) return;
+ setCreating(true);
+ try {
+ const res = await fetch("/api/alerts", {
+ method: "POST",
+ headers: getHeaders(),
+ body: JSON.stringify({
+ reference_number: referenceNumber,
+ alert_type: alertType,
+ threshold: parseFloat(threshold),
+ notes: notes || null,
+ }),
+ });
+ const json = await res.json();
+ if (json.success) {
+ setReferenceNumber("");
+ setThreshold("");
+ setNotes("");
+ loadAlerts();
+ } else {
+ setError(json.error?.message || "Failed to create alert");
+ }
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ if (loading) {
+ return <div className="flex h-full items-center justify-center"><div className="text-gray-400">Loading alerts...</div></div>;
+ }
+
+ return (
+ <div className="space-y-6">
+ <div className="flex items-center justify-between">
+ <h1 className="text-xl font-bold text-gray-100">Price Alert Rules</h1>
+ <button onClick={loadAlerts} className="btn-secondary text-xs">Refresh</button>
+ </div>
+
+ {error && (
+ <div className="rounded bg-red-900/30 p-3 text-red-300">
+ {error}
+ <button onClick={() => setError("")} className="ml-2 text-xs underline">Dismiss</button>
+ </div>
+ )}
+
+ {/* Summary */}
+ <div className="grid gap-4 md:grid-cols-3">
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-gray-200">{summary.total}</div>
+ <div className="text-xs text-gray-500">Total Rules</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-green-400">{summary.active}</div>
+ <div className="text-xs text-gray-500">Active</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className={`text-3xl font-bold ${summary.triggered > 0 ? "text-orange-400" : "text-green-400"}`}>
+ {summary.triggered}
+ </div>
+ <div className="text-xs text-gray-500">Triggered</div>
+ </div>
+ </div>
+
+ {/* Create Form */}
+ <div className="card border-navy-700">
+ <h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">Create New Alert</h2>
+ <div className="grid gap-4 md:grid-cols-2">
+ <div>
+ <label className="mb-1 block text-xs text-gray-400">Reference Number</label>
+ <input type="text" className="input" placeholder="e.g., 310.30.42.50.01.001"
+ value={referenceNumber} onChange={(e) => setReferenceNumber(e.target.value)} />
+ </div>
+ <div>
+ <label className="mb-1 block text-xs text-gray-400">Alert Type</label>
+ <select className="input" value={alertType}
+ onChange={(e) => setAlertType(e.target.value as "price_below" | "price_above")}>
+ <option value="price_below">Price Falls Below</option>
+ <option value="price_above">Price Rises Above</option>
+ </select>
+ </div>
+ <div>
+ <label className="mb-1 block text-xs text-gray-400">Threshold (USD)</label>
+ <input type="number" className="input" placeholder="e.g., 5000"
+ value={threshold} onChange={(e) => setThreshold(e.target.value)} />
+ </div>
+ <div>
+ <label className="mb-1 block text-xs text-gray-400">Notes (Optional)</label>
+ <input type="text" className="input" placeholder="e.g., Target buy price"
+ value={notes} onChange={(e) => setNotes(e.target.value)} />
+ </div>
+ </div>
+ <div className="mt-4 flex justify-end">
+ <button onClick={createAlert} disabled={creating || !referenceNumber || !threshold}
+ className="btn-primary disabled:opacity-50">
+ {creating ? "Creating..." : "Create Alert"}
+ </button>
+ </div>
+ </div>
+
+ {/* Alert Rules */}
+ <div className="card border-navy-700">
+ <h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Alert Rules ({alerts.length})
+ </h2>
+
+ {alerts.length === 0 ? (
+ <div className="p-8 text-center">
+ <div className="text-4xl text-gray-600">🔔</div>
+ <div className="mt-2 text-lg font-medium text-gray-400">No Alerts Yet</div>
+ <div className="text-sm text-gray-500">Create your first price alert rule above.</div>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {alerts.map((alert) => {
+ const triggered = alert.is_triggered;
+ return (
+ <div key={alert.id}
+ className={triggered
+ ? "rounded-lg border border-orange-700 bg-orange-900/20 p-4 shadow-lg shadow-orange-900/30"
+ : "rounded-lg border border-navy-700 bg-navy-900/50 p-4"
+ }>
+ <div className="flex items-start justify-between">
+ <div className="flex-1">
+ <div className="flex items-center gap-2">
+ <span className="font-mono text-sm font-medium text-omega-400">
+ {alert.reference_number}
+ </span>
+ <span className="text-xs text-gray-500">{alert.collection}</span>
+ {triggered && (
+ <span className="rounded bg-orange-900/50 px-2 py-0.5 text-xs font-bold text-orange-300 animate-pulse">
+ TRIGGERED
+ </span>
+ )}
+ <span className={`rounded px-2 py-0.5 text-xs font-bold ${
+ alert.is_active ? "bg-green-900/50 text-green-300" : "bg-gray-800 text-gray-500"
+ }`}>
+ {alert.is_active ? "Active" : "Inactive"}
+ </span>
+ </div>
+ {alert.model_name && (
+ <div className="mt-1 text-xs text-gray-400">{alert.model_name}</div>
+ )}
+ <div className="mt-2 flex flex-wrap items-center gap-4 text-xs">
+ <div>
+ <span className="text-gray-500">Type: </span>
+ <span className="text-gray-300">
+ {alert.alert_type === "price_below" ? "Price Falls Below" : "Price Rises Above"}
+ </span>
+ </div>
+ <div>
+ <span className="text-gray-500">Threshold: </span>
+ <span className="font-medium text-gray-200">
+ {formatCurrency(parseFloat(alert.threshold))}
+ </span>
+ </div>
+ {alert.current_avg_price && (
+ <div>
+ <span className="text-gray-500">Current Avg: </span>
+ <span className={`font-medium ${triggered ? "text-orange-400" : "text-gray-200"}`}>
+ {formatCurrency(parseFloat(alert.current_avg_price))}
+ </span>
+ </div>
+ )}
+ <div>
+ <span className="text-gray-500">Triggers: </span>
+ <span className="text-gray-300">{alert.trigger_count}</span>
+ </div>
+ </div>
+ {alert.notes && (
+ <div className="mt-2 text-xs text-gray-500">
+ <span className="font-medium">Note:</span> {alert.notes}
+ </div>
+ )}
+ <div className="mt-2 flex items-center gap-4 text-xs text-gray-600">
+ <span>By {alert.created_by}</span>
+ <span>{formatDateTime(alert.created_at)}</span>
+ {alert.last_triggered_at && (
+ <span className="text-orange-500">Last triggered {formatDateTime(alert.last_triggered_at)}</span>
+ )}
+ </div>
+ </div>
+ <button onClick={() => toggleActive(alert.id, !alert.is_active)}
+ className={`ml-4 rounded-md px-3 py-1 text-xs font-medium transition-colors ${
+ alert.is_active
+ ? "bg-red-900/50 text-red-300 hover:bg-red-900/70"
+ : "bg-green-900/50 text-green-300 hover:bg-green-900/70"
+ }`}>
+ {alert.is_active ? "Disable" : "Enable"}
+ </button>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/src/app/(dashboard)/api-docs/page.tsx b/src/app/(dashboard)/api-docs/page.tsx
new file mode 100644
index 0000000..0d9beb7
--- /dev/null
+++ b/src/app/(dashboard)/api-docs/page.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+const endpoints = [
+ {
+ category: "Authentication",
+ routes: [
+ { method: "POST", path: "/api/auth/login", desc: "Login with username/password", params: "username, password", returns: "token, username, role" },
+ ],
+ },
+ {
+ category: "System",
+ routes: [
+ { method: "GET", path: "/api/health", desc: "System health check", params: "—", returns: "status, version, uptime, database stats" },
+ { method: "GET", path: "/api/dashboard", desc: "Dashboard aggregated data", params: "—", returns: "recent_runs, collection_summary, alerts, 24h stats, top_movers" },
+ { method: "POST", path: "/api/admin/refresh-views", desc: "Refresh materialized views", params: "—", returns: "view refresh results with timing" },
+ ],
+ },
+ {
+ category: "Watch References",
+ routes: [
+ { method: "GET", path: "/api/references", desc: "List watch references with stats", params: "collection, search, limit", returns: "references[], summary" },
+ { method: "GET", path: "/api/models/:ref/timeseries", desc: "Price history for a reference", params: "event_types, date_from, date_to, include_asks", returns: "events[], msrp_series[]" },
+ { method: "GET", path: "/api/models/:ref/distributions", desc: "Price & condition distributions", params: "—", returns: "price_distribution, condition_breakdown, source_breakdown" },
+ ],
+ },
+ {
+ category: "MSRP Ledger",
+ routes: [
+ { method: "GET", path: "/api/msrp/:ref", desc: "MSRP history for a reference", params: "region (default: US)", returns: "current_msrp, history[], changes[]" },
+ ],
+ },
+ {
+ category: "Data Sources",
+ routes: [
+ { method: "GET", path: "/api/sources", desc: "List data sources with health", params: "—", returns: "sources[] with reliability, runs, records" },
+ { method: "GET", path: "/api/sources/:name/status", desc: "Detailed source status", params: "—", returns: "source details, event counts, metrics" },
+ ],
+ },
+ {
+ category: "Collector Jobs",
+ routes: [
+ { method: "GET", path: "/api/jobs", desc: "List collector runs", params: "date, status, limit (max 200)", returns: "runs[] with metrics" },
+ { method: "POST", path: "/api/jobs/run", desc: "Trigger incremental run", params: "source_name, job_type?", returns: "run_id" },
+ { method: "POST", path: "/api/jobs/backfill", desc: "Trigger backfill run", params: "source_name, date_from, date_to", returns: "run_id" },
+ ],
+ },
+ {
+ category: "Quality Control",
+ routes: [
+ { method: "GET", path: "/api/quarantine", desc: "List quarantined records", params: "resolved, severity, limit", returns: "records[] with severity breakdown" },
+ { method: "POST", path: "/api/quarantine/:id/resolve", desc: "Resolve quarantine record", params: "resolution_notes, action (release|reject)", returns: "updated record" },
+ ],
+ },
+ {
+ category: "Entity Resolution",
+ routes: [
+ { method: "GET", path: "/api/entity-resolution", desc: "Unmatched events with suggestions", params: "—", returns: "unmatched[], suggestions{}, references[], stats" },
+ { method: "POST", path: "/api/entity-resolution", desc: "Link event to reference", params: "event_id, reference_number", returns: "updated event" },
+ ],
+ },
+ {
+ category: "Snapshots & Audit",
+ routes: [
+ { method: "GET", path: "/api/snapshots", desc: "Raw snapshot archive", params: "source, limit (max 500)", returns: "snapshots[], stats, by_source" },
+ { method: "GET", path: "/api/auditlog", desc: "Audit log entries", params: "table, operation, limit", returns: "entries[] (admin/auditor only)" },
+ ],
+ },
+ {
+ category: "FX Rates",
+ routes: [
+ { method: "GET", path: "/api/fx-rates", desc: "Currency conversion rates", params: "—", returns: "latest[], history[], stats" },
+ { method: "POST", path: "/api/fx-rates", desc: "Add/update FX rate", params: "currency_from, rate, rate_date, source?", returns: "saved rate" },
+ ],
+ },
+ {
+ category: "Price Alerts",
+ routes: [
+ { method: "GET", path: "/api/alerts", desc: "List alert rules with triggered status", params: "—", returns: "rules[], summary (total, active, triggered)" },
+ { method: "POST", path: "/api/alerts", desc: "Create new price alert rule", params: "reference_number, alert_type (price_below|price_above), threshold, notes?", returns: "created rule" },
+ { method: "PUT", path: "/api/alerts", desc: "Toggle alert active status", params: "id, is_active", returns: "updated status" },
+ ],
+ },
+ {
+ category: "Condition Mappings",
+ routes: [
+ { method: "GET", path: "/api/condition-mappings", desc: "List condition mappings by source", params: "—", returns: "mappings[], by_source{}, stats" },
+ { method: "POST", path: "/api/condition-mappings", desc: "Add/update condition mapping", params: "source_name, source_category, normalized_grade, score_0_100", returns: "saved mapping" },
+ ],
+ },
+ {
+ category: "Data Export",
+ routes: [
+ { method: "GET", path: "/api/export", desc: "Export data as CSV or JSON", params: "type (market_events|msrp), reference, collection, date_from, date_to, format (csv|json), limit", returns: "CSV file or JSON array" },
+ ],
+ },
+];
+
+const methodColors: Record<string, string> = {
+ GET: "bg-blue-900 text-blue-300",
+ POST: "bg-green-900 text-green-300",
+ PUT: "bg-yellow-900 text-yellow-300",
+ DELETE: "bg-red-900 text-red-300",
+};
+
+export default function ApiDocsPage() {
+ return (
+ <div className="space-y-6">
+ <div className="flex items-center justify-between">
+ <h1 className="text-xl font-bold text-gray-100">API Documentation</h1>
+ <div className="text-xs text-gray-500">
+ Base URL: <code className="rounded bg-navy-800 px-2 py-1 text-omega-400">http://45.61.58.125:7600</code>
+ </div>
+ </div>
+
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Authentication
+ </h2>
+ <div className="space-y-2 text-sm text-gray-400">
+ <p>All endpoints (except /api/health) require authentication via JWT token.</p>
+ <p>Include the token in either:</p>
+ <ul className="ml-4 list-disc space-y-1">
+ <li><code className="text-omega-400">Authorization: Bearer <token></code> header</li>
+ <li><code className="text-omega-400">omega_session</code> cookie (set automatically on login)</li>
+ </ul>
+ <p className="mt-2">Roles: <span className="text-red-400">admin</span>, <span className="text-orange-400">operator</span>, <span className="text-blue-400">auditor</span>, <span className="text-gray-300">viewer</span></p>
+ </div>
+ </div>
+
+ {endpoints.map((cat) => (
+ <div key={cat.category} className="card border-navy-700">
+ <h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ {cat.category}
+ </h2>
+ <div className="space-y-3">
+ {cat.routes.map((r) => (
+ <div key={`${r.method}-${r.path}`} className="rounded-lg bg-navy-900 p-3">
+ <div className="flex items-center gap-3">
+ <span className={`rounded px-2 py-0.5 text-xs font-bold ${methodColors[r.method] || "bg-gray-800 text-gray-300"}`}>
+ {r.method}
+ </span>
+ <code className="text-sm text-omega-400">{r.path}</code>
+ </div>
+ <div className="mt-2 text-sm text-gray-300">{r.desc}</div>
+ <div className="mt-2 grid gap-2 md:grid-cols-2">
+ <div>
+ <span className="text-xs text-gray-500">Parameters: </span>
+ <span className="text-xs text-gray-400">{r.params}</span>
+ </div>
+ <div>
+ <span className="text-xs text-gray-500">Returns: </span>
+ <span className="text-xs text-gray-400">{r.returns}</span>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ ))}
+
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Response Format
+ </h2>
+ <div className="space-y-3">
+ <div>
+ <div className="mb-1 text-xs text-gray-500">Success</div>
+ <pre className="rounded bg-navy-900 p-3 text-xs text-gray-300">
+{`{
+ "success": true,
+ "data": { ... },
+ "meta": { "total": 100, "timestamp": "..." }
+}`}
+ </pre>
+ </div>
+ <div>
+ <div className="mb-1 text-xs text-gray-500">Error</div>
+ <pre className="rounded bg-navy-900 p-3 text-xs text-gray-300">
+{`{
+ "success": false,
+ "error": {
+ "code": "UNAUTHORIZED",
+ "message": "Login required"
+ }
+}`}
+ </pre>
+ </div>
+ </div>
+ </div>
+
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Cron Schedule
+ </h2>
+ <div className="overflow-x-auto">
+ <table className="w-full text-sm">
+ <thead>
+ <tr className="border-b border-navy-700 text-left text-xs uppercase text-gray-500">
+ <th className="p-2">Time (UTC)</th>
+ <th className="p-2">Source</th>
+ <th className="p-2">Script</th>
+ <th className="p-2">Timeout</th>
+ </tr>
+ </thead>
+ <tbody className="text-gray-400">
+ {[
+ ["06:00", "Omega Official", "daily-msrp.sh", "5 min"],
+ ["08:00", "Chrono24", "daily-chrono24.sh", "10 min"],
+ ["10:00", "Antiquorum", "daily-antiquorum.sh", "10 min"],
+ ].map(([time, source, script, timeout]) => (
+ <tr key={script} className="border-b border-navy-800">
+ <td className="p-2 font-mono text-omega-400">{time}</td>
+ <td className="p-2">{source}</td>
+ <td className="p-2 font-mono text-xs">{script}</td>
+ <td className="p-2">{timeout}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/app/(dashboard)/audit/page.tsx b/src/app/(dashboard)/audit/page.tsx
new file mode 100644
index 0000000..6887f80
--- /dev/null
+++ b/src/app/(dashboard)/audit/page.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { api } from "@/lib/api-client";
+import { formatDateTime } from "@/lib/utils";
+
+export default function AuditLogPage() {
+ const [logs, setLogs] = useState<any[]>([]);
+ const [error, setError] = useState("");
+ const [filter, setFilter] = useState({ table: "", operation: "" });
+
+ useEffect(() => {
+ api.getAuditLog(filter.table || filter.operation ? filter : undefined)
+ .then(setLogs)
+ .catch((e) => setError(e.message));
+ }, [filter]);
+
+ return (
+ <div className="space-y-6">
+ <h1 className="text-xl font-bold text-gray-100">Audit Log</h1>
+
+ <div className="flex gap-3">
+ <select
+ className="input w-48"
+ value={filter.table}
+ onChange={(e) => setFilter((f) => ({ ...f, table: e.target.value }))}
+ >
+ <option value="">All Tables</option>
+ <option value="market_event">market_event</option>
+ <option value="msrp_snapshot">msrp_snapshot</option>
+ <option value="watch_reference">watch_reference</option>
+ </select>
+ <select
+ className="input w-36"
+ value={filter.operation}
+ onChange={(e) => setFilter((f) => ({ ...f, operation: e.target.value }))}
+ >
+ <option value="">All Ops</option>
+ <option value="INSERT">INSERT</option>
+ <option value="UPDATE">UPDATE</option>
+ <option value="DELETE">DELETE</option>
+ </select>
+ </div>
+
+ {error && <div className="rounded bg-red-900/30 p-3 text-red-300">{error}</div>}
+
+ <div className="card border-navy-700 p-0">
+ <div className="overflow-x-auto">
+ <table className="w-full text-sm">
+ <thead>
+ <tr className="border-b border-navy-700 text-left text-xs uppercase text-gray-500">
+ <th className="p-3">Time</th>
+ <th className="p-3">Table</th>
+ <th className="p-3">Op</th>
+ <th className="p-3">Record ID</th>
+ <th className="p-3">Changed By</th>
+ <th className="p-3">Details</th>
+ </tr>
+ </thead>
+ <tbody>
+ {logs.length === 0 ? (
+ <tr>
+ <td colSpan={6} className="p-6 text-center text-gray-500">
+ No audit entries yet. Changes to market_event and other tables are logged automatically.
+ </td>
+ </tr>
+ ) : (
+ logs.map((l) => (
+ <tr key={l.id} className="border-b border-navy-800 hover:bg-navy-900">
+ <td className="p-3 text-xs text-gray-500">{formatDateTime(l.changed_at)}</td>
+ <td className="p-3 font-mono text-xs text-gray-300">{l.table_name}</td>
+ <td className="p-3">
+ <span className={`badge ${l.operation === "INSERT" ? "badge-success" : l.operation === "DELETE" ? "badge-error" : "badge-warning"}`}>
+ {l.operation}
+ </span>
+ </td>
+ <td className="p-3 font-mono text-xs text-gray-400">{l.record_id?.slice(0, 12)}...</td>
+ <td className="p-3 text-gray-400">{l.changed_by}</td>
+ <td className="p-3 text-xs text-gray-500">
+ {l.new_values ? `${Object.keys(l.new_values).length} fields` : "-"}
+ </td>
+ </tr>
+ ))
+ )}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/app/(dashboard)/condition-mappings/page.tsx b/src/app/(dashboard)/condition-mappings/page.tsx
new file mode 100644
index 0000000..05c28ea
--- /dev/null
+++ b/src/app/(dashboard)/condition-mappings/page.tsx
@@ -0,0 +1,247 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { formatDateTime } from "@/lib/utils";
+
+interface ConditionMapping {
+ id: number;
+ source_name: string;
+ source_category: string;
+ normalized_grade: string;
+ score_0_100: number;
+ version: number;
+ created_at: string;
+}
+
+const GRADES = [
+ "new_unworn", "excellent", "very_good", "good", "fair", "poor", "parts_only", "unknown",
+];
+
+const gradeColor: Record<string, string> = {
+ new_unworn: "bg-green-900/30 text-green-300",
+ excellent: "bg-blue-900/30 text-blue-300",
+ very_good: "bg-cyan-900/30 text-cyan-300",
+ good: "bg-yellow-900/30 text-yellow-300",
+ fair: "bg-orange-900/30 text-orange-300",
+ poor: "bg-red-900/30 text-red-300",
+ parts_only: "bg-gray-800 text-gray-400",
+ unknown: "bg-navy-800 text-gray-500",
+};
+
+export default function ConditionMappingsPage() {
+ const [mappings, setMappings] = useState<ConditionMapping[]>([]);
+ const [bySource, setBySource] = useState<Record<string, ConditionMapping[]>>({});
+ const [stats, setStats] = useState({ total_mappings: 0, sources: 0, grades: [] as string[] });
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
+ const [submitting, setSubmitting] = useState(false);
+ const [successMsg, setSuccessMsg] = useState("");
+
+ const [sourceName, setSourceName] = useState("");
+ const [sourceCategory, setSourceCategory] = useState("");
+ const [grade, setGrade] = useState("unknown");
+ const [score, setScore] = useState(50);
+
+ function getHeaders() {
+ const token = typeof window !== "undefined" ? localStorage.getItem("omega_token") : null;
+ const h: Record<string, string> = { "Content-Type": "application/json" };
+ if (token) h["Authorization"] = `Bearer ${token}`;
+ return h;
+ }
+
+ async function loadMappings() {
+ try {
+ const res = await fetch("/api/condition-mappings", { headers: getHeaders() });
+ const json = await res.json();
+ if (json.success) {
+ setMappings(json.data.mappings);
+ setBySource(json.data.by_source);
+ setStats(json.data.stats);
+ if (expanded.size === 0) {
+ setExpanded(new Set(Object.keys(json.data.by_source)));
+ }
+ } else {
+ setError(json.error?.message || "Failed to load");
+ }
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ useEffect(() => { loadMappings(); }, []);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!sourceName || !sourceCategory) return;
+ setSubmitting(true);
+ try {
+ const res = await fetch("/api/condition-mappings", {
+ method: "POST",
+ headers: getHeaders(),
+ body: JSON.stringify({
+ source_name: sourceName.trim().toLowerCase(),
+ source_category: sourceCategory.trim(),
+ normalized_grade: grade,
+ score_0_100: score,
+ }),
+ });
+ const json = await res.json();
+ if (json.success) {
+ setSourceName("");
+ setSourceCategory("");
+ setGrade("unknown");
+ setScore(50);
+ setSuccessMsg("Mapping saved");
+ setTimeout(() => setSuccessMsg(""), 3000);
+ loadMappings();
+ } else {
+ setError(json.error?.message || "Failed to save");
+ }
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ function toggleSource(src: string) {
+ const next = new Set(expanded);
+ next.has(src) ? next.delete(src) : next.add(src);
+ setExpanded(next);
+ }
+
+ if (loading) {
+ return <div className="flex h-full items-center justify-center"><div className="text-gray-400">Loading mappings...</div></div>;
+ }
+
+ return (
+ <div className="space-y-6">
+ <div className="flex items-center justify-between">
+ <h1 className="text-xl font-bold text-gray-100">Condition Mappings</h1>
+ <div className="flex gap-2 text-xs">
+ <button onClick={() => setExpanded(new Set(Object.keys(bySource)))}
+ className="text-omega-400 hover:text-omega-300">Expand All</button>
+ <span className="text-gray-600">|</span>
+ <button onClick={() => setExpanded(new Set())}
+ className="text-omega-400 hover:text-omega-300">Collapse All</button>
+ </div>
+ </div>
+
+ {error && (
+ <div className="rounded bg-red-900/30 p-3 text-red-300">
+ {error}
+ <button onClick={() => setError("")} className="ml-2 text-xs underline">Dismiss</button>
+ </div>
+ )}
+
+ {/* Stats */}
+ <div className="grid gap-4 md:grid-cols-3">
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-omega-400">{stats.total_mappings}</div>
+ <div className="text-xs text-gray-500">Total Mappings</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-omega-400">{stats.sources}</div>
+ <div className="text-xs text-gray-500">Sources</div>
+ </div>
+ <div className="card border-navy-700">
+ <div className="text-xs text-gray-500 mb-2">Grades Used</div>
+ <div className="flex flex-wrap gap-1">
+ {stats.grades.map((g) => (
+ <span key={g} className={`rounded px-2 py-0.5 text-xs ${gradeColor[g] || "bg-navy-800 text-gray-500"}`}>
+ {g}
+ </span>
+ ))}
+ </div>
+ </div>
+ </div>
+
+ {/* Add Form */}
+ <div className="card border-navy-700">
+ <h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">Add / Update Mapping</h2>
+ <form onSubmit={handleSubmit}>
+ <div className="grid gap-4 md:grid-cols-2">
+ <div>
+ <label className="mb-1 block text-xs text-gray-400">Source Name</label>
+ <input type="text" className="input" placeholder="e.g., chrono24"
+ value={sourceName} onChange={(e) => setSourceName(e.target.value)} required />
+ </div>
+ <div>
+ <label className="mb-1 block text-xs text-gray-400">Source Category</label>
+ <input type="text" className="input" placeholder="e.g., Very good"
+ value={sourceCategory} onChange={(e) => setSourceCategory(e.target.value)} required />
+ </div>
+ <div>
+ <label className="mb-1 block text-xs text-gray-400">Normalized Grade</label>
+ <select className="input" value={grade} onChange={(e) => setGrade(e.target.value)}>
+ {GRADES.map((g) => <option key={g} value={g}>{g}</option>)}
+ </select>
+ </div>
+ <div>
+ <label className="mb-1 block text-xs text-gray-400">Score (0-100)</label>
+ <input type="number" min={0} max={100} className="input"
+ value={score} onChange={(e) => setScore(parseInt(e.target.value) || 0)} required />
+ </div>
+ </div>
+ <div className="mt-4 flex items-center justify-between">
+ <button type="submit" disabled={submitting} className="btn-primary disabled:opacity-50">
+ {submitting ? "Saving..." : "Save Mapping"}
+ </button>
+ {successMsg && <span className="text-sm text-green-400">{successMsg}</span>}
+ </div>
+ </form>
+ </div>
+
+ {/* Grouped Mappings */}
+ <div className="space-y-3">
+ {Object.entries(bySource).sort(([a], [b]) => a.localeCompare(b)).map(([src, items]) => (
+ <div key={src} className="card border-navy-700 overflow-hidden p-0">
+ <button onClick={() => toggleSource(src)}
+ className="flex w-full items-center justify-between px-4 py-3 hover:bg-navy-800 transition-colors">
+ <div className="flex items-center gap-3">
+ <span className={`text-gray-400 transition-transform ${expanded.has(src) ? "rotate-90" : ""}`}>
+ ▶
+ </span>
+ <span className="text-sm font-semibold text-gray-100">{src}</span>
+ <span className="text-xs text-gray-500">({items.length} mappings)</span>
+ </div>
+ </button>
+ {expanded.has(src) && (
+ <div className="border-t border-navy-700">
+ <table className="w-full text-sm">
+ <thead>
+ <tr className="border-b border-navy-700 text-left text-xs uppercase text-gray-500">
+ <th className="px-4 py-2">Source Category</th>
+ <th className="px-4 py-2">Normalized Grade</th>
+ <th className="px-4 py-2">Score</th>
+ <th className="px-4 py-2">Version</th>
+ <th className="px-4 py-2">Created</th>
+ </tr>
+ </thead>
+ <tbody>
+ {(items as ConditionMapping[]).map((m) => (
+ <tr key={m.id} className="border-b border-navy-800 hover:bg-navy-800/50">
+ <td className="px-4 py-2 text-gray-200">{m.source_category}</td>
+ <td className="px-4 py-2">
+ <span className={`rounded px-2 py-0.5 text-xs ${gradeColor[m.normalized_grade] || "bg-navy-800 text-gray-500"}`}>
+ {m.normalized_grade}
+ </span>
+ </td>
+ <td className="px-4 py-2 text-gray-300">{m.score_0_100}</td>
+ <td className="px-4 py-2 text-gray-400">v{m.version}</td>
+ <td className="px-4 py-2 text-gray-500 text-xs">{formatDateTime(m.created_at)}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
diff --git a/src/app/(dashboard)/entity-resolution/page.tsx b/src/app/(dashboard)/entity-resolution/page.tsx
new file mode 100644
index 0000000..f2a0dfe
--- /dev/null
+++ b/src/app/(dashboard)/entity-resolution/page.tsx
@@ -0,0 +1,285 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { formatDateTime, formatCurrency } from "@/lib/utils";
+
+interface UnmatchedEvent {
+ event_id: number;
+ source_name: string;
+ source_listing_id: string;
+ event_type: string;
+ reference_number_raw: string;
+ sale_date: string;
+ price_amount: string;
+ currency: string;
+ condition_raw: string;
+ condition_normalized: string;
+ source_url: string;
+ created_at: string;
+}
+
+interface MatchSuggestion {
+ id: string;
+ reference_number: string;
+ collection: string;
+ model_name: string;
+ score: number;
+}
+
+export default function EntityResolutionPage() {
+ const [unmatched, setUnmatched] = useState<UnmatchedEvent[]>([]);
+ const [suggestions, setSuggestions] = useState<Record<string, MatchSuggestion[]>>({});
+ const [references, setReferences] = useState<any[]>([]);
+ const [stats, setStats] = useState<any>(null);
+ const [error, setError] = useState("");
+ const [linking, setLinking] = useState<number | null>(null);
+ const [manualRef, setManualRef] = useState("");
+
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ async function loadData() {
+ try {
+ const token = localStorage.getItem("omega_token");
+ const res = await fetch("/api/entity-resolution", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ const json = await res.json();
+ if (json.success) {
+ setUnmatched(json.data.unmatched);
+ setSuggestions(json.data.suggestions);
+ setReferences(json.data.references);
+ setStats(json.data.stats);
+ }
+ } catch (e: any) {
+ setError(e.message);
+ }
+ }
+
+ async function linkEvent(eventId: number, referenceNumber: string) {
+ try {
+ const token = localStorage.getItem("omega_token");
+ const res = await fetch("/api/entity-resolution", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ event_id: eventId, reference_number: referenceNumber }),
+ });
+ const json = await res.json();
+ if (json.success) {
+ setUnmatched((prev) => prev.filter((e) => e.event_id !== eventId));
+ setLinking(null);
+ setManualRef("");
+ }
+ } catch (e: any) {
+ setError(e.message);
+ }
+ }
+
+ const matchRate = stats
+ ? Math.round((parseInt(stats.matched) / parseInt(stats.total)) * 100)
+ : 0;
+
+ return (
+ <div className="space-y-6">
+ <div className="flex items-center justify-between">
+ <h1 className="text-xl font-bold text-gray-100">Entity Resolution Workbench</h1>
+ <button onClick={loadData} className="btn-secondary text-xs">
+ Refresh
+ </button>
+ </div>
+
+ {error && <div className="rounded bg-red-900/30 p-3 text-red-300">{error}</div>}
+
+ {/* Stats */}
+ {stats && (
+ <div className="grid gap-4 md:grid-cols-4">
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-gray-200">{stats.total}</div>
+ <div className="text-xs text-gray-500">Total Events</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-green-400">{stats.matched}</div>
+ <div className="text-xs text-gray-500">Matched</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className={`text-3xl font-bold ${parseInt(stats.unmatched) > 0 ? "text-yellow-400" : "text-green-400"}`}>
+ {stats.unmatched}
+ </div>
+ <div className="text-xs text-gray-500">Unmatched</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className={`text-3xl font-bold ${matchRate >= 95 ? "text-green-400" : matchRate >= 80 ? "text-yellow-400" : "text-red-400"}`}>
+ {matchRate}%
+ </div>
+ <div className="text-xs text-gray-500">Match Rate</div>
+ </div>
+ </div>
+ )}
+
+ {/* Dedup Pipeline Info */}
+ <div className="grid gap-4 md:grid-cols-2">
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Deduplication Pipeline
+ </h2>
+ <div className="space-y-2">
+ {[
+ { stage: "Hard Keys", desc: "(source_name, source_listing_id) unique", status: "Active" },
+ { stage: "Strong Identity", desc: "(reference_number, serial_number)", status: "Active" },
+ { stage: "Fuzzy Match", desc: "Levenshtein similarity scoring", status: "Active" },
+ { stage: "Image pHash", desc: "Perceptual hashing for near-duplicates", status: "Pending" },
+ ].map((s) => (
+ <div key={s.stage} className="flex items-center gap-3 rounded bg-navy-900 p-2">
+ <span className={`badge text-xs ${s.status === "Active" ? "badge-success" : "badge-warning"}`}>
+ {s.status}
+ </span>
+ <div>
+ <span className="text-sm text-gray-200">{s.stage}</span>
+ <span className="ml-2 text-xs text-gray-500">{s.desc}</span>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Validation Rules
+ </h2>
+ <div className="space-y-2">
+ {[
+ "Schema Constraints: Currency codes, numeric ranges, required fields",
+ "Cross-Field Consistency: Condition category vs description",
+ "Venue Semantics: Auction fee inclusion from definitions",
+ "Price Plausibility: 10x jumps, negatives, currency switches",
+ "Temporal Logic: Sale date before scrape date",
+ ].map((v, i) => (
+ <div key={i} className="flex items-center gap-2 rounded bg-navy-900 p-2">
+ <span className="h-1.5 w-1.5 rounded-full bg-green-400" />
+ <span className="text-xs text-gray-400">{v}</span>
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+
+ {/* Unmatched Events */}
+ <div className="card border-navy-700">
+ <h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Unmatched Market Events ({unmatched.length})
+ </h2>
+
+ {unmatched.length === 0 ? (
+ <div className="p-8 text-center">
+ <div className="text-4xl text-green-400">✓</div>
+ <div className="mt-2 text-lg font-medium text-green-400">All Resolved</div>
+ <div className="text-sm text-gray-500">Every market event is linked to a known reference.</div>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {unmatched.map((event) => {
+ const eventSuggestions = suggestions[event.event_id] || [];
+ const isLinking = linking === event.event_id;
+
+ return (
+ <div key={event.event_id} className="rounded-lg border border-navy-700 bg-navy-900/50 p-4">
+ <div className="flex items-start justify-between">
+ <div>
+ <div className="flex items-center gap-2">
+ <span className="font-mono text-sm text-yellow-400">{event.reference_number_raw}</span>
+ <span className={`badge text-xs ${
+ event.event_type === "auction_realized" ? "badge-success" :
+ event.event_type === "marketplace_sold" ? "badge-info" : "badge-warning"
+ }`}>
+ {event.event_type.replace(/_/g, " ")}
+ </span>
+ <span className="text-xs text-gray-500">{event.source_name}</span>
+ </div>
+ <div className="mt-1 flex items-center gap-4 text-xs text-gray-400">
+ {event.price_amount && (
+ <span>{formatCurrency(parseFloat(event.price_amount))}</span>
+ )}
+ {event.sale_date && <span>{event.sale_date}</span>}
+ {event.condition_raw && <span>{event.condition_raw}</span>}
+ </div>
+ </div>
+ <span className="text-xs text-gray-600">#{event.event_id}</span>
+ </div>
+
+ {/* Fuzzy match suggestions */}
+ {eventSuggestions.length > 0 && (
+ <div className="mt-3">
+ <div className="text-xs text-gray-500 mb-1">Suggested matches:</div>
+ <div className="flex flex-wrap gap-2">
+ {eventSuggestions.map((s) => (
+ <button
+ key={s.id}
+ onClick={() => linkEvent(event.event_id, s.reference_number)}
+ className="flex items-center gap-1 rounded border border-navy-600 bg-navy-800 px-2 py-1 text-xs transition-colors hover:border-omega-500 hover:bg-navy-700"
+ >
+ <span className="font-mono text-omega-400">{s.reference_number}</span>
+ <span className="text-gray-400">{s.collection}</span>
+ <span className={`ml-1 rounded px-1 text-[10px] ${
+ s.score > 0.8 ? "bg-green-900 text-green-400" :
+ s.score > 0.5 ? "bg-yellow-900 text-yellow-400" : "bg-gray-800 text-gray-400"
+ }`}>
+ {Math.round(s.score * 100)}%
+ </span>
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Manual link */}
+ <div className="mt-2 flex items-center gap-2">
+ {isLinking ? (
+ <>
+ <select
+ className="input w-72 text-xs"
+ value={manualRef}
+ onChange={(e) => setManualRef(e.target.value)}
+ >
+ <option value="">Select reference...</option>
+ {references.map((r: any) => (
+ <option key={r.id} value={r.reference_number}>
+ {r.reference_number} — {r.collection} {r.model_name}
+ </option>
+ ))}
+ </select>
+ <button
+ onClick={() => manualRef && linkEvent(event.event_id, manualRef)}
+ disabled={!manualRef}
+ className="btn-primary text-xs disabled:opacity-50"
+ >
+ Link
+ </button>
+ <button
+ onClick={() => { setLinking(null); setManualRef(""); }}
+ className="btn-secondary text-xs"
+ >
+ Cancel
+ </button>
+ </>
+ ) : (
+ <button
+ onClick={() => setLinking(event.event_id)}
+ className="text-xs text-gray-500 hover:text-gray-300"
+ >
+ Manual link...
+ </button>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/src/app/(dashboard)/jobs/page.tsx b/src/app/(dashboard)/jobs/page.tsx
new file mode 100644
index 0000000..73c0a8f
--- /dev/null
+++ b/src/app/(dashboard)/jobs/page.tsx
@@ -0,0 +1,351 @@
+"use client";
+
+import { useEffect, useState, useMemo } from "react";
+import { api } from "@/lib/api-client";
+import { formatDateTime } from "@/lib/utils";
+import { JobStatusChart } from "@/components/charts";
+
+interface Job {
+ run_id: number;
+ source_id: string;
+ source_name: string;
+ source_display_name: string;
+ job_type: string;
+ status: string;
+ records_fetched: number;
+ records_parsed: number;
+ records_inserted: number;
+ records_updated: number;
+ records_quarantined: number;
+ records_deduplicated: number;
+ duration_ms: number | null;
+ started_at: string;
+ completed_at: string | null;
+ parser_version: string;
+ error_message: string | null;
+}
+
+const STATUS_TAB = ["all", "completed", "running", "pending", "failed"] as const;
+
+const statusColor: Record<string, string> = {
+ completed: "badge-success",
+ running: "badge-info",
+ pending: "badge-warning",
+ failed: "badge-error",
+ cancelled: "badge-error",
+};
+
+export default function JobRunsPage() {
+ const [jobs, setJobs] = useState<Job[]>([]);
+ const [sources, setSources] = useState<any[]>([]);
+ const [dailyStats, setDailyStats] = useState<any[]>([]);
+ const [error, setError] = useState("");
+ const [statusFilter, setStatusFilter] = useState<string>("all");
+ const [sourceFilter, setSourceFilter] = useState("");
+ const [expanded, setExpanded] = useState<number | null>(null);
+ const [triggering, setTriggering] = useState(false);
+ const [triggerSource, setTriggerSource] = useState("");
+ const [triggerMsg, setTriggerMsg] = useState("");
+
+ useEffect(() => {
+ loadAll();
+ const interval = setInterval(() => loadJobs(), 15000);
+ return () => clearInterval(interval);
+ }, []);
+
+ async function loadAll() {
+ try {
+ const [j, s, ds] = await Promise.all([
+ api.getJobs({ limit: 200 }),
+ api.getSources(),
+ api.getJobsDailyStats().catch(() => []),
+ ]);
+ setJobs(j);
+ setSources(s);
+ setDailyStats(ds);
+ } catch (e: any) {
+ setError(e.message);
+ }
+ }
+
+ async function loadJobs() {
+ try {
+ const j = await api.getJobs({ limit: 200 });
+ setJobs(j);
+ } catch {}
+ }
+
+ async function triggerRun() {
+ if (!triggerSource) return;
+ setTriggering(true);
+ setTriggerMsg("");
+ try {
+ const result = await api.runJob(triggerSource);
+ setTriggerMsg(`Job #${result.run_id} queued for ${triggerSource}`);
+ setTimeout(loadJobs, 1000);
+ } catch (e: any) {
+ setTriggerMsg(`Error: ${e.message}`);
+ } finally {
+ setTriggering(false);
+ }
+ }
+
+ // Summary stats
+ const stats = useMemo(() => {
+ const total = jobs.length;
+ const completed = jobs.filter((j) => j.status === "completed").length;
+ const failed = jobs.filter((j) => j.status === "failed").length;
+ const running = jobs.filter((j) => j.status === "running").length;
+ const totalFetched = jobs.reduce((a, j) => a + (j.records_fetched || 0), 0);
+ const totalInserted = jobs.reduce((a, j) => a + (j.records_inserted || 0), 0);
+ const totalQuarantined = jobs.reduce((a, j) => a + (j.records_quarantined || 0), 0);
+ const avgDuration = jobs.filter((j) => j.duration_ms).length > 0
+ ? Math.round(jobs.filter((j) => j.duration_ms).reduce((a, j) => a + (j.duration_ms || 0), 0) / jobs.filter((j) => j.duration_ms).length)
+ : 0;
+ const successRate = total > 0 ? Math.round((completed / total) * 100) : 0;
+
+ return { total, completed, failed, running, totalFetched, totalInserted, totalQuarantined, avgDuration, successRate };
+ }, [jobs]);
+
+ // Filtered jobs
+ const filtered = useMemo(() => {
+ return jobs.filter((j) => {
+ if (statusFilter !== "all" && j.status !== statusFilter) return false;
+ if (sourceFilter && j.source_name !== sourceFilter) return false;
+ return true;
+ });
+ }, [jobs, statusFilter, sourceFilter]);
+
+ const uniqueSources = [...new Set(jobs.map((j) => j.source_name))].sort();
+ const activeSources = sources.filter((s) => s.is_active);
+
+ return (
+ <div className="space-y-6">
+ <div className="flex items-center justify-between">
+ <h1 className="text-xl font-bold text-gray-100">Collector Job Runs</h1>
+ <div className="flex gap-2">
+ <button onClick={loadJobs} className="btn-secondary text-xs">
+ Refresh
+ </button>
+ </div>
+ </div>
+
+ {error && <div className="rounded bg-red-900/30 p-3 text-red-300">{error}</div>}
+
+ {/* Summary KPIs */}
+ <div className="grid gap-4 md:grid-cols-5">
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-gray-200">{stats.total}</div>
+ <div className="text-xs text-gray-500">Total Runs</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className={`text-3xl font-bold ${stats.successRate >= 90 ? "text-green-400" : stats.successRate >= 70 ? "text-yellow-400" : "text-red-400"}`}>
+ {stats.successRate}%
+ </div>
+ <div className="text-xs text-gray-500">Success Rate</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-blue-400">{stats.totalFetched.toLocaleString()}</div>
+ <div className="text-xs text-gray-500">Records Fetched</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-green-400">{stats.totalInserted.toLocaleString()}</div>
+ <div className="text-xs text-gray-500">Records Inserted</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className={`text-3xl font-bold ${stats.totalQuarantined > 0 ? "text-yellow-400" : "text-gray-400"}`}>
+ {stats.totalQuarantined}
+ </div>
+ <div className="text-xs text-gray-500">Quarantined</div>
+ </div>
+ </div>
+
+ {/* Daily Job Status Chart */}
+ {dailyStats.length > 0 && (
+ <JobStatusChart data={dailyStats} title="Daily Job Status (30 Days)" />
+ )}
+
+ {/* Trigger Run Panel */}
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Trigger New Run
+ </h2>
+ <div className="flex items-center gap-3">
+ <select
+ className="input w-64"
+ value={triggerSource}
+ onChange={(e) => setTriggerSource(e.target.value)}
+ >
+ <option value="">Select source...</option>
+ {activeSources.map((s) => (
+ <option key={s.name} value={s.name}>
+ {s.display_name} ({s.access_method})
+ </option>
+ ))}
+ </select>
+ <button
+ onClick={triggerRun}
+ disabled={!triggerSource || triggering}
+ className="btn-primary text-xs disabled:opacity-50"
+ >
+ {triggering ? "Queuing..." : "Run Incremental"}
+ </button>
+ {triggerMsg && (
+ <span className={`text-xs ${triggerMsg.startsWith("Error") ? "text-red-400" : "text-green-400"}`}>
+ {triggerMsg}
+ </span>
+ )}
+ </div>
+ </div>
+
+ {/* Filters */}
+ <div className="flex items-center gap-3">
+ {/* Status tabs */}
+ <div className="flex rounded-lg border border-navy-700 bg-navy-900 p-0.5">
+ {STATUS_TAB.map((tab) => (
+ <button
+ key={tab}
+ onClick={() => setStatusFilter(tab)}
+ className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
+ statusFilter === tab
+ ? "bg-omega-500 text-white"
+ : "text-gray-400 hover:text-gray-200"
+ }`}
+ >
+ {tab === "all" ? "All" : tab.charAt(0).toUpperCase() + tab.slice(1)}
+ {tab !== "all" && (
+ <span className="ml-1 text-[10px] opacity-70">
+ ({jobs.filter((j) => j.status === tab).length})
+ </span>
+ )}
+ </button>
+ ))}
+ </div>
+
+ <select
+ className="input w-48"
+ value={sourceFilter}
+ onChange={(e) => setSourceFilter(e.target.value)}
+ >
+ <option value="">All Sources</option>
+ {uniqueSources.map((s) => (
+ <option key={s} value={s}>{s}</option>
+ ))}
+ </select>
+
+ <span className="text-xs text-gray-500">
+ Showing {filtered.length} of {jobs.length} runs
+ </span>
+ </div>
+
+ {/* Jobs Table */}
+ <div className="card border-navy-700 p-0">
+ <div className="overflow-x-auto">
+ <table className="w-full text-sm">
+ <thead>
+ <tr className="border-b border-navy-700 text-left text-xs uppercase text-gray-500">
+ <th className="p-3">Run</th>
+ <th className="p-3">Source</th>
+ <th className="p-3">Type</th>
+ <th className="p-3">Status</th>
+ <th className="p-3 text-right">Fetched</th>
+ <th className="p-3 text-right">Inserted</th>
+ <th className="p-3 text-right">Quarantined</th>
+ <th className="p-3 text-right">Duration</th>
+ <th className="p-3">Started</th>
+ <th className="p-3">Parser</th>
+ </tr>
+ </thead>
+ <tbody>
+ {filtered.length === 0 ? (
+ <tr>
+ <td colSpan={10} className="p-6 text-center text-gray-500">
+ {jobs.length === 0
+ ? "No job runs yet. Configure collectors and trigger a run."
+ : "No runs match the current filters."}
+ </td>
+ </tr>
+ ) : (
+ filtered.map((j) => (
+ <>
+ <tr
+ key={j.run_id}
+ className={`cursor-pointer border-b border-navy-800 transition-colors hover:bg-navy-900 ${
+ expanded === j.run_id ? "bg-navy-900" : ""
+ }`}
+ onClick={() => setExpanded(expanded === j.run_id ? null : j.run_id)}
+ >
+ <td className="p-3 font-mono text-xs text-omega-400">#{j.run_id}</td>
+ <td className="p-3 font-medium text-gray-200">{j.source_display_name}</td>
+ <td className="p-3">
+ <span className={`badge ${j.job_type === "full" ? "badge-warning" : "badge-info"}`}>
+ {j.job_type}
+ </span>
+ </td>
+ <td className="p-3">
+ <span className={`badge ${statusColor[j.status] || "badge-info"}`}>
+ {j.status}
+ </span>
+ </td>
+ <td className="p-3 text-right text-gray-400">{j.records_fetched || 0}</td>
+ <td className="p-3 text-right text-gray-400">{j.records_inserted || 0}</td>
+ <td className="p-3 text-right">
+ <span className={j.records_quarantined > 0 ? "text-yellow-400 font-medium" : "text-gray-400"}>
+ {j.records_quarantined || 0}
+ </span>
+ </td>
+ <td className="p-3 text-right text-gray-400">
+ {j.duration_ms ? `${(j.duration_ms / 1000).toFixed(1)}s` : "-"}
+ </td>
+ <td className="p-3 text-xs text-gray-500">
+ {j.started_at ? formatDateTime(j.started_at) : "-"}
+ </td>
+ <td className="p-3 font-mono text-xs text-gray-500">{j.parser_version}</td>
+ </tr>
+ {expanded === j.run_id && (
+ <tr key={`${j.run_id}-detail`} className="border-b border-navy-800 bg-navy-900/50">
+ <td colSpan={10} className="p-4">
+ <div className="grid gap-4 md:grid-cols-4">
+ <div className="rounded bg-navy-900 p-3">
+ <div className="text-xs text-gray-500">Records Parsed</div>
+ <div className="mt-1 text-lg font-bold text-gray-200">{j.records_parsed || 0}</div>
+ </div>
+ <div className="rounded bg-navy-900 p-3">
+ <div className="text-xs text-gray-500">Records Updated</div>
+ <div className="mt-1 text-lg font-bold text-blue-400">{j.records_updated || 0}</div>
+ </div>
+ <div className="rounded bg-navy-900 p-3">
+ <div className="text-xs text-gray-500">Deduplicated</div>
+ <div className="mt-1 text-lg font-bold text-purple-400">{j.records_deduplicated || 0}</div>
+ </div>
+ <div className="rounded bg-navy-900 p-3">
+ <div className="text-xs text-gray-500">Completed At</div>
+ <div className="mt-1 text-sm text-gray-300">
+ {j.completed_at ? formatDateTime(j.completed_at) : "In progress..."}
+ </div>
+ </div>
+ </div>
+ {j.error_message && (
+ <div className="mt-3 rounded bg-red-900/20 p-3">
+ <div className="text-xs font-medium text-red-400">Error</div>
+ <pre className="mt-1 whitespace-pre-wrap text-xs text-red-300">{j.error_message}</pre>
+ </div>
+ )}
+ <div className="mt-3 flex items-center gap-4 text-xs text-gray-500">
+ <span>Source ID: {j.source_id?.substring(0, 8)}...</span>
+ <span>Parser: {j.parser_version}</span>
+ {j.duration_ms && <span>Avg {((j.records_fetched || 0) / (j.duration_ms / 1000)).toFixed(0)} records/sec</span>}
+ </div>
+ </td>
+ </tr>
+ )}
+ </>
+ ))
+ )}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx
new file mode 100644
index 0000000..a18765e
--- /dev/null
+++ b/src/app/(dashboard)/layout.tsx
@@ -0,0 +1,20 @@
+import Sidebar from "@/components/layout/Sidebar";
+import Header from "@/components/layout/Header";
+
+export default function DashboardLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+ <div className="flex h-screen overflow-hidden">
+ <Sidebar />
+ <div className="flex flex-1 flex-col overflow-hidden">
+ <Header />
+ <main className="flex-1 overflow-y-auto bg-navy-950 p-6">
+ {children}
+ </main>
+ </div>
+ </div>
+ );
+}
diff --git a/src/app/(dashboard)/models/page.tsx b/src/app/(dashboard)/models/page.tsx
new file mode 100644
index 0000000..c8aff50
--- /dev/null
+++ b/src/app/(dashboard)/models/page.tsx
@@ -0,0 +1,346 @@
+"use client";
+
+import { useEffect, useState, useMemo } from "react";
+import { api } from "@/lib/api-client";
+import { formatCurrency, formatDate } from "@/lib/utils";
+import dynamic from "next/dynamic";
+
+const PriceTimeSeries = dynamic(() => import("@/components/charts/PriceTimeSeries"), { ssr: false });
+const ConditionBreakdown = dynamic(() => import("@/components/charts/ConditionBreakdown"), { ssr: false });
+
+interface WatchRef {
+ id: string;
+ brand: string;
+ collection: string;
+ model_name: string;
+ reference_number: string;
+ calibre: string;
+ case_material: string;
+ case_diameter_mm: string;
+ dial_color: string;
+ year_introduced: number;
+ notes: string;
+ event_count: string;
+ msrp_count: string;
+ latest_msrp: string | null;
+ avg_market_price: string | null;
+ latest_sale_price: string | null;
+ latest_sale_date: string | null;
+}
+
+interface Summary {
+ total_references: string;
+ total_events: string;
+ total_snapshots: string;
+ collection_count: string;
+}
+
+export default function ModelDashboardPage() {
+ const [references, setReferences] = useState<WatchRef[]>([]);
+ const [summary, setSummary] = useState<Summary | null>(null);
+ const [error, setError] = useState("");
+ const [search, setSearch] = useState("");
+ const [collection, setCollection] = useState("");
+ const [selected, setSelected] = useState<WatchRef | null>(null);
+ const [detail, setDetail] = useState<any>(null);
+
+ useEffect(() => {
+ load();
+ }, [collection]);
+
+ async function load() {
+ try {
+ const params: any = {};
+ if (collection) params.collection = collection;
+ const token = localStorage.getItem("omega_token");
+ const qs = new URLSearchParams(params).toString();
+ const res = await fetch(`/api/references${qs ? `?${qs}` : ""}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ const json = await res.json();
+ if (json.success) {
+ setReferences(json.data);
+ setSummary(json.summary);
+ }
+ } catch (e: any) {
+ setError(e.message);
+ }
+ }
+
+ async function loadDetail(ref: WatchRef) {
+ setSelected(ref);
+ try {
+ const [msrp, ts] = await Promise.all([
+ api.getMSRP(ref.reference_number),
+ api.getTimeseries(ref.reference_number, { include_asks: "true" }),
+ ]);
+ setDetail({ msrp, timeseries: ts });
+ } catch {
+ setDetail(null);
+ }
+ }
+
+ // Build chart data from detail
+ const chartData = useMemo(() => {
+ if (!detail) return [];
+ const points: any[] = [];
+ // Add MSRP points
+ (detail.timeseries?.msrp_history || []).forEach((m: any) => {
+ points.push({ date: m.date, msrp: parseFloat(m.value), source: "MSRP" });
+ });
+ // Add market event points
+ (detail.timeseries?.market_events || []).forEach((e: any) => {
+ points.push({
+ date: e.date,
+ price: parseFloat(e.value),
+ event_type: e.event_type,
+ source: e.source,
+ });
+ });
+ // Sort by date and merge same-date MSRP+price
+ points.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
+ const merged: any[] = [];
+ let lastMsrp: number | undefined;
+ for (const p of points) {
+ if (p.msrp) lastMsrp = p.msrp;
+ merged.push({ ...p, msrp: p.msrp || lastMsrp });
+ }
+ return merged;
+ }, [detail]);
+
+ // Build condition breakdown from events
+ const conditionData = useMemo(() => {
+ if (!detail?.timeseries?.market_events) return [];
+ const counts: Record<string, { count: number; total: number }> = {};
+ for (const e of detail.timeseries.market_events) {
+ const cond = e.condition || "unknown";
+ if (!counts[cond]) counts[cond] = { count: 0, total: 0 };
+ counts[cond].count++;
+ counts[cond].total += parseFloat(e.value) || 0;
+ }
+ return Object.entries(counts).map(([condition, { count, total }]) => ({
+ condition,
+ count,
+ avg_price: Math.round(total / count),
+ }));
+ }, [detail]);
+
+ const collections = [...new Set(references.map((r) => r.collection))];
+ const filtered = search
+ ? references.filter(
+ (r) =>
+ r.reference_number.toLowerCase().includes(search.toLowerCase()) ||
+ r.model_name.toLowerCase().includes(search.toLowerCase()) ||
+ r.collection.toLowerCase().includes(search.toLowerCase())
+ )
+ : references;
+
+ return (
+ <div className="space-y-6">
+ <div className="flex items-center justify-between">
+ <h1 className="text-xl font-bold text-gray-100">Watch References</h1>
+ <div className="flex gap-2">
+ <select
+ className="input w-40"
+ value={collection}
+ onChange={(e) => setCollection(e.target.value)}
+ >
+ <option value="">All Collections</option>
+ {collections.map((c) => (
+ <option key={c} value={c}>{c}</option>
+ ))}
+ </select>
+ <input
+ type="text"
+ placeholder="Search references..."
+ className="input w-64"
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ />
+ </div>
+ </div>
+
+ {error && <div className="rounded bg-red-900/30 p-3 text-red-300">{error}</div>}
+
+ {summary && (
+ <div className="grid gap-4 md:grid-cols-4">
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-omega-400">{summary.total_references}</div>
+ <div className="text-sm text-gray-500">Watch References</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-blue-400">{summary.total_events}</div>
+ <div className="text-sm text-gray-500">Market Events</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-green-400">{summary.total_snapshots}</div>
+ <div className="text-sm text-gray-500">MSRP Snapshots</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-purple-400">{summary.collection_count}</div>
+ <div className="text-sm text-gray-500">Collections</div>
+ </div>
+ </div>
+ )}
+
+ <div className="card border-navy-700">
+ <h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Reference Catalog ({filtered.length})
+ </h2>
+ <div className="overflow-x-auto">
+ <table className="w-full text-sm">
+ <thead>
+ <tr className="border-b border-navy-700 text-left text-xs uppercase text-gray-500">
+ <th className="pb-2 pr-3">Reference</th>
+ <th className="pb-2 pr-3">Collection</th>
+ <th className="pb-2 pr-3">Model</th>
+ <th className="pb-2 pr-3">Calibre</th>
+ <th className="pb-2 pr-3">Size</th>
+ <th className="pb-2 pr-3">Dial</th>
+ <th className="pb-2 pr-3 text-right">MSRP</th>
+ <th className="pb-2 pr-3 text-right">Avg Market</th>
+ <th className="pb-2 pr-3 text-right">Events</th>
+ <th className="pb-2 text-right">Year</th>
+ </tr>
+ </thead>
+ <tbody>
+ {filtered.map((ref) => {
+ const msrp = ref.latest_msrp ? parseFloat(ref.latest_msrp) : null;
+ const avg = ref.avg_market_price ? parseFloat(ref.avg_market_price) : null;
+ const premium = msrp && avg ? ((avg - msrp) / msrp) * 100 : null;
+
+ return (
+ <tr
+ key={ref.id}
+ className={`cursor-pointer border-b border-navy-800 transition-colors hover:bg-navy-900 ${
+ selected?.id === ref.id ? "bg-navy-900" : ""
+ }`}
+ onClick={() => loadDetail(ref)}
+ >
+ <td className="py-2.5 pr-3 font-mono text-xs text-omega-400">
+ {ref.reference_number}
+ </td>
+ <td className="py-2.5 pr-3">
+ <span className="badge badge-info">{ref.collection}</span>
+ </td>
+ <td className="py-2.5 pr-3 font-medium text-gray-200">{ref.model_name}</td>
+ <td className="py-2.5 pr-3 text-gray-400">{ref.calibre}</td>
+ <td className="py-2.5 pr-3 text-gray-400">{ref.case_diameter_mm}mm</td>
+ <td className="py-2.5 pr-3 text-gray-400">{ref.dial_color}</td>
+ <td className="py-2.5 pr-3 text-right text-gray-200">
+ {msrp ? formatCurrency(msrp) : "—"}
+ </td>
+ <td className="py-2.5 pr-3 text-right">
+ {avg ? (
+ <span className={premium && premium > 0 ? "text-green-400" : premium && premium < -5 ? "text-red-400" : "text-gray-200"}>
+ {formatCurrency(avg)}
+ {premium !== null && (
+ <span className="ml-1 text-xs">
+ ({premium > 0 ? "+" : ""}{premium.toFixed(0)}%)
+ </span>
+ )}
+ </span>
+ ) : "—"}
+ </td>
+ <td className="py-2.5 pr-3 text-right text-gray-400">{ref.event_count}</td>
+ <td className="py-2.5 text-right text-gray-500">{ref.year_introduced}</td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ {/* Detail panel with charts */}
+ {selected && detail && (
+ <>
+ {/* Price Chart */}
+ {chartData.length > 0 && (
+ <PriceTimeSeries
+ data={chartData}
+ title={`${selected.model_name} — Price History`}
+ showMSRP={true}
+ />
+ )}
+
+ <div className="grid gap-4 md:grid-cols-2">
+ {/* Condition breakdown donut */}
+ {conditionData.length > 0 && (
+ <ConditionBreakdown
+ data={conditionData}
+ title="Condition Distribution"
+ />
+ )}
+
+ {/* Market Events list */}
+ <div className="card border-navy-700">
+ <h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Recent Market Events ({detail.timeseries?.market_events?.length || 0})
+ </h3>
+ {detail.timeseries?.market_events?.length > 0 ? (
+ <div className="space-y-2 max-h-80 overflow-y-auto">
+ {detail.timeseries.market_events.map((ev: any, i: number) => (
+ <div key={i} className="flex items-center justify-between rounded bg-navy-900 p-2">
+ <div>
+ <span className={`badge text-xs ${
+ ev.event_type === "auction_realized" ? "badge-success" :
+ ev.event_type === "marketplace_sold" ? "badge-info" :
+ ev.event_type === "dealer_ask" ? "badge-warning" : "badge-info"
+ }`}>
+ {ev.event_type.replace(/_/g, " ")}
+ </span>
+ <span className="ml-2 text-xs text-gray-500">{ev.source}</span>
+ </div>
+ <div className="text-right">
+ <div className="font-mono text-gray-200">
+ {ev.value ? formatCurrency(parseFloat(ev.value)) : "—"}
+ </div>
+ <div className="text-xs text-gray-500">
+ {ev.date ? formatDate(ev.date) : ""}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : (
+ <div className="text-center text-sm text-gray-600">No market events recorded</div>
+ )}
+ </div>
+ </div>
+
+ {/* MSRP detail */}
+ <div className="card border-navy-700">
+ <h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ MSRP Snapshots — {selected.reference_number}
+ </h3>
+ {detail.msrp?.history?.length > 0 ? (
+ <div className="grid gap-2 md:grid-cols-5">
+ {detail.msrp.history.map((h: any, i: number) => {
+ const prev = i > 0 ? parseFloat(detail.msrp.history[i - 1].msrp_amount) : null;
+ const curr = parseFloat(h.msrp_amount);
+ const change = prev ? curr - prev : 0;
+ return (
+ <div key={i} className="rounded bg-navy-900 p-3 text-center">
+ <div className="text-xs text-gray-500">{formatDate(h.captured_date)}</div>
+ <div className="mt-1 text-lg font-bold text-gray-200">{formatCurrency(curr)}</div>
+ {change !== 0 && (
+ <div className={`text-xs ${change > 0 ? "text-green-400" : "text-red-400"}`}>
+ {change > 0 ? "+" : ""}{formatCurrency(change)}
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ ) : (
+ <div className="text-center text-sm text-gray-600">
+ {selected.year_introduced < 2000 ? "Vintage/discontinued — no current MSRP" : "No MSRP snapshots yet"}
+ </div>
+ )}
+ </div>
+ </>
+ )}
+ </div>
+ );
+}
diff --git a/src/app/(dashboard)/msrp/page.tsx b/src/app/(dashboard)/msrp/page.tsx
new file mode 100644
index 0000000..37f0d03
--- /dev/null
+++ b/src/app/(dashboard)/msrp/page.tsx
@@ -0,0 +1,223 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { api } from "@/lib/api-client";
+import { formatCurrency, formatDate } from "@/lib/utils";
+
+interface WatchRef {
+ id: string;
+ reference_number: string;
+ model_name: string;
+ collection: string;
+ latest_msrp: string | null;
+ msrp_count: string;
+}
+
+export default function MSRPDashboardPage() {
+ const [references, setReferences] = useState<WatchRef[]>([]);
+ const [selected, setSelected] = useState<string>("");
+ const [msrpData, setMsrpData] = useState<any>(null);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ loadRefs();
+ }, []);
+
+ useEffect(() => {
+ if (selected) loadMSRP(selected);
+ }, [selected]);
+
+ async function loadRefs() {
+ try {
+ const token = localStorage.getItem("omega_token");
+ const res = await fetch("/api/references", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ const json = await res.json();
+ if (json.success) {
+ const withMsrp = json.data.filter((r: any) => parseInt(r.msrp_count) > 0);
+ setReferences(withMsrp);
+ if (withMsrp.length > 0) setSelected(withMsrp[0].reference_number);
+ }
+ } catch (e: any) {
+ setError(e.message);
+ }
+ }
+
+ async function loadMSRP(refNum: string) {
+ try {
+ const data = await api.getMSRP(refNum);
+ setMsrpData(data);
+ } catch (e: any) {
+ setError(e.message);
+ }
+ }
+
+ const ref = msrpData?.reference;
+ const currentMSRP = msrpData?.current_msrp || [];
+ const history = msrpData?.history || [];
+
+ return (
+ <div className="space-y-6">
+ <div className="flex items-center justify-between">
+ <h1 className="text-xl font-bold text-gray-100">MSRP Ledger</h1>
+ <select
+ className="input w-96"
+ value={selected}
+ onChange={(e) => setSelected(e.target.value)}
+ >
+ <option value="">Select a reference...</option>
+ {references.map((r) => (
+ <option key={r.id} value={r.reference_number}>
+ {r.reference_number} — {r.collection} {r.model_name}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ {error && <div className="rounded bg-red-900/30 p-3 text-red-300">{error}</div>}
+
+ <div className="card border-navy-700">
+ <div className="mb-4 flex items-center gap-4">
+ <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-omega-500/10">
+ <span className="text-xl font-bold text-omega-400">M</span>
+ </div>
+ <div>
+ <div className="font-semibold text-gray-200">MSRP Ledger (Ledger 1)</div>
+ <div className="text-xs text-gray-500">
+ Immutable daily manufacturer suggested retail price snapshots
+ </div>
+ </div>
+ <div className="ml-auto text-right">
+ <div className="text-2xl font-bold text-omega-400">{references.length}</div>
+ <div className="text-xs text-gray-500">References with MSRP</div>
+ </div>
+ </div>
+ </div>
+
+ {ref && (
+ <>
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ {ref.reference_number} — {ref.model_name}
+ </h2>
+ <div className="grid gap-3 md:grid-cols-4">
+ <div className="rounded bg-navy-900 p-3">
+ <div className="text-xs text-gray-500">Collection</div>
+ <div className="mt-1 font-medium text-gray-200">{ref.collection}</div>
+ </div>
+ <div className="rounded bg-navy-900 p-3">
+ <div className="text-xs text-gray-500">Calibre</div>
+ <div className="mt-1 font-medium text-gray-200">{ref.calibre}</div>
+ </div>
+ <div className="rounded bg-navy-900 p-3">
+ <div className="text-xs text-gray-500">Case</div>
+ <div className="mt-1 font-medium text-gray-200">{ref.case_material} {ref.case_diameter_mm}mm</div>
+ </div>
+ <div className="rounded bg-navy-900 p-3">
+ <div className="text-xs text-gray-500">Dial</div>
+ <div className="mt-1 font-medium text-gray-200">{ref.dial_color}</div>
+ </div>
+ </div>
+ </div>
+
+ <div className="grid gap-4 md:grid-cols-2">
+ <div className="card border-navy-700">
+ <h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Current MSRP by Region
+ </h3>
+ {currentMSRP.length > 0 ? (
+ <div className="space-y-2">
+ {currentMSRP.map((m: any, i: number) => (
+ <div key={i} className="flex items-center justify-between rounded bg-navy-900 p-3">
+ <div>
+ <span className="badge badge-info">{m.region_code}</span>
+ <span className="ml-2 text-xs text-gray-500">{m.currency?.trim()}</span>
+ </div>
+ <div className="text-right">
+ <div className="text-lg font-bold text-gray-200">
+ {formatCurrency(parseFloat(m.msrp_amount), m.currency?.trim())}
+ </div>
+ <div className="text-xs text-gray-500">as of {formatDate(m.captured_date)}</div>
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : (
+ <div className="text-center text-sm text-gray-600">No current MSRP data</div>
+ )}
+ </div>
+
+ <div className="card border-navy-700">
+ <h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ MSRP Timeline (US Region)
+ </h3>
+ {history.length > 0 ? (
+ <div className="space-y-1">
+ {history.map((h: any, i: number) => {
+ const prev = i > 0 ? parseFloat(history[i - 1].msrp_amount) : null;
+ const curr = parseFloat(h.msrp_amount);
+ const change = prev ? curr - prev : 0;
+ return (
+ <div key={i} className="flex items-center justify-between rounded bg-navy-900 p-2">
+ <div className="text-xs text-gray-500">{formatDate(h.captured_date)}</div>
+ <div className="flex items-center gap-3">
+ <span className="font-mono text-gray-200">{formatCurrency(curr)}</span>
+ {change !== 0 && (
+ <span className={`text-xs ${change > 0 ? "text-green-400" : "text-red-400"}`}>
+ {change > 0 ? "+" : ""}{formatCurrency(change)}
+ </span>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ ) : (
+ <div className="text-center text-sm text-gray-600">No MSRP history</div>
+ )}
+ </div>
+ </div>
+ </>
+ )}
+
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ All References with MSRP Data
+ </h2>
+ <div className="overflow-x-auto">
+ <table className="w-full text-sm">
+ <thead>
+ <tr className="border-b border-navy-700 text-left text-xs uppercase text-gray-500">
+ <th className="pb-2 pr-3">Reference</th>
+ <th className="pb-2 pr-3">Collection</th>
+ <th className="pb-2 pr-3">Model</th>
+ <th className="pb-2 pr-3 text-right">Latest MSRP</th>
+ <th className="pb-2 text-right">Snapshots</th>
+ </tr>
+ </thead>
+ <tbody>
+ {references.map((r) => (
+ <tr
+ key={r.id}
+ className={`cursor-pointer border-b border-navy-800 transition-colors hover:bg-navy-900 ${
+ selected === r.reference_number ? "bg-navy-900" : ""
+ }`}
+ onClick={() => setSelected(r.reference_number)}
+ >
+ <td className="py-2 pr-3 font-mono text-xs text-omega-400">{r.reference_number}</td>
+ <td className="py-2 pr-3"><span className="badge badge-info">{r.collection}</span></td>
+ <td className="py-2 pr-3 text-gray-200">{r.model_name}</td>
+ <td className="py-2 pr-3 text-right text-gray-200">
+ {r.latest_msrp ? formatCurrency(parseFloat(r.latest_msrp)) : "—"}
+ </td>
+ <td className="py-2 text-right text-gray-400">{r.msrp_count}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx
new file mode 100644
index 0000000..d0b6635
--- /dev/null
+++ b/src/app/(dashboard)/page.tsx
@@ -0,0 +1,428 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { api } from "@/lib/api-client";
+import { formatDateTime, formatCurrency } from "@/lib/utils";
+import { CollectionPriceChart, MarketActivityChart, TopMoversChart, SourceHealthDonut } from "@/components/charts";
+
+interface HealthData {
+ status: string;
+ version: string;
+ uptime_seconds: number;
+ database: { ok: boolean; latency: number; connections: { total: number; idle: number; waiting: number } };
+ timestamp: string;
+}
+
+function StatCard({ label, value, sub, color = "text-gray-100" }: { label: string; value: string | number; sub?: string; color?: string }) {
+ return (
+ <div className="card border-navy-700">
+ <div className="text-xs uppercase tracking-wider text-gray-500">{label}</div>
+ <div className={`mt-1 text-2xl font-bold ${color}`}>{value}</div>
+ {sub && <div className="mt-1 text-xs text-gray-500">{sub}</div>}
+ </div>
+ );
+}
+
+export default function SystemHealthPage() {
+ const [health, setHealth] = useState<HealthData | null>(null);
+ const [sources, setSources] = useState<any[]>([]);
+ const [summary, setSummary] = useState<any>(null);
+ const [dashboard, setDashboard] = useState<any>(null);
+ const [chartData, setChartData] = useState<any>(null);
+ const [refreshing, setRefreshing] = useState(false);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ async function load() {
+ try {
+ const token = localStorage.getItem("omega_token");
+ const headers = { Authorization: `Bearer ${token}` };
+
+ const [h, s, refRes, dashRes, chartRes] = await Promise.all([
+ api.health(),
+ api.getSources(),
+ fetch("/api/references", { headers }).then((r) => r.json()),
+ fetch("/api/dashboard", { headers }).then((r) => r.json()),
+ api.getDashboardChartData().catch(() => null),
+ ]);
+
+ setHealth(h);
+ setSources(s);
+ if (refRes.success) setSummary(refRes.summary);
+ if (dashRes.success) setDashboard(dashRes.data);
+ if (chartRes) setChartData(chartRes);
+ } catch (e: any) {
+ setError(e.message);
+ }
+ }
+ load();
+ const interval = setInterval(load, 30000);
+ return () => clearInterval(interval);
+ }, []);
+
+ async function refreshViews() {
+ setRefreshing(true);
+ try {
+ const token = localStorage.getItem("omega_token");
+ await fetch("/api/admin/refresh-views", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ } catch {}
+ setRefreshing(false);
+ }
+
+ if (error) {
+ return <div className="rounded bg-red-900/30 p-4 text-red-300">{error}</div>;
+ }
+
+ const uptimeMin = health ? Math.round(health.uptime_seconds / 60) : 0;
+ const uptimeStr = uptimeMin >= 60 ? `${Math.floor(uptimeMin / 60)}h ${uptimeMin % 60}m` : `${uptimeMin}m`;
+ const activeSources = sources.filter((s: any) => s.is_active).length;
+ const totalRuns = sources.reduce((acc: number, s: any) => acc + (parseInt(s.total_runs) || 0), 0);
+ const failedRuns = sources.reduce((acc: number, s: any) => acc + (parseInt(s.failed_runs) || 0), 0);
+ const totalRecords = sources.reduce((acc: number, s: any) => acc + (parseInt(s.total_records_inserted) || 0), 0);
+ const s24 = dashboard?.stats_24h;
+
+ return (
+ <div className="space-y-6">
+ <div className="flex items-center justify-between">
+ <h1 className="text-xl font-bold text-gray-100">Omega Watches 2.0 — Control Plane</h1>
+ <div className="flex items-center gap-3">
+ <button
+ onClick={refreshViews}
+ disabled={refreshing}
+ className="btn-secondary text-xs disabled:opacity-50"
+ >
+ {refreshing ? "Refreshing..." : "Refresh Views"}
+ </button>
+ <span className={`badge ${health?.status === "healthy" ? "badge-success" : "badge-warning"}`}>
+ {health?.status || "loading..."}
+ </span>
+ </div>
+ </div>
+
+ {/* Primary KPIs */}
+ <div className="grid grid-cols-2 gap-4 md:grid-cols-5">
+ <StatCard
+ label="Watch References"
+ value={summary?.total_references || "—"}
+ sub="Across all collections"
+ color="text-omega-400"
+ />
+ <StatCard
+ label="Market Events"
+ value={summary?.total_events || "—"}
+ sub="Auctions, sales, asks"
+ color="text-blue-400"
+ />
+ <StatCard
+ label="MSRP Snapshots"
+ value={summary?.total_snapshots || "—"}
+ sub="Daily price observations"
+ color="text-green-400"
+ />
+ <StatCard
+ label="Active Sources"
+ value={activeSources}
+ sub={`${sources.length} total configured`}
+ />
+ <StatCard
+ label="Collector Runs"
+ value={totalRuns}
+ sub={failedRuns > 0 ? `${failedRuns} failed` : "All healthy"}
+ color={failedRuns > 0 ? "text-yellow-400" : "text-gray-100"}
+ />
+ </div>
+
+ {/* 24-Hour Stats + System Health */}
+ <div className="grid gap-4 md:grid-cols-4">
+ <StatCard
+ label="Database"
+ value={health?.database.ok ? "Connected" : "Down"}
+ sub={health ? `${health.database.latency}ms latency` : ""}
+ color={health?.database.ok ? "text-green-400" : "text-red-400"}
+ />
+ <StatCard label="Uptime" value={uptimeStr} sub={health ? formatDateTime(health.timestamp) : ""} />
+ <StatCard
+ label="24h Inserted"
+ value={s24?.inserted_24h || 0}
+ sub={s24 ? `${s24.runs_24h} runs (${s24.failed_24h} failed)` : ""}
+ color="text-blue-400"
+ />
+ <StatCard
+ label="24h Quarantined"
+ value={s24?.quarantined_24h || 0}
+ sub="Flagged for review"
+ color={parseInt(s24?.quarantined_24h || "0") > 0 ? "text-yellow-400" : "text-green-400"}
+ />
+ </div>
+
+ {/* Charts Grid */}
+ <div className="grid gap-4 md:grid-cols-2">
+ <CollectionPriceChart
+ data={(dashboard?.collection_summary || [])
+ .filter((c: any) => c.avg_market_price)
+ .map((c: any) => ({
+ collection: c.collection,
+ avg: parseFloat(c.avg_market_price),
+ min: parseFloat(c.min_price),
+ max: parseFloat(c.max_price),
+ count: parseInt(c.event_count),
+ }))}
+ title="Collection Price Comparison"
+ />
+ <MarketActivityChart
+ data={chartData?.daily_activity || []}
+ title="Market Activity (30 Days)"
+ />
+ <TopMoversChart
+ data={(dashboard?.top_movers || []).map((m: any) => ({
+ label: m.reference_number,
+ collection: m.collection,
+ model: m.model_name,
+ stddev: parseFloat(m.price_stddev) || 0,
+ avg_price: parseFloat(m.avg_price),
+ events: parseInt(m.events_30d),
+ }))}
+ title="Top Price Movers (30d Volatility)"
+ />
+ <SourceHealthDonut
+ data={chartData?.source_health || []}
+ title="Source Health Overview"
+ />
+ </div>
+
+ {/* Collection Price Summary + Recent Activity */}
+ <div className="grid gap-4 md:grid-cols-2">
+ {/* Collection Summary */}
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Collection Price Summary
+ </h2>
+ <div className="space-y-2">
+ {(dashboard?.collection_summary || []).map((c: any) => (
+ <div key={c.collection} className="flex items-center justify-between rounded bg-navy-900 p-3">
+ <div>
+ <div className="text-sm font-medium text-gray-200">{c.collection}</div>
+ <div className="text-xs text-gray-500">
+ {c.ref_count} refs · {c.event_count} events
+ </div>
+ </div>
+ <div className="text-right">
+ {c.avg_market_price ? (
+ <>
+ <div className="text-sm font-bold text-omega-400">
+ {formatCurrency(parseFloat(c.avg_market_price))}
+ </div>
+ <div className="text-xs text-gray-500">
+ {formatCurrency(parseFloat(c.min_price))} – {formatCurrency(parseFloat(c.max_price))}
+ </div>
+ </>
+ ) : (
+ <span className="text-xs text-gray-500">No market data</span>
+ )}
+ </div>
+ </div>
+ ))}
+ {(!dashboard?.collection_summary || dashboard.collection_summary.length === 0) && (
+ <div className="p-4 text-center text-sm text-gray-500">No collection data yet</div>
+ )}
+ </div>
+ </div>
+
+ {/* Recent Activity */}
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Recent Collector Runs
+ </h2>
+ <div className="space-y-2">
+ {(dashboard?.recent_runs || []).slice(0, 8).map((r: any) => (
+ <div key={r.run_id} className="flex items-center justify-between rounded bg-navy-900 p-2">
+ <div className="flex items-center gap-2">
+ <span className={`badge text-xs ${
+ r.status === "completed" ? "badge-success" :
+ r.status === "running" ? "badge-info" :
+ r.status === "failed" ? "badge-error" : "badge-warning"
+ }`}>
+ {r.status}
+ </span>
+ <div>
+ <span className="text-sm text-gray-200">{r.source_name}</span>
+ <span className="ml-2 text-xs text-gray-500">{r.job_type}</span>
+ </div>
+ </div>
+ <div className="text-right">
+ <div className="text-xs text-gray-400">
+ +{r.records_inserted || 0} records
+ {r.records_quarantined > 0 && (
+ <span className="ml-1 text-yellow-400">({r.records_quarantined} flagged)</span>
+ )}
+ </div>
+ <div className="text-xs text-gray-600">
+ {r.started_at ? formatDateTime(r.started_at) : "—"}
+ </div>
+ </div>
+ </div>
+ ))}
+ {(!dashboard?.recent_runs || dashboard.recent_runs.length === 0) && (
+ <div className="p-4 text-center text-sm text-gray-500">No runs yet</div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* Top Movers + Quarantine Alerts */}
+ <div className="grid gap-4 md:grid-cols-2">
+ {/* Top Movers */}
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Top Price Movers (30d)
+ </h2>
+ <div className="space-y-2">
+ {(dashboard?.top_movers || []).map((m: any) => (
+ <div key={m.reference_number} className="flex items-center justify-between rounded bg-navy-900 p-3">
+ <div>
+ <div className="font-mono text-sm text-omega-400">{m.reference_number}</div>
+ <div className="text-xs text-gray-500">{m.collection} — {m.model_name}</div>
+ </div>
+ <div className="text-right">
+ <div className="text-sm text-gray-200">{formatCurrency(parseFloat(m.avg_price))}</div>
+ <div className="text-xs text-gray-500">
+ {m.events_30d} events · {m.price_stddev ? `\u00B1${formatCurrency(parseFloat(m.price_stddev))}` : "—"}
+ </div>
+ </div>
+ </div>
+ ))}
+ {(!dashboard?.top_movers || dashboard.top_movers.length === 0) && (
+ <div className="p-4 text-center text-sm text-gray-500">Not enough data for variance analysis</div>
+ )}
+ </div>
+ </div>
+
+ {/* Quarantine Alerts */}
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Quarantine Alerts
+ </h2>
+ <div className="space-y-2">
+ {(dashboard?.recent_alerts || []).map((a: any) => (
+ <div key={a.id} className="flex items-center justify-between rounded bg-navy-900 p-3">
+ <div className="flex items-center gap-2">
+ <span className={`badge text-xs ${
+ a.severity === "critical" ? "badge-error" :
+ a.severity === "high" ? "badge-warning" : "badge-info"
+ }`}>
+ {a.severity}
+ </span>
+ <div>
+ <div className="text-sm text-gray-200">{a.field_name}</div>
+ <div className="text-xs text-gray-500">
+ {a.reference_number_raw} · {a.source_name}
+ </div>
+ </div>
+ </div>
+ <div className="text-xs text-gray-600">
+ {a.created_at ? formatDateTime(a.created_at) : "—"}
+ </div>
+ </div>
+ ))}
+ {(!dashboard?.recent_alerts || dashboard.recent_alerts.length === 0) && (
+ <div className="p-4 text-center text-sm text-gray-500">No unresolved alerts</div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* Source Catalog */}
+ <div className="card border-navy-700">
+ <h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Source Catalog ({sources.length})
+ </h2>
+ <div className="overflow-x-auto">
+ <table className="w-full text-sm">
+ <thead>
+ <tr className="border-b border-navy-700 text-left text-xs uppercase text-gray-500">
+ <th className="pb-2 pr-4">Source</th>
+ <th className="pb-2 pr-4">Type</th>
+ <th className="pb-2 pr-4">Access</th>
+ <th className="pb-2 pr-4">Reliability</th>
+ <th className="pb-2 pr-4">Runs</th>
+ <th className="pb-2 pr-4">Success</th>
+ <th className="pb-2 pr-4">Records</th>
+ <th className="pb-2 pr-4">ToS</th>
+ <th className="pb-2">Status</th>
+ </tr>
+ </thead>
+ <tbody>
+ {sources.map((s: any) => (
+ <tr key={s.id} className="border-b border-navy-800 hover:bg-navy-900">
+ <td className="py-2 pr-4 font-medium text-gray-200">{s.display_name}</td>
+ <td className="py-2 pr-4 text-gray-400">{s.source_type}</td>
+ <td className="py-2 pr-4">
+ <span className={`badge ${s.access_method === "api" ? "badge-success" : s.access_method === "manual" ? "badge-warning" : "badge-info"}`}>
+ {s.access_method}
+ </span>
+ </td>
+ <td className="py-2 pr-4">
+ <div className="flex gap-0.5">
+ {[1, 2, 3, 4, 5].map((n) => (
+ <span key={n} className={`inline-block h-2 w-2 rounded-full ${n <= s.reliability_score ? "bg-omega-400" : "bg-navy-700"}`} />
+ ))}
+ </div>
+ </td>
+ <td className="py-2 pr-4 text-gray-400">{s.total_runs || 0}</td>
+ <td className="py-2 pr-4">
+ <span className={parseInt(s.success_rate_pct) >= 90 ? "text-green-400" : parseInt(s.success_rate_pct) >= 70 ? "text-yellow-400" : "text-red-400"}>
+ {s.success_rate_pct || 0}%
+ </span>
+ </td>
+ <td className="py-2 pr-4 text-gray-400">{s.total_records_inserted || 0}</td>
+ <td className="py-2 pr-4">
+ {s.tos_reviewed ? (
+ s.tos_allows_collection === false ? (
+ <span className="badge-error">Blocked</span>
+ ) : (
+ <span className="badge-success">OK</span>
+ )
+ ) : (
+ <span className="badge-warning">Pending</span>
+ )}
+ </td>
+ <td className="py-2">
+ <span className={`badge ${s.is_active ? "badge-success" : "badge-error"}`}>
+ {s.is_active ? "Active" : "Disabled"}
+ </span>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ {/* Database Connections */}
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Database Connections
+ </h2>
+ {health && (
+ <div className="grid grid-cols-3 gap-4 text-center">
+ <div>
+ <div className="text-2xl font-bold text-gray-200">{health.database.connections.total}</div>
+ <div className="text-xs text-gray-500">Total</div>
+ </div>
+ <div>
+ <div className="text-2xl font-bold text-green-400">{health.database.connections.idle}</div>
+ <div className="text-xs text-gray-500">Idle</div>
+ </div>
+ <div>
+ <div className="text-2xl font-bold text-yellow-400">{health.database.connections.waiting}</div>
+ <div className="text-xs text-gray-500">Waiting</div>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/src/app/(dashboard)/quarantine/page.tsx b/src/app/(dashboard)/quarantine/page.tsx
new file mode 100644
index 0000000..7f478de
--- /dev/null
+++ b/src/app/(dashboard)/quarantine/page.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { api } from "@/lib/api-client";
+import { formatDateTime } from "@/lib/utils";
+
+export default function QuarantineQueuePage() {
+ const [data, setData] = useState<any>({ records: [], severity_counts: [] });
+ const [error, setError] = useState("");
+ const [resolving, setResolving] = useState<string | null>(null);
+ const [notes, setNotes] = useState("");
+
+ useEffect(() => {
+ api.getQuarantine({ resolved: false }).then(setData).catch((e) => setError(e.message));
+ }, []);
+
+ async function handleResolve(id: string, action: "release" | "reject") {
+ if (!notes.trim()) return;
+ setResolving(id);
+ try {
+ await api.resolveQuarantine(id, notes, action);
+ setData((prev: any) => ({
+ ...prev,
+ records: prev.records.filter((r: any) => r.id !== id),
+ }));
+ setNotes("");
+ } catch (e: any) {
+ setError(e.message);
+ }
+ setResolving(null);
+ }
+
+ const severityColor: Record<string, string> = {
+ critical: "text-red-400 bg-red-900/20",
+ high: "text-orange-400 bg-orange-900/20",
+ medium: "text-yellow-400 bg-yellow-900/20",
+ low: "text-blue-400 bg-blue-900/20",
+ };
+
+ return (
+ <div className="space-y-6">
+ <div className="flex items-center justify-between">
+ <h1 className="text-xl font-bold text-gray-100">Quarantine Review Queue</h1>
+ <div className="flex gap-2">
+ {data.severity_counts.map((sc: any) => (
+ <span key={sc.severity} className={`badge ${severityColor[sc.severity] || ""}`}>
+ {sc.severity}: {sc.count}
+ </span>
+ ))}
+ </div>
+ </div>
+
+ {error && <div className="rounded bg-red-900/30 p-3 text-red-300">{error}</div>}
+
+ {data.records.length === 0 ? (
+ <div className="card border-navy-700 p-8 text-center">
+ <div className="text-4xl">✓</div>
+ <div className="mt-2 text-lg font-medium text-green-400">Queue Clear</div>
+ <div className="text-sm text-gray-500">No quarantined records requiring review.</div>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {data.records.map((r: any) => (
+ <div key={r.id} className="card border-navy-700">
+ <div className="flex items-start justify-between">
+ <div className="flex items-center gap-3">
+ <span className={`badge ${severityColor[r.severity] || "badge-info"}`}>{r.severity}</span>
+ <span className="text-sm font-medium text-gray-200">{r.reason}</span>
+ </div>
+ <span className="text-xs text-gray-500">{formatDateTime(r.created_at)}</span>
+ </div>
+ {r.source_name && (
+ <div className="mt-2 text-xs text-gray-400">
+ Source: {r.source_name} | Ref: {r.reference_number_raw || "N/A"} | Price: ${r.price_usd || "N/A"} | Type: {r.event_type}
+ </div>
+ )}
+ {r.details && (
+ <pre className="mt-2 rounded bg-navy-950 p-2 text-xs text-gray-500">
+ {JSON.stringify(r.details, null, 2)}
+ </pre>
+ )}
+ <div className="mt-3 flex items-center gap-2">
+ <input
+ type="text"
+ placeholder="Resolution notes..."
+ className="input flex-1 text-xs"
+ value={resolving === r.id ? notes : ""}
+ onChange={(e) => { setResolving(r.id); setNotes(e.target.value); }}
+ />
+ <button
+ onClick={() => handleResolve(r.id, "release")}
+ disabled={resolving === r.id && !notes.trim()}
+ className="btn-primary text-xs"
+ >
+ Release
+ </button>
+ <button
+ onClick={() => handleResolve(r.id, "reject")}
+ disabled={resolving === r.id && !notes.trim()}
+ className="btn-secondary text-xs"
+ >
+ Reject
+ </button>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx
new file mode 100644
index 0000000..747be40
--- /dev/null
+++ b/src/app/(dashboard)/settings/page.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { api } from "@/lib/api-client";
+import { formatDateTime } from "@/lib/utils";
+
+export default function SettingsPage() {
+ const [health, setHealth] = useState<any>(null);
+ const [sources, setSources] = useState<any[]>([]);
+
+ useEffect(() => {
+ api.health().then(setHealth).catch(() => {});
+ api.getSources().then(setSources).catch(() => {});
+ }, []);
+
+ const activeSources = sources.filter((s) => s.is_active).length;
+ const tosBlocked = sources.filter((s) => s.tos_reviewed && s.tos_allows_collection === false).length;
+
+ return (
+ <div className="space-y-6">
+ <h1 className="text-xl font-bold text-gray-100">Access Control & Settings</h1>
+
+ {/* System Status */}
+ <div className="grid gap-4 md:grid-cols-4">
+ <div className="card border-navy-700 text-center">
+ <div className={`text-2xl font-bold ${health?.status === "healthy" ? "text-green-400" : "text-red-400"}`}>
+ {health?.status || "..."}
+ </div>
+ <div className="text-xs text-gray-500">System Status</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className="text-2xl font-bold text-gray-200">{health?.version || "..."}</div>
+ <div className="text-xs text-gray-500">Version</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className="text-2xl font-bold text-blue-400">{activeSources}</div>
+ <div className="text-xs text-gray-500">Active Sources</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className={`text-2xl font-bold ${health?.database?.ok ? "text-green-400" : "text-red-400"}`}>
+ {health?.database?.ok ? `${health.database.latency}ms` : "Down"}
+ </div>
+ <div className="text-xs text-gray-500">DB Latency</div>
+ </div>
+ </div>
+
+ {/* RBAC Roles */}
+ <div className="card border-navy-700">
+ <h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ RBAC Roles
+ </h2>
+ <div className="space-y-3">
+ {[
+ { role: "admin", perms: "Full access: run/backfill/resolve/edit parsers/manage users", users: "admin", color: "text-red-400" },
+ { role: "operator", perms: "Run jobs, resolve quarantine, view all data", users: "-", color: "text-orange-400" },
+ { role: "auditor", perms: "View audit logs, source health, read-only access", users: "-", color: "text-blue-400" },
+ { role: "viewer", perms: "Read-only dashboard access", users: "-", color: "text-gray-400" },
+ ].map((r) => (
+ <div key={r.role} className="flex items-center gap-4 rounded-lg bg-navy-900 p-3">
+ <span className={`w-20 rounded bg-navy-800 px-2 py-1 text-center text-xs font-bold ${r.color}`}>
+ {r.role}
+ </span>
+ <div className="flex-1">
+ <div className="text-sm text-gray-300">{r.perms}</div>
+ </div>
+ <div className="text-xs text-gray-500">Users: {r.users}</div>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ {/* Collection Schedule */}
+ <div className="card border-navy-700">
+ <h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Automated Collection Schedule
+ </h2>
+ <div className="space-y-2">
+ {[
+ { time: "06:00 UTC", source: "Omega Official", type: "MSRP Snapshot", script: "daily-msrp.sh" },
+ { time: "08:00 UTC", source: "Chrono24", type: "Market Listings", script: "daily-chrono24.sh" },
+ ].map((s) => (
+ <div key={s.script} className="flex items-center justify-between rounded bg-navy-900 p-3">
+ <div className="flex items-center gap-3">
+ <span className="badge badge-success text-xs">Active</span>
+ <div>
+ <div className="text-sm text-gray-200">{s.source} — {s.type}</div>
+ <div className="text-xs text-gray-500">Cron: {s.time} daily</div>
+ </div>
+ </div>
+ <span className="font-mono text-xs text-gray-500">{s.script}</span>
+ </div>
+ ))}
+ <div className="mt-2 text-xs text-gray-500">
+ Collectors run as cron jobs. Results appear in the Jobs page. Materialized views refresh after each run.
+ </div>
+ </div>
+ </div>
+
+ {/* System Configuration */}
+ <div className="card border-navy-700">
+ <h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ System Configuration
+ </h2>
+ <div className="grid gap-4 md:grid-cols-2">
+ <div>
+ <label className="mb-1 block text-xs text-gray-500">Database</label>
+ <div className="input bg-navy-900 text-gray-400">omega_watches_v2 @ 127.0.0.1:5432</div>
+ </div>
+ <div>
+ <label className="mb-1 block text-xs text-gray-500">Port</label>
+ <div className="input bg-navy-900 text-gray-400">7600</div>
+ </div>
+ <div>
+ <label className="mb-1 block text-xs text-gray-500">Environment</label>
+ <div className="input bg-navy-900 text-gray-400">production</div>
+ </div>
+ <div>
+ <label className="mb-1 block text-xs text-gray-500">Process Manager</label>
+ <div className="input bg-navy-900 text-gray-400">PM2 (fork mode)</div>
+ </div>
+ <div>
+ <label className="mb-1 block text-xs text-gray-500">Architecture</label>
+ <div className="input bg-navy-900 text-gray-400">Dual-Ledger (MSRP + Market Events)</div>
+ </div>
+ <div>
+ <label className="mb-1 block text-xs text-gray-500">DB Connections</label>
+ <div className="input bg-navy-900 text-gray-400">
+ {health ? `${health.database.connections.total} total, ${health.database.connections.idle} idle` : "..."}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* ToS Compliance Dashboard */}
+ <div className="card border-navy-700">
+ <h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Compliance & Legal
+ </h2>
+ <div className="mb-4 flex gap-4">
+ <div className="rounded bg-navy-900 px-4 py-2 text-center">
+ <div className="text-xl font-bold text-green-400">{sources.filter((s) => s.tos_reviewed && s.tos_allows_collection !== false).length}</div>
+ <div className="text-xs text-gray-500">ToS Approved</div>
+ </div>
+ <div className="rounded bg-navy-900 px-4 py-2 text-center">
+ <div className="text-xl font-bold text-yellow-400">{sources.filter((s) => !s.tos_reviewed).length}</div>
+ <div className="text-xs text-gray-500">ToS Pending</div>
+ </div>
+ <div className="rounded bg-navy-900 px-4 py-2 text-center">
+ <div className={`text-xl font-bold ${tosBlocked > 0 ? "text-red-400" : "text-gray-400"}`}>{tosBlocked}</div>
+ <div className="text-xs text-gray-500">ToS Blocked</div>
+ </div>
+ </div>
+ <div className="space-y-2 text-sm text-gray-400">
+ <p>• All collection respects robots.txt and ToS directives</p>
+ <p>• Personal data minimization enforced (GDPR/CCPA compliant)</p>
+ <p>• Raw snapshots retained for auditability</p>
+ <p>• Parser versions tracked for reproducibility</p>
+ <p>• Sources with tos_allows_collection = false are auto-blocked from automated runs</p>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/app/(dashboard)/snapshots/page.tsx b/src/app/(dashboard)/snapshots/page.tsx
new file mode 100644
index 0000000..215ad2b
--- /dev/null
+++ b/src/app/(dashboard)/snapshots/page.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { formatDateTime } from "@/lib/utils";
+
+function formatBytes(bytes: number): string {
+ if (!bytes) return "0 B";
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
+}
+
+export default function RawSnapshotsPage() {
+ const [snapshots, setSnapshots] = useState<any[]>([]);
+ const [stats, setStats] = useState<any>(null);
+ const [bySource, setBySource] = useState<any[]>([]);
+ const [sourceFilter, setSourceFilter] = useState("");
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ loadData();
+ }, [sourceFilter]);
+
+ async function loadData() {
+ try {
+ const token = localStorage.getItem("omega_token");
+ const qs = sourceFilter ? `?source=${sourceFilter}` : "";
+ const res = await fetch(`/api/snapshots${qs}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ const json = await res.json();
+ if (json.success) {
+ setSnapshots(json.data.snapshots);
+ setStats(json.data.stats);
+ setBySource(json.data.by_source);
+ }
+ } catch (e: any) {
+ setError(e.message);
+ }
+ }
+
+ return (
+ <div className="space-y-6">
+ <div className="flex items-center justify-between">
+ <h1 className="text-xl font-bold text-gray-100">Raw Snapshot Archive</h1>
+ <div className="flex gap-2">
+ <select
+ className="input w-48"
+ value={sourceFilter}
+ onChange={(e) => setSourceFilter(e.target.value)}
+ >
+ <option value="">All Sources</option>
+ {bySource.map((s) => (
+ <option key={s.source_name} value={s.source_name}>
+ {s.source_name} ({s.count})
+ </option>
+ ))}
+ </select>
+ <button onClick={loadData} className="btn-secondary text-xs">
+ Refresh
+ </button>
+ </div>
+ </div>
+
+ {error && <div className="rounded bg-red-900/30 p-3 text-red-300">{error}</div>}
+
+ {/* Stats */}
+ {stats && (
+ <div className="grid gap-4 md:grid-cols-4">
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-gray-200">{stats.total}</div>
+ <div className="text-xs text-gray-500">Total Snapshots</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-blue-400">{stats.sources}</div>
+ <div className="text-xs text-gray-500">Sources</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className="text-3xl font-bold text-omega-400">
+ {formatBytes(parseInt(stats.total_bytes) || 0)}
+ </div>
+ <div className="text-xs text-gray-500">Total Storage</div>
+ </div>
+ <div className="card border-navy-700 text-center">
+ <div className="text-sm font-medium text-gray-200">
+ {stats.last_snapshot ? formatDateTime(stats.last_snapshot) : "Never"}
+ </div>
+ <div className="text-xs text-gray-500">Last Snapshot</div>
+ </div>
+ </div>
+ )}
+
+ {/* Per-source breakdown */}
+ {bySource.length > 0 && (
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Storage by Source
+ </h2>
+ <div className="grid gap-2 md:grid-cols-4">
+ {bySource.map((s) => (
+ <div
+ key={s.source_name}
+ onClick={() => setSourceFilter(s.source_name === sourceFilter ? "" : s.source_name)}
+ className={`cursor-pointer rounded bg-navy-900 p-3 transition-colors hover:bg-navy-800 ${
+ sourceFilter === s.source_name ? "ring-1 ring-omega-500" : ""
+ }`}
+ >
+ <div className="text-sm font-medium text-gray-200">{s.source_name}</div>
+ <div className="mt-1 flex items-center justify-between text-xs text-gray-500">
+ <span>{s.count} snapshots</span>
+ <span>{formatBytes(parseInt(s.total_bytes) || 0)}</span>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Snapshot table */}
+ <div className="card border-navy-700 p-0">
+ <div className="overflow-x-auto">
+ <table className="w-full text-sm">
+ <thead>
+ <tr className="border-b border-navy-700 text-left text-xs uppercase text-gray-500">
+ <th className="p-3">Source</th>
+ <th className="p-3">URL</th>
+ <th className="p-3">Content</th>
+ <th className="p-3 text-right">Size</th>
+ <th className="p-3">Hash</th>
+ <th className="p-3">Parser</th>
+ <th className="p-3">Captured</th>
+ </tr>
+ </thead>
+ <tbody>
+ {snapshots.length === 0 ? (
+ <tr>
+ <td colSpan={7} className="p-8 text-center text-gray-500">
+ {stats?.total === "0" || stats?.total === 0
+ ? "No snapshots archived yet. Snapshots are created automatically by collectors during runs."
+ : "No snapshots match the current filter."}
+ </td>
+ </tr>
+ ) : (
+ snapshots.map((s) => (
+ <tr key={s.snapshot_id} className="border-b border-navy-800 hover:bg-navy-900">
+ <td className="p-3 text-gray-200">{s.source_name}</td>
+ <td className="p-3 max-w-xs">
+ <div className="truncate text-xs text-blue-400" title={s.url}>
+ {s.url}
+ </div>
+ </td>
+ <td className="p-3">
+ <span className={`badge text-xs ${s.content_type?.includes("html") ? "badge-info" : "badge-warning"}`}>
+ {s.content_type || "unknown"}
+ </span>
+ </td>
+ <td className="p-3 text-right text-gray-400">
+ {formatBytes(s.content_size_bytes || 0)}
+ </td>
+ <td className="p-3 font-mono text-xs text-gray-500">
+ {s.content_hash?.substring(0, 12)}...
+ </td>
+ <td className="p-3 font-mono text-xs text-gray-500">{s.parser_version}</td>
+ <td className="p-3 text-xs text-gray-500">
+ {s.scraped_at ? formatDateTime(s.scraped_at) : "-"}
+ </td>
+ </tr>
+ ))
+ )}
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ {/* Schema reference */}
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
+ Snapshot Schema Reference
+ </h2>
+ <div className="overflow-x-auto">
+ <table className="w-full text-xs">
+ <thead>
+ <tr className="border-b border-navy-700 text-gray-500">
+ <th className="p-2 text-left">Column</th>
+ <th className="p-2 text-left">Type</th>
+ <th className="p-2 text-left">Purpose</th>
+ </tr>
+ </thead>
+ <tbody className="text-gray-400">
+ {[
+ ["source_name", "TEXT", "Which source this snapshot is from"],
+ ["url", "TEXT", "Original URL captured"],
+ ["content_hash", "SHA256", "Content dedup/change detection"],
+ ["content_type", "TEXT", "text/html or application/pdf"],
+ ["content_size_bytes", "INTEGER", "Size of archived content"],
+ ["parser_version", "TEXT", "Parser version at capture time"],
+ ["scraped_at", "TIMESTAMPTZ", "When the snapshot was taken"],
+ ].map(([col, type, purpose]) => (
+ <tr key={col} className="border-b border-navy-800">
+ <td className="p-2 font-mono text-omega-400">{col}</td>
+ <td className="p-2">{type}</td>
+ <td className="p-2">{purpose}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/app/(dashboard)/sources/page.tsx b/src/app/(dashboard)/sources/page.tsx
new file mode 100644
index 0000000..5cc2e55
--- /dev/null
+++ b/src/app/(dashboard)/sources/page.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { api } from "@/lib/api-client";
+import { SourceReliabilityChart } from "@/components/charts";
+
+export default function SourceCatalogPage() {
+ const [sources, setSources] = useState<any[]>([]);
+ const [selected, setSelected] = useState<any>(null);
+ const [detail, setDetail] = useState<any>(null);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ api.getSources().then(setSources).catch((e) => setError(e.message));
+ }, []);
+
+ async function loadDetail(name: string) {
+ try {
+ const d = await api.getSourceStatus(name);
+ setDetail(d);
+ } catch (e: any) {
+ setError(e.message);
+ }
+ }
+
+ return (
+ <div className="space-y-6">
+ <h1 className="text-xl font-bold text-gray-100">Source Catalog</h1>
+ {error && <div className="rounded bg-red-900/30 p-3 text-red-300">{error}</div>}
+
+ {sources.length > 0 && (
+ <SourceReliabilityChart
+ data={sources
+ .filter((s: any) => s.total_runs > 0)
+ .map((s: any) => ({
+ name: s.display_name,
+ success_rate: parseFloat(s.success_rate_pct) || 0,
+ total_runs: parseInt(s.total_runs) || 0,
+ records: parseInt(s.total_records_inserted) || 0,
+ }))
+ .sort((a: any, b: any) => b.success_rate - a.success_rate)}
+ title="Source Reliability Comparison"
+ />
+ )}
+
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+ {sources.map((s) => (
+ <div
+ key={s.id}
+ onClick={() => { setSelected(s); loadDetail(s.name); }}
+ className={`card cursor-pointer border-navy-700 transition-colors hover:border-omega-500/50 ${selected?.id === s.id ? "ring-1 ring-omega-500" : ""}`}
+ >
+ <div className="flex items-start justify-between">
+ <div>
+ <div className="font-medium text-gray-200">{s.display_name}</div>
+ <div className="text-xs text-gray-500">{s.source_type} | {s.access_method}</div>
+ </div>
+ <span className={`badge ${s.is_active ? "badge-success" : "badge-error"}`}>
+ {s.is_active ? "Active" : "Off"}
+ </span>
+ </div>
+ <div className="mt-3 flex items-center gap-1">
+ {[1, 2, 3, 4, 5].map((n) => (
+ <span key={n} className={`inline-block h-1.5 w-4 rounded-full ${n <= s.reliability_score ? "bg-omega-400" : "bg-navy-700"}`} />
+ ))}
+ <span className="ml-2 text-xs text-gray-500">{s.reliability_score}/5</span>
+ </div>
+ {s.tos_notes && (
+ <div className="mt-2 text-xs text-gray-500 line-clamp-2">{s.tos_notes}</div>
+ )}
+ <div className="mt-2 text-xs text-gray-600">
+ Coverage: {s.coverage_years_from || "?"} - {s.coverage_years_to || "?"}
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {detail && (
+ <div className="card border-navy-700">
+ <h2 className="mb-3 text-lg font-semibold text-gray-200">
+ {detail.source.display_name} — Detail
+ </h2>
+ <div className="grid gap-4 md:grid-cols-3">
+ <div>
+ <div className="text-xs text-gray-500">Base URL</div>
+ <div className="text-sm text-gray-300">{detail.source.base_url || "N/A"}</div>
+ </div>
+ <div>
+ <div className="text-xs text-gray-500">Rate Limit</div>
+ <div className="text-sm text-gray-300">{detail.source.rate_limit_rpm || "None"} rpm</div>
+ </div>
+ <div>
+ <div className="text-xs text-gray-500">Current Parser</div>
+ <div className="text-sm text-gray-300">{detail.current_parser?.version || "None"}</div>
+ </div>
+ </div>
+ <div className="mt-4">
+ <div className="text-xs text-gray-500">Event Counts</div>
+ <div className="mt-1 flex gap-4 text-sm">
+ <span className="text-gray-300">Total: {detail.event_counts?.total_events || 0}</span>
+ <span className="text-green-400">Auctions: {detail.event_counts?.auction_count || 0}</span>
+ <span className="text-blue-400">Marketplace: {detail.event_counts?.marketplace_count || 0}</span>
+ <span className="text-yellow-400">Quarantined: {detail.event_counts?.quarantined_count || 0}</span>
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/src/app/(dashboard)/terminal/page.tsx b/src/app/(dashboard)/terminal/page.tsx
new file mode 100644
index 0000000..f9eab0b
--- /dev/null
+++ b/src/app/(dashboard)/terminal/page.tsx
@@ -0,0 +1,213 @@
+'use client';
+
+import { useEffect, useState, useMemo } from 'react';
+import { api } from '@/lib/api-client';
+import { formatCurrency, formatDate } from '@/lib/utils';
+import dynamic from 'next/dynamic';
+
+const StockChart = dynamic(() => import('@/components/charts/StockChart'), { ssr: false });
+const WatchSelector = dynamic(() => import('@/components/panels/WatchSelector'), { ssr: false });
+const WilliamPanel = dynamic(() => import('@/components/panels/WilliamPanel'), { ssr: false });
+
+interface WatchRef {
+ id: string;
+ brand: string;
+ collection: string;
+ model_name: string;
+ reference_number: string;
+ calibre: string;
+ case_material: string;
+ case_diameter_mm: string;
+ dial_color: string;
+ year_introduced: number;
+ notes?: string;
+ event_count: string;
+ msrp_count: string;
+ latest_msrp: string | null;
+ avg_market_price: string | null;
+ latest_sale_price: string | null;
+ latest_sale_date: string | null;
+}
+
+export default function TerminalPage() {
+ const [references, setReferences] = useState<WatchRef[]>([]);
+ const [selected, setSelected] = useState<WatchRef | null>(null);
+ const [compareList, setCompareList] = useState<WatchRef[]>([]);
+ const [detail, setDetail] = useState<any>(null);
+ const [loading, setLoading] = useState(true);
+ const [chartLoading, setChartLoading] = useState(false);
+
+ useEffect(() => {
+ loadReferences();
+ }, []);
+
+ async function loadReferences() {
+ try {
+ const token = localStorage.getItem('omega_token');
+ const res = await fetch('/api/references', {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ const json = await res.json();
+ if (json.success) {
+ setReferences(json.data);
+ }
+ } catch {
+ // Handle silently
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleSelect(ref: WatchRef) {
+ setSelected(ref);
+ setChartLoading(true);
+ try {
+ const [msrp, ts] = await Promise.all([
+ api.getMSRP(ref.reference_number),
+ api.getTimeseries(ref.reference_number, { include_asks: 'true' }),
+ ]);
+ setDetail({ msrp, timeseries: ts });
+ } catch {
+ setDetail(null);
+ } finally {
+ setChartLoading(false);
+ }
+ }
+
+ function handleToggleCompare(ref: WatchRef) {
+ setCompareList(prev =>
+ prev.some(c => c.id === ref.id)
+ ? prev.filter(c => c.id !== ref.id)
+ : [...prev, ref]
+ );
+ }
+
+ // Build chart data from detail
+ const chartData = useMemo(() => {
+ if (!detail) return [];
+ const points: any[] = [];
+ (detail.timeseries?.msrp_history || []).forEach((m: any) => {
+ points.push({ date: m.date, msrp: parseFloat(m.value), source: 'MSRP' });
+ });
+ (detail.timeseries?.market_events || []).forEach((e: any) => {
+ points.push({
+ date: e.date,
+ price: parseFloat(e.value),
+ event_type: e.event_type,
+ source: e.source,
+ });
+ });
+ points.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
+ const merged: any[] = [];
+ let lastMsrp: number | undefined;
+ for (const p of points) {
+ if (p.msrp) lastMsrp = p.msrp;
+ merged.push({ ...p, msrp: p.msrp || lastMsrp });
+ }
+ return merged;
+ }, [detail]);
+
+ // Stats
+ const stats = useMemo(() => {
+ if (!selected || !detail) return null;
+ const msrp = selected.latest_msrp ? parseFloat(selected.latest_msrp) : null;
+ const avg = selected.avg_market_price ? parseFloat(selected.avg_market_price) : null;
+ const lastSale = selected.latest_sale_price ? parseFloat(selected.latest_sale_price) : null;
+ const premium = msrp && avg ? ((avg - msrp) / msrp) * 100 : null;
+ const events = parseInt(selected.event_count) || 0;
+ const high = chartData.length > 0 ? Math.max(...chartData.filter(d => d.price).map(d => d.price!)) : null;
+ const low = chartData.length > 0 ? Math.min(...chartData.filter(d => d.price).map(d => d.price!)) : null;
+ return { msrp, avg, lastSale, premium, events, high, low };
+ }, [selected, detail, chartData]);
+
+ if (loading) {
+ return (
+ <div className="flex h-screen items-center justify-center">
+ <div className="text-gray-400">Loading terminal...</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex h-[calc(100vh-2rem)] gap-3">
+ {/* Left — Watch Selector */}
+ <div className="w-72 flex-shrink-0">
+ <WatchSelector
+ references={references}
+ selected={selected}
+ compareList={compareList}
+ onSelect={handleSelect}
+ onToggleCompare={handleToggleCompare}
+ />
+ </div>
+
+ {/* Center — Chart + Stats */}
+ <div className="flex min-w-0 flex-1 flex-col gap-3">
+ {/* Stats bar */}
+ {stats && selected && (
+ <div className="grid grid-cols-7 gap-2">
+ {[
+ { label: 'MSRP', value: stats.msrp ? formatCurrency(stats.msrp) : '—', color: 'text-red-400' },
+ { label: 'Avg Market', value: stats.avg ? formatCurrency(stats.avg) : '—', color: 'text-omega-400' },
+ { label: 'Last Sale', value: stats.lastSale ? formatCurrency(stats.lastSale) : '—', color: 'text-green-400' },
+ { label: 'Premium', value: stats.premium !== null ? `${stats.premium >= 0 ? '+' : ''}${stats.premium.toFixed(1)}%` : '—', color: stats.premium && stats.premium >= 0 ? 'text-green-400' : 'text-red-400' },
+ { label: 'High', value: stats.high ? formatCurrency(stats.high) : '—', color: 'text-gray-200' },
+ { label: 'Low', value: stats.low ? formatCurrency(stats.low) : '—', color: 'text-gray-200' },
+ { label: 'Events', value: `${stats.events}`, color: 'text-blue-400' },
+ ].map((s, i) => (
+ <div key={i} className="rounded-lg border border-navy-700 bg-navy-800/40 px-3 py-2 text-center">
+ <div className="text-[10px] uppercase text-gray-500">{s.label}</div>
+ <div className={`mt-0.5 text-sm font-bold ${s.color}`}>{s.value}</div>
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* Chart */}
+ <div className="flex-1">
+ {chartLoading ? (
+ <div className="flex h-full items-center justify-center rounded-xl border border-navy-700 bg-navy-800/30">
+ <div className="text-gray-400">Loading chart data...</div>
+ </div>
+ ) : (
+ <StockChart
+ data={chartData}
+ title={selected ? selected.model_name : undefined}
+ reference={selected?.reference_number}
+ />
+ )}
+ </div>
+
+ {/* Bottom stats — recent events */}
+ {detail?.timeseries?.market_events?.length > 0 && (
+ <div className="rounded-xl border border-navy-700 bg-navy-800/30 p-3">
+ <h4 className="mb-2 text-xs font-semibold uppercase text-gray-400">Recent Transactions</h4>
+ <div className="flex gap-2 overflow-x-auto">
+ {detail.timeseries.market_events.slice(-8).reverse().map((ev: any, i: number) => (
+ <div key={i} className="flex-shrink-0 rounded-lg border border-navy-700 bg-navy-900 px-3 py-2 text-center">
+ <div className="text-[10px] text-gray-500">{ev.source}</div>
+ <div className="mt-0.5 font-mono text-sm text-gray-200">
+ {ev.value ? formatCurrency(parseFloat(ev.value)) : '—'}
+ </div>
+ <div className="text-[10px] text-gray-500">{ev.date ? formatDate(ev.date) : ''}</div>
+ <span className={`mt-0.5 inline-block rounded px-1.5 py-0.5 text-[9px] font-medium ${
+ ev.event_type === 'auction_realized' ? 'bg-green-900/30 text-green-400' :
+ ev.event_type === 'dealer_ask' ? 'bg-amber-900/30 text-amber-400' :
+ 'bg-blue-900/30 text-blue-400'
+ }`}>
+ {ev.event_type?.replace(/_/g, ' ')}
+ </span>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* Right — William Panel */}
+ <div className="w-72 flex-shrink-0">
+ <WilliamPanel />
+ </div>
+ </div>
+ );
+}
diff --git a/src/app/api/admin/refresh-views/route.ts b/src/app/api/admin/refresh-views/route.ts
new file mode 100644
index 0000000..47e4890
--- /dev/null
+++ b/src/app/api/admin/refresh-views/route.ts
@@ -0,0 +1,49 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+const MATERIALIZED_VIEWS = ["source_health", "ref_price_summary"];
+
+export async function POST(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json(
+ { success: false, error: { code: "UNAUTHORIZED", message: "Login required" } },
+ { status: 401 }
+ );
+ }
+
+ if (session.role !== "admin" && session.role !== "operator") {
+ return NextResponse.json(
+ { success: false, error: { code: "FORBIDDEN", message: "Admin or operator role required" } },
+ { status: 403 }
+ );
+ }
+
+ const results: { view: string; refreshed: boolean; duration_ms: number; error?: string }[] = [];
+
+ for (const view of MATERIALIZED_VIEWS) {
+ const start = Date.now();
+ try {
+ await query(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`);
+ results.push({ view, refreshed: true, duration_ms: Date.now() - start });
+ } catch (e: any) {
+ // If CONCURRENTLY fails (no unique index), try without
+ try {
+ await query(`REFRESH MATERIALIZED VIEW ${view}`);
+ results.push({ view, refreshed: true, duration_ms: Date.now() - start });
+ } catch (e2: any) {
+ results.push({ view, refreshed: false, duration_ms: Date.now() - start, error: e2.message });
+ }
+ }
+ }
+
+ const allSuccess = results.every((r) => r.refreshed);
+
+ return NextResponse.json({
+ success: allSuccess,
+ data: { results, refreshed_at: new Date().toISOString() },
+ });
+}
diff --git a/src/app/api/alerts/route.ts b/src/app/api/alerts/route.ts
new file mode 100644
index 0000000..cca0e36
--- /dev/null
+++ b/src/app/api/alerts/route.ts
@@ -0,0 +1,122 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json(
+ { success: false, error: { code: "UNAUTHORIZED", message: "Login required" } },
+ { status: 401 }
+ );
+ }
+
+ const rules = await query(`
+ SELECT par.*, wr.reference_number, wr.collection, wr.model_name,
+ (SELECT AVG(me.price_usd)::NUMERIC(14,2)
+ FROM market_event me
+ WHERE me.reference_id = par.reference_id
+ AND NOT me.is_quarantined AND me.price_usd > 0
+ AND me.sale_date > CURRENT_DATE - 30
+ ) as current_avg_price
+ FROM price_alert_rule par
+ JOIN watch_reference wr ON par.reference_id = wr.id
+ ORDER BY par.is_active DESC, par.created_at DESC
+ `);
+
+ // Check which rules are currently triggered
+ const enriched = rules.map((r: any) => {
+ const avg = parseFloat(r.current_avg_price) || 0;
+ let triggered = false;
+ if (r.alert_type === "price_below" && avg > 0 && avg < parseFloat(r.threshold)) triggered = true;
+ if (r.alert_type === "price_above" && avg > 0 && avg > parseFloat(r.threshold)) triggered = true;
+ return { ...r, is_triggered: triggered };
+ });
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ rules: enriched,
+ summary: {
+ total: rules.length,
+ active: rules.filter((r: any) => r.is_active).length,
+ triggered: enriched.filter((r: any) => r.is_triggered).length,
+ },
+ },
+ });
+}
+
+export async function POST(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json(
+ { success: false, error: { code: "UNAUTHORIZED", message: "Login required" } },
+ { status: 401 }
+ );
+ }
+
+ if (session.role !== "admin" && session.role !== "operator") {
+ return NextResponse.json(
+ { success: false, error: { code: "FORBIDDEN", message: "Admin or operator role required" } },
+ { status: 403 }
+ );
+ }
+
+ const { reference_number, alert_type, threshold, notes } = await request.json();
+
+ if (!reference_number || !alert_type || !threshold) {
+ return NextResponse.json(
+ { success: false, error: { code: "MISSING_PARAMS", message: "reference_number, alert_type, and threshold required" } },
+ { status: 400 }
+ );
+ }
+
+ const ref = await query(
+ "SELECT id FROM watch_reference WHERE reference_number = $1",
+ [reference_number]
+ );
+
+ if (ref.length === 0) {
+ return NextResponse.json(
+ { success: false, error: { code: "NOT_FOUND", message: "Reference not found" } },
+ { status: 404 }
+ );
+ }
+
+ const result = await query(
+ `INSERT INTO price_alert_rule (reference_id, alert_type, threshold, notes, created_by)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING *`,
+ [ref[0].id, alert_type, threshold, notes || null, session.username]
+ );
+
+ return NextResponse.json({ success: true, data: result[0] });
+}
+
+export async function PUT(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json(
+ { success: false, error: { code: "UNAUTHORIZED", message: "Login required" } },
+ { status: 401 }
+ );
+ }
+
+ const { id, is_active } = await request.json();
+
+ if (!id || is_active === undefined) {
+ return NextResponse.json(
+ { success: false, error: { code: "MISSING_PARAMS", message: "id and is_active required" } },
+ { status: 400 }
+ );
+ }
+
+ await query(
+ "UPDATE price_alert_rule SET is_active = $1 WHERE id = $2",
+ [is_active, id]
+ );
+
+ return NextResponse.json({ success: true, data: { id, is_active } });
+}
diff --git a/src/app/api/auditlog/route.ts b/src/app/api/auditlog/route.ts
new file mode 100644
index 0000000..b27b4bb
--- /dev/null
+++ b/src/app/api/auditlog/route.ts
@@ -0,0 +1,42 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "@/lib/db";
+import { getSession, requireRole } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
+ }
+ requireRole(session, ["admin", "auditor"]);
+
+ const { searchParams } = new URL(request.url);
+ const table = searchParams.get("table");
+ const operation = searchParams.get("operation");
+ const limit = Math.min(parseInt(searchParams.get("limit") || "100"), 500);
+
+ let where = "WHERE 1=1";
+ const params: any[] = [];
+
+ if (table) {
+ params.push(table);
+ where += ` AND table_name = $${params.length}`;
+ }
+ if (operation) {
+ params.push(operation);
+ where += ` AND operation = $${params.length}`;
+ }
+
+ params.push(limit);
+ const logs = await query(
+ `SELECT * FROM audit_log ${where} ORDER BY changed_at DESC LIMIT $${params.length}`,
+ params
+ );
+
+ return NextResponse.json({
+ success: true,
+ data: logs,
+ meta: { total: logs.length, timestamp: new Date().toISOString() },
+ });
+}
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
new file mode 100644
index 0000000..12bdce4
--- /dev/null
+++ b/src/app/api/auth/login/route.ts
@@ -0,0 +1,46 @@
+import { NextRequest, NextResponse } from "next/server";
+import { verifyCredentials, createSession, COOKIE_NAME } from "@/lib/auth";
+
+export async function POST(request: NextRequest) {
+ try {
+ const { username, password } = await request.json();
+
+ if (!username || !password) {
+ return NextResponse.json(
+ { success: false, error: { code: "MISSING_CREDENTIALS", message: "Username and password required" } },
+ { status: 400 }
+ );
+ }
+
+ const { valid, role } = await verifyCredentials(username, password);
+
+ if (!valid) {
+ return NextResponse.json(
+ { success: false, error: { code: "INVALID_CREDENTIALS", message: "Invalid username or password" } },
+ { status: 401 }
+ );
+ }
+
+ const token = await createSession(username, username, role);
+
+ const response = NextResponse.json({
+ success: true,
+ data: { username, role, token },
+ });
+
+ response.cookies.set(COOKIE_NAME, token, {
+ httpOnly: true,
+ secure: false,
+ sameSite: "lax",
+ maxAge: 86400,
+ path: "/",
+ });
+
+ return response;
+ } catch (error: any) {
+ return NextResponse.json(
+ { success: false, error: { code: "LOGIN_ERROR", message: error.message } },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/condition-mappings/route.ts b/src/app/api/condition-mappings/route.ts
new file mode 100644
index 0000000..4ac9473
--- /dev/null
+++ b/src/app/api/condition-mappings/route.ts
@@ -0,0 +1,79 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json(
+ { success: false, error: { code: "UNAUTHORIZED", message: "Login required" } },
+ { status: 401 }
+ );
+ }
+
+ const mappings = await query(`
+ SELECT id, source_name, source_category, normalized_grade,
+ score_0_100, version, created_at
+ FROM condition_mapping
+ ORDER BY source_name, score_0_100 DESC
+ `);
+
+ // Group by source
+ const bySource: Record<string, any[]> = {};
+ for (const m of mappings) {
+ const src = (m as any).source_name;
+ if (!bySource[src]) bySource[src] = [];
+ bySource[src].push(m);
+ }
+
+ // Summary
+ const stats = {
+ total_mappings: mappings.length,
+ sources: Object.keys(bySource).length,
+ grades: [...new Set(mappings.map((m: any) => m.normalized_grade))],
+ };
+
+ return NextResponse.json({
+ success: true,
+ data: { mappings, by_source: bySource, stats },
+ });
+}
+
+export async function POST(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json(
+ { success: false, error: { code: "UNAUTHORIZED", message: "Login required" } },
+ { status: 401 }
+ );
+ }
+
+ if (session.role !== "admin") {
+ return NextResponse.json(
+ { success: false, error: { code: "FORBIDDEN", message: "Admin role required" } },
+ { status: 403 }
+ );
+ }
+
+ const { source_name, source_category, normalized_grade, score_0_100 } = await request.json();
+
+ if (!source_name || !source_category || !normalized_grade || score_0_100 === undefined) {
+ return NextResponse.json(
+ { success: false, error: { code: "MISSING_PARAMS", message: "All fields required" } },
+ { status: 400 }
+ );
+ }
+
+ const result = await query(
+ `INSERT INTO condition_mapping (source_name, source_category, normalized_grade, score_0_100)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (source_name, source_category, version) DO UPDATE
+ SET normalized_grade = $3, score_0_100 = $4
+ RETURNING *`,
+ [source_name, source_category, normalized_grade, score_0_100]
+ );
+
+ return NextResponse.json({ success: true, data: result[0] });
+}
diff --git a/src/app/api/dashboard/chart-data/route.ts b/src/app/api/dashboard/chart-data/route.ts
new file mode 100644
index 0000000..2fb33be
--- /dev/null
+++ b/src/app/api/dashboard/chart-data/route.ts
@@ -0,0 +1,53 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json(
+ { success: false, error: { code: "UNAUTHORIZED", message: "Login required" } },
+ { status: 401 }
+ );
+ }
+
+ const dailyActivity = await query(`
+ SELECT
+ me.sale_date::date AS date,
+ COUNT(*) AS event_count,
+ COUNT(*) FILTER (WHERE me.event_type = 'auction_realized') AS auctions,
+ COUNT(*) FILTER (WHERE me.event_type = 'marketplace_sold') AS marketplace,
+ COUNT(*) FILTER (WHERE me.event_type = 'dealer_ask') AS dealer_asks,
+ AVG(me.price_usd)::NUMERIC(14,2) AS avg_price
+ FROM market_event me
+ WHERE me.sale_date >= CURRENT_DATE - 30
+ AND NOT me.is_quarantined
+ AND me.sale_date IS NOT NULL
+ GROUP BY me.sale_date::date
+ ORDER BY date
+ `);
+
+ const sourceHealth = await query(`
+ SELECT
+ ds.display_name AS name,
+ ds.source_type,
+ ds.is_active,
+ COALESCE(sh.success_rate_pct, 0)::INTEGER AS success_rate,
+ COALESCE(sh.total_runs, 0)::INTEGER AS total_runs,
+ COALESCE(sh.total_records_inserted, 0)::INTEGER AS records
+ FROM data_source ds
+ LEFT JOIN source_health sh ON ds.id = sh.source_id
+ WHERE ds.is_active = true
+ ORDER BY sh.total_runs DESC NULLS LAST
+ `);
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ daily_activity: dailyActivity,
+ source_health: sourceHealth,
+ },
+ });
+}
diff --git a/src/app/api/dashboard/route.ts b/src/app/api/dashboard/route.ts
new file mode 100644
index 0000000..bed8934
--- /dev/null
+++ b/src/app/api/dashboard/route.ts
@@ -0,0 +1,91 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json(
+ { success: false, error: { code: "UNAUTHORIZED", message: "Login required" } },
+ { status: 401 }
+ );
+ }
+
+ // Recent collector runs (last 10)
+ const recentRuns = await query(`
+ SELECT cr.run_id, ds.display_name as source_name, cr.job_type, cr.status,
+ cr.records_fetched, cr.records_inserted, cr.records_quarantined,
+ cr.duration_ms, cr.started_at, cr.error_message
+ FROM collector_run cr
+ JOIN data_source ds ON cr.source_id = ds.id
+ ORDER BY cr.started_at DESC
+ LIMIT 10
+ `);
+
+ // Collection-level price summary
+ const collectionSummary = await query(`
+ SELECT wr.collection,
+ COUNT(DISTINCT wr.id) as ref_count,
+ COUNT(me.event_id) as event_count,
+ AVG(me.price_usd)::NUMERIC(14,2) as avg_market_price,
+ MIN(me.price_usd)::NUMERIC(14,2) as min_price,
+ MAX(me.price_usd)::NUMERIC(14,2) as max_price
+ FROM watch_reference wr
+ LEFT JOIN market_event me ON me.reference_id = wr.id AND NOT me.is_quarantined AND me.price_usd > 0
+ GROUP BY wr.collection
+ ORDER BY event_count DESC
+ `);
+
+ // Recent quarantine alerts (unresolved, last 5)
+ const recentAlerts = await query(`
+ SELECT qr.id, qr.severity, qr.reason as field_name, qr.details,
+ me.reference_number_raw, me.source_name, qr.created_at
+ FROM quarantine_record qr
+ JOIN market_event me ON qr.event_id = me.event_id
+ WHERE qr.resolved = false
+ ORDER BY qr.created_at DESC
+ LIMIT 5
+ `);
+
+ // 24-hour collection stats
+ const stats24h = await query(`
+ SELECT
+ COUNT(*) as runs_24h,
+ COUNT(*) FILTER (WHERE status = 'completed') as success_24h,
+ COUNT(*) FILTER (WHERE status = 'failed') as failed_24h,
+ COALESCE(SUM(records_inserted), 0) as inserted_24h,
+ COALESCE(SUM(records_quarantined), 0) as quarantined_24h
+ FROM collector_run
+ WHERE started_at > NOW() - INTERVAL '24 hours'
+ `);
+
+ // Top movers — references with highest price variance in last 30 days
+ const topMovers = await query(`
+ SELECT wr.reference_number, wr.collection, wr.model_name,
+ COUNT(*) as events_30d,
+ AVG(me.price_usd)::NUMERIC(14,2) as avg_price,
+ STDDEV(me.price_usd)::NUMERIC(14,2) as price_stddev
+ FROM market_event me
+ JOIN watch_reference wr ON me.reference_id = wr.id
+ WHERE me.sale_date > CURRENT_DATE - 30
+ AND NOT me.is_quarantined
+ AND me.price_usd > 0
+ GROUP BY wr.id, wr.reference_number, wr.collection, wr.model_name
+ HAVING COUNT(*) >= 2
+ ORDER BY STDDEV(me.price_usd) DESC NULLS LAST
+ LIMIT 5
+ `);
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ recent_runs: recentRuns,
+ collection_summary: collectionSummary,
+ recent_alerts: recentAlerts,
+ stats_24h: stats24h[0],
+ top_movers: topMovers,
+ },
+ });
+}
diff --git a/src/app/api/entity-resolution/route.ts b/src/app/api/entity-resolution/route.ts
new file mode 100644
index 0000000..4f02930
--- /dev/null
+++ b/src/app/api/entity-resolution/route.ts
@@ -0,0 +1,119 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
+ }
+
+ // Find market events whose reference_number_raw doesn't match any watch_reference
+ const unmatched = await query(`
+ SELECT me.event_id, me.source_name, me.source_listing_id, me.event_type,
+ me.reference_number_raw, me.sale_date, me.price_amount, me.currency,
+ me.condition_raw, me.condition_normalized, me.source_url,
+ me.created_at
+ FROM market_event me
+ LEFT JOIN watch_reference wr ON me.reference_number_raw = wr.reference_number
+ WHERE wr.id IS NULL AND me.reference_number_raw IS NOT NULL
+ ORDER BY me.created_at DESC
+ LIMIT 100
+ `);
+
+ // Get all known references for fuzzy matching suggestions
+ const references = await query(`
+ SELECT id, reference_number, collection, model_name
+ FROM watch_reference
+ ORDER BY collection, reference_number
+ `);
+
+ // Build match suggestions using simple similarity
+ const suggestions: Record<string, any[]> = {};
+ for (const event of unmatched) {
+ const raw = event.reference_number_raw || "";
+ const matches = references
+ .map((ref: any) => ({
+ ...ref,
+ score: similarity(raw, ref.reference_number),
+ }))
+ .filter((m: any) => m.score > 0.3)
+ .sort((a: any, b: any) => b.score - a.score)
+ .slice(0, 5);
+ suggestions[event.event_id] = matches;
+ }
+
+ // Summary stats
+ const stats = await query(`
+ SELECT
+ COUNT(*) FILTER (WHERE wr.id IS NULL AND me.reference_number_raw IS NOT NULL) as unmatched,
+ COUNT(*) FILTER (WHERE wr.id IS NOT NULL) as matched,
+ COUNT(*) as total
+ FROM market_event me
+ LEFT JOIN watch_reference wr ON me.reference_number_raw = wr.reference_number
+ `);
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ unmatched,
+ suggestions,
+ references,
+ stats: stats[0],
+ },
+ });
+}
+
+// Simple Levenshtein-based similarity score (0-1)
+function similarity(a: string, b: string): number {
+ if (!a || !b) return 0;
+ a = a.toLowerCase().replace(/[^a-z0-9]/g, "");
+ b = b.toLowerCase().replace(/[^a-z0-9]/g, "");
+ if (a === b) return 1;
+
+ const matrix: number[][] = [];
+ for (let i = 0; i <= a.length; i++) {
+ matrix[i] = [i];
+ for (let j = 1; j <= b.length; j++) {
+ if (i === 0) {
+ matrix[i][j] = j;
+ } else {
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
+ matrix[i][j] = Math.min(
+ matrix[i - 1][j] + 1,
+ matrix[i][j - 1] + 1,
+ matrix[i - 1][j - 1] + cost
+ );
+ }
+ }
+ }
+ const maxLen = Math.max(a.length, b.length);
+ return maxLen > 0 ? 1 - matrix[a.length][b.length] / maxLen : 0;
+}
+
+// POST: Link a market event to a reference
+export async function POST(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
+ }
+
+ const { event_id, reference_number } = await request.json();
+
+ if (!event_id || !reference_number) {
+ return NextResponse.json({
+ success: false,
+ error: { code: "MISSING_PARAMS", message: "event_id and reference_number required" },
+ }, { status: 400 });
+ }
+
+ // Update the market event's reference_number_raw
+ await query(
+ `UPDATE market_event SET reference_number_raw = $1 WHERE event_id = $2`,
+ [reference_number, event_id]
+ );
+
+ return NextResponse.json({ success: true, data: { event_id, reference_number } });
+}
diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts
new file mode 100644
index 0000000..78e2266
--- /dev/null
+++ b/src/app/api/export/route.ts
@@ -0,0 +1,139 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json(
+ { success: false, error: { code: "UNAUTHORIZED", message: "Login required" } },
+ { status: 401 }
+ );
+ }
+
+ const { searchParams } = new URL(request.url);
+ const type = searchParams.get("type") || "market_events";
+ const reference = searchParams.get("reference");
+ const collection = searchParams.get("collection");
+ const dateFrom = searchParams.get("date_from");
+ const dateTo = searchParams.get("date_to");
+ const format = searchParams.get("format") || "csv";
+ const limit = Math.min(parseInt(searchParams.get("limit") || "10000"), 50000);
+
+ let rows: any[];
+
+ if (type === "market_events") {
+ const conditions: string[] = [];
+ const params: any[] = [];
+
+ if (reference) {
+ params.push(reference);
+ conditions.push(`wr.reference_number = $${params.length}`);
+ }
+ if (collection) {
+ params.push(collection);
+ conditions.push(`wr.collection = $${params.length}`);
+ }
+ if (dateFrom) {
+ params.push(dateFrom);
+ conditions.push(`me.sale_date >= $${params.length}`);
+ }
+ if (dateTo) {
+ params.push(dateTo);
+ conditions.push(`me.sale_date <= $${params.length}`);
+ }
+
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
+ params.push(limit);
+
+ rows = await query(
+ `SELECT wr.reference_number, wr.collection, wr.model_name,
+ me.event_type, me.source_name, me.sale_date,
+ me.price_amount, me.currency, me.price_usd,
+ me.condition_normalized, me.seller_type,
+ me.fee_semantics, me.source_url
+ FROM market_event me
+ JOIN watch_reference wr ON me.reference_id = wr.id
+ ${where}
+ ORDER BY me.sale_date DESC
+ LIMIT $${params.length}`,
+ params
+ );
+ } else if (type === "msrp") {
+ const conditions: string[] = [];
+ const params: any[] = [];
+
+ if (reference) {
+ params.push(reference);
+ conditions.push(`wr.reference_number = $${params.length}`);
+ }
+ if (collection) {
+ params.push(collection);
+ conditions.push(`wr.collection = $${params.length}`);
+ }
+ if (dateFrom) {
+ params.push(dateFrom);
+ conditions.push(`ms.captured_date >= $${params.length}`);
+ }
+ if (dateTo) {
+ params.push(dateTo);
+ conditions.push(`ms.captured_date <= $${params.length}`);
+ }
+
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
+ params.push(limit);
+
+ rows = await query(
+ `SELECT wr.reference_number, wr.collection, wr.model_name,
+ ms.region_code, ms.currency, ms.msrp_amount,
+ ms.captured_date, ms.source_url
+ FROM msrp_snapshot ms
+ JOIN watch_reference wr ON ms.reference_id = wr.id
+ ${where}
+ ORDER BY ms.captured_date DESC
+ LIMIT $${params.length}`,
+ params
+ );
+ } else {
+ return NextResponse.json(
+ { success: false, error: { code: "INVALID_TYPE", message: "type must be market_events or msrp" } },
+ { status: 400 }
+ );
+ }
+
+ if (format === "json") {
+ return NextResponse.json({ success: true, data: rows, meta: { count: rows.length } });
+ }
+
+ // CSV format
+ if (rows.length === 0) {
+ return new NextResponse("No data found", { status: 200, headers: { "Content-Type": "text/plain" } });
+ }
+
+ const headers = Object.keys(rows[0]);
+ const csvLines = [
+ headers.join(","),
+ ...rows.map((row) =>
+ headers.map((h) => {
+ const val = row[h];
+ if (val === null || val === undefined) return "";
+ const str = String(val);
+ return str.includes(",") || str.includes('"') || str.includes("\n")
+ ? `"${str.replace(/"/g, '""')}"`
+ : str;
+ }).join(",")
+ ),
+ ];
+
+ const filename = `omega_${type}_${new Date().toISOString().split("T")[0]}.csv`;
+
+ return new NextResponse(csvLines.join("\n"), {
+ status: 200,
+ headers: {
+ "Content-Type": "text/csv",
+ "Content-Disposition": `attachment; filename="${filename}"`,
+ },
+ });
+}
diff --git a/src/app/api/fx-rates/route.ts b/src/app/api/fx-rates/route.ts
new file mode 100644
index 0000000..27bc620
--- /dev/null
+++ b/src/app/api/fx-rates/route.ts
@@ -0,0 +1,88 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json(
+ { success: false, error: { code: "UNAUTHORIZED", message: "Login required" } },
+ { status: 401 }
+ );
+ }
+
+ // Latest rate for each currency pair
+ const latestRates = await query(`
+ SELECT DISTINCT ON (currency_from, currency_to)
+ id, currency_from, currency_to, rate, rate_date, source, created_at
+ FROM fx_rate
+ ORDER BY currency_from, currency_to, rate_date DESC
+ `);
+
+ // Rate history (last 30 days, all pairs)
+ const history = await query(`
+ SELECT currency_from, currency_to, rate, rate_date, source
+ FROM fx_rate
+ WHERE rate_date > CURRENT_DATE - 30
+ ORDER BY currency_from, rate_date DESC
+ `);
+
+ // Summary stats
+ const stats = await query(`
+ SELECT
+ COUNT(DISTINCT (currency_from, currency_to)) as pairs,
+ COUNT(*) as total_rates,
+ MIN(rate_date) as earliest,
+ MAX(rate_date) as latest
+ FROM fx_rate
+ `);
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ latest: latestRates,
+ history,
+ stats: stats[0],
+ },
+ });
+}
+
+// POST: Add or update FX rates
+export async function POST(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json(
+ { success: false, error: { code: "UNAUTHORIZED", message: "Login required" } },
+ { status: 401 }
+ );
+ }
+
+ if (session.role !== "admin" && session.role !== "operator") {
+ return NextResponse.json(
+ { success: false, error: { code: "FORBIDDEN", message: "Admin or operator role required" } },
+ { status: 403 }
+ );
+ }
+
+ const { currency_from, rate, rate_date, source = "manual" } = await request.json();
+
+ if (!currency_from || !rate || !rate_date) {
+ return NextResponse.json(
+ { success: false, error: { code: "MISSING_PARAMS", message: "currency_from, rate, and rate_date required" } },
+ { status: 400 }
+ );
+ }
+
+ const result = await query(
+ `INSERT INTO fx_rate (currency_from, currency_to, rate, rate_date, source)
+ VALUES ($1, 'USD', $2, $3, $4)
+ ON CONFLICT (currency_from, currency_to, rate_date, source)
+ DO UPDATE SET rate = $2
+ RETURNING *`,
+ [currency_from.toUpperCase(), rate, rate_date, source]
+ );
+
+ return NextResponse.json({ success: true, data: result[0] });
+}
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts
new file mode 100644
index 0000000..862c291
--- /dev/null
+++ b/src/app/api/health/route.ts
@@ -0,0 +1,20 @@
+import { NextResponse } from "next/server";
+import { healthCheck } from "@/lib/db";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ const db = await healthCheck();
+ const uptime = process.uptime();
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ status: db.ok ? "healthy" : "degraded",
+ version: "2.0.0",
+ uptime_seconds: Math.round(uptime),
+ database: db,
+ timestamp: new Date().toISOString(),
+ },
+ });
+}
diff --git a/src/app/api/jobs/backfill/route.ts b/src/app/api/jobs/backfill/route.ts
new file mode 100644
index 0000000..f6e6b63
--- /dev/null
+++ b/src/app/api/jobs/backfill/route.ts
@@ -0,0 +1,50 @@
+import { NextRequest, NextResponse } from "next/server";
+import { queryOne } from "@/lib/db";
+import { getSession, requireRole } from "@/lib/auth";
+
+export async function POST(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
+ }
+ requireRole(session, ["admin"]);
+
+ const { source_name, date_from, date_to } = await request.json();
+
+ if (!source_name || !date_from || !date_to) {
+ return NextResponse.json(
+ { success: false, error: { code: "MISSING_PARAMS", message: "source_name, date_from, date_to required" } },
+ { status: 400 }
+ );
+ }
+
+ const source = await queryOne("SELECT * FROM data_source WHERE name = $1", [source_name]);
+ if (!source) {
+ return NextResponse.json(
+ { success: false, error: { code: "NOT_FOUND", message: "Source not found" } },
+ { status: 404 }
+ );
+ }
+
+ const parser = await queryOne(
+ "SELECT version FROM parser_version WHERE source_id = $1 AND is_current = true",
+ [source.id]
+ );
+
+ const run = await queryOne(
+ `INSERT INTO collector_run (source_id, job_type, status, parser_version, metadata, started_at)
+ VALUES ($1, 'backfill', 'pending', $2, $3, NOW())
+ RETURNING *`,
+ [source.id, parser?.version || "1.0.0", JSON.stringify({ date_from, date_to, requested_by: session.username })]
+ );
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ run_id: run.run_id,
+ status: "pending",
+ message: `Backfill queued for ${source_name} from ${date_from} to ${date_to}. Requires admin approval.`,
+ approval_required: true,
+ },
+ });
+}
diff --git a/src/app/api/jobs/daily-stats/route.ts b/src/app/api/jobs/daily-stats/route.ts
new file mode 100644
index 0000000..b00f559
--- /dev/null
+++ b/src/app/api/jobs/daily-stats/route.ts
@@ -0,0 +1,35 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json(
+ { success: false, error: { code: "UNAUTHORIZED", message: "Login required" } },
+ { status: 401 }
+ );
+ }
+
+ const dailyStats = await query(`
+ SELECT
+ cr.started_at::date AS date,
+ COUNT(*) AS total,
+ COUNT(*) FILTER (WHERE cr.status = 'completed') AS completed,
+ COUNT(*) FILTER (WHERE cr.status = 'failed') AS failed,
+ COUNT(*) FILTER (WHERE cr.status = 'running') AS running,
+ COUNT(*) FILTER (WHERE cr.status = 'pending') AS pending,
+ COALESCE(SUM(cr.records_inserted), 0) AS records_inserted
+ FROM collector_run cr
+ WHERE cr.started_at >= CURRENT_DATE - 30
+ GROUP BY cr.started_at::date
+ ORDER BY date
+ `);
+
+ return NextResponse.json({
+ success: true,
+ data: dailyStats,
+ });
+}
diff --git a/src/app/api/jobs/route.ts b/src/app/api/jobs/route.ts
new file mode 100644
index 0000000..099f11d
--- /dev/null
+++ b/src/app/api/jobs/route.ts
@@ -0,0 +1,46 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const date = searchParams.get("date");
+ const status = searchParams.get("status");
+ const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 200);
+
+ let whereClause = "WHERE 1=1";
+ const params: any[] = [];
+
+ if (date) {
+ params.push(date);
+ whereClause += ` AND cr.started_at::date = $${params.length}`;
+ }
+ if (status) {
+ params.push(status);
+ whereClause += ` AND cr.status = $${params.length}`;
+ }
+
+ params.push(limit);
+ const runs = await query(
+ `SELECT cr.*, ds.name as source_name, ds.display_name as source_display_name
+ FROM collector_run cr
+ JOIN data_source ds ON cr.source_id = ds.id
+ ${whereClause}
+ ORDER BY cr.created_at DESC
+ LIMIT $${params.length}`,
+ params
+ );
+
+ return NextResponse.json({
+ success: true,
+ data: runs,
+ meta: { total: runs.length, timestamp: new Date().toISOString() },
+ });
+}
diff --git a/src/app/api/jobs/run/route.ts b/src/app/api/jobs/run/route.ts
new file mode 100644
index 0000000..62b923e
--- /dev/null
+++ b/src/app/api/jobs/run/route.ts
@@ -0,0 +1,53 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query, queryOne } from "@/lib/db";
+import { getSession, requireRole } from "@/lib/auth";
+
+export async function POST(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
+ }
+ requireRole(session, ["admin", "operator"]);
+
+ const { source_name, job_type = "incremental" } = await request.json();
+
+ if (!source_name) {
+ return NextResponse.json(
+ { success: false, error: { code: "MISSING_SOURCE", message: "source_name is required" } },
+ { status: 400 }
+ );
+ }
+
+ const source = await queryOne("SELECT * FROM data_source WHERE name = $1 AND is_active = true", [source_name]);
+ if (!source) {
+ return NextResponse.json(
+ { success: false, error: { code: "SOURCE_NOT_FOUND", message: "Active source not found" } },
+ { status: 404 }
+ );
+ }
+
+ // Check ToS compliance
+ if (source.tos_reviewed && source.tos_allows_collection === false) {
+ return NextResponse.json(
+ { success: false, error: { code: "TOS_BLOCKED", message: `Collection blocked by ToS for ${source_name}. Manual collection only.` } },
+ { status: 403 }
+ );
+ }
+
+ const parser = await queryOne(
+ "SELECT version FROM parser_version WHERE source_id = $1 AND is_current = true",
+ [source.id]
+ );
+
+ const run = await queryOne(
+ `INSERT INTO collector_run (source_id, job_type, status, parser_version, started_at)
+ VALUES ($1, $2, 'pending', $3, NOW())
+ RETURNING *`,
+ [source.id, job_type, parser?.version || "1.0.0"]
+ );
+
+ return NextResponse.json({
+ success: true,
+ data: { run_id: run.run_id, status: "pending", message: `Job queued for ${source_name}` },
+ });
+}
diff --git a/src/app/api/models/[reference]/distributions/route.ts b/src/app/api/models/[reference]/distributions/route.ts
new file mode 100644
index 0000000..b3940fc
--- /dev/null
+++ b/src/app/api/models/[reference]/distributions/route.ts
@@ -0,0 +1,91 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query, queryOne } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { reference: string } }
+) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
+ }
+
+ const ref = await queryOne(
+ "SELECT * FROM watch_reference WHERE reference_number = $1",
+ [params.reference]
+ );
+
+ if (!ref) {
+ return NextResponse.json({ success: false, error: { code: "NOT_FOUND", message: "Reference not found" } }, { status: 404 });
+ }
+
+ // Price distribution histogram (20 buckets)
+ const distribution = await query(
+ `WITH stats AS (
+ SELECT
+ MIN(price_usd) AS min_p,
+ MAX(price_usd) AS max_p
+ FROM market_event
+ WHERE reference_id = $1 AND NOT is_quarantined AND price_usd > 0
+ ),
+ buckets AS (
+ SELECT
+ width_bucket(me.price_usd, s.min_p, s.max_p + 1, 20) AS bucket,
+ s.min_p,
+ s.max_p
+ FROM market_event me, stats s
+ WHERE me.reference_id = $1 AND NOT me.is_quarantined AND me.price_usd > 0
+ )
+ SELECT
+ bucket,
+ min_p + (bucket - 1) * (max_p - min_p + 1) / 20.0 AS bucket_low,
+ min_p + bucket * (max_p - min_p + 1) / 20.0 AS bucket_high,
+ COUNT(*) AS count
+ FROM buckets
+ GROUP BY bucket, min_p, max_p
+ ORDER BY bucket`,
+ [ref.id]
+ );
+
+ // Condition breakdown
+ const conditionBreakdown = await query(
+ `SELECT
+ condition_normalized AS condition,
+ COUNT(*) AS count,
+ AVG(price_usd)::NUMERIC(14,2) AS avg_price,
+ MIN(price_usd) AS min_price,
+ MAX(price_usd) AS max_price
+ FROM market_event
+ WHERE reference_id = $1 AND NOT is_quarantined AND price_usd > 0
+ GROUP BY condition_normalized
+ ORDER BY avg_price DESC`,
+ [ref.id]
+ );
+
+ // Source breakdown
+ const sourceBreakdown = await query(
+ `SELECT
+ source_name,
+ event_type,
+ COUNT(*) AS count,
+ AVG(price_usd)::NUMERIC(14,2) AS avg_price
+ FROM market_event
+ WHERE reference_id = $1 AND NOT is_quarantined AND price_usd > 0
+ GROUP BY source_name, event_type
+ ORDER BY count DESC`,
+ [ref.id]
+ );
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ reference: ref,
+ price_distribution: distribution,
+ condition_breakdown: conditionBreakdown,
+ source_breakdown: sourceBreakdown,
+ },
+ });
+}
diff --git a/src/app/api/models/[reference]/timeseries/route.ts b/src/app/api/models/[reference]/timeseries/route.ts
new file mode 100644
index 0000000..ad756c0
--- /dev/null
+++ b/src/app/api/models/[reference]/timeseries/route.ts
@@ -0,0 +1,89 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query, queryOne } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { reference: string } }
+) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const eventTypes = searchParams.get("event_types")?.split(",") || null;
+ const dateFrom = searchParams.get("date_from");
+ const dateTo = searchParams.get("date_to");
+ const includeAsks = searchParams.get("include_asks") === "true";
+
+ // Find reference
+ const ref = await queryOne(
+ "SELECT * FROM watch_reference WHERE reference_number = $1",
+ [params.reference]
+ );
+
+ if (!ref) {
+ return NextResponse.json({ success: false, error: { code: "NOT_FOUND", message: "Reference not found" } }, { status: 404 });
+ }
+
+ // Build market events time series
+ let eventWhere = "WHERE me.reference_id = $1 AND NOT me.is_quarantined";
+ const eventParams: any[] = [ref.id];
+
+ if (!includeAsks) {
+ eventWhere += " AND me.event_type NOT IN ('dealer_ask', 'forum_listing')";
+ }
+ if (eventTypes) {
+ eventParams.push(eventTypes);
+ eventWhere += ` AND me.event_type = ANY($${eventParams.length})`;
+ }
+ if (dateFrom) {
+ eventParams.push(dateFrom);
+ eventWhere += ` AND me.sale_date >= $${eventParams.length}`;
+ }
+ if (dateTo) {
+ eventParams.push(dateTo);
+ eventWhere += ` AND me.sale_date <= $${eventParams.length}`;
+ }
+
+ const events = await query(
+ `SELECT
+ me.sale_date AS date,
+ me.price_usd AS value,
+ me.source_name AS source,
+ me.event_type,
+ me.condition_normalized AS condition,
+ me.fee_semantics
+ FROM market_event me
+ ${eventWhere}
+ AND me.sale_date IS NOT NULL AND me.price_usd IS NOT NULL
+ ORDER BY me.sale_date`,
+ eventParams
+ );
+
+ // Get MSRP series
+ const msrp = await query(
+ `SELECT captured_date AS date, msrp_amount AS value, region_code, currency
+ FROM msrp_snapshot
+ WHERE reference_id = $1
+ ORDER BY captured_date`,
+ [ref.id]
+ );
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ reference: ref,
+ market_events: events,
+ msrp_history: msrp,
+ },
+ meta: {
+ market_event_count: events.length,
+ msrp_snapshot_count: msrp.length,
+ timestamp: new Date().toISOString(),
+ },
+ });
+}
diff --git a/src/app/api/msrp/[reference]/route.ts b/src/app/api/msrp/[reference]/route.ts
new file mode 100644
index 0000000..74d2c10
--- /dev/null
+++ b/src/app/api/msrp/[reference]/route.ts
@@ -0,0 +1,70 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query, queryOne } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { reference: string } }
+) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const region = searchParams.get("region") || "US";
+
+ const ref = await queryOne(
+ "SELECT * FROM watch_reference WHERE reference_number = $1",
+ [params.reference]
+ );
+
+ if (!ref) {
+ return NextResponse.json({ success: false, error: { code: "NOT_FOUND", message: "Reference not found" } }, { status: 404 });
+ }
+
+ // Current MSRP (latest snapshot per region)
+ const currentMSRP = await query(
+ `SELECT DISTINCT ON (region_code)
+ region_code, currency, msrp_amount, captured_date, source_url
+ FROM msrp_snapshot
+ WHERE reference_id = $1
+ ORDER BY region_code, captured_date DESC`,
+ [ref.id]
+ );
+
+ // MSRP history for requested region
+ const history = await query(
+ `SELECT captured_date, msrp_amount, currency, source_url, page_hash
+ FROM msrp_snapshot
+ WHERE reference_id = $1 AND region_code = $2
+ ORDER BY captured_date`,
+ [ref.id, region]
+ );
+
+ // MSRP changes for this reference
+ const changes = await query(
+ `SELECT * FROM msrp_changes
+ WHERE reference_id = $1
+ ORDER BY change_date DESC
+ LIMIT 50`,
+ [ref.id]
+ );
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ reference: ref,
+ current_msrp: currentMSRP,
+ history,
+ changes,
+ },
+ meta: {
+ region_requested: region,
+ regions_available: currentMSRP.map((r: any) => r.region_code),
+ timestamp: new Date().toISOString(),
+ },
+ });
+}
diff --git a/src/app/api/quarantine/[id]/resolve/route.ts b/src/app/api/quarantine/[id]/resolve/route.ts
new file mode 100644
index 0000000..e71f406
--- /dev/null
+++ b/src/app/api/quarantine/[id]/resolve/route.ts
@@ -0,0 +1,51 @@
+import { NextRequest, NextResponse } from "next/server";
+import { queryOne } from "@/lib/db";
+import { getSession, requireRole } from "@/lib/auth";
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
+ }
+ requireRole(session, ["admin", "operator"]);
+
+ const { resolution_notes, action } = await request.json();
+
+ if (!resolution_notes) {
+ return NextResponse.json(
+ { success: false, error: { code: "MISSING_NOTES", message: "Resolution notes required" } },
+ { status: 400 }
+ );
+ }
+
+ const record = await queryOne(
+ `UPDATE quarantine_record
+ SET resolved = true, resolved_by = $1, resolved_at = NOW(), resolution_notes = $2
+ WHERE id = $3 AND resolved = false
+ RETURNING *`,
+ [session.username, resolution_notes, params.id]
+ );
+
+ if (!record) {
+ return NextResponse.json(
+ { success: false, error: { code: "NOT_FOUND", message: "Quarantine record not found or already resolved" } },
+ { status: 404 }
+ );
+ }
+
+ // If action is "release", un-quarantine the market event
+ if (action === "release" && record.event_id) {
+ await queryOne(
+ `UPDATE market_event SET is_quarantined = false, quarantine_reason = NULL WHERE event_id = $1 RETURNING event_id`,
+ [record.event_id]
+ );
+ }
+
+ return NextResponse.json({
+ success: true,
+ data: record,
+ });
+}
diff --git a/src/app/api/quarantine/route.ts b/src/app/api/quarantine/route.ts
new file mode 100644
index 0000000..a3126b3
--- /dev/null
+++ b/src/app/api/quarantine/route.ts
@@ -0,0 +1,64 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const resolved = searchParams.get("resolved") === "true";
+ const severity = searchParams.get("severity");
+ const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 200);
+
+ let where = `WHERE qr.resolved = $1`;
+ const params: any[] = [resolved];
+
+ if (severity) {
+ params.push(severity);
+ where += ` AND qr.severity = $${params.length}`;
+ }
+
+ params.push(limit);
+ const records = await query(
+ `SELECT
+ qr.*,
+ me.source_name,
+ me.reference_number_raw,
+ me.price_usd,
+ me.event_type,
+ me.source_url
+ FROM quarantine_record qr
+ LEFT JOIN market_event me ON qr.event_id = me.event_id
+ ${where}
+ ORDER BY
+ CASE qr.severity
+ WHEN 'critical' THEN 0
+ WHEN 'high' THEN 1
+ WHEN 'medium' THEN 2
+ WHEN 'low' THEN 3
+ END,
+ qr.created_at DESC
+ LIMIT $${params.length}`,
+ params
+ );
+
+ const counts = await query(
+ `SELECT severity, COUNT(*) as count
+ FROM quarantine_record WHERE resolved = false
+ GROUP BY severity`
+ );
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ records,
+ severity_counts: counts,
+ },
+ meta: { total: records.length, timestamp: new Date().toISOString() },
+ });
+}
diff --git a/src/app/api/references/route.ts b/src/app/api/references/route.ts
new file mode 100644
index 0000000..58d97ff
--- /dev/null
+++ b/src/app/api/references/route.ts
@@ -0,0 +1,71 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json(
+ { success: false, error: { code: "UNAUTHORIZED", message: "Login required" } },
+ { status: 401 }
+ );
+ }
+
+ const { searchParams } = new URL(request.url);
+ const collection = searchParams.get("collection");
+ const search = searchParams.get("q");
+
+ let where = "WHERE 1=1";
+ const params: any[] = [];
+ let idx = 1;
+
+ if (collection) {
+ where += ` AND wr.collection = $${idx++}`;
+ params.push(collection);
+ }
+ if (search) {
+ where += ` AND (wr.reference_number ILIKE $${idx} OR wr.model_name ILIKE $${idx} OR wr.collection ILIKE $${idx})`;
+ params.push(`%${search}%`);
+ idx++;
+ }
+
+ const references = await query(
+ `SELECT
+ wr.*,
+ (SELECT COUNT(*) FROM market_event me WHERE me.reference_id = wr.id) AS event_count,
+ (SELECT COUNT(*) FROM msrp_snapshot ms WHERE ms.reference_id = wr.id) AS msrp_count,
+ (SELECT ms.msrp_amount FROM msrp_snapshot ms
+ WHERE ms.reference_id = wr.id AND ms.region_code = 'US'
+ ORDER BY ms.captured_date DESC LIMIT 1) AS latest_msrp,
+ (SELECT AVG(me.price_usd) FROM market_event me
+ WHERE me.reference_id = wr.id AND me.price_usd IS NOT NULL
+ AND me.event_type IN ('auction_realized', 'marketplace_sold')) AS avg_market_price,
+ (SELECT me.price_usd FROM market_event me
+ WHERE me.reference_id = wr.id AND me.price_usd IS NOT NULL
+ ORDER BY me.sale_date DESC LIMIT 1) AS latest_sale_price,
+ (SELECT me.sale_date FROM market_event me
+ WHERE me.reference_id = wr.id AND me.sale_date IS NOT NULL
+ ORDER BY me.sale_date DESC LIMIT 1) AS latest_sale_date
+ FROM watch_reference wr
+ ${where}
+ ORDER BY wr.collection, wr.model_name`,
+ params
+ );
+
+ const summary = await query(
+ `SELECT
+ (SELECT COUNT(*) FROM watch_reference) AS total_references,
+ (SELECT COUNT(*) FROM market_event) AS total_events,
+ (SELECT COUNT(*) FROM msrp_snapshot) AS total_snapshots,
+ (SELECT COUNT(DISTINCT collection) FROM watch_reference) AS collection_count`
+ );
+
+ return NextResponse.json({
+ success: true,
+ data: references,
+ summary: summary[0],
+ meta: { timestamp: new Date().toISOString() },
+ });
+}
diff --git a/src/app/api/snapshots/route.ts b/src/app/api/snapshots/route.ts
new file mode 100644
index 0000000..4dd566d
--- /dev/null
+++ b/src/app/api/snapshots/route.ts
@@ -0,0 +1,65 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const source = searchParams.get("source");
+ const limit = Math.min(parseInt(searchParams.get("limit") || "100"), 500);
+
+ let whereClause = "WHERE 1=1";
+ const params: any[] = [];
+
+ if (source) {
+ params.push(source);
+ whereClause += ` AND source_name = $${params.length}`;
+ }
+
+ params.push(limit);
+
+ const snapshots = await query(
+ `SELECT id as snapshot_id, source_name, url, content_hash, content_type,
+ content_size_bytes, parser_version, scraped_at
+ FROM raw_snapshot
+ ${whereClause}
+ ORDER BY scraped_at DESC
+ LIMIT $${params.length}`,
+ params
+ );
+
+ // Summary stats
+ const stats = await query(`
+ SELECT
+ COUNT(*) as total,
+ COUNT(DISTINCT source_name) as sources,
+ COALESCE(SUM(content_size_bytes), 0) as total_bytes,
+ MAX(scraped_at) as last_snapshot
+ FROM raw_snapshot
+ `);
+
+ // Per-source breakdown
+ const bySource = await query(`
+ SELECT source_name, COUNT(*) as count,
+ SUM(content_size_bytes) as total_bytes,
+ MAX(scraped_at) as latest
+ FROM raw_snapshot
+ GROUP BY source_name
+ ORDER BY count DESC
+ `);
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ snapshots,
+ stats: stats[0],
+ by_source: bySource,
+ },
+ });
+}
diff --git a/src/app/api/sources/[source]/status/route.ts b/src/app/api/sources/[source]/status/route.ts
new file mode 100644
index 0000000..1f06479
--- /dev/null
+++ b/src/app/api/sources/[source]/status/route.ts
@@ -0,0 +1,55 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query, queryOne } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { source: string } }
+) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
+ }
+
+ const source = await queryOne(
+ "SELECT * FROM data_source WHERE name = $1",
+ [params.source]
+ );
+
+ if (!source) {
+ return NextResponse.json({ success: false, error: { code: "NOT_FOUND", message: "Source not found" } }, { status: 404 });
+ }
+
+ const recentRuns = await query(
+ `SELECT * FROM collector_run WHERE source_id = $1 ORDER BY created_at DESC LIMIT 20`,
+ [source.id]
+ );
+
+ const currentParser = await queryOne(
+ `SELECT * FROM parser_version WHERE source_id = $1 AND is_current = true`,
+ [source.id]
+ );
+
+ const eventCounts = await queryOne(
+ `SELECT
+ COUNT(*) as total_events,
+ COUNT(*) FILTER (WHERE event_type = 'auction_realized') as auction_count,
+ COUNT(*) FILTER (WHERE event_type = 'marketplace_sold') as marketplace_count,
+ COUNT(*) FILTER (WHERE event_type = 'dealer_ask') as dealer_ask_count,
+ COUNT(*) FILTER (WHERE is_quarantined) as quarantined_count
+ FROM market_event WHERE source_name = $1`,
+ [params.source]
+ );
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ source,
+ recent_runs: recentRuns,
+ current_parser: currentParser,
+ event_counts: eventCounts,
+ },
+ });
+}
diff --git a/src/app/api/sources/route.ts b/src/app/api/sources/route.ts
new file mode 100644
index 0000000..d134b3e
--- /dev/null
+++ b/src/app/api/sources/route.ts
@@ -0,0 +1,33 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "@/lib/db";
+import { getSession } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ const session = await getSession(request);
+ if (!session) {
+ return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
+ }
+
+ const sources = await query(`
+ SELECT
+ ds.*,
+ sh.total_runs,
+ sh.completed_runs,
+ sh.failed_runs,
+ sh.last_completed,
+ sh.success_rate_pct,
+ sh.total_records_inserted,
+ sh.total_records_quarantined
+ FROM data_source ds
+ LEFT JOIN source_health sh ON ds.id = sh.source_id
+ ORDER BY ds.reliability_score DESC, ds.name
+ `);
+
+ return NextResponse.json({
+ success: true,
+ data: sources,
+ meta: { total: sources.length, timestamp: new Date().toISOString() },
+ });
+}
diff --git a/src/app/api/william/route.ts b/src/app/api/william/route.ts
new file mode 100644
index 0000000..d784133
--- /dev/null
+++ b/src/app/api/william/route.ts
@@ -0,0 +1,63 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+const WILLIAM_URL = 'http://127.0.0.1:9601';
+const AUTH = 'Basic ' + Buffer.from('admin:DWSecure2024!').toString('base64');
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = new URL(request.url);
+ const action = searchParams.get('action');
+
+ try {
+ let endpoint = '/api/observations';
+ if (action === 'status') endpoint = '/health';
+ else if (action === 'market') endpoint = '/api/market-summary';
+
+ const res = await fetch(`${WILLIAM_URL}${endpoint}`, {
+ headers: { Authorization: AUTH },
+ cache: 'no-store',
+ });
+
+ if (!res.ok) {
+ return NextResponse.json(
+ { error: 'William is not responding', observations: [] },
+ { status: 502 }
+ );
+ }
+
+ const data = await res.json();
+ return NextResponse.json(data);
+ } catch {
+ return NextResponse.json(
+ { error: 'Cannot reach William', observations: [], status: 'offline' },
+ { status: 502 }
+ );
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const action = body.action || 'trigger';
+
+ let endpoint = '/api/trigger';
+ if (action === 'pause') endpoint = '/api/pause';
+ else if (action === 'resume') endpoint = '/api/resume';
+
+ const res = await fetch(`${WILLIAM_URL}${endpoint}`, {
+ method: 'POST',
+ headers: {
+ Authorization: AUTH,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+
+ const data = await res.json();
+ return NextResponse.json(data);
+ } catch {
+ return NextResponse.json(
+ { error: 'Cannot reach William' },
+ { status: 502 }
+ );
+ }
+}
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..f3855f2
--- /dev/null
+++ b/src/app/globals.css
@@ -0,0 +1,80 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 222 47% 11%;
+ --muted: 210 40% 96%;
+ --muted-foreground: 215 16% 47%;
+ --border: 214 32% 91%;
+ --ring: 35 78% 50%;
+ --primary: 35 78% 50%;
+ --primary-foreground: 0 0% 100%;
+ --destructive: 0 84% 60%;
+ --success: 142 76% 36%;
+ --warning: 38 92% 50%;
+ }
+
+ .dark {
+ --background: 222 47% 6%;
+ --foreground: 210 40% 98%;
+ --muted: 217 33% 17%;
+ --muted-foreground: 215 20% 65%;
+ --border: 217 33% 17%;
+ --ring: 35 78% 50%;
+ --primary: 35 78% 50%;
+ --primary-foreground: 0 0% 100%;
+ --destructive: 0 63% 31%;
+ --success: 142 76% 36%;
+ --warning: 38 92% 50%;
+ }
+}
+
+@layer base {
+ * {
+ border-color: hsl(var(--border));
+ }
+ body {
+ background-color: hsl(var(--background));
+ color: hsl(var(--foreground));
+ font-feature-settings: "rlig" 1, "calt" 1;
+ }
+}
+
+@layer components {
+ .card {
+ @apply rounded-lg border bg-white p-6 shadow-sm dark:bg-navy-900;
+ }
+ .btn-primary {
+ @apply inline-flex items-center justify-center rounded-md bg-omega-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-omega-600 focus:outline-none focus:ring-2 focus:ring-omega-400 focus:ring-offset-2 disabled:opacity-50;
+ }
+ .btn-secondary {
+ @apply inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-omega-400 focus:ring-offset-2 disabled:opacity-50 dark:border-navy-600 dark:bg-navy-800 dark:text-gray-200 dark:hover:bg-navy-700;
+ }
+ .input {
+ @apply block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-omega-500 focus:outline-none focus:ring-1 focus:ring-omega-500 dark:border-navy-600 dark:bg-navy-800 dark:text-gray-100;
+ }
+ .badge {
+ @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
+ }
+ .badge-success {
+ @apply badge bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200;
+ }
+ .badge-warning {
+ @apply badge bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200;
+ }
+ .badge-error {
+ @apply badge bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200;
+ }
+ .badge-info {
+ @apply badge bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200;
+ }
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar { width: 6px; height: 6px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: hsl(var(--muted-foreground) / 0.3); border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: hsl(var(--muted-foreground) / 0.5); }
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..f635074
--- /dev/null
+++ b/src/app/layout.tsx
@@ -0,0 +1,33 @@
+import type { Metadata } from "next";
+import { Inter, JetBrains_Mono } from "next/font/google";
+import "./globals.css";
+
+const inter = Inter({
+ subsets: ["latin"],
+ variable: "--font-inter",
+});
+
+const jetbrains = JetBrains_Mono({
+ subsets: ["latin"],
+ variable: "--font-jetbrains",
+});
+
+export const metadata: Metadata = {
+ title: "Omega Watches 2.0 | Enterprise Price Tracking",
+ description:
+ "Enterprise-grade dual-ledger system for Omega watch MSRP tracking and secondary market price history.",
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+ <html lang="en" className="dark">
+ <body className={`${inter.variable} ${jetbrains.variable} font-display antialiased`}>
+ {children}
+ </body>
+ </html>
+ );
+}
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
new file mode 100644
index 0000000..b9fc87d
--- /dev/null
+++ b/src/app/login/page.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+export default function LoginPage() {
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+ const router = useRouter();
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setLoading(true);
+ setError("");
+
+ try {
+ const res = await fetch("/api/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, password }),
+ });
+ const data = await res.json();
+ if (data.success) {
+ localStorage.setItem("omega_token", data.data.token);
+ localStorage.setItem("omega_user", JSON.stringify(data.data));
+ router.push("/");
+ } else {
+ setError(data.error?.message || "Login failed");
+ }
+ } catch {
+ setError("Network error");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+ <div className="flex min-h-screen items-center justify-center bg-navy-950">
+ <div className="w-full max-w-md">
+ <div className="mb-8 text-center">
+ <div className="mb-2 text-4xl font-bold text-omega-400">OMEGA</div>
+ <div className="text-sm tracking-widest text-gray-400">
+ WATCHES 2.0 — ENTERPRISE CONTROL PLANE
+ </div>
+ </div>
+ <form onSubmit={handleSubmit} className="card space-y-4 border-navy-700 bg-navy-900">
+ <h2 className="text-lg font-semibold text-gray-100">Sign In</h2>
+ {error && (
+ <div className="rounded bg-red-900/30 p-3 text-sm text-red-300">
+ {error}
+ </div>
+ )}
+ <div>
+ <label className="mb-1 block text-sm text-gray-400">Username</label>
+ <input
+ type="text"
+ value={username}
+ onChange={(e) => setUsername(e.target.value)}
+ className="input"
+ required
+ autoFocus
+ />
+ </div>
+ <div>
+ <label className="mb-1 block text-sm text-gray-400">Password</label>
+ <input
+ type="password"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ className="input"
+ required
+ />
+ </div>
+ <button type="submit" disabled={loading} className="btn-primary w-full">
+ {loading ? "Authenticating..." : "Sign In"}
+ </button>
+ <p className="text-center text-xs text-gray-500">
+ Dual-Ledger Price Tracking System
+ </p>
+ </form>
+ </div>
+ </div>
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
new file mode 100644
index 0000000..2cdfbea
--- /dev/null
+++ b/src/app/page.tsx
@@ -0,0 +1,7 @@
+import { redirect } from "next/navigation";
+import { cookies } from "next/headers";
+
+export default function RootPage() {
+ // If authenticated, go to dashboard; otherwise middleware sends to /login
+ redirect("/sources");
+}
diff --git a/src/components/charts/CollectionPriceChart.tsx b/src/components/charts/CollectionPriceChart.tsx
new file mode 100644
index 0000000..9758df6
--- /dev/null
+++ b/src/components/charts/CollectionPriceChart.tsx
@@ -0,0 +1,61 @@
+'use client';
+
+import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
+import { formatCurrency } from '@/lib/utils';
+
+interface CollectionData {
+ collection: string;
+ avg: number;
+ min: number;
+ max: number;
+ count: number;
+}
+
+interface CollectionPriceChartProps {
+ data: CollectionData[];
+ title?: string;
+}
+
+const COLORS = ['#d4842e', '#6366f1', '#22c55e', '#f59e0b', '#14b8a6', '#ec4899'];
+
+const CustomTooltip = ({ active, payload }: any) => {
+ if (!active || !payload?.length) return null;
+ const d = payload[0].payload;
+ return (
+ <div className="bg-navy-800 border border-navy-600 rounded-lg p-3 shadow-xl">
+ <p className="text-sm font-semibold text-white">{d.collection}</p>
+ <p className="text-xs text-omega-400">Avg: {formatCurrency(d.avg)}</p>
+ <p className="text-xs text-navy-300">Range: {formatCurrency(d.min)} – {formatCurrency(d.max)}</p>
+ <p className="text-xs text-navy-400">{d.count} events</p>
+ </div>
+ );
+};
+
+export default function CollectionPriceChart({ data, title }: CollectionPriceChartProps) {
+ if (!data?.length) {
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-8 text-center">
+ <p className="text-navy-400">No collection price data available</p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-4">
+ {title && <h3 className="text-sm font-semibold uppercase tracking-wider text-gray-400 mb-4">{title}</h3>}
+ <ResponsiveContainer width="100%" height={280}>
+ <BarChart data={data} margin={{ top: 5, right: 10, bottom: 5, left: 10 }}>
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
+ <XAxis dataKey="collection" stroke="#64748b" fontSize={11} tick={{ fill: '#94a3b8' }} />
+ <YAxis stroke="#64748b" fontSize={11} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
+ <Tooltip content={<CustomTooltip />} />
+ <Bar dataKey="avg" radius={[4, 4, 0, 0]}>
+ {data.map((_, i) => (
+ <Cell key={i} fill={COLORS[i % COLORS.length]} />
+ ))}
+ </Bar>
+ </BarChart>
+ </ResponsiveContainer>
+ </div>
+ );
+}
diff --git a/src/components/charts/ConditionBreakdown.tsx b/src/components/charts/ConditionBreakdown.tsx
new file mode 100644
index 0000000..15253b4
--- /dev/null
+++ b/src/components/charts/ConditionBreakdown.tsx
@@ -0,0 +1,84 @@
+'use client';
+
+import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from 'recharts';
+
+interface ConditionData {
+ condition: string;
+ count: number;
+ avg_price?: number;
+}
+
+interface ConditionBreakdownProps {
+ data: ConditionData[];
+ title?: string;
+}
+
+const CONDITION_COLORS: Record<string, string> = {
+ 'new_unworn': '#22c55e',
+ 'mint': '#4ade80',
+ 'excellent': '#86efac',
+ 'very_good': '#c89b3c',
+ 'good': '#f59e0b',
+ 'fair': '#f97316',
+ 'poor': '#ef4444',
+ 'parts_only': '#64748b',
+ 'unknown': '#334155',
+};
+
+const CustomTooltip = ({ active, payload }: any) => {
+ if (!active || !payload?.length) return null;
+ const d = payload[0].payload;
+ return (
+ <div className="bg-navy-800 border border-navy-600 rounded-lg p-3 shadow-xl">
+ <p className="text-sm font-medium text-white">{d.condition.replace(/_/g, ' ')}</p>
+ <p className="text-xs text-navy-300">{d.count} listings</p>
+ {d.avg_price && (
+ <p className="text-xs text-omega-400">Avg: ${d.avg_price.toLocaleString()}</p>
+ )}
+ </div>
+ );
+};
+
+export default function ConditionBreakdown({ data, title }: ConditionBreakdownProps) {
+ if (!data?.length) {
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-8 text-center">
+ <p className="text-navy-400">No condition data available</p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-4">
+ {title && <h3 className="text-lg font-semibold text-white mb-4">{title}</h3>}
+ <ResponsiveContainer width="100%" height={300}>
+ <PieChart>
+ <Pie
+ data={data}
+ dataKey="count"
+ nameKey="condition"
+ cx="50%"
+ cy="50%"
+ outerRadius={100}
+ innerRadius={50}
+ strokeWidth={2}
+ stroke="#0f172a"
+ >
+ {data.map((entry, i) => (
+ <Cell
+ key={i}
+ fill={CONDITION_COLORS[entry.condition] || '#64748b'}
+ />
+ ))}
+ </Pie>
+ <Tooltip content={<CustomTooltip />} />
+ <Legend
+ formatter={(value: string) => (
+ <span className="text-xs text-navy-300">{value.replace(/_/g, ' ')}</span>
+ )}
+ />
+ </PieChart>
+ </ResponsiveContainer>
+ </div>
+ );
+}
diff --git a/src/components/charts/JobStatusChart.tsx b/src/components/charts/JobStatusChart.tsx
new file mode 100644
index 0000000..5a3c6e1
--- /dev/null
+++ b/src/components/charts/JobStatusChart.tsx
@@ -0,0 +1,77 @@
+'use client';
+
+import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts';
+import { format } from 'date-fns';
+
+interface DailyJobStats {
+ date: string;
+ total: number;
+ completed: number;
+ failed: number;
+ running: number;
+ pending: number;
+ records_inserted: number;
+}
+
+interface JobStatusChartProps {
+ data: DailyJobStats[];
+ title?: string;
+}
+
+const CustomTooltip = ({ active, payload, label }: any) => {
+ if (!active || !payload?.length) return null;
+ return (
+ <div className="bg-navy-800 border border-navy-600 rounded-lg p-3 shadow-xl">
+ <p className="text-xs font-medium text-white mb-1">
+ {label ? format(new Date(label), 'MMM d, yyyy') : label}
+ </p>
+ {payload.map((p: any) => (
+ <p key={p.dataKey} className="text-xs" style={{ color: p.color }}>
+ {p.name}: {parseInt(p.value)}
+ </p>
+ ))}
+ </div>
+ );
+};
+
+export default function JobStatusChart({ data, title }: JobStatusChartProps) {
+ if (!data?.length) {
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-8 text-center">
+ <p className="text-navy-400">No job status data available</p>
+ </div>
+ );
+ }
+
+ const chartData = data.map((d) => ({
+ ...d,
+ completed: parseInt(String(d.completed)) || 0,
+ failed: parseInt(String(d.failed)) || 0,
+ running: parseInt(String(d.running)) || 0,
+ pending: parseInt(String(d.pending)) || 0,
+ dateLabel: format(new Date(d.date), 'MMM d'),
+ }));
+
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-4">
+ {title && <h3 className="text-sm font-semibold uppercase tracking-wider text-gray-400 mb-4">{title}</h3>}
+ <ResponsiveContainer width="100%" height={300}>
+ <BarChart data={chartData} margin={{ top: 5, right: 10, bottom: 5, left: 10 }}>
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
+ <XAxis dataKey="dateLabel" stroke="#64748b" fontSize={10} tick={{ fill: '#94a3b8' }} />
+ <YAxis stroke="#64748b" fontSize={11} />
+ <Tooltip content={<CustomTooltip />} />
+ <Legend
+ formatter={(value: string) => (
+ <span className="text-xs text-navy-300">{value}</span>
+ )}
+ />
+ <Bar dataKey="completed" name="Completed" stackId="status" fill="#22c55e" radius={[0, 0, 0, 0]} />
+ <Bar dataKey="failed" name="Failed" stackId="status" fill="#ef4444" />
+ <Bar dataKey="running" name="Running" stackId="status" fill="#6366f1" />
+ <Bar dataKey="pending" name="Pending" stackId="status" fill="#f59e0b" radius={[4, 4, 0, 0]} />
+ </BarChart>
+ </ResponsiveContainer>
+ </div>
+ );
+}
diff --git a/src/components/charts/MSRPChangeChart.tsx b/src/components/charts/MSRPChangeChart.tsx
new file mode 100644
index 0000000..0e487f3
--- /dev/null
+++ b/src/components/charts/MSRPChangeChart.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine } from 'recharts';
+import { formatCurrency, formatDate } from '@/lib/utils';
+
+interface MSRPPoint {
+ captured_date: string;
+ msrp_amount: number;
+ region_code: string;
+}
+
+interface MSRPChangeChartProps {
+ data: MSRPPoint[];
+ title?: string;
+}
+
+const REGION_COLORS: Record<string, string> = {
+ US: '#c89b3c',
+ EU: '#6366f1',
+ UK: '#22c55e',
+ CH: '#f59e0b',
+ JP: '#ec4899',
+ HK: '#14b8a6',
+};
+
+const CustomTooltip = ({ active, payload, label }: any) => {
+ if (!active || !payload?.length) return null;
+ return (
+ <div className="bg-navy-800 border border-navy-600 rounded-lg p-3 shadow-xl">
+ <p className="text-xs text-navy-300 mb-1">{formatDate(label)}</p>
+ {payload.map((entry: any, i: number) => (
+ <p key={i} className="text-sm" style={{ color: entry.color }}>
+ {entry.name}: {formatCurrency(entry.value)}
+ </p>
+ ))}
+ </div>
+ );
+};
+
+export default function MSRPChangeChart({ data, title }: MSRPChangeChartProps) {
+ if (!data?.length) {
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-8 text-center">
+ <p className="text-navy-400">No MSRP history available</p>
+ </div>
+ );
+ }
+
+ // Group by region, pivot to { date, US: val, EU: val, ... }
+ const dateMap = new Map<string, Record<string, number>>();
+ for (const p of data) {
+ if (!dateMap.has(p.captured_date)) dateMap.set(p.captured_date, {});
+ dateMap.get(p.captured_date)![p.region_code] = p.msrp_amount;
+ }
+
+ const chartData = Array.from(dateMap.entries())
+ .map(([date, regions]) => ({ date, ...regions }))
+ .sort((a, b) => a.date.localeCompare(b.date));
+
+ const regions = [...new Set(data.map((p) => p.region_code))];
+
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-4">
+ {title && <h3 className="text-lg font-semibold text-white mb-4">{title}</h3>}
+ <ResponsiveContainer width="100%" height={350}>
+ <LineChart data={chartData} margin={{ top: 5, right: 20, bottom: 5, left: 20 }}>
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
+ <XAxis dataKey="date" tickFormatter={(v) => formatDate(v)} stroke="#64748b" fontSize={11} />
+ <YAxis tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} stroke="#64748b" fontSize={11} />
+ <Tooltip content={<CustomTooltip />} />
+ {regions.map((region) => (
+ <Line
+ key={region}
+ type="stepAfter"
+ dataKey={region}
+ name={region}
+ stroke={REGION_COLORS[region] || '#64748b'}
+ strokeWidth={2}
+ dot={false}
+ connectNulls
+ />
+ ))}
+ </LineChart>
+ </ResponsiveContainer>
+ </div>
+ );
+}
diff --git a/src/components/charts/MarketActivityChart.tsx b/src/components/charts/MarketActivityChart.tsx
new file mode 100644
index 0000000..c3d4ef3
--- /dev/null
+++ b/src/components/charts/MarketActivityChart.tsx
@@ -0,0 +1,88 @@
+'use client';
+
+import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts';
+import { format } from 'date-fns';
+
+interface DailyActivity {
+ date: string;
+ event_count: number;
+ auctions: number;
+ marketplace: number;
+ dealer_asks: number;
+ avg_price?: number;
+}
+
+interface MarketActivityChartProps {
+ data: DailyActivity[];
+ title?: string;
+}
+
+const CustomTooltip = ({ active, payload, label }: any) => {
+ if (!active || !payload?.length) return null;
+ return (
+ <div className="bg-navy-800 border border-navy-600 rounded-lg p-3 shadow-xl">
+ <p className="text-xs font-medium text-white mb-1">
+ {label ? format(new Date(label), 'MMM d, yyyy') : label}
+ </p>
+ {payload.map((p: any) => (
+ <p key={p.dataKey} className="text-xs" style={{ color: p.color }}>
+ {p.name}: {parseInt(p.value).toLocaleString()}
+ </p>
+ ))}
+ </div>
+ );
+};
+
+export default function MarketActivityChart({ data, title }: MarketActivityChartProps) {
+ if (!data?.length) {
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-8 text-center">
+ <p className="text-navy-400">No market activity data available</p>
+ </div>
+ );
+ }
+
+ const chartData = data.map((d) => ({
+ ...d,
+ auctions: parseInt(String(d.auctions)) || 0,
+ marketplace: parseInt(String(d.marketplace)) || 0,
+ dealer_asks: parseInt(String(d.dealer_asks)) || 0,
+ dateLabel: format(new Date(d.date), 'MMM d'),
+ }));
+
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-4">
+ {title && <h3 className="text-sm font-semibold uppercase tracking-wider text-gray-400 mb-4">{title}</h3>}
+ <ResponsiveContainer width="100%" height={280}>
+ <AreaChart data={chartData} margin={{ top: 5, right: 10, bottom: 5, left: 10 }}>
+ <defs>
+ <linearGradient id="gradAuctions" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="5%" stopColor="#d4842e" stopOpacity={0.3} />
+ <stop offset="95%" stopColor="#d4842e" stopOpacity={0} />
+ </linearGradient>
+ <linearGradient id="gradMarketplace" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
+ <stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
+ </linearGradient>
+ <linearGradient id="gradDealer" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="5%" stopColor="#f59e0b" stopOpacity={0.3} />
+ <stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
+ </linearGradient>
+ </defs>
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
+ <XAxis dataKey="dateLabel" stroke="#64748b" fontSize={10} tick={{ fill: '#94a3b8' }} />
+ <YAxis stroke="#64748b" fontSize={11} />
+ <Tooltip content={<CustomTooltip />} />
+ <Legend
+ formatter={(value: string) => (
+ <span className="text-xs text-navy-300">{value}</span>
+ )}
+ />
+ <Area type="monotone" dataKey="auctions" name="Auctions" stackId="1" stroke="#d4842e" fill="url(#gradAuctions)" />
+ <Area type="monotone" dataKey="marketplace" name="Marketplace" stackId="1" stroke="#6366f1" fill="url(#gradMarketplace)" />
+ <Area type="monotone" dataKey="dealer_asks" name="Dealer Asks" stackId="1" stroke="#f59e0b" fill="url(#gradDealer)" />
+ </AreaChart>
+ </ResponsiveContainer>
+ </div>
+ );
+}
diff --git a/src/components/charts/PriceDistribution.tsx b/src/components/charts/PriceDistribution.tsx
new file mode 100644
index 0000000..0a22e1d
--- /dev/null
+++ b/src/components/charts/PriceDistribution.tsx
@@ -0,0 +1,69 @@
+'use client';
+
+import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
+import { formatCurrency } from '@/lib/utils';
+
+interface DistributionBucket {
+ range_low: number;
+ range_high: number;
+ count: number;
+}
+
+interface PriceDistributionProps {
+ data: DistributionBucket[];
+ title?: string;
+ color?: string;
+}
+
+const CustomTooltip = ({ active, payload }: any) => {
+ if (!active || !payload?.length) return null;
+ const d = payload[0].payload;
+ return (
+ <div className="bg-navy-800 border border-navy-600 rounded-lg p-3 shadow-xl">
+ <p className="text-xs text-navy-300">
+ {formatCurrency(d.range_low)} — {formatCurrency(d.range_high)}
+ </p>
+ <p className="text-sm font-semibold text-omega-400">{d.count} transactions</p>
+ </div>
+ );
+};
+
+export default function PriceDistribution({ data, title, color = '#c89b3c' }: PriceDistributionProps) {
+ if (!data?.length) {
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-8 text-center">
+ <p className="text-navy-400">No distribution data available</p>
+ </div>
+ );
+ }
+
+ const chartData = data.map((b) => ({
+ ...b,
+ label: `$${(b.range_low / 1000).toFixed(0)}k`,
+ }));
+
+ const maxCount = Math.max(...data.map((b) => b.count));
+
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-4">
+ {title && <h3 className="text-lg font-semibold text-white mb-4">{title}</h3>}
+ <ResponsiveContainer width="100%" height={300}>
+ <BarChart data={chartData} margin={{ top: 5, right: 20, bottom: 5, left: 20 }}>
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
+ <XAxis dataKey="label" stroke="#64748b" fontSize={10} />
+ <YAxis stroke="#64748b" fontSize={11} />
+ <Tooltip content={<CustomTooltip />} />
+ <Bar dataKey="count" radius={[4, 4, 0, 0]}>
+ {chartData.map((entry, i) => (
+ <Cell
+ key={i}
+ fill={color}
+ fillOpacity={0.3 + (entry.count / maxCount) * 0.7}
+ />
+ ))}
+ </Bar>
+ </BarChart>
+ </ResponsiveContainer>
+ </div>
+ );
+}
diff --git a/src/components/charts/PriceTimeSeries.tsx b/src/components/charts/PriceTimeSeries.tsx
new file mode 100644
index 0000000..2116f5b
--- /dev/null
+++ b/src/components/charts/PriceTimeSeries.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import { ResponsiveContainer, ComposedChart, Line, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine } from 'recharts';
+import { formatCurrency, formatDate } from '@/lib/utils';
+
+interface TimeSeriesPoint {
+ date: string;
+ price?: number;
+ msrp?: number;
+ event_type?: string;
+ source?: string;
+ label?: string;
+}
+
+interface PriceTimeSeriesProps {
+ data: TimeSeriesPoint[];
+ title?: string;
+ showMSRP?: boolean;
+ currency?: string;
+}
+
+const EVENT_COLORS: Record<string, string> = {
+ auction_result: '#c89b3c',
+ dealer_ask: '#6366f1',
+ marketplace_sold: '#22c55e',
+ forum_listing: '#f59e0b',
+ msrp: '#ef4444',
+};
+
+const CustomTooltip = ({ active, payload, label }: any) => {
+ if (!active || !payload?.length) return null;
+ return (
+ <div className="bg-navy-800 border border-navy-600 rounded-lg p-3 shadow-xl">
+ <p className="text-xs text-navy-300 mb-1">{formatDate(label)}</p>
+ {payload.map((entry: any, i: number) => (
+ <p key={i} className="text-sm" style={{ color: entry.color }}>
+ {entry.name}: {formatCurrency(entry.value)}
+ </p>
+ ))}
+ </div>
+ );
+};
+
+export default function PriceTimeSeries({ data, title, showMSRP = true, currency = 'USD' }: PriceTimeSeriesProps) {
+ if (!data?.length) {
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-8 text-center">
+ <p className="text-navy-400">No time series data available</p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-4">
+ {title && <h3 className="text-lg font-semibold text-white mb-4">{title}</h3>}
+ <ResponsiveContainer width="100%" height={400}>
+ <ComposedChart data={data} margin={{ top: 5, right: 20, bottom: 5, left: 20 }}>
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
+ <XAxis
+ dataKey="date"
+ tickFormatter={(v) => formatDate(v)}
+ stroke="#64748b"
+ fontSize={11}
+ />
+ <YAxis
+ tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`}
+ stroke="#64748b"
+ fontSize={11}
+ />
+ <Tooltip content={<CustomTooltip />} />
+ <Legend />
+ {showMSRP && (
+ <Area
+ type="monotone"
+ dataKey="msrp"
+ name="MSRP"
+ fill="#ef444420"
+ stroke="#ef4444"
+ strokeWidth={2}
+ strokeDasharray="5 5"
+ dot={false}
+ />
+ )}
+ <Line
+ type="monotone"
+ dataKey="price"
+ name="Market Price"
+ stroke="#c89b3c"
+ strokeWidth={2}
+ dot={{ fill: '#c89b3c', r: 3 }}
+ activeDot={{ r: 6 }}
+ />
+ </ComposedChart>
+ </ResponsiveContainer>
+ </div>
+ );
+}
diff --git a/src/components/charts/ScatterPlot.tsx b/src/components/charts/ScatterPlot.tsx
new file mode 100644
index 0000000..3d3a471
--- /dev/null
+++ b/src/components/charts/ScatterPlot.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import { ResponsiveContainer, ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ZAxis } from 'recharts';
+import { formatCurrency, formatDate } from '@/lib/utils';
+
+interface ScatterPoint {
+ x: number; // timestamp or numeric
+ y: number; // price
+ label?: string;
+ source?: string;
+ event_type?: string;
+}
+
+interface ScatterPlotProps {
+ data: ScatterPoint[];
+ title?: string;
+ xLabel?: string;
+ yLabel?: string;
+}
+
+const CustomTooltip = ({ active, payload }: any) => {
+ if (!active || !payload?.length) return null;
+ const d = payload[0].payload;
+ return (
+ <div className="bg-navy-800 border border-navy-600 rounded-lg p-3 shadow-xl">
+ {d.label && <p className="text-sm font-medium text-white">{d.label}</p>}
+ <p className="text-xs text-navy-300">{formatDate(new Date(d.x).toISOString())}</p>
+ <p className="text-sm text-omega-400">{formatCurrency(d.y)}</p>
+ {d.source && <p className="text-xs text-navy-400">{d.source}</p>}
+ </div>
+ );
+};
+
+export default function ScatterPlot({ data, title, xLabel, yLabel }: ScatterPlotProps) {
+ if (!data?.length) {
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-8 text-center">
+ <p className="text-navy-400">No scatter data available</p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-4">
+ {title && <h3 className="text-lg font-semibold text-white mb-4">{title}</h3>}
+ <ResponsiveContainer width="100%" height={350}>
+ <ScatterChart margin={{ top: 5, right: 20, bottom: 5, left: 20 }}>
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
+ <XAxis
+ type="number"
+ dataKey="x"
+ name={xLabel || 'Date'}
+ tickFormatter={(v) => formatDate(new Date(v).toISOString())}
+ stroke="#64748b"
+ fontSize={11}
+ />
+ <YAxis
+ type="number"
+ dataKey="y"
+ name={yLabel || 'Price'}
+ tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`}
+ stroke="#64748b"
+ fontSize={11}
+ />
+ <ZAxis range={[40, 120]} />
+ <Tooltip content={<CustomTooltip />} />
+ <Scatter data={data} fill="#c89b3c" fillOpacity={0.7} />
+ </ScatterChart>
+ </ResponsiveContainer>
+ </div>
+ );
+}
diff --git a/src/components/charts/SourceHealthDonut.tsx b/src/components/charts/SourceHealthDonut.tsx
new file mode 100644
index 0000000..36b72c6
--- /dev/null
+++ b/src/components/charts/SourceHealthDonut.tsx
@@ -0,0 +1,90 @@
+'use client';
+
+import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from 'recharts';
+
+interface SourceData {
+ name: string;
+ source_type: string;
+ success_rate: number;
+ total_runs: number;
+ records: number;
+}
+
+interface SourceHealthDonutProps {
+ data: SourceData[];
+ title?: string;
+}
+
+function getHealthColor(rate: number): string {
+ if (rate >= 95) return '#22c55e';
+ if (rate >= 80) return '#f59e0b';
+ if (rate >= 50) return '#f97316';
+ return '#ef4444';
+}
+
+const CustomTooltip = ({ active, payload }: any) => {
+ if (!active || !payload?.length) return null;
+ const d = payload[0].payload;
+ return (
+ <div className="bg-navy-800 border border-navy-600 rounded-lg p-3 shadow-xl">
+ <p className="text-sm font-medium text-white">{d.name}</p>
+ <p className="text-xs text-navy-300">Type: {d.source_type}</p>
+ <p className="text-xs" style={{ color: getHealthColor(d.success_rate) }}>
+ Success: {d.success_rate}%
+ </p>
+ <p className="text-xs text-navy-300">{d.total_runs} runs · {d.records.toLocaleString()} records</p>
+ </div>
+ );
+};
+
+export default function SourceHealthDonut({ data, title }: SourceHealthDonutProps) {
+ if (!data?.length) {
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-8 text-center">
+ <p className="text-navy-400">No source health data available</p>
+ </div>
+ );
+ }
+
+ const chartData = data.filter((d) => d.total_runs > 0);
+ const totalSources = chartData.length;
+
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-4">
+ {title && <h3 className="text-sm font-semibold uppercase tracking-wider text-gray-400 mb-4">{title}</h3>}
+ <div className="relative">
+ <ResponsiveContainer width="100%" height={280}>
+ <PieChart>
+ <Pie
+ data={chartData}
+ dataKey="total_runs"
+ nameKey="name"
+ cx="50%"
+ cy="50%"
+ outerRadius={90}
+ innerRadius={55}
+ strokeWidth={2}
+ stroke="#0f1623"
+ >
+ {chartData.map((entry, i) => (
+ <Cell key={i} fill={getHealthColor(entry.success_rate)} />
+ ))}
+ </Pie>
+ <Tooltip content={<CustomTooltip />} />
+ <Legend
+ formatter={(value: string) => (
+ <span className="text-xs text-navy-300">{value}</span>
+ )}
+ />
+ </PieChart>
+ </ResponsiveContainer>
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-none" style={{ marginBottom: 40 }}>
+ <div className="text-center">
+ <div className="text-2xl font-bold text-white">{totalSources}</div>
+ <div className="text-[10px] text-gray-500 uppercase">Sources</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/charts/SourceHealthMap.tsx b/src/components/charts/SourceHealthMap.tsx
new file mode 100644
index 0000000..cab4bf7
--- /dev/null
+++ b/src/components/charts/SourceHealthMap.tsx
@@ -0,0 +1,79 @@
+'use client';
+
+interface SourceHealth {
+ name: string;
+ display_name: string;
+ source_type: string;
+ success_rate: number;
+ total_runs: number;
+ last_run?: string;
+ is_active: boolean;
+}
+
+interface SourceHealthMapProps {
+ sources: SourceHealth[];
+}
+
+function getHealthColor(rate: number): string {
+ if (rate >= 95) return 'bg-green-500';
+ if (rate >= 80) return 'bg-yellow-500';
+ if (rate >= 50) return 'bg-orange-500';
+ return 'bg-red-500';
+}
+
+function getHealthBorder(rate: number): string {
+ if (rate >= 95) return 'border-green-500/30';
+ if (rate >= 80) return 'border-yellow-500/30';
+ if (rate >= 50) return 'border-orange-500/30';
+ return 'border-red-500/30';
+}
+
+export default function SourceHealthMap({ sources }: SourceHealthMapProps) {
+ if (!sources?.length) {
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-8 text-center">
+ <p className="text-navy-400">No source data available</p>
+ </div>
+ );
+ }
+
+ const grouped = {
+ auction: sources.filter((s) => s.source_type === 'auction'),
+ marketplace: sources.filter((s) => s.source_type === 'marketplace'),
+ dealer: sources.filter((s) => s.source_type === 'dealer'),
+ forum: sources.filter((s) => s.source_type === 'forum'),
+ official: sources.filter((s) => s.source_type === 'official'),
+ };
+
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-4">
+ <h3 className="text-lg font-semibold text-white mb-4">Source Health Map</h3>
+ <div className="space-y-4">
+ {Object.entries(grouped).map(([type, srcs]) =>
+ srcs.length > 0 ? (
+ <div key={type}>
+ <p className="text-xs uppercase tracking-wider text-navy-400 mb-2">{type}</p>
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
+ {srcs.map((s) => (
+ <div
+ key={s.name}
+ className={`rounded-lg border ${getHealthBorder(s.success_rate)} bg-navy-900/50 p-3 hover:bg-navy-800 transition-colors`}
+ >
+ <div className="flex items-center gap-2 mb-1">
+ <div className={`w-2.5 h-2.5 rounded-full ${getHealthColor(s.success_rate)} ${s.is_active ? 'animate-pulse' : 'opacity-40'}`} />
+ <span className="text-sm font-medium text-white truncate">{s.display_name}</span>
+ </div>
+ <div className="flex items-center justify-between text-xs text-navy-400">
+ <span>{s.success_rate.toFixed(0)}% success</span>
+ <span>{s.total_runs} runs</span>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ ) : null
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/charts/SourceReliabilityChart.tsx b/src/components/charts/SourceReliabilityChart.tsx
new file mode 100644
index 0000000..a168fc0
--- /dev/null
+++ b/src/components/charts/SourceReliabilityChart.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell, ReferenceLine } from 'recharts';
+
+interface ReliabilityData {
+ name: string;
+ success_rate: number;
+ total_runs: number;
+ records: number;
+}
+
+interface SourceReliabilityChartProps {
+ data: ReliabilityData[];
+ title?: string;
+}
+
+function getBarColor(rate: number): string {
+ if (rate >= 90) return '#22c55e';
+ if (rate >= 70) return '#f59e0b';
+ return '#ef4444';
+}
+
+const CustomTooltip = ({ active, payload }: any) => {
+ if (!active || !payload?.length) return null;
+ const d = payload[0].payload;
+ return (
+ <div className="bg-navy-800 border border-navy-600 rounded-lg p-3 shadow-xl">
+ <p className="text-sm font-medium text-white">{d.name}</p>
+ <p className="text-xs" style={{ color: getBarColor(d.success_rate) }}>
+ Success Rate: {d.success_rate}%
+ </p>
+ <p className="text-xs text-navy-300">{d.total_runs} runs · {d.records.toLocaleString()} records</p>
+ </div>
+ );
+};
+
+export default function SourceReliabilityChart({ data, title }: SourceReliabilityChartProps) {
+ if (!data?.length) {
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-8 text-center">
+ <p className="text-navy-400">No source reliability data available</p>
+ </div>
+ );
+ }
+
+ const height = Math.max(300, data.length * 40);
+
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-4">
+ {title && <h3 className="text-sm font-semibold uppercase tracking-wider text-gray-400 mb-4">{title}</h3>}
+ <ResponsiveContainer width="100%" height={height}>
+ <BarChart data={data} layout="vertical" margin={{ top: 5, right: 20, bottom: 5, left: 100 }}>
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" horizontal={false} />
+ <XAxis type="number" domain={[0, 100]} stroke="#64748b" fontSize={11} tickFormatter={(v) => `${v}%`} />
+ <YAxis type="category" dataKey="name" stroke="#64748b" fontSize={11} tick={{ fill: '#94a3b8' }} width={95} />
+ <Tooltip content={<CustomTooltip />} />
+ <ReferenceLine x={90} stroke="#22c55e" strokeDasharray="3 3" strokeOpacity={0.5} />
+ <ReferenceLine x={70} stroke="#f59e0b" strokeDasharray="3 3" strokeOpacity={0.5} />
+ <Bar dataKey="success_rate" radius={[0, 4, 4, 0]}>
+ {data.map((entry, i) => (
+ <Cell key={i} fill={getBarColor(entry.success_rate)} />
+ ))}
+ </Bar>
+ </BarChart>
+ </ResponsiveContainer>
+ </div>
+ );
+}
diff --git a/src/components/charts/StockChart.tsx b/src/components/charts/StockChart.tsx
new file mode 100644
index 0000000..dba3a35
--- /dev/null
+++ b/src/components/charts/StockChart.tsx
@@ -0,0 +1,312 @@
+'use client';
+
+import { useState, useMemo } from 'react';
+import {
+ ResponsiveContainer, ComposedChart, Line, Area, XAxis, YAxis,
+ CartesianGrid, Tooltip, ReferenceLine, Bar
+} from 'recharts';
+import { formatCurrency, formatDate } from '@/lib/utils';
+
+interface DataPoint {
+ date: string;
+ price?: number;
+ msrp?: number;
+ volume?: number;
+ event_type?: string;
+ source?: string;
+ sma20?: number;
+ sma50?: number;
+}
+
+interface StockChartProps {
+ data: DataPoint[];
+ title?: string;
+ reference?: string;
+ onRangeChange?: (range: string) => void;
+}
+
+const RANGES = ['1W', '1M', '3M', '6M', '1Y', 'ALL'] as const;
+
+function computeSMA(data: DataPoint[], period: number): (number | undefined)[] {
+ return data.map((_, i) => {
+ if (i < period - 1) return undefined;
+ const slice = data.slice(i - period + 1, i + 1);
+ const prices = slice.map(d => d.price).filter(Boolean) as number[];
+ if (prices.length === 0) return undefined;
+ return prices.reduce((a, b) => a + b, 0) / prices.length;
+ });
+}
+
+function filterByRange(data: DataPoint[], range: string): DataPoint[] {
+ if (range === 'ALL') return data;
+ const now = new Date();
+ const cutoff = new Date();
+ switch (range) {
+ case '1W': cutoff.setDate(now.getDate() - 7); break;
+ case '1M': cutoff.setMonth(now.getMonth() - 1); break;
+ case '3M': cutoff.setMonth(now.getMonth() - 3); break;
+ case '6M': cutoff.setMonth(now.getMonth() - 6); break;
+ case '1Y': cutoff.setFullYear(now.getFullYear() - 1); break;
+ }
+ return data.filter(d => new Date(d.date) >= cutoff);
+}
+
+const ChartTooltip = ({ active, payload, label }: any) => {
+ if (!active || !payload?.length) return null;
+ return (
+ <div className="rounded-lg border border-navy-600 bg-navy-900 p-3 shadow-2xl">
+ <p className="mb-1.5 text-xs font-medium text-navy-300">{formatDate(label)}</p>
+ {payload.map((entry: any, i: number) => (
+ <div key={i} className="flex items-center gap-2 text-sm">
+ <span className="h-2 w-2 rounded-full" style={{ backgroundColor: entry.color }} />
+ <span className="text-gray-400">{entry.name}:</span>
+ <span className="font-mono font-medium" style={{ color: entry.color }}>
+ {entry.name === 'Volume' ? entry.value : formatCurrency(entry.value)}
+ </span>
+ </div>
+ ))}
+ </div>
+ );
+};
+
+export default function StockChart({ data, title, reference, onRangeChange }: StockChartProps) {
+ const [range, setRange] = useState<string>('ALL');
+ const [showMSRP, setShowMSRP] = useState(true);
+ const [showSMA20, setShowSMA20] = useState(false);
+ const [showSMA50, setShowSMA50] = useState(false);
+ const [showVolume, setShowVolume] = useState(true);
+ const [chartType, setChartType] = useState<'line' | 'area'>('area');
+
+ const enrichedData = useMemo(() => {
+ const filtered = filterByRange(data, range);
+ const sma20 = computeSMA(filtered, 20);
+ const sma50 = computeSMA(filtered, 50);
+ return filtered.map((d, i) => ({
+ ...d,
+ sma20: sma20[i],
+ sma50: sma50[i],
+ volume: d.volume || (d.price ? 1 : 0),
+ }));
+ }, [data, range]);
+
+ const priceChange = useMemo(() => {
+ const prices = enrichedData.filter(d => d.price).map(d => d.price!);
+ if (prices.length < 2) return null;
+ const first = prices[0];
+ const last = prices[prices.length - 1];
+ return { value: last - first, pct: ((last - first) / first) * 100 };
+ }, [enrichedData]);
+
+ const latestMSRP = useMemo(() => {
+ const msrps = enrichedData.filter(d => d.msrp).map(d => d.msrp!);
+ return msrps.length > 0 ? msrps[msrps.length - 1] : null;
+ }, [enrichedData]);
+
+ const handleRange = (r: string) => {
+ setRange(r);
+ onRangeChange?.(r);
+ };
+
+ if (!data?.length) {
+ return (
+ <div className="flex h-96 items-center justify-center rounded-xl border border-navy-700 bg-navy-800/50">
+ <p className="text-navy-400">Select a watch to view price history</p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="rounded-xl border border-navy-700 bg-navy-800/30">
+ {/* Header */}
+ <div className="flex flex-wrap items-center justify-between gap-2 border-b border-navy-700 px-4 py-3">
+ <div className="flex items-center gap-3">
+ {title && <h3 className="text-base font-semibold text-white">{title}</h3>}
+ {reference && <span className="font-mono text-xs text-omega-400">{reference}</span>}
+ {priceChange && (
+ <span className={`text-sm font-medium ${priceChange.value >= 0 ? 'text-green-400' : 'text-red-400'}`}>
+ {priceChange.value >= 0 ? '+' : ''}{formatCurrency(priceChange.value)}
+ <span className="ml-1 text-xs">
+ ({priceChange.pct >= 0 ? '+' : ''}{priceChange.pct.toFixed(1)}%)
+ </span>
+ </span>
+ )}
+ </div>
+ <div className="flex items-center gap-1">
+ {RANGES.map(r => (
+ <button
+ key={r}
+ onClick={() => handleRange(r)}
+ className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
+ range === r
+ ? 'bg-omega-500 text-white'
+ : 'text-gray-400 hover:bg-navy-700 hover:text-white'
+ }`}
+ >
+ {r}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ {/* Toggles */}
+ <div className="flex flex-wrap items-center gap-3 border-b border-navy-800 px-4 py-2">
+ <button
+ onClick={() => setChartType(chartType === 'line' ? 'area' : 'line')}
+ className="text-xs text-gray-400 hover:text-white"
+ >
+ {chartType === 'line' ? '◇ Line' : '◆ Area'}
+ </button>
+ <label className="flex items-center gap-1.5 text-xs text-gray-400">
+ <input type="checkbox" checked={showMSRP} onChange={() => setShowMSRP(!showMSRP)} className="accent-red-500" />
+ MSRP
+ </label>
+ <label className="flex items-center gap-1.5 text-xs text-gray-400">
+ <input type="checkbox" checked={showSMA20} onChange={() => setShowSMA20(!showSMA20)} className="accent-blue-400" />
+ SMA(20)
+ </label>
+ <label className="flex items-center gap-1.5 text-xs text-gray-400">
+ <input type="checkbox" checked={showSMA50} onChange={() => setShowSMA50(!showSMA50)} className="accent-purple-400" />
+ SMA(50)
+ </label>
+ <label className="flex items-center gap-1.5 text-xs text-gray-400">
+ <input type="checkbox" checked={showVolume} onChange={() => setShowVolume(!showVolume)} className="accent-gray-400" />
+ Volume
+ </label>
+ {latestMSRP && (
+ <span className="ml-auto text-xs text-gray-500">MSRP: {formatCurrency(latestMSRP)}</span>
+ )}
+ </div>
+
+ {/* Chart */}
+ <div className="p-2">
+ <ResponsiveContainer width="100%" height={420}>
+ <ComposedChart data={enrichedData} margin={{ top: 10, right: 15, bottom: 5, left: 15 }}>
+ <defs>
+ <linearGradient id="priceGrad" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="5%" stopColor="#c89b3c" stopOpacity={0.3} />
+ <stop offset="95%" stopColor="#c89b3c" stopOpacity={0} />
+ </linearGradient>
+ <linearGradient id="msrpGrad" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="5%" stopColor="#ef4444" stopOpacity={0.15} />
+ <stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
+ </linearGradient>
+ </defs>
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
+ <XAxis
+ dataKey="date"
+ tickFormatter={v => formatDate(v)}
+ stroke="#475569"
+ fontSize={10}
+ tickLine={false}
+ axisLine={{ stroke: '#334155' }}
+ />
+ <YAxis
+ yAxisId="price"
+ tickFormatter={v => `$${(v / 1000).toFixed(0)}k`}
+ stroke="#475569"
+ fontSize={10}
+ tickLine={false}
+ axisLine={false}
+ width={55}
+ />
+ {showVolume && (
+ <YAxis
+ yAxisId="volume"
+ orientation="right"
+ tickFormatter={v => `${v}`}
+ stroke="#475569"
+ fontSize={10}
+ tickLine={false}
+ axisLine={false}
+ width={30}
+ />
+ )}
+ <Tooltip content={<ChartTooltip />} />
+
+ {/* Volume bars */}
+ {showVolume && (
+ <Bar
+ yAxisId="volume"
+ dataKey="volume"
+ name="Volume"
+ fill="#334155"
+ opacity={0.4}
+ barSize={4}
+ />
+ )}
+
+ {/* MSRP overlay */}
+ {showMSRP && (
+ <Area
+ yAxisId="price"
+ type="stepAfter"
+ dataKey="msrp"
+ name="MSRP"
+ fill="url(#msrpGrad)"
+ stroke="#ef4444"
+ strokeWidth={1.5}
+ strokeDasharray="6 3"
+ dot={false}
+ connectNulls
+ />
+ )}
+
+ {/* SMA lines */}
+ {showSMA20 && (
+ <Line
+ yAxisId="price"
+ type="monotone"
+ dataKey="sma20"
+ name="SMA(20)"
+ stroke="#60a5fa"
+ strokeWidth={1}
+ dot={false}
+ connectNulls
+ />
+ )}
+ {showSMA50 && (
+ <Line
+ yAxisId="price"
+ type="monotone"
+ dataKey="sma50"
+ name="SMA(50)"
+ stroke="#a78bfa"
+ strokeWidth={1}
+ dot={false}
+ connectNulls
+ />
+ )}
+
+ {/* Price line/area */}
+ {chartType === 'area' ? (
+ <Area
+ yAxisId="price"
+ type="monotone"
+ dataKey="price"
+ name="Market Price"
+ fill="url(#priceGrad)"
+ stroke="#c89b3c"
+ strokeWidth={2}
+ dot={false}
+ activeDot={{ r: 5, fill: '#c89b3c', stroke: '#fff', strokeWidth: 2 }}
+ connectNulls
+ />
+ ) : (
+ <Line
+ yAxisId="price"
+ type="monotone"
+ dataKey="price"
+ name="Market Price"
+ stroke="#c89b3c"
+ strokeWidth={2}
+ dot={false}
+ activeDot={{ r: 5, fill: '#c89b3c', stroke: '#fff', strokeWidth: 2 }}
+ connectNulls
+ />
+ )}
+ </ComposedChart>
+ </ResponsiveContainer>
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/charts/TopMoversChart.tsx b/src/components/charts/TopMoversChart.tsx
new file mode 100644
index 0000000..aa2707c
--- /dev/null
+++ b/src/components/charts/TopMoversChart.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
+import { formatCurrency } from '@/lib/utils';
+
+interface MoverData {
+ label: string;
+ collection: string;
+ model: string;
+ stddev: number;
+ avg_price: number;
+ events: number;
+}
+
+interface TopMoversChartProps {
+ data: MoverData[];
+ title?: string;
+}
+
+const CustomTooltip = ({ active, payload }: any) => {
+ if (!active || !payload?.length) return null;
+ const d = payload[0].payload;
+ return (
+ <div className="bg-navy-800 border border-navy-600 rounded-lg p-3 shadow-xl">
+ <p className="text-xs font-mono text-omega-400">{d.label}</p>
+ <p className="text-xs text-white">{d.collection} — {d.model}</p>
+ <p className="text-xs text-navy-300">Avg Price: {formatCurrency(d.avg_price)}</p>
+ <p className="text-xs text-navy-300">Volatility: \u00B1{formatCurrency(d.stddev)}</p>
+ <p className="text-xs text-navy-400">{d.events} events (30d)</p>
+ </div>
+ );
+};
+
+export default function TopMoversChart({ data, title }: TopMoversChartProps) {
+ if (!data?.length) {
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-8 text-center">
+ <p className="text-navy-400">Not enough data for volatility analysis</p>
+ </div>
+ );
+ }
+
+ const maxStddev = Math.max(...data.map((d) => d.stddev));
+
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-4">
+ {title && <h3 className="text-sm font-semibold uppercase tracking-wider text-gray-400 mb-4">{title}</h3>}
+ <ResponsiveContainer width="100%" height={280}>
+ <BarChart data={data} layout="vertical" margin={{ top: 5, right: 10, bottom: 5, left: 80 }}>
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" horizontal={false} />
+ <XAxis type="number" stroke="#64748b" fontSize={11} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
+ <YAxis type="category" dataKey="label" stroke="#64748b" fontSize={10} tick={{ fill: '#c89b3c' }} width={75} />
+ <Tooltip content={<CustomTooltip />} />
+ <Bar dataKey="stddev" radius={[0, 4, 4, 0]}>
+ {data.map((entry, i) => (
+ <Cell key={i} fill="#d4842e" fillOpacity={0.4 + (entry.stddev / maxStddev) * 0.6} />
+ ))}
+ </Bar>
+ </BarChart>
+ </ResponsiveContainer>
+ </div>
+ );
+}
diff --git a/src/components/charts/index.ts b/src/components/charts/index.ts
new file mode 100644
index 0000000..3210d2f
--- /dev/null
+++ b/src/components/charts/index.ts
@@ -0,0 +1,12 @@
+export { default as PriceTimeSeries } from './PriceTimeSeries';
+export { default as PriceDistribution } from './PriceDistribution';
+export { default as SourceHealthMap } from './SourceHealthMap';
+export { default as ConditionBreakdown } from './ConditionBreakdown';
+export { default as MSRPChangeChart } from './MSRPChangeChart';
+export { default as ScatterPlot } from './ScatterPlot';
+export { default as CollectionPriceChart } from './CollectionPriceChart';
+export { default as MarketActivityChart } from './MarketActivityChart';
+export { default as TopMoversChart } from './TopMoversChart';
+export { default as SourceHealthDonut } from './SourceHealthDonut';
+export { default as SourceReliabilityChart } from './SourceReliabilityChart';
+export { default as JobStatusChart } from './JobStatusChart';
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
new file mode 100644
index 0000000..6f64066
--- /dev/null
+++ b/src/components/layout/Header.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+
+export default function Header() {
+ const router = useRouter();
+
+ function handleLogout() {
+ localStorage.removeItem("omega_token");
+ localStorage.removeItem("omega_user");
+ document.cookie = "omega_session=; path=/; max-age=0";
+ router.push("/login");
+ }
+
+ return (
+ <header className="flex h-14 items-center justify-between border-b border-navy-700 bg-navy-950 px-6">
+ <div className="flex items-center gap-4">
+ <h1 className="text-sm font-medium text-gray-300">
+ Enterprise Price Tracking
+ </h1>
+ <span className="badge-info">LIVE</span>
+ </div>
+ <div className="flex items-center gap-3">
+ <span className="text-xs text-gray-500">
+ {new Date().toLocaleDateString("en-US", {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })}
+ </span>
+ <button onClick={handleLogout} className="btn-secondary text-xs">
+ Logout
+ </button>
+ </div>
+ </header>
+ );
+}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
new file mode 100644
index 0000000..34f24da
--- /dev/null
+++ b/src/components/layout/Sidebar.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+
+const navItems = [
+ { href: "/", label: "System Health", icon: "H" },
+ { href: "/terminal", label: "Trading Terminal", icon: "T", accent: true },
+ { href: "/jobs", label: "Job Runs", icon: "J" },
+ { href: "/sources", label: "Source Catalog", icon: "S" },
+ { href: "/msrp", label: "MSRP Changes", icon: "M" },
+ { href: "/models", label: "Model Dashboard", icon: "D" },
+ { href: "/quarantine", label: "Quarantine Queue", icon: "Q" },
+ { href: "/entity-resolution", label: "Entity Resolution", icon: "E" },
+ { href: "/snapshots", label: "Raw Snapshots", icon: "R" },
+ { href: "/audit", label: "Audit Log", icon: "A" },
+ { href: "/alerts", label: "Price Alerts", icon: "!" },
+ { href: "/condition-mappings", label: "Condition Map", icon: "G" },
+ { href: "/settings", label: "Access Control", icon: "C" },
+ { href: "/api-docs", label: "API Docs", icon: "?" },
+];
+
+export default function Sidebar() {
+ const pathname = usePathname();
+
+ return (
+ <aside className="flex h-screen w-64 flex-col border-r border-navy-700 bg-navy-950">
+ <div className="border-b border-navy-700 p-4">
+ <div className="text-xl font-bold text-omega-400">OMEGA</div>
+ <div className="text-[10px] tracking-[0.2em] text-gray-500">
+ WATCHES 2.0 CONTROL PLANE
+ </div>
+ </div>
+ <nav className="flex-1 overflow-y-auto p-2">
+ {navItems.map((item) => {
+ const isActive =
+ pathname === item.href ||
+ (item.href !== "/" && pathname.startsWith(item.href));
+ return (
+ <Link
+ key={item.href}
+ href={item.href}
+ className={cn(
+ "mb-0.5 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
+ isActive
+ ? "bg-omega-500/10 text-omega-400"
+ : "text-gray-400 hover:bg-navy-800 hover:text-gray-200"
+ )}
+ >
+ <span
+ className={cn(
+ "flex h-6 w-6 items-center justify-center rounded text-xs font-bold",
+ isActive
+ ? (item as any).accent ? "bg-[#722F37] text-white" : "bg-omega-500 text-white"
+ : (item as any).accent ? "bg-[#722F37]/30 text-[#C89B3C]" : "bg-navy-800 text-gray-500"
+ )}
+ >
+ {item.icon}
+ </span>
+ {item.label}
+ </Link>
+ );
+ })}
+ </nav>
+ <div className="border-t border-navy-700 p-3">
+ <div className="text-xs text-gray-500">v2.0.0 Enterprise</div>
+ <div className="text-xs text-gray-600">Dual-Ledger Architecture</div>
+ </div>
+ </aside>
+ );
+}
diff --git a/src/components/panels/WatchSelector.tsx b/src/components/panels/WatchSelector.tsx
new file mode 100644
index 0000000..41f1a80
--- /dev/null
+++ b/src/components/panels/WatchSelector.tsx
@@ -0,0 +1,221 @@
+'use client';
+
+import { useState, useMemo } from 'react';
+import { formatCurrency } from '@/lib/utils';
+
+interface WatchRef {
+ id: string;
+ brand: string;
+ collection: string;
+ model_name: string;
+ reference_number: string;
+ calibre: string;
+ case_material: string;
+ case_diameter_mm: string;
+ dial_color: string;
+ year_introduced: number;
+ notes?: string;
+ event_count: string;
+ msrp_count: string;
+ latest_msrp: string | null;
+ avg_market_price: string | null;
+ latest_sale_price: string | null;
+ latest_sale_date: string | null;
+}
+
+interface WatchSelectorProps {
+ references: WatchRef[];
+ selected: WatchRef | null;
+ compareList: WatchRef[];
+ onSelect: (ref: WatchRef) => void;
+ onToggleCompare: (ref: WatchRef) => void;
+}
+
+const COLLECTION_ICONS: Record<string, string> = {
+ Speedmaster: 'S',
+ Seamaster: 'W',
+ Constellation: 'C',
+ 'De Ville': 'D',
+ Vintage: 'V',
+};
+
+function Sparkline({ prices, color = '#c89b3c' }: { prices: number[]; color?: string }) {
+ if (prices.length < 2) return null;
+ const min = Math.min(...prices);
+ const max = Math.max(...prices);
+ const range = max - min || 1;
+ const w = 60;
+ const h = 20;
+ const points = prices.map((p, i) => {
+ const x = (i / (prices.length - 1)) * w;
+ const y = h - ((p - min) / range) * h;
+ return `${x},${y}`;
+ }).join(' ');
+
+ return (
+ <svg width={w} height={h} className="inline-block">
+ <polyline
+ points={points}
+ fill="none"
+ stroke={color}
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ );
+}
+
+export default function WatchSelector({ references, selected, compareList, onSelect, onToggleCompare }: WatchSelectorProps) {
+ const [search, setSearch] = useState('');
+ const [expandedCollection, setExpandedCollection] = useState<string | null>(null);
+
+ const grouped = useMemo(() => {
+ const groups: Record<string, WatchRef[]> = {};
+ for (const ref of references) {
+ const col = ref.collection || 'Other';
+ if (!groups[col]) groups[col] = [];
+ groups[col].push(ref);
+ }
+ return groups;
+ }, [references]);
+
+ const filtered = useMemo(() => {
+ if (!search) return null;
+ const s = search.toLowerCase();
+ return references.filter(r =>
+ r.reference_number.toLowerCase().includes(s) ||
+ r.model_name.toLowerCase().includes(s) ||
+ r.collection.toLowerCase().includes(s) ||
+ r.dial_color?.toLowerCase().includes(s)
+ );
+ }, [search, references]);
+
+ const isCompared = (ref: WatchRef) => compareList.some(c => c.id === ref.id);
+
+ const renderRef = (ref: WatchRef) => {
+ const msrp = ref.latest_msrp ? parseFloat(ref.latest_msrp) : null;
+ const avg = ref.avg_market_price ? parseFloat(ref.avg_market_price) : null;
+ const premium = msrp && avg ? ((avg - msrp) / msrp) * 100 : null;
+ const isActive = selected?.id === ref.id;
+
+ return (
+ <div
+ key={ref.id}
+ className={`group cursor-pointer rounded-lg border p-2.5 transition-all ${
+ isActive
+ ? 'border-omega-500/50 bg-omega-500/10'
+ : 'border-transparent hover:border-navy-600 hover:bg-navy-800/50'
+ }`}
+ onClick={() => onSelect(ref)}
+ >
+ <div className="flex items-start justify-between">
+ <div className="min-w-0 flex-1">
+ <div className="flex items-center gap-1.5">
+ <span className="font-mono text-xs text-omega-400">{ref.reference_number}</span>
+ {isCompared(ref) && (
+ <span className="rounded bg-blue-500/20 px-1 text-[9px] font-medium text-blue-400">CMP</span>
+ )}
+ </div>
+ <div className="mt-0.5 truncate text-sm font-medium text-gray-200">{ref.model_name}</div>
+ <div className="mt-0.5 flex items-center gap-2 text-[11px] text-gray-500">
+ <span>{ref.case_diameter_mm}mm</span>
+ <span>{ref.dial_color}</span>
+ <span>{ref.event_count} events</span>
+ </div>
+ </div>
+ <div className="ml-2 text-right">
+ {avg && (
+ <div className="text-sm font-medium text-gray-200">{formatCurrency(avg)}</div>
+ )}
+ {premium !== null && (
+ <div className={`text-[11px] font-medium ${premium >= 0 ? 'text-green-400' : 'text-red-400'}`}>
+ {premium >= 0 ? '+' : ''}{premium.toFixed(1)}%
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* Compare toggle on hover */}
+ <button
+ onClick={(e) => { e.stopPropagation(); onToggleCompare(ref); }}
+ className={`mt-1.5 w-full rounded py-0.5 text-[10px] font-medium transition-colors ${
+ isCompared(ref)
+ ? 'bg-blue-500/20 text-blue-400 hover:bg-red-500/20 hover:text-red-400'
+ : 'bg-navy-700/50 text-gray-500 opacity-0 group-hover:opacity-100 hover:text-white'
+ }`}
+ >
+ {isCompared(ref) ? 'Remove from compare' : '+ Compare'}
+ </button>
+ </div>
+ );
+ };
+
+ return (
+ <div className="flex h-full flex-col rounded-xl border border-navy-700 bg-navy-800/30">
+ {/* Header */}
+ <div className="border-b border-navy-700 p-3">
+ <h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-400">
+ Watch Selector
+ </h3>
+ <input
+ type="text"
+ placeholder="Search models, refs..."
+ value={search}
+ onChange={e => setSearch(e.target.value)}
+ className="w-full rounded-md border border-navy-600 bg-navy-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 outline-none focus:border-omega-500"
+ />
+ {compareList.length > 0 && (
+ <div className="mt-2 text-[11px] text-blue-400">
+ Comparing {compareList.length} model{compareList.length > 1 ? 's' : ''}
+ </div>
+ )}
+ </div>
+
+ {/* Tree / search results */}
+ <div className="flex-1 overflow-y-auto p-2">
+ {filtered ? (
+ <div className="space-y-1">
+ {filtered.length === 0 ? (
+ <p className="py-4 text-center text-xs text-gray-500">No matches found</p>
+ ) : (
+ filtered.map(renderRef)
+ )}
+ </div>
+ ) : (
+ <div className="space-y-1">
+ {Object.entries(grouped).map(([collection, refs]) => (
+ <div key={collection}>
+ <button
+ className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium text-gray-300 hover:bg-navy-800"
+ onClick={() => setExpandedCollection(expandedCollection === collection ? null : collection)}
+ >
+ <span className={`flex h-5 w-5 items-center justify-center rounded text-[10px] font-bold ${
+ expandedCollection === collection ? 'bg-omega-500 text-white' : 'bg-navy-700 text-gray-400'
+ }`}>
+ {COLLECTION_ICONS[collection] || collection[0]}
+ </span>
+ <span className="flex-1">{collection}</span>
+ <span className="text-[10px] text-gray-500">{refs.length}</span>
+ <span className="text-gray-500">{expandedCollection === collection ? '▾' : '▸'}</span>
+ </button>
+ {expandedCollection === collection && (
+ <div className="ml-2 mt-1 space-y-1 border-l border-navy-700 pl-2">
+ {refs.map(renderRef)}
+ </div>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+
+ {/* Summary footer */}
+ <div className="border-t border-navy-700 p-2.5 text-center">
+ <div className="text-[11px] text-gray-500">
+ {references.length} references across {Object.keys(grouped).length} collections
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/panels/WilliamPanel.tsx b/src/components/panels/WilliamPanel.tsx
new file mode 100644
index 0000000..e43fcf9
--- /dev/null
+++ b/src/components/panels/WilliamPanel.tsx
@@ -0,0 +1,153 @@
+'use client';
+
+import { useState, useEffect, useRef } from 'react';
+
+interface WilliamMessage {
+ timestamp: string;
+ text: string;
+ type: 'observation' | 'alert' | 'commentary' | 'action';
+}
+
+interface WilliamStatus {
+ status: string;
+ agent: string;
+ paused: boolean;
+ processing: boolean;
+ color: string;
+ uptime: number;
+}
+
+const WILLIAM_URL = '/api/william';
+
+function formatUptime(seconds: number): string {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ if (h > 0) return `${h}h ${m}m`;
+ return `${m}m`;
+}
+
+function formatTime(ts: string): string {
+ try {
+ return new Date(ts).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
+ } catch {
+ return '';
+ }
+}
+
+export default function WilliamPanel() {
+ const [messages, setMessages] = useState<WilliamMessage[]>([]);
+ const [status, setStatus] = useState<WilliamStatus | null>(null);
+ const [loading, setLoading] = useState(true);
+ const scrollRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ fetchData();
+ const interval = setInterval(fetchData, 30000);
+ return () => clearInterval(interval);
+ }, []);
+
+ useEffect(() => {
+ if (scrollRef.current) {
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ }
+ }, [messages]);
+
+ async function fetchData() {
+ try {
+ const [obsRes, statusRes] = await Promise.all([
+ fetch(WILLIAM_URL),
+ fetch(`${WILLIAM_URL}?action=status`),
+ ]);
+ if (obsRes.ok) {
+ const obsData = await obsRes.json();
+ setMessages(obsData.observations || []);
+ }
+ if (statusRes.ok) {
+ const statusData = await statusRes.json();
+ setStatus(statusData);
+ }
+ } catch {
+ // Silently handle — William may be offline
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ const typeStyles: Record<string, { bg: string; border: string; icon: string }> = {
+ alert: { bg: 'bg-red-900/20', border: 'border-red-800/40', icon: '!' },
+ observation: { bg: 'bg-[#722F37]/10', border: 'border-[#722F37]/30', icon: 'W' },
+ commentary: { bg: 'bg-[#722F37]/5', border: 'border-[#722F37]/20', icon: '~' },
+ action: { bg: 'bg-amber-900/20', border: 'border-amber-800/30', icon: '>' },
+ };
+
+ return (
+ <div className="flex h-full flex-col rounded-xl border border-[#722F37]/30 bg-[#722F37]/5">
+ {/* Header */}
+ <div className="flex items-center justify-between border-b border-[#722F37]/20 px-3 py-2.5"
+ style={{ background: 'linear-gradient(135deg, #722F37 0%, #4a1f24 100%)' }}>
+ <div className="flex items-center gap-2">
+ <span className="text-lg">🎩</span>
+ <div>
+ <div className="text-sm font-bold text-white">William</div>
+ <div className="text-[10px] text-gray-300">Autonomous Watch Agent</div>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ {status && (
+ <>
+ <span className={`h-2 w-2 rounded-full ${status.paused ? 'bg-amber-400' : 'bg-green-400'} animate-pulse`} />
+ <span className="text-[10px] text-gray-300">
+ {status.paused ? 'Paused' : 'Active'} · {formatUptime(status.uptime)}
+ </span>
+ </>
+ )}
+ </div>
+ </div>
+
+ {/* Messages feed */}
+ <div ref={scrollRef} className="flex-1 space-y-2 overflow-y-auto p-2.5">
+ {loading ? (
+ <div className="flex h-32 items-center justify-center">
+ <div className="text-xs text-gray-500">Connecting to William...</div>
+ </div>
+ ) : messages.length === 0 ? (
+ <div className="flex h-32 flex-col items-center justify-center gap-2 text-center">
+ <span className="text-2xl">🎩</span>
+ <p className="text-xs text-gray-500">
+ William is observing the market.<br />
+ Commentary shall appear presently.
+ </p>
+ </div>
+ ) : (
+ messages.map((msg, i) => {
+ const style = typeStyles[msg.type] || typeStyles.observation;
+ return (
+ <div
+ key={i}
+ className={`rounded-lg border ${style.border} ${style.bg} p-2.5`}
+ >
+ <div className="flex items-center gap-1.5">
+ <span className="flex h-4 w-4 items-center justify-center rounded bg-[#722F37] text-[9px] font-bold text-white">
+ {style.icon}
+ </span>
+ <span className="text-[10px] text-gray-500">{formatTime(msg.timestamp)}</span>
+ <span className="rounded bg-[#722F37]/30 px-1 text-[9px] text-[#C89B3C]">{msg.type}</span>
+ </div>
+ <p className="mt-1 text-xs leading-relaxed text-gray-300" style={{ fontFamily: 'Georgia, serif' }}>
+ {msg.text}
+ </p>
+ </div>
+ );
+ })
+ )}
+ </div>
+
+ {/* Footer */}
+ <div className="border-t border-[#722F37]/20 px-3 py-2 text-center">
+ <div className="text-[10px] text-gray-500" style={{ fontFamily: 'Georgia, serif' }}>
+ “One observes the market with the keenest attention.”
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/ui/DataTable.tsx b/src/components/ui/DataTable.tsx
new file mode 100644
index 0000000..64ee90b
--- /dev/null
+++ b/src/components/ui/DataTable.tsx
@@ -0,0 +1,74 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+
+interface Column<T> {
+ key: string;
+ header: string;
+ render?: (row: T) => React.ReactNode;
+ className?: string;
+}
+
+interface DataTableProps<T> {
+ columns: Column<T>[];
+ data: T[];
+ onRowClick?: (row: T) => void;
+ emptyMessage?: string;
+}
+
+export default function DataTable<T extends Record<string, any>>({
+ columns,
+ data,
+ onRowClick,
+ emptyMessage = 'No data available',
+}: DataTableProps<T>) {
+ if (!data?.length) {
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 p-8 text-center">
+ <p className="text-navy-400">{emptyMessage}</p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="bg-navy-800/50 rounded-xl border border-navy-700 overflow-hidden">
+ <div className="overflow-x-auto">
+ <table className="w-full">
+ <thead>
+ <tr className="border-b border-navy-700">
+ {columns.map((col) => (
+ <th
+ key={col.key}
+ className={cn(
+ 'px-4 py-3 text-left text-xs uppercase tracking-wider text-navy-400 font-medium',
+ col.className
+ )}
+ >
+ {col.header}
+ </th>
+ ))}
+ </tr>
+ </thead>
+ <tbody className="divide-y divide-navy-700/50">
+ {data.map((row, i) => (
+ <tr
+ key={i}
+ onClick={() => onRowClick?.(row)}
+ className={cn(
+ 'hover:bg-navy-800/80 transition-colors',
+ onRowClick && 'cursor-pointer'
+ )}
+ >
+ {columns.map((col) => (
+ <td key={col.key} className={cn('px-4 py-3 text-sm text-navy-200', col.className)}>
+ {col.render ? col.render(row) : row[col.key]}
+ </td>
+ ))}
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/ui/StatCard.tsx b/src/components/ui/StatCard.tsx
new file mode 100644
index 0000000..04f32ca
--- /dev/null
+++ b/src/components/ui/StatCard.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+
+interface StatCardProps {
+ title: string;
+ value: string | number;
+ subtitle?: string;
+ change?: number;
+ icon?: React.ReactNode;
+ variant?: 'default' | 'success' | 'warning' | 'danger';
+}
+
+const variantStyles = {
+ default: 'border-navy-700',
+ success: 'border-green-500/30',
+ warning: 'border-yellow-500/30',
+ danger: 'border-red-500/30',
+};
+
+export default function StatCard({ title, value, subtitle, change, icon, variant = 'default' }: StatCardProps) {
+ return (
+ <div className={cn(
+ 'bg-navy-800/50 rounded-xl border p-4 hover:bg-navy-800 transition-colors',
+ variantStyles[variant]
+ )}>
+ <div className="flex items-center justify-between mb-2">
+ <span className="text-xs uppercase tracking-wider text-navy-400">{title}</span>
+ {icon && <span className="text-navy-400">{icon}</span>}
+ </div>
+ <div className="text-2xl font-bold text-white">{value}</div>
+ <div className="flex items-center gap-2 mt-1">
+ {subtitle && <span className="text-xs text-navy-400">{subtitle}</span>}
+ {change !== undefined && (
+ <span className={cn('text-xs font-medium', change >= 0 ? 'text-green-400' : 'text-red-400')}>
+ {change >= 0 ? '+' : ''}{change.toFixed(1)}%
+ </span>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/ui/StatusBadge.tsx b/src/components/ui/StatusBadge.tsx
new file mode 100644
index 0000000..ff88b7b
--- /dev/null
+++ b/src/components/ui/StatusBadge.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+
+type BadgeVariant = 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'gold';
+
+interface StatusBadgeProps {
+ label: string;
+ variant?: BadgeVariant;
+ pulse?: boolean;
+}
+
+const variantStyles: Record<BadgeVariant, string> = {
+ success: 'bg-green-500/20 text-green-400 border-green-500/30',
+ warning: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
+ danger: 'bg-red-500/20 text-red-400 border-red-500/30',
+ info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
+ neutral: 'bg-navy-700/50 text-navy-300 border-navy-600',
+ gold: 'bg-omega-500/20 text-omega-400 border-omega-500/30',
+};
+
+export default function StatusBadge({ label, variant = 'neutral', pulse = false }: StatusBadgeProps) {
+ return (
+ <span className={cn(
+ 'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border',
+ variantStyles[variant]
+ )}>
+ {pulse && (
+ <span className={cn(
+ 'w-1.5 h-1.5 rounded-full animate-pulse',
+ variant === 'success' && 'bg-green-400',
+ variant === 'warning' && 'bg-yellow-400',
+ variant === 'danger' && 'bg-red-400',
+ variant === 'info' && 'bg-blue-400',
+ variant === 'gold' && 'bg-omega-400',
+ )} />
+ )}
+ {label}
+ </span>
+ );
+}
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
new file mode 100644
index 0000000..73f8b8b
--- /dev/null
+++ b/src/components/ui/index.ts
@@ -0,0 +1,3 @@
+export { default as StatCard } from './StatCard';
+export { default as StatusBadge } from './StatusBadge';
+export { default as DataTable } from './DataTable';
diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts
new file mode 100644
index 0000000..5d1f849
--- /dev/null
+++ b/src/lib/api-client.ts
@@ -0,0 +1,119 @@
+const BASE_URL = "";
+
+function getToken(): string | null {
+ if (typeof window === "undefined") return null;
+ return localStorage.getItem("omega_token");
+}
+
+async function fetchAPI<T>(
+ endpoint: string,
+ options: RequestInit = {}
+): Promise<T> {
+ const token = getToken();
+ const headers: Record<string, string> = {
+ "Content-Type": "application/json",
+ ...(options.headers as Record<string, string>),
+ };
+ if (token) {
+ headers["Authorization"] = `Bearer ${token}`;
+ }
+
+ const res = await fetch(`${BASE_URL}${endpoint}`, {
+ ...options,
+ headers,
+ });
+
+ if (res.status === 401) {
+ if (typeof window !== "undefined") {
+ localStorage.removeItem("omega_token");
+ window.location.href = "/login";
+ }
+ throw new Error("Unauthorized");
+ }
+
+ const data = await res.json();
+ if (!data.success) {
+ throw new Error(data.error?.message || "API Error");
+ }
+ return data.data;
+}
+
+export const api = {
+ // Health
+ health: () => fetchAPI<any>("/api/health"),
+
+ // Auth
+ login: (username: string, password: string) =>
+ fetchAPI<any>("/api/auth/login", {
+ method: "POST",
+ body: JSON.stringify({ username, password }),
+ }),
+
+ // Sources
+ getSources: () => fetchAPI<any[]>("/api/sources"),
+ getSourceStatus: (name: string) =>
+ fetchAPI<any>(`/api/sources/${name}/status`),
+
+ // Jobs
+ getJobs: (params?: { date?: string; status?: string; limit?: number }) => {
+ const qs = new URLSearchParams(params as any).toString();
+ return fetchAPI<any[]>(`/api/jobs${qs ? `?${qs}` : ""}`);
+ },
+ runJob: (source_name: string, job_type?: string) =>
+ fetchAPI<any>("/api/jobs/run", {
+ method: "POST",
+ body: JSON.stringify({ source_name, job_type }),
+ }),
+ backfill: (source_name: string, date_from: string, date_to: string) =>
+ fetchAPI<any>("/api/jobs/backfill", {
+ method: "POST",
+ body: JSON.stringify({ source_name, date_from, date_to }),
+ }),
+
+ // Models
+ getTimeseries: (reference: string, params?: Record<string, string>) => {
+ const qs = new URLSearchParams(params).toString();
+ return fetchAPI<any>(
+ `/api/models/${encodeURIComponent(reference)}/timeseries${qs ? `?${qs}` : ""}`
+ );
+ },
+ getDistributions: (reference: string) =>
+ fetchAPI<any>(
+ `/api/models/${encodeURIComponent(reference)}/distributions`
+ ),
+
+ // MSRP
+ getMSRP: (reference: string, region?: string) =>
+ fetchAPI<any>(
+ `/api/msrp/${encodeURIComponent(reference)}${region ? `?region=${region}` : ""}`
+ ),
+
+ // Quarantine
+ getQuarantine: (params?: { resolved?: boolean; severity?: string }) => {
+ const qs = new URLSearchParams(params as any).toString();
+ return fetchAPI<any>(`/api/quarantine${qs ? `?${qs}` : ""}`);
+ },
+ resolveQuarantine: (id: string, resolution_notes: string, action?: string) =>
+ fetchAPI<any>(`/api/quarantine/${id}/resolve`, {
+ method: "POST",
+ body: JSON.stringify({ resolution_notes, action }),
+ }),
+
+ // Audit
+ getAuditLog: (params?: { table?: string; operation?: string }) => {
+ const qs = new URLSearchParams(params as any).toString();
+ return fetchAPI<any>(`/api/auditlog${qs ? `?${qs}` : ""}`);
+ },
+
+ // Dashboard chart data
+ getDashboardChartData: () => fetchAPI<any>("/api/dashboard/chart-data"),
+
+ // Jobs daily stats
+ getJobsDailyStats: () => fetchAPI<any>("/api/jobs/daily-stats"),
+
+ // References
+ getReferences: (params?: { collection?: string; q?: string }) => {
+ const qs = new URLSearchParams(params as any).toString();
+ return fetchAPI<any>(`/api/references${qs ? `?${qs}` : ""}`);
+ },
+};
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..451c8d1
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,84 @@
+import { SignJWT, jwtVerify } from "jose";
+import { compare, hash } from "bcryptjs";
+import { cookies } from "next/headers";
+import { NextRequest } from "next/server";
+
+const JWT_SECRET = new TextEncoder().encode(
+ process.env.JWT_SECRET || "omega-watches-2-enterprise-secret-key-2024"
+);
+const TOKEN_EXPIRY = "24h";
+const COOKIE_NAME = "omega_session";
+
+// Omega standalone credentials
+const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "admin";
+const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "OmegaAdmin2024!";
+
+export interface SessionPayload {
+ userId: string;
+ username: string;
+ role: "admin" | "operator" | "auditor" | "viewer";
+ iat: number;
+ exp: number;
+}
+
+export async function verifyCredentials(
+ username: string,
+ password: string
+): Promise<{ valid: boolean; role: string }> {
+ if (username === ADMIN_USERNAME && password === ADMIN_PASSWORD) {
+ return { valid: true, role: "admin" };
+ }
+ return { valid: false, role: "" };
+}
+
+export async function createSession(
+ userId: string,
+ username: string,
+ role: string
+): Promise<string> {
+ const token = await new SignJWT({ userId, username, role })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt()
+ .setExpirationTime(TOKEN_EXPIRY)
+ .sign(JWT_SECRET);
+ return token;
+}
+
+export async function verifySession(
+ token: string
+): Promise<SessionPayload | null> {
+ try {
+ const { payload } = await jwtVerify(token, JWT_SECRET);
+ return payload as unknown as SessionPayload;
+ } catch {
+ return null;
+ }
+}
+
+export async function getSession(
+ request: NextRequest
+): Promise<SessionPayload | null> {
+ const token =
+ request.cookies.get(COOKIE_NAME)?.value ||
+ request.headers.get("authorization")?.replace("Bearer ", "");
+ if (!token) return null;
+ return verifySession(token);
+}
+
+export async function requireAuth(
+ request: NextRequest
+): Promise<SessionPayload> {
+ const session = await getSession(request);
+ if (!session) {
+ throw new Error("Unauthorized");
+ }
+ return session;
+}
+
+export function requireRole(session: SessionPayload, roles: string[]): void {
+ if (!roles.includes(session.role)) {
+ throw new Error(`Forbidden: requires one of [${roles.join(", ")}]`);
+ }
+}
+
+export { COOKIE_NAME };
diff --git a/src/lib/db.ts b/src/lib/db.ts
new file mode 100644
index 0000000..734a957
--- /dev/null
+++ b/src/lib/db.ts
@@ -0,0 +1,83 @@
+import { Pool, PoolClient } from "pg";
+
+const pool = new Pool({
+ host: process.env.DB_HOST || "127.0.0.1",
+ port: parseInt(process.env.DB_PORT || "5432"),
+ database: process.env.DB_NAME || "omega_watches_v2",
+ user: process.env.DB_USER || "omega_admin",
+ password: process.env.DB_PASSWORD || "OmegaWatch2024Pass",
+ max: 20,
+ idleTimeoutMillis: 30000,
+ connectionTimeoutMillis: 5000,
+});
+
+pool.on("error", (err) => {
+ console.error("[DB] Unexpected pool error:", err.message);
+});
+
+export async function query<T = any>(
+ text: string,
+ params?: any[]
+): Promise<T[]> {
+ const start = Date.now();
+ const result = await pool.query(text, params);
+ const duration = Date.now() - start;
+ if (duration > 1000) {
+ console.warn(`[DB] Slow query (${duration}ms):`, text.slice(0, 100));
+ }
+ return result.rows as T[];
+}
+
+export async function queryOne<T = any>(
+ text: string,
+ params?: any[]
+): Promise<T | null> {
+ const rows = await query<T>(text, params);
+ return rows[0] || null;
+}
+
+export async function transaction<T>(
+ fn: (client: PoolClient) => Promise<T>
+): Promise<T> {
+ const client = await pool.connect();
+ try {
+ await client.query("BEGIN");
+ const result = await fn(client);
+ await client.query("COMMIT");
+ return result;
+ } catch (e) {
+ await client.query("ROLLBACK");
+ throw e;
+ } finally {
+ client.release();
+ }
+}
+
+export async function healthCheck(): Promise<{
+ ok: boolean;
+ latency: number;
+ connections: { total: number; idle: number; waiting: number };
+}> {
+ const start = Date.now();
+ try {
+ await pool.query("SELECT 1");
+ return {
+ ok: true,
+ latency: Date.now() - start,
+ connections: {
+ total: pool.totalCount,
+ idle: pool.idleCount,
+ waiting: pool.waitingCount,
+ },
+ };
+ } catch {
+ return {
+ ok: false,
+ latency: Date.now() - start,
+ connections: { total: 0, idle: 0, waiting: 0 },
+ };
+ }
+}
+
+export { pool };
+export default pool;
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..16f36e7
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,63 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export function formatCurrency(
+ amount: number,
+ currency: string = "USD"
+): string {
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(amount);
+}
+
+export function formatDate(date: string | Date): string {
+ return new Intl.DateTimeFormat("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ }).format(new Date(date));
+}
+
+export function formatDateTime(date: string | Date): string {
+ return new Intl.DateTimeFormat("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ }).format(new Date(date));
+}
+
+export function slugify(text: string): string {
+ return text
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "");
+}
+
+export function truncate(str: string, length: number): string {
+ if (str.length <= length) return str;
+ return str.slice(0, length) + "...";
+}
+
+export function percentChange(oldVal: number, newVal: number): number {
+ if (oldVal === 0) return 0;
+ return ((newVal - oldVal) / oldVal) * 100;
+}
+
+export function cagr(
+ startVal: number,
+ endVal: number,
+ years: number
+): number {
+ if (startVal <= 0 || years <= 0) return 0;
+ return (Math.pow(endVal / startVal, 1 / years) - 1) * 100;
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..1ecba31
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,50 @@
+import { NextRequest, NextResponse } from "next/server";
+import { verifySession, COOKIE_NAME } from "@/lib/auth";
+
+const publicPaths = ["/login", "/api/health", "/api/auth/login"];
+
+export async function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+
+ // Allow public paths
+ if (publicPaths.some((p) => pathname.startsWith(p))) {
+ return NextResponse.next();
+ }
+
+ // Allow static assets
+ if (pathname.startsWith("/_next") || pathname.startsWith("/favicon")) {
+ return NextResponse.next();
+ }
+
+ // Check session
+ const token =
+ request.cookies.get(COOKIE_NAME)?.value ||
+ request.headers.get("authorization")?.replace("Bearer ", "");
+
+ if (!token) {
+ if (pathname.startsWith("/api/")) {
+ return NextResponse.json(
+ { success: false, error: { code: "UNAUTHORIZED", message: "Login required" } },
+ { status: 401 }
+ );
+ }
+ return NextResponse.redirect(new URL("/login", request.url));
+ }
+
+ const session = await verifySession(token);
+ if (!session) {
+ if (pathname.startsWith("/api/")) {
+ return NextResponse.json(
+ { success: false, error: { code: "SESSION_EXPIRED", message: "Session expired" } },
+ { status: 401 }
+ );
+ }
+ return NextResponse.redirect(new URL("/login", request.url));
+ }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
+};
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..15f6625
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,297 @@
+// ============================================================================
+// OMEGA WATCHES 2.0 — ENTERPRISE TYPE DEFINITIONS
+// Dual-Ledger: MSRP Snapshots + Secondary Market Events
+// ============================================================================
+
+// --- Watch Reference (Master Entity) ---
+export interface WatchReference {
+ id: string;
+ brand: string;
+ collection: string;
+ model_name: string;
+ reference_number: string;
+ calibre: string | null;
+ case_material: string | null;
+ case_diameter_mm: number | null;
+ dial_color: string | null;
+ year_introduced: number | null;
+ year_discontinued: number | null;
+ is_limited_edition: boolean;
+ limited_edition_count: number | null;
+ notes: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+// --- MSRP Snapshot (Ledger 1: Primary Market) ---
+export interface MSRPSnapshot {
+ msrp_id: string;
+ reference_id: string;
+ region_code: string;
+ currency: string;
+ msrp_amount: number;
+ captured_date: string;
+ source_url: string;
+ page_hash: string | null;
+ parser_version: string;
+ created_at: string;
+}
+
+export interface MSRPChangeEvent {
+ id: string;
+ reference_id: string;
+ region_code: string;
+ change_date: string;
+ old_price: number;
+ new_price: number;
+ currency: string;
+ pct_change: number;
+}
+
+// --- Market Event (Ledger 2: Secondary Market) ---
+export type EventType =
+ | "auction_realized"
+ | "marketplace_sold"
+ | "dealer_sold"
+ | "dealer_ask"
+ | "forum_listing"
+ | "price_guide";
+
+export type SellerType =
+ | "auction_house"
+ | "dealer"
+ | "private"
+ | "marketplace"
+ | "unknown";
+
+export type FeeSemantics =
+ | "hammer_only"
+ | "price_realised"
+ | "total_to_buyer"
+ | "unknown";
+
+export type ConditionGrade =
+ | "new_unworn"
+ | "excellent"
+ | "very_good"
+ | "good"
+ | "fair"
+ | "poor"
+ | "parts_only"
+ | "unknown";
+
+export interface MarketEvent {
+ event_id: string;
+ source_name: string;
+ source_listing_id: string;
+ event_type: EventType;
+ reference_id: string | null;
+ reference_number_raw: string | null;
+ serial_number: string | null;
+ year_text: string | null;
+ case_material: string | null;
+ movement: string | null;
+ condition_raw: string | null;
+ condition_normalized: ConditionGrade;
+ provenance_raw: string | null;
+ sale_date: string | null;
+ location_text: string | null;
+ seller_type: SellerType;
+ // Price fields (dual-ledger fee semantics)
+ price_amount: number | null;
+ currency: string;
+ hammer_price: number | null;
+ buyers_premium: number | null;
+ total_to_buyer: number | null;
+ fee_semantics: FeeSemantics;
+ // FX normalization
+ price_usd: number | null;
+ fx_rate: number | null;
+ fx_source: string | null;
+ // Inflation adjustment
+ price_usd_real: number | null;
+ cpi_base_year: number | null;
+ // Metadata
+ images_json: string[] | null;
+ source_url: string;
+ scraped_at: string;
+ page_hash: string | null;
+ parser_version: string;
+ // Quality
+ quality_score: number | null;
+ is_quarantined: boolean;
+ quarantine_reason: string | null;
+ created_at: string;
+}
+
+// --- Data Source ---
+export type SourceAccessMethod = "api" | "scrape" | "manual" | "export";
+
+export interface DataSource {
+ id: string;
+ name: string;
+ display_name: string;
+ source_type: string;
+ access_method: SourceAccessMethod;
+ base_url: string | null;
+ robots_txt_compliant: boolean;
+ tos_reviewed: boolean;
+ tos_allows_collection: boolean | null;
+ reliability_score: number;
+ coverage_years_from: number | null;
+ coverage_years_to: number | null;
+ notes: string | null;
+ is_active: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
+// --- Collector Run ---
+export type JobStatus =
+ | "pending"
+ | "running"
+ | "completed"
+ | "failed"
+ | "cancelled";
+
+export interface CollectorRun {
+ run_id: string;
+ source_id: string;
+ job_type: string;
+ status: JobStatus;
+ started_at: string | null;
+ completed_at: string | null;
+ records_fetched: number;
+ records_parsed: number;
+ records_inserted: number;
+ records_updated: number;
+ records_quarantined: number;
+ records_deduplicated: number;
+ error_message: string | null;
+ parser_version: string;
+ metadata: Record<string, any> | null;
+ created_at: string;
+}
+
+// --- Parser Version ---
+export interface ParserVersion {
+ id: string;
+ source_id: string;
+ version: string;
+ selectors_json: Record<string, any>;
+ dom_hash: string | null;
+ is_current: boolean;
+ tested_at: string | null;
+ test_pass_rate: number | null;
+ notes: string | null;
+ created_at: string;
+}
+
+// --- Quarantine Record ---
+export interface QuarantineRecord {
+ id: string;
+ event_id: string | null;
+ msrp_id: string | null;
+ reason: string;
+ severity: "low" | "medium" | "high" | "critical";
+ details: Record<string, any> | null;
+ resolved: boolean;
+ resolved_by: string | null;
+ resolved_at: string | null;
+ resolution_notes: string | null;
+ created_at: string;
+}
+
+// --- Condition Mapping ---
+export interface ConditionMapping {
+ id: string;
+ source_name: string;
+ source_category: string;
+ normalized_grade: ConditionGrade;
+ score_0_100: number;
+ version: number;
+ created_at: string;
+}
+
+// --- FX Rate ---
+export interface FXRate {
+ id: string;
+ currency_from: string;
+ currency_to: string;
+ rate: number;
+ rate_date: string;
+ source: string;
+ created_at: string;
+}
+
+// --- Audit Log ---
+export interface AuditLogEntry {
+ id: string;
+ table_name: string;
+ record_id: string;
+ operation: "INSERT" | "UPDATE" | "DELETE";
+ old_values: Record<string, any> | null;
+ new_values: Record<string, any> | null;
+ changed_by: string;
+ ip_address: string | null;
+ changed_at: string;
+}
+
+// --- API Response Wrappers ---
+export interface ApiResponse<T> {
+ success: boolean;
+ data: T;
+ meta?: {
+ total?: number;
+ page?: number;
+ per_page?: number;
+ timestamp: string;
+ };
+}
+
+export interface ApiError {
+ success: false;
+ error: {
+ code: string;
+ message: string;
+ details?: any;
+ };
+}
+
+// --- Dashboard / Control Plane ---
+export interface SystemHealth {
+ database: { ok: boolean; latency: number };
+ collectors: {
+ active: number;
+ failed_24h: number;
+ last_run: string | null;
+ };
+ msrp: {
+ snapshots_today: number;
+ sources_reporting: number;
+ last_capture: string | null;
+ };
+ market_events: {
+ total: number;
+ quarantined: number;
+ last_ingested: string | null;
+ };
+ uptime: number;
+ version: string;
+}
+
+export interface TimeSeriesPoint {
+ date: string;
+ value: number;
+ source?: string;
+ event_type?: EventType;
+ condition?: ConditionGrade;
+ fee_semantics?: FeeSemantics;
+}
+
+export interface PriceDistribution {
+ bucket_low: number;
+ bucket_high: number;
+ count: number;
+ avg_condition_score: number;
+}
diff --git a/tailwind.config.ts b/tailwind.config.ts
new file mode 100644
index 0000000..029d1bf
--- /dev/null
+++ b/tailwind.config.ts
@@ -0,0 +1,48 @@
+import type { Config } from "tailwindcss";
+
+const config: Config = {
+ content: [
+ "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
+ "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
+ "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
+ ],
+ darkMode: "class",
+ theme: {
+ extend: {
+ colors: {
+ omega: {
+ 50: "#fdf8f0",
+ 100: "#f9edd9",
+ 200: "#f2d8b0",
+ 300: "#e9bd7e",
+ 400: "#df9c4a",
+ 500: "#d4842e",
+ 600: "#c06a23",
+ 700: "#a05220",
+ 800: "#824221",
+ 900: "#6a381e",
+ 950: "#391b0e",
+ },
+ navy: {
+ 50: "#f0f4f8",
+ 100: "#dce4ed",
+ 200: "#bccbdd",
+ 300: "#8fa9c5",
+ 400: "#5e82a8",
+ 500: "#43668e",
+ 600: "#355178",
+ 700: "#2d4264",
+ 800: "#293954",
+ 900: "#263248",
+ 950: "#0f1623",
+ },
+ },
+ fontFamily: {
+ display: ["var(--font-inter)", "system-ui", "sans-serif"],
+ mono: ["var(--font-jetbrains)", "ui-monospace", "monospace"],
+ },
+ },
+ },
+ plugins: [],
+};
+export default config;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..c21e50a
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "target": "es2017",
+ "downlevelIteration": true,
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": { "@/*": ["./src/*"] }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
(oldest)
·
back to Omega Watches 2
·
tighten .gitignore: add missing standing-rule patterns (.env 14e25b3 →