feat: Implement analytics comparison endpoint for multi-child analysis
Some checks failed
CI/CD Pipeline / Build Application (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled

Comparison API:
- GET /api/v1/analytics/compare?childIds=id1,id2&metric=sleep-patterns&startDate=...&endDate=...
- Supports 2+ children comparison
- Validates user access to all children
- Date range validation with ISO 8601 format

Supported Metrics:
- sleep-patterns: Average hours, night sleep %, nap frequency, daily breakdown
- feeding-frequency: Total feedings, average per day, methods, amounts
- diaper-changes: Total changes, wet/dirty/both breakdown
- activities: Total count, types distribution, most common activity

ComparisonService:
- compareSleepPatterns() - Detailed sleep analysis with night vs day sleep
- compareFeedingFrequency() - Feeding patterns and methods tracking
- compareDiaperChanges() - Diaper change patterns by type
- compareActivities() - Activity type distribution and frequency

Response Structure:
{
  metric: "sleep-patterns",
  dateRange: { start, end },
  children: [{
    childId, childName, age,
    data: { detailed metrics },
    summary: { key stats }
  }],
  overallSummary: {
    averageAcrossChildren,
    childWithMost/Least,
    ...
  }
}

Daily Breakdown:
- Each metric includes day-by-day data
- Allows chart rendering on frontend
- Supports trend analysis

Use Cases:
- "How does Emma's sleep compare to Noah's?"
- "Which child eats more frequently?"
- "Compare diaper patterns for twins"
- Side-by-side analytics dashboards

Security:
- Family-level permission checks
- User must have access to all children
- Cross-family comparison supported

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-04 21:24:02 +00:00
parent cdc4845f1a
commit 2c9ae0c3bf
4 changed files with 655 additions and 3 deletions

View File

@@ -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,
};
}
}

View File

@@ -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 {}

View File

@@ -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<Activity>,
@InjectRepository(Child)
private childRepository: Repository<Child>,
@InjectRepository(FamilyMember)
private familyMemberRepository: Repository<FamilyMember>,
) {}
async compareChildren(
userId: string,
childIds: string[],
metric: ComparisonMetric,
startDate: Date,
endDate: Date,
): Promise<ComparisonResult> {
// 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<ComparisonResult> {
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<ComparisonResult> {
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<string, number> = {};
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<ComparisonResult> {
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<ComparisonResult> {
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<string, number> = {};
const dailyBreakdown = days.map((day) => {
const dayActivities = activities.filter((a) =>
isWithinInterval(new Date(a.startedAt), {
start: startOfDay(day),
end: endOfDay(day),
}),
);
const dayTypes: Record<string, number> = {};
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,
};
}
}

View File

@@ -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<string, number>; // { 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<string, number>;
mostCommonActivity: string;
dailyBreakdown: Array<{
date: string;
count: number;
types: Record<string, number>;
}>;
}