Skip to main content

Campaign Planner

Overview​

Campaign Planner is a monthly calendar that lays every campaign out by day, for admins and marketing teams who need to plan which day to send, spot days that are too crowded, and check for drafts left sitting around.

The key thing to understand before using it: this menu is read-only and has no backend module of its own at all. Everything on the calendar is the same data as the Campaign menu, fetched through the very same GET /api/campaign endpoint — the frontend just arranges it as a calendar instead of a table. (Searching the entire Go backend for the word "planner" returns zero hits: no controller, no service, no dedicated endpoint.)

Who uses it — anyone with permission to view campaign data (the exact same permission as the Campaign menu, on both the frontend and the server — see Access control).

User journey​

1. Open the calendar and read the summary cards​

Open Campaign Planner. The current month's calendar appears with summary cards at the top — the left card shows the month/year and the Total Campaigns figure for that month; the right card breaks it down across 4 statuses (Sent / Scheduled / Cancelled / Draft).

While data is still loading, "Total Campaigns" shows a dash — and the entire calendar area is replaced by a spinner — no day grid is drawn yet.

2. Read the day grid​

  1. Each day cell shows at most 3 campaigns, each with a status-colored dot. If a day has more, a "+N more" button opens a panel listing all of that day's campaigns (scrollable, capped at 420px tall, with a header showing the date and the total count).
  2. Ordering within a single day is fixed: Scheduled first, then Sent → Draft → Cancelled; ties break on the scheduled send time.
  3. A color legend sits above the calendar explaining which dot means which status.

