Your club has agreed that members contribute a certain number of hours each year. The coordinator needs a clear answer before arranging the next work day: who has met their target, who still has hours left, and which confirmed shifts explain the balance?
Build a Developer App hosted inside campai with an annual member table, a form for confirmed work, and a correction history. The app stores its own ledger. Use an external API integration when another service collects work and needs to exchange records with campai; this guide is for a coordinator working inside campai.
What you’ll have at the end
An annual ledger with agreed targets, confirmed work, individual balances, correction history, and exports.
- Coordinator records confirmed work
- Widget sends a named server action
- App database commits the versioned ledger
- Member balances and exports reflect saved work
Before you start
- Permission to create an Admin-scoped Developer App Widget
- Access to the new app’s database
- Agreed annual targets and synthetic member labels for the first run
On this page
Choose the year and the targets your club has already agreed, including individual exemptions. The example accepts a separate target for each member; zero means no required hours. It does not decide your club's rules or calculate charges for missing hours.
Start with these synthetic entries:
| Member | Member number | Annual target |
|---|---|---|
| Maya Fischer | GUIDE-HOURS-01 | 8 hours |
| Jonas Weber | GUIDE-HOURS-02 | 6 hours |
| Lea Keller | GUIDE-HOURS-03 | 0 hours |
The server stores minutes as integers. Maya's target is 480, and a shift of 2 hours 15 minutes is 135. Avoid decimal-hour arithmetic such as treating 2.15 as two hours and fifteen minutes. See Understand data formats for the distinction between values used in calculations and formatted text.
The names and member numbers are manual labels. The app does not read or verify contacts. Use one membership-number namespace per app so equal numbers from different mandates cannot be confused.
- Open More > Developers > Apps and create
Volunteer hours. - Open Edit > Add group. Select Widget, name it
Annual hours, and choose Admin Scope. - Require Members > Read from visitors. This is an audience requirement, not a grant to the app.
- Open Settings and enable Database access. Leave Server permissions and Outbound hosts empty.
Everyone allowed to use this widget can change the entire ledger. The coordinator's name in a work entry does not restrict access or prove who approved it. Keep the app unpublished while testing.
Replace each destination file with the corresponding source at the end of this guide:
| Source file | Destination |
|---|---|
server.ts |
The group's server index.ts |
widget.tsx |
Widget > index.tsx |
widget.css |
Widget > index.css |
Set YEAR in the server before entering records. The year is part of the database key, so changing it opens a separate annual ledger. It does not move existing records into the new year.
The CSS contains only the editor's standard Tailwind import. If the generated CSS already has that import, keep it. Save the source files, select Widget > index.tsx, and start Annual hours: Simulate. The client calls action('loadLedger') and action('changeLedger', input); the server uses the built-in storage object. You do not need an API key, package installation, or external server.
Select Add member, enter Maya's name and number, and set Hours to 8 and Minutes to 0. Save the target. Repeat for Jonas and Lea using the table above.
Maya should show 0h 00m / 8h 00m, with 8h 00m remaining. Lea should already show Target met, because her agreed target is zero. This status describes the target you entered; it does not mean Lea completed a shift.
Select Record work and enter Maya's first shift:
| Field | Value |
|---|---|
| Member | Maya Fischer |
| Work date | A completed work day in the selected year |
| Work reference | ROSTER-2026-001 |
| Activity | Prepare the clubhouse for the open day |
| Hours / Minutes | 2 / 15 |
| Confirmed by | Sam |
Select Save confirmed work. Maya's balance should change to 2h 15m confirmed and 5h 45m remaining. The latest-work panel should show the activity and its date.
Use a unique reference from your roster for each entry. If saving loses its response, reload and look for that reference before trying again. The server rejects a reference already in the ledger, including one you later exclude.
The Confirmed by field records a name you enter after your club's approval process. It does not request approval or verify the coordinator's identity. Only enter work that has actually happened; a valid date is not proof of completion.
Open Maya's name in the member table to see her work history. Choose Correct this entry, enter a reason, and select Exclude from balance. The entry remains visible with its original date, duration, reference, and correction reason, but its minutes stop counting.
If Maya actually worked 1 hour 30 minutes, record that replacement under a new reference, such as ROSTER-2026-001-CORRECTED. The history should contain both entries, while the balance counts only the replacement: 1h 30m completed and 6h 30m remaining.
You can change a member's target under Adjust annual target, for example after an agreed exemption. This example replaces the target without keeping a target-change history. The work history is also not a tamper-proof audit record: an app editor can change the program or its stored data.
Use Show remaining work only to find members below their individual targets. Surplus time stays with the member who completed it. For example, Jonas completing 7 hours against a 6-hour target does not reduce Maya's remaining hours.
Download balances CSV exports all member balances, including members outside the current search. The numeric columns contain minutes so your spreadsheet can calculate without parsing formatted durations. Download full ledger exports JSON with the targets, complete work entries, and exclusion reasons. Keep these files within the same access rules as your club's member data. The example does not include an import or restore button.
Both exports open a preview with the full file content. If your browser does not save the download, select Copy export. When automatic clipboard access is unavailable, the content stays selected: use your browser’s Copy command and save a UTF-8 file with the displayed .csv or .json filename. A download click alone does not confirm a saved file; open it and compare the member count and balances before relying on it.
The ledger does not create invoices, calculate a fee, send reminders, or change campai contact records. Add such a workflow only after defining your club's rules and a separate explicit review step.
Restart the simulation. The targets, work history, and balances should return. They live at ['volunteer-hours', String(YEAR), 'ledger-v1'] in the app database, rather than in the browser's widgetStorage.
Open two views of the same ledger. Save in the first, then try a change from the older second view. The second must ask you to reload. The server checks the displayed version and performs another check inside the atomic commit. It never silently overwrites an intervening change.
| Check | Expected result |
|---|---|
| Add an existing member number | Rejected without changing the existing target. |
| Reuse a work reference | Rejected, even if the original entry was excluded. |
| Submit fractional minutes or an impossible date | Rejected before saving. |
| Record a date outside the configured year | Rejected before saving. |
| Save after another view changed the ledger | Conflict shown; reload required. |
| Exclude a work entry | Balance changes; original entry remains visible. |
| Disable Database access | Loading fails visibly; previous results are marked as possibly out of date. |
A lost response may mean the write already committed. After any failed save the widget blocks further edits until you reload and inspect the result. It does not automatically repeat the request.
This small example supports at most 80 members, 250 work entries including exclusions, and 32,000 UTF-8 bytes of ledger JSON. The first reached limit stops further growth without discarding existing records. For a larger club, split the ledger into separately stored records with paginated reads before raising the bounds.
An empty member row can be removed after typing its exact member number. Once work exists, the interface preserves the member's history. Agree on retention and export procedures before collecting real data. Use a separate unpublished app for the synthetic first run so cleanup cannot affect a live ledger.
Review the Admin audience before publishing. Test an authorized administrator and a signed-out visitor through the published link; owner simulation alone does not verify access. See Publish and share an app for that check.
The server and widget below belong to campai’s hosted runtime. Copy each complete file into its destination, or download all four files together.
The complete annual volunteer-hours app
Copy server.ts into the group’s index.ts, widget.tsx into Widget/index.tsx, and widget.css into Widget/index.css. Enable Database access; leave server API permissions empty.
Download ZIPView and copy the complete source
The complete annual volunteer-hours app
// Hosted Widget server: a small annual ledger in this app's own database.
// Choose the year before entering records. A different year uses a separate key.
const YEAR = 2026;
const KEY = ['volunteer-hours', String(YEAR), 'ledger-v1'] as const;
function utf8Length(text: string) {
let bytes = 0;
for (const character of text) {
const point = character.codePointAt(0)!;
bytes += point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4;
}
return bytes;
}
const MAX_MEMBERS = 80;
const MAX_ENTRIES = 250;
const MAX_JSON_BYTES = 32000;
type Member = { number: string; name: string; requiredMinutes: number };
type Work = {
reference: string;
number: string;
date: string;
activity: string;
minutes: number;
confirmedBy: string;
recordedAt: string;
voided: null | { reason: string; at: string };
};
type Ledger = { schema: 1; year: number; members: Member[]; entries: Work[] };
function text(value: unknown, label: string, maximum: number) {
if (typeof value !== 'string' || !value.trim() || value.trim().length > maximum)
throw new Error(`${label} is required (maximum ${maximum} characters).`);
return value.trim();
}
function integer(value: unknown, label: string, minimum: number, maximum: number): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum)
throw new Error(`${label} must be a whole number from ${minimum} to ${maximum}.`);
return value;
}
function dateOnly(value: unknown) {
const date = text(value, 'Work date', 10);
if (
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
Number(date.slice(0, 4)) !== YEAR ||
!Number.isFinite(Date.parse(`${date}T00:00:00Z`)) ||
new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) !== date
)
throw new Error(`Enter a real work date within ${YEAR}.`);
return date;
}
function timestamp(value: unknown) {
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value)))
throw new Error('Unexpected saved timestamp.');
}
function parseLedger(value: unknown): Ledger {
if (!value || typeof value !== 'object')
throw new Error('Unexpected saved ledger. Review it before making changes.');
const ledger = value as Ledger;
if (
ledger.schema !== 1 ||
ledger.year !== YEAR ||
!Array.isArray(ledger.members) ||
!Array.isArray(ledger.entries) ||
ledger.members.length > MAX_MEMBERS ||
ledger.entries.length > MAX_ENTRIES
)
throw new Error('Unexpected ledger schema or size.');
const numbers = new Set<string>();
for (const member of ledger.members) {
if (!member || typeof member !== 'object') throw new Error('Unexpected saved member.');
const number = text(member.number, 'Member number', 40);
text(member.name, 'Member name', 80);
integer(member.requiredMinutes, 'Annual target in minutes', 0, 60000);
if (numbers.has(number)) throw new Error('Duplicate saved member number.');
numbers.add(number);
}
const references = new Set<string>();
for (const entry of ledger.entries) {
if (!entry || typeof entry !== 'object') throw new Error('Unexpected saved work entry.');
const reference = text(entry.reference, 'Work reference', 60);
if (references.has(reference) || !numbers.has(entry.number))
throw new Error('Unexpected duplicate reference or missing member.');
dateOnly(entry.date);
text(entry.activity, 'Activity', 120);
text(entry.confirmedBy, 'Confirmed by', 80);
integer(entry.minutes, 'Work duration in minutes', 1, 1440);
timestamp(entry.recordedAt);
if (entry.voided !== null) {
if (!entry.voided || typeof entry.voided !== 'object')
throw new Error('Unexpected correction record.');
text(entry.voided.reason, 'Correction reason', 160);
timestamp(entry.voided.at);
}
references.add(reference);
}
return ledger;
}
async function read() {
const entry = await storage.get<unknown>(KEY);
const ledger: Ledger =
entry.versionstamp === null
? { schema: 1, year: YEAR, members: [], entries: [] }
: parseLedger(entry.value);
return { version: entry.versionstamp, ledger };
}
export async function loadLedger() {
const current = await read();
const rows = current.ledger.members.map((member) => {
const completedMinutes = current.ledger.entries
.filter((entry) => entry.number === member.number && entry.voided === null)
.reduce((sum, entry) => sum + entry.minutes, 0);
return {
...member,
completedMinutes,
remainingMinutes: Math.max(0, member.requiredMinutes - completedMinutes),
};
});
return {
...current,
rows,
limits: { members: MAX_MEMBERS, entries: MAX_ENTRIES, jsonBytes: MAX_JSON_BYTES },
};
}
export async function changeLedger(input: unknown) {
if (!input || typeof input !== 'object' || Array.isArray(input))
throw new Error('Invalid ledger request.');
const request = input as Record<string, unknown>;
if (request.version !== null && (typeof request.version !== 'string' || request.version.length > 100))
throw new Error('Reload the ledger before saving.');
const current = await read();
if (request.version !== current.version)
throw new Error('Someone changed the ledger. Reload and review before saving again.');
const ledger: Ledger = {
...current.ledger,
members: current.ledger.members.map((member) => ({ ...member })),
entries: current.ledger.entries.map((entry) => ({ ...entry })),
};
const now = new Date().toISOString();
if (request.operation === 'member') {
const number = text(request.number, 'Member number', 40);
if (ledger.members.some((member) => member.number === number))
throw new Error('This member number already has an annual target.');
if (ledger.members.length >= MAX_MEMBERS)
throw new Error('This example has reached its member limit.');
ledger.members.push({
number,
name: text(request.name, 'Member name', 80),
requiredMinutes: integer(request.requiredMinutes, 'Annual target in minutes', 0, 60000),
});
} else if (request.operation === 'target') {
const member = ledger.members.find((member) => member.number === request.number);
if (!member) throw new Error('Member not found. Reload the ledger.');
member.requiredMinutes = integer(request.requiredMinutes, 'Annual target in minutes', 0, 60000);
} else if (request.operation === 'work') {
const number = text(request.number, 'Member number', 40);
if (!ledger.members.some((member) => member.number === number))
throw new Error('Add the member and their annual target first.');
const reference = text(request.reference, 'Work reference', 60);
if (ledger.entries.some((entry) => entry.reference === reference))
throw new Error(
'This work reference already exists, including corrections. Review the existing entry.',
);
if (ledger.entries.length >= MAX_ENTRIES)
throw new Error('This example has reached its entry limit.');
ledger.entries.push({
reference,
number,
date: dateOnly(request.date),
activity: text(request.activity, 'Activity', 120),
minutes: integer(request.minutes, 'Work duration in minutes', 1, 1440),
confirmedBy: text(request.confirmedBy, 'Confirmed by', 80),
recordedAt: now,
voided: null,
});
} else if (request.operation === 'void') {
const entry = ledger.entries.find((entry) => entry.reference === request.reference);
if (!entry) throw new Error('Work entry not found.');
if (entry.voided) throw new Error('This entry is already excluded.');
entry.voided = { reason: text(request.reason, 'Correction reason', 160), at: now };
} else if (request.operation === 'removeEmptyMember') {
const number = text(request.number, 'Member number', 40);
if (request.confirmNumber !== number) throw new Error('Confirm the exact member number.');
if (ledger.entries.some((entry) => entry.number === number))
throw new Error('This member has work history. Keep it, including excluded entries.');
if (!ledger.members.some((member) => member.number === number)) throw new Error('Member not found.');
ledger.members = ledger.members.filter((member) => member.number !== number);
} else throw new Error('Unknown ledger operation.');
// Keep this bounded example well below the database's per-value storage limit.
if (utf8Length(JSON.stringify(ledger)) > MAX_JSON_BYTES)
throw new Error(
'This ledger has reached its size limit. Export it and use separately stored records before growing it further.',
);
let committed: { ok: boolean; versionstamp?: string };
try {
committed = await storage
.atomic()
.check({ key: KEY, versionstamp: current.version })
.set(KEY, ledger)
.commit();
} catch {
throw new Error('Save was not confirmed. Reload and inspect the ledger before trying again.');
}
if (!committed.ok) throw new Error('Someone changed the ledger while you saved. Reload and review.');
try {
return await loadLedger();
} catch {
throw new Error(
'The save committed, but the ledger could not be reloaded. Reload before making another change.',
);
}
}
Check your result
- Restart the widget
- Saved targets and work history return.
- Reuse a work reference
- The duplicate is rejected, including an excluded original.
- Save from an older view
- The change is rejected until the ledger is reloaded.
- Export with a search active
- The file still includes all members and their balances.