diff --git a/README.md b/README.md index 5ec94db60..c8905fa0d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Install: `bb plugin install git:https://github.com/brsbl/bb-plugins.git@plugin/d Delivers private briefings as threads with numbered priorities, review buttons, and collapsed routine details. Add or edit a prompt-based brief under the site it reads in Settings. Connections reuse existing bb Browser sign-ins; Reading remembers summarized newsletters without marking them read. Other threads can publish their own briefs. -![A brief with numbered priorities and review buttons](https://github.com/user-attachments/assets/6d6f4f82-9f51-475a-b5e7-356a1e43459f) +![A sample Morning brief: one reply needs you, with a Review reply button, the counts, and the No action needed table expanded](plugins/digests/docs/brief.png) [Source](plugins/digests) · [README](plugins/digests/README.md) diff --git a/plugins/digests/PLUGIN_OVERVIEW.md b/plugins/digests/PLUGIN_OVERVIEW.md index ea4fdb556..0c345a70f 100644 --- a/plugins/digests/PLUGIN_OVERVIEW.md +++ b/plugins/digests/PLUGIN_OVERVIEW.md @@ -1,16 +1,48 @@ -# Briefs - -Private briefings that arrive as ordinary bb threads. Each brief opens with a -clear headline, numbered priorities and buttons that open the page where each -task gets done, with its domain shown. Routine detail stays collapsed. Move or archive briefs yourself when done. - -Add a digest under a signed-in site in Settings: give it a name, describe what -it should tell you, and choose a schedule. Existing starter definitions for -Unread email, Money and Reading stay disabled until migration is confirmed. -X scorecards can be published by another thread with `bb digest publish`. - -Uses existing bb Browser sign-ins; import opens bb’s own Browser settings. -Failures show Retry or Reconnect. Each email appears in one brief only: Money -gets money mail, Reading gets newsletters, and copies in several inboxes -collapse into one row, all without marking Gmail read. Collection is read-only; only an approved Inline -Action Card click authorizes an external write. +## Your scheduled briefing + +Choose what you want to know and when. Each run arrives in a Briefs inbox as +an ordinary bb thread with a headline, numbered priorities, buttons that open +the page where each task gets done with its domain shown, and collapsible +routine details. Move or archive it when finished; thread search +keeps earlier briefs findable. + +Add a brief under a signed-in site in Settings, describe what it should tell +you, and choose a schedule. Run it immediately to preview the result. Starter +recipes cover unread email, money, and reading; another thread can also publish +a prepared report. + +## Gmail read state and recovery + +Opening an email can mark it read. **Keep unread**, the default, instructs the +collecting agent to restore each originally unread message immediately and +verify the result before continuing. **Mark as read** leaves opened messages +read. Messages that were already read stay read in both modes. + +A saved per-message journal retains the original state across interruptions. +Unverified or failed restoration stays visible in the brief; Retry instructs +the agent to repair those records before collecting more. The journal records +the agent's browser observations; it does not operate Gmail itself. +Each email appears in one brief only: Money gets money mail, Reading gets +newsletters, and copies in several inboxes collapse into one row. Successfully +summarized message IDs are remembered to avoid repeats. + +Collection must not send, reply, archive, delete, or change labels or settings. +Restoring unread state is its only permitted explicit Gmail write. Other sites +are read-only. Task buttons open the page where you can finish the task; +Open email returns to the original message. Opening a button performs no write. + +## Requirements and privacy + +Install **Automations**, **Browser Automation**, and **Thread Organizer** with +additional-inbox support. Browser collection requires **bb 0.45.0 or later** +on the browser desktop and connected server, a connected browser computer, +and a configured agent provider and model. Runs use that provider's account +and usage allowance. Sign in to your chosen sources—Gmail, X, or LinkedIn—in +BB Browser; Briefs reuses those sign-ins in fresh tabs without copying cookies. +Import opens bb’s own Browser settings. + +Signed-out connections show Reconnect. Unavailable or interrupted runs show +Retry. Brief content, definitions, read-state journals, and processed message +IDs stay in plugin-owned storage on your bb server. Content read by the agent +is sent to your configured provider. Optional **Inline Decisions** adds +approval cards for proposed actions; without it, proposals remain plain text. diff --git a/plugins/digests/README.md b/plugins/digests/README.md index 55867a4e9..986f80699 100644 --- a/plugins/digests/README.md +++ b/plugins/digests/README.md @@ -4,13 +4,34 @@ Private briefings delivered as threads: one clear headline, a few numbered items with buttons that open where each task gets done, each email in one brief only, and a flat “No action needed” email table collapsed. Briefs arrive in a Briefs inbox. Archive them when done and find old briefs with thread search. -![A brief with numbered priorities and review buttons](https://github.com/user-attachments/assets/6d6f4f82-9f51-475a-b5e7-356a1e43459f) +![A sample Morning brief thread: one reply needs you, with a Review reply button, the counts, and the No action needed table expanded](docs/brief.png) + +![Settings → Briefs with a Gmail site on an example account and a disabled weekday Morning brief](docs/settings.png) + +![Editing the Morning brief: its prompt, weekday schedule and the After reading: Keep unread selector](docs/settings-edit.png) + +## Requirements + +- **Automations** schedules and dispatches brief runs. +- **Browser Automation** lets the collecting agent read fresh, brief-owned tabs + using your BB Browser sign-ins. +- **Thread Organizer** must support additional inboxes (`role: "inbox"` and + `catchesPluginId`) to create and route the Briefs inbox. +- **bb 0.45.0 or later** on both the browser desktop and connected server is + required for collection. Keep the browser computer connected and configure + an agent provider/model; runs use that provider's account and usage allowance. +- Sign in to each source you choose (Gmail, X, or LinkedIn) in BB Browser. + Briefs does not supply service accounts or copy cookies. + +Install the required plugins before setting up Briefs. The current plugin +manifest contract has no field for declaring dependencies on other plugins; +these requirements are checked through the setup and connection flows. +Inline Decisions is optional for proposals that need your approval. ## Install -```bash -bb plugin install git:https://github.com/brsbl/bb-plugins.git@plugin/digests --yes -``` +Install **Briefs** from the Community marketplace once listed, or use its +published Git install ref with `bb plugin install`. ## Use @@ -26,13 +47,16 @@ are never adopted or changed. | Starter brief | Default time | | --- | --- | -| Unread email | Weekdays, 10am PT | -| Money | Monday, 10am PT | -| Reading | Sunday, 11am PT | +| Unread email | Weekdays, 10am | +| Money | Monday, 10am | +| Reading | Sunday, 11am | | X scorecard | Published by another thread | -Requires Automations, Browser Automation, and Thread Organizer with support for -additional inboxes. Setup creates a Briefs inbox that catches this plugin’s +Starter setup uses the bb server’s system timezone. New briefs created in +Settings use your browser’s timezone, shown beside the time. Saved prompts and +schedules keep their existing values. Edit starter prompts to add your interests. + +Setup creates a Briefs inbox that catches this plugin’s briefs. Read and unread briefs stay there until you move or archive them. Browser collection requires **bb 0.45.0 or later** on the browser desktop and @@ -68,6 +92,16 @@ bb plugin install "path:$PWD/plugins/digests" --yes ``` CI runs `npm run check --workspace=bb-plugin-digests` remotely. Use an isolated -bb development app with fixture accounts for browser and screenshot verification. +bb development app with synthetic fixtures for browser and screenshot verification. Digest cards keep the name prominent, with a muted schedule at the bottom-left opposite Run now. Edit your own prompt in place; collection rules and Gmail searches live in the runtime, never in the prompt preview. A small emoji field beside the name controls the prefix on new brief thread titles. Saved definitions fall back to their starter emoji when none is set. + +## Verification boundary + +`service.test.ts` uses mocked Browser Automation, desktop tabs, and Automations +with a synthetic mailbox to cover both after-reading modes, mixed-state reply +chains, failed restoration, and interrupted runs. The journal stores what the +agent reports; it does not operate Gmail or independently verify a click. +The collecting agent must restore and verify message state through its browser +session. These tests do not prove compatibility with Gmail's current live UI. +Never use a personal mailbox, browser profile, or account for release QA. diff --git a/plugins/digests/app.css b/plugins/digests/app.css index e8d0e85c6..4a91f9d05 100644 --- a/plugins/digests/app.css +++ b/plugins/digests/app.css @@ -113,18 +113,23 @@ .digest-later ul { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-lg, 8px); overflow: hidden; } .digest-cards > li + li, .digest-later li + li { border-top: 1px solid var(--border); } -.digest-card { display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; align-items: center; column-gap: 12px; padding: 10px 12px; } +.digest-cards { container-type: inline-size; } +.digest-card { display: grid; grid-template-columns: 8px minmax(12rem, 1fr) minmax(0, 1fr); align-items: center; gap: 8px 12px; padding: 10px 12px; } .digest-card-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--digest-accent, var(--muted-foreground)); } .digest-card[data-urgency="later"] .digest-card-dot { background: transparent; box-shadow: inset 0 0 0 1.5px var(--digest-accent, var(--muted-foreground)); } .digest-card-content { min-width: 0; } .digest-card-content h4 { margin: 0; color: var(--foreground); font-size: 14px; font-weight: 500; line-height: 20px; } .digest-card-content p { margin: 0; overflow: hidden; color: var(--muted-foreground); font-size: 13px; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; } -.digest-card-meta { display: flex; align-items: center; gap: 8px; flex-wrap: nowrap; } +.digest-card-meta { display: flex; min-width: 0; align-items: center; gap: 8px; flex-wrap: wrap; } .digest-chip, -.digest-urgency { padding: 0; background: none; color: var(--muted-foreground); font-size: 12px; font-weight: 400; line-height: 18px; white-space: nowrap; } +.digest-urgency { padding: 0; background: none; color: var(--muted-foreground); font-size: 12px; font-weight: 400; line-height: 18px; white-space: normal; } /* Mixed toward foreground so deadlines keep 4.5:1 on tinted Ambient surfaces. */ [data-urgency="today"] .digest-urgency { color: color-mix(in oklab, var(--warning-text, var(--warning)) 75%, var(--foreground)); background: none; } -.digest-card-meta .digest-button { height: 26px; min-height: 26px; padding: 0 10px; } +.digest-card-meta .digest-button { max-width: 100%; height: auto; min-height: 26px; padding: 3px 10px; white-space: normal; } +@container (max-width: 28rem) { + .digest-card { grid-template-columns: 8px minmax(0, 1fr); } + .digest-card-meta { grid-column: 2; } +} .digest-later li { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 40px; padding: 6px 6px 6px 12px; color: var(--foreground); font-size: 13px; line-height: 18px; } .digest-later .digest-button { flex: none; color: var(--muted-foreground); } .digest-later .digest-button:hover:not(:disabled) { color: var(--foreground); } diff --git a/plugins/digests/app.test.tsx b/plugins/digests/app.test.tsx index c87f2e039..f94509c00 100644 --- a/plugins/digests/app.test.tsx +++ b/plugins/digests/app.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { cleanup, fireEvent, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; const message = { id: "msg_1", threadId: "thr_issue", turnId: "turn_1", projectId: "proj_1" }; @@ -35,7 +35,7 @@ const definition = { automationId: null, }; -afterEach(cleanup); +afterEach(() => { cleanup(); vi.restoreAllMocks(); }); describe("Digests app", () => { it("shows the newsletter immediately with safe rich text and optional more detail", async () => { @@ -45,7 +45,7 @@ describe("Digests app", () => { const slot = renderSlot(app.messageDirectives[0]!, directiveProps, { rpc: { getIssue: () => ({ ...readyIssue, lede: "A quiet weekend: **14 new emails**, but only **2 need you**. Google flagged a sign-in to ==confirm today==.", - details: "## Reply to\n\n[**Ada Example**](https://example.test/ada) :chip[Example Co] suggested **Thursday at 3pm**.\n\n**[Review reply](https://example.test/reply)**\n\n## The rest\n\nYour scorecard gained :gain[+12 followers]. An [unsafe link](javascript:alert%281%29) stays inert.\n\n\n\n\n\nEarlier context: `:chip[not a chip]`.", + details: "## Reply to\n\n[**Example Sender**](https://example.test/sender) :chip[Example Co] suggested **Thursday at 3pm**.\n\n**[Review reply](https://example.test/reply)**\n\n## The rest\n\nYour scorecard gained :gain[+12 followers]. An [unsafe link](javascript:alert%281%29) stays inert.\n\n\n\n\n\nEarlier context: `:chip[not a chip]`.", }) }, }); expect(await slot.findByRole("heading", { name: readyIssue.headline })).toBeDefined(); @@ -54,7 +54,7 @@ describe("Digests app", () => { expect(slot.getByText("Example Co").className).toBe("digest-chip"); expect(slot.getByText("+12 followers").className).toBe("digest-gain"); expect(slot.getByRole("heading", { name: "Reply to" }).closest("details")).toBeNull(); - expect(slot.getByRole("link", { name: "Ada Example" }).getAttribute("href")).toBe("https://example.test/ada"); + expect(slot.getByRole("link", { name: "Example Sender" }).getAttribute("href")).toBe("https://example.test/sender"); expect(slot.getByRole("link", { name: "Review reply" }).closest("p")?.className).toBe("digest-item-actions"); expect(slot.queryByRole("link", { name: "unsafe link" })).toBeNull(); expect(slot.container.querySelector("script, dl, time")).toBeNull(); @@ -171,7 +171,7 @@ describe("Digests app", () => { it("retries the failed digest from its persisted Settings error without enabling it", async () => { const app = await loadPluginApp(() => import("./app.js")); const slot = renderSlot(app.settingsSections[0]!, {}, { rpc: { - overview: () => ({ definitions: [definition], connections: [{ id: "gmail", name: "Gmail", status: "unknown" }], organizerReady: true, actionCardsAvailable: false, runErrors: { reading: "My Mac is offline. Open bb on that computer, then Retry." } }), + overview: () => ({ definitions: [definition], connections: [{ id: "gmail", name: "Gmail", status: "unknown" }], organizerReady: true, actionCardsAvailable: false, runErrors: { reading: "Fixture computer is offline. Open bb on that computer, then Retry." } }), run: () => ({ threadId: "thr_retry" }), } }); fireEvent.click(await slot.findByRole("button", { name: "Retry" })); @@ -239,6 +239,10 @@ describe("Digests app", () => { it("creates a prompt digest at 10am and edits the same nested row", async () => { const app = await loadPluginApp(() => import("./app.js")); + const resolved = Intl.DateTimeFormat.prototype.resolvedOptions; + vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockImplementation(function (this: Intl.DateTimeFormat) { + return { ...resolved.call(this), timeZone: "Europe/Paris" }; + }); let definitions: typeof definition[] = []; const slot = renderSlot(app.settingsSections[0]!, {}, { rpc: { overview: () => ({ definitions, connections: [{ id: "gmail", name: "Gmail", status: "signed-in" }], organizerReady: true, actionCardsAvailable: false }), @@ -249,6 +253,7 @@ describe("Digests app", () => { }, } }); fireEvent.click(await slot.findByRole("button", { name: "+ Add brief" })); + expect(slot.getByText("Europe/Paris")).toBeDefined(); const name = slot.getByLabelText("Name"); expect(document.activeElement).toBe(name); expect(slot.getByLabelText("Emoji")).toBeDefined(); @@ -257,7 +262,7 @@ describe("Digests app", () => { fireEvent.change(slot.getByLabelText("What should it tell you?"), { target: { value: "Only messages that need a reply." } }); fireEvent.click(slot.getByRole("button", { name: "Create brief" })); expect(await slot.findByText("Run now to preview")).toBeDefined(); - expect(slot.inspection.rpcCalls).toContainEqual({ method: "saveDigest", input: { name: "My inbox", emoji: "📰", instructions: "Only messages that need a reply.", connectionId: "gmail", afterReading: "keep-unread", schedule: { cron: "0 10 * * 1-5", timezone: "America/Los_Angeles" } } }); + expect(slot.inspection.rpcCalls).toContainEqual({ method: "saveDigest", input: { name: "My inbox", emoji: "📰", instructions: "Only messages that need a reply.", connectionId: "gmail", afterReading: "keep-unread", schedule: { cron: "0 10 * * 1-5", timezone: "Europe/Paris" } } }); expect(slot.getByText("Only messages that need a reply.").className).toContain("digest-prompt-preview"); expect(slot.getByRole("heading", { name: "My inbox" }).textContent).toBe("My inbox"); fireEvent.click(slot.getByRole("button", { name: "Edit My inbox" })); @@ -270,10 +275,29 @@ describe("Digests app", () => { expect(slot.inspection.rpcCalls.filter((call) => call.method === "saveDigest").at(-1)?.input).toMatchObject({ id: "digest-new", name: "Replies", emoji: "📮", afterReading: "mark-read" }); }); + it("keeps a saved timezone and prompt when editing on a computer in another timezone", async () => { + const resolved = Intl.DateTimeFormat.prototype.resolvedOptions; + vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockImplementation(function (this: Intl.DateTimeFormat) { + return { ...resolved.call(this), timeZone: "Asia/Tokyo" }; + }); + const app = await loadPluginApp(() => import("./app.js")); + const slot = renderSlot(app.settingsSections[0]!, {}, { rpc: { + overview: () => ({ definitions: [definition], connections: [{ id: "gmail", name: "Gmail", status: "signed-in" }], organizerReady: true, actionCardsAvailable: false }), + saveDigest: (input) => ({ ...definition, ...input as object }), + } }); + fireEvent.click(await slot.findByRole("button", { name: "Edit Reading" })); + expect(slot.getByText("PT")).toBeDefined(); + fireEvent.change(slot.getByLabelText("Name"), { target: { value: "Saved reading" } }); + fireEvent.click(slot.getByRole("button", { name: "Save changes" })); + await waitFor(() => expect(slot.inspection.rpcCalls.filter((call) => call.method === "saveDigest").at(-1)?.input).toMatchObject({ + id: definition.id, instructions: definition.instructions, schedule: definition.schedule, + })); + }); + it("shows source rows and a collapsed tail without performing account actions", async () => { const app = await loadPluginApp(() => import("./app.js")); const slot = renderSlot(app.messageDirectives[0]!, directiveProps, { rpc: { getIssue: () => ({ ...readyIssue, brief: { - heading: "Needs you", items: [{ title: "Reply to Ada", text: "Coffee Thursday at 3pm.", urgency: "today", action: { label: "Review reply", url: "https://example.test/reply" } }], + heading: "Needs you", items: [{ title: "Reply to Example Sender", text: "Coffee Thursday at 3pm.", urgency: "today", action: { label: "Review reply", url: "https://example.test/reply" } }], later: [], laterLabel: "Later", tail: { label: "12 routine emails", details: "Receipts and newsletters." }, } }) } }); const action = await slot.findByRole("button", { name: "Review reply" }); @@ -284,6 +308,31 @@ describe("Digests app", () => { expect(slot.inspection.navigateCalls).toContainEqual({ method: "openUrl", url: "https://example.test/reply" }); }); + it("keeps a card's title, long context, deadline and both source actions", async () => { + const app = await loadPluginApp(() => import("./app.js")); + const slot = renderSlot(app.messageDirectives[0]!, directiveProps, { rpc: { getIssue: () => ({ ...readyIssue, brief: { + heading: "Needs you", items: [{ + title: "Confirm Room B for the workshop", text: "The previous reply suggested Room A, which is unavailable.", + context: "The workshop team requests confirmation of Room B.", deadline: "Due Tuesday at 15:00", + secondaryAction: { label: "View workshop details", url: "https://example.test/workshop" }, + action: { label: "Review room request", url: "https://example.test/room" }, + }], later: [], laterLabel: "Later", + } }) } }); + const title = await slot.findByRole("heading", { name: "Confirm Room B for the workshop" }); + const card = title.closest("li")!; + expect(card.contains(slot.getByText("The workshop team requests confirmation of Room B."))).toBe(true); + expect(card.contains(slot.getByText("Due Tuesday at 15:00"))).toBe(true); + for (const label of ["View workshop details", "Review room request"]) { + const action = slot.getByRole("button", { name: label }); + expect(card.contains(action)).toBe(true); + fireEvent.click(action); + } + expect(slot.inspection.navigateCalls).toEqual([ + { method: "openUrl", url: "https://example.test/workshop" }, + { method: "openUrl", url: "https://example.test/room" }, + ]); + }); + it("opens one email section at a time from counts and preserves source links", async () => { const app = await loadPluginApp(() => import("./app.js")); const slot = renderSlot(app.messageDirectives[0]!, directiveProps, { rpc: { getIssue: () => ({ ...readyIssue, brief: { @@ -291,17 +340,17 @@ describe("Digests app", () => { heading: "Needs you", items: [], later: [], laterLabel: "Later", all: { label: "All unread (4)", items: [{ title: "Meeting", text: "Confirm Thursday.", url: "https://example.test/meeting" }] }, tail: { label: "Routine (2)", details: "Old Markdown fallback.", items: [ - { title: "Amex · Autopay processed", text: "Payment complete.", url: "https://example.test/amex" }, + { title: "Example Bank · Autopay processed", text: "Payment complete.", url: "https://example.test/bank" }, { title: "Newsletter · This week", text: "Product news.", url: "https://example.test/news" }, ] }, } }) } }); fireEvent.click(await slot.findByRole("link", { name: "2 need nothing from you" })); expect(slot.container.querySelector("details[id$='-tail']")?.open).toBe(true); expect(document.activeElement).toBe(slot.container.querySelector("details[id$='-tail'] > summary")); - fireEvent.click(slot.getByRole("link", { name: /Amex · Autopay processed/ })); + fireEvent.click(slot.getByRole("link", { name: /Example Bank · Autopay processed/ })); fireEvent.click(slot.getByRole("link", { name: "4 unread emails" })); expect(slot.inspection.navigateCalls).toEqual([ - { method: "openUrl", url: "https://example.test/amex" }, + { method: "openUrl", url: "https://example.test/bank" }, ]); expect(slot.getByText("All unread").closest("details")?.open).toBe(true); expect(slot.container.querySelector("details[id$='-tail']")?.open).toBe(false); @@ -311,13 +360,13 @@ describe("Digests app", () => { it("keeps repeat senders flat and preserves every source link and semantic accent", async () => { const app = await loadPluginApp(() => import("./app.js")); - const rows = ["Robinhood", "Figma", "robinhood", "Robinhood", "Figma"].map((sender, index) => ({ - title: `${sender} · Notice ${index}`, text: index === 2 ? "September statement is ready." : `Detail ${index}`, url: `https://example.test/mail/${index}`, + const rows = ["Example Finance", "Example Design", "example finance", "Example Finance", "Example Design"].map((sender, index) => ({ + title: `${sender} · Notice ${index}`, text: index === 2 ? "September statement is ready." : index === 3 ? "Investment account update." : `Detail ${index}`, url: `https://example.test/mail/${index}`, ...(index === 0 ? { kind: "receipt", receivedAt: 1791043200000 } : {}), })); const slot = renderSlot(app.messageDirectives[0]!, directiveProps, { rpc: { getIssue: () => ({ ...readyIssue, brief: { heading: "Needs you", items: [ - { title: "Reply to Ada", text: "Confirm Thursday.", action: { label: "Review reply", url: "https://example.test/reply" } }, + { title: "Reply to Example Sender", text: "Confirm Thursday.", action: { label: "Review reply", url: "https://example.test/reply" } }, { title: "Check a new sign-in", tone: "danger", text: "An unfamiliar device.", action: { label: "Review alert", url: "https://example.test/alert" } }, ], later: [], laterLabel: "Later", tail: { label: "Routine (5)", details: "", items: rows }, } }) } }); diff --git a/plugins/digests/app.tsx b/plugins/digests/app.tsx index 8264f0514..0961d2c2a 100644 --- a/plugins/digests/app.tsx +++ b/plugins/digests/app.tsx @@ -249,7 +249,7 @@ function emailKind(item: EmailRow): string | undefined { if (item.kind) return item.kind; const text = `${item.title} ${item.sender ?? ""} ${item.subject ?? ""} ${item.text}`; if (/\bstatements?\b/iu.test(text)) return "statement"; - if (/\b(dividend|trade confirmation|brokerage|investment|retirement|robinhood)\b/iu.test(text)) return "account"; + if (/\b(dividend|trade confirmation|brokerage|investment|retirement)\b/iu.test(text)) return "account"; if (/\b(receipt|payment (?:complete|completed|received)|order confirmation)\b/iu.test(text)) return "receipt"; if (/\b(bill|invoice|autopay|payment due|amount due)\b/iu.test(text)) return "bill"; if (/\b(shipp?ing|shipped|package|tracking|delivery)\b/iu.test(text)) return "shipping"; @@ -482,6 +482,7 @@ function DigestForm({ connection, definition, pending, onCancel, onSave }: { const simpleSchedule = day === "*" && month === "*" && DAYS.some(([value]) => value === weekday) && /^\d+$/u.test(hour) && /^\d+$/u.test(minute); const [frequency, setFrequency] = useState(simpleSchedule ? weekday : "custom"); const [time, setTime] = useState(`${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`); + const timezone = definition?.schedule?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; const publishOnly = !!definition && !definition.schedule; const times = Array.from({ length: 48 }, (_, index) => `${String(Math.floor(index / 2)).padStart(2, "0")}:${index % 2 ? "30" : "00"}`); if (!times.includes(time)) times.push(time); @@ -490,19 +491,19 @@ function DigestForm({ connection, definition, pending, onCancel, onSave }: { const [hours, minutes] = time.split(":"); void onSave({ ...(definition ? { id: definition.id } : {}), connectionId: connection.id, name, ...(emoji.trim() ? { emoji: emoji.trim() } : {}), instructions, ...(execution || definition?.execution ? { execution } : {}), ...(connection.id === "gmail" ? { afterReading } : {}), - schedule: publishOnly ? null : frequency === "custom" ? definition!.schedule : { cron: `${Number(minutes)} ${Number(hours)} * * ${frequency}`, timezone: definition?.schedule?.timezone ?? "America/Los_Angeles" } }); + schedule: publishOnly ? null : frequency === "custom" ? definition!.schedule : { cron: `${Number(minutes)} ${Number(hours)} * * ${frequency}`, timezone } }); }}>
-