From f9b0b9713ae9bb27bb1228188b9e46f5fb7f84fb Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Fri, 20 Mar 2026 14:56:50 +0000 Subject: [PATCH 1/3] refactor(rules): rename custom rule context type to UsageContext Use UsageContext and context parameter names in custom rules templates and test fixtures to make the API naming clearer and more TypeScript-idiomatic. --- frontend/src/components/custom-rules.tsx | 12 +++++----- .../usage/classifier_custom_rules_test.go | 22 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/custom-rules.tsx b/frontend/src/components/custom-rules.tsx index c2194ab..a904e7e 100644 --- a/frontend/src/components/custom-rules.tsx +++ b/frontend/src/components/custom-rules.tsx @@ -84,7 +84,7 @@ interface ClassificationDecision { /** * Provides context for the current rule execution including usage data. */ -interface Context { +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'). */ @@ -107,7 +107,7 @@ interface Context { * @returns Total minutes of usage in the specified time window * @example * // Block if used more than 30 minutes in the last hour - * if (ctx.minutesUsedInPeriod(60) > 30) { + * if (context.minutesUsedInPeriod(60) > 30) { * return { terminationMode: TerminationMode.Block, terminationReasoning: 'Usage limit exceeded' }; * } */ @@ -278,14 +278,14 @@ const starterRulesTS = `/** * * @example * // Classify all GitHub activity as productive - * if (ctx.domain === 'github.com') { + * if (context.domain === 'github.com') { * return { * classification: Classification.Productive, * classificationReasoning: 'GitHub is a development tool' * }; * } */ -export function classify(ctx: Context): ClassificationDecision | undefined { +export function classify(context: UsageContext): ClassificationDecision | undefined { return undefined; } @@ -295,14 +295,14 @@ export function classify(ctx: Context): ClassificationDecision | undefined { * * @example * // Block social media after 10 PM in London - * if (ctx.domain === 'twitter.com' && now(Timezone.Europe_London).getHours() >= 22) { + * if (context.domain === 'twitter.com' && now(Timezone.Europe_London).getHours() >= 22) { * return { * terminationMode: TerminationMode.Block, * terminationReasoning: 'Social media blocked after 10 PM' * }; * } */ -export function terminationMode(ctx: Context): TerminationDecision | undefined { +export function terminationMode(context: UsageContext): TerminationDecision | undefined { return undefined; } `; diff --git a/internal/usage/classifier_custom_rules_test.go b/internal/usage/classifier_custom_rules_test.go index 1403541..cb5c18f 100644 --- a/internal/usage/classifier_custom_rules_test.go +++ b/internal/usage/classifier_custom_rules_test.go @@ -17,7 +17,7 @@ var customRulesApps = ` * Custom classification logic. * Return a ClassificationDecision to override the default, or undefined to keep the default. */ -export function classify(ctx: Context): ClassificationDecision | undefined { +export function classify(context: UsageContext): ClassificationDecision | undefined { console.log("should capture this"); if (now().getHours() == 10 && now().getMinutes() > 0 && now().getMinutes() < 30) { @@ -30,7 +30,7 @@ export function classify(ctx: Context): ClassificationDecision | undefined { console.log("and this too"); - if (ctx.appName == "Slack") { + if (context.appName == "Slack") { return { classification: Classification.Neutral, classificationReasoning: "Slack is a neutral app", @@ -40,7 +40,7 @@ export function classify(ctx: Context): ClassificationDecision | undefined { console.log("also this"); - if (ctx.minutesSinceLastBlock >= 20 && ctx.minutesUsedSinceLastBlock < 5 && ctx.appName == "Discord") { + if (context.minutesSinceLastBlock >= 20 && context.minutesUsedSinceLastBlock < 5 && context.appName == "Discord") { return { classification: Classification.Neutral, classificationReasoning: "Allow using 5 mins every 20 mins", @@ -55,14 +55,14 @@ export function classify(ctx: Context): ClassificationDecision | undefined { * Custom termination logic (blocking). * Return a TerminationDecision to override the default, or undefined to keep the default. */ -export function terminationMode(ctx: Context): TerminationDecision | undefined { +export function terminationMode(context: UsageContext): TerminationDecision | undefined { } ` var customRulesWithMinutesUsedInPeriod = ` -export function classify(ctx: Context): ClassificationDecision | undefined { - const minutesUsed = ctx.minutesUsedInPeriod(60); +export function classify(context: UsageContext): ClassificationDecision | undefined { + const minutesUsed = context.minutesUsedInPeriod(60); if (minutesUsed > 30) { return { @@ -81,9 +81,9 @@ export function classify(ctx: Context): ClassificationDecision | undefined { ` var customRulesWebsite = ` -export function classify(ctx: Context): ClassificationDecision | undefined { +export function classify(context: UsageContext): ClassificationDecision | undefined { // Match by domain - if (ctx.domain === "youtube.com") { + if (context.domain === "youtube.com") { return { classification: Classification.Distracting, classificationReasoning: "YouTube is distracting", @@ -92,7 +92,7 @@ export function classify(ctx: Context): ClassificationDecision | undefined { } // Match by hostname (subdomain-aware) - if (ctx.hostname === "docs.google.com") { + if (context.hostname === "docs.google.com") { return { classification: Classification.Productive, classificationReasoning: "Google Docs is productive", @@ -101,7 +101,7 @@ export function classify(ctx: Context): ClassificationDecision | undefined { } // Match by path - if (ctx.hostname === "github.com" && ctx.path.startsWith("/pulls")) { + if (context.hostname === "github.com" && context.path.startsWith("/pulls")) { return { classification: Classification.Productive, classificationReasoning: "Reviewing pull requests", @@ -110,7 +110,7 @@ export function classify(ctx: Context): ClassificationDecision | undefined { } // Match by full URL - if (ctx.url === "https://twitter.com/home") { + if (context.url === "https://twitter.com/home") { return { classification: Classification.Distracting, classificationReasoning: "Twitter home feed", From 27a430a7bc1a2751e6624fdc0f64a6a020aac2c4 Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Fri, 20 Mar 2026 20:58:20 +0000 Subject: [PATCH 2/3] refactor(usage): rename termination terminology to enforcement - Renamed TerminationMode to EnforcementAction and TerminationModeSource to EnforcementSource.\n- Updated ApplicationUsage and related structs/constants to use Enforcement terminology.\n- Updated frontend components and store to reflect the name changes.\n- Refactored internal usage services and tests to align with the new terminology. --- frontend/src/components/custom-rules.tsx | 30 ++--- frontend/src/components/execution-logs.tsx | 4 +- frontend/src/components/usage-item.tsx | 42 +++---- frontend/src/routes/activity.tsx | 6 +- .../src/routes/screen-time/screentime.tsx | 6 +- frontend/src/stores/usage-store.ts | 13 ++- .../usage/classifier_custom_rules_test.go | 4 +- internal/usage/harness_test.go | 8 +- internal/usage/insights_basic.go | 30 ++--- internal/usage/insights_basic_test.go | 96 +++++++-------- internal/usage/insights_daily_summary.go | 4 +- internal/usage/insights_report.go | 4 +- internal/usage/protection.go | 70 +++++------ internal/usage/protection_test.go | 110 +++++++++--------- internal/usage/sandbox.go | 26 ++--- internal/usage/service.go | 34 ++++++ internal/usage/service_usage.go | 14 +-- internal/usage/service_usage_test.go | 80 ++++++------- internal/usage/types_db.go | 34 +++--- internal/usage/types_usage.go | 32 ++--- main.go | 2 +- 21 files changed, 343 insertions(+), 306 deletions(-) diff --git a/frontend/src/components/custom-rules.tsx b/frontend/src/components/custom-rules.tsx index a904e7e..03d78bf 100644 --- a/frontend/src/components/custom-rules.tsx +++ b/frontend/src/components/custom-rules.tsx @@ -43,18 +43,18 @@ declare const Classification: { /** * Determines whether to block or allow the activity. */ -type TerminationModeType = "none" | "block" | "paused" | "allow"; +type EnforcementActionType = "none" | "block" | "paused" | "allow"; /** * Global constant for termination mode values. - * Use these values when returning a TerminationDecision. + * Use these values when returning a EnforcementDecision. * @example * return { - * terminationMode: TerminationMode.Block, - * terminationReasoning: "Blocked during focus hours" + * enforcementAction: EnforcementAction.Block, + * enforcementReason: "Blocked during focus hours" * }; */ -declare const TerminationMode: { +declare const EnforcementAction: { readonly None: "none"; readonly Block: "block"; readonly Paused: "paused"; @@ -62,13 +62,13 @@ declare const TerminationMode: { }; /** - * Decision returned from the terminationMode function. + * Decision returned from the enforcementDecision function. */ -interface TerminationDecision { - /** The termination mode to apply. Use TerminationMode constants. */ - terminationMode: TerminationModeType; +interface EnforcementDecision { + /** The termination mode to apply. Use EnforcementAction constants. */ + enforcementAction: EnforcementActionType; /** Human-readable explanation for why this decision was made. */ - terminationReasoning: string; + enforcementReason: string; } /** @@ -108,7 +108,7 @@ interface UsageContext { * @example * // Block if used more than 30 minutes in the last hour * if (context.minutesUsedInPeriod(60) > 30) { - * return { terminationMode: TerminationMode.Block, terminationReasoning: 'Usage limit exceeded' }; + * return { enforcementAction: EnforcementAction.Block, enforcementReason: 'Usage limit exceeded' }; * } */ minutesUsedInPeriod(minutes: number): number; @@ -291,18 +291,18 @@ export function classify(context: UsageContext): ClassificationDecision | undefi /** * Custom termination logic (blocking). - * Return a TerminationDecision to override the default, or undefined to keep the default. + * 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 { - * terminationMode: TerminationMode.Block, - * terminationReasoning: 'Social media blocked after 10 PM' + * enforcementAction: EnforcementAction.Block, + * enforcementReason: 'Social media blocked after 10 PM' * }; * } */ -export function terminationMode(context: UsageContext): TerminationDecision | undefined { +export function enforcementDecision(context: UsageContext): EnforcementDecision | undefined { return undefined; } `; diff --git a/frontend/src/components/execution-logs.tsx b/frontend/src/components/execution-logs.tsx index c6c0597..06e9dbb 100644 --- a/frontend/src/components/execution-logs.tsx +++ b/frontend/src/components/execution-logs.tsx @@ -263,9 +263,9 @@ export function ExecutionLogsSheet({ Classify )} diff --git a/frontend/src/components/usage-item.tsx b/frontend/src/components/usage-item.tsx index e68e6bd..bed1f23 100644 --- a/frontend/src/components/usage-item.tsx +++ b/frontend/src/components/usage-item.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import { Link } from "@tanstack/react-router"; +import { Browser } from "@wailsio/runtime"; import { IconWorld, IconAppWindow, @@ -16,6 +17,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useUsageStore } from "@/stores/usage-store"; +import { useAccountStore } from "@/stores/account-store"; import type { ApplicationUsage } from "../../bindings/github.com/focusd-so/focusd/internal/usage/models"; import { EnforcementAction } from "../../bindings/github.com/focusd-so/focusd/internal/usage/models"; @@ -331,7 +333,7 @@ export function ClassificationReasoningLabel({ {displayIcon} {shouldBeLink || enforcementSource?.isLink ? ( - + ) : ( - {displayText} + {displayText} )} ); @@ -387,6 +389,7 @@ export function UsageItem({ usage }: { usage: ApplicationUsage }) { const isGrayTheme = isNeutralOrSystem(usage.classification); const resumeProtection = useUsageStore((state) => state.resumeProtection); const currentPause = useUsageStore((state) => state.currentPause); + const checkoutLink = useAccountStore((state) => state.checkoutLink); const [showLogs, setShowLogs] = useState(false); // Check if there's currently an active pause @@ -424,27 +427,68 @@ export function UsageItem({ usage }: { usage: ApplicationUsage }) { ? usage.ended_at - usage.started_at : null; + const classificationSandboxContext = + (usage as any).classification_sandbox_context ?? (usage as any).sandbox_context; + const classificationSandboxResponse = + (usage as any).classification_sandbox_response ?? (usage as any).sandbox_response; + const classificationSandboxLogs = + (usage as any).classification_sandbox_logs ?? (usage as any).sandbox_logs; + + const enforcementSandboxContext = (usage as any).enforcement_sandbox_context; + const enforcementSandboxResponse = (usage as any).enforcement_sandbox_response; + const enforcementSandboxLogs = (usage as any).enforcement_sandbox_logs; + + const hasSandboxResult = (response?: string | null) => { + if (!response) return false; + const normalized = response.trim().toLowerCase(); + return normalized !== "" && normalized !== "no response" && normalized !== "null"; + }; + + const hasClassificationSandbox = + hasSandboxResult(classificationSandboxResponse); + const hasEnforcementSandbox = hasSandboxResult(enforcementSandboxResponse); + const hasAnySandbox = hasClassificationSandbox || hasEnforcementSandbox; + // Detect script execution that was ignored (Free tier) let sandboxDecision: any = null; try { - if (usage.sandbox_response && usage.sandbox_response !== "no response") { - sandboxDecision = JSON.parse(usage.sandbox_response); + if (enforcementSandboxResponse && enforcementSandboxResponse !== "no response") { + sandboxDecision = JSON.parse(enforcementSandboxResponse); + } else if (classificationSandboxResponse && classificationSandboxResponse !== "no response") { + sandboxDecision = JSON.parse(classificationSandboxResponse); } } catch (e) { // ignore } + const actualDidBlock = usage.enforcement_action === EnforcementAction.EnforcementActionBlock; + let scriptDidBlock = false; + let hasScriptDecision = false; + if (sandboxDecision) { + if (sandboxDecision.enforcementAction === "block") { + scriptDidBlock = true; + hasScriptDecision = true; + } else if (sandboxDecision.enforcementAction === "allow") { + scriptDidBlock = false; + hasScriptDecision = true; + } else if (sandboxDecision.classification) { + scriptDidBlock = isDistracting(sandboxDecision.classification); + hasScriptDecision = true; + } + } + const isIgnoredRule = - !!sandboxDecision && + hasScriptDecision && usage.classification_source !== "custom_rules" && - usage.enforcement_source !== "custom_rules"; + usage.enforcement_source !== "custom_rules" && + scriptDidBlock !== actualDidBlock; return (
-
-
+
+
{/* Icon Container */}
{/* Text Content */} -
+
{usage.application?.hostname || usage.application?.name || "Unknown"} @@ -487,17 +531,53 @@ export function UsageItem({ usage }: { usage: ApplicationUsage }) { )} - + {usage.window_title || (isWeb ? "Browsing" : "Using app")}
+ {isIgnoredRule && ( +
+ + + Script would have{" "} + + {scriptDidBlock ? "blocked" : "allowed"} + {" "} + this on paid tiers. + +
+ {checkoutLink && ( + <> + + · + + )} + e.stopPropagation()} + > + Activate custom rules + +
+
+ )}
{/* Right Side Group */} -
-
-
+
+
+
{durationSeconds != null && durationSeconds >= 0 && (
@@ -541,32 +621,10 @@ export function UsageItem({ usage }: { usage: ApplicationUsage }) { termSource?.label === "custom rules" ? termSource : null } /> - {isIgnoredRule && ( -
- - - Script would have{" "} - - {sandboxDecision.enforcementAction && sandboxDecision.enforcementAction !== "none" - ? sandboxDecision.enforcementAction - : sandboxDecision.classification} - {" "} - this.{" "} - e.stopPropagation()} - > - Upgrade to Pro - - -
- )}
{/* Sandbox Logs Toggle */} - {(usage.sandbox_context || usage.sandbox_response || usage.sandbox_logs) && ( + {hasAnySandbox && (