diff --git a/.vscode/settings.json b/.vscode/settings.json index c657932..afb19be 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -40,7 +40,8 @@ "task.autoDetect": "off", "[xml]": { "editor.defaultFormatter": "DotJoshJohnson.xml" - } + }, + "cSpell.words": ["Minigolf", "Timeslot", "Timeslots"] // Remove comments if branch policies are used // "git.branchProtection": ["main", "master"], diff --git a/src/client/src/app/+state/events/actions/event-timeslot-registration-changed.action.ts b/src/client/src/app/+state/events/actions/event-timeslot-registration-changed.action.ts index 2f71488..4271e94 100644 --- a/src/client/src/app/+state/events/actions/event-timeslot-registration-changed.action.ts +++ b/src/client/src/app/+state/events/actions/event-timeslot-registration-changed.action.ts @@ -1,14 +1,14 @@ import { createAction, on, props } from '@ngrx/store'; import { produce } from 'immer'; -import { PlayerEventTimeslotRegistrationChanged } from '../../../models/realtime-events'; +import { PlayerEventTimeslotRegistrationChangedRealtimeEvent } from '../../../models/realtime-events'; import { Reducers } from '../../utils'; import { EVENTS_ACTION_SCOPE } from '../consts'; import { eventEntityAdapter, EventsFeatureState } from '../events.state'; export const eventTimeslotRegistrationChangedAction = createAction( `[${EVENTS_ACTION_SCOPE}] Event Timeslot Registration Changed`, - props() + props() ); export const eventTimeslotRegistrationChangedReducers: Reducers = [ diff --git a/src/client/src/app/+state/player-events/actions/event-timeslot-registration-changed.action.ts b/src/client/src/app/+state/player-events/actions/event-timeslot-registration-changed.action.ts new file mode 100644 index 0000000..6c70eca --- /dev/null +++ b/src/client/src/app/+state/player-events/actions/event-timeslot-registration-changed.action.ts @@ -0,0 +1,49 @@ +import { createAction, on, props } from '@ngrx/store'; +import { produce } from 'immer'; + +import { PlayerEventTimeslotRegistrationChangedRealtimeEvent } from '../../../models/realtime-events'; +import { Reducers } from '../../utils'; +import { PLAYER_EVENTS_ACTION_SCOPE } from '../consts'; +import { playerEventEntityAdapter, PlayerEventsFeatureState } from '../player-events.state'; + +export const eventTimeslotRegistrationChangedAction = createAction( + `[${PLAYER_EVENTS_ACTION_SCOPE}] Event Timeslot Registration Changed`, + props() +); + +export const eventTimeslotRegistrationChangedReducers: Reducers = [ + on(eventTimeslotRegistrationChangedAction, (state, event) => + playerEventEntityAdapter.mapOne( + { + id: event.eventId, + map: produce(draft => { + if (!draft.playerEventRegistrations) { + draft.playerEventRegistrations = []; + } + let registration = draft.playerEventRegistrations.find(x => x.userId == event.userId); + if (!registration) { + registration = { + userId: event.userId, + userAlias: event.userAlias, + registeredTimeslotIds: [], + }; + draft.playerEventRegistrations.push(registration); + } + if (event.isRegistered) { + registration.registeredTimeslotIds.push(event.eventTimeslotId); + } else { + registration.registeredTimeslotIds = registration.registeredTimeslotIds.filter( + x => x !== event.eventTimeslotId + ); + } + if (registration.registeredTimeslotIds.length === 0) { + draft.playerEventRegistrations = draft.playerEventRegistrations.filter( + x => x.userId !== event.userId + ); + } + }), + }, + state + ) + ), +]; diff --git a/src/client/src/app/+state/player-events/player-events.effects.ts b/src/client/src/app/+state/player-events/player-events.effects.ts index a437815..771acfa 100644 --- a/src/client/src/app/+state/player-events/player-events.effects.ts +++ b/src/client/src/app/+state/player-events/player-events.effects.ts @@ -1,6 +1,7 @@ import { inject } from '@angular/core'; -import { EMPTY, merge, mergeMap, of } from 'rxjs'; +import { EMPTY, map, merge, mergeMap, of } from 'rxjs'; +import { eventTimeslotRegistrationChangedAction } from './actions/event-timeslot-registration-changed.action'; import { loadPlayerEventAction, loadPlayerEventEffects } from './actions/load-player-event.action'; import { loadPlayerEventsEffects } from './actions/load-player-events.action'; import { updateEventRegistrationEffects } from './actions/update-event-registration.action'; @@ -33,6 +34,12 @@ export const PlayerEventsFeatureEffects: Effects[] = [ ) ), + eventTimeslotRegistrationChanged$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).playerEventTimeslotRegistrationChanged.pipe( + map(event => eventTimeslotRegistrationChangedAction(event)) + ) + ), + onServerReconnected$: createFunctionalEffect.dispatching(() => inject(RealtimeEventsService).onReconnected$.pipe( mergeMap(() => diff --git a/src/client/src/app/+state/player-events/player-events.reducer.ts b/src/client/src/app/+state/player-events/player-events.reducer.ts index 24b457c..e4459eb 100644 --- a/src/client/src/app/+state/player-events/player-events.reducer.ts +++ b/src/client/src/app/+state/player-events/player-events.reducer.ts @@ -1,6 +1,7 @@ import { createReducer, on } from '@ngrx/store'; import { produce } from 'immer'; +import { eventTimeslotRegistrationChangedReducers } from './actions/event-timeslot-registration-changed.action'; import { loadPlayerEventReducers } from './actions/load-player-event.action'; import { loadPlayerEventsReducers } from './actions/load-player-events.action'; import { playerEventRemovedReducers } from './actions/player-event-removed.action'; @@ -28,6 +29,7 @@ export const playerEventsReducer = createReducer( ...playerEventRemovedReducers, ...resetPlayerEventsActionStateReducers, ...updateEventRegistrationReducers, + ...eventTimeslotRegistrationChangedReducers, on(addEventAction.success, (state, { response }) => state.actionStates.load.state === 'none' diff --git a/src/client/src/app/components/events/event-timeslot/event-timeslot.component.html b/src/client/src/app/components/events/event-timeslot/event-timeslot.component.html index 546a62c..9231df0 100644 --- a/src/client/src/app/components/events/event-timeslot/event-timeslot.component.html +++ b/src/client/src/app/components/events/event-timeslot/event-timeslot.component.html @@ -74,7 +74,7 @@ translations.events_timeslot_map() }} - {{ map.name }} + {{ map.name }} } } diff --git a/src/client/src/app/components/player-events/player-event-details/player-event-details.component.html b/src/client/src/app/components/player-events/player-event-details/player-event-details.component.html index 79fa3ff..0ac8869 100644 --- a/src/client/src/app/components/player-events/player-event-details/player-event-details.component.html +++ b/src/client/src/app/components/player-events/player-event-details/player-event-details.component.html @@ -207,6 +207,34 @@

