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..da28d55 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,
@@ -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 { 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 +32,83 @@ 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) => 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"];
function HourlyBreakdownChart({
hourlyData,
}: {
- hourlyData: UsagePerHourBreakdown[];
+ hourlyData: HourlySlot[];
}) {
const [visible, setVisible] = useState>({
productive: true,
@@ -76,48 +144,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 (
+
+
+
+ {formatDuration(val)}
+
+
+ );
+ })}
+ {(() => {
+ 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 (
+
+
+
+ {formatDuration(untrackedMinutes * 60)}
+
+
+ );
+ })()}
+
@@ -184,19 +307,17 @@ 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 productiveMinutes = Math.round(productiveSeconds / 60);
- const distractiveMinutes = Math.round(distractiveSeconds / 60);
+ const focusScore = Math.round(overview?.productivity_score?.productivity_score ?? 0);
- // 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 +373,9 @@ export function BentoDashboard() {
{/* Row 0: LLM Summary (At the top if it exists) */}
- {overview?.DailyUsageSummary && (
+ {overview?.llm_daily_summary && (
)}
@@ -324,7 +445,7 @@ export function BentoDashboard() {
Productive
- {formatMinutes(productiveMinutes)}
+ {formatDuration(productiveSeconds)}
Deep focus time
@@ -339,7 +460,7 @@ export function BentoDashboard() {
Distractive
- {formatMinutes(distractiveMinutes)}
+ {formatDuration(distractiveSeconds)}
Time lost
@@ -373,14 +494,14 @@ 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..1f04cc2 100644
--- a/frontend/src/components/insights/categories-card.tsx
+++ b/frontend/src/components/insights/categories-card.tsx
@@ -1,11 +1,11 @@
-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 { formatMinutes } from "@/lib/mock-data";
-import type { ProjectBreakdown } from "@/../bindings/github.com/focusd-so/focusd/internal/usage/models";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { formatDuration } from "@/lib/mock-data";
interface CategoriesCardProps {
- projects: ProjectBreakdown[];
+ projects: { name: string; duration_seconds: number }[];
}
const projectColors = [
@@ -18,8 +18,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);
@@ -30,12 +30,22 @@ 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).
+
+
+
- {formatMinutes(totalMinutes)} total
+ {formatDuration(totalSeconds)} total
@@ -47,7 +57,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 +71,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 ce8c4cf..28162d3 100644
--- a/frontend/src/components/insights/communication-card.tsx
+++ b/frontend/src/components/insights/communication-card.tsx
@@ -1,12 +1,10 @@
-import { IconMessages, IconArrowRight } from "@tabler/icons-react";
+import { IconMessages, IconArrowRight, IconInfoCircle } 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { formatDuration } 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,9 +22,9 @@ const channelBarColors: Record = {
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);
+export function CommunicationCard({ channels }: { channels: CommunicationBreakdown[] }) {
+ // const totalMinutes = channels.reduce((sum, c) => sum + c.minutes, 0);
+ const maxSeconds = Math.max(...channels.map((c) => c.duration_seconds), 1);
return (
@@ -35,12 +33,22 @@ export function CommunicationCard({ channels }: CommunicationCardProps) {
Communication
+
+
+
+
+
+
+ Channels and conversation names are automatically inferred by AI from your messaging apps (e.g., Slack).
+
+
+
- {formatMinutes(totalMinutes)} total
+ {/* {formatMinutes(totalMinutes)} total */}
@@ -51,21 +59,26 @@ export function CommunicationCard({ channels }: CommunicationCardProps) {
No communication activity
) : (
- channels.slice(0, 3).map((channel, index) => {
- const textColor = channelTextColors[channel.name] || "text-muted-foreground";
- const widthPct = (channel.minutes / maxMinutes) * 100;
+ channels.slice(0, 5).map((channel, index) => {
+ const textColor = channelTextColors[channel.channel] || "text-muted-foreground";
+ const widthPct = (channel.duration_seconds / maxSeconds) * 100;
return (
- {channel.name}
+
+ {channel.name} | {channel.channel}
+
- {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/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) => (
+
+ ))
+ )}
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/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 60a6893..6e53649 100644
--- a/internal/usage/classifier_llm_apps.go
+++ b/internal/usage/classifier_llm_apps.go
@@ -3,9 +3,18 @@ package usage
import (
"context"
"fmt"
+ "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.
@@ -34,29 +43,102 @@ 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".
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)
+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, bundleIDValue, appCategoryValue)
+
+ response, err := s.classifyWithGemini(ctx, instructions, input)
+ if err != nil {
+ return nil, fmt.Errorf("failed to classify with Gemini: %w", err)
}
- input := fmt.Sprintf(inputTmpl, appName, title, urlValue)
+ 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
+`
+ 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 {
diff --git a/internal/usage/classifier_llm_test.go b/internal/usage/classifier_llm_test.go
index 6793810..e5ae0c8 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, 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 c5d3fec..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"
)
@@ -20,6 +19,15 @@ func (s *Service) GetDayInsights(date time.Time) (DayInsights, error) {
score := ProductivityScore{}
hourly := make(ProductivityPerHourBreakdown)
+ insights := DayInsights{
+ ProductivityScore: score,
+ ProductivityPerHourBreakdown: hourly,
+ TopDistractions: make(map[string]int),
+ TopBlocked: make(map[string]int),
+ ProjectBreakdown: make(map[string]int),
+ CommunicationBreakdown: make(map[string]CommunicationBreakdown),
+ }
+
for i, usage := range usages {
end := resolveEndTime(usage, usages, i)
if end <= usage.StartedAt {
@@ -35,6 +43,28 @@ func (s *Service) GetDayInsights(date time.Time) (DayInsights, error) {
entry.addSeconds(usage.Classification, secs, isIdle)
hourly[hour] = entry
}
+
+ if usage.IsCommunicationUsage() {
+ key := fmt.Sprintf("%s:%s", usage.Application.Name, usage.CommunicationChannel())
+
+ entry := insights.CommunicationBreakdown[key]
+ 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)
@@ -43,14 +73,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 {
@@ -107,103 +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
-}
-
-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_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 41df4d5..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")
@@ -294,7 +295,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..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 {
@@ -79,6 +80,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 5b0d57a..f4a32e7 100644
--- a/internal/usage/types_insights.go
+++ b/internal/usage/types_insights.go
@@ -1,41 +1,32 @@
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"`
+ ProductivityScore ProductivityScore `json:"productivity_score"`
+ ProductivityPerHourBreakdown ProductivityPerHourBreakdown `json:"productivity_per_hour_breakdown"`
+ LLMDailySummary *LLMDailySummary `json:"llm_daily_summary"`
+ 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 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"`
- Minutes int `json:"minutes"`
+ Name string `json:"name"`
+ Channel string `json:"channel"`
+ DurationSeconds int `json:"duration_seconds"`
}
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) {
diff --git a/internal/usage/types_usage.go b/internal/usage/types_usage.go
index 517ad23..ccc23eb 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"
@@ -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 8554180..81cedf6 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,14 +193,30 @@ func main() {
return
}
+ 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,
event.ExecutablePath,
event.Title,
event.AppName,
event.Icon,
- &event.BundleID,
- &event.URL,
+ bundleID,
+ url,
+ category,
)
if err != nil {
slog.Error("failed to handle title change", "error", err)