diff --git a/frontend/src/components/allow-custom-dialog.tsx b/frontend/src/components/allow-custom-dialog.tsx new file mode 100644 index 0000000..5f62a80 --- /dev/null +++ b/frontend/src/components/allow-custom-dialog.tsx @@ -0,0 +1,216 @@ +import { useState, useMemo, useEffect } from "react"; +import { format } from "date-fns"; +import { IconCalendar } from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogBody, + DialogFooter, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + + +interface AllowCustomDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (durationMinutes: number) => void; + appName: string; + defaultDate?: Date; +} + +const TIME_OPTIONS = Array.from({ length: 96 }, (_, index) => { + const hours = Math.floor(index / 4); + const minutes = (index % 4) * 15; + const value = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; + const label = new Date(2024, 0, 1, hours, minutes).toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + }); + + return { value, label }; +}); + +function getNextQuarterHourValue(date = new Date()): string { + const next = new Date(date); + next.setSeconds(0, 0); + next.setMinutes(Math.ceil(next.getMinutes() / 15) * 15); + + if (next.getMinutes() === 60) { + next.setHours(next.getHours() + 1, 0, 0, 0); + } + + return `${String(next.getHours()).padStart(2, "0")}:${String(next.getMinutes()).padStart(2, "0")}`; +} + +function formatPauseDuration(totalMinutes: number): string { + const days = Math.floor(totalMinutes / (60 * 24)); + const hours = Math.floor((totalMinutes % (60 * 24)) / 60); + const minutes = totalMinutes % 60; + + const parts: string[] = []; + + if (days > 0) { + parts.push(`${days} day${days === 1 ? "" : "s"}`); + } + if (hours > 0) { + parts.push(`${hours} hour${hours === 1 ? "" : "s"}`); + } + if (days === 0 && minutes > 0) { + parts.push(`${minutes} minute${minutes === 1 ? "" : "s"}`); + } + + return parts.join(" "); +} + +export function AllowCustomDialog({ + open, + onOpenChange, + onConfirm, + appName, + defaultDate, +}: AllowCustomDialogProps) { + const [allowUntilDate, setAllowUntilDate] = useState(defaultDate || new Date()); + const [allowUntilTime, setAllowUntilTime] = useState(""); + + useEffect(() => { + if (open) { + const initDate = defaultDate || new Date(); + setAllowUntilDate(initDate); + setAllowUntilTime(getNextQuarterHourValue(initDate)); + } + }, [open, defaultDate]); + + const allowUntilDateTime = useMemo(() => { + if (!allowUntilDate || !allowUntilTime) return null; + const [hours, minutes] = allowUntilTime.split(":").map(Number); + + if (Number.isNaN(hours) || Number.isNaN(minutes)) return null; + + const date = new Date(allowUntilDate); + date.setHours(hours, minutes, 0, 0); + return date; + }, [allowUntilDate, allowUntilTime]); + + const allowUntilDurationMinutes = useMemo(() => { + if (!allowUntilDateTime) return null; + return Math.ceil((allowUntilDateTime.getTime() - Date.now()) / (1000 * 60)); + }, [allowUntilDateTime]); + + const canConfirm = allowUntilDurationMinutes !== null && allowUntilDurationMinutes > 0; + + const handleConfirm = () => { + if (allowUntilDurationMinutes && allowUntilDurationMinutes > 0) { + onConfirm(allowUntilDurationMinutes); + onOpenChange(false); + } + }; + + return ( + + + + Allow {appName} + + Select a custom date and time to allow access until. + + + + +
+
+ + + + + + date < new Date(new Date().setHours(0, 0, 0, 0))} + initialFocus + /> + + + + +
+ + {allowUntilDurationMinutes !== null && allowUntilDurationMinutes > 0 && ( +

+ Access allowed for {formatPauseDuration(allowUntilDurationMinutes)} + {allowUntilDateTime ? ` · ${format(allowUntilDateTime, "MMM d, yyyy h:mm a")}` : ""}. +

+ )} + + {allowUntilDurationMinutes !== null && allowUntilDurationMinutes <= 0 && ( +

+ Please select a future date and time. +

+ )} +
+
+ + + + + +
+
+ ); +} diff --git a/frontend/src/routes/activity.tsx b/frontend/src/routes/activity.tsx index 06f8b15..70eeb6c 100644 --- a/frontend/src/routes/activity.tsx +++ b/frontend/src/routes/activity.tsx @@ -8,6 +8,7 @@ import { IconChevronDown, IconClock, IconSearch, + IconCalendar, } from "@tabler/icons-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -35,6 +36,7 @@ import { formatClassificationSource, formatEnforcementSource, } from "@/components/usage-item"; +import { AllowCustomDialog } from "@/components/allow-custom-dialog"; import type { ApplicationUsage } from "../../bindings/github.com/focusd-so/focusd/internal/usage/models"; // Extended blocked item for UI display with allowed status @@ -225,7 +227,7 @@ function ActivityPage() { const name = usage.application?.name?.toLowerCase() || ""; const host = usage.application?.hostname?.toLowerCase() || ""; const title = usage.window_title?.toLowerCase() || ""; - const tags = usage.tags?.map((t) => t.tag.toLowerCase()).join(" ") || ""; + const tags = usage.tags?.map((t: any) => t.tag.toLowerCase()).join(" ") || ""; return name.includes(q) || host.includes(q) || title.includes(q) || tags.includes(q); }); }, [blockedUsagesDisplay, searchQuery]); @@ -312,6 +314,7 @@ function BlockedUsageItem({ item }: { item: BlockedUsageDisplay }) { const { usage, count, isAllowed, expiresAt, whitelistId } = item; const isWeb = !!usage.application?.hostname; const [timeLeft, setTimeLeft] = useState(null); + const [isCustomDialogOpen, setIsCustomDialogOpen] = useState(false); const addToWhitelist = useUsageStore((state) => state.addToWhitelist); const removeFromWhitelist = useUsageStore((state) => state.removeFromWhitelist); @@ -349,9 +352,15 @@ function BlockedUsageItem({ item }: { item: BlockedUsageDisplay }) { }, [isAllowed, expiresAt, whitelistId, removeFromWhitelist]); const formatTime = (seconds: number) => { - const mins = Math.floor(seconds / 60); + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; - return `${mins}:${secs.toString().padStart(2, "0")}`; + + // Always pad minutes and seconds. Include hours if >= 1 + if (hours > 0) { + return `${hours}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; + } + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; }; const handleAllowWithDuration = async (durationMinutes: number) => { @@ -360,6 +369,15 @@ function BlockedUsageItem({ item }: { item: BlockedUsageDisplay }) { await addToWhitelist(appname, hostname, durationMinutes); }; + const handleExtendDuration = async (addedMinutes: number) => { + if (!expiresAt) return; + const now = Math.floor(Date.now() / 1000); + // If it already expired, just add from now + const currentRemaining = Math.max(0, expiresAt - now); + const totalMinutes = Math.floor(currentRemaining / 60) + addedMinutes; + await handleAllowWithDuration(totalMinutes); + }; + const handleUnallow = async () => { if (whitelistId) { await removeFromWhitelist(whitelistId); @@ -421,11 +439,6 @@ function BlockedUsageItem({ item }: { item: BlockedUsageDisplay }) { {isAllowed ? "ALLOWED" : "BLOCKED"} - {isAllowed && timeLeft !== null && ( - - {formatTime(timeLeft)} left - - )} {/* Row 2: Window title / rule source */} @@ -489,7 +502,7 @@ function BlockedUsageItem({ item }: { item: BlockedUsageDisplay }) { {formatSmartDate(usage.started_at)} - {usage.tags?.map((usageTag) => ( + {usage.tags?.map((usageTag: any) => ( 1 hour + + setIsCustomDialogOpen(true)} + className="text-sm text-white/80 hover:text-white focus:text-white focus:bg-white/8 cursor-pointer gap-2 justify-start" + > + + Custom time... + )} {isAllowed && ( - + + + + + + + Extend by… + + + handleExtendDuration(15)} + className="text-sm text-white/80 hover:text-white focus:text-white focus:bg-white/8 cursor-pointer gap-2 justify-start" + > + + +15 minutes + + handleExtendDuration(30)} + className="text-sm text-white/80 hover:text-white focus:text-white focus:bg-white/8 cursor-pointer gap-2 justify-start" + > + + +30 minutes + + handleExtendDuration(60)} + className="text-sm text-white/80 hover:text-white focus:text-white focus:bg-white/8 cursor-pointer gap-2 justify-start" + > + + +1 hour + + + setIsCustomDialogOpen(true)} + className="text-sm text-white/80 hover:text-white focus:text-white focus:bg-white/8 cursor-pointer gap-2 justify-start" + > + + Custom time... + + + + + Block now + + + )} + + ); } diff --git a/internal/usage/classifier_llm.go b/internal/usage/classifier_llm.go index 809fc63..0e5b5d8 100644 --- a/internal/usage/classifier_llm.go +++ b/internal/usage/classifier_llm.go @@ -8,6 +8,7 @@ import ( "net/http" "slices" "strings" + "time" apiv1 "github.com/focusd-so/focusd/gen/api/v1" "github.com/focusd-so/focusd/internal/api" @@ -170,7 +171,10 @@ func classifyWithGrok(ctx context.Context, instructions, input string) (*Classif } func classifyWithLLM(ctx context.Context, model llms.Model, instructions, input string) (*ClassificationResponse, error) { - resp, err := model.GenerateContent(ctx, []llms.MessageContent{ + reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + resp, err := model.GenerateContent(reqCtx, []llms.MessageContent{ llms.TextParts(llms.ChatMessageTypeSystem, instructions), llms.TextParts(llms.ChatMessageTypeHuman, input), }) diff --git a/internal/usage/service_usage.go b/internal/usage/service_usage.go index 252c573..909d584 100644 --- a/internal/usage/service_usage.go +++ b/internal/usage/service_usage.go @@ -352,7 +352,10 @@ func fetchFavicon(ctx context.Context, rawURL string) (string, error) { return "", fmt.Errorf("failed to parse URL: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleFaviconURL+parsedURL.Host, nil) + reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, googleFaviconURL+parsedURL.Host, nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) }