Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 e5d615e398 Series: year on year report (1.47.2)
New report (Reports -> Other, or "Compare" on the Event series page):
each event in a series side by side, oldest first - registrations,
tickets, income (dashboard definition), people checked in (average per
session on multi-session events) and attendance rate, with the change
from the event before. Exports like the other reports; campus-scoped
staff see only their campuses' events. GET /api/series/:id/compare.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:57:04 +02:00
24 changed files with 320 additions and 28 deletions
+13
View File
@@ -7,6 +7,19 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.47.2] - 2026-10-28
### Added
- **Series: year on year** report (Reports → Other) — pick a series and see each of its events side
by side, oldest first: registrations (not cancelled), tickets, income (as on the dashboard: refunds
counted, donations applied to orders left out) and people checked in (a multi-session event shows
the average per session), with the attendance rate and the change from the event before. Bar charts
of registrations and income; exports to PDF, Excel, email and WhatsApp like the other reports.
Campus-scoped staff see only their campuses' events. **Compare** on each series on the Event series
page opens it (`?report=seriesComparison&series=<id>`).
- `GET /api/series/:id/compare`.
## [1.47.1] - 2026-10-28
### Added
+4
View File
@@ -509,6 +509,7 @@ a viewer with its own filters, stat tiles and bar charts.
| Ticketing | Ticket usage summary |
| Donations | Donations breakdown (used / unused per event, plus the sponsorship pool) |
| Cashup | Cashup reconciliation · Cashup audit trail |
| Other | Series: year on year (a series' events side by side — registrations, tickets, income, checked in, attendance rate, change from the one before) |
**Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook,
Email (to yourself), or WhatsApp (the PDF to your own number). Dashboard tiles
@@ -555,6 +556,9 @@ keeps its own registrations; for one event on several dates see multi-day events
- **Follow a series**: signed-in people follow on the series page; when a new event in it goes live
(before it starts) they get one email, sent as News & announcements (opted-in only, with an
unsubscribe link). The `series` job runs every 15 minutes.
- **Year on year**: Reports → Series: year on year (or **Compare** on the Event series page)
compares the series' events — registrations, tickets, income, checked in (average per session on
multi-session events), attendance rate — each against the one before.
## Multi-campus
+6
View File
@@ -12,6 +12,12 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.47.2
- **Compare a series year on year.** A new report puts the events in a series side by side —
registrations, tickets, income and how many came — with how much each went up or down from the
one before. Open it from Reports, or with **Compare** on the Event series page.
## 1.47.1
- **Event series.** Group events that come back — every year's camp, the monthly breakfast — on
+1
View File
@@ -943,6 +943,7 @@ role linked to the room); checked-in ones are counted in `leaders`.
| DELETE | `/api/series/:id` | admin | Delete; events stay without a series, followers dropped |
| GET | `/api/series/by-slug/:slug` | public | `{ id, name, slug, description, picture, upcoming, past, following }` — public events only |
| POST / DELETE | `/api/series/:id/follow` | user | Follow / unfollow |
| GET | `/api/series/:id/compare` | supervisor+ | Year on year: `{ series, events: [{ eventId, title, startDate, registrations, tickets, income, checkedIn, attendanceRate, sessions, change }] }`, oldest first; `change` is % against the event before. Scoped staff: their campuses' events only |
Events take `seriesId` (null = none) on create and update; `GET /api/events?series=<slug>` lists one
series; `GET /api/events/:id` includes `series` and `nextInSeries`. The `series` job emails
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.47.1",
"version": "1.47.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.47.1",
"version": "1.47.2",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.47.1",
"version": "1.47.2",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+13 -3
View File
@@ -1,8 +1,8 @@
const prisma = require('../config/db');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { cleanSeriesInput, publicEventWhere, splitEvents, follow, unfollow } = require('../utils/series');
const { scopeFor, canView } = require('../utils/campusScope');
const { cleanSeriesInput, publicEventWhere, splitEvents, follow, unfollow, compareSeries } = require('../utils/series');
const { scopeFor, canView, eventWhere } = require('../utils/campusScope');
const { EVENT_CAMPUS_SELECT } = require('../utils/campuses');
const fail = (res, error) => res.status(error.statusCode || (error.code === 'P2002' ? 409 : 400)).json({
@@ -96,6 +96,16 @@ const getSeriesBySlug = async (req, res) => {
} catch (error) { fail(res, error); }
};
// @desc Year-on-year: registrations, tickets, income and attendance per event in the series
// (the events this person may see), oldest first, with the change from the one before
// @route GET /api/series/:id/compare
// @access Private/Supervisor
const compare = async (req, res) => {
try {
res.json(await compareSeries(req.params.id, { scopeWhere: eventWhere(await scopeFor(req)) }));
} catch (error) { fail(res, error); }
};
// @route POST /api/series/:id/follow · DELETE /api/series/:id/follow
// @access Private
const followSeries = async (req, res) => {
@@ -105,4 +115,4 @@ const unfollowSeries = async (req, res) => {
try { await unfollow(req.params.id, req.user.id); res.json({ following: false }); } catch (error) { fail(res, error); }
};
module.exports = { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries };
module.exports = { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries, compare };
+1
View File
@@ -1007,6 +1007,7 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/series/by-slug/:slug', auth:'public', desc:'The series page: details, upcoming and past public events, and following (signed in)' },
{ method:'POST', path:'/api/series/:id/follow', auth:'user', desc:'Follow a series: an email (News & announcements) when a new event in it goes live' },
{ method:'DELETE', path:'/api/series/:id/follow', auth:'user', desc:'Unfollow a series' },
{ method:'GET', path:'/api/series/:id/compare', auth:'supervisor+', desc:'Year on year: registrations, tickets, income (as on the dashboard), checked in (average per session on multi-session events) and attendance rate per event in the series, oldest first, with the % change from the one before. Campus-scoped staff: their campuses\x27 events only' },
]},
{ title: 'Volunteer rosters', base: '/api/volunteers', endpoints: [
+4 -2
View File
@@ -1,8 +1,8 @@
const express = require('express');
const router = express.Router();
const { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries } = require('../controllers/seriesController');
const { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries, compare } = require('../controllers/seriesController');
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
const { campusIndependent, scopeList } = require('../utils/campusScope');
const SPANS = campusIndependent('a series spans campuses; adding an event to one is checked on the event');
@@ -15,5 +15,7 @@ router.get('/', protect, supervisor, SPANS, listSeries);
router.post('/', protect, supervisor, SPANS, createSeries);
router.put('/:id', protect, supervisor, SPANS, updateSeries);
router.delete('/:id', protect, admin, deleteSeries);
// Year-on-year report: only the events the person may see (filtered in the controller).
router.get('/:id/compare', protect, supervisor, scopeList, compare);
module.exports = router;
+78 -1
View File
@@ -149,6 +149,83 @@ function announcementEmail({ name, event, series }) {
return { subject, text, html: emailWrapper(body, { preheader: subject }) };
}
// ─── Year-on-year comparison (1.47.2) ────────────────────────────────────────
// The same "real income" as the dashboard's top events: everything except the legs that apply an
// earlier donation to an order (not new money). Refunds count, as negatives.
const EXCLUDE_DONATION_LEGS = {
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] },
};
const pct = (now, before) => (before > 0 ? Math.round(((now - before) / before) * 100) : null);
/**
* Adds `change` to each row against the row before it (rows oldest first): the percentage up or
* down for registrations, tickets, income and checked in, null when there's nothing to compare.
* Pure, for tests.
*/
function withChanges(rows) {
return rows.map((r, i) => {
const prev = rows[i - 1];
return {
...r,
change: prev
? { registrations: pct(r.registrations, prev.registrations), tickets: pct(r.tickets, prev.tickets), income: pct(r.income, prev.income), checkedIn: pct(r.checkedIn, prev.checkedIn) }
: null,
};
});
}
/**
* Registrations, tickets, income and attendance for each event in a series that the person may
* see (`scopeWhere`, from campusScope.eventWhere), oldest first, with the change from the one
* before. Checked in: check-ins on a single-date event; on a multi-session event, the average per
* session that wasn't cancelled.
*/
async function compareSeries(seriesId, { scopeWhere = {} } = {}, db = prisma) {
const series = await db.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true, name: true, slug: true } });
if (!series) throw seriesError('Series not found', 404);
const events = await db.event.findMany({
where: { AND: [{ seriesId }, scopeWhere] },
orderBy: { startDate: 'asc' },
select: {
id: true, title: true, startDate: true, endDate: true, scheduleType: true, isActive: true,
campus: { select: { id: true, name: true, slug: true, colour: true } },
_count: { select: { sessions: { where: { cancelledAt: null } } } },
},
});
const ids = events.map(e => e.id);
const [regs, tickets, income, usages] = ids.length ? await Promise.all([
db.registration.groupBy({ by: ['eventId'], where: { eventId: { in: ids }, status: { not: 'cancelled' } }, _count: { _all: true } }),
db.ticket.groupBy({ by: ['eventId'], where: { eventId: { in: ids } }, _sum: { quantity: true } }),
db.payment.groupBy({ by: ['eventId'], where: { ...EXCLUDE_DONATION_LEGS, eventId: { in: ids } }, _sum: { amount: true } }),
// People checked in (a group scan can let several in at once), per event through the ticket.
Promise.all(ids.map(async eventId => ({ eventId, n: (await db.ticketUsage.aggregate({ where: { ticket: { eventId } }, _sum: { quantityRedeemed: true } }))._sum.quantityRedeemed || 0 }))),
]) : [[], [], [], []];
const map = (rows, pick) => new Map(rows.map(r => [r.eventId, pick(r)]));
const regBy = map(regs, r => r._count._all);
const ticketBy = map(tickets, r => r._sum.quantity || 0);
const incomeBy = map(income, r => r._sum.amount || 0);
const usageBy = map(usages, r => r.n);
const rows = events.map(e => {
const scans = usageBy.get(e.id) || 0;
const sessions = e.scheduleType === 'sessions' ? e._count.sessions : 0;
const t = ticketBy.get(e.id) || 0;
const checkedIn = sessions > 0 ? Math.round(scans / sessions) : scans;
return {
eventId: e.id, title: e.title, startDate: e.startDate, endDate: e.endDate, campus: e.campus, isActive: e.isActive,
sessions,
registrations: regBy.get(e.id) || 0,
tickets: t,
income: Math.round((incomeBy.get(e.id) || 0) * 100) / 100,
checkedIn,
attendanceRate: t > 0 ? Math.min(100, Math.round((checkedIn / t) * 100)) : null,
};
});
return { series, events: withChanges(rows) };
}
// ─── Privacy ─────────────────────────────────────────────────────────────────
/** Series someone follows, for "Download my data". */
@@ -159,5 +236,5 @@ async function exportFollows(userId, db = prisma) {
module.exports = {
publicEventWhere, cleanSeriesInput, seriesForEvent, nextInSeries, splitEvents,
follow, unfollow, runAnnounceJob, announcementEmail, exportFollows, seriesError,
follow, unfollow, runAnnounceJob, announcementEmail, exportFollows, seriesError, withChanges, compareSeries,
};
+43
View File
@@ -46,6 +46,49 @@ describe('event series', () => {
expect(isBlocked('POST', '/api/series/abc/follow')).toBe(false);
});
describe('year-on-year comparison', () => {
test('each event against the one before it', () => {
const rows = series.withChanges([
{ registrations: 40, tickets: 50, income: 10000, checkedIn: 45 },
{ registrations: 50, tickets: 50, income: 9000, checkedIn: 0 },
{ registrations: 25, tickets: 60, income: 9000, checkedIn: 30 },
]);
expect(rows[0].change).toBeNull();
expect(rows[1].change).toEqual({ registrations: 25, tickets: 0, income: -10, checkedIn: -100 });
// Nothing to compare with after a zero.
expect(rows[2].change).toEqual({ registrations: -50, tickets: 20, income: 0, checkedIn: null });
});
test('counts per event, check-ins averaged per session, only events in scope', async () => {
const db = {
eventSeries: { findUnique: jest.fn().mockResolvedValue({ id: 's1', name: 'Family Camp', slug: 'family-camp' }) },
event: {
findMany: jest.fn().mockResolvedValue([
{ id: 'e25', title: 'Camp 2025', startDate: new Date('2025-03-01'), endDate: new Date('2025-03-04'), scheduleType: 'single', isActive: true, campus: null, _count: { sessions: 0 } },
{ id: 'e26', title: 'Camp 2026', startDate: new Date('2026-03-01'), endDate: new Date('2026-03-04'), scheduleType: 'sessions', isActive: true, campus: null, _count: { sessions: 3 } },
]),
},
registration: { groupBy: jest.fn().mockResolvedValue([{ eventId: 'e25', _count: { _all: 40 } }, { eventId: 'e26', _count: { _all: 50 } }]) },
ticket: { groupBy: jest.fn().mockResolvedValue([{ eventId: 'e25', _sum: { quantity: 50 } }, { eventId: 'e26', _sum: { quantity: 60 } }]) },
payment: { groupBy: jest.fn().mockResolvedValue([{ eventId: 'e25', _sum: { amount: 10000 } }, { eventId: 'e26', _sum: { amount: 12000.456 } }]) },
ticketUsage: { aggregate: jest.fn(({ where }) => Promise.resolve({ _sum: { quantityRedeemed: where.ticket.eventId === 'e25' ? 45 : 150 } })) },
};
const scopeWhere = { OR: [{ campusId: { in: ['north'] } }, { campusId: null }] };
const out = await series.compareSeries('s1', { scopeWhere }, db);
expect(db.event.findMany.mock.calls[0][0].where).toEqual({ AND: [{ seriesId: 's1' }, scopeWhere] });
expect(out.events.map(e => [e.title, e.registrations, e.tickets, e.income, e.checkedIn, e.attendanceRate])).toEqual([
['Camp 2025', 40, 50, 10000, 45, 90],
['Camp 2026', 50, 60, 12000.46, 50, 83], // 150 check-ins over 3 sessions
]);
expect(out.events[1].change).toEqual({ registrations: 25, tickets: 20, income: 20, checkedIn: 11 });
});
test('an unknown series is a 404', async () => {
const db = { eventSeries: { findUnique: jest.fn().mockResolvedValue(null) } };
await expect(series.compareSeries('nope', {}, db)).rejects.toMatchObject({ statusCode: 404 });
});
});
describe('announcing a new event to followers', () => {
const now = new Date('2026-06-01T08:00:00Z');
const event = { id: 'e1', title: 'Camp 2027', startDate: new Date('2027-03-01T08:00:00Z'), location: 'The farm', series: { id: 's1', name: 'Family Camp', slug: 'family-camp' } };
+4 -3
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.47.1** (and Hub 1.4.0). The plan itself is in
Last updated: 2026-10-28, after **1.47.2** (and Hub 1.4.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -14,6 +14,7 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.47.2 | **Series: year on year** report (§6 part 3): `GET /api/series/:id/compare` (`compareSeries` in `utils/series.js`), report `seriesComparison` in `ReportsV2`, "Compare" on the Event series page |
| 1.47.1 | **Event series** (§6 part 2): Event series page, Series box on events, public `/series/<slug>`, "Next in this series", follow (News & announcements email from the `series` job when a new event goes live). Migration `20261028140000_event_series` |
| 1.47.0 | **Copy an event** (§6 part 1): "Copy" opens the event editor filled in from the source (campus, sessions, options, tiers, sections, form, recipients, feedback, certificates); "New start" moves every date; allocation groups and (admins) costs copied by `POST /api/events/:id/copy-from`. No registrations, coupons, attachments, kids check-in or volunteer roles |
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
@@ -49,8 +50,8 @@ encrypted settings unreadable or break existing installs.
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. The rest of §6, one patch per part: **1.47.2 year-on-year reports** (compare a series' events),
**1.47.3 season pass** (a bundle across a series' events) — then **1.48.0 — outbound webhooks** (§8).
1. **1.47.3 season pass** (§6: a bundle across a series' events, extending event bundles) — then
**1.48.0 — outbound webhooks** (§8).
## Working on this repo
+4 -4
View File
@@ -34,8 +34,8 @@ Wallet passes are removed from scope (2026-10-28), so everything after volunteer
up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticker panel, rooms with
ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or session, sign-ups and
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) shipped.
**Next: 1.47.2 Year-on-year reports.**
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) and 1.47.2 **year-on-year
report** shipped. **Next: 1.47.3 Season pass.**
**Repositories:**
@@ -475,7 +475,7 @@ year-on-year reports, **1.47.3** season pass.
follow/notify option. **A series can span several campuses.** Following is **News &
announcements** (opted-in only, unsubscribe link); followers get one email when a new event in the
series goes live.
- **Year-on-year reports (1.47.2):** registrations, income and attendance compared
- **Year-on-year reports (1.47.2 ✅):** registrations, income and attendance compared
across events in a series.
- **Season pass (1.47.3):** a bundle across events in a series (extends event bundles).
@@ -962,7 +962,7 @@ income justify the hardware:
| 6 | 1.41.0 ✅ – 1.42.0 ✅ + Hub 1.0 ✅ – 1.1 ✅ | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo); rename to Cross Events (1.41.3); Request update (1.42.0 / Hub 1.1.0) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
| 7 | 1.43.0 ✅ – 1.44.0 ✅ | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
| 8 | 1.45.0 ✅ – 1.46.0 ✅ | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.47.0 ✅ 1.47.1 ✅ – 1.47.3, 1.48.0 | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 9 | 1.47.0 ✅ 1.47.1 ✅ 1.47.2 ✅ – 1.47.3, 1.48.0 | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
+10
View File
@@ -62,4 +62,14 @@ test.describe.serial("event series", () => {
const again = await api<JobRun>(page, "admin", "POST", "/api/admin/jobs/series/run");
expect(again.output.announced).toBe(0);
});
test("year on year: the series report compares its events, oldest first", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/series");
await page.getByTestId("series-row").filter({ hasText: "E2E Breakfast" }).getByRole("link", { name: "Compare" }).click();
const rows = page.getByTestId("comparison-row");
await expect(rows).toHaveCount(3);
await expect(rows.first()).toContainText("E2E Breakfast (last month)");
await expect(rows.last()).toContainText("E2E Breakfast (in two months)");
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.47.1",
"version": "1.47.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.47.1",
"version": "1.47.2",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.47.1",
"version": "1.47.2",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Layers, ExternalLink, Users } from "lucide-react";
import { Layers, ExternalLink, Users, BarChart3 } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
@@ -151,6 +151,11 @@ export default function SeriesPage() {
{s.description && <p className="text-sm text-gray-600 mt-1 whitespace-pre-line">{s.description}</p>}
</div>
<div className="flex gap-2 shrink-0">
{s.events.length > 0 && (
<Link href={`/dashboard/supervisor/reports?report=seriesComparison&series=${s.id}`} className="inline-flex items-center gap-1 text-xs px-3 py-1.5 rounded border bg-white hover:bg-gray-50">
<BarChart3 className="w-3.5 h-3.5" /> Compare
</Link>
)}
<button type="button" className="text-xs px-3 py-1.5 rounded border bg-white hover:bg-gray-50"
onClick={() => { setDraft({ id: s.id, name: s.name, slug: s.slug, description: s.description || "", picture: s.picture || "" }); setNotice(null); }}>Edit</button>
{isAdmin && <button type="button" className="text-xs px-3 py-1.5 rounded bg-red-50 text-red-700 border border-red-200" onClick={() => remove(s)}>Delete</button>}
@@ -18,6 +18,7 @@ import {
HeartPulse,
MessageSquareHeart,
Filter,
Layers,
} from "lucide-react";
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
@@ -42,6 +43,7 @@ export const REPORTS = [
{ key: "funnel", label: "Registration funnel", description: "Event page views → started → submitted → paid, with trend and sources", category: "Registration", icon: Filter, fields: 5 },
{ key: "feedbackSummary", label: "Feedback summary", description: "Post-event feedback: ratings, choices and comments; compare events", category: "Registration", icon: MessageSquareHeart, fields: 4 },
{ key: "medicalList", label: "Medical & emergency list", description: "Youth events: confidential list for leaders (audited)", category: "Registration", icon: HeartPulse, fields: 5 },
{ key: "seriesComparison", label: "Series: year on year", description: "Registrations, income and attendance compared across the events in a series", category: "Other", icon: Layers, fields: 6 },
{ key: "discounts", label: "Discounts (codes & bundles)", description: "Uses and value given away per code, bundle deal and event", category: "Financial", icon: TicketPercent, fields: 4 },
] as const satisfies ReadonlyArray<{
key: string;
+108 -3
View File
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck, Star, MessageSquareHeart, Send, Eye, MousePointerClick, BadgeCheck } from "lucide-react";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck, Star, MessageSquareHeart, Send, Eye, MousePointerClick, BadgeCheck, Users, Ticket as TicketIcon } from "lucide-react";
import { REPORTS, type ReportKey } from "./ReportCatalog";
import ReportsShell from "./ReportsShell";
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
@@ -15,7 +15,7 @@ import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing";
import { CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
import type { ConsentRow, Event, EventCashup, EventFinancials, MedicalRow, Payment, Registration, Ticket, UserSummary } from "@/types";
import type { ConsentRow, Event, EventCashup, EventFinancials, EventSeries, MedicalRow, Payment, Registration, SeriesComparison, Ticket, UserSummary } from "@/types";
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
const METHOD_LABEL: Record<string, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
@@ -147,6 +147,9 @@ type FunnelReport = {
perEvent?: { eventId: string; title: string; totals: FunnelCounts; conversion: FunnelConversion }[];
};
/** "+12%", "−5%", "no change" — a year-on-year change. */
const changeLabel = (c: number) => (c > 0 ? `+${c}%` : c < 0 ? `−${Math.abs(c)}%` : "no change");
// Simple MultiSelect control used in filters
export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
const { token, user } = useAuth();
@@ -241,6 +244,18 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
const [feedbackRows, setFeedbackRows] = useState<FeedbackEventSummary[]>([]);
// Registration funnel for the selected events (null until viewed).
const [funnel, setFunnel] = useState<FunnelReport | null>(null);
// Series: year on year — picked by series, not by the events selection.
const [seriesList, setSeriesList] = useState<EventSeries[]>([]);
const [compareSeriesId, setCompareSeriesId] = useState<string>("");
const [comparison, setComparison] = useState<SeriesComparison | null>(null);
useEffect(() => {
if (!token) return;
apiFetch<EventSeries[]>("/api/series", { authToken: token }).then(l => {
const list = Array.isArray(l) ? l : [];
setSeriesList(list);
setCompareSeriesId(id => id || list[0]?.id || "");
}).catch(() => {});
}, [token]);
// Deep-linking: dashboards link here with ?report=<key>&range=trailing_month to jump
// straight into a specific report, pre-filtered to every currently-visible event and the
@@ -259,6 +274,8 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
deepLinkHandledRef.current = true;
setReport(reportParam as ReportKey);
setSelectedEventIds(filteredEvents.map((e) => e.id));
const seriesParam = searchParams.get("series");
if (seriesParam) setCompareSeriesId(seriesParam);
if (searchParams.get("range") === "trailing_month") {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30);
@@ -527,6 +544,10 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
setFeedbackRows(await loadFeedback(selectedEventIds));
} else if (report === "medicalList") {
setMedicalRows(await loadConsent(selectedEventIds, "medical"));
} else if (report === "seriesComparison") {
setComparison(compareSeriesId && token
? await apiFetch<SeriesComparison>(`/api/series/${encodeURIComponent(compareSeriesId)}/compare`, { authToken: token }).catch(() => null)
: null);
}
setReady(true);
} finally {
@@ -959,6 +980,24 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
// mirror exactly what the on-screen report shows above its table, computed the same way.
const buildReportPayload = (): ReportPdfPayload | null => {
const subtitle = `Generated ${new Date().toLocaleString()}`;
if (report === "seriesComparison") {
if (!comparison) return null;
const withChange = (v: string, c: number | null | undefined) => (c == null ? v : `${v} (${changeLabel(c)})`);
return {
title: `${comparison.series.name}: year on year`, subtitle, kind: "table", orientation: "landscape",
note: "Each change is against the event before. Checked in on a multi-session event is the average per session. Income leaves out donations applied to orders (counted when they were given).",
...(comparison.events.length > 1 ? { chart: { title: "Income, by event", data: comparison.events.map(e => ({ label: e.title, value: e.income, displayValue: `R ${e.income.toFixed(2)}` })) } } : {}),
table: {
columns: ["Event", "Date", "Registrations", "Tickets", "Income", "Checked in", "Attendance"],
rows: comparison.events.map(e => [
e.title, new Date(e.startDate).toLocaleDateString(),
withChange(String(e.registrations), e.change?.registrations), withChange(String(e.tickets), e.change?.tickets),
withChange(`R ${e.income.toFixed(2)}`, e.change?.income), withChange(String(e.checkedIn), e.change?.checkedIn),
e.attendanceRate != null ? `${e.attendanceRate}%` : "—",
]),
},
};
}
if (report === "payments") {
return {
title: 'Payments Report', subtitle, kind: 'table', orientation: 'portrait',
@@ -1372,6 +1411,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
discounts: "Discounts Report",
consentStatus: "Parental Consent Status",
medicalList: "Medical & Emergency List",
seriesComparison: "Series: year on year",
};
const doPrintPdf = async () => {
@@ -1412,7 +1452,18 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
// Report-specific filters shown inside the popup — whatever each report had beyond the
// universal Events/Date range, which now live in the sidebar (ReportsShell) instead. `null`
// when a report has none, so the modal chrome shows its own fallback note.
const reportSpecificFilters = report === "attendees" ? (
const reportSpecificFilters = report === "seriesComparison" ? (
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm">
<span className="mr-2">Series</span>
<select className="border rounded px-2 py-1 text-sm" value={compareSeriesId} onChange={e => setCompareSeriesId(e.target.value)} aria-label="Series">
{seriesList.length === 0 && <option value="">No series yet</option>}
{seriesList.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
<span className="text-xs text-gray-500">Press refresh after changing the series. The events selection on the left doesn&apos;t apply.</span>
</div>
) : report === "attendees" ? (
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm">
<span className="mr-2">Event</span>
@@ -1869,6 +1920,60 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
})()}
</div>
)}
{report === "seriesComparison" && (
<div data-testid="series-comparison">
{!comparison ? (
<div className="text-sm text-gray-500">{seriesList.length === 0 ? "No series yet — make one on the Event series page and put events in it." : "Choose a series above."}</div>
) : comparison.events.length === 0 ? (
<div className="text-sm text-gray-500">{comparison.series.name} has no events yet (or none at your campuses).</div>
) : (() => {
const last = comparison.events[comparison.events.length - 1];
const change = (c: number | null | undefined) => c == null ? null : <span className={`ml-1 text-xs ${c > 0 ? "text-green-700" : c < 0 ? "text-rose-700" : "text-gray-500"}`}>{changeLabel(c)}</span>;
return (
<>
<StatTileRow>
<StatTile icon={Users} label={`Registrations, ${last.title}`} value={`${last.registrations}${last.change?.registrations != null ? ` (${changeLabel(last.change.registrations)})` : ""}`} tone="violet" />
<StatTile icon={TrendingUp} label="Income" value={`R ${last.income.toFixed(2)}${last.change?.income != null ? ` (${changeLabel(last.change.income)})` : ""}`} tone="green" />
<StatTile icon={TicketIcon} label="Attendance" value={last.attendanceRate != null ? `${last.attendanceRate}%` : "—"} tone="blue" />
</StatTileRow>
{comparison.events.length > 1 && (
<div className="mt-4 grid md:grid-cols-2 gap-4">
<div className="border border-gray-100 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-3">Registrations, by event</div>
<HorizontalBarChart data={comparison.events.map(e => ({ label: e.title, value: e.registrations }))} valueFormatter={v => String(v)} />
</div>
<div className="border border-gray-100 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-3">Income, by event</div>
<HorizontalBarChart data={comparison.events.map(e => ({ label: e.title, value: e.income }))} valueFormatter={v => `R ${v.toFixed(2)}`} />
</div>
</div>
)}
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="text-left text-xs text-gray-500 border-b">
<th className="py-2 pr-3">Event</th><th className="py-2 pr-3">Date</th><th className="py-2 pr-3">Registrations</th><th className="py-2 pr-3">Tickets</th><th className="py-2 pr-3">Income</th><th className="py-2 pr-3">Checked in</th><th className="py-2">Attendance</th>
</tr></thead>
<tbody>
{comparison.events.map(e => (
<tr key={e.eventId} className="border-b last:border-0" data-testid="comparison-row">
<td className="py-2 pr-3 font-medium">{e.title}{e.campus && <span className="ml-1 text-[10px] text-gray-500">· {e.campus.name}</span>}</td>
<td className="py-2 pr-3 text-gray-600">{new Date(e.startDate).toLocaleDateString()}</td>
<td className="py-2 pr-3">{e.registrations}{change(e.change?.registrations)}</td>
<td className="py-2 pr-3">{e.tickets}{change(e.change?.tickets)}</td>
<td className="py-2 pr-3">R {e.income.toFixed(2)}{change(e.change?.income)}</td>
<td className="py-2 pr-3">{e.checkedIn}{e.sessions > 0 && <span className="text-[10px] text-gray-500"> avg/session</span>}{change(e.change?.checkedIn)}</td>
<td className="py-2">{e.attendanceRate != null ? `${e.attendanceRate}%` : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-gray-500 mt-3">Each change is against the event before. Income leaves out donations applied to orders (they count when they were given).</p>
</>
);
})()}
</div>
)}
{report === "feedbackSummary" && (
<div>
{feedbackSummary.perEvent.length === 0 ? (
+1 -1
View File
@@ -29,7 +29,7 @@ export const reportsHelpContent: HelpContent = {
Preview your report, adjust filters, and choose how you want the data to appear.
</GuideItem>
<GuideItem icon={Download} title="Export or email" tone="amber">
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026). The Registration funnel shows where people drop off between viewing an event and paying, by source and device. The source comes from short links and utm_source links, else the site the visitor came from (Facebook, Instagram, Google…). The event page&apos;s Share and Save QR buttons tag their links as &quot;share&quot; and &quot;qr&quot;. WhatsApp doesn&apos;t say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as &quot;direct&quot;. It counts only visitors who allowed cookies.
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026), or use Series: year on year to compare a whole series&apos; registrations, income and attendance. The Registration funnel shows where people drop off between viewing an event and paying, by source and device. The source comes from short links and utm_source links, else the site the visitor came from (Facebook, Instagram, Google…). The event page&apos;s Share and Save QR buttons tag their links as &quot;share&quot; and &quot;qr&quot;. WhatsApp doesn&apos;t say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as &quot;direct&quot;. It counts only visitors who allowed cookies.
</GuideItem>
</div>
</div>
+4 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Layers, Calendar, Bell, MapPin, Copy } from "lucide-react";
import { Layers, Calendar, Bell, MapPin, Copy, BarChart3 } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -28,6 +28,9 @@ export const seriesHelpContent: HelpContent = {
<GuideItem icon={Bell} title="Following" tone="amber">
People with an account can follow a series on its page. When a new event in the series goes live (and before it starts) followers get one email. It counts as <strong>News &amp; announcements</strong>, so only people who chose to get news receive it, with an unsubscribe link.
</GuideItem>
<GuideItem icon={BarChart3} title="Year on year" tone="green">
&quot;Compare&quot; opens Reports → Series: year on year — each event in the series side by side (registrations, tickets, income, how many came) with how much it went up or down from the one before.
</GuideItem>
<GuideItem icon={MapPin} title="Campuses" tone="gray">
A series can have events at different campuses. Supervisors limited to some campuses see the series, but only their campuses&apos; events in it.
</GuideItem>
+9
View File
@@ -203,6 +203,15 @@ export type EventSeries = EventSeriesRef & {
events: { id: string; title: string; startDate: string; endDate: string; isActive: boolean; isHidden: boolean; campus?: Event["campus"] }[];
};
/** GET /api/series/:id/compare — one row per event in the series, oldest first. */
export type SeriesComparisonRow = {
eventId: string; title: string; startDate: string; endDate: string; campus?: Event["campus"]; isActive: boolean;
sessions: number; registrations: number; tickets: number; income: number; checkedIn: number; attendanceRate: number | null;
// % up or down from the event before (null on the first, or when the one before had none).
change: { registrations: number | null; tickets: number | null; income: number | null; checkedIn: number | null } | null;
};
export type SeriesComparison = { series: EventSeriesRef; events: SeriesComparisonRow[] };
export type EventSession = {
id: string;
eventId?: string;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.47.1",
"version": "1.47.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.47.1",
"version": "1.47.2",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.47.1",
"version": "1.47.2",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",