Skip to content

Commit 41d3461

Browse files
committed
feat(ads): split analytics dashboard by channel and reuse shared date filter
Replace the single Ads analytics menu item with one per ads-eligible channel (Click-to-WhatsApp/Messenger/Instagram). Each lands on /dashboard/ads/<channel>, validated against adsEligibleChannelTypes (404 on anything else) and superAdmin- guarded; the channel-less route redirects to the requested channel when a stale ?channel= bookmark names a valid one, else the default. Per-channel integration resolution is a compile-time-exhaustive Record<AdsEligibleChannelType, ...> map, so adding a channel breaks the build rather than silently missing a case. Swap the ads-only date-range controls for the shared DateRangePresetFilter (refresh + preset dropdown + custom calendar), same position as Contacts/ Conversations, defaulting to Last 7 days. A URL bridge (useAdsRangeUrl) formats the picked local calendar day into the ?from=&to= keys and restores the active preset on load; the interim local-key-over-UTC-pipeline seam is documented, with the full timezone migration tracked in docs/plans. Fix an over-cap range (the Lifetime preset on an old workspace, or a manipulated URL) silently collapsing to 7 days: parseAnalyticsDateRange now clamps to the last 366 days, and workspaceCreatedAt floors Lifetime at workspace birth so its range stays under the cap and the preset label resolves correctly.
1 parent cb21920 commit 41d3461

43 files changed

Lines changed: 1300 additions & 864 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/builder/__tests__/ads-analytics-channel-filter-component.test.tsx renamed to apps/builder/__tests__/ads-account-filter.test.tsx

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
import { act, type ReactNode } from "react"
44
import { createRoot, type Root } from "react-dom/client"
55
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
6-
import { ChannelFilter } from "@/features/ads/components/channel-filter"
6+
import { AdsAccountFilter } from "@/features/ads/components/ads-account-filter"
77
import type { AdsAnalyticsSearchParams } from "@/features/ads/schemas/analytics"
88

99
vi.mock("next/navigation", () => ({
10-
usePathname: () => "/space/ws-1/dashboard/ads",
10+
usePathname: () => "/space/ws-1/dashboard/ads/messenger",
1111
useRouter: () => ({ push: vi.fn() }),
1212
useSearchParams: () => new URLSearchParams(),
1313
}))
@@ -34,9 +34,10 @@ const baseRange = {
3434
account: "",
3535
channelAccount: "",
3636
adAccount: "",
37-
} as Omit<AdsAnalyticsSearchParams, "channel">
37+
channel: "messenger",
38+
} as AdsAnalyticsSearchParams
3839

