Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/v1/common/timezone-formatter.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
3 changes: 2 additions & 1 deletion src/v1/dashboard/dashboard.module.ts
Original file line number Diff line number Diff line change
@@ -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],
})
Expand Down
162 changes: 161 additions & 1 deletion src/v1/dashboard/dashboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -54,11 +55,42 @@ type DeviceLocationRecord = Pick<DeviceRow, 'location_id'> & {
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<TrafficCountColumn, number>;
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,
Expand Down Expand Up @@ -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('*')
Expand Down Expand Up @@ -450,6 +494,24 @@ export class DashboardService {
primaryCol: string,
secondaryCol: string,
): Promise<DashboardRow['latest']> {
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<string>(['created_at']);
if (primaryCol && primaryCol !== '-') cols.add(primaryCol);
if (secondaryCol && secondaryCol !== '-' && secondaryCol !== '') {
Expand Down Expand Up @@ -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<SupabaseService['getClient']>,
devEui: string,
): Promise<TrafficTodayAggregate | null> {
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<Record<string, unknown>>;
const sums = Object.fromEntries(
TRAFFIC_COUNT_COLUMNS.map((col) => [col, 0]),
) as Record<TrafficCountColumn, number>;
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.
Expand Down
82 changes: 16 additions & 66 deletions src/v1/traffic/traffic.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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;
Expand All @@ -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();
}
}
Loading