diff --git a/apps/web/app/[locale]/(public)/page.tsx b/apps/web/app/[locale]/(public)/page.tsx index 6a4ea49..411e64b 100644 --- a/apps/web/app/[locale]/(public)/page.tsx +++ b/apps/web/app/[locale]/(public)/page.tsx @@ -25,9 +25,15 @@ import { listingsApi, type ListingDetail } from '@/lib/listings-api'; /* ------------------------------------------------------------------ */ +/** + * Heatmap + district stats are aggregated quarterly in MarketIndex + * (`YYYY-QN`). The previous `YYYY-MM` format never matched any row, so + * the heatmap and district table came back empty. + */ function currentPeriod(): string { const now = new Date(); - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + const quarter = Math.floor(now.getMonth() / 3) + 1; + return `${now.getFullYear()}-Q${quarter}`; } /* ------------------------------------------------------------------ */ diff --git a/apps/web/components/charts/__tests__/price-area-chart.spec.tsx b/apps/web/components/charts/__tests__/price-area-chart.spec.tsx index 0e44121..20d03fc 100644 --- a/apps/web/components/charts/__tests__/price-area-chart.spec.tsx +++ b/apps/web/components/charts/__tests__/price-area-chart.spec.tsx @@ -58,7 +58,7 @@ describe('PriceAreaChart', () => { ); expect(screen.getByTestId('area-avgPriceM2')).toHaveAttribute( 'data-stroke', - 'var(--color-signal-up)', + 'hsl(var(--signal-up))', ); }); @@ -73,7 +73,7 @@ describe('PriceAreaChart', () => { ); expect(screen.getByTestId('area-avgPriceM2')).toHaveAttribute( 'data-stroke', - 'var(--color-signal-down)', + 'hsl(var(--signal-down))', ); }); @@ -81,7 +81,7 @@ describe('PriceAreaChart', () => { render(); expect(screen.getByTestId('area-avgPriceM2')).toHaveAttribute( 'data-stroke', - 'var(--color-signal-down)', + 'hsl(var(--signal-down))', ); }); diff --git a/apps/web/components/charts/price-area-chart.tsx b/apps/web/components/charts/price-area-chart.tsx index 74095d5..b09f95a 100644 --- a/apps/web/components/charts/price-area-chart.tsx +++ b/apps/web/components/charts/price-area-chart.tsx @@ -29,12 +29,12 @@ export function PriceAreaChart({ data, height = 280, className }: PriceAreaChart const isUp = data.length >= 2 && data[data.length - 1]!.avgPriceM2 >= data[0]!.avgPriceM2; - const strokeColor = isUp - ? 'var(--color-signal-up)' - : 'var(--color-signal-down)'; - const fillColor = isUp - ? 'var(--color-signal-up)' - : 'var(--color-signal-down)'; + // CSS tokens are stored as raw HSL components (`--signal-up: 142 72% 50%`), + // so they must be wrapped in `hsl(...)`. The previous `var(--color-signal-up)` + // form referenced a non-existent variable, leaving recharts with `undefined` + // and rendering an invisible line/area. + const strokeColor = isUp ? 'hsl(var(--signal-up))' : 'hsl(var(--signal-down))'; + const fillColor = strokeColor; return (
@@ -48,17 +48,17 @@ export function PriceAreaChart({ data, height = 280, className }: PriceAreaChart diff --git a/apps/web/components/listings/sparkline.tsx b/apps/web/components/listings/sparkline.tsx index 3ab8b5f..001ab4b 100644 --- a/apps/web/components/listings/sparkline.tsx +++ b/apps/web/components/listings/sparkline.tsx @@ -49,7 +49,7 @@ export function Sparkline({ listingId, width = 64, height = 20 }: SparklineProps // Color based on trend direction const trending = prices[prices.length - 1]! >= prices[0]!; - const strokeColor = trending ? 'var(--color-signal-up)' : 'var(--color-signal-down)'; + const strokeColor = trending ? 'hsl(var(--signal-up))' : 'hsl(var(--signal-down))'; return ( = { ], }; -const PERIODS = ['2025-Q3', '2025-Q4', '2026-Q1']; +const PERIODS = ['2025-Q3', '2025-Q4', '2026-Q1', '2026-Q2']; function randomVariation(base: number, pct: number): number { return base * (1 + (Math.random() - 0.5) * 2 * pct); diff --git a/scripts/seed-bulk-listings-per-district.ts b/scripts/seed-bulk-listings-per-district.ts new file mode 100644 index 0000000..3487ddd --- /dev/null +++ b/scripts/seed-bulk-listings-per-district.ts @@ -0,0 +1,150 @@ +/** + * Top up the dev DB with synthetic listings so the homepage's + * "Top biến động giá 7 ngày" query can find ≥10 listings per district + * in two adjacent 7-day windows. Uses existing properties as templates. + * + * Idempotent: re-running deletes the previously generated synthetic batch + * (id prefix `bulk-mvr-`) before inserting fresh rows so the timestamps + * stay inside the rolling window. + * + * Usage: + * NODE_OPTIONS="-r dotenv/config" DOTENV_CONFIG_PATH=.env \ + * pnpm tsx scripts/seed-bulk-listings-per-district.ts + */ +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import pg from 'pg'; + +const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const ID_PREFIX = 'bulk-mvr-'; +const PROP_PREFIX = 'bulk-prop-'; +const PER_DISTRICT_PER_WINDOW = 12; + +interface Template { + district: string; + city: string; + ward: string; + address: string; + lat: number; + lng: number; + basePriceVND: bigint; + areaM2: number; + propertyType: 'APARTMENT' | 'TOWNHOUSE' | 'LAND' | 'OFFICE' | 'VILLA' | 'SHOPHOUSE'; +} + +const TEMPLATES: Template[] = [ + { district: 'Quận 1', city: 'Hồ Chí Minh', ward: 'Bến Nghé', address: '123 Nguyễn Huệ', lat: 10.7731, lng: 106.703, basePriceVND: 25_000_000_000n, areaM2: 90, propertyType: 'APARTMENT' }, + { district: 'Quận 7', city: 'Hồ Chí Minh', ward: 'Tân Phú', address: '12 Nguyễn Lương Bằng', lat: 10.7285, lng: 106.7195, basePriceVND: 8_000_000_000n, areaM2: 85, propertyType: 'APARTMENT' }, + { district: 'Quận Bình Thạnh', city: 'Hồ Chí Minh', ward: 'Phường 22', address: '208 Nguyễn Hữu Cảnh', lat: 10.7942, lng: 106.7214, basePriceVND: 12_000_000_000n, areaM2: 100, propertyType: 'APARTMENT' }, + { district: 'Quận Gò Vấp', city: 'Hồ Chí Minh', ward: 'Phường 17', address: '88 Nguyễn Oanh', lat: 10.8352, lng: 106.6648, basePriceVND: 6_500_000_000n, areaM2: 65, propertyType: 'TOWNHOUSE' }, + { district: 'Thành phố Thủ Đức', city: 'Hồ Chí Minh', ward: 'An Phú', address: '28 Mai Chí Thọ', lat: 10.7696, lng: 106.7511, basePriceVND: 7_500_000_000n, areaM2: 76, propertyType: 'APARTMENT' }, + { district: 'Quận 3', city: 'Hồ Chí Minh', ward: 'Phường 8', address: '45 Võ Thị Sáu', lat: 10.7849, lng: 106.6909, basePriceVND: 18_000_000_000n, areaM2: 80, propertyType: 'APARTMENT' }, + { district: 'Quận Tân Bình', city: 'Hồ Chí Minh', ward: 'Phường 4', address: '67 Lê Văn Sỹ', lat: 10.7916, lng: 106.6747, basePriceVND: 9_500_000_000n, areaM2: 75, propertyType: 'APARTMENT' }, + { district: 'Quận Phú Nhuận', city: 'Hồ Chí Minh', ward: 'Phường 7', address: '23 Phan Đình Phùng', lat: 10.7972, lng: 106.6791, basePriceVND: 11_000_000_000n, areaM2: 70, propertyType: 'APARTMENT' }, +]; + +async function getSellerId(): Promise { + const seller = await prisma.user.findFirst({ where: { role: 'SELLER' } }); + if (!seller) throw new Error('No SELLER user found — seed users first'); + return seller.id; +} + +async function clearPrevious() { + await prisma.listing.deleteMany({ where: { id: { startsWith: ID_PREFIX } } }); + await prisma.property.deleteMany({ where: { id: { startsWith: PROP_PREFIX } } }); +} + +async function generate() { + await clearPrevious(); + const sellerId = await getSellerId(); + const now = Date.now(); + const day = 24 * 60 * 60 * 1000; + + let propCount = 0; + let listingCount = 0; + + for (const tpl of TEMPLATES) { + // 12 listings in current 7-day window + 12 in previous 7-day window + for (let win = 0; win < 2; win++) { + // win=0 → 0..7 days ago (current window) + // win=1 → 7..14 days ago (previous window) + // Current window prices drift +5% so movers detects an "up" signal + const drift = win === 0 ? 1.05 : 1.0; + + for (let i = 0; i < PER_DISTRICT_PER_WINDOW; i++) { + const dayOffset = win * 7 + Math.floor(Math.random() * 7); + const createdAt = new Date(now - dayOffset * day - Math.random() * day); + + // ±8% price jitter around the template baseline + const jitter = 0.92 + Math.random() * 0.16; + const priceVND = BigInt(Math.round(Number(tpl.basePriceVND) * drift * jitter)); + const areaM2 = Math.round((tpl.areaM2 + (Math.random() * 20 - 10)) * 10) / 10; + const pricePerM2 = Number(priceVND) / areaM2; + const districtSlug = tpl.district.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + const propId = `${PROP_PREFIX}${districtSlug}-w${win}-${i}`; + const lat = tpl.lat + (Math.random() - 0.5) * 0.005; + const lng = tpl.lng + (Math.random() - 0.5) * 0.005; + + await prisma.$executeRaw` + INSERT INTO "Property" ( + "id", "propertyType", "title", "description", "address", + "ward", "district", "city", "location", + "areaM2", "createdAt", "updatedAt" + ) VALUES ( + ${propId}, + ${tpl.propertyType}::"PropertyType", + ${`${tpl.propertyType} ${tpl.district} #${i + 1} (window ${win})`}, + ${`Synthetic listing in ${tpl.district} for movers panel.`}, + ${tpl.address}, + ${tpl.ward}, + ${tpl.district}, + ${tpl.city}, + ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326), + ${areaM2}, + NOW(), + NOW() + ) + ON CONFLICT ("id") DO NOTHING + `; + propCount++; + + await prisma.listing.create({ + data: { + id: `${ID_PREFIX}${districtSlug}-w${win}-${i}`, + propertyId: propId, + sellerId, + transactionType: 'SALE', + priceVND, + pricePerM2, + status: 'ACTIVE', + createdAt, + updatedAt: createdAt, + publishedAt: createdAt, + }, + }); + listingCount++; + } + } + } + + console.log(`✓ Inserted ${propCount} properties + ${listingCount} listings across ${TEMPLATES.length} districts.`); +} + +async function main() { + try { + await generate(); + } catch (err) { + console.error(err); + process.exit(1); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +} + +if (require.main === module) { + void main(); +}