From 653f76f76deed5ef54837f93edc7913a4030db61 Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Wed, 11 Mar 2026 00:27:46 +0400 Subject: [PATCH 1/4] feat: split Slack app classification and fix productivity insights - Split Slack application into a specialized classifier in classifier_llm_apps.go - Add classifier_llm_apps_test.go to verify application classification - Add JSON tags to Go structs in types_insights.go for consistent frontend data - Update GetDayInsights in insights_report.go to handle communication breakdown correctly - Update frontend components (ai-insight-card.tsx, bento-dashboard.tsx) to match new data structure --- .../components/insights/ai-insight-card.tsx | 4 +- .../components/insights/bento-dashboard.tsx | 193 +++++++++++++----- .../insights/communication-card.tsx | 15 +- frontend/src/stores/usage-store.ts | 148 +------------- internal/usage/classifier_llm_apps.go | 68 ++++++ internal/usage/classifier_llm_apps_test.go | 100 +++++++++ internal/usage/insights_report.go | 55 +++-- internal/usage/types_insights.go | 25 +-- 8 files changed, 361 insertions(+), 247 deletions(-) create mode 100644 internal/usage/classifier_llm_apps_test.go diff --git a/frontend/src/components/insights/ai-insight-card.tsx b/frontend/src/components/insights/ai-insight-card.tsx index f3ee1d4..f0b7fef 100644 --- a/frontend/src/components/insights/ai-insight-card.tsx +++ b/frontend/src/components/insights/ai-insight-card.tsx @@ -16,10 +16,10 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import type { DailyUsageSummary } from "@/stores/usage-store"; +import type { LLMDailySummary } from "@/../bindings/github.com/focusd-so/focusd/internal/usage/models"; interface LLMInsightCardProps { - dailyUsageSummary: DailyUsageSummary; + dailyUsageSummary: LLMDailySummary; isYesterday?: boolean; } diff --git a/frontend/src/components/insights/bento-dashboard.tsx b/frontend/src/components/insights/bento-dashboard.tsx index 26e7edf..0ac7017 100644 --- a/frontend/src/components/insights/bento-dashboard.tsx +++ b/frontend/src/components/insights/bento-dashboard.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { IconChevronLeft, IconChevronRight, @@ -19,7 +19,7 @@ import { formatDate, } from "@/lib/mock-data"; import { useUsageStore, isToday } from "@/stores/usage-store"; -import type { UsagePerHourBreakdown } from "@/stores/usage-store"; +import type { ProductivityScore, CommunicationBreakdown } from "@/../bindings/github.com/focusd-so/focusd/internal/usage/models"; import { LLMInsightCard } from "./ai-insight-card"; import { TopBlockedCard } from "./top-blocked-card"; import { TopDistractionsCard } from "./top-distractions-card"; @@ -29,18 +29,56 @@ import { CommunicationCard } from "./communication-card"; const MIN_SECONDS_FOR_INSIGHTS = 3600; 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" }, + { key: "productive", field: "productive_seconds", label: "Productive", bg: "bg-emerald-500/80", dot: "bg-emerald-500", text: "text-emerald-400" }, + { key: "distractive", field: "distractive_seconds", label: "Distractive", bg: "bg-rose-500/80", dot: "bg-rose-500", text: "text-rose-400" }, + { key: "idle", field: "idle_seconds", label: "Idle", bg: "bg-zinc-400/60", dot: "bg-zinc-400", text: "text-zinc-400" }, + { key: "other", field: "other_seconds", label: "Other", bg: "bg-amber-400/60", dot: "bg-amber-400", text: "text-amber-400" }, ] as const; +type SeriesField = (typeof SERIES)[number]["field"]; + +interface HourlySlot extends Record { + HourLabel: string; +} + +const formatHourLabel = (hour: number): string => { + const suffix = hour >= 12 ? "pm" : "am"; + const normalizedHour = hour % 12 === 0 ? 12 : hour % 12; + return `${normalizedHour}${suffix}`; +}; + +const buildHourlySlots = (breakdown: Record | null | undefined): HourlySlot[] => { + const map = breakdown ?? {}; + return Array.from({ length: 24 }, (_, hour) => { + const score = map[String(hour)]; + return { + HourLabel: formatHourLabel(hour), + productive_seconds: score?.productive_seconds ?? 0, + distractive_seconds: score?.distractive_seconds ?? 0, + idle_seconds: score?.idle_seconds ?? 0, + other_seconds: score?.other_seconds ?? 0, + }; + }); +}; + +/** + * Converts the backend communication map to a sorted array. + * Groups by channel name (A-Z) and then sorts by minutes (descending). + */ +const buildSortedChannels = ( + breakdown: Record | null | undefined +): CommunicationBreakdown[] => { + return Object.values(breakdown ?? {}) + .filter((c): c is CommunicationBreakdown => c != null) + .sort((a, b) => a.channel.localeCompare(b.channel) || b.minutes - a.minutes); +}; + type SeriesKey = (typeof SERIES)[number]["key"]; function HourlyBreakdownChart({ hourlyData, }: { - hourlyData: UsagePerHourBreakdown[]; + hourlyData: HourlySlot[]; }) { const [visible, setVisible] = useState>({ productive: true, @@ -76,48 +114,103 @@ function HourlyBreakdownChart({ if (totalMinutes === 0) { return ( -
-
-
+ + +
+
+
+ + +
+ + {hour.HourLabel} + + + No activity tracked + +
+
+ ); } // 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 }, + { ...SERIES[1], seconds: visible.distractive ? hour.distractive_seconds : 0 }, + { ...SERIES[3], seconds: visible.other ? hour.other_seconds : 0 }, + { ...SERIES[2], seconds: visible.idle ? hour.idle_seconds : 0 }, + { ...SERIES[0], seconds: visible.productive ? hour.productive_seconds : 0 }, ].filter((seg) => seg.seconds > 0); return ( -
- {segments.map((seg, i) => ( -
- ))} +
+ {/* Dashed placeholder for untracked portion of the hour */} +
+ {/* Actual activity segments */} +
+ {segments.map((seg, i) => ( +
+ ))} +
- -
-

{hour.HourLabel}

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

