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
- 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).
- Ordering within a single day is fixed: Scheduled first, then Sent → Draft → Cancelled; ties break on the scheduled send time.
- A color legend sits above the calendar explaining which dot means which status.
3. Preview and open a campaign
- 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).
- 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/campaigncan 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 sendslimit: 500and there is no pagination at all — that number is a real ceiling, because the backend only validateslimitwithMin(1)andIsIntwith 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 andendDate= 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, statusessent/scheduledfilter oncampaign.start_date, whilecancel/draftfilter oncampaign.updated_dateinstead (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 usestartDate; draft/cancelled useupdatedDatefalling back tostartDate), 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 usesnew 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/campaignhas a Redis-backed cache mode triggered by acachequery 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 —
isLoadingis anORacross 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.
Related menus in this journey
- 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
sendingwaiting 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
PermissionGuardchecking theVIEWaction on thecampaign-managementsubject (not a subject of its own) insrc/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
readAllon thecampaignmodule, 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 thePermissionGuardchecking thecampaign-managementsubjectsrc/components/campaign-planner/campaign-planner.container.tsx— the 4 parallel requests, the ±7-day window, day bucketing and summary-card computationsrc/components/campaign-planner/campaign-calendar.tsx— the calendar grid, 3-item-per-day cap, "+N" button, color legendsrc/components/campaign-planner/campaign-event-tooltip.tsx,campaign-day-list.tsx— the quick-info box and per-day panel (where date formatting is pinned toen-US)src/components/campaign-planner/status-badge.tsx— the color and label for each statussrc/services/campaign-management.service.ts— thegetListfunction this page shares with the Campaign menu
Backend (line-management-cms-api-go)
internal/modules/campaign/controller.go— theGET /campaignroute and theModuleGate("campaign")wrapping itinternal/modules/campaign/service.go—setFilter(where the date column switches by status) and the cache mode this page never usesinternal/core/pagination/pagination.go— confirmation thatlimithas no upper bound
See the documentation team's earlier technical write-up at Campaign Planning Calendar