feat: Implement analytics comparison endpoint for multi-child analysis
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:
@@ -6,11 +6,14 @@ import {
|
|||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
Header,
|
Header,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { PatternAnalysisService } from './pattern-analysis.service';
|
import { PatternAnalysisService } from './pattern-analysis.service';
|
||||||
import { PredictionService } from './prediction.service';
|
import { PredictionService } from './prediction.service';
|
||||||
import { ReportService } from './report.service';
|
import { ReportService } from './report.service';
|
||||||
|
import { ComparisonService } from './comparison.service';
|
||||||
|
import { ComparisonMetric } from './dto/comparison.dto';
|
||||||
|
|
||||||
@Controller('api/v1/analytics')
|
@Controller('api/v1/analytics')
|
||||||
export class AnalyticsController {
|
export class AnalyticsController {
|
||||||
@@ -18,6 +21,7 @@ export class AnalyticsController {
|
|||||||
private readonly patternAnalysisService: PatternAnalysisService,
|
private readonly patternAnalysisService: PatternAnalysisService,
|
||||||
private readonly predictionService: PredictionService,
|
private readonly predictionService: PredictionService,
|
||||||
private readonly reportService: ReportService,
|
private readonly reportService: ReportService,
|
||||||
|
private readonly comparisonService: ComparisonService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('insights/:childId')
|
@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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,18 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Activity } from '../../database/entities/activity.entity';
|
import { Activity } from '../../database/entities/activity.entity';
|
||||||
import { Child } from '../../database/entities/child.entity';
|
import { Child } from '../../database/entities/child.entity';
|
||||||
|
import { FamilyMember } from '../../database/entities/family-member.entity';
|
||||||
import { PatternAnalysisService } from './pattern-analysis.service';
|
import { PatternAnalysisService } from './pattern-analysis.service';
|
||||||
import { PredictionService } from './prediction.service';
|
import { PredictionService } from './prediction.service';
|
||||||
import { ReportService } from './report.service';
|
import { ReportService } from './report.service';
|
||||||
|
import { ComparisonService } from './comparison.service';
|
||||||
import { AnalyticsController } from './analytics.controller';
|
import { AnalyticsController } from './analytics.controller';
|
||||||
import { InsightsController } from './insights.controller';
|
import { InsightsController } from './insights.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Activity, Child])],
|
imports: [TypeOrmModule.forFeature([Activity, Child, FamilyMember])],
|
||||||
controllers: [AnalyticsController, InsightsController],
|
controllers: [AnalyticsController, InsightsController],
|
||||||
providers: [PatternAnalysisService, PredictionService, ReportService],
|
providers: [PatternAnalysisService, PredictionService, ReportService, ComparisonService],
|
||||||
exports: [PatternAnalysisService, PredictionService, ReportService],
|
exports: [PatternAnalysisService, PredictionService, ReportService, ComparisonService],
|
||||||
})
|
})
|
||||||
export class AnalyticsModule {}
|
export class AnalyticsModule {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user