Skip to content

Commit

Permalink
feat: Render basic graph for event sales
Browse files Browse the repository at this point in the history
  • Loading branch information
beverloo committed Feb 22, 2025
1 parent 8fecbdc commit 5ebf862
Show file tree
Hide file tree
Showing 4 changed files with 183 additions and 9 deletions.
136 changes: 133 additions & 3 deletions app/admin/events/[event]/finance/graphs/EventSalesGraph.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
// Copyright 2025 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { useId } from 'react';

import { ResponsiveChartContainer, type ResponsiveChartContainerProps } from '@mui/x-charts/ResponsiveChartContainer';
import { ChartsClipPath } from '@mui/x-charts/ChartsClipPath';
import { ChartsReferenceLine } from '@mui/x-charts/ChartsReferenceLine';
import { ChartsXAxis } from '@mui/x-charts/ChartsXAxis';
import { ChartsYAxis } from '@mui/x-charts/ChartsYAxis';
import { LinePlot } from '@mui/x-charts/LineChart';
import Typography from '@mui/material/Typography';

import type { EventSalesCategory } from '@lib/database/Types';
import { Temporal, isBefore, isAfter } from '@lib/Temporal';
import db, { tActivities, tEventsSales } from '@lib/database';

import { kMaximumColor, kTodayColor } from './GraphCommon';

/**
* Props accepted by the <EventSalesGraph> component.
Expand All @@ -18,9 +32,9 @@ export interface EventSalesGraphProps {
category: EventSalesCategory;

/**
* URL-safe slug of the event for which the graph should be displayed.
* Unique ID of the event for which data should be displayed.
*/
event: string;
eventId: number;

/**
* Optional limit indicating the maximum number of tickets that can be sold.
Expand All @@ -31,16 +45,132 @@ export interface EventSalesGraphProps {
* String of products that will be counted towards this graph.
*/
products: string[];

/**
* Range of the graph, indicated as the first and last date to display. Dates must be formatted
* in a Temporal PlainDate-compatible format.
*/
range: [ string, string ];
}

/**
* The <EventSalesGraph> component displays a graph specific to the sales of a particular event part
* of one of our festivals. Sales of this kind may have a limit set as well.
*/
export async function EventSalesGraph(props: EventSalesGraphProps) {
const today = Temporal.Now.plainDateISO();
const start = Temporal.PlainDate.from(props.range[0]);
const end = Temporal.PlainDate.from(props.range[1]);

const clipPathId = useId();
const dbInstance = db;

// TODO: Enable linking the user through to the associated activity
// TODO: Enable hover dialogs to indicate what's being looked at
// TODO: Consider enabling a grid on the graph, for additional clarity

// ---------------------------------------------------------------------------------------------
// Determine the title of the graph. When an Activity ID is given this will be used, with the
// label of the first product being the fallback.

let title = props.products[0];

if (!!props.activityId) {
title = await dbInstance.selectFrom(tActivities)
.where(tActivities.activityId.equals(props.activityId))
.and(tActivities.activityDeleted.isNull())
.selectOneColumn(tActivities.activityTitle)
.executeSelectNoneOrOne() ?? props.products[0];
}

// ---------------------------------------------------------------------------------------------
// Determine labels on the X axis. This will be the range of dates displayed on the graph, which
// is controlled by the |start| and |end| properties.

const xLabels = [];

for (let date = start; !isAfter(date, end); date = date.add({ days: 1 }))
xLabels.push(date.toString());

// ---------------------------------------------------------------------------------------------
// Determine series of the graph. A series is created for each of the products, and one more
// series showing total sales across them in case there are multiple.

const series: ResponsiveChartContainerProps['series'] = [];
{
type SeriesData = Map<string, number>;
type SeriesMap = Map<string, SeriesData>;

const salesDatabaseData = await dbInstance.selectFrom(tEventsSales)
.where(tEventsSales.eventId.equals(props.eventId))
.and(tEventsSales.eventSaleType.in(props.products))
.select({
date: dbInstance.dateAsString(tEventsSales.eventSaleDate),
type: tEventsSales.eventSaleType,
count: tEventsSales.eventSaleCount,
})
.executeSelectMany();

const salesData: SeriesMap = new Map();
for (const entry of salesDatabaseData) {
if (!salesData.has(entry.type))
salesData.set(entry.type, new Map());

salesData.get(entry.type)!.set(entry.date, entry.count);
}

for (const product of props.products) {
if (!salesData.has(product))
continue; // no sales data, skip this product

const productSalesData = salesData.get(product)!;
let totalProductSales = 0;

const data = [];

for (let date = start; !isAfter(date, end); date = date.add({ days: 1 })) {
const productSales = productSalesData.get(date.toString());
if (productSales !== undefined && productSales > 0)
totalProductSales += productSales;

data.push(totalProductSales);
}

series.push({
data,
label: product,
type: 'line',
});
}
}

// ---------------------------------------------------------------------------------------------

return (
<>
EventSalesGraph ({props.products.join(', ')})
<Typography variant="h5">
{title}
</Typography>
<ResponsiveChartContainer series={series} height={300} margin={{ top: 24 }}
xAxis={[ { scaleType: 'point', data: xLabels } ]}>
<g clipPath={clipPathId}>
<LinePlot />
{ (!isBefore(today, start) && !isAfter(today, end)) &&
<ChartsReferenceLine x={today.toString()}
lineStyle={{
stroke: kTodayColor
}} /> }
{ !!props.limit &&
<ChartsReferenceLine y={props.limit}
lineStyle={{
strokeDasharray: 4,
stroke: kMaximumColor
}} /> }
</g>
<ChartsXAxis />
<ChartsYAxis />
<ChartsClipPath id={clipPathId} />
</ResponsiveChartContainer>
</>
);
}
12 changes: 12 additions & 0 deletions app/admin/events/[event]/finance/graphs/GraphCommon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright 2025 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

