Skip to content

Commit 2f42fe7

Browse files
authored
Add capacity insights to admin stats dashboard (#129)
* chore(admin): Log duplicate check-ins * Hide menu options for volunteers * chore: add test helpers for admin components * chore: prep admin stats release * oops --------- Co-authored-by: Joel Meaders <joelmeaders@outlook.com>
1 parent a1d75ec commit 2f42fe7

File tree

9 files changed

+332
-6
lines changed

9 files changed

+332
-6
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to a versioning scheme of `year.minor.patch`.
77

8+
## [2025.2.1] - 2025-11-15
9+
10+
### Added
11+
12+
- **Registration Capacity Insights**: Admin stats dashboard now surfaces "Capacity by Day" cards with donut charts so admins can see used, remaining, and overflow slots at a glance.
13+
- **Test Helper Enhancements**: Added reusable FireRepoLite and PROGRAM_YEAR providers to simplify Angular admin component testing.
14+
15+
### Changed
16+
17+
- **Schedule Charts**: Each schedule bar chart now shows the total number of families for that day directly in the heading for faster scanning.
18+
- **Admin Detection Logic**: Landing page admin toggle now keys off `meaders` email addresses to keep the new stats tooling limited to the intended team.
19+
820
## [2025.2.0] - 2025-11-15
921

1022
### Added

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "santasworkshop",
3-
"version": "2025.2.0",
3+
"version": "2025.2.1",
44
"author": "Joel Meaders",
55
"private": true,
66
"scripts": {

santashop-admin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@santashop/admin",
3-
"version": "2025.2.0",
3+
"version": "2025.2.1",
44
"description": "",
55
"scripts": {
66
"lint": "ng lint santashop-admin --fix --cache",

santashop-admin/src/app/pages/admin/checkin/review/review.page.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { ReviewPage } from './review.page';
33
import {
44
provideFirestoreWrapperMock,
55
provideFunctionsMock,
6+
provideModalControllerMock,
7+
provideAlertControllerMock,
68
} from '../../../../../test-helpers';
79
import { provideRouter } from '@angular/router';
810

@@ -16,6 +18,8 @@ describe('ReviewPage', () => {
1618
providers: [
1719
provideFirestoreWrapperMock(),
1820
provideFunctionsMock(),
21+
provideModalControllerMock(),
22+
provideAlertControllerMock(),
1923
provideRouter([]),
2024
],
2125
}).compileComponents();

santashop-admin/src/app/pages/admin/stats/registration/registration.page.html

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,54 @@ <h3>Children per Customer*</h3>
7070
</ion-row>
7171

7272
@if ((hasScheduleData$ | async) === true) {
73+
<ion-row *appLet="capacityByDay$ | async as capacities">
74+
<ion-col size="12">
75+
<h2>Capacity by Day</h2>
76+
</ion-col>
77+
@for (capacity of capacities; track capacity.label) {
78+
<ion-col sizeXs="12" sizeSm="6" sizeMd="3" sizeLg="3">
79+
<div class="capacity-card">
80+
<div class="capacity-card__header">
81+
<h3>{{ capacity.label }}</h3>
82+
<div class="capacity-card__percent">
83+
{{ capacity.percent | number:'1.0-1' }}%
84+
</div>
85+
</div>
86+
<canvas
87+
baseChart
88+
[data]="capacity.chartData"
89+
[options]="chartOptions"
90+
[legend]="false"
91+
type="doughnut"
92+
></canvas>
93+
<div class="capacity-card__meta">
94+
<span>Used: {{ capacity.used | number }}</span>
95+
<span>Total: {{ capacity.total | number }}</span>
96+
@if (capacity.overflow === 0) {
97+
<span
98+
>Remaining: {{ capacity.remaining | number }}</span
99+
>
100+
} @if (capacity.overflow > 0) {
101+
<span>
102+
Overflow: {{ capacity.overflow | number }}
103+
</span>
104+
}
105+
</div>
106+
</div>
107+
</ion-col>
108+
}
109+
</ion-row>
110+
73111
<ion-row *appLet="familiesBySlotsChartData$ | async as slots">
74112
<ion-col size="12">
75113
<h2>Schedules by Day</h2>
76114
</ion-col>
77115
@for (slot of slots; track slot; let i = $index) {
78116
<ion-col sizeXs="12" sizeSm="12" sizeMd="6" sizeLg="6">
79-
<h3>{{ slot.datasets[0].label }}</h3>
117+
<h3>
118+
{{ slot.datasets[0].label }} - Total: {{
119+
getTotalCount(slot.datasets[0].data) }}
120+
</h3>
80121
<canvas
81122
baseChart
82123
[options]="barChartOptions"

santashop-admin/src/app/pages/admin/stats/registration/registration.page.scss

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,47 @@ h3 {
2525
rgba(0, 0, 0, 0.04) 0px 10px 10px -5px;
2626
border-radius: 24px;
2727
}
28+
29+
.capacity-card {
30+
padding: 16px;
31+
display: flex;
32+
flex-direction: column;
33+
gap: 12px;
34+
box-shadow:
35+
rgba(0, 0, 0, 0.1) 0px 20px 25px -5px,
36+
rgba(0, 0, 0, 0.04) 0px 10px 10px -5px;
37+
border-radius: 24px;
38+
}
39+
40+
.capacity-card h3 {
41+
text-align: left;
42+
margin: 0;
43+
}
44+
45+
.capacity-card__header {
46+
display: flex;
47+
align-items: center;
48+
justify-content: space-between;
49+
gap: 12px;
50+
}
51+
52+
.capacity-card__date {
53+
font-size: 0.95rem;
54+
color: #666;
55+
margin-left: auto;
56+
}
57+
58+
.capacity-card__percent {
59+
font-size: 2rem;
60+
font-weight: 700;
61+
color: #3f51b5;
62+
}
63+
64+
.capacity-card__meta {
65+
display: flex;
66+
flex-direction: column;
67+
gap: 4px;
68+
font-size: 0.95rem;
69+
text-align: left;
70+
}
71+

santashop-admin/src/app/pages/admin/stats/registration/registration.page.ts

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import {
55
filterNil,
66
CoreModule,
77
shopSchedule,
8+
timestampToDate,
89
} from '@santashop/core';
910
import {
1011
COLLECTION_SCHEMA,
12+
DateTimeSlot,
1113
RegistrationStats,
1214
ScheduleStats,
1315
} from '@santashop/models';
@@ -40,7 +42,7 @@ import {
4042
IonTitle,
4143
} from '@ionic/angular/standalone';
4244
import { FormsModule } from '@angular/forms';
43-
import { Timestamp } from '@angular/fire/firestore';
45+
import { QueryConstraint, Timestamp, where } from '@angular/fire/firestore';
4446

4547
Chart.register(ChartDataLabels);
4648

@@ -77,6 +79,12 @@ export class RegistrationPage {
7779
private readonly statsCollection = <T>(): IFireRepoCollection<T> =>
7880
this.httpService.collection<T>(COLLECTION_SCHEMA.stats);
7981

82+
private readonly dateTimeSlotCollection =
83+
(): IFireRepoCollection<DateTimeSlot> =>
84+
this.httpService.collection<DateTimeSlot>(
85+
COLLECTION_SCHEMA.dateTimeSlots,
86+
);
87+
8088
private readonly registrationStats$ = this.refreshYear.pipe(
8189
switchMap(() =>
8290
this.statsCollection<RegistrationStats>()
@@ -93,6 +101,36 @@ export class RegistrationPage {
93101
),
94102
);
95103

104+
private readonly dateTimeSlots$ = this.refreshYear.pipe(
105+
switchMap(() => {
106+
const queryConstraints: QueryConstraint[] = [
107+
where('programYear', '==', this.year),
108+
];
109+
110+
return this.dateTimeSlotCollection()
111+
.readMany(queryConstraints, 'id')
112+
.pipe(
113+
map((slots) =>
114+
slots.map((slot) => ({
115+
...slot,
116+
dateTime:
117+
slot.dateTime instanceof Date
118+
? slot.dateTime
119+
: timestampToDate(slot.dateTime),
120+
})),
121+
),
122+
map((slots) =>
123+
slots.sort(
124+
(a, b) =>
125+
(a.dateTime as Date).valueOf() -
126+
(b.dateTime as Date).valueOf(),
127+
),
128+
),
129+
);
130+
}),
131+
shareReplay(1),
132+
);
133+
96134
public readonly hasScheduleData$ = this.scheduleStats$.pipe(
97135
map((schedule) => !!schedule),
98136
);
@@ -235,6 +273,17 @@ export class RegistrationPage {
235273
borderWidth: 1,
236274
};
237275

276+
private readonly capacityColorSettings = {
277+
used: 'rgba(63, 81, 181, 0.85)',
278+
available: 'rgba(102, 187, 106, 0.8)',
279+
overflow: 'rgba(255, 99, 132, 0.85)',
280+
border: '#ffffff',
281+
};
282+
283+
public readonly capacityByDay$ = this.dateTimeSlots$.pipe(
284+
map((slots) => this.mapSlotsToCapacityCharts(slots)),
285+
);
286+
238287
public zipCodeOptions: ChartConfiguration['options'] = {
239288
responsive: true,
240289
plugins: {
@@ -373,4 +422,121 @@ export class RegistrationPage {
373422

374423
return day + 'th';
375424
}
425+
426+
public getTotalCount(data: number[]): number {
427+
return data.reduce((a, b) => a + b, 0);
428+
}
429+
430+
private mapSlotsToCapacityCharts(
431+
slots: DateTimeSlot[],
432+
): DayCapacityChart[] {
433+
const schedule = this.schedule.find((s) => s.year === this.year);
434+
if (!schedule) return [];
435+
436+
const grouped = slots.reduce<Map<number, DateTimeSlot[]>>(
437+
(acc, slot) => {
438+
const date =
439+
slot.dateTime instanceof Date
440+
? slot.dateTime
441+
: (slot.dateTime as Timestamp).toDate();
442+
const day = date.getDate();
443+
const existing = acc.get(day) ?? [];
444+
existing.push(slot);
445+
acc.set(day, existing);
446+
return acc;
447+
},
448+
new Map(),
449+
);
450+
451+
return schedule.days.map((day) => {
452+
const label = this.friendlyDay(day);
453+
const daySlots = grouped.get(day) ?? [];
454+
const dateValue = daySlots[0]
455+
? (daySlots[0].dateTime as Date)
456+
: undefined;
457+
const dateLabel = dateValue
458+
? (dateValue as Date).toLocaleDateString('en-US', {
459+
month: 'short',
460+
day: 'numeric',
461+
})
462+
: '';
463+
const stats = daySlots.reduce(
464+
(acc, slot) => {
465+
const max = slot.maxSlots ?? 0;
466+
const used = slot.slotsReserved ?? 0;
467+
acc.total += max;
468+
acc.used += used;
469+
return acc;
470+
},
471+
{ used: 0, total: 0 },
472+
);
473+
474+
const overflow = Math.max(stats.used - stats.total, 0);
475+
const usedWithinCapacity = Math.min(stats.used, stats.total);
476+
const remaining = Math.max(stats.total - usedWithinCapacity, 0);
477+
const percent =
478+
stats.total === 0 ? 0 : (stats.used / stats.total) * 100;
479+
480+
return {
481+
label,
482+
dateLabel,
483+
percent,
484+
used: stats.used,
485+
total: stats.total,
486+
remaining,
487+
overflow,
488+
chartData: this.buildCapacityChartData(
489+
usedWithinCapacity,
490+
remaining,
491+
overflow,
492+
),
493+
};
494+
});
495+
}
496+
497+
private buildCapacityChartData(
498+
used: number,
499+
remaining: number,
500+
overflow: number,
501+
): ChartData<'doughnut', number[], string | string[]> {
502+
const labels =
503+
overflow > 0 ? ['Used', 'Overflow'] : ['Used', 'Remaining'];
504+
const data = overflow > 0 ? [used, overflow] : [used, remaining];
505+
const backgroundColor =
506+
overflow > 0
507+
? [
508+
this.capacityColorSettings.used,
509+
this.capacityColorSettings.overflow,
510+
]
511+
: [
512+
this.capacityColorSettings.used,
513+
this.capacityColorSettings.available,
514+
];
515+
const borderColor = new Array(labels.length).fill(
516+
this.capacityColorSettings.border,
517+
);
518+
519+
return {
520+
labels,
521+
datasets: [
522+
{
523+
data,
524+
backgroundColor,
525+
borderColor,
526+
borderWidth: 1,
527+
},
528+
],
529+
};
530+
}
531+
}
532+
533+
interface DayCapacityChart {
534+
label: string;
535+
dateLabel: string;
536+
percent: number;
537+
used: number;
538+
total: number;
539+
remaining: number;
540+
overflow: number;
541+
chartData: ChartData<'doughnut', number[], string | string[]>;
376542
}

0 commit comments

Comments
 (0)