diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index fb70eca..51a9f83 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -146,7 +146,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -511,7 +510,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
},
@@ -555,7 +553,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -3452,7 +3449,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.144.0.tgz",
"integrity": "sha512-GmRyIGmHtGj3VLTHXepIwXAxTcHyL5W7Vw7O1CnVEtFxQQWKMVOnWgI7tPY6FhlNwMKVb3n0mPFWz9KMYyd2GA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@tanstack/history": "1.141.0",
"@tanstack/react-store": "^0.8.0",
@@ -3658,7 +3654,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -3852,7 +3847,6 @@
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3863,7 +3857,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3874,7 +3867,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3884,7 +3876,8 @@
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
- "optional": true
+ "optional": true,
+ "peer": true
},
"node_modules/@typescript/vfs": {
"version": "1.6.2",
@@ -4237,7 +4230,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4665,6 +4657,7 @@
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
+ "peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -5075,7 +5068,6 @@
"integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@asamuzakjp/dom-selector": "^6.7.2",
"cssstyle": "^5.3.1",
@@ -5450,6 +5442,7 @@
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
+ "peer": true,
"bin": {
"marked": "bin/marked.js"
},
@@ -5827,7 +5820,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5858,7 +5850,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5871,7 +5862,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6194,7 +6184,6 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.2.tgz",
"integrity": "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10"
}
@@ -6491,7 +6480,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6631,7 +6619,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
diff --git a/frontend/src/components/onboarding/privacy-note.tsx b/frontend/src/components/onboarding/privacy-note.tsx
index 97b5e06..2b05e69 100644
--- a/frontend/src/components/onboarding/privacy-note.tsx
+++ b/frontend/src/components/onboarding/privacy-note.tsx
@@ -1,36 +1,35 @@
import { ShieldCheck } from "lucide-react";
+import { Browser } from "@wailsio/runtime";
interface PrivacyNoteProps {
- className?: string;
- style?: React.CSSProperties;
+ className?: string;
+ style?: React.CSSProperties;
}
export function PrivacyNote({ className = "", style }: PrivacyNoteProps) {
- return (
-
+
+
+ Privacy First — All
+ data stays on your Mac. When needed, it's processed through our
+ encrypted server to AI models with zero retention — never stored,
+ shared, or used for training. Focusd is source available on{" "}
+ Browser.OpenURL("https://github.com/focusd-so/focusd")}
+ className="text-indigo-400 hover:text-indigo-300 underline cursor-pointer"
>
-
-
- Privacy First — All
- data stays on your Mac. When needed, it's processed through our
- encrypted server to AI models with zero retention — never stored,
- shared, or used for training. Focusd is source available on{" "}
-
- GitHub
-
- .
-
-
- );
+ GitHub
+
+ .
+
+
+ );
}
diff --git a/frontend/src/components/onboarding/step2.tsx b/frontend/src/components/onboarding/step2.tsx
index ab8612e..cb993af 100644
--- a/frontend/src/components/onboarding/step2.tsx
+++ b/frontend/src/components/onboarding/step2.tsx
@@ -12,6 +12,7 @@ import {
RequestAccessibility,
RequestAutomation,
OpenSettings,
+ GetInstalledBrowsers,
} from "../../../bindings/github.com/focusd-so/focusd/internal/native/nativeservice";
import { PrivacyNote } from "./privacy-note";
import { OnboardingHeader } from "./onboarding-header";
@@ -27,6 +28,17 @@ interface PermissionCard {
icon: React.ReactNode;
}
+interface BrowserEntry {
+ bundleID: string;
+ name: string;
+ status: PermissionStatus;
+}
+
+const MANDATORY_BROWSERS = new Set([
+ "com.apple.Safari",
+ "com.google.Chrome",
+]);
+
interface Step2Props {
onAllGranted: (allGranted: boolean) => void;
entered: boolean;
@@ -36,60 +48,94 @@ export function Step2({ onAllGranted, entered }: Step2Props) {
const [isVisible, setIsVisible] = useState(false);
const [accessibility, setAccessibility] =
useState("pending");
- const [systemEvents, setSystemEvents] =
- useState("pending");
- const [browsers, setBrowsers] = useState("pending");
+ const [systemEvents, setSystemEvents] = useState("pending");
+ const [browsers, setBrowsers] = useState([]);
- // Pre-check all permissions on mount (silent, no prompts)
useEffect(() => {
const t = setTimeout(() => setIsVisible(true), 50);
+
CheckAccessibility().then((granted) => {
if (granted) setAccessibility("granted");
});
+
CheckAutomation("com.apple.systemevents").then((granted) => {
+ console.log(granted)
if (granted) setSystemEvents("granted");
});
- Promise.all([
- CheckAutomation("com.google.Chrome"),
- CheckAutomation("com.apple.Safari"),
- ]).then(([chrome, safari]) => {
- if (chrome || safari) setBrowsers("granted");
+
+ GetInstalledBrowsers().then(async (installed) => {
+ const entries: BrowserEntry[] = await Promise.all(
+ installed.map(async (b) => {
+ const granted = await CheckAutomation(b.bundleID);
+ return {
+ bundleID: b.bundleID,
+ name: b.name,
+ status: granted ? ("granted" as const) : ("pending" as const),
+ };
+ }),
+ );
+ setBrowsers(entries);
});
+
return () => clearTimeout(t);
}, []);
- // Notify parent when all granted
+ const mandatoryBrowsersGranted = browsers
+ .filter((b) => MANDATORY_BROWSERS.has(b.bundleID))
+ .every((b) => b.status === "granted");
+
useEffect(() => {
const allGranted =
accessibility === "granted" &&
systemEvents === "granted" &&
- browsers === "granted";
+ mandatoryBrowsersGranted;
onAllGranted(allGranted);
- }, [accessibility, systemEvents, browsers, onAllGranted]);
-
- const handleGrant = useCallback(
- async (id: string) => {
- switch (id) {
- case "accessibility": {
- const granted = await RequestAccessibility();
- setAccessibility(granted ? "granted" : "denied");
- break;
- }
- case "system-events": {
- const granted = await RequestAutomation("com.apple.systemevents");
- setSystemEvents(granted ? "granted" : "denied");
- break;
- }
- case "browsers": {
- const chrome = await RequestAutomation("com.google.Chrome");
- const safari = await RequestAutomation("com.apple.Safari");
- setBrowsers(chrome || safari ? "granted" : "denied");
- break;
- }
+ }, [accessibility, systemEvents, mandatoryBrowsersGranted, onAllGranted]);
+
+ const handleGrant = useCallback(async (id: string) => {
+ switch (id) {
+ case "accessibility": {
+ const granted = await RequestAccessibility();
+ setAccessibility(granted ? "granted" : "denied");
+ break;
}
- },
- []
- );
+ case "system-events": {
+ const granted = await RequestAutomation("com.apple.systemevents");
+ setSystemEvents(granted ? "granted" : "denied");
+ break;
+ }
+ }
+ }, []);
+
+ const handleBrowserGrant = useCallback(async (bundleID: string) => {
+ const granted = await RequestAutomation(bundleID);
+ setBrowsers((prev) =>
+ prev.map((b) =>
+ b.bundleID === bundleID
+ ? { ...b, status: granted ? "granted" : "denied" }
+ : b,
+ ),
+ );
+ }, []);
+
+ const handleAllowAll = useCallback(async () => {
+ for (const browser of browsers) {
+ if (browser.status === "granted") continue;
+ const granted = await RequestAutomation(browser.bundleID);
+ setBrowsers((prev) =>
+ prev.map((b) =>
+ b.bundleID === browser.bundleID
+ ? { ...b, status: granted ? "granted" : "denied" }
+ : b,
+ ),
+ );
+ }
+ }, [browsers]);
+
+ const allBrowsersGranted =
+ browsers.length > 0 && browsers.every((b) => b.status === "granted");
+ const anyBrowserDenied = browsers.some((b) => b.status === "denied");
+ const anyBrowserPending = browsers.some((b) => b.status === "pending");
const cards: PermissionCard[] = [
{
@@ -108,16 +154,37 @@ export function Step2({ onAllGranted, entered }: Step2Props) {
status: systemEvents,
icon: ,
},
- {
- id: "browsers",
- title: "Web Browsing",
- description:
- "Detects distracting websites in your browser and redirects you to productivity.",
- status: browsers,
- icon: ,
- },
];
+ const renderAction = (status: PermissionStatus, onGrant: () => void) => {
+ if (status === "granted") {
+ return (
+
+ Granted
+
+ );
+ }
+ if (status === "denied") {
+ return (
+ OpenSettings()}
+ >
+
+ Fix in Settings
+
+ );
+ }
+ return (
+
+ Allow
+
+ );
+ };
+
return (
- Granted
-
- ) : card.status === "denied" ? (
- OpenSettings()}
- >
-
- Fix in Settings
-
- ) : (
- handleGrant(card.id)}
- >
- Allow
-
- )
- }
+ action={renderAction(card.status, () => handleGrant(card.id))}
/>
))}
+
+ {browsers.length > 0 && (
+
+
+
+
+
+
+ Web Browsing
+
+
+ {allBrowsersGranted ? (
+
+
+ All Granted
+
+ ) : anyBrowserPending ? (
+
+ Allow All
+
+ ) : anyBrowserDenied ? (
+
OpenSettings()}
+ >
+
+ Fix in Settings
+
+ ) : null}
+
+
+ Allow Focusd to monitor and block distracting websites in your
+ browsers.
+
+
+
+
+ {browsers.map((browser, i) => {
+ const isMandatory = MANDATORY_BROWSERS.has(browser.bundleID);
+ return (
+
0 ? "border-t border-white/5" : ""}`}
+ >
+
+ {browser.status === "granted" ? (
+
+ ) : (
+
+ )}
+
+ {browser.name}
+
+ {isMandatory && browser.status !== "granted" && (
+
+ Required
+
+ )}
+
+ {browser.status === "granted" ? (
+
+ Granted
+
+ ) : browser.status === "denied" ? (
+
OpenSettings()}
+ >
+
+ Fix
+
+ ) : isMandatory ? (
+
handleBrowserGrant(browser.bundleID)}
+ >
+ Allow
+
+ ) : (
+
handleBrowserGrant(browser.bundleID)}
+ >
+ Allow
+
+ )}
+
+ );
+ })}
+
+
+ )}
- {/* Privacy note */}
{
- const parsedHour = new Date(hourKey).getHours();
+ const parsedHour = parseInt(hourKey, 10);
const safeScore = normalizeProductivityScore(score);
const current = hourlyTotals.get(parsedHour);
diff --git a/internal/native/native_service_darwin.go b/internal/native/native_service_darwin.go
index 3c0908e..eec3193 100644
--- a/internal/native/native_service_darwin.go
+++ b/internal/native/native_service_darwin.go
@@ -7,8 +7,10 @@ package native
#cgo CFLAGS: -x objective-c -fobjc-arc
#cgo LDFLAGS: -framework Cocoa -framework ApplicationServices -framework ServiceManagement
+#include
#include
+#import
#import
static Boolean checkAccessibility(Boolean prompt) {
@@ -55,11 +57,34 @@ static int loginItemEnabled(void) {
}
return 0;
}
+
+// getInstalledAppName returns the display name of the app with the given bundle ID,
+// or NULL if the app is not installed.
+static const char* getInstalledAppName(const char* bundleID) {
+ @autoreleasepool {
+ NSString *bid = [NSString stringWithUTF8String:bundleID];
+ NSURL *appURL = [[NSWorkspace sharedWorkspace] URLForApplicationWithBundleIdentifier:bid];
+ if (appURL == nil) {
+ return NULL;
+ }
+ NSBundle *bundle = [NSBundle bundleWithURL:appURL];
+ NSString *name = [bundle objectForInfoDictionaryKey:@"CFBundleDisplayName"];
+ if (name == nil) {
+ name = [bundle objectForInfoDictionaryKey:@"CFBundleName"];
+ }
+ if (name == nil) {
+ name = [[appURL lastPathComponent] stringByDeletingPathExtension];
+ }
+ return strdup([name UTF8String]);
+ }
+}
*/
import "C"
import (
"fmt"
+ "sort"
"sync"
+ "unsafe"
)
// NativeService is a Wails-bound service for managing macOS permissions
@@ -134,3 +159,51 @@ func (s *NativeService) DisableLoginItem() error {
func (s *NativeService) LoginItemEnabled() bool {
return C.loginItemEnabled() != 0
}
+
+var browserPriority = map[string]int{
+ "com.apple.Safari": 0,
+ "com.google.Chrome": 1,
+ "com.brave.Browser": 2,
+ "com.microsoft.edgemac": 3,
+ "com.operasoftware.Opera": 4,
+ "com.vivaldi.Vivaldi": 5,
+ "company.thebrowser.Browser": 6,
+}
+
+// GetInstalledBrowsers returns all known browsers that are installed on the system,
+// sorted with popular browsers first.
+func (s *NativeService) GetInstalledBrowsers() []InstalledBrowser {
+ allBundleIDs := make([]string, 0, len(chromeBaseBundleIDs)+len(safariBasedBundleIDs))
+ allBundleIDs = append(allBundleIDs, safariBasedBundleIDs...)
+ allBundleIDs = append(allBundleIDs, chromeBaseBundleIDs...)
+
+ var installed []InstalledBrowser
+ for _, bid := range allBundleIDs {
+ cBid := C.CString(bid)
+ cName := C.getInstalledAppName(cBid)
+ C.free(unsafe.Pointer(cBid))
+ if cName == nil {
+ continue
+ }
+ name := C.GoString(cName)
+ C.free(unsafe.Pointer(cName))
+ installed = append(installed, InstalledBrowser{BundleID: bid, Name: name})
+ }
+
+ sort.SliceStable(installed, func(i, j int) bool {
+ pi, okI := browserPriority[installed[i].BundleID]
+ pj, okJ := browserPriority[installed[j].BundleID]
+ if okI && okJ {
+ return pi < pj
+ }
+ if okI {
+ return true
+ }
+ if okJ {
+ return false
+ }
+ return installed[i].Name < installed[j].Name
+ })
+
+ return installed
+}
diff --git a/internal/native/native_service_linux.go b/internal/native/native_service_linux.go
index fc8f31c..fbf418d 100644
--- a/internal/native/native_service_linux.go
+++ b/internal/native/native_service_linux.go
@@ -58,3 +58,7 @@ func (s *NativeService) DisableLoginItem() error {
func (s *NativeService) LoginItemEnabled() bool {
return false
}
+
+func (s *NativeService) GetInstalledBrowsers() []InstalledBrowser {
+ return []InstalledBrowser{}
+}
diff --git a/internal/native/native_service_windows.go b/internal/native/native_service_windows.go
index 25e7a15..19fdbac 100644
--- a/internal/native/native_service_windows.go
+++ b/internal/native/native_service_windows.go
@@ -58,3 +58,7 @@ func (s *NativeService) DisableLoginItem() error {
func (s *NativeService) LoginItemEnabled() bool {
return false
}
+
+func (s *NativeService) GetInstalledBrowsers() []InstalledBrowser {
+ return []InstalledBrowser{}
+}
diff --git a/internal/native/types.go b/internal/native/types.go
index e2e1bac..d7c6ac8 100644
--- a/internal/native/types.go
+++ b/internal/native/types.go
@@ -49,6 +49,11 @@ func (e *NativeEvent) BrowserHostname() string {
return hostname
}
+type InstalledBrowser struct {
+ BundleID string `json:"bundleID"`
+ Name string `json:"name"`
+}
+
func (e *NativeEvent) Domain() string {
if e.URL == "" {
return ""
diff --git a/internal/usage/insights_report.go b/internal/usage/insights_report.go
index 4107703..8231d7f 100644
--- a/internal/usage/insights_report.go
+++ b/internal/usage/insights_report.go
@@ -2,82 +2,77 @@ package usage
import (
"fmt"
- "log/slog"
"math"
"time"
)
func (s *Service) GetDayInsights(date time.Time) (DayInsights, error) {
-
- slog.Info("[INGISHTS]: requestung for", "date", date)
-
if date.IsZero() {
date = time.Now()
}
- listOpts := GetUsageListOptions{
- Date: &date,
- }
-
- usages, err := s.GetUsageList(listOpts)
+ usages, err := s.GetUsageList(GetUsageListOptions{Date: &date})
if err != nil {
return DayInsights{}, fmt.Errorf("failed to get usage list: %w", err)
}
- slog.Info("[INGISHTS]: number of usages", "count", len(usages))
-
- var (
- productivityScore = ProductivityScore{}
- productivityPerHourBreakdown = make(ProductivityPerHourBreakdown)
- )
+ score := ProductivityScore{}
+ hourly := make(ProductivityPerHourBreakdown)
for i, usage := range usages {
- // calculate overall summary
- end := fromPtr(usage.EndedAt)
-
- if end == 0 {
- if len(usages) > i+1 {
- end = usages[i+1].StartedAt
- }
-
- if end == 0 {
- continue
- }
- }
-
- durationSeconds := end - usage.StartedAt
- if durationSeconds <= 0 {
+ end := resolveEndTime(usage, usages, i)
+ if end <= usage.StartedAt {
continue
}
- slog.Info("[INGISHTS]: calculated duration in seconds", "duration", durationSeconds)
+ dur := int(end - usage.StartedAt)
+ score.addSeconds(usage.Classification, dur)
- switch usage.Classification {
- case ClassificationProductive:
- productivityScore.ProductiveSeconds += int(durationSeconds)
- case ClassificationDistracting:
- productivityScore.DistractiveSeconds += int(durationSeconds)
- default:
- productivityScore.OtherSeconds += int(durationSeconds)
+ for hour, secs := range splitSecondsPerHour(usage.StartedAt, end) {
+ entry := hourly[hour]
+ entry.addSeconds(usage.Classification, secs)
+ hourly[hour] = entry
}
+ }
+ score.ProductivityScore = calculateProductivityScore(score.ProductiveSeconds, score.DistractiveSeconds)
+ for hour, s := range hourly {
+ s.ProductivityScore = calculateProductivityScore(s.ProductiveSeconds, s.DistractiveSeconds)
+ hourly[hour] = s
}
- slog.Info("[INGISHTS]: total seconds", "productivity", productivityScore.ProductiveSeconds, "distracting", productivityScore.DistractiveSeconds)
+ return DayInsights{ProductivityScore: score, ProductivityPerHourBreakdown: hourly}, nil
+}
- // Calculate overall score
- productivityScore.ProductivityScore = calculateProductivityScore(productivityScore.ProductiveSeconds, productivityScore.DistractiveSeconds)
+func resolveEndTime(usage ApplicationUsage, usages []ApplicationUsage, i int) int64 {
+ if usage.EndedAt != nil {
+ return *usage.EndedAt
+ }
+ if i+1 < len(usages) {
+ return usages[i+1].StartedAt
+ }
+ return 0
+}
+
+func splitSecondsPerHour(startUnix, endUnix int64) map[int]int {
+ start := time.Unix(startUnix, 0).UTC()
+ end := time.Unix(endUnix, 0).UTC()
+ result := make(map[int]int)
+
+ for cursor := start; cursor.Before(end); {
+ hour := cursor.Hour()
+ nextHour := time.Date(cursor.Year(), cursor.Month(), cursor.Day(), hour+1, 0, 0, 0, time.UTC)
+
+ segmentEnd := end
+ if nextHour.Before(end) {
+ segmentEnd = nextHour
+ }
- // Calculate hourly scores
- for hour, score := range productivityPerHourBreakdown {
- score.ProductivityScore = calculateProductivityScore(score.ProductiveSeconds, score.DistractiveSeconds)
- productivityPerHourBreakdown[hour] = score
+ result[hour] += int(segmentEnd.Sub(cursor).Seconds())
+ cursor = segmentEnd
}
- return DayInsights{
- ProductivityScore: productivityScore,
- ProductivityPerHourBreakdown: productivityPerHourBreakdown,
- }, nil
+ return result
}
func calculateProductivityScore(productiveSeconds, distractiveSeconds int) int {
diff --git a/internal/usage/types_insights.go b/internal/usage/types_insights.go
index ee3c39a..962384e 100644
--- a/internal/usage/types_insights.go
+++ b/internal/usage/types_insights.go
@@ -12,4 +12,15 @@ type ProductivityScore struct {
ProductivityScore int
}
+func (p *ProductivityScore) addSeconds(classification Classification, seconds int) {
+ switch classification {
+ case ClassificationProductive:
+ p.ProductiveSeconds += seconds
+ case ClassificationDistracting:
+ p.DistractiveSeconds += seconds
+ default:
+ p.OtherSeconds += seconds
+ }
+}
+
type ProductivityPerHourBreakdown map[int]ProductivityScore