Prepare a board meeting with the same report date for all your membership figures. You will build a dashboard that shows membership intervals, entry and exit dates by month, and the records that cannot be counted reliably. A copy button produces a short summary for your meeting notes.
This is a Developer App hosted inside campai, using the built-in api object on its server. Use the external API track when the report belongs in your own website, business-intelligence service, or cross-organization worker. This dashboard serves administrators reviewing one mandate inside campai.
What you’ll have at the end
A read-only membership dashboard with a chosen report date, monthly counts, visible data gaps, and a copyable board summary.
- Administrator selects the report date
- Widget calls the buildReport server action
- Built-in API reads the configured mandate
- Aggregate counts and gaps return to the widget
Before you start
- Permission to create an Admin-scoped Developer App Widget
- Reviewed Mandates Read and Members Read server access
- A full mandate ID and synthetic members for the first run
On this page
The report reads the dates currently stored on member records. It considers a member within their recorded membership interval when the entry date is on or before the chosen day and the exit date is absent or on or after that day. Both boundary days are included.
| Record | Result on November 4, 2026 |
|---|---|
| Entered January 1, 2020; no exit date | Included in the interval count. |
| Entry date November 4, 2026 | Included; also counted as a November entry. |
| Exit date November 4, 2026 | Included on this final day; also counted as a November exit date. |
| Exit date November 3, 2026 | Outside the interval. |
| Entry date December 1, 2026 | Counted under future entry dates. |
| No entry date | Excluded from date-based counts and flagged for review. |
An exit before the entry date is also excluded and flagged. The cancellation-notice field terminatedAt is not a substitute for the effective exit date exitAt.
This is a report from current records, not a reconstruction of historical membership. Deleted records, previous membership periods, or later corrections cannot be recovered from the current entry and exit fields. For audited historical reporting, agree on a snapshot or event-history process first. The chart counts recorded entry and exit dates; it does not claim to show a renewal rate or explain why members leave.
- Open More > Developers > Apps and create
Board membership dashboard. - Under Edit > Add group, select Widget and name the group
Membership report. - Choose Admin Scope and require Members > Read from visitors.
- In Settings > Server permissions, grant Mandates > Read and Members > Read, with access to membership dates.
- Leave Database access disabled and Outbound hosts empty.
The visitor requirement and the server grant are separate. The visitor must already have the selected permission. The server reads through the app's managed key, whose grant applies across the organization. The fixed mandate in this example limits its code's calls; it does not restrict the credential itself. Review that distinction before enabling server access. The widget returns only aggregate counts, but the server receives contact records from the API.
Keep the app unpublished while testing. If the board should not receive access to member details elsewhere in campai, review a suitable audience design before reusing the Members Read visitor requirement.
| Source file | Destination |
|---|---|
server.ts |
The group's server index.ts |
widget.tsx |
Widget > index.tsx |
widget.css |
Widget > index.css |
Replace YOUR_MANDATE_ID in the server with the full mandate ID. For the first test, put the full IDs of synthetic test members in TEST_CONTACT_IDS. The server restricts the API list selection to those contacts and the widget labels the report accordingly. It rejects missing or unexpected records; at most 1,000 distinct test IDs are supported. After verifying your results and access, empty that list to scan the selected mandate.
Save all files, select Widget > index.tsx, and start Membership report: Simulate. The widget waits for you to choose a date and select Run report. It calls the server's buildReport export through action; the server reads api.crm.contacts. There is no package installation, API-key field, or report database to configure.
Choose a report date and select Run report. The completed report shows its own date and check time, three headline cards, and monthly entry and exit bars. Expand Show exact monthly counts to inspect the values behind the chart.
The last month ends on the selected day. Future entry dates remain visible in the review panel but are excluded from the current interval and year-to-date entry totals. A member with an exit date on the chosen day appears in both the interval card and the exit-date card. Do not subtract those cards to calculate a same-day net change.
Changing the date input does not relabel an old report. Select Run report again to apply it. If the new scan fails, the old result stays visible with its original date and an error explaining that it was not refreshed.
Look at Before sharing. Missing entry dates and reversed date intervals are excluded from date-based counts. Correct them in the appropriate member records, then run the report again. A null date is a known absence; an omitted field may mean restricted access. The server rejects a report with unavailable fields instead of treating them as ordinary empty dates.
Membership dates are calendar days represented at midnight UTC. The server validates that representation and compares YYYY-MM-DD values. The widget formats report dates in UTC so a viewer in another time zone does not see the previous day. It uses American English by default; change DISPLAY_LOCALE to match your team’s reporting language. Check times include the browser’s local time-zone label. See Understand data formats.
Select Copy board summary. The copied text includes the report date, scanned-record count, headline totals, future entry dates, data gaps, and the check timestamp. It also retains the explanation that the figures come from current records.
The widget immediately makes the summary available as selectable text while trying the clipboard. If copying is blocked or does not finish, select that text and use the browser’s Copy command. Neither path includes member names, email addresses, or contact IDs.
| Try this | Expected result |
|---|---|
| Use synthetic contacts with boundary dates | Counts follow the inclusive-date table above. |
| Leave an entry date empty | The data-gap total increases. |
| Use a future entry date | It appears under future entry dates. |
| Remove server read access | The report fails visibly; previous totals keep their old timestamp. |
| Interrupt a later page of the scan | No partial totals replace the last complete report. |
| Change the date without running | The displayed report retains its original date. |
The example reads 100 contacts at a time, sorts by ID, and rejects overlapping pages. It stops at 5,000 member records. Offset pagination does not provide a database snapshot: edits during the scan can still change which records or dates it sees. Run the report when the data is stable; use a versioned export process when stronger consistency is required.
Remove the synthetic-only selection when you are ready for the complete mandate report. Review the Admin audience, publish, and test the resulting link with an authorized administrator and a signed-out visitor. Owner simulation alone does not establish published access.
The dashboard never writes contacts or saves a report. Its visible result lasts only for the current widget session. A copied summary should retain its report date and check time when you add it to meeting notes.
These files run in campai’s hosted app runtime. Copy each complete file into its destination, or download the ZIP with all four files.
The complete board membership dashboard
Copy server.ts into the group’s index.ts, widget.tsx into Widget/index.tsx, and widget.css into Widget/index.css. Set the mandate and synthetic test IDs; grant only the reviewed read permissions.
Download ZIPView and copy the complete source
The complete board membership dashboard
// Hosted Widget server. Read-only member reporting for one fixed mandate.
const MANDATE_ID = 'YOUR_MANDATE_ID';
const TEST_CONTACT_IDS: string[] = []; // Optional synthetic fixture IDs for the first test.
const PAGE_SIZE = 100;
const MAX_MEMBERS = 5000;
type Dates = { entry: string | null; exit: string | null };
function id(value: unknown) {
const result =
typeof value === 'string'
? value
: value && typeof value === 'object' && '$oid' in value
? String(value.$oid)
: String(value);
if (!/^[a-f0-9]{24}$/i.test(result)) throw new Error('Configure valid full mandate and contact IDs.');
return result;
}
function day(value: unknown, label: string) {
if (
typeof value !== 'string' ||
!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
!Number.isFinite(Date.parse(`${value}T00:00:00Z`)) ||
new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) !== value
)
throw new Error(`${label} must be a real YYYY-MM-DD date.`);
return value;
}
function storedDay(value: unknown): string | null {
if (value === null) return null;
const iso = value instanceof Date ? value.toISOString() : value;
if (typeof iso !== 'string' || !/^\d{4}-\d{2}-\d{2}T00:00:00\.000Z$/.test(iso))
throw new Error('Unexpected membership date representation. No partial report was returned.');
return day(iso.slice(0, 10), 'Membership date');
}
function project(contact: any) {
if (!contact || !Array.isArray(contact.types) || !contact.types.includes('member'))
throw new Error('A returned record is not a member.');
if (
!contact.member ||
!Object.prototype.hasOwnProperty.call(contact.member, 'entryAt') ||
!Object.prototype.hasOwnProperty.call(contact.member, 'exitAt')
)
throw new Error('Membership date fields are unavailable. Review the app field permissions.');
return {
id: id(contact._id),
entry: storedDay(contact.member.entryAt),
exit: storedDay(contact.member.exitAt),
};
}
export async function buildReport(input: unknown) {
if (!input || typeof input !== 'object') throw new Error('Choose a report date.');
const asOf = day((input as Record<string, unknown>).asOf, 'Report date');
const year = Number(asOf.slice(0, 4));
if (year < 1900 || year > 2100) throw new Error('Choose a report year from 1900 to 2100.');
const mandateId = id(MANDATE_ID);
const fixtureIds = [...new Set(TEST_CONTACT_IDS.map(id))];
if (fixtureIds.length > 1000) throw new Error('Use at most 1,000 test contact IDs.');
const members: Dates[] = [];
const seen = new Set<string>();
const add = (contact: any) => {
const row = project(contact);
if (fixtureIds.length && !fixtureIds.includes(row.id))
throw new Error('A returned member is outside the test selection. No report was returned.');
if (seen.has(row.id)) throw new Error('The member list moved during scanning. Run a fresh report.');
seen.add(row.id);
members.push({ entry: row.entry, exit: row.exit });
if (members.length > MAX_MEMBERS)
throw new Error(
'This example supports up to 5,000 member records. Split the report before continuing.',
);
};
let complete = false;
for (let offset = 0; offset <= MAX_MEMBERS; offset += PAGE_SIZE) {
const page = await api.crm.contacts.listContacts({
mandateId,
types: ['member'],
sort: { _id: 'asc' },
limit: PAGE_SIZE,
offset,
...(fixtureIds.length ? { selection: { ids: fixtureIds } } : {}),
});
if (!Array.isArray(page.contacts) || page.contacts.length > PAGE_SIZE)
throw new Error('Unexpected member-list response.');
for (const contact of page.contacts) add(contact);
if (page.contacts.length < PAGE_SIZE) {
complete = true;
break;
}
}
if (!complete) throw new Error('The report did not finish. No partial totals were returned.');
if (fixtureIds.length && seen.size !== fixtureIds.length)
throw new Error('Some test contacts were not returned. Check the IDs, mandate, and member type.');
// A null entry date cannot establish membership on the chosen day.
const unknownStart = members.filter((member) => member.entry === null).length;
const inconsistent = members.filter(
(member) => member.entry && member.exit && member.exit < member.entry,
).length;
const usable = members.filter(
(member): member is { entry: string; exit: string | null } =>
member.entry !== null && (member.exit === null || member.exit >= member.entry),
);
// The exit day is included. The member is outside the interval the following day.
const active = usable.filter(
(member) => member.entry <= asOf && (member.exit === null || member.exit >= asOf),
).length;
const futureStarts = usable.filter((member) => member.entry > asOf).length;
const ended = usable.filter((member) => member.exit !== null && member.exit < asOf).length;
const yearStart = `${year}-01-01`;
const months = Array.from({ length: Number(asOf.slice(5, 7)) }, (_, index) => {
const prefix = `${year}-${String(index + 1).padStart(2, '0')}`;
return {
month: prefix,
joined: usable.filter((member) => member.entry.startsWith(prefix) && member.entry <= asOf).length,
exits: usable.filter((member) => member.exit?.startsWith(prefix) && member.exit <= asOf).length,
};
});
// Return aggregates only. Names, IDs and contact details never reach the widget.
return {
asOf,
year,
scanned: members.length,
active,
ended,
futureStarts,
unknownStart,
inconsistent,
joinedThisYear: usable.filter((member) => member.entry >= yearStart && member.entry <= asOf).length,
exitsThisYear: usable.filter(
(member) => member.exit !== null && member.exit >= yearStart && member.exit <= asOf,
).length,
months,
checkedAt: new Date().toISOString(),
fixtureOnly: TEST_CONTACT_IDS.length > 0,
};
}
Check your result
- Choose the effective exit day
- The member remains within the recorded interval that day.
- Change the input date without running
- The previous report keeps its original date.
- Remove server read access
- An error appears and the previous complete report stays marked as unrefreshed.
- Copy the board summary
- Only counts, dates, and explanatory text appear; no contact IDs or names.