- {s.label}: {Math.round(val / 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 + +
+ ); + })} + {(() => { + const trackedMinutes = SERIES.reduce( + (sum, s) => sum + (hour[s.field] ?? 0) / 60, + 0 + ); + const untrackedMinutes = Math.round(60 - trackedMinutes); + if (untrackedMinutes <= 1) return null; + return ( +
+
+
+ + Untracked + +
+ + {untrackedMinutes}m + +
+ ); + })()} +
@@ -184,19 +277,19 @@ export function BentoDashboard() { const isLoading = isStoreLoading || isQueryLoading; - const productiveSeconds = overview?.UsageOverview?.ProductiveSeconds ?? 0; - const distractiveSeconds = overview?.UsageOverview?.DistractiveSeconds ?? 0; + const productiveSeconds = overview?.productivity_score?.productive_seconds ?? 0; + const distractiveSeconds = overview?.productivity_score?.distractive_seconds ?? 0; const totalTrackedSeconds = productiveSeconds + distractiveSeconds; const hasEnoughData = totalTrackedSeconds >= MIN_SECONDS_FOR_INSIGHTS; - const focusScore = Math.round(overview?.UsageOverview?.ProductivityScore ?? 0); + const focusScore = Math.round(overview?.productivity_score?.productivity_score ?? 0); const productiveMinutes = Math.round(productiveSeconds / 60); const distractiveMinutes = Math.round(distractiveSeconds / 60); - // Get hourly breakdown from backend (already in UsagePerHourBreakdown format with seconds) - // Filter out any null values that may come from the backend - const hourlyBreakdown = (overview?.UsagePerHourBreakdown ?? []).filter( - (item): item is UsagePerHourBreakdown => item !== null + // Build 24-slot hourly breakdown from the backend's per-hour map + const hourlyBreakdown = useMemo( + () => buildHourlySlots(overview?.productivity_per_hour_breakdown as Record | undefined), + [overview?.productivity_per_hour_breakdown] ); const canGoNext = !isToday(selectedDate); @@ -252,9 +345,9 @@ export function BentoDashboard() {
{/* Row 0: LLM Summary (At the top if it exists) */} - {overview?.DailyUsageSummary && ( + {overview?.llm_daily_summary && ( )} @@ -373,14 +466,14 @@ export function BentoDashboard() { {/* Row 3: Time Lost To + Blocked Today */}
- - + +
{/* Row 4: Projects + Communication */}
- - + +
); diff --git a/frontend/src/components/insights/communication-card.tsx b/frontend/src/components/insights/communication-card.tsx index ce8c4cf..72b2e2c 100644 --- a/frontend/src/components/insights/communication-card.tsx +++ b/frontend/src/components/insights/communication-card.tsx @@ -4,9 +4,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { formatMinutes } from "@/lib/mock-data"; import type { CommunicationBreakdown } from "@/../bindings/github.com/focusd-so/focusd/internal/usage/models"; -interface CommunicationCardProps { - channels: CommunicationBreakdown[]; -} const channelTextColors: Record = { Slack: "text-purple-400", @@ -24,8 +21,8 @@ const channelBarColors: Record = { Teams: "bg-violet-500/60", }; -export function CommunicationCard({ channels }: CommunicationCardProps) { - const totalMinutes = channels.reduce((sum, c) => sum + c.minutes, 0); +export function CommunicationCard({ channels }: { channels: CommunicationBreakdown[] }) { + // const totalMinutes = channels.reduce((sum, c) => sum + c.minutes, 0); const maxMinutes = Math.max(...channels.map((c) => c.minutes), 1); return ( @@ -40,7 +37,7 @@ export function CommunicationCard({ channels }: CommunicationCardProps) { to="/screen-time/screentime" className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" > - {formatMinutes(totalMinutes)} total + {/* {formatMinutes(totalMinutes)} total */}
@@ -51,8 +48,8 @@ export function CommunicationCard({ channels }: CommunicationCardProps) { No communication activity

) : ( - channels.slice(0, 3).map((channel, index) => { - const textColor = channelTextColors[channel.name] || "text-muted-foreground"; + channels.slice(0, 5).map((channel, index) => { + const textColor = channelTextColors[channel.channel] || "text-muted-foreground"; const widthPct = (channel.minutes / maxMinutes) * 100; return ( @@ -65,7 +62,7 @@ export function CommunicationCard({ channels }: CommunicationCardProps) {
diff --git a/frontend/src/stores/usage-store.ts b/frontend/src/stores/usage-store.ts index 36ee039..c039140 100644 --- a/frontend/src/stores/usage-store.ts +++ b/frontend/src/stores/usage-store.ts @@ -5,12 +5,7 @@ import type { ProtectionWhitelist, ProtectionPause, 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 { @@ -27,48 +22,7 @@ import { } from "../../bindings/github.com/focusd-so/focusd/internal/usage/service"; import { Duration } from "../../bindings/time/models"; -export interface BlockedItem { - usage: ApplicationUsage; - count: number; -} - -interface UsageOverview { - ProductivityScore: number; - ProductiveSeconds: number; - DistractiveSeconds: number; - SupportiveSeconds: number; -} -export interface UsagePerHourBreakdown { - HourLabel: string; - ProductiveSeconds: number; - DistractiveSeconds: number; - IdleSeconds: number; - SupportiveSeconds: number; -} - -export interface DailyUsageSummary { - headline: 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 ───────────────────────────────────────────────────────────────── @@ -87,103 +41,12 @@ const getYesterday = (): Date => { return yesterday; }; -const formatHourLabel = (hour: number): string => { - const suffix = hour >= 12 ? "pm" : "am"; - const normalizedHour = hour % 12 === 0 ? 12 : hour % 12; - return `${normalizedHour}${suffix}`; -}; - -const normalizeProductivityScore = ( - score: ProductivityScore | null | undefined -): ProductivityScore => { - return { - ProductiveSeconds: score?.ProductiveSeconds ?? 0, - DistractiveSeconds: score?.DistractiveSeconds ?? 0, - IdleSeconds: score?.IdleSeconds ?? 0, - OtherSeconds: score?.OtherSeconds ?? 0, - ProductivityScore: score?.ProductivityScore ?? 0, - }; -}; - -const mapDayInsightsToOverview = ( - insights: DayInsights -): DailyOverview => { - const usageOverviewScore = normalizeProductivityScore(insights.ProductivityScore); - const hourlyTotals = new Map(); - - const hourlyBreakdown = insights.ProductivityPerHourBreakdown ?? {}; - Object.entries(hourlyBreakdown).forEach(([hourKey, score]) => { - const parsedHour = parseInt(hourKey, 10); - const safeScore = normalizeProductivityScore(score); - - const current = hourlyTotals.get(parsedHour); - if (!current) { - hourlyTotals.set(parsedHour, safeScore); - return; - } - - hourlyTotals.set(parsedHour, { - ProductiveSeconds: current.ProductiveSeconds + safeScore.ProductiveSeconds, - DistractiveSeconds: current.DistractiveSeconds + safeScore.DistractiveSeconds, - IdleSeconds: current.IdleSeconds + safeScore.IdleSeconds, - OtherSeconds: current.OtherSeconds + safeScore.OtherSeconds, - ProductivityScore: 0, - }); - }); - - const usagePerHourBreakdown: UsagePerHourBreakdown[] = Array.from( - { length: 24 }, - (_, hourIndex) => { - const score = normalizeProductivityScore(hourlyTotals.get(hourIndex)); - - return { - HourLabel: formatHourLabel(hourIndex), - ProductiveSeconds: score.ProductiveSeconds, - DistractiveSeconds: score.DistractiveSeconds, - IdleSeconds: score.IdleSeconds, - SupportiveSeconds: score.OtherSeconds, - }; - } - ); - - return { - UsageOverview: { - ProductivityScore: usageOverviewScore.ProductivityScore, - ProductiveSeconds: usageOverviewScore.ProductiveSeconds, - DistractiveSeconds: usageOverviewScore.DistractiveSeconds, - SupportiveSeconds: usageOverviewScore.OtherSeconds, - }, - UsagePerHourBreakdown: usagePerHourBreakdown, - 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 && !summary.narrative)) return null; - return { - headline: summary.headline ?? "", - narrative: summary.narrative ?? "", - key_pattern: summary.key_pattern ?? "", - suggestion: summary.suggestion ?? "", - day_vibe: summary.day_vibe ?? "", - wins: summary.wins ?? "[]", - context_switch_count: summary.context_switch_count ?? 0, - longest_focus_minutes: summary.longest_focus_minutes ?? 0, - deep_work_minutes: summary.deep_work_minutes ?? 0, - blocked_attempt_count: summary.blocked_attempt_count ?? 0, - }; -}; - // ── Store interface ───────────────────────────────────────────────────────── interface UsageState { // Activity tracking recentUsages: ApplicationUsage[]; - blockedItems: Map; + blockedItems: Map; isSubscribed: boolean; // Whitelist (allowed items) @@ -195,7 +58,7 @@ interface UsageState { // Insights selectedDate: Date; - overview: DailyOverview | null; + overview: DayInsights | null; isLoading: boolean; error: string | null; @@ -208,7 +71,7 @@ interface UsageState { addUsage: (usage: ApplicationUsage) => void; initSubscription: () => void; fetchRecentUsages: () => Promise; - getBlockedItemsList: () => BlockedItem[]; + getBlockedItemsList: () => { usage: ApplicationUsage; count: number }[]; getActiveUsages: () => ApplicationUsage[]; // Whitelist actions @@ -319,7 +182,7 @@ export const useUsageStore = create()((set, get) => ({ GetUsageList(recentUsagesOptions), GetUsageList(blockedItemsOptions), ]); - const blockedItemsMap = new Map(); + const blockedItemsMap = new Map(); blockedItems.forEach((usage: ApplicationUsage) => { const key = usage.application?.hostname || usage.application?.bundle_id || String(usage.id); const existing = blockedItemsMap.get(key); @@ -424,8 +287,7 @@ export const useUsageStore = create()((set, get) => ({ try { set({ isLoading: true, error: null }); - const insights = await GetDayInsights(targetDate); - const overview = mapDayInsightsToOverview(insights); + const overview = await GetDayInsights(targetDate); set({ overview, isLoading: false, error: null }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to fetch overview data"; diff --git a/internal/usage/classifier_llm_apps.go b/internal/usage/classifier_llm_apps.go index 60a6893..0b049bf 100644 --- a/internal/usage/classifier_llm_apps.go +++ b/internal/usage/classifier_llm_apps.go @@ -3,9 +3,15 @@ package usage import ( "context" "fmt" + "strings" ) func (s *Service) classifyApplication(ctx context.Context, appName, title string, url *string) (*ClassificationResponse, error) { + switch strings.ToLower(appName) { + case "slack": + return s.classifySlackApp(ctx, appName, title) + } + var ( instructions = ` You are a Software Engineering Application Intent Classifier. Your job is to determine if the user is actively doing work related to their software engineering job or seeking entertainment/distraction. @@ -65,3 +71,65 @@ Executable Path: %s return response, nil } + +// classifySlackApp classifies Slack desktop application activity using the window title. +// Titles typically look like: "Slack | #engineering | Acme Corp" or "Slack - #random - Workspace" +func (s *Service) classifySlackApp(ctx context.Context, appName, title string) (*ClassificationResponse, error) { + var ( + instructions = ` +You are a Slack Software Engineer Intent Classifier. Your job is to determine if the user is engaged in productive work communication, general organizational activity, or distracted by non-essential chatter. You will receive the desktop window title — this is your main signal. + +# Classification Logic + +## **productive** +**Criteria:** Direct work communication, project discussions, incident response, technical collaboration, or focused async work. +**Indicators:** +- Work-related channels: #engineering, #product, #design, #support, #incidents, #deploys, #standup, #sprint-*, #dev-*, #backend, #frontend, #infra, #security, #ops, #platform, #release-*, #bug-*, #feature-*, #project-*, #review-*, #ci-*, #monitoring, #alerts, #on-call +- DMs discussing work tasks (inferred from title context) +- Threads with technical discussions +- Huddles or calls (likely meetings) +- Canvas or document collaboration +- Any channel name that clearly relates to a specific project, team function, or work task + +## **neutral** +**Criteria:** General organizational communication that is neither clearly productive nor distracting. Company-wide or team-wide channels used for announcements and coordination. +**Indicators:** +- General channels: #general, #announcements, #company, #all-hands, #team-*, #org-*, #office-*, #hr, #it-support, #helpdesk, #onboarding, #welcome +- Channels that serve organizational purposes without being directly about project work +- Workspace home page or search without specific channel context +- Browsing channel list without engaging (title contains "Browse channels" or similar) + +## **distracting** +**Criteria:** Social channels, watercooler chat, entertainment, or passive browsing without clear work purpose. +**Indicators:** +- Social/fun channels: #random, #watercooler, #pets, #food, #memes, #off-topic, #fun-*, #chat-*, #social-*, #music, #gaming, #sports, #books, #movies, #travel, #fitness, #jokes, #dogs, #cats, #photos +- Content consumption channels: #links, #articles, #videos, #interesting-*, #cool-* +- Any channel name that suggests entertainment, socializing, or non-work activity + +# Output Format + +Return a JSON object with the following keys: +1. "classification": "productive" (work communication), "neutral" (general org channels), or "distracting" (social/entertainment). +2. "reasoning": Brief explanation. +3. "tags": Array of strings from ONLY these options: ["communication", "entertainment", "time-sink", "content-consumption"]. +4. "confidence_score": Float (0.0 - 1.0) +5. "detected_communication_channel": The channel or DM name extracted from the title (e.g., "#engineering", "#random", "DM with John", "thread in #product"). Return empty string if unable to determine. +` + inputTmpl = ` +The user is currently using the Slack desktop application. Classify their activity based on the following information: + +Application Name: %s +Window Title: %s +` + ) + + input := fmt.Sprintf(inputTmpl, appName, title) + + response, err := s.classifyWithGemini(ctx, instructions, input) + if err != nil { + return nil, fmt.Errorf("failed to classify with Gemini: %w", err) + } + + return response, nil +} + diff --git a/internal/usage/classifier_llm_apps_test.go b/internal/usage/classifier_llm_apps_test.go new file mode 100644 index 0000000..635c97b --- /dev/null +++ b/internal/usage/classifier_llm_apps_test.go @@ -0,0 +1,100 @@ +package usage + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/genai" +) + +// roundTripFunc is an adapter to allow the use of ordinary functions as http.RoundTripper. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +func TestService_ClassifyApplication_Slack(t *testing.T) { + // Classification response that the mocked LLM returns. + classificationJSON := `{"classification":"productive","reasoning":"Productive work communication","confidence_score":0.95,"tags":["communication"],"detected_communication_channel":"#engineering"}` + + // Wrap in a valid Gemini API response envelope + geminiResponse := fmt.Sprintf(`{"candidates":[{"content":{"parts":[{"text":%q}],"role":"model"}}]}`, classificationJSON) + + genaiClient, err := genai.NewClient(context.Background(), &genai.ClientConfig{ + APIKey: "test-key", + Backend: genai.BackendGeminiAPI, + HTTPClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + // Verify that the instructions contain Slack-specific keywords + body, _ := io.ReadAll(req.Body) + bodyStr := string(body) + assert.Contains(t, bodyStr, "Slack Software Engineer Intent Classifier") + assert.Contains(t, bodyStr, "#engineering") + + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(geminiResponse)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + }, + }) + require.NoError(t, err, "failed to create genai client") + + s := &Service{ + genaiClient: genaiClient, + } + + t.Run("Slack productive", func(t *testing.T) { + response, err := s.classifyApplication(context.Background(), "Slack", "#engineering | Acme Corp", nil) + require.NoError(t, err) + + assert.Equal(t, ClassificationProductive, response.Classification) + assert.Equal(t, "#engineering", response.DetectedCommunicationChannel) + }) +} + +func TestService_ClassifyApplication_Generic(t *testing.T) { + // Classification response for a generic app + classificationJSON := `{"classification":"productive","reasoning":"Coding in VS Code","confidence_score":0.99,"tags":["coding"]}` + geminiResponse := fmt.Sprintf(`{"candidates":[{"content":{"parts":[{"text":%q}],"role":"model"}}]}`, classificationJSON) + + genaiClient, err := genai.NewClient(context.Background(), &genai.ClientConfig{ + APIKey: "test-key", + Backend: genai.BackendGeminiAPI, + HTTPClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + // Verify that generic instructions are used + body, _ := io.ReadAll(req.Body) + bodyStr := string(body) + assert.Contains(t, bodyStr, "Software Engineering Application Intent Classifier") + assert.NotContains(t, bodyStr, "Slack Software Engineer Intent Classifier") + + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(geminiResponse)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + }, + }) + require.NoError(t, err) + + s := &Service{ + genaiClient: genaiClient, + } + + t.Run("VS Code productive", func(t *testing.T) { + response, err := s.classifyApplication(context.Background(), "Code", "main.go - focusd", nil) + require.NoError(t, err) + + assert.Equal(t, ClassificationProductive, response.Classification) + }) +} diff --git a/internal/usage/insights_report.go b/internal/usage/insights_report.go index c5d3fec..1056102 100644 --- a/internal/usage/insights_report.go +++ b/internal/usage/insights_report.go @@ -20,6 +20,15 @@ func (s *Service) GetDayInsights(date time.Time) (DayInsights, error) { score := ProductivityScore{} hourly := make(ProductivityPerHourBreakdown) + insights := DayInsights{ + ProductivityScore: score, + ProductivityPerHourBreakdown: hourly, + TopDistractions: buildDistractionBreakdown(usages), + TopBlocked: buildBlockedBreakdown(usages), + ProjectBreakdown: buildProjectBreakdown(usages), + CommunicationBreakdown: make(map[string]CommunicationBreakdown), + } + for i, usage := range usages { end := resolveEndTime(usage, usages, i) if end <= usage.StartedAt { @@ -35,6 +44,19 @@ func (s *Service) GetDayInsights(date time.Time) (DayInsights, error) { entry.addSeconds(usage.Classification, secs, isIdle) hourly[hour] = entry } + + commChannel := fromPtr(usage.DetectedCommunicationChannel) + + if commChannel != "" { + + key := fmt.Sprintf("%s:%s", commChannel, usage.Application.Name) + + entry := insights.CommunicationBreakdown[key] + entry.Name = commChannel + entry.Channel = commChannel + entry.Minutes += dur / 60 + insights.CommunicationBreakdown[key] = entry + } } score.ProductivityScore = calculateProductivityScore(score.ProductiveSeconds, score.DistractiveSeconds) @@ -43,14 +65,8 @@ func (s *Service) GetDayInsights(date time.Time) (DayInsights, error) { hourly[hour] = s } - insights := DayInsights{ - ProductivityScore: score, - ProductivityPerHourBreakdown: hourly, - TopDistractions: buildDistractionBreakdown(usages), - TopBlocked: buildBlockedBreakdown(usages), - ProjectBreakdown: buildProjectBreakdown(usages), - CommunicationBreakdown: buildCommunicationBreakdown(usages), - } + insights.ProductivityScore = score + insights.ProductivityPerHourBreakdown = hourly var summary LLMDailySummary if err := s.db.Where("date = ?", date.Format("2006-01-02")).First(&summary).Error; err == nil { @@ -184,26 +200,3 @@ func hasTag(tags []ApplicationUsageTags, tag string) bool { } 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/types_insights.go b/internal/usage/types_insights.go index 5b0d57a..bfb4943 100644 --- a/internal/usage/types_insights.go +++ b/internal/usage/types_insights.go @@ -1,13 +1,13 @@ package usage type DayInsights struct { - ProductivityScore ProductivityScore - ProductivityPerHourBreakdown ProductivityPerHourBreakdown - LLMDailySummary *LLMDailySummary - TopDistractions []DistractionBreakdown - TopBlocked []BlockedBreakdown - ProjectBreakdown []ProjectBreakdown - CommunicationBreakdown []CommunicationBreakdown + ProductivityScore ProductivityScore `json:"productivity_score"` + ProductivityPerHourBreakdown ProductivityPerHourBreakdown `json:"productivity_per_hour_breakdown"` + LLMDailySummary *LLMDailySummary `json:"llm_daily_summary"` + TopDistractions []DistractionBreakdown `json:"top_distractions"` + TopBlocked []BlockedBreakdown `json:"top_blocked"` + ProjectBreakdown []ProjectBreakdown `json:"project_breakdown"` + CommunicationBreakdown map[string]CommunicationBreakdown `json:"communication_breakdown"` } type DistractionBreakdown struct { @@ -27,15 +27,16 @@ type ProjectBreakdown struct { type CommunicationBreakdown struct { Name string `json:"name"` + Channel string `json:"channel"` Minutes int `json:"minutes"` } type ProductivityScore struct { - ProductiveSeconds int - DistractiveSeconds int - IdleSeconds int - OtherSeconds int - ProductivityScore int + ProductiveSeconds int `json:"productive_seconds"` + DistractiveSeconds int `json:"distractive_seconds"` + IdleSeconds int `json:"idle_seconds"` + OtherSeconds int `json:"other_seconds"` + ProductivityScore int `json:"productivity_score"` } func (p *ProductivityScore) addSeconds(classification Classification, seconds int, isIdle bool) { From bc617c6daba2177630fa1792c83bdcc2b64de378 Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Wed, 11 Mar 2026 15:46:38 +0400 Subject: [PATCH 2/4] feat(usage): update insights data structures and LLM prompts for terminal classification --- .../components/insights/bento-dashboard.tsx | 52 +++++++-- .../components/insights/categories-card.tsx | 15 ++- .../insights/communication-card.tsx | 8 +- .../components/insights/top-blocked-card.tsx | 3 +- .../insights/top-distractions-card.tsx | 15 ++- frontend/src/lib/mock-data.ts | 18 ++- internal/usage/classifier_llm_apps.go | 6 +- internal/usage/classifier_llm_apps_test.go | 100 ---------------- internal/usage/classifier_llm_test.go | 18 +++ internal/usage/classifier_llm_website.go | 7 +- internal/usage/insights_report.go | 109 ++++-------------- internal/usage/service_usage_test.go | 2 +- internal/usage/types_db.go | 26 +++++ internal/usage/types_insights.go | 26 ++--- internal/usage/types_usage.go | 8 +- main.go | 9 +- 16 files changed, 166 insertions(+), 256 deletions(-) delete mode 100644 internal/usage/classifier_llm_apps_test.go diff --git a/frontend/src/components/insights/bento-dashboard.tsx b/frontend/src/components/insights/bento-dashboard.tsx index 0ac7017..956195c 100644 --- a/frontend/src/components/insights/bento-dashboard.tsx +++ b/frontend/src/components/insights/bento-dashboard.tsx @@ -15,11 +15,14 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { - formatMinutes, + formatDuration, formatDate, } from "@/lib/mock-data"; import { useUsageStore, isToday } from "@/stores/usage-store"; -import type { ProductivityScore, CommunicationBreakdown } from "@/../bindings/github.com/focusd-so/focusd/internal/usage/models"; +import type { + ProductivityScore, + CommunicationBreakdown, +} from "@/../bindings/github.com/focusd-so/focusd/internal/usage/models"; import { LLMInsightCard } from "./ai-insight-card"; import { TopBlockedCard } from "./top-blocked-card"; import { TopDistractionsCard } from "./top-distractions-card"; @@ -70,7 +73,34 @@ const buildSortedChannels = ( ): CommunicationBreakdown[] => { return Object.values(breakdown ?? {}) .filter((c): c is CommunicationBreakdown => c != null) - .sort((a, b) => a.channel.localeCompare(b.channel) || b.minutes - a.minutes); + .sort((a, b) => a.channel.localeCompare(b.channel) || b.duration_seconds - a.duration_seconds); +}; + +const buildSortedDistractions = ( + breakdown: Record | null | undefined +): { name: string; duration_seconds: number }[] => { + return Object.entries(breakdown ?? {}) + .filter((entry): entry is [string, number] => typeof entry[1] === "number") + .map(([name, duration_seconds]) => ({ name, duration_seconds })) + .sort((a, b) => b.duration_seconds - a.duration_seconds); +}; + +const buildSortedBlocked = ( + breakdown: Record | null | undefined +): { name: string; count: number }[] => { + return Object.entries(breakdown ?? {}) + .filter((entry): entry is [string, number] => typeof entry[1] === "number") + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count); +}; + +const buildSortedProjects = ( + breakdown: Record | null | undefined +): { name: string; duration_seconds: number }[] => { + return Object.entries(breakdown ?? {}) + .filter((entry): entry is [string, number] => typeof entry[1] === "number") + .map(([name, duration_seconds]) => ({ name, duration_seconds })) + .sort((a, b) => b.duration_seconds - a.duration_seconds); }; type SeriesKey = (typeof SERIES)[number]["key"]; @@ -184,7 +214,7 @@ function HourlyBreakdownChart({
- {Math.round(val / 60)}m + {formatDuration(val)}
); @@ -205,7 +235,7 @@ function HourlyBreakdownChart({
- {untrackedMinutes}m + {formatDuration(untrackedMinutes * 60)}
); @@ -283,8 +313,6 @@ export function BentoDashboard() { const hasEnoughData = totalTrackedSeconds >= MIN_SECONDS_FOR_INSIGHTS; const focusScore = Math.round(overview?.productivity_score?.productivity_score ?? 0); - const productiveMinutes = Math.round(productiveSeconds / 60); - const distractiveMinutes = Math.round(distractiveSeconds / 60); // Build 24-slot hourly breakdown from the backend's per-hour map const hourlyBreakdown = useMemo( @@ -417,7 +445,7 @@ export function BentoDashboard() { Productive

- {formatMinutes(productiveMinutes)} + {formatDuration(productiveSeconds)}

Deep focus time @@ -432,7 +460,7 @@ export function BentoDashboard() { Distractive

- {formatMinutes(distractiveMinutes)} + {formatDuration(distractiveSeconds)}

Time lost @@ -466,13 +494,13 @@ export function BentoDashboard() { {/* Row 3: Time Lost To + Blocked Today */}

- - + +
{/* Row 4: Projects + Communication */}
- +
diff --git a/frontend/src/components/insights/categories-card.tsx b/frontend/src/components/insights/categories-card.tsx index f11693a..94e5999 100644 --- a/frontend/src/components/insights/categories-card.tsx +++ b/frontend/src/components/insights/categories-card.tsx @@ -1,11 +1,10 @@ import { IconFolder, IconArrowRight } from "@tabler/icons-react"; import { Link } from "@tanstack/react-router"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { formatMinutes } from "@/lib/mock-data"; -import type { ProjectBreakdown } from "@/../bindings/github.com/focusd-so/focusd/internal/usage/models"; +import { formatDuration } from "@/lib/mock-data"; interface CategoriesCardProps { - projects: ProjectBreakdown[]; + projects: { name: string; duration_seconds: number }[]; } const projectColors = [ @@ -18,8 +17,8 @@ const projectColors = [ ]; export function CategoriesCard({ projects }: CategoriesCardProps) { - const totalMinutes = projects.reduce((sum, p) => sum + p.minutes, 0); - const maxMinutes = Math.max(...projects.map((p) => p.minutes), 1); + const totalSeconds = projects.reduce((sum, p) => sum + p.duration_seconds, 0); + const maxSeconds = Math.max(...projects.map((p) => p.duration_seconds), 1); const topProjects = projects.slice(0, 3); @@ -35,7 +34,7 @@ export function CategoriesCard({ projects }: CategoriesCardProps) { to="/screen-time/screentime" className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" > - {formatMinutes(totalMinutes)} total + {formatDuration(totalSeconds)} total
@@ -47,7 +46,7 @@ export function CategoriesCard({ projects }: CategoriesCardProps) {

) : ( topProjects.map((project, index) => { - const widthPct = (project.minutes / maxMinutes) * 100; + const widthPct = (project.duration_seconds / maxSeconds) * 100; const colorClass = projectColors[index % projectColors.length]; return (
@@ -61,7 +60,7 @@ export function CategoriesCard({ projects }: CategoriesCardProps) { - {formatMinutes(project.minutes)} + {formatDuration(project.duration_seconds)}
diff --git a/frontend/src/components/insights/communication-card.tsx b/frontend/src/components/insights/communication-card.tsx index 72b2e2c..0dd1fb4 100644 --- a/frontend/src/components/insights/communication-card.tsx +++ b/frontend/src/components/insights/communication-card.tsx @@ -1,7 +1,7 @@ import { IconMessages, IconArrowRight } from "@tabler/icons-react"; import { Link } from "@tanstack/react-router"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { formatMinutes } from "@/lib/mock-data"; +import { formatDuration } from "@/lib/mock-data"; import type { CommunicationBreakdown } from "@/../bindings/github.com/focusd-so/focusd/internal/usage/models"; @@ -23,7 +23,7 @@ const channelBarColors: Record = { export function CommunicationCard({ channels }: { channels: CommunicationBreakdown[] }) { // const totalMinutes = channels.reduce((sum, c) => sum + c.minutes, 0); - const maxMinutes = Math.max(...channels.map((c) => c.minutes), 1); + const maxSeconds = Math.max(...channels.map((c) => c.duration_seconds), 1); return ( @@ -50,14 +50,14 @@ export function CommunicationCard({ channels }: { channels: CommunicationBreakdo ) : ( channels.slice(0, 5).map((channel, index) => { const textColor = channelTextColors[channel.channel] || "text-muted-foreground"; - const widthPct = (channel.minutes / maxMinutes) * 100; + const widthPct = (channel.duration_seconds / maxSeconds) * 100; return (
{channel.name} - {formatMinutes(channel.minutes)} + {formatDuration(channel.duration_seconds)}
diff --git a/frontend/src/components/insights/top-blocked-card.tsx b/frontend/src/components/insights/top-blocked-card.tsx index 116dcb9..1a4c3cc 100644 --- a/frontend/src/components/insights/top-blocked-card.tsx +++ b/frontend/src/components/insights/top-blocked-card.tsx @@ -1,10 +1,9 @@ import { IconShield, IconArrowRight } from "@tabler/icons-react"; import { Link } from "@tanstack/react-router"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { BlockedBreakdown } from "@/../bindings/github.com/focusd-so/focusd/internal/usage/models"; interface TopBlockedCardProps { - blockedAttempts: BlockedBreakdown[]; + blockedAttempts: { name: string; count: number }[]; } export function TopBlockedCard({ blockedAttempts }: TopBlockedCardProps) { diff --git a/frontend/src/components/insights/top-distractions-card.tsx b/frontend/src/components/insights/top-distractions-card.tsx index 3f66f67..282b750 100644 --- a/frontend/src/components/insights/top-distractions-card.tsx +++ b/frontend/src/components/insights/top-distractions-card.tsx @@ -1,16 +1,15 @@ import { IconAlertTriangle, IconArrowRight } from "@tabler/icons-react"; import { Link } from "@tanstack/react-router"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { formatMinutes } from "@/lib/mock-data"; -import type { DistractionBreakdown } from "@/../bindings/github.com/focusd-so/focusd/internal/usage/models"; +import { formatDuration } from "@/lib/mock-data"; interface TopDistractionsCardProps { - distractions: DistractionBreakdown[]; + distractions: { name: string; duration_seconds: number }[]; } 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); + const totalSeconds = distractions.reduce((sum, d) => sum + d.duration_seconds, 0); + const maxSeconds = Math.max(...distractions.map((d) => d.duration_seconds), 1); const topDistractions = distractions.slice(0, 5); @@ -26,7 +25,7 @@ export function TopDistractionsCard({ distractions }: TopDistractionsCardProps) to="/screen-time/screentime" className="flex items-center gap-1 text-xs text-muted-foreground hover:text-rose-400 transition-colors" > - {formatMinutes(totalMinutes)} + {formatDuration(totalSeconds)}
@@ -40,13 +39,13 @@ export function TopDistractionsCard({ distractions }: TopDistractionsCardProps)
) : ( topDistractions.map((distraction, index) => { - const widthPct = (distraction.minutes / maxMinutes) * 100; + const widthPct = (distraction.duration_seconds / maxSeconds) * 100; return (
{distraction.name} - {formatMinutes(distraction.minutes)} + {formatDuration(distraction.duration_seconds)}
diff --git a/frontend/src/lib/mock-data.ts b/frontend/src/lib/mock-data.ts index 26d21a2..eb47f89 100644 --- a/frontend/src/lib/mock-data.ts +++ b/frontend/src/lib/mock-data.ts @@ -462,9 +462,21 @@ export const mockAppUsage: AppUsageStats[] = [ // Helper to format minutes as "Xh Ym" export function formatMinutes(minutes: number): string { - const h = Math.floor(minutes / 60); - const m = Math.round(minutes % 60); - if (h > 0) return `${h}h ${m}m`; + return formatDuration(minutes * 60); +} + +// Helper to format duration in a human readable way +export function formatDuration(seconds: number): string { + if (seconds < 60) { + return `${Math.round(seconds)}s`; + } + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + + if (h > 0) { + if (m > 0) return `${h}h ${m}m`; + return `${h}h`; + } return `${m}m`; } diff --git a/internal/usage/classifier_llm_apps.go b/internal/usage/classifier_llm_apps.go index 0b049bf..534d228 100644 --- a/internal/usage/classifier_llm_apps.go +++ b/internal/usage/classifier_llm_apps.go @@ -45,7 +45,8 @@ You are a Software Engineering Application Intent Classifier. Your job is to det Return a JSON object with the following keys: 1. "classification": "productive", "neutral", or "distracting". 2. "reasoning": Brief explanation. -3. "tags": Array of strings from ONLY these options: ["coding", "docs", "debug", "communication", "planning", "learning", "entertainment", "news", "social", "shopping", "other"]. +3. "tags": Array of strings from ONLY these options: ["coding", "docs", "debug", "communication", "terminal", "planning", "learning", "entertainment", "news", "social", "shopping", "terminal", "other"]. + - IMPORTANT: Be extremely conservative with the "communication" tag. Only use it when there is actual messaging, emailing, or chatting happening. Do NOT use it for reading code reviews, terminal multiplexers, or project management. 4. "confidence_score": Float (0.0 - 1.0) ` inputTmpl = ` @@ -113,7 +114,7 @@ Return a JSON object with the following keys: 2. "reasoning": Brief explanation. 3. "tags": Array of strings from ONLY these options: ["communication", "entertainment", "time-sink", "content-consumption"]. 4. "confidence_score": Float (0.0 - 1.0) -5. "detected_communication_channel": The channel or DM name extracted from the title (e.g., "#engineering", "#random", "DM with John", "thread in #product"). Return empty string if unable to determine. +5. "detected_communication_channel": The channel or DM name extracted from the title ` inputTmpl = ` The user is currently using the Slack desktop application. Classify their activity based on the following information: @@ -132,4 +133,3 @@ Window Title: %s return response, nil } - diff --git a/internal/usage/classifier_llm_apps_test.go b/internal/usage/classifier_llm_apps_test.go deleted file mode 100644 index 635c97b..0000000 --- a/internal/usage/classifier_llm_apps_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package usage - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/genai" -) - -// roundTripFunc is an adapter to allow the use of ordinary functions as http.RoundTripper. -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { - return f(r) -} - -func TestService_ClassifyApplication_Slack(t *testing.T) { - // Classification response that the mocked LLM returns. - classificationJSON := `{"classification":"productive","reasoning":"Productive work communication","confidence_score":0.95,"tags":["communication"],"detected_communication_channel":"#engineering"}` - - // Wrap in a valid Gemini API response envelope - geminiResponse := fmt.Sprintf(`{"candidates":[{"content":{"parts":[{"text":%q}],"role":"model"}}]}`, classificationJSON) - - genaiClient, err := genai.NewClient(context.Background(), &genai.ClientConfig{ - APIKey: "test-key", - Backend: genai.BackendGeminiAPI, - HTTPClient: &http.Client{ - Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - // Verify that the instructions contain Slack-specific keywords - body, _ := io.ReadAll(req.Body) - bodyStr := string(body) - assert.Contains(t, bodyStr, "Slack Software Engineer Intent Classifier") - assert.Contains(t, bodyStr, "#engineering") - - return &http.Response{ - StatusCode: 200, - Body: io.NopCloser(strings.NewReader(geminiResponse)), - Header: http.Header{"Content-Type": []string{"application/json"}}, - }, nil - }), - }, - }) - require.NoError(t, err, "failed to create genai client") - - s := &Service{ - genaiClient: genaiClient, - } - - t.Run("Slack productive", func(t *testing.T) { - response, err := s.classifyApplication(context.Background(), "Slack", "#engineering | Acme Corp", nil) - require.NoError(t, err) - - assert.Equal(t, ClassificationProductive, response.Classification) - assert.Equal(t, "#engineering", response.DetectedCommunicationChannel) - }) -} - -func TestService_ClassifyApplication_Generic(t *testing.T) { - // Classification response for a generic app - classificationJSON := `{"classification":"productive","reasoning":"Coding in VS Code","confidence_score":0.99,"tags":["coding"]}` - geminiResponse := fmt.Sprintf(`{"candidates":[{"content":{"parts":[{"text":%q}],"role":"model"}}]}`, classificationJSON) - - genaiClient, err := genai.NewClient(context.Background(), &genai.ClientConfig{ - APIKey: "test-key", - Backend: genai.BackendGeminiAPI, - HTTPClient: &http.Client{ - Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - // Verify that generic instructions are used - body, _ := io.ReadAll(req.Body) - bodyStr := string(body) - assert.Contains(t, bodyStr, "Software Engineering Application Intent Classifier") - assert.NotContains(t, bodyStr, "Slack Software Engineer Intent Classifier") - - return &http.Response{ - StatusCode: 200, - Body: io.NopCloser(strings.NewReader(geminiResponse)), - Header: http.Header{"Content-Type": []string{"application/json"}}, - }, nil - }), - }, - }) - require.NoError(t, err) - - s := &Service{ - genaiClient: genaiClient, - } - - t.Run("VS Code productive", func(t *testing.T) { - response, err := s.classifyApplication(context.Background(), "Code", "main.go - focusd", nil) - require.NoError(t, err) - - assert.Equal(t, ClassificationProductive, response.Classification) - }) -} diff --git a/internal/usage/classifier_llm_test.go b/internal/usage/classifier_llm_test.go index 6793810..da63557 100644 --- a/internal/usage/classifier_llm_test.go +++ b/internal/usage/classifier_llm_test.go @@ -280,3 +280,21 @@ func TestClassify_Website_Generic(t *testing.T) { assert.Equal(t, ClassificationDistracting, response.Classification) }) } + +func TestClassify_Application_Slack(t *testing.T) { + genaiClient, err := genai.NewClient(context.Background(), &genai.ClientConfig{ + APIKey: os.Getenv("GEMINI_API_KEY"), + }) + + require.NoError(t, err, "failed to create genai client") + + s, _ := setUpService(t, WithGenaiClient(genaiClient)) + + t.Run("extract channel name", func(t *testing.T) { + response, err := s.classifyApplication(context.Background(), "slack", "private-coin-team-chat (Channel) - Snyk - Slack", nil) + require.NoError(t, err, "failed to classify application") + + assert.Equal(t, ClassificationProductive, response.Classification) + assert.Equal(t, "private-coin-team-chat", response.DetectedCommunicationChannel) + }) +} diff --git a/internal/usage/classifier_llm_website.go b/internal/usage/classifier_llm_website.go index e662075..496cbd9 100644 --- a/internal/usage/classifier_llm_website.go +++ b/internal/usage/classifier_llm_website.go @@ -95,7 +95,8 @@ You are a General Software Engineer Intent Classifier. Your job is to determine Return a JSON object with the following keys: 1. "classification": "productive", "neutral", or "distracting". 2. "reasoning": Brief explanation. -3. "tags": Array of strings from ONLY these options: ["coding", "docs", "debug", "communication", "planning", "learning", "entertainment", "news", "social", "shopping", "other"]. +3. "tags": Array of strings from ONLY these options: ["coding", "docs", "debug", "communication", "terminal", "planning", "learning", "entertainment", "news", "social", "shopping", "other"]. + - IMPORTANT: Be extremely conservative with the "communication" tag. Only use it when there is actual messaging, emailing, or chatting happening. Do NOT use it for reading code reviews, terminal multiplexers, or project management. 4. "confidence_score": Float (0.0 - 1.0) ` inputTmpl = ` @@ -858,13 +859,15 @@ func (s *Service) classifyWithGemini(ctx context.Context, instructions, input st replacer := strings.NewReplacer("```json", "", "`", "") text = replacer.Replace(text) + slog.Info("Website classification response", "resp", text) + var response ClassificationResponse if err := json.Unmarshal([]byte(text), &response); err != nil { return nil, err } - response.ClassificationSource = ClassificationSourceCloudLLM + response.ClassificationSource = ClassificationSourceCloudLLMGemini return &response, nil } diff --git a/internal/usage/insights_report.go b/internal/usage/insights_report.go index 1056102..3ea82ec 100644 --- a/internal/usage/insights_report.go +++ b/internal/usage/insights_report.go @@ -3,7 +3,6 @@ package usage import ( "fmt" "math" - "sort" "time" ) @@ -23,9 +22,9 @@ func (s *Service) GetDayInsights(date time.Time) (DayInsights, error) { insights := DayInsights{ ProductivityScore: score, ProductivityPerHourBreakdown: hourly, - TopDistractions: buildDistractionBreakdown(usages), - TopBlocked: buildBlockedBreakdown(usages), - ProjectBreakdown: buildProjectBreakdown(usages), + TopDistractions: make(map[string]int), + TopBlocked: make(map[string]int), + ProjectBreakdown: make(map[string]int), CommunicationBreakdown: make(map[string]CommunicationBreakdown), } @@ -45,18 +44,27 @@ func (s *Service) GetDayInsights(date time.Time) (DayInsights, error) { hourly[hour] = entry } - commChannel := fromPtr(usage.DetectedCommunicationChannel) - - if commChannel != "" { - - key := fmt.Sprintf("%s:%s", commChannel, usage.Application.Name) + if usage.IsCommunicationUsage() { + key := fmt.Sprintf("%s:%s", usage.Application.Name, usage.CommunicationChannel()) entry := insights.CommunicationBreakdown[key] - entry.Name = commChannel - entry.Channel = commChannel - entry.Minutes += dur / 60 + entry.Name = usage.Application.Name + entry.Channel = usage.CommunicationChannel() + entry.DurationSeconds += dur insights.CommunicationBreakdown[key] = entry } + + if usage.TerminationMode == TerminationModeBlock { + insights.TopBlocked[usageDisplayName(usage)] += dur + } + + if usage.Classification == ClassificationDistracting && usage.TerminationMode != TerminationModeBlock { + insights.TopDistractions[usageDisplayName(usage)] += dur + } + + if usage.HasDetectedProject() { + insights.ProjectBreakdown[usage.GetDetectedProject()] += dur + } } score.ProductivityScore = calculateProductivityScore(score.ProductiveSeconds, score.DistractiveSeconds) @@ -123,80 +131,3 @@ func usageDisplayName(usage ApplicationUsage) string { } 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 -} diff --git a/internal/usage/service_usage_test.go b/internal/usage/service_usage_test.go index 41df4d5..8131286 100644 --- a/internal/usage/service_usage_test.go +++ b/internal/usage/service_usage_test.go @@ -294,7 +294,7 @@ func TestService_TitleChanged_PropogateClassificationFromLLM(t *testing.T) { require.NoError(t, db.Preload("Tags").Where("ended_at IS NULL").First(&readApplicationUsage).Error) require.Equal(t, usage.ClassificationProductive, readApplicationUsage.Classification) - require.Equal(t, usage.ClassificationSourceCloudLLM, readApplicationUsage.ClassificationSource) + require.Equal(t, usage.ClassificationSourceCloudLLMGemini, readApplicationUsage.ClassificationSource) require.Equal(t, "Productive work communication", readApplicationUsage.ClassificationReasoning) require.Equal(t, float32(0.95), readApplicationUsage.ClassificationConfidence) diff --git a/internal/usage/types_db.go b/internal/usage/types_db.go index 0fbff47..3d0795a 100644 --- a/internal/usage/types_db.go +++ b/internal/usage/types_db.go @@ -79,6 +79,32 @@ func (a *ApplicationUsage) TableName() string { return "application_usage" } +func (a *ApplicationUsage) IsCommunicationUsage() bool { + if fromPtr(a.DetectedCommunicationChannel) != "" { + return true + } + + for _, tag := range a.Tags { + if tag.Tag == "communication" { + return true + } + } + + return false +} + +func (a *ApplicationUsage) CommunicationChannel() string { + return fromPtr(a.DetectedCommunicationChannel) +} + +func (a *ApplicationUsage) HasDetectedProject() bool { + return a.GetDetectedProject() != "" +} + +func (a *ApplicationUsage) GetDetectedProject() string { + return fromPtr(a.DetectedProject) +} + // Same returns true if the application usage is the same as the given application usage // // Application usage is considered the same if: diff --git a/internal/usage/types_insights.go b/internal/usage/types_insights.go index bfb4943..f4a32e7 100644 --- a/internal/usage/types_insights.go +++ b/internal/usage/types_insights.go @@ -4,31 +4,21 @@ type DayInsights struct { ProductivityScore ProductivityScore `json:"productivity_score"` ProductivityPerHourBreakdown ProductivityPerHourBreakdown `json:"productivity_per_hour_breakdown"` LLMDailySummary *LLMDailySummary `json:"llm_daily_summary"` - TopDistractions []DistractionBreakdown `json:"top_distractions"` - TopBlocked []BlockedBreakdown `json:"top_blocked"` - ProjectBreakdown []ProjectBreakdown `json:"project_breakdown"` + TopDistractions map[string]int `json:"top_distractions"` + TopBlocked map[string]int `json:"top_blocked"` + ProjectBreakdown map[string]int `json:"project_breakdown"` CommunicationBreakdown map[string]CommunicationBreakdown `json:"communication_breakdown"` } -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"` + Name string `json:"name"` + DurationSeconds int `json:"duration_seconds"` } type CommunicationBreakdown struct { - Name string `json:"name"` - Channel string `json:"channel"` - Minutes int `json:"minutes"` + Name string `json:"name"` + Channel string `json:"channel"` + DurationSeconds int `json:"duration_seconds"` } type ProductivityScore struct { diff --git a/internal/usage/types_usage.go b/internal/usage/types_usage.go index 517ad23..219e02b 100644 --- a/internal/usage/types_usage.go +++ b/internal/usage/types_usage.go @@ -18,10 +18,10 @@ const ( TerminationModeSourceWhitelist TerminationModeSource = "whitelist" TerminationModeSourcePaused TerminationModeSource = "paused" - ClassificationSourceUserSet ClassificationSource = "user_set" - ClassificationSourceObviously ClassificationSource = "obviously" - ClassificationSourceCloudLLM ClassificationSource = "llm" - ClassificationSourceCustomRules ClassificationSource = "custom_rules" + ClassificationSourceUserSet ClassificationSource = "user_set" + ClassificationSourceObviously ClassificationSource = "obviously" + ClassificationSourceCustomRules ClassificationSource = "custom_rules" + ClassificationSourceCloudLLMGemini ClassificationSource = "llm_gemini" IdleApplicationName = "Idle" diff --git a/main.go b/main.go index 8554180..b6293ae 100644 --- a/main.go +++ b/main.go @@ -10,9 +10,9 @@ import ( "log" "log/slog" "net/http" - "os/exec" "net/url" "os" + "os/exec" "path/filepath" "github.com/go-chi/chi/v5" @@ -193,6 +193,11 @@ func main() { return } + var url *string + if event.URL != "" { + url = &event.URL + } + err := usageService.TitleChanged( ctx, event.ExecutablePath, @@ -200,7 +205,7 @@ func main() { event.AppName, event.Icon, &event.BundleID, - &event.URL, + url, ) if err != nil { slog.Error("failed to handle title change", "error", err) From 78d408ece69d32cdecf91a5fb38da0ddbc63ddbc Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Wed, 11 Mar 2026 17:00:44 +0400 Subject: [PATCH 3/4] feat(usage): enrich app classification with BundleID and AppCategory metadata - Modified native macOS observer to fetch LSApplicationCategoryType from Info.plist. - Updated LLM classification prompts to utilize BundleID and AppCategory for higher precision. - Enhanced LLM output to include AI-inferred project and communication channel details. - Threaded new metadata through the service layer and persisted it in the database. - Added info tooltips to frontend Projects and Communication cards to explain AI inference. - Improved frontend channel sorting to prioritize duration. --- .../components/insights/bento-dashboard.tsx | 2 +- .../components/insights/categories-card.tsx | 13 +++++++- .../insights/communication-card.tsx | 20 ++++++++++-- internal/native/darwin.go | 19 +++++++++-- internal/native/types.go | 1 + internal/usage/classifier_llm.go | 4 +-- internal/usage/classifier_llm_apps.go | 32 +++++++++++++------ internal/usage/classifier_llm_test.go | 2 +- internal/usage/service_usage.go | 12 ++++--- internal/usage/service_usage_test.go | 7 ++-- internal/usage/types_db.go | 3 +- internal/usage/types_usage.go | 14 ++++++-- main.go | 15 +++++++-- 13 files changed, 112 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/insights/bento-dashboard.tsx b/frontend/src/components/insights/bento-dashboard.tsx index 956195c..da28d55 100644 --- a/frontend/src/components/insights/bento-dashboard.tsx +++ b/frontend/src/components/insights/bento-dashboard.tsx @@ -73,7 +73,7 @@ const buildSortedChannels = ( ): CommunicationBreakdown[] => { return Object.values(breakdown ?? {}) .filter((c): c is CommunicationBreakdown => c != null) - .sort((a, b) => a.channel.localeCompare(b.channel) || b.duration_seconds - a.duration_seconds); + .sort((a, b) => b.duration_seconds - a.duration_seconds); }; const buildSortedDistractions = ( diff --git a/frontend/src/components/insights/categories-card.tsx b/frontend/src/components/insights/categories-card.tsx index 94e5999..1f04cc2 100644 --- a/frontend/src/components/insights/categories-card.tsx +++ b/frontend/src/components/insights/categories-card.tsx @@ -1,6 +1,7 @@ -import { IconFolder, IconArrowRight } from "@tabler/icons-react"; +import { IconFolder, IconArrowRight, IconInfoCircle } from "@tabler/icons-react"; import { Link } from "@tanstack/react-router"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { formatDuration } from "@/lib/mock-data"; interface CategoriesCardProps { @@ -29,6 +30,16 @@ export function CategoriesCard({ projects }: CategoriesCardProps) { Projects + + + + + + + Project names are automatically inferred by AI from your application window titles (e.g., from your code editor or terminal). + + + Communication + + + + + + + Channels and conversation names are automatically inferred by AI from your messaging apps (e.g., Slack). + + +
- {channel.name} + + {channel.name} | {channel.channel} + {formatDuration(channel.duration_seconds)} diff --git a/internal/native/darwin.go b/internal/native/darwin.go index 9a8b2d7..659ec99 100644 --- a/internal/native/darwin.go +++ b/internal/native/darwin.go @@ -30,7 +30,7 @@ static int checkAutomationPermission(const char* bundleID) { } // Forward declaration of Go callback -extern void goOnTitleChange(int pid, char* bundleID, char* title, char* appName, char* executablePath, char* appIcon); +extern void goOnTitleChange(int pid, char* bundleID, char* title, char* appName, char* executablePath, char* appIcon, char* appCategory); // Global state static AXObserverRef gObserver = NULL; @@ -77,7 +77,18 @@ static void emitTitleChange(pid_t pid, NSString* title) { } const char* appIconStr = [appIconBase64 UTF8String] ?: ""; - goOnTitleChange((int)pid, (char*)bundleIDStr, (char*)titleStr, (char*)appNameStr, (char*)execPathStr, (char*)appIconStr); + // Get app category from Info.plist (LSApplicationCategoryType) + NSString* appCategoryStr = @""; + if (app.bundleURL) { + NSBundle* appBundle = [NSBundle bundleWithURL:app.bundleURL]; + if (appBundle) { + NSString* cat = [appBundle objectForInfoDictionaryKey:@"LSApplicationCategoryType"]; + if (cat) appCategoryStr = cat; + } + } + const char* appCategoryC = [appCategoryStr UTF8String] ?: ""; + + goOnTitleChange((int)pid, (char*)bundleIDStr, (char*)titleStr, (char*)appNameStr, (char*)execPathStr, (char*)appIconStr, (char*)appCategoryC); } // AXObserver callback @@ -246,7 +257,7 @@ func startObserver() { } //export goOnTitleChange -func goOnTitleChange(cPID C.int, cBundleID *C.char, cTitle *C.char, cAppName *C.char, cExecutablePath *C.char, cAppIcon *C.char) { +func goOnTitleChange(cPID C.int, cBundleID *C.char, cTitle *C.char, cAppName *C.char, cExecutablePath *C.char, cAppIcon *C.char, cAppCategory *C.char) { // Copy C strings to Go strings synchronously (C memory may be freed after return) pid := int(cPID) bundleID := C.GoString(cBundleID) @@ -254,6 +265,7 @@ func goOnTitleChange(cPID C.int, cBundleID *C.char, cTitle *C.char, cAppName *C. appName := C.GoString(cAppName) executablePath := C.GoString(cExecutablePath) appIcon := C.GoString(cAppIcon) + appCategory := C.GoString(cAppCategory) go func() { var browserURL string @@ -282,6 +294,7 @@ func goOnTitleChange(cPID C.int, cBundleID *C.char, cTitle *C.char, cAppName *C. Title: title, AppIcon: appIcon, URL: browserURL, + AppCategory: appCategory, }) }() } diff --git a/internal/native/types.go b/internal/native/types.go index d7c6ac8..547f93a 100644 --- a/internal/native/types.go +++ b/internal/native/types.go @@ -29,6 +29,7 @@ type NativeEvent struct { Title string AppIcon string // base64 encoded PNG URL string + AppCategory string // LSApplicationCategoryType from Info.plist, e.g. "public.app-category.developer-tools" } func (e *NativeEvent) BrowserHostname() string { diff --git a/internal/usage/classifier_llm.go b/internal/usage/classifier_llm.go index a84ca48..e433854 100644 --- a/internal/usage/classifier_llm.go +++ b/internal/usage/classifier_llm.go @@ -2,10 +2,10 @@ package usage import "context" -func (s *Service) ClassifyWithLLM(ctx context.Context, appName, title string, url *string) (*ClassificationResponse, error) { +func (s *Service) ClassifyWithLLM(ctx context.Context, appName, title string, url, bundleID, appCategory *string) (*ClassificationResponse, error) { if url != nil { return s.classifyWebsite(ctx, *url, title) } - return s.classifyApplication(ctx, appName, title, url) + return s.classifyApplication(ctx, appName, title, bundleID, appCategory) } diff --git a/internal/usage/classifier_llm_apps.go b/internal/usage/classifier_llm_apps.go index 534d228..6e53649 100644 --- a/internal/usage/classifier_llm_apps.go +++ b/internal/usage/classifier_llm_apps.go @@ -6,12 +6,15 @@ import ( "strings" ) -func (s *Service) classifyApplication(ctx context.Context, appName, title string, url *string) (*ClassificationResponse, error) { +func (s *Service) classifyApplication(ctx context.Context, appName, title string, bundleID, appCategory *string) (*ClassificationResponse, error) { switch strings.ToLower(appName) { case "slack": return s.classifySlackApp(ctx, appName, title) } + bundleIDValue := fromPtr(bundleID) + appCategoryValue := fromPtr(appCategory) + var ( instructions = ` You are a Software Engineering Application Intent Classifier. Your job is to determine if the user is actively doing work related to their software engineering job or seeking entertainment/distraction. @@ -40,7 +43,20 @@ You are a Software Engineering Application Intent Classifier. Your job is to det **Includes:** - Email, Calendar, Notes, general file managers. - General communication apps without context (Slack, Teams). -- System settings or OS utilities. +- System settings or OS utilities. + +# Metadata Signals + +**Bundle ID** (when provided) is a precise, machine-readable identifier for the app (e.g. "com.apple.dt.Xcode", "com.spotify.client"). Use it as a strong classification signal. + +**App Store Category** (when provided) is Apple's own pre-assigned category for the app. Treat it as the strongest available signal: +- "public.app-category.developer-tools" -> productive +- "public.app-category.games" -> distracting +- "public.app-category.entertainment" -> distracting +- "public.app-category.social-networking" -> distracting +- "public.app-category.music" -> distracting (unless context suggests otherwise) +- "public.app-category.productivity" / "public.app-category.business" -> likely productive or neutral +- "public.app-category.utilities" -> neutral Return a JSON object with the following keys: 1. "classification": "productive", "neutral", or "distracting". @@ -48,22 +64,20 @@ Return a JSON object with the following keys: 3. "tags": Array of strings from ONLY these options: ["coding", "docs", "debug", "communication", "terminal", "planning", "learning", "entertainment", "news", "social", "shopping", "terminal", "other"]. - IMPORTANT: Be extremely conservative with the "communication" tag. Only use it when there is actual messaging, emailing, or chatting happening. Do NOT use it for reading code reviews, terminal multiplexers, or project management. 4. "confidence_score": Float (0.0 - 1.0) +5. "detected_project": If the window title clearly implies a project name. Return null if no project can be reliably inferred. +6. "detected_communication_channel": If the window title clearly implies a communication channel and the app has communication tag in the tags array, extract just the channel name (e.g. "engineering", "random"). Return null if no channel can be reliably inferred. ` inputTmpl = ` The user is currently using an application. Classify the activity based on the following information: Application Name: %s Window Title: %s -Executable Path: %s +Bundle ID: %s +App Store Category: %s ` ) - urlValue := "" - if url != nil { - urlValue = *url - } - - input := fmt.Sprintf(inputTmpl, appName, title, urlValue) + input := fmt.Sprintf(inputTmpl, appName, title, bundleIDValue, appCategoryValue) response, err := s.classifyWithGemini(ctx, instructions, input) if err != nil { diff --git a/internal/usage/classifier_llm_test.go b/internal/usage/classifier_llm_test.go index da63557..e5ae0c8 100644 --- a/internal/usage/classifier_llm_test.go +++ b/internal/usage/classifier_llm_test.go @@ -291,7 +291,7 @@ func TestClassify_Application_Slack(t *testing.T) { s, _ := setUpService(t, WithGenaiClient(genaiClient)) t.Run("extract channel name", func(t *testing.T) { - response, err := s.classifyApplication(context.Background(), "slack", "private-coin-team-chat (Channel) - Snyk - Slack", nil) + response, err := s.classifyApplication(context.Background(), "slack", "private-coin-team-chat (Channel) - Snyk - Slack", nil, nil) require.NoError(t, err, "failed to classify application") assert.Equal(t, ClassificationProductive, response.Classification) diff --git a/internal/usage/service_usage.go b/internal/usage/service_usage.go index 558c7b0..5395358 100644 --- a/internal/usage/service_usage.go +++ b/internal/usage/service_usage.go @@ -93,7 +93,7 @@ func (s *Service) IdleChanged(ctx context.Context, isIdle bool) error { // TitleChanged is called when the title of the current application changes, // whether it's a new application or the same application title has changed -func (s *Service) TitleChanged(ctx context.Context, executablePath, windowTitle, appName, icon string, bundleID, url *string) error { +func (s *Service) TitleChanged(ctx context.Context, executablePath, windowTitle, appName, icon string, bundleID, url, appCategory *string) error { s.mu.Lock() defer s.mu.Unlock() @@ -113,7 +113,7 @@ func (s *Service) TitleChanged(ctx context.Context, executablePath, windowTitle, return fmt.Errorf("failed to close current application usage: %w", err) } - application, err := s.getOrCreateApplication(ctx, appName, icon, bundleID, url) + application, err := s.getOrCreateApplication(ctx, appName, icon, bundleID, url, appCategory) if err != nil { return fmt.Errorf("failed to get or create application: %w", err) } @@ -240,7 +240,7 @@ func (s *Service) classifyApplicationUsage(ctx context.Context, applicationUsage } slog.Info("classifying application usage with LLM") - resp, err := s.ClassifyWithLLM(ctx, applicationUsage.Application.Name, applicationUsage.WindowTitle, applicationUsage.BrowserURL) + resp, err := s.ClassifyWithLLM(ctx, applicationUsage.Application.Name, applicationUsage.WindowTitle, applicationUsage.BrowserURL, applicationUsage.Application.BundleID, applicationUsage.Application.AppCategory) if err != nil { return nil, fmt.Errorf("failed to classify application usage with LLM: %w", err) } @@ -285,7 +285,7 @@ func (s *Service) classifyApplicationUsage(ctx context.Context, applicationUsage // Returns: // - Application: The found or newly created application record // - error: Any error encountered during database operations or favicon fetching -func (s *Service) getOrCreateApplication(ctx context.Context, name, icon string, bundleID, rawURL *string) (Application, error) { +func (s *Service) getOrCreateApplication(ctx context.Context, name, icon string, bundleID, rawURL, appCategory *string) (Application, error) { // Handle web applications (browser tabs with URLs) rawURLValue := fromPtr(rawURL) @@ -340,6 +340,8 @@ func (s *Service) getOrCreateApplication(ctx context.Context, name, icon string, } } + application.AppCategory = appCategory + if err := s.db.Save(&application).Error; err != nil { return Application{}, fmt.Errorf("failed to create application: %w", err) } @@ -370,6 +372,8 @@ func (s *Service) getOrCreateApplication(ctx context.Context, name, icon string, application.Icon = &icon } + application.AppCategory = appCategory + // Persist the application (creates new or updates existing) if err := s.db.Save(&application).Error; err != nil { return Application{}, fmt.Errorf("failed to create application: %w", err) diff --git a/internal/usage/service_usage_test.go b/internal/usage/service_usage_test.go index 8131286..a6a84bd 100644 --- a/internal/usage/service_usage_test.go +++ b/internal/usage/service_usage_test.go @@ -186,7 +186,7 @@ func TestService_TitleChanged_WhenSameApplication_ContinueCurrentApplicationUsag } // change the title of the current application - err := service.TitleChanged(ctx, "/Applications/Slack.app/Contents/MacOS/Slack", "Slack", "Slack", "", nil, nil) + err := service.TitleChanged(ctx, "/Applications/Slack.app/Contents/MacOS/Slack", "Slack", "Slack", "", nil, nil, nil) require.NoError(t, err, "failed to change title") // read the application usage @@ -216,7 +216,7 @@ func TestService_TitleChanged_WhenDifferentApplication_CloseCurrentApplicationUs } // change the title of the current application - err := service.TitleChanged(ctx, "com.apple.Safari", "Safari", "New Tab", "", nil, nil) + err := service.TitleChanged(ctx, "com.apple.Safari", "Safari", "New Tab", "", nil, nil, nil) require.NoError(t, err, "failed to change title") // read the application usage @@ -232,7 +232,7 @@ func TestService_TitleChanged_ClassificationErrorStored(t *testing.T) { // setup a service with a mock settings service that returns invalid custom rules to trigger a classification error usageService, db := setUpServiceWithSettings(t, "invalid custom rules") - err := usageService.TitleChanged(context.Background(), "com.apple.Safari", "Safari", "New Tab", "", nil, nil) + err := usageService.TitleChanged(context.Background(), "com.apple.Safari", "Safari", "New Tab", "", nil, nil, nil) require.Nil(t, err) var readApplicationUsage usage.ApplicationUsage @@ -286,6 +286,7 @@ func TestService_TitleChanged_PropogateClassificationFromLLM(t *testing.T) { "", nil, &url, + nil, ) require.NoError(t, err, "failed to change title") diff --git a/internal/usage/types_db.go b/internal/usage/types_db.go index 3d0795a..d69f5f9 100644 --- a/internal/usage/types_db.go +++ b/internal/usage/types_db.go @@ -32,7 +32,8 @@ type Application struct { Domain *string `json:"domain"` // darwin only - BundleID *string `json:"bundle_id"` + BundleID *string `json:"bundle_id"` + AppCategory *string `json:"app_category"` // LSApplicationCategoryType, e.g. "public.app-category.developer-tools" } func (a Application) TableName() string { diff --git a/internal/usage/types_usage.go b/internal/usage/types_usage.go index 219e02b..ccc23eb 100644 --- a/internal/usage/types_usage.go +++ b/internal/usage/types_usage.go @@ -47,9 +47,17 @@ type ClassificationResponse struct { ClassificationSource ClassificationSource `json:"classification_source"` Reasoning string `json:"reasoning"` ConfidenceScore float32 `json:"confidence_score"` - DetectedProject string `json:"detected_project"` - DetectedCommunicationChannel string `json:"detected_communication_channel"` - Tags []string `json:"tags"` + // DetectedProject is inferred by the LLM from the window title or channel name. + // For coding apps (VS Code, Xcode, etc.), it extracts the workspace/project name from the title format. + // For communication apps (Slack), it extracts the project/team context if strongly implied by the channel name. + DetectedProject string `json:"detected_project"` + + // DetectedCommunicationChannel is inferred by the LLM from the window title for communication apps. + // E.g., for Slack it extracts "engineering" from "Slack | #engineering | Acme Corp". + // This is only populated when the "communication" tag is assigned. + DetectedCommunicationChannel string `json:"detected_communication_channel"` + + Tags []string `json:"tags"` SandboxContext string `json:"sandbox_context"` SandboxResponse *string `json:"sandbox_response"` diff --git a/main.go b/main.go index b6293ae..81cedf6 100644 --- a/main.go +++ b/main.go @@ -193,10 +193,20 @@ func main() { return } - var url *string + var ( + url *string + bundleID *string + category *string + ) if event.URL != "" { url = &event.URL } + if event.BundleID != "" { + bundleID = &event.BundleID + } + if event.AppCategory != "" { + category = &event.AppCategory + } err := usageService.TitleChanged( ctx, @@ -204,8 +214,9 @@ func main() { event.Title, event.AppName, event.Icon, - &event.BundleID, + bundleID, url, + category, ) if err != nil { slog.Error("failed to handle title change", "error", err) From ecfde803c786d79237863a5969245d4b03d86f76 Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Wed, 11 Mar 2026 18:49:37 +0400 Subject: [PATCH 4/4] feat(ui): add sleek search input for blocked distractions --- frontend/src/routes/activity.tsx | 60 ++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/frontend/src/routes/activity.tsx b/frontend/src/routes/activity.tsx index fc65ef1..b6e46bb 100644 --- a/frontend/src/routes/activity.tsx +++ b/frontend/src/routes/activity.tsx @@ -7,6 +7,7 @@ import { IconShield, IconChevronDown, IconClock, + IconSearch, } from "@tabler/icons-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -123,6 +124,8 @@ function ActivityPage() { const allowedItems = useUsageStore((state) => state.allowedItems); const blockedItems = useUsageStore((state) => state.blockedItems); // Subscribe to blocked items map + const [searchQuery, setSearchQuery] = useState(""); + // Defer rendering of the full list to make navigation instant const [renderCount, setRenderCount] = useState(15); @@ -214,6 +217,19 @@ function ActivityPage() { return result.sort((a, b) => (b.usage.started_at ?? 0) - (a.usage.started_at ?? 0)); }, [getBlockedItemsList, blockedItems, allowedItems, recentUsages]); + const filteredBlockedUsages = useMemo(() => { + if (!searchQuery) return blockedUsagesDisplay; + const q = searchQuery.toLowerCase(); + return blockedUsagesDisplay.filter((item) => { + const { usage } = item; + const name = usage.application?.name?.toLowerCase() || ""; + const host = usage.application?.hostname?.toLowerCase() || ""; + const title = usage.window_title?.toLowerCase() || ""; + const tags = usage.tags?.map((t) => t.tag.toLowerCase()).join(" ") || ""; + return name.includes(q) || host.includes(q) || title.includes(q) || tags.includes(q); + }); + }, [blockedUsagesDisplay, searchQuery]); + return (
@@ -222,26 +238,42 @@ function ActivityPage() { {blockedUsagesDisplay.length > 0 && (
-
-

+

+

Blocked Distractions Today

- - {blockedUsagesDisplay.filter((b) => !b.isAllowed).length} PREVENTED - +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search..." + className="h-6 w-20 focus:w-40 bg-transparent text-xs text-white/70 pl-5 pr-0 outline-none placeholder:text-white/20 transition-all focus:placeholder:opacity-0 cursor-pointer focus:cursor-text" + /> +
+ + {filteredBlockedUsages.filter((b) => !b.isAllowed).length} PREVENTED + +
- {blockedUsagesDisplay.map((item) => ( - - ))} + {filteredBlockedUsages.length === 0 ? ( +
No matches found
+ ) : ( + filteredBlockedUsages.map((item) => ( + + )) + )}