From d4c714fadcae6c891ae9dc7cd310e2afd454e660 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Mon, 23 Mar 2026 15:03:21 -0400 Subject: [PATCH] Initial commit: STR Optimization Manager MVP Full-stack short-term rental management platform with: - React/Vite frontend with dark theme dashboard, performance, pricing, reservations, experiments, and settings pages - Fastify API server with auth, platform management, performance tracking, pricing, reservations, experiments, and weekly report endpoints - Playwright-based scraper service with Airbnb adapter (login with MFA, performance metrics, reservations, calendar pricing, price changes) - VRBO adapter scaffold and mock adapter for development - PostgreSQL with Drizzle ORM, migrations, and seed scripts - Job queue with worker for async scraping tasks - AES-256-GCM credential encryption for platform credentials - Session cookie persistence for scraper browser sessions - Docker Compose for PostgreSQL database Co-Authored-By: Claude Opus 4.6 --- .claude/launch.json | 29 + .env.example | 24 + .gitignore | 6 + apps/api/drizzle.config.ts | 12 + apps/api/package.json | 41 + apps/api/src/config.ts | 27 + apps/api/src/db/demo-seed.sql | 545 ++ apps/api/src/db/index.ts | 14 + .../db/migrations/0000_modern_screwball.sql | 96 + .../src/db/migrations/meta/0000_snapshot.json | 630 ++ apps/api/src/db/migrations/meta/_journal.json | 13 + apps/api/src/db/schema.ts | 195 + apps/api/src/db/seed-clean.ts | 44 + apps/api/src/db/seed-demo.ts | 340 + apps/api/src/db/seed.ts | 340 + apps/api/src/email/weeklyReport.ts | 194 + apps/api/src/index.ts | 54 + apps/api/src/plugins/auth.ts | 41 + apps/api/src/routes/auth.ts | 47 + apps/api/src/routes/experiments.ts | 145 + apps/api/src/routes/health.ts | 20 + apps/api/src/routes/performance.ts | 142 + apps/api/src/routes/platforms.ts | 209 + apps/api/src/routes/pricing.ts | 185 + apps/api/src/routes/reports.ts | 30 + apps/api/src/routes/reservations.ts | 75 + apps/api/src/scheduler/cron.ts | 58 + apps/api/src/services/encryption.ts | 45 + apps/api/tsconfig.json | 8 + apps/frontend/index.html | 15 + apps/frontend/package.json | 41 + apps/frontend/postcss.config.js | 6 + apps/frontend/src/App.tsx | 42 + apps/frontend/src/components/layout/Shell.tsx | 138 + apps/frontend/src/hooks/useAuth.ts | 47 + apps/frontend/src/index.css | 62 + apps/frontend/src/lib/api.ts | 119 + apps/frontend/src/lib/constants.ts | 27 + apps/frontend/src/lib/utils.ts | 52 + apps/frontend/src/main.tsx | 25 + apps/frontend/src/pages/Dashboard.tsx | 306 + apps/frontend/src/pages/Experiments.tsx | 530 ++ apps/frontend/src/pages/Login.tsx | 105 + apps/frontend/src/pages/Performance.tsx | 602 ++ apps/frontend/src/pages/Pricing.tsx | 714 ++ apps/frontend/src/pages/Reservations.tsx | 367 + apps/frontend/src/pages/Settings.tsx | 573 ++ apps/frontend/tailwind.config.ts | 27 + apps/frontend/tsconfig.json | 19 + apps/frontend/vite.config.ts | 21 + apps/scraper/package.json | 25 + .../src/adapters/airbnb/AirbnbAdapter.ts | 215 + .../src/adapters/airbnb/airbnb.flows.ts | 663 ++ .../src/adapters/airbnb/airbnb.selectors.ts | 86 + .../src/adapters/base/AdapterRegistry.ts | 31 + .../src/adapters/base/PlatformAdapter.ts | 36 + apps/scraper/src/adapters/mock/MockAdapter.ts | 79 + apps/scraper/src/adapters/mock/mock-data.ts | 151 + apps/scraper/src/adapters/vrbo/VrboAdapter.ts | 48 + apps/scraper/src/adapters/vrbo/vrbo.flows.ts | 20 + .../src/adapters/vrbo/vrbo.selectors.ts | 18 + apps/scraper/src/index.ts | 270 + apps/scraper/src/queue/jobQueue.ts | 78 + apps/scraper/src/queue/worker.ts | 288 + apps/scraper/src/utils/browser.ts | 54 + apps/scraper/src/utils/delay.ts | 8 + apps/scraper/src/utils/encryption.ts | 62 + apps/scraper/tsconfig.json | 8 + docker-compose.yml | 20 + package-lock.json | 7880 +++++++++++++++++ package.json | 24 + packages/shared-types/package.json | 14 + packages/shared-types/src/index.ts | 256 + packages/shared-types/tsconfig.json | 8 + str-optimization-manager-spec.md | 661 ++ tsconfig.base.json | 15 + 76 files changed, 18465 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 apps/api/drizzle.config.ts create mode 100644 apps/api/package.json create mode 100644 apps/api/src/config.ts create mode 100644 apps/api/src/db/demo-seed.sql create mode 100644 apps/api/src/db/index.ts create mode 100644 apps/api/src/db/migrations/0000_modern_screwball.sql create mode 100644 apps/api/src/db/migrations/meta/0000_snapshot.json create mode 100644 apps/api/src/db/migrations/meta/_journal.json create mode 100644 apps/api/src/db/schema.ts create mode 100644 apps/api/src/db/seed-clean.ts create mode 100644 apps/api/src/db/seed-demo.ts create mode 100644 apps/api/src/db/seed.ts create mode 100644 apps/api/src/email/weeklyReport.ts create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/plugins/auth.ts create mode 100644 apps/api/src/routes/auth.ts create mode 100644 apps/api/src/routes/experiments.ts create mode 100644 apps/api/src/routes/health.ts create mode 100644 apps/api/src/routes/performance.ts create mode 100644 apps/api/src/routes/platforms.ts create mode 100644 apps/api/src/routes/pricing.ts create mode 100644 apps/api/src/routes/reports.ts create mode 100644 apps/api/src/routes/reservations.ts create mode 100644 apps/api/src/scheduler/cron.ts create mode 100644 apps/api/src/services/encryption.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/frontend/index.html create mode 100644 apps/frontend/package.json create mode 100644 apps/frontend/postcss.config.js create mode 100644 apps/frontend/src/App.tsx create mode 100644 apps/frontend/src/components/layout/Shell.tsx create mode 100644 apps/frontend/src/hooks/useAuth.ts create mode 100644 apps/frontend/src/index.css create mode 100644 apps/frontend/src/lib/api.ts create mode 100644 apps/frontend/src/lib/constants.ts create mode 100644 apps/frontend/src/lib/utils.ts create mode 100644 apps/frontend/src/main.tsx create mode 100644 apps/frontend/src/pages/Dashboard.tsx create mode 100644 apps/frontend/src/pages/Experiments.tsx create mode 100644 apps/frontend/src/pages/Login.tsx create mode 100644 apps/frontend/src/pages/Performance.tsx create mode 100644 apps/frontend/src/pages/Pricing.tsx create mode 100644 apps/frontend/src/pages/Reservations.tsx create mode 100644 apps/frontend/src/pages/Settings.tsx create mode 100644 apps/frontend/tailwind.config.ts create mode 100644 apps/frontend/tsconfig.json create mode 100644 apps/frontend/vite.config.ts create mode 100644 apps/scraper/package.json create mode 100644 apps/scraper/src/adapters/airbnb/AirbnbAdapter.ts create mode 100644 apps/scraper/src/adapters/airbnb/airbnb.flows.ts create mode 100644 apps/scraper/src/adapters/airbnb/airbnb.selectors.ts create mode 100644 apps/scraper/src/adapters/base/AdapterRegistry.ts create mode 100644 apps/scraper/src/adapters/base/PlatformAdapter.ts create mode 100644 apps/scraper/src/adapters/mock/MockAdapter.ts create mode 100644 apps/scraper/src/adapters/mock/mock-data.ts create mode 100644 apps/scraper/src/adapters/vrbo/VrboAdapter.ts create mode 100644 apps/scraper/src/adapters/vrbo/vrbo.flows.ts create mode 100644 apps/scraper/src/adapters/vrbo/vrbo.selectors.ts create mode 100644 apps/scraper/src/index.ts create mode 100644 apps/scraper/src/queue/jobQueue.ts create mode 100644 apps/scraper/src/queue/worker.ts create mode 100644 apps/scraper/src/utils/browser.ts create mode 100644 apps/scraper/src/utils/delay.ts create mode 100644 apps/scraper/src/utils/encryption.ts create mode 100644 apps/scraper/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/shared-types/package.json create mode 100644 packages/shared-types/src/index.ts create mode 100644 packages/shared-types/tsconfig.json create mode 100644 str-optimization-manager-spec.md create mode 100644 tsconfig.base.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..1ac4d95 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,29 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "PostgreSQL", + "runtimeExecutable": "docker", + "runtimeArgs": ["compose", "up", "db"], + "port": 5433 + }, + { + "name": "API Server", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev:api"], + "port": 3000 + }, + { + "name": "Scraper Service", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev:scraper"], + "port": 3001 + }, + { + "name": "Frontend (Vite)", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev:frontend"], + "port": 5173 + } + ] +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..84e842d --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Database +DB_USER=str_manager +DB_PASSWORD=changeme_strong_password +DB_HOST=localhost +DB_PORT=5433 +DATABASE_URL=postgresql://str_manager:changeme_strong_password@localhost:5433/str_manager + +# App Security +JWT_SECRET=changeme_64_char_random_string +ENCRYPTION_KEY=changeme_32_char_aes_key_here!! + +# Email (weekly report) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=you@gmail.com +SMTP_PASS=your_app_password +REPORT_EMAIL_TO=you@gmail.com + +# App login +APP_USERNAME=admin +APP_PASSWORD_HASH=$2b$10$placeholder_bcrypt_hash + +# Scraper +SCRAPER_URL=http://localhost:3001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..088eb73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +.env.local +.airbnb-session/ +*.log diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts new file mode 100644 index 0000000..494e2d1 --- /dev/null +++ b/apps/api/drizzle.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'drizzle-kit'; +import dotenv from 'dotenv'; +dotenv.config({ path: '../../.env' }); + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './src/db/migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..2a73d47 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,41 @@ +{ + "name": "@str/api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:seed": "tsx src/db/seed.ts", + "db:seed:demo": "tsx src/db/seed-demo.ts", + "db:seed:clean": "tsx src/db/seed-clean.ts", + "db:restore:demo": "psql $DATABASE_URL < src/db/demo-seed.sql" + }, + "dependencies": { + "@str/shared-types": "*", + "fastify": "^5.2.0", + "@fastify/cors": "^10.0.0", + "@fastify/cookie": "^11.0.0", + "@fastify/jwt": "^9.0.0", + "drizzle-orm": "^0.38.0", + "postgres": "^3.4.0", + "bcrypt": "^5.1.1", + "node-cron": "^3.0.3", + "nodemailer": "^6.9.0", + "zod": "^3.24.0", + "dotenv": "^16.4.0", + "fastify-plugin": "^5.0.0" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.2", + "@types/node": "^22.0.0", + "@types/node-cron": "^3.0.11", + "@types/nodemailer": "^6.4.17", + "drizzle-kit": "^0.30.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..089805c --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1,27 @@ +import dotenv from 'dotenv'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; +import { z } from 'zod'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: resolve(__dirname, '../../../.env') }); + +const envSchema = z.object({ + DATABASE_URL: z.string().min(1), + JWT_SECRET: z.string().min(1), + ENCRYPTION_KEY: z.string().min(1), + APP_USERNAME: z.string().min(1), + APP_PASSWORD_HASH: z.string().min(1), + + SMTP_HOST: z.string().optional(), + SMTP_PORT: z.coerce.number().optional().default(587), + SMTP_USER: z.string().optional(), + SMTP_PASS: z.string().optional(), + REPORT_EMAIL_TO: z.string().optional(), + + SCRAPER_URL: z.string().default('http://localhost:3001'), +}); + +export type Config = z.infer; + +export const config: Config = envSchema.parse(process.env); diff --git a/apps/api/src/db/demo-seed.sql b/apps/api/src/db/demo-seed.sql new file mode 100644 index 0000000..04e1d91 --- /dev/null +++ b/apps/api/src/db/demo-seed.sql @@ -0,0 +1,545 @@ +-- +-- PostgreSQL database dump +-- + +\restrict MeN1ws58DquMlfn6S1mGywDt5fuo6rhsE6g75LgmXhiU6DV4nSIsGAX8gVJ0f2n + +-- Dumped from database version 16.13 +-- Dumped by pg_dump version 16.13 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Data for Name: __drizzle_migrations; Type: TABLE DATA; Schema: drizzle; Owner: str_manager +-- + +INSERT INTO drizzle.__drizzle_migrations VALUES (1, 'ad15e3288889b1c1a4e2a827a3805267b06787a9dafee630dfde2b265bf2e90c', 1774223467634); + + +-- +-- Data for Name: platforms; Type: TABLE DATA; Schema: public; Owner: str_manager +-- + +INSERT INTO public.platforms VALUES ('airbnb', 'Airbnb', NULL, NULL, NULL, true); +INSERT INTO public.platforms VALUES ('vrbo', 'VRBO', NULL, NULL, NULL, true); +INSERT INTO public.platforms VALUES ('mock', 'Mock (Dev)', NULL, NULL, NULL, true); + + +-- +-- Data for Name: daily_prices; Type: TABLE DATA; Schema: public; Owner: str_manager +-- + +INSERT INTO public.daily_prices VALUES ('2c9dea07-ec0d-48f5-b8a8-d7110c39bbb1', 'airbnb', '2025-12-23', 237.95, true, 1, '2025-12-23 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('dd1f5e59-4c64-4f68-81e5-29a6598b22cc', 'airbnb', '2025-12-24', 194.13, true, 1, '2025-12-24 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('a1852c5a-c712-443c-8b9d-6c8c2eab081b', 'airbnb', '2025-12-25', 235.94, true, 1, '2025-12-25 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('d9debe06-4f06-4749-8186-0636450764c3', 'airbnb', '2025-12-26', 195.10, true, 2, '2025-12-26 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('11bcd32e-ef92-4c61-9f04-d2fd55b5761f', 'airbnb', '2025-12-27', 191.84, true, 2, '2025-12-27 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('b7c303f0-4570-4bec-ae35-7e4506571831', 'airbnb', '2025-12-28', 249.27, true, 2, '2025-12-28 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('9255fbb6-c744-4bf2-a7f8-fb16f8248a17', 'airbnb', '2025-12-29', 174.15, true, 1, '2025-12-29 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('4cef5cdd-0354-48ab-b35a-87d2ba36b71c', 'airbnb', '2025-12-30', 233.95, true, 1, '2025-12-30 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('2f664dbf-8ccc-4f30-b488-27f1650fda9f', 'airbnb', '2025-12-31', 163.79, true, 1, '2025-12-31 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('d68f65ea-f2e1-4c8a-ab7e-7fe078f4a8dd', 'airbnb', '2026-01-01', 196.62, true, 1, '2026-01-01 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('9929a38c-03c8-4bbf-9e17-dc1f0d459dfa', 'airbnb', '2026-01-02', 290.31, true, 2, '2026-01-02 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('6a55a1b7-8283-4a5f-b2fb-f7de046cb143', 'airbnb', '2026-01-03', 247.44, true, 2, '2026-01-03 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('02024d55-bae0-499b-86fc-6856d1576924', 'airbnb', '2026-01-04', 298.87, true, 2, '2026-01-04 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('621cc0bd-d499-4e36-ad3b-239374980d43', 'airbnb', '2026-01-05', 225.26, false, 1, '2026-01-05 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('0efe73d0-ca93-4629-8192-63662bcfe764', 'airbnb', '2026-01-06', 164.11, true, 1, '2026-01-06 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('4e653a32-cfcd-42d0-a6f8-fcd9c7cc0803', 'airbnb', '2026-01-07', 186.78, true, 1, '2026-01-07 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('312e8284-ccdb-4ddd-a5ee-f8ef6c02a472', 'airbnb', '2026-01-08', 242.66, false, 1, '2026-01-08 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('8d33139a-d235-4e44-a02d-873a087460a4', 'airbnb', '2026-01-09', 208.09, false, 2, '2026-01-09 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('41ec240d-15de-409f-8f55-882f16f33dbb', 'airbnb', '2026-01-10', 216.00, true, 2, '2026-01-10 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('9a6ae96b-03f0-40e4-a984-5479a9503421', 'airbnb', '2026-01-11', 254.83, true, 2, '2026-01-11 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('4cd31c31-1911-4845-864f-ffc0fc0bd9f6', 'airbnb', '2026-01-12', 180.48, true, 1, '2026-01-12 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('24710d96-c5e3-467b-bdd7-71774a3916da', 'airbnb', '2026-01-13', 201.25, false, 1, '2026-01-13 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('07d4c62f-585d-4b34-b28e-d0b57be028b6', 'airbnb', '2026-01-14', 234.48, true, 1, '2026-01-14 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('f85aa922-16bd-4c3f-b9c9-f9980f48b69d', 'airbnb', '2026-01-15', 240.79, true, 1, '2026-01-15 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('9ce4ffc0-2285-45e5-9b5b-aa3a95108c19', 'airbnb', '2026-01-16', 252.73, false, 2, '2026-01-16 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('fab441e7-8bdb-4b09-a417-0c6b349b0959', 'airbnb', '2026-01-17', 325.09, true, 2, '2026-01-17 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('38148a0f-742c-438e-92b9-70f82565a231', 'airbnb', '2026-01-18', 246.46, true, 2, '2026-01-18 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('2cdbebcd-3158-43f9-8467-85188410f113', 'airbnb', '2026-01-19', 246.85, true, 1, '2026-01-19 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('1926caaf-9c20-48b9-8979-a2267d07b192', 'airbnb', '2026-01-20', 221.77, true, 1, '2026-01-20 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('c2e2691a-0c9c-44a3-a7d1-46939fee3df0', 'airbnb', '2026-01-21', 244.93, true, 1, '2026-01-21 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('41e57335-31af-40e7-b0c6-7294dab2131c', 'airbnb', '2026-01-22', 184.38, false, 1, '2026-01-22 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('f04b3baf-8071-44e3-9edc-b481687486b2', 'airbnb', '2026-01-23', 300.03, true, 2, '2026-01-23 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('e0352ebf-9642-4d6b-a8e4-185e60bae543', 'airbnb', '2026-01-24', 287.10, true, 2, '2026-01-24 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('a5cf1b79-7baf-4cf2-8a11-6efa368f8b19', 'airbnb', '2026-01-25', 261.57, true, 2, '2026-01-25 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('7e965a25-bdde-441b-94e9-06395c448f71', 'airbnb', '2026-01-26', 182.52, true, 1, '2026-01-26 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('8996b768-71d3-45d1-8f6e-a0a6da83af42', 'airbnb', '2026-01-27', 237.59, false, 1, '2026-01-27 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('84e5dd9d-b708-4df3-8a3b-dcf876a26c62', 'airbnb', '2026-01-28', 164.46, true, 1, '2026-01-28 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('cea3a47f-1784-4419-b234-cbf13bca7c47', 'airbnb', '2026-01-29', 166.64, false, 1, '2026-01-29 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('66a6b2f8-59e9-43d3-8067-6b826d3d9e57', 'airbnb', '2026-01-30', 250.10, true, 2, '2026-01-30 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('4b887be4-1507-4433-bcc0-9dae5c20b6ff', 'airbnb', '2026-01-31', 303.08, true, 2, '2026-01-31 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('2f355b7e-7492-488b-a37c-fa92cd293571', 'airbnb', '2026-02-01', 229.66, true, 2, '2026-02-01 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('600623bd-d604-47ca-ab53-6485977e239b', 'airbnb', '2026-02-02', 244.30, true, 1, '2026-02-02 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('fe31882c-9b0b-47d4-8e19-f6b94510a872', 'airbnb', '2026-02-03', 177.29, true, 1, '2026-02-03 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('0662fddb-e7d7-423d-8e33-5f144a5a1432', 'airbnb', '2026-02-04', 225.51, true, 1, '2026-02-04 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('5f12731a-adf5-4bd3-981c-99dd50cc421f', 'airbnb', '2026-02-05', 199.96, true, 1, '2026-02-05 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('9c440537-e4ae-4c56-8cd5-4be5dc3d582b', 'airbnb', '2026-02-06', 282.86, true, 2, '2026-02-06 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('ae8003e0-358f-4b69-a92a-8ee3a0cd3c01', 'airbnb', '2026-02-07', 309.42, true, 2, '2026-02-07 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('4ff3ab6f-9f0c-4f5d-b994-cf7f7b2da380', 'airbnb', '2026-02-08', 224.26, false, 2, '2026-02-08 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('6dd9ead4-e6c0-4b72-aed2-42a3516fe717', 'airbnb', '2026-02-09', 227.35, true, 1, '2026-02-09 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('6e725f12-0d6a-4a70-a5a1-e628f448a544', 'airbnb', '2026-02-10', 162.89, true, 1, '2026-02-10 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('883fb377-6532-41ae-b533-0fe1388cf768', 'airbnb', '2026-02-11', 218.63, true, 1, '2026-02-11 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('974b56a4-97de-4da7-b116-044a29c0fed2', 'airbnb', '2026-02-12', 171.73, true, 1, '2026-02-12 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('a9135752-c32d-475c-b652-e1716ba1ba57', 'airbnb', '2026-02-13', 189.10, true, 2, '2026-02-13 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('dfa95f66-0b3f-4d30-8aaf-916ac1a0cb49', 'airbnb', '2026-02-14', 341.28, true, 2, '2026-02-14 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('b091cdca-6d4a-4c37-9452-89e8f0d2049c', 'airbnb', '2026-02-15', 268.24, true, 2, '2026-02-15 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('db55b412-5bfc-4d69-b527-7a065f408edb', 'airbnb', '2026-02-16', 159.41, true, 1, '2026-02-16 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('05184acc-7c48-43b6-a7b0-3e93e4a722b4', 'airbnb', '2026-02-17', 157.34, true, 1, '2026-02-17 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('7b0c965f-7e3f-4e5e-816e-3ce3ddf953b7', 'airbnb', '2026-02-18', 222.94, true, 1, '2026-02-18 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('59a3a5e3-fdd0-4142-9cfa-639d1e70984c', 'airbnb', '2026-02-19', 232.85, true, 1, '2026-02-19 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('56446833-d44c-4d61-a3e4-c0f3749080f1', 'airbnb', '2026-02-20', 188.73, true, 2, '2026-02-20 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('9c0e4c8c-2e60-489c-bf58-b19dc9352187', 'airbnb', '2026-02-21', 221.09, true, 2, '2026-02-21 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('d6b83823-b2b3-49b8-ad70-3e2357403ab9', 'airbnb', '2026-02-22', 299.01, true, 2, '2026-02-22 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('02739fe6-9480-4f8b-8038-9eba4eb51451', 'airbnb', '2026-02-23', 211.04, true, 1, '2026-02-23 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('421546f8-b1ea-4f51-b426-fc2f5ed34cb6', 'airbnb', '2026-02-24', 180.85, true, 1, '2026-02-24 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('889f9040-e3ec-478e-9ed0-85275f950368', 'airbnb', '2026-02-25', 185.16, true, 1, '2026-02-25 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('8ddaf51a-7c09-4a1a-9303-9b8b0865227b', 'airbnb', '2026-02-26', 254.54, true, 1, '2026-02-26 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('4c8cf0f1-3d70-4dd7-bb14-48d43bc0f128', 'airbnb', '2026-02-27', 282.83, false, 2, '2026-02-27 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('4043b859-8096-43f6-a4c9-0520176aa1d4', 'airbnb', '2026-02-28', 296.40, true, 2, '2026-02-28 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('8907ddc3-b485-474e-ab7d-29ab4d685c2a', 'airbnb', '2026-03-01', 304.85, true, 2, '2026-03-01 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('5bbc17c8-8afe-44f1-972c-f259b6783afc', 'airbnb', '2026-03-02', 174.80, true, 1, '2026-03-02 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('667f8722-1752-4c07-8f4f-4d0342c4dd98', 'airbnb', '2026-03-03', 231.72, true, 1, '2026-03-03 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('6619cc03-4c21-43d7-a565-e976e5d75229', 'airbnb', '2026-03-04', 229.93, true, 1, '2026-03-04 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('dbeb6d1b-f0d1-4788-807f-f77d051e758c', 'airbnb', '2026-03-05', 212.14, true, 1, '2026-03-05 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('c0ad47f4-b1a0-42e7-bdde-cb85fdf70a60', 'airbnb', '2026-03-06', 295.08, false, 2, '2026-03-06 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('4814fdfe-fbcc-491a-a19b-9c253846ba64', 'airbnb', '2026-03-07', 332.95, true, 2, '2026-03-07 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('eaf35023-a102-4d1d-a7f1-7ccccc95ddf6', 'airbnb', '2026-03-08', 205.24, true, 2, '2026-03-08 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('b4b00968-9fde-403c-acfc-8ef478e286fe', 'airbnb', '2026-03-09', 220.21, true, 1, '2026-03-09 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('ab62b569-7de8-42de-87c4-df3fe95a9a92', 'airbnb', '2026-03-10', 191.39, true, 1, '2026-03-10 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('3b1b7cc6-62f8-45a2-9e7d-ad35df9e0b37', 'airbnb', '2026-03-11', 243.49, true, 1, '2026-03-11 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('7e2111bb-bb0e-429c-aede-564dd6762a76', 'airbnb', '2026-03-12', 229.69, false, 1, '2026-03-12 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('3ace8e2f-bd5d-4879-a941-b5918778cd45', 'airbnb', '2026-03-13', 288.21, true, 2, '2026-03-13 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('7db61230-cc4e-4f60-9836-5bd16487410b', 'airbnb', '2026-03-14', 287.18, true, 2, '2026-03-14 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('09b8cea7-1e43-4224-b5b9-8111cf0d394a', 'airbnb', '2026-03-15', 264.88, true, 2, '2026-03-15 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('c6a0a46c-e2a0-4309-8801-3dce2eb6f3e5', 'airbnb', '2026-03-16', 180.82, false, 1, '2026-03-16 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('58c6866d-551b-403b-88fd-9f12001c6e43', 'airbnb', '2026-03-17', 206.31, true, 1, '2026-03-17 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('b4e30d53-e615-4cc8-a554-9f5965625c81', 'airbnb', '2026-03-18', 246.44, false, 1, '2026-03-18 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('43d1d722-6dcd-4d6e-930b-47bc60d9cfcd', 'airbnb', '2026-03-19', 228.78, true, 1, '2026-03-19 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('730f18ae-68e1-4fea-a194-2c5c037ace08', 'airbnb', '2026-03-20', 295.96, true, 2, '2026-03-20 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('cb37b090-71a1-4f47-b33e-8bdb8cb88205', 'airbnb', '2026-03-21', 323.74, false, 2, '2026-03-21 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('afabbb45-3061-412d-852f-8b415126a5a5', 'airbnb', '2026-03-22', 298.92, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('66e28822-a497-460f-bfe4-42aaaaae160e', 'airbnb', '2026-03-23', 245.29, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('18286a2a-e233-495e-b21c-6349d352c5e7', 'airbnb', '2026-03-24', 191.22, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('a4ac710b-74cf-44c4-8833-99103f053eb4', 'airbnb', '2026-03-25', 182.74, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('6a09fd4b-4d3d-4f23-83f3-6f44a5b9dc1b', 'airbnb', '2026-03-26', 245.85, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('055cbe5f-23ff-4208-a2b4-656455a56437', 'airbnb', '2026-03-27', 254.91, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('fba2dc5a-c6e5-4525-ac5d-6f6c621b882f', 'airbnb', '2026-03-28', 296.14, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('bdcc65dd-9e59-4301-9038-a880afa0665b', 'airbnb', '2026-03-29', 260.76, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('301cea30-76a9-4953-a335-bb548e91f3c3', 'airbnb', '2026-03-30', 196.18, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('b40b779b-13a9-4757-a3d5-f4a132bf5638', 'airbnb', '2026-03-31', 187.47, false, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('ba3be540-5191-4bb5-bc28-d1a5971bbea6', 'airbnb', '2026-04-01', 245.51, false, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('ac25034a-1f27-403f-90df-1b46c9d9ba5d', 'airbnb', '2026-04-02', 164.85, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('6f932708-7adf-4ebf-932d-a184e45514ec', 'airbnb', '2026-04-03', 315.69, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('4df356f5-2917-4eb2-9428-2e7e71998b1f', 'airbnb', '2026-04-04', 265.43, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('19f011a9-a5e8-4220-9d28-9394b5ea92e3', 'airbnb', '2026-04-05', 295.06, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('a27361ae-6ffe-4843-8a31-c772c3b1d489', 'airbnb', '2026-04-06', 172.34, false, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('9ad04c21-c9d3-4e7b-94f6-6595d7961d01', 'airbnb', '2026-04-07', 234.57, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('f55506ac-fe17-4e6e-bf14-1b6b50f8514f', 'airbnb', '2026-04-08', 173.22, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('2cc7bc05-8a06-4168-bdc9-9f9d017b627e', 'airbnb', '2026-04-09', 203.89, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('5f735c14-0a30-4300-b7bc-52646d6777a1', 'airbnb', '2026-04-10', 270.28, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('84cdbc92-eb99-4c09-a039-ba29e6446eef', 'airbnb', '2026-04-11', 233.24, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('d67afeed-cfc7-453c-b418-80c7f7b19a16', 'airbnb', '2026-04-12', 209.87, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('ce9aacd6-1caa-47a1-b7e6-2c79285b0292', 'airbnb', '2026-04-13', 262.22, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('04fdddcd-a030-4405-998d-e764c64afb62', 'airbnb', '2026-04-14', 170.57, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('86d6983f-c21e-498a-84b3-d2c956d6ae67', 'airbnb', '2026-04-15', 163.64, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('1a555fbf-394d-48a8-8a24-ff60da28c349', 'airbnb', '2026-04-16', 235.11, false, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('74afffbb-4915-450d-8c31-11a5a308571f', 'airbnb', '2026-04-17', 314.95, false, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('dac5964d-8337-4a12-9f91-37a5526fb2e0', 'airbnb', '2026-04-18', 263.67, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('d5edf515-5e42-47f0-97a4-448ff6e4be67', 'airbnb', '2026-04-19', 220.53, false, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('c64d0d9f-0c53-4470-bd4e-fffc42300f65', 'airbnb', '2026-04-20', 197.42, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('81a16975-fc48-4e73-abe9-0d1e01622058', 'airbnb', '2026-04-21', 254.52, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('59d58410-4942-422b-8c6c-f98489d2c7e0', 'vrbo', '2025-12-23', 228.36, true, 1, '2025-12-23 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('26434ea6-9727-4cf3-90ab-f3f57a78da93', 'vrbo', '2025-12-24', 152.61, true, 1, '2025-12-24 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('5d880bcb-1c16-486c-ac91-c8eceee4827b', 'vrbo', '2025-12-25', 230.97, true, 1, '2025-12-25 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('21b6cf5f-dcef-40f9-9583-e7f756b4d616', 'vrbo', '2025-12-26', 211.16, true, 2, '2025-12-26 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('029ae0a4-a90b-4d6d-8775-d0878c2a7acc', 'vrbo', '2025-12-27', 200.67, true, 2, '2025-12-27 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('53310caf-b6bc-40f6-8860-6fad010df42d', 'vrbo', '2025-12-28', 315.46, true, 2, '2025-12-28 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('882f60dc-145e-4b4a-8125-6ab10dc90c3f', 'vrbo', '2025-12-29', 249.07, true, 1, '2025-12-29 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('f59a7811-0394-4e70-903c-17a75346338f', 'vrbo', '2025-12-30', 187.75, true, 1, '2025-12-30 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('b6cc13f9-0770-4145-baa2-87b1a4602679', 'vrbo', '2025-12-31', 214.94, true, 1, '2025-12-31 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('42ffac8d-7f43-4071-8870-c3269c9e00ea', 'vrbo', '2026-01-01', 228.78, true, 1, '2026-01-01 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('d030c063-0a82-4208-8b7c-c40d0a04d5ad', 'vrbo', '2026-01-02', 261.39, true, 2, '2026-01-02 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('b78b9727-5668-431c-bfa1-685eded88735', 'vrbo', '2026-01-03', 256.04, true, 2, '2026-01-03 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('3c72e68f-0267-4f52-a2d6-642059802d38', 'vrbo', '2026-01-04', 225.05, true, 2, '2026-01-04 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('56d6eb63-efb3-42af-bde0-79c4e79bafc7', 'vrbo', '2026-01-05', 176.31, true, 1, '2026-01-05 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('21c09c9d-4cbc-43f9-8109-f4aa29225189', 'vrbo', '2026-01-06', 202.40, true, 1, '2026-01-06 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('4af10ce2-c8e6-46b5-b8a6-301566f33ac3', 'vrbo', '2026-01-07', 174.60, true, 1, '2026-01-07 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('dcb7c44d-a88a-4d1d-a952-73207eb8f371', 'vrbo', '2026-01-08', 211.56, true, 1, '2026-01-08 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('b39d66c6-3795-4543-ba96-04426798b8df', 'vrbo', '2026-01-09', 202.87, true, 2, '2026-01-09 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('23dcd0c0-4cce-4788-a761-6f20749f00df', 'vrbo', '2026-01-10', 202.58, true, 2, '2026-01-10 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('42c324ab-1306-4521-8add-884ad53a85db', 'vrbo', '2026-01-11', 269.75, true, 2, '2026-01-11 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('2acc849f-9f41-4362-9092-d3ab38110118', 'vrbo', '2026-01-12', 170.09, true, 1, '2026-01-12 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('c99561e9-de23-433b-b20d-de0b826b22c9', 'vrbo', '2026-01-13', 216.18, false, 1, '2026-01-13 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('7253b5d5-6b46-4798-b8a2-e8473de22ef9', 'vrbo', '2026-01-14', 220.32, true, 1, '2026-01-14 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('5b1a3913-b826-4b67-8734-6c631adc4848', 'vrbo', '2026-01-15', 183.16, true, 1, '2026-01-15 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('2e0cae4e-6d5b-43e2-af4b-9189c531be51', 'vrbo', '2026-01-16', 277.42, true, 2, '2026-01-16 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('7be55507-0230-4924-9fe1-769213c01ca7', 'vrbo', '2026-01-17', 230.84, true, 2, '2026-01-17 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('ffc2ba5d-e559-431a-aa0d-55333a908ed6', 'vrbo', '2026-01-18', 238.68, true, 2, '2026-01-18 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('5dd9e151-6c22-4dbc-8899-030cf60a3369', 'vrbo', '2026-01-19', 225.99, true, 1, '2026-01-19 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('b5bac0e4-3cb9-4534-8bed-53e3b69261d9', 'vrbo', '2026-01-20', 219.21, true, 1, '2026-01-20 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('a8983c73-13eb-466d-8496-eb4637a5eaa1', 'vrbo', '2026-01-21', 172.70, true, 1, '2026-01-21 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('9958021f-52e5-484b-9272-650c35d5fd2d', 'vrbo', '2026-01-22', 190.05, false, 1, '2026-01-22 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('3cec201d-3af1-4e02-aa81-fbb523d9f5f7', 'vrbo', '2026-01-23', 265.87, false, 2, '2026-01-23 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('8a3aaae5-b21d-4c24-a02c-00d5e02de680', 'vrbo', '2026-01-24', 273.18, true, 2, '2026-01-24 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('439ba613-10a0-449b-8ad7-265af119c36c', 'vrbo', '2026-01-25', 234.99, true, 2, '2026-01-25 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('38e4b52d-d78f-4a48-a853-c4728c56db5d', 'vrbo', '2026-01-26', 188.70, true, 1, '2026-01-26 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('301e47cf-e6e0-4505-b676-aa2bbea7ad89', 'vrbo', '2026-01-27', 237.70, true, 1, '2026-01-27 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('553385b8-bad8-4e82-9c8c-42fe032d616a', 'vrbo', '2026-01-28', 157.71, true, 1, '2026-01-28 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('31bc7167-4eb8-424e-843c-9a1e75691a82', 'vrbo', '2026-01-29', 254.73, true, 1, '2026-01-29 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('e9b2979d-7bbd-4b29-9506-387f16d74f78', 'vrbo', '2026-01-30', 223.78, true, 2, '2026-01-30 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('28602304-b061-4d18-b4a6-b0029e0a2d81', 'vrbo', '2026-01-31', 223.12, true, 2, '2026-01-31 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('058e8f00-856f-4c05-9091-2b4806d3aec5', 'vrbo', '2026-02-01', 258.79, true, 2, '2026-02-01 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('bc9aadae-7289-4e9d-ba36-1ab3796ec3bd', 'vrbo', '2026-02-02', 233.09, true, 1, '2026-02-02 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('b0571daa-0de9-4743-b448-5d5918a3dc25', 'vrbo', '2026-02-03', 167.80, true, 1, '2026-02-03 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('a75d15cd-2fde-4bb1-9b7b-4ef6b676375b', 'vrbo', '2026-02-04', 198.99, false, 1, '2026-02-04 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('6c5f920d-2336-47b0-980e-7c9157863f96', 'vrbo', '2026-02-05', 191.26, true, 1, '2026-02-05 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('b3924b90-22c5-446b-a0ef-965606c4da0f', 'vrbo', '2026-02-06', 247.77, true, 2, '2026-02-06 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('bb398835-3b26-4b13-b6a2-33eecb287361', 'vrbo', '2026-02-07', 205.75, true, 2, '2026-02-07 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('1af1e5d2-4211-49b0-97d6-56fa07c15db8', 'vrbo', '2026-02-08', 240.70, false, 2, '2026-02-08 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('59214f4d-9669-447a-9ffb-d0116a7ad316', 'vrbo', '2026-02-09', 235.25, true, 1, '2026-02-09 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('7ce7434b-a583-4e61-af2e-31b8245e072e', 'vrbo', '2026-02-10', 223.08, true, 1, '2026-02-10 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('fb14010d-0dfd-40a3-8b16-75a6eed5d3f1', 'vrbo', '2026-02-11', 176.65, true, 1, '2026-02-11 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('7d15debf-bbfd-4a7f-8669-fa9fc8a40f47', 'vrbo', '2026-02-12', 222.24, true, 1, '2026-02-12 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('a18440f6-4829-499a-84ad-b9484a0c3944', 'vrbo', '2026-02-13', 263.91, true, 2, '2026-02-13 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('57b56e55-b59b-46d2-ad0f-f89acc34a471', 'vrbo', '2026-02-14', 259.97, true, 2, '2026-02-14 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('530614fd-39b5-412e-aa9d-a2c238777be7', 'vrbo', '2026-02-15', 193.89, true, 2, '2026-02-15 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('b1cfd580-a89b-4a65-b757-5f0b13e9ab33', 'vrbo', '2026-02-16', 209.09, true, 1, '2026-02-16 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('7c038c7b-7a19-4867-8b71-060c8ef64ce1', 'vrbo', '2026-02-17', 221.17, true, 1, '2026-02-17 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('8ba39ab5-1092-4f7a-a1fd-a53ed173d33c', 'vrbo', '2026-02-18', 227.03, true, 1, '2026-02-18 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('dcaedf18-31ec-4e96-b9e3-7c986fa50830', 'vrbo', '2026-02-19', 240.63, true, 1, '2026-02-19 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('fb6e116e-f0ee-46a4-8d02-6d3fd3614cc4', 'vrbo', '2026-02-20', 264.04, true, 2, '2026-02-20 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('bb71c3e6-4e9e-4a4c-a914-73f2ecddac30', 'vrbo', '2026-02-21', 261.36, true, 2, '2026-02-21 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('3cb03c40-56c5-4311-bcfe-1ccce1aa25d3', 'vrbo', '2026-02-22', 250.35, false, 2, '2026-02-22 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('0f1fe27e-36f4-4863-9ec4-ac057709dd2a', 'vrbo', '2026-02-23', 194.31, true, 1, '2026-02-23 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('7d2a24a1-1a61-4c41-8dd0-583d96ef0ca3', 'vrbo', '2026-02-24', 216.36, true, 1, '2026-02-24 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('b0ee14d6-f037-4640-b738-162c3524d42e', 'vrbo', '2026-02-25', 248.33, false, 1, '2026-02-25 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('471ed7cf-2a1f-46b5-8a08-ba5d5e18500d', 'vrbo', '2026-02-26', 241.62, false, 1, '2026-02-26 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('c91cc721-1b32-4a08-9ce1-4a32be45aff2', 'vrbo', '2026-02-27', 277.87, true, 2, '2026-02-27 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('2cddb799-b926-4222-aa65-e0c4a2d61931', 'vrbo', '2026-02-28', 229.45, false, 2, '2026-02-28 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('5fb76c31-0cb3-4cfd-9de4-bafd61b0b543', 'vrbo', '2026-03-01', 240.38, true, 2, '2026-03-01 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('62d3d7f8-3fb7-45fb-93d0-79054b924c4f', 'vrbo', '2026-03-02', 208.47, true, 1, '2026-03-02 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('8c8e1eb3-d5a7-4a6e-8f28-807fdd913359', 'vrbo', '2026-03-03', 203.70, true, 1, '2026-03-03 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('e34a0d7e-f39d-4fcc-9c48-a0b7d33c1faf', 'vrbo', '2026-03-04', 191.99, true, 1, '2026-03-04 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('f4067600-2701-4679-9267-c1527170c404', 'vrbo', '2026-03-05', 175.46, false, 1, '2026-03-05 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('3f14ee80-b3ac-4cd5-988e-9e53c5f2c121', 'vrbo', '2026-03-06', 247.94, true, 2, '2026-03-06 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('68cc38c2-aeae-4b0c-bd9a-60b4bb74636b', 'vrbo', '2026-03-07', 202.52, true, 2, '2026-03-07 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('89730edc-2bda-4b83-b725-ea45af253796', 'vrbo', '2026-03-08', 249.99, true, 2, '2026-03-08 05:00:00+00'); +INSERT INTO public.daily_prices VALUES ('7e2138d5-7fd3-42ac-b4c3-9b55bd4b5fd5', 'vrbo', '2026-03-09', 183.16, true, 1, '2026-03-09 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('7d0fa092-9d46-44dc-a2b8-27821f5af1f8', 'vrbo', '2026-03-10', 208.75, false, 1, '2026-03-10 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('4253f00f-5e01-49aa-adab-a433bbea762d', 'vrbo', '2026-03-11', 195.87, true, 1, '2026-03-11 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('adc088ab-a6d2-4d75-befa-5b8030302c1d', 'vrbo', '2026-03-12', 217.33, true, 1, '2026-03-12 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('7be41ebf-c53a-4436-91dd-a6055a46f18f', 'vrbo', '2026-03-13', 326.82, true, 2, '2026-03-13 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('88bfd1b6-ea0f-4da5-9c47-d77694a2520c', 'vrbo', '2026-03-14', 209.27, true, 2, '2026-03-14 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('2da98a40-bc28-4389-8a53-27921d487136', 'vrbo', '2026-03-15', 345.83, false, 2, '2026-03-15 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('17b79dbf-1de4-424a-af0f-7d8b9734f0b7', 'vrbo', '2026-03-16', 255.31, false, 1, '2026-03-16 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('55346d5a-9cf8-45fc-828b-8326dc723382', 'vrbo', '2026-03-17', 215.49, true, 1, '2026-03-17 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('5d6b34a6-a72f-4a12-9c10-98809f0afc7d', 'vrbo', '2026-03-18', 215.74, true, 1, '2026-03-18 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('aaf63647-2cf8-4c27-b504-d83d1630d9aa', 'vrbo', '2026-03-19', 220.55, true, 1, '2026-03-19 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('155323e3-cb9f-476a-b1c7-e00055e9df36', 'vrbo', '2026-03-20', 280.97, true, 2, '2026-03-20 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('3dd75efd-68e5-47ef-b4aa-61d264f0ae50', 'vrbo', '2026-03-21', 264.34, true, 2, '2026-03-21 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('d2f9b63d-a335-42c6-8819-fe81fdb1f33a', 'vrbo', '2026-03-22', 323.49, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('384a80b1-0670-453d-8a76-fb1efef72bc3', 'vrbo', '2026-03-23', 221.25, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('c17633a0-7229-4812-8b55-2eae11a7f331', 'vrbo', '2026-03-24', 231.85, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('ee198138-ed30-441c-8b1a-76bc13a7ed7d', 'vrbo', '2026-03-25', 254.89, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('f5f14ea6-9441-4f31-a34b-00a79fe109a4', 'vrbo', '2026-03-26', 232.54, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('207f3bdd-cbe1-4003-a5cf-d7f0562a2267', 'vrbo', '2026-03-27', 279.08, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('e1cfa57f-cc81-448e-94ba-bf778e1d505a', 'vrbo', '2026-03-28', 347.93, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('2920f98f-c062-4b92-a649-dffc79423b9d', 'vrbo', '2026-03-29', 247.31, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('93d4e29a-8ba8-45d4-b3cf-bce8bd34ae14', 'vrbo', '2026-03-30', 239.21, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('742caaca-3ded-4ac5-94a5-49968841106c', 'vrbo', '2026-03-31', 185.94, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('c65408e6-14a9-4878-83ee-562870df2a8c', 'vrbo', '2026-04-01', 198.91, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('5a516832-fbbb-4ae2-8ce6-48b228045573', 'vrbo', '2026-04-02', 238.87, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('acf3bb0f-ad57-40e1-b9a7-951726639db0', 'vrbo', '2026-04-03', 240.52, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('be39fab2-dcc8-46bd-950d-5f718a1df1ba', 'vrbo', '2026-04-04', 240.56, false, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('c36fea22-bc12-471b-bc19-631ac5a4a53f', 'vrbo', '2026-04-05', 344.03, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('e83e9fb8-0eb7-4974-8410-1662577e033a', 'vrbo', '2026-04-06', 259.61, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('437d1fee-b933-4615-b95a-9ccbb460f939', 'vrbo', '2026-04-07', 212.35, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('f415db90-3679-4502-a05a-fde530215e27', 'vrbo', '2026-04-08', 185.00, false, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('b851aef4-1fce-4826-91f4-70314219de66', 'vrbo', '2026-04-09', 195.79, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('b2b4930c-ee19-4611-9d09-88d902bd16ae', 'vrbo', '2026-04-10', 215.78, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('ac7ee906-15ad-474c-98df-b41d4bd58f91', 'vrbo', '2026-04-11', 267.56, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('d76ef069-8d69-49dc-a648-890cbf8034b2', 'vrbo', '2026-04-12', 225.30, false, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('259a4b27-75fc-42f4-92c8-16b2ffe7da6d', 'vrbo', '2026-04-13', 160.55, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('4199fd01-cfb0-479f-a2e9-b524a9f2d00e', 'vrbo', '2026-04-14', 246.16, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('843d9fbb-d63c-409d-98ee-0807ed99587b', 'vrbo', '2026-04-15', 229.60, false, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('c1a93d3f-6353-4223-9fdf-d6c37445e143', 'vrbo', '2026-04-16', 213.72, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('d7cbc98c-d60b-40f8-8f9c-d33fb55c1e3b', 'vrbo', '2026-04-17', 227.68, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('0d0a467f-ecbd-440c-a21f-f1df0b71b3e3', 'vrbo', '2026-04-18', 320.55, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('25ec3766-1c97-4714-add3-8bb86dca116f', 'vrbo', '2026-04-19', 259.75, true, 2, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('502cebf6-7a75-451a-a1b5-d98dfaa27e6a', 'vrbo', '2026-04-20', 177.45, true, 1, '2026-03-22 04:00:00+00'); +INSERT INTO public.daily_prices VALUES ('817c4d2e-3799-465d-9301-b696c1642cb8', 'vrbo', '2026-04-21', 214.50, true, 1, '2026-03-22 04:00:00+00'); + + +-- +-- Data for Name: experiments; Type: TABLE DATA; Schema: public; Owner: str_manager +-- + +INSERT INTO public.experiments VALUES ('93286d29-29ac-494a-945e-38a6da3511a9', 'Weekend Premium Pricing', 'Increasing weekend rates by 15% will maintain occupancy while boosting revenue.', '2026-01-21', '2026-02-20', 'completed', '2026-03-22 23:53:19.584947+00', 'Weekend premium of 15% maintained 92% of baseline bookings while increasing weekend revenue by 11%.'); +INSERT INTO public.experiments VALUES ('495d07b2-2d8b-4a8e-b0f4-0c686ff54723', 'Minimum Stay Reduction', 'Reducing minimum stay from 3 nights to 2 nights on weekdays will fill gap nights and increase occupancy.', '2026-02-20', '2026-03-15', 'completed', '2026-03-22 23:53:19.584947+00', 'Occupancy rose 8% during the test period. Gap nights filled at an acceptable rate. Recommend keeping 2-night min on weekdays.'); +INSERT INTO public.experiments VALUES ('84410aa4-4b71-43f2-8fe3-5df561d8cb88', 'Dynamic Last-Minute Discount', 'Offering a 10% discount for bookings within 3 days of check-in will reduce vacancy on otherwise-empty nights.', '2026-03-08', NULL, 'active', '2026-03-22 23:53:19.584947+00', NULL); + + +-- +-- Data for Name: performance_snapshots; Type: TABLE DATA; Schema: public; Owner: str_manager +-- + +INSERT INTO public.performance_snapshots VALUES ('e37a3501-cb67-4180-909e-0195d5975349', 'airbnb', '2025-12-23 05:00:00+00', '2025-12-23', 1518, 246, 2.44, 6, 79.5, 166.61, 3974.32, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('1a8ae8cc-db15-4576-8b33-7e9c5c61c996', 'airbnb', '2025-12-24 05:00:00+00', '2025-12-24', 1898, 352, 0.57, 2, 59.7, 236.57, 4234.21, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('a81e12ff-4865-4472-829b-15d04fc0eeb6', 'airbnb', '2025-12-25 05:00:00+00', '2025-12-25', 917, 335, 1.79, 6, 62.8, 209.20, 3942.35, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('b0e8c48c-f428-40b9-9fd4-730843a39eb5', 'airbnb', '2025-12-26 05:00:00+00', '2025-12-26', 1199, 426, 1.41, 6, 79.3, 243.08, 5780.30, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('4d6df332-e34c-454c-b1b6-ad988cec2f57', 'airbnb', '2025-12-27 05:00:00+00', '2025-12-27', 1246, 121, 1.65, 2, 58.7, 223.01, 3925.95, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('c32976de-f31d-4ce7-8501-4afa7e75412e', 'airbnb', '2025-12-28 05:00:00+00', '2025-12-28', 1306, 299, 1.67, 5, 63.7, 150.93, 2885.49, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('6672972f-cd76-4b58-b6ae-6718d8d7492b', 'airbnb', '2025-12-29 05:00:00+00', '2025-12-29', 1595, 259, 1.16, 3, 68.0, 237.44, 4846.82, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('66e4d1d6-d914-40a6-bc04-0e7d82b7e4f8', 'airbnb', '2025-12-30 05:00:00+00', '2025-12-30', 1617, 114, 7.02, 8, 75.2, 236.43, 5333.25, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('ee8f88ea-cb0f-44be-92bd-f030fab19d57', 'airbnb', '2025-12-31 05:00:00+00', '2025-12-31', 937, 368, 2.72, 10, 84.5, 229.28, 5809.68, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('4ef0c205-cef1-43fd-b41d-afd95ff5ccdb', 'airbnb', '2026-01-01 05:00:00+00', '2026-01-01', 1876, 483, 0.62, 3, 80.7, 239.06, 5785.00, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('b6243d4d-74c1-49e7-a129-6b46d007f814', 'airbnb', '2026-01-02 05:00:00+00', '2026-01-02', 1050, 113, 7.08, 8, 83.5, 231.92, 5809.88, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('3bdbd4d1-d7c8-48a5-ae32-133bdd369cbe', 'airbnb', '2026-01-03 05:00:00+00', '2026-01-03', 742, 192, 1.56, 3, 60.8, 180.60, 3291.74, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('c4b9b5f3-2480-41fb-9adf-d75aeb020fdc', 'airbnb', '2026-01-04 05:00:00+00', '2026-01-04', 1988, 485, 0.62, 3, 77.7, 242.74, 5657.54, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('d6d72802-6a71-4a09-861d-0fe545a993dd', 'airbnb', '2026-01-05 05:00:00+00', '2026-01-05', 516, 257, 1.95, 5, 62.5, 203.90, 3824.34, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('05b72597-d958-4361-aabd-58347df90261', 'airbnb', '2026-01-06 05:00:00+00', '2026-01-06', 1206, 460, 0.87, 4, 57.9, 155.47, 2698.69, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('790fcd04-df70-40da-909b-d16bf103fa12', 'airbnb', '2026-01-07 05:00:00+00', '2026-01-07', 1484, 350, 1.71, 6, 58.6, 172.37, 3028.03, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('bfa1eec0-670d-499c-b772-56e5fce9162c', 'airbnb', '2026-01-08 05:00:00+00', '2026-01-08', 1431, 230, 0.87, 2, 72.7, 205.23, 4474.93, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('1d55ceb1-2a00-419e-afc6-16f470b6147b', 'airbnb', '2026-01-09 05:00:00+00', '2026-01-09', 1660, 415, 0.72, 3, 69.9, 154.28, 3234.88, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('eae7310f-0909-465c-9ed5-761c34dc3f1f', 'airbnb', '2026-01-10 05:00:00+00', '2026-01-10', 1805, 320, 0.94, 3, 58.4, 156.97, 2751.81, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('5c4df048-d216-4b64-9828-86dd2ee63ed2', 'airbnb', '2026-01-11 05:00:00+00', '2026-01-11', 771, 486, 1.03, 5, 81.7, 161.68, 3964.72, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('7f33a359-60ba-435d-b255-7aca1190aa7e', 'airbnb', '2026-01-12 05:00:00+00', '2026-01-12', 1091, 378, 1.32, 5, 85.9, 154.50, 3982.13, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('1d59967a-1bf2-4cf4-a50e-822f1e461dda', 'airbnb', '2026-01-13 05:00:00+00', '2026-01-13', 1615, 389, 0.77, 3, 76.1, 209.15, 4776.70, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('6ad3c51a-343d-4a5a-b084-345f7e1ea475', 'airbnb', '2026-01-14 05:00:00+00', '2026-01-14', 1057, 443, 0.45, 2, 70.3, 159.54, 3364.39, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('99b20238-4bc0-407d-abd7-745685b87e88', 'airbnb', '2026-01-15 05:00:00+00', '2026-01-15', 930, 147, 2.04, 3, 60.2, 181.54, 3279.85, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('42fbc79d-6d2b-40d5-8592-a34c04dd4e24', 'airbnb', '2026-01-16 05:00:00+00', '2026-01-16', 710, 395, 0.51, 2, 69.7, 234.48, 4904.01, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('30e91aef-b46c-4e2b-ae94-091911a8c834', 'airbnb', '2026-01-17 05:00:00+00', '2026-01-17', 1670, 313, 0.64, 2, 56.8, 200.58, 3420.65, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('17b7e10d-9a2d-48bf-a12c-48197a57927e', 'airbnb', '2026-01-18 05:00:00+00', '2026-01-18', 1349, 432, 0.93, 4, 67.1, 252.18, 5078.73, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('0341510a-c4df-412f-b5fc-b383a80cc6f7', 'airbnb', '2026-01-19 05:00:00+00', '2026-01-19', 1400, 332, 1.81, 6, 77.6, 216.25, 5032.96, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('d1c1c004-1aa6-4327-852c-3cfc41924500', 'airbnb', '2026-01-20 05:00:00+00', '2026-01-20', 638, 263, 2.28, 6, 64.4, 165.09, 3188.16, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('12524681-aa26-4996-9f32-efeed7ad400b', 'airbnb', '2026-01-21 05:00:00+00', '2026-01-21', 1571, 193, 2.59, 5, 59.7, 236.53, 4233.71, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('47e5c0cc-7576-4dde-af6e-bc91fda5234d', 'airbnb', '2026-01-22 05:00:00+00', '2026-01-22', 760, 461, 1.30, 6, 81.5, 228.83, 5594.14, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('ba70e2da-2830-4042-ab6e-b7c6c03207f4', 'airbnb', '2026-01-23 05:00:00+00', '2026-01-23', 734, 227, 3.08, 7, 68.6, 162.80, 3349.86, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('d84f8d4f-4bb1-4a9b-a505-0dc6da53e89c', 'airbnb', '2026-01-24 05:00:00+00', '2026-01-24', 1265, 304, 1.97, 6, 75.3, 210.28, 4751.82, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('215fd154-ae77-4462-a2f4-ba118528850c', 'airbnb', '2026-01-25 05:00:00+00', '2026-01-25', 1810, 428, 1.64, 7, 86.4, 173.80, 4504.13, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('fe318719-35d1-438c-a208-24acc46b4f3f', 'airbnb', '2026-01-26 05:00:00+00', '2026-01-26', 693, 139, 4.32, 6, 68.4, 161.19, 3307.75, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('c30c1d8a-d735-424c-b36f-42279781f5ed', 'airbnb', '2026-01-27 05:00:00+00', '2026-01-27', 1623, 389, 2.06, 8, 75.9, 252.18, 5744.26, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('8ad7670e-8127-4f8a-9fcb-aca382d038a7', 'airbnb', '2026-01-28 05:00:00+00', '2026-01-28', 1449, 400, 1.75, 7, 80.8, 172.83, 4187.71, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('3be9857f-6cce-4d2c-bd1e-164b25daf76f', 'airbnb', '2026-01-29 05:00:00+00', '2026-01-29', 682, 116, 2.59, 3, 87.8, 231.51, 6099.02, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('7ee3083c-e09d-4612-8065-ff130173febe', 'airbnb', '2026-01-30 05:00:00+00', '2026-01-30', 937, 485, 1.86, 9, 78.6, 213.06, 5022.19, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('0a863ff8-8ceb-4c95-be56-539d6a44f12a', 'airbnb', '2026-01-31 05:00:00+00', '2026-01-31', 878, 444, 0.45, 2, 58.5, 168.27, 2951.45, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('bc5aa091-53dc-4110-a4bc-a30455092df8', 'airbnb', '2026-02-01 05:00:00+00', '2026-02-01', 1093, 175, 3.43, 6, 84.4, 163.89, 4148.65, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('6e01338a-c154-4229-af93-73192418d20a', 'airbnb', '2026-02-02 05:00:00+00', '2026-02-02', 1949, 490, 0.61, 3, 57.5, 253.74, 4374.97, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('c1a7d7d1-c1cf-496e-b99e-9d568a8559af', 'airbnb', '2026-02-03 05:00:00+00', '2026-02-03', 1522, 211, 3.79, 8, 80.6, 166.11, 4014.05, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('9cdbea6c-4f94-460e-ab42-b4c88374c28f', 'airbnb', '2026-02-04 05:00:00+00', '2026-02-04', 1883, 366, 1.09, 4, 66.8, 220.12, 4410.23, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('0310a54a-33d8-4cf6-af1b-808b896fd6ca', 'airbnb', '2026-02-05 05:00:00+00', '2026-02-05', 1573, 178, 2.81, 5, 74.6, 222.36, 4975.96, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('101f620c-8120-4fb6-b3df-d9f4f9378ef9', 'airbnb', '2026-02-06 05:00:00+00', '2026-02-06', 1829, 449, 1.34, 6, 58.3, 188.73, 3298.89, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('5945576d-550c-498b-9768-ef6d6b9aae22', 'airbnb', '2026-02-07 05:00:00+00', '2026-02-07', 1870, 513, 0.78, 4, 66.8, 261.11, 5232.02, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('4c8a7d4c-c006-48ce-8d3c-1a349e1991a5', 'airbnb', '2026-02-08 05:00:00+00', '2026-02-08', 763, 145, 3.45, 5, 84.1, 245.51, 6192.04, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('176bf91f-1c8a-445a-93c9-ad392fffdacd', 'airbnb', '2026-02-09 05:00:00+00', '2026-02-09', 1158, 111, 1.80, 2, 74.2, 249.27, 5551.45, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('a7ad5c31-bf6e-47e4-b6e0-61fda94da673', 'airbnb', '2026-02-10 05:00:00+00', '2026-02-10', 756, 470, 0.85, 4, 64.4, 253.13, 4890.70, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('e49963aa-02d4-4e2d-9175-5d083348b9d3', 'airbnb', '2026-02-11 05:00:00+00', '2026-02-11', 1118, 238, 2.10, 5, 63.1, 168.42, 3187.54, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('af15067c-ac52-4619-b18a-a1fc91a51dc9', 'airbnb', '2026-02-12 05:00:00+00', '2026-02-12', 734, 402, 1.99, 8, 76.6, 242.69, 5574.70, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('9a31af88-02ca-4fcd-a204-5aa486fc5e8a', 'airbnb', '2026-02-13 05:00:00+00', '2026-02-13', 1318, 497, 1.41, 7, 63.6, 177.57, 3387.33, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('bc77afe8-099c-48bf-8322-d2ab4da82f7b', 'airbnb', '2026-02-14 05:00:00+00', '2026-02-14', 1611, 276, 1.45, 4, 61.9, 162.40, 3016.93, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('be706c74-716d-4489-8a3d-2a82d1026ca6', 'airbnb', '2026-02-15 05:00:00+00', '2026-02-15', 1904, 473, 0.85, 4, 59.0, 259.26, 4587.10, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('92fbc6dd-d12a-4cc7-8d30-c1e6202d6bf9', 'airbnb', '2026-02-16 05:00:00+00', '2026-02-16', 1571, 183, 4.37, 8, 75.2, 221.82, 5005.35, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('9ce56cce-2725-4088-a821-032c92456f6a', 'airbnb', '2026-02-17 05:00:00+00', '2026-02-17', 1401, 448, 0.45, 2, 80.3, 158.97, 3829.20, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('06bb86d8-8e66-4cf8-bd93-652fe29009ad', 'airbnb', '2026-02-18 05:00:00+00', '2026-02-18', 700, 366, 2.19, 8, 87.6, 209.21, 5500.26, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('8114ad85-faaa-40da-982c-c4dfea296954', 'airbnb', '2026-02-19 05:00:00+00', '2026-02-19', 1670, 251, 2.39, 6, 81.4, 237.75, 5809.11, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('9b3b4190-2a1b-4f00-8688-ab22620268ae', 'airbnb', '2026-02-20 05:00:00+00', '2026-02-20', 1465, 390, 1.03, 4, 69.0, 205.86, 4262.47, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('66a32049-4e7d-4118-a801-a4aede4e7153', 'airbnb', '2026-02-21 05:00:00+00', '2026-02-21', 1695, 349, 1.72, 6, 64.9, 217.55, 4233.06, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('74ad1be3-da0b-4968-9220-4eb2b02fed24', 'airbnb', '2026-02-22 05:00:00+00', '2026-02-22', 1305, 287, 1.39, 4, 64.5, 215.95, 4181.63, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('4fd9f864-ef00-4129-a696-25b12295c815', 'airbnb', '2026-02-23 05:00:00+00', '2026-02-23', 1934, 117, 3.42, 4, 88.3, 233.47, 6183.64, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('a4b4f8f4-1e76-4967-a523-24a8d301bb47', 'airbnb', '2026-02-24 05:00:00+00', '2026-02-24', 748, 132, 3.79, 5, 59.7, 250.58, 4491.14, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('5d60b4ec-3913-4a6a-a5f7-a38dffd65898', 'airbnb', '2026-02-25 05:00:00+00', '2026-02-25', 1474, 164, 3.66, 6, 77.4, 163.65, 3802.19, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('0eda95f6-4af1-4099-bd3a-d97608719f57', 'airbnb', '2026-02-26 05:00:00+00', '2026-02-26', 1655, 449, 1.11, 5, 85.4, 163.38, 4186.97, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('800b7226-1620-4e45-b7bc-aba34c61544d', 'airbnb', '2026-02-27 05:00:00+00', '2026-02-27', 2089, 407, 1.97, 8, 75.8, 183.47, 4171.59, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('b1bacff8-db8c-4d5b-8b01-7193758cb9e4', 'airbnb', '2026-02-28 05:00:00+00', '2026-02-28', 1642, 485, 0.41, 2, 71.5, 231.69, 4968.84, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('956ad085-a4d5-4624-9010-0ff6cd6f1229', 'airbnb', '2026-03-01 05:00:00+00', '2026-03-01', 1171, 359, 1.11, 4, 88.4, 171.65, 4551.37, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('1a58bacd-7373-4e7f-9272-83b0a7725485', 'airbnb', '2026-03-02 05:00:00+00', '2026-03-02', 1003, 282, 1.77, 5, 62.3, 243.94, 4556.49, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('9258e227-5c7f-4d52-ad84-734bd7301645', 'airbnb', '2026-03-03 05:00:00+00', '2026-03-03', 1012, 506, 0.40, 2, 69.0, 198.90, 4118.42, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('8e68e39d-df5a-40d9-8c63-1ced9e3e53e0', 'airbnb', '2026-03-04 05:00:00+00', '2026-03-04', 1433, 362, 0.83, 3, 73.5, 223.02, 4916.08, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('4ad0ffb6-d603-49df-af55-a23a8c9fa789', 'airbnb', '2026-03-05 05:00:00+00', '2026-03-05', 1262, 310, 2.90, 9, 80.7, 191.86, 4643.63, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('c35e411c-e541-4c32-b4ef-2ae0afec1af6', 'airbnb', '2026-03-06 05:00:00+00', '2026-03-06', 991, 498, 1.00, 5, 81.4, 264.12, 6448.55, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('f0ed7608-46c1-4528-978a-b44890babc6a', 'airbnb', '2026-03-07 05:00:00+00', '2026-03-07', 936, 531, 1.69, 9, 89.2, 201.97, 5405.07, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('e9f2024c-cd28-43ea-b104-08042946f3b1', 'airbnb', '2026-03-08 05:00:00+00', '2026-03-08', 1861, 148, 4.73, 7, 76.5, 259.05, 5947.76, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('e9a02122-12ac-4df3-b797-60ccf4dd4dc7', 'airbnb', '2026-03-09 04:00:00+00', '2026-03-09', 600, 123, 5.69, 7, 71.2, 236.90, 5062.69, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('7943e7fc-23f8-460c-a5e6-c27ce023ef7a', 'airbnb', '2026-03-10 04:00:00+00', '2026-03-10', 1242, 166, 2.41, 4, 87.0, 261.11, 6818.25, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('58057a6e-b486-49fc-8697-ebe8e7a179c9', 'airbnb', '2026-03-11 04:00:00+00', '2026-03-11', 1911, 192, 4.69, 9, 91.4, 199.54, 5470.29, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('a8f3d24e-13ce-4a98-8b78-adeab0c46ba9', 'airbnb', '2026-03-12 04:00:00+00', '2026-03-12', 1031, 123, 4.07, 5, 85.3, 236.12, 6043.44, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('88602e18-9a8a-4537-a421-390e61ea9115', 'airbnb', '2026-03-13 04:00:00+00', '2026-03-13', 1212, 441, 0.91, 4, 83.8, 239.54, 6023.31, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('3bee59c3-c736-41e0-87ce-741741dd0a38', 'airbnb', '2026-03-14 04:00:00+00', '2026-03-14', 1425, 187, 4.81, 9, 81.4, 198.21, 4840.71, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('777c12b7-26d7-4a3a-8929-6e39fb70df95', 'airbnb', '2026-03-15 04:00:00+00', '2026-03-15', 819, 510, 0.98, 5, 67.5, 185.16, 3750.76, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('e2e16394-4f61-45e1-8f9a-171b7f7506d2', 'airbnb', '2026-03-16 04:00:00+00', '2026-03-16', 1009, 505, 1.19, 6, 75.5, 230.12, 5210.89, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('27b242b3-453c-4d13-b407-fec2ce3153a2', 'airbnb', '2026-03-17 04:00:00+00', '2026-03-17', 970, 388, 2.58, 10, 85.2, 269.45, 6884.76, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('8fc650c7-4c44-48de-a9b5-315a32bab231', 'airbnb', '2026-03-18 04:00:00+00', '2026-03-18', 1069, 185, 3.24, 6, 83.7, 224.41, 5637.86, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('0fc9b766-5d62-4766-b515-215828efb8c7', 'airbnb', '2026-03-19 04:00:00+00', '2026-03-19', 778, 408, 1.23, 5, 61.1, 200.74, 3677.79, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('76a03869-33ee-436c-8d1c-cf066a50dafe', 'airbnb', '2026-03-20 04:00:00+00', '2026-03-20', 1467, 214, 3.27, 7, 84.0, 183.83, 4634.17, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('cc9a412a-11fd-42cb-8001-f6cc0fd86791', 'airbnb', '2026-03-21 04:00:00+00', '2026-03-21', 1108, 213, 0.94, 2, 77.9, 167.28, 3909.96, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('879d10be-a291-498f-843b-41537596c758', 'airbnb', '2026-03-22 04:00:00+00', '2026-03-22', 1891, 428, 0.93, 4, 92.1, 227.23, 6280.57, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('2eab0ccb-ac2c-42ed-9b9c-ab1b5808332a', 'vrbo', '2025-12-23 05:00:00+00', '2025-12-23', 899, 211, 0.95, 2, 69.6, 245.19, 5117.95, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('418d080b-cb71-4d3b-adae-e2b82effbc12', 'vrbo', '2025-12-24 05:00:00+00', '2025-12-24', 960, 185, 1.08, 2, 68.9, 165.88, 3429.06, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('e490cee1-221e-4d76-a6ea-f432d02cc493', 'vrbo', '2025-12-25 05:00:00+00', '2025-12-25', 578, 131, 1.53, 2, 64.9, 178.34, 3473.27, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('b82c334e-b0cc-49ab-bbbe-ae21200ea1db', 'vrbo', '2025-12-26 05:00:00+00', '2025-12-26', 395, 162, 2.47, 4, 65.1, 207.39, 4047.70, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('6b0282de-1dca-4602-9981-6876c8585630', 'vrbo', '2025-12-27 05:00:00+00', '2025-12-27', 406, 260, 0.77, 2, 71.0, 164.17, 3495.30, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('8be231fc-b4da-4db0-8088-4f5ea27872a8', 'vrbo', '2025-12-28 05:00:00+00', '2025-12-28', 321, 83, 3.61, 3, 69.8, 243.31, 5096.73, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('0d3bf356-e0ea-48a5-9f21-9db258d2cf5f', 'vrbo', '2025-12-29 05:00:00+00', '2025-12-29', 901, 274, 1.09, 3, 74.7, 247.07, 5535.57, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('71268916-c463-4ee1-862b-683615e7d003', 'vrbo', '2025-12-30 05:00:00+00', '2025-12-30', 645, 65, 7.69, 5, 81.1, 244.48, 5946.70, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('0b015265-8699-481d-b7f1-a752468e1611', 'vrbo', '2025-12-31 05:00:00+00', '2025-12-31', 987, 137, 2.92, 4, 74.8, 201.67, 4523.69, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('fbfaa3de-e41f-435e-b9a4-1c1c9ac954bc', 'vrbo', '2026-01-01 05:00:00+00', '2026-01-01', 928, 299, 0.67, 2, 67.1, 183.77, 3698.46, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('b9241ec5-4fc6-42ad-863d-8d513b167161', 'vrbo', '2026-01-02 05:00:00+00', '2026-01-02', 491, 126, 4.76, 6, 84.0, 189.88, 4787.77, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('148db6fd-69d7-4481-9b82-459b13b5fd74', 'vrbo', '2026-01-03 05:00:00+00', '2026-01-03', 360, 179, 1.68, 3, 77.8, 152.05, 3549.25, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('a3f91809-12a8-402c-8ead-869bd74078c8', 'vrbo', '2026-01-04 05:00:00+00', '2026-01-04', 646, 70, 4.29, 3, 57.3, 224.43, 3859.14, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('6483a1b0-8026-457f-9c82-cd08ac672a15', 'vrbo', '2026-01-05 05:00:00+00', '2026-01-05', 723, 193, 2.07, 4, 61.5, 153.40, 2829.96, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('058000ba-1b2f-4f85-a2c5-b7174668d2e7', 'vrbo', '2026-01-06 05:00:00+00', '2026-01-06', 336, 230, 0.87, 2, 64.2, 244.82, 4713.70, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('a55c7e9b-c1c3-4b6f-abb1-a1f9ee04d559', 'vrbo', '2026-01-07 05:00:00+00', '2026-01-07', 1212, 212, 1.89, 4, 83.2, 247.17, 6171.46, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('f37d14f7-00bb-4192-9502-2076daed13ff', 'vrbo', '2026-01-08 05:00:00+00', '2026-01-08', 690, 87, 2.30, 2, 83.1, 171.72, 4282.99, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('0ea80884-d5ef-4f9c-8296-5109cbf38302', 'vrbo', '2026-01-09 05:00:00+00', '2026-01-09', 1100, 231, 0.43, 1, 58.8, 206.58, 3645.18, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('7c97b3bc-a834-42ac-ab19-6aad1207f99d', 'vrbo', '2026-01-10 05:00:00+00', '2026-01-10', 575, 99, 5.05, 5, 77.9, 216.59, 5063.39, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('ad4f392e-d672-4734-b465-6650836e78a2', 'vrbo', '2026-01-11 05:00:00+00', '2026-01-11', 854, 128, 3.91, 5, 79.5, 159.12, 3797.14, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('4cad4d6f-ddf9-4638-9296-57191e244d3d', 'vrbo', '2026-01-12 05:00:00+00', '2026-01-12', 919, 93, 3.23, 3, 81.1, 246.59, 5999.58, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('887b8c44-7656-4148-882e-a0e18235c7e9', 'vrbo', '2026-01-13 05:00:00+00', '2026-01-13', 521, 143, 1.40, 2, 64.0, 245.64, 4719.29, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('66734730-e21b-4a39-8c48-fc11f5733c2d', 'vrbo', '2026-01-14 05:00:00+00', '2026-01-14', 308, 231, 0.87, 2, 57.3, 235.59, 4049.28, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('5d020b76-bba2-4417-a062-d66927bba029', 'vrbo', '2026-01-15 05:00:00+00', '2026-01-15', 438, 227, 0.88, 2, 62.4, 154.57, 2894.43, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('a43531b6-930c-4fcd-959c-339cda8ba422', 'vrbo', '2026-01-16 05:00:00+00', '2026-01-16', 617, 240, 0.83, 2, 86.8, 199.36, 5190.21, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('d7fbfd22-17a0-46e6-b5d4-bcad304c52ab', 'vrbo', '2026-01-17 05:00:00+00', '2026-01-17', 462, 94, 6.38, 6, 80.5, 211.98, 5119.87, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('4380c274-efe6-457f-b8e8-1d62fa4e9f57', 'vrbo', '2026-01-18 05:00:00+00', '2026-01-18', 783, 70, 7.14, 5, 80.9, 173.30, 4205.35, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('e04f9111-e83a-4a72-b271-bfbfe764f6cc', 'vrbo', '2026-01-19 05:00:00+00', '2026-01-19', 1147, 268, 1.49, 4, 71.9, 196.09, 4227.00, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('4bdc56cb-b316-412c-9700-944fcaef0a78', 'vrbo', '2026-01-20 05:00:00+00', '2026-01-20', 879, 295, 1.69, 5, 72.8, 223.12, 4875.12, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('e9ae8b49-0f99-4142-af73-57ca502b3e77', 'vrbo', '2026-01-21 05:00:00+00', '2026-01-21', 1018, 81, 6.17, 5, 77.7, 234.58, 5467.93, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('d80310ea-f2d7-4698-acb2-323b69187828', 'vrbo', '2026-01-22 05:00:00+00', '2026-01-22', 708, 229, 1.75, 4, 71.6, 248.08, 5330.34, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('aea4a8fe-60f3-40e9-b1c5-9b235347f5b9', 'vrbo', '2026-01-23 05:00:00+00', '2026-01-23', 707, 310, 0.97, 3, 74.3, 169.60, 3780.74, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('a76d7247-71a4-4098-a2b3-9c1aa02610d1', 'vrbo', '2026-01-24 05:00:00+00', '2026-01-24', 443, 211, 1.90, 4, 58.3, 245.87, 4302.05, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('ccacce19-08e7-4ea0-aaca-ed464bb6f063', 'vrbo', '2026-01-25 05:00:00+00', '2026-01-25', 538, 300, 2.00, 6, 83.6, 155.78, 3909.06, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('e59c7398-ccce-468b-99d4-1d546376d0da', 'vrbo', '2026-01-26 05:00:00+00', '2026-01-26', 655, 213, 1.41, 3, 72.1, 238.34, 5156.29, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('084e9d7d-f045-4607-ad31-a7641680153c', 'vrbo', '2026-01-27 05:00:00+00', '2026-01-27', 1216, 182, 1.10, 2, 61.4, 216.20, 3983.30, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('d7ec6692-675e-44fa-8bbc-fa3f276a90eb', 'vrbo', '2026-01-28 05:00:00+00', '2026-01-28', 809, 115, 2.61, 3, 74.4, 162.82, 3636.29, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('b4c5aa02-85f6-4a81-8a1f-9b0a43459c16', 'vrbo', '2026-01-29 05:00:00+00', '2026-01-29', 1165, 113, 1.77, 2, 58.4, 213.90, 3750.05, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('a88ec25e-3970-435c-9296-6733b12743e9', 'vrbo', '2026-01-30 05:00:00+00', '2026-01-30', 844, 270, 1.48, 4, 74.2, 163.31, 3633.34, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('201c6997-a977-42d6-b0ba-ad221a09e847', 'vrbo', '2026-01-31 05:00:00+00', '2026-01-31', 1022, 220, 0.45, 1, 69.1, 221.98, 4602.27, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('78cdccd2-a90f-42b4-a58a-087304e530dc', 'vrbo', '2026-02-01 05:00:00+00', '2026-02-01', 827, 154, 2.60, 4, 83.5, 224.45, 5621.61, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('e648f1ca-6090-4fb6-aaea-36083077e2f9', 'vrbo', '2026-02-02 05:00:00+00', '2026-02-02', 1058, 64, 3.13, 2, 62.9, 234.01, 4418.34, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('91d0a3da-7f61-4e48-942b-463d11332262', 'vrbo', '2026-02-03 05:00:00+00', '2026-02-03', 321, 257, 1.17, 3, 65.2, 196.46, 3842.13, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('f99749b9-b30d-47f7-b565-b32dfba158ac', 'vrbo', '2026-02-04 05:00:00+00', '2026-02-04', 983, 301, 1.33, 4, 79.0, 226.00, 5356.24, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('ec5ba9af-d695-4a1b-9e90-a8cf0a359fc2', 'vrbo', '2026-02-05 05:00:00+00', '2026-02-05', 1108, 260, 0.38, 1, 61.1, 255.71, 4687.30, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('df6bf2d3-93dc-4898-8fb1-edab0677880b', 'vrbo', '2026-02-06 05:00:00+00', '2026-02-06', 570, 84, 4.76, 4, 80.3, 160.99, 3876.81, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('86df6648-c970-41b8-b1d4-abf4ebacd12b', 'vrbo', '2026-02-07 05:00:00+00', '2026-02-07', 1164, 153, 3.27, 5, 71.2, 210.76, 4500.38, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('e678d779-8c0c-48fd-acb2-ebf76adf5daa', 'vrbo', '2026-02-08 05:00:00+00', '2026-02-08', 932, 148, 2.03, 3, 62.2, 247.10, 4610.42, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('b53bd705-ae32-48ca-9b2a-071cb5ec8b8e', 'vrbo', '2026-02-09 05:00:00+00', '2026-02-09', 393, 315, 0.95, 3, 61.9, 161.14, 2994.31, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('bedfe513-cd8f-4051-8469-8bd75cfa19aa', 'vrbo', '2026-02-10 05:00:00+00', '2026-02-10', 965, 260, 1.92, 5, 69.9, 172.03, 3606.85, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('e6f8abd0-04f0-4e72-aea6-4d7c436f5f36', 'vrbo', '2026-02-11 05:00:00+00', '2026-02-11', 820, 104, 0.96, 1, 86.1, 173.43, 4481.87, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('282219de-5d1c-4ae5-bfa7-fdd81d02f196', 'vrbo', '2026-02-12 05:00:00+00', '2026-02-12', 383, 259, 2.32, 6, 89.0, 245.72, 6557.09, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('ed4c5c1c-c6d0-4f35-873e-8b0ab33db601', 'vrbo', '2026-02-13 05:00:00+00', '2026-02-13', 1231, 238, 0.42, 1, 72.0, 184.08, 3977.99, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('9860ab15-8f66-4ea6-b1dd-f70bdc4c646c', 'vrbo', '2026-02-14 05:00:00+00', '2026-02-14', 1067, 96, 2.08, 2, 62.9, 237.15, 4473.27, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('ece78d07-cf61-4e41-a05c-bfe9e1eae89c', 'vrbo', '2026-02-15 05:00:00+00', '2026-02-15', 763, 302, 0.66, 2, 76.1, 259.01, 5914.95, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('4b3e0a8e-a0dd-4edf-8c6e-c0ae6237d719', 'vrbo', '2026-02-16 05:00:00+00', '2026-02-16', 507, 264, 1.14, 3, 80.7, 184.56, 4469.54, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('309174ef-ab34-47af-8225-703cef4a7f53', 'vrbo', '2026-02-17 05:00:00+00', '2026-02-17', 1172, 188, 0.53, 1, 82.0, 202.97, 4993.90, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('06fe611d-1bbd-45ba-9369-777cf716629b', 'vrbo', '2026-02-18 05:00:00+00', '2026-02-18', 575, 65, 7.69, 5, 65.8, 208.11, 4105.87, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('f84df611-a5f3-4154-aa37-2b3c79bbc8b3', 'vrbo', '2026-02-19 05:00:00+00', '2026-02-19', 1111, 308, 0.65, 2, 78.0, 226.01, 5286.30, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('b10c891a-3c92-4169-9608-099fbeb74742', 'vrbo', '2026-02-20 05:00:00+00', '2026-02-20', 419, 135, 2.96, 4, 85.2, 241.66, 6174.63, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('a73a6843-cf8a-46e2-86d1-2f83cf7f7ccb', 'vrbo', '2026-02-21 05:00:00+00', '2026-02-21', 883, 266, 1.50, 4, 71.2, 160.81, 3435.47, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('f3bab766-faa4-4c50-b376-a009c668206c', 'vrbo', '2026-02-22 05:00:00+00', '2026-02-22', 386, 299, 0.33, 1, 66.1, 191.95, 3808.04, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('f6196c15-76dc-4801-9fb9-b962f0d42be8', 'vrbo', '2026-02-23 05:00:00+00', '2026-02-23', 361, 182, 1.10, 2, 88.8, 261.32, 6959.42, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('4b2fdd7e-bba2-4856-979a-928226775bae', 'vrbo', '2026-02-24 05:00:00+00', '2026-02-24', 328, 102, 0.98, 1, 77.4, 188.06, 4367.89, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('963412a6-1052-4eda-97b6-6cec819ab142', 'vrbo', '2026-02-25 05:00:00+00', '2026-02-25', 551, 231, 1.30, 3, 62.3, 245.65, 4592.96, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('5a519622-4084-4a67-be16-9496668dcac0', 'vrbo', '2026-02-26 05:00:00+00', '2026-02-26', 1261, 258, 1.55, 4, 65.1, 196.24, 3831.81, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('6fa16059-e48c-42ff-8352-3b72568b25af', 'vrbo', '2026-02-27 05:00:00+00', '2026-02-27', 826, 90, 3.33, 3, 60.7, 193.82, 3529.29, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('e91a320b-468d-42c5-84cb-35cd3c04c029', 'vrbo', '2026-02-28 05:00:00+00', '2026-02-28', 465, 130, 2.31, 3, 78.0, 166.79, 3904.57, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('215e8ba5-a042-4644-9c61-a1acc070058d', 'vrbo', '2026-03-01 05:00:00+00', '2026-03-01', 546, 298, 1.01, 3, 85.6, 231.53, 5948.95, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('22616899-85b2-4859-b6fc-f3fc3b121fd1', 'vrbo', '2026-03-02 05:00:00+00', '2026-03-02', 690, 302, 0.33, 1, 63.6, 241.05, 4600.45, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('c7646ea6-b754-4df8-8c45-e93e509e1129', 'vrbo', '2026-03-03 05:00:00+00', '2026-03-03', 914, 200, 3.00, 6, 84.7, 260.06, 6609.56, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('86eb199d-017a-4d66-8804-85068cf7826d', 'vrbo', '2026-03-04 05:00:00+00', '2026-03-04', 379, 146, 2.74, 4, 83.2, 161.84, 4038.42, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('686b3e33-3e20-492e-b9f3-513a7cde023a', 'vrbo', '2026-03-05 05:00:00+00', '2026-03-05', 963, 79, 7.59, 6, 87.6, 260.98, 6857.73, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('69e6be4e-044e-4eb4-be3d-7f1cc551e392', 'vrbo', '2026-03-06 05:00:00+00', '2026-03-06', 1194, 131, 3.05, 4, 68.2, 166.62, 3408.27, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('5022357c-43dc-456d-a43b-8f5a86dc4729', 'vrbo', '2026-03-07 05:00:00+00', '2026-03-07', 360, 156, 3.21, 5, 74.9, 205.87, 4623.13, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('36106b04-2567-4a3b-a144-11e34712a0f0', 'vrbo', '2026-03-08 05:00:00+00', '2026-03-08', 1093, 81, 4.94, 4, 82.8, 202.59, 5033.81, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('9be17d0d-b3d1-4d7e-9c59-86ef9ac3b1ae', 'vrbo', '2026-03-09 04:00:00+00', '2026-03-09', 330, 170, 2.35, 4, 89.4, 233.68, 6269.48, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('6816aa8a-1541-408c-a8f7-75a811ea711d', 'vrbo', '2026-03-10 04:00:00+00', '2026-03-10', 1167, 281, 1.78, 5, 87.8, 206.57, 5438.26, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('9ccc5192-3c16-4666-8935-96e47c0c3308', 'vrbo', '2026-03-11 04:00:00+00', '2026-03-11', 1143, 294, 1.36, 4, 84.5, 167.39, 4241.21, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('1dd655f7-8b11-40bf-bb77-099e020f83f1', 'vrbo', '2026-03-12 04:00:00+00', '2026-03-12', 452, 231, 2.60, 6, 85.4, 177.01, 4534.07, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('4185234b-68c8-4764-8669-f6ab55a7ad6f', 'vrbo', '2026-03-13 04:00:00+00', '2026-03-13', 1274, 248, 0.81, 2, 63.8, 182.72, 3499.50, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('b9631b12-291c-4be5-b7c3-ca12c0781b2b', 'vrbo', '2026-03-14 04:00:00+00', '2026-03-14', 1263, 253, 1.58, 4, 70.0, 234.80, 4931.46, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('fefb99b9-aa03-485b-90cf-2b63d9de5d1c', 'vrbo', '2026-03-15 04:00:00+00', '2026-03-15', 552, 169, 2.37, 4, 86.4, 218.05, 5654.37, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('868c761c-f604-47dd-820d-6a69efb1bf41', 'vrbo', '2026-03-16 04:00:00+00', '2026-03-16', 595, 142, 1.41, 2, 62.1, 210.00, 3915.28, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('e27e93d6-764e-4e75-8358-62cae817ab03', 'vrbo', '2026-03-17 04:00:00+00', '2026-03-17', 1258, 262, 1.53, 4, 82.7, 262.06, 6502.75, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('96f05ce2-1948-4e34-b110-406cc3756de2', 'vrbo', '2026-03-18 04:00:00+00', '2026-03-18', 1139, 71, 2.82, 2, 72.3, 259.55, 5631.93, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('017227c3-99dc-4f62-a64b-f0f9e6a04f44', 'vrbo', '2026-03-19 04:00:00+00', '2026-03-19', 537, 87, 4.60, 4, 60.5, 230.69, 4187.59, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('fccbd533-f7d9-48e1-9b4b-aff8622a74a9', 'vrbo', '2026-03-20 04:00:00+00', '2026-03-20', 416, 240, 0.83, 2, 69.0, 248.46, 5139.61, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('f7e1c486-52c6-422e-b73c-82bfb0f84188', 'vrbo', '2026-03-21 04:00:00+00', '2026-03-21', 1300, 81, 3.70, 3, 76.4, 241.36, 5530.78, '{"source": "seed", "generated": true}'); +INSERT INTO public.performance_snapshots VALUES ('22822337-fba4-4e5b-a670-e8c3035e5d02', 'vrbo', '2026-03-22 04:00:00+00', '2026-03-22', 388, 234, 1.71, 4, 74.6, 246.57, 5517.31, '{"source": "seed", "generated": true}'); + + +-- +-- Data for Name: price_changes; Type: TABLE DATA; Schema: public; Owner: str_manager +-- + +INSERT INTO public.price_changes VALUES ('9ea58dc0-9c2a-4a7b-b880-aeb408f2502e', 'airbnb', '2026-02-06', 195.69, 225.05, '2026-03-22 23:53:19.624483+00', 'experiment', 'Weekend premium pricing experiment', '93286d29-29ac-494a-945e-38a6da3511a9'); +INSERT INTO public.price_changes VALUES ('f0ccc342-2bdd-48ee-a93c-6c1303604021', 'vrbo', '2026-01-28', 212.45, 244.32, '2026-03-22 23:53:19.624483+00', 'experiment', 'Weekend premium pricing experiment', '93286d29-29ac-494a-945e-38a6da3511a9'); +INSERT INTO public.price_changes VALUES ('267a9989-eda2-4c6b-84ea-6c5091e11d37', 'airbnb', '2026-02-03', 199.81, 229.78, '2026-03-22 23:53:19.624483+00', 'experiment', 'Weekend premium pricing experiment', '93286d29-29ac-494a-945e-38a6da3511a9'); +INSERT INTO public.price_changes VALUES ('728c0a86-f214-423e-bc1f-c386cc549578', 'vrbo', '2026-02-10', 183.61, 211.15, '2026-03-22 23:53:19.624483+00', 'experiment', 'Weekend premium pricing experiment', '93286d29-29ac-494a-945e-38a6da3511a9'); +INSERT INTO public.price_changes VALUES ('01f0d793-2dd3-48f9-9ef9-7b34fec2584a', 'airbnb', '2026-02-19', 207.13, 238.20, '2026-03-22 23:53:19.624483+00', 'experiment', 'Weekend premium pricing experiment', '93286d29-29ac-494a-945e-38a6da3511a9'); +INSERT INTO public.price_changes VALUES ('2f78b2cb-234c-4ada-926e-d9ff4864c9f0', 'vrbo', '2026-01-22', 192.77, 221.68, '2026-03-22 23:53:19.624483+00', 'experiment', 'Weekend premium pricing experiment', '93286d29-29ac-494a-945e-38a6da3511a9'); +INSERT INTO public.price_changes VALUES ('34aa0f2f-2dac-4b0c-a74d-b84d77c60316', 'airbnb', '2026-01-22', 211.19, 242.87, '2026-03-22 23:53:19.624483+00', 'experiment', 'Weekend premium pricing experiment', '93286d29-29ac-494a-945e-38a6da3511a9'); +INSERT INTO public.price_changes VALUES ('0908dac9-9bc9-4fd5-a398-908b1c53b5d6', 'vrbo', '2026-02-03', 192.50, 221.38, '2026-03-22 23:53:19.624483+00', 'experiment', 'Weekend premium pricing experiment', '93286d29-29ac-494a-945e-38a6da3511a9'); +INSERT INTO public.price_changes VALUES ('1849553b-ec27-4e8a-87ea-7a83112ecf33', 'airbnb', '2026-03-10', 233.93, 252.46, '2026-03-22 23:53:19.624483+00', 'manual', 'Manual price adjustment', NULL); +INSERT INTO public.price_changes VALUES ('0ae102c4-2372-4646-a51d-92692d48c6c7', 'airbnb', '2026-03-17', 202.66, 186.09, '2026-03-22 23:53:19.624483+00', 'manual', 'Manual price adjustment', NULL); +INSERT INTO public.price_changes VALUES ('ecfe30ec-a7b0-4fcb-b019-a7e594ab0f05', 'airbnb', '2026-03-10', 219.61, 216.50, '2026-03-22 23:53:19.624483+00', 'manual', 'Manual price adjustment', NULL); +INSERT INTO public.price_changes VALUES ('5fb17220-1d5e-4d9a-aa2c-da1c62452efb', 'airbnb', '2026-02-28', 198.23, 182.94, '2026-03-22 23:53:19.624483+00', 'manual', 'Manual price adjustment', NULL); + + +-- +-- Data for Name: reservations; Type: TABLE DATA; Schema: public; Owner: str_manager +-- + +INSERT INTO public.reservations VALUES ('51619f59-a28e-4200-bd71-53f1abfcd8bb', 'vrbo', 'VRBO-1001', 'Sarah Johnson', '2026-03-05', '2026-03-09', 4, 1, 182.12, 109, 24.56, 812.91, 'completed', '2026-02-15 05:00:00+00', '2026-03-22 23:53:19.622069+00', '{"source": "seed", "generated": true}'); +INSERT INTO public.reservations VALUES ('04416b43-c1fd-4023-a98a-69320627d205', 'airbnb', 'AIRBNB-1002', 'Michael Chen', '2026-03-02', '2026-03-07', 5, 6, 256.74, 83, 61.11, 1305.59, 'completed', '2026-02-04 05:00:00+00', '2026-03-22 23:53:19.622069+00', '{"source": "seed", "generated": true}'); +INSERT INTO public.reservations VALUES ('99fbfe16-fa3e-42cf-8dd3-4e5bf669a59d', 'airbnb', 'AIRBNB-1003', 'Emily Rodriguez', '2026-03-04', '2026-03-06', 2, 2, 191.40, 94, 13.50, 463.29, 'completed', '2026-02-24 05:00:00+00', '2026-03-22 23:53:19.622069+00', '{"source": "seed", "generated": true}'); +INSERT INTO public.reservations VALUES ('1c35d6e5-ae54-4082-a0ef-65dd9f475a7f', 'vrbo', 'VRBO-1004', 'James Wilson', '2026-03-05', '2026-03-11', 6, 3, 255.52, 149, 50.52, 1631.57, 'completed', '2026-02-18 05:00:00+00', '2026-03-22 23:53:19.622069+00', '{"source": "seed", "generated": true}'); +INSERT INTO public.reservations VALUES ('d8be4d57-62de-463b-a13e-88b737f95424', 'airbnb', 'AIRBNB-1005', 'Amanda Foster', '2026-01-07', '2026-01-10', 3, 4, 187.53, 91, 25.68, 627.90, 'completed', '2025-12-14 05:00:00+00', '2026-03-22 23:53:19.622069+00', '{"source": "seed", "generated": true}'); +INSERT INTO public.reservations VALUES ('ffd9cabd-47df-48b2-ba6f-37ba94b59a98', 'airbnb', 'AIRBNB-1006', 'David Kim', '2026-01-05', '2026-01-08', 3, 3, 164.14, 114, 17.35, 589.06, 'completed', '2026-01-01 05:00:00+00', '2026-03-22 23:53:19.622069+00', '{"source": "seed", "generated": true}'); +INSERT INTO public.reservations VALUES ('af9f48de-4568-4338-81f3-3671c6fd437e', 'vrbo', 'VRBO-1007', 'Jessica Taylor', '2026-03-13', '2026-03-20', 7, 5, 182.06, 110, 38.37, 1346.05, 'completed', '2026-02-16 05:00:00+00', '2026-03-22 23:53:19.622069+00', '{"source": "seed", "generated": true}'); +INSERT INTO public.reservations VALUES ('36529cc5-1867-4ec7-b333-9b37b75eb8cc', 'airbnb', 'AIRBNB-1008', 'Robert Martinez', '2026-03-07', '2026-03-11', 4, 3, 193.30, 76, 33.25, 815.94, 'completed', '2026-02-25 05:00:00+00', '2026-03-22 23:53:19.622069+00', '{"source": "seed", "generated": true}'); +INSERT INTO public.reservations VALUES ('ec1838fb-3503-4abe-af7f-0c3cf603dd0a', 'airbnb', 'AIRBNB-1009', 'Lisa Anderson', '2025-12-30', '2026-01-02', 3, 6, 279.86, 90, 31.28, 898.29, 'completed', '2025-12-09 05:00:00+00', '2026-03-22 23:53:19.622069+00', '{"source": "seed", "generated": true}'); +INSERT INTO public.reservations VALUES ('d0555972-1045-4930-8857-0f12cdb52371', 'vrbo', 'VRBO-1010', 'Chris Thompson', '2026-01-04', '2026-01-11', 7, 3, 210.60, 128, 73.50, 1528.72, 'completed', '2025-12-20 05:00:00+00', '2026-03-22 23:53:19.622069+00', '{"source": "seed", "generated": true}'); +INSERT INTO public.reservations VALUES ('a33e70f5-59c7-4288-9d65-e55b7b28b9eb', 'airbnb', 'AIRBNB-1011', 'Rachel Lee', '2026-03-01', '2026-03-03', 2, 6, 174.00, 89, 17.00, 419.99, 'completed', '2026-02-13 05:00:00+00', '2026-03-22 23:53:19.622069+00', '{"source": "seed", "generated": true}'); +INSERT INTO public.reservations VALUES ('c92ea7c2-efc5-4cde-b076-0ac7aebf7568', 'airbnb', 'AIRBNB-1012', 'Daniel Brown', '2026-01-25', '2026-01-31', 6, 4, 237.65, 108, 53.90, 1479.98, 'completed', '2026-01-01 05:00:00+00', '2026-03-22 23:53:19.622069+00', '{"source": "seed", "generated": true}'); +INSERT INTO public.reservations VALUES ('68a41a37-9932-48c4-a3ba-2b26f81f6b58', 'vrbo', 'VRBO-1013', 'Maria Garcia', '2026-03-12', '2026-03-19', 7, 1, 253.22, 84, 68.53, 1788.00, 'completed', '2026-02-26 05:00:00+00', '2026-03-22 23:53:19.622069+00', '{"source": "seed", "generated": true}'); +INSERT INTO public.reservations VALUES ('2a9b6665-4a85-4460-bdcd-d05f13d5cce9', 'airbnb', 'AIRBNB-1014', 'Kevin Wright', '2026-01-02', '2026-01-07', 5, 4, 255.56, 84, 41.63, 1320.16, 'completed', '2025-12-24 05:00:00+00', '2026-03-22 23:53:19.622069+00', '{"source": "seed", "generated": true}'); +INSERT INTO public.reservations VALUES ('b6e7d75f-9d2d-4700-994d-ff0943043c65', 'airbnb', 'AIRBNB-1015', 'Stephanie Davis', '2026-02-17', '2026-02-20', 3, 6, 254.88, 142, 36.75, 869.88, 'completed', '2026-01-26 05:00:00+00', '2026-03-22 23:53:19.622069+00', '{"source": "seed", "generated": true}'); + + +-- +-- Data for Name: scrape_jobs; Type: TABLE DATA; Schema: public; Owner: str_manager +-- + +INSERT INTO public.scrape_jobs VALUES ('5e87a505-7e50-4d2a-bda9-16f30f76ba65', 'airbnb', 'performance', 'manual', 'completed', '2026-03-16 04:00:00+00', '2026-03-16 04:01:14+00', NULL, 50); +INSERT INTO public.scrape_jobs VALUES ('08e3157b-5d6d-4009-9485-23dccbd7bc6a', 'airbnb', 'calendar', 'scheduled', 'completed', '2026-03-08 05:00:00+00', '2026-03-08 05:01:07+00', NULL, 194); +INSERT INTO public.scrape_jobs VALUES ('19977120-01b9-4ed4-8a30-b513ceba46d0', 'airbnb', 'performance', 'scheduled', 'completed', '2026-03-18 04:00:00+00', '2026-03-18 04:01:41+00', NULL, 135); +INSERT INTO public.scrape_jobs VALUES ('874266e0-d821-4953-848c-ad8ef1ab4532', 'airbnb', 'calendar', 'scheduled', 'completed', '2026-03-15 04:00:00+00', '2026-03-15 04:00:06+00', NULL, 105); +INSERT INTO public.scrape_jobs VALUES ('113ae8d3-364c-4f4c-a4d3-2f55912ed8c8', 'airbnb', 'performance', 'scheduled', 'completed', '2026-03-03 05:00:00+00', '2026-03-03 05:01:28+00', NULL, 169); +INSERT INTO public.scrape_jobs VALUES ('4981633e-a8e3-437b-b8ee-7c51d4227746', 'vrbo', 'performance', 'manual', 'completed', '2026-02-27 05:00:00+00', '2026-02-27 05:01:49+00', NULL, 155); +INSERT INTO public.scrape_jobs VALUES ('792564e6-7196-4408-892d-1e0e8e621acd', 'vrbo', 'calendar', 'scheduled', 'completed', '2026-02-28 05:00:00+00', '2026-02-28 05:01:26+00', NULL, 69); +INSERT INTO public.scrape_jobs VALUES ('453e6d24-4a92-48d9-ac40-ef5cfac1ef66', 'vrbo', 'performance', 'scheduled', 'completed', '2026-03-01 05:00:00+00', '2026-03-01 05:00:17+00', NULL, 13); +INSERT INTO public.scrape_jobs VALUES ('ed8f5c32-eae1-4ca0-ac08-68092185a055', 'vrbo', 'calendar', 'scheduled', 'completed', '2026-02-24 05:00:00+00', '2026-02-24 05:01:12+00', NULL, 200); +INSERT INTO public.scrape_jobs VALUES ('cae7b256-fad8-441b-b76c-2b0fae4a38c1', 'vrbo', 'performance', 'scheduled', 'completed', '2026-02-20 05:00:00+00', '2026-02-20 05:01:58+00', NULL, 37); + + +-- +-- Name: __drizzle_migrations_id_seq; Type: SEQUENCE SET; Schema: drizzle; Owner: str_manager +-- + +SELECT pg_catalog.setval('drizzle.__drizzle_migrations_id_seq', 1, true); + + +-- +-- PostgreSQL database dump complete +-- + +\unrestrict MeN1ws58DquMlfn6S1mGywDt5fuo6rhsE6g75LgmXhiU6DV4nSIsGAX8gVJ0f2n + diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts new file mode 100644 index 0000000..0b959b4 --- /dev/null +++ b/apps/api/src/db/index.ts @@ -0,0 +1,14 @@ +import dotenv from 'dotenv'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: resolve(__dirname, '../../../../.env') }); +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema.js'; + +const connectionString = process.env.DATABASE_URL!; +const sql = postgres(connectionString); +export const db = drizzle(sql, { schema }); +export { sql }; diff --git a/apps/api/src/db/migrations/0000_modern_screwball.sql b/apps/api/src/db/migrations/0000_modern_screwball.sql new file mode 100644 index 0000000..0fbcf87 --- /dev/null +++ b/apps/api/src/db/migrations/0000_modern_screwball.sql @@ -0,0 +1,96 @@ +CREATE TABLE "daily_prices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "platform_id" varchar NOT NULL, + "date" date NOT NULL, + "price" numeric NOT NULL, + "is_available" boolean DEFAULT true NOT NULL, + "min_stay_nights" integer DEFAULT 1 NOT NULL, + "synced_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "daily_prices_platform_date" UNIQUE("platform_id","date") +); +--> statement-breakpoint +CREATE TABLE "experiments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar NOT NULL, + "hypothesis" text, + "start_date" date NOT NULL, + "end_date" date, + "status" varchar DEFAULT 'active' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "conclusion" text +); +--> statement-breakpoint +CREATE TABLE "performance_snapshots" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "platform_id" varchar NOT NULL, + "captured_at" timestamp with time zone DEFAULT now() NOT NULL, + "period_label" varchar NOT NULL, + "views_search" integer, + "views_listing" integer, + "conversion_rate" numeric, + "bookings_count" integer, + "occupancy_rate" numeric, + "avg_daily_rate" numeric, + "revenue_total" numeric, + "raw_json" jsonb +); +--> statement-breakpoint +CREATE TABLE "platforms" ( + "id" varchar PRIMARY KEY NOT NULL, + "display_name" varchar NOT NULL, + "credentials_encrypted" text, + "session_data_encrypted" text, + "last_scrape_at" timestamp with time zone, + "is_active" boolean DEFAULT true NOT NULL +); +--> statement-breakpoint +CREATE TABLE "price_changes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "platform_id" varchar NOT NULL, + "date" date NOT NULL, + "price_before" numeric NOT NULL, + "price_after" numeric NOT NULL, + "changed_at" timestamp with time zone DEFAULT now() NOT NULL, + "changed_by" varchar, + "note" text, + "experiment_id" uuid +); +--> statement-breakpoint +CREATE TABLE "reservations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "platform_id" varchar NOT NULL, + "platform_reservation_id" varchar NOT NULL, + "guest_name" varchar NOT NULL, + "check_in" date NOT NULL, + "check_out" date NOT NULL, + "nights" integer NOT NULL, + "guests_count" integer, + "nightly_rate" numeric NOT NULL, + "cleaning_fee" numeric, + "platform_fee" numeric, + "total_payout" numeric NOT NULL, + "status" varchar DEFAULT 'confirmed' NOT NULL, + "booked_at" timestamp with time zone, + "synced_at" timestamp with time zone DEFAULT now() NOT NULL, + "raw_json" jsonb, + CONSTRAINT "reservations_platform_res_id" UNIQUE("platform_id","platform_reservation_id") +); +--> statement-breakpoint +CREATE TABLE "scrape_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "platform_id" varchar NOT NULL, + "job_type" varchar NOT NULL, + "triggered_by" varchar NOT NULL, + "status" varchar DEFAULT 'pending' NOT NULL, + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone, + "error_message" text, + "rows_collected" integer +); +--> statement-breakpoint +ALTER TABLE "daily_prices" ADD CONSTRAINT "daily_prices_platform_id_platforms_id_fk" FOREIGN KEY ("platform_id") REFERENCES "public"."platforms"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "performance_snapshots" ADD CONSTRAINT "performance_snapshots_platform_id_platforms_id_fk" FOREIGN KEY ("platform_id") REFERENCES "public"."platforms"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "price_changes" ADD CONSTRAINT "price_changes_platform_id_platforms_id_fk" FOREIGN KEY ("platform_id") REFERENCES "public"."platforms"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "price_changes" ADD CONSTRAINT "price_changes_experiment_id_experiments_id_fk" FOREIGN KEY ("experiment_id") REFERENCES "public"."experiments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reservations" ADD CONSTRAINT "reservations_platform_id_platforms_id_fk" FOREIGN KEY ("platform_id") REFERENCES "public"."platforms"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scrape_jobs" ADD CONSTRAINT "scrape_jobs_platform_id_platforms_id_fk" FOREIGN KEY ("platform_id") REFERENCES "public"."platforms"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/0000_snapshot.json b/apps/api/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..2b6fac2 --- /dev/null +++ b/apps/api/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,630 @@ +{ + "id": "1ae55d0b-2157-4fab-ac85-289ea14275fb", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.daily_prices": { + "name": "daily_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "platform_id": { + "name": "platform_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "is_available": { + "name": "is_available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "min_stay_nights": { + "name": "min_stay_nights", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "daily_prices_platform_id_platforms_id_fk": { + "name": "daily_prices_platform_id_platforms_id_fk", + "tableFrom": "daily_prices", + "tableTo": "platforms", + "columnsFrom": [ + "platform_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "daily_prices_platform_date": { + "name": "daily_prices_platform_date", + "nullsNotDistinct": false, + "columns": [ + "platform_id", + "date" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.experiments": { + "name": "experiments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "hypothesis": { + "name": "hypothesis", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "conclusion": { + "name": "conclusion", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.performance_snapshots": { + "name": "performance_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "platform_id": { + "name": "platform_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "period_label": { + "name": "period_label", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "views_search": { + "name": "views_search", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "views_listing": { + "name": "views_listing", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "conversion_rate": { + "name": "conversion_rate", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "bookings_count": { + "name": "bookings_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "occupancy_rate": { + "name": "occupancy_rate", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "avg_daily_rate": { + "name": "avg_daily_rate", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "revenue_total": { + "name": "revenue_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "raw_json": { + "name": "raw_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "performance_snapshots_platform_id_platforms_id_fk": { + "name": "performance_snapshots_platform_id_platforms_id_fk", + "tableFrom": "performance_snapshots", + "tableTo": "platforms", + "columnsFrom": [ + "platform_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platforms": { + "name": "platforms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "credentials_encrypted": { + "name": "credentials_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_data_encrypted": { + "name": "session_data_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_scrape_at": { + "name": "last_scrape_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.price_changes": { + "name": "price_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "platform_id": { + "name": "platform_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "price_before": { + "name": "price_before", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price_after": { + "name": "price_after", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "changed_at": { + "name": "changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "changed_by": { + "name": "changed_by", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "experiment_id": { + "name": "experiment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "price_changes_platform_id_platforms_id_fk": { + "name": "price_changes_platform_id_platforms_id_fk", + "tableFrom": "price_changes", + "tableTo": "platforms", + "columnsFrom": [ + "platform_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "price_changes_experiment_id_experiments_id_fk": { + "name": "price_changes_experiment_id_experiments_id_fk", + "tableFrom": "price_changes", + "tableTo": "experiments", + "columnsFrom": [ + "experiment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reservations": { + "name": "reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "platform_id": { + "name": "platform_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "platform_reservation_id": { + "name": "platform_reservation_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "guest_name": { + "name": "guest_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "check_in": { + "name": "check_in", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "check_out": { + "name": "check_out", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "nights": { + "name": "nights", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "guests_count": { + "name": "guests_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "nightly_rate": { + "name": "nightly_rate", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "cleaning_fee": { + "name": "cleaning_fee", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "platform_fee": { + "name": "platform_fee", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "total_payout": { + "name": "total_payout", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'confirmed'" + }, + "booked_at": { + "name": "booked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "raw_json": { + "name": "raw_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reservations_platform_id_platforms_id_fk": { + "name": "reservations_platform_id_platforms_id_fk", + "tableFrom": "reservations", + "tableTo": "platforms", + "columnsFrom": [ + "platform_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reservations_platform_res_id": { + "name": "reservations_platform_res_id", + "nullsNotDistinct": false, + "columns": [ + "platform_id", + "platform_reservation_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scrape_jobs": { + "name": "scrape_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "platform_id": { + "name": "platform_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "job_type": { + "name": "job_type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "triggered_by": { + "name": "triggered_by", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rows_collected": { + "name": "rows_collected", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "scrape_jobs_platform_id_platforms_id_fk": { + "name": "scrape_jobs_platform_id_platforms_id_fk", + "tableFrom": "scrape_jobs", + "tableTo": "platforms", + "columnsFrom": [ + "platform_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json new file mode 100644 index 0000000..8ac95ad --- /dev/null +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1774223467634, + "tag": "0000_modern_screwball", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts new file mode 100644 index 0000000..5ea9f6b --- /dev/null +++ b/apps/api/src/db/schema.ts @@ -0,0 +1,195 @@ +import { relations } from 'drizzle-orm'; +import { + boolean, + date, + integer, + jsonb, + numeric, + pgTable, + text, + timestamp, + unique, + uuid, + varchar, +} from 'drizzle-orm/pg-core'; + +const timestamptz = (name: string) => timestamp(name, { withTimezone: true, mode: 'string' }); + +// ── Platforms ──────────────────────────────────────────────────────────────── + +export const platforms = pgTable('platforms', { + id: varchar('id').primaryKey(), + displayName: varchar('display_name').notNull(), + credentialsEncrypted: text('credentials_encrypted'), + sessionDataEncrypted: text('session_data_encrypted'), + lastScrapeAt: timestamptz('last_scrape_at'), + isActive: boolean('is_active').default(true).notNull(), +}); + +export const platformsRelations = relations(platforms, ({ many }) => ({ + performanceSnapshots: many(performanceSnapshots), + dailyPrices: many(dailyPrices), + priceChanges: many(priceChanges), + reservations: many(reservations), + scrapeJobs: many(scrapeJobs), +})); + +// ── Performance Snapshots ─────────────────────────────────────────────────── + +export const performanceSnapshots = pgTable('performance_snapshots', { + id: uuid('id').defaultRandom().primaryKey(), + platformId: varchar('platform_id') + .notNull() + .references(() => platforms.id), + capturedAt: timestamptz('captured_at').defaultNow().notNull(), + periodLabel: varchar('period_label').notNull(), + viewsSearch: integer('views_search'), + viewsListing: integer('views_listing'), + conversionRate: numeric('conversion_rate'), + bookingsCount: integer('bookings_count'), + occupancyRate: numeric('occupancy_rate'), + avgDailyRate: numeric('avg_daily_rate'), + revenueTotal: numeric('revenue_total'), + rawJson: jsonb('raw_json'), +}); + +export const performanceSnapshotsRelations = relations( + performanceSnapshots, + ({ one }) => ({ + platform: one(platforms, { + fields: [performanceSnapshots.platformId], + references: [platforms.id], + }), + }), +); + +// ── Daily Prices ──────────────────────────────────────────────────────────── + +export const dailyPrices = pgTable( + 'daily_prices', + { + id: uuid('id').defaultRandom().primaryKey(), + platformId: varchar('platform_id') + .notNull() + .references(() => platforms.id), + date: date('date').notNull(), + price: numeric('price').notNull(), + isAvailable: boolean('is_available').default(true).notNull(), + minStayNights: integer('min_stay_nights').default(1).notNull(), + syncedAt: timestamptz('synced_at').defaultNow().notNull(), + }, + (t) => [unique('daily_prices_platform_date').on(t.platformId, t.date)], +); + +export const dailyPricesRelations = relations(dailyPrices, ({ one }) => ({ + platform: one(platforms, { + fields: [dailyPrices.platformId], + references: [platforms.id], + }), +})); + +// ── Experiments ───────────────────────────────────────────────────────────── + +export const experiments = pgTable('experiments', { + id: uuid('id').defaultRandom().primaryKey(), + name: varchar('name').notNull(), + hypothesis: text('hypothesis'), + startDate: date('start_date').notNull(), + endDate: date('end_date'), + status: varchar('status').default('active').notNull(), + createdAt: timestamptz('created_at').defaultNow().notNull(), + conclusion: text('conclusion'), +}); + +export const experimentsRelations = relations(experiments, ({ many }) => ({ + priceChanges: many(priceChanges), +})); + +// ── Price Changes ─────────────────────────────────────────────────────────── + +export const priceChanges = pgTable('price_changes', { + id: uuid('id').defaultRandom().primaryKey(), + platformId: varchar('platform_id') + .notNull() + .references(() => platforms.id), + date: date('date').notNull(), + priceBefore: numeric('price_before').notNull(), + priceAfter: numeric('price_after').notNull(), + changedAt: timestamptz('changed_at').defaultNow().notNull(), + changedBy: varchar('changed_by'), + note: text('note'), + experimentId: uuid('experiment_id').references(() => experiments.id), +}); + +export const priceChangesRelations = relations(priceChanges, ({ one }) => ({ + platform: one(platforms, { + fields: [priceChanges.platformId], + references: [platforms.id], + }), + experiment: one(experiments, { + fields: [priceChanges.experimentId], + references: [experiments.id], + }), +})); + +// ── Reservations ──────────────────────────────────────────────────────────── + +export const reservations = pgTable( + 'reservations', + { + id: uuid('id').defaultRandom().primaryKey(), + platformId: varchar('platform_id') + .notNull() + .references(() => platforms.id), + platformReservationId: varchar('platform_reservation_id').notNull(), + guestName: varchar('guest_name').notNull(), + checkIn: date('check_in').notNull(), + checkOut: date('check_out').notNull(), + nights: integer('nights').notNull(), + guestsCount: integer('guests_count'), + nightlyRate: numeric('nightly_rate').notNull(), + cleaningFee: numeric('cleaning_fee'), + platformFee: numeric('platform_fee'), + totalPayout: numeric('total_payout').notNull(), + status: varchar('status').default('confirmed').notNull(), + bookedAt: timestamptz('booked_at'), + syncedAt: timestamptz('synced_at').defaultNow().notNull(), + rawJson: jsonb('raw_json'), + }, + (t) => [ + unique('reservations_platform_res_id').on( + t.platformId, + t.platformReservationId, + ), + ], +); + +export const reservationsRelations = relations(reservations, ({ one }) => ({ + platform: one(platforms, { + fields: [reservations.platformId], + references: [platforms.id], + }), +})); + +// ── Scrape Jobs ───────────────────────────────────────────────────────────── + +export const scrapeJobs = pgTable('scrape_jobs', { + id: uuid('id').defaultRandom().primaryKey(), + platformId: varchar('platform_id') + .notNull() + .references(() => platforms.id), + jobType: varchar('job_type').notNull(), + triggeredBy: varchar('triggered_by').notNull(), + status: varchar('status').default('pending').notNull(), + startedAt: timestamptz('started_at'), + completedAt: timestamptz('completed_at'), + errorMessage: text('error_message'), + rowsCollected: integer('rows_collected'), +}); + +export const scrapeJobsRelations = relations(scrapeJobs, ({ one }) => ({ + platform: one(platforms, { + fields: [scrapeJobs.platformId], + references: [platforms.id], + }), +})); diff --git a/apps/api/src/db/seed-clean.ts b/apps/api/src/db/seed-clean.ts new file mode 100644 index 0000000..76abf8b --- /dev/null +++ b/apps/api/src/db/seed-clean.ts @@ -0,0 +1,44 @@ +import dotenv from 'dotenv'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: resolve(__dirname, '../../../../.env') }); +import { db, sql } from './index.js'; +import { + platforms, + performanceSnapshots, + dailyPrices, + priceChanges, + experiments, + reservations, + scrapeJobs, +} from './schema.js'; + +async function seedClean() { + console.log('Clearing all existing data...'); + + await db.delete(priceChanges); + await db.delete(scrapeJobs); + await db.delete(reservations); + await db.delete(dailyPrices); + await db.delete(performanceSnapshots); + await db.delete(experiments); + await db.delete(platforms); + + console.log('Inserting platform entries...'); + + await db.insert(platforms).values([ + { id: 'airbnb', displayName: 'Airbnb' }, + { id: 'vrbo', displayName: 'VRBO' }, + ]); + + console.log('Clean seed complete. Database ready for real data.'); +} + +seedClean() + .then(() => process.exit(0)) + .catch((err) => { + console.error('Clean seed failed:', err); + process.exit(1); + }); diff --git a/apps/api/src/db/seed-demo.ts b/apps/api/src/db/seed-demo.ts new file mode 100644 index 0000000..eac8ecf --- /dev/null +++ b/apps/api/src/db/seed-demo.ts @@ -0,0 +1,340 @@ +import dotenv from 'dotenv'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: resolve(__dirname, '../../../../.env') }); +import { db, sql } from './index.js'; +import { + platforms, + performanceSnapshots, + dailyPrices, + priceChanges, + experiments, + reservations, + scrapeJobs, +} from './schema.js'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function rand(min: number, max: number): number { + return Math.random() * (max - min) + min; +} + +function randInt(min: number, max: number): number { + return Math.floor(rand(min, max + 1)); +} + +function roundTo(n: number, decimals: number): string { + return n.toFixed(decimals); +} + +function daysAgo(n: number): Date { + const d = new Date(); + d.setDate(d.getDate() - n); + d.setHours(0, 0, 0, 0); + return d; +} + +function formatDate(d: Date): string { + return d.toISOString().split('T')[0]; +} + +function isWeekend(d: Date): boolean { + const day = d.getDay(); + return day === 0 || day === 5 || day === 6; // Fri, Sat, Sun +} + +const GUEST_NAMES = [ + 'Sarah Johnson', + 'Michael Chen', + 'Emily Rodriguez', + 'James Wilson', + 'Amanda Foster', + 'David Kim', + 'Jessica Taylor', + 'Robert Martinez', + 'Lisa Anderson', + 'Chris Thompson', + 'Rachel Lee', + 'Daniel Brown', + 'Maria Garcia', + 'Kevin Wright', + 'Stephanie Davis', + 'Andrew Moore', + 'Nicole Clark', + 'Brandon Hall', +]; + +// ── Seed Logic ────────────────────────────────────────────────────────────── + +async function seed() { + console.log('Clearing existing data...'); + + // Delete in FK-safe order + await db.delete(priceChanges); + await db.delete(scrapeJobs); + await db.delete(reservations); + await db.delete(dailyPrices); + await db.delete(performanceSnapshots); + await db.delete(experiments); + await db.delete(platforms); + + console.log('Inserting platforms...'); + + await db.insert(platforms).values([ + { id: 'airbnb', displayName: 'Airbnb' }, + { id: 'vrbo', displayName: 'VRBO' }, + { id: 'mock', displayName: 'Mock (Dev)' }, + ]); + + // ── Experiments ─────────────────────────────────────────────────────────── + + console.log('Inserting experiments...'); + + const experimentRows = [ + { + name: 'Weekend Premium Pricing', + hypothesis: + 'Increasing weekend rates by 15% will maintain occupancy while boosting revenue.', + startDate: formatDate(daysAgo(60)), + endDate: formatDate(daysAgo(30)), + status: 'completed', + conclusion: + 'Weekend premium of 15% maintained 92% of baseline bookings while increasing weekend revenue by 11%.', + }, + { + name: 'Minimum Stay Reduction', + hypothesis: + 'Reducing minimum stay from 3 nights to 2 nights on weekdays will fill gap nights and increase occupancy.', + startDate: formatDate(daysAgo(30)), + endDate: formatDate(daysAgo(7)), + status: 'completed', + conclusion: + 'Occupancy rose 8% during the test period. Gap nights filled at an acceptable rate. Recommend keeping 2-night min on weekdays.', + }, + { + name: 'Dynamic Last-Minute Discount', + hypothesis: + 'Offering a 10% discount for bookings within 3 days of check-in will reduce vacancy on otherwise-empty nights.', + startDate: formatDate(daysAgo(14)), + status: 'active', + }, + ]; + + const insertedExperiments = await db + .insert(experiments) + .values(experimentRows) + .returning({ id: experiments.id }); + + // ── Performance Snapshots ───────────────────────────────────────────────── + + console.log('Inserting performance snapshots...'); + + const snapshotRows: (typeof performanceSnapshots.$inferInsert)[] = []; + + for (const platformId of ['airbnb', 'vrbo']) { + const platformMultiplier = platformId === 'airbnb' ? 1.0 : 0.6; + + for (let daysBack = 89; daysBack >= 0; daysBack--) { + const d = daysAgo(daysBack); + // Slight upward trend: later days get a small boost + const trendFactor = 1 + (90 - daysBack) * 0.001; + + const viewsSearch = Math.round( + randInt(500, 2000) * platformMultiplier * trendFactor, + ); + const viewsListing = Math.round( + randInt(100, 500) * platformMultiplier * trendFactor, + ); + const occupancy = Math.min( + rand(55, 85) * trendFactor, + 98, + ); + const adr = rand(150, 250) * trendFactor; + const bookingsCount = Math.round( + randInt(2, 8) * platformMultiplier * (occupancy / 70), + ); + const conversionRate = (bookingsCount / viewsListing) * 100; + const revenue = adr * occupancy * 0.3; // rough approximation + + snapshotRows.push({ + platformId, + capturedAt: d.toISOString(), + periodLabel: formatDate(d), + viewsSearch, + viewsListing, + conversionRate: roundTo(conversionRate, 2), + bookingsCount, + occupancyRate: roundTo(occupancy, 1), + avgDailyRate: roundTo(adr, 2), + revenueTotal: roundTo(revenue, 2), + rawJson: { source: 'seed', generated: true }, + }); + } + } + + // Insert in batches + for (let i = 0; i < snapshotRows.length; i += 50) { + await db + .insert(performanceSnapshots) + .values(snapshotRows.slice(i, i + 50)); + } + + // ── Daily Prices ────────────────────────────────────────────────────────── + + console.log('Inserting daily prices...'); + + const priceRows: (typeof dailyPrices.$inferInsert)[] = []; + + for (const platformId of ['airbnb', 'vrbo']) { + for (let daysBack = 89; daysBack >= -30; daysBack--) { + // Include 30 days into the future + const d = daysAgo(daysBack); + const trendFactor = 1 + (90 - daysBack) * 0.0005; + + let basePrice = rand(150, 250) * trendFactor; + if (isWeekend(d)) { + basePrice *= rand(1.15, 1.35); // Weekend premium + } + + priceRows.push({ + platformId, + date: formatDate(d), + price: roundTo(basePrice, 2), + isAvailable: Math.random() > 0.15, // ~85% availability + minStayNights: isWeekend(d) ? 2 : 1, + syncedAt: daysAgo(Math.max(daysBack, 0)).toISOString(), + }); + } + } + + for (let i = 0; i < priceRows.length; i += 50) { + await db.insert(dailyPrices).values(priceRows.slice(i, i + 50)); + } + + // ── Reservations ────────────────────────────────────────────────────────── + + console.log('Inserting reservations...'); + + const reservationRows: (typeof reservations.$inferInsert)[] = []; + let reservationCounter = 1000; + + for (let i = 0; i < 15; i++) { + const platformId = i % 3 === 0 ? 'vrbo' : 'airbnb'; + const checkInDaysAgo = randInt(5, 85); + const nights = randInt(2, 7); + const checkIn = daysAgo(checkInDaysAgo); + const checkOut = daysAgo(checkInDaysAgo - nights); + const nightlyRate = rand(160, 280); + const cleaningFee = randInt(75, 150); + const platformFee = nightlyRate * nights * rand(0.03, 0.05); + const totalPayout = + nightlyRate * nights + cleaningFee - platformFee; + + const bookedDaysBeforeCheckIn = randInt(3, 30); + const bookedAt = daysAgo(checkInDaysAgo + bookedDaysBeforeCheckIn); + + reservationCounter++; + const isPast = checkInDaysAgo - nights > 0; + + reservationRows.push({ + platformId, + platformReservationId: `${platformId.toUpperCase()}-${reservationCounter}`, + guestName: GUEST_NAMES[i % GUEST_NAMES.length], + checkIn: formatDate(checkIn), + checkOut: formatDate(checkOut), + nights, + guestsCount: randInt(1, 6), + nightlyRate: roundTo(nightlyRate, 2), + cleaningFee: cleaningFee.toString(), + platformFee: roundTo(platformFee, 2), + totalPayout: roundTo(totalPayout, 2), + status: isPast ? 'completed' : 'confirmed', + bookedAt: bookedAt.toISOString(), + rawJson: { source: 'seed', generated: true }, + }); + } + + await db.insert(reservations).values(reservationRows); + + // ── Price Changes ───────────────────────────────────────────────────────── + + console.log('Inserting price changes...'); + + const priceChangeRows: (typeof priceChanges.$inferInsert)[] = []; + + // Link some price changes to the first experiment (weekend premium) + for (let i = 0; i < 8; i++) { + const d = daysAgo(randInt(30, 60)); + const before = rand(180, 230); + const after = before * 1.15; + + priceChangeRows.push({ + platformId: i % 2 === 0 ? 'airbnb' : 'vrbo', + date: formatDate(d), + priceBefore: roundTo(before, 2), + priceAfter: roundTo(after, 2), + changedBy: 'experiment', + note: 'Weekend premium pricing experiment', + experimentId: insertedExperiments[0].id, + }); + } + + // A few manual changes + for (let i = 0; i < 4; i++) { + const d = daysAgo(randInt(5, 25)); + const before = rand(170, 250); + const after = before * rand(0.9, 1.1); + + priceChangeRows.push({ + platformId: 'airbnb', + date: formatDate(d), + priceBefore: roundTo(before, 2), + priceAfter: roundTo(after, 2), + changedBy: 'manual', + note: 'Manual price adjustment', + }); + } + + await db.insert(priceChanges).values(priceChangeRows); + + // ── Scrape Jobs ─────────────────────────────────────────────────────────── + + console.log('Inserting scrape jobs...'); + + const scrapeRows: (typeof scrapeJobs.$inferInsert)[] = []; + + for (const platformId of ['airbnb', 'vrbo']) { + for (let i = 0; i < 5; i++) { + const startedDaysAgo = randInt(0, 30); + const started = daysAgo(startedDaysAgo); + const completed = new Date( + started.getTime() + randInt(5, 120) * 1000, + ); + + scrapeRows.push({ + platformId, + jobType: i % 2 === 0 ? 'performance' : 'calendar', + triggeredBy: i === 0 ? 'manual' : 'scheduled', + status: 'completed', + startedAt: started.toISOString(), + completedAt: completed.toISOString(), + rowsCollected: randInt(10, 200), + }); + } + } + + await db.insert(scrapeJobs).values(scrapeRows); + + console.log('Seed complete.'); +} + +seed() + .then(() => { + process.exit(0); + }) + .catch((err) => { + console.error('Seed failed:', err); + process.exit(1); + }); diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts new file mode 100644 index 0000000..eac8ecf --- /dev/null +++ b/apps/api/src/db/seed.ts @@ -0,0 +1,340 @@ +import dotenv from 'dotenv'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: resolve(__dirname, '../../../../.env') }); +import { db, sql } from './index.js'; +import { + platforms, + performanceSnapshots, + dailyPrices, + priceChanges, + experiments, + reservations, + scrapeJobs, +} from './schema.js'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function rand(min: number, max: number): number { + return Math.random() * (max - min) + min; +} + +function randInt(min: number, max: number): number { + return Math.floor(rand(min, max + 1)); +} + +function roundTo(n: number, decimals: number): string { + return n.toFixed(decimals); +} + +function daysAgo(n: number): Date { + const d = new Date(); + d.setDate(d.getDate() - n); + d.setHours(0, 0, 0, 0); + return d; +} + +function formatDate(d: Date): string { + return d.toISOString().split('T')[0]; +} + +function isWeekend(d: Date): boolean { + const day = d.getDay(); + return day === 0 || day === 5 || day === 6; // Fri, Sat, Sun +} + +const GUEST_NAMES = [ + 'Sarah Johnson', + 'Michael Chen', + 'Emily Rodriguez', + 'James Wilson', + 'Amanda Foster', + 'David Kim', + 'Jessica Taylor', + 'Robert Martinez', + 'Lisa Anderson', + 'Chris Thompson', + 'Rachel Lee', + 'Daniel Brown', + 'Maria Garcia', + 'Kevin Wright', + 'Stephanie Davis', + 'Andrew Moore', + 'Nicole Clark', + 'Brandon Hall', +]; + +// ── Seed Logic ────────────────────────────────────────────────────────────── + +async function seed() { + console.log('Clearing existing data...'); + + // Delete in FK-safe order + await db.delete(priceChanges); + await db.delete(scrapeJobs); + await db.delete(reservations); + await db.delete(dailyPrices); + await db.delete(performanceSnapshots); + await db.delete(experiments); + await db.delete(platforms); + + console.log('Inserting platforms...'); + + await db.insert(platforms).values([ + { id: 'airbnb', displayName: 'Airbnb' }, + { id: 'vrbo', displayName: 'VRBO' }, + { id: 'mock', displayName: 'Mock (Dev)' }, + ]); + + // ── Experiments ─────────────────────────────────────────────────────────── + + console.log('Inserting experiments...'); + + const experimentRows = [ + { + name: 'Weekend Premium Pricing', + hypothesis: + 'Increasing weekend rates by 15% will maintain occupancy while boosting revenue.', + startDate: formatDate(daysAgo(60)), + endDate: formatDate(daysAgo(30)), + status: 'completed', + conclusion: + 'Weekend premium of 15% maintained 92% of baseline bookings while increasing weekend revenue by 11%.', + }, + { + name: 'Minimum Stay Reduction', + hypothesis: + 'Reducing minimum stay from 3 nights to 2 nights on weekdays will fill gap nights and increase occupancy.', + startDate: formatDate(daysAgo(30)), + endDate: formatDate(daysAgo(7)), + status: 'completed', + conclusion: + 'Occupancy rose 8% during the test period. Gap nights filled at an acceptable rate. Recommend keeping 2-night min on weekdays.', + }, + { + name: 'Dynamic Last-Minute Discount', + hypothesis: + 'Offering a 10% discount for bookings within 3 days of check-in will reduce vacancy on otherwise-empty nights.', + startDate: formatDate(daysAgo(14)), + status: 'active', + }, + ]; + + const insertedExperiments = await db + .insert(experiments) + .values(experimentRows) + .returning({ id: experiments.id }); + + // ── Performance Snapshots ───────────────────────────────────────────────── + + console.log('Inserting performance snapshots...'); + + const snapshotRows: (typeof performanceSnapshots.$inferInsert)[] = []; + + for (const platformId of ['airbnb', 'vrbo']) { + const platformMultiplier = platformId === 'airbnb' ? 1.0 : 0.6; + + for (let daysBack = 89; daysBack >= 0; daysBack--) { + const d = daysAgo(daysBack); + // Slight upward trend: later days get a small boost + const trendFactor = 1 + (90 - daysBack) * 0.001; + + const viewsSearch = Math.round( + randInt(500, 2000) * platformMultiplier * trendFactor, + ); + const viewsListing = Math.round( + randInt(100, 500) * platformMultiplier * trendFactor, + ); + const occupancy = Math.min( + rand(55, 85) * trendFactor, + 98, + ); + const adr = rand(150, 250) * trendFactor; + const bookingsCount = Math.round( + randInt(2, 8) * platformMultiplier * (occupancy / 70), + ); + const conversionRate = (bookingsCount / viewsListing) * 100; + const revenue = adr * occupancy * 0.3; // rough approximation + + snapshotRows.push({ + platformId, + capturedAt: d.toISOString(), + periodLabel: formatDate(d), + viewsSearch, + viewsListing, + conversionRate: roundTo(conversionRate, 2), + bookingsCount, + occupancyRate: roundTo(occupancy, 1), + avgDailyRate: roundTo(adr, 2), + revenueTotal: roundTo(revenue, 2), + rawJson: { source: 'seed', generated: true }, + }); + } + } + + // Insert in batches + for (let i = 0; i < snapshotRows.length; i += 50) { + await db + .insert(performanceSnapshots) + .values(snapshotRows.slice(i, i + 50)); + } + + // ── Daily Prices ────────────────────────────────────────────────────────── + + console.log('Inserting daily prices...'); + + const priceRows: (typeof dailyPrices.$inferInsert)[] = []; + + for (const platformId of ['airbnb', 'vrbo']) { + for (let daysBack = 89; daysBack >= -30; daysBack--) { + // Include 30 days into the future + const d = daysAgo(daysBack); + const trendFactor = 1 + (90 - daysBack) * 0.0005; + + let basePrice = rand(150, 250) * trendFactor; + if (isWeekend(d)) { + basePrice *= rand(1.15, 1.35); // Weekend premium + } + + priceRows.push({ + platformId, + date: formatDate(d), + price: roundTo(basePrice, 2), + isAvailable: Math.random() > 0.15, // ~85% availability + minStayNights: isWeekend(d) ? 2 : 1, + syncedAt: daysAgo(Math.max(daysBack, 0)).toISOString(), + }); + } + } + + for (let i = 0; i < priceRows.length; i += 50) { + await db.insert(dailyPrices).values(priceRows.slice(i, i + 50)); + } + + // ── Reservations ────────────────────────────────────────────────────────── + + console.log('Inserting reservations...'); + + const reservationRows: (typeof reservations.$inferInsert)[] = []; + let reservationCounter = 1000; + + for (let i = 0; i < 15; i++) { + const platformId = i % 3 === 0 ? 'vrbo' : 'airbnb'; + const checkInDaysAgo = randInt(5, 85); + const nights = randInt(2, 7); + const checkIn = daysAgo(checkInDaysAgo); + const checkOut = daysAgo(checkInDaysAgo - nights); + const nightlyRate = rand(160, 280); + const cleaningFee = randInt(75, 150); + const platformFee = nightlyRate * nights * rand(0.03, 0.05); + const totalPayout = + nightlyRate * nights + cleaningFee - platformFee; + + const bookedDaysBeforeCheckIn = randInt(3, 30); + const bookedAt = daysAgo(checkInDaysAgo + bookedDaysBeforeCheckIn); + + reservationCounter++; + const isPast = checkInDaysAgo - nights > 0; + + reservationRows.push({ + platformId, + platformReservationId: `${platformId.toUpperCase()}-${reservationCounter}`, + guestName: GUEST_NAMES[i % GUEST_NAMES.length], + checkIn: formatDate(checkIn), + checkOut: formatDate(checkOut), + nights, + guestsCount: randInt(1, 6), + nightlyRate: roundTo(nightlyRate, 2), + cleaningFee: cleaningFee.toString(), + platformFee: roundTo(platformFee, 2), + totalPayout: roundTo(totalPayout, 2), + status: isPast ? 'completed' : 'confirmed', + bookedAt: bookedAt.toISOString(), + rawJson: { source: 'seed', generated: true }, + }); + } + + await db.insert(reservations).values(reservationRows); + + // ── Price Changes ───────────────────────────────────────────────────────── + + console.log('Inserting price changes...'); + + const priceChangeRows: (typeof priceChanges.$inferInsert)[] = []; + + // Link some price changes to the first experiment (weekend premium) + for (let i = 0; i < 8; i++) { + const d = daysAgo(randInt(30, 60)); + const before = rand(180, 230); + const after = before * 1.15; + + priceChangeRows.push({ + platformId: i % 2 === 0 ? 'airbnb' : 'vrbo', + date: formatDate(d), + priceBefore: roundTo(before, 2), + priceAfter: roundTo(after, 2), + changedBy: 'experiment', + note: 'Weekend premium pricing experiment', + experimentId: insertedExperiments[0].id, + }); + } + + // A few manual changes + for (let i = 0; i < 4; i++) { + const d = daysAgo(randInt(5, 25)); + const before = rand(170, 250); + const after = before * rand(0.9, 1.1); + + priceChangeRows.push({ + platformId: 'airbnb', + date: formatDate(d), + priceBefore: roundTo(before, 2), + priceAfter: roundTo(after, 2), + changedBy: 'manual', + note: 'Manual price adjustment', + }); + } + + await db.insert(priceChanges).values(priceChangeRows); + + // ── Scrape Jobs ─────────────────────────────────────────────────────────── + + console.log('Inserting scrape jobs...'); + + const scrapeRows: (typeof scrapeJobs.$inferInsert)[] = []; + + for (const platformId of ['airbnb', 'vrbo']) { + for (let i = 0; i < 5; i++) { + const startedDaysAgo = randInt(0, 30); + const started = daysAgo(startedDaysAgo); + const completed = new Date( + started.getTime() + randInt(5, 120) * 1000, + ); + + scrapeRows.push({ + platformId, + jobType: i % 2 === 0 ? 'performance' : 'calendar', + triggeredBy: i === 0 ? 'manual' : 'scheduled', + status: 'completed', + startedAt: started.toISOString(), + completedAt: completed.toISOString(), + rowsCollected: randInt(10, 200), + }); + } + } + + await db.insert(scrapeJobs).values(scrapeRows); + + console.log('Seed complete.'); +} + +seed() + .then(() => { + process.exit(0); + }) + .catch((err) => { + console.error('Seed failed:', err); + process.exit(1); + }); diff --git a/apps/api/src/email/weeklyReport.ts b/apps/api/src/email/weeklyReport.ts new file mode 100644 index 0000000..7d2e73d --- /dev/null +++ b/apps/api/src/email/weeklyReport.ts @@ -0,0 +1,194 @@ +import nodemailer from 'nodemailer'; +import { sql } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { performanceSnapshots, reservations, platforms } from '../db/schema.js'; +import { config } from '../config.js'; + +export interface WeeklyReportData { + generatedAt: string; + thisWeek: WeekMetrics; + lastWeek: WeekMetrics; + platformBreakdown: PlatformMetrics[]; +} + +interface WeekMetrics { + from: string; + to: string; + totalRevenue: string; + reservationCount: number; + avgDailyRate: string; + avgOccupancy: string; +} + +interface PlatformMetrics { + platformId: string; + displayName: string; + revenue: string; + reservations: number; + avgDailyRate: string; +} + +export async function buildWeeklyReport(): Promise { + const now = new Date(); + const thisWeekEnd = new Date(now); + thisWeekEnd.setHours(23, 59, 59, 999); + + const thisWeekStart = new Date(now); + thisWeekStart.setDate(thisWeekStart.getDate() - 7); + thisWeekStart.setHours(0, 0, 0, 0); + + const lastWeekEnd = new Date(thisWeekStart); + lastWeekEnd.setMilliseconds(-1); + const lastWeekStart = new Date(lastWeekEnd); + lastWeekStart.setDate(lastWeekStart.getDate() - 7); + lastWeekStart.setHours(0, 0, 0, 0); + + const [thisWeekMetrics] = await db + .select({ + totalRevenue: sql`coalesce(sum(${reservations.totalPayout}::numeric), 0)`.as('total_revenue'), + reservationCount: sql`count(*)::int`.as('reservation_count'), + avgDailyRate: sql`coalesce(avg(${reservations.nightlyRate}::numeric), 0)`.as('avg_daily_rate'), + }) + .from(reservations) + .where( + sql`${reservations.checkIn}::date >= ${thisWeekStart.toISOString().split('T')[0]} + AND ${reservations.checkIn}::date <= ${thisWeekEnd.toISOString().split('T')[0]} + AND ${reservations.status} = 'confirmed'`, + ); + + const [lastWeekMetrics] = await db + .select({ + totalRevenue: sql`coalesce(sum(${reservations.totalPayout}::numeric), 0)`.as('total_revenue'), + reservationCount: sql`count(*)::int`.as('reservation_count'), + avgDailyRate: sql`coalesce(avg(${reservations.nightlyRate}::numeric), 0)`.as('avg_daily_rate'), + }) + .from(reservations) + .where( + sql`${reservations.checkIn}::date >= ${lastWeekStart.toISOString().split('T')[0]} + AND ${reservations.checkIn}::date <= ${lastWeekEnd.toISOString().split('T')[0]} + AND ${reservations.status} = 'confirmed'`, + ); + + // Get avg occupancy from performance snapshots + const [thisWeekOccupancy] = await db + .select({ + avgOccupancy: sql`coalesce(avg(${performanceSnapshots.occupancyRate}::numeric), 0)`.as('avg_occupancy'), + }) + .from(performanceSnapshots) + .where( + sql`${performanceSnapshots.capturedAt} >= ${thisWeekStart.toISOString()} + AND ${performanceSnapshots.capturedAt} <= ${thisWeekEnd.toISOString()}`, + ); + + const [lastWeekOccupancy] = await db + .select({ + avgOccupancy: sql`coalesce(avg(${performanceSnapshots.occupancyRate}::numeric), 0)`.as('avg_occupancy'), + }) + .from(performanceSnapshots) + .where( + sql`${performanceSnapshots.capturedAt} >= ${lastWeekStart.toISOString()} + AND ${performanceSnapshots.capturedAt} <= ${lastWeekEnd.toISOString()}`, + ); + + // Platform breakdown for this week + const platformBreakdown = await db + .select({ + platformId: reservations.platformId, + displayName: platforms.displayName, + revenue: sql`coalesce(sum(${reservations.totalPayout}::numeric), 0)`.as('revenue'), + reservationCount: sql`count(*)::int`.as('reservations'), + avgDailyRate: sql`coalesce(avg(${reservations.nightlyRate}::numeric), 0)`.as('avg_daily_rate'), + }) + .from(reservations) + .innerJoin(platforms, sql`${reservations.platformId} = ${platforms.id}`) + .where( + sql`${reservations.checkIn}::date >= ${thisWeekStart.toISOString().split('T')[0]} + AND ${reservations.checkIn}::date <= ${thisWeekEnd.toISOString().split('T')[0]} + AND ${reservations.status} = 'confirmed'`, + ) + .groupBy(reservations.platformId, platforms.displayName); + + const fmt = (d: Date) => d.toISOString().split('T')[0]!; + + return { + generatedAt: now.toISOString(), + thisWeek: { + from: fmt(thisWeekStart), + to: fmt(thisWeekEnd), + totalRevenue: thisWeekMetrics?.totalRevenue ?? '0', + reservationCount: thisWeekMetrics?.reservationCount ?? 0, + avgDailyRate: thisWeekMetrics?.avgDailyRate ?? '0', + avgOccupancy: thisWeekOccupancy?.avgOccupancy ?? '0', + }, + lastWeek: { + from: fmt(lastWeekStart), + to: fmt(lastWeekEnd), + totalRevenue: lastWeekMetrics?.totalRevenue ?? '0', + reservationCount: lastWeekMetrics?.reservationCount ?? 0, + avgDailyRate: lastWeekMetrics?.avgDailyRate ?? '0', + avgOccupancy: lastWeekOccupancy?.avgOccupancy ?? '0', + }, + platformBreakdown: platformBreakdown.map((p) => ({ + platformId: p.platformId, + displayName: p.displayName, + revenue: p.revenue, + reservations: p.reservationCount, + avgDailyRate: p.avgDailyRate, + })), + }; +} + +export async function sendWeeklyReport(report: WeeklyReportData): Promise { + if (!config.SMTP_HOST || !config.REPORT_EMAIL_TO) { + console.log('[email] SMTP not configured, skipping send. Report data:', JSON.stringify(report, null, 2)); + return; + } + + const transporter = nodemailer.createTransport({ + host: config.SMTP_HOST, + port: config.SMTP_PORT, + secure: config.SMTP_PORT === 465, + auth: + config.SMTP_USER && config.SMTP_PASS + ? { user: config.SMTP_USER, pass: config.SMTP_PASS } + : undefined, + }); + + const revenueChange = + parseFloat(report.lastWeek.totalRevenue) > 0 + ? ( + ((parseFloat(report.thisWeek.totalRevenue) - parseFloat(report.lastWeek.totalRevenue)) / + parseFloat(report.lastWeek.totalRevenue)) * + 100 + ).toFixed(1) + : 'N/A'; + + const subject = `STR Weekly Report: $${parseFloat(report.thisWeek.totalRevenue).toFixed(0)} revenue (${revenueChange}% WoW)`; + + const body = ` +STR Optimization Manager - Weekly Report +Generated: ${report.generatedAt} + +=== This Week (${report.thisWeek.from} to ${report.thisWeek.to}) === +Revenue: $${parseFloat(report.thisWeek.totalRevenue).toFixed(2)} +Reservations: ${report.thisWeek.reservationCount} +Avg Daily Rate: $${parseFloat(report.thisWeek.avgDailyRate).toFixed(2)} +Avg Occupancy: ${(parseFloat(report.thisWeek.avgOccupancy) * 100).toFixed(1)}% + +=== Last Week (${report.lastWeek.from} to ${report.lastWeek.to}) === +Revenue: $${parseFloat(report.lastWeek.totalRevenue).toFixed(2)} +Reservations: ${report.lastWeek.reservationCount} +Avg Daily Rate: $${parseFloat(report.lastWeek.avgDailyRate).toFixed(2)} +Avg Occupancy: ${(parseFloat(report.lastWeek.avgOccupancy) * 100).toFixed(1)}% + +=== Platform Breakdown (This Week) === +${report.platformBreakdown.map((p) => `${p.displayName}: $${parseFloat(p.revenue).toFixed(2)} (${p.reservations} reservations)`).join('\n')} +`.trim(); + + await transporter.sendMail({ + from: config.SMTP_USER ?? 'noreply@str-manager.local', + to: config.REPORT_EMAIL_TO, + subject, + text: body, + }); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..1b0f674 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,54 @@ +import './config.js'; // loads dotenv first +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import cookie from '@fastify/cookie'; +import authPlugin from './plugins/auth.js'; +import healthRoutes from './routes/health.js'; +import authRoutes from './routes/auth.js'; +import platformRoutes from './routes/platforms.js'; +import performanceRoutes from './routes/performance.js'; +import pricingRoutes from './routes/pricing.js'; +import experimentRoutes from './routes/experiments.js'; +import reservationRoutes from './routes/reservations.js'; +import reportRoutes from './routes/reports.js'; +import { initCronJobs } from './scheduler/cron.js'; + +declare module 'fastify' { + interface FastifyInstance { + authenticate: (request: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise; + } +} + +const fastify = Fastify({ logger: true }); + +async function start() { + // Plugins + await fastify.register(cors, { + origin: 'http://localhost:5173', + credentials: true, + }); + await fastify.register(cookie); + await fastify.register(authPlugin); + + // Routes - health at root, everything else under /api/v1 + await fastify.register(healthRoutes); + await fastify.register(authRoutes, { prefix: '/api/v1' }); + await fastify.register(platformRoutes, { prefix: '/api/v1' }); + await fastify.register(performanceRoutes, { prefix: '/api/v1' }); + await fastify.register(pricingRoutes, { prefix: '/api/v1' }); + await fastify.register(experimentRoutes, { prefix: '/api/v1' }); + await fastify.register(reservationRoutes, { prefix: '/api/v1' }); + await fastify.register(reportRoutes, { prefix: '/api/v1' }); + + // Cron + initCronJobs(); + + // Start + await fastify.listen({ port: 3000, host: '0.0.0.0' }); + console.log('API server running on http://localhost:3000'); +} + +start().catch((err) => { + fastify.log.error(err); + process.exit(1); +}); diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts new file mode 100644 index 0000000..22b76bb --- /dev/null +++ b/apps/api/src/plugins/auth.ts @@ -0,0 +1,41 @@ +import fp from 'fastify-plugin'; +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { config } from '../config.js'; + +declare module 'fastify' { + interface FastifyRequest { + user: { username: string }; + } +} + +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: { username: string }; + user: { username: string }; + } +} + +export default fp(async function authPlugin(fastify: FastifyInstance) { + fastify.register(import('@fastify/jwt'), { + secret: config.JWT_SECRET, + cookie: { + cookieName: 'token', + signed: false, + }, + }); + + fastify.decorate( + 'authenticate', + async function (request: FastifyRequest, reply: FastifyReply) { + try { + await request.jwtVerify(); + } catch { + reply.status(401).send({ error: 'Unauthorized' }); + } + }, + ); +}); + +export function signToken(fastify: FastifyInstance, payload: { username: string }): string { + return fastify.jwt.sign(payload, { expiresIn: '24h' }); +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..3110bb9 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,47 @@ +import { FastifyInstance } from 'fastify'; +import bcrypt from 'bcrypt'; +import { config } from '../config.js'; +import { signToken } from '../plugins/auth.js'; + +export default async function (fastify: FastifyInstance) { + fastify.post<{ + Body: { username: string; password: string }; + }>('/auth/login', async (request, reply) => { + const { username, password } = request.body; + + if (username !== config.APP_USERNAME) { + return reply.status(401).send({ error: 'Invalid credentials' }); + } + + const valid = await bcrypt.compare(password, config.APP_PASSWORD_HASH); + if (!valid) { + return reply.status(401).send({ error: 'Invalid credentials' }); + } + + const token = signToken(fastify, { username }); + + reply + .setCookie('token', token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24, // 24 hours + }) + .send({ ok: true, username }); + }); + + fastify.post('/auth/logout', async (_request, reply) => { + reply + .clearCookie('token', { path: '/' }) + .send({ ok: true }); + }); + + fastify.get( + '/auth/me', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + return reply.send({ username: request.user.username }); + }, + ); +} diff --git a/apps/api/src/routes/experiments.ts b/apps/api/src/routes/experiments.ts new file mode 100644 index 0000000..902a788 --- /dev/null +++ b/apps/api/src/routes/experiments.ts @@ -0,0 +1,145 @@ +import { FastifyInstance } from 'fastify'; +import { eq, and, gte, lte, desc } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { experiments, priceChanges, performanceSnapshots, platforms } from '../db/schema.js'; + +export default async function (fastify: FastifyInstance) { + // List all experiments + fastify.get( + '/experiments', + { preHandler: [fastify.authenticate] }, + async (_request, reply) => { + const rows = await db + .select() + .from(experiments) + .orderBy(desc(experiments.createdAt)); + + return reply.send(rows); + }, + ); + + // Create new experiment + fastify.post<{ + Body: { + name: string; + hypothesis?: string; + startDate: string; + endDate?: string; + }; + }>( + '/experiments', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { name, hypothesis, startDate, endDate } = request.body; + + const [created] = await db + .insert(experiments) + .values({ + name, + hypothesis: hypothesis ?? null, + startDate, + endDate: endDate ?? null, + status: 'active', + }) + .returning(); + + return reply.status(201).send(created); + }, + ); + + // Update experiment + fastify.put<{ + Params: { id: string }; + Body: { + status?: string; + conclusion?: string; + endDate?: string; + name?: string; + hypothesis?: string; + }; + }>( + '/experiments/:id', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { id } = request.params; + const updates: Record = {}; + + if (request.body.status !== undefined) updates.status = request.body.status; + if (request.body.conclusion !== undefined) updates.conclusion = request.body.conclusion; + if (request.body.endDate !== undefined) updates.endDate = request.body.endDate; + if (request.body.name !== undefined) updates.name = request.body.name; + if (request.body.hypothesis !== undefined) updates.hypothesis = request.body.hypothesis; + + if (Object.keys(updates).length === 0) { + return reply.status(400).send({ error: 'No fields to update' }); + } + + const [updated] = await db + .update(experiments) + .set(updates) + .where(eq(experiments.id, id)) + .returning(); + + if (!updated) { + return reply.status(404).send({ error: 'Experiment not found' }); + } + + return reply.send(updated); + }, + ); + + // Analysis: linked price changes + performance data for the experiment date range + fastify.get<{ Params: { id: string } }>( + '/experiments/:id/analysis', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { id } = request.params; + + const [experiment] = await db + .select() + .from(experiments) + .where(eq(experiments.id, id)) + .limit(1); + + if (!experiment) { + return reply.status(404).send({ error: 'Experiment not found' }); + } + + // Get price changes linked to this experiment + const linkedChanges = await db + .select({ + id: priceChanges.id, + platformId: priceChanges.platformId, + date: priceChanges.date, + priceBefore: priceChanges.priceBefore, + priceAfter: priceChanges.priceAfter, + changedAt: priceChanges.changedAt, + platformDisplayName: platforms.displayName, + }) + .from(priceChanges) + .innerJoin(platforms, eq(priceChanges.platformId, platforms.id)) + .where(eq(priceChanges.experimentId, id)) + .orderBy(priceChanges.date); + + // Get performance snapshots within the experiment date range + const perfConditions = [ + gte(performanceSnapshots.capturedAt, new Date(experiment.startDate)), + ]; + if (experiment.endDate) { + perfConditions.push(lte(performanceSnapshots.capturedAt, new Date(experiment.endDate))); + } + + const snapshots = await db + .select() + .from(performanceSnapshots) + .where(and(...perfConditions)) + .orderBy(performanceSnapshots.capturedAt); + + return reply.send({ + experiment, + priceChanges: linkedChanges, + performanceSnapshots: snapshots, + }); + }, + ); +} diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts new file mode 100644 index 0000000..65f5bac --- /dev/null +++ b/apps/api/src/routes/health.ts @@ -0,0 +1,20 @@ +import { FastifyInstance } from 'fastify'; +import { sql } from '../db/index.js'; + +export default async function (fastify: FastifyInstance) { + fastify.get('/health', async (_request, reply) => { + let dbOk = false; + try { + await sql`SELECT 1`; + dbOk = true; + } catch { + // db unreachable + } + + return reply.send({ + status: 'ok', + db: dbOk, + timestamp: new Date().toISOString(), + }); + }); +} diff --git a/apps/api/src/routes/performance.ts b/apps/api/src/routes/performance.ts new file mode 100644 index 0000000..2d9c9d7 --- /dev/null +++ b/apps/api/src/routes/performance.ts @@ -0,0 +1,142 @@ +import { FastifyInstance } from 'fastify'; +import { eq, and, gte, lte, desc, sql } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { performanceSnapshots, platforms } from '../db/schema.js'; + +export default async function (fastify: FastifyInstance) { + // Query snapshots with filters + fastify.get<{ + Querystring: { platform?: string; from?: string; to?: string }; + }>( + '/performance/snapshots', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { platform, from, to } = request.query; + + const conditions = []; + if (platform) conditions.push(eq(performanceSnapshots.platformId, platform)); + if (from) conditions.push(gte(performanceSnapshots.capturedAt, from)); + if (to) conditions.push(lte(performanceSnapshots.capturedAt, to)); + + const rows = await db + .select() + .from(performanceSnapshots) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(performanceSnapshots.capturedAt)) + .limit(200); + + return reply.send(rows); + }, + ); + + // Aggregated summary for the dashboard + fastify.get( + '/performance/summary', + { preHandler: [fastify.authenticate] }, + async (_request, reply) => { + // Get latest snapshot per platform + const latestPerPlatform = await db + .selectDistinctOn([performanceSnapshots.platformId], { + platformId: performanceSnapshots.platformId, + viewsSearch: performanceSnapshots.viewsSearch, + viewsListing: performanceSnapshots.viewsListing, + conversionRate: performanceSnapshots.conversionRate, + bookingsCount: performanceSnapshots.bookingsCount, + occupancyRate: performanceSnapshots.occupancyRate, + avgDailyRate: performanceSnapshots.avgDailyRate, + revenueTotal: performanceSnapshots.revenueTotal, + }) + .from(performanceSnapshots) + .orderBy(performanceSnapshots.platformId, desc(performanceSnapshots.capturedAt)); + + // Aggregate across platforms + let totalOccupancy = 0; + let totalAdr = 0; + let totalRevenue = 0; + let totalViews = 0; + const count = latestPerPlatform.length || 1; + + for (const snap of latestPerPlatform) { + totalOccupancy += Number(snap.occupancyRate || 0); + totalAdr += Number(snap.avgDailyRate || 0); + totalRevenue += Number(snap.revenueTotal || 0); + totalViews += Number(snap.viewsSearch || 0); + } + + // Get data from ~30 days ago for trend calculation + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + const oldSnapshots = await db + .selectDistinctOn([performanceSnapshots.platformId], { + platformId: performanceSnapshots.platformId, + occupancyRate: performanceSnapshots.occupancyRate, + avgDailyRate: performanceSnapshots.avgDailyRate, + revenueTotal: performanceSnapshots.revenueTotal, + viewsSearch: performanceSnapshots.viewsSearch, + }) + .from(performanceSnapshots) + .where(lte(performanceSnapshots.capturedAt, thirtyDaysAgo.toISOString())) + .orderBy(performanceSnapshots.platformId, desc(performanceSnapshots.capturedAt)); + + let oldOccupancy = 0, oldAdr = 0, oldRevenue = 0, oldViews = 0; + for (const snap of oldSnapshots) { + oldOccupancy += Number(snap.occupancyRate || 0); + oldAdr += Number(snap.avgDailyRate || 0); + oldRevenue += Number(snap.revenueTotal || 0); + oldViews += Number(snap.viewsSearch || 0); + } + const oldCount = oldSnapshots.length || 1; + + const calcTrend = (current: number, old: number) => + old > 0 ? (current - old) / old : 0; + + const summary = { + occupancyRate: (totalOccupancy / count) / 100, + occupancyTrend: calcTrend(totalOccupancy / count, oldOccupancy / oldCount), + avgDailyRate: totalAdr / count, + adrTrend: calcTrend(totalAdr / count, oldAdr / oldCount), + revenueMtd: totalRevenue, + revenueTrend: calcTrend(totalRevenue, oldRevenue), + searchViews30d: totalViews, + viewsTrend: calcTrend(totalViews, oldViews), + platforms: latestPerPlatform, + }; + + return reply.send(summary); + }, + ); + + // Time-series data grouped by date for charts + fastify.get<{ + Querystring: { platform?: string; from?: string; to?: string }; + }>( + '/performance/trends', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { platform, from, to } = request.query; + + const conditions = []; + if (platform) conditions.push(eq(performanceSnapshots.platformId, platform)); + if (from) conditions.push(gte(performanceSnapshots.capturedAt, from)); + if (to) conditions.push(lte(performanceSnapshots.capturedAt, to)); + + const rows = await db + .select({ + date: performanceSnapshots.periodLabel, + platformId: performanceSnapshots.platformId, + viewsSearch: performanceSnapshots.viewsSearch, + viewsListing: performanceSnapshots.viewsListing, + conversionRate: performanceSnapshots.conversionRate, + bookingsCount: performanceSnapshots.bookingsCount, + occupancyRate: performanceSnapshots.occupancyRate, + avgDailyRate: performanceSnapshots.avgDailyRate, + revenueTotal: performanceSnapshots.revenueTotal, + }) + .from(performanceSnapshots) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(performanceSnapshots.capturedAt); + + return reply.send(rows); + }, + ); +} diff --git a/apps/api/src/routes/platforms.ts b/apps/api/src/routes/platforms.ts new file mode 100644 index 0000000..e1901ce --- /dev/null +++ b/apps/api/src/routes/platforms.ts @@ -0,0 +1,209 @@ +import { FastifyInstance } from 'fastify'; +import { eq, desc } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { platforms, scrapeJobs, performanceSnapshots } from '../db/schema.js'; +import { encrypt, decrypt } from '../services/encryption.js'; +import { config } from '../config.js'; + +export default async function (fastify: FastifyInstance) { + // List all platforms with latest metrics + fastify.get( + '/platforms', + { preHandler: [fastify.authenticate] }, + async (_request, reply) => { + const rows = await db + .select({ + id: platforms.id, + displayName: platforms.displayName, + hasCredentials: platforms.credentialsEncrypted, + lastScrapeAt: platforms.lastScrapeAt, + isActive: platforms.isActive, + }) + .from(platforms); + + // Get latest snapshot and scrape job per platform + const result = await Promise.all( + rows.map(async (r) => { + const [latestSnapshot] = await db + .select() + .from(performanceSnapshots) + .where(eq(performanceSnapshots.platformId, r.id)) + .orderBy(desc(performanceSnapshots.capturedAt)) + .limit(1); + + const [latestJob] = await db + .select() + .from(scrapeJobs) + .where(eq(scrapeJobs.platformId, r.id)) + .orderBy(desc(scrapeJobs.startedAt)) + .limit(1); + + return { + id: r.id, + name: r.id, + displayName: r.displayName, + hasCredentials: r.hasCredentials !== null, + lastScrapeAt: latestJob?.completedAt || r.lastScrapeAt, + lastScrapeStatus: latestJob?.status === 'completed' ? 'success' : (latestJob?.status || 'unknown'), + isActive: r.isActive, + occupancyRate: latestSnapshot ? Number(latestSnapshot.occupancyRate) / 100 : 0, + avgDailyRate: latestSnapshot ? Number(latestSnapshot.avgDailyRate) : 0, + revenueMtd: latestSnapshot ? Number(latestSnapshot.revenueTotal) : 0, + searchViews30d: latestSnapshot?.viewsSearch || 0, + }; + }), + ); + + return reply.send(result); + }, + ); + + // Update encrypted credentials + fastify.put<{ + Params: { id: string }; + Body: Record; + }>( + '/platforms/:id/credentials', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { id } = request.params; + const credentials = request.body; + + const encrypted = encrypt(JSON.stringify(credentials)); + + const updated = await db + .update(platforms) + .set({ credentialsEncrypted: encrypted }) + .where(eq(platforms.id, id)) + .returning(); + + if (updated.length === 0) { + return reply.status(404).send({ error: 'Platform not found' }); + } + + return reply.send({ ok: true }); + }, + ); + + // Test platform credentials via scraper + fastify.post<{ Params: { id: string } }>( + '/platforms/:id/test', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { id } = request.params; + + try { + const res = await fetch(`${config.SCRAPER_URL}/platforms/${id}/test`, { + method: 'POST', + }); + const data = await res.json(); + return reply.status(res.status).send(data); + } catch { + return reply.status(502).send({ error: 'Scraper service unavailable' }); + } + }, + ); + + // Trigger scrape job + fastify.post<{ Params: { id: string } }>( + '/platforms/:id/scrape', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { id } = request.params; + + try { + const res = await fetch(`${config.SCRAPER_URL}/jobs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ platformId: id, jobType: 'full', triggeredBy: 'manual' }), + }); + const data = await res.json(); + return reply.status(res.status).send(data); + } catch { + return reply.status(502).send({ error: 'Scraper service unavailable' }); + } + }, + ); + + // Login to platform via scraper (launches browser for MFA) + fastify.post<{ + Params: { id: string }; + Body: { email?: string; password?: string }; + }>( + '/platforms/:id/login', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { id } = request.params; + let { email, password } = request.body || {}; + + // If no credentials in body, pull from DB + if (!email || !password) { + const [platform] = await db + .select({ credentialsEncrypted: platforms.credentialsEncrypted }) + .from(platforms) + .where(eq(platforms.id, id)); + + if (platform?.credentialsEncrypted) { + try { + const saved = JSON.parse(decrypt(platform.credentialsEncrypted)); + email = email || saved.email; + password = password || saved.password; + } catch (err) { + return reply.status(500).send({ error: 'Failed to decrypt stored credentials' }); + } + } + } + + if (!email || !password) { + return reply.status(400).send({ error: 'No credentials provided and none saved for this platform. Save credentials first.' }); + } + + try { + const res = await fetch(`${config.SCRAPER_URL}/platforms/${id}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const data = await res.json(); + return reply.status(res.status).send(data); + } catch { + return reply.status(502).send({ error: 'Scraper service unavailable' }); + } + }, + ); + + // Check platform session status + fastify.get<{ Params: { id: string } }>( + '/platforms/:id/session', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { id } = request.params; + + try { + const res = await fetch(`${config.SCRAPER_URL}/platforms/${id}/session`); + const data = await res.json(); + return reply.status(res.status).send(data); + } catch { + return reply.status(502).send({ error: 'Scraper service unavailable' }); + } + }, + ); + + // List recent scrape jobs for a platform + fastify.get<{ Params: { id: string } }>( + '/platforms/:id/scrape-jobs', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { id } = request.params; + + const jobs = await db + .select() + .from(scrapeJobs) + .where(eq(scrapeJobs.platformId, id)) + .orderBy(desc(scrapeJobs.startedAt)) + .limit(20); + + return reply.send(jobs); + }, + ); +} diff --git a/apps/api/src/routes/pricing.ts b/apps/api/src/routes/pricing.ts new file mode 100644 index 0000000..c521764 --- /dev/null +++ b/apps/api/src/routes/pricing.ts @@ -0,0 +1,185 @@ +import { FastifyInstance } from 'fastify'; +import { eq, and, gte, lte, desc } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { dailyPrices, priceChanges, platforms } from '../db/schema.js'; +import { config } from '../config.js'; + +interface PriceChangeItem { + date: string; + price: number; + platformIds: string[]; +} + +export default async function (fastify: FastifyInstance) { + // Get daily prices calendar + fastify.get<{ + Querystring: { platform?: string; from?: string; to?: string }; + }>( + '/pricing/calendar', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { platform, from, to } = request.query; + + const conditions = []; + if (platform) conditions.push(eq(dailyPrices.platformId, platform)); + if (from) conditions.push(gte(dailyPrices.date, from)); + if (to) conditions.push(lte(dailyPrices.date, to)); + + const rows = await db + .select() + .from(dailyPrices) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(dailyPrices.date); + + return reply.send(rows); + }, + ); + + // Preview price changes - returns diff with previewToken + fastify.post<{ + Body: { changes: PriceChangeItem[] }; + }>( + '/pricing/preview', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { changes } = request.body; + + // Build the diff: fetch current prices for the affected dates/platforms + const diff = []; + for (const change of changes) { + for (const platformId of change.platformIds) { + const current = await db + .select() + .from(dailyPrices) + .where( + and( + eq(dailyPrices.platformId, platformId), + eq(dailyPrices.date, change.date), + ), + ) + .limit(1); + + diff.push({ + platformId, + date: change.date, + currentPrice: current[0]?.price ?? null, + newPrice: change.price.toString(), + }); + } + } + + // Issue a short-lived preview token + const previewToken = fastify.jwt.sign( + { changes, diff, type: 'price-preview' }, + { expiresIn: '5m' }, + ); + + return reply.send({ diff, previewToken }); + }, + ); + + // Apply previewed price changes + fastify.post<{ + Body: { previewToken: string; note?: string; experimentId?: string }; + }>( + '/pricing/apply', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { previewToken, note, experimentId } = request.body; + + // Verify the preview token + let payload: { changes: PriceChangeItem[]; diff: Array<{ platformId: string; date: string; currentPrice: string | null; newPrice: string }> }; + try { + payload = fastify.jwt.verify(previewToken); + } catch { + return reply.status(400).send({ error: 'Invalid or expired preview token' }); + } + + // Forward price updates to scraper service + try { + const res = await fetch(`${config.SCRAPER_URL}/api/pricing/apply`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ changes: payload.changes }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return reply.status(res.status).send({ error: 'Scraper failed to apply prices', detail: err }); + } + } catch { + return reply.status(502).send({ error: 'Scraper service unavailable' }); + } + + // Record price changes in DB + const changeRecords = payload.diff.map((d) => ({ + platformId: d.platformId, + date: d.date, + priceBefore: d.currentPrice ?? '0', + priceAfter: d.newPrice, + changedBy: request.user.username, + note: note ?? null, + experimentId: experimentId ?? null, + })); + + if (changeRecords.length > 0) { + await db.insert(priceChanges).values(changeRecords); + } + + // Update daily_prices table + for (const d of payload.diff) { + await db + .insert(dailyPrices) + .values({ + platformId: d.platformId, + date: d.date, + price: d.newPrice, + syncedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [dailyPrices.platformId, dailyPrices.date], + set: { price: d.newPrice, syncedAt: new Date() }, + }); + } + + return reply.send({ ok: true, applied: changeRecords.length }); + }, + ); + + // Price change log + fastify.get<{ + Querystring: { platform?: string; from?: string; to?: string }; + }>( + '/pricing/changes', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { platform, from, to } = request.query; + + const conditions = []; + if (platform) conditions.push(eq(priceChanges.platformId, platform)); + if (from) conditions.push(gte(priceChanges.date, from)); + if (to) conditions.push(lte(priceChanges.date, to)); + + const rows = await db + .select({ + id: priceChanges.id, + platformId: priceChanges.platformId, + date: priceChanges.date, + priceBefore: priceChanges.priceBefore, + priceAfter: priceChanges.priceAfter, + changedAt: priceChanges.changedAt, + changedBy: priceChanges.changedBy, + note: priceChanges.note, + experimentId: priceChanges.experimentId, + platformDisplayName: platforms.displayName, + }) + .from(priceChanges) + .innerJoin(platforms, eq(priceChanges.platformId, platforms.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(priceChanges.changedAt)) + .limit(200); + + return reply.send(rows); + }, + ); +} diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts new file mode 100644 index 0000000..4a2ea26 --- /dev/null +++ b/apps/api/src/routes/reports.ts @@ -0,0 +1,30 @@ +import { FastifyInstance } from 'fastify'; +import { buildWeeklyReport, sendWeeklyReport } from '../email/weeklyReport.js'; + +export default async function (fastify: FastifyInstance) { + // Send weekly report email + fastify.post( + '/reports/weekly/send', + { preHandler: [fastify.authenticate] }, + async (_request, reply) => { + try { + const report = await buildWeeklyReport(); + await sendWeeklyReport(report); + return reply.send({ ok: true, message: 'Weekly report sent' }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return reply.status(500).send({ error: 'Failed to send report', detail: message }); + } + }, + ); + + // Preview weekly report as JSON + fastify.get( + '/reports/weekly/preview', + { preHandler: [fastify.authenticate] }, + async (_request, reply) => { + const report = await buildWeeklyReport(); + return reply.send(report); + }, + ); +} diff --git a/apps/api/src/routes/reservations.ts b/apps/api/src/routes/reservations.ts new file mode 100644 index 0000000..662c19d --- /dev/null +++ b/apps/api/src/routes/reservations.ts @@ -0,0 +1,75 @@ +import { FastifyInstance } from 'fastify'; +import { eq, and, gte, lte, desc, sql } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { reservations, platforms } from '../db/schema.js'; + +export default async function (fastify: FastifyInstance) { + // List reservations with filters + fastify.get<{ + Querystring: { platform?: string; status?: string; from?: string; to?: string }; + }>( + '/reservations', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const { platform, status, from, to } = request.query; + + const conditions = []; + if (platform) conditions.push(eq(reservations.platformId, platform)); + if (status) conditions.push(eq(reservations.status, status)); + if (from) conditions.push(gte(reservations.checkIn, from)); + if (to) conditions.push(lte(reservations.checkIn, to)); + + const rows = await db + .select({ + id: reservations.id, + platformId: reservations.platformId, + platformReservationId: reservations.platformReservationId, + guestName: reservations.guestName, + checkIn: reservations.checkIn, + checkOut: reservations.checkOut, + nights: reservations.nights, + guestsCount: reservations.guestsCount, + nightlyRate: reservations.nightlyRate, + cleaningFee: reservations.cleaningFee, + platformFee: reservations.platformFee, + totalPayout: reservations.totalPayout, + status: reservations.status, + bookedAt: reservations.bookedAt, + platformDisplayName: platforms.displayName, + }) + .from(reservations) + .innerJoin(platforms, eq(reservations.platformId, platforms.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(reservations.checkIn)) + .limit(200); + + return reply.send(rows); + }, + ); + + // Monthly revenue and occupancy aggregation + fastify.get( + '/reservations/summary', + { preHandler: [fastify.authenticate] }, + async (_request, reply) => { + const rows = await db + .select({ + month: sql`to_char(${reservations.checkIn}::date, 'YYYY-MM')`.as('month'), + platformId: reservations.platformId, + totalRevenue: sql`sum(${reservations.totalPayout}::numeric)`.as('total_revenue'), + reservationCount: sql`count(*)::int`.as('reservation_count'), + totalNights: sql`sum(${reservations.nights})::int`.as('total_nights'), + avgNightlyRate: sql`avg(${reservations.nightlyRate}::numeric)`.as('avg_nightly_rate'), + }) + .from(reservations) + .where(eq(reservations.status, 'confirmed')) + .groupBy( + sql`to_char(${reservations.checkIn}::date, 'YYYY-MM')`, + reservations.platformId, + ) + .orderBy(sql`to_char(${reservations.checkIn}::date, 'YYYY-MM')`); + + return reply.send(rows); + }, + ); +} diff --git a/apps/api/src/scheduler/cron.ts b/apps/api/src/scheduler/cron.ts new file mode 100644 index 0000000..29db097 --- /dev/null +++ b/apps/api/src/scheduler/cron.ts @@ -0,0 +1,58 @@ +import cron from 'node-cron'; +import { eq } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { platforms } from '../db/schema.js'; +import { config } from '../config.js'; +import { buildWeeklyReport, sendWeeklyReport } from '../email/weeklyReport.js'; + +async function triggerDailyScrape() { + console.log('[cron] Starting daily scrape for all active platforms'); + + const activePlatforms = await db + .select({ id: platforms.id, displayName: platforms.displayName }) + .from(platforms) + .where(eq(platforms.isActive, true)); + + for (const platform of activePlatforms) { + try { + const res = await fetch(`${config.SCRAPER_URL}/api/platforms/${platform.id}/scrape`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ triggeredBy: 'cron' }), + }); + + if (res.ok) { + console.log(`[cron] Scrape triggered for ${platform.displayName}`); + } else { + console.error(`[cron] Scrape failed for ${platform.displayName}: ${res.status}`); + } + } catch (err) { + console.error(`[cron] Scrape error for ${platform.displayName}:`, err); + } + } +} + +async function triggerWeeklyReport() { + console.log('[cron] Building and sending weekly report'); + try { + const report = await buildWeeklyReport(); + await sendWeeklyReport(report); + console.log('[cron] Weekly report sent successfully'); + } catch (err) { + console.error('[cron] Weekly report failed:', err); + } +} + +export function initCronJobs() { + // Daily scrape at 3:00 AM + cron.schedule('0 3 * * *', () => { + triggerDailyScrape().catch(console.error); + }); + + // Weekly report Monday at 8:00 AM + cron.schedule('0 8 * * 1', () => { + triggerWeeklyReport().catch(console.error); + }); + + console.log('[cron] Scheduled jobs initialized: daily scrape (3am), weekly report (Mon 8am)'); +} diff --git a/apps/api/src/services/encryption.ts b/apps/api/src/services/encryption.ts new file mode 100644 index 0000000..1c29591 --- /dev/null +++ b/apps/api/src/services/encryption.ts @@ -0,0 +1,45 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; +import { config } from '../config.js'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; +const AUTH_TAG_LENGTH = 16; + +function getKey(): Buffer { + const key = Buffer.from(config.ENCRYPTION_KEY, 'hex'); + if (key.length !== 32) { + throw new Error('ENCRYPTION_KEY must be 32 bytes (64 hex characters)'); + } + return key; +} + +export function encrypt(plaintext: string): string { + const key = getKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(plaintext, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + const authTag = cipher.getAuthTag(); + + // Format: iv:authTag:ciphertext (all hex) + return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; +} + +export function decrypt(encryptedPayload: string): string { + const key = getKey(); + const [ivHex, authTagHex, ciphertext] = encryptedPayload.split(':'); + + if (!ivHex || !authTagHex || !ciphertext) { + throw new Error('Invalid encrypted payload format'); + } + + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(ciphertext, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..792172f --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/apps/frontend/index.html b/apps/frontend/index.html new file mode 100644 index 0000000..f4f6d44 --- /dev/null +++ b/apps/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + STR Optimization Manager + + + + + +
+ + + diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 0000000..c53f455 --- /dev/null +++ b/apps/frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "@str/frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 5173", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^7.1.0", + "recharts": "^2.15.0", + "@tanstack/react-query": "^5.62.0", + "clsx": "^2.1.0", + "tailwind-merge": "^2.6.0", + "class-variance-authority": "^0.7.1", + "lucide-react": "^0.468.0", + "date-fns": "^4.1.0", + "@radix-ui/react-dialog": "^1.1.0", + "@radix-ui/react-popover": "^1.1.0", + "@radix-ui/react-select": "^2.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-dropdown-menu": "^2.1.0", + "@radix-ui/react-toast": "^1.2.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/apps/frontend/postcss.config.js b/apps/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/apps/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx new file mode 100644 index 0000000..4156708 --- /dev/null +++ b/apps/frontend/src/App.tsx @@ -0,0 +1,42 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuth } from '@/hooks/useAuth'; +import Shell from '@/components/layout/Shell'; +import Login from '@/pages/Login'; +import Dashboard from '@/pages/Dashboard'; +import Performance from '@/pages/Performance'; +import Pricing from '@/pages/Pricing'; +import Experiments from '@/pages/Experiments'; +import Reservations from '@/pages/Reservations'; +import SettingsPage from '@/pages/Settings'; + +function App() { + const { isAuthenticated, isLoading, login, logout } = useAuth(); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ); +} + +export default App; diff --git a/apps/frontend/src/components/layout/Shell.tsx b/apps/frontend/src/components/layout/Shell.tsx new file mode 100644 index 0000000..a452eed --- /dev/null +++ b/apps/frontend/src/components/layout/Shell.tsx @@ -0,0 +1,138 @@ +import { Outlet, NavLink, useLocation } from 'react-router-dom'; +import { + LayoutDashboard, + BarChart3, + DollarSign, + FlaskConical, + CalendarCheck, + Settings, + LogOut, + Menu, + X, +} from 'lucide-react'; +import { useState } from 'react'; +import { cn } from '@/lib/utils'; + +const navItems = [ + { path: '/', icon: LayoutDashboard, label: 'Dashboard' }, + { path: '/performance', icon: BarChart3, label: 'Performance' }, + { path: '/pricing', icon: DollarSign, label: 'Pricing' }, + { path: '/experiments', icon: FlaskConical, label: 'Experiments' }, + { path: '/reservations', icon: CalendarCheck, label: 'Reservations' }, + { path: '/settings', icon: Settings, label: 'Settings' }, +]; + +export default function Shell({ onLogout }: { onLogout: () => void }) { + const [mobileOpen, setMobileOpen] = useState(false); + const location = useLocation(); + + return ( +
+ {/* Desktop Sidebar */} + + + {/* Mobile Header */} +
+
+

STR OPTIMIZER

+ +
+ + {/* Mobile Menu Overlay */} + {mobileOpen && ( +
+ +
+ )} + + {/* Main Content */} +
+ +
+ + {/* Mobile Bottom Nav */} + +
+
+ ); +} diff --git a/apps/frontend/src/hooks/useAuth.ts b/apps/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..3574567 --- /dev/null +++ b/apps/frontend/src/hooks/useAuth.ts @@ -0,0 +1,47 @@ +import { useState, useEffect, useCallback } from 'react'; +import { api, ApiError } from '@/lib/api'; + +interface AuthState { + isAuthenticated: boolean; + username: string | null; + isLoading: boolean; +} + +export function useAuth() { + const [state, setState] = useState({ + isAuthenticated: false, + username: null, + isLoading: true, + }); + + const checkSession = useCallback(async () => { + try { + const data = await api.me(); + setState({ isAuthenticated: true, username: data.username, isLoading: false }); + } catch { + setState({ isAuthenticated: false, username: null, isLoading: false }); + } + }, []); + + useEffect(() => { + checkSession(); + }, [checkSession]); + + const login = async (username: string, password: string) => { + try { + await api.login(username, password); + setState({ isAuthenticated: true, username, isLoading: false }); + return true; + } catch (err) { + if (err instanceof ApiError) throw err; + throw new Error('Login failed'); + } + }; + + const logout = async () => { + await api.logout(); + setState({ isAuthenticated: false, username: null, isLoading: false }); + }; + + return { ...state, login, logout, checkSession }; +} diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css new file mode 100644 index 0000000..934212c --- /dev/null +++ b/apps/frontend/src/index.css @@ -0,0 +1,62 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: #0a0a0a; + --surface: #141414; + --border: #262626; + --accent: #22c55e; + --warning: #f59e0b; + --danger: #ef4444; + --text-primary: #fafafa; + --text-muted: #737373; + } + + * { + border-color: var(--border); + } + + body { + background-color: var(--background); + color: var(--text-primary); + font-family: 'DM Sans', system-ui, sans-serif; + } +} + +@layer utilities { + .font-mono-data { + font-family: 'JetBrains Mono', 'IBM Plex Mono', monospace; + font-variant-numeric: tabular-nums; + } +} + +/* Skeleton loading animation */ +@keyframes skeleton-pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 0.1; } +} + +.animate-skeleton { + animation: skeleton-pulse 2s ease-in-out infinite; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: var(--background); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts new file mode 100644 index 0000000..48eccfe --- /dev/null +++ b/apps/frontend/src/lib/api.ts @@ -0,0 +1,119 @@ +const BASE_URL = '/api/v1'; + +class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + this.name = 'ApiError'; + } +} + +async function request(path: string, options: RequestInit = {}): Promise { + const res = await fetch(`${BASE_URL}${path}`, { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + ...options, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({ message: res.statusText })); + throw new ApiError(res.status, body.message || res.statusText); + } + + if (res.status === 204) return undefined as T; + return res.json(); +} + +export const api = { + // Auth + login: (username: string, password: string) => + request<{ success: boolean }>('/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }), + logout: () => request('/auth/logout', { method: 'POST' }), + me: () => request<{ username: string }>('/auth/me'), + + // Platforms + getPlatforms: () => request('/platforms'), + updateCredentials: (id: string, credentials: { email: string; password: string }) => + request(`/platforms/${id}/credentials`, { + method: 'PUT', + body: JSON.stringify(credentials), + }), + testPlatform: (id: string) => + request(`/platforms/${id}/test`, { method: 'POST' }), + loginPlatform: (id: string, credentials?: { email: string; password: string }) => + request(`/platforms/${id}/login`, { + method: 'POST', + body: JSON.stringify(credentials || {}), + }), + checkSession: (id: string) => + request(`/platforms/${id}/session`), + triggerScrape: (id: string) => + request(`/platforms/${id}/scrape`, { method: 'POST' }), + getScrapeJobs: (id: string) => request(`/platforms/${id}/scrape-jobs`), + + // Performance + getSnapshots: (params?: { platform?: string; from?: string; to?: string }) => { + const qs = new URLSearchParams(params as Record).toString(); + return request(`/performance/snapshots${qs ? `?${qs}` : ''}`); + }, + getPerformanceSummary: () => request('/performance/summary'), + getPerformanceTrends: (params?: { platform?: string; from?: string; to?: string }) => { + const qs = new URLSearchParams(params as Record).toString(); + return request(`/performance/trends${qs ? `?${qs}` : ''}`); + }, + + // Pricing + getPricingCalendar: (params?: { platform?: string; from?: string; to?: string }) => { + const qs = new URLSearchParams(params as Record).toString(); + return request(`/pricing/calendar${qs ? `?${qs}` : ''}`); + }, + previewPriceChanges: (changes: any[]) => + request('/pricing/preview', { + method: 'POST', + body: JSON.stringify({ changes }), + }), + applyPriceChanges: (previewToken: string) => + request('/pricing/apply', { + method: 'POST', + body: JSON.stringify({ previewToken }), + }), + getPriceChanges: (params?: Record) => { + const qs = new URLSearchParams(params).toString(); + return request(`/pricing/changes${qs ? `?${qs}` : ''}`); + }, + + // Experiments + getExperiments: () => request('/experiments'), + createExperiment: (data: any) => + request('/experiments', { + method: 'POST', + body: JSON.stringify(data), + }), + updateExperiment: (id: string, data: any) => + request(`/experiments/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + getExperimentAnalysis: (id: string) => request(`/experiments/${id}/analysis`), + + // Reservations + getReservations: (params?: Record) => { + const qs = new URLSearchParams(params).toString(); + return request(`/reservations${qs ? `?${qs}` : ''}`); + }, + getReservationSummary: () => request('/reservations/summary'), + + // Reports + sendWeeklyReport: () => request('/reports/weekly/send', { method: 'POST' }), + previewWeeklyReport: () => request('/reports/weekly/preview'), + + // Health + health: () => request('/health'), +}; + +export { ApiError }; diff --git a/apps/frontend/src/lib/constants.ts b/apps/frontend/src/lib/constants.ts new file mode 100644 index 0000000..063b0b8 --- /dev/null +++ b/apps/frontend/src/lib/constants.ts @@ -0,0 +1,27 @@ +export const CHART_COLORS = { + primary: '#22c55e', + secondary: '#3b82f6', + tertiary: '#a855f7', + quaternary: '#f59e0b', + airbnb: '#ff5a5f', + vrbo: '#3b5998', +} as const; + +export const DATE_PRESETS = [ + { label: '7D', days: 7 }, + { label: '30D', days: 30 }, + { label: '90D', days: 90 }, + { label: 'YTD', days: -1 }, // special: calculate from Jan 1 +] as const; + +export const PLATFORM_LABELS: Record = { + airbnb: 'Airbnb', + vrbo: 'VRBO', + mock: 'Mock', +}; + +export const PLATFORM_COLORS: Record = { + airbnb: '#ff5a5f', + vrbo: '#3b5998', + mock: '#22c55e', +}; diff --git a/apps/frontend/src/lib/utils.ts b/apps/frontend/src/lib/utils.ts new file mode 100644 index 0000000..bdd0db1 --- /dev/null +++ b/apps/frontend/src/lib/utils.ts @@ -0,0 +1,52 @@ +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): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +} + +export function formatPercent(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +export function formatNumber(value: number): string { + return new Intl.NumberFormat('en-US').format(value); +} + +export function formatDate(date: string | Date): string { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(date)); +} + +export function formatDateShort(date: string | Date): string { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + }).format(new Date(date)); +} + +export function getRelativeTime(date: string | Date): string { + const now = new Date(); + const then = new Date(date); + const diffMs = now.getTime() - then.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + return `${diffDays}d ago`; +} diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx new file mode 100644 index 0000000..252d276 --- /dev/null +++ b/apps/frontend/src/main.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import App from './App'; +import './index.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + }, + }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + , +); diff --git a/apps/frontend/src/pages/Dashboard.tsx b/apps/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..deebfe6 --- /dev/null +++ b/apps/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,306 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { LineChart, Line, ResponsiveContainer } from 'recharts'; +import { + TrendingUp, + TrendingDown, + Play, + Loader2, + CheckCircle2, + XCircle, + Eye, + DollarSign, + Percent, + CalendarCheck, +} from 'lucide-react'; +import { cn, formatCurrency, formatPercent, formatDateShort, getRelativeTime } from '@/lib/utils'; +import { api } from '@/lib/api'; +import { CHART_COLORS, PLATFORM_LABELS, PLATFORM_COLORS } from '@/lib/constants'; + +// --------------------------------------------------------------------------- +// Mock / fallback data +// --------------------------------------------------------------------------- + +const MOCK_SUMMARY = { + occupancyRate: 0.78, + occupancyTrend: 0.04, + avgDailyRate: 189, + adrTrend: 0.06, + revenueMtd: 4720, + revenueTrend: 0.12, + searchViews30d: 1843, + viewsTrend: -0.03, +}; + +const MOCK_PLATFORMS = [ + { + id: 'airbnb', + name: 'airbnb', + enabled: true, + lastScrapeAt: new Date(Date.now() - 3600000 * 2).toISOString(), + lastScrapeStatus: 'success', + occupancyRate: 0.82, + avgDailyRate: 195, + revenueMtd: 2960, + searchViews30d: 1120, + }, + { + id: 'vrbo', + name: 'vrbo', + enabled: true, + lastScrapeAt: new Date(Date.now() - 3600000 * 5).toISOString(), + lastScrapeStatus: 'success', + occupancyRate: 0.71, + avgDailyRate: 178, + revenueMtd: 1760, + searchViews30d: 723, + }, +]; + +const MOCK_RESERVATIONS = [ + { id: '1', guestName: 'Sarah M.', platform: 'airbnb', checkIn: '2026-03-25', checkOut: '2026-03-28', payout: 585 }, + { id: '2', guestName: 'James T.', platform: 'vrbo', checkIn: '2026-03-20', checkOut: '2026-03-23', payout: 534 }, + { id: '3', guestName: 'Emily R.', platform: 'airbnb', checkIn: '2026-03-15', checkOut: '2026-03-18', payout: 612 }, + { id: '4', guestName: 'David L.', platform: 'airbnb', checkIn: '2026-03-10', checkOut: '2026-03-14', payout: 780 }, + { id: '5', guestName: 'Lisa K.', platform: 'vrbo', checkIn: '2026-03-06', checkOut: '2026-03-09', payout: 498 }, +]; + +function generateSparklineData() { + const data = []; + let value = 3; + for (let i = 0; i < 90; i++) { + value = Math.max(0, value + (Math.random() - 0.45) * 2); + data.push({ day: i, bookings: Math.round(value) }); + } + return data; +} + +const MOCK_SPARKLINE = generateSparklineData(); + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +interface KpiCardProps { + label: string; + value: string; + trend: number; + icon: React.ElementType; +} + +function KpiCard({ label, value, trend, icon: Icon }: KpiCardProps) { + const isPositive = trend >= 0; + return ( +
+
+ {label} + +
+
{value}
+
+ {isPositive ? : } + {isPositive ? '+' : ''}{(trend * 100).toFixed(1)}% +
+
+ ); +} + +function PlatformCard({ platform }: { platform: any }) { + const queryClient = useQueryClient(); + const scrapeMutation = useMutation({ + mutationFn: () => api.triggerScrape(platform.id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['platforms'] }); + }, + }); + + const color = PLATFORM_COLORS[platform.name] || CHART_COLORS.primary; + const label = PLATFORM_LABELS[platform.name] || platform.name; + const isSuccess = platform.lastScrapeStatus === 'success'; + + return ( +
+
+
+
+ {label} +
+
+ {isSuccess ? : } + {platform.lastScrapeAt ? getRelativeTime(platform.lastScrapeAt) : 'never'} +
+
+ +
+
+ Occupancy +
{formatPercent(platform.occupancyRate ?? 0)}
+
+
+ ADR +
{formatCurrency(platform.avgDailyRate ?? 0)}
+
+
+ Revenue MTD +
{formatCurrency(platform.revenueMtd ?? 0)}
+
+
+ Views (30d) +
{(platform.searchViews30d ?? 0).toLocaleString()}
+
+
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// Dashboard +// --------------------------------------------------------------------------- + +export default function Dashboard() { + const summaryQuery = useQuery({ + queryKey: ['performance-summary'], + queryFn: () => api.getPerformanceSummary(), + retry: false, + }); + + const reservationsQuery = useQuery({ + queryKey: ['reservations'], + queryFn: () => api.getReservations(), + retry: false, + }); + + const platformsQuery = useQuery({ + queryKey: ['platforms'], + queryFn: () => api.getPlatforms(), + retry: false, + }); + + const summary = summaryQuery.data ?? MOCK_SUMMARY; + const reservations = (reservationsQuery.data ?? MOCK_RESERVATIONS).slice(0, 5); + const platforms = platformsQuery.data ?? MOCK_PLATFORMS; + + return ( +
+ {/* Page header */} +
+

Dashboard

+

Overview of your short-term rental performance

+
+ + {/* KPI Cards */} +
+ + + + +
+ + {/* Platform comparison + Sparkline row */} +
+ {/* Platform cards */} +
+ {platforms.map((p: any) => ( + + ))} +
+ + {/* Booking sparkline */} +
+ Bookings (90 days) +
+ + + + + +
+
+
+ + {/* Recent reservations */} +
+
+ Recent Reservations +
+
+ + + + + + + + + + + {reservations.map((r: any) => { + const color = PLATFORM_COLORS[r.platformId || r.platform] || CHART_COLORS.primary; + const label = PLATFORM_LABELS[r.platformId || r.platform] || r.platformId || r.platform; + return ( + + + + + + + ); + })} + +
GuestPlatformDatesPayout
{r.guestName} + + + {label} + + + {formatDateShort(r.checkIn)} - {formatDateShort(r.checkOut)} + + {formatCurrency(Number(r.totalPayout || r.payout || 0))} +
+
+
+
+ ); +} diff --git a/apps/frontend/src/pages/Experiments.tsx b/apps/frontend/src/pages/Experiments.tsx new file mode 100644 index 0000000..8c8ecc1 --- /dev/null +++ b/apps/frontend/src/pages/Experiments.tsx @@ -0,0 +1,530 @@ +import { useState } from 'react'; +import { cn, formatDate, formatCurrency, formatPercent } from '@/lib/utils'; +import { + FlaskConical, + Plus, + X, + ChevronDown, + ChevronRight, + CheckCircle2, + XCircle, + LinkIcon, +} from 'lucide-react'; + +// ── Types ────────────────────────────────────────────────────────────── + +type ExperimentStatus = 'active' | 'completed' | 'cancelled'; + +interface PriceChange { + id: string; + date: string; + platform: string; + oldPrice: number; + newPrice: number; + changePercent: number; +} + +interface MetricComparison { + label: string; + before: number; + after: number; + format: 'currency' | 'percent' | 'number'; +} + +interface Experiment { + id: string; + name: string; + hypothesis: string; + status: ExperimentStatus; + startDate: string; + endDate: string; + conclusion: string; + priceChanges: PriceChange[]; + metrics: MetricComparison[]; +} + +// ── Mock Data ────────────────────────────────────────────────────────── + +const MOCK_EXPERIMENTS: Experiment[] = [ + { + id: 'exp-1', + name: 'Weekend Premium Pricing', + hypothesis: + 'Increasing Friday-Sunday rates by 15% during peak season will increase weekend revenue without hurting occupancy below 75%.', + status: 'active', + startDate: '2026-03-01', + endDate: '2026-04-15', + conclusion: '', + priceChanges: [ + { id: 'pc-1', date: '2026-03-07', platform: 'Airbnb', oldPrice: 195, newPrice: 224, changePercent: 14.9 }, + { id: 'pc-2', date: '2026-03-08', platform: 'Airbnb', oldPrice: 195, newPrice: 224, changePercent: 14.9 }, + { id: 'pc-3', date: '2026-03-14', platform: 'VRBO', oldPrice: 185, newPrice: 213, changePercent: 15.1 }, + { id: 'pc-4', date: '2026-03-15', platform: 'VRBO', oldPrice: 185, newPrice: 213, changePercent: 15.1 }, + ], + metrics: [ + { label: 'Avg Nightly Rate', before: 190, after: 218, format: 'currency' }, + { label: 'Weekend Occupancy', before: 0.82, after: 0.78, format: 'percent' }, + { label: 'Weekend Revenue', before: 3120, after: 3408, format: 'currency' }, + ], + }, + { + id: 'exp-2', + name: 'Midweek Discount Strategy', + hypothesis: + 'Offering a 10% discount on Tuesday-Thursday stays will increase midweek bookings and improve overall occupancy from 58% to 70%.', + status: 'completed', + startDate: '2026-01-15', + endDate: '2026-02-28', + conclusion: + 'Midweek occupancy rose from 58% to 67%, short of the 70% target. Revenue per available night increased by 4.2%. Recommend continuing at a 7% discount.', + priceChanges: [ + { id: 'pc-5', date: '2026-01-21', platform: 'Airbnb', oldPrice: 175, newPrice: 158, changePercent: -9.7 }, + { id: 'pc-6', date: '2026-01-22', platform: 'Airbnb', oldPrice: 175, newPrice: 158, changePercent: -9.7 }, + { id: 'pc-7', date: '2026-02-04', platform: 'VRBO', oldPrice: 170, newPrice: 153, changePercent: -10.0 }, + ], + metrics: [ + { label: 'Midweek Occupancy', before: 0.58, after: 0.67, format: 'percent' }, + { label: 'RevPAN', before: 101, after: 106, format: 'currency' }, + { label: 'Total Midweek Revenue', before: 4410, after: 4620, format: 'currency' }, + ], + }, + { + id: 'exp-3', + name: 'Minimum Stay Reduction', + hypothesis: + 'Reducing the minimum stay from 3 nights to 2 nights will capture more short-trip bookings and increase monthly occupancy by 8%.', + status: 'completed', + startDate: '2025-11-01', + endDate: '2025-12-31', + conclusion: + 'Occupancy increased by 11% and total revenue grew 6.3%. Cleaning costs rose 18% due to higher turnover. Net profit impact was +3.1%. Keeping 2-night minimum.', + priceChanges: [ + { id: 'pc-8', date: '2025-11-05', platform: 'Airbnb', oldPrice: 180, newPrice: 180, changePercent: 0 }, + { id: 'pc-9', date: '2025-11-05', platform: 'VRBO', oldPrice: 175, newPrice: 175, changePercent: 0 }, + ], + metrics: [ + { label: 'Monthly Occupancy', before: 0.64, after: 0.75, format: 'percent' }, + { label: 'Total Revenue', before: 8640, after: 9180, format: 'currency' }, + { label: 'Avg Booking Length', before: 4.2, after: 3.1, format: 'number' }, + ], + }, + { + id: 'exp-4', + name: 'Dynamic Pricing by Lead Time', + hypothesis: + 'Applying a 20% premium for bookings made within 3 days of check-in will capture last-minute willingness to pay.', + status: 'cancelled', + startDate: '2026-02-01', + endDate: '2026-02-28', + conclusion: 'Cancelled after 10 days due to platform policy conflicts with Airbnb smart pricing. Need to disable smart pricing first.', + priceChanges: [ + { id: 'pc-10', date: '2026-02-03', platform: 'Airbnb', oldPrice: 195, newPrice: 234, changePercent: 20.0 }, + ], + metrics: [ + { label: 'Last-Minute Bookings', before: 4, after: 1, format: 'number' }, + { label: 'Avg Nightly Rate', before: 195, after: 234, format: 'currency' }, + ], + }, +]; + +// ── Helpers ───────────────────────────────────────────────────────────── + +const STATUS_STYLES: Record = { + active: 'bg-green-500/10 text-green-400', + completed: 'bg-blue-500/10 text-blue-400', + cancelled: 'bg-neutral-500/10 text-neutral-400', +}; + +function formatMetric(value: number, format: MetricComparison['format']) { + if (format === 'currency') return formatCurrency(value); + if (format === 'percent') return formatPercent(value); + return value.toFixed(1); +} + +function metricDelta(before: number, after: number, format: MetricComparison['format']) { + const diff = after - before; + const pct = before !== 0 ? ((diff / before) * 100).toFixed(1) : '0.0'; + const sign = diff >= 0 ? '+' : ''; + if (format === 'currency') return `${sign}${formatCurrency(diff)} (${sign}${pct}%)`; + if (format === 'percent') return `${sign}${(diff * 100).toFixed(1)}pp`; + return `${sign}${diff.toFixed(1)}`; +} + +// ── Component ────────────────────────────────────────────────────────── + +export default function Experiments() { + const [experiments, setExperiments] = useState(MOCK_EXPERIMENTS); + const [expandedId, setExpandedId] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); + + // Create form state + const [formName, setFormName] = useState(''); + const [formHypothesis, setFormHypothesis] = useState(''); + const [formStart, setFormStart] = useState(''); + const [formEnd, setFormEnd] = useState(''); + + function handleCreate() { + if (!formName || !formStart || !formEnd) return; + const newExp: Experiment = { + id: `exp-${Date.now()}`, + name: formName, + hypothesis: formHypothesis, + status: 'active', + startDate: formStart, + endDate: formEnd, + conclusion: '', + priceChanges: [], + metrics: [], + }; + setExperiments((prev) => [newExp, ...prev]); + setFormName(''); + setFormHypothesis(''); + setFormStart(''); + setFormEnd(''); + setShowCreateModal(false); + } + + function handleStatusChange(id: string, newStatus: ExperimentStatus) { + setExperiments((prev) => + prev.map((e) => (e.id === id ? { ...e, status: newStatus } : e)) + ); + } + + function handleConclusionChange(id: string, conclusion: string) { + setExperiments((prev) => + prev.map((e) => (e.id === id ? { ...e, conclusion } : e)) + ); + } + + const inputClass = cn( + 'w-full rounded-md bg-surface border border-border px-3 py-2 text-sm text-text-primary', + 'placeholder:text-text-muted/50 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent', + 'transition-colors' + ); + + return ( +
+ {/* Header */} +
+
+ +

Experiments

+ + {experiments.length} + +
+ +
+ + {/* Experiment Cards */} +
+ {experiments.map((exp) => { + const isExpanded = expandedId === exp.id; + return ( +
+ {/* Card header */} + + + {/* Expanded detail */} + {isExpanded && ( +
+ {/* Hypothesis */} +
+

+ Hypothesis +

+

+ {exp.hypothesis} +

+
+ + {/* Linked price changes table */} + {exp.priceChanges.length > 0 && ( +
+

+ Linked Price Changes +

+
+ + + + + + + + + + + + {exp.priceChanges.map((pc) => ( + + + + + + + + ))} + +
DatePlatformOld PriceNew PriceChange
+ {formatDate(pc.date)} + {pc.platform} + {formatCurrency(pc.oldPrice)} + + {formatCurrency(pc.newPrice)} + 0 + ? 'text-green-400' + : pc.changePercent < 0 + ? 'text-red-400' + : 'text-text-muted' + )} + > + {pc.changePercent > 0 ? '+' : ''} + {pc.changePercent.toFixed(1)}% +
+
+
+ )} + + {/* Metric comparison */} + {exp.metrics.length > 0 && ( +
+

+ Before / After Comparison +

+
+ {exp.metrics.map((m) => { + const improved = + m.format === 'percent' || m.format === 'number' + ? m.after >= m.before + : m.after >= m.before; + return ( +
+
{m.label}
+
+
+
Before
+
+ {formatMetric(m.before, m.format)} +
+
+
+
+
After
+
+ {formatMetric(m.after, m.format)} +
+
+
+
+ {metricDelta(m.before, m.after, m.format)} +
+
+ ); + })} +
+
+ )} + + {/* Conclusion */} +
+

+ Conclusion +

+ {exp.status === 'active' ? ( +

+ Experiment still running. Conclusion can be added once completed. +

+ ) : ( +