This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

content breakdowns

+287 -2
+38 -1
src/modules/user/application/useCases/queries/GetUserStatsUseCase.ts
··· 5 5 UserGrowthStatsDTO, 6 6 UserEngagementStatsDTO, 7 7 DailyActivityStatsDTO, 8 + ContentBreakdownStatsDTO, 8 9 UserStatType, 9 10 TimeInterval, 10 11 } from '../../../domain/IUserStatsRepository'; ··· 20 21 export type GetUserStatsResult = 21 22 | UserGrowthStatsDTO 22 23 | UserEngagementStatsDTO 23 - | DailyActivityStatsDTO; // Union type for different stat types 24 + | DailyActivityStatsDTO 25 + | ContentBreakdownStatsDTO; // Union type for different stat types 24 26 25 27 export class ValidationError extends Error { 26 28 constructor(message: string) { ··· 51 53 52 54 case 'activity': 53 55 return await this.handleActivityStats(query); 56 + 57 + case 'breakdown': 58 + return await this.handleBreakdownStats(query); 54 59 55 60 default: 56 61 return err( ··· 159 164 return err( 160 165 new Error( 161 166 `Failed to retrieve activity stats: ${error instanceof Error ? error.message : 'Unknown error'}`, 167 + ), 168 + ); 169 + } 170 + } 171 + 172 + private async handleBreakdownStats( 173 + query: GetUserStatsQuery, 174 + ): Promise<Result<ContentBreakdownStatsDTO>> { 175 + // Apply defaults 176 + const interval = query.interval || 'day'; 177 + const limit = query.limit || 30; 178 + 179 + // Validate parameters 180 + if (!['day', 'week', 'month'].includes(interval)) { 181 + return err(new ValidationError(`Invalid interval: ${interval}`)); 182 + } 183 + 184 + if (limit < 1 || limit > 365) { 185 + return err(new ValidationError('Limit must be between 1 and 365')); 186 + } 187 + 188 + try { 189 + const stats = await this.userStatsRepository.getContentBreakdownStats({ 190 + interval, 191 + limit, 192 + }); 193 + 194 + return ok(stats); 195 + } catch (error) { 196 + return err( 197 + new Error( 198 + `Failed to retrieve breakdown stats: ${error instanceof Error ? error.message : 'Unknown error'}`, 162 199 ), 163 200 ); 164 201 }
+61 -1
src/modules/user/domain/IUserStatsRepository.ts
··· 85 85 limit: number; // Number of intervals to return 86 86 } 87 87 88 + // Content Breakdown Statistics DTOs 89 + 90 + export interface ContentBreakdownDataPoint { 91 + date: string; // ISO date string 92 + 93 + // URL Cards breakdown 94 + urlCards: { 95 + total: number; 96 + byType: Record<string, number>; // e.g., { "article": 50, "video": 30, "tool": 20 } 97 + }; 98 + 99 + // Collections breakdown 100 + collections: { 101 + total: number; 102 + byAccessType: Record<string, number>; // e.g., { "OPEN": 80, "CLOSED": 45 } 103 + }; 104 + 105 + // Connections breakdown 106 + connections: { 107 + total: number; 108 + byType: Record<string, number>; // e.g., { "SUPPORTS": 30, "OPPOSES": 20, null: 15 } 109 + }; 110 + } 111 + 112 + export interface ContentBreakdownStatsDTO { 113 + dataPoints: ContentBreakdownDataPoint[]; 114 + 115 + // Current totals (latest snapshot) 116 + currentTotals: { 117 + urlCards: { 118 + total: number; 119 + byType: Record<string, number>; 120 + }; 121 + collections: { 122 + total: number; 123 + byAccessType: Record<string, number>; 124 + }; 125 + connections: { 126 + total: number; 127 + byType: Record<string, number>; 128 + }; 129 + }; 130 + 131 + periodStart: string; 132 + periodEnd: string; 133 + } 134 + 135 + export interface ContentBreakdownStatsOptions { 136 + interval: TimeInterval; // day, week, month 137 + limit: number; // Number of intervals to return 138 + } 139 + 88 140 // Future stat types can be added here 89 - export type UserStatType = 'growth' | 'activity' | 'engagement'; 141 + export type UserStatType = 'growth' | 'activity' | 'engagement' | 'breakdown'; 90 142 91 143 /** 92 144 * Repository interface for user statistics and analytics queries ··· 116 168 getDailyActivityStats( 117 169 options: DailyActivityStatsOptions, 118 170 ): Promise<DailyActivityStatsDTO>; 171 + 172 + /** 173 + * Get content breakdown statistics 174 + * Returns cumulative content totals broken down by subtypes over time 175 + */ 176 + getContentBreakdownStats( 177 + options: ContentBreakdownStatsOptions, 178 + ): Promise<ContentBreakdownStatsDTO>; 119 179 }
+188
src/modules/user/infrastructure/repositories/DrizzleUserStatsRepository.ts
··· 11 11 DailyActivityStatsDTO, 12 12 DailyActivityStatsOptions, 13 13 DailyActivityDataPoint, 14 + ContentBreakdownStatsDTO, 15 + ContentBreakdownStatsOptions, 16 + ContentBreakdownDataPoint, 14 17 } from '../../domain/IUserStatsRepository'; 15 18 import { users } from './schema/user.sql'; 16 19 import { cards } from '../../../cards/infrastructure/repositories/schema/card.sql'; ··· 314 317 return { 315 318 dataPoints, 316 319 totals, 320 + periodStart, 321 + periodEnd, 322 + }; 323 + } 324 + 325 + async getContentBreakdownStats( 326 + options: ContentBreakdownStatsOptions, 327 + ): Promise<ContentBreakdownStatsDTO> { 328 + const { interval, limit } = options; 329 + 330 + // Query for URL cards breakdown by type over time 331 + const urlCardsQuery = sql` 332 + WITH periods AS ( 333 + SELECT DISTINCT date_trunc(${interval}, created_at) AS period 334 + FROM ${cards} 335 + WHERE type = 'URL' AND created_at IS NOT NULL 336 + ), 337 + url_card_counts AS ( 338 + SELECT 339 + p.period, 340 + COALESCE(c.url_type, 'unspecified') AS url_type, 341 + COUNT(c.id) AS count 342 + FROM periods p 343 + CROSS JOIN LATERAL ( 344 + SELECT * FROM ${cards} 345 + WHERE type = 'URL' 346 + AND created_at <= p.period + interval '1 ${interval}' 347 + ) c 348 + GROUP BY p.period, c.url_type 349 + ), 350 + url_card_aggregated AS ( 351 + SELECT 352 + period::text AS date, 353 + jsonb_object_agg(url_type, count) AS by_type, 354 + SUM(count)::int AS total 355 + FROM url_card_counts 356 + GROUP BY period 357 + ORDER BY period DESC 358 + LIMIT ${limit} 359 + ) 360 + SELECT * FROM url_card_aggregated 361 + `; 362 + 363 + // Query for collections breakdown by access type over time 364 + const collectionsQuery = sql` 365 + WITH periods AS ( 366 + SELECT DISTINCT date_trunc(${interval}, created_at) AS period 367 + FROM ${collections} 368 + WHERE created_at IS NOT NULL 369 + ), 370 + collection_counts AS ( 371 + SELECT 372 + p.period, 373 + col.access_type, 374 + COUNT(col.id) AS count 375 + FROM periods p 376 + CROSS JOIN LATERAL ( 377 + SELECT * FROM ${collections} 378 + WHERE created_at <= p.period + interval '1 ${interval}' 379 + ) col 380 + GROUP BY p.period, col.access_type 381 + ), 382 + collection_aggregated AS ( 383 + SELECT 384 + period::text AS date, 385 + jsonb_object_agg(access_type, count) AS by_access_type, 386 + SUM(count)::int AS total 387 + FROM collection_counts 388 + GROUP BY period 389 + ORDER BY period DESC 390 + LIMIT ${limit} 391 + ) 392 + SELECT * FROM collection_aggregated 393 + `; 394 + 395 + // Query for connections breakdown by type over time 396 + const connectionsQuery = sql` 397 + WITH periods AS ( 398 + SELECT DISTINCT date_trunc(${interval}, created_at) AS period 399 + FROM ${connections} 400 + WHERE created_at IS NOT NULL 401 + ), 402 + connection_counts AS ( 403 + SELECT 404 + p.period, 405 + COALESCE(con.connection_type, 'unspecified') AS connection_type, 406 + COUNT(con.id) AS count 407 + FROM periods p 408 + CROSS JOIN LATERAL ( 409 + SELECT * FROM ${connections} 410 + WHERE created_at <= p.period + interval '1 ${interval}' 411 + ) con 412 + GROUP BY p.period, con.connection_type 413 + ), 414 + connection_aggregated AS ( 415 + SELECT 416 + period::text AS date, 417 + jsonb_object_agg(connection_type, count) AS by_type, 418 + SUM(count)::int AS total 419 + FROM connection_counts 420 + GROUP BY period 421 + ORDER BY period DESC 422 + LIMIT ${limit} 423 + ) 424 + SELECT * FROM connection_aggregated 425 + `; 426 + 427 + // Execute all queries in parallel 428 + const [urlCardsResult, collectionsResult, connectionsResult] = 429 + await Promise.all([ 430 + this.db.execute(urlCardsQuery), 431 + this.db.execute(collectionsQuery), 432 + this.db.execute(connectionsQuery), 433 + ]); 434 + 435 + // Create maps for easy lookup 436 + const urlCardsMap = new Map( 437 + urlCardsResult.map((row: any) => [row.date, row]), 438 + ); 439 + const collectionsMap = new Map( 440 + collectionsResult.map((row: any) => [row.date, row]), 441 + ); 442 + const connectionsMap = new Map( 443 + connectionsResult.map((row: any) => [row.date, row]), 444 + ); 445 + 446 + // Get all unique dates and sort them 447 + const allDates = new Set([ 448 + ...urlCardsMap.keys(), 449 + ...collectionsMap.keys(), 450 + ...connectionsMap.keys(), 451 + ]); 452 + const sortedDates = Array.from(allDates).sort(); 453 + 454 + // Build data points 455 + const dataPoints: ContentBreakdownDataPoint[] = sortedDates.map((date) => { 456 + const urlCardData = urlCardsMap.get(date); 457 + const collectionData = collectionsMap.get(date); 458 + const connectionData = connectionsMap.get(date); 459 + 460 + return { 461 + date, 462 + urlCards: { 463 + total: urlCardData?.total || 0, 464 + byType: urlCardData?.by_type || {}, 465 + }, 466 + collections: { 467 + total: collectionData?.total || 0, 468 + byAccessType: collectionData?.by_access_type || {}, 469 + }, 470 + connections: { 471 + total: connectionData?.total || 0, 472 + byType: connectionData?.by_type || {}, 473 + }, 474 + }; 475 + }); 476 + 477 + // Get current totals (the most recent data point or fetch separately if needed) 478 + const currentUrlCards = urlCardsResult[0] as any; 479 + const currentCollections = collectionsResult[0] as any; 480 + const currentConnections = connectionsResult[0] as any; 481 + 482 + const currentTotals = { 483 + urlCards: { 484 + total: currentUrlCards?.total || 0, 485 + byType: currentUrlCards?.by_type || {}, 486 + }, 487 + collections: { 488 + total: currentCollections?.total || 0, 489 + byAccessType: currentCollections?.by_access_type || {}, 490 + }, 491 + connections: { 492 + total: currentConnections?.total || 0, 493 + byType: currentConnections?.by_type || {}, 494 + }, 495 + }; 496 + 497 + // Determine period range 498 + const periodStart = sortedDates[0] || new Date().toISOString(); 499 + const periodEnd = 500 + sortedDates[sortedDates.length - 1] || new Date().toISOString(); 501 + 502 + return { 503 + dataPoints, 504 + currentTotals, 317 505 periodStart, 318 506 periodEnd, 319 507 };