Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to a versioning scheme of `year.minor.patch`.

## [2025.2.1] - 2025-11-15

### Added

- **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.
- **Test Helper Enhancements**: Added reusable FireRepoLite and PROGRAM_YEAR providers to simplify Angular admin component testing.

### Changed

- **Schedule Charts**: Each schedule bar chart now shows the total number of families for that day directly in the heading for faster scanning.
- **Admin Detection Logic**: Landing page admin toggle now keys off `meaders` email addresses to keep the new stats tooling limited to the intended team.

## [2025.2.0] - 2025-11-15

### Added
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "santasworkshop",
"version": "2025.2.0",
"version": "2025.2.1",
"author": "Joel Meaders",
"private": true,
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion santashop-admin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@santashop/admin",
"version": "2025.2.0",
"version": "2025.2.1",
"description": "",
"scripts": {
"lint": "ng lint santashop-admin --fix --cache",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { ReviewPage } from './review.page';
import {
provideFirestoreWrapperMock,
provideFunctionsMock,
provideModalControllerMock,
provideAlertControllerMock,
} from '../../../../../test-helpers';
import { provideRouter } from '@angular/router';

Expand All @@ -16,6 +18,8 @@ describe('ReviewPage', () => {
providers: [
provideFirestoreWrapperMock(),
provideFunctionsMock(),
provideModalControllerMock(),
provideAlertControllerMock(),
provideRouter([]),
],
}).compileComponents();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,54 @@ <h3>Children per Customer*</h3>
</ion-row>

@if ((hasScheduleData$ | async) === true) {
<ion-row *appLet="capacityByDay$ | async as capacities">
<ion-col size="12">
<h2>Capacity by Day</h2>
</ion-col>
@for (capacity of capacities; track capacity.label) {
<ion-col sizeXs="12" sizeSm="6" sizeMd="3" sizeLg="3">
<div class="capacity-card">
<div class="capacity-card__header">
<h3>{{ capacity.label }}</h3>
<div class="capacity-card__percent">
{{ capacity.percent | number:'1.0-1' }}%
</div>
</div>
<canvas
baseChart
[data]="capacity.chartData"
[options]="chartOptions"
[legend]="false"
type="doughnut"
></canvas>
<div class="capacity-card__meta">
<span>Used: {{ capacity.used | number }}</span>
<span>Total: {{ capacity.total | number }}</span>
@if (capacity.overflow === 0) {
<span
>Remaining: {{ capacity.remaining | number }}</span
>
} @if (capacity.overflow > 0) {
<span>
Overflow: {{ capacity.overflow | number }}
</span>
}
</div>
</div>
</ion-col>
}
</ion-row>

<ion-row *appLet="familiesBySlotsChartData$ | async as slots">
<ion-col size="12">
<h2>Schedules by Day</h2>
</ion-col>
@for (slot of slots; track slot; let i = $index) {
<ion-col sizeXs="12" sizeSm="12" sizeMd="6" sizeLg="6">
<h3>{{ slot.datasets[0].label }}</h3>
<h3>
{{ slot.datasets[0].label }} - Total: {{
getTotalCount(slot.datasets[0].data) }}
</h3>
<canvas
baseChart
[options]="barChartOptions"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,47 @@ h3 {
rgba(0, 0, 0, 0.04) 0px 10px 10px -5px;
border-radius: 24px;
}

.capacity-card {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
box-shadow:
rgba(0, 0, 0, 0.1) 0px 20px 25px -5px,
rgba(0, 0, 0, 0.04) 0px 10px 10px -5px;
border-radius: 24px;
}

.capacity-card h3 {
text-align: left;
margin: 0;
}

.capacity-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}

.capacity-card__date {
font-size: 0.95rem;
color: #666;
margin-left: auto;
}

.capacity-card__percent {
font-size: 2rem;
font-weight: 700;
color: #3f51b5;
}

.capacity-card__meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.95rem;
text-align: left;
}

Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import {
filterNil,
CoreModule,
shopSchedule,
timestampToDate,
} from '@santashop/core';
import {
COLLECTION_SCHEMA,
DateTimeSlot,
RegistrationStats,
ScheduleStats,
} from '@santashop/models';
Expand Down Expand Up @@ -40,7 +42,7 @@ import {
IonTitle,
} from '@ionic/angular/standalone';
import { FormsModule } from '@angular/forms';
import { Timestamp } from '@angular/fire/firestore';
import { QueryConstraint, Timestamp, where } from '@angular/fire/firestore';

Chart.register(ChartDataLabels);

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

private readonly dateTimeSlotCollection =
(): IFireRepoCollection<DateTimeSlot> =>
this.httpService.collection<DateTimeSlot>(
COLLECTION_SCHEMA.dateTimeSlots,
);

private readonly registrationStats$ = this.refreshYear.pipe(
switchMap(() =>
this.statsCollection<RegistrationStats>()
Expand All @@ -93,6 +101,36 @@ export class RegistrationPage {
),
);

private readonly dateTimeSlots$ = this.refreshYear.pipe(
switchMap(() => {
const queryConstraints: QueryConstraint[] = [
where('programYear', '==', this.year),
];

return this.dateTimeSlotCollection()
.readMany(queryConstraints, 'id')
.pipe(
map((slots) =>
slots.map((slot) => ({
...slot,
dateTime:
slot.dateTime instanceof Date
? slot.dateTime
: timestampToDate(slot.dateTime),
})),
),
map((slots) =>
slots.sort(
(a, b) =>
(a.dateTime as Date).valueOf() -
(b.dateTime as Date).valueOf(),
),
),
);
}),
shareReplay(1),
);

public readonly hasScheduleData$ = this.scheduleStats$.pipe(
map((schedule) => !!schedule),
);
Expand Down Expand Up @@ -235,6 +273,17 @@ export class RegistrationPage {
borderWidth: 1,
};

private readonly capacityColorSettings = {
used: 'rgba(63, 81, 181, 0.85)',
available: 'rgba(102, 187, 106, 0.8)',
overflow: 'rgba(255, 99, 132, 0.85)',
border: '#ffffff',
};

public readonly capacityByDay$ = this.dateTimeSlots$.pipe(
map((slots) => this.mapSlotsToCapacityCharts(slots)),
);

public zipCodeOptions: ChartConfiguration['options'] = {
responsive: true,
plugins: {
Expand Down Expand Up @@ -373,4 +422,121 @@ export class RegistrationPage {

return day + 'th';
}

public getTotalCount(data: number[]): number {
return data.reduce((a, b) => a + b, 0);
}

private mapSlotsToCapacityCharts(
slots: DateTimeSlot[],
): DayCapacityChart[] {
const schedule = this.schedule.find((s) => s.year === this.year);
if (!schedule) return [];

const grouped = slots.reduce<Map<number, DateTimeSlot[]>>(
(acc, slot) => {
const date =
slot.dateTime instanceof Date
? slot.dateTime
: (slot.dateTime as Timestamp).toDate();
const day = date.getDate();
const existing = acc.get(day) ?? [];
existing.push(slot);
acc.set(day, existing);
return acc;
},
new Map(),
);

return schedule.days.map((day) => {
const label = this.friendlyDay(day);
const daySlots = grouped.get(day) ?? [];
const dateValue = daySlots[0]
? (daySlots[0].dateTime as Date)
: undefined;
const dateLabel = dateValue
? (dateValue as Date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
: '';
const stats = daySlots.reduce(
(acc, slot) => {
const max = slot.maxSlots ?? 0;
const used = slot.slotsReserved ?? 0;
acc.total += max;
acc.used += used;
return acc;
},
{ used: 0, total: 0 },
);

const overflow = Math.max(stats.used - stats.total, 0);
const usedWithinCapacity = Math.min(stats.used, stats.total);
const remaining = Math.max(stats.total - usedWithinCapacity, 0);
const percent =
stats.total === 0 ? 0 : (stats.used / stats.total) * 100;

return {
label,
dateLabel,
percent,
used: stats.used,
total: stats.total,
remaining,
overflow,
chartData: this.buildCapacityChartData(
usedWithinCapacity,
remaining,
overflow,
),
};
});
}

private buildCapacityChartData(
used: number,
remaining: number,
overflow: number,
): ChartData<'doughnut', number[], string | string[]> {
const labels =
overflow > 0 ? ['Used', 'Overflow'] : ['Used', 'Remaining'];
const data = overflow > 0 ? [used, overflow] : [used, remaining];
const backgroundColor =
overflow > 0
? [
this.capacityColorSettings.used,
this.capacityColorSettings.overflow,
]
: [
this.capacityColorSettings.used,
this.capacityColorSettings.available,
];
const borderColor = new Array(labels.length).fill(
this.capacityColorSettings.border,
);

return {
labels,
datasets: [
{
data,
backgroundColor,
borderColor,
borderWidth: 1,
},
],
};
}
}

interface DayCapacityChart {
label: string;
dateLabel: string;
percent: number;
used: number;
total: number;
remaining: number;
overflow: number;
chartData: ChartData<'doughnut', number[], string | string[]>;
}
Loading