Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 47 additions & 5 deletions backend/api/swagger.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -876,4 +918,4 @@ components:
id: { type: string }
event_name: { type: string }
source: { type: string }
created_at: { type: string, format: date-time }
created_at: { type: string, format: date-time }
20 changes: 15 additions & 5 deletions backend/internal/domain/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
74 changes: 61 additions & 13 deletions backend/internal/infrastructure/postgres/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/infrastructure/postgres/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
39 changes: 38 additions & 1 deletion backend/internal/usecase/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
4 changes: 3 additions & 1 deletion frontend/src/components/analytics/StatCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<Card className="flex flex-col items-start p-5 bg-surface border-border shadow-none transition-colors hover:bg-surfaceHighlight/50">
<div className={`mb-3 flex h-8 w-8 items-center justify-center rounded bg-surfaceHighlight ${className}`}>
<Icon size={16} className="text-primary" />
Expand All @@ -17,5 +18,6 @@ export const StatCard: React.FC<{
<span className="text-2xl font-bold tracking-tight text-primary font-mono">{value}</span>
{suffix && <span className="text-xs text-muted-foreground font-mono">{suffix}</span>}
</div>
{hint && <p className="mt-2 text-[11px] leading-snug text-muted-foreground">{hint}</p>}
</Card>
);
42 changes: 39 additions & 3 deletions frontend/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,21 @@ const translations: Record<AppLanguage, TranslationDict> = {
'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',
Expand Down Expand Up @@ -314,9 +326,21 @@ const translations: Record<AppLanguage, TranslationDict> = {
'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': 'Меньше',
Expand Down Expand Up @@ -491,9 +515,21 @@ const translations: Record<AppLanguage, TranslationDict> = {
'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': 'Аз',
Expand Down
Loading
Loading