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:
29
.claude/launch.json
Normal file
29
.claude/launch.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "PostgreSQL",
|
||||
"runtimeExecutable": "docker",
|
||||
"runtimeArgs": ["compose", "up", "db"],
|
||||
"port": 5433
|
||||
},
|
||||
{
|
||||
"name": "API Server",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev:api"],
|
||||
"port": 3000
|
||||
},
|
||||
{
|
||||
"name": "Scraper Service",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev:scraper"],
|
||||
"port": 3001
|
||||
},
|
||||
{
|
||||
"name": "Frontend (Vite)",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev:frontend"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
24
.env.example
Normal file
24
.env.example
Normal file
@@ -0,0 +1,24 @@
|
||||
# Database
|
||||
DB_USER=str_manager
|
||||
DB_PASSWORD=changeme_strong_password
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5433
|
||||
DATABASE_URL=postgresql://str_manager:changeme_strong_password@localhost:5433/str_manager
|
||||
|
||||
# App Security
|
||||
JWT_SECRET=changeme_64_char_random_string
|
||||
ENCRYPTION_KEY=changeme_32_char_aes_key_here!!
|
||||
|
||||
# Email (weekly report)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=you@gmail.com
|
||||
SMTP_PASS=your_app_password
|
||||
REPORT_EMAIL_TO=you@gmail.com
|
||||
|
||||
# App login
|
||||
APP_USERNAME=admin
|
||||
APP_PASSWORD_HASH=$2b$10$placeholder_bcrypt_hash
|
||||
|
||||
# Scraper
|
||||
SCRAPER_URL=http://localhost:3001
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
.airbnb-session/
|
||||
*.log
|
||||
12
apps/api/drizzle.config.ts
Normal file
12
apps/api/drizzle.config.ts
Normal 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
41
apps/api/package.json
Normal 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
27
apps/api/src/config.ts
Normal 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);
|
||||
545
apps/api/src/db/demo-seed.sql
Normal file
545
apps/api/src/db/demo-seed.sql
Normal 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
14
apps/api/src/db/index.ts
Normal 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 };
|
||||
96
apps/api/src/db/migrations/0000_modern_screwball.sql
Normal file
96
apps/api/src/db/migrations/0000_modern_screwball.sql
Normal 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;
|
||||
630
apps/api/src/db/migrations/meta/0000_snapshot.json
Normal file
630
apps/api/src/db/migrations/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
apps/api/src/db/migrations/meta/_journal.json
Normal file
13
apps/api/src/db/migrations/meta/_journal.json
Normal 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
195
apps/api/src/db/schema.ts
Normal 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],
|
||||
}),
|
||||
}));
|
||||
44
apps/api/src/db/seed-clean.ts
Normal file
44
apps/api/src/db/seed-clean.ts
Normal 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);
|
||||
});
|
||||
340
apps/api/src/db/seed-demo.ts
Normal file
340
apps/api/src/db/seed-demo.ts
Normal 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
340
apps/api/src/db/seed.ts
Normal 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);
|
||||
});
|
||||
194
apps/api/src/email/weeklyReport.ts
Normal file
194
apps/api/src/email/weeklyReport.ts
Normal 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
54
apps/api/src/index.ts
Normal 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);
|
||||
});
|
||||
41
apps/api/src/plugins/auth.ts
Normal file
41
apps/api/src/plugins/auth.ts
Normal 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' });
|
||||
}
|
||||
47
apps/api/src/routes/auth.ts
Normal file
47
apps/api/src/routes/auth.ts
Normal 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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
145
apps/api/src/routes/experiments.ts
Normal file
145
apps/api/src/routes/experiments.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
20
apps/api/src/routes/health.ts
Normal file
20
apps/api/src/routes/health.ts
Normal 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(),
|
||||
});
|
||||
});
|
||||
}
|
||||
142
apps/api/src/routes/performance.ts
Normal file
142
apps/api/src/routes/performance.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
209
apps/api/src/routes/platforms.ts
Normal file
209
apps/api/src/routes/platforms.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
185
apps/api/src/routes/pricing.ts
Normal file
185
apps/api/src/routes/pricing.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
30
apps/api/src/routes/reports.ts
Normal file
30
apps/api/src/routes/reports.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
75
apps/api/src/routes/reservations.ts
Normal file
75
apps/api/src/routes/reservations.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
58
apps/api/src/scheduler/cron.ts
Normal file
58
apps/api/src/scheduler/cron.ts
Normal 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)');
|
||||
}
|
||||
45
apps/api/src/services/encryption.ts
Normal file
45
apps/api/src/services/encryption.ts
Normal 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
8
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
15
apps/frontend/index.html
Normal file
15
apps/frontend/index.html
Normal 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>
|
||||
41
apps/frontend/package.json
Normal file
41
apps/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/frontend/postcss.config.js
Normal file
6
apps/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
42
apps/frontend/src/App.tsx
Normal file
42
apps/frontend/src/App.tsx
Normal 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;
|
||||
138
apps/frontend/src/components/layout/Shell.tsx
Normal file
138
apps/frontend/src/components/layout/Shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
apps/frontend/src/hooks/useAuth.ts
Normal file
47
apps/frontend/src/hooks/useAuth.ts
Normal 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 };
|
||||
}
|
||||
62
apps/frontend/src/index.css
Normal file
62
apps/frontend/src/index.css
Normal 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);
|
||||
}
|
||||
119
apps/frontend/src/lib/api.ts
Normal file
119
apps/frontend/src/lib/api.ts
Normal 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 };
|
||||
27
apps/frontend/src/lib/constants.ts
Normal file
27
apps/frontend/src/lib/constants.ts
Normal 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',
|
||||
};
|
||||
52
apps/frontend/src/lib/utils.ts
Normal file
52
apps/frontend/src/lib/utils.ts
Normal 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`;
|
||||
}
|
||||
25
apps/frontend/src/main.tsx
Normal file
25
apps/frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
306
apps/frontend/src/pages/Dashboard.tsx
Normal file
306
apps/frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
530
apps/frontend/src/pages/Experiments.tsx
Normal file
530
apps/frontend/src/pages/Experiments.tsx
Normal 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)} – {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">→</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>
|
||||
);
|
||||
}
|
||||
105
apps/frontend/src/pages/Login.tsx
Normal file
105
apps/frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
602
apps/frontend/src/pages/Performance.tsx
Normal file
602
apps/frontend/src/pages/Performance.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
714
apps/frontend/src/pages/Pricing.tsx
Normal file
714
apps/frontend/src/pages/Pricing.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
367
apps/frontend/src/pages/Reservations.tsx
Normal file
367
apps/frontend/src/pages/Reservations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
573
apps/frontend/src/pages/Settings.tsx
Normal file
573
apps/frontend/src/pages/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/frontend/tailwind.config.ts
Normal file
27
apps/frontend/tailwind.config.ts
Normal 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;
|
||||
19
apps/frontend/tsconfig.json
Normal file
19
apps/frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
21
apps/frontend/vite.config.ts
Normal file
21
apps/frontend/vite.config.ts
Normal 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
25
apps/scraper/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
215
apps/scraper/src/adapters/airbnb/AirbnbAdapter.ts
Normal file
215
apps/scraper/src/adapters/airbnb/AirbnbAdapter.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
663
apps/scraper/src/adapters/airbnb/airbnb.flows.ts
Normal file
663
apps/scraper/src/adapters/airbnb/airbnb.flows.ts
Normal 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;
|
||||
}
|
||||
86
apps/scraper/src/adapters/airbnb/airbnb.selectors.ts
Normal file
86
apps/scraper/src/adapters/airbnb/airbnb.selectors.ts
Normal 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;
|
||||
31
apps/scraper/src/adapters/base/AdapterRegistry.ts
Normal file
31
apps/scraper/src/adapters/base/AdapterRegistry.ts
Normal 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();
|
||||
36
apps/scraper/src/adapters/base/PlatformAdapter.ts
Normal file
36
apps/scraper/src/adapters/base/PlatformAdapter.ts
Normal 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;
|
||||
}>;
|
||||
}
|
||||
79
apps/scraper/src/adapters/mock/MockAdapter.ts
Normal file
79
apps/scraper/src/adapters/mock/MockAdapter.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
151
apps/scraper/src/adapters/mock/mock-data.ts
Normal file
151
apps/scraper/src/adapters/mock/mock-data.ts
Normal 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;
|
||||
}
|
||||
48
apps/scraper/src/adapters/vrbo/VrboAdapter.ts
Normal file
48
apps/scraper/src/adapters/vrbo/VrboAdapter.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
20
apps/scraper/src/adapters/vrbo/vrbo.flows.ts
Normal file
20
apps/scraper/src/adapters/vrbo/vrbo.flows.ts
Normal 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');
|
||||
}
|
||||
18
apps/scraper/src/adapters/vrbo/vrbo.selectors.ts
Normal file
18
apps/scraper/src/adapters/vrbo/vrbo.selectors.ts
Normal 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
270
apps/scraper/src/index.ts
Normal 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();
|
||||
78
apps/scraper/src/queue/jobQueue.ts
Normal file
78
apps/scraper/src/queue/jobQueue.ts
Normal 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();
|
||||
288
apps/scraper/src/queue/worker.ts
Normal file
288
apps/scraper/src/queue/worker.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
54
apps/scraper/src/utils/browser.ts
Normal file
54
apps/scraper/src/utils/browser.ts
Normal 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;
|
||||
}
|
||||
8
apps/scraper/src/utils/delay.ts
Normal file
8
apps/scraper/src/utils/delay.ts
Normal 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));
|
||||
}
|
||||
62
apps/scraper/src/utils/encryption.ts
Normal file
62
apps/scraper/src/utils/encryption.ts
Normal 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');
|
||||
}
|
||||
8
apps/scraper/tsconfig.json
Normal file
8
apps/scraper/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: str_manager
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
ports:
|
||||
- "${DB_PORT:-5433}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
7880
package-lock.json
generated
Normal file
7880
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "str-optimization-manager",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev --workspace apps/api\" \"npm run dev --workspace apps/frontend\"",
|
||||
"dev:api": "npm run dev --workspace apps/api",
|
||||
"dev:frontend": "npm run dev --workspace apps/frontend",
|
||||
"dev:scraper": "npm run dev --workspace apps/scraper",
|
||||
"db:generate": "npm run db:generate --workspace apps/api",
|
||||
"db:migrate": "npm run db:migrate --workspace apps/api",
|
||||
"db:seed": "npm run db:seed --workspace apps/api",
|
||||
"db:seed:demo": "npm run db:seed:demo --workspace apps/api",
|
||||
"db:seed:clean": "npm run db:seed:clean --workspace apps/api",
|
||||
"build": "npm run build --workspaces"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
14
packages/shared-types/package.json
Normal file
14
packages/shared-types/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@str/shared-types",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
256
packages/shared-types/src/index.ts
Normal file
256
packages/shared-types/src/index.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
// ─── Enums (string literal unions) ───────────────────────────────────────────
|
||||
|
||||
export type PlatformId = 'airbnb' | 'vrbo' | 'mock';
|
||||
|
||||
export type JobType = 'performance' | 'pricing' | 'reservations';
|
||||
|
||||
export type JobStatus = 'pending' | 'running' | 'success' | 'failed';
|
||||
|
||||
export type ExperimentStatus = 'active' | 'completed' | 'cancelled';
|
||||
|
||||
export type ReservationStatus = 'confirmed' | 'cancelled' | 'completed';
|
||||
|
||||
export type TriggerSource = 'schedule' | 'manual';
|
||||
|
||||
// ─── Core DB Interfaces ─────────────────────────────────────────────────────
|
||||
|
||||
export interface Platform {
|
||||
id: PlatformId;
|
||||
displayName: string;
|
||||
lastScrapeAt: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface PerformanceSnapshot {
|
||||
id: string;
|
||||
platformId: PlatformId;
|
||||
capturedAt: string;
|
||||
periodLabel: string;
|
||||
viewsSearch: number;
|
||||
viewsListing: number;
|
||||
conversionRate: number;
|
||||
bookingsCount: number;
|
||||
occupancyRate: number;
|
||||
avgDailyRate: number;
|
||||
revenueTotal: number;
|
||||
rawJson: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface DailyPrice {
|
||||
id: string;
|
||||
platformId: PlatformId;
|
||||
date: string;
|
||||
price: number;
|
||||
isAvailable: boolean;
|
||||
minStayNights: number;
|
||||
syncedAt: string;
|
||||
}
|
||||
|
||||
export interface PriceChange {
|
||||
id: string;
|
||||
platformId: PlatformId;
|
||||
date: string;
|
||||
priceBefore: number;
|
||||
priceAfter: number;
|
||||
changedAt: string;
|
||||
changedBy: string;
|
||||
note: string | null;
|
||||
experimentId: string | null;
|
||||
}
|
||||
|
||||
export interface Experiment {
|
||||
id: string;
|
||||
name: string;
|
||||
hypothesis: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: ExperimentStatus;
|
||||
createdAt: string;
|
||||
conclusion: string | null;
|
||||
}
|
||||
|
||||
export interface Reservation {
|
||||
id: string;
|
||||
platformId: PlatformId;
|
||||
platformReservationId: string;
|
||||
guestName: string;
|
||||
checkIn: string;
|
||||
checkOut: string;
|
||||
nights: number;
|
||||
guestsCount: number;
|
||||
nightlyRate: number;
|
||||
cleaningFee: number;
|
||||
platformFee: number;
|
||||
totalPayout: number;
|
||||
status: ReservationStatus;
|
||||
bookedAt: string;
|
||||
syncedAt: string;
|
||||
rawJson: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface ScrapeJob {
|
||||
id: string;
|
||||
platformId: PlatformId;
|
||||
jobType: JobType;
|
||||
triggeredBy: TriggerSource;
|
||||
status: JobStatus;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
errorMessage: string | null;
|
||||
rowsCollected: number | null;
|
||||
}
|
||||
|
||||
// ─── Adapter Interfaces ─────────────────────────────────────────────────────
|
||||
|
||||
export interface Credentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface SessionStore {
|
||||
save(platformId: string, data: string): Promise<void>;
|
||||
load(platformId: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface PriceChangeRequest {
|
||||
date: string;
|
||||
price: number;
|
||||
platformIds: string[];
|
||||
}
|
||||
|
||||
export interface PriceChangeDiffItem {
|
||||
date: string;
|
||||
platformId: string;
|
||||
currentPrice: number;
|
||||
newPrice: number;
|
||||
delta: number;
|
||||
}
|
||||
|
||||
export interface PriceChangeDiff {
|
||||
items: PriceChangeDiffItem[];
|
||||
previewToken: string;
|
||||
}
|
||||
|
||||
export interface PriceChangeSuccessItem {
|
||||
date: string;
|
||||
platformId: string;
|
||||
}
|
||||
|
||||
export interface PriceChangeFailedItem {
|
||||
date: string;
|
||||
platformId: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface PriceChangeResult {
|
||||
success: PriceChangeSuccessItem[];
|
||||
failed: PriceChangeFailedItem[];
|
||||
}
|
||||
|
||||
export interface AdapterHealthStatus {
|
||||
platformId: string;
|
||||
healthy: boolean;
|
||||
message: string;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
// ─── Platform Adapter ────────────────────────────────────────────────────────
|
||||
|
||||
export interface PlatformAdapter {
|
||||
readonly platformId: string;
|
||||
readonly displayName: string;
|
||||
login(credentials: Credentials): Promise<void>;
|
||||
isSessionValid(): Promise<boolean>;
|
||||
saveSession(store: SessionStore): Promise<void>;
|
||||
restoreSession(store: SessionStore): Promise<boolean>;
|
||||
scrapePerformanceMetrics(): Promise<PerformanceSnapshot>;
|
||||
scrapeReservations(): Promise<Reservation[]>;
|
||||
scrapePricing(dateRange: DateRange): Promise<DailyPrice[]>;
|
||||
previewPriceChanges(changes: PriceChangeRequest[]): Promise<PriceChangeDiff>;
|
||||
applyPriceChanges(changes: PriceChangeRequest[]): Promise<PriceChangeResult>;
|
||||
selfTest(): Promise<AdapterHealthStatus>;
|
||||
}
|
||||
|
||||
// ─── API DTOs ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
error: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface PerformanceSummary {
|
||||
platformId: PlatformId;
|
||||
periodLabel: string;
|
||||
viewsSearch: number;
|
||||
viewsListing: number;
|
||||
conversionRate: number;
|
||||
bookingsCount: number;
|
||||
occupancyRate: number;
|
||||
avgDailyRate: number;
|
||||
revenueTotal: number;
|
||||
}
|
||||
|
||||
export interface PerformanceTrend {
|
||||
platformId: PlatformId;
|
||||
metric: string;
|
||||
dataPoints: Array<{ date: string; value: number }>;
|
||||
}
|
||||
|
||||
export interface ReservationSummary {
|
||||
totalReservations: number;
|
||||
upcomingCheckIns: number;
|
||||
totalRevenue: number;
|
||||
averageNightlyRate: number;
|
||||
averageStayLength: number;
|
||||
occupancyRate: number;
|
||||
}
|
||||
|
||||
export interface WeeklyReportPreview {
|
||||
weekStarting: string;
|
||||
weekEnding: string;
|
||||
performanceSummaries: PerformanceSummary[];
|
||||
reservationSummary: ReservationSummary;
|
||||
priceChanges: PriceChange[];
|
||||
activeExperiments: Experiment[];
|
||||
}
|
||||
|
||||
export interface CreateExperimentRequest {
|
||||
name: string;
|
||||
hypothesis: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export interface UpdateExperimentRequest {
|
||||
name?: string;
|
||||
hypothesis?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
status?: ExperimentStatus;
|
||||
conclusion?: string;
|
||||
}
|
||||
8
packages/shared-types/tsconfig.json
Normal file
8
packages/shared-types/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
661
str-optimization-manager-spec.md
Normal file
661
str-optimization-manager-spec.md
Normal file
@@ -0,0 +1,661 @@
|
||||
# STR Optimization Manager — Claude Code Requirements Specification
|
||||
|
||||
## 1. Project Overview
|
||||
|
||||
A self-hosted, Dockerized web application for managing and optimizing a short-term rental (STR) property listed on multiple platforms (initially Airbnb and VRBO). The system automates daily performance data collection via browser automation, stores historical metrics in a local database, enables bulk pricing management across platforms, tracks pricing experiments (A/B style), and syncs reservation data for long-term record keeping.
|
||||
|
||||
**Design philosophy:** Modular by platform and by property. Every platform integration is an isolated adapter. Adding a new platform or a second property should require only a new adapter and config entry, not architectural changes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals & Non-Goals
|
||||
|
||||
### Goals
|
||||
- Automated daily (and on-demand) scraping of performance metrics from Airbnb and VRBO
|
||||
- Local time-series database of all collected metrics
|
||||
- Dashboard with filterable, date-ranged charts for performance analysis
|
||||
- Bulk pricing management across platforms (with preview/diff before commit)
|
||||
- Pricing change log with experiment tagging and correlation to booking outcomes
|
||||
- Full reservation sync and local storage
|
||||
- Weekly performance summary email report
|
||||
- Docker Compose deployment, runs on Mac (dev) and Debian (prod)
|
||||
- Responsive UI: desktop and mobile
|
||||
|
||||
### Non-Goals (explicitly out of scope for v1)
|
||||
- Multi-property support (architecture should allow it later, but not built now)
|
||||
- Cleaning/turnover scheduling
|
||||
- Guest messaging
|
||||
- Expense tracking / P&L
|
||||
- Tax reporting
|
||||
- Public platform APIs (all data collection is via authenticated browser sessions)
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Docker Compose │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │
|
||||
│ │ Frontend │ │ API Server │ │ Scraper Worker │ │
|
||||
│ │ (React/TS) │◄──│ (Node/TS + │◄──│ (Playwright + │ │
|
||||
│ │ Vite SPA │ │ Fastify) │ │ adapters) │ │
|
||||
│ └──────────────┘ └──────┬───────┘ └───────┬────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌───────▼────────────────────▼───────┐ │
|
||||
│ │ PostgreSQL Database │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Scheduler (node-cron in API) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Services
|
||||
| Service | Image | Purpose |
|
||||
|---|---|---|
|
||||
| `frontend` | Node (build) → nginx | React SPA served via nginx |
|
||||
| `api` | Node 20 Alpine | Fastify REST API + cron scheduler |
|
||||
| `scraper` | Node 20 + Playwright | Browser automation workers |
|
||||
| `db` | PostgreSQL 16 Alpine | Primary data store |
|
||||
|
||||
---
|
||||
|
||||
## 4. Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Backend API | Node.js 20 + TypeScript + Fastify |
|
||||
| Browser Automation | Playwright (headless Chromium) |
|
||||
| Database | PostgreSQL 16 |
|
||||
| ORM / Migrations | Drizzle ORM + drizzle-kit |
|
||||
| Job Scheduling | node-cron |
|
||||
| Frontend | React 18 + TypeScript + Vite |
|
||||
| Charting | Recharts |
|
||||
| UI Components | shadcn/ui + Tailwind CSS |
|
||||
| Email | Nodemailer (SMTP) |
|
||||
| Auth (app login) | Simple single-user session auth with bcrypt + JWT stored in httpOnly cookie |
|
||||
| Container | Docker + Docker Compose v2 |
|
||||
| Secrets | `.env` file (never committed), credentials encrypted at rest in DB using AES-256 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Platform Adapter Interface
|
||||
|
||||
Every platform integration implements this TypeScript interface. This is the core abstraction that keeps the system extensible.
|
||||
|
||||
```typescript
|
||||
interface PlatformAdapter {
|
||||
readonly platformId: string; // e.g. 'airbnb' | 'vrbo'
|
||||
readonly displayName: string;
|
||||
|
||||
// Session management
|
||||
login(credentials: Credentials): Promise<void>;
|
||||
isSessionValid(): Promise<boolean>;
|
||||
saveSession(store: SessionStore): Promise<void>;
|
||||
restoreSession(store: SessionStore): Promise<boolean>;
|
||||
|
||||
// Data collection
|
||||
scrapePerformanceMetrics(): Promise<PerformanceSnapshot>;
|
||||
scrapeReservations(): Promise<Reservation[]>;
|
||||
scrapePricing(dateRange: DateRange): Promise<DailyPrice[]>;
|
||||
|
||||
// Pricing mutations
|
||||
previewPriceChanges(changes: PriceChange[]): Promise<PriceChangeDiff>;
|
||||
applyPriceChanges(changes: PriceChange[]): Promise<PriceChangeResult>;
|
||||
|
||||
// Adapter health
|
||||
selfTest(): Promise<AdapterHealthStatus>;
|
||||
}
|
||||
```
|
||||
|
||||
### Initial Adapters
|
||||
- `AirbnbAdapter` — targets Airbnb Host dashboard
|
||||
- `VrboAdapter` — targets VRBO Owner dashboard
|
||||
|
||||
### Adapter File Structure
|
||||
```
|
||||
src/
|
||||
adapters/
|
||||
base/
|
||||
PlatformAdapter.ts ← interface + shared types
|
||||
SessionStore.ts ← encrypted session persistence
|
||||
AdapterRegistry.ts ← registers all adapters
|
||||
airbnb/
|
||||
AirbnbAdapter.ts ← main adapter class
|
||||
airbnb.selectors.ts ← ALL CSS selectors isolated here
|
||||
airbnb.flows.ts ← login, nav, scraping flows
|
||||
vrbo/
|
||||
VrboAdapter.ts
|
||||
vrbo.selectors.ts
|
||||
vrbo.flows.ts
|
||||
```
|
||||
|
||||
**Critical pattern:** All CSS selectors and XPaths live in `*.selectors.ts` files only. When a platform updates their UI, only that file needs updating — never the business logic.
|
||||
|
||||
---
|
||||
|
||||
## 6. Database Schema
|
||||
|
||||
### `platforms`
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | varchar PK | e.g. 'airbnb' |
|
||||
| display_name | varchar | |
|
||||
| credentials_encrypted | text | AES-256 encrypted JSON |
|
||||
| session_data_encrypted | text | Stored browser session |
|
||||
| last_scrape_at | timestamptz | |
|
||||
| is_active | boolean | |
|
||||
|
||||
### `performance_snapshots`
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | uuid PK | |
|
||||
| platform_id | varchar FK | |
|
||||
| captured_at | timestamptz | When this row was written |
|
||||
| period_label | varchar | e.g. 'last_30_days' |
|
||||
| views_search | integer | Times appeared in search |
|
||||
| views_listing | integer | Times listing was clicked |
|
||||
| conversion_rate | numeric | views_listing / views_search |
|
||||
| bookings_count | integer | |
|
||||
| occupancy_rate | numeric | % of available days booked |
|
||||
| avg_daily_rate | numeric | |
|
||||
| revenue_total | numeric | |
|
||||
| raw_json | jsonb | Full raw payload for future parsing |
|
||||
|
||||
### `daily_prices`
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | uuid PK | |
|
||||
| platform_id | varchar FK | |
|
||||
| date | date | The night in question |
|
||||
| price | numeric | |
|
||||
| is_available | boolean | |
|
||||
| min_stay_nights | integer | |
|
||||
| synced_at | timestamptz | |
|
||||
|
||||
### `price_changes`
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | uuid PK | |
|
||||
| platform_id | varchar FK | |
|
||||
| date | date | Night being changed |
|
||||
| price_before | numeric | |
|
||||
| price_after | numeric | |
|
||||
| changed_at | timestamptz | |
|
||||
| changed_by | varchar | 'scheduled' or 'manual' |
|
||||
| note | text | User-provided reason |
|
||||
| experiment_id | uuid FK nullable | |
|
||||
|
||||
### `experiments`
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | uuid PK | |
|
||||
| name | varchar | e.g. "Lower weekend rate Jan test" |
|
||||
| hypothesis | text | What you expect to happen |
|
||||
| start_date | date | |
|
||||
| end_date | date | |
|
||||
| status | varchar | 'active' \| 'completed' \| 'cancelled' |
|
||||
| created_at | timestamptz | |
|
||||
| conclusion | text | Notes written at end |
|
||||
|
||||
### `reservations`
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | uuid PK | |
|
||||
| platform_id | varchar FK | |
|
||||
| platform_reservation_id | varchar | Native ID from platform |
|
||||
| guest_name | varchar | |
|
||||
| check_in | date | |
|
||||
| check_out | date | |
|
||||
| nights | integer | |
|
||||
| guests_count | integer | |
|
||||
| nightly_rate | numeric | |
|
||||
| cleaning_fee | numeric | |
|
||||
| platform_fee | numeric | |
|
||||
| total_payout | numeric | |
|
||||
| status | varchar | 'confirmed' \| 'cancelled' \| 'completed' |
|
||||
| booked_at | timestamptz | |
|
||||
| synced_at | timestamptz | |
|
||||
| raw_json | jsonb | |
|
||||
|
||||
### `scrape_jobs`
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | uuid PK | |
|
||||
| platform_id | varchar FK | |
|
||||
| job_type | varchar | 'performance' \| 'pricing' \| 'reservations' |
|
||||
| triggered_by | varchar | 'schedule' \| 'manual' |
|
||||
| status | varchar | 'pending' \| 'running' \| 'success' \| 'failed' |
|
||||
| started_at | timestamptz | |
|
||||
| completed_at | timestamptz | |
|
||||
| error_message | text | |
|
||||
| rows_collected | integer | |
|
||||
|
||||
---
|
||||
|
||||
## 7. API Endpoints
|
||||
|
||||
All endpoints are prefixed `/api/v1`. Auth required on all except `/api/v1/auth/login`.
|
||||
|
||||
### Auth
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| POST | `/auth/login` | App login (single user) |
|
||||
| POST | `/auth/logout` | Clear session |
|
||||
| GET | `/auth/me` | Current session info |
|
||||
|
||||
### Platforms
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/platforms` | List platforms and status |
|
||||
| PUT | `/platforms/:id/credentials` | Update stored credentials |
|
||||
| POST | `/platforms/:id/test` | Test login + adapter health |
|
||||
| POST | `/platforms/:id/scrape` | Trigger on-demand scrape (all types) |
|
||||
| GET | `/platforms/:id/scrape-jobs` | Recent job history |
|
||||
|
||||
### Performance
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/performance/snapshots` | Query snapshots, supports `?platform=&from=&to=` |
|
||||
| GET | `/performance/summary` | Aggregated summary across platforms |
|
||||
| GET | `/performance/trends` | Time-series data for charts |
|
||||
|
||||
### Pricing
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/pricing/calendar` | All daily prices `?platform=&from=&to=` |
|
||||
| POST | `/pricing/preview` | Dry-run bulk price changes, returns diff |
|
||||
| POST | `/pricing/apply` | Apply previewed changes after confirmation |
|
||||
| GET | `/pricing/changes` | Price change log `?platform=&from=&to=&experiment_id=` |
|
||||
|
||||
### Experiments
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/experiments` | List all experiments |
|
||||
| POST | `/experiments` | Create new experiment |
|
||||
| PUT | `/experiments/:id` | Update (add conclusion, change status) |
|
||||
| GET | `/experiments/:id/analysis` | Correlation: price changes → bookings/views |
|
||||
|
||||
### Reservations
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/reservations` | List reservations `?platform=&status=&from=&to=` |
|
||||
| GET | `/reservations/summary` | Occupancy, revenue totals by month/year |
|
||||
|
||||
### Reports
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| POST | `/reports/weekly/send` | Manually trigger weekly email report |
|
||||
| GET | `/reports/weekly/preview` | Preview this week's report as JSON |
|
||||
|
||||
---
|
||||
|
||||
## 8. Frontend — Pages & Views
|
||||
|
||||
### Navigation Structure
|
||||
```
|
||||
/ (Dashboard)
|
||||
/performance
|
||||
/pricing
|
||||
/experiments
|
||||
/reservations
|
||||
/settings
|
||||
```
|
||||
|
||||
### Dashboard (`/`)
|
||||
- KPI cards: occupancy rate, avg daily rate, total revenue MTD, search views (last 30d)
|
||||
- Side-by-side platform comparison for all KPIs
|
||||
- Booking trend sparkline (last 90 days)
|
||||
- Recent reservations list (last 5)
|
||||
- Scraper job status indicators (last run time per platform, success/fail badge)
|
||||
- "Run Scrape Now" button per platform
|
||||
|
||||
### Performance (`/performance`)
|
||||
- Date range picker (presets: 7d, 30d, 90d, YTD, custom)
|
||||
- Platform filter toggle (All / Airbnb / VRBO)
|
||||
- Charts (all use Recharts):
|
||||
- Search views over time (line)
|
||||
- Listing click-through rate over time (line)
|
||||
- Bookings per week (bar)
|
||||
- Occupancy rate over time (area)
|
||||
- Avg daily rate over time (line, overlaid with booking events)
|
||||
- Data table below charts: raw snapshot history, exportable to CSV
|
||||
|
||||
### Pricing (`/pricing`)
|
||||
- Calendar grid view: each day shows price per platform, color-coded by deviation from base rate
|
||||
- Sidebar panel: select date range + enter new price → generates preview
|
||||
- Preview modal: shows diff table (date | platform | old price | new price) before any changes go live. Requires explicit "Confirm & Apply" button.
|
||||
- Pricing change log table: filterable by platform, date range, experiment
|
||||
- "Link to Experiment" action on any change or group of changes
|
||||
|
||||
### Experiments (`/experiments`)
|
||||
- List view: all experiments with status badge, date range, linked price changes count
|
||||
- Create experiment modal: name, hypothesis, date range, initial notes
|
||||
- Experiment detail page:
|
||||
- Linked price changes table
|
||||
- Performance chart for the experiment date range (views, bookings, occupancy)
|
||||
- Before/after comparison: avg metrics N days before vs during experiment
|
||||
- Conclusion text field (editable when status = completed)
|
||||
|
||||
### Reservations (`/reservations`)
|
||||
- Table: all reservations, sortable, filterable by platform/status/date range
|
||||
- Monthly occupancy heatmap calendar
|
||||
- Revenue by month bar chart
|
||||
- YoY comparison once data spans 12+ months
|
||||
|
||||
### Settings (`/settings`)
|
||||
- Platform credentials (masked, update form per platform)
|
||||
- App login password change
|
||||
- Scrape schedule configuration (time of day for daily run)
|
||||
- SMTP configuration for weekly report email
|
||||
- Adapter health check panel: "Test Connection" per platform with live output log
|
||||
|
||||
---
|
||||
|
||||
## 9. Scraper Worker — Detailed Behavior
|
||||
|
||||
### Session Management
|
||||
- On first run, performs full login flow (email → password → handle any MFA prompt interactively via a special "needs attention" UI state)
|
||||
- After successful login, saves browser storage state (cookies + localStorage) encrypted to DB
|
||||
- On subsequent runs, restores session state and verifies validity before scraping
|
||||
- If session invalid, re-triggers login flow and flags for user attention if MFA required
|
||||
|
||||
### Scrape Job Flow
|
||||
```
|
||||
1. Job queued (by scheduler or API trigger)
|
||||
2. Worker picks up job
|
||||
3. Restore session → validate → re-login if needed
|
||||
4. Navigate to performance dashboard → extract metrics → insert performance_snapshot row
|
||||
5. Navigate to calendar/pricing → extract N days of pricing → upsert daily_prices rows
|
||||
6. Navigate to reservations → extract all reservations → upsert reservations rows
|
||||
7. Update scrape_jobs row with status + counts
|
||||
8. Emit websocket event → UI updates in real time
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
- Retry up to 3 times on transient failures (network, selector not found)
|
||||
- On persistent failure: mark job as failed, store error message, surface in UI
|
||||
- Never silently swallow errors — all failures logged to scrape_jobs table
|
||||
- Screenshot on failure: save to `/data/screenshots/` volume for debugging
|
||||
|
||||
### Anti-Detection Considerations (document in spec, implement in adapters)
|
||||
- Randomized delays between actions (200–800ms)
|
||||
- Human-like mouse movement patterns via Playwright's `mouse.move()`
|
||||
- Persist and reuse sessions to minimize login frequency
|
||||
- Run during off-peak hours by default (configurable)
|
||||
- User-agent set to current stable Chrome
|
||||
|
||||
---
|
||||
|
||||
## 10. Pricing Change Flow (Safety-First)
|
||||
|
||||
This flow must never apply changes without explicit user confirmation.
|
||||
|
||||
```
|
||||
User selects dates + enters new price
|
||||
↓
|
||||
POST /pricing/preview
|
||||
↓
|
||||
System queries current prices from daily_prices table
|
||||
↓
|
||||
Returns diff: [{date, platform, currentPrice, newPrice, delta}]
|
||||
↓
|
||||
UI renders preview modal with full diff table
|
||||
↓
|
||||
User reviews → clicks "Confirm & Apply"
|
||||
↓
|
||||
POST /pricing/apply (idempotency key from preview response)
|
||||
↓
|
||||
Scraper worker opens browser → navigates to platform calendar
|
||||
↓
|
||||
Applies changes date by date (with verification reads after each)
|
||||
↓
|
||||
Writes price_changes rows for each date changed
|
||||
↓
|
||||
Returns result: {success: [], failed: []}
|
||||
↓
|
||||
UI shows success/failure summary
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Weekly Report Email
|
||||
|
||||
Sent every Monday at 8am (configurable). Contains:
|
||||
|
||||
- **This week vs last week:** views, clicks, CTR, bookings
|
||||
- **MTD vs same period last month:** revenue, occupancy
|
||||
- **Upcoming 30 days:** occupancy %, revenue booked
|
||||
- **Active experiments:** name, days running, early metric movement
|
||||
- **Pricing changes this week:** count, avg delta
|
||||
- **Any scraper failures** from the past 7 days
|
||||
|
||||
Format: HTML email with inline styles (no external CSS). Plain-text fallback included.
|
||||
|
||||
---
|
||||
|
||||
## 12. Docker Compose — Full Stack Definition
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: str_manager
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ./apps/api
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/str_manager
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
SMTP_HOST: ${SMTP_HOST}
|
||||
SMTP_PORT: ${SMTP_PORT}
|
||||
SMTP_USER: ${SMTP_USER}
|
||||
SMTP_PASS: ${SMTP_PASS}
|
||||
REPORT_EMAIL_TO: ${REPORT_EMAIL_TO}
|
||||
SCRAPER_URL: http://scraper:3001
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
scraper:
|
||||
build:
|
||||
context: ./apps/scraper
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/str_manager
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
PLAYWRIGHT_HEADLESS: "true"
|
||||
volumes:
|
||||
- scraper_screenshots:/app/screenshots
|
||||
shm_size: '2gb' # Required for Chromium in Docker
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./apps/frontend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
scraper_screenshots:
|
||||
```
|
||||
|
||||
### `.env.example`
|
||||
```env
|
||||
# Database
|
||||
DB_USER=str_manager
|
||||
DB_PASSWORD=changeme_strong_password
|
||||
|
||||
# App Security
|
||||
JWT_SECRET=changeme_64_char_random_string
|
||||
ENCRYPTION_KEY=changeme_32_char_aes_key
|
||||
|
||||
# Email (weekly report)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=you@gmail.com
|
||||
SMTP_PASS=your_app_password
|
||||
REPORT_EMAIL_TO=you@gmail.com
|
||||
|
||||
# App login
|
||||
APP_USERNAME=admin
|
||||
APP_PASSWORD_HASH=bcrypt_hash_of_your_password
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Monorepo Structure
|
||||
|
||||
```
|
||||
str-optimization-manager/
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.dev.yml ← mounts source for hot reload
|
||||
├── .env.example
|
||||
├── .gitignore ← must include .env, screenshots/
|
||||
├── README.md
|
||||
├── apps/
|
||||
│ ├── api/
|
||||
│ │ ├── Dockerfile
|
||||
│ │ ├── package.json
|
||||
│ │ ├── tsconfig.json
|
||||
│ │ └── src/
|
||||
│ │ ├── index.ts ← Fastify server entry
|
||||
│ │ ├── routes/ ← one file per route group
|
||||
│ │ ├── services/ ← business logic
|
||||
│ │ ├── db/
|
||||
│ │ │ ├── schema.ts ← Drizzle schema (single source of truth)
|
||||
│ │ │ └── migrations/
|
||||
│ │ ├── scheduler/
|
||||
│ │ │ └── cron.ts
|
||||
│ │ └── email/
|
||||
│ │ └── weeklyReport.ts
|
||||
│ ├── scraper/
|
||||
│ │ ├── Dockerfile
|
||||
│ │ ├── package.json
|
||||
│ │ ├── tsconfig.json
|
||||
│ │ └── src/
|
||||
│ │ ├── index.ts ← HTTP server (receives jobs from api)
|
||||
│ │ ├── adapters/ ← see section 5
|
||||
│ │ ├── queue/ ← in-memory job queue
|
||||
│ │ └── utils/
|
||||
│ │ ├── browser.ts ← Playwright browser factory
|
||||
│ │ └── encryption.ts ← AES-256 helpers
|
||||
│ └── frontend/
|
||||
│ ├── Dockerfile
|
||||
│ ├── nginx.conf
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.ts
|
||||
│ └── src/
|
||||
│ ├── main.tsx
|
||||
│ ├── App.tsx
|
||||
│ ├── pages/
|
||||
│ ├── components/
|
||||
│ ├── hooks/
|
||||
│ └── lib/
|
||||
│ ├── api.ts ← typed API client
|
||||
│ └── utils.ts
|
||||
└── packages/
|
||||
└── shared-types/ ← shared TypeScript types between api/scraper/frontend
|
||||
└── src/
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. UI Design Direction
|
||||
|
||||
**Aesthetic:** Clean, data-dense, utilitarian dashboard. Think Vercel analytics meets a Bloomberg terminal — dark mode default, monospaced numbers, high contrast data visualizations. Not a generic SaaS template.
|
||||
|
||||
**Color palette:**
|
||||
- Background: `#0a0a0a` (near-black)
|
||||
- Surface: `#141414`
|
||||
- Border: `#262626`
|
||||
- Primary accent: `#22c55e` (green — for positive metrics, occupancy, revenue)
|
||||
- Warning: `#f59e0b`
|
||||
- Danger: `#ef4444`
|
||||
- Text primary: `#fafafa`
|
||||
- Text muted: `#737373`
|
||||
- Chart colors: distinct accessible palette (no red/green only for accessibility)
|
||||
|
||||
**Typography:**
|
||||
- Numbers/data: `JetBrains Mono` or `IBM Plex Mono` — monospaced for alignment
|
||||
- UI labels: `Geist` or `DM Sans` — clean, modern, not generic
|
||||
|
||||
**Interaction patterns:**
|
||||
- All data tables have column sorting
|
||||
- All date range pickers have keyboard support
|
||||
- Loading states: skeleton screens (not spinners)
|
||||
- Real-time job status via WebSocket (SSE acceptable as simpler alternative)
|
||||
- Mobile: bottom tab navigation, cards stack vertically, charts scroll horizontally
|
||||
|
||||
---
|
||||
|
||||
## 15. Key Implementation Notes for Claude Code
|
||||
|
||||
1. **Start with the database schema and Drizzle migrations** — everything else derives from this
|
||||
2. **Build the adapter interface and a mock adapter first** — use the mock for all frontend/API development before real scrapers are needed
|
||||
3. **The Airbnb adapter is higher priority** than VRBO — build and test it first
|
||||
4. **All selector strings must be constants** — never inline a CSS selector in logic code
|
||||
5. **Preview-before-apply is non-negotiable** — the `/pricing/apply` endpoint must reject requests without a valid preview token
|
||||
6. **Session encryption is day-one, not a later hardening step** — credentials never touch disk unencrypted
|
||||
7. **The weekly email must work with any standard SMTP provider** — no vendor lock-in (no SendGrid dependency)
|
||||
8. **Write a `docker-compose.dev.yml`** that mounts source volumes and enables hot reload for both api and frontend
|
||||
9. **Include a `/api/v1/health` endpoint** that checks DB connectivity and returns scraper worker status
|
||||
10. **The README must include** first-run setup steps, how to update platform credentials, how to add a new platform adapter, and how to run outside Docker for local development
|
||||
|
||||
---
|
||||
|
||||
## 16. Acceptance Criteria
|
||||
|
||||
The following must all be true for v1 to be considered complete:
|
||||
|
||||
- [ ] `docker compose up` from a fresh clone brings the full stack online
|
||||
- [ ] App login works (single user, password-protected)
|
||||
- [ ] Airbnb credentials can be entered, tested, and stored encrypted
|
||||
- [ ] VRBO credentials can be entered, tested, and stored encrypted
|
||||
- [ ] Manual "scrape now" triggers all three data collection types per platform
|
||||
- [ ] Daily cron scrape runs at configured time
|
||||
- [ ] Performance dashboard renders with real data from at least one platform
|
||||
- [ ] All performance charts filter correctly by platform and date range
|
||||
- [ ] Pricing calendar shows current prices per platform per day
|
||||
- [ ] Bulk price change goes through preview → confirm → apply flow with no way to skip preview
|
||||
- [ ] Every applied price change is recorded in price_changes table
|
||||
- [ ] An experiment can be created, price changes linked to it, and the analysis view shows before/after metric comparison
|
||||
- [ ] All reservations sync and display in reservations table
|
||||
- [ ] Weekly report email sends successfully via configured SMTP
|
||||
- [ ] UI is usable on a 390px wide mobile screen
|
||||
- [ ] Scraper failure is visible in the UI within 60 seconds of occurrence
|
||||
- [ ] `.env.example` covers every required environment variable
|
||||
15
tsconfig.base.json
Normal file
15
tsconfig.base.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user