3. Preview and open a campaign​

  1. Hover an item (0.3s delay) to open a quick-info box — status badge, campaign name, recipient group (falls back to "All friends on OA" when there's no group), recipient count (only shown when greater than 0), and a date/time line whose label changes with the status — Sent / Scheduled / Cancelled / Updated (for drafts).
  2. Click an item to open its detail — this leaves the Planner for the Campaign menu's form in view-only mode (?view=true), regardless of status. Even a draft opens read-only; you have to go back to the Campaign list to edit it.

4. Change the month​

Change the month using the calendar header controls and the system fetches fresh data automatically. (Only a month-level panel change triggers a refetch — switching to the year view calls no API.)

Key details verified against the source​

Confirmed from src/components/campaign-planner/ in cms-web-2026 together with internal/modules/campaign/{controller,service}.go and internal/core/pagination/pagination.go in line-management-cms-api-go:

  • This page fires four parallel API calls, not one — because GET /api/campaign can only filter one status at a time, the frontend issues 4 separate requests (sent / scheduled / draft / cancelled) and concatenates the results in the browser. Every request sends limit: 500 and there is no pagination at all — that number is a real ceiling, because the backend only validates limit with Min(1) and IsInt with no upper bound, so 500 is honored as asked. But if a single status in one month has more than 500 campaigns, the overflow silently vanishes from the calendar with no warning.
  • The fetched date window is 7 days wider than the displayed month on both sides — every request sends startDate = month start − 7 days and endDate = month end + 7 days, so the leading/trailing cells belonging to the neighboring months have data too. But the summary cards are recomputed on the frontend, trimmed down to days that genuinely fall inside the month — so it's normal to see items in the edge cells that aren't counted in the cards.
  • The date column the backend filters on changes with the status being filtered — in setFilter, statuses sent/scheduled filter on campaign.start_date, while cancel/draft filter on campaign.updated_date instead (and if no status is given at all, the date conditions are skipped entirely). The Planner buckets items into day cells by exactly the same rule (sent/scheduled use startDate; draft/cancelled use updatedDate falling back to startDate), so the two sides agree — but a day cell doesn't mean the same thing for every status. See Things to watch out for.
  • Dates and numbers on this page are hard-coded to English formatting — the month name on the summary card, the date/time in the quick-info box, and the "+N" panel header are all produced by new Intl.DateTimeFormat('en-US', …) declared as module constants, and the recipient count uses new Intl.NumberFormat('en-US'). Switch the UI to Thai and the labels turn Thai but the month and date/time stay English (e.g. "August 2026", "Wednesday, August 19, 2026").
  • No backend caching is used — GET /api/campaign has a Redis-backed cache mode triggered by a cache query parameter, but the Planner never sends it, so every month change reads the database live (caching happens instead in the browser's React Query layer, keyed by the date range).
  • The calendar waits for all four requests before it draws — isLoading is an OR across every request's loading state, so one slow request keeps the whole calendar as a spinner rather than progressively revealing statuses as they arrive.
  • Every link target is view-only — items in the day cells and items in the "+N" panel all point at /campaign-management/form?id={id}&view=true. No path out of the Planner leads to edit mode.
  • Campaign — both the sole data source for this page (GET /api/campaign) and the destination of every click. Creating, editing, cancelling and deleting campaigns happens only there.
  • Dashboard — the View All button on the Dashboard's "Upcoming Activities" card lands here on Campaign Planner.
  • Segment audience — the recipient-group name shown in the quick-info box is a target group prepared in that menu (Broadcast campaigns show "All friends on OA" instead).

Things to watch out for​

  • The day cell for Draft and Cancelled campaigns is not the day they were meant to send — both statuses are placed by their last-edited date (matching the column the backend filters on). So a campaign meant to go out at month-end whose draft you edited today shows up on today, not at month-end. The calendar reads as a genuine "send plan" only for the first two statuses (Sent/Scheduled).
  • A 500-item ceiling per status per month — beyond that, items neither display nor warn; both the summary cards and the calendar under-report.
  • A campaign you just hit "send now" on may briefly not appear on the calendar — while the backend holds it at status sending waiting for the worker, it matches none of the Planner's 4 status queries, so it disappears from the calendar exactly as it disappears from every tab in the Campaign menu.
  • There is no refresh button — to refetch, change month and change back, or reload the page.
  • Month names and date/times always render in English even on the Thai UI (a limitation hard-coded in the source, not a setting).

Access control (verified against the source)​

  • Frontend — the page is wrapped in a PermissionGuard checking the VIEW action on the campaign-management subject (not a subject of its own) in src/app/campaign-planner/page.tsx. Anyone who can see the Campaign menu implicitly sees this one too; conversely, revoking campaign-view permission locks this page out immediately.
  • Backend — there is no Campaign Planner module and no ModuleGate for it, because it has no endpoint of its own. Every request this page makes runs through the Campaign module's genuinely enforced ModuleGate("campaign") — so if a platform admin closes the campaign module for an organization, that org's users get a 403 on all 4 requests and see an empty calendar.
  • The policy annotation attached to the route is readAll on the campaign module, but it runs through the same no-op middleware as the rest of the system — the real enforcement is the Campaign module's module-level ModuleGate, and nothing else.

Source file reference​

Frontend (cms-web-2026)

  • src/app/campaign-planner/page.tsx — page entry point and the PermissionGuard checking the campaign-management subject
  • src/components/campaign-planner/campaign-planner.container.tsx — the 4 parallel requests, the ±7-day window, day bucketing and summary-card computation
  • src/components/campaign-planner/campaign-calendar.tsx — the calendar grid, 3-item-per-day cap, "+N" button, color legend
  • src/components/campaign-planner/campaign-event-tooltip.tsx, campaign-day-list.tsx — the quick-info box and per-day panel (where date formatting is pinned to en-US)
  • src/components/campaign-planner/status-badge.tsx — the color and label for each status
  • src/services/campaign-management.service.ts — the getList function this page shares with the Campaign menu

Backend (line-management-cms-api-go)

  • internal/modules/campaign/controller.go — the GET /campaign route and the ModuleGate("campaign") wrapping it
  • internal/modules/campaign/service.go — setFilter (where the date column switches by status) and the cache mode this page never uses
  • internal/core/pagination/pagination.go — confirmation that limit has no upper bound

See the documentation team's earlier technical write-up at Campaign Planning Calendar