diff --git a/src/v1/common/timezone-formatter.service.ts b/src/v1/common/timezone-formatter.service.ts index 4687cb1..223be42 100644 --- a/src/v1/common/timezone-formatter.service.ts +++ b/src/v1/common/timezone-formatter.service.ts @@ -38,6 +38,69 @@ export class TimezoneFormatterService { return `${dateTime}${this.getTimeZoneOffset(timeZone, date)}`; } + /** + * Converts a local midnight (year/month/day 00:00:00 in the given timezone) + * to a UTC Date. + */ + localMidnightToUtc( + year: number, + month: number, + day: number, + timeZone: string, + ): Date { + const guess = new Date(Date.UTC(year, month - 1, day)); + const offsetMs = this.getTimezoneOffsetMs(guess, timeZone); + return new Date(Date.UTC(year, month - 1, day) - offsetMs); + } + + /** + * Returns the local date string (YYYY-MM-DD) for a UTC timestamp in the + * given timezone. + */ + toLocalDateString(utcIso: string, timeZone: string): string { + const date = new Date(utcIso); + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + + const byType = new Map(parts.map((p) => [p.type, p.value])); + return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`; + } + + /** + * Returns the UTC offset in milliseconds for the given timezone at the + * specified instant (positive = ahead of UTC). + */ + getTimezoneOffsetMs(instant: Date, timeZone: string): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: false, + }).formatToParts(instant); + + const get = (type: string) => + parseInt(parts.find((p) => p.type === type)!.value, 10); + + const localEquiv = Date.UTC( + get('year'), + get('month') - 1, + get('day'), + get('hour') === 24 ? 0 : get('hour'), + get('minute'), + get('second'), + ); + + return localEquiv - instant.getTime(); + } + private getTimeZoneOffset(timeZone: string, date: Date): string { const tzName = getTimeZoneName(timeZone, date); diff --git a/src/v1/dashboard/dashboard.module.ts b/src/v1/dashboard/dashboard.module.ts index 3f091bc..383c939 100644 --- a/src/v1/dashboard/dashboard.module.ts +++ b/src/v1/dashboard/dashboard.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { SupabaseModule } from '../../supabase/supabase.module'; +import { CommonModule } from '../common/common.module'; import { DashboardController } from './dashboard.controller'; import { DashboardService } from './dashboard.service'; @Module({ - imports: [SupabaseModule], + imports: [SupabaseModule, CommonModule], controllers: [DashboardController], providers: [DashboardService], }) diff --git a/src/v1/dashboard/dashboard.service.ts b/src/v1/dashboard/dashboard.service.ts index 85f2746..256da28 100644 --- a/src/v1/dashboard/dashboard.service.ts +++ b/src/v1/dashboard/dashboard.service.ts @@ -8,6 +8,7 @@ import { import type { PostgrestError } from '@supabase/supabase-js'; import { SupabaseService } from '../../supabase/supabase.service'; import { READ_EXCLUSIVE_CEILING } from '../common/permission-levels'; +import { TimezoneFormatterService } from '../common/timezone-formatter.service'; import { sanitizeOrFilterTerm } from '../common/postgrest-filter.helper'; import type { TableRow } from '../types/supabase'; import { @@ -54,11 +55,42 @@ type DeviceLocationRecord = Pick & { cw_locations: LocationJoin | LocationJoin[] | null; }; +/** + * cw_traffic2 is an hourly accumulator: one row per (dev_eui, traffic_hour, + * line_number), upserted by an increment RPC that never bumps created_at. A + * plain ORDER BY created_at DESC LIMIT 1 therefore returns one arbitrary + * line's bucket — usually the freshly created (near-empty) current-hour one. + * Dashboard values for traffic devices are instead today's running totals, + * summed across all hours and detection lines. + */ +const TRAFFIC_COUNT_COLUMNS = [ + 'people_count', + 'bicycle_count', + 'motorcycle_count', + 'car_count', + 'bus_count', + 'truck_count', + 'train_count', +] as const; + +const TRAFFIC_TIMEZONE = 'Asia/Tokyo'; + +type TrafficCountColumn = (typeof TRAFFIC_COUNT_COLUMNS)[number]; + +interface TrafficTodayAggregate { + sums: Record; + latestCreatedAt: string | null; + latestTrafficHour: string | null; +} + @Injectable() export class DashboardService { private readonly logger = new Logger(DashboardService.name); - constructor(private readonly supabaseService: SupabaseService) {} + constructor( + private readonly supabaseService: SupabaseService, + private readonly timezoneFormatter: TimezoneFormatterService, + ) {} async getDevices( user: AuthenticatedUser, @@ -369,6 +401,18 @@ export class DashboardService { ); } + if (table === 'cw_traffic2') { + const aggregate = await this.fetchTrafficToday(client, normalized); + if (!aggregate) return null; + + return { + dev_eui: normalized, + created_at: aggregate.latestCreatedAt, + traffic_hour: aggregate.latestTrafficHour, + ...aggregate.sums, + }; + } + const { data: latest, error: latestError } = (await client .from(table) .select('*') @@ -450,6 +494,24 @@ export class DashboardService { primaryCol: string, secondaryCol: string, ): Promise { + if (table === 'cw_traffic2') { + const aggregate = await this.fetchTrafficToday(client, devEui); + if (!aggregate) return null; + + const hasSecondary = + Boolean(secondaryCol) && secondaryCol !== '-' && secondaryCol !== ''; + const readSum = (col: string): number | null => + (TRAFFIC_COUNT_COLUMNS as readonly string[]).includes(col) + ? aggregate.sums[col as TrafficCountColumn] + : null; + + return { + created_at: aggregate.latestCreatedAt, + primary: primaryCol && primaryCol !== '-' ? readSum(primaryCol) : null, + secondary: hasSecondary ? readSum(secondaryCol) : null, + }; + } + const cols = new Set(['created_at']); if (primaryCol && primaryCol !== '-') cols.add(primaryCol); if (secondaryCol && secondaryCol !== '-' && secondaryCol !== '') { @@ -479,6 +541,104 @@ export class DashboardService { } as DashboardRow['latest']; } + /** + * Today's traffic totals for one device: every cw_traffic2 count column + * summed across all of today's hour buckets and detection lines. "Today" is + * the local day in Asia/Tokyo (matching TrafficService's default). Returns + * zero totals when the device has history but no rows today — a quiet day + * is legitimately 0 — and null only when the device has no data at all. + */ + private async fetchTrafficToday( + client: ReturnType, + devEui: string, + ): Promise { + const now = new Date(); + const [year, month, day] = this.timezoneFormatter + .toLocalDateString(now.toISOString(), TRAFFIC_TIMEZONE) + .split('-') + .map(Number); + const startUtc = this.timezoneFormatter.localMidnightToUtc( + year, + month, + day, + TRAFFIC_TIMEZONE, + ); + const endUtc = this.timezoneFormatter.localMidnightToUtc( + year, + month, + day + 1, + TRAFFIC_TIMEZONE, + ); + + const selectColumns = `created_at, traffic_hour, ${TRAFFIC_COUNT_COLUMNS.join(', ')}`; + const { data, error } = await client + .from('cw_traffic2') + .select(selectColumns) + .eq('dev_eui', devEui) + .gte('traffic_hour', startUtc.toISOString()) + .lt('traffic_hour', endUtc.toISOString()); + + if (error) { + this.logger.warn( + `Failed to aggregate today's traffic for ${devEui}: ${error.message}`, + ); + return null; + } + + const rows = (data ?? []) as unknown as Array>; + const sums = Object.fromEntries( + TRAFFIC_COUNT_COLUMNS.map((col) => [col, 0]), + ) as Record; + let latestCreatedAt: string | null = null; + let latestTrafficHour: string | null = null; + + for (const row of rows) { + for (const col of TRAFFIC_COUNT_COLUMNS) { + const value = row[col]; + if (typeof value === 'number' && Number.isFinite(value)) { + sums[col] += value; + } + } + const createdAt = row.created_at; + if ( + typeof createdAt === 'string' && + (!latestCreatedAt || createdAt > latestCreatedAt) + ) { + latestCreatedAt = createdAt; + } + const trafficHour = row.traffic_hour; + if ( + typeof trafficHour === 'string' && + (!latestTrafficHour || trafficHour > latestTrafficHour) + ) { + latestTrafficHour = trafficHour; + } + } + + if (rows.length === 0) { + // No buckets today: report zero totals, but keep the freshness stamp of + // the most recent bucket so "last seen" stays truthful. + const { data: lastRow, error: lastError } = (await client + .from('cw_traffic2') + .select('created_at, traffic_hour') + .eq('dev_eui', devEui) + .order('traffic_hour', { ascending: false }) + .limit(1) + .maybeSingle()) as { + data: { created_at: string | null; traffic_hour: string | null } | null; + error: PostgrestError | null; + }; + + if (lastError || !lastRow) { + return null; + } + latestCreatedAt = lastRow.created_at ?? null; + latestTrafficHour = lastRow.traffic_hour ?? null; + } + + return { sums, latestCreatedAt, latestTrafficHour }; + } + /** * Resolve location ids whose name matches the search term, so a device-table * query can OR in `location_id.in.(...)` and surface devices by location name. diff --git a/src/v1/traffic/traffic.service.ts b/src/v1/traffic/traffic.service.ts index a331214..5495e55 100644 --- a/src/v1/traffic/traffic.service.ts +++ b/src/v1/traffic/traffic.service.ts @@ -38,10 +38,20 @@ export class TrafficService extends BaseDataService<'cw_traffic2'> { await this.assertDeviceAccess(devEui, user); // Compute month boundaries as UTC timestamps corresponding to local midnight - const startUtc = this.localMidnightToUtc(year, month, 1, tz); + const startUtc = this.timezoneFormatter.localMidnightToUtc( + year, + month, + 1, + tz, + ); const nextMonth = month === 12 ? 1 : month + 1; const nextYear = month === 12 ? year + 1 : year; - const endUtc = this.localMidnightToUtc(nextYear, nextMonth, 1, tz); + const endUtc = this.timezoneFormatter.localMidnightToUtc( + nextYear, + nextMonth, + 1, + tz, + ); const { data, error } = (await this.supabaseService .getClient() @@ -81,7 +91,10 @@ export class TrafficService extends BaseDataService<'cw_traffic2'> { // traffic_hour is nullable in the schema, but the gte/lt filters above // exclude null rows; skip defensively to keep the types honest. if (!row.traffic_hour) continue; - const localDate = this.toLocalDateString(row.traffic_hour, tz); + const localDate = this.timezoneFormatter.toLocalDateString( + row.traffic_hour, + tz, + ); const bucket = dayMap.get(localDate); if (bucket) { bucket.total_people += row.people_count ?? 0; @@ -93,67 +106,4 @@ export class TrafficService extends BaseDataService<'cw_traffic2'> { return Array.from(dayMap.values()); } - - /** - * Converts a local midnight (year/month/day 00:00:00 in the given timezone) - * to a UTC Date. - */ - private localMidnightToUtc( - year: number, - month: number, - day: number, - timezone: string, - ): Date { - const guess = new Date(Date.UTC(year, month - 1, day)); - const offsetMs = this.getTimezoneOffsetMs(guess, timezone); - return new Date(Date.UTC(year, month - 1, day) - offsetMs); - } - - /** - * Returns the local date string (YYYY-MM-DD) for a UTC timestamp in the - * given timezone. - */ - private toLocalDateString(utcIso: string, timezone: string): string { - const date = new Date(utcIso); - const parts = new Intl.DateTimeFormat('en-CA', { - timeZone: timezone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).formatToParts(date); - - const byType = new Map(parts.map((p) => [p.type, p.value])); - return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`; - } - - /** - * Returns the UTC offset in milliseconds for the given timezone at the - * specified instant (positive = ahead of UTC). - */ - private getTimezoneOffsetMs(instant: Date, timezone: string): number { - const parts = new Intl.DateTimeFormat('en-US', { - timeZone: timezone, - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - hour12: false, - }).formatToParts(instant); - - const get = (type: string) => - parseInt(parts.find((p) => p.type === type)!.value, 10); - - const localEquiv = Date.UTC( - get('year'), - get('month') - 1, - get('day'), - get('hour') === 24 ? 0 : get('hour'), - get('minute'), - get('second'), - ); - - return localEquiv - instant.getTime(); - } }