diff --git a/src/irrigation-events/device-events.ts b/src/irrigation-events/device-events.ts new file mode 100644 index 0000000..26dd07e --- /dev/null +++ b/src/irrigation-events/device-events.ts @@ -0,0 +1,45 @@ +import { compareAsc, parseISO } from 'date-fns' +import { IrrigationEventDocument } from './interfaces/irrigation-event-document.interface' +import { DeviceState } from './enums/device-state.interface' + +const sortFn = (a: IrrigationEventDocument, b: IrrigationEventDocument) => compareAsc(parseISO(a._id), parseISO(b._id)) + +export class DeviceEvents { + private deviceId: number + private events: IrrigationEventDocument[] = [] + private currentDeviceState?: DeviceState + + constructor(deviceId: number, events: IrrigationEventDocument[]) { + this.deviceId = deviceId + this.events = [...events].sort(sortFn) + } + + public getDeviceId(): number { + return this.deviceId + } + + public getEvents(): IrrigationEventDocument[] { + return [...this.events] + } + + public getFirstEvent(): IrrigationEventDocument { + return this.events[0] + } + + public getLastEvent(): IrrigationEventDocument { + return this.events[this.events.length - 1] + } + + public setCurrentDeviceState(state: DeviceState | undefined) { + this.currentDeviceState = state + } + + public getCurrentDeviceState(): DeviceState | undefined { + return this.currentDeviceState + } + + public addEvent(event: IrrigationEventDocument) { + this.events.push(event) + this.events.sort(sortFn) + } +} diff --git a/src/irrigation-events/interfaces/irrigation-event-viewmodel.interface.ts b/src/irrigation-events/dto/irrigation-event-viewmodel.dto.ts similarity index 100% rename from src/irrigation-events/interfaces/irrigation-event-viewmodel.interface.ts rename to src/irrigation-events/dto/irrigation-event-viewmodel.dto.ts diff --git a/src/irrigation-events/dto/maker-api-event-dto.ts b/src/irrigation-events/dto/maker-api-event.dto.ts similarity index 100% rename from src/irrigation-events/dto/maker-api-event-dto.ts rename to src/irrigation-events/dto/maker-api-event.dto.ts diff --git a/src/irrigation-events/interfaces/device-states.interface.ts b/src/irrigation-events/interfaces/device-states.interface.ts index 4611a32..e0ef5b9 100644 --- a/src/irrigation-events/interfaces/device-states.interface.ts +++ b/src/irrigation-events/interfaces/device-states.interface.ts @@ -1,5 +1,5 @@ import { DeviceState } from '../enums/device-state.interface' export interface DeviceStates { - [deviceId: string]: DeviceState | undefined + [deviceId: number]: DeviceState | undefined } diff --git a/src/irrigation-events/irrigation-events.controller.ts b/src/irrigation-events/irrigation-events.controller.ts index 826d568..130ed27 100644 --- a/src/irrigation-events/irrigation-events.controller.ts +++ b/src/irrigation-events/irrigation-events.controller.ts @@ -3,10 +3,11 @@ import { IsISO8601 } from 'class-validator' import { isWithinInterval, parseISO } from 'date-fns' import { IrrigationEventsService } from './irrigation-events.service' import { IrrigationEventDocument } from './interfaces/irrigation-event-document.interface' -import { MakerApiEventDto } from './dto/maker-api-event-dto' +import { MakerApiEventDto } from './dto/maker-api-event.dto' import { DeviceState } from './enums/device-state.interface' import { MakerApiService } from './maker-api.service' import { ViewmodelTransformService } from './viewmodel-transform.service' +import { DeviceEvents } from './device-events' class QueryParameters { @IsISO8601() @@ -15,14 +16,6 @@ class QueryParameters { endTimestamp: string } -type DeviceEventLists = { [deviceId: string]: IrrigationEventDocument[] } - -const shouldPrependAdditionalOnEvent = (events: IrrigationEventDocument[]) => - events && events.length >= 1 && events[0].state === DeviceState.ON - -const shouldAppendAdditionalOffEvent = (events: IrrigationEventDocument[]) => - events && events.length >= 1 && events[0].state === DeviceState.OFF - const isCurrentTimeWithinInterval = (startTimestamp: string, endTimestamp: string) => isWithinInterval(Date.now(), { start: parseISO(startTimestamp), end: parseISO(endTimestamp) }) @@ -34,21 +27,18 @@ const makerEventToIrrigationEvent = ({ displayName, value, deviceId }: MakerApiE deviceId, }) as IrrigationEventDocument -// Split the list of events into lists by deviceId -const createDeviceEventLists = (dbDocuments: IrrigationEventDocument[]): DeviceEventLists => - dbDocuments.reduce( - (accumulator, event) => { - const deviceId = event.deviceId.toString() - if (!accumulator[deviceId]) { - accumulator[deviceId] = [] - } - accumulator[deviceId].push(event) - return accumulator - }, - // I'd like to leave deviceId as a number, but Object.entries() - // would convert it to a string. - {} as { [deviceId: string]: IrrigationEventDocument[] } - ) +const createDeviceEvents = (dbDocuments: IrrigationEventDocument[]): DeviceEvents[] => { + const deviceEvents: DeviceEvents[] = [] + const deviceIds = new Set() + dbDocuments.forEach((event) => { + deviceIds.add(event.deviceId) + }) + deviceIds.forEach((deviceId) => { + const events = dbDocuments.filter((event) => event.deviceId === deviceId) + deviceEvents.push(new DeviceEvents(deviceId, events)) + }) + return deviceEvents +} @Controller('irrigation-events') export class IrrigationEventsController { @@ -70,46 +60,51 @@ export class IrrigationEventsController { @UsePipes(new ValidationPipe()) async get(@Query() { startTimestamp, endTimestamp }: QueryParameters) { const irrigationEvents = await this.irrigationEventsService.getIrrigationEvents(startTimestamp, endTimestamp) - const deviceEventLists = createDeviceEventLists(irrigationEvents) - const eventLists = await this.addMissingEvents(deviceEventLists, startTimestamp, endTimestamp) - const currentDeviceStates = isCurrentTimeWithinInterval(startTimestamp, endTimestamp) - ? await this.makerApiService.getAllDeviceStates() - : {} - return this.viewmodelTransformService.transform(eventLists, currentDeviceStates) + const deviceEventsList = createDeviceEvents(irrigationEvents) + await this.addEventsOutsideTimeRange(deviceEventsList, startTimestamp, endTimestamp) + if (isCurrentTimeWithinInterval(startTimestamp, endTimestamp)) { + this.addCurrentDeviceStates(deviceEventsList) + } + return this.viewmodelTransformService.transform(deviceEventsList) } - private readonly addMissingEvents = async ( - deviceEventLists: DeviceEventLists, + private async addEventsOutsideTimeRange( + deviceEventLists: DeviceEvents[], startTimestamp: string, endTimestamp: string - ): Promise => { - const eventListEntries = Object.entries(deviceEventLists) - const appendOnEventPromises = eventListEntries.map(async ([deviceId, deviceEvents]) => { - if (deviceEvents[0].state !== DeviceState.ON) { + ): Promise { + const appendOnEventPromises = deviceEventLists.map(async (deviceEvents) => { + if (deviceEvents.getFirstEvent().state !== DeviceState.ON) { const eventsBeforeStart = await this.irrigationEventsService.getEventsBeforeStart( startTimestamp, - parseInt(deviceId, 10) + deviceEvents.getDeviceId() ) - if (shouldPrependAdditionalOnEvent(eventsBeforeStart)) { - deviceEvents.unshift(eventsBeforeStart[0]) + if (eventsBeforeStart[0]?.state === DeviceState.ON) { + deviceEvents.addEvent(eventsBeforeStart[0]) } } }) await Promise.allSettled(appendOnEventPromises) - const appendOffEventPromises = eventListEntries.map(async ([deviceId, deviceEvents]) => { - if (deviceEvents[deviceEvents.length - 1].state !== DeviceState.OFF) { + const appendOffEventPromises = deviceEventLists.map(async (deviceEvents) => { + if (deviceEvents.getLastEvent().state !== DeviceState.OFF) { const eventsAfterEnd = await this.irrigationEventsService.getEventsAfterEnd( endTimestamp, - parseInt(deviceId, 10) + deviceEvents.getDeviceId() ) - if (shouldAppendAdditionalOffEvent(eventsAfterEnd)) { - deviceEvents.push(eventsAfterEnd[0]) + if (eventsAfterEnd[0]?.state === DeviceState.OFF) { + deviceEvents.addEvent(eventsAfterEnd[0]) } } }) await Promise.allSettled(appendOffEventPromises) + } - return Object.values(deviceEventLists) + private async addCurrentDeviceStates(deviceEventsList: DeviceEvents[]): Promise { + const makerEvents = await this.makerApiService.getAllDeviceStates() + deviceEventsList.forEach((device) => { + const currentDeviceState = makerEvents[device.getDeviceId()] + device.setCurrentDeviceState(currentDeviceState) + }) } } diff --git a/src/irrigation-events/maker-api.service.ts b/src/irrigation-events/maker-api.service.ts index 8ae5d96..ce8b6ee 100644 --- a/src/irrigation-events/maker-api.service.ts +++ b/src/irrigation-events/maker-api.service.ts @@ -18,6 +18,6 @@ export class MakerApiService { } public async getAllDeviceStates() { const data = await this.axiosInstance.get('/all').then((response) => response.data) - return Object.fromEntries(data.map((device) => [device.id, device.attributes.switch])) as DeviceStates + return Object.fromEntries(data.map((device) => [parseInt(device.id, 10), device.attributes.switch])) as DeviceStates } } diff --git a/src/irrigation-events/viewmodel-transform.service.ts b/src/irrigation-events/viewmodel-transform.service.ts index 7ad31ac..17ee609 100644 --- a/src/irrigation-events/viewmodel-transform.service.ts +++ b/src/irrigation-events/viewmodel-transform.service.ts @@ -1,10 +1,9 @@ import { Injectable } from '@nestjs/common' import { roundToNearestMinutes } from 'date-fns' -import { IrrigationEventDocument } from './interfaces/irrigation-event-document.interface' -import { DeviceStates } from './interfaces/device-states.interface' -import { IrrigationEventViewmodel } from './interfaces/irrigation-event-viewmodel.interface' +import { IrrigationEventViewmodel } from './dto/irrigation-event-viewmodel.dto' import { DeviceState } from './enums/device-state.interface' import { Warning } from './enums/warning.interface' +import { DeviceEvents } from './device-events' const roundTimestampToMinute = (timestamp: string): string => roundToNearestMinutes(new Date(timestamp), { @@ -12,14 +11,12 @@ const roundTimestampToMinute = (timestamp: string): string => roundingMethod: 'trunc', }).toISOString() -function createViewmodelsFromDeviceEvents( - deviceEvents: IrrigationEventDocument[], - deviceStates: DeviceStates -): IrrigationEventViewmodel[] { +function createViewmodelsFromDeviceEvents(deviceEvents: DeviceEvents): IrrigationEventViewmodel[] { const viewmodels: IrrigationEventViewmodel[] = [] + const events = deviceEvents.getEvents() let i = 0 - while (i < deviceEvents.length) { - const [event, nextEvent] = deviceEvents.slice(i, i + 2) + while (i < events.length) { + const [event, nextEvent] = events.slice(i, i + 2) if (event.state === DeviceState.ON && nextEvent?.state === DeviceState.OFF) { // Happy path: ON followed by OFF viewmodels.push({ @@ -46,7 +43,7 @@ function createViewmodelsFromDeviceEvents( // the final OFF event is missing. Check the current device // states to determine which is the case. viewmodels.push( - deviceStates[event.deviceId.toString()] === DeviceState.ON + deviceEvents.getCurrentDeviceState() === DeviceState.ON ? { // eslint-disable-next-line no-underscore-dangle startDate: roundTimestampToMinute(event._id), @@ -81,7 +78,7 @@ function createViewmodelsFromDeviceEvents( @Injectable() export class ViewmodelTransformService { - public transform(eventLists: IrrigationEventDocument[][], deviceStates: DeviceStates): IrrigationEventViewmodel[] { - return eventLists.flatMap((deviceEvents) => createViewmodelsFromDeviceEvents(deviceEvents, deviceStates)) + public transform(deviceEventsList: DeviceEvents[]): IrrigationEventViewmodel[] { + return deviceEventsList.flatMap((deviceEvents) => createViewmodelsFromDeviceEvents(deviceEvents)) } }