diff --git a/frontend/src/components/custom-rules.tsx b/frontend/src/components/custom-rules.tsx index 5d897ab..8e4d522 100644 --- a/frontend/src/components/custom-rules.tsx +++ b/frontend/src/components/custom-rules.tsx @@ -7,368 +7,76 @@ import { useAccountStore } from "@/stores/account-store"; import { DeviceHandshakeResponse_AccountTier } from "../../bindings/github.com/focusd-so/focusd/gen/api/v1/models"; import { Browser } from "@wailsio/runtime"; import { Button } from "@/components/ui/button"; -import { IconDeviceFloppy, IconFileText, IconTerminal, IconTestPipe, IconCrown } from "@tabler/icons-react"; +import { IconBook, IconCrown, IconDeviceFloppy, IconFileText, IconTerminal, IconTestPipe } from "@tabler/icons-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip"; import { ExecutionLogsSheet } from "@/components/execution-logs"; import { TestRulesSheet } from "@/components/test-rules-sheet"; +import { RulesReferenceSheet } from "@/components/rules-reference-sheet"; +import { STARTER_RULES_TS } from "@/lib/rules/starter-template"; +import { RUNTIME_TYPES_FILE_PATH, RUNTIME_TYPES_SOURCE } from "@/lib/rules/runtime-types"; -const TYPES_FILE_PATH = "file:///focusd-types.d.ts"; const SETTINGS_KEY = "custom_rules"; const DRAFT_STORAGE_KEY = "focusd_custom_rules_draft"; -const typeDefinitions = ` -/** - * Represents the type of activity classification. - */ -type ClassificationType = "unknown" | "productive" | "distracting" | "neutral" | "system"; - -/** - * Global constant for classification values. - * Use these values when returning a ClassificationDecision. - * @example - * return { - * classification: Classification.Productive, - * classificationReasoning: "Work-related activity" - * }; - */ -declare const Classification: { - readonly Unknown: "unknown"; - readonly Productive: "productive"; - readonly Distracting: "distracting"; - readonly Neutral: "neutral"; - readonly System: "system"; -}; - -/** - * Determines whether to block or allow the activity. - */ -type EnforcementActionType = "none" | "block" | "paused" | "allow"; - -/** - * Global constant for termination mode values. - * Use these values when returning a EnforcementDecision. - * @example - * return { - * enforcementAction: EnforcementAction.Block, - * enforcementReason: "Blocked during focus hours" - * }; - */ -declare const EnforcementAction: { - readonly None: "none"; - readonly Block: "block"; - readonly Paused: "paused"; - readonly Allow: "allow"; -}; - -/** - * Decision returned from the enforcementDecision function. - */ -interface EnforcementDecision { - /** The termination mode to apply. Use EnforcementAction constants. */ - enforcementAction: EnforcementActionType; - /** Human-readable explanation for why this decision was made. */ - enforcementReason: string; -} - -/** - * Decision returned from the classify function. - */ -interface ClassificationDecision { - /** The classification to apply. Use Classification constants. */ - classification: ClassificationType; - /** Human-readable explanation for why this classification was chosen. */ - classificationReasoning: string; -} - -/** - * Provides context for the current rule execution including usage data. - */ -interface UsageContext { - /** The display name of the application (e.g., 'Safari', 'Slack'). */ - readonly appName?: string; - /** The application's bundle identifier (e.g., 'com.apple.Safari'). */ - readonly bundleID: string; - /** The hostname if the activity is a website (e.g., 'www.github.com'). */ - readonly hostname: string; - /** The registered domain extracted from the hostname (e.g., 'github.com'). */ - readonly domain: string; - /** The full URL if available. */ - readonly url: string; - /** The current classification of this usage (may be empty if not yet classified). */ - readonly classification: string; - /** Minutes since this app/site was last blocked (-1 if never blocked). */ - readonly minutesSinceLastBlock: number; - /** Total minutes of usage since this app/site was last blocked (-1 if never blocked). */ - readonly minutesUsedSinceLastBlock: number; - /** - * Returns total minutes this app/site was used in the last N minutes. - * @param minutes - The time window to check (e.g., 60 for last hour, 30 for last 30 minutes) - * @returns Total minutes of usage in the specified time window - * @example - * // Block if used more than 30 minutes in the last hour - * if (context.minutesUsedInPeriod(60) > 30) { - * return { enforcementAction: EnforcementAction.Block, enforcementReason: 'Usage limit exceeded' }; - * } - */ - minutesUsedInPeriod(minutes: number): number; -} - -// ============ Timezone Constants ============ - -/** - * Common IANA timezone constants for use with now() and dayOfWeek(). - * Type Timezone. to see autocomplete suggestions. - * @example - * const londonTime = now(Timezone.Europe_London); - * const tokyoDay = dayOfWeek(Timezone.Asia_Tokyo); - */ -declare const Timezone: { - // Americas - readonly America_New_York: "America/New_York"; - readonly America_Chicago: "America/Chicago"; - readonly America_Denver: "America/Denver"; - readonly America_Los_Angeles: "America/Los_Angeles"; - readonly America_Anchorage: "America/Anchorage"; - readonly America_Toronto: "America/Toronto"; - readonly America_Vancouver: "America/Vancouver"; - readonly America_Mexico_City: "America/Mexico_City"; - readonly America_Sao_Paulo: "America/Sao_Paulo"; - readonly America_Buenos_Aires: "America/Buenos_Aires"; - readonly America_Bogota: "America/Bogota"; - readonly America_Santiago: "America/Santiago"; - // Europe - readonly Europe_London: "Europe/London"; - readonly Europe_Paris: "Europe/Paris"; - readonly Europe_Berlin: "Europe/Berlin"; - readonly Europe_Madrid: "Europe/Madrid"; - readonly Europe_Rome: "Europe/Rome"; - readonly Europe_Amsterdam: "Europe/Amsterdam"; - readonly Europe_Zurich: "Europe/Zurich"; - readonly Europe_Brussels: "Europe/Brussels"; - readonly Europe_Stockholm: "Europe/Stockholm"; - readonly Europe_Oslo: "Europe/Oslo"; - readonly Europe_Helsinki: "Europe/Helsinki"; - readonly Europe_Warsaw: "Europe/Warsaw"; - readonly Europe_Prague: "Europe/Prague"; - readonly Europe_Vienna: "Europe/Vienna"; - readonly Europe_Athens: "Europe/Athens"; - readonly Europe_Bucharest: "Europe/Bucharest"; - readonly Europe_Istanbul: "Europe/Istanbul"; - readonly Europe_Moscow: "Europe/Moscow"; - readonly Europe_Dublin: "Europe/Dublin"; - readonly Europe_Lisbon: "Europe/Lisbon"; - // Asia - readonly Asia_Dubai: "Asia/Dubai"; - readonly Asia_Riyadh: "Asia/Riyadh"; - readonly Asia_Tehran: "Asia/Tehran"; - readonly Asia_Kolkata: "Asia/Kolkata"; - readonly Asia_Dhaka: "Asia/Dhaka"; - readonly Asia_Bangkok: "Asia/Bangkok"; - readonly Asia_Singapore: "Asia/Singapore"; - readonly Asia_Hong_Kong: "Asia/Hong_Kong"; - readonly Asia_Shanghai: "Asia/Shanghai"; - readonly Asia_Tokyo: "Asia/Tokyo"; - readonly Asia_Seoul: "Asia/Seoul"; - readonly Asia_Taipei: "Asia/Taipei"; - readonly Asia_Jakarta: "Asia/Jakarta"; - readonly Asia_Manila: "Asia/Manila"; - readonly Asia_Karachi: "Asia/Karachi"; - readonly Asia_Jerusalem: "Asia/Jerusalem"; - readonly Asia_Yerevan: "Asia/Yerevan"; - readonly Asia_Tbilisi: "Asia/Tbilisi"; - readonly Asia_Baku: "Asia/Baku"; - // Africa - readonly Africa_Cairo: "Africa/Cairo"; - readonly Africa_Lagos: "Africa/Lagos"; - readonly Africa_Johannesburg: "Africa/Johannesburg"; - readonly Africa_Nairobi: "Africa/Nairobi"; - readonly Africa_Casablanca: "Africa/Casablanca"; - // Oceania - readonly Australia_Sydney: "Australia/Sydney"; - readonly Australia_Melbourne: "Australia/Melbourne"; - readonly Australia_Perth: "Australia/Perth"; - readonly Australia_Brisbane: "Australia/Brisbane"; - readonly Pacific_Auckland: "Pacific/Auckland"; - readonly Pacific_Honolulu: "Pacific/Honolulu"; - // UTC - readonly UTC: "UTC"; -}; - -// ============ Weekday Constants ============ - -/** - * Weekday enum values returned by dayOfWeek(). - * Use Weekday.Monday, Weekday.Tuesday, etc. for comparisons. - * @example - * if (dayOfWeek() === Weekday.Friday) { ... } - */ -declare const Weekday: { - readonly Sunday: "Sunday"; - readonly Monday: "Monday"; - readonly Tuesday: "Tuesday"; - readonly Wednesday: "Wednesday"; - readonly Thursday: "Thursday"; - readonly Friday: "Friday"; - readonly Saturday: "Saturday"; -}; - -type WeekdayType = "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday"; - -/** - * Boolean constants for the current day of the week (local timezone). - * For timezone-specific checks, use dayOfWeek(Timezone.X) === Weekday.Monday. - * @example - * if (IsMonday) { ... } - * if (IsWeekend) { ... } - */ -declare const IsMonday: boolean; -declare const IsTuesday: boolean; -declare const IsWednesday: boolean; -declare const IsThursday: boolean; -declare const IsFriday: boolean; -declare const IsSaturday: boolean; -declare const IsSunday: boolean; -declare const IsWeekday: boolean; -declare const IsWeekend: boolean; - -// ============ Global Helper Functions ============ - -/** - * Returns a Date object for the current time in the specified IANA timezone. - * Use Timezone.* constants for autocomplete, or pass any valid IANA timezone string. - * If no timezone is provided or the string is invalid, uses local time. - * @param timezone - IANA timezone (e.g. Timezone.Europe_London, Timezone.Asia_Tokyo) - * @returns A Date object representing the current time - * @example - * const currentTime = now(); - * const londonTime = now(Timezone.Europe_London); - * if (now(Timezone.America_New_York).getHours() >= 22) { - * // After 10 PM in New York - * } - */ -declare function now(timezone?: string): Date; - -/** - * Returns the day of the week for the current time in the specified IANA timezone. - * @param timezone - IANA timezone (e.g. Timezone.Europe_London) - * @returns The day name: 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', or 'Saturday' - * @example - * if (dayOfWeek(Timezone.Europe_London) === 'Saturday' || dayOfWeek(Timezone.Europe_London) === 'Sunday') { - * // Weekend in London - * } - */ -declare function dayOfWeek(timezone?: string): WeekdayType; - -/** - * Console logging (output appears in application logs). - */ -declare const console: { - log(...args: unknown[]): void; - info(...args: unknown[]): void; - warn(...args: unknown[]): void; - error(...args: unknown[]): void; - debug(...args: unknown[]): void; -}; -`; - -const starterRulesTS = `/** - * Custom classification logic. - * Return a ClassificationDecision to override the default, or undefined to keep the default. - * - * @example - * // Classify all GitHub activity as productive - * if (context.domain === 'github.com') { - * return { - * classification: Classification.Productive, - * classificationReasoning: 'GitHub is a development tool' - * }; - * } - */ -export function classify(context: UsageContext): ClassificationDecision | undefined { - return undefined; -} - -/** - * Custom termination logic (blocking). - * Return a EnforcementDecision to override the default, or undefined to keep the default. - * - * @example - * // Block social media after 10 PM in London - * if (context.domain === 'twitter.com' && now(Timezone.Europe_London).getHours() >= 22) { - * return { - * enforcementAction: EnforcementAction.Block, - * enforcementReason: 'Social media blocked after 10 PM' - * }; - * } - */ -export function enforcementDecision(context: UsageContext): EnforcementDecision | undefined { - return undefined; -} -`; - export function CustomRules() { - const { - customRules, - updateSetting, - } = useSettingsStore(); - + const { customRules, updateSetting } = useSettingsStore(); const { checkoutLink, fetchAccountTier } = useAccountStore(); const { data: accountTier } = useQuery({ - queryKey: ['accountTier'], + queryKey: ["accountTier"], queryFn: () => fetchAccountTier(), }); const isFreeTier = accountTier === DeviceHandshakeResponse_AccountTier.DeviceHandshakeResponse_ACCOUNT_TIER_FREE; - - // Track unsaved draft changes - null means no local changes (use store value) const [draft, setDraft] = useState(null); const [logsOpen, setLogsOpen] = useState(false); const [testOpen, setTestOpen] = useState(false); - // Track whether to show the draft restoration banner + const [referenceOpen, setReferenceOpen] = useState(false); const [showDraftBanner, setShowDraftBanner] = useState(false); - const monacoRef = useRef(null); const editorRef = useRef(null); - // Derive the displayed value: local draft takes precedence, then store, then starter template - const displayedRules = draft ?? (customRules || starterRulesTS); + const displayedRules = draft ?? (customRules || STARTER_RULES_TS); const hasUnsavedChanges = draft !== null && draft !== customRules; - // Load draft from localStorage on mount useEffect(() => { const savedDraft = localStorage.getItem(DRAFT_STORAGE_KEY); - if (savedDraft) { - const savedValue = customRules || starterRulesTS; - // Only show banner if the draft differs from the current saved value - if (savedDraft !== savedValue) { - setShowDraftBanner(true); - } else { - // Draft matches saved value, clean it up - localStorage.removeItem(DRAFT_STORAGE_KEY); - } + if (!savedDraft) { + return; } + + const savedValue = customRules || STARTER_RULES_TS; + if (savedDraft !== savedValue) { + setShowDraftBanner(true); + return; + } + + localStorage.removeItem(DRAFT_STORAGE_KEY); }, [customRules]); - // Save draft to localStorage whenever it changes useEffect(() => { - if (draft !== null) { - const savedValue = customRules || starterRulesTS; - if (draft !== savedValue) { - localStorage.setItem(DRAFT_STORAGE_KEY, draft); - } else { - // Draft matches saved value, clean it up - localStorage.removeItem(DRAFT_STORAGE_KEY); - } + if (draft === null) { + return; + } + + const savedValue = customRules || STARTER_RULES_TS; + if (draft !== savedValue) { + localStorage.setItem(DRAFT_STORAGE_KEY, draft); + return; } + + localStorage.removeItem(DRAFT_STORAGE_KEY); }, [draft, customRules]); const handleRestoreDraft = useCallback(() => { const savedDraft = localStorage.getItem(DRAFT_STORAGE_KEY); - if (savedDraft) { - setDraft(savedDraft); - setShowDraftBanner(false); - toast.info("Draft restored. Click Save to apply changes."); + if (!savedDraft) { + return; } + + setDraft(savedDraft); + setShowDraftBanner(false); + toast.info("Draft restored. Click Save to apply changes."); }, []); const handleDiscardDraft = useCallback(() => { @@ -382,15 +90,14 @@ export function CustomRules() { }, []); const handleSave = useCallback(async () => { - // Early return if no changes to save if (draft === null || draft === customRules) { return; } try { await updateSetting(SETTINGS_KEY, draft); - setDraft(null); // Clear draft after successful save - localStorage.removeItem(DRAFT_STORAGE_KEY); // Clear localStorage draft + setDraft(null); + localStorage.removeItem(DRAFT_STORAGE_KEY); setShowDraftBanner(false); toast.success("Custom rules saved successfully"); } catch (error) { @@ -399,82 +106,71 @@ export function CustomRules() { } }, [draft, customRules, updateSetting]); - // Ref to hold the latest save function for keybinding const saveRef = useRef(handleSave); saveRef.current = handleSave; - const handleEditorMount = useCallback( - (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => { - editorRef.current = editor; - - // Add Cmd+S / Ctrl+S keybinding for save - editor.addAction({ - id: "save-custom-rules", - label: "Save Custom Rules", - keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], - run: () => { - saveRef.current(); - }, - }); - }, - [] - ); + const handleEditorMount = useCallback((instance: editor.IStandaloneCodeEditor, monaco: Monaco) => { + editorRef.current = instance; + + instance.addAction({ + id: "save-custom-rules", + label: "Save Custom Rules", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], + run: () => { + saveRef.current(); + }, + }); + }, []); const handleEditorWillMount = useCallback((monaco: Monaco) => { - monacoRef.current = monaco; - - // Add extraLib for intellisense - monaco.languages.typescript.typescriptDefaults.addExtraLib( - typeDefinitions, - TYPES_FILE_PATH - ); + monaco.languages.typescript.typescriptDefaults.addExtraLib(RUNTIME_TYPES_SOURCE, RUNTIME_TYPES_FILE_PATH); - // Create a model for the types file so Go to Definition works - const typesUri = monaco.Uri.parse(TYPES_FILE_PATH); + const typesUri = monaco.Uri.parse(RUNTIME_TYPES_FILE_PATH); if (!monaco.editor.getModel(typesUri)) { - monaco.editor.createModel(typeDefinitions, "typescript", typesUri); + monaco.editor.createModel(RUNTIME_TYPES_SOURCE, "typescript", typesUri); } }, []); return ( -
- {isFreeTier && ( -
-
-
- -
- Custom Rules are available on Plus or Pro plans. Upgrade to execute advanced logic. -
- -
- )} - +
- {/* Integrated Toolbar */}
rules.ts
- {!isFreeTier && ( + {isFreeTier ? ( + + + + + + +

What does this mean?

+

+ Custom rules will execute and you can view their logs, but they won't enforce blocks or warnings unless you upgrade to a Plus or Pro plan. +

+
+
+
+ ) : (
- Plus Feature + Plus
)} {hasUnsavedChanges && (
- - + + Unsaved
@@ -484,7 +180,7 @@ export function CustomRules() {
+ +
- {/* Draft restoration banner */} {showDraftBanner && (
- - Restorable draft found from a previous session. - + Restorable draft found from a previous session.
); diff --git a/frontend/src/components/hero-metric-cards.tsx b/frontend/src/components/hero-metric-cards.tsx index e698f23..647847a 100644 --- a/frontend/src/components/hero-metric-cards.tsx +++ b/frontend/src/components/hero-metric-cards.tsx @@ -11,7 +11,7 @@ import { Progress } from "@/components/ui/progress"; interface UsageStats { productive_minutes: number; neutral_minutes: number; - distractive_minutes: number; + distracting_minutes: number; productivity_score: number; } @@ -22,7 +22,7 @@ export type DailyStats = { date: number; productive_minutes: number; neutral_minutes: number; - distractive_minutes: number; + distracting_minutes: number; }; interface MetricCardProps { @@ -57,8 +57,8 @@ function getContextLabel(comparisonMode: ComparisonMode): string { } } -function calculateFocusScore(productive: number, distractive: number): number { - const total = productive + distractive; +function calculateFocusScore(productive: number, distracting: number): number { + const total = productive + distracting; if (total === 0) return 100; // No activity = perfect focus (no distractions) return (productive / total) * 100; } @@ -226,11 +226,11 @@ export function HeroMetricCards({ const totalActiveMinutes = stats.productive_minutes + stats.neutral_minutes + - stats.distractive_minutes; + stats.distracting_minutes; const focusScore = Number.isFinite(stats.productivity_score) ? stats.productivity_score - : calculateFocusScore(stats.productive_minutes, stats.distractive_minutes); + : calculateFocusScore(stats.productive_minutes, stats.distracting_minutes); return (
@@ -242,8 +242,8 @@ export function HeroMetricCards({ contextLabel={contextLabel} /> diff --git a/frontend/src/components/insights/bento-dashboard.tsx b/frontend/src/components/insights/bento-dashboard.tsx index da28d55..442e104 100644 --- a/frontend/src/components/insights/bento-dashboard.tsx +++ b/frontend/src/components/insights/bento-dashboard.tsx @@ -33,7 +33,7 @@ const MIN_SECONDS_FOR_INSIGHTS = 3600; const SERIES = [ { 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: "distracting", field: "distracting_seconds", label: "Distracting", 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; @@ -57,7 +57,7 @@ const buildHourlySlots = (breakdown: Record | null | return { HourLabel: formatHourLabel(hour), productive_seconds: score?.productive_seconds ?? 0, - distractive_seconds: score?.distractive_seconds ?? 0, + distracting_seconds: score?.distracting_seconds ?? 0, idle_seconds: score?.idle_seconds ?? 0, other_seconds: score?.other_seconds ?? 0, }; @@ -112,7 +112,7 @@ function HourlyBreakdownChart({ }) { const [visible, setVisible] = useState>({ productive: true, - distractive: true, + distracting: true, idle: false, other: false, }); @@ -164,9 +164,9 @@ function HourlyBreakdownChart({ ); } - // Stacking order top-to-bottom: distractive, other, idle, productive + // Stacking order top-to-bottom: distracting, other, idle, productive const segments = [ - { ...SERIES[1], seconds: visible.distractive ? hour.distractive_seconds : 0 }, + { ...SERIES[1], seconds: visible.distracting ? hour.distracting_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 }, @@ -308,8 +308,8 @@ export function BentoDashboard() { const isLoading = isStoreLoading || isQueryLoading; const productiveSeconds = overview?.productivity_score?.productive_seconds ?? 0; - const distractiveSeconds = overview?.productivity_score?.distractive_seconds ?? 0; - const totalTrackedSeconds = productiveSeconds + distractiveSeconds; + const distractingSeconds = overview?.productivity_score?.distracting_seconds ?? 0; + const totalTrackedSeconds = productiveSeconds + distractingSeconds; const hasEnoughData = totalTrackedSeconds >= MIN_SECONDS_FOR_INSIGHTS; const focusScore = Math.round(overview?.productivity_score?.productivity_score ?? 0); @@ -453,14 +453,14 @@ export function BentoDashboard() { - {/* Distractive Hours */} + {/* Distracting Hours */}

- Distractive + Distracting

- {formatDuration(distractiveSeconds)} + {formatDuration(distractingSeconds)}

Time lost diff --git a/frontend/src/components/rules-reference-sheet.tsx b/frontend/src/components/rules-reference-sheet.tsx new file mode 100644 index 0000000..3df2cf7 --- /dev/null +++ b/frontend/src/components/rules-reference-sheet.tsx @@ -0,0 +1,169 @@ +import { useMemo } from "react"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Button } from "@/components/ui/button"; +import { RULE_SNIPPETS } from "@/lib/rules/snippets"; +import { CodeBlock } from "@/components/ui/code-block"; +import { toast } from "sonner"; + +export function RulesReferenceSheet({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const examples = useMemo( + () => RULE_SNIPPETS.filter((snippet) => snippet.id !== "import-runtime"), + [] + ); + + const copySnippet = async (code: string) => { + try { + await navigator.clipboard.writeText(code); + toast.success("Example copied"); + } catch { + toast.error("Could not copy example"); + } + }; + + return ( + + + + Docs & Examples + + Runtime API reference and ready-to-use rule snippets + + + + +

+ + Docs + Examples + +
+ + + +
+
+

Import

+ +
+ +
+

Function Signature

+ +
+ +
+

Identity

+
    +
  • runtime.usage.app / title / domain / host / path / url
  • +
  • runtime.usage.classification
  • +
+
+ +
+

Day & Hour (Runtime)

+
    +
  • runtime.today.focusScore / runtime.today.productiveMinutes / runtime.today.distractingMinutes
  • +
  • runtime.hour.focusScore / runtime.hour.productiveMinutes / runtime.hour.distractingMinutes
  • +
+
+ +
+

Current App/Site

+
    +
  • runtime.usage.current.usedToday
  • +
  • runtime.usage.current.blocks
  • +
  • runtime.usage.current.sinceBlock
  • +
  • runtime.usage.current.usedSinceBlock
  • +
  • runtime.usage.current.last(60)
  • +
+
+ +
+

Migration

+
    +
  • Old style classify(usage) / enforcement(usage) is no longer supported.
  • +
+
+ +
+

Time (Runtime)

+
    +
  • runtime.time.now(Timezone.UTC)
  • +
  • runtime.time.day(Timezone.Europe_London)
  • +
+
+ +
+

Helpers

+
    +
  • productive(reason, tags?)
  • +
  • distracting(reason, tags?)
  • +
  • neutral(reason, tags?)
  • +
  • block(reason)
  • +
  • allow(reason)
  • +
  • pause(reason)
  • +
+
+
+
+
+ + + +
+ {examples.map((example) => { + const lineCount = example.code.split('\n').length; + const estimatedHeight = Math.max(50, lineCount * 22) + 20 + "px"; // 22px per line + padding + + return ( +
+
+

{example.title}

+

{example.description}

+
+ + +
+ ); + })} +
+
+
+ + + + ); +} diff --git a/frontend/src/components/ui/code-block.tsx b/frontend/src/components/ui/code-block.tsx new file mode 100644 index 0000000..7dbc366 --- /dev/null +++ b/frontend/src/components/ui/code-block.tsx @@ -0,0 +1,44 @@ +import Editor, { type Monaco } from "@monaco-editor/react"; +import { useCallback } from "react"; +import { RUNTIME_TYPES_FILE_PATH, RUNTIME_TYPES_SOURCE } from "@/lib/rules/runtime-types"; + +export function CodeBlock({ code, height = "100px" }: { code: string; height?: string }) { + const handleEditorWillMount = useCallback((monaco: Monaco) => { + monaco.languages.typescript.typescriptDefaults.addExtraLib(RUNTIME_TYPES_SOURCE, RUNTIME_TYPES_FILE_PATH); + + const typesUri = monaco.Uri.parse(RUNTIME_TYPES_FILE_PATH); + if (!monaco.editor.getModel(typesUri)) { + monaco.editor.createModel(RUNTIME_TYPES_SOURCE, "typescript", typesUri); + } + }, []); + + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/weekly-trend-chart.tsx b/frontend/src/components/weekly-trend-chart.tsx index 03d6f4b..261e74b 100644 --- a/frontend/src/components/weekly-trend-chart.tsx +++ b/frontend/src/components/weekly-trend-chart.tsx @@ -20,13 +20,13 @@ export type DailyStats = { date: number; productive_minutes: number; neutral_minutes: number; - distractive_minutes: number; + distracting_minutes: number; }; const trendChartConfig = { productive: { label: "Productive", color: "#22c55e" }, neutral: { label: "Neutral", color: "#eab308" }, - distractive: { label: "Distractive", color: "#ef4444" }, + distracting: { label: "Distracting", color: "#ef4444" }, } satisfies ChartConfig; interface WeeklyTrendChartProps { @@ -46,13 +46,13 @@ function generateMockData(): DailyStats[] { // Generate varied but realistic mock data const productiveBase = 240 + Math.floor(Math.random() * 180); // 4-7 hours const neutralBase = 30 + Math.floor(Math.random() * 60); // 0.5-1.5 hours - const distractiveBase = 20 + Math.floor(Math.random() * 80); // 0.3-1.6 hours + const distractingBase = 20 + Math.floor(Math.random() * 80); // 0.3-1.6 hours data.push({ date: Math.floor(date.getTime() / 1000), productive_minutes: productiveBase, neutral_minutes: neutralBase, - distractive_minutes: distractiveBase, + distracting_minutes: distractingBase, }); } @@ -88,7 +88,7 @@ export function WeeklyTrendChart({ day: getDayName(day.date), productive: minutesToHours(day.productive_minutes), neutral: minutesToHours(day.neutral_minutes), - distractive: minutesToHours(day.distractive_minutes), + distracting: minutesToHours(day.distracting_minutes), })); return ( @@ -144,9 +144,9 @@ export function WeeklyTrendChart({ radius={[0, 0, 0, 0]} /> diff --git a/frontend/src/lib/mock-data.ts b/frontend/src/lib/mock-data.ts index eb47f89..b337500 100644 --- a/frontend/src/lib/mock-data.ts +++ b/frontend/src/lib/mock-data.ts @@ -4,7 +4,7 @@ export interface DailyStats { date: number; // unix timestamp (start of day) productiveMinutes: number; neutralMinutes: number; - distractiveMinutes: number; + distractingMinutes: number; focusScore: number; deepWorkSessions: number; longestSessionMinutes: number; @@ -15,7 +15,7 @@ export interface DailyStats { export interface HourlyStats { hour: number; // 0-23 productiveMinutes: number; - distractiveMinutes: number; + distractingMinutes: number; neutralMinutes: number; } @@ -127,7 +127,7 @@ function generateHourlyBreakdown(seed: number): HourlyStats[] { // Work hours (9-18) have more activity const isWorkHour = hour >= 9 && hour <= 18; const baseProductive = isWorkHour ? 30 + (seed % 25) : seed % 5; - const baseDistractive = isWorkHour ? 2 + (seed % 8) : seed % 3; + const baseDistracting = isWorkHour ? 2 + (seed % 8) : seed % 3; const baseNeutral = isWorkHour ? 5 + (seed % 10) : seed % 2; // Add some variation based on hour @@ -137,7 +137,7 @@ function generateHourlyBreakdown(seed: number): HourlyStats[] { hours.push({ hour, productiveMinutes: Math.max(0, baseProductive + peakBonus + lunchDip + ((seed * hour) % 10)), - distractiveMinutes: Math.max(0, baseDistractive + ((seed * hour) % 5)), + distractingMinutes: Math.max(0, baseDistracting + ((seed * hour) % 5)), neutralMinutes: Math.max(0, baseNeutral + ((seed * hour) % 5)), }); } @@ -245,10 +245,10 @@ function generateAISummary( ); const peakWindow = `${peakHour.hour}:00 - ${peakHour.hour + 1}:00`; - // Find danger zone (hour with most distractive minutes during work hours) + // Find danger zone (hour with most distracting minutes during work hours) const workHours = hourlyBreakdown.filter((h) => h.hour >= 9 && h.hour <= 18); const dangerHour = workHours.reduce((worst, h) => - h.distractiveMinutes > worst.distractiveMinutes ? h : worst + h.distractingMinutes > worst.distractingMinutes ? h : worst ); const dangerZone = `${dangerHour.hour}:00 - ${dangerHour.hour + 1}:00`; @@ -267,7 +267,7 @@ function generateAISummary( stats.focusScore >= 75 ? `Great focus day! You maintained ${stats.focusScore}% productivity and blocked ${totalBlocked} distractions.` : stats.focusScore >= 50 - ? `Decent focus with room for improvement. ${stats.focusScore}% productive, with ${stats.distractiveMinutes}m lost to distractions.` + ? `Decent focus with room for improvement. ${stats.focusScore}% productive, with ${stats.distractingMinutes}m lost to distractions.` : `Challenging focus day at ${stats.focusScore}%. Tomorrow, try protecting your peak hours from interruptions.`; const fullMarkdown = `## Daily Focus Report @@ -346,9 +346,9 @@ function generateDayData(date: Date, dayOffset: number): DayData { const hourly = generateHourlyBreakdown(seed); const productiveMinutes = hourly.reduce((sum, h) => sum + h.productiveMinutes, 0); - const distractiveMinutes = hourly.reduce((sum, h) => sum + h.distractiveMinutes, 0); + const distractingMinutes = hourly.reduce((sum, h) => sum + h.distractingMinutes, 0); const neutralMinutes = hourly.reduce((sum, h) => sum + h.neutralMinutes, 0); - const totalActive = productiveMinutes + distractiveMinutes; + const totalActive = productiveMinutes + distractingMinutes; const projects = generateProjects(seed, dayOffset); const deepWorkSessions = generateDeepWorkSessions(seed, dayOffset, projects); @@ -360,7 +360,7 @@ function generateDayData(date: Date, dayOffset: number): DayData { date: getStartOfDay(date), productiveMinutes, neutralMinutes, - distractiveMinutes, + distractingMinutes, focusScore: totalActive > 0 ? Math.round((productiveMinutes / totalActive) * 100) : 0, deepWorkSessions: deepWorkSessions.length, longestSessionMinutes: Math.max(...deepWorkSessions.map((s) => s.durationMinutes), 0), diff --git a/frontend/src/lib/rules/runtime-types.ts b/frontend/src/lib/rules/runtime-types.ts new file mode 100644 index 0000000..d4a062d --- /dev/null +++ b/frontend/src/lib/rules/runtime-types.ts @@ -0,0 +1,221 @@ +export const RUNTIME_TYPES_FILE_PATH = "file:///focusd-runtime.d.ts"; + +export const RUNTIME_TYPES_SOURCE = `declare module "@focusd/runtime" { + export type ClassificationType = "unknown" | "productive" | "distracting" | "neutral" | "system"; + export type EnforcementActionType = "none" | "block" | "paused" | "allow"; + export type WeekdayType = "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday"; + export type Minutes = number; + + export const Classification: { + readonly Unknown: "unknown"; + readonly Productive: "productive"; + readonly Distracting: "distracting"; + readonly Neutral: "neutral"; + readonly System: "system"; + }; + + export const EnforcementAction: { + readonly None: "none"; + readonly Block: "block"; + readonly Paused: "paused"; + readonly Allow: "allow"; + }; + + export const Timezone: { + readonly America_New_York: "America/New_York"; + readonly America_Chicago: "America/Chicago"; + readonly America_Denver: "America/Denver"; + readonly America_Los_Angeles: "America/Los_Angeles"; + readonly America_Anchorage: "America/Anchorage"; + readonly America_Toronto: "America/Toronto"; + readonly America_Vancouver: "America/Vancouver"; + readonly America_Mexico_City: "America/Mexico_City"; + readonly America_Sao_Paulo: "America/Sao_Paulo"; + readonly America_Buenos_Aires: "America/Buenos_Aires"; + readonly America_Bogota: "America/Bogota"; + readonly America_Santiago: "America/Santiago"; + readonly Europe_London: "Europe/London"; + readonly Europe_Paris: "Europe/Paris"; + readonly Europe_Berlin: "Europe/Berlin"; + readonly Europe_Madrid: "Europe/Madrid"; + readonly Europe_Rome: "Europe/Rome"; + readonly Europe_Amsterdam: "Europe/Amsterdam"; + readonly Europe_Zurich: "Europe/Zurich"; + readonly Europe_Brussels: "Europe/Brussels"; + readonly Europe_Stockholm: "Europe/Stockholm"; + readonly Europe_Oslo: "Europe/Oslo"; + readonly Europe_Helsinki: "Europe/Helsinki"; + readonly Europe_Warsaw: "Europe/Warsaw"; + readonly Europe_Prague: "Europe/Prague"; + readonly Europe_Vienna: "Europe/Vienna"; + readonly Europe_Athens: "Europe/Athens"; + readonly Europe_Bucharest: "Europe/Bucharest"; + readonly Europe_Istanbul: "Europe/Istanbul"; + readonly Europe_Moscow: "Europe/Moscow"; + readonly Europe_Dublin: "Europe/Dublin"; + readonly Europe_Lisbon: "Europe/Lisbon"; + readonly Asia_Dubai: "Asia/Dubai"; + readonly Asia_Riyadh: "Asia/Riyadh"; + readonly Asia_Tehran: "Asia/Tehran"; + readonly Asia_Kolkata: "Asia/Kolkata"; + readonly Asia_Dhaka: "Asia/Dhaka"; + readonly Asia_Bangkok: "Asia/Bangkok"; + readonly Asia_Singapore: "Asia/Singapore"; + readonly Asia_Hong_Kong: "Asia/Hong_Kong"; + readonly Asia_Shanghai: "Asia/Shanghai"; + readonly Asia_Tokyo: "Asia/Tokyo"; + readonly Asia_Seoul: "Asia/Seoul"; + readonly Asia_Taipei: "Asia/Taipei"; + readonly Asia_Jakarta: "Asia/Jakarta"; + readonly Asia_Manila: "Asia/Manila"; + readonly Asia_Karachi: "Asia/Karachi"; + readonly Asia_Jerusalem: "Asia/Jerusalem"; + readonly Asia_Yerevan: "Asia/Yerevan"; + readonly Asia_Tbilisi: "Asia/Tbilisi"; + readonly Asia_Baku: "Asia/Baku"; + readonly Africa_Cairo: "Africa/Cairo"; + readonly Africa_Lagos: "Africa/Lagos"; + readonly Africa_Johannesburg: "Africa/Johannesburg"; + readonly Africa_Nairobi: "Africa/Nairobi"; + readonly Africa_Casablanca: "Africa/Casablanca"; + readonly Australia_Sydney: "Australia/Sydney"; + readonly Australia_Melbourne: "Australia/Melbourne"; + readonly Australia_Perth: "Australia/Perth"; + readonly Australia_Brisbane: "Australia/Brisbane"; + readonly Pacific_Auckland: "Pacific/Auckland"; + readonly Pacific_Honolulu: "Pacific/Honolulu"; + readonly UTC: "UTC"; + }; + + export const Weekday: { + readonly Sunday: "Sunday"; + readonly Monday: "Monday"; + readonly Tuesday: "Tuesday"; + readonly Wednesday: "Wednesday"; + readonly Thursday: "Thursday"; + readonly Friday: "Friday"; + readonly Saturday: "Saturday"; + }; + + export interface Classify { + classification: ClassificationType; + classificationReasoning: string; + tags?: string[]; + } + + export interface Enforce { + enforcementAction: EnforcementActionType; + enforcementReason: string; + } + + export function productive(reason: string, tags?: string[]): Classify; + export function distracting(reason: string, tags?: string[]): Classify; + export function neutral(reason: string, tags?: string[]): Classify; + export function block(reason: string): Enforce; + export function allow(reason: string): Enforce; + export function pause(reason: string): Enforce; + + /** + * Summary of time spent in a specific period (e.g., today, this hour). + */ + export interface TimeSummary { + /** + * Overall productivity score for this period, ranging from 0 to 100. + * Higher score indicates more time spent on productive activities. + */ + readonly focusScore: number; + + /** Total minutes classified as productive during this period. */ + readonly productiveMinutes: Minutes; + + /** Total minutes classified as distracting during this period. */ + readonly distractingMinutes: Minutes; + } + + /** + * Insights and duration metrics specific to the currently active application or website. + */ + export interface CurrentUsage { + /** Total minutes spent on this specific app/site today. */ + readonly usedToday: Minutes; + + /** Number of times this specific app/site was blocked today. */ + readonly blocks: number; + + /** + * Minutes elapsed since the last block event for this app/site. + * Returns null if it hasn't been blocked today. + */ + readonly sinceBlock: Minutes | null; + + /** + * Minutes actually spent using this app/site since it was last blocked. + * Returns null if it hasn't been blocked today. + */ + readonly usedSinceBlock: Minutes | null; + + /** + * Calculates how many minutes were spent on this specific app/site + * within the given sliding window of minutes. + * + * @param minutes - The sliding window size in minutes (e.g., 60 for the last hour). + * @returns Minutes spent on this app/site in that window. + */ + last(minutes: number): number; + } + + /** + * The global runtime context available to your custom rules. + */ + export interface Runtime { + /** Aggregate time and score metrics for the entire day. */ + readonly today: TimeSummary; + + /** Aggregate time and score metrics for the current hour. */ + readonly hour: TimeSummary; + + /** Real-time metadata and metrics for the currently active app or website. */ + readonly usage: Usage; + + /** Time utilities bound to specific timezones. */ + readonly time: { + /** Returns a Date object for the current time in the given timezone. */ + now(timezone?: string): Date; + /** Returns the current day of the week in the given timezone. */ + day(timezone?: string): WeekdayType; + }; + } + + /** The global runtime instance. */ + export const runtime: Runtime; + + /** + * Real-time metadata about the active application or website. + */ + export interface Usage { + /** Name of the desktop application (e.g., "Chrome", "Slack"). */ + readonly app: string; + + /** Active window title. */ + readonly title: string; + + /** Root domain of the website (e.g., "youtube.com"), empty for desktop apps. */ + readonly domain: string; + + /** Full hostname of the website (e.g., "www.youtube.com"), empty for desktop apps. */ + readonly host: string; + + /** URL path (e.g., "/watch"), empty for desktop apps. */ + readonly path: string; + + /** Complete URL, empty for desktop apps. */ + readonly url: string; + + /** Current classification of this app/site before custom rules run. */ + readonly classification: string; + + /** Granular usage durations and limits for this specific app/site. */ + readonly current: CurrentUsage; + } +} +`; diff --git a/frontend/src/lib/rules/snippets.ts b/frontend/src/lib/rules/snippets.ts new file mode 100644 index 0000000..7cfdaeb --- /dev/null +++ b/frontend/src/lib/rules/snippets.ts @@ -0,0 +1,55 @@ +export interface RuleSnippet { + id: string; + title: string; + description: string; + code: string; +} + +export const RULE_SNIPPETS: RuleSnippet[] = [ + { + id: "import-runtime", + title: "Import Runtime SDK", + description: "Insert the recommended import line.", + code: `import { productive, distracting, neutral, block, Timezone, runtime, type Classify, type Enforce } from "@focusd/runtime";`, + }, + { + id: "productive-domain", + title: "Classify Productive Domain", + description: "Mark a domain as productive.", + code: `if (runtime.usage.domain === "github.com") { + return productive("GitHub is productive work"); +}`, + }, + { + id: "hour-budget", + title: "Hourly Usage Budget", + description: "Mark as distracting after a threshold.", + code: `if (runtime.usage.current.last(60) > 30) { + return distracting("Exceeded hourly usage budget"); +}`, + }, + { + id: "late-night-block", + title: "Late Night Block", + description: "Block social media after 10 PM.", + code: `if (runtime.usage.domain === "twitter.com" && runtime.time.now(Timezone.Europe_London).getHours() >= 22) { + return block("Blocked after 10 PM"); +}`, + }, + { + id: "insights-trigger", + title: "Insights-Based Enforcement", + description: "Block when today's distraction is high with repeated attempts.", + code: `if (runtime.today.distractingMinutes >= 90 && runtime.usage.current.blocks >= 3) { + return block("High distraction day with repeated attempts"); +}`, + }, + { + id: "cooldown-after-block", + title: "Cooldown After Block", + description: "Allow brief usage after a cooldown period since last block.", + code: `if (runtime.usage.current.sinceBlock != null && runtime.usage.current.sinceBlock >= 20 && (runtime.usage.current.usedSinceBlock ?? 0) < 5) { + return neutral("Allow 5 mins every 20 mins after block"); +}`, + }, +]; diff --git a/frontend/src/lib/rules/starter-template.ts b/frontend/src/lib/rules/starter-template.ts new file mode 100644 index 0000000..3bf4071 --- /dev/null +++ b/frontend/src/lib/rules/starter-template.ts @@ -0,0 +1,38 @@ +export const STARTER_RULES_TS = `import { + productive, + distracting, + block, + Timezone, + runtime, + type Classify, + type Enforce, +} from "@focusd/runtime"; + +export function classify(): Classify | undefined { + const { domain, current } = runtime.usage; + + if (domain === "github.com") { + return productive("GitHub is productive work"); + } + + if (runtime.hour.distractingMinutes > 30 && current.last(60) > 45) { + return distracting("High distraction this hour"); + } + + return undefined; +} + +export function enforcement(): Enforce | undefined { + const { domain, current } = runtime.usage; + + if (domain === "twitter.com" && runtime.time.now(Timezone.Europe_London).getHours() >= 22) { + return block("Blocked after 10 PM in London"); + } + + if (runtime.today.distractingMinutes >= 90 && current.blocks >= 3) { + return block("High distraction day with repeated attempts"); + } + + return undefined; +} +`; diff --git a/frontend/src/routes/insights/trends.tsx b/frontend/src/routes/insights/trends.tsx index 54cfc03..72964d2 100644 --- a/frontend/src/routes/insights/trends.tsx +++ b/frontend/src/routes/insights/trends.tsx @@ -44,7 +44,7 @@ function TrendsPage() { ); const maxProductive = Math.max(...weeklyStats.map((d) => d.productiveMinutes)); - const maxDistractive = Math.max(...weeklyStats.map((d) => d.distractiveMinutes)); + const maxDistracting = Math.max(...weeklyStats.map((d) => d.distractingMinutes)); return (
@@ -161,10 +161,10 @@ function TrendsPage() { - {/* Productive vs Distractive Chart */} + {/* Productive vs Distracting Chart */} - Productive vs Distractive Time + Productive vs Distracting Time
@@ -172,7 +172,7 @@ function TrendsPage() { const date = new Date(day.date * 1000); const dayOfWeek = dayLabels[date.getDay()]; const productivePct = (day.productiveMinutes / maxProductive) * 100; - const distractivePct = (day.distractiveMinutes / maxDistractive) * 100; + const distractingPct = (day.distractingMinutes / maxDistracting) * 100; return (
@@ -192,10 +192,10 @@ function TrendsPage() {
- {formatMinutes(day.distractiveMinutes)} + {formatMinutes(day.distractingMinutes)}
@@ -208,7 +208,7 @@ function TrendsPage() { Productive - Distractive + Distracting
diff --git a/frontend/src/routes/screen-time/trends.tsx b/frontend/src/routes/screen-time/trends.tsx index 8e08b2d..41d9c70 100644 --- a/frontend/src/routes/screen-time/trends.tsx +++ b/frontend/src/routes/screen-time/trends.tsx @@ -44,7 +44,7 @@ function TrendsPage() { ); const maxProductive = Math.max(...weeklyStats.map((d) => d.productiveMinutes)); - const maxDistractive = Math.max(...weeklyStats.map((d) => d.distractiveMinutes)); + const maxDistracting = Math.max(...weeklyStats.map((d) => d.distractingMinutes)); return (
@@ -161,10 +161,10 @@ function TrendsPage() { - {/* Productive vs Distractive Chart */} + {/* Productive vs Distracting Chart */} - Productive vs Distractive Time + Productive vs Distracting Time
@@ -172,7 +172,7 @@ function TrendsPage() { const date = new Date(day.date * 1000); const dayOfWeek = dayLabels[date.getDay()]; const productivePct = (day.productiveMinutes / maxProductive) * 100; - const distractivePct = (day.distractiveMinutes / maxDistractive) * 100; + const distractingPct = (day.distractingMinutes / maxDistracting) * 100; return (
@@ -192,10 +192,10 @@ function TrendsPage() {
- {formatMinutes(day.distractiveMinutes)} + {formatMinutes(day.distractingMinutes)}
@@ -208,7 +208,7 @@ function TrendsPage() { Productive - Distractive + Distracting
diff --git a/internal/usage/classifier_custom_rules.go b/internal/usage/classifier_custom_rules.go index f589410..7798936 100644 --- a/internal/usage/classifier_custom_rules.go +++ b/internal/usage/classifier_custom_rules.go @@ -18,6 +18,8 @@ func (s *Service) ClassifyCustomRules(ctx context.Context, opts ...sandboxContex } func (s *Service) classifyCustomRulesWithSandbox(ctx context.Context, sandboxCtx sandboxContext) (*ClassificationResponse, error) { + s.enrichSandboxContext(&sandboxCtx) + // Serialize the context to JSON contextJSON, err := json.Marshal(sandboxCtx) if err != nil { @@ -35,18 +37,6 @@ func (s *Service) classifyCustomRulesWithSandbox(ctx context.Context, sandboxCtx return nil, err } - if sandboxCtx.Now == nil { - sandboxCtx.Now = func(loc *time.Location) time.Time { - return time.Now().In(loc) - } - } - - if sandboxCtx.MinutesUsedInPeriod == nil { - sandboxCtx.MinutesUsedInPeriod = func(bundleID, hostname string, durationMinutes int64) (int64, error) { - return 0, nil - } - } - resp, logs, err := s.classifySandbox(ctx, sandboxCtx) if err != nil { @@ -98,7 +88,7 @@ func (s *Service) classifyCustomRulesWithSandbox(ctx context.Context, sandboxCtx }, nil } -func (s *Service) classifySandbox(ctx context.Context, sandboxCtx sandboxContext) (desicion *classificationDecision, logs []string, err error) { +func (s *Service) classifySandbox(ctx context.Context, sandboxCtx sandboxContext) (decision *classificationResult, logs []string, err error) { // Get the latest custom rules code customRules := settings.GetCustomRulesJS() if customRules == "" { diff --git a/internal/usage/classifier_custom_rules_test.go b/internal/usage/classifier_custom_rules_test.go index 1431692..acf4a7a 100644 --- a/internal/usage/classifier_custom_rules_test.go +++ b/internal/usage/classifier_custom_rules_test.go @@ -13,109 +13,78 @@ import ( ) var customRulesApps = ` -/** -* Custom classification logic. -* Return a ClassificationDecision to override the default, or undefined to keep the default. -*/ -export function classify(context: UsageContext): ClassificationDecision | undefined { +import { + productive, + neutral, + runtime, + type Classify, + type Enforce, +} from "@focusd/runtime"; + +export function classify(): Classify | undefined { + const { app, current } = runtime.usage; + console.log("should capture this"); - if (now().getHours() == 10 && now().getMinutes() > 0 && now().getMinutes() < 30) { - return { - classification: Classification.Productive, - classificationReasoning: "Work-related activity", - tags: ["work", "productivity"], - } + if (runtime.time.now().getHours() == 10 && runtime.time.now().getMinutes() > 0 && runtime.time.now().getMinutes() < 30) { + return productive("Work-related activity", ["work", "productivity"]); } console.log("and this too"); - if (context.appName == "Slack") { - return { - classification: Classification.Neutral, - classificationReasoning: "Slack is a neutral app", - tags: ["communication", "work"], - } + if (app == "Slack") { + return neutral("Slack is a neutral app", ["communication", "work"]); } console.log("also this"); - if (context.minutesSinceLastBlock >= 20 && context.minutesUsedSinceLastBlock < 5 && context.appName == "Discord") { - return { - classification: Classification.Neutral, - classificationReasoning: "Allow using 5 mins every 20 mins", - tags: ["resting", "relaxing"], - } + if (current.sinceBlock >= 20 && current.usedSinceBlock < 5 && app == "Discord") { + return neutral("Allow using 5 mins every 20 mins", ["resting", "relaxing"]); } return undefined; } -/** -* Custom termination logic (blocking). -* Return a EnforcementDecision to override the default, or undefined to keep the default. -*/ -export function enforcementDecision(context: UsageContext): EnforcementDecision | undefined { +export function enforcement(): Enforce | undefined { } ` var customRulesWithMinutesUsedInPeriod = ` -export function classify(context: UsageContext): ClassificationDecision | undefined { - const minutesUsed = context.minutesUsedInPeriod(60); +import { distracting, neutral, runtime, type Classify } from "@focusd/runtime"; + +export function classify(): Classify | undefined { + const { current } = runtime.usage; + const minutesUsed = current.last(60); if (minutesUsed > 30) { - return { - classification: Classification.Distracting, - classificationReasoning: "Too much time spent: " + minutesUsed + " minutes", - tags: ["limit-exceeded"], - } + return distracting("Too much time spent: " + minutesUsed + " minutes", ["limit-exceeded"]); } - return { - classification: Classification.Neutral, - classificationReasoning: "Under limit: " + minutesUsed + " minutes", - tags: ["within-limit"], - } + return neutral("Under limit: " + minutesUsed + " minutes", ["within-limit"]); } ` var customRulesWebsite = ` -export function classify(context: UsageContext): ClassificationDecision | undefined { - // Match by domain - if (context.domain === "youtube.com") { - return { - classification: Classification.Distracting, - classificationReasoning: "YouTube is distracting", - tags: ["video", "entertainment"], - } +import { productive, distracting, runtime, type Classify } from "@focusd/runtime"; + +export function classify(): Classify | undefined { + const { domain, host, path, url } = runtime.usage; + + if (domain === "youtube.com") { + return distracting("YouTube is distracting", ["video", "entertainment"]); } - // Match by hostname (subdomain-aware) - if (context.hostname === "docs.google.com") { - return { - classification: Classification.Productive, - classificationReasoning: "Google Docs is productive", - tags: ["docs", "work"], - } + if (host === "docs.google.com") { + return productive("Google Docs is productive", ["docs", "work"]); } - // Match by path - if (context.hostname === "github.com" && context.path.startsWith("/pulls")) { - return { - classification: Classification.Productive, - classificationReasoning: "Reviewing pull requests", - tags: ["code-review", "work"], - } + if (host === "github.com" && path.startsWith("/pulls")) { + return productive("Reviewing pull requests", ["code-review", "work"]); } - // Match by full URL - if (context.url === "https://twitter.com/home") { - return { - classification: Classification.Distracting, - classificationReasoning: "Twitter home feed", - tags: ["social-media"], - } + if (url === "https://twitter.com/home") { + return distracting("Twitter home feed", ["social-media"]); } return undefined; diff --git a/internal/usage/harness_test.go b/internal/usage/harness_test.go index efc7589..18e7d95 100644 --- a/internal/usage/harness_test.go +++ b/internal/usage/harness_test.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "net/http/httptest" "net/url" "reflect" "strings" @@ -15,6 +16,10 @@ import ( "testing" "time" + "connectrpc.com/connect" + apiv1 "github.com/focusd-so/focusd/gen/api/v1" + "github.com/focusd-so/focusd/gen/api/v1/apiv1connect" + "github.com/focusd-so/focusd/internal/identity" "github.com/focusd-so/focusd/internal/settings" "github.com/focusd-so/focusd/internal/usage" "github.com/spf13/viper" @@ -38,6 +43,7 @@ type usageHarnessConfig struct { customRulesJS string llmResponse usage.ClassificationResponse llmResponseRaw *string + accountTier *apiv1.DeviceHandshakeResponse_AccountTier } type usageHarnessOption func(*usageHarnessConfig) @@ -60,15 +66,22 @@ func withDummyLLMResponseRaw(resp string) usageHarnessOption { } } +func withAccountTier(tier apiv1.DeviceHandshakeResponse_AccountTier) usageHarnessOption { + return func(cfg *usageHarnessConfig) { + cfg.accountTier = &tier + } +} + func newUsageHarness(t *testing.T, opts ...usageHarnessOption) *usageHarness { t.Helper() cfg := usageHarnessConfig{ llmResponse: usage.ClassificationResponse{ - Classification: usage.ClassificationNone, - Reasoning: "dummy integration classification", - ConfidenceScore: 1, - Tags: []string{"other"}, + Classification: usage.ClassificationNone, + ClassificationSource: usage.ClassificationSourceCloudLLMOpenAI, + Reasoning: "dummy integration classification", + ConfidenceScore: 1, + Tags: []string{"other"}, }, } @@ -78,6 +91,9 @@ func newUsageHarness(t *testing.T, opts ...usageHarnessOption) *usageHarness { overrideTestConfig(t, cfg) stubFaviconFetcher(t) + if cfg.accountTier != nil { + setTestAccountTier(t, *cfg.accountTier) + } db, err := gorm.Open(sqlite.Open(memoryDSNForHarness(t)), &gorm.Config{}) require.NoError(t, err) @@ -111,6 +127,47 @@ func newUsageHarness(t *testing.T, opts ...usageHarnessOption) *usageHarness { return h } +type testAPIService struct { + apiv1connect.UnimplementedApiServiceHandler + tier apiv1.DeviceHandshakeResponse_AccountTier +} + +func (m testAPIService) DeviceHandshake(_ context.Context, _ *connect.Request[apiv1.DeviceHandshakeRequest]) (*connect.Response[apiv1.DeviceHandshakeResponse], error) { + return connect.NewResponse(&apiv1.DeviceHandshakeResponse{ + UserId: 1, + SessionToken: "test-session-token", + AccountTier: m.tier, + }), nil +} + +func setTestAccountTier(t *testing.T, tier apiv1.DeviceHandshakeResponse_AccountTier) { + t.Helper() + + prevTier := identity.GetAccountTier() + + mux := http.NewServeMux() + _, handler := apiv1connect.NewApiServiceHandler(testAPIService{tier: tier}) + mux.Handle("/", handler) + + server := httptest.NewServer(mux) + defer server.Close() + + client := apiv1connect.NewApiServiceClient(server.Client(), server.URL) + require.NoError(t, identity.PerformHandshake(context.Background(), client)) + + t.Cleanup(func() { + mux := http.NewServeMux() + _, handler := apiv1connect.NewApiServiceHandler(testAPIService{tier: prevTier}) + mux.Handle("/", handler) + + server := httptest.NewServer(mux) + defer server.Close() + + client := apiv1connect.NewApiServiceClient(server.Client(), server.URL) + require.NoError(t, identity.PerformHandshake(context.Background(), client)) + }) +} + func memoryDSNForHarness(t *testing.T) string { t.Helper() @@ -351,6 +408,52 @@ func assertUsageClassification(t *testing.T, classification usage.Classification } } +func assertUsageClassificationSource(t *testing.T, source usage.ClassificationSource) func(*usage.ApplicationUsage) { + t.Helper() + + return func(u *usage.ApplicationUsage) { + require.Equal(t, source, fromPtr(u.ClassificationSource)) + } +} + +func assertUsageClassificationReasoning(t *testing.T, reasoning string) func(*usage.ApplicationUsage) { + t.Helper() + + return func(u *usage.ApplicationUsage) { + require.Equal(t, reasoning, fromPtr(u.ClassificationReasoning)) + } +} + +func assertUsageClassificationConfidence(t *testing.T, confidence float32) func(*usage.ApplicationUsage) { + t.Helper() + + return func(u *usage.ApplicationUsage) { + require.Equal(t, confidence, fromPtr(u.ClassificationConfidence)) + } +} + +func assertUsageTags(t *testing.T, tags ...string) func(*usage.ApplicationUsage) { + t.Helper() + + return func(u *usage.ApplicationUsage) { + actualTags := make([]string, len(u.Tags)) + for i, tag := range u.Tags { + actualTags[i] = tag.Tag + } + require.ElementsMatch(t, tags, actualTags) + } +} + +func assertClassificationSandboxRecorded(t *testing.T) func(*usage.ApplicationUsage) { + t.Helper() + + return func(u *usage.ApplicationUsage) { + require.NotNil(t, u.ClassificationSandboxContext) + require.NotNil(t, u.ClassificationSandboxResponse) + require.NotNil(t, u.ClassificationSandboxLogs) + } +} + func assertEnforcementAction(t *testing.T, mode usage.EnforcementAction) func(*usage.ApplicationUsage) { t.Helper() diff --git a/internal/usage/insights_basic_test.go b/internal/usage/insights_basic_test.go index be01b43..a1c73bc 100644 --- a/internal/usage/insights_basic_test.go +++ b/internal/usage/insights_basic_test.go @@ -536,6 +536,7 @@ func TestGetUsageList_EnforcementActionFilter(t *testing.T) { usage.EnforcementActionNone, usage.EnforcementActionBlock, usage.EnforcementActionAllow, + usage.EnforcementActionNone, } dur := 1800 for i := range starts { diff --git a/internal/usage/insights_daily_summary.go b/internal/usage/insights_daily_summary.go index 1fadb66..1edb4de 100644 --- a/internal/usage/insights_daily_summary.go +++ b/internal/usage/insights_daily_summary.go @@ -152,7 +152,7 @@ func (s *Service) computeLLMDaySummaryInput(date time.Time) (LLMDaySummaryInput, var ( productiveSecs int - distractiveSecs int + distractingSecs int contextSwitches int // productive↔distracting transitions prevClass Classification @@ -188,7 +188,7 @@ func (s *Service) computeLLMDaySummaryInput(date time.Time) (LLMDaySummaryInput, cascade.endCascade(u.StartedAt) case ClassificationDistracting: - distractiveSecs += dur + distractingSecs += dur appDistractSecs[appName] += dur appDistractVisit[appName]++ hourDistractSecs[startHour] += dur @@ -219,8 +219,8 @@ func (s *Service) computeLLMDaySummaryInput(date time.Time) (LLMDaySummaryInput, input := LLMDaySummaryInput{ Date: date.Format("2006-01-02"), TotalProductiveMinutes: productiveSecs / 60, - TotalDistractiveMinutes: distractiveSecs / 60, - FocusScore: calculateProductivityScore(productiveSecs, distractiveSecs), + TotalDistractingMinutes: distractingSecs / 60, + FocusScore: calculateProductivityScore(productiveSecs, distractingSecs), ContextSwitchCount: contextSwitches, LongestFocusStretchMin: focus.longestMinutes(), DeepWorkSessions: deep.sessions, @@ -229,7 +229,7 @@ func (s *Service) computeLLMDaySummaryInput(date time.Time) (LLMDaySummaryInput, TopDistractions: topApps(appDistractSecs, appDistractVisit, 5), TopProductiveApps: topApps(appProductiveSecs, appProductiveVisit, 5), MostProductiveHours: peakHour(hourProductiveSecs), - MostDistractiveHours: peakHour(hourDistractSecs), + MostDistractingHours: peakHour(hourDistractSecs), } s.enrichWithDBStats(&input, date) @@ -442,7 +442,7 @@ func (s *Service) computeFocusTrend(referenceDate time.Time) (avgScore int, tren if err != nil { continue } - if insights.ProductivityScore.ProductiveSeconds+insights.ProductivityScore.DistractiveSeconds < minSecondsForSummary { + if insights.ProductivityScore.ProductiveSeconds+insights.ProductivityScore.DistractingSeconds < minSecondsForSummary { continue } scores = append(scores, insights.ProductivityScore.ProductivityScore) diff --git a/internal/usage/insights_report.go b/internal/usage/insights_report.go index c329f2a..53c66c8 100644 --- a/internal/usage/insights_report.go +++ b/internal/usage/insights_report.go @@ -67,9 +67,9 @@ func (s *Service) GetDayInsights(date time.Time) (DayInsights, error) { } } - score.ProductivityScore = calculateProductivityScore(score.ProductiveSeconds, score.DistractiveSeconds) + score.ProductivityScore = calculateProductivityScore(score.ProductiveSeconds, score.DistractingSeconds) for hour, s := range hourly { - s.ProductivityScore = calculateProductivityScore(s.ProductiveSeconds, s.DistractiveSeconds) + s.ProductivityScore = calculateProductivityScore(s.ProductiveSeconds, s.DistractingSeconds) hourly[hour] = s } @@ -115,8 +115,8 @@ func splitSecondsPerHour(startUnix, endUnix int64) map[int]int { return result } -func calculateProductivityScore(productiveSeconds, distractiveSeconds int) int { - totalSeconds := productiveSeconds + distractiveSeconds +func calculateProductivityScore(productiveSeconds, distractingSeconds int) int { + totalSeconds := productiveSeconds + distractingSeconds if totalSeconds == 0 { return 0 diff --git a/internal/usage/protection.go b/internal/usage/protection.go index 6430040..864b3a8 100644 --- a/internal/usage/protection.go +++ b/internal/usage/protection.go @@ -237,7 +237,7 @@ func (s *Service) RemoveWhitelist(id int64) error { return s.db.Delete(&ProtectionWhitelist{}, id).Error } -// CalculateEnforcementDecision determines whether an application or website should be blocked, allowed, or paused based on classification, custom rules, protection status, and whitelist entries. +// CalculateEnforcementDecision determines whether usage should be blocked, allowed, or paused. // // This function evaluates multiple factors in order of priority: // 1. Custom rules (if configured) - highest priority @@ -246,16 +246,11 @@ func (s *Service) RemoveWhitelist(id int64) error { // 4. Whitelist entries - temporarily whitelisted bundle ID/hostname combinations are allowed // 5. Default blocking - distracting usage is blocked when protection is active // -// Parameters: -// - bundleID: The application bundle identifier (e.g., "com.example.app") -// - hostname: The website hostname (e.g., "example.com") - empty string for non-browser apps -// - domain: The domain name extracted from the URL -// - url: The full URL being accessed -// - classification: The classification result indicating whether the usage is distracting -// - enforcementAction: The requested termination mode (may be overridden by custom rules) +// Parameter: +// - appUsage: Usage details for the current app or site event // // Returns: -// - EnforcementDecision: A decision containing the mode (Allow/Block/Paused), reasoning, and source +// - EnforcementDecision: A decision containing the action (Allow/Block/Paused), reasoning, and source // - error: Database error if protection status or whitelist lookup fails func (s *Service) CalculateEnforcementDecision(ctx context.Context, appUsage *ApplicationUsage) (EnforcementDecision, error) { classification := appUsage.Classification @@ -326,13 +321,16 @@ func (s *Service) CalculateEnforcementDecision(ctx context.Context, appUsage *Ap func (s *Service) calculateEnforcementDecisionWithCustomRules(_ context.Context, appUsage *ApplicationUsage) (EnforcementDecision, error) { sandboxCtx := createSandboxContext(appUsage.Application.Name, appUsage.BrowserURL) - sandboxCtx.Classification = string(appUsage.Classification) + sandboxCtx.Usage.Meta.Title = appUsage.WindowTitle + sandboxCtx.Usage.Meta.Classification = string(appUsage.Classification) customRules := settings.GetCustomRulesJS() if customRules == "" { return EnforcementDecision{Action: EnforcementActionNone}, nil } + s.enrichSandboxContext(&sandboxCtx) + contextJSON, err := json.Marshal(sandboxCtx) if err != nil { return EnforcementDecision{}, err @@ -348,7 +346,7 @@ func (s *Service) calculateEnforcementDecisionWithCustomRules(_ context.Context, return EnforcementDecision{}, err } - finalizeExecutionLog := func(decision *enforcementDecision, logs []string, invokeErr error) error { + finalizeExecutionLog := func(decision *enforcement, logs []string, invokeErr error) error { if invokeErr != nil { errMsg := invokeErr.Error() executionLog.Error = &errMsg @@ -397,7 +395,7 @@ func (s *Service) calculateEnforcementDecisionWithCustomRules(_ context.Context, return EnforcementDecision{}, err } - decision, logs, err := sb.invokeEnforcementDecision(sandboxCtx) + decision, logs, err := sb.invokeEnforcement(sandboxCtx) if logErr := finalizeExecutionLog(decision, logs, err); logErr != nil { return EnforcementDecision{}, logErr } diff --git a/internal/usage/protection_test.go b/internal/usage/protection_test.go index a44b867..15ba604 100644 --- a/internal/usage/protection_test.go +++ b/internal/usage/protection_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + apiv1 "github.com/focusd-so/focusd/gen/api/v1" "github.com/spf13/viper" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" @@ -287,6 +288,7 @@ func TestProtection_RemoveWhitelist_NonExistentID(t *testing.T) { func setUpServiceWithSettings(t *testing.T, customRules string) (*usage.Service, *gorm.DB) { t.Helper() + setTestAccountTier(t, apiv1.DeviceHandshakeResponse_ACCOUNT_TIER_PLUS) db, _ := gorm.Open(sqlite.Open(memoryDSN(t)), &gorm.Config{}) @@ -301,14 +303,15 @@ func setUpServiceWithSettings(t *testing.T, customRules string) (*usage.Service, func TestProtection_CalculateEnforcementDecision_CustomRules(t *testing.T) { - t.Run("ctx.appName is accessible", func(t *testing.T) { + t.Run("ctx.app is accessible", func(t *testing.T) { customRules := ` -export function enforcementDecision(ctx) { - if (ctx.appName == "Slack") { - return { - enforcementAction: EnforcementAction.Block, - enforcementReason: "Slack is blocked by custom rule", - } +import { block, runtime } from "@focusd/runtime"; + +export function enforcement() { + const { app } = runtime.usage; + + if (app == "Slack") { + return block("Slack is blocked by custom rule"); } return undefined; } @@ -322,17 +325,18 @@ export function enforcementDecision(ctx) { require.NoError(t, err) require.Equal(t, usage.EnforcementActionBlock, decision.Action) require.Equal(t, usage.EnforcementSourceCustomRules, decision.Source) - require.Equal(t, "Slack is blocked by custom rule", decision.Reason) + require.Equal(t, usage.EnforcementReason("Slack is blocked by custom rule"), decision.Reason) }) t.Run("ctx.classification is accessible", func(t *testing.T) { customRules := ` -export function enforcementDecision(ctx) { - if (ctx.classification == "distracting") { - return { - enforcementAction: EnforcementAction.Block, - enforcementReason: "distracting classification detected", - } +import { block, runtime } from "@focusd/runtime"; + +export function enforcement() { + const { classification } = runtime.usage; + + if (classification == "distracting") { + return block("distracting classification detected"); } return undefined; } @@ -346,19 +350,18 @@ export function enforcementDecision(ctx) { require.NoError(t, err) require.Equal(t, usage.EnforcementActionBlock, decision.Action) require.Equal(t, usage.EnforcementSourceCustomRules, decision.Source) - require.Equal(t, "distracting classification detected", decision.Reason) + require.Equal(t, usage.EnforcementReason("distracting classification detected"), decision.Reason) }) - t.Run("ctx.hostname and ctx.domain are accessible", func(t *testing.T) { - // Note: parseURL strips "www." prefix, so "docs.google.com" stays as-is - // while domain is extracted via publicsuffix as "google.com" + t.Run("ctx.host and ctx.domain are accessible", func(t *testing.T) { customRules := ` -export function enforcementDecision(ctx) { - if (ctx.hostname == "docs.google.com" && ctx.domain == "google.com") { - return { - enforcementAction: EnforcementAction.Block, - enforcementReason: "Google Docs blocked via hostname/domain", - } +import { block, runtime } from "@focusd/runtime"; + +export function enforcement() { + const { host, domain } = runtime.usage; + + if (host == "docs.google.com" && domain == "google.com") { + return block("Google Docs blocked via hostname/domain"); } return undefined; } @@ -374,17 +377,18 @@ export function enforcementDecision(ctx) { require.NoError(t, err) require.Equal(t, usage.EnforcementActionBlock, decision.Action) require.Equal(t, usage.EnforcementSourceCustomRules, decision.Source) - require.Equal(t, "Google Docs blocked via hostname/domain", decision.Reason) + require.Equal(t, usage.EnforcementReason("Google Docs blocked via hostname/domain"), decision.Reason) }) t.Run("ctx.url and ctx.path are accessible", func(t *testing.T) { customRules := ` -export function enforcementDecision(ctx) { - if (ctx.url == "https://github.com/pulls" && ctx.path == "/pulls") { - return { - enforcementAction: EnforcementAction.Allow, - enforcementReason: "PR reviews are allowed", - } +import { allow, runtime } from "@focusd/runtime"; + +export function enforcement() { + const { url, path } = runtime.usage; + + if (url == "https://github.com/pulls" && path == "/pulls") { + return allow("PR reviews are allowed"); } return undefined; } @@ -400,12 +404,12 @@ export function enforcementDecision(ctx) { require.NoError(t, err) require.Equal(t, usage.EnforcementActionAllow, decision.Action) require.Equal(t, usage.EnforcementSourceCustomRules, decision.Source) - require.Equal(t, "PR reviews are allowed", decision.Reason) + require.Equal(t, usage.EnforcementReason("PR reviews are allowed"), decision.Reason) }) t.Run("returns undefined falls through to default", func(t *testing.T) { customRules := ` -export function enforcementDecision(ctx) { +export function enforcement() { return undefined; } ` @@ -420,20 +424,21 @@ export function enforcementDecision(ctx) { // Non-distracting classification results in Allow from the application source. require.Equal(t, usage.EnforcementActionAllow, decision.Action) require.Equal(t, usage.EnforcementSourceApplication, decision.Source) - require.Equal(t, "non distracting usage", decision.Reason) + require.Equal(t, usage.EnforcementReason("non distracting usage"), decision.Reason) }) } func TestProtection_CalculateEnforcementDecision_CustomRules_ExecutionLogs(t *testing.T) { t.Run("stores enforcement response and console logs", func(t *testing.T) { customRules := ` -export function enforcementDecision(ctx) { - console.log("enforcement executed", ctx.appName) - if (ctx.appName == "Slack") { - return { - enforcementAction: EnforcementAction.Block, - enforcementReason: "Slack is blocked by custom rule", - } +import { block, runtime } from "@focusd/runtime"; + +export function enforcement() { + const { app } = runtime.usage; + + console.log("enforcement executed", app) + if (app == "Slack") { + return block("Slack is blocked by custom rule"); } return undefined; } @@ -475,8 +480,12 @@ export function enforcementDecision(ctx) { t.Run("stores no response when decision is undefined", func(t *testing.T) { customRules := ` -export function enforcementDecision(ctx) { - console.log("undefined decision for", ctx.appName) +import { runtime } from "@focusd/runtime"; + +export function enforcement() { + const { app } = runtime.usage; + + console.log("undefined decision for", app) return undefined; } ` @@ -507,8 +516,12 @@ export function enforcementDecision(ctx) { t.Run("stores errors for failed enforcement execution", func(t *testing.T) { customRules := ` -export function enforcementDecision(ctx) { - console.log("about to fail", ctx.appName) +import { runtime } from "@focusd/runtime"; + +export function enforcement() { + const { app } = runtime.usage; + + console.log("about to fail", app) throw new Error("enforcement fail"); } ` @@ -521,7 +534,7 @@ export function enforcementDecision(ctx) { _, err := service.CalculateEnforcementDecision(context.Background(), appUsage) require.Error(t, err) - require.Contains(t, err.Error(), "failed to execute enforcementDecision") + require.Contains(t, err.Error(), "failed to execute enforcement") require.NotNil(t, appUsage.EnforcementSandboxContext) require.NotNil(t, appUsage.EnforcementSandboxResponse) require.NotNil(t, appUsage.EnforcementSandboxLogs) @@ -557,7 +570,7 @@ func TestProtection_CalculateEnforcementDecision_ProtectionPaused(t *testing.T) require.NoError(t, err) require.Equal(t, usage.EnforcementActionAllow, decision.Action) require.Equal(t, usage.EnforcementSourcePaused, decision.Source) - require.Equal(t, "focus protection has been paused by the user", decision.Reason) + require.Equal(t, usage.EnforcementReason("focus protection has been paused by the user"), decision.Reason) } func TestProtection_AllowedByWhitelist(t *testing.T) { @@ -574,7 +587,7 @@ func TestProtection_AllowedByWhitelist(t *testing.T) { require.NoError(t, err) require.Equal(t, usage.EnforcementActionAllow, decision.Action) require.Equal(t, usage.EnforcementSourceWhitelist, decision.Source) - require.Equal(t, "temporarily allowed usage by user", decision.Reason) + require.Equal(t, usage.EnforcementReason("temporarily allowed usage by user"), decision.Reason) }) t.Run("whitelisted by executable path and hostname", func(t *testing.T) { @@ -591,7 +604,7 @@ func TestProtection_AllowedByWhitelist(t *testing.T) { require.NoError(t, err) require.Equal(t, usage.EnforcementActionAllow, decision.Action) require.Equal(t, usage.EnforcementSourceWhitelist, decision.Source) - require.Equal(t, "temporarily allowed usage by user", decision.Reason) + require.Equal(t, usage.EnforcementReason("temporarily allowed usage by user"), decision.Reason) }) t.Run("does not allow other hostnames in same browser", func(t *testing.T) { diff --git a/internal/usage/sandbox.go b/internal/usage/sandbox.go index 62373f8..4c00ec6 100644 --- a/internal/usage/sandbox.go +++ b/internal/usage/sandbox.go @@ -11,15 +11,15 @@ import ( v8 "rogchap.com/v8go" ) -// classificationDecision is returned from the classify function -type classificationDecision struct { +// classificationResult is returned from the classify function. +type classificationResult struct { Classification string `json:"classification"` ClassificationReasoning string `json:"classificationReasoning"` Tags []string `json:"tags"` } -// enforcementDecision is returned from the enforcement decision function -type enforcementDecision struct { +// enforcement is returned from the enforcement function. +type enforcement struct { EnforcementAction string `json:"enforcementAction"` EnforcementReason string `json:"enforcementReason"` } @@ -53,7 +53,7 @@ func formatEsbuildErrors(errors []api.Message) string { return strings.Join(messages, "\n") } -// prepareScript transpiles TypeScript and adds global function exports, console polyfill, and now() helper +// prepareScript transpiles TypeScript and exposes @focusd/runtime as the only importable module. func prepareScript(code string) (string, error) { // Transpile user code with CommonJS format to handle export statements // Use ES2016 target to transpile async/await to generators which can run synchronously @@ -69,24 +69,24 @@ func prepareScript(code string) (string, error) { transpiledCode := string(result.Code) - // Wrap the transpiled code with CommonJS environment and expose functions to globalThis + // Wrap transpiled CommonJS with runtime module + function exports. preparedScript := fmt.Sprintf(` -// Define global constants for user scripts -var EnforcementAction = { - None: "none", - Block: "block", - Paused: "paused", - Allow: "allow" -}; - -var Classification = { +var Classification = Object.freeze({ + Unknown: "unknown", Productive: "productive", Distracting: "distracting", Neutral: "neutral", System: "system" -}; +}); + +var EnforcementAction = Object.freeze({ + None: "none", + Block: "block", + Paused: "paused", + Allow: "allow" +}); -var Weekday = { +var Weekday = Object.freeze({ Sunday: "Sunday", Monday: "Monday", Tuesday: "Tuesday", @@ -94,9 +94,9 @@ var Weekday = { Thursday: "Thursday", Friday: "Friday", Saturday: "Saturday" -}; +}); -var Timezone = { +var Timezone = Object.freeze({ // Americas America_New_York: "America/New_York", America_Chicago: "America/Chicago", @@ -166,7 +166,71 @@ var Timezone = { Pacific_Honolulu: "Pacific/Honolulu", // UTC UTC: "UTC" +}); + +function __runtimeNow(timezone) { + const ts = __getShiftedTimestamp(timezone); + return new Date(ts); +} + +function __runtimeDayOfWeek(timezone) { + const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + return days[__runtimeNow(timezone).getDay()]; +} + +function productive(reason, tags) { + return { classification: "productive", classificationReasoning: reason, tags: tags }; +} +function distracting(reason, tags) { + return { classification: "distracting", classificationReasoning: reason, tags: tags }; +} +function neutral(reason, tags) { + return { classification: "neutral", classificationReasoning: reason, tags: tags }; +} +function block(reason) { + return { enforcementAction: "block", enforcementReason: reason }; +} +function allow(reason) { + return { enforcementAction: "allow", enforcementReason: reason }; +} +function pause(reason) { + return { enforcementAction: "paused", enforcementReason: reason }; +} + +var __runtimeModule = { + Classification: Classification, + EnforcementAction: EnforcementAction, + Timezone: Timezone, + Weekday: Weekday, + productive: productive, + distracting: distracting, + neutral: neutral, + block: block, + allow: allow, + pause: pause, + get runtime() { + return globalThis.__focusd_runtime_context || { + today: { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, + hour: { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, + time: { + now: __runtimeNow, + day: __runtimeDayOfWeek + } + }; + } }; +Object.freeze(__runtimeModule.Classification); +Object.freeze(__runtimeModule.EnforcementAction); +Object.freeze(__runtimeModule.Timezone); +Object.freeze(__runtimeModule.Weekday); + +function require(specifier) { + if (specifier === "@focusd/runtime") { + return __runtimeModule; + } + + throw new Error("Unsupported import: " + specifier + ". Only '@focusd/runtime' is available."); +} var exports = {}; var module = { exports: exports }; @@ -177,10 +241,10 @@ var module = { exports: exports }; // Check both module.exports and exports for functions var _exported = module.exports || exports; if (_exported && typeof _exported.classify === 'function') { globalThis.__classify = _exported.classify; } -if (_exported && typeof _exported.enforcementDecision === 'function') { globalThis.__enforcementDecision = _exported.enforcementDecision; } +if (_exported && typeof _exported.enforcement === 'function') { globalThis.__enforcement = _exported.enforcement; } // Also check for top-level function declarations (non-exported) if (typeof classify === 'function') { globalThis.__classify = classify; } -if (typeof enforcementDecision === 'function') { globalThis.__enforcementDecision = enforcementDecision; } +if (typeof enforcement === 'function') { globalThis.__enforcement = enforcement; } // Polyfill console if (typeof console === 'undefined') { @@ -198,39 +262,6 @@ if (typeof console === 'undefined') { console.error = __console_log; console.debug = __console_log; } - -/** - * Returns a Date object for the current time in the specified IANA timezone. - * Use Timezone.* constants for autocomplete, or pass any valid IANA timezone string. - * If no timezone is provided or the string is invalid, uses local time. - * @param {string} [timezone] - IANA timezone (e.g. Timezone.Europe_London, 'America/New_York') - * @returns {Date} - */ -function now(timezone) { - const ts = __getShiftedTimestamp(timezone); - return new Date(ts); -} - -/** - * Returns the day of the week in the specified IANA timezone. - * @param {string} [timezone] - IANA timezone (e.g. Timezone.Asia_Tokyo) - * @returns {string} - */ -function dayOfWeek(timezone) { - const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - return days[now(timezone).getDay()]; -} - -var __currentDay = dayOfWeek(); -var IsMonday = __currentDay === "Monday"; -var IsTuesday = __currentDay === "Tuesday"; -var IsWednesday = __currentDay === "Wednesday"; -var IsThursday = __currentDay === "Thursday"; -var IsFriday = __currentDay === "Friday"; -var IsSaturday = __currentDay === "Saturday"; -var IsSunday = __currentDay === "Sunday"; -var IsWeekday = !IsSaturday && !IsSunday; -var IsWeekend = IsSaturday || IsSunday; `, transpiledCode) return preparedScript, nil @@ -296,7 +327,7 @@ func (s *sandbox) setupContext(ctx sandboxContext, v8ctx *v8.Context) error { return fmt.Errorf("failed to set __console_log function: %w", err) } - // Inject __minutesUsedInPeriod function (bundleID, hostname, minutes) -> int64 + // Inject __minutesUsedInPeriod function (appName, hostname, minutes) -> int64 if ctx.MinutesUsedInPeriod != nil { usageCb := v8.NewFunctionTemplate(s.isolate, func(info *v8.FunctionCallbackInfo) *v8.Value { args := info.Args() @@ -305,11 +336,11 @@ func (s *sandbox) setupContext(ctx sandboxContext, v8ctx *v8.Context) error { return val } - bundleID := args[0].String() + appName := args[0].String() hostname := args[1].String() minutes := int64(args[2].Integer()) - result, err := ctx.MinutesUsedInPeriod(bundleID, hostname, minutes) + result, err := ctx.MinutesUsedInPeriod(appName, hostname, minutes) if err != nil { slog.Debug("failed to query minutes used", "error", err) val, _ := v8.NewValue(s.isolate, int32(0)) @@ -326,22 +357,6 @@ func (s *sandbox) setupContext(ctx sandboxContext, v8ctx *v8.Context) error { } } - // Inject __minutesSinceLastBlockValue as a pre-computed value - if ctx.MinutesSinceLastBlock != nil { - val, _ := v8.NewValue(s.isolate, int32(*ctx.MinutesSinceLastBlock)) - if err := global.Set("__minutesSinceLastBlockValue", val); err != nil { - return fmt.Errorf("failed to set __minutesSinceLastBlockValue: %w", err) - } - } - - // Inject __minutesUsedSinceLastBlockValue as a pre-computed value - if ctx.MinutesUsedSinceLastBlock != nil { - val, _ := v8.NewValue(s.isolate, int32(*ctx.MinutesUsedSinceLastBlock)) - if err := global.Set("__minutesUsedSinceLastBlockValue", val); err != nil { - return fmt.Errorf("failed to set __minutesUsedSinceLastBlockValue: %w", err) - } - } - return nil } @@ -371,33 +386,49 @@ func (s *sandbox) executeFunction(v8ctx *v8.Context, preparedScript string, func return "", fmt.Errorf("failed to marshal context: %w", err) } - // Call the function - // Add minutesUsedInPeriod as a method on the context object + // Call the function with flat usage context. callScript := fmt.Sprintf(` (function() { - const ctx = %s; - // Add minutesUsedInPeriod method to context - if (typeof __minutesUsedInPeriod === 'function') { - ctx.minutesUsedInPeriod = function(minutes) { - return __minutesUsedInPeriod(ctx.bundleID, ctx.hostname, minutes); - }; - } else { - ctx.minutesUsedInPeriod = function(minutes) { return 0; }; - } - - // Add minutesSinceLastBlock as a method that returns the pre-computed value - if (typeof __minutesSinceLastBlockValue === 'number') { - ctx.minutesSinceLastBlock = __minutesSinceLastBlockValue; - } else { - ctx.minutesSinceLastBlock = -1; - } - - // Add minutesUsedSinceLastBlock as a method that returns the pre-computed value - if (typeof __minutesUsedSinceLastBlockValue === 'number') { - ctx.minutesUsedSinceLastBlock = __minutesUsedSinceLastBlockValue; - } - - const result = %s(ctx); + var raw = %s; + var u = raw.usage || {}; + var meta = u.meta || {}; + var ins = u.insights || {}; + var cur = ins.current || {}; + var dur = cur.duration || {}; + var blk = cur.blocks || {}; + + var lastFn = (typeof __minutesUsedInPeriod === 'function') + ? function(m) { return __minutesUsedInPeriod(meta.appName || "", meta.host || "", m); } + : function() { return 0; }; + + var ctx = { + app: meta.appName || "", + title: meta.title || "", + domain: meta.domain || "", + host: meta.host || "", + path: meta.path || "", + url: meta.url || "", + classification: meta.classification || "", + current: { + usedToday: dur.today || 0, + blocks: blk.count || 0, + sinceBlock: dur.sinceLastBlock != null ? dur.sinceLastBlock : null, + usedSinceBlock: dur.usedSinceLastBlock != null ? dur.usedSinceLastBlock : null, + last: lastFn + } + }; + + globalThis.__focusd_runtime_context = { + today: ins.today || { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, + hour: ins.hour || { focusScore: 0, productiveMinutes: 0, distractingMinutes: 0 }, + time: { + now: __runtimeNow, + day: __runtimeDayOfWeek + }, + usage: ctx + }; + + var result = %s(); if (result === undefined || result === null) { return undefined; } @@ -432,7 +463,7 @@ func (s *sandbox) close() { // invokeClassify executes the classify function and returns the result // Returns nil if the function returns undefined -func (s *sandbox) invokeClassify(ctx sandboxContext) (*classificationDecision, []string, error) { +func (s *sandbox) invokeClassify(ctx sandboxContext) (*classificationResult, []string, error) { // Prepare script with function exports and helpers preparedScript, err := prepareScript(s.code) if err != nil { @@ -453,7 +484,7 @@ func (s *sandbox) invokeClassify(ctx sandboxContext) (*classificationDecision, [ return nil, s.logs, fmt.Errorf("failed to execute classify: %w", err) } - var decision classificationDecision + var decision classificationResult if resultJSON == "" { return nil, s.logs, nil @@ -466,9 +497,9 @@ func (s *sandbox) invokeClassify(ctx sandboxContext) (*classificationDecision, [ return &decision, s.logs, nil } -// invokeEnforcementDecision executes the enforcement decision function and returns the result +// invokeEnforcement executes the enforcement function and returns the result. // Returns nil if the function returns undefined -func (s *sandbox) invokeEnforcementDecision(ctx sandboxContext) (*enforcementDecision, []string, error) { +func (s *sandbox) invokeEnforcement(ctx sandboxContext) (*enforcement, []string, error) { // Prepare script with function exports and helpers preparedScript, err := prepareScript(s.code) if err != nil { @@ -484,16 +515,16 @@ func (s *sandbox) invokeEnforcementDecision(ctx sandboxContext) (*enforcementDec } // Execute the function - resultJSON, err := s.executeFunction(v8ctx, preparedScript, "__enforcementDecision", ctx) + resultJSON, err := s.executeFunction(v8ctx, preparedScript, "__enforcement", ctx) if err != nil { - return nil, s.logs, fmt.Errorf("failed to execute enforcementDecision: %w", err) + return nil, s.logs, fmt.Errorf("failed to execute enforcement: %w", err) } if resultJSON == "" { return nil, s.logs, nil } - var decision enforcementDecision + var decision enforcement if err := json.Unmarshal([]byte(resultJSON), &decision); err != nil { return nil, s.logs, fmt.Errorf("failed to parse enforcement decision: %w", err) } diff --git a/internal/usage/sandbox_context.go b/internal/usage/sandbox_context.go index c163367..b49b747 100644 --- a/internal/usage/sandbox_context.go +++ b/internal/usage/sandbox_context.go @@ -6,50 +6,84 @@ import ( "golang.org/x/net/publicsuffix" ) -// sandboxContext provides context for the current rule execution including usage data and helper functions -type sandboxContext struct { - // Input data - AppName string `json:"appName"` - Title string `json:"title"` - - Hostname string `json:"hostname"` +type sandboxUsageMetadata struct { + AppName string `json:"appName"` + Title string `json:"title"` + Host string `json:"host"` Path string `json:"path"` Domain string `json:"domain"` URL string `json:"url"` Classification string `json:"classification"` +} + +type sandboxUsageBlocked struct { + Count int `json:"count"` + Since *int `json:"since"` + Used *int `json:"used"` + Last *int `json:"last"` +} + +type sandboxUsageDuration struct { + Today int `json:"today"` + SinceLastBlock *int `json:"sinceLastBlock"` + UsedSinceLastBlock *int `json:"usedSinceLastBlock"` + LastBlocked *int `json:"lastBlocked"` +} + +type sandboxPeriodSummary struct { + FocusScore int `json:"focusScore"` + ProductiveMinutes int `json:"productiveMinutes"` + DistractingMinutes int `json:"distractingMinutes"` +} + +type sandboxUsageCurrentInsights struct { + Duration sandboxUsageDuration `json:"duration"` + Blocks sandboxUsageBlocked `json:"blocks"` +} + +type sandboxUsageInsights struct { + Today sandboxPeriodSummary `json:"today"` + Hour sandboxPeriodSummary `json:"hour"` + Current sandboxUsageCurrentInsights `json:"current"` +} + +type sandboxUsageContext struct { + Meta sandboxUsageMetadata `json:"meta"` + Insights sandboxUsageInsights `json:"insights"` +} - // Helper pre-computed values - MinutesSinceLastBlock *int `json:"minutesSinceLastBlock"` - MinutesUsedSinceLastBlock *int `json:"minutesUsedSinceLastBlock"` +// sandboxContext provides context for the current rule execution including usage data and helper functions. +type sandboxContext struct { + Usage sandboxUsageContext `json:"usage"` // Helper functions - Now func(loc *time.Location) time.Time `json:"-"` - MinutesUsedInPeriod func(bundleID, hostname string, durationMinutes int64) (int64, error) `json:"-"` + Now func(loc *time.Location) time.Time `json:"-"` + MinutesUsedInPeriod func(appName, hostname string, durationMinutes int64) (int64, error) `json:"-"` } type sandboxContextOption func(*sandboxContext) func WithAppNameContext(appName string) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.AppName = appName + ctx.Usage.Meta.AppName = appName } } func WithWindowTitleContext(title string) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.Title = title + ctx.Usage.Meta.Title = title } } func WithBrowserURLContext(url string) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.URL = url + ctx.Usage.Meta.URL = url u, err := parseURLNormalized(url) if err == nil { - ctx.Hostname = u.Hostname() - ctx.Path = u.Path - ctx.Domain, _ = publicsuffix.EffectiveTLDPlusOne(u.Hostname()) + ctx.Usage.Meta.Host = u.Hostname() + ctx.Usage.Meta.Path = u.Path + ctx.Usage.Meta.Domain, _ = publicsuffix.EffectiveTLDPlusOne(u.Hostname()) } } } @@ -62,7 +96,7 @@ func WithNowContext(now time.Time) sandboxContextOption { } } -func WithMinutesUsedInPeriodContext(minutesUsedInPeriod func(bundleID, hostname string, durationMinutes int64) (int64, error)) sandboxContextOption { +func WithMinutesUsedInPeriodContext(minutesUsedInPeriod func(appName, hostname string, durationMinutes int64) (int64, error)) sandboxContextOption { return func(ctx *sandboxContext) { ctx.MinutesUsedInPeriod = minutesUsedInPeriod } @@ -70,13 +104,19 @@ func WithMinutesUsedInPeriodContext(minutesUsedInPeriod func(bundleID, hostname func WithMinutesSinceLastBlockContext(minutesSinceLastBlock int) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.MinutesSinceLastBlock = &minutesSinceLastBlock + ctx.Usage.Insights.Current.Duration.SinceLastBlock = &minutesSinceLastBlock } } func WithMinutesUsedSinceLastBlockContext(minutesUsedSinceLastBlock int) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.MinutesUsedSinceLastBlock = &minutesUsedSinceLastBlock + ctx.Usage.Insights.Current.Duration.UsedSinceLastBlock = &minutesUsedSinceLastBlock + } +} + +func WithClassificationContext(classification Classification) sandboxContextOption { + return func(ctx *sandboxContext) { + ctx.Usage.Meta.Classification = string(classification) } } diff --git a/internal/usage/sandbox_context_enrich.go b/internal/usage/sandbox_context_enrich.go new file mode 100644 index 0000000..bd6b638 --- /dev/null +++ b/internal/usage/sandbox_context_enrich.go @@ -0,0 +1,155 @@ +package usage + +import ( + "errors" + "log/slog" + "time" + + "gorm.io/gorm" +) + +func (s *Service) enrichSandboxContext(ctx *sandboxContext) { + if ctx.Now == nil { + ctx.Now = func(loc *time.Location) time.Time { + return time.Now().In(loc) + } + } + + if ctx.MinutesUsedInPeriod == nil { + ctx.MinutesUsedInPeriod = s.minutesUsedInPeriod + } + + if err := s.populateInsightsContext(ctx); err != nil { + slog.Debug("failed to populate sandbox insights context", "error", err) + } + + if err := s.populateCurrentUsageContext(ctx); err != nil { + slog.Debug("failed to populate sandbox current-usage context", "error", err) + } +} + +func (s *Service) scopedUsageIdentityQuery(appName, hostname string) *gorm.DB { + query := s.db.Model(&ApplicationUsage{}). + Joins("JOIN application ON application.id = application_usage.application_id"). + Where("application.name = ?", appName) + + if hostname == "" { + return query.Where("(application.hostname IS NULL OR application.hostname = '')") + } + + return query.Where("(application.hostname = ? OR application.hostname = ?)", hostname, "www."+hostname) +} + +func (s *Service) minutesUsedInPeriod(appName, hostname string, durationMinutes int64) (int64, error) { + if appName == "" || durationMinutes <= 0 { + return 0, nil + } + + cutoff := time.Now().Add(-time.Duration(durationMinutes) * time.Minute).Unix() + query := s.scopedUsageIdentityQuery(appName, hostname). + Where("application_usage.started_at >= ?", cutoff) + + var totalSeconds int64 + if err := query.Select("COALESCE(SUM(COALESCE(application_usage.duration_seconds, 0)), 0)"). + Scan(&totalSeconds).Error; err != nil { + return 0, err + } + + return totalSeconds / 60, nil +} + +func (s *Service) populateCurrentUsageContext(ctx *sandboxContext) error { + appName := ctx.Usage.Meta.AppName + hostname := ctx.Usage.Meta.Host + if appName == "" { + return nil + } + + var lastBlocked ApplicationUsage + err := s.scopedUsageIdentityQuery(appName, hostname). + Where("application_usage.enforcement_action = ?", EnforcementActionBlock). + Order("application_usage.started_at DESC"). + Limit(1). + First(&lastBlocked).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + + if ctx.Usage.Insights.Current.Duration.SinceLastBlock == nil { + minutesSinceLastBlock := int(time.Since(time.Unix(lastBlocked.StartedAt, 0)).Minutes()) + ctx.Usage.Insights.Current.Duration.SinceLastBlock = &minutesSinceLastBlock + } + + if ctx.Usage.Insights.Current.Duration.LastBlocked == nil { + lastBlockedDurationMinutes := 0 + if lastBlocked.DurationSeconds != nil { + lastBlockedDurationMinutes = *lastBlocked.DurationSeconds / 60 + } + ctx.Usage.Insights.Current.Duration.LastBlocked = &lastBlockedDurationMinutes + } + + if ctx.Usage.Insights.Current.Duration.UsedSinceLastBlock == nil { + var totalSeconds int64 + sumErr := s.scopedUsageIdentityQuery(appName, hostname). + Where("application_usage.started_at > ?", lastBlocked.StartedAt). + Select("COALESCE(SUM(COALESCE(application_usage.duration_seconds, 0)), 0)"). + Scan(&totalSeconds).Error + if sumErr != nil { + return sumErr + } + + minutesUsedSinceLastBlock := int(totalSeconds / 60) + ctx.Usage.Insights.Current.Duration.UsedSinceLastBlock = &minutesUsedSinceLastBlock + } + + return nil +} + +func (s *Service) populateInsightsContext(ctx *sandboxContext) error { + now := time.Now() + insights, err := s.GetDayInsights(now) + if err != nil { + return err + } + + ctx.Usage.Insights.Today.ProductiveMinutes = insights.ProductivityScore.ProductiveSeconds / 60 + ctx.Usage.Insights.Today.DistractingMinutes = insights.ProductivityScore.DistractingSeconds / 60 + ctx.Usage.Insights.Today.FocusScore = insights.ProductivityScore.ProductivityScore + + hourly := insights.ProductivityPerHourBreakdown[now.Hour()] + ctx.Usage.Insights.Hour.ProductiveMinutes = hourly.ProductiveSeconds / 60 + ctx.Usage.Insights.Hour.DistractingMinutes = hourly.DistractingSeconds / 60 + ctx.Usage.Insights.Hour.FocusScore = hourly.ProductivityScore + + todayKey := now.Format("2006-01-02") + + appName := ctx.Usage.Meta.AppName + hostname := ctx.Usage.Meta.Host + if appName == "" { + return nil + } + + var currentDistractingSeconds int64 + if err := s.scopedUsageIdentityQuery(appName, hostname). + Where("date(application_usage.started_at, 'unixepoch') = ?", todayKey). + Where("application_usage.classification = ?", ClassificationDistracting). + Select("COALESCE(SUM(COALESCE(application_usage.duration_seconds, 0)), 0)"). + Scan(¤tDistractingSeconds).Error; err != nil { + return err + } + ctx.Usage.Insights.Current.Duration.Today = int(currentDistractingSeconds / 60) + + var currentBlockedCount int64 + if err := s.scopedUsageIdentityQuery(appName, hostname). + Where("date(application_usage.started_at, 'unixepoch') = ?", todayKey). + Where("application_usage.enforcement_action = ?", EnforcementActionBlock). + Count(¤tBlockedCount).Error; err != nil { + return err + } + ctx.Usage.Insights.Current.Blocks.Count = int(currentBlockedCount) + + return nil +} diff --git a/internal/usage/service_usage.go b/internal/usage/service_usage.go index 870b130..252c573 100644 --- a/internal/usage/service_usage.go +++ b/internal/usage/service_usage.go @@ -152,7 +152,13 @@ func (s *Service) saveApplicationUsage(applicationUsage *ApplicationUsage) error func (s *Service) classifyApplicationUsage(ctx context.Context, applicationUsage *ApplicationUsage) (*ClassificationResponse, error) { // Do sandbox classification first, eg user defined custom rules - customRulesResp, err := s.ClassifyCustomRules(ctx, WithAppNameContext(applicationUsage.Application.Name), WithWindowTitleContext(applicationUsage.WindowTitle), WithBrowserURLContext(fromPtr(applicationUsage.BrowserURL))) + customRulesResp, err := s.ClassifyCustomRules( + ctx, + WithAppNameContext(applicationUsage.Application.Name), + WithWindowTitleContext(applicationUsage.WindowTitle), + WithBrowserURLContext(fromPtr(applicationUsage.BrowserURL)), + WithClassificationContext(applicationUsage.Classification), + ) if err != nil { return nil, fmt.Errorf("failed to classify application usage with custom rules: %w", err) } diff --git a/internal/usage/service_usage_test.go b/internal/usage/service_usage_test.go index 94bae3c..0ad8d39 100644 --- a/internal/usage/service_usage_test.go +++ b/internal/usage/service_usage_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + apiv1 "github.com/focusd-so/focusd/gen/api/v1" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -269,3 +270,100 @@ func TestService_ProtectionPauseAndWhitelisting(t *testing.T) { assertEnforcementSource(t, usage.EnforcementSourceWhitelist), ) } + +func TestService_Classification(t *testing.T) { + customRulesOverrideAmazon := ` +import { productive, runtime, type Classify } from "@focusd/runtime"; + +export function classify(): Classify | undefined { + const { domain } = runtime.usage; + + if (domain === "amazon.com") { + return productive("Amazon is productive for procurement work", ["custom", "procurement"]); + } + + return undefined; +} +` + + t.Run("custom rules override obvious classification for paid tier", func(t *testing.T) { + h := newUsageHarness(t, + withAccountTier(apiv1.DeviceHandshakeResponse_ACCOUNT_TIER_PLUS), + withCustomRulesJS(customRulesOverrideAmazon), + ) + + h. + TitleChanged("Chrome", "Amazon", withPtr("https://www.amazon.com")). + AssertLastUsage( + assertUsageClassification(t, usage.ClassificationProductive), + assertUsageClassificationSource(t, usage.ClassificationSourceCustomRules), + assertUsageClassificationReasoning(t, "Amazon is productive for procurement work"), + assertUsageClassificationConfidence(t, 1), + assertUsageTags(t, "custom", "procurement"), + ) + }) + + t.Run("obvious classification wins when tier cannot execute custom rules", func(t *testing.T) { + h := newUsageHarness(t, + withAccountTier(apiv1.DeviceHandshakeResponse_ACCOUNT_TIER_FREE), + withCustomRulesJS(customRulesOverrideAmazon), + ) + + h. + TitleChanged("Chrome", "Amazon", withPtr("https://www.amazon.com")). + AssertLastUsage( + assertUsageClassification(t, usage.ClassificationDistracting), + assertUsageClassificationSource(t, usage.ClassificationSourceObviously), + assertClassificationSandboxRecorded(t), + ) + }) + + t.Run("obvious classification applies when custom rules do not match", func(t *testing.T) { + h := newUsageHarness(t, + withAccountTier(apiv1.DeviceHandshakeResponse_ACCOUNT_TIER_PLUS), + withCustomRulesJS(` +import { productive, runtime, type Classify } from "@focusd/runtime"; + +export function classify(): Classify | undefined { + const { domain } = runtime.usage; + + if (domain === "not-amazon.com") { + return productive("Unreachable rule", ["custom"]); + } + + return undefined; +} +`), + ) + + h. + TitleChanged("Chrome", "Amazon", withPtr("https://www.amazon.com")). + AssertLastUsage( + assertUsageClassification(t, usage.ClassificationDistracting), + assertUsageClassificationSource(t, usage.ClassificationSourceObviously), + ) + }) + + t.Run("LLM fallback applies when custom and obvious do not classify", func(t *testing.T) { + h := newUsageHarness(t, + withAccountTier(apiv1.DeviceHandshakeResponse_ACCOUNT_TIER_FREE), + withDummyLLMResponse(usage.ClassificationResponse{ + Classification: usage.ClassificationNeutral, + ClassificationSource: usage.ClassificationSourceCloudLLMOpenAI, + Reasoning: "LLM fallback for unknown website", + ConfidenceScore: 0.77, + Tags: []string{"llm", "fallback"}, + }), + ) + + h. + TitleChanged("Chrome", "Unknown", withPtr("https://niche-unknown-example.com/rare/page")). + AssertLastUsage( + assertUsageClassification(t, usage.ClassificationNeutral), + assertUsageClassificationSource(t, usage.ClassificationSourceCloudLLMOpenAI), + assertUsageClassificationReasoning(t, "LLM fallback for unknown website"), + assertUsageClassificationConfidence(t, 0.77), + assertUsageTags(t, "llm", "fallback"), + ) + }) +} diff --git a/internal/usage/types_daily_summary.go b/internal/usage/types_daily_summary.go index 19f2812..425876d 100644 --- a/internal/usage/types_daily_summary.go +++ b/internal/usage/types_daily_summary.go @@ -28,27 +28,27 @@ func (LLMDailySummary) TableName() string { // LLMDaySummaryInput is the pre-computed data that gets serialized and sent to the LLM. // It is never stored -- only used as an intermediate representation. type LLMDaySummaryInput struct { - Date string `json:"date"` - TotalProductiveMinutes int `json:"total_productive_minutes"` - TotalDistractiveMinutes int `json:"total_distractive_minutes"` - FocusScore int `json:"focus_score"` - ContextSwitchCount int `json:"context_switch_count"` - LongestFocusStretchMin int `json:"longest_focus_stretch_min"` - DeepWorkSessions []LLMDeepWorkSession `json:"deep_work_sessions"` - DeepWorkTotalMinutes int `json:"deep_work_total_minutes"` - DistractionCascades []LLMDistractionCascade `json:"distraction_cascades"` - TopDistractions []LLMAppTimeSummary `json:"top_distractions"` - TopProductiveApps []LLMAppTimeSummary `json:"top_productive_apps"` - MostProductiveHours string `json:"most_productive_hours"` - MostDistractiveHours string `json:"most_distractive_hours"` - BlockedAttemptCount int `json:"blocked_attempt_count"` - ProtectionPauseCount int `json:"protection_pause_count"` - AvgFocusScoreLast7Days int `json:"avg_focus_score_last_7_days"` - FocusScoreTrend string `json:"focus_score_trend"` + Date string `json:"date"` + TotalProductiveMinutes int `json:"total_productive_minutes"` + TotalDistractingMinutes int `json:"total_distracting_minutes"` + FocusScore int `json:"focus_score"` + ContextSwitchCount int `json:"context_switch_count"` + LongestFocusStretchMin int `json:"longest_focus_stretch_min"` + DeepWorkSessions []LLMDeepWorkSession `json:"deep_work_sessions"` + DeepWorkTotalMinutes int `json:"deep_work_total_minutes"` + DistractionCascades []LLMDistractionCascade `json:"distraction_cascades"` + TopDistractions []LLMAppTimeSummary `json:"top_distractions"` + TopProductiveApps []LLMAppTimeSummary `json:"top_productive_apps"` + MostProductiveHours string `json:"most_productive_hours"` + MostDistractingHours string `json:"most_distracting_hours"` + BlockedAttemptCount int `json:"blocked_attempt_count"` + ProtectionPauseCount int `json:"protection_pause_count"` + AvgFocusScoreLast7Days int `json:"avg_focus_score_last_7_days"` + FocusScoreTrend string `json:"focus_score_trend"` } func (i LLMDaySummaryInput) hasMinimumData() bool { - totalTrackedSecs := (i.TotalProductiveMinutes + i.TotalDistractiveMinutes) * 60 + totalTrackedSecs := (i.TotalProductiveMinutes + i.TotalDistractingMinutes) * 60 return totalTrackedSecs >= minSecondsForSummary } @@ -60,11 +60,11 @@ type LLMDeepWorkSession struct { } type LLMDistractionCascade struct { - TriggerTime string `json:"trigger_time"` - TriggerApp string `json:"trigger_app"` - CascadeApps []string `json:"cascade_apps"` - TotalMinutes int `json:"total_minutes"` - ReturnedToWork string `json:"returned_to_work_at"` + TriggerTime string `json:"trigger_time"` + TriggerApp string `json:"trigger_app"` + CascadeApps []string `json:"cascade_apps"` + TotalMinutes int `json:"total_minutes"` + ReturnedToWork string `json:"returned_to_work_at"` } type LLMAppTimeSummary struct { diff --git a/internal/usage/types_insights.go b/internal/usage/types_insights.go index f4a32e7..b9df209 100644 --- a/internal/usage/types_insights.go +++ b/internal/usage/types_insights.go @@ -23,7 +23,7 @@ type CommunicationBreakdown struct { type ProductivityScore struct { ProductiveSeconds int `json:"productive_seconds"` - DistractiveSeconds int `json:"distractive_seconds"` + DistractingSeconds int `json:"distracting_seconds"` IdleSeconds int `json:"idle_seconds"` OtherSeconds int `json:"other_seconds"` ProductivityScore int `json:"productivity_score"` @@ -38,7 +38,7 @@ func (p *ProductivityScore) addSeconds(classification Classification, seconds in case ClassificationProductive: p.ProductiveSeconds += seconds case ClassificationDistracting: - p.DistractiveSeconds += seconds + p.DistractingSeconds += seconds default: p.OtherSeconds += seconds } diff --git a/internal/usage/utils.go b/internal/usage/utils.go index 251cbd8..089ec56 100644 --- a/internal/usage/utils.go +++ b/internal/usage/utils.go @@ -12,7 +12,6 @@ import ( readability "codeberg.org/readeck/go-readability" "golang.org/x/net/html" - "golang.org/x/net/publicsuffix" ) func parseURLNormalized(browserURL string) (*url.URL, error) { @@ -115,22 +114,12 @@ func fetchMainContent(ctx context.Context, rawURL string) (string, error) { } func createSandboxContext(appName string, url *string) sandboxContext { - sandboxCtx := sandboxContext{ - AppName: appName, - } - + opts := []sandboxContextOption{WithAppNameContext(appName)} if url != nil { - sandboxCtx.URL = *url - - u, err := parseURLNormalized(*url) - if err == nil { - sandboxCtx.Hostname = u.Hostname() - sandboxCtx.Path = u.Path - sandboxCtx.Domain, _ = publicsuffix.EffectiveTLDPlusOne(u.Hostname()) - } + opts = append(opts, WithBrowserURLContext(*url)) } - return sandboxCtx + return NewSandboxContext(opts...) } func withPtr[T any](v T) *T {