fix(web): visible 30d chart + populate homepage analytics panels
Some checks failed
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 36s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Filesystem Scan (push) Failing after 30s
Deploy / Deploy to Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 6s
CI / E2E Tests (push) Has been skipped
Deploy / Build API Image (push) Failing after 13s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
CI / AI Services (Python) — Smoke (push) Failing after 5s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 57s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 6s
E2E Tests / Playwright E2E (push) Failing after 12s
Security Scanning / Trivy Scan — API Image (push) Failing after 51s
Security Scanning / Trivy Scan — Web Image (push) Failing after 35s
Some checks failed
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 36s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Filesystem Scan (push) Failing after 30s
Deploy / Deploy to Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 6s
CI / E2E Tests (push) Has been skipped
Deploy / Build API Image (push) Failing after 13s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
CI / AI Services (Python) — Smoke (push) Failing after 5s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 57s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 6s
E2E Tests / Playwright E2E (push) Failing after 12s
Security Scanning / Trivy Scan — API Image (push) Failing after 51s
Security Scanning / Trivy Scan — Web Image (push) Failing after 35s
- price-area-chart + sparkline: replace non-existent `var(--color-signal-up)` with proper `hsl(var(--signal-up))` (and same for -down + border + muted-foreground). The previous tokens resolved to undefined, leaving the chart line + sparkline invisible against the dark background. - public/page: switch `currentPeriod()` from monthly (YYYY-MM) to quarterly (YYYY-Qn) to match the MarketIndex aggregation period — heatmap and district stats now find rows. - import-market-data: add `2026-Q2` to seeded periods so the current quarter has data on a freshly seeded dev DB. - new scripts/seed-bulk-listings-per-district.ts: top up the dev DB with 12 synthetic listings per district per 7-day window so the movers query (which requires >= 10 listings/district/window) has signal to compute against. - update price-area-chart.spec to match new color tokens. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
function currentPeriod(): string {
|
||||||
const now = new Date();
|
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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ describe('PriceAreaChart', () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByTestId('area-avgPriceM2')).toHaveAttribute(
|
expect(screen.getByTestId('area-avgPriceM2')).toHaveAttribute(
|
||||||
'data-stroke',
|
'data-stroke',
|
||||||
'var(--color-signal-up)',
|
'hsl(var(--signal-up))',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ describe('PriceAreaChart', () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByTestId('area-avgPriceM2')).toHaveAttribute(
|
expect(screen.getByTestId('area-avgPriceM2')).toHaveAttribute(
|
||||||
'data-stroke',
|
'data-stroke',
|
||||||
'var(--color-signal-down)',
|
'hsl(var(--signal-down))',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ describe('PriceAreaChart', () => {
|
|||||||
render(<PriceAreaChart data={[]} />);
|
render(<PriceAreaChart data={[]} />);
|
||||||
expect(screen.getByTestId('area-avgPriceM2')).toHaveAttribute(
|
expect(screen.getByTestId('area-avgPriceM2')).toHaveAttribute(
|
||||||
'data-stroke',
|
'data-stroke',
|
||||||
'var(--color-signal-down)',
|
'hsl(var(--signal-down))',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ export function PriceAreaChart({ data, height = 280, className }: PriceAreaChart
|
|||||||
const isUp =
|
const isUp =
|
||||||
data.length >= 2 && data[data.length - 1]!.avgPriceM2 >= data[0]!.avgPriceM2;
|
data.length >= 2 && data[data.length - 1]!.avgPriceM2 >= data[0]!.avgPriceM2;
|
||||||
|
|
||||||
const strokeColor = isUp
|
// CSS tokens are stored as raw HSL components (`--signal-up: 142 72% 50%`),
|
||||||
? 'var(--color-signal-up)'
|
// so they must be wrapped in `hsl(...)`. The previous `var(--color-signal-up)`
|
||||||
: 'var(--color-signal-down)';
|
// form referenced a non-existent variable, leaving recharts with `undefined`
|
||||||
const fillColor = isUp
|
// and rendering an invisible line/area.
|
||||||
? 'var(--color-signal-up)'
|
const strokeColor = isUp ? 'hsl(var(--signal-up))' : 'hsl(var(--signal-down))';
|
||||||
: 'var(--color-signal-down)';
|
const fillColor = strokeColor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
@@ -48,17 +48,17 @@ export function PriceAreaChart({ data, height = 280, className }: PriceAreaChart
|
|||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
stroke="var(--color-border)"
|
stroke="hsl(var(--border))"
|
||||||
strokeOpacity={0.5}
|
strokeOpacity={0.5}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="period"
|
dataKey="period"
|
||||||
tick={{ fontSize: 11, fill: 'var(--color-foreground-muted)' }}
|
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 11, fill: 'var(--color-foreground-muted)' }}
|
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(v: number) =>
|
tickFormatter={(v: number) =>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function Sparkline({ listingId, width = 64, height = 20 }: SparklineProps
|
|||||||
|
|
||||||
// Color based on trend direction
|
// Color based on trend direction
|
||||||
const trending = prices[prices.length - 1]! >= prices[0]!;
|
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 (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ const MARKET_DATA: Record<string, DistrictPricing[]> = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
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 {
|
function randomVariation(base: number, pct: number): number {
|
||||||
return base * (1 + (Math.random() - 0.5) * 2 * pct);
|
return base * (1 + (Math.random() - 0.5) * 2 * pct);
|
||||||
|
|||||||
150
scripts/seed-bulk-listings-per-district.ts
Normal file
150
scripts/seed-bulk-listings-per-district.ts
Normal file
@@ -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<string> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user