diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/DigestSetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/DigestSetting.tsx index a28cfdd671..1f9a854678 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/DigestSetting.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/DigestSetting.tsx @@ -1,23 +1,77 @@ "use client"; -import { useDigestEnabled } from "@/hooks/useFeatureFlags"; +import { useState } from "react"; +import Image from "next/image"; import { Button } from "@/components/ui/button"; import { SettingCard } from "@/components/SettingCard"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { useDigestEnabled } from "@/hooks/useFeatureFlags"; +import { DigestScheduleForm } from "@/app/(app)/[emailAccountId]/settings/DigestScheduleForm"; +import { + ExampleDialog, + SeeExampleDialogButton, +} from "@/app/(app)/[emailAccountId]/assistant/onboarding/ExampleDialog"; +import { DigestItemsForm } from "@/app/(app)/[emailAccountId]/settings/DigestItemsForm"; export function DigestSetting() { const enabled = useDigestEnabled(); + const [showExampleDialog, setShowExampleDialog] = useState(false); if (!enabled) return null; return ( - - Configure - - } - /> + <> + + + + + + + Digest Settings + + Configure when your digest emails are sent and which rules are + included.{" "} + setShowExampleDialog(true)} + /> + + + + + + + + } + /> + + + } + /> + ); } diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/SettingsTab.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/SettingsTab.tsx index 094614b065..188deb8cc3 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/SettingsTab.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/SettingsTab.tsx @@ -1,4 +1,5 @@ import { AboutSetting } from "@/app/(app)/[emailAccountId]/assistant/AboutSetting"; +import { DigestSetting } from "@/app/(app)/[emailAccountId]/assistant/DigestSetting"; import { DraftReplies } from "@/app/(app)/[emailAccountId]/assistant/DraftReplies"; import { Rules } from "@/app/(app)/[emailAccountId]/assistant/Rules"; import { RulesPrompt } from "@/app/(app)/[emailAccountId]/assistant/RulesPrompt"; @@ -23,13 +24,13 @@ export function SettingsTab() {
- {/* */}
+
); diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/DigestFrequencyDialog.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/DigestFrequencyDialog.tsx deleted file mode 100644 index 7123919c32..0000000000 --- a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/DigestFrequencyDialog.tsx +++ /dev/null @@ -1,94 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { SchedulePicker } from "@/app/(app)/[emailAccountId]/settings/SchedulePicker"; -import { updateDigestScheduleAction } from "@/utils/actions/settings"; -import { toastError, toastSuccess } from "@/components/Toast"; -import type { SaveDigestScheduleBody } from "@/utils/actions/settings.validation"; -import { useAccount } from "@/providers/EmailAccountProvider"; - -interface DigestFrequencyDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function DigestFrequencyDialog({ - open, - onOpenChange, -}: DigestFrequencyDialogProps) { - const { emailAccountId } = useAccount(); - const [isLoading, setIsLoading] = useState(false); - const [digestScheduleValue, setDigestScheduleValue] = useState< - SaveDigestScheduleBody["schedule"] - >({ - intervalDays: 7, - daysOfWeek: 1 << (6 - 1), // Monday (1) - timeOfDay: new Date(new Date().setHours(11, 0, 0, 0)), // 11 AM - occurrences: 1, - }); - - const updateDigestSchedule = updateDigestScheduleAction.bind( - null, - emailAccountId, - ); - - const handleSave = async () => { - if (!digestScheduleValue) return; - - setIsLoading(true); - try { - const result = await updateDigestSchedule({ - schedule: digestScheduleValue, - }); - - if (result?.serverError) { - toastError({ - description: "Failed to save digest frequency. Please try again.", - }); - } else { - toastSuccess({ description: "Digest frequency saved!" }); - onOpenChange(false); - } - } catch (error) { - toastError({ - description: "Failed to save digest frequency. Please try again.", - }); - } finally { - setIsLoading(false); - } - }; - - return ( - - - - Digest Email Frequency - - Choose how often you want to receive your digest emails. These - emails will include a summary of the actions taken on your behalf. - - -
- -
- - - - -
-
- ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/digest-frequency/page.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/digest-frequency/page.tsx deleted file mode 100644 index bf529b1771..0000000000 --- a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/digest-frequency/page.tsx +++ /dev/null @@ -1,234 +0,0 @@ -"use client"; - -import useSWR from "swr"; -import Image from "next/image"; -import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useState, useEffect } from "react"; -import { - updateDigestCategoriesAction, - updateDigestScheduleAction, - ensureDefaultDigestScheduleAction, -} from "@/utils/actions/settings"; -import { toastError, toastSuccess } from "@/components/Toast"; -import { prefixPath } from "@/utils/path"; -import { useAccount } from "@/providers/EmailAccountProvider"; -import { - markOnboardingAsCompleted, - ASSISTANT_ONBOARDING_COOKIE, -} from "@/utils/cookies"; -import { - ExampleDialog, - SeeExampleDialogButton, -} from "@/app/(app)/[emailAccountId]/assistant/onboarding/ExampleDialog"; -import { DigestFrequencyDialog } from "@/app/(app)/[emailAccountId]/assistant/onboarding/DigestFrequencyDialog"; -import { Toggle } from "@/components/Toggle"; -import { TooltipExplanation } from "@/components/TooltipExplanation"; -import type { UpdateDigestCategoriesBody } from "@/utils/actions/settings.validation"; -import { categoryConfig } from "@/utils/category-config"; -import { Skeleton } from "@/components/ui/skeleton"; -import type { GetDigestSettingsResponse } from "@/app/api/user/digest-settings/route"; - -export default function DigestFrequencyPage() { - const { emailAccountId } = useAccount(); - const router = useRouter(); - const [isLoading, setIsLoading] = useState(false); - const [showExampleDialog, setShowExampleDialog] = useState(false); - const [showFrequencyDialog, setShowFrequencyDialog] = useState(false); - - const { data: digestSettings, isLoading: isLoadingSettings } = - useSWR("/api/user/digest-settings"); - - const [settings, setSettings] = useState({ - toReply: false, - newsletter: false, - marketing: false, - calendar: false, - receipt: false, - notification: false, - coldEmail: false, - }); - - // Update local state when digest settings are loaded - useEffect(() => { - if (digestSettings) { - setSettings(digestSettings); - } - }, [digestSettings]); - - useEffect(() => { - // Ensure user has a digest schedule entry when they visit this page, otherwise the digest is not sent - const timeOfDay = new Date(); - timeOfDay.setHours(11, 0, 0, 0); // 11 AM in user's timezone - ensureDefaultDigestScheduleAction(emailAccountId, { timeOfDay }); - }, [emailAccountId]); - - const updateDigestCategories = updateDigestCategoriesAction.bind( - null, - emailAccountId, - ); - - const handleToggle = (key: keyof UpdateDigestCategoriesBody) => { - setSettings((prev) => ({ - ...prev, - [key]: !prev[key], - })); - }; - - const handleFinish = async () => { - setIsLoading(true); - try { - // Save digest categories - const result = await updateDigestCategories(settings); - - if (result?.serverError) { - toastError({ - description: "Failed to save digest settings. Please try again.", - }); - return; - } - - // Ensure a default digest schedule is set if none exists - const updateDigestSchedule = updateDigestScheduleAction.bind( - null, - emailAccountId, - ); - - const scheduleResult = await updateDigestSchedule({ - schedule: { - intervalDays: 7, - daysOfWeek: 1 << (6 - 1), // Monday (1) - timeOfDay: new Date(new Date().setHours(11, 0, 0, 0)), // 11 AM - occurrences: 1, - }, - }); - - if (scheduleResult?.serverError) { - toastError({ - description: "Failed to set digest frequency. Please try again.", - }); - return; - } - - toastSuccess({ description: "Digest settings saved!" }); - markOnboardingAsCompleted(ASSISTANT_ONBOARDING_COOKIE); - router.push( - prefixPath(emailAccountId, "/assistant/onboarding/completed"), - ); - } catch (error) { - toastError({ - description: "Failed to save digest settings. Please try again.", - }); - } finally { - setIsLoading(false); - } - }; - - return ( -
- - - Digest Email - - -
-

- Get a summary of actions taken on your behalf in a single email.{" "} - setShowExampleDialog(true)} - /> -

- -
-
-

- Choose categories to include: -

- -
- - {isLoadingSettings ? ( -
- {categoryConfig.map((category) => ( -
- -
- -
- -
- ))} -
- ) : ( -
- {categoryConfig.map((category) => ( -
- {category.icon} -
- {category.label} - {category.tooltipText && ( - - )} -
- handleToggle(category.key)} - /> -
- ))} -
- )} -
- - -
-
-
- - - } - /> - - -
- ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/draft-replies/page.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/draft-replies/page.tsx index 150d02e1a3..c7d72d5a29 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/draft-replies/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/draft-replies/page.tsx @@ -28,17 +28,11 @@ export default function DraftRepliesPage() { }); } - if (digestEnabled) { - router.push( - prefixPath(emailAccountId, "/assistant/onboarding/digest-frequency"), - ); - } else { - router.push( - prefixPath(emailAccountId, "/assistant/onboarding/completed"), - ); - } + router.push( + prefixPath(emailAccountId, "/assistant/onboarding/completed"), + ); }, - [router, emailAccountId, digestEnabled], + [router, emailAccountId], ); return ( diff --git a/apps/web/app/(app)/[emailAccountId]/settings/DigestItemsForm.tsx b/apps/web/app/(app)/[emailAccountId]/settings/DigestItemsForm.tsx new file mode 100644 index 0000000000..49ee2bd098 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/settings/DigestItemsForm.tsx @@ -0,0 +1,105 @@ +import { useCallback, useEffect } from "react"; +import { useForm, type SubmitHandler } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { toastError, toastSuccess } from "@/components/Toast"; +import { LoadingContent } from "@/components/LoadingContent"; +import { useRules } from "@/hooks/useRules"; +import { Toggle } from "@/components/Toggle"; +import { updateDigestItemsAction } from "@/utils/actions/settings"; +import { + updateDigestItemsBody, + type UpdateDigestItemsBody, +} from "@/utils/actions/settings.validation"; +import { ActionType } from "@prisma/client"; +import { useAccount } from "@/providers/EmailAccountProvider"; + +export function DigestItemsForm() { + const { emailAccountId } = useAccount(); + const { data: rules, isLoading, error, mutate } = useRules(); + + const { + handleSubmit, + formState: { isSubmitting }, + reset, + watch, + setValue, + } = useForm({ + resolver: zodResolver(updateDigestItemsBody), + defaultValues: { + ruleDigestPreferences: {}, + }, + }); + + const ruleDigestPreferences = watch("ruleDigestPreferences"); + + // Initialize preferences from rules data + useEffect(() => { + if (rules) { + const preferences: Record = {}; + rules.forEach((rule) => { + preferences[rule.id] = rule.actions.some( + (action) => action.type === ActionType.DIGEST, + ); + }); + reset({ + ruleDigestPreferences: preferences, + }); + } + }, [rules, reset]); + + const handleRuleDigestToggle = useCallback( + (ruleId: string, enabled: boolean) => { + setValue(`ruleDigestPreferences.${ruleId}`, enabled); + }, + [setValue], + ); + + const onSubmit: SubmitHandler = useCallback( + async (data) => { + const result = await updateDigestItemsAction(emailAccountId, data); + + if (result?.serverError) { + toastError({ + title: "Error updating digest items", + description: result.serverError, + }); + } else { + toastSuccess({ description: "Your digest items have been updated!" }); + mutate(); + } + }, + [mutate, emailAccountId], + ); + + return ( + +
+ + +
+ {rules?.map((rule) => ( +
+
+ {rule.name} +
+ handleRuleDigestToggle(rule.id, enabled)} + /> +
+ ))} +
+ + +
+
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/settings/DigestMailFrequencySection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/DigestMailFrequencySection.tsx deleted file mode 100644 index c4ea78d045..0000000000 --- a/apps/web/app/(app)/[emailAccountId]/settings/DigestMailFrequencySection.tsx +++ /dev/null @@ -1,100 +0,0 @@ -"use client"; - -import type { FormEvent } from "react"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - FormSection, - FormSectionLeft, - SubmitButtonWrapper, -} from "@/components/Form"; -import { toastError, toastSuccess } from "@/components/Toast"; -import type { Schedule } from "@prisma/client"; -import type { SaveDigestScheduleBody } from "@/utils/actions/settings.validation"; -import { updateDigestScheduleAction } from "@/utils/actions/settings"; -import { useAccount } from "@/providers/EmailAccountProvider"; -import { useAction } from "next-safe-action/hooks"; -import { - SchedulePicker, - getInitialScheduleProps, - mapToSchedule, -} from "./SchedulePicker"; - -export function DigestMailFrequencySection({ - digestSchedule, - mutate, -}: { - digestSchedule?: Schedule; - mutate: () => void; -}) { - return ( - - - - - - ); -} - -function DigestUpdateSectionForm({ - digestSchedule, - mutate, -}: { - digestSchedule?: Schedule; - mutate: () => void; -}) { - const { emailAccountId } = useAccount(); - const [digestScheduleValue, setDigestScheduleValue] = useState( - mapToSchedule(getInitialScheduleProps(digestSchedule)), - ); - - const { execute, isExecuting } = useAction( - updateDigestScheduleAction.bind(null, emailAccountId), - { - onSuccess: () => { - toastSuccess({ - description: "Your digest settings have been updated!", - }); - }, - onError: (error) => { - toastError({ - description: - error.error.serverError ?? - "An unknown error occurred while updating your settings", - }); - }, - onSettled: () => { - mutate(); - }, - }, - ); - - const onSubmit = (e: FormEvent) => { - e.preventDefault(); - execute({ - schedule: digestScheduleValue, - }); - }; - - return ( -
-
- - - - -
-
- ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/settings/DigestScheduleForm.tsx b/apps/web/app/(app)/[emailAccountId]/settings/DigestScheduleForm.tsx new file mode 100644 index 0000000000..fc8a58a13a --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/settings/DigestScheduleForm.tsx @@ -0,0 +1,375 @@ +import { z } from "zod"; +import { type SubmitHandler, useForm } from "react-hook-form"; +import { useCallback } from "react"; +import useSWR from "swr"; +import { + Select, + SelectItem, + SelectContent, + SelectTrigger, +} from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { FormItem } from "@/components/ui/form"; +import { + createCanonicalTimeOfDay, + dayOfWeekToBitmask, + bitmaskToDayOfWeek, +} from "@/utils/schedule"; +import { Button } from "@/components/ui/button"; +import { toastError, toastSuccess } from "@/components/Toast"; +import { updateDigestScheduleAction } from "@/utils/actions/settings"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { useAction } from "next-safe-action/hooks"; +import type { GetDigestScheduleResponse } from "@/app/api/user/digest-schedule/route"; +import { LoadingContent } from "@/components/LoadingContent"; +import { ErrorMessage } from "@/components/Input"; +import { zodResolver } from "@hookform/resolvers/zod"; + +const digestScheduleFormSchema = z.object({ + schedule: z.string().min(1, "Please select a frequency"), + dayOfWeek: z.string().min(1, "Please select a day"), + hour: z.string().min(1, "Please select an hour"), + minute: z.string().min(1, "Please select minutes"), + ampm: z.enum(["AM", "PM"], { required_error: "Please select AM or PM" }), +}); + +type DigestScheduleFormValues = z.infer; + +const frequencies = [ + { value: "daily", label: "Day" }, + { value: "weekly", label: "Week" }, + { value: "biweekly", label: "Two weeks" }, + { value: "monthly", label: "Month" }, +]; + +const daysOfWeek = [ + { value: "0", label: "Sunday" }, + { value: "1", label: "Monday" }, + { value: "2", label: "Tuesday" }, + { value: "3", label: "Wednesday" }, + { value: "4", label: "Thursday" }, + { value: "5", label: "Friday" }, + { value: "6", label: "Saturday" }, +]; + +const hours = Array.from({ length: 12 }, (_, i) => ({ + value: (i + 1).toString().padStart(2, "0"), + label: (i + 1).toString(), +})); + +const minutes = ["00", "15", "30", "45"].map((m) => ({ + value: m, + label: m, +})); + +const ampmOptions = [ + { value: "AM", label: "AM" }, + { value: "PM", label: "PM" }, +]; + +export function DigestScheduleForm() { + const { data, isLoading, error, mutate } = useSWR( + "/api/user/digest-schedule", + ); + + return ( + + + + ); +} + +function DigestScheduleFormInner({ + data, + mutate, +}: { + data: GetDigestScheduleResponse | undefined; + mutate: () => void; +}) { + const { emailAccountId } = useAccount(); + + const { + handleSubmit, + watch, + setValue, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(digestScheduleFormSchema), + defaultValues: getInitialScheduleProps(data), + }); + + const watchedValues = watch(); + + const { execute, isExecuting } = useAction( + updateDigestScheduleAction.bind(null, emailAccountId), + { + onSuccess: () => { + toastSuccess({ + description: "Your digest settings have been updated!", + }); + mutate(); + }, + onError: (error) => { + toastError({ + description: + error.error.serverError ?? + "An unknown error occurred while updating your settings", + }); + }, + }, + ); + + const onSubmit: SubmitHandler = useCallback( + async (data) => { + const { schedule, dayOfWeek, hour, minute, ampm } = data; + + let intervalDays: number; + switch (schedule) { + case "daily": + intervalDays = 1; + break; + case "weekly": + intervalDays = 7; + break; + case "biweekly": + intervalDays = 14; + break; + case "monthly": + intervalDays = 30; + break; + default: + intervalDays = 1; + } + + let hour24 = Number.parseInt(hour, 10); + if (ampm === "AM" && hour24 === 12) hour24 = 0; + else if (ampm === "PM" && hour24 !== 12) hour24 += 12; + + // Use canonical date (1970-01-01) to store only time information + const timeOfDay = createCanonicalTimeOfDay( + hour24, + Number.parseInt(minute, 10), + ); + + const scheduleData = { + intervalDays, + occurrences: 1, + daysOfWeek: dayOfWeekToBitmask(Number.parseInt(dayOfWeek, 10)), + timeOfDay, + }; + + execute(scheduleData); + }, + [execute], + ); + + return ( +
+ + +
+ + + + {errors.schedule && ( + + )} + + + {watchedValues.schedule !== "daily" && ( + + + + {errors.dayOfWeek && ( + + )} + + )} + +
+ +
+ + + + : + + + + + + +
+ {(errors.hour || errors.minute || errors.ampm) && ( +
+ {errors.hour && ( + + )} + {errors.minute && ( + + )} + {errors.ampm && ( + + )} +
+ )} +
+
+ +
+ ); +} + +function getInitialScheduleProps( + digestSchedule?: GetDigestScheduleResponse | null, +) { + const initialSchedule = (() => { + if (!digestSchedule) return "daily"; + switch (digestSchedule.intervalDays) { + case 1: + return "daily"; + case 7: + return "weekly"; + case 14: + return "biweekly"; + case 30: + return "monthly"; + default: + return "daily"; + } + })(); + + const initialDayOfWeek = (() => { + if (!digestSchedule || digestSchedule.daysOfWeek == null) return "1"; + const dayOfWeek = bitmaskToDayOfWeek(digestSchedule.daysOfWeek); + return dayOfWeek !== null ? dayOfWeek.toString() : "1"; + })(); + + const initialTimeOfDay = digestSchedule?.timeOfDay + ? (() => { + // Extract time from canonical date (1970-01-01T00:00:00Z + time) + const hours = new Date(digestSchedule.timeOfDay) + .getHours() + .toString() + .padStart(2, "0"); + const minutes = new Date(digestSchedule.timeOfDay) + .getMinutes() + .toString() + .padStart(2, "0"); + return `${hours}:${minutes}`; + })() + : "09:00"; + + const [initHour24, initMinute] = initialTimeOfDay.split(":"); + const hour12 = (Number.parseInt(initHour24, 10) % 12 || 12) + .toString() + .padStart(2, "0"); + const ampm = (Number.parseInt(initHour24, 10) < 12 ? "AM" : "PM") as + | "AM" + | "PM"; + + return { + schedule: initialSchedule, + dayOfWeek: initialDayOfWeek, + hour: hour12, + minute: initMinute || "00", + ampm, + }; +} diff --git a/apps/web/app/(app)/[emailAccountId]/settings/SchedulePicker.tsx b/apps/web/app/(app)/[emailAccountId]/settings/SchedulePicker.tsx deleted file mode 100644 index bbe715c2fa..0000000000 --- a/apps/web/app/(app)/[emailAccountId]/settings/SchedulePicker.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import React, { useState } from "react"; -import { - Select, - SelectItem, - SelectContent, - SelectTrigger, -} from "@/components/ui/select"; -import { Label } from "@/components/ui/label"; -import { FormItem } from "@/components/ui/form"; -import { createCanonicalTimeOfDay } from "@/utils/schedule"; - -const frequencies = [ - { value: "daily", label: "Day" }, - { value: "weekly", label: "Week" }, - { value: "biweekly", label: "Two weeks" }, - { value: "monthly", label: "Month" }, -]; -const daysOfWeek = [ - { value: "0", label: "Sunday" }, - { value: "1", label: "Monday" }, - { value: "2", label: "Tuesday" }, - { value: "3", label: "Wednesday" }, - { value: "4", label: "Thursday" }, - { value: "5", label: "Friday" }, - { value: "6", label: "Saturday" }, -]; -const hours = Array.from({ length: 12 }, (_, i) => ({ - value: (i + 1).toString().padStart(2, "0"), - label: (i + 1).toString(), -})); -const minutes = ["00", "15", "30", "45"].map((m) => ({ - value: m, - label: m, -})); -const ampmOptions = [ - { value: "AM", label: "AM" }, - { value: "PM", label: "PM" }, -]; - -export type SchedulePickerFormValues = { - schedule: string; - dayOfWeek: string; - hour: string; - minute: string; - ampm: "AM" | "PM"; -}; - -export function getInitialScheduleProps(digestSchedule?: { - intervalDays?: number | null; - daysOfWeek?: number | null; - timeOfDay?: Date | null; -}): SchedulePickerFormValues { - const initialSchedule = (() => { - if (!digestSchedule) return "daily"; - switch (digestSchedule.intervalDays) { - case 1: - return "daily"; - case 7: - return "weekly"; - case 14: - return "biweekly"; - case 30: - return "monthly"; - default: - return "daily"; - } - })(); - const initialDayOfWeek = (() => { - if (!digestSchedule || digestSchedule.daysOfWeek == null) return "1"; - for (let i = 0; i < 7; i++) { - if ((digestSchedule.daysOfWeek & (1 << (6 - i))) !== 0) - return i.toString(); - } - return "1"; - })(); - const initialTimeOfDay = digestSchedule?.timeOfDay - ? (() => { - // Extract time from canonical date (1970-01-01T00:00:00Z + time) - const hours = new Date(digestSchedule.timeOfDay) - .getHours() - .toString() - .padStart(2, "0"); - const minutes = new Date(digestSchedule.timeOfDay) - .getMinutes() - .toString() - .padStart(2, "0"); - return `${hours}:${minutes}`; - })() - : "09:00"; - const [initHour24, initMinute] = initialTimeOfDay.split(":"); - const hour12 = (Number.parseInt(initHour24, 10) % 12 || 12) - .toString() - .padStart(2, "0"); - const ampm = (Number.parseInt(initHour24, 10) < 12 ? "AM" : "PM") as - | "AM" - | "PM"; - return { - schedule: initialSchedule, - dayOfWeek: initialDayOfWeek, - hour: hour12, - minute: initMinute || "00", - ampm, - }; -} - -export function mapToSchedule({ - schedule, - dayOfWeek, - hour, - minute, - ampm, -}: SchedulePickerFormValues) { - let intervalDays: number; - switch (schedule) { - case "daily": - intervalDays = 1; - break; - case "weekly": - intervalDays = 7; - break; - case "biweekly": - intervalDays = 14; - break; - case "monthly": - intervalDays = 30; - break; - default: - intervalDays = 1; - } - let hour24 = Number.parseInt(hour, 10) % 12; - if (ampm === "PM") hour24 += 12; - if (ampm === "AM" && hour24 === 12) hour24 = 0; - - // Use canonical date (1970-01-01) to store only time information - const timeOfDay = createCanonicalTimeOfDay( - hour24, - Number.parseInt(minute, 10), - ); - - return { - intervalDays, - occurrences: 1, - daysOfWeek: 1 << (6 - Number.parseInt(dayOfWeek, 10)), - timeOfDay, - }; -} - -export function SchedulePicker({ - defaultValue, - onChange, - hideDayOfWeekIfDaily = true, - disabled = false, -}: { - defaultValue?: SchedulePickerFormValues; - onChange?: (backendValue: ReturnType) => void; - hideDayOfWeekIfDaily?: boolean; - disabled?: boolean; -}) { - const [value, setValue] = useState( - defaultValue || { - schedule: "daily", - dayOfWeek: "1", - hour: "11", - minute: "00", - ampm: "AM", - }, - ); - - const handleFieldChange = ( - field: keyof SchedulePickerFormValues, - fieldValue: string, - ) => { - const newValue = { ...value, [field]: fieldValue }; - setValue(newValue); - onChange?.(mapToSchedule(newValue)); - }; - - return ( -
- - - - - {(!hideDayOfWeekIfDaily || value.schedule !== "daily") && ( - - - - - )} -
- -
- - - - : - - - - - - -
-
-
- ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/settings/page.tsx b/apps/web/app/(app)/[emailAccountId]/settings/page.tsx index 9d39113208..707cfa0512 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/page.tsx @@ -9,18 +9,12 @@ import { WebhookSection } from "@/app/(app)/[emailAccountId]/settings/WebhookSec import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TabsToolbar } from "@/components/TabsToolbar"; import { ResetAnalyticsSection } from "@/app/(app)/[emailAccountId]/settings/ResetAnalyticsSection"; -import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; -import { LoadingContent } from "@/components/LoadingContent"; -import { DigestMailFrequencySection } from "@/app/(app)/[emailAccountId]/settings/DigestMailFrequencySection"; -import { useDigestEnabled } from "@/hooks/useFeatureFlags"; import { BillingSection } from "@/app/(app)/[emailAccountId]/settings/BillingSection"; import { SectionDescription } from "@/components/Typography"; +import { useAccount } from "@/providers/EmailAccountProvider"; -export default function SettingsPage(_props: { - params: Promise<{ emailAccountId: string }>; -}) { - const { data, isLoading, error, mutate } = useEmailAccountFull(); - const digestEnabled = useDigestEnabled(); +export default function SettingsPage() { + const { emailAccount } = useAccount(); return ( @@ -45,31 +39,24 @@ export default function SettingsPage(_props: { - - {data && ( - - - - Settings for {data.email} - - + {emailAccount && ( + + + + Settings for {emailAccount?.email} + + - {/* this is only used in Gmail when sending a new message. disabling for now. */} - {/* */} - {/* + + {/* this is only used in Gmail when sending a new message. disabling for now. */} + {/* */} + {/* */} - {digestEnabled && ( - - )} - - - )} - + + )} ); diff --git a/apps/web/app/api/user/digest-schedule/route.ts b/apps/web/app/api/user/digest-schedule/route.ts new file mode 100644 index 0000000000..ddcd64d24e --- /dev/null +++ b/apps/web/app/api/user/digest-schedule/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { withEmailAccount } from "@/utils/middleware"; +import prisma from "@/utils/prisma"; + +export type GetDigestScheduleResponse = Awaited< + ReturnType +>; + +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + + const result = await getDigestSchedule({ emailAccountId }); + return NextResponse.json(result); +}); + +async function getDigestSchedule({ + emailAccountId, +}: { + emailAccountId: string; +}) { + const schedule = await prisma.schedule.findUnique({ + where: { emailAccountId }, + select: { + id: true, + intervalDays: true, + occurrences: true, + daysOfWeek: true, + timeOfDay: true, + lastOccurrenceAt: true, + nextOccurrenceAt: true, + }, + }); + + return schedule; +} diff --git a/apps/web/hooks/useFeatureFlags.ts b/apps/web/hooks/useFeatureFlags.ts index 34320d3305..36e7454d87 100644 --- a/apps/web/hooks/useFeatureFlags.ts +++ b/apps/web/hooks/useFeatureFlags.ts @@ -39,6 +39,7 @@ export function usePricingFrequencyDefault() { } export function useDigestEnabled() { return useFeatureFlagEnabled("digest-emails"); + // return true; } export type TestimonialsVariant = "control" | "senja-widget"; diff --git a/apps/web/utils/actions/settings.ts b/apps/web/utils/actions/settings.ts index 11de848904..e6f85c56b2 100644 --- a/apps/web/utils/actions/settings.ts +++ b/apps/web/utils/actions/settings.ts @@ -1,19 +1,18 @@ "use server"; -import { z } from "zod"; import { actionClient } from "@/utils/actions/safe-action"; import { saveAiSettingsBody, saveEmailUpdateSettingsBody, saveDigestScheduleBody, - updateDigestCategoriesBody, + updateDigestItemsBody, } from "@/utils/actions/settings.validation"; import { DEFAULT_PROVIDER } from "@/utils/llms/config"; import prisma from "@/utils/prisma"; import { calculateNextScheduleDate } from "@/utils/schedule"; import { actionClientUser } from "@/utils/actions/safe-action"; -import { SafeError } from "@/utils/error"; -import { SystemType, ActionType } from "@prisma/client"; +import { ActionType, type Prisma } from "@prisma/client"; +import { createScopedLogger } from "@/utils/logger"; export const updateEmailSettingsAction = actionClient .metadata({ name: "updateEmailSettings" }) @@ -54,184 +53,84 @@ export const updateAiSettingsAction = actionClientUser export const updateDigestScheduleAction = actionClient .metadata({ name: "updateDigestSchedule" }) .schema(saveDigestScheduleBody) - .action(async ({ ctx: { emailAccountId }, parsedInput: { schedule } }) => { - try { - // Check if email account exists - const emailAccount = await prisma.emailAccount.findUnique({ - where: { id: emailAccountId }, - select: { id: true }, - }); - - if (!emailAccount) { - return { serverError: "Email account not found" }; - } - - if (schedule) { - // Create or update the Schedule - await prisma.schedule.upsert({ - where: { - emailAccountId, - }, - create: { - emailAccountId, - intervalDays: schedule.intervalDays, - daysOfWeek: schedule.daysOfWeek, - timeOfDay: schedule.timeOfDay, - occurrences: schedule.occurrences, - lastOccurrenceAt: new Date(), - nextOccurrenceAt: calculateNextScheduleDate(schedule), - }, - update: { - intervalDays: schedule.intervalDays, - daysOfWeek: schedule.daysOfWeek, - timeOfDay: schedule.timeOfDay, - occurrences: schedule.occurrences, - lastOccurrenceAt: new Date(), - nextOccurrenceAt: calculateNextScheduleDate(schedule), - }, - }); - } else { - // If schedule is null, delete the existing schedule if it exists - await prisma.schedule.deleteMany({ - where: { emailAccountId }, - }); - } - - return { success: true }; - } catch (error) { - throw new SafeError("Failed to update settings", 500); - } + .action(async ({ ctx: { emailAccountId }, parsedInput }) => { + const { intervalDays, daysOfWeek, timeOfDay, occurrences } = parsedInput; + + const create: Prisma.ScheduleUpsertArgs["create"] = { + emailAccountId, + intervalDays, + daysOfWeek, + timeOfDay, + occurrences, + lastOccurrenceAt: new Date(), + nextOccurrenceAt: calculateNextScheduleDate(parsedInput), + }; + + // remove emailAccountId for update + const { emailAccountId: _emailAccountId, ...update } = create; + + await prisma.schedule.upsert({ + where: { emailAccountId }, + create, + update, + }); + + return { success: true }; }); -export const updateDigestCategoriesAction = actionClient - .metadata({ name: "updateDigestCategories" }) - .schema(updateDigestCategoriesBody) +export const updateDigestItemsAction = actionClient + .metadata({ name: "updateDigestItems" }) + .schema(updateDigestItemsBody) .action( async ({ ctx: { emailAccountId }, - parsedInput: { - toReply, - newsletter, - marketing, - calendar, - receipt, - notification, - coldEmail, - }, + parsedInput: { ruleDigestPreferences }, }) => { - const promises: Promise[] = []; - - // Update cold email digest setting - if (coldEmail !== undefined) { - promises.push( - prisma.emailAccount.update({ - where: { id: emailAccountId }, - data: { coldEmailDigest: coldEmail }, - }), - ); - } - - // Update rule digest settings - const systemTypeMap = { - toReply: SystemType.TO_REPLY, - newsletter: SystemType.NEWSLETTER, - marketing: SystemType.MARKETING, - calendar: SystemType.CALENDAR, - receipt: SystemType.RECEIPT, - notification: SystemType.NOTIFICATION, - }; - - for (const [key, systemType] of Object.entries(systemTypeMap)) { - const value = { - toReply, - newsletter, - marketing, - calendar, - receipt, - notification, - }[key as keyof typeof systemTypeMap]; + const logger = createScopedLogger("updateDigestItems").with({ + emailAccountId, + }); - if (value !== undefined) { - const promise = async () => { - const rule = await prisma.rule.findUnique({ + const promises = Object.entries(ruleDigestPreferences).map( + async ([ruleId, enabled]) => { + // Verify the rule belongs to this email account + const rule = await prisma.rule.findUnique({ + where: { + id: ruleId, + emailAccountId, + }, + select: { id: true, actions: true }, + }); + + if (!rule) { + logger.error("Rule not found", { ruleId }); + return; + } + + const hasDigestAction = rule.actions.some( + (action) => action.type === ActionType.DIGEST, + ); + + if (enabled && !hasDigestAction) { + // Add DIGEST action + await prisma.action.create({ + data: { + ruleId: rule.id, + type: ActionType.DIGEST, + }, + }); + } else if (!enabled && hasDigestAction) { + // Remove DIGEST action + await prisma.action.deleteMany({ where: { - emailAccountId_systemType: { - emailAccountId, - systemType, - }, + ruleId: rule.id, + type: ActionType.DIGEST, }, - select: { id: true, actions: true }, }); - - if (!rule) return; - - const hasDigestAction = rule.actions.some( - (action) => action.type === ActionType.DIGEST, - ); - - if (value && !hasDigestAction) { - // Add DIGEST action - await prisma.action.create({ - data: { - ruleId: rule.id, - type: ActionType.DIGEST, - }, - }); - } else if (!value && hasDigestAction) { - // Remove DIGEST action - await prisma.action.deleteMany({ - where: { - ruleId: rule.id, - type: ActionType.DIGEST, - }, - }); - } - }; - - promises.push(promise()); - } - } + } + }, + ); await Promise.all(promises); return { success: true }; }, ); - -export const ensureDefaultDigestScheduleAction = actionClient - .metadata({ name: "ensureDefaultDigestSchedule" }) - .schema(z.object({ timeOfDay: z.date() })) - .action(async ({ ctx: { emailAccountId }, parsedInput: { timeOfDay } }) => { - try { - // Check if user already has a digest schedule - const existingSchedule = await prisma.schedule.findUnique({ - where: { emailAccountId }, - select: { id: true }, - }); - - // If no schedule exists, create a default one - if (!existingSchedule) { - const defaultSchedule = { - intervalDays: 7, - daysOfWeek: 1 << (6 - 1), // Monday (bit 5 set = Monday) - timeOfDay: timeOfDay, // Use provided date/time from user to set the accurate users timezone - occurrences: 1, - }; - - await prisma.schedule.create({ - data: { - emailAccountId, - intervalDays: defaultSchedule.intervalDays, - daysOfWeek: defaultSchedule.daysOfWeek, - timeOfDay: defaultSchedule.timeOfDay, - occurrences: defaultSchedule.occurrences, - lastOccurrenceAt: new Date(), - nextOccurrenceAt: calculateNextScheduleDate(defaultSchedule), - }, - }); - } - - return { success: true }; - } catch (error) { - throw new SafeError("Failed to ensure digest schedule", 500); - } - }); diff --git a/apps/web/utils/actions/settings.validation.ts b/apps/web/utils/actions/settings.validation.ts index b2d8cf122c..feaf9cbe92 100644 --- a/apps/web/utils/actions/settings.validation.ts +++ b/apps/web/utils/actions/settings.validation.ts @@ -2,14 +2,12 @@ import { z } from "zod"; import { Frequency } from "@prisma/client"; import { DEFAULT_PROVIDER, Provider } from "@/utils/llms/config"; -const scheduleSchema = z.object({ +export const saveDigestScheduleBody = z.object({ intervalDays: z.number().nullable(), daysOfWeek: z.number().nullable(), timeOfDay: z.coerce.date().nullable(), occurrences: z.number().nullable(), }); - -export const saveDigestScheduleBody = z.object({ schedule: scheduleSchema }); export type SaveDigestScheduleBody = z.infer; export const saveEmailUpdateSettingsBody = z.object({ @@ -20,7 +18,6 @@ export const saveEmailUpdateSettingsBody = z.object({ Frequency.WEEKLY, Frequency.NEVER, ]), - schedule: scheduleSchema.nullable(), }); export type SaveEmailUpdateSettingsBody = z.infer< typeof saveEmailUpdateSettingsBody @@ -51,15 +48,7 @@ export const saveAiSettingsBody = z }); export type SaveAiSettingsBody = z.infer; -export const updateDigestCategoriesBody = z.object({ - toReply: z.boolean().optional(), - newsletter: z.boolean().optional(), - marketing: z.boolean().optional(), - calendar: z.boolean().optional(), - receipt: z.boolean().optional(), - notification: z.boolean().optional(), - coldEmail: z.boolean().optional(), +export const updateDigestItemsBody = z.object({ + ruleDigestPreferences: z.record(z.string(), z.boolean()), }); -export type UpdateDigestCategoriesBody = z.infer< - typeof updateDigestCategoriesBody ->; +export type UpdateDigestItemsBody = z.infer; diff --git a/apps/web/utils/schedule.test.ts b/apps/web/utils/schedule.test.ts index 2efc297109..de79285aa5 100644 --- a/apps/web/utils/schedule.test.ts +++ b/apps/web/utils/schedule.test.ts @@ -3,6 +3,9 @@ import { createCanonicalTimeOfDay, calculateNextScheduleDate, DAYS, + dayOfWeekToBitmask, + bitmaskToDayOfWeek, + bitmaskToDaysOfWeek, } from "./schedule"; describe("createCanonicalTimeOfDay", () => { @@ -53,6 +56,118 @@ describe("DAYS constant", () => { }); }); +describe("dayOfWeekToBitmask", () => { + it("should convert JavaScript day of week to correct bitmask", () => { + expect(dayOfWeekToBitmask(0)).toBe(DAYS.SUNDAY); // 64 + expect(dayOfWeekToBitmask(1)).toBe(DAYS.MONDAY); // 32 + expect(dayOfWeekToBitmask(2)).toBe(DAYS.TUESDAY); // 16 + expect(dayOfWeekToBitmask(3)).toBe(DAYS.WEDNESDAY); // 8 + expect(dayOfWeekToBitmask(4)).toBe(DAYS.THURSDAY); // 4 + expect(dayOfWeekToBitmask(5)).toBe(DAYS.FRIDAY); // 2 + expect(dayOfWeekToBitmask(6)).toBe(DAYS.SATURDAY); // 1 + }); + + it("should throw error for invalid day values", () => { + expect(() => dayOfWeekToBitmask(-1)).toThrow( + "Invalid day of week: -1. Must be integer between 0 and 6.", + ); + expect(() => dayOfWeekToBitmask(7)).toThrow( + "Invalid day of week: 7. Must be integer between 0 and 6.", + ); + expect(() => dayOfWeekToBitmask(1.5)).toThrow( + "Invalid day of week: 1.5. Must be integer between 0 and 6.", + ); + }); +}); + +describe("bitmaskToDayOfWeek", () => { + it("should convert individual day bitmasks to JavaScript day of week", () => { + expect(bitmaskToDayOfWeek(DAYS.SUNDAY)).toBe(0); + expect(bitmaskToDayOfWeek(DAYS.MONDAY)).toBe(1); + expect(bitmaskToDayOfWeek(DAYS.TUESDAY)).toBe(2); + expect(bitmaskToDayOfWeek(DAYS.WEDNESDAY)).toBe(3); + expect(bitmaskToDayOfWeek(DAYS.THURSDAY)).toBe(4); + expect(bitmaskToDayOfWeek(DAYS.FRIDAY)).toBe(5); + expect(bitmaskToDayOfWeek(DAYS.SATURDAY)).toBe(6); + }); + + it("should return null for empty bitmask", () => { + expect(bitmaskToDayOfWeek(0)).toBeNull(); + }); + + it("should return first day when multiple days are set", () => { + // Sunday and Wednesday + expect(bitmaskToDayOfWeek(DAYS.SUNDAY | DAYS.WEDNESDAY)).toBe(0); + + // Monday and Friday + expect(bitmaskToDayOfWeek(DAYS.MONDAY | DAYS.FRIDAY)).toBe(1); + + // Tuesday, Thursday, Saturday + expect( + bitmaskToDayOfWeek(DAYS.TUESDAY | DAYS.THURSDAY | DAYS.SATURDAY), + ).toBe(2); + }); + + it("should handle all days set", () => { + const allDays = + DAYS.SUNDAY | + DAYS.MONDAY | + DAYS.TUESDAY | + DAYS.WEDNESDAY | + DAYS.THURSDAY | + DAYS.FRIDAY | + DAYS.SATURDAY; + expect(bitmaskToDayOfWeek(allDays)).toBe(0); // Should return Sunday (first day) + }); +}); + +describe("bitmaskToDaysOfWeek", () => { + it("should convert individual day bitmasks to array with single day", () => { + expect(bitmaskToDaysOfWeek(DAYS.SUNDAY)).toEqual([0]); + expect(bitmaskToDaysOfWeek(DAYS.MONDAY)).toEqual([1]); + expect(bitmaskToDaysOfWeek(DAYS.TUESDAY)).toEqual([2]); + expect(bitmaskToDaysOfWeek(DAYS.WEDNESDAY)).toEqual([3]); + expect(bitmaskToDaysOfWeek(DAYS.THURSDAY)).toEqual([4]); + expect(bitmaskToDaysOfWeek(DAYS.FRIDAY)).toEqual([5]); + expect(bitmaskToDaysOfWeek(DAYS.SATURDAY)).toEqual([6]); + }); + + it("should return empty array for empty bitmask", () => { + expect(bitmaskToDaysOfWeek(0)).toEqual([]); + }); + + it("should return all days when multiple days are set", () => { + // Sunday and Wednesday + expect(bitmaskToDaysOfWeek(DAYS.SUNDAY | DAYS.WEDNESDAY)).toEqual([0, 3]); + + // Monday, Wednesday, Friday + expect( + bitmaskToDaysOfWeek(DAYS.MONDAY | DAYS.WEDNESDAY | DAYS.FRIDAY), + ).toEqual([1, 3, 5]); + + // Weekend days + expect(bitmaskToDaysOfWeek(DAYS.SATURDAY | DAYS.SUNDAY)).toEqual([0, 6]); + }); + + it("should handle all days set", () => { + const allDays = + DAYS.SUNDAY | + DAYS.MONDAY | + DAYS.TUESDAY | + DAYS.WEDNESDAY | + DAYS.THURSDAY | + DAYS.FRIDAY | + DAYS.SATURDAY; + expect(bitmaskToDaysOfWeek(allDays)).toEqual([0, 1, 2, 3, 4, 5, 6]); + }); + + it("should return days in order from Sunday to Saturday", () => { + // Mixed order input should return ordered output + const mixedDays = DAYS.FRIDAY | DAYS.TUESDAY | DAYS.SUNDAY | DAYS.THURSDAY; + expect(bitmaskToDaysOfWeek(mixedDays)).toEqual([0, 2, 4, 5]); // Sunday, Tuesday, Thursday, Friday + }); +}); + describe("calculateNextScheduleDate", () => { describe("null/undefined inputs", () => { it("should return null for null frequency", () => { diff --git a/apps/web/utils/schedule.ts b/apps/web/utils/schedule.ts index a18eae7fae..b895601ea2 100644 --- a/apps/web/utils/schedule.ts +++ b/apps/web/utils/schedule.ts @@ -38,6 +38,78 @@ export const DAYS = { */ const maskFor = (jsDay: number) => 1 << (6 - jsDay); +/** + * Converts a JavaScript day of week (0-6, Sunday-Saturday) to its corresponding bitmask. + * This is a public version of the internal maskFor function. + * + * @param jsDay - JavaScript day of week (0 = Sunday, 6 = Saturday) + * @returns The bitmask for the given day + * @throws Error if jsDay is not between 0 and 6 + * + * @example + * // Convert Sunday (0) to bitmask + * const sundayMask = dayOfWeekToBitmask(0); // Returns 64 (0b1000000) + * + * // Convert Wednesday (3) to bitmask + * const wednesdayMask = dayOfWeekToBitmask(3); // Returns 8 (0b0001000) + */ +export function dayOfWeekToBitmask(jsDay: number): number { + if (jsDay < 0 || jsDay > 6 || !Number.isInteger(jsDay)) { + throw new Error( + `Invalid day of week: ${jsDay}. Must be integer between 0 and 6.`, + ); + } + return maskFor(jsDay); +} + +/** + * Converts a bitmask back to the first JavaScript day of week (0-6, Sunday-Saturday) it represents. + * If multiple days are set in the bitmask, returns the first one found (Sunday first). + * + * @param bitmask - The days of week bitmask + * @returns The first JavaScript day of week (0-6), or null if no days are set + * + * @example + * // Convert Sunday bitmask to JS day + * const day = bitmaskToDayOfWeek(64); // Returns 0 (Sunday) + * + * // Convert Wednesday bitmask to JS day + * const day = bitmaskToDayOfWeek(8); // Returns 3 (Wednesday) + * + * // Multiple days set - returns first one + * const day = bitmaskToDayOfWeek(64 | 8); // Returns 0 (Sunday, first day found) + */ +export function bitmaskToDayOfWeek(bitmask: number): number | null { + if (bitmask === 0) return null; + + for (let jsDay = 0; jsDay < 7; jsDay++) { + if (bitmask & maskFor(jsDay)) { + return jsDay; + } + } + return null; +} + +/** + * Gets all JavaScript days of week (0-6, Sunday-Saturday) represented in a bitmask. + * + * @param bitmask - The days of week bitmask + * @returns Array of JavaScript day numbers (0-6) that are set in the bitmask + * + * @example + * // Get all days from a bitmask with multiple days + * const days = bitmaskToDaysOfWeek(64 | 8); // Returns [0, 3] (Sunday and Wednesday) + */ +export function bitmaskToDaysOfWeek(bitmask: number): number[] { + const days: number[] = []; + for (let jsDay = 0; jsDay < 7; jsDay++) { + if (bitmask & maskFor(jsDay)) { + days.push(jsDay); + } + } + return days; +} + /** * Calculates the next occurrence date based on schedule settings. * diff --git a/version.txt b/version.txt index bb667f9c3e..4e0d755440 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v1.8.5 +v1.8.6