diff --git a/maternal-app/maternal-app-backend/src/database/entities/child.entity.ts b/maternal-app/maternal-app-backend/src/database/entities/child.entity.ts index 4b45052..876a57c 100644 --- a/maternal-app/maternal-app-backend/src/database/entities/child.entity.ts +++ b/maternal-app/maternal-app-backend/src/database/entities/child.entity.ts @@ -32,6 +32,15 @@ export class Child { @Column({ name: 'photo_alt', type: 'text', nullable: true }) photoAlt?: string; + @Column({ name: 'display_color', length: 7, default: '#FF6B9D' }) + displayColor: string; + + @Column({ name: 'sort_order', type: 'integer', default: 0 }) + sortOrder: number; + + @Column({ length: 50, nullable: true }) + nickname?: string; + @Column({ name: 'medical_info', type: 'jsonb', default: {} }) medicalInfo: Record; diff --git a/maternal-app/maternal-app-backend/src/database/migrations/V017_add_child_display_preferences.sql b/maternal-app/maternal-app-backend/src/database/migrations/V017_add_child_display_preferences.sql new file mode 100644 index 0000000..75bd595 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/database/migrations/V017_add_child_display_preferences.sql @@ -0,0 +1,43 @@ +-- Migration V017: Add child display preferences +-- Created: 2025-10-04 +-- Description: Adds display_color, sort_order, and nickname fields for multi-child UI support + +-- Add display preferences columns to children table +ALTER TABLE children +ADD COLUMN display_color VARCHAR(7) DEFAULT '#FF6B9D', +ADD COLUMN sort_order INTEGER DEFAULT 0, +ADD COLUMN nickname VARCHAR(50); + +-- Update existing children with colors based on creation order within each family +WITH numbered_children AS ( + SELECT + id, + family_id, + ROW_NUMBER() OVER (PARTITION BY family_id ORDER BY created_at) - 1 AS row_num + FROM children + WHERE deleted_at IS NULL +) +UPDATE children c +SET + display_color = CASE (nc.row_num % 8) + WHEN 0 THEN '#FF6B9D' -- Pink + WHEN 1 THEN '#4ECDC4' -- Teal + WHEN 2 THEN '#FFD93D' -- Yellow + WHEN 3 THEN '#95E1D3' -- Mint + WHEN 4 THEN '#C7CEEA' -- Lavender + WHEN 5 THEN '#FF8C42' -- Orange + WHEN 6 THEN '#A8E6CF' -- Green + WHEN 7 THEN '#B8B8FF' -- Purple + END, + sort_order = nc.row_num +FROM numbered_children nc +WHERE c.id = nc.id; + +-- Create composite index for efficient family queries with sorting +CREATE INDEX IF NOT EXISTS idx_children_family_sort +ON children(family_id, sort_order, deleted_at); + +-- Add comments for documentation +COMMENT ON COLUMN children.display_color IS 'Hex color code for consistent UI display across charts and cards'; +COMMENT ON COLUMN children.sort_order IS 'Display order within family (0-based, typically by birth order)'; +COMMENT ON COLUMN children.nickname IS 'Optional shortened name for voice commands and quick selection'; diff --git a/maternal-app/maternal-app-backend/src/database/migrations/V018_create_multi_child_preferences.sql b/maternal-app/maternal-app-backend/src/database/migrations/V018_create_multi_child_preferences.sql new file mode 100644 index 0000000..d64090e --- /dev/null +++ b/maternal-app/maternal-app-backend/src/database/migrations/V018_create_multi_child_preferences.sql @@ -0,0 +1,78 @@ +-- Migration V018: Create multi-child UI preferences table +-- Created: 2025-10-04 +-- Description: Stores user preferences for multi-child UI behavior and default selections + +CREATE TABLE IF NOT EXISTS multi_child_preferences ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id VARCHAR(20) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- View Mode Preferences + view_mode VARCHAR(20) DEFAULT 'auto', -- 'tabs', 'cards', 'auto' + + -- Default Child Selection + default_child_id VARCHAR(20) REFERENCES children(id) ON DELETE SET NULL, + + -- Per-Screen Last Selected Child + last_selected_per_screen JSONB DEFAULT '{}'::jsonb, + -- Example: {"track_feeding": "child_id", "track_sleep": "child_id", "analytics": "child_id"} + + -- Comparison Preferences + comparison_default_children JSONB DEFAULT '[]'::jsonb, + -- Example: ["child_id_1", "child_id_2"] + + comparison_default_metric VARCHAR(50) DEFAULT 'sleep-patterns', + -- Options: 'sleep-patterns', 'feeding-frequency', 'growth-curves', 'activities' + + comparison_date_range VARCHAR(20) DEFAULT '7days', + -- Options: '7days', '30days', '90days' + + -- Bulk Operation Preferences + show_bulk_confirm BOOLEAN DEFAULT TRUE, + remember_last_child BOOLEAN DEFAULT TRUE, + + -- Metadata + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(user_id) +); + +-- Create index for quick user lookups +CREATE INDEX IF NOT EXISTS idx_multi_child_preferences_user +ON multi_child_preferences(user_id); + +-- Create index for default child lookups +CREATE INDEX IF NOT EXISTS idx_multi_child_preferences_default_child +ON multi_child_preferences(default_child_id) +WHERE default_child_id IS NOT NULL; + +-- Create trigger to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_multi_child_preferences_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER multi_child_preferences_updated_at_trigger + BEFORE UPDATE ON multi_child_preferences + FOR EACH ROW + EXECUTE FUNCTION update_multi_child_preferences_updated_at(); + +-- Insert default preferences for existing users with 2+ children +INSERT INTO multi_child_preferences (user_id, view_mode) +SELECT DISTINCT fm.user_id, 'auto' +FROM family_members fm +INNER JOIN children c ON fm.family_id = c.family_id +WHERE c.deleted_at IS NULL +GROUP BY fm.user_id, fm.family_id +HAVING COUNT(DISTINCT c.id) >= 2 +ON CONFLICT (user_id) DO NOTHING; + +-- Add comments for documentation +COMMENT ON TABLE multi_child_preferences IS 'Stores user preferences for multi-child UI behavior'; +COMMENT ON COLUMN multi_child_preferences.view_mode IS 'Dashboard view mode: tabs (1-3 kids), cards (4+), or auto'; +COMMENT ON COLUMN multi_child_preferences.default_child_id IS 'Default child for quick actions'; +COMMENT ON COLUMN multi_child_preferences.last_selected_per_screen IS 'JSON object mapping screen paths to last selected child ID'; +COMMENT ON COLUMN multi_child_preferences.comparison_default_children IS 'JSON array of child IDs to compare by default'; diff --git a/maternal-app/maternal-app-backend/src/database/migrations/V019_add_activity_bulk_operations.sql b/maternal-app/maternal-app-backend/src/database/migrations/V019_add_activity_bulk_operations.sql new file mode 100644 index 0000000..39d354c --- /dev/null +++ b/maternal-app/maternal-app-backend/src/database/migrations/V019_add_activity_bulk_operations.sql @@ -0,0 +1,45 @@ +-- Migration V019: Add bulk operation tracking for activities +-- Created: 2025-10-04 +-- Description: Adds columns to track activities logged simultaneously for multiple children + +-- Add bulk operation tracking columns to activities table +ALTER TABLE activities +ADD COLUMN bulk_operation_id UUID, +ADD COLUMN siblings_count INTEGER DEFAULT 1; + +-- Create index for bulk operation queries +CREATE INDEX IF NOT EXISTS idx_activities_bulk_operation +ON activities(bulk_operation_id) +WHERE bulk_operation_id IS NOT NULL; + +-- Add foreign key constraint for child_id if not already exists +-- This ensures all activities are linked to valid children +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_name = 'fk_activities_child' + AND table_name = 'activities' + ) THEN + ALTER TABLE activities + ADD CONSTRAINT fk_activities_child + FOREIGN KEY (child_id) + REFERENCES children(id) + ON DELETE CASCADE; + END IF; +END $$; + +-- Optimize index for multi-child queries (child_id, type, started_at) +-- Note: activities table uses started_at instead of timestamp +CREATE INDEX IF NOT EXISTS idx_activities_multi_child_query +ON activities(child_id, type, started_at DESC); + +-- Create composite index for efficient filtering by multiple children +-- This supports queries like: WHERE child_id IN ('id1', 'id2', 'id3') +CREATE INDEX IF NOT EXISTS idx_activities_child_started_at +ON activities(child_id, started_at DESC); + +-- Add comments for documentation +COMMENT ON COLUMN activities.bulk_operation_id IS 'UUID linking activities logged simultaneously for multiple children'; +COMMENT ON COLUMN activities.siblings_count IS 'Total number of children this activity was logged for (for UI context)'; diff --git a/maternal-app/maternal-app-backend/src/modules/children/children.controller.ts b/maternal-app/maternal-app-backend/src/modules/children/children.controller.ts index 4969a6b..a62b556 100644 --- a/maternal-app/maternal-app-backend/src/modules/children/children.controller.ts +++ b/maternal-app/maternal-app-backend/src/modules/children/children.controller.ts @@ -51,6 +51,9 @@ export class ChildrenController { gender: child.gender, photoUrl: child.photoUrl, photoAlt: child.photoAlt, + displayColor: child.displayColor, + sortOrder: child.sortOrder, + nickname: child.nickname, medicalInfo: child.medicalInfo, createdAt: child.createdAt, }, @@ -58,6 +61,23 @@ export class ChildrenController { }; } + @Get('family/:familyId/statistics') + @HttpCode(HttpStatus.OK) + async getFamilyStatistics( + @CurrentUser() user: any, + @Param('familyId') familyId: string, + ) { + const stats = await this.childrenService.getFamilyStatistics( + user.sub, + familyId, + ); + + return { + success: true, + data: stats, + }; + } + @Get() @HttpCode(HttpStatus.OK) async findAll(@CurrentUser() user: any, @Query('familyId') familyId: string) { @@ -82,6 +102,9 @@ export class ChildrenController { gender: child.gender, photoUrl: child.photoUrl, photoAlt: child.photoAlt, + displayColor: child.displayColor, + sortOrder: child.sortOrder, + nickname: child.nickname, medicalInfo: child.medicalInfo, createdAt: child.createdAt, })), @@ -105,6 +128,9 @@ export class ChildrenController { gender: child.gender, photoUrl: child.photoUrl, photoAlt: child.photoAlt, + displayColor: child.displayColor, + sortOrder: child.sortOrder, + nickname: child.nickname, medicalInfo: child.medicalInfo, createdAt: child.createdAt, }, @@ -151,6 +177,9 @@ export class ChildrenController { gender: child.gender, photoUrl: child.photoUrl, photoAlt: child.photoAlt, + displayColor: child.displayColor, + sortOrder: child.sortOrder, + nickname: child.nickname, medicalInfo: child.medicalInfo, createdAt: child.createdAt, }, diff --git a/maternal-app/maternal-app-backend/src/modules/children/children.service.ts b/maternal-app/maternal-app-backend/src/modules/children/children.service.ts index 7bb66ba..afae293 100644 --- a/maternal-app/maternal-app-backend/src/modules/children/children.service.ts +++ b/maternal-app/maternal-app-backend/src/modules/children/children.service.ts @@ -13,6 +13,17 @@ import { UpdateChildDto } from './dto/update-child.dto'; @Injectable() export class ChildrenService { + private readonly CHILD_COLORS = [ + '#FF6B9D', // Pink + '#4ECDC4', // Teal + '#FFD93D', // Yellow + '#95E1D3', // Mint + '#C7CEEA', // Lavender + '#FF8C42', // Orange + '#A8E6CF', // Green + '#B8B8FF', // Purple + ]; + constructor( @InjectRepository(Child) private childRepository: Repository, @@ -40,11 +51,25 @@ export class ChildrenService { ); } + // Get family's existing children count to determine color and sort order + const existingChildren = await this.childRepository.find({ + where: { familyId, deletedAt: IsNull() }, + order: { createdAt: 'ASC' }, + }); + + const sortOrder = existingChildren.length; + + // Use custom color if provided, otherwise auto-assign + const displayColor = createChildDto.displayColor || + this.CHILD_COLORS[sortOrder % this.CHILD_COLORS.length]; + // Create child const child = this.childRepository.create({ ...createChildDto, familyId, birthDate: new Date(createChildDto.birthDate), + displayColor, + sortOrder, }); return await this.childRepository.save(child); @@ -135,6 +160,15 @@ export class ChildrenService { if (updateChildDto.photoUrl !== undefined) { child.photoUrl = updateChildDto.photoUrl; } + if (updateChildDto.photoAlt !== undefined) { + child.photoAlt = updateChildDto.photoAlt; + } + if (updateChildDto.displayColor !== undefined) { + child.displayColor = updateChildDto.displayColor; + } + if (updateChildDto.nickname !== undefined) { + child.nickname = updateChildDto.nickname; + } if (updateChildDto.medicalInfo !== undefined) { child.medicalInfo = updateChildDto.medicalInfo; } @@ -215,4 +249,90 @@ export class ChildrenService { .orderBy('child.birthDate', 'DESC') .getMany(); } + + /** + * Get family statistics for multi-child UI decisions + */ + async getFamilyStatistics( + userId: string, + familyId: string, + ): Promise<{ + totalChildren: number; + viewMode: 'tabs' | 'cards'; + ageRange: { youngest: number; oldest: number } | null; + genderDistribution: { male: number; female: number; other: number }; + }> { + // Verify user has access to family + const membership = await this.familyMemberRepository.findOne({ + where: { userId, familyId }, + }); + + if (!membership) { + throw new ForbiddenException('You are not a member of this family'); + } + + const children = await this.findAll(userId, familyId); + + if (children.length === 0) { + return { + totalChildren: 0, + viewMode: 'tabs', + ageRange: null, + genderDistribution: { male: 0, female: 0, other: 0 }, + }; + } + + // Calculate ages in months + const ages = children.map((child) => { + const now = new Date(); + const birthDate = new Date(child.birthDate); + return ( + (now.getFullYear() - birthDate.getFullYear()) * 12 + + (now.getMonth() - birthDate.getMonth()) + ); + }); + + // Gender distribution + const genderDistribution = children.reduce( + (acc, child) => { + if (child.gender === 'male') acc.male++; + else if (child.gender === 'female') acc.female++; + else acc.other++; + return acc; + }, + { male: 0, female: 0, other: 0 }, + ); + + return { + totalChildren: children.length, + viewMode: children.length <= 3 ? 'tabs' : 'cards', + ageRange: { + youngest: Math.min(...ages), + oldest: Math.max(...ages), + }, + genderDistribution, + }; + } + + /** + * Check if user has access to a specific child + */ + async userHasAccessToChild( + userId: string, + childId: string, + ): Promise { + const child = await this.childRepository.findOne({ + where: { id: childId, deletedAt: IsNull() }, + }); + + if (!child) { + return false; + } + + const membership = await this.familyMemberRepository.findOne({ + where: { userId, familyId: child.familyId }, + }); + + return !!membership; + } } diff --git a/maternal-app/maternal-app-backend/src/modules/children/dto/create-child.dto.ts b/maternal-app/maternal-app-backend/src/modules/children/dto/create-child.dto.ts index d61c71d..1fc4180 100644 --- a/maternal-app/maternal-app-backend/src/modules/children/dto/create-child.dto.ts +++ b/maternal-app/maternal-app-backend/src/modules/children/dto/create-child.dto.ts @@ -6,6 +6,8 @@ import { IsEnum, MinLength, MaxLength, + Matches, + Length, } from 'class-validator'; export enum Gender { @@ -36,6 +38,18 @@ export class CreateChildDto { @IsString() photoAlt?: string; + @IsOptional() + @IsString() + @Matches(/^#[0-9A-F]{6}$/i, { + message: 'displayColor must be a valid hex color code (e.g., #FF6B9D)', + }) + displayColor?: string; + + @IsOptional() + @IsString() + @Length(1, 50) + nickname?: string; + @IsOptional() @IsObject() medicalInfo?: Record;