diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js index 9788106..444cb31 100644 --- a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js +++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js @@ -12,13 +12,15 @@ import * as usage$0 from "../../../../focusd-so/focusd/internal/usage/models.js" function configure() { Object.freeze(Object.assign($Create.Events, { - "protection:status": $$createType0, - "usage:update": $$createType1, + "daily-summary:ready": $$createType0, + "protection:status": $$createType1, + "usage:update": $$createType2, })); } // Private type creation functions -const $$createType0 = usage$0.ProtectionPause.createFrom; -const $$createType1 = usage$0.ApplicationUsage.createFrom; +const $$createType0 = usage$0.LLMDailySummary.createFrom; +const $$createType1 = usage$0.ProtectionPause.createFrom; +const $$createType2 = usage$0.ApplicationUsage.createFrom; configure(); diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts index 8f0949d..83e18a2 100644 --- a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts +++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts @@ -13,6 +13,7 @@ declare module "@wailsio/runtime" { namespace Events { interface CustomEvents { "authctx:updated": any; + "daily-summary:ready": usage$0.LLMDailySummary; "protection:status": usage$0.ProtectionPause; "usage:update": usage$0.ApplicationUsage; } diff --git a/frontend/src/components/insights/ai-insight-card.tsx b/frontend/src/components/insights/ai-insight-card.tsx index bf15ed2..e7ad7a5 100644 --- a/frontend/src/components/insights/ai-insight-card.tsx +++ b/frontend/src/components/insights/ai-insight-card.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from "react"; -import { IconSparkles, IconChevronDown, IconChevronUp, IconBulb, IconTrophy } 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 { @@ -7,14 +7,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -// DailyUsageSummary defined locally - insights service was removed from backend -interface DailyUsageSummary { - headline: string; - summary: string; - suggestion: string; - day_vibe: string; - wins: string; -} +import type { DailyUsageSummary } from "@/stores/usage-store"; interface LLMInsightCardProps { dailyUsageSummary: DailyUsageSummary; @@ -34,8 +27,9 @@ export function LLMInsightCard({ dailyUsageSummary, isYesterday = false }: LLMIn } }, [dailyUsageSummary?.wins]); - const headline = dailyUsageSummary?.headline || "Daily LLM Insight"; - const mainSummary = dailyUsageSummary?.summary || ""; + const headline = dailyUsageSummary?.headline || "Daily Insight"; + const narrative = dailyUsageSummary?.narrative || ""; + const keyPattern = dailyUsageSummary?.key_pattern || ""; const suggestion = dailyUsageSummary?.suggestion || ""; const dayVibe = dailyUsageSummary?.day_vibe || ""; @@ -69,21 +63,55 @@ export function LLMInsightCard({ dailyUsageSummary, isYesterday = false }: LLMIn - {/* Main Summary */}