{{ translations.playerEvents_notFound() }}

/> } + +

{{ translations.playerEvents_registeredPlayers() }}

+ +
+ @for (timeslot of timeslots(); track timeslot.id) { +
+ {{ timeslot.time.hour | number: '2.0-0' }}:{{ + timeslot.time.minute | number: '2.0-0' + }} +
+ } +
{{ translations.shared_name() }}
+ @for (eventRegistration of eventRegistrations(); track eventRegistration.userId) { + @for (timeslot of timeslots(); track timeslot.id) { +
+ } +
{{ eventRegistration.userAlias }}
+ } +
+
} diff --git a/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts b/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts index e32f529..9790da0 100644 --- a/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts +++ b/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts @@ -92,6 +92,11 @@ export class PlayerEventDetailsComponent { isActionBusy(this.registerActionState()) ); protected readonly event = selectSignal(computed(() => selectPlayerEvent(this.eventId()))); + protected readonly eventRegistrations = computed(() => + [...(this.event()?.playerEventRegistrations ?? [])].sort( + (a, b) => a.userAlias?.localeCompare(b.userAlias ?? '') ?? 1 + ) + ); protected readonly externalUri = computed(() => this.event()?.externalUri); protected readonly timeslots = computed(() => [...(this.event()?.timeslots ?? [])].sort((a, b) => compareTimes(a.time, b.time)) diff --git a/src/client/src/app/components/users/user-dialog/user-dialog.component.ts b/src/client/src/app/components/users/user-dialog/user-dialog.component.ts index 6275978..94a7ae3 100644 --- a/src/client/src/app/components/users/user-dialog/user-dialog.component.ts +++ b/src/client/src/app/components/users/user-dialog/user-dialog.component.ts @@ -227,7 +227,11 @@ export class UserDialogComponent { } protected submit() { - if (Object.values(this._allUsers()).some(x => x?.alias === this.form.value.alias)) { + if ( + Object.values(this._allUsers()).some( + x => x?.id !== this.userToUpdate()?.id && x?.alias === this.form.value.alias + ) + ) { this._messageService.add({ severity: 'error', summary: this.translations.users_dialog_error_exists({ diff --git a/src/client/src/app/i18n/de.json b/src/client/src/app/i18n/de.json index fc8d5fc..1722956 100644 --- a/src/client/src/app/i18n/de.json +++ b/src/client/src/app/i18n/de.json @@ -241,6 +241,7 @@ "backToEvents": "Zurück zu den Veranstaltungen", "registrationClosed": "Die Anmeldung für diese Veranstaltung ist geschlossen.", "registrations": "Anmeldungen", + "registeredPlayers": "Registrierte Spieler", "playerAmount": "Spieleranzahl", "fallbackTimeslot": "Ersatzzeitslot", "fallbackTimeslotDescription": "Dieser Zeitslot ist nicht so beliebt wie die anderen. Wenn sich zu wenige Personen für diesen Zeitslot anmelden, kann es passieren, dass er nicht stattfindet. Daher kannst du einen Ersatzzeitslot angeben, der verwendet wird, wenn dieser Zeitslot nicht stattfindet.", diff --git a/src/client/src/app/i18n/en.json b/src/client/src/app/i18n/en.json index 5f58ed0..ca65307 100644 --- a/src/client/src/app/i18n/en.json +++ b/src/client/src/app/i18n/en.json @@ -241,6 +241,7 @@ "backToEvents": "Back to events", "registrationClosed": "The registration for this event is closed.", "registrations": "Registrations", + "registeredPlayers": "Registered players", "playerAmount": "Player count", "fallbackTimeslot": "Fallback timeslot", "fallbackTimeslotDescription": "This timeslot is not as popular as the others. It can happend that if too few peaple register for this timeslot, it will not take place. Therefore you can specify a fallback timeslot that will be used if this timeslot is canceled.", diff --git a/src/client/src/app/models/realtime-events.ts b/src/client/src/app/models/realtime-events.ts index 2793586..89ade23 100644 --- a/src/client/src/app/models/realtime-events.ts +++ b/src/client/src/app/models/realtime-events.ts @@ -38,9 +38,10 @@ export type PlayerEventRegistrationChangedRealtimeEvent = { eventId: string; }; export type UserSettingsChangedRealtimeEvent = {}; -export type PlayerEventTimeslotRegistrationChanged = { +export type PlayerEventTimeslotRegistrationChangedRealtimeEvent = { eventId: string; eventTimeslotId: string; userId: string; + userAlias: string | null | undefined; isRegistered: boolean; }; diff --git a/src/client/src/app/services/realtime-events.service.ts b/src/client/src/app/services/realtime-events.service.ts index f8ef9f3..8820617 100644 --- a/src/client/src/app/services/realtime-events.service.ts +++ b/src/client/src/app/services/realtime-events.service.ts @@ -30,7 +30,7 @@ import { PlayerEventChangedRealtimeEvent, PlayerEventRegistrationChangedRealtimeEvent, UserSettingsChangedRealtimeEvent, - PlayerEventTimeslotRegistrationChanged, + PlayerEventTimeslotRegistrationChangedRealtimeEvent, EventInstancesEditorChangedEvent, } from '../models/realtime-events'; import { SignalrRetryPolicy } from '../signalr-retry-policy'; @@ -69,7 +69,7 @@ export class RealtimeEventsService implements OnDestroy { new EventEmitter(); public readonly userSettingsChanged = new EventEmitter(); public readonly playerEventTimeslotRegistrationChanged = - new EventEmitter(); + new EventEmitter(); public readonly isConnected = computed(() => !!this._isConnected()); public readonly onReconnected$ = toObservable(this._isConnected).pipe( startWith(null), diff --git a/src/server/domain/Models/PlayerEvent.cs b/src/server/domain/Models/PlayerEvent.cs index e1b8f94..10eef44 100644 --- a/src/server/domain/Models/PlayerEvent.cs +++ b/src/server/domain/Models/PlayerEvent.cs @@ -16,9 +16,22 @@ public record PlayerEvent( [property: Required] DateTimeOffset RegistrationDeadline, [property: Required] PlayerEventTimeslot[] Timeslots, [property: Required] bool IsStarted, + PlayerEventRegistration[]? PlayerEventRegistrations, string? ExternalUri ); +/// +/// Represents the timeslots for which a player has been registered. +/// +/// The id of the user. +/// The alias of the user. +/// The ids of timeslots a player has been registered for. +public record PlayerEventRegistration( + [property: Required] string UserId, + string? UserAlias, + [property: Required] string[] RegisteredTimeslotIds +); + /// /// Represents a timeslot of an event that players can register to. /// diff --git a/src/server/domain/Models/RealtimeEvents/RealtimeEvent.cs b/src/server/domain/Models/RealtimeEvents/RealtimeEvent.cs index e39b955..57036cc 100644 --- a/src/server/domain/Models/RealtimeEvents/RealtimeEvent.cs +++ b/src/server/domain/Models/RealtimeEvents/RealtimeEvent.cs @@ -123,6 +123,7 @@ public record PlayerEventTimeslotRegistrationChanged( string EventId, string EventTimeslotId, string UserId, + string? UserAlias, bool IsRegistered ) : IGroupRealtimeEvent { diff --git a/src/server/host/Endpoints/Events/UpdatePlayerEventRegistrationsEndpoint.cs b/src/server/host/Endpoints/Events/UpdatePlayerEventRegistrationsEndpoint.cs index 9d427e5..a85d0db 100644 --- a/src/server/host/Endpoints/Events/UpdatePlayerEventRegistrationsEndpoint.cs +++ b/src/server/host/Endpoints/Events/UpdatePlayerEventRegistrationsEndpoint.cs @@ -158,12 +158,17 @@ await realtimeEventsService.SendEventAsync( ), ct ); + var userAlias = await databaseContext + .Users.Where(x => x.Id == userId) + .Select(x => x.Alias) + .FirstOrDefaultAsync(ct); var changeEvents = registrations .Where(x => !targetRegistrations.Any(y => y.TimeslotId == x.EventTimeslotId)) .Select(x => new RealtimeEvent.PlayerEventTimeslotRegistrationChanged( idService.Event.Encode(eventId), idService.EventTimeslot.Encode(x.EventTimeslotId), idService.User.Encode(userId), + userAlias, false )) .Concat( @@ -173,6 +178,7 @@ await realtimeEventsService.SendEventAsync( idService.Event.Encode(eventId), idService.EventTimeslot.Encode(x.TimeslotId), idService.User.Encode(userId), + userAlias, true )) ); diff --git a/src/server/host/Mappers/PlayerEventMapper.cs b/src/server/host/Mappers/PlayerEventMapper.cs index afee385..4a04809 100644 --- a/src/server/host/Mappers/PlayerEventMapper.cs +++ b/src/server/host/Mappers/PlayerEventMapper.cs @@ -19,6 +19,18 @@ public PlayerEvent Map(EventEntity entity, long userId) .Select(timeslot => Map(timeslot, userId)) .ToArray(), entity.StartedAt != null, + entity + .Timeslots.SelectMany(x => x.Registrations.Select(x => x.Player)) + .DistinctBy(x => x.Id) + .Select(p => new PlayerEventRegistration( + idService.User.Encode(p.Id), + p.Alias, + entity + .Timeslots.Where(x => x.Registrations.Any(x => x.PlayerId == p.Id)) + .Select(x => idService.EventTimeslot.Encode(x.Id)) + .ToArray() + )) + .ToArray(), entity.ExternalUri ); } @@ -67,6 +79,7 @@ public IQueryable AddIncludes(IQueryable events) return events .Include(x => x.Timeslots) .ThenInclude(x => x.Registrations) + .ThenInclude(x => x.Player) .Include(x => x.Timeslots) .ThenInclude(x => x.Instances) .ThenInclude(x => x.Players)