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 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 15:03:21 -04:00
parent 4735c73b3a
commit d4c714fadc
76 changed files with 18465 additions and 0 deletions

View File

@@ -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!,
},
});

41
apps/api/package.json Normal file
View File

@@ -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"
}
}

27
apps/api/src/config.ts Normal file
View File

@@ -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<typeof envSchema>;
export const config: Config = envSchema.parse(process.env);

View File

@@ -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

14
apps/api/src/db/index.ts Normal file
View File

@@ -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 };

View File

@@ -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;

View File

@@ -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": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1774223467634,
"tag": "0000_modern_screwball",
"breakpoints": true
}
]
}

195
apps/api/src/db/schema.ts Normal file
View File

@@ -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],
}),
}));

View File

@@ -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);
});

View File

@@ -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);
});

340
apps/api/src/db/seed.ts Normal file
View File

@@ -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);
});

View File

@@ -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<WeeklyReportData> {
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<string>`coalesce(sum(${reservations.totalPayout}::numeric), 0)`.as('total_revenue'),
reservationCount: sql<number>`count(*)::int`.as('reservation_count'),
avgDailyRate: sql<string>`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<string>`coalesce(sum(${reservations.totalPayout}::numeric), 0)`.as('total_revenue'),
reservationCount: sql<number>`count(*)::int`.as('reservation_count'),
avgDailyRate: sql<string>`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<string>`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<string>`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<string>`coalesce(sum(${reservations.totalPayout}::numeric), 0)`.as('revenue'),
reservationCount: sql<number>`count(*)::int`.as('reservations'),
avgDailyRate: sql<string>`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<void> {
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,
});
}

54
apps/api/src/index.ts Normal file
View File

@@ -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<void>;
}
}
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);
});

View File

@@ -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' });
}

View File

@@ -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 });
},
);
}

View File

@@ -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<string, unknown> = {};
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,
});
},
);
}

View File

@@ -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(),
});
});
}

View File

@@ -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);
},
);
}

View File

@@ -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<string, string>;
}>(
'/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);
},
);
}

View File

@@ -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<typeof payload>(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);
},
);
}

View File

@@ -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);
},
);
}

View File

@@ -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<string>`to_char(${reservations.checkIn}::date, 'YYYY-MM')`.as('month'),
platformId: reservations.platformId,
totalRevenue: sql<string>`sum(${reservations.totalPayout}::numeric)`.as('total_revenue'),
reservationCount: sql<number>`count(*)::int`.as('reservation_count'),
totalNights: sql<number>`sum(${reservations.nights})::int`.as('total_nights'),
avgNightlyRate: sql<string>`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);
},
);
}

View File

@@ -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)');
}

View File

@@ -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;
}

8
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}

15
apps/frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>STR Optimization Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
</head>
<body class="bg-background text-text-primary">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

42
apps/frontend/src/App.tsx Normal file
View File

@@ -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 (
<div className="h-screen flex items-center justify-center">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!isAuthenticated) {
return <Login onLogin={login} />;
}
return (
<Routes>
<Route element={<Shell onLogout={logout} />}>
<Route path="/" element={<Dashboard />} />
<Route path="/performance" element={<Performance />} />
<Route path="/pricing" element={<Pricing />} />
<Route path="/experiments" element={<Experiments />} />
<Route path="/reservations" element={<Reservations />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export default App;

View File

@@ -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 (
<div className="flex h-screen overflow-hidden">
{/* Desktop Sidebar */}
<aside className="hidden md:flex w-56 flex-col bg-surface border-r border-border">
<div className="p-4 border-b border-border">
<h1 className="text-sm font-semibold tracking-wide text-accent">STR OPTIMIZER</h1>
</div>
<nav className="flex-1 p-2 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors',
isActive
? 'bg-accent/10 text-accent'
: 'text-text-muted hover:text-text-primary hover:bg-white/5'
)
}
>
<item.icon className="w-4 h-4" />
{item.label}
</NavLink>
))}
</nav>
<div className="p-2 border-t border-border">
<button
onClick={onLogout}
className="flex items-center gap-3 px-3 py-2 rounded-md text-sm text-text-muted hover:text-danger w-full transition-colors"
>
<LogOut className="w-4 h-4" />
Logout
</button>
</div>
</aside>
{/* Mobile Header */}
<div className="flex-1 flex flex-col overflow-hidden">
<header className="md:hidden flex items-center justify-between p-3 bg-surface border-b border-border">
<h1 className="text-sm font-semibold tracking-wide text-accent">STR OPTIMIZER</h1>
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="p-1 text-text-muted"
>
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</header>
{/* Mobile Menu Overlay */}
{mobileOpen && (
<div className="md:hidden absolute inset-0 z-50 bg-background/95 pt-14">
<nav className="p-4 space-y-2">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/'}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-4 py-3 rounded-md text-base',
isActive ? 'bg-accent/10 text-accent' : 'text-text-muted'
)
}
>
<item.icon className="w-5 h-5" />
{item.label}
</NavLink>
))}
<button
onClick={onLogout}
className="flex items-center gap-3 px-4 py-3 rounded-md text-base text-text-muted w-full"
>
<LogOut className="w-5 h-5" />
Logout
</button>
</nav>
</div>
)}
{/* Main Content */}
<main className="flex-1 overflow-auto p-4 md:p-6">
<Outlet />
</main>
{/* Mobile Bottom Nav */}
<nav className="md:hidden flex items-center justify-around border-t border-border bg-surface py-2">
{navItems.slice(0, 5).map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/'}
className={({ isActive }) =>
cn(
'flex flex-col items-center gap-0.5 px-2 py-1 text-[10px]',
isActive ? 'text-accent' : 'text-text-muted'
)
}
>
<item.icon className="w-4 h-4" />
{item.label}
</NavLink>
))}
</nav>
</div>
</div>
);
}

View File

@@ -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<AuthState>({
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 };
}

View File

@@ -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);
}

View File

@@ -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<T>(path: string, options: RequestInit = {}): Promise<T> {
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<any[]>('/platforms'),
updateCredentials: (id: string, credentials: { email: string; password: string }) =>
request(`/platforms/${id}/credentials`, {
method: 'PUT',
body: JSON.stringify(credentials),
}),
testPlatform: (id: string) =>
request<any>(`/platforms/${id}/test`, { method: 'POST' }),
loginPlatform: (id: string, credentials?: { email: string; password: string }) =>
request<any>(`/platforms/${id}/login`, {
method: 'POST',
body: JSON.stringify(credentials || {}),
}),
checkSession: (id: string) =>
request<any>(`/platforms/${id}/session`),
triggerScrape: (id: string) =>
request<any>(`/platforms/${id}/scrape`, { method: 'POST' }),
getScrapeJobs: (id: string) => request<any[]>(`/platforms/${id}/scrape-jobs`),
// Performance
getSnapshots: (params?: { platform?: string; from?: string; to?: string }) => {
const qs = new URLSearchParams(params as Record<string, string>).toString();
return request<any[]>(`/performance/snapshots${qs ? `?${qs}` : ''}`);
},
getPerformanceSummary: () => request<any>('/performance/summary'),
getPerformanceTrends: (params?: { platform?: string; from?: string; to?: string }) => {
const qs = new URLSearchParams(params as Record<string, string>).toString();
return request<any[]>(`/performance/trends${qs ? `?${qs}` : ''}`);
},
// Pricing
getPricingCalendar: (params?: { platform?: string; from?: string; to?: string }) => {
const qs = new URLSearchParams(params as Record<string, string>).toString();
return request<any[]>(`/pricing/calendar${qs ? `?${qs}` : ''}`);
},
previewPriceChanges: (changes: any[]) =>
request<any>('/pricing/preview', {
method: 'POST',
body: JSON.stringify({ changes }),
}),
applyPriceChanges: (previewToken: string) =>
request<any>('/pricing/apply', {
method: 'POST',
body: JSON.stringify({ previewToken }),
}),
getPriceChanges: (params?: Record<string, string>) => {
const qs = new URLSearchParams(params).toString();
return request<any[]>(`/pricing/changes${qs ? `?${qs}` : ''}`);
},
// Experiments
getExperiments: () => request<any[]>('/experiments'),
createExperiment: (data: any) =>
request<any>('/experiments', {
method: 'POST',
body: JSON.stringify(data),
}),
updateExperiment: (id: string, data: any) =>
request<any>(`/experiments/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
getExperimentAnalysis: (id: string) => request<any>(`/experiments/${id}/analysis`),
// Reservations
getReservations: (params?: Record<string, string>) => {
const qs = new URLSearchParams(params).toString();
return request<any[]>(`/reservations${qs ? `?${qs}` : ''}`);
},
getReservationSummary: () => request<any>('/reservations/summary'),
// Reports
sendWeeklyReport: () => request('/reports/weekly/send', { method: 'POST' }),
previewWeeklyReport: () => request<any>('/reports/weekly/preview'),
// Health
health: () => request<any>('/health'),
};
export { ApiError };

View File

@@ -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<string, string> = {
airbnb: 'Airbnb',
vrbo: 'VRBO',
mock: 'Mock',
};
export const PLATFORM_COLORS: Record<string, string> = {
airbnb: '#ff5a5f',
vrbo: '#3b5998',
mock: '#22c55e',
};

View File

@@ -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`;
}

View File

@@ -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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);

View File