39-
describe("ChannelFilter'All channels' option and integration-select visibility", () => {
40+
describe("AdsAccountFilterintegration/account select only, no channel select", () => {
4041
let container: HTMLDivElement
4142
let root: Root
4243

@@ -54,31 +55,36 @@ describe("ChannelFilter — 'All channels' option and integration-select visibil
5455
container.remove()
5556
})
5657

57-
test("renders an 'All channels' option via the special label branch, not a fake tabs.all key", async () => {
58+
test("renders no channel select — the channel select trigger id is absent", async () => {
5859
await act(async () => {
5960
root.render(
60-
<ChannelFilter
61-
channelIntegrations={[]}
62-
range={{ ...baseRange, channel: "all" }}
63-
selectedIntegrationId={null}
61+
<AdsAccountFilter
62+
channelIntegrations={[{ id: "msg-1", name: "My Page" }]}
63+
range={baseRange}
64+
selectedIntegrationId="msg-1"
6465
/>,
6566
)
6667
await Promise.resolve()
6768
})
6869

69-
expect(container.textContent).toContain(
70+
expect(
71+
container.querySelector('[data-select-trigger="ads-analytics-channel"]'),
72+
).toBeNull()
73+
expect(container.textContent).not.toContain(
7074
"ads.analytics.channelFilter.allChannels",
7175
)
72-
expect(container.textContent).not.toContain("ads.conversionEvents.tabs.all")
76+
expect(container.textContent).not.toContain(
77+
"ads.analytics.channelFilter.label",
78+
)
7379
})
7480

75-
test("hides the integration select entirely when channel is 'all'", async () => {
81+
test("renders the integration/account select with the current channel's integrations", async () => {
7682
await act(async () => {
7783
root.render(
78-
<ChannelFilter
84+
<AdsAccountFilter
7985
channelIntegrations={[{ id: "msg-1", name: "My Page" }]}
80-
range={{ ...baseRange, channel: "all" }}
81-
selectedIntegrationId={null}
86+
range={baseRange}
87+
selectedIntegrationId="msg-1"
8288
/>,
8389
)
8490
await Promise.resolve()
@@ -88,16 +94,20 @@ describe("ChannelFilter — 'All channels' option and integration-select visibil
8894
container.querySelector(
8995
'[data-select-trigger="ads-analytics-channel-account"]',
9096
),
91-
).toBeNull()
97+
).not.toBeNull()
98+
expect(container.textContent).toContain("My Page")
99+
expect(container.textContent).toContain(
100+
"ads.analytics.channelFilter.allAccounts",
101+
)
92102
})
93103

94-
test("shows the integration select for a concrete channel", async () => {
104+
test("shows the integration select even with an empty integrations list (no 'all channels' hide branch anymore)", async () => {
95105
await act(async () => {
96106
root.render(
97-
<ChannelFilter
98-
channelIntegrations={[{ id: "msg-1", name: "My Page" }]}
99-
range={{ ...baseRange, channel: "messenger" }}
100-
selectedIntegrationId="msg-1"
107+
<AdsAccountFilter
108+
channelIntegrations={[]}
109+
range={baseRange}
110+
selectedIntegrationId={null}
101111
/>,
102112
)
103113
await Promise.resolve()

apps/builder/__tests__/ads-analytics-date-range-presets.test.ts

Lines changed: 0 additions & 43 deletions
This file was deleted.

apps/builder/__tests__/ads-analytics-date-range.test.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,26 @@ import {
44
parseAnalyticsDateRange,
55
} from "@/features/ads/schemas/analytics"
66

7+
describe("getDefaultAdsAnalyticsRange", () => {
8+
test("returns a 7-day window (today back 6 days, UTC) matching the Last 7 days preset default", () => {
9+
const now = new Date("2026-08-11T15:30:00.000Z")
10+
11+
expect(getDefaultAdsAnalyticsRange(now)).toEqual({
12+
from: "2026-08-05",
13+
to: "2026-08-11",
14+
})
15+
})
16+
17+
test("anchors to UTC midnight, ignoring the time-of-day component", () => {
18+
const earlyMorning = new Date("2026-08-11T00:00:01.000Z")
19+
const lateNight = new Date("2026-08-11T23:59:59.000Z")
20+
21+
expect(getDefaultAdsAnalyticsRange(earlyMorning)).toEqual(
22+
getDefaultAdsAnalyticsRange(lateNight),
23+
)
24+
})
25+
})
26+
727
describe("parseAnalyticsDateRange", () => {
828
test("keeps a normal 30-day range unchanged", () => {
929
const result = parseAnalyticsDateRange({
@@ -25,16 +45,21 @@ describe("parseAnalyticsDateRange", () => {
2545
expect(result.to).toBe("2026-08-11")
2646
})
2747

28-
test("falls back to the default range for a 40-year span (HIGH-5)", () => {
29-
const fallback = getDefaultAdsAnalyticsRange()
30-
48+
test("clamps an over-cap span to the last 366 days ending at `to` (HIGH-5)", () => {
49+
// A 40-year span (or the "Lifetime" preset on an old workspace) must stay
50+
// bounded by the scan guard, but the user should see the most recent year
51+
// under their chosen label — not a silent collapse to the 7-day default.
3152
const result = parseAnalyticsDateRange({
3253
from: "1986-08-11",
3354
to: "2026-08-11",
3455
})
3556

36-
expect(result.from).toBe(fallback.from)
37-
expect(result.to).toBe(fallback.to)
57+
expect(result.from).toBe("2025-08-11")
58+
expect(result.to).toBe("2026-08-11")
59+
// The clamped window is exactly at the cap boundary (still accepted).
60+
const spanDays =
61+
(result.until.getTime() - result.since.getTime()) / (24 * 60 * 60 * 1000)
62+
expect(spanDays).toBeLessThanOrEqual(366)
3863
})
3964

4065
test("falls back to the default range when since > until (existing behavior, unchanged)", () => {

0 commit comments

Comments
 (0)