- {mainSummary} + {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 + + )} +
+
+ {/* Key Pattern */} + {keyPattern && ( +
+

+ + Key Pattern +

+

+ {keyPattern} +

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

- Today's Wins + Wins

    {wins.map((win, i) => ( diff --git a/frontend/src/components/insights/bento-dashboard.tsx b/frontend/src/components/insights/bento-dashboard.tsx index d80c734..21bc38d 100644 --- a/frontend/src/components/insights/bento-dashboard.tsx +++ b/frontend/src/components/insights/bento-dashboard.tsx @@ -16,7 +16,6 @@ import { import { formatMinutes, formatDate, - getDataForDate, } from "@/lib/mock-data"; import { useUsageStore, isToday } from "@/stores/usage-store"; import type { UsagePerHourBreakdown } from "@/stores/usage-store"; @@ -176,9 +175,6 @@ export function BentoDashboard() { (item): item is UsagePerHourBreakdown => item !== null ); - // Mock data for new cards (until backend supports these) - const mockDayData = getDataForDate(selectedDate); - const canGoNext = !isToday(selectedDate); // Show loading overlay if data is loading @@ -199,7 +195,7 @@ export function BentoDashboard() { } return ( -
    +
    {/* Date Picker Header */}
    @@ -328,7 +324,7 @@ export function BentoDashboard() {
    - {/* Row 2: Full-width Hourly Breakdown with small blocked badge */} + {/* Row 2: Full-width Hourly Breakdown */}
    @@ -341,12 +337,18 @@ export function BentoDashboard() { Productive - Distractive
    + + + History +
    @@ -355,40 +357,17 @@ export function BentoDashboard() { - {/* Row 3: Top Blocked + More Insights placeholder */} -
    -
    - -
    -
    - -
    -
    -

    Deep Dive

    - -
    -

    Detailed History

    -

    View your full activity feed and usage aggregations.

    -
    - -
    + {/* Row 3: Time Lost To + Blocked Today */} +
    + +
    - {/* Row 4: Top Distractions + Categories/Projects */} -
    -
    - -
    -
    - -
    + {/* Row 4: Projects + Communication */} +
    + +
    - - {/* Row 5: Communication Channels */} -
    ); } \ No newline at end of file diff --git a/frontend/src/components/insights/categories-card.tsx b/frontend/src/components/insights/categories-card.tsx index 83c8123..f11693a 100644 --- a/frontend/src/components/insights/categories-card.tsx +++ b/frontend/src/components/insights/categories-card.tsx @@ -1,12 +1,13 @@ -import { IconFolder } from "@tabler/icons-react"; +import { IconFolder, IconArrowRight } from "@tabler/icons-react"; +import { Link } from "@tanstack/react-router"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { formatMinutes, type ProjectStats } from "@/lib/mock-data"; +import { formatMinutes } from "@/lib/mock-data"; +import type { ProjectBreakdown } from "@/../bindings/github.com/focusd-so/focusd/internal/usage/models"; interface CategoriesCardProps { - projects: ProjectStats[]; + projects: ProjectBreakdown[]; } -// Color palette for projects const projectColors = [ "bg-emerald-500", "bg-blue-500", @@ -17,11 +18,10 @@ const projectColors = [ ]; export function CategoriesCard({ projects }: CategoriesCardProps) { - const totalMinutes = projects.reduce((sum, p) => sum + p.totalMinutes, 0); - const maxMinutes = Math.max(...projects.map((p) => p.totalMinutes), 1); + const totalMinutes = projects.reduce((sum, p) => sum + p.minutes, 0); + const maxMinutes = Math.max(...projects.map((p) => p.minutes), 1); - // Show top 5 projects - const topProjects = projects.slice(0, 5); + const topProjects = projects.slice(0, 3); return ( @@ -31,22 +31,26 @@ export function CategoriesCard({ projects }: CategoriesCardProps) { Projects - + {formatMinutes(totalMinutes)} total - + +
    - + {topProjects.length === 0 ? (

    No project activity

    ) : ( topProjects.map((project, index) => { - const widthPct = (project.totalMinutes / maxMinutes) * 100; + const widthPct = (project.minutes / maxMinutes) * 100; const colorClass = projectColors[index % projectColors.length]; return ( -
    +
    - {formatMinutes(project.totalMinutes)} + {formatMinutes(project.minutes)}
    diff --git a/frontend/src/components/insights/communication-card.tsx b/frontend/src/components/insights/communication-card.tsx index 0c7aef8..ce8c4cf 100644 --- a/frontend/src/components/insights/communication-card.tsx +++ b/frontend/src/components/insights/communication-card.tsx @@ -1,20 +1,13 @@ -import { IconMessages } from "@tabler/icons-react"; +import { IconMessages, IconArrowRight } from "@tabler/icons-react"; +import { Link } from "@tanstack/react-router"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { formatMinutes, type CommunicationChannel } from "@/lib/mock-data"; +import { formatMinutes } from "@/lib/mock-data"; +import type { CommunicationBreakdown } from "@/../bindings/github.com/focusd-so/focusd/internal/usage/models"; interface CommunicationCardProps { - channels: CommunicationChannel[]; + channels: CommunicationBreakdown[]; } -// Background colors for each channel -const channelColors: Record = { - Slack: "bg-purple-500/20 border-purple-500/30", - Email: "bg-blue-500/20 border-blue-500/30", - Zoom: "bg-sky-500/20 border-sky-500/30", - Discord: "bg-indigo-500/20 border-indigo-500/30", - Teams: "bg-violet-500/20 border-violet-500/30", -}; - const channelTextColors: Record = { Slack: "text-purple-400", Email: "text-blue-400", @@ -23,6 +16,14 @@ const channelTextColors: Record = { Teams: "text-violet-400", }; +const channelBarColors: Record = { + Slack: "bg-purple-500/60", + Email: "bg-blue-500/60", + Zoom: "bg-sky-500/60", + Discord: "bg-indigo-500/60", + Teams: "bg-violet-500/60", +}; + export function CommunicationCard({ channels }: CommunicationCardProps) { const totalMinutes = channels.reduce((sum, c) => sum + c.minutes, 0); const maxMinutes = Math.max(...channels.map((c) => c.minutes), 1); @@ -35,41 +36,43 @@ export function CommunicationCard({ channels }: CommunicationCardProps) { Communication - + {formatMinutes(totalMinutes)} total - + +
    - -
    - {channels.map((channel) => { - const bgColor = channelColors[channel.name] || "bg-muted/20 border-muted/30"; + + {channels.length === 0 ? ( +

    + No communication activity +

    + ) : ( + channels.slice(0, 3).map((channel, index) => { const textColor = channelTextColors[channel.name] || "text-muted-foreground"; - const heightPct = Math.max(20, (channel.minutes / maxMinutes) * 100); + const widthPct = (channel.minutes / maxMinutes) * 100; return ( -
    - {/* Visual bar indicator */} -
    - - {/* Content */} -
    - {channel.icon} -

    - {channel.name} -

    -

    +

    +
    + {channel.name} + {formatMinutes(channel.minutes)} -

    +
    +
    +
    +
    ); - })} -
    + }) + )} ); diff --git a/frontend/src/components/insights/top-blocked-card.tsx b/frontend/src/components/insights/top-blocked-card.tsx index 7c60088..116dcb9 100644 --- a/frontend/src/components/insights/top-blocked-card.tsx +++ b/frontend/src/components/insights/top-blocked-card.tsx @@ -1,16 +1,16 @@ -import { IconShield } from "@tabler/icons-react"; +import { IconShield, IconArrowRight } from "@tabler/icons-react"; +import { Link } from "@tanstack/react-router"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { BlockedAttempt } from "@/lib/mock-data"; +import type { BlockedBreakdown } from "@/../bindings/github.com/focusd-so/focusd/internal/usage/models"; interface TopBlockedCardProps { - blockedAttempts: BlockedAttempt[]; + blockedAttempts: BlockedBreakdown[]; } export function TopBlockedCard({ blockedAttempts }: TopBlockedCardProps) { const totalBlocked = blockedAttempts.reduce((sum, b) => sum + b.count, 0); const maxCount = Math.max(...blockedAttempts.map((b) => b.count), 1); - // Show top 5 const topBlocked = blockedAttempts.slice(0, 5); return ( @@ -21,23 +21,29 @@ export function TopBlockedCard({ blockedAttempts }: TopBlockedCardProps) { Blocked Today - + {totalBlocked} total - + +
    {topBlocked.length === 0 ? ( -

    - No blocked attempts today -

    +
    + +

    No blocked attempts today

    +

    Your blocklist is ready to protect your focus

    +
    ) : ( - topBlocked.map((attempt) => { + topBlocked.map((attempt, index) => { const widthPct = (attempt.count / maxCount) * 100; return ( -
    +
    - {attempt.hostname} + {attempt.name} {attempt.count}x diff --git a/frontend/src/components/insights/top-distractions-card.tsx b/frontend/src/components/insights/top-distractions-card.tsx index 0d779de..3f66f67 100644 --- a/frontend/src/components/insights/top-distractions-card.tsx +++ b/frontend/src/components/insights/top-distractions-card.tsx @@ -1,16 +1,17 @@ -import { IconAlertTriangle } from "@tabler/icons-react"; +import { IconAlertTriangle, IconArrowRight } from "@tabler/icons-react"; +import { Link } from "@tanstack/react-router"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { formatMinutes, type DistractionItem } from "@/lib/mock-data"; +import { formatMinutes } from "@/lib/mock-data"; +import type { DistractionBreakdown } from "@/../bindings/github.com/focusd-so/focusd/internal/usage/models"; interface TopDistractionsCardProps { - distractions: DistractionItem[]; + distractions: DistractionBreakdown[]; } export function TopDistractionsCard({ distractions }: TopDistractionsCardProps) { const totalMinutes = distractions.reduce((sum, d) => sum + d.minutes, 0); const maxMinutes = Math.max(...distractions.map((d) => d.minutes), 1); - // Show top 5 const topDistractions = distractions.slice(0, 5); return ( @@ -21,29 +22,29 @@ export function TopDistractionsCard({ distractions }: TopDistractionsCardProps) Time Lost To - + {formatMinutes(totalMinutes)} - + +
    {topDistractions.length === 0 ? ( -

    - No distractions recorded -

    +
    + +

    No distractions recorded

    +

    Stay focused and keep it that way

    +
    ) : ( topDistractions.map((distraction, index) => { const widthPct = (distraction.minutes / maxMinutes) * 100; return ( -
    +
    - - {index + 1}. - {distraction.name} - - {distraction.category} - - + {distraction.name} {formatMinutes(distraction.minutes)} diff --git a/frontend/src/components/settings/general-settings.tsx b/frontend/src/components/settings/general-settings.tsx index eaddec1..cc39de6 100644 --- a/frontend/src/components/settings/general-settings.tsx +++ b/frontend/src/components/settings/general-settings.tsx @@ -1,95 +1,13 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { useSettingsStore } from "@/stores/settings-store"; -import { SettingsKey } from "../../../bindings/github.com/focusd-so/focusd/internal/settings/models"; +import { IconSparkles } from "@tabler/icons-react"; export function GeneralSettings() { - const { idleThreshold, historyRetention, distractionAllowance, updateSetting } = useSettingsStore(); - return ( -
    - - - Preferences - - General application preferences. - - - - -
    -
    - -
    - How long before Focusd considers you away from your computer. -
    -
    - -
    - -
    -
    - -
    - How long to keep your Focusd usage history. -
    -
    - -
    - -
    -
    - -
    - Maximum time allowed on distracting apps/sites per day. -
    -
    - -
    - -
    -
    +
    + +

    Coming soon

    +

    + General preferences will be available in a future update. +

    ); } diff --git a/frontend/src/stores/usage-store.ts b/frontend/src/stores/usage-store.ts index cf5d135..c20b2dc 100644 --- a/frontend/src/stores/usage-store.ts +++ b/frontend/src/stores/usage-store.ts @@ -7,6 +7,10 @@ import type { DayInsights, ProductivityScore, UsageAggregation, + DistractionBreakdown, + BlockedBreakdown, + ProjectBreakdown, + CommunicationBreakdown, } from "../../bindings/github.com/focusd-so/focusd/internal/usage/models"; import { TerminationMode, GetUsageListOptions } from "../../bindings/github.com/focusd-so/focusd/internal/usage/models"; import { @@ -42,18 +46,27 @@ export interface UsagePerHourBreakdown { SupportiveSeconds: number; } -interface DailyUsageSummary { +export interface DailyUsageSummary { headline: string; - summary: string; + narrative: string; + key_pattern: string; suggestion: string; day_vibe: string; wins: string; + context_switch_count: number; + longest_focus_minutes: number; + deep_work_minutes: number; + blocked_attempt_count: number; } export interface DailyOverview { UsageOverview: UsageOverview | null; UsagePerHourBreakdown: UsagePerHourBreakdown[] | null; DailyUsageSummary: DailyUsageSummary | null; + TopDistractions: DistractionBreakdown[]; + TopBlocked: BlockedBreakdown[]; + ProjectBreakdown: ProjectBreakdown[]; + CommunicationBreakdown: CommunicationBreakdown[]; } // ── Helpers ───────────────────────────────────────────────────────────────── @@ -137,7 +150,27 @@ const mapDayInsightsToOverview = ( SupportiveSeconds: usageOverviewScore.OtherSeconds, }, UsagePerHourBreakdown: usagePerHourBreakdown, - DailyUsageSummary: null, + DailyUsageSummary: mapLLMDailySummary(insights.LLMDailySummary), + TopDistractions: insights.TopDistractions ?? [], + TopBlocked: insights.TopBlocked ?? [], + ProjectBreakdown: insights.ProjectBreakdown ?? [], + CommunicationBreakdown: insights.CommunicationBreakdown ?? [], + }; +}; + +const mapLLMDailySummary = (summary: any): DailyUsageSummary | null => { + if (!summary || !summary.Headline) return null; + return { + headline: summary.Headline ?? "", + narrative: summary.Narrative ?? "", + key_pattern: summary.KeyPattern ?? "", + suggestion: summary.Suggestion ?? "", + day_vibe: summary.DayVibe ?? "", + wins: summary.Wins ?? "[]", + context_switch_count: summary.ContextSwitchCount ?? 0, + longest_focus_minutes: summary.LongestFocusMinutes ?? 0, + deep_work_minutes: summary.DeepWorkMinutes ?? 0, + blocked_attempt_count: summary.BlockedAttemptCount ?? 0, }; }; diff --git a/internal/usage/classifier_obviously.go b/internal/usage/classifier_obviously.go index 1a6f555..f7684e5 100644 --- a/internal/usage/classifier_obviously.go +++ b/internal/usage/classifier_obviously.go @@ -285,6 +285,7 @@ var exactBlockOnlyCategory = hostnameCategory{ hostnames: []string{ "x.com", "linkedin.com", + "reddit.com", }, } @@ -301,7 +302,6 @@ var ambiguousHostnameCategory = hostnameCategory{ hostnames: []string{ "youtube.com", "youtu.be", - "reddit.com", "pinterest.com", "medium.com", "news.ycombinator.com", @@ -321,6 +321,7 @@ var alwaysProductiveCategories = []hostnameCategory{ tags: []string{"development", "programming", "productive"}, hostnames: []string{ "github.com", + "cursor.com", "gitlab.com", "bitbucket.org", "stackoverflow.com", @@ -393,6 +394,8 @@ var alwaysProductiveCategories = []hostnameCategory{ reasoning: "Productivity tool - work-related activity", tags: []string{"productivity", "work", "tools"}, hostnames: []string{ + // Anti-embarrassment, imgine self-blocking + "focusd.so", // Google Workspace "drive.google.com", "docs.google.com", diff --git a/internal/usage/insights_daily_summary.go b/internal/usage/insights_daily_summary.go new file mode 100644 index 0000000..c509001 --- /dev/null +++ b/internal/usage/insights_daily_summary.go @@ -0,0 +1,501 @@ +package usage + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "sort" + "strings" + "time" + + apiv1 "github.com/focusd-so/focusd/gen/api/v1" + "github.com/focusd-so/focusd/internal/identity" + + "google.golang.org/genai" + "gorm.io/gorm" +) + +const ( + minSecondsForSummary = 3600 // 1 hour of tracked activity required + deepWorkThresholdSecs = 25 * 60 + summaryGenerationHour = 9 +) + +const llmDailySummaryPrompt = `You are a personal productivity coach analyzing a user's computer usage for one day. +Your job is to write a brief, insightful summary that helps the user understand +behavioral patterns and improve their focus habits. + +RULES: +- Be conversational and warm, like a supportive coach -- not a report generator +- Lead with wins before addressing problems +- NEVER restate numbers in prose ("you spent 2h on VS Code") -- add insight the numbers alone can't convey +- Focus on cause-and-effect chains and behavioral patterns +- The narrative must be 3-4 sentences max +- The suggestion must be specific and actionable, referencing actual apps, times, or patterns from this day -- never generic ("try to focus more") +- key_pattern should surface something the user likely didn't notice themselves + +HARD_RULES: +- Don't invent wins or key patterns that aren't present in the data +- Accuracy expontentially important than speed +- Avoid generic suggestions and day vibes, only valuable insights that are specific to the user's data + +OUTPUT (strict JSON, no markdown fences): +{ + "headline": "5-8 word punchy summary", + "narrative": "3-4 sentence story of the day", + "key_pattern": "single most important behavioral pattern", + "wins": ["1-3 specific wins"], + "suggestion": "one actionable suggestion referencing today's data", + "day_vibe": "locked-in | productive | balanced | scattered | recovering | rough" +}` + +// GenerateLLMDailySummaryIfNeeded checks if it's time to generate yesterday's summary +// and produces one if it doesn't already exist. +func (s *Service) GenerateLLMDailySummaryIfNeeded(ctx context.Context) error { + if s.genaiClient == nil { + return nil + } + + now := time.Now() + if now.Hour() < summaryGenerationHour { + return nil + } + + yesterday := now.AddDate(0, 0, -1) + dateStr := yesterday.Format("2006-01-02") + + var existing LLMDailySummary + if err := s.db.Where("date = ?", dateStr).First(&existing).Error; err == nil { + return nil // already generated + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("failed to check existing summary: %w", err) + } + + input, err := s.computeLLMDaySummaryInput(yesterday) + if err != nil { + return fmt.Errorf("failed to compute summary input: %w", err) + } + + totalTracked := (input.TotalProductiveMinutes + input.TotalDistractiveMinutes) * 60 + if totalTracked < minSecondsForSummary { + slog.Info("not enough data for daily summary", "date", dateStr, "tracked_minutes", input.TotalProductiveMinutes+input.TotalDistractiveMinutes) + return nil + } + + summary, err := s.generateLLMDailySummary(ctx, input) + if err != nil { + return fmt.Errorf("failed to generate LLM summary: %w", err) + } + + summary.Date = dateStr + summary.ContextSwitchCount = input.ContextSwitchCount + summary.LongestFocusMinutes = input.LongestFocusStretchMin + summary.DeepWorkMinutes = input.DeepWorkTotalMinutes + summary.BlockedAttemptCount = input.BlockedAttemptCount + summary.CreatedAt = time.Now().Unix() + + if err := s.db.Create(&summary).Error; err != nil { + return fmt.Errorf("failed to save daily summary: %w", err) + } + + slog.Info("daily summary generated", "date", dateStr, "headline", summary.Headline) + + if s.onLLMDailySummaryReady != nil { + s.onLLMDailySummaryReady(summary) + } + + return nil +} + +func (s *Service) computeLLMDaySummaryInput(date time.Time) (LLMDaySummaryInput, error) { + usages, err := s.GetUsageList(GetUsageListOptions{Date: &date}) + if err != nil { + return LLMDaySummaryInput{}, fmt.Errorf("failed to get usage list: %w", err) + } + + // GetUsageList returns DESC order, we need ASC for chronological walking + sort.Slice(usages, func(i, j int) bool { + return usages[i].StartedAt < usages[j].StartedAt + }) + + input := LLMDaySummaryInput{ + Date: date.Format("2006-01-02"), + } + + var ( + productiveSecs int + distractiveSecs int + contextSwitches int + prevClass Classification + + // deep work tracking + currentDeepStart int64 + currentDeepSecs int + currentDeepApp string + deepSessions []LLMDeepWorkSession + + // longest focus stretch + currentFocusSecs int + longestFocusSecs int + + // distraction cascade tracking + cascadeStart int64 + cascadeApps []string + cascadeSecs int + cascadeTrigger string + cascades []LLMDistractionCascade + + // per-app aggregation + appProductiveSecs = make(map[string]int) + appProductiveVisit = make(map[string]int) + appDistractSecs = make(map[string]int) + appDistractVisit = make(map[string]int) + + // per-hour aggregation + hourProductiveSecs = make(map[int]int) + hourDistractSecs = make(map[int]int) + ) + + for i, u := range usages { + end := resolveEndTime(u, usages, i) + if end <= u.StartedAt { + continue + } + dur := int(end - u.StartedAt) + appName := u.Application.Name + + startHour := time.Unix(u.StartedAt, 0).Hour() + + switch u.Classification { + case ClassificationProductive: + productiveSecs += dur + appProductiveSecs[appName] += dur + appProductiveVisit[appName]++ + hourProductiveSecs[startHour] += dur + + // deep work tracking: extend or start + if currentDeepStart == 0 { + currentDeepStart = u.StartedAt + currentDeepApp = appName + } + currentDeepSecs += dur + + // focus stretch + currentFocusSecs += dur + if currentFocusSecs > 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 + } + + 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 + } + + // 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 { + 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) + } + } + 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 + 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) + } + + // 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", + apiv1.DeviceHandshakeResponse_ACCOUNT_TIER_FREE: "gemini-2.5-flash", + apiv1.DeviceHandshakeResponse_ACCOUNT_TIER_TRIAL: "gemini-2.5-flash", + apiv1.DeviceHandshakeResponse_ACCOUNT_TIER_PLUS: "gemini-2.5-flash", + apiv1.DeviceHandshakeResponse_ACCOUNT_TIER_PRO: "gemini-2.5-flash", + } + + tier := identity.GetAccountTier() + model, ok := models[tier] + if !ok { + model = "gemini-2.5-flash" + } + + resp, err := s.genaiClient.Models.GenerateContent(ctx, model, []*genai.Content{ + { + Role: "user", + Parts: []*genai.Part{ + genai.NewPartFromText(string(inputJSON)), + }, + }, + }, &genai.GenerateContentConfig{ + SystemInstruction: &genai.Content{ + Parts: []*genai.Part{ + genai.NewPartFromText(llmDailySummaryPrompt), + }, + }, + }) + if err != nil { + return LLMDailySummary{}, 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") + } + + 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 +} + +// computeFocusTrend looks at the last 7 days and returns the average score + trend direction. +func (s *Service) computeFocusTrend(referenceDate time.Time) (avgScore int, trend string) { + var scores []int + for i := 1; i <= 7; i++ { + d := referenceDate.AddDate(0, 0, -i) + insights, err := s.GetDayInsights(d) + if err != nil { + continue + } + if insights.ProductivityScore.ProductiveSeconds+insights.ProductivityScore.DistractiveSeconds < minSecondsForSummary { + continue + } + scores = append(scores, insights.ProductivityScore.ProductivityScore) + } + + if len(scores) == 0 { + return 0, "stable" + } + + total := 0 + for _, sc := range scores { + total += sc + } + avgScore = total / len(scores) + + // Compare first half vs second half for trend + if len(scores) >= 4 { + firstHalf := 0 + for _, sc := range scores[:len(scores)/2] { + firstHalf += sc + } + secondHalf := 0 + for _, sc := range scores[len(scores)/2:] { + secondHalf += sc + } + firstAvg := firstHalf / (len(scores) / 2) + secondAvg := secondHalf / (len(scores) - len(scores)/2) + + // "scores" is ordered recent-first, so firstHalf = more recent days + if firstAvg > secondAvg+5 { + trend = "improving" + } else if firstAvg < secondAvg-5 { + trend = "declining" + } else { + trend = "stable" + } + } else { + trend = "stable" + } + + return avgScore, trend +} + +func topApps(secsByApp map[string]int, visitsByApp map[string]int, limit int) []LLMAppTimeSummary { + type entry struct { + app string + secs int + } + var entries []entry + for app, secs := range secsByApp { + entries = append(entries, entry{app, secs}) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].secs > entries[j].secs + }) + if len(entries) > limit { + entries = entries[:limit] + } + + result := make([]LLMAppTimeSummary, len(entries)) + for i, e := range entries { + result[i] = LLMAppTimeSummary{ + App: e.app, + Minutes: e.secs / 60, + Visits: visitsByApp[e.app], + } + } + return result +} + +func peakHour(hourSecs map[int]int) string { + if len(hourSecs) == 0 { + return "" + } + maxHour := -1 + maxSecs := 0 + for h, s := range hourSecs { + if s > maxSecs { + maxSecs = s + maxHour = h + } + } + if maxHour < 0 { + return "" + } + return fmt.Sprintf("%s-%s", formatHour(maxHour), formatHour(maxHour+1)) +} + +func formatHour(h int) string { + h = h % 24 + if h == 0 { + return "12am" + } + if h == 12 { + return "12pm" + } + if h < 12 { + return fmt.Sprintf("%dam", h) + } + return fmt.Sprintf("%dpm", h-12) +} + +func uniqueStrings(s []string) []string { + seen := make(map[string]bool) + var result []string + for _, v := range s { + if !seen[v] { + seen[v] = true + result = append(result, v) + } + } + return result +} diff --git a/internal/usage/insights_report.go b/internal/usage/insights_report.go index 251cb54..3d07227 100644 --- a/internal/usage/insights_report.go +++ b/internal/usage/insights_report.go @@ -3,6 +3,7 @@ package usage import ( "fmt" "math" + "sort" "time" ) @@ -41,7 +42,21 @@ func (s *Service) GetDayInsights(date time.Time) (DayInsights, error) { hourly[hour] = s } - return DayInsights{ProductivityScore: score, ProductivityPerHourBreakdown: hourly}, nil + insights := DayInsights{ + ProductivityScore: score, + ProductivityPerHourBreakdown: hourly, + TopDistractions: buildDistractionBreakdown(usages), + TopBlocked: buildBlockedBreakdown(usages), + ProjectBreakdown: buildProjectBreakdown(usages), + CommunicationBreakdown: buildCommunicationBreakdown(usages), + } + + var summary LLMDailySummary + if err := s.db.Where("date = ?", date.Format("2006-01-02")).First(&summary).Error; err == nil { + insights.LLMDailySummary = &summary + } + + return insights, nil } func resolveEndTime(usage ApplicationUsage, usages []ApplicationUsage, i int) int64 { @@ -84,3 +99,110 @@ func calculateProductivityScore(productiveSeconds, distractiveSeconds int) int { return int(math.Round((float64(productiveSeconds) / float64(totalSeconds)) * 100)) } + +func usageDisplayName(usage ApplicationUsage) string { + if usage.Application.Hostname != nil && *usage.Application.Hostname != "" { + return *usage.Application.Hostname + } + return usage.Application.Name +} + +func buildDistractionBreakdown(usages []ApplicationUsage) []DistractionBreakdown { + seconds := make(map[string]int) + for i, u := range usages { + if u.Classification != ClassificationDistracting { + continue + } + if u.TerminationMode == TerminationModeBlock { + continue + } + end := resolveEndTime(u, usages, i) + if end <= u.StartedAt { + continue + } + seconds[usageDisplayName(u)] += int(end - u.StartedAt) + } + + out := make([]DistractionBreakdown, 0, len(seconds)) + for name, secs := range seconds { + out = append(out, DistractionBreakdown{Name: name, Minutes: secs / 60}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Minutes > out[j].Minutes }) + if len(out) > 5 { + out = out[:5] + } + return out +} + +func buildBlockedBreakdown(usages []ApplicationUsage) []BlockedBreakdown { + counts := make(map[string]int) + for _, u := range usages { + if u.TerminationMode != TerminationModeBlock { + continue + } + counts[usageDisplayName(u)]++ + } + + out := make([]BlockedBreakdown, 0, len(counts)) + for name, count := range counts { + out = append(out, BlockedBreakdown{Name: name, Count: count}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count }) + if len(out) > 5 { + out = out[:5] + } + return out +} + +func buildProjectBreakdown(usages []ApplicationUsage) []ProjectBreakdown { + seconds := make(map[string]int) + for i, u := range usages { + if u.DetectedProject == nil || *u.DetectedProject == "" { + continue + } + end := resolveEndTime(u, usages, i) + if end <= u.StartedAt { + continue + } + seconds[*u.DetectedProject] += int(end - u.StartedAt) + } + + out := make([]ProjectBreakdown, 0, len(seconds)) + for name, secs := range seconds { + out = append(out, ProjectBreakdown{Name: name, Minutes: secs / 60}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Minutes > out[j].Minutes }) + return out +} + +func hasTag(tags []ApplicationUsageTags, tag string) bool { + for _, t := range tags { + if t.Tag == tag { + return true + } + } + return false +} + +func buildCommunicationBreakdown(usages []ApplicationUsage) []CommunicationBreakdown { + seconds := make(map[string]int) + for i, u := range usages { + isCommunication := (u.DetectedCommunicationChannel != nil && *u.DetectedCommunicationChannel != "") || + hasTag(u.Tags, "communication") + if !isCommunication { + continue + } + end := resolveEndTime(u, usages, i) + if end <= u.StartedAt { + continue + } + seconds[usageDisplayName(u)] += int(end - u.StartedAt) + } + + out := make([]CommunicationBreakdown, 0, len(seconds)) + for name, secs := range seconds { + out = append(out, CommunicationBreakdown{Name: name, Minutes: secs / 60}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Minutes > out[j].Minutes }) + return out +} diff --git a/internal/usage/service.go b/internal/usage/service.go index e3a1184..7115c21 100644 --- a/internal/usage/service.go +++ b/internal/usage/service.go @@ -24,8 +24,9 @@ type Service struct { appBlocker func(appName, title, reason string, tags []string, browserURL *string) // events - onProtectionPaused func(pause ProtectionPause) - onProtectionResumed func(pause ProtectionPause) + onProtectionPaused func(pause ProtectionPause) + onProtectionResumed func(pause ProtectionPause) + onLLMDailySummaryReady func(summary LLMDailySummary) // mu serializes title change processing to prevent race conditions // when multiple events fire concurrently @@ -43,6 +44,7 @@ func NewService(ctx context.Context, db *gorm.DB, options ...Option) (*Service, &ProtectionPause{}, &ProtectionWhitelist{}, &SandboxExecutionLog{}, + &LLMDailySummary{}, ); err != nil { return nil, fmt.Errorf("failed to migrate usage tables: %w", err) } @@ -67,10 +69,13 @@ func (s *Service) scheduleJobs(ctx context.Context) { select { case <-ctx.Done(): return - case <-time.After(1 * time.Hour): - if err := s.removeOldSandboxExecutionLogs(ctx); err != nil { - slog.Error("failed to remove old sandbox execution logs", "error", err) - } + case <-time.After(1 * time.Hour): + if err := s.removeOldSandboxExecutionLogs(ctx); err != nil { + slog.Error("failed to remove old sandbox execution logs", "error", err) + } + if err := s.GenerateLLMDailySummaryIfNeeded(ctx); err != nil { + slog.Error("failed to generate daily summary", "error", err) + } } } }() diff --git a/internal/usage/service_option.go b/internal/usage/service_option.go index a7f45ca..bd01b49 100644 --- a/internal/usage/service_option.go +++ b/internal/usage/service_option.go @@ -45,3 +45,10 @@ func WithProtectionResumed(onProtectionResumed func(pause ProtectionPause)) Opti s.onProtectionResumed = onProtectionResumed } } + +// WithLLMDailySummaryReady configures the Service with a function to call when a daily LLM summary is generated. +func WithLLMDailySummaryReady(fn func(summary LLMDailySummary)) Option { + return func(s *Service) { + s.onLLMDailySummaryReady = fn + } +} diff --git a/internal/usage/types_daily_summary.go b/internal/usage/types_daily_summary.go new file mode 100644 index 0000000..b4ea69f --- /dev/null +++ b/internal/usage/types_daily_summary.go @@ -0,0 +1,77 @@ +package usage + +// LLMDailySummary is the persisted daily summary generated by the LLM. +type LLMDailySummary struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` + Date string `json:"date" gorm:"uniqueIndex;not null"` + Headline string `json:"headline"` + Narrative string `json:"narrative"` + KeyPattern string `json:"key_pattern"` + Wins string `json:"wins"` // JSON array of strings + Suggestion string `json:"suggestion"` + DayVibe string `json:"day_vibe"` + + ContextSwitchCount int `json:"context_switch_count"` + LongestFocusMinutes int `json:"longest_focus_minutes"` + DeepWorkMinutes int `json:"deep_work_minutes"` + BlockedAttemptCount int `json:"blocked_attempt_count"` + + CreatedAt int64 `json:"created_at"` +} + +func (LLMDailySummary) TableName() string { + return "llm_daily_summary" +} + +// LLMDaySummaryInput is the pre-computed data that gets serialized and sent to the LLM. +// It is never stored -- only used as an intermediate representation. +type LLMDaySummaryInput struct { + Date string `json:"date"` + TotalProductiveMinutes int `json:"total_productive_minutes"` + TotalDistractiveMinutes int `json:"total_distractive_minutes"` + FocusScore int `json:"focus_score"` + ContextSwitchCount int `json:"context_switch_count"` + LongestFocusStretchMin int `json:"longest_focus_stretch_min"` + DeepWorkSessions []LLMDeepWorkSession `json:"deep_work_sessions"` + DeepWorkTotalMinutes int `json:"deep_work_total_minutes"` + DistractionCascades []LLMDistractionCascade `json:"distraction_cascades"` + TopDistractions []LLMAppTimeSummary `json:"top_distractions"` + TopProductiveApps []LLMAppTimeSummary `json:"top_productive_apps"` + MostProductiveHours string `json:"most_productive_hours"` + MostDistractiveHours string `json:"most_distractive_hours"` + BlockedAttemptCount int `json:"blocked_attempt_count"` + ProtectionPauseCount int `json:"protection_pause_count"` + AvgFocusScoreLast7Days int `json:"avg_focus_score_last_7_days"` + FocusScoreTrend string `json:"focus_score_trend"` +} + +type LLMDeepWorkSession struct { + Start string `json:"start"` + End string `json:"end"` + App string `json:"app"` + Minutes int `json:"minutes"` +} + +type LLMDistractionCascade struct { + TriggerTime string `json:"trigger_time"` + TriggerApp string `json:"trigger_app"` + CascadeApps []string `json:"cascade_apps"` + TotalMinutes int `json:"total_minutes"` + ReturnedToWork string `json:"returned_to_work_at"` +} + +type LLMAppTimeSummary struct { + App string `json:"app"` + Minutes int `json:"minutes"` + Visits int `json:"visits"` +} + +// LLM response type for JSON parsing +type llmDailySummaryResponse struct { + Headline string `json:"headline"` + Narrative string `json:"narrative"` + KeyPattern string `json:"key_pattern"` + Wins []string `json:"wins"` + Suggestion string `json:"suggestion"` + DayVibe string `json:"day_vibe"` +} diff --git a/internal/usage/types_insights.go b/internal/usage/types_insights.go index 962384e..16a2301 100644 --- a/internal/usage/types_insights.go +++ b/internal/usage/types_insights.go @@ -3,6 +3,31 @@ package usage type DayInsights struct { ProductivityScore ProductivityScore ProductivityPerHourBreakdown ProductivityPerHourBreakdown + LLMDailySummary *LLMDailySummary + TopDistractions []DistractionBreakdown + TopBlocked []BlockedBreakdown + ProjectBreakdown []ProjectBreakdown + CommunicationBreakdown []CommunicationBreakdown +} + +type DistractionBreakdown struct { + Name string `json:"name"` + Minutes int `json:"minutes"` +} + +type BlockedBreakdown struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type ProjectBreakdown struct { + Name string `json:"name"` + Minutes int `json:"minutes"` +} + +type CommunicationBreakdown struct { + Name string `json:"name"` + Minutes int `json:"minutes"` } type ProductivityScore struct { diff --git a/main.go b/main.go index 10cd51b..8554180 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "log" "log/slog" "net/http" + "os/exec" "net/url" "os" "path/filepath" @@ -55,6 +56,7 @@ func init() { // and provide a strongly typed JS/TS API for them. application.RegisterEvent[usage.ApplicationUsage]("usage:update") application.RegisterEvent[usage.ProtectionPause]("protection:status") + application.RegisterEvent[usage.LLMDailySummary]("daily-summary:ready") application.RegisterEvent[any]("authctx:updated") } @@ -167,6 +169,15 @@ func main() { }), usage.WithGenaiClient(genaiClient), usage.WithSettingsService(settingsService), + usage.WithLLMDailySummaryReady(func(summary usage.LLMDailySummary) { + slog.Info("daily LLM summary ready", "date", summary.Date, "headline", summary.Headline) + if wailsAppPtr != nil { + wailsAppPtr.Event.Emit("daily-summary:ready", summary) + } + exec.Command("osascript", "-e", + fmt.Sprintf(`display notification "%s" with title "Focusd" subtitle "Daily Summary"`, + summary.Headline)).Run() + }), ) if err != nil { log.Fatal("failed to create usage service: %w", err)