diff --git a/maternal-app/maternal-app-backend/src/modules/analytics/analytics.controller.ts b/maternal-app/maternal-app-backend/src/modules/analytics/analytics.controller.ts index b80ca44..51776b5 100644 --- a/maternal-app/maternal-app-backend/src/modules/analytics/analytics.controller.ts +++ b/maternal-app/maternal-app-backend/src/modules/analytics/analytics.controller.ts @@ -6,11 +6,14 @@ import { Req, Res, Header, + BadRequestException, } from '@nestjs/common'; import { Response } from 'express'; import { PatternAnalysisService } from './pattern-analysis.service'; import { PredictionService } from './prediction.service'; import { ReportService } from './report.service'; +import { ComparisonService } from './comparison.service'; +import { ComparisonMetric } from './dto/comparison.dto'; @Controller('api/v1/analytics') export class AnalyticsController { @@ -18,6 +21,7 @@ export class AnalyticsController { private readonly patternAnalysisService: PatternAnalysisService, private readonly predictionService: PredictionService, private readonly reportService: ReportService, + private readonly comparisonService: ComparisonService, ) {} @Get('insights/:childId') @@ -128,4 +132,69 @@ export class AnalyticsController { }); } } + + @Get('compare') + async compareChildren( + @Req() req: any, + @Query('childIds') childIdsParam: string, + @Query('metric') metricParam: string, + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + ) { + // Validate required parameters + if (!childIdsParam) { + throw new BadRequestException('childIds query parameter is required (comma-separated)'); + } + + if (!metricParam) { + throw new BadRequestException('metric query parameter is required'); + } + + if (!startDate || !endDate) { + throw new BadRequestException('startDate and endDate query parameters are required'); + } + + // Parse child IDs + const childIds = childIdsParam.split(',').filter(id => id.trim()); + + if (childIds.length < 2) { + throw new BadRequestException('At least 2 children are required for comparison'); + } + + // Validate metric + const validMetrics = Object.values(ComparisonMetric); + if (!validMetrics.includes(metricParam as ComparisonMetric)) { + throw new BadRequestException( + `Invalid metric. Must be one of: ${validMetrics.join(', ')}`, + ); + } + + const metric = metricParam as ComparisonMetric; + + // Parse dates + const start = new Date(startDate); + const end = new Date(endDate); + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + throw new BadRequestException('Invalid date format. Use ISO 8601 format (YYYY-MM-DD)'); + } + + if (start > end) { + throw new BadRequestException('startDate must be before endDate'); + } + + // Perform comparison + const comparison = await this.comparisonService.compareChildren( + req.user.userId, + childIds, + metric, + start, + end, + ); + + return { + success: true, + data: comparison, + }; + } } diff --git a/maternal-app/maternal-app-backend/src/modules/analytics/analytics.module.ts b/maternal-app/maternal-app-backend/src/modules/analytics/analytics.module.ts index f040d70..d60a1fa 100644 --- a/maternal-app/maternal-app-backend/src/modules/analytics/analytics.module.ts +++ b/maternal-app/maternal-app-backend/src/modules/analytics/analytics.module.ts @@ -2,16 +2,18 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Activity } from '../../database/entities/activity.entity'; import { Child } from '../../database/entities/child.entity'; +import { FamilyMember } from '../../database/entities/family-member.entity'; import { PatternAnalysisService } from './pattern-analysis.service'; import { PredictionService } from './prediction.service'; import { ReportService } from './report.service'; +import { ComparisonService } from './comparison.service'; import { AnalyticsController } from './analytics.controller'; import { InsightsController } from './insights.controller'; @Module({ - imports: [TypeOrmModule.forFeature([Activity, Child])], + imports: [TypeOrmModule.forFeature([Activity, Child, FamilyMember])], controllers: [AnalyticsController, InsightsController], - providers: [PatternAnalysisService, PredictionService, ReportService], - exports: [PatternAnalysisService, PredictionService, ReportService], + providers: [PatternAnalysisService, PredictionService, ReportService, ComparisonService], + exports: [PatternAnalysisService, PredictionService, ReportService, ComparisonService], }) export class AnalyticsModule {} diff --git a/maternal-app/maternal-app-backend/src/modules/analytics/comparison.service.ts b/maternal-app/maternal-app-backend/src/modules/analytics/comparison.service.ts new file mode 100644 index 0000000..8095c27 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/analytics/comparison.service.ts @@ -0,0 +1,505 @@ +import { Injectable, ForbiddenException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between } from 'typeorm'; +import { Activity, ActivityType } from '../../database/entities/activity.entity'; +import { Child } from '../../database/entities/child.entity'; +import { FamilyMember } from '../../database/entities/family-member.entity'; +import { + ComparisonMetric, + ComparisonResult, + ComparisonChildData, + SleepComparisonData, + FeedingComparisonData, + DiapeChangesComparisonData, + ActivitiesComparisonData, +} from './dto/comparison.dto'; +import { + startOfDay, + endOfDay, + eachDayOfInterval, + differenceInMonths, + format, + isWithinInterval, + parseISO, +} from 'date-fns'; + +@Injectable() +export class ComparisonService { + constructor( + @InjectRepository(Activity) + private activityRepository: Repository, + @InjectRepository(Child) + private childRepository: Repository, + @InjectRepository(FamilyMember) + private familyMemberRepository: Repository, + ) {} + + async compareChildren( + userId: string, + childIds: string[], + metric: ComparisonMetric, + startDate: Date, + endDate: Date, + ): Promise { + // Validate at least 2 children + if (childIds.length < 2) { + throw new ForbiddenException('At least 2 children required for comparison'); + } + + // Verify all children exist and user has access + const children = await this.childRepository + .createQueryBuilder('child') + .where('child.id IN (:...childIds)', { childIds }) + .andWhere('child.deletedAt IS NULL') + .getMany(); + + if (children.length !== childIds.length) { + throw new ForbiddenException('One or more children not found'); + } + + // Verify user has access to all children + const familyIds = [...new Set(children.map((c) => c.familyId))]; + for (const familyId of familyIds) { + const membership = await this.familyMemberRepository.findOne({ + where: { userId, familyId }, + }); + + if (!membership) { + throw new ForbiddenException( + 'You do not have access to one or more children', + ); + } + } + + // Route to appropriate comparison method + switch (metric) { + case ComparisonMetric.SLEEP_PATTERNS: + return this.compareSleepPatterns(children, startDate, endDate); + case ComparisonMetric.FEEDING_FREQUENCY: + return this.compareFeedingFrequency(children, startDate, endDate); + case ComparisonMetric.DIAPER_CHANGES: + return this.compareDiaperChanges(children, startDate, endDate); + case ComparisonMetric.ACTIVITIES: + return this.compareActivities(children, startDate, endDate); + default: + throw new ForbiddenException(`Unsupported metric: ${metric}`); + } + } + + private async compareSleepPatterns( + children: Child[], + startDate: Date, + endDate: Date, + ): Promise { + const childrenData: ComparisonChildData[] = []; + + for (const child of children) { + const sleepActivities = await this.activityRepository.find({ + where: { + childId: child.id, + type: ActivityType.SLEEP, + startedAt: Between(startOfDay(startDate), endOfDay(endDate)), + }, + order: { startedAt: 'ASC' }, + }); + + const data = this.calculateSleepData(sleepActivities, startDate, endDate); + const age = differenceInMonths(new Date(), new Date(child.birthDate)); + + childrenData.push({ + childId: child.id, + childName: child.name, + age, + data, + summary: { + averageHoursPerDay: data.averageHoursPerDay, + totalSessions: data.totalSleepSessions, + nightSleepPercentage: data.nightSleepPercentage, + }, + }); + } + + // Calculate overall summary + const overallSummary = { + averageHoursAcrossChildren: + childrenData.reduce((sum, c) => sum + c.data.averageHoursPerDay, 0) / + childrenData.length, + childWithMostSleep: childrenData.reduce((prev, current) => + prev.data.averageHoursPerDay > current.data.averageHoursPerDay + ? prev + : current, + ).childName, + childWithLeastSleep: childrenData.reduce((prev, current) => + prev.data.averageHoursPerDay < current.data.averageHoursPerDay + ? prev + : current, + ).childName, + }; + + return { + metric: ComparisonMetric.SLEEP_PATTERNS, + dateRange: { start: startDate, end: endDate }, + children: childrenData, + overallSummary, + }; + } + + private calculateSleepData( + activities: Activity[], + startDate: Date, + endDate: Date, + ): SleepComparisonData { + const days = eachDayOfInterval({ start: startDate, end: endDate }); + const totalDays = days.length; + + let totalMinutes = 0; + let nightSleepMinutes = 0; + let longestSleepDuration = 0; + + const dailyBreakdown = days.map((day) => { + const dayActivities = activities.filter((a) => + isWithinInterval(new Date(a.startedAt), { + start: startOfDay(day), + end: endOfDay(day), + }), + ); + + let dayTotalMinutes = 0; + dayActivities.forEach((activity) => { + const start = new Date(activity.startedAt); + const end = activity.endedAt + ? new Date(activity.endedAt) + : new Date(); + const duration = Math.floor((end.getTime() - start.getTime()) / 60000); + + dayTotalMinutes += duration; + totalMinutes += duration; + + if (duration > longestSleepDuration) { + longestSleepDuration = duration; + } + + // Check if night sleep (8PM - 6AM) + const hour = start.getHours(); + if (hour >= 20 || hour < 6) { + nightSleepMinutes += duration; + } + }); + + return { + date: format(day, 'yyyy-MM-dd'), + totalMinutes: dayTotalMinutes, + sessionsCount: dayActivities.length, + }; + }); + + // Count naps (sleep sessions during 6AM - 8PM) + const naps = activities.filter((a) => { + const hour = new Date(a.startedAt).getHours(); + return hour >= 6 && hour < 20; + }); + + return { + averageHoursPerDay: totalMinutes / totalDays / 60, + totalSleepSessions: activities.length, + longestSleepDuration, + averageNapsPerDay: naps.length / totalDays, + nightSleepPercentage: + totalMinutes > 0 ? (nightSleepMinutes / totalMinutes) * 100 : 0, + dailyBreakdown, + }; + } + + private async compareFeedingFrequency( + children: Child[], + startDate: Date, + endDate: Date, + ): Promise { + const childrenData: ComparisonChildData[] = []; + + for (const child of children) { + const feedingActivities = await this.activityRepository.find({ + where: { + childId: child.id, + type: ActivityType.FEEDING, + startedAt: Between(startOfDay(startDate), endOfDay(endDate)), + }, + order: { startedAt: 'ASC' }, + }); + + const data = this.calculateFeedingData( + feedingActivities, + startDate, + endDate, + ); + const age = differenceInMonths(new Date(), new Date(child.birthDate)); + + childrenData.push({ + childId: child.id, + childName: child.name, + age, + data, + summary: { + totalFeedings: data.totalFeedings, + averagePerDay: data.averageFeedingsPerDay, + }, + }); + } + + const overallSummary = { + averageFeedingsAcrossChildren: + childrenData.reduce((sum, c) => sum + c.data.averageFeedingsPerDay, 0) / + childrenData.length, + childWithMostFeedings: childrenData.reduce((prev, current) => + prev.data.totalFeedings > current.data.totalFeedings ? prev : current, + ).childName, + }; + + return { + metric: ComparisonMetric.FEEDING_FREQUENCY, + dateRange: { start: startDate, end: endDate }, + children: childrenData, + overallSummary, + }; + } + + private calculateFeedingData( + activities: Activity[], + startDate: Date, + endDate: Date, + ): FeedingComparisonData { + const days = eachDayOfInterval({ start: startDate, end: endDate }); + const totalDays = days.length; + + const feedingMethods: Record = {}; + let totalAmount = 0; + let amountCount = 0; + + const dailyBreakdown = days.map((day) => { + const dayActivities = activities.filter((a) => + isWithinInterval(new Date(a.startedAt), { + start: startOfDay(day), + end: endOfDay(day), + }), + ); + + let dayTotalAmount = 0; + dayActivities.forEach((activity) => { + const method = activity.metadata?.method || 'unknown'; + feedingMethods[method] = (feedingMethods[method] || 0) + 1; + + if (activity.metadata?.amount) { + const amount = parseFloat(activity.metadata.amount); + totalAmount += amount; + dayTotalAmount += amount; + amountCount++; + } + }); + + return { + date: format(day, 'yyyy-MM-dd'), + count: dayActivities.length, + totalAmount: dayTotalAmount > 0 ? dayTotalAmount : undefined, + }; + }); + + return { + totalFeedings: activities.length, + averageFeedingsPerDay: activities.length / totalDays, + feedingMethods, + averageAmountPerFeeding: + amountCount > 0 ? totalAmount / amountCount : undefined, + dailyBreakdown, + }; + } + + private async compareDiaperChanges( + children: Child[], + startDate: Date, + endDate: Date, + ): Promise { + const childrenData: ComparisonChildData[] = []; + + for (const child of children) { + const diaperActivities = await this.activityRepository.find({ + where: { + childId: child.id, + type: ActivityType.DIAPER, + startedAt: Between(startOfDay(startDate), endOfDay(endDate)), + }, + order: { startedAt: 'ASC' }, + }); + + const data = this.calculateDiaperData( + diaperActivities, + startDate, + endDate, + ); + const age = differenceInMonths(new Date(), new Date(child.birthDate)); + + childrenData.push({ + childId: child.id, + childName: child.name, + age, + data, + summary: { + totalChanges: data.totalChanges, + averagePerDay: data.averageChangesPerDay, + }, + }); + } + + return { + metric: ComparisonMetric.DIAPER_CHANGES, + dateRange: { start: startDate, end: endDate }, + children: childrenData, + }; + } + + private calculateDiaperData( + activities: Activity[], + startDate: Date, + endDate: Date, + ): DiapeChangesComparisonData { + const days = eachDayOfInterval({ start: startDate, end: endDate }); + const totalDays = days.length; + + let wetDiapers = 0; + let dirtyDiapers = 0; + let bothDiapers = 0; + + const dailyBreakdown = days.map((day) => { + const dayActivities = activities.filter((a) => + isWithinInterval(new Date(a.startedAt), { + start: startOfDay(day), + end: endOfDay(day), + }), + ); + + let dayWet = 0; + let dayDirty = 0; + let dayBoth = 0; + + dayActivities.forEach((activity) => { + const type = activity.metadata?.type || 'wet'; + if (type === 'wet') { + wetDiapers++; + dayWet++; + } else if (type === 'dirty') { + dirtyDiapers++; + dayDirty++; + } else if (type === 'both') { + bothDiapers++; + dayBoth++; + } + }); + + return { + date: format(day, 'yyyy-MM-dd'), + wet: dayWet, + dirty: dayDirty, + both: dayBoth, + }; + }); + + return { + totalChanges: activities.length, + averageChangesPerDay: activities.length / totalDays, + wetDiapers, + dirtyDiapers, + bothDiapers, + dailyBreakdown, + }; + } + + private async compareActivities( + children: Child[], + startDate: Date, + endDate: Date, + ): Promise { + const childrenData: ComparisonChildData[] = []; + + for (const child of children) { + const activities = await this.activityRepository.find({ + where: { + childId: child.id, + type: ActivityType.ACTIVITY, + startedAt: Between(startOfDay(startDate), endOfDay(endDate)), + }, + order: { startedAt: 'ASC' }, + }); + + const data = this.calculateActivitiesData(activities, startDate, endDate); + const age = differenceInMonths(new Date(), new Date(child.birthDate)); + + childrenData.push({ + childId: child.id, + childName: child.name, + age, + data, + summary: { + totalActivities: data.totalActivities, + averagePerDay: data.averageActivitiesPerDay, + mostCommon: data.mostCommonActivity, + }, + }); + } + + return { + metric: ComparisonMetric.ACTIVITIES, + dateRange: { start: startDate, end: endDate }, + children: childrenData, + }; + } + + private calculateActivitiesData( + activities: Activity[], + startDate: Date, + endDate: Date, + ): ActivitiesComparisonData { + const days = eachDayOfInterval({ start: startDate, end: endDate }); + const totalDays = days.length; + + const activitiesByType: Record = {}; + + const dailyBreakdown = days.map((day) => { + const dayActivities = activities.filter((a) => + isWithinInterval(new Date(a.startedAt), { + start: startOfDay(day), + end: endOfDay(day), + }), + ); + + const dayTypes: Record = {}; + dayActivities.forEach((activity) => { + const activityType = + activity.metadata?.activityType || 'unspecified'; + activitiesByType[activityType] = + (activitiesByType[activityType] || 0) + 1; + dayTypes[activityType] = (dayTypes[activityType] || 0) + 1; + }); + + return { + date: format(day, 'yyyy-MM-dd'), + count: dayActivities.length, + types: dayTypes, + }; + }); + + // Find most common activity + const mostCommonActivity = + Object.keys(activitiesByType).length > 0 + ? Object.entries(activitiesByType).reduce((prev, current) => + prev[1] > current[1] ? prev : current, + )[0] + : 'none'; + + return { + totalActivities: activities.length, + averageActivitiesPerDay: activities.length / totalDays, + activitiesByType, + mostCommonActivity, + dailyBreakdown, + }; + } +} diff --git a/maternal-app/maternal-app-backend/src/modules/analytics/dto/comparison.dto.ts b/maternal-app/maternal-app-backend/src/modules/analytics/dto/comparison.dto.ts new file mode 100644 index 0000000..e6d0581 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/analytics/dto/comparison.dto.ts @@ -0,0 +1,76 @@ +export enum ComparisonMetric { + SLEEP_PATTERNS = 'sleep-patterns', + FEEDING_FREQUENCY = 'feeding-frequency', + GROWTH_CURVES = 'growth-curves', + DIAPER_CHANGES = 'diaper-changes', + ACTIVITIES = 'activities', +} + +export interface ComparisonChildData { + childId: string; + childName: string; + age: number; // in months + data: any; + summary: any; +} + +export interface ComparisonResult { + metric: ComparisonMetric; + dateRange: { + start: Date; + end: Date; + }; + children: ComparisonChildData[]; + overallSummary?: any; +} + +export interface SleepComparisonData { + averageHoursPerDay: number; + totalSleepSessions: number; + longestSleepDuration: number; // in minutes + averageNapsPerDay: number; + nightSleepPercentage: number; // percentage of sleep during night (8PM-6AM) + dailyBreakdown: Array<{ + date: string; + totalMinutes: number; + sessionsCount: number; + }>; +} + +export interface FeedingComparisonData { + totalFeedings: number; + averageFeedingsPerDay: number; + feedingMethods: Record; // { bottle: 10, breast: 5 } + averageAmountPerFeeding?: number; // if tracked + dailyBreakdown: Array<{ + date: string; + count: number; + totalAmount?: number; + }>; +} + +export interface DiapeChangesComparisonData { + totalChanges: number; + averageChangesPerDay: number; + wetDiapers: number; + dirtyDiapers: number; + bothDiapers: number; + dailyBreakdown: Array<{ + date: string; + wet: number; + dirty: number; + both: number; + }>; +} + +export interface ActivitiesComparisonData { + totalActivities: number; + averageActivitiesPerDay: number; + activitiesByType: Record; + mostCommonActivity: string; + dailyBreakdown: Array<{ + date: string; + count: number; + types: Record; + }>; +}