@@ -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 (
<div className="bg-surface border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-text-muted text-xs uppercase tracking-wide">{label}</span>
<Icon className="w-4 h-4 text-text-muted" />
</div>
<div className="text-2xl font-mono-data text-text-primary">{value}</div>
<div className={cn('flex items-center gap-1 mt-1 text-xs', isPositive ? 'text-green-400' : 'text-red-400')}>
{isPositive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
<span className="font-mono-data">{isPositive ? '+' : ''}{(trend * 100).toFixed(1)}%</span>
</div>
</div>
);
}
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 (
<div className="bg-surface border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-sm font-medium text-text-primary">{label}</span>
</div>
<div className={cn('flex items-center gap-1 text-xs', isSuccess ? 'text-green-400' : 'text-red-400')}>
{isSuccess ? <CheckCircle2 className="w-3 h-3" /> : <XCircle className="w-3 h-3" />}
<span>{platform.lastScrapeAt ? getRelativeTime(platform.lastScrapeAt) : 'never'}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-text-muted">Occupancy</span>
<div className="font-mono-data text-text-primary text-sm">{formatPercent(platform.occupancyRate ?? 0)}</div>
</div>
<div>
<span className="text-text-muted">ADR</span>
<div className="font-mono-data text-text-primary text-sm">{formatCurrency(platform.avgDailyRate ?? 0)}</div>
</div>
<div>
<span className="text-text-muted">Revenue MTD</span>
<div className="font-mono-data text-text-primary text-sm">{formatCurrency(platform.revenueMtd ?? 0)}</div>
</div>
<div>
<span className="text-text-muted">Views (30d)</span>
<div className="font-mono-data text-text-primary text-sm">{(platform.searchViews30d ?? 0).toLocaleString()}</div>
</div>
</div>
<button
onClick={() => scrapeMutation.mutate()}
disabled={scrapeMutation.isPending}
className={cn(
'w-full flex items-center justify-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs',
'text-text-muted hover:text-text-primary hover:border-accent/50 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{scrapeMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Play className="w-3 h-3" />
)}
{scrapeMutation.isPending ? 'Running...' : 'Run Scrape Now'}
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="space-y-6">
{/* Page header */}
<div>
<h2 className="text-lg font-semibold text-text-primary">Dashboard</h2>
<p className="text-xs text-text-muted mt-0.5">Overview of your short-term rental performance</p>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiCard
label="Occupancy Rate"
value={formatPercent(summary.occupancyRate ?? 0)}
trend={summary.occupancyTrend ?? 0}
icon={Percent}
/>
<KpiCard
label="Avg Daily Rate"
value={formatCurrency(summary.avgDailyRate ?? 0)}
trend={summary.adrTrend ?? 0}
icon={DollarSign}
/>
<KpiCard
label="Revenue MTD"
value={formatCurrency(summary.revenueMtd ?? 0)}
trend={summary.revenueTrend ?? 0}
icon={CalendarCheck}
/>
<KpiCard
label="Search Views (30d)"
value={(summary.searchViews30d ?? 0).toLocaleString()}
trend={summary.viewsTrend ?? 0}
icon={Eye}
/>
</div>
{/* Platform comparison + Sparkline row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
{/* Platform cards */}
<div className="lg:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-3">
{platforms.map((p: any) => (
<PlatformCard key={p.id} platform={p} />
))}
</div>
{/* Booking sparkline */}
<div className="bg-surface border border-border rounded-lg p-4 flex flex-col">
<span className="text-text-muted text-xs uppercase tracking-wide mb-2">Bookings (90 days)</span>
<div className="flex-1 min-h-[120px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={MOCK_SPARKLINE}>
<Line
type="monotone"
dataKey="bookings"
stroke={CHART_COLORS.primary}
strokeWidth={1.5}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Recent reservations */}
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-border">
<span className="text-text-muted text-xs uppercase tracking-wide">Recent Reservations</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-text-muted text-xs uppercase border-b border-border">
<th className="text-left px-4 py-2 font-medium">Guest</th>
<th className="text-left px-4 py-2 font-medium">Platform</th>
<th className="text-left px-4 py-2 font-medium">Dates</th>
<th className="text-right px-4 py-2 font-medium">Payout</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={r.id} className="border-b border-border last:border-0 hover:bg-white/[0.02] transition-colors">
<td className="px-4 py-2.5 text-text-primary">{r.guestName}</td>
<td className="px-4 py-2.5">
<span className="inline-flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
<span className="text-text-muted">{label}</span>
</span>
</td>
<td className="px-4 py-2.5 text-text-muted whitespace-nowrap">
{formatDateShort(r.checkIn)} - {formatDateShort(r.checkOut)}
</td>
<td className="px-4 py-2.5 text-right font-mono-data text-text-primary">
{formatCurrency(Number(r.totalPayout || r.payout || 0))}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -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<ExperimentStatus, string> = {
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<Experiment[]>(MOCK_EXPERIMENTS);
const [expandedId, setExpandedId] = useState<string | null>(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 (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-3">
<FlaskConical className="w-5 h-5 text-accent" />
<h1 className="text-lg font-semibold text-text-primary">Experiments</h1>
<span className="text-xs text-text-muted bg-surface border border-border rounded-full px-2 py-0.5">
{experiments.length}
</span>
</div>
<button
onClick={() => setShowCreateModal(true)}
className={cn(
'inline-flex items-center gap-2 rounded-md bg-accent text-black font-semibold px-4 py-2 text-sm',
'hover:bg-accent/90 transition-colors'
)}
>
<Plus className="w-4 h-4" />
New Experiment
</button>
</div>
{/* Experiment Cards */}
<div className="space-y-3">
{experiments.map((exp) => {
const isExpanded = expandedId === exp.id;
return (
<div
key={exp.id}
className="bg-surface border border-border rounded-lg overflow-hidden"
>
{/* Card header */}
<button
onClick={() => setExpandedId(isExpanded ? null : exp.id)}
className="w-full flex items-center gap-4 px-5 py-4 text-left hover:bg-white/[0.02] transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-text-muted shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-text-muted shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<span className="font-medium text-text-primary truncate">
{exp.name}
</span>
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium w-fit capitalize',
STATUS_STYLES[exp.status]
)}
>
{exp.status}
</span>
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-text-muted">
<span>
{formatDate(exp.startDate)} &ndash; {formatDate(exp.endDate)}
</span>
<span className="inline-flex items-center gap-1">
<LinkIcon className="w-3 h-3" />
{exp.priceChanges.length} price changes
</span>
</div>
</div>
</button>
{/* Expanded detail */}
{isExpanded && (
<div className="border-t border-border px-5 py-5 space-y-6">
{/* Hypothesis */}
<div>
<h3 className="text-xs text-text-muted uppercase tracking-wide mb-1.5">
Hypothesis
</h3>
<p className="text-sm text-text-primary leading-relaxed">
{exp.hypothesis}
</p>
</div>
{/* Linked price changes table */}
{exp.priceChanges.length > 0 && (
<div>
<h3 className="text-xs text-text-muted uppercase tracking-wide mb-2">
Linked Price Changes
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted border-b border-border">
<th className="pb-2 pr-4 font-medium">Date</th>
<th className="pb-2 pr-4 font-medium">Platform</th>
<th className="pb-2 pr-4 font-medium text-right">Old Price</th>
<th className="pb-2 pr-4 font-medium text-right">New Price</th>
<th className="pb-2 font-medium text-right">Change</th>
</tr>
</thead>
<tbody>
{exp.priceChanges.map((pc) => (
<tr key={pc.id} className="border-b border-border/50 last:border-0">
<td className="py-2 pr-4 text-text-primary">
{formatDate(pc.date)}
</td>
<td className="py-2 pr-4 text-text-primary">{pc.platform}</td>
<td className="py-2 pr-4 text-right font-mono text-text-muted">
{formatCurrency(pc.oldPrice)}
</td>
<td className="py-2 pr-4 text-right font-mono text-text-primary">
{formatCurrency(pc.newPrice)}
</td>
<td
className={cn(
'py-2 text-right font-mono',
pc.changePercent > 0
? 'text-green-400'
: pc.changePercent < 0
? 'text-red-400'
: 'text-text-muted'
)}
>
{pc.changePercent > 0 ? '+' : ''}
{pc.changePercent.toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Metric comparison */}
{exp.metrics.length > 0 && (
<div>
<h3 className="text-xs text-text-muted uppercase tracking-wide mb-2">
Before / After Comparison
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{exp.metrics.map((m) => {
const improved =
m.format === 'percent' || m.format === 'number'
? m.after >= m.before
: m.after >= m.before;
return (
<div
key={m.label}
className="bg-background border border-border rounded-md p-3"
>
<div className="text-xs text-text-muted mb-2">{m.label}</div>
<div className="flex items-end justify-between">
<div>
<div className="text-xs text-text-muted">Before</div>
<div className="font-mono text-sm text-text-primary">
{formatMetric(m.before, m.format)}
</div>
</div>
<div className="text-lg text-text-muted mx-2">&rarr;</div>
<div className="text-right">
<div className="text-xs text-text-muted">After</div>
<div className="font-mono text-sm text-text-primary">
{formatMetric(m.after, m.format)}
</div>
</div>
</div>
<div
className={cn(
'text-xs font-mono mt-2 text-right',
improved ? 'text-green-400' : 'text-red-400'
)}
>
{metricDelta(m.before, m.after, m.format)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Conclusion */}
<div>
<h3 className="text-xs text-text-muted uppercase tracking-wide mb-1.5">
Conclusion
</h3>
{exp.status === 'active' ? (
<p className="text-sm text-text-muted italic">
Experiment still running. Conclusion can be added once completed.
</p>
) : (
<textarea
value={exp.conclusion}
onChange={(e) => handleConclusionChange(exp.id, e.target.value)}
rows={3}
placeholder="Write your conclusion here..."
className={cn(inputClass, 'resize-y')}
/>
)}
</div>
{/* Status actions */}
{exp.status === 'active' && (
<div className="flex items-center gap-3 pt-2 border-t border-border">
<button
onClick={() => handleStatusChange(exp.id, 'completed')}
className={cn(
'inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
'bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors'
)}
>
<CheckCircle2 className="w-4 h-4" />
Complete
</button>
<button
onClick={() => handleStatusChange(exp.id, 'cancelled')}
className={cn(
'inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
'bg-neutral-500/10 text-neutral-400 hover:bg-neutral-500/20 transition-colors'
)}
>
<XCircle className="w-4 h-4" />
Cancel
</button>
</div>
)}
</div>
)}
</div>
);
})}
</div>
{/* Create Experiment Modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
<div className="bg-surface border border-border rounded-lg w-full max-w-lg p-6 space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-text-primary">
New Experiment
</h2>
<button
onClick={() => setShowCreateModal(false)}
className="text-text-muted hover:text-text-primary transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs text-text-muted uppercase tracking-wide mb-1.5">
Name
</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="e.g. Weekend Premium Pricing"
className={inputClass}
/>
</div>
<div>
<label className="block text-xs text-text-muted uppercase tracking-wide mb-1.5">
Hypothesis
</label>
<textarea
value={formHypothesis}
onChange={(e) => setFormHypothesis(e.target.value)}
rows={3}
placeholder="Describe what you expect to happen and why..."
className={cn(inputClass, 'resize-y')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-text-muted uppercase tracking-wide mb-1.5">
Start Date
</label>
<input
type="date"
value={formStart}
onChange={(e) => setFormStart(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className="block text-xs text-text-muted uppercase tracking-wide mb-1.5">
End Date
</label>
<input
type="date"
value={formEnd}
onChange={(e) => setFormEnd(e.target.value)}
className={inputClass}
/>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
<button
onClick={() => setShowCreateModal(false)}
className="rounded-md px-4 py-2 text-sm text-text-muted hover:text-text-primary transition-colors"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={!formName || !formStart || !formEnd}
className={cn(
'rounded-md bg-accent text-black font-semibold px-4 py-2 text-sm',
'hover:bg-accent/90 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
Create Experiment
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { useState, type FormEvent } from 'react';
import { cn } from '@/lib/utils';
import { Loader2 } from 'lucide-react';
interface LoginProps {
onLogin: (username: string, password: string) => Promise<boolean>;
}
export default function Login({ onLogin }: LoginProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
await onLogin(username, password);
} catch (err: any) {
setError(err?.message || 'Invalid credentials');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-sm">
<div className="bg-surface border border-border rounded-lg p-8">
<h1 className="text-center text-lg font-semibold tracking-wide text-accent mb-1">
STR Optimizer
</h1>
<p className="text-center text-text-muted text-xs mb-8">
Short-Term Rental Performance
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-xs text-text-muted uppercase tracking-wide mb-1.5">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
className={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'
)}
placeholder="admin"
/>
</div>
<div>
<label htmlFor="password" className="block text-xs text-text-muted uppercase tracking-wide mb-1.5">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className={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'
)}
placeholder="••••••••"
/>
</div>
{error && (
<p className="text-xs text-red-400 bg-red-400/10 rounded-md px-3 py-2">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className={cn(
'w-full rounded-md bg-accent text-black font-semibold py-2 text-sm',
'hover:bg-accent/90 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed',
'flex items-center justify-center gap-2'
)}
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,602 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
LineChart,
Line,
BarChart,
Bar,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { ArrowUpDown, TrendingUp } from 'lucide-react';
import { format, subDays, startOfYear, isAfter, parseISO, startOfWeek } from 'date-fns';
import { cn, formatCurrency, formatPercent, formatNumber } from '@/lib/utils';
import { api } from '@/lib/api';
import { DATE_PRESETS, CHART_COLORS } from '@/lib/constants';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface TrendPoint {
date: string;
platform: string;
views_search: number;
conversion_rate: number;
bookings_count: number;
occupancy_rate: number;
avg_daily_rate: number;
}
interface SnapshotRow {
date: string;
platform: string;
views: number;
clicks: number;
ctr: number;
bookings: number;
occupancy: number;
adr: number;
revenue: number;
}
type SortField = keyof SnapshotRow;
type SortDir = 'asc' | 'desc';
type PlatformFilter = 'all' | 'airbnb' | 'vrbo';
// ---------------------------------------------------------------------------
// Mock data generator (90 days, realistic STR metrics w/ upward trend)
// ---------------------------------------------------------------------------
function generateMockTrends(): TrendPoint[] {
const points: TrendPoint[] = [];
const today = new Date();
const platforms: string[] = ['airbnb', 'vrbo'];
for (let i = 89; i >= 0; i--) {
const d = subDays(today, i);
const dateStr = format(d, 'yyyy-MM-dd');
const progress = (90 - i) / 90; // 0 -> 1 over 90 days
const dayOfWeek = d.getDay();
const weekendBoost = dayOfWeek === 0 || dayOfWeek === 6 ? 1.15 : 1;
for (const platform of platforms) {
const platformMul = platform === 'airbnb' ? 1.3 : 1;
const noise = () => 0.85 + Math.random() * 0.3;
const views = Math.round(
(400 + progress * 600 + Math.random() * 400) * platformMul * weekendBoost * noise()
);
const ctr = (0.03 + progress * 0.015 + Math.random() * 0.01) * weekendBoost;
const bookings = Math.round(
(0.4 + progress * 0.4 + Math.random() * 0.6) * platformMul * weekendBoost
);
const occupancy = Math.min(
0.95,
(0.55 + progress * 0.15 + Math.random() * 0.1) * weekendBoost
);
const adr = Math.round(
(155 + progress * 45 + Math.random() * 30) * (weekendBoost > 1 ? 1.2 : 1)
);
points.push({
date: dateStr,
platform,
views_search: views,
conversion_rate: parseFloat(ctr.toFixed(4)),
bookings_count: bookings,
occupancy_rate: parseFloat(occupancy.toFixed(3)),
avg_daily_rate: adr,
});
}
}
return points;
}
function generateMockSnapshots(): SnapshotRow[] {
const rows: SnapshotRow[] = [];
const today = new Date();
const platforms: string[] = ['airbnb', 'vrbo'];
for (let i = 89; i >= 0; i--) {
const d = subDays(today, i);
const dateStr = format(d, 'yyyy-MM-dd');
const progress = (90 - i) / 90;
const dayOfWeek = d.getDay();
const weekendBoost = dayOfWeek === 0 || dayOfWeek === 6 ? 1.15 : 1;
for (const platform of platforms) {
const platformMul = platform === 'airbnb' ? 1.3 : 1;
const noise = () => 0.85 + Math.random() * 0.3;
const views = Math.round(
(400 + progress * 600 + Math.random() * 400) * platformMul * weekendBoost * noise()
);
const clicks = Math.round(views * (0.08 + Math.random() * 0.04));
const ctr = clicks / views;
const bookings = Math.round(
(0.4 + progress * 0.4 + Math.random() * 0.6) * platformMul * weekendBoost
);
const occupancy = Math.min(
0.95,
(0.55 + progress * 0.15 + Math.random() * 0.1) * weekendBoost
);
const adr = Math.round(
(155 + progress * 45 + Math.random() * 30) * (weekendBoost > 1 ? 1.2 : 1)
);
const revenue = Math.round(adr * occupancy * 30 * (Math.random() * 0.2 + 0.9));
rows.push({
date: dateStr,
platform,
views,
clicks,
ctr: parseFloat(ctr.toFixed(4)),
bookings,
occupancy: parseFloat(occupancy.toFixed(3)),
adr,
revenue,
});
}
}
return rows;
}
const MOCK_TRENDS = generateMockTrends();
const MOCK_SNAPSHOTS = generateMockSnapshots();
// ---------------------------------------------------------------------------
// Chart wrapper
// ---------------------------------------------------------------------------
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="bg-surface border border-border rounded-lg p-4">
<h3 className="text-sm font-medium text-text-primary mb-3">{title}</h3>
<div className="h-64">{children}</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Custom tooltip
// ---------------------------------------------------------------------------
function ChartTooltip({ active, payload, label, valueFormatter }: any) {
if (!active || !payload?.length) return null;
return (
<div className="bg-surface border border-border rounded-md px-3 py-2 text-xs shadow-lg">
<p className="text-text-muted mb-1">{label}</p>
{payload.map((entry: any, i: number) => (
<p key={i} style={{ color: entry.color }} className="font-mono">
{valueFormatter ? valueFormatter(entry.value) : entry.value}
</p>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export default function Performance() {
const [dateRange, setDateRange] = useState(30);
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
const [sortField, setSortField] = useState<SortField>('date');
const [sortDir, setSortDir] = useState<SortDir>('desc');
// ---- Data fetching with fallback to mock data ----
const { data: rawTrends } = useQuery({
queryKey: ['performanceTrends'],
queryFn: () => api.getPerformanceTrends(),
retry: false,
});
const { data: rawSnapshots } = useQuery({
queryKey: ['snapshots'],
queryFn: () => api.getSnapshots(),
retry: false,
});
const trends: TrendPoint[] = rawTrends?.length
? rawTrends.map((r: any) => ({
date: r.date || r.periodLabel,
platform: r.platformId || r.platform,
views_search: Number(r.viewsSearch ?? r.views_search ?? 0),
conversion_rate: Number(r.conversionRate ?? r.conversion_rate ?? 0),
bookings_count: Number(r.bookingsCount ?? r.bookings_count ?? 0),
occupancy_rate: Number(r.occupancyRate ?? r.occupancy_rate ?? 0) / 100,
avg_daily_rate: Number(r.avgDailyRate ?? r.avg_daily_rate ?? 0),
}))
: MOCK_TRENDS;
const snapshots: SnapshotRow[] = rawSnapshots?.length
? rawSnapshots.map((r: any) => ({
date: r.periodLabel || r.date,
platform: r.platformId || r.platform,
views: Number(r.viewsSearch ?? r.views ?? 0),
clicks: Number(r.viewsListing ?? r.clicks ?? 0),
ctr: Number(r.conversionRate ?? r.ctr ?? 0) / 100,
bookings: Number(r.bookingsCount ?? r.bookings ?? 0),
occupancy: Number(r.occupancyRate ?? r.occupancy ?? 0) / 100,
adr: Number(r.avgDailyRate ?? r.adr ?? 0),
revenue: Number(r.revenueTotal ?? r.revenue ?? 0),
}))
: MOCK_SNAPSHOTS;
// ---- Compute cutoff date ----
const cutoffDate = useMemo(() => {
if (dateRange === -1) return startOfYear(new Date());
return subDays(new Date(), dateRange);
}, [dateRange]);
// ---- Filter helpers ----
const filterData = <T extends { date: string; platform: string }>(data: T[]): T[] =>
data.filter((d) => {
const dateOk = isAfter(parseISO(d.date), cutoffDate);
const platOk = platformFilter === 'all' || d.platform === platformFilter;
return dateOk && platOk;
});
// ---- Filtered + aggregated data ----
const filteredTrends = useMemo(() => {
const filtered = filterData(trends);
// Aggregate by date (sum across platforms if "all")
const byDate = new Map<string, Omit<TrendPoint, 'platform'>>();
for (const row of filtered) {
const existing = byDate.get(row.date);
if (existing) {
existing.views_search += row.views_search;
existing.conversion_rate = (existing.conversion_rate + row.conversion_rate) / 2;
existing.bookings_count += row.bookings_count;
existing.occupancy_rate = (existing.occupancy_rate + row.occupancy_rate) / 2;
existing.avg_daily_rate = (existing.avg_daily_rate + row.avg_daily_rate) / 2;
} else {
byDate.set(row.date, { ...row });
}
}
return Array.from(byDate.values()).sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trends, cutoffDate, platformFilter]);
// Weekly bookings aggregation
const weeklyBookings = useMemo(() => {
const weekMap = new Map<string, number>();
for (const row of filteredTrends) {
const weekStart = format(startOfWeek(parseISO(row.date), { weekStartsOn: 1 }), 'MMM d');
weekMap.set(weekStart, (weekMap.get(weekStart) || 0) + row.bookings_count);
}
return Array.from(weekMap.entries()).map(([week, count]) => ({ week, bookings: count }));
}, [filteredTrends]);
// Chart-ready date labels
const chartTrends = useMemo(
() =>
filteredTrends.map((d) => ({
...d,
label: format(parseISO(d.date), 'MMM d'),
})),
[filteredTrends]
);
// ---- Table data ----
const tableData = useMemo(() => {
const filtered = filterData(snapshots);
const sorted = [...filtered].sort((a, b) => {
const aVal = a[sortField];
const bVal = b[sortField];
if (typeof aVal === 'string' && typeof bVal === 'string')
return sortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
return sortDir === 'asc'
? (aVal as number) - (bVal as number)
: (bVal as number) - (aVal as number);
});
return sorted;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [snapshots, cutoffDate, platformFilter, sortField, sortDir]);
const toggleSort = (field: SortField) => {
if (sortField === field) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDir('desc');
}
};
// ---- Recharts common props ----
const gridProps = { stroke: '#262626', strokeDasharray: '3 3' };
const xAxisProps = {
dataKey: 'label' as const,
stroke: '#262626',
tick: { fill: '#737373', fontSize: 11 },
tickLine: false,
};
const yAxisProps = {
stroke: '#262626',
tick: { fill: '#737373', fontSize: 11 },
tickLine: false,
width: 50,
};
// ---- Render ----
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-accent" />
<h2 className="text-lg font-semibold text-text-primary">Performance</h2>
</div>
{/* Controls bar */}
<div className="flex flex-wrap items-center gap-3">
{/* Date range presets */}
<div className="flex items-center rounded-md border border-border overflow-hidden">
{DATE_PRESETS.map((preset) => {
const active = dateRange === preset.days;
return (
<button
key={preset.label}
onClick={() => setDateRange(preset.days)}
className={cn(
'px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'bg-accent/10 text-accent border-accent'
: 'bg-surface text-text-muted hover:text-text-primary'
)}
>
{preset.label}
</button>
);
})}
</div>
{/* Platform filter */}
<div className="flex items-center rounded-md border border-border overflow-hidden">
{(['all', 'airbnb', 'vrbo'] as const).map((p) => {
const active = platformFilter === p;
const label = p === 'all' ? 'All' : p === 'airbnb' ? 'Airbnb' : 'VRBO';
return (
<button
key={p}
onClick={() => setPlatformFilter(p)}
className={cn(
'px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'bg-accent/10 text-accent border-accent'
: 'bg-surface text-text-muted hover:text-text-primary'
)}
>
{label}
</button>
);
})}
</div>
</div>
{/* Charts grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Search Views */}
<ChartCard title="Search Views">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartTrends}>
<CartesianGrid {...gridProps} />
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} />
<Tooltip content={<ChartTooltip valueFormatter={formatNumber} />} />
<Line
type="monotone"
dataKey="views_search"
stroke={CHART_COLORS.primary}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: CHART_COLORS.primary }}
/>
</LineChart>
</ResponsiveContainer>
</ChartCard>
{/* Click-Through Rate */}
<ChartCard title="Click-Through Rate">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartTrends}>
<CartesianGrid {...gridProps} />
<XAxis {...xAxisProps} />
<YAxis
{...yAxisProps}
tickFormatter={(v: number) => `${(v * 100).toFixed(1)}%`}
/>
<Tooltip
content={<ChartTooltip valueFormatter={(v: number) => formatPercent(v)} />}
/>
<Line
type="monotone"
dataKey="conversion_rate"
stroke={CHART_COLORS.secondary}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: CHART_COLORS.secondary }}
/>
</LineChart>
</ResponsiveContainer>
</ChartCard>
{/* Bookings per Week */}
<ChartCard title="Bookings per Week">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={weeklyBookings}>
<CartesianGrid {...gridProps} />
<XAxis
dataKey="week"
stroke="#262626"
tick={{ fill: '#737373', fontSize: 11 }}
tickLine={false}
/>
<YAxis {...yAxisProps} />
<Tooltip content={<ChartTooltip valueFormatter={formatNumber} />} />
<Bar dataKey="bookings" fill={CHART_COLORS.primary} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
{/* Occupancy Rate */}
<ChartCard title="Occupancy Rate">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartTrends}>
<defs>
<linearGradient id="occupancyGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART_COLORS.primary} stopOpacity={0.3} />
<stop offset="100%" stopColor={CHART_COLORS.primary} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid {...gridProps} />
<XAxis {...xAxisProps} />
<YAxis
{...yAxisProps}
tickFormatter={(v: number) => `${(v * 100).toFixed(0)}%`}
domain={[0.4, 1]}
/>
<Tooltip
content={<ChartTooltip valueFormatter={(v: number) => formatPercent(v)} />}
/>
<Area
type="monotone"
dataKey="occupancy_rate"
stroke={CHART_COLORS.primary}
strokeWidth={2}
fill="url(#occupancyGradient)"
activeDot={{ r: 4, fill: CHART_COLORS.primary }}
/>
</AreaChart>
</ResponsiveContainer>
</ChartCard>
{/* Avg Daily Rate */}
<ChartCard title="Avg Daily Rate">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartTrends}>
<CartesianGrid {...gridProps} />
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} tickFormatter={(v: number) => `$${v}`} />
<Tooltip
content={<ChartTooltip valueFormatter={(v: number) => formatCurrency(v)} />}
/>
<Line
type="monotone"
dataKey="avg_daily_rate"
stroke={CHART_COLORS.quaternary}
strokeWidth={2}
dot={{ r: 2, fill: CHART_COLORS.quaternary, strokeWidth: 0 }}
activeDot={{ r: 5, fill: CHART_COLORS.quaternary }}
/>
</LineChart>
</ResponsiveContainer>
</ChartCard>
</div>
{/* Data table */}
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-border">
<h3 className="text-sm font-medium text-text-primary">Snapshot Data</h3>
<p className="text-xs text-text-muted mt-0.5">
{formatNumber(tableData.length)} records
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
{(
[
['date', 'Date'],
['platform', 'Platform'],
['views', 'Views'],
['clicks', 'Clicks'],
['ctr', 'CTR'],
['bookings', 'Bookings'],
['occupancy', 'Occupancy'],
['adr', 'ADR'],
['revenue', 'Revenue'],
] as [SortField, string][]
).map(([field, label]) => (
<th
key={field}
onClick={() => toggleSort(field)}
className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-text-muted cursor-pointer select-none hover:text-text-primary transition-colors whitespace-nowrap"
>
<span className="inline-flex items-center gap-1">
{label}
<ArrowUpDown
className={cn(
'w-3 h-3',
sortField === field ? 'text-accent' : 'opacity-30'
)}
/>
</span>
</th>
))}
</tr>
</thead>
<tbody>
{tableData.slice(0, 50).map((row, i) => (
<tr
key={`${row.date}-${row.platform}-${i}`}
className="border-b border-border last:border-0 hover:bg-white/[0.02] transition-colors"
>
<td className="px-4 py-2 text-text-primary whitespace-nowrap">
{format(parseISO(row.date), 'MMM d, yyyy')}
</td>
<td className="px-4 py-2">
<span
className={cn(
'text-xs font-medium px-1.5 py-0.5 rounded',
row.platform === 'airbnb'
? 'bg-[#ff5a5f]/10 text-[#ff5a5f]'
: 'bg-[#3b5998]/10 text-[#3b5998]'
)}
>
{row.platform === 'airbnb' ? 'Airbnb' : 'VRBO'}
</span>
</td>
<td className="px-4 py-2 font-mono text-text-primary">
{formatNumber(row.views)}
</td>
<td className="px-4 py-2 font-mono text-text-primary">
{formatNumber(row.clicks)}
</td>
<td className="px-4 py-2 font-mono text-text-primary">
{formatPercent(row.ctr)}
</td>
<td className="px-4 py-2 font-mono text-text-primary">
{row.bookings}
</td>
<td className="px-4 py-2 font-mono text-text-primary">
{formatPercent(row.occupancy)}
</td>
<td className="px-4 py-2 font-mono text-text-primary">
{formatCurrency(row.adr)}
</td>
<td className="px-4 py-2 font-mono text-accent font-medium">
{formatCurrency(row.revenue)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{tableData.length > 50 && (
<div className="px-4 py-2 border-t border-border text-xs text-text-muted">
Showing 50 of {formatNumber(tableData.length)} records
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,714 @@
import { useState, useMemo, useCallback, type MouseEvent } from 'react';
import {
ChevronLeft,
ChevronRight,
X,
AlertTriangle,
ArrowUpRight,
ArrowDownRight,
Filter,
SlidersHorizontal,
} from 'lucide-react';
import {
startOfMonth,
endOfMonth,
eachDayOfInterval,
format,
addMonths,
subMonths,
getDay,
isSameDay,
isSameMonth,
isWeekend,
addDays,
isWithinInterval,
startOfDay,
} from 'date-fns';
import { cn, formatCurrency } from '@/lib/utils';
import { PLATFORM_LABELS, PLATFORM_COLORS } from '@/lib/constants';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface DayPricing {
date: Date;
prices: Record<string, number>; // platform -> price
}
interface PriceChangeRecord {
id: string;
date: Date;
platform: string;
oldPrice: number;
newPrice: number;
changedBy: string;
note: string;
experiment?: string;
changedAt: Date;
}
interface PreviewRow {
date: Date;
platform: string;
currentPrice: number;
newPrice: number;
delta: number;
}
// ---------------------------------------------------------------------------
// Mock data generator
// ---------------------------------------------------------------------------
const PLATFORMS = ['airbnb', 'vrbo'] as const;
function generateMockPricing(baseDate: Date): DayPricing[] {
const start = addDays(startOfMonth(baseDate), -7);
const end = addDays(endOfMonth(addMonths(baseDate, 1)), 7);
const days = eachDayOfInterval({ start, end });
return days.map((date) => {
const base = 180 + Math.sin(date.getTime() / 864000000) * 40;
const weekendBoost = isWeekend(date) ? 1.2 : 1.0;
const airbnbPrice = Math.round(base * weekendBoost + (Math.random() - 0.5) * 30);
const vrboPrice = Math.round(airbnbPrice * (0.92 + Math.random() * 0.08));
return {
date,
prices: {
airbnb: Math.max(150, Math.min(300, airbnbPrice)),
vrbo: Math.max(150, Math.min(300, vrboPrice)),
},
};
});
}
function generateMockChangeLog(): PriceChangeRecord[] {
const records: PriceChangeRecord[] = [];
const now = new Date();
for (let i = 0; i < 12; i++) {
const d = addDays(now, -i * 3);
const platform = i % 2 === 0 ? 'airbnb' : 'vrbo';
const oldPrice = 180 + Math.round(Math.random() * 60);
const newPrice = oldPrice + Math.round((Math.random() - 0.4) * 40);
records.push({
id: `chg-${i}`,
date: addDays(d, 2),
platform,
oldPrice,
newPrice,
changedBy: 'admin',
note: i % 3 === 0 ? 'Weekend rate bump' : '',
experiment: i % 4 === 0 ? 'EXP-001' : undefined,
changedAt: d,
});
}
return records;
}
const MOCK_EXPERIMENTS = [
{ id: 'exp-1', name: 'EXP-001: Weekend +15%' },
{ id: 'exp-2', name: 'EXP-002: Midweek Discount' },
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function priceDeviationClass(price: number, avg: number): string {
const ratio = price / avg;
if (ratio < 0.9) return 'text-accent';
if (ratio <= 1.1) return 'text-text-primary';
if (ratio <= 1.25) return 'text-warning';
return 'text-danger';
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function Pricing() {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDays, setSelectedDays] = useState<Date[]>([]);
const [shiftAnchor, setShiftAnchor] = useState<Date | null>(null);
// Price change panel state
const [newPrice, setNewPrice] = useState<string>('');
const [selectedPlatforms, setSelectedPlatforms] = useState<Record<string, boolean>>({
airbnb: true,
vrbo: true,
});
const [note, setNote] = useState('');
const [linkedExperiment, setLinkedExperiment] = useState('');
// Preview modal
const [previewRows, setPreviewRows] = useState<PreviewRow[] | null>(null);
// Change log filter
const [logPlatformFilter, setLogPlatformFilter] = useState<string>('all');
// Mock data
const [pricingData, setPricingData] = useState<DayPricing[]>(() => generateMockPricing(new Date()));
const [changeLog, setChangeLog] = useState<PriceChangeRecord[]>(() => generateMockChangeLog());
// Derived
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const calendarDays = useMemo(() => eachDayOfInterval({ start: monthStart, end: monthEnd }), [monthStart.getTime(), monthEnd.getTime()]);
const startPadding = getDay(monthStart); // 0=Sun
const avgPrice = useMemo(() => {
const relevantDays = pricingData.filter((d) => isSameMonth(d.date, currentMonth));
if (relevantDays.length === 0) return 200;
const total = relevantDays.reduce((sum, d) => {
const vals = Object.values(d.prices);
return sum + vals.reduce((a, b) => a + b, 0) / vals.length;
}, 0);
return total / relevantDays.length;
}, [pricingData, currentMonth]);
const getPricing = useCallback(
(date: Date): Record<string, number> | undefined => {
const found = pricingData.find((d) => isSameDay(d.date, date));
return found?.prices;
},
[pricingData],
);
const isSelected = useCallback(
(date: Date) => selectedDays.some((d) => isSameDay(d, date)),
[selectedDays],
);
// ---------------------------------------------------------------------------
// Event handlers
// ---------------------------------------------------------------------------
function handleDayClick(date: Date, e: MouseEvent) {
if (e.shiftKey && shiftAnchor) {
const range = shiftAnchor < date
? eachDayOfInterval({ start: shiftAnchor, end: date })
: eachDayOfInterval({ start: date, end: shiftAnchor });
setSelectedDays((prev) => {
const existing = prev.filter((d) => !range.some((r) => isSameDay(r, d)));
return [...existing, ...range];
});
} else {
setSelectedDays((prev) => {
const already = prev.some((d) => isSameDay(d, date));
if (already) return prev.filter((d) => !isSameDay(d, date));
return [...prev, date];
});
setShiftAnchor(date);
}
}
function handlePreview() {
const price = parseFloat(newPrice);
if (isNaN(price) || price <= 0 || selectedDays.length === 0) return;
const rows: PreviewRow[] = [];
const platforms = Object.entries(selectedPlatforms)
.filter(([, checked]) => checked)
.map(([p]) => p);
for (const day of selectedDays) {
const current = getPricing(day);
for (const platform of platforms) {
const currentPrice = current?.[platform] ?? 0;
rows.push({
date: day,
platform,
currentPrice,
newPrice: price,
delta: price - currentPrice,
});
}
}
rows.sort((a, b) => a.date.getTime() - b.date.getTime());
setPreviewRows(rows);
}
function handleConfirmApply() {
if (!previewRows) return;
// Apply to pricing data
setPricingData((prev) => {
const next = [...prev];
for (const row of previewRows) {
const idx = next.findIndex((d) => isSameDay(d.date, row.date));
if (idx >= 0) {
next[idx] = {
...next[idx],
prices: { ...next[idx].prices, [row.platform]: row.newPrice },
};
}
}
return next;
});
// Add change log entries
const newRecords: PriceChangeRecord[] = previewRows.map((row, i) => ({
id: `chg-new-${Date.now()}-${i}`,
date: row.date,
platform: row.platform,
oldPrice: row.currentPrice,
newPrice: row.newPrice,
changedBy: 'admin',
note,
experiment: linkedExperiment || undefined,
changedAt: new Date(),
}));
setChangeLog((prev) => [...newRecords, ...prev]);
// Reset
setPreviewRows(null);
setSelectedDays([]);
setNewPrice('');
setNote('');
setLinkedExperiment('');
}
const filteredLog = useMemo(
() => (logPlatformFilter === 'all' ? changeLog : changeLog.filter((r) => r.platform === logPlatformFilter)),
[changeLog, logPlatformFilter],
);
const hasSelection = selectedDays.length > 0;
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<h1 className="text-lg font-semibold text-text-primary">Pricing</h1>
<p className="text-xs text-text-muted mt-0.5">
Select dates on the calendar, then set new prices in the panel.
</p>
</div>
{hasSelection && (
<button
onClick={() => setSelectedDays([])}
className="text-xs text-text-muted hover:text-text-primary transition-colors flex items-center gap-1"
>
<X className="w-3 h-3" />
Clear {selectedDays.length} selected
</button>
)}
</div>
{/* Main layout: calendar + sidebar */}
<div className="flex flex-col lg:flex-row gap-4">
{/* Calendar */}
<div className="flex-1 min-w-0">
{/* Month navigation */}
<div className="flex items-center justify-between mb-3">
<button
onClick={() => setCurrentMonth((m) => subMonths(m, 1))}
className="p-1.5 rounded-md hover:bg-white/5 text-text-muted hover:text-text-primary transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
<h2 className="text-sm font-medium text-text-primary">
{format(currentMonth, 'MMMM yyyy')}
</h2>
<button
onClick={() => setCurrentMonth((m) => addMonths(m, 1))}
className="p-1.5 rounded-md hover:bg-white/5 text-text-muted hover:text-text-primary transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 gap-px mb-px">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
<div key={d} className="text-center text-[10px] text-text-muted uppercase tracking-wider py-1.5">
{d}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-px">
{/* Empty cells for start padding */}
{Array.from({ length: startPadding }).map((_, i) => (
<div key={`pad-${i}`} className="bg-surface/30 rounded-md min-h-[60px] sm:min-h-[72px]" />
))}
{/* Day cells */}
{calendarDays.map((day) => {
const prices = getPricing(day);
const selected = isSelected(day);
return (
<button
key={day.toISOString()}
onClick={(e) => handleDayClick(day, e)}
className={cn(
'bg-surface border rounded-md p-1 sm:p-1.5 min-h-[60px] sm:min-h-[72px] text-left transition-all',
'hover:bg-white/5 cursor-pointer',
selected ? 'border-accent ring-1 ring-accent/40' : 'border-border',
)}
>
<span
className={cn(
'block text-[10px] sm:text-xs font-medium mb-0.5',
isWeekend(day) ? 'text-text-muted' : 'text-text-primary',
)}
>
{format(day, 'd')}
</span>
{prices && (
<div className="space-y-0.5">
{PLATFORMS.map((p) => {
const price = prices[p];
if (price == null) return null;
return (
<div key={p} className="flex items-center gap-1">
<span
className="w-1 h-1 rounded-full flex-shrink-0"
style={{ backgroundColor: PLATFORM_COLORS[p] }}
/>
<span
className={cn(
'text-[9px] sm:text-[10px] font-mono leading-none',
priceDeviationClass(price, avgPrice),
)}
>
{formatCurrency(price)}
</span>
</div>
);
})}
</div>
)}
</button>
);
})}
</div>
</div>
{/* Price Change Panel */}
<div
className={cn(
'lg:w-72 xl:w-80 flex-shrink-0 transition-all',
hasSelection ? 'opacity-100' : 'opacity-40 pointer-events-none',
)}
>
<div className="bg-surface border border-border rounded-lg p-4 space-y-4 sticky top-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-text-primary flex items-center gap-2">
<SlidersHorizontal className="w-3.5 h-3.5 text-text-muted" />
Price Change
</h3>
<span className="text-[10px] text-text-muted bg-white/5 px-1.5 py-0.5 rounded">
{selectedDays.length} day{selectedDays.length !== 1 ? 's' : ''}
</span>
</div>
{/* New price input */}
<div>
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
New Price (USD)
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted text-sm">$</span>
<input
type="number"
min={1}
step={1}
value={newPrice}
onChange={(e) => setNewPrice(e.target.value)}
placeholder="0"
className={cn(
'w-full rounded-md bg-background border border-border pl-7 pr-3 py-2 text-sm text-text-primary',
'placeholder:text-text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent',
'transition-colors font-mono',
)}
/>
</div>
</div>
{/* Platform checkboxes */}
<div>
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1.5">
Platforms
</label>
<div className="space-y-1.5">
{PLATFORMS.map((p) => (
<label
key={p}
className="flex items-center gap-2 text-sm text-text-primary cursor-pointer"
>
<input
type="checkbox"
checked={selectedPlatforms[p] ?? false}
onChange={(e) =>
setSelectedPlatforms((prev) => ({ ...prev, [p]: e.target.checked }))
}
className="rounded border-border bg-background text-accent focus:ring-accent focus:ring-offset-0 h-3.5 w-3.5"
/>
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: PLATFORM_COLORS[p] }}
/>
{PLATFORM_LABELS[p]}
</label>
))}
</div>
</div>
{/* Note */}
<div>
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
Note (optional)
</label>
<input
type="text"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="e.g. Weekend rate bump"
className={cn(
'w-full rounded-md bg-background border border-border px-3 py-1.5 text-xs text-text-primary',
'placeholder:text-text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent',
'transition-colors',
)}
/>
</div>
{/* Link to experiment */}
<div>
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
Link to Experiment (optional)
</label>
<select
value={linkedExperiment}
onChange={(e) => setLinkedExperiment(e.target.value)}
className={cn(
'w-full rounded-md bg-background border border-border px-3 py-1.5 text-xs text-text-primary',
'focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors',
)}
>
<option value="">None</option>
{MOCK_EXPERIMENTS.map((exp) => (
<option key={exp.id} value={exp.name}>
{exp.name}
</option>
))}
</select>
</div>
{/* Preview button */}
<button
onClick={handlePreview}
disabled={!newPrice || parseFloat(newPrice) <= 0 || !Object.values(selectedPlatforms).some(Boolean)}
className={cn(
'w-full rounded-md bg-accent text-black font-semibold py-2 text-sm',
'hover:bg-accent/90 transition-colors',
'disabled:opacity-40 disabled:cursor-not-allowed',
)}
>
Preview Changes
</button>
</div>
</div>
</div>
{/* Preview Modal */}
{previewRows && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="bg-surface border border-border rounded-lg w-full max-w-2xl max-h-[80vh] flex flex-col">
{/* Modal header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="text-sm font-semibold text-text-primary">Preview Price Changes</h3>
<button
onClick={() => setPreviewRows(null)}
className="p-1 text-text-muted hover:text-text-primary transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Warning */}
<div className="mx-5 mt-4 flex items-start gap-2 bg-warning/10 border border-warning/20 rounded-md px-3 py-2">
<AlertTriangle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
<p className="text-xs text-warning">
This will update prices on the selected platforms. Review carefully before confirming.
</p>
</div>
{/* Diff table */}
<div className="flex-1 overflow-auto px-5 py-3">
<table className="w-full text-xs">
<thead>
<tr className="text-text-muted text-[10px] uppercase tracking-wider border-b border-border">
<th className="text-left py-2 pr-2">Date</th>
<th className="text-left py-2 pr-2">Platform</th>
<th className="text-right py-2 pr-2">Current</th>
<th className="text-right py-2 pr-2">New</th>
<th className="text-right py-2">Delta</th>
</tr>
</thead>
<tbody>
{previewRows.map((row, i) => (
<tr key={i} className="border-b border-border/50">
<td className="py-1.5 pr-2 text-text-primary font-mono">
{format(row.date, 'MMM d')}
</td>
<td className="py-1.5 pr-2">
<span className="flex items-center gap-1.5">
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: PLATFORM_COLORS[row.platform] }}
/>
<span className="text-text-primary">{PLATFORM_LABELS[row.platform]}</span>
</span>
</td>
<td className="py-1.5 pr-2 text-right text-text-muted font-mono">
{formatCurrency(row.currentPrice)}
</td>
<td className="py-1.5 pr-2 text-right text-text-primary font-mono">
{formatCurrency(row.newPrice)}
</td>
<td
className={cn(
'py-1.5 text-right font-mono flex items-center justify-end gap-0.5',
row.delta > 0 ? 'text-danger' : row.delta < 0 ? 'text-accent' : 'text-text-muted',
)}
>
{row.delta > 0 ? (
<ArrowUpRight className="w-3 h-3" />
) : row.delta < 0 ? (
<ArrowDownRight className="w-3 h-3" />
) : null}
{row.delta > 0 ? '+' : ''}
{formatCurrency(row.delta)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Modal footer */}
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-border">
<button
onClick={() => setPreviewRows(null)}
className="rounded-md border border-border px-4 py-1.5 text-xs text-text-muted hover:text-text-primary transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirmApply}
className={cn(
'rounded-md bg-accent text-black font-semibold px-4 py-1.5 text-xs',
'hover:bg-accent/90 transition-colors',
)}
>
Confirm &amp; Apply
</button>
</div>
</div>
</div>
)}
{/* Price Change Log */}
<div className="bg-surface border border-border rounded-lg">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-medium text-text-primary">Price Change Log</h3>
<div className="flex items-center gap-2">
<Filter className="w-3 h-3 text-text-muted" />
<select
value={logPlatformFilter}
onChange={(e) => setLogPlatformFilter(e.target.value)}
className={cn(
'rounded-md bg-background border border-border px-2 py-1 text-[10px] text-text-primary',
'focus:outline-none focus:ring-1 focus:ring-accent transition-colors',
)}
>
<option value="all">All Platforms</option>
{PLATFORMS.map((p) => (
<option key={p} value={p}>
{PLATFORM_LABELS[p]}
</option>
))}
</select>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-text-muted text-[10px] uppercase tracking-wider border-b border-border">
<th className="text-left px-4 py-2">Date</th>
<th className="text-left px-4 py-2">Platform</th>
<th className="text-right px-4 py-2">Old</th>
<th className="text-right px-4 py-2">New</th>
<th className="text-left px-4 py-2">Changed By</th>
<th className="text-left px-4 py-2">Note</th>
<th className="text-left px-4 py-2">Experiment</th>
</tr>
</thead>
<tbody>
{filteredLog.slice(0, 20).map((record) => {
const delta = record.newPrice - record.oldPrice;
return (
<tr key={record.id} className="border-b border-border/50 hover:bg-white/[0.02]">
<td className="px-4 py-2 text-text-primary font-mono">
{format(record.date, 'MMM d, yyyy')}
</td>
<td className="px-4 py-2">
<span className="flex items-center gap-1.5">
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: PLATFORM_COLORS[record.platform] }}
/>
<span className="text-text-primary">{PLATFORM_LABELS[record.platform]}</span>
</span>
</td>
<td className="px-4 py-2 text-right text-text-muted font-mono">
{formatCurrency(record.oldPrice)}
</td>
<td
className={cn(
'px-4 py-2 text-right font-mono',
delta > 0 ? 'text-danger' : delta < 0 ? 'text-accent' : 'text-text-primary',
)}
>
{formatCurrency(record.newPrice)}
</td>
<td className="px-4 py-2 text-text-muted">{record.changedBy}</td>
<td className="px-4 py-2 text-text-muted max-w-[160px] truncate">
{record.note || '\u2014'}
</td>
<td className="px-4 py-2">
{record.experiment ? (
<span className="text-accent bg-accent/10 px-1.5 py-0.5 rounded text-[10px]">
{record.experiment}
</span>
) : (
<span className="text-text-muted">{'\u2014'}</span>
)}
</td>
</tr>
);
})}
{filteredLog.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text-muted text-xs">
No price changes recorded yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,367 @@
import { useState, useMemo } from 'react';
import { cn, formatCurrency, formatPercent, formatDate } from '@/lib/utils';
import { PLATFORM_COLORS } from '@/lib/constants';
import {
CalendarDays,
ArrowUpDown,
Filter,
ChevronDown,
} from 'lucide-react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
// ── Types ──────────────────────────────────────────────────────────────
type ReservationStatus = 'confirmed' | 'completed' | 'cancelled';
type Platform = 'airbnb' | 'vrbo';
type SortField = 'guest' | 'checkin' | 'checkout' | 'nights' | 'rate' | 'total' | 'status';
type SortDir = 'asc' | 'desc';
interface Reservation {
id: string;
guest: string;
platform: Platform;
checkin: string;
checkout: string;
nights: number;
nightlyRate: number;
totalPayout: number;
status: ReservationStatus;
}
// ── Mock Data ──────────────────────────────────────────────────────────
const MOCK_RESERVATIONS: Reservation[] = [
{ id: 'r-01', guest: 'Sarah Mitchell', platform: 'airbnb', checkin: '2025-10-04', checkout: '2025-10-08', nights: 4, nightlyRate: 195, totalPayout: 741, status: 'completed' },
{ id: 'r-02', guest: 'James Park', platform: 'vrbo', checkin: '2025-10-12', checkout: '2025-10-15', nights: 3, nightlyRate: 185, totalPayout: 527, status: 'completed' },
{ id: 'r-03', guest: 'Emily Rodriguez', platform: 'airbnb', checkin: '2025-10-22', checkout: '2025-10-27', nights: 5, nightlyRate: 210, totalPayout: 999, status: 'completed' },
{ id: 'r-04', guest: 'Michael Chen', platform: 'airbnb', checkin: '2025-11-01', checkout: '2025-11-04', nights: 3, nightlyRate: 175, totalPayout: 499, status: 'completed' },
{ id: 'r-05', guest: 'Lisa Thompson', platform: 'vrbo', checkin: '2025-11-10', checkout: '2025-11-17', nights: 7, nightlyRate: 165, totalPayout: 1098, status: 'completed' },
{ id: 'r-06', guest: 'David Kim', platform: 'airbnb', checkin: '2025-11-22', checkout: '2025-11-24', nights: 2, nightlyRate: 220, totalPayout: 418, status: 'cancelled' },
{ id: 'r-07', guest: 'Amanda Foster', platform: 'vrbo', checkin: '2025-12-05', checkout: '2025-12-09', nights: 4, nightlyRate: 230, totalPayout: 874, status: 'completed' },
{ id: 'r-08', guest: 'Robert Johnson', platform: 'airbnb', checkin: '2025-12-18', checkout: '2025-12-25', nights: 7, nightlyRate: 250, totalPayout: 1663, status: 'completed' },
{ id: 'r-09', guest: 'Jennifer Lee', platform: 'airbnb', checkin: '2025-12-28', checkout: '2025-12-31', nights: 3, nightlyRate: 245, totalPayout: 698, status: 'completed' },
{ id: 'r-10', guest: 'Chris Martinez', platform: 'vrbo', checkin: '2026-01-03', checkout: '2026-01-06', nights: 3, nightlyRate: 180, totalPayout: 513, status: 'completed' },
{ id: 'r-11', guest: 'Natalie Wright', platform: 'airbnb', checkin: '2026-01-15', checkout: '2026-01-20', nights: 5, nightlyRate: 190, totalPayout: 903, status: 'completed' },
{ id: 'r-12', guest: 'Kevin Brown', platform: 'vrbo', checkin: '2026-01-28', checkout: '2026-02-01', nights: 4, nightlyRate: 175, totalPayout: 665, status: 'completed' },
{ id: 'r-13', guest: 'Patricia Davis', platform: 'airbnb', checkin: '2026-02-07', checkout: '2026-02-12', nights: 5, nightlyRate: 200, totalPayout: 950, status: 'completed' },
{ id: 'r-14', guest: 'Thomas Wilson', platform: 'airbnb', checkin: '2026-02-20', checkout: '2026-02-22', nights: 2, nightlyRate: 215, totalPayout: 409, status: 'cancelled' },
{ id: 'r-15', guest: 'Rachel Garcia', platform: 'vrbo', checkin: '2026-03-01', checkout: '2026-03-05', nights: 4, nightlyRate: 205, totalPayout: 779, status: 'confirmed' },
{ id: 'r-16', guest: 'Daniel Taylor', platform: 'airbnb', checkin: '2026-03-10', checkout: '2026-03-14', nights: 4, nightlyRate: 210, totalPayout: 798, status: 'confirmed' },
{ id: 'r-17', guest: 'Stephanie Moore', platform: 'vrbo', checkin: '2026-03-20', checkout: '2026-03-26', nights: 6, nightlyRate: 195, totalPayout: 1112, status: 'confirmed' },
{ id: 'r-18', guest: 'Brian Anderson', platform: 'airbnb', checkin: '2026-03-28', checkout: '2026-03-31', nights: 3, nightlyRate: 225, totalPayout: 641, status: 'confirmed' },
];
const STATUS_STYLES: Record<ReservationStatus, string> = {
confirmed: 'bg-green-500/10 text-green-400',
completed: 'bg-blue-500/10 text-blue-400',
cancelled: 'bg-red-500/10 text-red-400',
};
const PLATFORM_DOT: Record<Platform, string> = {
airbnb: PLATFORM_COLORS.airbnb,
vrbo: PLATFORM_COLORS.vrbo,
};
// ── Component ──────────────────────────────────────────────────────────
export default function Reservations() {
const [platformFilter, setPlatformFilter] = useState<Platform | 'all'>('all');
const [statusFilter, setStatusFilter] = useState<ReservationStatus | 'all'>('all');
const [sortField, setSortField] = useState<SortField>('checkin');
const [sortDir, setSortDir] = useState<SortDir>('desc');
function toggleSort(field: SortField) {
if (sortField === field) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDir('asc');
}
}
const filtered = useMemo(() => {
let list = [...MOCK_RESERVATIONS];
if (platformFilter !== 'all') list = list.filter((r) => r.platform === platformFilter);
if (statusFilter !== 'all') list = list.filter((r) => r.status === statusFilter);
list.sort((a, b) => {
const dir = sortDir === 'asc' ? 1 : -1;
switch (sortField) {
case 'guest': return a.guest.localeCompare(b.guest) * dir;
case 'checkin': return (new Date(a.checkin).getTime() - new Date(b.checkin).getTime()) * dir;
case 'checkout': return (new Date(a.checkout).getTime() - new Date(b.checkout).getTime()) * dir;
case 'nights': return (a.nights - b.nights) * dir;
case 'rate': return (a.nightlyRate - b.nightlyRate) * dir;
case 'total': return (a.totalPayout - b.totalPayout) * dir;
case 'status': return a.status.localeCompare(b.status) * dir;
default: return 0;
}
});
return list;
}, [platformFilter, statusFilter, sortField, sortDir]);
// Monthly revenue chart data
const monthlyRevenue = useMemo(() => {
const map = new Map<string, number>();
MOCK_RESERVATIONS.filter((r) => r.status !== 'cancelled').forEach((r) => {
const d = new Date(r.checkin);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
map.set(key, (map.get(key) || 0) + r.totalPayout);
});
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, revenue]) => {
const [y, m] = month.split('-');
const label = new Date(Number(y), Number(m) - 1).toLocaleDateString('en-US', {
month: 'short',
year: '2-digit',
});
return { month: label, revenue };
});
}, []);
// Monthly occupancy summary
const monthlyOccupancy = useMemo(() => {
const map = new Map<string, number>();
MOCK_RESERVATIONS.filter((r) => r.status !== 'cancelled').forEach((r) => {
const d = new Date(r.checkin);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
map.set(key, (map.get(key) || 0) + r.nights);
});
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, nights]) => {
const [y, m] = month.split('-');
const daysInMonth = new Date(Number(y), Number(m), 0).getDate();
const label = new Date(Number(y), Number(m) - 1).toLocaleDateString('en-US', {
month: 'short',
year: '2-digit',
});
return { month: label, nights, daysInMonth, occupancy: nights / daysInMonth };
});
}, []);
const totalRevenue = filtered
.filter((r) => r.status !== 'cancelled')
.reduce((sum, r) => sum + r.totalPayout, 0);
const SortHeader = ({ field, label, align }: { field: SortField; label: string; align?: string }) => (
<th
className={cn('pb-2 pr-4 font-medium cursor-pointer select-none hover:text-text-primary transition-colors', align)}
onClick={() => toggleSort(field)}
>
<span className="inline-flex items-center gap-1">
{label}
{sortField === field && (
<ArrowUpDown className="w-3 h-3 text-accent" />
)}
</span>
</th>
);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<CalendarDays className="w-5 h-5 text-accent" />
<h1 className="text-lg font-semibold text-text-primary">Reservations</h1>
<span className="text-xs text-text-muted bg-surface border border-border rounded-full px-2 py-0.5">
{filtered.length} reservations
</span>
</div>
{/* Revenue chart + occupancy summary */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Bar chart */}
<div className="lg:col-span-2 bg-surface border border-border rounded-lg p-5">
<h2 className="text-sm font-medium text-text-primary mb-4">Revenue by Month</h2>
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={monthlyRevenue} barCategoryGap="20%">
<CartesianGrid strokeDasharray="3 3" stroke="#262626" />
<XAxis
dataKey="month"
tick={{ fill: '#737373', fontSize: 12 }}
axisLine={{ stroke: '#262626' }}
tickLine={false}
/>
<YAxis
tick={{ fill: '#737373', fontSize: 12 }}
axisLine={{ stroke: '#262626' }}
tickLine={false}
tickFormatter={(v) => `$${(v / 1000).toFixed(1)}k`}
/>
<Tooltip
contentStyle={{
backgroundColor: '#141414',
border: '1px solid #262626',
borderRadius: 8,
fontSize: 13,
}}
labelStyle={{ color: '#fafafa' }}
formatter={(value: number) => [formatCurrency(value), 'Revenue']}
/>
<Bar dataKey="revenue" fill="#22c55e" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Occupancy cards */}
<div className="bg-surface border border-border rounded-lg p-5">
<h2 className="text-sm font-medium text-text-primary mb-4">Monthly Occupancy</h2>
<div className="space-y-3">
{monthlyOccupancy.map((m) => (
<div key={m.month} className="flex items-center justify-between">
<span className="text-sm text-text-muted">{m.month}</span>
<div className="flex items-center gap-3 flex-1 ml-4">
<div className="flex-1 h-2 bg-background rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all"
style={{ width: `${Math.min(m.occupancy * 100, 100)}%` }}
/>
</div>
<span className="font-mono text-sm text-text-primary w-12 text-right">
{formatPercent(m.occupancy)}
</span>
</div>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-border">
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Total Revenue</span>
<span className="font-mono font-semibold text-accent">
{formatCurrency(totalRevenue)}
</span>
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5 text-text-muted">
<Filter className="w-4 h-4" />
<span className="text-xs uppercase tracking-wide">Filters</span>
</div>
{/* Platform filter */}
<div className="relative">
<select
value={platformFilter}
onChange={(e) => setPlatformFilter(e.target.value as Platform | 'all')}
className={cn(
'appearance-none rounded-md bg-surface border border-border pl-3 pr-8 py-1.5 text-sm text-text-primary',
'focus:outline-none focus:ring-1 focus:ring-accent cursor-pointer'
)}
>
<option value="all">All Platforms</option>
<option value="airbnb">Airbnb</option>
<option value="vrbo">VRBO</option>
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-muted pointer-events-none" />
</div>
{/* Status filter */}
<div className="relative">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as ReservationStatus | 'all')}
className={cn(
'appearance-none rounded-md bg-surface border border-border pl-3 pr-8 py-1.5 text-sm text-text-primary',
'focus:outline-none focus:ring-1 focus:ring-accent cursor-pointer'
)}
>
<option value="all">All Statuses</option>
<option value="confirmed">Confirmed</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-muted pointer-events-none" />
</div>
</div>
{/* Reservations table */}
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted border-b border-border px-5">
<SortHeader field="guest" label="Guest" />
<th className="pb-2 pr-4 font-medium">Platform</th>
<SortHeader field="checkin" label="Check-in" />
<SortHeader field="checkout" label="Check-out" />
<SortHeader field="nights" label="Nights" align="text-right" />
<SortHeader field="rate" label="Nightly Rate" align="text-right" />
<SortHeader field="total" label="Total Payout" align="text-right" />
<SortHeader field="status" label="Status" />
</tr>
</thead>
<tbody>
{filtered.map((r) => (
<tr
key={r.id}
className="border-b border-border/50 last:border-0 hover:bg-white/[0.02] transition-colors"
>
<td className="px-5 py-3 font-medium text-text-primary whitespace-nowrap">
{r.guest}
</td>
<td className="py-3 pr-4 whitespace-nowrap">
<span className="inline-flex items-center gap-2 text-text-primary capitalize">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: PLATFORM_DOT[r.platform] }}
/>
{r.platform === 'vrbo' ? 'VRBO' : 'Airbnb'}
</span>
</td>
<td className="py-3 pr-4 text-text-primary whitespace-nowrap">
{formatDate(r.checkin)}
</td>
<td className="py-3 pr-4 text-text-primary whitespace-nowrap">
{formatDate(r.checkout)}
</td>
<td className="py-3 pr-4 text-right font-mono text-text-primary">
{r.nights}
</td>
<td className="py-3 pr-4 text-right font-mono text-text-primary">
{formatCurrency(r.nightlyRate)}
</td>
<td className="py-3 pr-4 text-right font-mono font-semibold text-text-primary">
{formatCurrency(r.totalPayout)}
</td>
<td className="py-3 pr-5">
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium capitalize',
STATUS_STYLES[r.status]
)}
>
{r.status}
</span>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={8} className="px-5 py-8 text-center text-text-muted text-sm">
No reservations match the selected filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,573 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
import {
Settings as SettingsIcon,
Eye,
EyeOff,
Loader2,
CheckCircle2,
XCircle,
Send,
Wifi,
WifiOff,
Clock,
Lock,
Mail,
KeyRound,
LogIn,
} from 'lucide-react';
import { api } from '@/lib/api';
// ── Types ──────────────────────────────────────────────────────────────
type SessionStatus = 'active' | 'expired' | 'checking' | 'unknown';
type LoginStatus = 'idle' | 'logging_in' | 'success' | 'error';
// ── Helpers ─────────────────────────────────────────────────────────────
const inputClass = cn(
'w-full rounded-md bg-background 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'
);
const sectionClass = 'bg-surface border border-border rounded-lg p-6';
function SessionBadge({ status }: { status: SessionStatus }) {
if (status === 'checking') {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-amber-400">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Checking...
</span>
);
}
if (status === 'active') {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-green-400">
<Wifi className="w-3.5 h-3.5" />
Session Active
</span>
);
}
if (status === 'expired') {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-red-400">
<WifiOff className="w-3.5 h-3.5" />
Session Expired
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 text-xs text-text-muted">
<Wifi className="w-3.5 h-3.5" />
Unknown
</span>
);
}
const PLATFORM_META: Record<string, { color: string }> = {
airbnb: { color: '#ff5a5f' },
vrbo: { color: '#3b5998' },
};
// ── Component ──────────────────────────────────────────────────────────
export default function Settings() {
const queryClient = useQueryClient();
// ── Platform data from API ──────────────────────────────────────────
const platformsQuery = useQuery({
queryKey: ['platforms'],
queryFn: () => api.getPlatforms(),
retry: false,
});
const platforms = platformsQuery.data ?? [];
// ── Session status per platform ─────────────────────────────────────
const [sessionStatuses, setSessionStatuses] = useState<Record<string, SessionStatus>>({});
const [loginStatuses, setLoginStatuses] = useState<Record<string, LoginStatus>>({});
const [loginErrors, setLoginErrors] = useState<Record<string, string>>({});
// ── Credential editing ──────────────────────────────────────────────
const [editingPlatform, setEditingPlatform] = useState<string | null>(null);
const [credEmail, setCredEmail] = useState('');
const [credPassword, setCredPassword] = useState('');
const [showCredPassword, setShowCredPassword] = useState(false);
// App password
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordSaved, setPasswordSaved] = useState(false);
// Scrape schedule
const [scrapeTime, setScrapeTime] = useState('06:00');
const [scheduleSaved, setScheduleSaved] = useState(false);
// SMTP
const [smtpHost, setSmtpHost] = useState('smtp.gmail.com');
const [smtpPort, setSmtpPort] = useState('587');
const [smtpUser, setSmtpUser] = useState('');
const [smtpPassword, setSmtpPassword] = useState('');
const [showSmtpPassword, setShowSmtpPassword] = useState(false);
const [smtpTestStatus, setSmtpTestStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle');
// ── Mutations ───────────────────────────────────────────────────────
const credentialsMutation = useMutation({
mutationFn: ({ id, credentials }: { id: string; credentials: { email: string; password: string } }) =>
api.updateCredentials(id, credentials),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['platforms'] });
setEditingPlatform(null);
setCredEmail('');
setCredPassword('');
},
});
// ── Handlers ────────────────────────────────────────────────────────
async function handleCheckSession(platformId: string) {
setSessionStatuses((prev) => ({ ...prev, [platformId]: 'checking' }));
try {
const result = await api.checkSession(platformId);
setSessionStatuses((prev) => ({
...prev,
[platformId]: result.sessionValid ? 'active' : 'expired',
}));
} catch {
setSessionStatuses((prev) => ({ ...prev, [platformId]: 'expired' }));
}
}
async function handleLogin(platformId: string) {
setLoginStatuses((prev) => ({ ...prev, [platformId]: 'logging_in' }));
setLoginErrors((prev) => ({ ...prev, [platformId]: '' }));
try {
const creds = credEmail && credPassword ? { email: credEmail, password: credPassword } : undefined;
await api.loginPlatform(platformId, creds);
setLoginStatuses((prev) => ({ ...prev, [platformId]: 'success' }));
setSessionStatuses((prev) => ({ ...prev, [platformId]: 'active' }));
setTimeout(() => setLoginStatuses((prev) => ({ ...prev, [platformId]: 'idle' })), 3000);
} catch (err: any) {
setLoginStatuses((prev) => ({ ...prev, [platformId]: 'error' }));
setLoginErrors((prev) => ({ ...prev, [platformId]: err.message || 'Login failed' }));
}
}
function handleUpdateCredentials(platformId: string) {
if (!credEmail || !credPassword) return;
credentialsMutation.mutate({ id: platformId, credentials: { email: credEmail, password: credPassword } });
}
function startEditingPlatform(platform: any) {
setEditingPlatform(platform.id);
setCredEmail('');
setCredPassword('');
setShowCredPassword(false);
}
function handleSavePassword() {
if (!newPassword || newPassword !== confirmPassword) return;
setPasswordSaved(true);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setTimeout(() => setPasswordSaved(false), 3000);
}
function handleSaveSchedule() {
setScheduleSaved(true);
setTimeout(() => setScheduleSaved(false), 3000);
}
function handleTestEmail() {
setSmtpTestStatus('sending');
setTimeout(() => {
setSmtpTestStatus('success');
setTimeout(() => setSmtpTestStatus('idle'), 3000);
}, 2000);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<SettingsIcon className="w-5 h-5 text-accent" />
<h1 className="text-lg font-semibold text-text-primary">Settings</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* ─── Platform Credentials & Session ─── */}
<div className={cn(sectionClass, 'lg:col-span-2')}>
<div className="flex items-center gap-2 mb-5">
<KeyRound className="w-4 h-4 text-text-muted" />
<h2 className="text-sm font-semibold text-text-primary uppercase tracking-wide">
Platform Connections
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{platforms.map((platform: any) => {
const meta = PLATFORM_META[platform.id] || { color: '#888' };
const session = sessionStatuses[platform.id] || 'unknown';
const loginStatus = loginStatuses[platform.id] || 'idle';
const loginError = loginErrors[platform.id] || '';
return (
<div
key={platform.id}
className="bg-background border border-border rounded-lg p-4 space-y-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: meta.color }}
/>
<span className="font-medium text-text-primary">{platform.displayName}</span>
</div>
<SessionBadge status={session} />
</div>
<div className="text-xs text-text-muted">
Credentials: {platform.hasCredentials ? (
<span className="text-green-400">Saved</span>
) : (
<span className="text-amber-400">Not configured</span>
)}
</div>
{editingPlatform === platform.id ? (
<div className="space-y-3">
<div>
<label className="block text-xs text-text-muted mb-1">Email</label>
<input
type="email"
value={credEmail}
onChange={(e) => setCredEmail(e.target.value)}
className={inputClass}
placeholder="Airbnb email address"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Password</label>
<div className="relative">
<input
type={showCredPassword ? 'text' : 'password'}
value={credPassword}
onChange={(e) => setCredPassword(e.target.value)}
className={cn(inputClass, 'pr-10')}
placeholder="Platform password"
/>
<button
type="button"
onClick={() => setShowCredPassword(!showCredPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
>
{showCredPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleUpdateCredentials(platform.id)}
disabled={credentialsMutation.isPending}
className={cn(
'rounded-md bg-accent text-black font-semibold px-3 py-1.5 text-xs',
'hover:bg-accent/90 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{credentialsMutation.isPending ? 'Saving...' : 'Save Credentials'}
</button>
<button
onClick={() => handleLogin(platform.id)}
disabled={loginStatus === 'logging_in' || !credEmail || !credPassword}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border border-accent/50 px-3 py-1.5 text-xs text-accent',
'hover:bg-accent/10 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{loginStatus === 'logging_in' ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<LogIn className="w-3 h-3" />
)}
{loginStatus === 'logging_in' ? 'Logging in...' : 'Save & Login'}
</button>
<button
onClick={() => setEditingPlatform(null)}
className="rounded-md px-3 py-1.5 text-xs text-text-muted hover:text-text-primary transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex flex-wrap items-center gap-2 pt-1">
<button
onClick={() => startEditingPlatform(platform)}
className={cn(
'rounded-md border border-border px-3 py-1.5 text-xs text-text-primary',
'hover:bg-white/[0.04] transition-colors'
)}
>
{platform.hasCredentials ? 'Update Credentials' : 'Set Credentials'}
</button>
<button
onClick={() => handleCheckSession(platform.id)}
disabled={session === 'checking'}
className={cn(
'rounded-md border border-border px-3 py-1.5 text-xs text-text-primary',
'hover:bg-white/[0.04] transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
Check Session
</button>
<button
onClick={() => handleLogin(platform.id)}
disabled={loginStatus === 'logging_in'}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border border-accent/50 px-3 py-1.5 text-xs text-accent',
'hover:bg-accent/10 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{loginStatus === 'logging_in' ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<LogIn className="w-3 h-3" />
)}
{loginStatus === 'logging_in' ? 'Logging in...' : 'Login'}
</button>
</div>
)}
{/* Login feedback */}
{loginStatus === 'success' && (
<div className="flex items-center gap-1.5 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
Login successful session active
</div>
)}
{loginStatus === 'error' && (
<div className="flex items-center gap-1.5 text-xs text-red-400">
<XCircle className="w-3.5 h-3.5" />
{loginError || 'Login failed'}
</div>
)}
{loginStatus === 'logging_in' && (
<div className="text-xs text-amber-400">
A browser window will open. Complete MFA if prompted, then wait...
</div>
)}
</div>
);
})}
</div>
</div>
{/* ─── App Password ─── */}
<div className={sectionClass}>
<div className="flex items-center gap-2 mb-5">
<Lock className="w-4 h-4 text-text-muted" />
<h2 className="text-sm font-semibold text-text-primary uppercase tracking-wide">
App Password
</h2>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs text-text-muted mb-1">Current Password</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className={inputClass}
placeholder="Current password"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">New Password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className={inputClass}
placeholder="New password"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={inputClass}
placeholder="Confirm new password"
/>
{confirmPassword && newPassword !== confirmPassword && (
<p className="text-xs text-red-400 mt-1">Passwords do not match</p>
)}
</div>
<div className="flex items-center gap-3 pt-1">
<button
onClick={handleSavePassword}
disabled={!currentPassword || !newPassword || newPassword !== confirmPassword}
className={cn(
'rounded-md bg-accent text-black font-semibold px-4 py-2 text-sm',
'hover:bg-accent/90 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
Update Password
</button>
{passwordSaved && (
<span className="inline-flex items-center gap-1 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
Saved
</span>
)}
</div>
</div>
</div>
{/* ─── Scrape Schedule ─── */}
<div className={sectionClass}>
<div className="flex items-center gap-2 mb-5">
<Clock className="w-4 h-4 text-text-muted" />
<h2 className="text-sm font-semibold text-text-primary uppercase tracking-wide">
Scrape Schedule
</h2>
</div>
<p className="text-sm text-text-muted mb-4">
Set the daily time for automatic platform data scraping. All times are in your local timezone.
</p>
<div className="space-y-3">
<div>
<label className="block text-xs text-text-muted mb-1">Daily Scrape Time</label>
<input
type="time"
value={scrapeTime}
onChange={(e) => setScrapeTime(e.target.value)}
className={cn(inputClass, 'max-w-[200px]')}
/>
</div>
<div className="flex items-center gap-3 pt-1">
<button
onClick={handleSaveSchedule}
className={cn(
'rounded-md bg-accent text-black font-semibold px-4 py-2 text-sm',
'hover:bg-accent/90 transition-colors'
)}
>
Save Schedule
</button>
{scheduleSaved && (
<span className="inline-flex items-center gap-1 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
Saved
</span>
)}
</div>
</div>
</div>
{/* ─── SMTP Configuration ─── */}
<div className={cn(sectionClass, 'lg:col-span-2')}>
<div className="flex items-center gap-2 mb-5">
<Mail className="w-4 h-4 text-text-muted" />
<h2 className="text-sm font-semibold text-text-primary uppercase tracking-wide">
SMTP Configuration
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-text-muted mb-1">SMTP Host</label>
<input
type="text"
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
className={inputClass}
placeholder="smtp.gmail.com"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Port</label>
<input
type="text"
value={smtpPort}
onChange={(e) => setSmtpPort(e.target.value)}
className={inputClass}
placeholder="587"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Username</label>
<input
type="text"
value={smtpUser}
onChange={(e) => setSmtpUser(e.target.value)}
className={inputClass}
placeholder="user@example.com"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Password</label>
<div className="relative">
<input
type={showSmtpPassword ? 'text' : 'password'}
value={smtpPassword}
onChange={(e) => setSmtpPassword(e.target.value)}
className={cn(inputClass, 'pr-10')}
placeholder="App password"
/>
<button
type="button"
onClick={() => setShowSmtpPassword(!showSmtpPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
>
{showSmtpPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
<div className="flex items-center gap-3 mt-4">
<button
onClick={handleTestEmail}
disabled={smtpTestStatus === 'sending'}
className={cn(
'inline-flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm text-text-primary',
'hover:bg-white/[0.04] transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{smtpTestStatus === 'sending' ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
Send Test Email
</button>
{smtpTestStatus === 'success' && (
<span className="inline-flex items-center gap-1 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
Test email sent
</span>
)}
{smtpTestStatus === 'error' && (
<span className="inline-flex items-center gap-1 text-xs text-red-400">
<XCircle className="w-3.5 h-3.5" />
Failed to send
</span>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: 'class',
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
background: '#0a0a0a',
surface: '#141414',
border: '#262626',
accent: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
'text-primary': '#fafafa',
'text-muted': '#737373',
},
fontFamily: {
mono: ['JetBrains Mono', 'IBM Plex Mono', 'monospace'],
sans: ['DM Sans', 'Geist', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
};
export default config;

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});

25
apps/scraper/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@str/scraper",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@str/shared-types": "*",
"fastify": "^5.2.0",
"drizzle-orm": "^0.38.0",
"postgres": "^3.4.0",
"playwright": "^1.49.0",
"dotenv": "^16.4.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,215 @@
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { PlatformAdapter } from '../base/PlatformAdapter.js';
import {
loginFlow,
checkSessionFlow,
scrapePerformanceFlow,
scrapeReservationsFlow,
scrapePricingFlow,
applyPriceChangesFlow,
} from './airbnb.flows.js';
const SESSION_DIR = process.env.AIRBNB_SESSION_DIR || './.airbnb-session';
function isHeadless(): boolean {
return process.env.AIRBNB_HEADLESS !== 'false'; // evaluated at call time, not import time
}
export class AirbnbAdapter extends PlatformAdapter {
readonly platformId = 'airbnb';
readonly displayName = 'Airbnb';
private browser: Browser | null = null;
private context: BrowserContext | null = null;
private page: Page | null = null;
// ── Browser Lifecycle ──────────────────────────────────────────────────
async ensureBrowser(): Promise<Page> {
if (this.page && !this.page.isClosed()) {
return this.page;
}
if (!this.browser || !this.browser.isConnected()) {
const headless = isHeadless();
console.log(`[airbnb] Launching browser (headless=${headless}, env=${process.env.AIRBNB_HEADLESS})`);
this.browser = await chromium.launch({
headless,
args: ['--disable-blink-features=AutomationControlled'],
});
}
// Use persistent context to maintain cookies/session across runs
this.context = await this.browser.newContext({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
viewport: { width: 1440, height: 900 },
locale: 'en-US',
timezoneId: 'America/New_York',
});
// Try to restore saved cookies
try {
const fs = await import('fs');
const cookiePath = `${SESSION_DIR}/cookies.json`;
if (fs.existsSync(cookiePath)) {
const cookies = JSON.parse(fs.readFileSync(cookiePath, 'utf-8'));
await this.context.addCookies(cookies);
console.log('[airbnb] Restored saved session cookies');
}
} catch {
// No saved cookies, that's fine
}
this.page = await this.context.newPage();
return this.page;
}
private async saveCookies(): Promise<void> {
if (!this.context) return;
try {
const fs = await import('fs');
const cookies = await this.context.cookies();
fs.mkdirSync(SESSION_DIR, { recursive: true });
fs.writeFileSync(`${SESSION_DIR}/cookies.json`, JSON.stringify(cookies, null, 2));
console.log('[airbnb] Session cookies saved');
} catch (err) {
console.warn('[airbnb] Failed to save cookies:', err);
}
}
async closeBrowser(): Promise<void> {
await this.saveCookies();
if (this.page && !this.page.isClosed()) await this.page.close();
if (this.context) await this.context.close();
if (this.browser) await this.browser.close();
this.page = null;
this.context = null;
this.browser = null;
}
// ── Adapter Interface ──────────────────────────────────────────────────
async login(credentials: { email: string; password: string }): Promise<void> {
const page = await this.ensureBrowser();
await loginFlow(page, credentials.email, credentials.password);
await this.saveCookies();
}
async isSessionValid(): Promise<boolean> {
try {
const page = await this.ensureBrowser();
return await checkSessionFlow(page);
} catch {
return false;
}
}
async scrapePerformanceMetrics(): Promise<any> {
const page = await this.ensureBrowser();
// Ensure session is valid first
const valid = await checkSessionFlow(page);
if (!valid) {
throw new Error('Airbnb session is not valid. Please log in first.');
}
return await scrapePerformanceFlow(page);
}
async scrapeReservations(): Promise<any[]> {
const page = await this.ensureBrowser();
const valid = await checkSessionFlow(page);
if (!valid) {
throw new Error('Airbnb session is not valid. Please log in first.');
}
return await scrapeReservationsFlow(page);
}
async scrapePricing(dateRange: { from: string; to: string }): Promise<any[]> {
const page = await this.ensureBrowser();
const valid = await checkSessionFlow(page);
if (!valid) {
throw new Error('Airbnb session is not valid. Please log in first.');
}
return await scrapePricingFlow(page, dateRange);
}
async previewPriceChanges(changes: any[]): Promise<any> {
// Preview doesn't actually apply — just compute diffs
return {
platformId: this.platformId,
previewedAt: new Date().toISOString(),
changesCount: changes.length,
changes: changes.map((c) => ({
date: c.date,
currentPrice: c.currentPrice ?? 0,
proposedPrice: c.newPrice,
diff: c.newPrice - (c.currentPrice ?? 0),
diffPercent:
c.currentPrice > 0
? Number((((c.newPrice - c.currentPrice) / c.currentPrice) * 100).toFixed(1))
: 0,
})),
};
}
async applyPriceChanges(changes: any[]): Promise<any> {
const page = await this.ensureBrowser();
const valid = await checkSessionFlow(page);
if (!valid) {
throw new Error('Airbnb session is not valid. Please log in first.');
}
const results = await applyPriceChangesFlow(page, changes);
const appliedCount = results.filter((r) => r.applied).length;
return {
platformId: this.platformId,
appliedAt: new Date().toISOString(),
success: appliedCount === changes.length,
appliedCount,
results,
};
}
async selfTest(): Promise<{
platformId: string;
healthy: boolean;
message: string;
checkedAt: string;
}> {
try {
const page = await this.ensureBrowser();
const sessionValid = await checkSessionFlow(page);
if (sessionValid) {
return {
platformId: this.platformId,
healthy: true,
message: 'Airbnb adapter operational — session active',
checkedAt: new Date().toISOString(),
};
}
return {
platformId: this.platformId,
healthy: false,
message: 'Airbnb adapter operational but session expired — login required',
checkedAt: new Date().toISOString(),
};
} catch (err: any) {
return {
platformId: this.platformId,
healthy: false,
message: `Airbnb adapter error: ${err.message}`,
checkedAt: new Date().toISOString(),
};
}
}
}

View File

@@ -0,0 +1,663 @@
import type { Page } from 'playwright';
import { URLS, SELECTORS } from './airbnb.selectors.js';
// ── Helpers ──────────────────────────────────────────────────────────────────
async function waitForNavigation(page: Page, timeoutMs = 15000): Promise<void> {
await page.waitForLoadState('networkidle', { timeout: timeoutMs }).catch(() => {
// networkidle can be flaky; fall back to domcontentloaded
});
}
function parseCurrency(text: string): number {
return Number(text.replace(/[^0-9.\-]/g, '')) || 0;
}
function parseDate(text: string): string {
// Handle Airbnb date formats:
// "May 21, 2026" — standard
// "Mar 2, 20269:09 PM ET" — time glued to year
// "Mar 2, 2026 9:09 PM ET" — time with space
// Strip time portion (everything after the 4-digit year)
const cleaned = text.replace(/(\d{4})\d{1,2}:\d{2}.*/, '$1').replace(/(\d{4})\s+\d{1,2}:\d{2}.*/, '$1').trim();
const d = new Date(cleaned);
if (!isNaN(d.getTime())) {
return d.toISOString().split('T')[0];
}
// Also try the original text
const d2 = new Date(text);
if (!isNaN(d2.getTime())) {
return d2.toISOString().split('T')[0];
}
return text;
}
function computeNights(checkIn: string, checkOut: string): number {
const a = new Date(checkIn);
const b = new Date(checkOut);
return Math.max(1, Math.round((b.getTime() - a.getTime()) / 86400000));
}
// ── Login Flow ───────────────────────────────────────────────────────────────
export async function loginFlow(
page: Page,
email: string,
password: string,
): Promise<void> {
console.log('[airbnb] Navigating to login page...');
await page.goto('https://www.airbnb.com/login', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
// Take a screenshot of what we see for debugging
console.log('[airbnb] Login page loaded, URL:', page.url());
// Click "Continue with email" if present (Airbnb sometimes shows social login first)
for (const text of ['Continue with email', 'Email', 'Use email']) {
const btn = page.locator(`button:has-text("${text}")`).first();
if (await btn.isVisible({ timeout: 2000 }).catch(() => false)) {
console.log(`[airbnb] Clicking "${text}" button`);
await btn.click();
await page.waitForTimeout(2000);
break;
}
}
// Look for email input with broader selectors
const emailInput = page.locator(
'input[type="email"], input[name="email"], input[autocomplete="email"], input[autocomplete="username"], input[data-testid*="email"]'
).first();
// If no email input visible, try clicking any visible text input
if (!(await emailInput.isVisible({ timeout: 5000 }).catch(() => false))) {
console.log('[airbnb] Email input not found with standard selectors, trying text input...');
const textInput = page.locator('input[type="text"]').first();
if (await textInput.isVisible({ timeout: 3000 }).catch(() => false)) {
await textInput.fill(email);
} else {
// Last resort: log page content for debugging
const bodyText = await page.locator('body').textContent().catch(() => '');
console.log('[airbnb] Page text preview:', bodyText?.substring(0, 500));
throw new Error('Could not find email input on login page. The page structure may have changed.');
}
} else {
console.log('[airbnb] Found email input, filling...');
await emailInput.fill(email);
}
// Click Continue / Next / Submit after email
for (const selector of [
'button:has-text("Continue")',
'button:has-text("Next")',
'button[type="submit"]',
]) {
const btn = page.locator(selector).first();
if (await btn.isVisible({ timeout: 2000 }).catch(() => false)) {
console.log(`[airbnb] Clicking: ${selector}`);
await btn.click();
await page.waitForTimeout(3000);
break;
}
}
// Enter password
const passwordInput = page.locator(
'input[type="password"], input[name="password"], input[autocomplete="current-password"]'
).first();
if (await passwordInput.isVisible({ timeout: 10000 }).catch(() => false)) {
console.log('[airbnb] Found password input, filling...');
await passwordInput.fill(password);
// Submit the password form
for (const selector of [
'button:has-text("Log in")',
'button:has-text("Login")',
'button:has-text("Continue")',
'button[type="submit"]',
]) {
const btn = page.locator(selector).first();
if (await btn.isVisible({ timeout: 2000 }).catch(() => false)) {
console.log(`[airbnb] Submitting with: ${selector}`);
await btn.click();
break;
}
}
} else {
console.log('[airbnb] No password field — may be a passwordless flow or MFA-only');
}
// Wait for either MFA prompt or successful redirect
await page.waitForTimeout(5000);
console.log('[airbnb] Post-submit URL:', page.url());
// Check for MFA
const mfaInput = page.locator('input[inputmode="numeric"], input[autocomplete="one-time-code"], input[name*="code"]').first();
if (await mfaInput.isVisible({ timeout: 5000 }).catch(() => false)) {
console.log('[airbnb] MFA required. Waiting for manual code entry (up to 3 minutes)...');
await page.waitForURL(/\/(hosting|dashboard|account|users)/, { timeout: 180000 }).catch(() => {
throw new Error('MFA timeout: code was not entered within 3 minutes');
});
}
// Check if we're on a logged-in page
const currentUrl = page.url();
if (currentUrl.includes('/login') || currentUrl.includes('/signup')) {
// Still on login page — maybe waiting for user action in non-headless mode
console.log('[airbnb] Still on login page. Waiting for user to complete login (up to 3 minutes)...');
await page.waitForURL(/\/(hosting|dashboard|account|users)/, { timeout: 180000 }).catch(() => {
throw new Error('Login timeout: did not reach a logged-in page within 3 minutes');
});
}
console.log('[airbnb] Login successful, URL:', page.url());
}
// ── Session Check ────────────────────────────────────────────────────────────
export async function checkSessionFlow(page: Page): Promise<boolean> {
try {
await page.goto(URLS.HOST_HOME, { waitUntil: 'domcontentloaded', timeout: 15000 });
await page.waitForTimeout(2000);
// If we see the host nav, we're logged in
const nav = page.locator(SELECTORS.NAV_PRIMARY);
return await nav.isVisible({ timeout: 5000 }).catch(() => false);
} catch {
return false;
}
}
// ── Discover Listing ID ──────────────────────────────────────────────────────
export async function discoverListingId(page: Page): Promise<string> {
await page.goto(URLS.LISTINGS, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
// Try multiple selector patterns for finding the listing ID
const selectors = [
'a[href*="/hosting/listings/editor/"]',
'a[href*="/multicalendar/"]',
'a[href*="/hosting/listings/"]',
'a[href*="/rooms/"]',
];
for (const selector of selectors) {
const link = page.locator(selector).first();
if (await link.isVisible({ timeout: 3000 }).catch(() => false)) {
const href = await link.getAttribute('href');
if (!href) continue;
// Extract numeric ID from various URL patterns
const match = href.match(/\/(?:editor|multicalendar|rooms|listings)\/(\d+)/);
if (match) {
console.log(`[airbnb] Discovered listing ID: ${match[1]} (from ${selector})`);
return match[1];
}
}
}
// Fallback: scan all links on the page for any numeric ID pattern
const allHrefs = await page.evaluate(() => {
return Array.from(document.querySelectorAll('a[href]'))
.map((a) => a.getAttribute('href') || '')
.filter((h) => /\/\d{5,}/.test(h));
});
for (const href of allHrefs) {
const match = href.match(/\/(\d{5,})/);
if (match) {
console.log(`[airbnb] Discovered listing ID: ${match[1]} (from page scan: ${href})`);
return match[1];
}
}
// Last resort: check the calendar URL pattern from nav
const calendarLink = page.locator('a[href*="/calendar"]').first();
if (await calendarLink.isVisible({ timeout: 2000 }).catch(() => false)) {
const href = await calendarLink.getAttribute('href');
const match = href?.match(/\/(\d{5,})/);
if (match) {
console.log(`[airbnb] Discovered listing ID: ${match[1]} (from calendar nav)`);
return match[1];
}
}
// Hardcoded fallback from initial DOM exploration
const fallbackId = process.env.AIRBNB_LISTING_ID;
if (fallbackId) {
console.log(`[airbnb] Using fallback listing ID from env: ${fallbackId}`);
return fallbackId;
}
throw new Error('Could not find listing ID from listings page. Set AIRBNB_LISTING_ID env var as fallback.');
}
// ── Scrape Performance Metrics ───────────────────────────────────────────────
export async function scrapePerformanceFlow(page: Page): Promise<any> {
// First get listing ID for views page
let listingId: string;
try {
listingId = await discoverListingId(page);
} catch {
listingId = '';
}
// ── Scrape Earnings / Performance ────────────────────────────────────
await page.goto(URLS.EARNINGS_PERFORMANCE, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
const earningsText = await page.locator('main').textContent() ?? '';
// Parse monthly earnings — look for the summary card
let revenueTotal = 0;
let nightsBooked = 0;
let avgNightStay = 0;
// Parse "Total (USD)" amount
const totalMatch = earningsText.match(/Total \(USD\)\s*\$?([\d,]+\.?\d*)/);
if (totalMatch) {
revenueTotal = parseCurrency(totalMatch[1]);
} else {
// Fallback: look for the first currency amount after "Paid"
const paidMatch = earningsText.match(/\$([\d,]+\.?\d*)\s*Paid/);
if (paidMatch) revenueTotal = parseCurrency(paidMatch[1]);
}
// Expand Performance stats if collapsed
const perfStatsBtn = page.locator('button:has-text("Performance stats")');
if (await perfStatsBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await perfStatsBtn.click();
await page.waitForTimeout(500);
}
const updatedText = await page.locator('main').textContent() ?? '';
const nightsMatch = updatedText.match(/(\d+)\s*Nights? booked/i);
if (nightsMatch) nightsBooked = parseInt(nightsMatch[1], 10);
const avgStayMatch = updatedText.match(/(\d+)\s*Avg night stay/i);
if (avgStayMatch) avgNightStay = parseInt(avgStayMatch[1], 10);
// ── Scrape Insights / Views ──────────────────────────────────────────
let viewsSearch = 0;
let newBookings = 0;
let bookingRate = 0;
if (listingId) {
await page.goto(URLS.INSIGHTS_VIEWS(listingId), { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
const viewsPageText = await page.locator('main').textContent() ?? '';
// "161" Views, past 30 days
const viewsMatch = viewsPageText.match(/(\d+)\s*Views,?\s*past 30 days/i);
if (viewsMatch) viewsSearch = parseInt(viewsMatch[1], 10);
// "2" New bookings, past 30 days
const bookingsMatch = viewsPageText.match(/(\d+)\s*New bookings,?\s*past 30 days/i);
if (bookingsMatch) newBookings = parseInt(bookingsMatch[1], 10);
// "1.2%" Booking rate
const rateMatch = viewsPageText.match(/([\d.]+)%\s*Booking rate/i);
if (rateMatch) bookingRate = parseFloat(rateMatch[1]);
}
// ── Scrape Insights / Reviews ────────────────────────────────────────
let overallRating = 0;
let reviewCount = 0;
await page.goto(URLS.INSIGHTS_REVIEWS, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
const reviewsText = await page.locator('main').textContent() ?? '';
const ratingMatch = reviewsText.match(/([\d.]+)\s*overall rating/i);
if (ratingMatch) overallRating = parseFloat(ratingMatch[1]);
const reviewCountMatch = reviewsText.match(/(\d+)\s*reviews/i);
if (reviewCountMatch) reviewCount = parseInt(reviewCountMatch[1], 10);
// ── Compute derived metrics ──────────────────────────────────────────
const avgDailyRate = nightsBooked > 0 ? revenueTotal / nightsBooked : 0;
// Occupancy: nights booked / 30 days * 100
const occupancyRate = (nightsBooked / 30) * 100;
return {
platformId: 'airbnb',
capturedAt: new Date().toISOString(),
periodLabel: 'last_30_days',
viewsSearch,
viewsListing: viewsSearch, // Airbnb doesn't split search vs listing views
conversionRate: bookingRate,
bookingsCount: newBookings || nightsBooked,
occupancyRate: Number(occupancyRate.toFixed(1)),
avgDailyRate: Number(avgDailyRate.toFixed(2)),
revenueTotal: Number(revenueTotal.toFixed(2)),
rawJson: {
source: 'airbnb',
scrapedAt: new Date().toISOString(),
listingId,
nightsBooked,
avgNightStay,
overallRating,
reviewCount,
bookingRate,
},
};
}
// ── Scrape Reservations ──────────────────────────────────────────────────────
export async function scrapeReservationsFlow(page: Page): Promise<any[]> {
const reservations: any[] = [];
// Scrape both completed and upcoming
for (const url of [URLS.RESERVATIONS_COMPLETED, URLS.RESERVATIONS]) {
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
// Wait for table to appear
const table = page.locator('table');
if (!(await table.isVisible({ timeout: 5000 }).catch(() => false))) {
continue;
}
// Get all rows
const rows = page.locator('table tbody tr, table tr').filter({
has: page.locator('td'),
});
const rowCount = await rows.count();
for (let i = 0; i < rowCount; i++) {
try {
const row = rows.nth(i);
const cells = row.locator('td');
const cellCount = await cells.count();
if (cellCount < 8) continue;
// Actual Airbnb columns (10 cols):
// [0]=Status [1]=Guests [2]=Contact [3]=Check-in [4]=Checkout
// [5]=Booked [6]=Listing [7]=Confirmation Code [8]=Total Payout [9]=Actions
const status = (await cells.nth(0).textContent())?.trim().toLowerCase() ?? '';
const guestText = (await cells.nth(1).textContent())?.trim() ?? '';
const checkInText = (await cells.nth(3).textContent())?.trim() ?? '';
const checkOutText = (await cells.nth(4).textContent())?.trim() ?? '';
const bookedText = (await cells.nth(5).textContent())?.trim() ?? '';
const confirmationCode = (await cells.nth(7).textContent())?.trim() ?? '';
const payoutText = cellCount > 8 ? (await cells.nth(8).textContent())?.trim() ?? '' : '';
// Parse guest name from profile link (clean text) or fall back to cell text
let guestName = 'Unknown';
const profileLink = cells.nth(1).locator('a[href*="/users/profile/"], a[href*="/users/show/"]').first();
if (await profileLink.count() > 0) {
guestName = (await profileLink.textContent())?.trim() || 'Unknown';
}
if (guestName === 'Unknown' || /\d+\s*(adult|guest)/i.test(guestName)) {
// Fallback: split merged "NameNadults" text — e.g., "Cassie Graham7 adults"
const nameMatch = guestText.match(/^(.+?)(\d+\s*(?:adult|guest|infant|child|pet))/i);
guestName = nameMatch ? nameMatch[1].trim() : guestText.split('\n')[0]?.trim() || 'Unknown';
}
const guestsCountMatch = guestText.match(/(\d+)\s*(adult|guest)/i);
const guestsCount = guestsCountMatch ? parseInt(guestsCountMatch[1], 10) : 1;
const checkIn = parseDate(checkInText);
const checkOut = parseDate(checkOutText);
const nights = computeNights(checkIn, checkOut);
const totalPayout = parseCurrency(payoutText);
// Map status
let mappedStatus: string;
if (status.includes('past guest') || status.includes('completed')) {
mappedStatus = 'completed';
} else if (status.includes('confirmed') || status.includes('upcoming')) {
mappedStatus = 'confirmed';
} else if (status.includes('cancel')) {
mappedStatus = 'cancelled';
} else if (status.includes('check')) {
mappedStatus = 'checked_in';
} else {
mappedStatus = status || 'unknown';
}
// Estimate nightly rate from total payout (total / nights is rough)
const nightlyRate = nights > 0 ? Number((totalPayout / nights).toFixed(2)) : 0;
// Safely parse bookedAt — fallback to now if invalid
let bookedAt: string;
try {
const parsed = new Date(parseDate(bookedText));
bookedAt = isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
} catch {
bookedAt = new Date().toISOString();
}
reservations.push({
platformId: 'airbnb',
platformReservationId: confirmationCode || `ABB-${Date.now()}-${i}`,
guestName,
checkIn,
checkOut,
nights,
guestsCount,
nightlyRate,
cleaningFee: 0, // Not available in table view; could be scraped from detail
platformFee: 0, // Not available in table view
totalPayout,
status: mappedStatus,
bookedAt,
rawJson: {
source: 'airbnb',
confirmationCode,
scrapedAt: new Date().toISOString(),
},
});
} catch (err) {
console.warn(`[airbnb] Failed to parse reservation row ${i}:`, err);
}
}
}
// Deduplicate by confirmation code
const seen = new Set<string>();
const unique = reservations.filter((r) => {
if (seen.has(r.platformReservationId)) return false;
seen.add(r.platformReservationId);
return true;
});
console.log(`[airbnb] Scraped ${unique.length} reservations`);
return unique.sort((a, b) => new Date(a.checkIn).getTime() - new Date(b.checkIn).getTime());
}
// ── Scrape Pricing from Calendar ─────────────────────────────────────────────
export async function scrapePricingFlow(
page: Page,
dateRange: { from: string; to: string },
): Promise<any[]> {
const listingId = await discoverListingId(page);
await page.goto(URLS.CALENDAR(listingId), { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(5000);
// Airbnb's multicalendar uses a virtualized scroll list (virtuoso).
// Day text follows pattern: "Wednesday 4 Mar4Nightly price$275"
// or "Unavailable" for blocked days, or no price text for reserved days.
// We parse the main text content to extract date-price pairs.
const prices: any[] = [];
const targetEnd = new Date(dateRange.to);
const targetStart = new Date(dateRange.from);
let scrollAttempts = 0;
const maxScrolls = 20;
// Determine current year from page context
const currentYear = new Date().getFullYear();
while (scrollAttempts < maxScrolls) {
const dayData = await page.evaluate((year: number) => {
const results: { dateText: string; price: number; available: boolean }[] = [];
const mainEl = document.querySelector('[data-testid="listing-calendar"]') || document.querySelector('main');
if (!mainEl) return results;
const text = mainEl.textContent || '';
// Match day entries like "Sunday 1 Feb1Nightly price$275" or "Monday 20 Mar20UnavailableNightly price$275"
// The pattern is: DayName DD MonDD[Unavailable][Nightly price$NNN]
// Month headers appear as standalone month names like "February", "March", etc.
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const fullMonths = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const dayNames = '(?:Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday)';
const monthAbbr = '(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)';
// Match each day entry
const dayRegex = new RegExp(
`(?:Today, )?${dayNames}\\s+(\\d{1,2})\\s+(${monthAbbr})\\1(Unavailable)?(?:Nightly price\\$(\\d+))?`,
'g',
);
let match;
while ((match = dayRegex.exec(text)) !== null) {
const day = parseInt(match[1], 10);
const monthAbbreviation = match[2];
const isUnavailable = !!match[3];
const price = match[4] ? parseInt(match[4], 10) : 0;
const monthIndex = months.indexOf(monthAbbreviation);
if (monthIndex === -1) continue;
// Determine the year — if month is before current month, it might be next year
const currentMonth = new Date().getMonth();
let dateYear = year;
if (monthIndex < currentMonth - 1) {
dateYear = year + 1;
}
const dateStr = `${dateYear}-${String(monthIndex + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
results.push({
dateText: dateStr,
price,
available: !isUnavailable && price > 0,
});
}
return results;
}, currentYear);
for (const d of dayData) {
if (d.dateText >= dateRange.from && d.dateText <= dateRange.to) {
prices.push({
platformId: 'airbnb',
date: d.dateText,
price: d.price,
isAvailable: d.available,
minStayNights: 3,
syncedAt: new Date().toISOString(),
});
}
}
// Check if we've reached the target end date
const latestDate = dayData.length > 0
? new Date(dayData[dayData.length - 1].dateText)
: new Date();
if (latestDate >= targetEnd) break;
// Scroll the virtuoso scroller down to load more months
const scrolled = await page.evaluate(() => {
const scroller = document.querySelector('[data-testid="virtuoso-scroller"]');
if (scroller) {
const prevTop = scroller.scrollTop;
scroller.scrollTop += 800;
return scroller.scrollTop > prevTop;
}
return false;
});
if (!scrolled) {
// Try clicking next month button as fallback
const nextBtn = page.locator('button[aria-label*="Move forward"], button[aria-label*="next month"]').first();
if (await nextBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await nextBtn.click();
await page.waitForTimeout(2000);
} else {
break;
}
} else {
await page.waitForTimeout(1500);
}
scrollAttempts++;
}
// Deduplicate by date
const seen = new Set<string>();
const unique = prices.filter((p) => {
if (seen.has(p.date)) return false;
seen.add(p.date);
return true;
});
console.log(`[airbnb] Scraped ${unique.length} daily prices`);
return unique.sort((a, b) => a.date.localeCompare(b.date));
}
// ── Apply Price Changes via Calendar ─────────────────────────────────────────
export async function applyPriceChangesFlow(
page: Page,
changes: Array<{ date: string; newPrice: number }>,
): Promise<Array<{ date: string; newPrice: number; applied: boolean; error?: string }>> {
const listingId = await discoverListingId(page);
const results: Array<{ date: string; newPrice: number; applied: boolean; error?: string }> = [];
for (const change of changes) {
try {
// Navigate to the calendar
await page.goto(URLS.CALENDAR(listingId), { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
// Click on the specific date cell
const dateObj = new Date(change.date);
const label = dateObj.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
});
const dayCell = page.locator(`td[aria-label*="${label}"], td:has-text("${dateObj.getDate()}")`).first();
if (!(await dayCell.isVisible({ timeout: 3000 }).catch(() => false))) {
results.push({ ...change, applied: false, error: 'Date cell not found' });
continue;
}
await dayCell.click();
await page.waitForTimeout(1000);
// Look for price input in the sidebar
const priceInput = page.locator('input[aria-label*="price"], input[name*="price"]').first();
if (!(await priceInput.isVisible({ timeout: 3000 }).catch(() => false))) {
results.push({ ...change, applied: false, error: 'Price input not found' });
continue;
}
await priceInput.click({ clickCount: 3 }); // Select all
await priceInput.fill(String(change.newPrice));
// Save
const saveBtn = page.locator('button:has-text("Save")').first();
if (await saveBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await saveBtn.click();
await page.waitForTimeout(2000);
results.push({ ...change, applied: true });
} else {
results.push({ ...change, applied: false, error: 'Save button not found' });
}
} catch (err: any) {
results.push({ ...change, applied: false, error: err.message });
}
}
return results;
}

View File

@@ -0,0 +1,86 @@
// Airbnb Host Dashboard selectors mapped from live DOM exploration (March 2026)
// These target the accessibility tree structure rather than fragile CSS classes.
export const URLS = {
HOST_HOME: 'https://www.airbnb.com/hosting',
CALENDAR: (listingId: string) => `https://www.airbnb.com/multicalendar/${listingId}`,
LISTINGS: 'https://www.airbnb.com/hosting/listings',
LISTING_EDITOR: (listingId: string) => `https://www.airbnb.com/hosting/listings/editor/${listingId}/details/photo-tour`,
RESERVATIONS: 'https://www.airbnb.com/hosting/reservations',
RESERVATIONS_COMPLETED: 'https://www.airbnb.com/hosting/reservations/completed',
RESERVATIONS_ALL: 'https://www.airbnb.com/hosting/reservations/all',
RESERVATION_DETAIL: (confirmationCode: string) => `https://www.airbnb.com/hosting/reservations/details/${confirmationCode}`,
EARNINGS_PERFORMANCE: 'https://www.airbnb.com/users/transaction_history',
INSIGHTS_REVIEWS: 'https://www.airbnb.com/progress/reviews',
INSIGHTS_VIEWS: (listingId: string) => `https://www.airbnb.com/progress/views/${listingId}`,
} as const;
export const SELECTORS = {
// ── Navigation ───────────────────────────────────────────────────────
NAV_PRIMARY: 'nav[aria-label="Primary"]',
NAV_TODAY: 'a[href="/hosting"]',
NAV_CALENDAR: 'a[href="/calendar-router"]',
NAV_LISTINGS: 'a[href="/hosting/listings"]',
NAV_MESSAGES: 'a[href="/hosting/messages"]',
NAV_MENU_BUTTON: 'button[aria-label="Main navigation menu"]',
// ── Login ────────────────────────────────────────────────────────────
LOGIN_EMAIL: 'input[type="email"], input[name="email"]',
LOGIN_PASSWORD: 'input[type="password"], input[name="password"]',
LOGIN_SUBMIT: 'button[type="submit"]',
LOGIN_CONTINUE: 'button:has-text("Continue")',
MFA_INPUT: 'input[inputmode="numeric"]',
// ── Reservations Page ────────────────────────────────────────────────
RESERVATIONS_TAB_UPCOMING: 'tab:has-text("Upcoming"), button:has-text("Upcoming")',
RESERVATIONS_TAB_COMPLETED: 'tab:has-text("Completed"), button:has-text("Completed")',
RESERVATIONS_TAB_CANCELLED: 'tab:has-text("Cancelled"), button:has-text("Cancelled")',
RESERVATIONS_TAB_ALL: 'tab:has-text("All"), button:has-text("All")',
RESERVATIONS_TABLE: 'table',
RESERVATIONS_TABLE_ROWS: 'table tbody tr',
RESERVATIONS_DETAIL_BUTTON: 'button:has-text("Details"), a:has-text("Details")',
RESERVATIONS_EXPORT_BUTTON: 'button:has-text("Export")',
// Reservation detail modal
RESERVATION_DETAIL_MODAL: '[role="dialog"], [aria-modal="true"]',
RESERVATION_DETAIL_CLOSE: 'button:has-text("×"), button[aria-label="Close"]',
// ── Earnings / Performance Page ──────────────────────────────────────
EARNINGS_NAV_PERFORMANCE: 'text=Performance',
EARNINGS_NAV_UPCOMING: 'text=Upcoming',
EARNINGS_NAV_PAID: 'text=Paid',
EARNINGS_NAV_REPORTS: 'text=Reports',
EARNINGS_MONTH_LABEL: 'text=/^\\w+ \\d{4}$/', // e.g., "March 2026"
EARNINGS_PAID_AMOUNT: 'text=/^\\$[\\d,]+\\.\\d{2}$/',
EARNINGS_PERFORMANCE_STATS: 'button:has-text("Performance stats")',
EARNINGS_PAID_BREAKDOWN: 'button:has-text("Paid breakdown")',
// ── Insights / Views Page ────────────────────────────────────────────
INSIGHTS_TAB_REVIEWS: 'tab:has-text("Reviews"), button:has-text("Reviews")',
INSIGHTS_TAB_VIEWS: 'tab:has-text("Views"), button:has-text("Views")',
INSIGHTS_TAB_OPPORTUNITIES: 'tab:has-text("Opportunities"), button:has-text("Opportunities")',
INSIGHTS_TAB_SUPERHOST: 'tab:has-text("Superhost"), button:has-text("Superhost")',
INSIGHTS_VIEWS_COUNT: 'text=/^\\d+$/', // "161"
INSIGHTS_VIEWS_LABEL: 'text="Views, past 30 days"',
INSIGHTS_BOOKINGS_LABEL: 'text="New bookings, past 30 days"',
INSIGHTS_BOOKING_RATE_LABEL: 'text="Booking rate"',
INSIGHTS_OVERALL_RATING: 'text=/★ [\\d.]+ overall rating/',
// ── Calendar / Pricing ───────────────────────────────────────────────
CALENDAR_DAY_CELL: 'td[data-testid]',
CALENDAR_PRICE_DISPLAY: '[data-testid="price-item-container"]',
CALENDAR_SIDEBAR: '[data-testid="calendar-sidebar"]',
CALENDAR_PRICE_INPUT: 'input[aria-label*="price"], input[name*="price"]',
CALENDAR_SAVE_BUTTON: 'button:has-text("Save")',
CALENDAR_NEXT_MONTH: 'button[aria-label="Move forward to switch to the next month"]',
CALENDAR_PREV_MONTH: 'button[aria-label="Move backward to switch to the previous month"]',
// ── Listings Page ────────────────────────────────────────────────────
LISTING_CARD: 'a[href*="/hosting/listings/editor/"]',
LISTING_STATUS_BADGE: 'text="Listed"',
LISTING_TITLE: 'h1, [data-testid="listing-title"]',
// ── General ──────────────────────────────────────────────────────────
LOADING_SPINNER: '[role="progressbar"], [aria-busy="true"]',
PAGE_MAIN: 'main',
} as const;

View File

@@ -0,0 +1,31 @@
import type { PlatformAdapterInterface } from './PlatformAdapter.js';
class AdapterRegistry {
private adapters = new Map<string, PlatformAdapterInterface>();
register(adapter: PlatformAdapterInterface): void {
this.adapters.set(adapter.platformId, adapter);
}
get(platformId: string): PlatformAdapterInterface | undefined {
return this.adapters.get(platformId);
}
getOrThrow(platformId: string): PlatformAdapterInterface {
const adapter = this.adapters.get(platformId);
if (!adapter) {
throw new Error(`No adapter registered for platform: ${platformId}`);
}
return adapter;
}
list(): string[] {
return Array.from(this.adapters.keys());
}
has(platformId: string): boolean {
return this.adapters.has(platformId);
}
}
export const registry = new AdapterRegistry();

View File

@@ -0,0 +1,36 @@
export interface PlatformAdapterInterface {
readonly platformId: string;
readonly displayName: string;
login(credentials: { email: string; password: string }): Promise<void>;
isSessionValid(): Promise<boolean>;
scrapePerformanceMetrics(): Promise<any>;
scrapeReservations(): Promise<any[]>;
scrapePricing(dateRange: { from: string; to: string }): Promise<any[]>;
previewPriceChanges(changes: any[]): Promise<any>;
applyPriceChanges(changes: any[]): Promise<any>;
selfTest(): Promise<{
platformId: string;
healthy: boolean;
message: string;
checkedAt: string;
}>;
}
export abstract class PlatformAdapter implements PlatformAdapterInterface {
abstract readonly platformId: string;
abstract readonly displayName: string;
abstract login(credentials: { email: string; password: string }): Promise<void>;
abstract isSessionValid(): Promise<boolean>;
abstract scrapePerformanceMetrics(): Promise<any>;
abstract scrapeReservations(): Promise<any[]>;
abstract scrapePricing(dateRange: { from: string; to: string }): Promise<any[]>;
abstract previewPriceChanges(changes: any[]): Promise<any>;
abstract applyPriceChanges(changes: any[]): Promise<any>;
abstract selfTest(): Promise<{
platformId: string;
healthy: boolean;
message: string;
checkedAt: string;
}>;
}

View File

@@ -0,0 +1,79 @@
import { PlatformAdapter } from '../base/PlatformAdapter.js';
import {
generatePerformanceSnapshot,
generateReservations,
generateDailyPrices,
} from './mock-data.js';
export class MockAdapter extends PlatformAdapter {
readonly platformId = 'mock';
readonly displayName = 'Mock Platform';
async login(_credentials: { email: string; password: string }): Promise<void> {
// No-op for mock adapter
}
async isSessionValid(): Promise<boolean> {
return true;
}
async scrapePerformanceMetrics(): Promise<any> {
return generatePerformanceSnapshot(this.platformId);
}
async scrapeReservations(): Promise<any[]> {
return generateReservations(this.platformId);
}
async scrapePricing(dateRange: { from: string; to: string }): Promise<any[]> {
return generateDailyPrices(this.platformId, dateRange.from, dateRange.to);
}
async previewPriceChanges(changes: any[]): Promise<any> {
return {
platformId: this.platformId,
previewedAt: new Date().toISOString(),
changesCount: changes.length,
changes: changes.map((c) => ({
date: c.date,
currentPrice: c.currentPrice ?? Math.round(Math.random() * 100 + 150),
proposedPrice: c.newPrice,
diff: c.newPrice - (c.currentPrice ?? 200),
diffPercent: Number(
(((c.newPrice - (c.currentPrice ?? 200)) / (c.currentPrice ?? 200)) * 100).toFixed(1),
),
})),
};
}
async applyPriceChanges(changes: any[]): Promise<any> {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 500));
return {
platformId: this.platformId,
appliedAt: new Date().toISOString(),
success: true,
appliedCount: changes.length,
results: changes.map((c) => ({
date: c.date,
newPrice: c.newPrice,
applied: true,
})),
};
}
async selfTest(): Promise<{
platformId: string;
healthy: boolean;
message: string;
checkedAt: string;
}> {
return {
platformId: this.platformId,
healthy: true,
message: 'Mock adapter is operational',
checkedAt: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,151 @@
const GUEST_NAMES = [
'Sarah Johnson',
'Michael Chen',
'Emily Rodriguez',
'James Williams',
'Olivia Martinez',
'David Kim',
'Sophia Brown',
'Daniel Taylor',
'Isabella Anderson',
'Matthew Thomas',
'Ava Wilson',
'Christopher Lee',
'Mia Garcia',
'Andrew Jackson',
'Charlotte White',
];
const RESERVATION_STATUSES = ['confirmed', 'checked_in', 'completed', 'cancelled'] as const;
function randomBetween(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function randomFloat(min: number, max: number, decimals = 2): number {
const val = Math.random() * (max - min) + min;
return Number(val.toFixed(decimals));
}
function randomItem<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
function addDays(dateStr: string, days: number): string {
const d = new Date(dateStr);
d.setDate(d.getDate() + days);
return d.toISOString().split('T')[0];
}
function getDayOfWeek(dateStr: string): number {
return new Date(dateStr).getDay();
}
export function generatePerformanceSnapshot(platformId: string) {
const viewsSearch = randomBetween(500, 2000);
const viewsListing = randomBetween(Math.floor(viewsSearch * 0.3), Math.floor(viewsSearch * 0.7));
const bookingsCount = randomBetween(5, 25);
const conversionRate = randomFloat(1.5, 8.0);
const occupancyRate = randomFloat(60, 85);
const avgDailyRate = randomFloat(150, 250);
const revenueTotal = randomFloat(
avgDailyRate * bookingsCount * 2,
avgDailyRate * bookingsCount * 5,
);
return {
platformId,
capturedAt: new Date().toISOString(),
periodLabel: 'last_30_days',
viewsSearch,
viewsListing,
conversionRate,
bookingsCount,
occupancyRate,
avgDailyRate,
revenueTotal,
rawJson: {
source: 'mock',
generatedAt: new Date().toISOString(),
},
};
}
export function generateReservations(platformId: string, count: number = randomBetween(5, 10)) {
const reservations = [];
const today = new Date().toISOString().split('T')[0];
for (let i = 0; i < count; i++) {
const daysOffset = randomBetween(-30, 60);
const checkIn = addDays(today, daysOffset);
const nights = randomBetween(2, 7);
const checkOut = addDays(checkIn, nights);
const nightlyRate = randomFloat(150, 300);
const cleaningFee = randomFloat(75, 150);
const platformFee = randomFloat(nightlyRate * nights * 0.03, nightlyRate * nights * 0.05);
const totalPayout = Number((nightlyRate * nights + cleaningFee - platformFee).toFixed(2));
const guestsCount = randomBetween(1, 6);
let status: (typeof RESERVATION_STATUSES)[number];
if (daysOffset < -7) {
status = 'completed';
} else if (daysOffset < 0) {
status = 'checked_in';
} else {
status = Math.random() > 0.1 ? 'confirmed' : 'cancelled';
}
const bookedDaysAgo = randomBetween(14, 90);
reservations.push({
platformId,
platformReservationId: `MOCK-${platformId.toUpperCase()}-${Date.now()}-${i}`,
guestName: randomItem(GUEST_NAMES),
checkIn,
checkOut,
nights,
guestsCount,
nightlyRate,
cleaningFee,
platformFee: Number(platformFee.toFixed(2)),
totalPayout,
status,
bookedAt: new Date(Date.now() - bookedDaysAgo * 86400000).toISOString(),
rawJson: {
source: 'mock',
generatedAt: new Date().toISOString(),
},
});
}
return reservations.sort(
(a, b) => new Date(a.checkIn).getTime() - new Date(b.checkIn).getTime(),
);
}
export function generateDailyPrices(platformId: string, from: string, to: string) {
const prices = [];
let current = from;
while (current <= to) {
const dayOfWeek = getDayOfWeek(current);
const isWeekend = dayOfWeek === 5 || dayOfWeek === 6;
const basePrice = isWeekend ? randomFloat(200, 300) : randomFloat(150, 220);
const isAvailable = Math.random() > 0.15;
const minStay = isWeekend ? randomBetween(2, 3) : 1;
prices.push({
platformId,
date: current,
price: basePrice,
isAvailable,
minStayNights: minStay,
syncedAt: new Date().toISOString(),
});
current = addDays(current, 1);
}
return prices;
}

View File

@@ -0,0 +1,48 @@
import { PlatformAdapter } from '../base/PlatformAdapter.js';
export class VrboAdapter extends PlatformAdapter {
readonly platformId = 'vrbo';
readonly displayName = 'VRBO';
async login(_credentials: { email: string; password: string }): Promise<void> {
throw new Error('VRBO adapter not yet implemented');
}
async isSessionValid(): Promise<boolean> {
throw new Error('VRBO adapter not yet implemented');
}
async scrapePerformanceMetrics(): Promise<any> {
throw new Error('VRBO adapter not yet implemented');
}
async scrapeReservations(): Promise<any[]> {
throw new Error('VRBO adapter not yet implemented');
}
async scrapePricing(_dateRange: { from: string; to: string }): Promise<any[]> {
throw new Error('VRBO adapter not yet implemented');
}
async previewPriceChanges(_changes: any[]): Promise<any> {
throw new Error('VRBO adapter not yet implemented');
}
async applyPriceChanges(_changes: any[]): Promise<any> {
throw new Error('VRBO adapter not yet implemented');
}
async selfTest(): Promise<{
platformId: string;
healthy: boolean;
message: string;
checkedAt: string;
}> {
return {
platformId: this.platformId,
healthy: false,
message: 'Not implemented',
checkedAt: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,20 @@
import type { Page } from 'playwright';
export async function loginFlow(_page: Page, _email: string, _password: string): Promise<void> {
throw new Error('VRBO login flow not yet implemented');
}
export async function scrapePerformanceFlow(_page: Page): Promise<any> {
throw new Error('VRBO scrapePerformance flow not yet implemented');
}
export async function scrapePricingFlow(
_page: Page,
_dateRange: { from: string; to: string },
): Promise<any[]> {
throw new Error('VRBO scrapePricing flow not yet implemented');
}
export async function scrapeReservationsFlow(_page: Page): Promise<any[]> {
throw new Error('VRBO scrapeReservations flow not yet implemented');
}

View File

@@ -0,0 +1,18 @@
export const SELECTORS = {
LOGIN_EMAIL: '',
LOGIN_PASSWORD: '',
LOGIN_SUBMIT: '',
DASHBOARD_NAV: '',
PERFORMANCE_TAB: '',
RESERVATIONS_TAB: '',
PRICING_TAB: '',
DATE_PICKER_FROM: '',
DATE_PICKER_TO: '',
METRICS_CONTAINER: '',
RESERVATIONS_TABLE: '',
PRICING_CALENDAR: '',
PRICE_INPUT: '',
SAVE_PRICE_BUTTON: '',
NEXT_PAGE_BUTTON: '',
LOADING_SPINNER: '',
} as const;

270
apps/scraper/src/index.ts Normal file
View File

@@ -0,0 +1,270 @@
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 Fastify from 'fastify';
import { registry } from './adapters/base/AdapterRegistry.js';
import { MockAdapter } from './adapters/mock/MockAdapter.js';
import { AirbnbAdapter } from './adapters/airbnb/AirbnbAdapter.js';
import { VrboAdapter } from './adapters/vrbo/VrboAdapter.js';
import { jobQueue, type JobType } from './queue/jobQueue.js';
import { startWorker } from './queue/worker.js';
// Register adapters
registry.register(new MockAdapter());
registry.register(new AirbnbAdapter());
registry.register(new VrboAdapter());
const app = Fastify({ logger: true });
// ── Health Check ──────────────────────────────────────────────────────────────
app.get('/health', async () => {
return {
status: 'ok',
service: 'scraper',
timestamp: new Date().toISOString(),
adapters: registry.list(),
};
});
// ── Create Scrape Job ─────────────────────────────────────────────────────────
app.post<{
Body: { platformId: string; jobType: JobType; triggeredBy: string };
}>('/jobs', async (request, reply) => {
const { platformId, jobType, triggeredBy } = request.body;
if (!platformId || !jobType || !triggeredBy) {
return reply.status(400).send({ error: 'Missing required fields: platformId, jobType, triggeredBy' });
}
if (!registry.has(platformId)) {
return reply.status(400).send({ error: `Unknown platform: ${platformId}` });
}
const validJobTypes: JobType[] = ['performance', 'reservations', 'pricing', 'full'];
if (!validJobTypes.includes(jobType)) {
return reply.status(400).send({ error: `Invalid jobType. Must be one of: ${validJobTypes.join(', ')}` });
}
const job = jobQueue.enqueue({ platformId, jobType, triggeredBy });
return reply.status(201).send(job);
});
// ── Get Job Status ────────────────────────────────────────────────────────────
app.get<{
Params: { id: string };
}>('/jobs/:id', async (request, reply) => {
const job = jobQueue.getJob(request.params.id);
if (!job) {
return reply.status(404).send({ error: 'Job not found' });
}
return job;
});
// ── Platform Self-Test ────────────────────────────────────────────────────────
app.post<{
Params: { id: string };
}>('/platforms/:id/test', async (request, reply) => {
const adapter = registry.get(request.params.id);
if (!adapter) {
return reply.status(404).send({ error: `Unknown platform: ${request.params.id}` });
}
try {
const result = await adapter.selfTest();
return result;
} catch (err: any) {
return reply.status(500).send({
platformId: request.params.id,
healthy: false,
message: err.message,
checkedAt: new Date().toISOString(),
});
}
});
// ── Platform Login ───────────────────────────────────────────────────────────
app.post<{
Params: { id: string };
Body: { email?: string; password?: string };
}>('/platforms/:id/login', async (request, reply) => {
const adapter = registry.get(request.params.id);
if (!adapter) {
return reply.status(404).send({ error: `Unknown platform: ${request.params.id}` });
}
const email = request.body?.email || process.env[`${request.params.id.toUpperCase()}_EMAIL`] || '';
const password = request.body?.password || process.env[`${request.params.id.toUpperCase()}_PASSWORD`] || '';
if (!email || !password) {
return reply.status(400).send({
error: `Missing credentials. Provide email/password in body or set ${request.params.id.toUpperCase()}_EMAIL and ${request.params.id.toUpperCase()}_PASSWORD env vars.`,
});
}
try {
await adapter.login({ email, password });
return {
platformId: request.params.id,
status: 'logged_in',
message: 'Login successful. Session cookies saved.',
at: new Date().toISOString(),
};
} catch (err: any) {
return reply.status(500).send({
platformId: request.params.id,
status: 'login_failed',
message: err.message,
at: new Date().toISOString(),
});
}
});
// ── Platform Session Check ──────────────────────────────────────────────────
app.get<{
Params: { id: string };
}>('/platforms/:id/session', async (request, reply) => {
const adapter = registry.get(request.params.id);
if (!adapter) {
return reply.status(404).send({ error: `Unknown platform: ${request.params.id}` });
}
try {
const valid = await adapter.isSessionValid();
return {
platformId: request.params.id,
sessionValid: valid,
checkedAt: new Date().toISOString(),
};
} catch (err: any) {
return reply.status(500).send({
platformId: request.params.id,
sessionValid: false,
error: err.message,
checkedAt: new Date().toISOString(),
});
}
});
// ── Debug DOM Inspection ────────────────────────────────────────────────────
app.get<{
Params: { id: string };
Querystring: { url: string };
}>('/platforms/:id/debug-dom', async (request, reply) => {
const adapter = registry.get(request.params.id) as any;
if (!adapter) {
return reply.status(404).send({ error: `Unknown platform: ${request.params.id}` });
}
const url = (request.query as any).url;
if (!url) {
return reply.status(400).send({ error: 'Provide ?url= parameter' });
}
try {
const page = await adapter.ensureBrowser();
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(5000);
const result = await page.evaluate(() => {
// Table structure
const headers: string[] = [];
document.querySelectorAll('table th, table thead td').forEach((th: any) => {
headers.push(th.textContent?.trim() || '');
});
const rows: string[][] = [];
document.querySelectorAll('table tbody tr, table tr').forEach((tr: any) => {
const cells: string[] = [];
tr.querySelectorAll('td').forEach((td: any) => {
cells.push(td.textContent?.trim().replace(/\n/g, ' | ') || '');
});
if (cells.length > 0) rows.push(cells);
});
// data-testid values
const testIds = new Set<string>();
document.querySelectorAll('[data-testid]').forEach(el => {
testIds.add(el.getAttribute('data-testid') || '');
});
// Price text
const priceTexts: string[] = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
const text = walker.currentNode.textContent?.trim() || '';
if (/^\$\d+$/.test(text)) priceTexts.push(text);
}
// All links with numeric IDs
const links: { href: string; text: string }[] = [];
document.querySelectorAll('a[href]').forEach((a: any) => {
const href = a.getAttribute('href') || '';
if (/\/\d{5,}/.test(href)) {
links.push({ href, text: a.textContent?.trim().substring(0, 100) || '' });
}
});
// Main text
const mainText = document.querySelector('main')?.textContent?.substring(0, 2000) || '';
return { headers, rows: rows.slice(0, 5), testIds: Array.from(testIds), priceTexts: priceTexts.slice(0, 20), links, mainText };
});
return reply.send({ url, ...result });
} catch (err: any) {
return reply.status(500).send({ error: err.message });
}
});
// ── Apply Price Changes ───────────────────────────────────────────────────────
app.post<{
Params: { id: string };
Body: { changes: any[] };
}>('/platforms/:id/price-apply', async (request, reply) => {
const adapter = registry.get(request.params.id);
if (!adapter) {
return reply.status(404).send({ error: `Unknown platform: ${request.params.id}` });
}
const { changes } = request.body;
if (!changes || !Array.isArray(changes)) {
return reply.status(400).send({ error: 'Request body must include a changes array' });
}
try {
const result = await adapter.applyPriceChanges(changes);
return result;
} catch (err: any) {
return reply.status(500).send({ error: err.message });
}
});
// ── Start Server ──────────────────────────────────────────────────────────────
const PORT = Number(process.env.SCRAPER_PORT) || 3001;
async function start() {
try {
startWorker();
await app.listen({ port: PORT, host: '0.0.0.0' });
console.log(`Scraper service running on port ${PORT}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
start();

View File

@@ -0,0 +1,78 @@
import { randomUUID } from 'node:crypto';
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed';
export type JobType = 'performance' | 'reservations' | 'pricing' | 'full';
export interface Job {
id: string;
platformId: string;
jobType: JobType;
triggeredBy: string;
status: JobStatus;
createdAt: string;
startedAt: string | null;
completedAt: string | null;
errorMessage: string | null;
rowsCollected: number | null;
result: any | null;
}
class JobQueue {
private jobs = new Map<string, Job>();
private pending: string[] = [];
enqueue(params: { platformId: string; jobType: JobType; triggeredBy: string }): Job {
const job: Job = {
id: randomUUID(),
platformId: params.platformId,
jobType: params.jobType,
triggeredBy: params.triggeredBy,
status: 'pending',
createdAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
errorMessage: null,
rowsCollected: null,
result: null,
};
this.jobs.set(job.id, job);
this.pending.push(job.id);
return job;
}
dequeue(): Job | undefined {
const id = this.pending.shift();
if (!id) return undefined;
const job = this.jobs.get(id);
if (job) {
job.status = 'running';
job.startedAt = new Date().toISOString();
}
return job;
}
getJob(id: string): Job | undefined {
return this.jobs.get(id);
}
updateJob(id: string, updates: Partial<Pick<Job, 'status' | 'completedAt' | 'errorMessage' | 'rowsCollected' | 'result'>>): Job | undefined {
const job = this.jobs.get(id);
if (!job) return undefined;
Object.assign(job, updates);
return job;
}
pendingCount(): number {
return this.pending.length;
}
listJobs(): Job[] {
return Array.from(this.jobs.values());
}
}
export const jobQueue = new JobQueue();

View File

@@ -0,0 +1,288 @@
import postgres from 'postgres';
import { jobQueue, type Job } from './jobQueue.js';
import { registry } from '../adapters/base/AdapterRegistry.js';
// Safely convert a value to a valid ISO date string, or return fallback
function safeDate(val: any, fallback?: string): string {
if (!val) return fallback || new Date().toISOString();
try {
const d = new Date(val);
if (isNaN(d.getTime())) return fallback || new Date().toISOString();
return d.toISOString();
} catch {
return fallback || new Date().toISOString();
}
}
// Safely convert to a YYYY-MM-DD date string
function safeDateOnly(val: any, fallback?: string): string {
if (!val) return fallback || new Date().toISOString().split('T')[0];
try {
const d = new Date(val);
if (isNaN(d.getTime())) return fallback || new Date().toISOString().split('T')[0];
return d.toISOString().split('T')[0];
} catch {
return fallback || new Date().toISOString().split('T')[0];
}
}
// Lazy-init raw postgres connection (tagged template = auto-parameterized)
let sql: ReturnType<typeof postgres> | null = null;
function getSql() {
if (!sql) {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
console.warn('[worker] DATABASE_URL not set - DB writes will be skipped');
return null;
}
sql = postgres(connectionString);
}
return sql;
}
async function processJob(job: Job): Promise<void> {
const adapter = registry.getOrThrow(job.platformId);
let result: any;
let rowsCollected = 0;
switch (job.jobType) {
case 'performance': {
result = await adapter.scrapePerformanceMetrics();
rowsCollected = 1;
await persistPerformanceSnapshot(result);
break;
}
case 'reservations': {
result = await adapter.scrapeReservations();
rowsCollected = result.length;
await persistReservations(result);
break;
}
case 'pricing': {
const today = new Date();
const from = today.toISOString().split('T')[0];
const toDate = new Date(today);
toDate.setDate(toDate.getDate() + 90);
const to = toDate.toISOString().split('T')[0];
result = await adapter.scrapePricing({ from, to });
rowsCollected = result.length;
await persistDailyPrices(result);
break;
}
case 'full': {
const perfResult = await adapter.scrapePerformanceMetrics();
await persistPerformanceSnapshot(perfResult);
const reservationsResult = await adapter.scrapeReservations();
await persistReservations(reservationsResult);
const todayFull = new Date();
const fromFull = todayFull.toISOString().split('T')[0];
const toDateFull = new Date(todayFull);
toDateFull.setDate(toDateFull.getDate() + 90);
const toFull = toDateFull.toISOString().split('T')[0];
const pricingResult = await adapter.scrapePricing({ from: fromFull, to: toFull });
await persistDailyPrices(pricingResult);
rowsCollected = 1 + reservationsResult.length + pricingResult.length;
result = {
performance: perfResult,
reservations: reservationsResult,
pricing: pricingResult,
};
break;
}
default:
throw new Error(`Unknown job type: ${job.jobType}`);
}
jobQueue.updateJob(job.id, {
status: 'completed',
completedAt: new Date().toISOString(),
rowsCollected,
result,
});
await persistJobStatus(job.id, 'completed', rowsCollected);
}
async function persistPerformanceSnapshot(snapshot: any): Promise<void> {
const db = getSql();
if (!db) return;
try {
await db`
INSERT INTO performance_snapshots (platform_id, captured_at, period_label, views_search, views_listing, conversion_rate, bookings_count, occupancy_rate, avg_daily_rate, revenue_total, raw_json)
VALUES (
${snapshot.platformId},
${snapshot.capturedAt},
${snapshot.periodLabel},
${snapshot.viewsSearch},
${snapshot.viewsListing},
${snapshot.conversionRate},
${snapshot.bookingsCount},
${snapshot.occupancyRate},
${snapshot.avgDailyRate},
${snapshot.revenueTotal},
${JSON.stringify(snapshot.rawJson)}
)
`;
} catch (err) {
console.error('[worker] Failed to persist performance snapshot:', err);
}
}
async function persistReservations(reservations: any[]): Promise<void> {
const db = getSql();
if (!db) return;
for (const r of reservations) {
try {
const checkIn = safeDateOnly(r.checkIn);
const checkOut = safeDateOnly(r.checkOut);
const bookedAt = safeDate(r.bookedAt);
const nights = isNaN(Number(r.nights)) ? 1 : Number(r.nights);
const guestsCount = isNaN(Number(r.guestsCount)) ? 1 : Number(r.guestsCount);
const nightlyRate = isNaN(Number(r.nightlyRate)) ? 0 : Number(r.nightlyRate);
const cleaningFee = isNaN(Number(r.cleaningFee)) ? 0 : Number(r.cleaningFee);
const platformFee = isNaN(Number(r.platformFee)) ? 0 : Number(r.platformFee);
const totalPayout = isNaN(Number(r.totalPayout)) ? 0 : Number(r.totalPayout);
await db`
INSERT INTO reservations (platform_id, platform_reservation_id, guest_name, check_in, check_out, nights, guests_count, nightly_rate, cleaning_fee, platform_fee, total_payout, status, booked_at, raw_json)
VALUES (
${r.platformId},
${r.platformReservationId},
${r.guestName},
${checkIn},
${checkOut},
${nights},
${guestsCount},
${nightlyRate},
${cleaningFee},
${platformFee},
${totalPayout},
${r.status},
${bookedAt},
${JSON.stringify(r.rawJson)}
)
ON CONFLICT (platform_id, platform_reservation_id) DO UPDATE SET
guest_name = EXCLUDED.guest_name,
check_in = EXCLUDED.check_in,
check_out = EXCLUDED.check_out,
nights = EXCLUDED.nights,
guests_count = EXCLUDED.guests_count,
nightly_rate = EXCLUDED.nightly_rate,
cleaning_fee = EXCLUDED.cleaning_fee,
platform_fee = EXCLUDED.platform_fee,
total_payout = EXCLUDED.total_payout,
status = EXCLUDED.status,
synced_at = NOW()
`;
} catch (err) {
console.error('[worker] Failed to persist reservation:', err);
}
}
}
async function persistDailyPrices(prices: any[]): Promise<void> {
const db = getSql();
if (!db) return;
for (const p of prices) {
try {
await db`
INSERT INTO daily_prices (platform_id, date, price, is_available, min_stay_nights, synced_at)
VALUES (
${p.platformId},
${p.date},
${p.price},
${p.isAvailable},
${p.minStayNights},
${p.syncedAt}
)
ON CONFLICT (platform_id, date) DO UPDATE SET
price = EXCLUDED.price,
is_available = EXCLUDED.is_available,
min_stay_nights = EXCLUDED.min_stay_nights,
synced_at = NOW()
`;
} catch (err) {
console.error('[worker] Failed to persist daily price:', err);
}
}
}
async function persistJobStatus(jobId: string, status: string, rowsCollected: number): Promise<void> {
const db = getSql();
if (!db) return;
try {
await db`
UPDATE scrape_jobs SET status = ${status}, completed_at = NOW(), rows_collected = ${rowsCollected}
WHERE id = ${jobId}::uuid
`;
} catch (err) {
// Job might not be in DB (e.g., in-memory only mode)
console.warn('[worker] Could not update job in DB:', err);
}
}
let polling = false;
let pollInterval: ReturnType<typeof setInterval> | null = null;
async function poll(): Promise<void> {
if (polling) return;
polling = true;
try {
const job = jobQueue.dequeue();
if (!job) return;
console.log(`[worker] Processing job ${job.id} (${job.jobType} for ${job.platformId})`);
try {
await processJob(job);
console.log(`[worker] Job ${job.id} completed`);
} catch (err: any) {
console.error(`[worker] Job ${job.id} failed:`, err.message);
jobQueue.updateJob(job.id, {
status: 'failed',
completedAt: new Date().toISOString(),
errorMessage: err.message,
});
const db = getSql();
if (db) {
try {
await db`
UPDATE scrape_jobs SET status = 'failed', completed_at = NOW(), error_message = ${err.message}
WHERE id = ${job.id}::uuid
`;
} catch {
// Ignore DB errors for job status
}
}
}
} finally {
polling = false;
}
}
export function startWorker(intervalMs = 2000): void {
if (pollInterval) return;
console.log(`[worker] Starting worker (polling every ${intervalMs}ms)`);
pollInterval = setInterval(poll, intervalMs);
// Run once immediately
poll();
}
export function stopWorker(): void {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
console.log('[worker] Worker stopped');
}
}

View File

@@ -0,0 +1,54 @@
import { chromium, type Browser } from 'playwright';
const DEFAULT_USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36';
export interface BrowserOptions {
headless?: boolean;
userAgent?: string;
viewportWidth?: number;
viewportHeight?: number;
}
export async function createBrowser(options: BrowserOptions = {}): Promise<Browser> {
const {
headless = true,
userAgent = DEFAULT_USER_AGENT,
viewportWidth = 1920,
viewportHeight = 1080,
} = options;
const browser = await chromium.launch({
headless,
args: [
'--disable-blink-features=AutomationControlled',
'--disable-features=IsolateOrigins,site-per-process',
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
`--window-size=${viewportWidth},${viewportHeight}`,
],
});
const context = await browser.newContext({
userAgent,
viewport: { width: viewportWidth, height: viewportHeight },
locale: 'en-US',
timezoneId: 'America/New_York',
permissions: [],
javaScriptEnabled: true,
});
// Remove the webdriver flag to avoid detection
await context.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
});
// Close the default context page - callers use context.newPage()
const pages = context.pages();
if (pages.length > 0) {
await pages[0].close();
}
return browser;
}

View File

@@ -0,0 +1,8 @@
/**
* Returns a promise that resolves after a random delay between min and max milliseconds.
* Useful for mimicking human-like timing in browser automation.
*/
export function randomDelay(min: number, max: number): Promise<void> {
const ms = Math.floor(Math.random() * (max - min + 1)) + min;
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,62 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const ENCODING = 'base64' as const;
function getEncryptionKey(): Buffer {
const key = process.env.ENCRYPTION_KEY;
if (!key) {
throw new Error('ENCRYPTION_KEY environment variable is not set');
}
const keyBuffer = Buffer.from(key, ENCODING);
if (keyBuffer.length !== 32) {
throw new Error('ENCRYPTION_KEY must be exactly 32 bytes (base64-encoded)');
}
return keyBuffer;
}
export function encrypt(plaintext: string): string {
const key = getEncryptionKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(plaintext, 'utf8');
encrypted = Buffer.concat([encrypted, cipher.final()]);
const tag = cipher.getAuthTag();
// Format: iv:tag:ciphertext (all base64)
return [
iv.toString(ENCODING),
tag.toString(ENCODING),
encrypted.toString(ENCODING),
].join(':');
}
export function decrypt(encryptedStr: string): string {
const key = getEncryptionKey();
const parts = encryptedStr.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted string format');
}
const iv = Buffer.from(parts[0], ENCODING);
const tag = Buffer.from(parts[1], ENCODING);
const encrypted = Buffer.from(parts[2], ENCODING);
if (iv.length !== IV_LENGTH) {
throw new Error('Invalid IV length');
}
if (tag.length !== TAG_LENGTH) {
throw new Error('Invalid auth tag length');
}
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
let decrypted = decipher.update(encrypted);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8');
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}