/**
* Colour in which the line visualising maximum ticket sales should be displayed.
*/
export const kMaximumColor = '#C62828';

/**
* Colour in which the line visualising today's date should be displayed.
*/
export const kTodayColor = '#1976D2';
10 changes: 8 additions & 2 deletions app/admin/events/[event]/finance/graphs/TicketSalesGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,20 @@ export interface TicketSalesGraphProps {
category: EventSalesCategory;

/**
* URL-safe slug of the event for which the graph should be displayed.
* Unique ID of the event for which data should be displayed.
*/
event: string;
eventId: number;

/**
* String of products that will be counted towards this graph.
*/
products: string[];

/**
* Range of the graph, indicated as the first and last date to display. Dates must be formatted
* in a Temporal PlainDate-compatible format.
*/
range: [ string, string ];
}

/**
Expand Down
34 changes: 30 additions & 4 deletions app/admin/events/[event]/finance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import { SalesConfigurationSection } from './SalesConfigurationSection';
import { SalesUploadSection } from './SalesUploadSection';
import { Section } from '@app/admin/components/Section';
import { SectionIntroduction } from '@app/admin/components/SectionIntroduction';
import { Temporal } from '@lib/Temporal';
import { generateEventMetadataFn } from '../generateEventMetadataFn';
import { verifyAccessAndFetchPageInfo } from '@app/admin/events/verifyAccessAndFetchPageInfo';
import { readUserSetting } from '@lib/UserSettings';
import db, { tEventsSalesConfiguration } from '@lib/database';
import db, { tEvents, tEventsSalesConfiguration, tEventsSales } from '@lib/database';

import { type EventSalesGraphProps, EventSalesGraph } from './graphs/EventSalesGraph';
import { type TicketSalesGraphProps, TicketSalesGraph } from './graphs/TicketSalesGraph';
Expand All @@ -32,15 +33,38 @@ export default async function EventFinancePage(props: NextPageParams<'event'>) {
permission: 'statistics.finances',
});

const dbInstance = db;

// Those who are allowed to manage an event's settings can manage financial information as well,
// even though that does not necessarily mean that they can access the source data.
const canManageFinances = access.can('event.settings', {
event: event.slug,
});

// ---------------------------------------------------------------------------------------------
// Determine the date ranges that should be displayed on the graphs. All graphs will show data
// along the same X axes to make sure that they're visually comparable.

const minimumRange = await dbInstance.selectFrom(tEventsSales)
.where(tEventsSales.eventId.equals(event.id))
.selectOneColumn(tEventsSales.eventSaleDate)
.orderBy(tEventsSales.eventSaleDate, 'asc')
.limit(1)
.executeSelectNoneOrOne() ?? Temporal.Now.plainDateISO().subtract({ days: 90 });

const maximumRange = await dbInstance.selectFrom(tEvents)
.where(tEvents.eventId.equals(event.id))
.selectOneColumn(tEvents.eventEndTime)
.executeSelectNoneOrOne() ?? Temporal.Now.zonedDateTimeISO().add({ days: 14 });

const range: [ string, string ] = [
minimumRange.toString(),
maximumRange.toPlainDate().toString()
];

const dbInstance = db;
// ---------------------------------------------------------------------------------------------
// Determine that graphs that have to be displayed. This is depending on the configuration that
// has been set for the event. Ticket graphs will be displayed before event graphs.

const graphs = await dbInstance.selectFrom(tEventsSalesConfiguration)
.where(tEventsSalesConfiguration.eventId.equals(event.id))
Expand All @@ -64,9 +88,10 @@ export default async function EventFinancePage(props: NextPageParams<'event'>) {
eventGraphs.push({
activityId: graph.saleEventId,
category: graph.category,
event: event.slug,
eventId: event.id,
limit: graph.categoryLimit,
products: graph.saleTypes,
range,
});
break;

Expand All @@ -80,8 +105,9 @@ export default async function EventFinancePage(props: NextPageParams<'event'>) {
case kEventSalesCategory.TicketWeekend:
ticketGraphs.push({
category: graph.category,
event: event.slug,
eventId: event.id,
products: graph.saleTypes,
range,
});
break;

Expand Down

0 comments on commit 5ebf862

Please sign in to comment.