feat(analytics): add GET /analytics/market-history endpoint
Time-series endpoint returning monthly/weekly market data points for the analytics page. Queries MarketIndex aggregated by period with 6-hour Redis cache. Includes unit tests. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
type HeatmapDataPoint,
|
||||
type PriceTrendPoint,
|
||||
type DistrictStatsResult,
|
||||
type MarketHistoryPoint,
|
||||
} from '../../domain/repositories/market-index.repository';
|
||||
|
||||
@Injectable()
|
||||
@@ -173,6 +174,53 @@ export class PrismaMarketIndexRepository implements IMarketIndexRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getMarketHistory(city: string, periods: string[]): Promise<MarketHistoryPoint[]> {
|
||||
const records = await this.prisma.marketIndex.findMany({
|
||||
where: {
|
||||
city: { equals: city, mode: 'insensitive' },
|
||||
period: { in: periods },
|
||||
},
|
||||
orderBy: { period: 'asc' },
|
||||
});
|
||||
|
||||
// Aggregate across all districts/property types per period
|
||||
const periodMap = new Map<string, {
|
||||
totalAvgPrice: number;
|
||||
totalMedian: bigint;
|
||||
totalListings: number;
|
||||
totalDaysOnMarket: number;
|
||||
count: number;
|
||||
}>();
|
||||
|
||||
for (const r of records) {
|
||||
const existing = periodMap.get(r.period);
|
||||
if (existing) {
|
||||
existing.totalAvgPrice += r.avgPriceM2;
|
||||
existing.totalMedian += r.medianPrice;
|
||||
existing.totalListings += r.totalListings;
|
||||
existing.totalDaysOnMarket += r.daysOnMarket;
|
||||
existing.count++;
|
||||
} else {
|
||||
periodMap.set(r.period, {
|
||||
totalAvgPrice: r.avgPriceM2,
|
||||
totalMedian: r.medianPrice,
|
||||
totalListings: r.totalListings,
|
||||
totalDaysOnMarket: r.daysOnMarket,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(periodMap.entries()).map(([period, data]) => ({
|
||||
date: period,
|
||||
avgPrice: Math.round(data.totalAvgPrice / data.count),
|
||||
medianPrice: (data.totalMedian / BigInt(data.count)).toString(),
|
||||
listingsCount: data.totalListings,
|
||||
inquiriesCount: 0, // inquiries not tracked in MarketIndex
|
||||
daysOnMarket: Math.round(data.totalDaysOnMarket / data.count),
|
||||
}));
|
||||
}
|
||||
|
||||
private toDomain(raw: PrismaMarketIndex): MarketIndexEntity {
|
||||
const props: MarketIndexProps = {
|
||||
district: raw.district,
|
||||
|
||||
Reference in New Issue
Block a user