From 6e88990bc160fe460b61fead0cabc2821a28c746 Mon Sep 17 00:00:00 2001 From: Aikyn Sagyntai Date: Wed, 11 Feb 2026 00:21:45 +0500 Subject: [PATCH] feat: add detailed focus index and primary actions to analytics --- backend/api/swagger.yml | 52 +++++++++++-- backend/internal/domain/models.go | 20 +++-- .../infrastructure/postgres/analytics.go | 74 +++++++++++++++---- .../infrastructure/postgres/helpers.go | 4 + backend/internal/usecase/analytics.go | 39 +++++++++- .../src/components/analytics/StatCard.tsx | 4 +- frontend/src/lib/i18n.ts | 42 ++++++++++- frontend/src/pages/AnalyticsPage.tsx | 35 ++++++++- frontend/src/types/index.ts | 10 +++ 9 files changed, 251 insertions(+), 29 deletions(-) diff --git a/backend/api/swagger.yml b/backend/api/swagger.yml index fa23a31..8989ba2 100644 --- a/backend/api/swagger.yml +++ b/backend/api/swagger.yml @@ -821,10 +821,52 @@ components: AnalyticsOverview: type: object properties: - completed_goals: { type: integer } - calm_score: { type: number } - focus_stability: { type: number } - total_nectar_earned: { type: integer } + completed_goals: + type: integer + description: Number of goals that have completed_at set. + calm_score: + type: number + description: Average session score (0..100). For completed sessions base is 100 with penalties -9 per pause and -12 per logged interruption; for non-completed base is 55 with lighter penalties. + focus_stability: + type: number + description: Average stability score (0..100) by session status. completed=100, paused=70, other statuses=50. + calm_score_base: + type: number + description: Average base part of calm score before penalties (100 for completed sessions, 55 for other statuses). + calm_score_pause_penalty: + type: number + description: Average pause penalty contribution deducted from calm score. + calm_score_interruption_penalty: + type: number + description: Average interruption penalty contribution deducted from calm score. + sessions_total: + type: integer + description: Total count of user sessions included in analytics. + completed_sessions: + type: integer + description: Sessions with completed status. + paused_sessions: + type: integer + description: Sessions currently paused. + other_sessions: + type: integer + description: Sessions in statuses other than completed or paused. + total_pauses: + type: integer + description: Total pauses across all sessions. + total_interruptions: + type: integer + description: Total interruption events across all sessions. + primary_action: + type: string + description: Suggested next optimization action key. + enum: [start_sessions, complete_more_sessions, reduce_pauses, reduce_interruptions, stabilize_schedule, keep_momentum] + best_hour_of_day_utc: + type: integer + description: Most frequent UTC start hour for user's sessions (0..23). -1 when no sessions exist. + total_nectar_earned: + type: integer + description: Total earned points (sum of minutes from completed sessions). DailyActivity: type: object properties: @@ -876,4 +918,4 @@ components: id: { type: string } event_name: { type: string } source: { type: string } - created_at: { type: string, format: date-time } \ No newline at end of file + created_at: { type: string, format: date-time } diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index 1a523de..1d9dfe5 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -157,11 +157,21 @@ type Reflection struct { } type AnalyticsOverview struct { - CompletedGoals int `json:"completed_goals"` - CalmScore float64 `json:"calm_score"` - FocusStability float64 `json:"focus_stability"` - BestHourOfDayUTC int `json:"best_hour_of_day_utc"` - TotalNectarEarned int `json:"total_nectar_earned"` + CompletedGoals int `json:"completed_goals"` + CalmScore float64 `json:"calm_score"` + FocusStability float64 `json:"focus_stability"` + BestHourOfDayUTC int `json:"best_hour_of_day_utc"` + TotalNectarEarned int `json:"total_nectar_earned"` + CalmScoreBase float64 `json:"calm_score_base"` + CalmScorePausePenalty float64 `json:"calm_score_pause_penalty"` + CalmScoreInterruptionPenalty float64 `json:"calm_score_interruption_penalty"` + SessionsTotal int `json:"sessions_total"` + CompletedSessions int `json:"completed_sessions"` + PausedSessions int `json:"paused_sessions"` + OtherSessions int `json:"other_sessions"` + TotalPauses int `json:"total_pauses"` + TotalInterruptions int `json:"total_interruptions"` + PrimaryAction string `json:"primary_action"` } type Insight struct { diff --git a/backend/internal/infrastructure/postgres/analytics.go b/backend/internal/infrastructure/postgres/analytics.go index 2785b04..46884a2 100644 --- a/backend/internal/infrastructure/postgres/analytics.go +++ b/backend/internal/infrastructure/postgres/analytics.go @@ -21,29 +21,74 @@ func (r *Repository) AnalyticsOverview(ctx context.Context, userID string) (doma } const scoreQuery = ` + WITH interruption_counts AS ( + SELECT session_id, COUNT(*)::INT AS interruptions + FROM interruptions + WHERE kind = 'interruption' + GROUP BY session_id + ), + session_stats AS ( + SELECT + fs.status, + fs.pause_count, + COALESCE(i.interruptions, 0) AS interruptions + FROM focus_sessions fs + LEFT JOIN interruption_counts i ON i.session_id = fs.id + WHERE fs.user_id = $1 + ) SELECT COALESCE(AVG( CASE - WHEN fs.status = 'completed' THEN 100 - (fs.pause_count * 9 + COALESCE(i.interruptions, 0) * 12) - ELSE 55 - (fs.pause_count * 6 + COALESCE(i.interruptions, 0) * 8) + WHEN status = 'completed' THEN 100 - (pause_count * 9 + interruptions * 12) + ELSE 55 - (pause_count * 6 + interruptions * 8) END ), 0) AS calm_score, COALESCE(AVG( CASE - WHEN fs.status = 'completed' THEN 100 - WHEN fs.status = 'paused' THEN 70 + WHEN status = 'completed' THEN 100 + WHEN status = 'paused' THEN 70 ELSE 50 END - ), 0) AS focus_stability - FROM focus_sessions fs - LEFT JOIN ( - SELECT session_id, COUNT(*)::INT AS interruptions - FROM interruptions WHERE kind = 'interruption' - GROUP BY session_id - ) i ON i.session_id = fs.id - WHERE fs.user_id = $1 + ), 0) AS focus_stability, + COALESCE(AVG( + CASE + WHEN status = 'completed' THEN 100 + ELSE 55 + END + ), 0) AS calm_score_base, + COALESCE(AVG( + CASE + WHEN status = 'completed' THEN pause_count * 9 + ELSE pause_count * 6 + END + ), 0) AS calm_score_pause_penalty, + COALESCE(AVG( + CASE + WHEN status = 'completed' THEN interruptions * 12 + ELSE interruptions * 8 + END + ), 0) AS calm_score_interruption_penalty, + COUNT(*)::INT AS sessions_total, + COUNT(*) FILTER (WHERE status = 'completed')::INT AS completed_sessions, + COUNT(*) FILTER (WHERE status = 'paused')::INT AS paused_sessions, + COUNT(*) FILTER (WHERE status NOT IN ('completed', 'paused'))::INT AS other_sessions, + COALESCE(SUM(pause_count), 0)::INT AS total_pauses, + COALESCE(SUM(interruptions), 0)::INT AS total_interruptions + FROM session_stats ` - if err := r.pool.QueryRow(ctx, scoreQuery, userID).Scan(&overview.CalmScore, &overview.FocusStability); err != nil { + if err := r.pool.QueryRow(ctx, scoreQuery, userID).Scan( + &overview.CalmScore, + &overview.FocusStability, + &overview.CalmScoreBase, + &overview.CalmScorePausePenalty, + &overview.CalmScoreInterruptionPenalty, + &overview.SessionsTotal, + &overview.CompletedSessions, + &overview.PausedSessions, + &overview.OtherSessions, + &overview.TotalPauses, + &overview.TotalInterruptions, + ); err != nil { return domain.AnalyticsOverview{}, fmt.Errorf("load overview scores: %w", err) } @@ -67,6 +112,9 @@ func (r *Repository) AnalyticsOverview(ctx context.Context, userID string) (doma overview.CalmScore = clampScore(overview.CalmScore) overview.FocusStability = clampScore(overview.FocusStability) + overview.CalmScoreBase = roundToTenth(overview.CalmScoreBase) + overview.CalmScorePausePenalty = roundToTenth(overview.CalmScorePausePenalty) + overview.CalmScoreInterruptionPenalty = roundToTenth(overview.CalmScoreInterruptionPenalty) return overview, nil } diff --git a/backend/internal/infrastructure/postgres/helpers.go b/backend/internal/infrastructure/postgres/helpers.go index 834a975..cc781f5 100644 --- a/backend/internal/infrastructure/postgres/helpers.go +++ b/backend/internal/infrastructure/postgres/helpers.go @@ -27,6 +27,10 @@ func clampScore(value float64) float64 { if value > 100 { return 100 } + return roundToTenth(value) +} + +func roundToTenth(value float64) float64 { return math.Round(value*10) / 10 } diff --git a/backend/internal/usecase/analytics.go b/backend/internal/usecase/analytics.go index af9ff13..6457c29 100644 --- a/backend/internal/usecase/analytics.go +++ b/backend/internal/usecase/analytics.go @@ -19,7 +19,12 @@ func NewAnalyticsUseCase(analyticsRepo AnalyticsRepository, sessionRepo SessionR } func (uc *AnalyticsUseCase) Overview(ctx context.Context, userID string) (domain.AnalyticsOverview, error) { - return uc.analyticsRepo.AnalyticsOverview(ctx, userID) + overview, err := uc.analyticsRepo.AnalyticsOverview(ctx, userID) + if err != nil { + return domain.AnalyticsOverview{}, err + } + overview.PrimaryAction = pickPrimaryAction(overview) + return overview, nil } func (uc *AnalyticsUseCase) DailyActivity(ctx context.Context, userID, timezone string) ([]domain.DailyActivity, error) { @@ -67,3 +72,35 @@ func (uc *AnalyticsUseCase) Insights(ctx context.Context, userID string) (domain return insight, nil } + +func pickPrimaryAction(overview domain.AnalyticsOverview) string { + if overview.SessionsTotal == 0 { + return "start_sessions" + } + + completionRate := float64(overview.CompletedSessions) / float64(maxInt(overview.SessionsTotal, 1)) + if completionRate < 0.6 { + return "complete_more_sessions" + } + + if overview.TotalPauses > 0 && overview.CalmScorePausePenalty >= overview.CalmScoreInterruptionPenalty { + return "reduce_pauses" + } + + if overview.TotalInterruptions > 0 { + return "reduce_interruptions" + } + + if overview.FocusStability < 75 || overview.PausedSessions*2 > maxInt(overview.CompletedSessions, 1) { + return "stabilize_schedule" + } + + return "keep_momentum" +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/frontend/src/components/analytics/StatCard.tsx b/frontend/src/components/analytics/StatCard.tsx index 59769a2..417b828 100644 --- a/frontend/src/components/analytics/StatCard.tsx +++ b/frontend/src/components/analytics/StatCard.tsx @@ -6,8 +6,9 @@ export const StatCard: React.FC<{ label: string; value: number; suffix?: string; + hint?: string; className?: string; -}> = ({ icon: Icon, label, value, suffix, className }) => ( +}> = ({ icon: Icon, label, value, suffix, hint, className }) => (
@@ -17,5 +18,6 @@ export const StatCard: React.FC<{ {value} {suffix && {suffix}}
+ {hint &&

{hint}

}
); diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 1ccf2f0..90ef14d 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -136,9 +136,21 @@ const translations: Record = { 'analytics.title': 'Analytics', 'analytics.subtitle': '// PERFORMANCE METRICS', 'analytics.objectivesDone': 'Objectives Done', - 'analytics.focusScore': 'Focus Score', + 'analytics.objectivesDoneHint': 'Goals with at least one completed session.', + 'analytics.focusScore': 'Focus Index', + 'analytics.focusScoreHint': 'Score formula: {{base}} - {{pausePenalty}} (pauses) - {{interruptionPenalty}} (interruptions).', 'analytics.stability': 'Stability', + 'analytics.stabilityHint': 'Completed sessions: {{completed}} of {{total}}.', 'analytics.totalPoints': 'Total Points', + 'analytics.totalPointsHint': 'Sum of minutes from completed sessions.', + 'analytics.nextStep': 'Next Step', + 'analytics.action.start_sessions': 'Start with one short session to get baseline analytics.', + 'analytics.action.complete_more_sessions': 'Complete more sessions: unfinished sessions drag your score down.', + 'analytics.action.reduce_pauses': 'Reduce pauses first: this is your biggest score penalty now.', + 'analytics.action.reduce_interruptions': 'Reduce interruptions: isolate distractions before session start.', + 'analytics.action.stabilize_schedule': 'Stabilize your rhythm: keep a consistent daily focus slot.', + 'analytics.action.keep_momentum': 'Momentum is strong. Keep the same cadence this week.', + 'analytics.breakdown': 'Sessions {{completed}}/{{total}} completed • Pauses {{pauses}} • Interruptions {{interruptions}}', 'analytics.error': 'Failed to load analytics. Please try again.', 'analytics.activityLog': 'Activity Log (Last 12 Months)', 'analytics.less': 'Less', @@ -314,9 +326,21 @@ const translations: Record = { 'analytics.title': 'Аналитика', 'analytics.subtitle': '// МЕТРИКИ ПРОДУКТИВНОСТИ', 'analytics.objectivesDone': 'Завершённые цели', - 'analytics.focusScore': 'Focus Score', + 'analytics.objectivesDoneHint': 'Количество целей, где есть хотя бы одна завершённая сессия.', + 'analytics.focusScore': 'Индекс фокуса', + 'analytics.focusScoreHint': 'Формула: {{base}} - {{pausePenalty}} (паузы) - {{interruptionPenalty}} (отвлечения).', 'analytics.stability': 'Стабильность', + 'analytics.stabilityHint': 'Завершено сессий: {{completed}} из {{total}}.', 'analytics.totalPoints': 'Всего очков', + 'analytics.totalPointsHint': 'Сумма минут всех завершённых сессий.', + 'analytics.nextStep': 'Следующий шаг', + 'analytics.action.start_sessions': 'Начни с одной короткой сессии, чтобы появилась база аналитики.', + 'analytics.action.complete_more_sessions': 'Доводи больше сессий до завершения: незавершённые сильно тянут score вниз.', + 'analytics.action.reduce_pauses': 'Сначала снизь паузы: сейчас это главный источник штрафа.', + 'analytics.action.reduce_interruptions': 'Снизь отвлечения: убери триггеры до старта сессии.', + 'analytics.action.stabilize_schedule': 'Выравни ритм: держи стабильное время фокус-сессии каждый день.', + 'analytics.action.keep_momentum': 'Темп хороший. Сохрани текущий ритм на этой неделе.', + 'analytics.breakdown': 'Сессии {{completed}}/{{total}} завершено • Пауз {{pauses}} • Отвлечений {{interruptions}}', 'analytics.error': 'Не удалось загрузить аналитику. Попробуйте снова.', 'analytics.activityLog': 'Активность (последние 12 месяцев)', 'analytics.less': 'Меньше', @@ -491,9 +515,21 @@ const translations: Record = { 'analytics.title': 'Аналитика', 'analytics.subtitle': '// ӨНІМДІЛІК МЕТРИКАЛАРЫ', 'analytics.objectivesDone': 'Аяқталған мақсаттар', - 'analytics.focusScore': 'Focus Score', + 'analytics.objectivesDoneHint': 'Кемінде бір сессиясы аяқталған мақсаттар саны.', + 'analytics.focusScore': 'Фокус индексі', + 'analytics.focusScoreHint': 'Формула: {{base}} - {{pausePenalty}} (үзілістер) - {{interruptionPenalty}} (бөлінулер).', 'analytics.stability': 'Тұрақтылық', + 'analytics.stabilityHint': 'Аяқталған сессиялар: {{completed}} / {{total}}.', 'analytics.totalPoints': 'Жалпы ұпай', + 'analytics.totalPointsHint': 'Аяқталған сессиялар минуттарының жиынтығы.', + 'analytics.nextStep': 'Келесі қадам', + 'analytics.action.start_sessions': 'Аналитика базасы үшін бір қысқа сессиядан баста.', + 'analytics.action.complete_more_sessions': 'Көбірек сессияны аяқта: аяқталмағандары score-ды төмендетеді.', + 'analytics.action.reduce_pauses': 'Алдымен үзілістерді азайт: қазір негізгі айып осыдан.', + 'analytics.action.reduce_interruptions': 'Бөлінуді азайт: сессия алдында алаңдатқыштарды алып таста.', + 'analytics.action.stabilize_schedule': 'Ырғақты тұрақтандыр: күнде бір тұрақты уақытта фокус жаса.', + 'analytics.action.keep_momentum': 'Қарқын жақсы. Осы аптада осы ритмді сақта.', + 'analytics.breakdown': 'Сессиялар {{completed}}/{{total}} аяқталды • Үзіліс {{pauses}} • Бөліну {{interruptions}}', 'analytics.error': 'Аналитиканы жүктеу сәтсіз. Қайталап көріңіз.', 'analytics.activityLog': 'Белсенділік (соңғы 12 ай)', 'analytics.less': 'Аз', diff --git a/frontend/src/pages/AnalyticsPage.tsx b/frontend/src/pages/AnalyticsPage.tsx index c7181b7..473d6ab 100644 --- a/frontend/src/pages/AnalyticsPage.tsx +++ b/frontend/src/pages/AnalyticsPage.tsx @@ -255,6 +255,15 @@ const AnalyticsContent: React.FC<{ setTooltip((prev) => ({ ...prev, visible: false })); }; + const calmBase = Number(overview?.calm_score_base ?? 0).toFixed(1); + const calmPausePenalty = Number(overview?.calm_score_pause_penalty ?? 0).toFixed(1); + const calmInterruptionPenalty = Number(overview?.calm_score_interruption_penalty ?? 0).toFixed(1); + const completedSessions = overview?.completed_sessions ?? 0; + const sessionsTotal = overview?.sessions_total ?? 0; + const primaryAction = overview?.primary_action ?? 'start_sessions'; + const totalPauses = overview?.total_pauses ?? 0; + const totalInterruptions = overview?.total_interruptions ?? 0; + return (
@@ -269,28 +278,52 @@ const AnalyticsContent: React.FC<{
+ +

{t('analytics.nextStep')}

+

{t(`analytics.action.${primaryAction}`)}

+

+ {t('analytics.breakdown', { + completed: completedSessions, + total: sessionsTotal, + pauses: totalPauses, + interruptions: totalInterruptions, + })} +

+
+ {/* Heatmap */}

{t('analytics.activityLog')}

diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index bd8ec86..7820cdb 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -120,6 +120,16 @@ export interface AnalyticsOverview { completed_goals: number; calm_score: number; focus_stability: number; + calm_score_base: number; + calm_score_pause_penalty: number; + calm_score_interruption_penalty: number; + sessions_total: number; + completed_sessions: number; + paused_sessions: number; + other_sessions: number; + total_pauses: number; + total_interruptions: number; + primary_action: 'start_sessions' | 'complete_more_sessions' | 'reduce_pauses' | 'reduce_interruptions' | 'stabilize_schedule' | 'keep_momentum'; best_hour_of_day_utc: number; total_nectar_earned: number; }