diff --git a/frontend/src/components/insights/ai-insight-card.tsx b/frontend/src/components/insights/ai-insight-card.tsx index e7ad7a5..f3ee1d4 100644 --- a/frontend/src/components/insights/ai-insight-card.tsx +++ b/frontend/src/components/insights/ai-insight-card.tsx @@ -1,5 +1,14 @@ import { useState, useMemo } from "react"; -import { IconSparkles, IconChevronDown, IconChevronUp, IconBulb, IconTrophy, IconEye, IconArrowsShuffle, IconTarget } from "@tabler/icons-react"; +import { + IconSparkles, + IconChevronDown, + IconChevronUp, + IconBulb, + IconTrophy, + IconEye, + IconArrowsShuffle, + IconTarget, +} from "@tabler/icons-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { @@ -14,8 +23,15 @@ interface LLMInsightCardProps { isYesterday?: boolean; } -export function LLMInsightCard({ dailyUsageSummary, isYesterday = false }: LLMInsightCardProps) { - const [isOpen, setIsOpen] = useState(isYesterday || !!dailyUsageSummary); +export function LLMInsightCard({ + dailyUsageSummary, + isYesterday = false, +}: LLMInsightCardProps) { + const [isOpen, setIsOpen] = useState( + isYesterday || !!dailyUsageSummary, + ); + const isInsufficientData = + dailyUsageSummary?.day_vibe === "insufficient-data"; const wins = useMemo(() => { if (!dailyUsageSummary?.wins) return []; @@ -40,12 +56,10 @@ export function LLMInsightCard({ dailyUsageSummary, isYesterday = false }: LLMIn
- - {headline} - + {headline}
- {dayVibe && ( + {dayVibe && !isInsufficientData && ( {dayVibe} @@ -62,82 +76,90 @@ export function LLMInsightCard({ dailyUsageSummary, isYesterday = false }: LLMIn
- -
+ + {isInsufficientData ? (

- {narrative} + Not enough activity was tracked. Use your computer a bit more for + a better AI summary.

-
+ ) : ( +
+

+ {narrative} +

- {/* Stat badges */} -
- {dailyUsageSummary.context_switch_count > 0 && ( - - - {dailyUsageSummary.context_switch_count} context switches - - )} - {dailyUsageSummary.deep_work_minutes > 0 && ( - - - {dailyUsageSummary.deep_work_minutes}m deep work - - )} - {dailyUsageSummary.longest_focus_minutes > 0 && ( - - - {dailyUsageSummary.longest_focus_minutes}m longest focus - - )} -
+ {/* Stat badges */} +
+ {dailyUsageSummary.context_switch_count > 0 && ( + + + {dailyUsageSummary.context_switch_count} context switches + + )} + {dailyUsageSummary.deep_work_minutes > 0 && ( + + + {dailyUsageSummary.deep_work_minutes}m deep work + + )} + {dailyUsageSummary.longest_focus_minutes > 0 && ( + + + {dailyUsageSummary.longest_focus_minutes}m longest focus + + )} +
- -
- {/* Key Pattern */} - {keyPattern && ( -
-

- - Key Pattern -

-

- {keyPattern} -

-
- )} + +
+ {/* Key Pattern */} + {keyPattern && ( +
+

+ + Key Pattern +

+

{keyPattern}

+
+ )} - {/* Wins */} - {wins.length > 0 && ( -
-

- - Wins -

-
    - {wins.map((win, i) => ( -
  • - - {win} -
  • - ))} -
-
- )} + {/* Wins */} + {wins.length > 0 && ( +
+

+ + Wins +

+
    + {wins.map((win, i) => ( +
  • + + {win} +
  • + ))} +
+
+ )} - {/* Suggestion */} - {suggestion && ( -
-

- - Coach's Suggestion -

-

- "{suggestion}" -

+ {/* Suggestion */} + {suggestion && ( +
+

+ + Coach's Suggestion +

+

+ "{suggestion}" +

+
+ )}
- )} +
-
+ )} diff --git a/frontend/src/components/insights/bento-dashboard.tsx b/frontend/src/components/insights/bento-dashboard.tsx index 4fe8d3a..90479be 100644 --- a/frontend/src/components/insights/bento-dashboard.tsx +++ b/frontend/src/components/insights/bento-dashboard.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { IconChevronLeft, IconChevronRight, @@ -27,53 +28,68 @@ import { CommunicationCard } from "./communication-card"; const MIN_SECONDS_FOR_INSIGHTS = 3600; -// Hourly breakdown chart component +const SERIES = [ + { key: "productive", field: "ProductiveSeconds", label: "Productive", bg: "bg-emerald-500/80", dot: "bg-emerald-500", text: "text-emerald-400" }, + { key: "distractive", field: "DistractiveSeconds", label: "Distractive", bg: "bg-rose-500/80", dot: "bg-rose-500", text: "text-rose-400" }, + { key: "idle", field: "IdleSeconds", label: "Idle", bg: "bg-zinc-400/60", dot: "bg-zinc-400", text: "text-zinc-400" }, + { key: "other", field: "SupportiveSeconds", label: "Other", bg: "bg-amber-400/60", dot: "bg-amber-400", text: "text-amber-400" }, +] as const; + +type SeriesKey = (typeof SERIES)[number]["key"]; + function HourlyBreakdownChart({ hourlyData, }: { hourlyData: UsagePerHourBreakdown[]; }) { - // Fixed 1-hour scale for Y-axis + const [visible, setVisible] = useState>({ + productive: true, + distractive: true, + idle: false, + other: false, + }); + const maxMinutes = 60; + const toggle = (key: SeriesKey) => + setVisible((prev) => ({ ...prev, [key]: !prev[key] })); + + const activeSeries = SERIES.filter((s) => visible[s.key]); + return (
- {/* Y-axis labels - fixed 1 hour scale */}
1h 30m 0
- {/* Bars */}
{hourlyData.map((hour) => { - // Only use Productive + Distractive - const totalSeconds = - hour.ProductiveSeconds + - hour.DistractiveSeconds; - + const totalSeconds = activeSeries.reduce( + (sum, s) => sum + (hour[s.field] ?? 0), + 0 + ); const totalMinutes = totalSeconds / 60; const rawHeight = maxMinutes > 0 ? (totalMinutes / maxMinutes) * 100 : 0; - // Ensure a minimum height of 5% (4px out of 80px) so tiny bars don't disappear, - // matching the h-1 (4px) size of the empty slots. const height = totalMinutes > 0 ? Math.max(rawHeight, 5) : 0; - const prodPct = - totalSeconds > 0 ? (hour.ProductiveSeconds / totalSeconds) * 100 : 0; - const disPct = - totalSeconds > 0 ? (hour.DistractiveSeconds / totalSeconds) * 100 : 0; if (totalMinutes === 0) { return ( -
+
); } + // Stacking order top-to-bottom: distractive, other, idle, productive + const segments = [ + { ...SERIES[1], seconds: visible.distractive ? hour.DistractiveSeconds : 0 }, + { ...SERIES[3], seconds: visible.other ? hour.SupportiveSeconds : 0 }, + { ...SERIES[2], seconds: visible.idle ? hour.IdleSeconds : 0 }, + { ...SERIES[0], seconds: visible.productive ? hour.ProductiveSeconds : 0 }, + ].filter((seg) => seg.seconds > 0); + return ( @@ -81,35 +97,27 @@ function HourlyBreakdownChart({ className="flex-1 flex flex-col" style={{ height: `${Math.min(100, height)}%` }} > - {/* Distractive (top - red) */} - {disPct > 0 && ( -
- )} - - {/* Productive (bottom - emerald) */} - {prodPct > 0 && ( + {segments.map((seg, i) => (
- )} + ))}
-

- {hour.HourLabel} -

-

- Productive: {Math.round(hour.ProductiveSeconds / 60)}m -

- -

- Distractive: {Math.round(hour.DistractiveSeconds / 60)}m -

+

{hour.HourLabel}

+ {SERIES.map((s) => { + const val = hour[s.field] ?? 0; + if (val === 0) return null; + return ( +

+ {s.label}: {Math.round(val / 60)}m +

+ ); + })}
@@ -117,7 +125,6 @@ function HourlyBreakdownChart({ })}
- {/* Hour labels - even hours only (12am, 2am, 4am, ...) */}
{hourlyData.filter((_, i) => i % 2 === 0).map((hour) => (
@@ -125,6 +132,20 @@ function HourlyBreakdownChart({
))}
+ {/* Legend toggles */} +
+ {SERIES.map((s) => ( + + ))} +
); } @@ -335,16 +356,6 @@ export function BentoDashboard() { Activity Throughout the Day
-
- - - Productive - - - - Distractive - -
longestFocusSecs { - longestFocusSecs = currentFocusSecs - } - - // end any distraction cascade - if len(cascadeApps) > 0 { - cascades = append(cascades, LLMDistractionCascade{ - TriggerTime: time.Unix(cascadeStart, 0).Format("3:04pm"), - TriggerApp: cascadeTrigger, - CascadeApps: uniqueStrings(cascadeApps), - TotalMinutes: cascadeSecs / 60, - ReturnedToWork: time.Unix(u.StartedAt, 0).Format("3:04pm"), - }) - cascadeApps = nil - cascadeSecs = 0 - } + deep.processProductive(u.StartedAt, dur, appName) + focus.addProductive(dur) + cascade.endCascade(u.StartedAt) case ClassificationDistracting: distractiveSecs += dur appDistractSecs[appName] += dur appDistractVisit[appName]++ hourDistractSecs[startHour] += dur - - // flush deep work session if it met the threshold - if currentDeepSecs >= deepWorkThresholdSecs { - deepSessions = append(deepSessions, LLMDeepWorkSession{ - Start: time.Unix(currentDeepStart, 0).Format("3:04pm"), - End: time.Unix(u.StartedAt, 0).Format("3:04pm"), - App: currentDeepApp, - Minutes: currentDeepSecs / 60, - }) - } - currentDeepStart = 0 - currentDeepSecs = 0 - currentDeepApp = "" - - // reset focus stretch - currentFocusSecs = 0 - - // distraction cascade tracking - if len(cascadeApps) == 0 { - cascadeStart = u.StartedAt - cascadeTrigger = appName - } - cascadeApps = append(cascadeApps, appName) - cascadeSecs += dur + deep.processDistracting(u.StartedAt) + focus.reset() + cascade.addDistracting(u.StartedAt, dur, appName) } - // context switches (only between productive <-> distracting) - if prevClass != "" && u.Classification != prevClass && - (u.Classification == ClassificationProductive || u.Classification == ClassificationDistracting) && - (prevClass == ClassificationProductive || prevClass == ClassificationDistracting) { - contextSwitches++ - } - if u.Classification == ClassificationProductive || u.Classification == ClassificationDistracting { + if u.Classification.IsProductiveOrDistracting() { + if prevClass != "" && u.Classification != prevClass { + contextSwitches++ + } prevClass = u.Classification } } - // flush final deep work session - if currentDeepSecs >= deepWorkThresholdSecs { - lastEnd := time.Now() - if len(usages) > 0 { - last := usages[len(usages)-1] - e := resolveEndTime(last, usages, len(usages)-1) - if e > 0 { - lastEnd = time.Unix(e, 0) - } + // Flush trackers for any in-progress sessions at end of day + lastEnd := time.Now() + if len(usages) > 0 { + last := usages[len(usages)-1] + if e := resolveEndTime(last, usages, len(usages)-1); e > 0 { + lastEnd = time.Unix(e, 0) } - deepSessions = append(deepSessions, LLMDeepWorkSession{ - Start: time.Unix(currentDeepStart, 0).Format("3:04pm"), - End: lastEnd.Format("3:04pm"), - App: currentDeepApp, - Minutes: currentDeepSecs / 60, - }) - } - - // flush final cascade - if len(cascadeApps) > 1 { - cascades = append(cascades, LLMDistractionCascade{ - TriggerTime: time.Unix(cascadeStart, 0).Format("3:04pm"), - TriggerApp: cascadeTrigger, - CascadeApps: uniqueStrings(cascadeApps), - TotalMinutes: cascadeSecs / 60, - }) - } - - deepWorkTotal := 0 - for _, ds := range deepSessions { - deepWorkTotal += ds.Minutes - } - - input.TotalProductiveMinutes = productiveSecs / 60 - input.TotalDistractiveMinutes = distractiveSecs / 60 - input.FocusScore = calculateProductivityScore(productiveSecs, distractiveSecs) - input.ContextSwitchCount = contextSwitches - input.LongestFocusStretchMin = longestFocusSecs / 60 - input.DeepWorkSessions = deepSessions - input.DeepWorkTotalMinutes = deepWorkTotal - input.DistractionCascades = cascades - input.TopDistractions = topApps(appDistractSecs, appDistractVisit, 5) - input.TopProductiveApps = topApps(appProductiveSecs, appProductiveVisit, 5) - input.MostProductiveHours = peakHour(hourProductiveSecs) - input.MostDistractiveHours = peakHour(hourDistractSecs) - - // blocked attempts + } + deep.flush(lastEnd) + cascade.flush() + + input := LLMDaySummaryInput{ + Date: date.Format("2006-01-02"), + TotalProductiveMinutes: productiveSecs / 60, + TotalDistractiveMinutes: distractiveSecs / 60, + FocusScore: calculateProductivityScore(productiveSecs, distractiveSecs), + ContextSwitchCount: contextSwitches, + LongestFocusStretchMin: focus.longestMinutes(), + DeepWorkSessions: deep.sessions, + DeepWorkTotalMinutes: deep.totalMinutes(), + DistractionCascades: cascade.cascades, + TopDistractions: topApps(appDistractSecs, appDistractVisit, 5), + TopProductiveApps: topApps(appProductiveSecs, appProductiveVisit, 5), + MostProductiveHours: peakHour(hourProductiveSecs), + MostDistractiveHours: peakHour(hourDistractSecs), + } + + s.enrichWithDBStats(&input, date) + + return input, nil +} + +func (s *Service) enrichWithDBStats(input *LLMDaySummaryInput, date time.Time) { blockMode := TerminationModeBlock blockedUsages, err := s.GetUsageList(GetUsageListOptions{Date: &date, TerminationMode: &blockMode}) if err == nil { input.BlockedAttemptCount = len(blockedUsages) } - // protection pauses dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.Local).Unix() dayEnd := dayStart + 86400 var pauseCount int64 s.db.Model(&ProtectionPause{}).Where("created_at >= ? AND created_at < ?", dayStart, dayEnd).Count(&pauseCount) input.ProtectionPauseCount = int(pauseCount) - // 7-day trend input.AvgFocusScoreLast7Days, input.FocusScoreTrend = s.computeFocusTrend(date) - - return input, nil } func (s *Service) generateLLMDailySummary(ctx context.Context, input LLMDaySummaryInput) (LLMDailySummary, error) { - if s.genaiClient == nil { - return LLMDailySummary{}, errors.New("genai client not configured") - } - inputJSON, err := json.Marshal(input) if err != nil { return LLMDailySummary{}, fmt.Errorf("failed to marshal input: %w", err) } + text, err := s.generateDailySummaryWithGemini(ctx, llmDailySummaryPrompt, string(inputJSON)) + if err != nil { + return LLMDailySummary{}, err + } + + var parsed llmDailySummaryResponse + if err := json.Unmarshal([]byte(text), &parsed); err != nil { + return LLMDailySummary{}, fmt.Errorf("failed to parse LLM response: %w", err) + } + + winsJSON, _ := json.Marshal(parsed.Wins) + + return LLMDailySummary{ + Headline: parsed.Headline, + Narrative: parsed.Narrative, + KeyPattern: parsed.KeyPattern, + Wins: string(winsJSON), + Suggestion: parsed.Suggestion, + DayVibe: parsed.DayVibe, + }, nil +} + +func (s *Service) generateDailySummaryWithGemini(ctx context.Context, systemPrompt, input string) (string, error) { + if s.genaiClient == nil { + return "", errors.New("genai client not configured") + } + // Use a more capable model for summaries since this runs once per day models := map[apiv1.DeviceHandshakeResponse_AccountTier]string{ apiv1.DeviceHandshakeResponse_ACCOUNT_TIER_UNSPECIFIED: "gemini-2.5-flash", @@ -338,42 +297,28 @@ func (s *Service) generateLLMDailySummary(ctx context.Context, input LLMDaySumma { Role: "user", Parts: []*genai.Part{ - genai.NewPartFromText(string(inputJSON)), + genai.NewPartFromText(input), }, }, }, &genai.GenerateContentConfig{ SystemInstruction: &genai.Content{ Parts: []*genai.Part{ - genai.NewPartFromText(llmDailySummaryPrompt), + genai.NewPartFromText(systemPrompt), }, }, }) if err != nil { - return LLMDailySummary{}, fmt.Errorf("gemini call failed: %w", err) + return "", fmt.Errorf("gemini call failed: %w", err) } if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { - return LLMDailySummary{}, errors.New("empty response from Gemini") + return "", errors.New("empty response from Gemini") } text := resp.Candidates[0].Content.Parts[0].Text text = strings.NewReplacer("```json", "", "`", "").Replace(text) - var parsed llmDailySummaryResponse - if err := json.Unmarshal([]byte(text), &parsed); err != nil { - return LLMDailySummary{}, fmt.Errorf("failed to parse LLM response: %w", err) - } - - winsJSON, _ := json.Marshal(parsed.Wins) - - return LLMDailySummary{ - Headline: parsed.Headline, - Narrative: parsed.Narrative, - KeyPattern: parsed.KeyPattern, - Wins: string(winsJSON), - Suggestion: parsed.Suggestion, - DayVibe: parsed.DayVibe, - }, nil + return text, nil } // computeFocusTrend looks at the last 7 days and returns the average score + trend direction. diff --git a/internal/usage/insights_report.go b/internal/usage/insights_report.go index 3d07227..c5d3fec 100644 --- a/internal/usage/insights_report.go +++ b/internal/usage/insights_report.go @@ -27,11 +27,12 @@ func (s *Service) GetDayInsights(date time.Time) (DayInsights, error) { } dur := int(end - usage.StartedAt) - score.addSeconds(usage.Classification, dur) + isIdle := usage.Application.Name == IdleApplicationName + score.addSeconds(usage.Classification, dur, isIdle) for hour, secs := range splitSecondsPerHour(usage.StartedAt, end) { entry := hourly[hour] - entry.addSeconds(usage.Classification, secs) + entry.addSeconds(usage.Classification, secs, isIdle) hourly[hour] = entry } } diff --git a/internal/usage/types_daily_summary.go b/internal/usage/types_daily_summary.go index b4ea69f..19f2812 100644 --- a/internal/usage/types_daily_summary.go +++ b/internal/usage/types_daily_summary.go @@ -1,5 +1,7 @@ package usage +import "time" + // LLMDailySummary is the persisted daily summary generated by the LLM. type LLMDailySummary struct { ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` @@ -45,6 +47,11 @@ type LLMDaySummaryInput struct { FocusScoreTrend string `json:"focus_score_trend"` } +func (i LLMDaySummaryInput) hasMinimumData() bool { + totalTrackedSecs := (i.TotalProductiveMinutes + i.TotalDistractiveMinutes) * 60 + return totalTrackedSecs >= minSecondsForSummary +} + type LLMDeepWorkSession struct { Start string `json:"start"` End string `json:"end"` @@ -75,3 +82,117 @@ type llmDailySummaryResponse struct { Suggestion string `json:"suggestion"` DayVibe string `json:"day_vibe"` } + +// deepWorkTracker tracks consecutive productive blocks and emits sessions >= 25min. +type deepWorkTracker struct { + currentStart int64 + currentSecs int + currentApp string + sessions []LLMDeepWorkSession +} + +func (t *deepWorkTracker) processProductive(startedAt int64, dur int, appName string) { + if t.currentStart == 0 { + t.currentStart = startedAt + t.currentApp = appName + } + t.currentSecs += dur +} + +func (t *deepWorkTracker) processDistracting(startedAt int64) { + if t.currentSecs >= deepWorkThresholdSecs { + t.sessions = append(t.sessions, LLMDeepWorkSession{ + Start: time.Unix(t.currentStart, 0).Format("3:04pm"), + End: time.Unix(startedAt, 0).Format("3:04pm"), + App: t.currentApp, + Minutes: t.currentSecs / 60, + }) + } + t.currentStart = 0 + t.currentSecs = 0 + t.currentApp = "" +} + +func (t *deepWorkTracker) flush(lastEnd time.Time) { + if t.currentSecs >= deepWorkThresholdSecs { + t.sessions = append(t.sessions, LLMDeepWorkSession{ + Start: time.Unix(t.currentStart, 0).Format("3:04pm"), + End: lastEnd.Format("3:04pm"), + App: t.currentApp, + Minutes: t.currentSecs / 60, + }) + } +} + +func (t *deepWorkTracker) totalMinutes() int { + total := 0 + for _, s := range t.sessions { + total += s.Minutes + } + return total +} + +// focusStretchTracker tracks the longest uninterrupted productive run. +type focusStretchTracker struct { + currentSecs int + longestSecs int +} + +func (t *focusStretchTracker) addProductive(dur int) { + t.currentSecs += dur + if t.currentSecs > t.longestSecs { + t.longestSecs = t.currentSecs + } +} + +func (t *focusStretchTracker) reset() { + t.currentSecs = 0 +} + +func (t *focusStretchTracker) longestMinutes() int { + return t.longestSecs / 60 +} + +// cascadeTracker tracks sequences of consecutive distracting apps. +type cascadeTracker struct { + start int64 + apps []string + secs int + trigger string + cascades []LLMDistractionCascade +} + +func (t *cascadeTracker) addDistracting(startedAt int64, dur int, appName string) { + if len(t.apps) == 0 { + t.start = startedAt + t.trigger = appName + } + t.apps = append(t.apps, appName) + t.secs += dur +} + +func (t *cascadeTracker) endCascade(returnedAt int64) { + if len(t.apps) == 0 { + return + } + t.cascades = append(t.cascades, LLMDistractionCascade{ + TriggerTime: time.Unix(t.start, 0).Format("3:04pm"), + TriggerApp: t.trigger, + CascadeApps: uniqueStrings(t.apps), + TotalMinutes: t.secs / 60, + ReturnedToWork: time.Unix(returnedAt, 0).Format("3:04pm"), + }) + t.apps = nil + t.secs = 0 +} + +func (t *cascadeTracker) flush() { + if len(t.apps) > 1 { + t.cascades = append(t.cascades, LLMDistractionCascade{ + TriggerTime: time.Unix(t.start, 0).Format("3:04pm"), + TriggerApp: t.trigger, + CascadeApps: uniqueStrings(t.apps), + TotalMinutes: t.secs / 60, + }) + } +} diff --git a/internal/usage/types_insights.go b/internal/usage/types_insights.go index 16a2301..5b0d57a 100644 --- a/internal/usage/types_insights.go +++ b/internal/usage/types_insights.go @@ -33,11 +33,16 @@ type CommunicationBreakdown struct { type ProductivityScore struct { ProductiveSeconds int DistractiveSeconds int + IdleSeconds int OtherSeconds int ProductivityScore int } -func (p *ProductivityScore) addSeconds(classification Classification, seconds int) { +func (p *ProductivityScore) addSeconds(classification Classification, seconds int, isIdle bool) { + if isIdle { + p.IdleSeconds += seconds + return + } switch classification { case ClassificationProductive: p.ProductiveSeconds += seconds diff --git a/internal/usage/types_usage.go b/internal/usage/types_usage.go index 9898f21..517ad23 100644 --- a/internal/usage/types_usage.go +++ b/internal/usage/types_usage.go @@ -32,6 +32,10 @@ const ( ClassificationSystem Classification = "system" ) +func (c Classification) IsProductiveOrDistracting() bool { + return c == ClassificationProductive || c == ClassificationDistracting +} + type TerminationDecision struct { Mode TerminationMode Reasoning string