From 7f1a9f22b0a7702f1ba2ac97eb73741c162a82ae Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Tue, 24 Mar 2026 07:33:40 +0000 Subject: [PATCH 1/5] test(usage): add classification test placeholder --- internal/usage/service_usage_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/usage/service_usage_test.go b/internal/usage/service_usage_test.go index 94bae3c..b14a52b 100644 --- a/internal/usage/service_usage_test.go +++ b/internal/usage/service_usage_test.go @@ -269,3 +269,6 @@ func TestService_ProtectionPauseAndWhitelisting(t *testing.T) { assertEnforcementSource(t, usage.EnforcementSourceWhitelist), ) } + +func TestService_Classification(t *testing.T) { +} From 75997f2324b3ae8a0813b59a337a0ac1269c6141 Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Tue, 24 Mar 2026 07:37:35 +0000 Subject: [PATCH 2/5] test(usage): cover classification precedence with tier-aware harness --- internal/usage/harness_test.go | 111 ++++++++++++++++++++++++++- internal/usage/service_usage_test.go | 95 +++++++++++++++++++++++ 2 files changed, 202 insertions(+), 4 deletions(-) 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/service_usage_test.go b/internal/usage/service_usage_test.go index b14a52b..36227ed 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" @@ -271,4 +272,98 @@ func TestService_ProtectionPauseAndWhitelisting(t *testing.T) { } func TestService_Classification(t *testing.T) { + customRulesOverrideAmazon := ` +export function classify(context: UsageContext): ClassificationDecision | undefined { + if (context.domain === "amazon.com") { + return { + classification: Classification.Productive, + classificationReasoning: "Amazon is productive for procurement work", + tags: ["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(` +export function classify(context: UsageContext): ClassificationDecision | undefined { + if (context.domain === "not-amazon.com") { + return { + classification: Classification.Productive, + classificationReasoning: "Unreachable rule", + tags: ["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"), + ) + }) } From 33831dbd078de98e4fd75fbbe0205f8c60bd37fe Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Tue, 24 Mar 2026 14:05:29 +0000 Subject: [PATCH 3/5] refactor(usage): reshape custom rules context for clarity Expose nested context.usage.metadata and context.usage.insights so rule logic reads naturally and metrics are clearly scoped. Expand the in-editor type docs and starter examples to make the API easier to discover and use. --- frontend/src/components/custom-rules.tsx | 138 ++++++++++---- internal/usage/classifier_custom_rules.go | 16 +- .../usage/classifier_custom_rules_test.go | 28 +-- internal/usage/protection.go | 22 +-- internal/usage/protection_test.go | 60 +++--- internal/usage/sandbox.go | 82 ++++----- internal/usage/sandbox_context.go | 92 +++++++--- internal/usage/sandbox_context_enrich.go | 173 ++++++++++++++++++ internal/usage/service_usage.go | 8 +- internal/usage/service_usage_test.go | 8 +- internal/usage/utils.go | 12 +- 11 files changed, 458 insertions(+), 181 deletions(-) create mode 100644 internal/usage/sandbox_context_enrich.go diff --git a/frontend/src/components/custom-rules.tsx b/frontend/src/components/custom-rules.tsx index 5d897ab..7af8544 100644 --- a/frontend/src/components/custom-rules.tsx +++ b/frontend/src/components/custom-rules.tsx @@ -25,7 +25,7 @@ type ClassificationType = "unknown" | "productive" | "distracting" | "neutral" | /** * Global constant for classification values. - * Use these values when returning a ClassificationDecision. + * Use these values when returning a Classify. * @example * return { * classification: Classification.Productive, @@ -46,8 +46,8 @@ declare const Classification: { type EnforcementActionType = "none" | "block" | "paused" | "allow"; /** - * Global constant for termination mode values. - * Use these values when returning a EnforcementDecision. + * Global constant for enforcement action values. + * Use these values when returning an Enforcement. * @example * return { * enforcementAction: EnforcementAction.Block, @@ -62,9 +62,9 @@ declare const EnforcementAction: { }; /** - * Decision returned from the enforcementDecision function. + * Result returned from the enforcement function. */ -interface EnforcementDecision { +interface Enforcement { /** The termination mode to apply. Use EnforcementAction constants. */ enforcementAction: EnforcementActionType; /** Human-readable explanation for why this decision was made. */ @@ -72,9 +72,9 @@ interface EnforcementDecision { } /** - * Decision returned from the classify function. + * Result returned from the classify function. */ -interface ClassificationDecision { +interface Classify { /** The classification to apply. Use Classification constants. */ classification: ClassificationType; /** Human-readable explanation for why this classification was chosen. */ @@ -82,38 +82,82 @@ interface ClassificationDecision { } /** - * Provides context for the current rule execution including usage data. + * Non-numeric details about the current app/site being evaluated. */ -interface UsageContext { +interface UsageMetadata { /** 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 appName: string; + /** The window title if available. */ + readonly title: string; + /** The hostname if the activity is a website (e.g., 'docs.github.com'). */ readonly hostname: string; /** The registered domain extracted from the hostname (e.g., 'github.com'). */ readonly domain: string; + /** The URL path (e.g., '/pulls/assigned'). */ + readonly path: 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; +} + +interface InsightsToday { + readonly productiveMinutes: number; + readonly distractingMinutes: number; + readonly idleMinutes: number; + readonly otherMinutes: number; + readonly focusScore: number; + readonly distractionCount: number; + readonly blockedCount: number; +} + +/** + * Numeric metrics scoped to the current usage target. + */ +interface UsageInsights { + /** Distracting minutes for this app/site today. */ + readonly distractingMinutes: number; + /** Blocked attempts for this app/site today. */ + readonly blockedCount: number; /** 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; + /** Duration (minutes) of the last blocked session for this app/site (-1 if never blocked). */ + readonly lastBlockedDurationMinutes: 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' }; - * } + * @param minutes - Time window in minutes (e.g. 60 for last hour) */ minutesUsedInPeriod(minutes: number): number; } +interface InsightsContext { + readonly today: InsightsToday; + readonly topDistractions: Record; + readonly topBlocked: Record; + readonly projectBreakdown: Record; + readonly communicationBreakdown: Record; +} + +interface UsageContext { + readonly metadata: UsageMetadata; + readonly insights: UsageInsights; +} + +/** + * Full context passed into classify/enforcement. + * + * Structure: + * - context.usage.metadata => descriptive fields for the current app/site + * - context.usage.insights => numeric fields for the current app/site + * - context.insights => broader daily/global dashboard insights + */ +interface Context { + readonly usage: UsageContext; + readonly insights: InsightsContext; +} + // ============ Timezone Constants ============ /** @@ -273,36 +317,68 @@ declare const console: { `; const starterRulesTS = `/** + * Quick reference + * + * context.usage.metadata.* + * - appName, title, hostname, domain, path, url, classification + * + * context.usage.insights.* + * - distractingMinutes, blockedCount + * - minutesSinceLastBlock (-1 means never blocked) + * - minutesUsedSinceLastBlock (-1 means never blocked) + * - lastBlockedDurationMinutes (-1 means never blocked) + * - minutesUsedInPeriod(minutes) + * + * context.insights.today.* + * - productiveMinutes, distractingMinutes, idleMinutes, otherMinutes + * - focusScore, distractionCount, blockedCount + */ + +/** * Custom classification logic. - * Return a ClassificationDecision to override the default, or undefined to keep the default. + * Return a Classify to override the default, or undefined to keep the default. * - * @example - * // Classify all GitHub activity as productive - * if (context.domain === 'github.com') { + * Example 1: classify a domain as productive + * if (context.usage.metadata.domain === 'github.com') { * return { * classification: Classification.Productive, * classificationReasoning: 'GitHub is a development tool' * }; * } + * + * Example 2: classify as distracting after 30 minutes in the last hour + * if (context.usage.insights.minutesUsedInPeriod(60) > 30) { + * return { + * classification: Classification.Distracting, + * classificationReasoning: 'Exceeded hourly usage budget' + * }; + * } */ -export function classify(context: UsageContext): ClassificationDecision | undefined { +export function classify(context: Context): Classify | undefined { return undefined; } /** - * Custom termination logic (blocking). - * Return a EnforcementDecision to override the default, or undefined to keep the default. + * Custom enforcement logic. + * Return an Enforcement 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) { + * Example 1: block social media after 10 PM in London + * if (context.usage.metadata.domain === 'twitter.com' && now(Timezone.Europe_London).getHours() >= 22) { * return { * enforcementAction: EnforcementAction.Block, * enforcementReason: 'Social media blocked after 10 PM' * }; * } + * + * Example 2: strict mode if distraction is already high today + * if (context.insights.today.distractingMinutes >= 90 && context.usage.insights.blockedCount >= 3) { + * return { + * enforcementAction: EnforcementAction.Block, + * enforcementReason: 'High distraction day with repeated blocked attempts' + * }; + * } */ -export function enforcementDecision(context: UsageContext): EnforcementDecision | undefined { +export function enforcement(context: Context): Enforcement | undefined { return undefined; } `; 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..ccc1bc2 100644 --- a/internal/usage/classifier_custom_rules_test.go +++ b/internal/usage/classifier_custom_rules_test.go @@ -15,9 +15,9 @@ import ( var customRulesApps = ` /** * Custom classification logic. -* Return a ClassificationDecision to override the default, or undefined to keep the default. + * Return a Classify to override the default, or undefined to keep the default. */ -export function classify(context: UsageContext): ClassificationDecision | undefined { +export function classify(context: Context): Classify | undefined { console.log("should capture this"); if (now().getHours() == 10 && now().getMinutes() > 0 && now().getMinutes() < 30) { @@ -30,7 +30,7 @@ export function classify(context: UsageContext): ClassificationDecision | undefi console.log("and this too"); - if (context.appName == "Slack") { + if (context.usage.metadata.appName == "Slack") { return { classification: Classification.Neutral, classificationReasoning: "Slack is a neutral app", @@ -40,7 +40,7 @@ export function classify(context: UsageContext): ClassificationDecision | undefi console.log("also this"); - if (context.minutesSinceLastBlock >= 20 && context.minutesUsedSinceLastBlock < 5 && context.appName == "Discord") { + if (context.usage.insights.minutesSinceLastBlock >= 20 && context.usage.insights.minutesUsedSinceLastBlock < 5 && context.usage.metadata.appName == "Discord") { return { classification: Classification.Neutral, classificationReasoning: "Allow using 5 mins every 20 mins", @@ -52,17 +52,17 @@ export function classify(context: UsageContext): ClassificationDecision | undefi } /** -* Custom termination logic (blocking). -* Return a EnforcementDecision to override the default, or undefined to keep the default. + * Custom enforcement logic. + * Return an Enforcement to override the default, or undefined to keep the default. */ -export function enforcementDecision(context: UsageContext): EnforcementDecision | undefined { +export function enforcement(context: Context): Enforcement | undefined { } ` var customRulesWithMinutesUsedInPeriod = ` -export function classify(context: UsageContext): ClassificationDecision | undefined { - const minutesUsed = context.minutesUsedInPeriod(60); +export function classify(context: Context): Classify | undefined { + const minutesUsed = context.usage.insights.minutesUsedInPeriod(60); if (minutesUsed > 30) { return { @@ -81,9 +81,9 @@ export function classify(context: UsageContext): ClassificationDecision | undefi ` var customRulesWebsite = ` -export function classify(context: UsageContext): ClassificationDecision | undefined { +export function classify(context: Context): Classify | undefined { // Match by domain - if (context.domain === "youtube.com") { + if (context.usage.metadata.domain === "youtube.com") { return { classification: Classification.Distracting, classificationReasoning: "YouTube is distracting", @@ -92,7 +92,7 @@ export function classify(context: UsageContext): ClassificationDecision | undefi } // Match by hostname (subdomain-aware) - if (context.hostname === "docs.google.com") { + if (context.usage.metadata.hostname === "docs.google.com") { return { classification: Classification.Productive, classificationReasoning: "Google Docs is productive", @@ -101,7 +101,7 @@ export function classify(context: UsageContext): ClassificationDecision | undefi } // Match by path - if (context.hostname === "github.com" && context.path.startsWith("/pulls")) { + if (context.usage.metadata.hostname === "github.com" && context.usage.metadata.path.startsWith("/pulls")) { return { classification: Classification.Productive, classificationReasoning: "Reviewing pull requests", @@ -110,7 +110,7 @@ export function classify(context: UsageContext): ClassificationDecision | undefi } // Match by full URL - if (context.url === "https://twitter.com/home") { + if (context.usage.metadata.url === "https://twitter.com/home") { return { classification: Classification.Distracting, classificationReasoning: "Twitter home feed", diff --git a/internal/usage/protection.go b/internal/usage/protection.go index 6430040..a9447bf 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.Metadata.Title = appUsage.WindowTitle + sandboxCtx.Usage.Metadata.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..98e1bd4 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,10 +303,10 @@ 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.usage.metadata.appName is accessible", func(t *testing.T) { customRules := ` -export function enforcementDecision(ctx) { - if (ctx.appName == "Slack") { +export function enforcement(ctx) { + if (ctx.usage.metadata.appName == "Slack") { return { enforcementAction: EnforcementAction.Block, enforcementReason: "Slack is blocked by custom rule", @@ -322,13 +324,13 @@ 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) { + t.Run("ctx.usage.metadata.classification is accessible", func(t *testing.T) { customRules := ` -export function enforcementDecision(ctx) { - if (ctx.classification == "distracting") { +export function enforcement(ctx) { + if (ctx.usage.metadata.classification == "distracting") { return { enforcementAction: EnforcementAction.Block, enforcementReason: "distracting classification detected", @@ -346,15 +348,15 @@ 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) { + t.Run("ctx.usage.metadata.hostname and ctx.usage.metadata.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" customRules := ` -export function enforcementDecision(ctx) { - if (ctx.hostname == "docs.google.com" && ctx.domain == "google.com") { +export function enforcement(ctx) { + if (ctx.usage.metadata.hostname == "docs.google.com" && ctx.usage.metadata.domain == "google.com") { return { enforcementAction: EnforcementAction.Block, enforcementReason: "Google Docs blocked via hostname/domain", @@ -374,13 +376,13 @@ 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) { + t.Run("ctx.usage.metadata.url and ctx.usage.metadata.path are accessible", func(t *testing.T) { customRules := ` -export function enforcementDecision(ctx) { - if (ctx.url == "https://github.com/pulls" && ctx.path == "/pulls") { +export function enforcement(ctx) { + if (ctx.usage.metadata.url == "https://github.com/pulls" && ctx.usage.metadata.path == "/pulls") { return { enforcementAction: EnforcementAction.Allow, enforcementReason: "PR reviews are allowed", @@ -400,12 +402,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(ctx) { return undefined; } ` @@ -420,16 +422,16 @@ 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") { +export function enforcement(ctx) { + console.log("enforcement executed", ctx.usage.metadata.appName) + if (ctx.usage.metadata.appName == "Slack") { return { enforcementAction: EnforcementAction.Block, enforcementReason: "Slack is blocked by custom rule", @@ -475,8 +477,8 @@ 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) +export function enforcement(ctx) { + console.log("undefined decision for", ctx.usage.metadata.appName) return undefined; } ` @@ -507,8 +509,8 @@ 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) +export function enforcement(ctx) { + console.log("about to fail", ctx.usage.metadata.appName) throw new Error("enforcement fail"); } ` @@ -521,7 +523,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 +559,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 +576,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 +593,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..72b2361 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"` } @@ -177,10 +177,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') { @@ -296,7 +296,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 +305,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 +326,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 } @@ -372,30 +356,30 @@ func (s *sandbox) executeFunction(v8ctx *v8.Context, preparedScript string, func } // Call the function - // Add minutesUsedInPeriod as a method on the context object + // Add minutesUsedInPeriod as a method on usage.insights. 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; }; + if (!ctx.usage) { + ctx.usage = {}; } - // Add minutesSinceLastBlock as a method that returns the pre-computed value - if (typeof __minutesSinceLastBlockValue === 'number') { - ctx.minutesSinceLastBlock = __minutesSinceLastBlockValue; - } else { - ctx.minutesSinceLastBlock = -1; + if (!ctx.usage.metadata) { + ctx.usage.metadata = {}; + } + + if (!ctx.usage.insights) { + ctx.usage.insights = {}; } - // Add minutesUsedSinceLastBlock as a method that returns the pre-computed value - if (typeof __minutesUsedSinceLastBlockValue === 'number') { - ctx.minutesUsedSinceLastBlock = __minutesUsedSinceLastBlockValue; - } + // Add minutesUsedInPeriod method to usage.insights. + if (typeof __minutesUsedInPeriod === 'function') { + ctx.usage.insights.minutesUsedInPeriod = function(minutes) { + return __minutesUsedInPeriod(ctx.usage.metadata.appName, ctx.usage.metadata.hostname, minutes); + }; + } else { + ctx.usage.insights.minutesUsedInPeriod = function(minutes) { return 0; }; + } const result = %s(ctx); if (result === undefined || result === null) { @@ -432,7 +416,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 +437,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 +450,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 +468,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..60b255f 100644 --- a/internal/usage/sandbox_context.go +++ b/internal/usage/sandbox_context.go @@ -6,50 +6,80 @@ 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"` - +type sandboxUsageMetadata struct { + AppName string `json:"appName"` + Title string `json:"title"` Hostname string `json:"hostname"` Path string `json:"path"` Domain string `json:"domain"` URL string `json:"url"` Classification string `json:"classification"` +} - // Helper pre-computed values - MinutesSinceLastBlock *int `json:"minutesSinceLastBlock"` - MinutesUsedSinceLastBlock *int `json:"minutesUsedSinceLastBlock"` +type sandboxUsageInsights struct { + DistractingMinutes int `json:"distractingMinutes"` + BlockedCount int `json:"blockedCount"` + MinutesSinceLastBlock int `json:"minutesSinceLastBlock"` + MinutesUsedSinceLastBlock int `json:"minutesUsedSinceLastBlock"` + LastBlockedDurationMinutes int `json:"lastBlockedDurationMinutes"` +} + +type sandboxUsageContext struct { + Metadata sandboxUsageMetadata `json:"metadata"` + Insights sandboxUsageInsights `json:"insights"` +} + +type sandboxInsightsToday struct { + ProductiveMinutes int `json:"productiveMinutes"` + DistractingMinutes int `json:"distractingMinutes"` + IdleMinutes int `json:"idleMinutes"` + OtherMinutes int `json:"otherMinutes"` + FocusScore int `json:"focusScore"` + DistractionCount int `json:"distractionCount"` + BlockedCount int `json:"blockedCount"` +} + +type sandboxInsightsContext struct { + Today sandboxInsightsToday `json:"today"` + TopDistractions map[string]int `json:"topDistractions"` + TopBlocked map[string]int `json:"topBlocked"` + ProjectBreakdown map[string]int `json:"projectBreakdown"` + CommunicationBreakdown map[string]CommunicationBreakdown `json:"communicationBreakdown"` +} + +// sandboxContext provides context for the current rule execution including usage data and helper functions. +type sandboxContext struct { + Usage sandboxUsageContext `json:"usage"` + Insights sandboxInsightsContext `json:"insights"` // 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.Metadata.AppName = appName } } func WithWindowTitleContext(title string) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.Title = title + ctx.Usage.Metadata.Title = title } } func WithBrowserURLContext(url string) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.URL = url + ctx.Usage.Metadata.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.Metadata.Hostname = u.Hostname() + ctx.Usage.Metadata.Path = u.Path + ctx.Usage.Metadata.Domain, _ = publicsuffix.EffectiveTLDPlusOne(u.Hostname()) } } } @@ -62,7 +92,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,18 +100,38 @@ func WithMinutesUsedInPeriodContext(minutesUsedInPeriod func(bundleID, hostname func WithMinutesSinceLastBlockContext(minutesSinceLastBlock int) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.MinutesSinceLastBlock = &minutesSinceLastBlock + ctx.Usage.Insights.MinutesSinceLastBlock = minutesSinceLastBlock } } func WithMinutesUsedSinceLastBlockContext(minutesUsedSinceLastBlock int) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.MinutesUsedSinceLastBlock = &minutesUsedSinceLastBlock + ctx.Usage.Insights.MinutesUsedSinceLastBlock = minutesUsedSinceLastBlock + } +} + +func WithClassificationContext(classification Classification) sandboxContextOption { + return func(ctx *sandboxContext) { + ctx.Usage.Metadata.Classification = string(classification) } } func NewSandboxContext(opts ...sandboxContextOption) sandboxContext { - ctx := sandboxContext{} + ctx := sandboxContext{ + Usage: sandboxUsageContext{ + Insights: sandboxUsageInsights{ + MinutesSinceLastBlock: -1, + MinutesUsedSinceLastBlock: -1, + LastBlockedDurationMinutes: -1, + }, + }, + Insights: sandboxInsightsContext{ + TopDistractions: make(map[string]int), + TopBlocked: make(map[string]int), + ProjectBreakdown: make(map[string]int), + CommunicationBreakdown: make(map[string]CommunicationBreakdown), + }, + } for _, opt := range opts { opt(&ctx) diff --git a/internal/usage/sandbox_context_enrich.go b/internal/usage/sandbox_context_enrich.go new file mode 100644 index 0000000..e010cd4 --- /dev/null +++ b/internal/usage/sandbox_context_enrich.go @@ -0,0 +1,173 @@ +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.Metadata.AppName + hostname := ctx.Usage.Metadata.Hostname + 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.MinutesSinceLastBlock < 0 { + ctx.Usage.Insights.MinutesSinceLastBlock = int(time.Since(time.Unix(lastBlocked.StartedAt, 0)).Minutes()) + } + + if ctx.Usage.Insights.LastBlockedDurationMinutes < 0 { + if lastBlocked.DurationSeconds != nil { + ctx.Usage.Insights.LastBlockedDurationMinutes = *lastBlocked.DurationSeconds / 60 + } else { + ctx.Usage.Insights.LastBlockedDurationMinutes = 0 + } + } + + if ctx.Usage.Insights.MinutesUsedSinceLastBlock < 0 { + 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 + } + + ctx.Usage.Insights.MinutesUsedSinceLastBlock = int(totalSeconds / 60) + } + + return nil +} + +func (s *Service) populateInsightsContext(ctx *sandboxContext) error { + now := time.Now() + insights, err := s.GetDayInsights(now) + if err != nil { + return err + } + + ctx.Insights.Today.ProductiveMinutes = insights.ProductivityScore.ProductiveSeconds / 60 + ctx.Insights.Today.DistractingMinutes = insights.ProductivityScore.DistractiveSeconds / 60 + ctx.Insights.Today.IdleMinutes = insights.ProductivityScore.IdleSeconds / 60 + ctx.Insights.Today.OtherMinutes = insights.ProductivityScore.OtherSeconds / 60 + ctx.Insights.Today.FocusScore = insights.ProductivityScore.ProductivityScore + + ctx.Insights.TopDistractions = insights.TopDistractions + ctx.Insights.TopBlocked = insights.TopBlocked + ctx.Insights.ProjectBreakdown = insights.ProjectBreakdown + ctx.Insights.CommunicationBreakdown = insights.CommunicationBreakdown + + todayKey := now.Format("2006-01-02") + + var distractionCount int64 + if err := s.db.Model(&ApplicationUsage{}). + Where("date(started_at, 'unixepoch') = ?", todayKey). + Where("classification = ?", ClassificationDistracting). + Count(&distractionCount).Error; err != nil { + return err + } + ctx.Insights.Today.DistractionCount = int(distractionCount) + + var blockedCount int64 + if err := s.db.Model(&ApplicationUsage{}). + Where("date(started_at, 'unixepoch') = ?", todayKey). + Where("enforcement_action = ?", EnforcementActionBlock). + Count(&blockedCount).Error; err != nil { + return err + } + ctx.Insights.Today.BlockedCount = int(blockedCount) + + appName := ctx.Usage.Metadata.AppName + hostname := ctx.Usage.Metadata.Hostname + 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.DistractingMinutes = 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.BlockedCount = 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 36227ed..acadd78 100644 --- a/internal/usage/service_usage_test.go +++ b/internal/usage/service_usage_test.go @@ -273,8 +273,8 @@ func TestService_ProtectionPauseAndWhitelisting(t *testing.T) { func TestService_Classification(t *testing.T) { customRulesOverrideAmazon := ` -export function classify(context: UsageContext): ClassificationDecision | undefined { - if (context.domain === "amazon.com") { +export function classify(context: Context): Classify | undefined { + if (context.usage.metadata.domain === "amazon.com") { return { classification: Classification.Productive, classificationReasoning: "Amazon is productive for procurement work", @@ -322,8 +322,8 @@ export function classify(context: UsageContext): ClassificationDecision | undefi h := newUsageHarness(t, withAccountTier(apiv1.DeviceHandshakeResponse_ACCOUNT_TIER_PLUS), withCustomRulesJS(` -export function classify(context: UsageContext): ClassificationDecision | undefined { - if (context.domain === "not-amazon.com") { +export function classify(context: Context): Classify | undefined { + if (context.usage.metadata.domain === "not-amazon.com") { return { classification: Classification.Productive, classificationReasoning: "Unreachable rule", diff --git a/internal/usage/utils.go b/internal/usage/utils.go index 251cbd8..74dca99 100644 --- a/internal/usage/utils.go +++ b/internal/usage/utils.go @@ -115,18 +115,16 @@ func fetchMainContent(ctx context.Context, rawURL string) (string, error) { } func createSandboxContext(appName string, url *string) sandboxContext { - sandboxCtx := sandboxContext{ - AppName: appName, - } + sandboxCtx := NewSandboxContext(WithAppNameContext(appName)) if url != nil { - sandboxCtx.URL = *url + sandboxCtx.Usage.Metadata.URL = *url u, err := parseURLNormalized(*url) if err == nil { - sandboxCtx.Hostname = u.Hostname() - sandboxCtx.Path = u.Path - sandboxCtx.Domain, _ = publicsuffix.EffectiveTLDPlusOne(u.Hostname()) + sandboxCtx.Usage.Metadata.Hostname = u.Hostname() + sandboxCtx.Usage.Metadata.Path = u.Path + sandboxCtx.Usage.Metadata.Domain, _ = publicsuffix.EffectiveTLDPlusOne(u.Hostname()) } } From 1617b2c881d68a693f137f363d920075a83e36e1 Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Wed, 25 Mar 2026 10:23:30 +0000 Subject: [PATCH 4/5] refactor(custom-rules): align context shape and terminology --- frontend/src/components/custom-rules.tsx | 139 ++++++++++-------- frontend/src/components/hero-metric-cards.tsx | 16 +- .../components/insights/bento-dashboard.tsx | 20 +-- .../src/components/weekly-trend-chart.tsx | 14 +- frontend/src/lib/mock-data.ts | 20 +-- frontend/src/routes/insights/trends.tsx | 14 +- frontend/src/routes/screen-time/trends.tsx | 14 +- .../usage/classifier_custom_rules_test.go | 14 +- internal/usage/insights_daily_summary.go | 12 +- internal/usage/insights_report.go | 8 +- internal/usage/protection.go | 4 +- internal/usage/protection_test.go | 24 +-- internal/usage/sandbox.go | 35 +++-- internal/usage/sandbox_context.go | 82 ++++------- internal/usage/sandbox_context_enrich.go | 64 +++----- internal/usage/service_usage_test.go | 4 +- internal/usage/types_daily_summary.go | 46 +++--- internal/usage/types_insights.go | 4 +- internal/usage/utils.go | 15 +- 19 files changed, 265 insertions(+), 284 deletions(-) diff --git a/frontend/src/components/custom-rules.tsx b/frontend/src/components/custom-rules.tsx index 7af8544..9e44e08 100644 --- a/frontend/src/components/custom-rules.tsx +++ b/frontend/src/components/custom-rules.tsx @@ -45,6 +45,9 @@ declare const Classification: { */ type EnforcementActionType = "none" | "block" | "paused" | "allow"; +/** Minutes unit used across usage summaries. */ +type Minutes = number; + /** * Global constant for enforcement action values. * Use these values when returning an Enforcement. @@ -81,16 +84,34 @@ interface Classify { classificationReasoning: string; } -/** - * Non-numeric details about the current app/site being evaluated. - */ -interface UsageMetadata { +interface UsageBlocks { + /** Blocked attempts for this app/site today. */ + readonly count: number; +} + +interface UsageDuration { + /** Distracting minutes for this app/site today. */ + readonly today: Minutes; + /** Minutes since this app/site was last blocked (null if never blocked). */ + readonly sinceLastBlock: Minutes | null; + /** Total minutes of usage since this app/site was last blocked (null if never blocked). */ + readonly usedSinceLastBlock: Minutes | null; + /** Duration (minutes) of the last blocked session for this app/site (null if never blocked). */ + readonly lastBlocked: Minutes | null; + /** + * Returns total minutes this app/site was used in the last N minutes. + * @param minutes - Time window in minutes (e.g. 60 for last hour) + */ + last(minutes: number): number; +} + +interface UsageMeta { /** The display name of the application (e.g., 'Safari', 'Slack'). */ readonly appName: string; /** The window title if available. */ readonly title: string; /** The hostname if the activity is a website (e.g., 'docs.github.com'). */ - readonly hostname: string; + readonly host: string; /** The registered domain extracted from the hostname (e.g., 'github.com'). */ readonly domain: string; /** The URL path (e.g., '/pulls/assigned'). */ @@ -101,63 +122,44 @@ interface UsageMetadata { readonly classification: string; } -interface InsightsToday { - readonly productiveMinutes: number; - readonly distractingMinutes: number; - readonly idleMinutes: number; - readonly otherMinutes: number; - readonly focusScore: number; - readonly distractionCount: number; - readonly blockedCount: number; -} - -/** - * Numeric metrics scoped to the current usage target. - */ -interface UsageInsights { - /** Distracting minutes for this app/site today. */ - readonly distractingMinutes: number; - /** Blocked attempts for this app/site today. */ - readonly blockedCount: number; - /** 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; - /** Duration (minutes) of the last blocked session for this app/site (-1 if never blocked). */ - readonly lastBlockedDurationMinutes: number; - /** - * Returns total minutes this app/site was used in the last N minutes. - * @param minutes - Time window in minutes (e.g. 60 for last hour) - */ - minutesUsedInPeriod(minutes: number): number; -} - -interface InsightsContext { - readonly today: InsightsToday; - readonly topDistractions: Record; - readonly topBlocked: Record; - readonly projectBreakdown: Record; - readonly communicationBreakdown: Record; +interface UsageContext { + /** Current app/site descriptors. */ + readonly meta: UsageMeta; + /** Duration metrics for this app/site. */ + readonly duration: UsageDuration; + /** Blocking metrics for this app/site. */ + readonly blocks: UsageBlocks; } -interface UsageContext { - readonly metadata: UsageMetadata; - readonly insights: UsageInsights; +interface TimeSummary { + readonly score: number; + /** Productive minutes in the period. */ + readonly productive: Minutes; + /** Distracting minutes in the period. */ + readonly distracting: Minutes; } /** * Full context passed into classify/enforcement. * * Structure: - * - context.usage.metadata => descriptive fields for the current app/site - * - context.usage.insights => numeric fields for the current app/site - * - context.insights => broader daily/global dashboard insights + * - context.usage => current app/site details and metrics */ interface Context { readonly usage: UsageContext; - readonly insights: InsightsContext; } +/** + * Runtime-provided readonly global for today's summary. + * Type \`today.\` to explore autocomplete. + */ +declare const today: TimeSummary; +/** + * Runtime-provided readonly global for the current hour summary. + * Type \`hour.\` to explore autocomplete. + */ +declare const hour: TimeSummary; + // ============ Timezone Constants ============ /** @@ -317,21 +319,28 @@ declare const console: { `; const starterRulesTS = `/** + * Global variables (provided by runtime) + * + * today.* + * - score, productive (minutes), distracting (minutes) + * + * hour.* + * - score, productive (minutes), distracting (minutes) + * * Quick reference * - * context.usage.metadata.* - * - appName, title, hostname, domain, path, url, classification + * context.usage.meta.* + * - appName, title, host, domain, path, url, classification * - * context.usage.insights.* - * - distractingMinutes, blockedCount - * - minutesSinceLastBlock (-1 means never blocked) - * - minutesUsedSinceLastBlock (-1 means never blocked) - * - lastBlockedDurationMinutes (-1 means never blocked) - * - minutesUsedInPeriod(minutes) + * context.usage.* + * - blocks.count + * - duration.today + * - duration.sinceLastBlock (null means never blocked) + * - duration.usedSinceLastBlock (null means never blocked) + * - duration.lastBlocked (null means never blocked) + * - duration.last(minutes) * - * context.insights.today.* - * - productiveMinutes, distractingMinutes, idleMinutes, otherMinutes - * - focusScore, distractionCount, blockedCount + * Tip: type \`today.\` or \`hour.\` for autocomplete. */ /** @@ -339,7 +348,7 @@ const starterRulesTS = `/** * Return a Classify to override the default, or undefined to keep the default. * * Example 1: classify a domain as productive - * if (context.usage.metadata.domain === 'github.com') { + * if (context.usage.meta.domain === 'github.com') { * return { * classification: Classification.Productive, * classificationReasoning: 'GitHub is a development tool' @@ -347,7 +356,7 @@ const starterRulesTS = `/** * } * * Example 2: classify as distracting after 30 minutes in the last hour - * if (context.usage.insights.minutesUsedInPeriod(60) > 30) { + * if (context.usage.duration.last(60) > 30) { * return { * classification: Classification.Distracting, * classificationReasoning: 'Exceeded hourly usage budget' @@ -363,7 +372,7 @@ export function classify(context: Context): Classify | undefined { * Return an Enforcement to override the default, or undefined to keep the default. * * Example 1: block social media after 10 PM in London - * if (context.usage.metadata.domain === 'twitter.com' && now(Timezone.Europe_London).getHours() >= 22) { + * if (context.usage.meta.domain === 'twitter.com' && now(Timezone.Europe_London).getHours() >= 22) { * return { * enforcementAction: EnforcementAction.Block, * enforcementReason: 'Social media blocked after 10 PM' @@ -371,7 +380,7 @@ export function classify(context: Context): Classify | undefined { * } * * Example 2: strict mode if distraction is already high today - * if (context.insights.today.distractingMinutes >= 90 && context.usage.insights.blockedCount >= 3) { + * if (today.distracting >= 90 && context.usage.blocks.count >= 3) { * return { * enforcementAction: EnforcementAction.Block, * enforcementReason: 'High distraction day with repeated blocked attempts' @@ -379,6 +388,8 @@ export function classify(context: Context): Classify | undefined { * } */ export function enforcement(context: Context): Enforcement | undefined { + // Globals are available directly. + // Example: if (today.distracting >= 90 && hour.score < 60) { ... } return undefined; } `; 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/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/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_test.go b/internal/usage/classifier_custom_rules_test.go index ccc1bc2..faf93d0 100644 --- a/internal/usage/classifier_custom_rules_test.go +++ b/internal/usage/classifier_custom_rules_test.go @@ -30,7 +30,7 @@ export function classify(context: Context): Classify | undefined { console.log("and this too"); - if (context.usage.metadata.appName == "Slack") { + if (context.usage.meta.appName == "Slack") { return { classification: Classification.Neutral, classificationReasoning: "Slack is a neutral app", @@ -40,7 +40,7 @@ export function classify(context: Context): Classify | undefined { console.log("also this"); - if (context.usage.insights.minutesSinceLastBlock >= 20 && context.usage.insights.minutesUsedSinceLastBlock < 5 && context.usage.metadata.appName == "Discord") { + if (context.usage.duration.sinceLastBlock >= 20 && context.usage.duration.usedSinceLastBlock < 5 && context.usage.meta.appName == "Discord") { return { classification: Classification.Neutral, classificationReasoning: "Allow using 5 mins every 20 mins", @@ -62,7 +62,7 @@ export function enforcement(context: Context): Enforcement | undefined { var customRulesWithMinutesUsedInPeriod = ` export function classify(context: Context): Classify | undefined { - const minutesUsed = context.usage.insights.minutesUsedInPeriod(60); + const minutesUsed = context.usage.duration.last(60); if (minutesUsed > 30) { return { @@ -83,7 +83,7 @@ export function classify(context: Context): Classify | undefined { var customRulesWebsite = ` export function classify(context: Context): Classify | undefined { // Match by domain - if (context.usage.metadata.domain === "youtube.com") { + if (context.usage.meta.domain === "youtube.com") { return { classification: Classification.Distracting, classificationReasoning: "YouTube is distracting", @@ -92,7 +92,7 @@ export function classify(context: Context): Classify | undefined { } // Match by hostname (subdomain-aware) - if (context.usage.metadata.hostname === "docs.google.com") { + if (context.usage.meta.host === "docs.google.com") { return { classification: Classification.Productive, classificationReasoning: "Google Docs is productive", @@ -101,7 +101,7 @@ export function classify(context: Context): Classify | undefined { } // Match by path - if (context.usage.metadata.hostname === "github.com" && context.usage.metadata.path.startsWith("/pulls")) { + if (context.usage.meta.host === "github.com" && context.usage.meta.path.startsWith("/pulls")) { return { classification: Classification.Productive, classificationReasoning: "Reviewing pull requests", @@ -110,7 +110,7 @@ export function classify(context: Context): Classify | undefined { } // Match by full URL - if (context.usage.metadata.url === "https://twitter.com/home") { + if (context.usage.meta.url === "https://twitter.com/home") { return { classification: Classification.Distracting, classificationReasoning: "Twitter home feed", 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 a9447bf..864b3a8 100644 --- a/internal/usage/protection.go +++ b/internal/usage/protection.go @@ -321,8 +321,8 @@ 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.Usage.Metadata.Title = appUsage.WindowTitle - sandboxCtx.Usage.Metadata.Classification = string(appUsage.Classification) + sandboxCtx.Usage.Meta.Title = appUsage.WindowTitle + sandboxCtx.Usage.Meta.Classification = string(appUsage.Classification) customRules := settings.GetCustomRulesJS() if customRules == "" { diff --git a/internal/usage/protection_test.go b/internal/usage/protection_test.go index 98e1bd4..e9598d8 100644 --- a/internal/usage/protection_test.go +++ b/internal/usage/protection_test.go @@ -303,10 +303,10 @@ func setUpServiceWithSettings(t *testing.T, customRules string) (*usage.Service, func TestProtection_CalculateEnforcementDecision_CustomRules(t *testing.T) { - t.Run("ctx.usage.metadata.appName is accessible", func(t *testing.T) { + t.Run("ctx.usage.meta.appName is accessible", func(t *testing.T) { customRules := ` export function enforcement(ctx) { - if (ctx.usage.metadata.appName == "Slack") { + if (ctx.usage.meta.appName == "Slack") { return { enforcementAction: EnforcementAction.Block, enforcementReason: "Slack is blocked by custom rule", @@ -327,10 +327,10 @@ export function enforcement(ctx) { require.Equal(t, usage.EnforcementReason("Slack is blocked by custom rule"), decision.Reason) }) - t.Run("ctx.usage.metadata.classification is accessible", func(t *testing.T) { + t.Run("ctx.usage.meta.classification is accessible", func(t *testing.T) { customRules := ` export function enforcement(ctx) { - if (ctx.usage.metadata.classification == "distracting") { + if (ctx.usage.meta.classification == "distracting") { return { enforcementAction: EnforcementAction.Block, enforcementReason: "distracting classification detected", @@ -351,12 +351,12 @@ export function enforcement(ctx) { require.Equal(t, usage.EnforcementReason("distracting classification detected"), decision.Reason) }) - t.Run("ctx.usage.metadata.hostname and ctx.usage.metadata.domain are accessible", func(t *testing.T) { + t.Run("ctx.usage.meta.host and ctx.usage.meta.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" customRules := ` export function enforcement(ctx) { - if (ctx.usage.metadata.hostname == "docs.google.com" && ctx.usage.metadata.domain == "google.com") { + if (ctx.usage.meta.host == "docs.google.com" && ctx.usage.meta.domain == "google.com") { return { enforcementAction: EnforcementAction.Block, enforcementReason: "Google Docs blocked via hostname/domain", @@ -379,10 +379,10 @@ export function enforcement(ctx) { require.Equal(t, usage.EnforcementReason("Google Docs blocked via hostname/domain"), decision.Reason) }) - t.Run("ctx.usage.metadata.url and ctx.usage.metadata.path are accessible", func(t *testing.T) { + t.Run("ctx.usage.meta.url and ctx.usage.meta.path are accessible", func(t *testing.T) { customRules := ` export function enforcement(ctx) { - if (ctx.usage.metadata.url == "https://github.com/pulls" && ctx.usage.metadata.path == "/pulls") { + if (ctx.usage.meta.url == "https://github.com/pulls" && ctx.usage.meta.path == "/pulls") { return { enforcementAction: EnforcementAction.Allow, enforcementReason: "PR reviews are allowed", @@ -430,8 +430,8 @@ func TestProtection_CalculateEnforcementDecision_CustomRules_ExecutionLogs(t *te t.Run("stores enforcement response and console logs", func(t *testing.T) { customRules := ` export function enforcement(ctx) { - console.log("enforcement executed", ctx.usage.metadata.appName) - if (ctx.usage.metadata.appName == "Slack") { + console.log("enforcement executed", ctx.usage.meta.appName) + if (ctx.usage.meta.appName == "Slack") { return { enforcementAction: EnforcementAction.Block, enforcementReason: "Slack is blocked by custom rule", @@ -478,7 +478,7 @@ export function enforcement(ctx) { t.Run("stores no response when decision is undefined", func(t *testing.T) { customRules := ` export function enforcement(ctx) { - console.log("undefined decision for", ctx.usage.metadata.appName) + console.log("undefined decision for", ctx.usage.meta.appName) return undefined; } ` @@ -510,7 +510,7 @@ export function enforcement(ctx) { t.Run("stores errors for failed enforcement execution", func(t *testing.T) { customRules := ` export function enforcement(ctx) { - console.log("about to fail", ctx.usage.metadata.appName) + console.log("about to fail", ctx.usage.meta.appName) throw new Error("enforcement fail"); } ` diff --git a/internal/usage/sandbox.go b/internal/usage/sandbox.go index 72b2361..7ea7684 100644 --- a/internal/usage/sandbox.go +++ b/internal/usage/sandbox.go @@ -356,29 +356,44 @@ func (s *sandbox) executeFunction(v8ctx *v8.Context, preparedScript string, func } // Call the function - // Add minutesUsedInPeriod as a method on usage.insights. + // Expose today/hour globals and add usage.duration.last helper. callScript := fmt.Sprintf(` (function() { - const ctx = %s; + const rawCtx = %s; + Object.defineProperty(globalThis, 'today', { + value: Object.freeze(rawCtx.today || {}), + writable: false, + configurable: true + }); + Object.defineProperty(globalThis, 'hour', { + value: Object.freeze(rawCtx.hour || {}), + writable: false, + configurable: true + }); + + const ctx = { + usage: rawCtx.usage || {} + }; + if (!ctx.usage) { ctx.usage = {}; } - if (!ctx.usage.metadata) { - ctx.usage.metadata = {}; + if (!ctx.usage.meta) { + ctx.usage.meta = {}; } - if (!ctx.usage.insights) { - ctx.usage.insights = {}; + if (!ctx.usage.duration) { + ctx.usage.duration = {}; } - // Add minutesUsedInPeriod method to usage.insights. + // Add last method to usage.duration. if (typeof __minutesUsedInPeriod === 'function') { - ctx.usage.insights.minutesUsedInPeriod = function(minutes) { - return __minutesUsedInPeriod(ctx.usage.metadata.appName, ctx.usage.metadata.hostname, minutes); + ctx.usage.duration.last = function(minutes) { + return __minutesUsedInPeriod(ctx.usage.meta.appName, ctx.usage.meta.host, minutes); }; } else { - ctx.usage.insights.minutesUsedInPeriod = function(minutes) { return 0; }; + ctx.usage.duration.last = function(minutes) { return 0; }; } const result = %s(ctx); diff --git a/internal/usage/sandbox_context.go b/internal/usage/sandbox_context.go index 60b255f..4504f0b 100644 --- a/internal/usage/sandbox_context.go +++ b/internal/usage/sandbox_context.go @@ -9,48 +9,44 @@ import ( type sandboxUsageMetadata struct { AppName string `json:"appName"` Title string `json:"title"` - Hostname string `json:"hostname"` + Host string `json:"host"` Path string `json:"path"` Domain string `json:"domain"` URL string `json:"url"` Classification string `json:"classification"` } -type sandboxUsageInsights struct { - DistractingMinutes int `json:"distractingMinutes"` - BlockedCount int `json:"blockedCount"` - MinutesSinceLastBlock int `json:"minutesSinceLastBlock"` - MinutesUsedSinceLastBlock int `json:"minutesUsedSinceLastBlock"` - LastBlockedDurationMinutes int `json:"lastBlockedDurationMinutes"` +type sandboxUsageBlocked struct { + Count int `json:"count"` + Since *int `json:"since"` + Used *int `json:"used"` + Last *int `json:"last"` } -type sandboxUsageContext struct { - Metadata sandboxUsageMetadata `json:"metadata"` - Insights sandboxUsageInsights `json:"insights"` +type sandboxUsageDuration struct { + Today int `json:"today"` + SinceLastBlock *int `json:"sinceLastBlock"` + UsedSinceLastBlock *int `json:"usedSinceLastBlock"` + LastBlocked *int `json:"lastBlocked"` } -type sandboxInsightsToday struct { - ProductiveMinutes int `json:"productiveMinutes"` - DistractingMinutes int `json:"distractingMinutes"` - IdleMinutes int `json:"idleMinutes"` - OtherMinutes int `json:"otherMinutes"` - FocusScore int `json:"focusScore"` - DistractionCount int `json:"distractionCount"` - BlockedCount int `json:"blockedCount"` +type sandboxUsageContext struct { + Meta sandboxUsageMetadata `json:"meta"` + Duration sandboxUsageDuration `json:"duration"` + Blocks sandboxUsageBlocked `json:"blocks"` } -type sandboxInsightsContext struct { - Today sandboxInsightsToday `json:"today"` - TopDistractions map[string]int `json:"topDistractions"` - TopBlocked map[string]int `json:"topBlocked"` - ProjectBreakdown map[string]int `json:"projectBreakdown"` - CommunicationBreakdown map[string]CommunicationBreakdown `json:"communicationBreakdown"` +type sandboxPeriodSummary struct { + Score int `json:"score"` + Productive int `json:"productive"` + Distracting int `json:"distracting"` } // sandboxContext provides context for the current rule execution including usage data and helper functions. type sandboxContext struct { - Usage sandboxUsageContext `json:"usage"` - Insights sandboxInsightsContext `json:"insights"` + Usage sandboxUsageContext `json:"usage"` + Today sandboxPeriodSummary `json:"today"` + Hour sandboxPeriodSummary `json:"hour"` // Helper functions Now func(loc *time.Location) time.Time `json:"-"` @@ -61,25 +57,25 @@ type sandboxContextOption func(*sandboxContext) func WithAppNameContext(appName string) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.Usage.Metadata.AppName = appName + ctx.Usage.Meta.AppName = appName } } func WithWindowTitleContext(title string) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.Usage.Metadata.Title = title + ctx.Usage.Meta.Title = title } } func WithBrowserURLContext(url string) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.Usage.Metadata.URL = url + ctx.Usage.Meta.URL = url u, err := parseURLNormalized(url) if err == nil { - ctx.Usage.Metadata.Hostname = u.Hostname() - ctx.Usage.Metadata.Path = u.Path - ctx.Usage.Metadata.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()) } } } @@ -100,38 +96,24 @@ func WithMinutesUsedInPeriodContext(minutesUsedInPeriod func(appName, hostname s func WithMinutesSinceLastBlockContext(minutesSinceLastBlock int) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.Usage.Insights.MinutesSinceLastBlock = minutesSinceLastBlock + ctx.Usage.Duration.SinceLastBlock = &minutesSinceLastBlock } } func WithMinutesUsedSinceLastBlockContext(minutesUsedSinceLastBlock int) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.Usage.Insights.MinutesUsedSinceLastBlock = minutesUsedSinceLastBlock + ctx.Usage.Duration.UsedSinceLastBlock = &minutesUsedSinceLastBlock } } func WithClassificationContext(classification Classification) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.Usage.Metadata.Classification = string(classification) + ctx.Usage.Meta.Classification = string(classification) } } func NewSandboxContext(opts ...sandboxContextOption) sandboxContext { - ctx := sandboxContext{ - Usage: sandboxUsageContext{ - Insights: sandboxUsageInsights{ - MinutesSinceLastBlock: -1, - MinutesUsedSinceLastBlock: -1, - LastBlockedDurationMinutes: -1, - }, - }, - Insights: sandboxInsightsContext{ - TopDistractions: make(map[string]int), - TopBlocked: make(map[string]int), - ProjectBreakdown: make(map[string]int), - CommunicationBreakdown: make(map[string]CommunicationBreakdown), - }, - } + ctx := sandboxContext{} for _, opt := range opts { opt(&ctx) diff --git a/internal/usage/sandbox_context_enrich.go b/internal/usage/sandbox_context_enrich.go index e010cd4..74cf906 100644 --- a/internal/usage/sandbox_context_enrich.go +++ b/internal/usage/sandbox_context_enrich.go @@ -59,8 +59,8 @@ func (s *Service) minutesUsedInPeriod(appName, hostname string, durationMinutes } func (s *Service) populateCurrentUsageContext(ctx *sandboxContext) error { - appName := ctx.Usage.Metadata.AppName - hostname := ctx.Usage.Metadata.Hostname + appName := ctx.Usage.Meta.AppName + hostname := ctx.Usage.Meta.Host if appName == "" { return nil } @@ -78,19 +78,20 @@ func (s *Service) populateCurrentUsageContext(ctx *sandboxContext) error { return err } - if ctx.Usage.Insights.MinutesSinceLastBlock < 0 { - ctx.Usage.Insights.MinutesSinceLastBlock = int(time.Since(time.Unix(lastBlocked.StartedAt, 0)).Minutes()) + if ctx.Usage.Duration.SinceLastBlock == nil { + minutesSinceLastBlock := int(time.Since(time.Unix(lastBlocked.StartedAt, 0)).Minutes()) + ctx.Usage.Duration.SinceLastBlock = &minutesSinceLastBlock } - if ctx.Usage.Insights.LastBlockedDurationMinutes < 0 { + if ctx.Usage.Duration.LastBlocked == nil { + lastBlockedDurationMinutes := 0 if lastBlocked.DurationSeconds != nil { - ctx.Usage.Insights.LastBlockedDurationMinutes = *lastBlocked.DurationSeconds / 60 - } else { - ctx.Usage.Insights.LastBlockedDurationMinutes = 0 + lastBlockedDurationMinutes = *lastBlocked.DurationSeconds / 60 } + ctx.Usage.Duration.LastBlocked = &lastBlockedDurationMinutes } - if ctx.Usage.Insights.MinutesUsedSinceLastBlock < 0 { + if ctx.Usage.Duration.UsedSinceLastBlock == nil { var totalSeconds int64 sumErr := s.scopedUsageIdentityQuery(appName, hostname). Where("application_usage.started_at > ?", lastBlocked.StartedAt). @@ -100,7 +101,8 @@ func (s *Service) populateCurrentUsageContext(ctx *sandboxContext) error { return sumErr } - ctx.Usage.Insights.MinutesUsedSinceLastBlock = int(totalSeconds / 60) + minutesUsedSinceLastBlock := int(totalSeconds / 60) + ctx.Usage.Duration.UsedSinceLastBlock = &minutesUsedSinceLastBlock } return nil @@ -113,39 +115,19 @@ func (s *Service) populateInsightsContext(ctx *sandboxContext) error { return err } - ctx.Insights.Today.ProductiveMinutes = insights.ProductivityScore.ProductiveSeconds / 60 - ctx.Insights.Today.DistractingMinutes = insights.ProductivityScore.DistractiveSeconds / 60 - ctx.Insights.Today.IdleMinutes = insights.ProductivityScore.IdleSeconds / 60 - ctx.Insights.Today.OtherMinutes = insights.ProductivityScore.OtherSeconds / 60 - ctx.Insights.Today.FocusScore = insights.ProductivityScore.ProductivityScore + ctx.Today.Productive = insights.ProductivityScore.ProductiveSeconds / 60 + ctx.Today.Distracting = insights.ProductivityScore.DistractingSeconds / 60 + ctx.Today.Score = insights.ProductivityScore.ProductivityScore - ctx.Insights.TopDistractions = insights.TopDistractions - ctx.Insights.TopBlocked = insights.TopBlocked - ctx.Insights.ProjectBreakdown = insights.ProjectBreakdown - ctx.Insights.CommunicationBreakdown = insights.CommunicationBreakdown + hourly := insights.ProductivityPerHourBreakdown[now.Hour()] + ctx.Hour.Productive = hourly.ProductiveSeconds / 60 + ctx.Hour.Distracting = hourly.DistractingSeconds / 60 + ctx.Hour.Score = hourly.ProductivityScore todayKey := now.Format("2006-01-02") - var distractionCount int64 - if err := s.db.Model(&ApplicationUsage{}). - Where("date(started_at, 'unixepoch') = ?", todayKey). - Where("classification = ?", ClassificationDistracting). - Count(&distractionCount).Error; err != nil { - return err - } - ctx.Insights.Today.DistractionCount = int(distractionCount) - - var blockedCount int64 - if err := s.db.Model(&ApplicationUsage{}). - Where("date(started_at, 'unixepoch') = ?", todayKey). - Where("enforcement_action = ?", EnforcementActionBlock). - Count(&blockedCount).Error; err != nil { - return err - } - ctx.Insights.Today.BlockedCount = int(blockedCount) - - appName := ctx.Usage.Metadata.AppName - hostname := ctx.Usage.Metadata.Hostname + appName := ctx.Usage.Meta.AppName + hostname := ctx.Usage.Meta.Host if appName == "" { return nil } @@ -158,7 +140,7 @@ func (s *Service) populateInsightsContext(ctx *sandboxContext) error { Scan(¤tDistractingSeconds).Error; err != nil { return err } - ctx.Usage.Insights.DistractingMinutes = int(currentDistractingSeconds / 60) + ctx.Usage.Duration.Today = int(currentDistractingSeconds / 60) var currentBlockedCount int64 if err := s.scopedUsageIdentityQuery(appName, hostname). @@ -167,7 +149,7 @@ func (s *Service) populateInsightsContext(ctx *sandboxContext) error { Count(¤tBlockedCount).Error; err != nil { return err } - ctx.Usage.Insights.BlockedCount = int(currentBlockedCount) + ctx.Usage.Blocks.Count = int(currentBlockedCount) return nil } diff --git a/internal/usage/service_usage_test.go b/internal/usage/service_usage_test.go index acadd78..faaebed 100644 --- a/internal/usage/service_usage_test.go +++ b/internal/usage/service_usage_test.go @@ -274,7 +274,7 @@ func TestService_ProtectionPauseAndWhitelisting(t *testing.T) { func TestService_Classification(t *testing.T) { customRulesOverrideAmazon := ` export function classify(context: Context): Classify | undefined { - if (context.usage.metadata.domain === "amazon.com") { + if (context.usage.meta.domain === "amazon.com") { return { classification: Classification.Productive, classificationReasoning: "Amazon is productive for procurement work", @@ -323,7 +323,7 @@ export function classify(context: Context): Classify | undefined { withAccountTier(apiv1.DeviceHandshakeResponse_ACCOUNT_TIER_PLUS), withCustomRulesJS(` export function classify(context: Context): Classify | undefined { - if (context.usage.metadata.domain === "not-amazon.com") { + if (context.usage.meta.domain === "not-amazon.com") { return { classification: Classification.Productive, classificationReasoning: "Unreachable rule", 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 74dca99..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,20 +114,12 @@ func fetchMainContent(ctx context.Context, rawURL string) (string, error) { } func createSandboxContext(appName string, url *string) sandboxContext { - sandboxCtx := NewSandboxContext(WithAppNameContext(appName)) - + opts := []sandboxContextOption{WithAppNameContext(appName)} if url != nil { - sandboxCtx.Usage.Metadata.URL = *url - - u, err := parseURLNormalized(*url) - if err == nil { - sandboxCtx.Usage.Metadata.Hostname = u.Hostname() - sandboxCtx.Usage.Metadata.Path = u.Path - sandboxCtx.Usage.Metadata.Domain, _ = publicsuffix.EffectiveTLDPlusOne(u.Hostname()) - } + opts = append(opts, WithBrowserURLContext(*url)) } - return sandboxCtx + return NewSandboxContext(opts...) } func withPtr[T any](v T) *T { From b1ab71e8d81c22597d07e6fb697e5aeb60d87b8c Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Thu, 26 Mar 2026 20:01:21 +0000 Subject: [PATCH 5/5] feat(custom-rules): simplify API to use runtime global instead of function arguments - Deprecate parameterized `classify(usage)` and `enforcement(usage)` - Make user-facing rule functions argument-free - Inject `usage` explicitly into `runtime.usage` inside V8 sandbox - Expose `runtime` in TypeScript declarations with `usage` typing - Add JSDoc hover documentation for metrics in Monaco editor - Switch ambiguous fields to explicit `focusScore`, `productiveMinutes`, and `distractingMinutes` - Replace plain `
` docs snippets with read-only Monaco `` for rich highlighting and hover support
- Update internal backend tests and starter template to match new API
---
 frontend/src/components/custom-rules.tsx      | 627 ++++--------------
 .../src/components/rules-reference-sheet.tsx  | 169 +++++
 frontend/src/components/ui/code-block.tsx     |  44 ++
 frontend/src/lib/rules/runtime-types.ts       | 221 ++++++
 frontend/src/lib/rules/snippets.ts            |  55 ++
 frontend/src/lib/rules/starter-template.ts    |  38 ++
 .../usage/classifier_custom_rules_test.go     | 107 ++-
 internal/usage/insights_basic_test.go         |   1 +
 internal/usage/protection_test.go             |  95 +--
 internal/usage/sandbox.go                     | 202 +++---
 internal/usage/sandbox_context.go             |  30 +-
 internal/usage/sandbox_context_enrich.go      |  28 +-
 internal/usage/service_usage_test.go          |  28 +-
 13 files changed, 905 insertions(+), 740 deletions(-)
 create mode 100644 frontend/src/components/rules-reference-sheet.tsx
 create mode 100644 frontend/src/components/ui/code-block.tsx
 create mode 100644 frontend/src/lib/rules/runtime-types.ts
 create mode 100644 frontend/src/lib/rules/snippets.ts
 create mode 100644 frontend/src/lib/rules/starter-template.ts

diff --git a/frontend/src/components/custom-rules.tsx b/frontend/src/components/custom-rules.tsx
index 9e44e08..8e4d522 100644
--- a/frontend/src/components/custom-rules.tsx
+++ b/frontend/src/components/custom-rules.tsx
@@ -7,455 +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 Classify.
- * @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";
-
-/** Minutes unit used across usage summaries. */
-type Minutes = number;
-
-/**
- * Global constant for enforcement action values.
- * Use these values when returning an Enforcement.
- * @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";
-};
-
-/**
- * Result returned from the enforcement function.
- */
-interface Enforcement {
-  /** The termination mode to apply. Use EnforcementAction constants. */
-  enforcementAction: EnforcementActionType;
-  /** Human-readable explanation for why this decision was made. */
-  enforcementReason: string;
-}
-
-/**
- * Result returned from the classify function.
- */
-interface Classify {
-  /** The classification to apply. Use Classification constants. */
-  classification: ClassificationType;
-  /** Human-readable explanation for why this classification was chosen. */
-  classificationReasoning: string;
-}
-
-interface UsageBlocks {
-  /** Blocked attempts for this app/site today. */
-  readonly count: number;
-}
-
-interface UsageDuration {
-  /** Distracting minutes for this app/site today. */
-  readonly today: Minutes;
-  /** Minutes since this app/site was last blocked (null if never blocked). */
-  readonly sinceLastBlock: Minutes | null;
-  /** Total minutes of usage since this app/site was last blocked (null if never blocked). */
-  readonly usedSinceLastBlock: Minutes | null;
-  /** Duration (minutes) of the last blocked session for this app/site (null if never blocked). */
-  readonly lastBlocked: Minutes | null;
-  /**
-   * Returns total minutes this app/site was used in the last N minutes.
-   * @param minutes - Time window in minutes (e.g. 60 for last hour)
-   */
-  last(minutes: number): number;
-}
-
-interface UsageMeta {
-  /** The display name of the application (e.g., 'Safari', 'Slack'). */
-  readonly appName: string;
-  /** The window title if available. */
-  readonly title: string;
-  /** The hostname if the activity is a website (e.g., 'docs.github.com'). */
-  readonly host: string;
-  /** The registered domain extracted from the hostname (e.g., 'github.com'). */
-  readonly domain: string;
-  /** The URL path (e.g., '/pulls/assigned'). */
-  readonly path: 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;
-}
-
-interface UsageContext {
-  /** Current app/site descriptors. */
-  readonly meta: UsageMeta;
-  /** Duration metrics for this app/site. */
-  readonly duration: UsageDuration;
-  /** Blocking metrics for this app/site. */
-  readonly blocks: UsageBlocks;
-}
-
-interface TimeSummary {
-  readonly score: number;
-  /** Productive minutes in the period. */
-  readonly productive: Minutes;
-  /** Distracting minutes in the period. */
-  readonly distracting: Minutes;
-}
-
-/**
- * Full context passed into classify/enforcement.
- *
- * Structure:
- * - context.usage => current app/site details and metrics
- */
-interface Context {
-  readonly usage: UsageContext;
-}
-
-/**
- * Runtime-provided readonly global for today's summary.
- * Type \`today.\` to explore autocomplete.
- */
-declare const today: TimeSummary;
-/**
- * Runtime-provided readonly global for the current hour summary.
- * Type \`hour.\` to explore autocomplete.
- */
-declare const hour: TimeSummary;
-
-// ============ 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 = `/**
- * Global variables (provided by runtime)
- *
- * today.*
- * - score, productive (minutes), distracting (minutes)
- *
- * hour.*
- * - score, productive (minutes), distracting (minutes)
- *
- * Quick reference
- *
- * context.usage.meta.*
- * - appName, title, host, domain, path, url, classification
- *
- * context.usage.*
- * - blocks.count
- * - duration.today
- * - duration.sinceLastBlock (null means never blocked)
- * - duration.usedSinceLastBlock (null means never blocked)
- * - duration.lastBlocked (null means never blocked)
- * - duration.last(minutes)
- *
- * Tip: type \`today.\` or \`hour.\` for autocomplete.
- */
-
-/**
- * Custom classification logic.
- * Return a Classify to override the default, or undefined to keep the default.
- *
- * Example 1: classify a domain as productive
- * if (context.usage.meta.domain === 'github.com') {
- *   return {
- *     classification: Classification.Productive,
- *     classificationReasoning: 'GitHub is a development tool'
- *   };
- * }
- *
- * Example 2: classify as distracting after 30 minutes in the last hour
- * if (context.usage.duration.last(60) > 30) {
- *   return {
- *     classification: Classification.Distracting,
- *     classificationReasoning: 'Exceeded hourly usage budget'
- *   };
- * }
- */
-export function classify(context: Context): Classify | undefined {
-  return undefined;
-}
-
-/**
- * Custom enforcement logic.
- * Return an Enforcement to override the default, or undefined to keep the default.
- *
- * Example 1: block social media after 10 PM in London
- * if (context.usage.meta.domain === 'twitter.com' && now(Timezone.Europe_London).getHours() >= 22) {
- *   return {
- *     enforcementAction: EnforcementAction.Block,
- *     enforcementReason: 'Social media blocked after 10 PM'
- *   };
- * }
- *
- * Example 2: strict mode if distraction is already high today
- * if (today.distracting >= 90 && context.usage.blocks.count >= 3) {
- *   return {
- *     enforcementAction: EnforcementAction.Block,
- *     enforcementReason: 'High distraction day with repeated blocked attempts'
- *   };
- * }
- */
-export function enforcement(context: Context): Enforcement | undefined {
-  // Globals are available directly.
-  // Example: if (today.distracting >= 90 && hour.score < 60) { ... }
-  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(() => {
@@ -469,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) {
@@ -486,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;
+    monaco.languages.typescript.typescriptDefaults.addExtraLib(RUNTIME_TYPES_SOURCE, RUNTIME_TYPES_FILE_PATH);
 
-    // Add extraLib for intellisense
-    monaco.languages.typescript.typescriptDefaults.addExtraLib(
-      typeDefinitions,
-      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
@@ -571,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/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/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/internal/usage/classifier_custom_rules_test.go b/internal/usage/classifier_custom_rules_test.go index faf93d0..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 Classify to override the default, or undefined to keep the default. -*/ -export function classify(context: Context): Classify | 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.usage.meta.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.usage.duration.sinceLastBlock >= 20 && context.usage.duration.usedSinceLastBlock < 5 && context.usage.meta.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 enforcement logic. - * Return an Enforcement to override the default, or undefined to keep the default. -*/ -export function enforcement(context: Context): Enforcement | undefined { +export function enforcement(): Enforce | undefined { } ` var customRulesWithMinutesUsedInPeriod = ` -export function classify(context: Context): Classify | undefined { - const minutesUsed = context.usage.duration.last(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: Context): Classify | undefined { - // Match by domain - if (context.usage.meta.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.usage.meta.host === "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.usage.meta.host === "github.com" && context.usage.meta.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.usage.meta.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/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/protection_test.go b/internal/usage/protection_test.go index e9598d8..15ba604 100644 --- a/internal/usage/protection_test.go +++ b/internal/usage/protection_test.go @@ -303,14 +303,15 @@ func setUpServiceWithSettings(t *testing.T, customRules string) (*usage.Service, func TestProtection_CalculateEnforcementDecision_CustomRules(t *testing.T) { - t.Run("ctx.usage.meta.appName is accessible", func(t *testing.T) { + t.Run("ctx.app is accessible", func(t *testing.T) { customRules := ` -export function enforcement(ctx) { - if (ctx.usage.meta.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; } @@ -327,14 +328,15 @@ export function enforcement(ctx) { require.Equal(t, usage.EnforcementReason("Slack is blocked by custom rule"), decision.Reason) }) - t.Run("ctx.usage.meta.classification is accessible", func(t *testing.T) { + t.Run("ctx.classification is accessible", func(t *testing.T) { customRules := ` -export function enforcement(ctx) { - if (ctx.usage.meta.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; } @@ -351,16 +353,15 @@ export function enforcement(ctx) { require.Equal(t, usage.EnforcementReason("distracting classification detected"), decision.Reason) }) - t.Run("ctx.usage.meta.host and ctx.usage.meta.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 enforcement(ctx) { - if (ctx.usage.meta.host == "docs.google.com" && ctx.usage.meta.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; } @@ -379,14 +380,15 @@ export function enforcement(ctx) { require.Equal(t, usage.EnforcementReason("Google Docs blocked via hostname/domain"), decision.Reason) }) - t.Run("ctx.usage.meta.url and ctx.usage.meta.path are accessible", func(t *testing.T) { + t.Run("ctx.url and ctx.path are accessible", func(t *testing.T) { customRules := ` -export function enforcement(ctx) { - if (ctx.usage.meta.url == "https://github.com/pulls" && ctx.usage.meta.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; } @@ -407,7 +409,7 @@ export function enforcement(ctx) { t.Run("returns undefined falls through to default", func(t *testing.T) { customRules := ` -export function enforcement(ctx) { +export function enforcement() { return undefined; } ` @@ -429,13 +431,14 @@ export function enforcement(ctx) { func TestProtection_CalculateEnforcementDecision_CustomRules_ExecutionLogs(t *testing.T) { t.Run("stores enforcement response and console logs", func(t *testing.T) { customRules := ` -export function enforcement(ctx) { - console.log("enforcement executed", ctx.usage.meta.appName) - if (ctx.usage.meta.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; } @@ -477,8 +480,12 @@ export function enforcement(ctx) { t.Run("stores no response when decision is undefined", func(t *testing.T) { customRules := ` -export function enforcement(ctx) { - console.log("undefined decision for", ctx.usage.meta.appName) +import { runtime } from "@focusd/runtime"; + +export function enforcement() { + const { app } = runtime.usage; + + console.log("undefined decision for", app) return undefined; } ` @@ -509,8 +516,12 @@ export function enforcement(ctx) { t.Run("stores errors for failed enforcement execution", func(t *testing.T) { customRules := ` -export function enforcement(ctx) { - console.log("about to fail", ctx.usage.meta.appName) +import { runtime } from "@focusd/runtime"; + +export function enforcement() { + const { app } = runtime.usage; + + console.log("about to fail", app) throw new Error("enforcement fail"); } ` diff --git a/internal/usage/sandbox.go b/internal/usage/sandbox.go index 7ea7684..4c00ec6 100644 --- a/internal/usage/sandbox.go +++ b/internal/usage/sandbox.go @@ -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 }; @@ -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 @@ -355,48 +386,49 @@ func (s *sandbox) executeFunction(v8ctx *v8.Context, preparedScript string, func return "", fmt.Errorf("failed to marshal context: %w", err) } - // Call the function - // Expose today/hour globals and add usage.duration.last helper. + // Call the function with flat usage context. callScript := fmt.Sprintf(` (function() { - const rawCtx = %s; - Object.defineProperty(globalThis, 'today', { - value: Object.freeze(rawCtx.today || {}), - writable: false, - configurable: true - }); - Object.defineProperty(globalThis, 'hour', { - value: Object.freeze(rawCtx.hour || {}), - writable: false, - configurable: true - }); - - const ctx = { - usage: rawCtx.usage || {} + 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 + } }; - if (!ctx.usage) { - ctx.usage = {}; - } - - if (!ctx.usage.meta) { - ctx.usage.meta = {}; - } - - if (!ctx.usage.duration) { - ctx.usage.duration = {}; - } - - // Add last method to usage.duration. - if (typeof __minutesUsedInPeriod === 'function') { - ctx.usage.duration.last = function(minutes) { - return __minutesUsedInPeriod(ctx.usage.meta.appName, ctx.usage.meta.host, minutes); - }; - } else { - ctx.usage.duration.last = function(minutes) { return 0; }; - } + 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 + }; - const result = %s(ctx); + var result = %s(); if (result === undefined || result === null) { return undefined; } diff --git a/internal/usage/sandbox_context.go b/internal/usage/sandbox_context.go index 4504f0b..b49b747 100644 --- a/internal/usage/sandbox_context.go +++ b/internal/usage/sandbox_context.go @@ -30,23 +30,31 @@ type sandboxUsageDuration struct { LastBlocked *int `json:"lastBlocked"` } -type sandboxUsageContext struct { - Meta sandboxUsageMetadata `json:"meta"` +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 sandboxPeriodSummary struct { - Score int `json:"score"` - Productive int `json:"productive"` - Distracting int `json:"distracting"` +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"` } // sandboxContext provides context for the current rule execution including usage data and helper functions. type sandboxContext struct { - Usage sandboxUsageContext `json:"usage"` - Today sandboxPeriodSummary `json:"today"` - Hour sandboxPeriodSummary `json:"hour"` + Usage sandboxUsageContext `json:"usage"` // Helper functions Now func(loc *time.Location) time.Time `json:"-"` @@ -96,13 +104,13 @@ func WithMinutesUsedInPeriodContext(minutesUsedInPeriod func(appName, hostname s func WithMinutesSinceLastBlockContext(minutesSinceLastBlock int) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.Usage.Duration.SinceLastBlock = &minutesSinceLastBlock + ctx.Usage.Insights.Current.Duration.SinceLastBlock = &minutesSinceLastBlock } } func WithMinutesUsedSinceLastBlockContext(minutesUsedSinceLastBlock int) sandboxContextOption { return func(ctx *sandboxContext) { - ctx.Usage.Duration.UsedSinceLastBlock = &minutesUsedSinceLastBlock + ctx.Usage.Insights.Current.Duration.UsedSinceLastBlock = &minutesUsedSinceLastBlock } } diff --git a/internal/usage/sandbox_context_enrich.go b/internal/usage/sandbox_context_enrich.go index 74cf906..bd6b638 100644 --- a/internal/usage/sandbox_context_enrich.go +++ b/internal/usage/sandbox_context_enrich.go @@ -78,20 +78,20 @@ func (s *Service) populateCurrentUsageContext(ctx *sandboxContext) error { return err } - if ctx.Usage.Duration.SinceLastBlock == nil { + if ctx.Usage.Insights.Current.Duration.SinceLastBlock == nil { minutesSinceLastBlock := int(time.Since(time.Unix(lastBlocked.StartedAt, 0)).Minutes()) - ctx.Usage.Duration.SinceLastBlock = &minutesSinceLastBlock + ctx.Usage.Insights.Current.Duration.SinceLastBlock = &minutesSinceLastBlock } - if ctx.Usage.Duration.LastBlocked == nil { + if ctx.Usage.Insights.Current.Duration.LastBlocked == nil { lastBlockedDurationMinutes := 0 if lastBlocked.DurationSeconds != nil { lastBlockedDurationMinutes = *lastBlocked.DurationSeconds / 60 } - ctx.Usage.Duration.LastBlocked = &lastBlockedDurationMinutes + ctx.Usage.Insights.Current.Duration.LastBlocked = &lastBlockedDurationMinutes } - if ctx.Usage.Duration.UsedSinceLastBlock == nil { + if ctx.Usage.Insights.Current.Duration.UsedSinceLastBlock == nil { var totalSeconds int64 sumErr := s.scopedUsageIdentityQuery(appName, hostname). Where("application_usage.started_at > ?", lastBlocked.StartedAt). @@ -102,7 +102,7 @@ func (s *Service) populateCurrentUsageContext(ctx *sandboxContext) error { } minutesUsedSinceLastBlock := int(totalSeconds / 60) - ctx.Usage.Duration.UsedSinceLastBlock = &minutesUsedSinceLastBlock + ctx.Usage.Insights.Current.Duration.UsedSinceLastBlock = &minutesUsedSinceLastBlock } return nil @@ -115,14 +115,14 @@ func (s *Service) populateInsightsContext(ctx *sandboxContext) error { return err } - ctx.Today.Productive = insights.ProductivityScore.ProductiveSeconds / 60 - ctx.Today.Distracting = insights.ProductivityScore.DistractingSeconds / 60 - ctx.Today.Score = insights.ProductivityScore.ProductivityScore + 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.Hour.Productive = hourly.ProductiveSeconds / 60 - ctx.Hour.Distracting = hourly.DistractingSeconds / 60 - ctx.Hour.Score = hourly.ProductivityScore + 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") @@ -140,7 +140,7 @@ func (s *Service) populateInsightsContext(ctx *sandboxContext) error { Scan(¤tDistractingSeconds).Error; err != nil { return err } - ctx.Usage.Duration.Today = int(currentDistractingSeconds / 60) + ctx.Usage.Insights.Current.Duration.Today = int(currentDistractingSeconds / 60) var currentBlockedCount int64 if err := s.scopedUsageIdentityQuery(appName, hostname). @@ -149,7 +149,7 @@ func (s *Service) populateInsightsContext(ctx *sandboxContext) error { Count(¤tBlockedCount).Error; err != nil { return err } - ctx.Usage.Blocks.Count = int(currentBlockedCount) + ctx.Usage.Insights.Current.Blocks.Count = int(currentBlockedCount) return nil } diff --git a/internal/usage/service_usage_test.go b/internal/usage/service_usage_test.go index faaebed..0ad8d39 100644 --- a/internal/usage/service_usage_test.go +++ b/internal/usage/service_usage_test.go @@ -273,13 +273,13 @@ func TestService_ProtectionPauseAndWhitelisting(t *testing.T) { func TestService_Classification(t *testing.T) { customRulesOverrideAmazon := ` -export function classify(context: Context): Classify | undefined { - if (context.usage.meta.domain === "amazon.com") { - return { - classification: Classification.Productive, - classificationReasoning: "Amazon is productive for procurement work", - tags: ["custom", "procurement"], - } +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; @@ -322,13 +322,13 @@ export function classify(context: Context): Classify | undefined { h := newUsageHarness(t, withAccountTier(apiv1.DeviceHandshakeResponse_ACCOUNT_TIER_PLUS), withCustomRulesJS(` -export function classify(context: Context): Classify | undefined { - if (context.usage.meta.domain === "not-amazon.com") { - return { - classification: Classification.Productive, - classificationReasoning: "Unreachable rule", - tags: ["custom"], - } +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;