diff --git a/backend/src/api/_mobile/event/dto/get-my-events-counts.dto.ts b/backend/src/api/_mobile/event/dto/get-my-events-counts.dto.ts new file mode 100644 index 000000000..53e7a333f --- /dev/null +++ b/backend/src/api/_mobile/event/dto/get-my-events-counts.dto.ts @@ -0,0 +1,10 @@ +import { PickType } from '@nestjs/swagger'; +import { GetMyEventsDto } from './get-my-events.dto'; + +// The segment badges take the list's search and filters, with no segment and no paging +export class GetMyEventsCountsDto extends PickType(GetMyEventsDto, [ + 'search', + 'organizationId', + 'startFrom', + 'startTo', +] as const) {} diff --git a/backend/src/api/_mobile/event/event.controller.ts b/backend/src/api/_mobile/event/event.controller.ts index 16708b5ae..d83a5120f 100644 --- a/backend/src/api/_mobile/event/event.controller.ts +++ b/backend/src/api/_mobile/event/event.controller.ts @@ -8,7 +8,7 @@ import { UseGuards, } from '@nestjs/common'; import { Controller } from '@nestjs/common'; -import { ApiBearerAuth, ApiBody } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiBody, ApiOkResponse } from '@nestjs/swagger'; import { UuidValidationPipe } from 'src/infrastructure/pipes/uuid.pipe'; import { CreateEventRSVPUseCase } from 'src/usecases/event/RSVP/create-rsvp.usecase'; import { DeleteEventRSVPUseCase } from 'src/usecases/event/RSVP/delete-rsvp.usecase'; @@ -28,6 +28,9 @@ import { MobileEventListItemPresenter } from './presenters/mobile-event-list-ite import { GetOneEventWithVolunteerStatusUsecase } from 'src/usecases/event/get-one-event-with-volunteer-status.usecase'; import { MobileEventPresenter } from './presenters/mobile-event.presenter'; import { GetEventsByOrganizationUsecase } from 'src/usecases/event/get-events-by-organization.usecase'; +import { GetMyEventsCountsDto } from './dto/get-my-events-counts.dto'; +import { GetMyEventsCountsUsecase } from 'src/usecases/event/get-my-events-counts.usecase'; +import { MobileEventsCountsPresenter } from './presenters/mobile-events-counts.presenter'; import { MobileEventOptionPresenter } from './presenters/mobile-event-option.presenter'; @ApiBearerAuth() @@ -40,6 +43,7 @@ export class MobileEventController { private readonly getMyEventsUsecase: GetMyEventsUsecase, private readonly getOneEventWithVolunteerStatus: GetOneEventWithVolunteerStatusUsecase, private readonly getEventsByOrganization: GetEventsByOrganizationUsecase, + private readonly getMyEventsCountsUsecase: GetMyEventsCountsUsecase, ) {} @Get() @@ -61,6 +65,21 @@ export class MobileEventController { }); } + // The badges of the three segments, for the same search and filters as the list + @Get('counts') + @ApiOkResponse({ type: MobileEventsCountsPresenter }) + async getCounts( + @Query() filters: GetMyEventsCountsDto, + @ExtractUser() { id }: IRegularUserModel, + ): Promise { + const counts = await this.getMyEventsCountsUsecase.execute({ + ...filters, + userId: id, + }); + + return new MobileEventsCountsPresenter(counts); + } + // TODO: Volunteer Guard @Get('organization/:organizationId') async getEventsByOrganizationId( diff --git a/backend/src/api/_mobile/event/presenters/mobile-events-counts.presenter.ts b/backend/src/api/_mobile/event/presenters/mobile-events-counts.presenter.ts new file mode 100644 index 000000000..2eb33290b --- /dev/null +++ b/backend/src/api/_mobile/event/presenters/mobile-events-counts.presenter.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IMyEventsCountsModel } from 'src/modules/event/models/event.model'; + +export class MobileEventsCountsPresenter { + constructor(counts: IMyEventsCountsModel) { + this.open = counts.open; + this.going = counts.going; + this.organizations = counts.organizations; + } + + @ApiProperty({ description: 'Events in the Deschise (OPEN) list' }) + open: number; + + @ApiProperty({ description: 'Events in the Particip (GOING) list' }) + going: number; + + @ApiProperty({ + description: 'Events in the Organizațiile mele (ORGANIZATIONS) list', + }) + organizations: number; +} diff --git a/backend/src/modules/event/interfaces/event-repository.interface.ts b/backend/src/modules/event/interfaces/event-repository.interface.ts index 35c075e35..532421f4f 100644 --- a/backend/src/modules/event/interfaces/event-repository.interface.ts +++ b/backend/src/modules/event/interfaces/event-repository.interface.ts @@ -3,6 +3,8 @@ import { CreateEventOptions, FindManyEventOptions, FindMyEventsOptions, + CountMyEventsOptions, + IMyEventsCountsModel, FindOneEventOptions, FindOngoingAndFinishedEventOptions, IEventModel, @@ -43,6 +45,9 @@ export interface IEventRepository { findGoingEvents( findOptions: Omit, ): Promise>; + countMyEvents( + findOptions: CountMyEventsOptions, + ): Promise; findOngoingAndFinishedEvents( findOptions: FindOngoingAndFinishedEventOptions, ): Promise; diff --git a/backend/src/modules/event/models/event.model.ts b/backend/src/modules/event/models/event.model.ts index 53e499398..7fcc1a493 100644 --- a/backend/src/modules/event/models/event.model.ts +++ b/backend/src/modules/event/models/event.model.ts @@ -130,6 +130,18 @@ export type FindMyEventsOptions = { startTo?: Date; } & IBasePaginationFilterModel; +// The mobile segment totals take the list's search and filters, but no segment nor paging +export type CountMyEventsOptions = Pick< + FindMyEventsOptions, + 'userId' | 'organizationId' | 'startFrom' | 'startTo' | 'search' +>; + +export interface IMyEventsCountsModel { + open: number; + going: number; + organizations: number; +} + export type FindOngoingAndFinishedEventOptions = { organizationId: string; userId: string; diff --git a/backend/src/modules/event/repositories/event.repository.my-events.spec.ts b/backend/src/modules/event/repositories/event.repository.my-events.spec.ts new file mode 100644 index 000000000..266fe9d26 --- /dev/null +++ b/backend/src/modules/event/repositories/event.repository.my-events.spec.ts @@ -0,0 +1,94 @@ +import { Repository } from 'typeorm'; +import { EventEntity } from '../entities/event.entity'; +import { EventRepository } from './event.repository'; + +// The mobile segment badges (GET /mobile/event/counts) must be built with the very query of each +// list, so a badge never says something other than its list. This records every call made on the +// query builders and compares the list's to the count's. +type Call = { method: string; args: unknown[] }; + +const recordingBuilder = (calls: Call[]): unknown => { + const builder: unknown = new Proxy( + {}, + { + get: (_target, method: string): unknown => { + if (method === 'then') return undefined; + if (method === 'getQuery') return (): string => '(subquery)'; + if (method === 'getCount') return async (): Promise => 7; + if (method === 'getManyAndCount') + return async (): Promise<[unknown[], number]> => [[], 7]; + return (...args: unknown[]): unknown => { + calls.push({ method, args }); + return builder; + }; + }, + }, + ); + return builder; +}; + +describe('EventRepository mobile segments', () => { + let calls: Call[][]; + const repository = new EventRepository({ + createQueryBuilder: () => { + const own: Call[] = []; + calls.push(own); + return recordingBuilder(own); + }, + } as unknown as Repository); + + const filters = { + userId: 'user-1', + search: 'Mara', + organizationId: 'org-1', + startFrom: new Date('2030-05-01T00:00:00Z'), + startTo: new Date('2030-05-31T00:00:00Z'), + }; + + // the conditions and their parameters, without the "now" that differs between two calls + const conditions = (recorded: Call[]): unknown[] => + recorded + .filter(({ method }) => + ['where', 'andWhere', 'from', 'select'].includes(method), + ) + .map(({ method, args }) => [ + method, + args.map((arg) => + arg && typeof arg === 'object' && 'currentDate' in arg + ? { ...arg, currentDate: 'now' } + : typeof arg === 'object' && arg?.constructor?.name === 'Brackets' + ? 'brackets' + : arg, + ), + ]); + + beforeEach(() => { + calls = []; + }); + + it.each([ + ['open', 'findOpenEvents'], + ['going', 'findGoingEvents'], + ['organizations', 'findMyOrganizationsEvents'], + ] as const)( + 'counts %s with the same conditions as its list', + async (segment, finder) => { + await repository[finder]({ ...filters, limit: 10, page: 1 }); + const listCalls = calls[0]; + + calls = []; + const counts = await repository.countMyEvents(filters); + expect(counts).toEqual({ open: 7, going: 7, organizations: 7 }); + + // countMyEvents builds open, going, organizations in this order + const index = { open: 0, going: 1, organizations: 2 }[segment]; + expect(conditions(calls[index])).toEqual(conditions(listCalls)); + // the filters are part of it + expect(JSON.stringify(conditions(calls[index]))).toContain( + 'filterOrganizationId', + ); + // and a count takes no page + expect(calls[index].map(({ method }) => method)).not.toContain('take'); + }, + ); +}); diff --git a/backend/src/modules/event/repositories/event.repository.ts b/backend/src/modules/event/repositories/event.repository.ts index bcd0a30f5..b0bd7a882 100644 --- a/backend/src/modules/event/repositories/event.repository.ts +++ b/backend/src/modules/event/repositories/event.repository.ts @@ -33,7 +33,10 @@ import { FindEventCandidatesOptions, IEventCandidateModel, IEventRegistrationExportRow, + CountMyEventsOptions, + IMyEventsCountsModel, } from '../models/event.model'; +import { EventFilterEnum } from '../enums/event-filter.enum'; import { EventStatus } from '../enums/event-status.enum'; import { VolunteerEntity } from 'src/modules/volunteer/entities/volunteer.entity'; import { EventRSVPEntity } from '../entities/event-rsvp.entity'; @@ -339,87 +342,138 @@ export class EventRepository async findOpenEvents( findOptions: Omit, ): Promise> { - const { userId } = findOptions; - const query = this.createSelectOpenOrganizationGoingEventsBaseSelectQuery(); - - // Get all events in progress from the organizations i am part or public events - query.andWhere( - `${EVENT_END} > :currentDate AND ((event.isPublic = :isPublic AND event.organizationId NOT IN ` + - query - .subQuery() - .select('vol.organizationId') - .from(VolunteerEntity, 'vol') - .where('vol.userId = :userId AND vol.status = :blocked ', { - userId, - blocked: VolunteerStatus.BLOCKED, - }) - .getQuery() + - ') OR (v.userId = :userId AND v.status = :active AND (targets.id = vp.departmentId OR targets.id IS NULL)))', - { - isPublic: true, - currentDate: new Date(), - userId, - active: VolunteerStatus.ACTIVE, - }, + return this.orderAndPaginate( + this.buildMyEventsQuery(EventFilterEnum.OPEN, findOptions), + findOptions, ); - - return this.searchOrderAndPaginate(query, findOptions); } - // Get all ongoing events public or from the organizations the user is part of + // Get all ongoing events from the organizations the user is an active volunteer of async findMyOrganizationsEvents( findOptions: Omit, ): Promise> { - const { userId } = findOptions; - const query = this.createSelectOpenOrganizationGoingEventsBaseSelectQuery(); - - query.andWhere( - `${EVENT_END} > :currentDate AND v.userId = :userId AND v.status = :volunteerStatus AND (targets.id = vp.departmentId OR targets.id IS NULL)`, - { - currentDate: new Date(), - userId, - volunteerStatus: VolunteerStatus.ACTIVE, - }, + return this.orderAndPaginate( + this.buildMyEventsQuery(EventFilterEnum.ORGANIZATIONS, findOptions), + findOptions, ); - - return this.searchOrderAndPaginate(query, findOptions); } // get all ongoing or upcoming events where the user have confirmed going async findGoingEvents( findOptions: Omit, ): Promise> { - const { userId } = findOptions; - const query = this.createSelectOpenOrganizationGoingEventsBaseSelectQuery(); + return this.orderAndPaginate( + this.buildMyEventsQuery(EventFilterEnum.GOING, findOptions), + findOptions, + ); + } - query.andWhere( - `${EVENT_END} > :currentDate AND ((v.status = :active AND v.userId = :userId) OR event.isPublic = :isPublic)`, - { - currentDate: new Date(), - active: VolunteerStatus.ACTIVE, - isPublic: true, - }, + // The totals of the three mobile segments, built with the very queries of the lists, so a badge + // always says what its list holds (the same visibility rules, search and filters). + async countMyEvents( + findOptions: CountMyEventsOptions, + ): Promise { + const [open, going, organizations] = await Promise.all( + [ + EventFilterEnum.OPEN, + EventFilterEnum.GOING, + EventFilterEnum.ORGANIZATIONS, + ].map((filter) => + // getCount counts distinct events, like the list's totalItems (getManyAndCount) + this.buildMyEventsQuery(filter, findOptions).getCount(), + ), ); - // Get all events where i have responded with going, unless the NGO rejected it (#501) - query.andWhere( - 'event.id IN ' + - query - .subQuery() - .select('rsvp.eventId') - .from(EventRSVPEntity, 'rsvp') - .where('rsvp.userId = :userId AND rsvp.going = :going ', { + return { open, going, organizations }; + } + + // One segment of the mobile events screen: its visibility rule plus the search and the + // Organizație / Când filters. Shared by the lists and their counts so they cannot drift. + private buildMyEventsQuery( + filter: EventFilterEnum, + findOptions: CountMyEventsOptions, + ): SelectQueryBuilder { + const { userId, search, organizationId, startFrom, startTo } = findOptions; + const query = this.createSelectOpenOrganizationGoingEventsBaseSelectQuery(); + + switch (filter) { + case EventFilterEnum.OPEN: + // Get all events in progress from the organizations i am part or public events + query.andWhere( + `${EVENT_END} > :currentDate AND ((event.isPublic = :isPublic AND event.organizationId NOT IN ` + + query + .subQuery() + .select('vol.organizationId') + .from(VolunteerEntity, 'vol') + .where('vol.userId = :userId AND vol.status = :blocked ', { + userId, + blocked: VolunteerStatus.BLOCKED, + }) + .getQuery() + + ') OR (v.userId = :userId AND v.status = :active AND (targets.id = vp.departmentId OR targets.id IS NULL)))', + { + isPublic: true, + currentDate: new Date(), userId, - going: true, - }) - .andWhere( - '(rsvp.status IS NULL OR rsvp.status <> :rejectedRegistration)', - { rejectedRegistration: EventRegistrationStatus.REJECTED }, - ) - .getQuery(), - ); + active: VolunteerStatus.ACTIVE, + }, + ); + break; + case EventFilterEnum.ORGANIZATIONS: + query.andWhere( + `${EVENT_END} > :currentDate AND v.userId = :userId AND v.status = :volunteerStatus AND (targets.id = vp.departmentId OR targets.id IS NULL)`, + { + currentDate: new Date(), + userId, + volunteerStatus: VolunteerStatus.ACTIVE, + }, + ); + break; + case EventFilterEnum.GOING: + query.andWhere( + `${EVENT_END} > :currentDate AND ((v.status = :active AND v.userId = :userId) OR event.isPublic = :isPublic)`, + { + currentDate: new Date(), + active: VolunteerStatus.ACTIVE, + isPublic: true, + }, + ); + // Get all events where i have responded with going, unless the NGO rejected it (#501) + query.andWhere( + 'event.id IN ' + + query + .subQuery() + .select('rsvp.eventId') + .from(EventRSVPEntity, 'rsvp') + .where('rsvp.userId = :userId AND rsvp.going = :going ', { + userId, + going: true, + }) + .andWhere( + '(rsvp.status IS NULL OR rsvp.status <> :rejectedRegistration)', + { rejectedRegistration: EventRegistrationStatus.REJECTED }, + ) + .getQuery(), + ); + break; + } - return this.searchOrderAndPaginate(query, findOptions); + if (search) { + query.andWhere(this.buildBracketSearchQuery(['event.name'], search)); + } + if (organizationId) { + query.andWhere('event.organizationId = :filterOrganizationId', { + filterOrganizationId: organizationId, + }); + } + if (startFrom) { + query.andWhere('event.startDate >= :startFrom', { startFrom }); + } + if (startTo) { + query.andWhere('event.startDate <= :startTo', { startTo }); + } + + return query; } async findOngoingAndFinishedEvents( @@ -486,41 +540,16 @@ export class EventRepository ); } - private async searchOrderAndPaginate( + private async orderAndPaginate( eventQuery: SelectQueryBuilder, findOptions: Omit, ): Promise> { - const { - orderBy, - orderDirection, - search, - userId, - organizationId, - startFrom, - startTo, - } = findOptions; + const { orderBy, orderDirection, userId } = findOptions; const updatedQuery = eventQuery.orderBy( this.buildOrderByQuery(orderBy || 'startDate', 'event'), orderDirection || OrderDirection.ASC, ); - if (search) { - updatedQuery.andWhere( - this.buildBracketSearchQuery(['event.name'], search), - ); - } - if (organizationId) { - updatedQuery.andWhere('event.organizationId = :filterOrganizationId', { - filterOrganizationId: organizationId, - }); - } - if (startFrom) { - updatedQuery.andWhere('event.startDate >= :startFrom', { startFrom }); - } - if (startTo) { - updatedQuery.andWhere('event.startDate <= :startTo', { startTo }); - } - const page = await this.paginateQuery( updatedQuery, findOptions.limit, diff --git a/backend/src/modules/event/services/event.facade.ts b/backend/src/modules/event/services/event.facade.ts index fd4574c73..7c26811f5 100644 --- a/backend/src/modules/event/services/event.facade.ts +++ b/backend/src/modules/event/services/event.facade.ts @@ -15,6 +15,8 @@ import { CreateEventOptions, FindManyEventOptions, FindMyEventsOptions, + CountMyEventsOptions, + IMyEventsCountsModel, FindOneEventOptions, FindOngoingAndFinishedEventOptions, IEventModel, @@ -151,6 +153,12 @@ export class EventFacade { return this.eventRepository.findGoingEvents(findOptions); } + async countMyEvents( + findOptions: CountMyEventsOptions, + ): Promise { + return this.eventRepository.countMyEvents(findOptions); + } + async findOngoingAndFinishedEvents( findOptions: FindOngoingAndFinishedEventOptions, ): Promise { diff --git a/backend/src/usecases/event/get-my-events-counts.usecase.spec.ts b/backend/src/usecases/event/get-my-events-counts.usecase.spec.ts new file mode 100644 index 000000000..32ca0ec2d --- /dev/null +++ b/backend/src/usecases/event/get-my-events-counts.usecase.spec.ts @@ -0,0 +1,53 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { GetMyEventsCountsDto } from 'src/api/_mobile/event/dto/get-my-events-counts.dto'; +import { EventFacade } from 'src/modules/event/services/event.facade'; +import { GetMyEventsCountsUsecase } from './get-my-events-counts.usecase'; + +describe('GetMyEventsCountsUsecase', () => { + it('answers the three segment totals for the filters', async () => { + const facade = { + countMyEvents: jest + .fn() + .mockResolvedValue({ open: 4, going: 2, organizations: 3 }), + }; + const usecase = new GetMyEventsCountsUsecase( + facade as unknown as EventFacade, + ); + const options = { userId: 'user-1', search: 'Mara', organizationId: 'o' }; + + await expect(usecase.execute(options)).resolves.toEqual({ + open: 4, + going: 2, + organizations: 3, + }); + expect(facade.countMyEvents).toHaveBeenCalledWith(options); + }); +}); + +describe('GetMyEventsCountsDto', () => { + const dto = (query: Record): GetMyEventsCountsDto => + plainToInstance(GetMyEventsCountsDto, query); + + it('takes the list filters, a day with no time as the whole day in Romania', async () => { + const filters = dto({ + search: 'Mara', + organizationId: '3f1c2d4e-5a6b-4c7d-8e9f-0a1b2c3d4e5f', + startFrom: '2030-05-20', + startTo: '2030-05-20', + }); + expect(await validate(filters)).toEqual([]); + expect(filters.startFrom.toISOString()).toBe('2030-05-19T21:00:00.000Z'); + expect(filters.startTo.toISOString()).toBe('2030-05-20T20:59:59.999Z'); + }); + + it('refuses a malformed organization or date', async () => { + const errors = await validate( + dto({ organizationId: 'nope', startFrom: 'not-a-date' }), + ); + expect(errors.map((error) => error.property).sort()).toEqual([ + 'organizationId', + 'startFrom', + ]); + }); +}); diff --git a/backend/src/usecases/event/get-my-events-counts.usecase.ts b/backend/src/usecases/event/get-my-events-counts.usecase.ts new file mode 100644 index 000000000..9ca0a17ad --- /dev/null +++ b/backend/src/usecases/event/get-my-events-counts.usecase.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface'; +import { + CountMyEventsOptions, + IMyEventsCountsModel, +} from 'src/modules/event/models/event.model'; +import { EventFacade } from 'src/modules/event/services/event.facade'; + +// The badges of the mobile Evenimente segments (Deschise, Particip, Organizațiile mele): how many +// events each list holds for the same search and filters. +@Injectable() +export class GetMyEventsCountsUsecase + implements IUseCaseService +{ + constructor(private readonly eventFacade: EventFacade) {} + + async execute( + findOptions: CountMyEventsOptions, + ): Promise { + return this.eventFacade.countMyEvents(findOptions); + } +} diff --git a/backend/src/usecases/use-case.module.ts b/backend/src/usecases/use-case.module.ts index b1665b148..284b88740 100644 --- a/backend/src/usecases/use-case.module.ts +++ b/backend/src/usecases/use-case.module.ts @@ -138,6 +138,7 @@ import { CancelAccessRequestUsecase } from './access-request/cancel-access-reque import { SwitchOrganizationUsecase } from './organization/switch-organization.usecase'; import { GetVolunteerProfileUsecase } from './volunteer/get-volunteer-profile.usecase'; import { GetMyEventsUsecase } from './event/get-my-events.usecase'; +import { GetMyEventsCountsUsecase } from './event/get-my-events-counts.usecase'; import { GetOneEventWithVolunteerStatusUsecase } from './event/get-one-event-with-volunteer-status.usecase'; import { CreateActivityLogByRegularUser } from './activity-log/create-activity-log-by-regular-user.usecase'; import { GetEventsByOrganizationUsecase } from './event/get-events-by-organization.usecase'; @@ -331,6 +332,7 @@ import { DeleteAccountRegularUserUsecase } from './user/delete-account.usecase'; GetManyEventUseCase, GetEventCountersUseCase, GetMyEventsUsecase, + GetMyEventsCountsUsecase, GetOneEventWithVolunteerStatusUsecase, GetEventsByOrganizationUsecase, // Events RSVP @@ -517,6 +519,7 @@ import { DeleteAccountRegularUserUsecase } from './user/delete-account.usecase'; GetEventCountersUseCase, GetManyForDownloadEventUseCase, GetMyEventsUsecase, + GetMyEventsCountsUsecase, GetOneEventWithVolunteerStatusUsecase, GetEventsByOrganizationUsecase, // Events RSVP diff --git a/backend/test/mobile-events-filters.e2e-spec.ts b/backend/test/mobile-events-filters.e2e-spec.ts index e33e2415c..822ff6c0c 100644 --- a/backend/test/mobile-events-filters.e2e-spec.ts +++ b/backend/test/mobile-events-filters.e2e-spec.ts @@ -198,6 +198,85 @@ describe('Mobile events filters (e2e)', () => { expect(body.meta.totalItems).toBe(4); }); + const counts = async ( + query: Record, + ): Promise<{ open: number; going: number; organizations: number }> => + ( + await request(testApp.app.getHttpServer()) + .get('/mobile/event/counts') + .query(query) + .set( + 'Authorization', + `Bearer ${await testApp.mintAccessToken(elena, AuthRealm.MOBILE)}`, + ) + .expect(200) + ).body; + + it('counts each segment as many events as its list holds, with the same filters', async () => { + // a public event of an organization Elena is not part of: open, but not in her organizations + const [other] = await testApp.dataSource.query( + `INSERT INTO organization (name, email, phone, status, validated_at) VALUES ('Altii', 'contact@altii.ro', '+40700009000', 'active', now()) RETURNING id`, + ); + await testApp.dataSource.query( + `INSERT INTO event (name, description, start_date, status, is_public, organization_id) + VALUES ('Maraton public', 'd', '2030-05-12T09:00:00Z', 'published', true, $1), + ('Ciornă', 'd', '2030-05-12T09:00:00Z', 'draft', true, $1)`, + [other.id], + ); + + const totals = async ( + query: Record, + ): Promise> => ({ + open: (await list({ ...query, eventFilter: 'OPEN' })).meta.totalItems, + going: (await list({ ...query, eventFilter: 'GOING' })).meta.totalItems, + organizations: (await list({ ...query, eventFilter: 'ORGANIZATIONS' })) + .meta.totalItems, + }); + + expect(await counts({})).toEqual({ open: 5, going: 2, organizations: 4 }); + for (const query of [ + {}, + { search: 'Maraton' }, + { organizationId: orgs.Salvati }, + { startFrom: '2030-05-20', startTo: '2030-05-20' }, + { organizationId: orgs.Crucea, startFrom: '2030-05-15', search: 'Cur' }, + { search: 'nimic' }, + ]) { + expect(await counts(query)).toEqual(await totals(query)); + } + expect(await counts({ search: 'Maraton' })).toEqual({ + open: 2, + going: 1, + organizations: 1, + }); + }); + + it('follows a new registration in the Particip count', async () => { + const [gala] = await testApp.dataSource.query( + `SELECT id FROM event WHERE name = 'Gală'`, + ); + expect((await counts({})).going).toBe(2); + await testApp.dataSource.query( + `INSERT INTO event_rsvp (going, status, event_id, user_id) VALUES (true, 'pending', $1, $2)`, + [gala.id, userId], + ); + expect((await counts({})).going).toBe(3); + }); + + it('refuses a malformed filter on the counts', async () => { + await request(testApp.app.getHttpServer()) + .get('/mobile/event/counts') + .query({ organizationId: 'nope' }) + .set( + 'Authorization', + `Bearer ${await testApp.mintAccessToken(elena, AuthRealm.MOBILE)}`, + ) + .expect(400); + await request(testApp.app.getHttpServer()) + .get('/mobile/event/counts') + .expect(401); + }); + it('refuses a malformed organization or date', async () => { await request(testApp.app.getHttpServer()) .get('/mobile/event') diff --git a/mobile/src/design/components/SegmentedControl.tsx b/mobile/src/design/components/SegmentedControl.tsx index bbcc48e4b..5e0bab27e 100644 --- a/mobile/src/design/components/SegmentedControl.tsx +++ b/mobile/src/design/components/SegmentedControl.tsx @@ -1,5 +1,8 @@ // Segmented control „Îndrăzneț”: a navy track with a yellow thumb that slides and resizes to the // selected segment (spring snappy). Segments size to their labels (flex 1 1 auto). +// With count badges the row may not fit (small phones, Dynamic Type): the control measures what +// each segment needs (its longest word + badge) and, only when the row is too narrow, first tightens +// the padding, then puts each badge under its label, so no word is ever cut. import React, { ReactNode, useCallback, useState } from 'react'; import { LayoutChangeEvent, Pressable, Text, View } from 'react-native'; import Animated, { useAnimatedStyle, useReducedMotion } from 'react-native-reanimated'; @@ -31,8 +34,32 @@ export interface SegmentedControlProps { const TRACK_PADDING = 5; const LABEL_OFF = 'rgba(255,255,255,0.75)'; +const SEGMENT_PADDING = 8; +const SEGMENT_GAP = 6; +const COMPACT_PADDING = 5; +const COMPACT_GAP = 4; type Layout = { x: number; width: number }; +/** inline: label and badge side by side; compact: the same, tighter; stacked: badge under label */ +export type SegmentFit = 'inline' | 'compact' | 'stacked'; + +/** How the segments fit the track, from the width each needs laid out inline (padding included). */ +export const segmentFit = ( + needed: number[], + withCount: boolean[], + available: number, +): SegmentFit => { + const total = needed.reduce((sum, width) => sum + width, 0); + if (total <= available + 0.5) { + return 'inline'; + } + const saved = withCount.reduce( + (sum, hasCount) => + sum + 2 * (SEGMENT_PADDING - COMPACT_PADDING) + (hasCount ? SEGMENT_GAP - COMPACT_GAP : 0), + 0, + ); + return total - saved <= available + 0.5 ? 'compact' : 'stacked'; +}; export function SegmentedControl({ options, @@ -44,6 +71,24 @@ export function SegmentedControl({ }: SegmentedControlProps) { const reduced = useReducedMotion(); const [layouts, setLayouts] = useState>>({}); + const [trackWidth, setTrackWidth] = useState(0); + const [needed, setNeeded] = useState>>({}); + const withCount = options.map((option) => typeof option.count === 'number'); + const measure = trackWidth > 0 && withCount.some(Boolean); + const neededWidths = options.map((option) => needed[option.key]); + const fit: SegmentFit = + measure && neededWidths.every((width) => typeof width === 'number') + ? segmentFit(neededWidths as number[], withCount, trackWidth - 2 * TRACK_PADDING) + : 'inline'; + const onNeededFor = useCallback( + (key: T) => (event: LayoutChangeEvent) => { + const { width: segmentWidth } = event.nativeEvent.layout; + setNeeded((previous) => + previous[key] === segmentWidth ? previous : { ...previous, [key]: segmentWidth }, + ); + }, + [], + ); const active = options.find((option) => option.key === value) ?? options[0]; const activeLayout = active ? layouts[active.key] : undefined; const x = activeLayout?.x ?? 0; @@ -77,7 +122,42 @@ export function SegmentedControl({ accessibilityRole="tablist" accessibilityLabel={accessibilityLabel} style={styles.track} + onLayout={(event) => setTrackWidth(event.nativeEvent.layout.width)} > + {measure && ( + // invisible: what each segment needs inline, its longest word next to its badge + + {options.map((option) => ( + + {option.icon?.(false)} + + {option.label.split(' ').map((word, index) => ( + + {word} + + ))} + + {typeof option.count === 'number' && ( + + + {option.count} + + + )} + + ))} + + )} {activeLayout && width > 0 && ( ({ }} // Dynamic Type: a label of several words wraps to a second line instead of being cut; // one-word labels keep their width, so they never break mid-word - style={[styles.segment, option.label.includes(' ') ? null : styles.segmentOneWord]} + style={[ + styles.segment, + option.label.includes(' ') ? null : styles.segmentOneWord, + fit === 'compact' ? styles.segmentCompact : null, + fit === 'stacked' ? styles.segmentStacked : null, + ]} > {option.icon?.(selected)} ({ minHeight: 42, minWidth: 0, borderRadius: 17, - paddingHorizontal: 8, + paddingHorizontal: SEGMENT_PADDING, paddingVertical: 3, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - gap: 6, + gap: SEGMENT_GAP, }, segmentOneWord: { flexShrink: 0 }, + segmentCompact: { paddingHorizontal: COMPACT_PADDING, gap: COMPACT_GAP }, + segmentStacked: { flexDirection: 'column', gap: 2, paddingVertical: 4 }, + // far out of the track, unconstrained, so every word keeps its natural width + measurer: { position: 'absolute', top: 0, left: -10000, width: 5000, opacity: 0 }, + measureRow: { + alignSelf: 'flex-start', + flexDirection: 'row', + alignItems: 'center', + gap: SEGMENT_GAP, + paddingHorizontal: SEGMENT_PADDING, + }, label: { flexShrink: 1, fontFamily: theme.fontFamily.textHeavy, @@ -177,7 +273,8 @@ const styles = StyleSheet.create((theme) => ({ }, count: { minWidth: 20, - height: 20, + // grows with Dynamic Type instead of clipping the number + minHeight: 20, borderRadius: 10, paddingHorizontal: 5, alignItems: 'center', @@ -185,5 +282,10 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: 'rgba(255,255,255,0.16)', }, countOn: { backgroundColor: theme.colors.navy }, - countText: { fontFamily: theme.fontFamily.textHeavy, fontSize: 11, lineHeight: 14 }, + countText: { + fontFamily: theme.fontFamily.textHeavy, + fontSize: 11, + lineHeight: 14, + fontVariant: ['tabular-nums'], + }, })); diff --git a/mobile/src/design/components/groupB.test.tsx b/mobile/src/design/components/groupB.test.tsx index c99a7cc86..91d5fbd73 100644 --- a/mobile/src/design/components/groupB.test.tsx +++ b/mobile/src/design/components/groupB.test.tsx @@ -9,6 +9,7 @@ import { hexInitials, ListRow, SegmentedControl, + segmentFit, toastConfig, } from './groupB'; import { palette } from '../tokens'; @@ -68,6 +69,51 @@ describe('SegmentedControl', () => { }); }); +describe('SegmentedControl with counts on every segment (Evenimente)', () => { + // Manrope 800 widths of Deschise / Particip / Organizațiile with their badges, padding included + const atDefault = [105, 96, 129]; + const atLargest = [130, 118, 163]; + const counts = [true, true, true]; + + it('stays inline when the row fits, tightens a little, then puts the badges under the labels', () => { + expect(segmentFit(atDefault, counts, 390 - 36 - 10)).toBe('inline'); + // iPhone SE / mini at the default size: 1pt short, the tighter padding is enough + expect(segmentFit([111, 101, 118], counts, 375 - 36 - 10)).toBe('compact'); + // largest Dynamic Type: no word may be cut, the badge goes under the label + expect(segmentFit(atLargest, counts, 430 - 36 - 10)).toBe('stacked'); + }); + + it('measures its segments once laid out and stacks the badges when they do not fit', async () => { + const eventOptions = [ + { key: 'OPEN', label: 'Deschise', count: 3 }, + { key: 'GOING', label: 'Particip', count: 2 }, + { key: 'ORGANIZATIONS', label: 'Organizațiile mele', count: 5 }, + ]; + await render( + , + ); + // before any layout nothing is measured and the segments are inline + expect(screen.getByTestId('seg-GOING')).toHaveStyle({ flexDirection: 'row' }); + await fireEvent(screen.getByTestId('seg'), 'layout', { + nativeEvent: { layout: { x: 0, y: 0, width: 354, height: 52 } }, + }); + for (const [index, width] of atLargest.entries()) { + await fireEvent( + screen.getByTestId(`seg-measure-${eventOptions[index].key}`, { + includeHiddenElements: true, + }), + 'layout', + { + nativeEvent: { layout: { x: 0, y: 0, width, height: 30 } }, + }, + ); + } + expect(screen.getByTestId('seg-GOING')).toHaveStyle({ flexDirection: 'column' }); + // every badge is still there, and still part of its segment's label + expect(screen.getByLabelText('Organizațiile mele, 5')).toBeTruthy(); + }); +}); + describe('ListRow and Group', () => { it('a pressable row has a button role, a label and fires', async () => { const onPress = jest.fn(); diff --git a/mobile/src/screens/Events.tsx b/mobile/src/screens/Events.tsx index 2493638b4..4c6d6bd67 100644 --- a/mobile/src/screens/Events.tsx +++ b/mobile/src/screens/Events.tsx @@ -12,7 +12,8 @@ import { StyleSheet } from 'react-native-unistyles'; import { useTranslation } from 'react-i18next'; import { EventType } from '../common/enums/event-type.enum'; import { OrderDirection } from '../common/enums/order-direction.enum'; -import { useEventsInfiniteQuery } from '../services/event/event.service'; +import { useEventsCountsQuery, useEventsInfiniteQuery } from '../services/event/event.service'; +import { EventsCounts } from '../services/event/event.api'; import { useEvent } from '../store/event/event.selector'; import useMyOrganizations from '../hooks/useMyOrganizations'; import { useFocusedStatusBarStyle } from '../hooks/useFocusedStatusBarStyle'; @@ -44,6 +45,12 @@ import EventSkeletonListItem from '../components/skeleton/event-skeleton-item'; const SWAP_OUT_MS = 140; /** the header takes the first three entrance steps (title, search row, segmented control) */ const HEADER_STEPS = 3; +/** which field of GET /mobile/event/counts badges each segment */ +const COUNT_FIELD: Record = { + [EventType.OPEN]: 'open', + [EventType.GOING]: 'going', + [EventType.ORGANIZATIONS]: 'organizations', +}; /** One list item: enters with the stagger (first screenful only) and follows the swap-out. */ const ListItem = ({ @@ -166,13 +173,25 @@ const Events = ({ navigation, route }: any) => { isRefetching, } = useEventsInfiniteQuery(OrderDirection.ASC, filters.search, eventFilter, queryFilters); + // the badges of the three segments, for the same search and filters as the list + const { data: counts, refetch: refetchCounts } = useEventsCountsQuery( + filters.search, + queryFilters, + ); + // an RSVP from the event page changes what the tabs show const reload = useCallback(() => { refetch(); - }, [refetch]); + refetchCounts(); + }, [refetch, refetchCounts]); useEffect(() => { reload(); }, [event, reload]); + // coming back to the tab (or from an event) brings the badges up to date + useEffect( + () => navigation.addListener?.('focus', () => refetchCounts()), + [navigation, refetchCounts], + ); const items = useMemo( () => (events?.pages ?? []).flatMap((page) => page.items) as EventCardItem[], @@ -203,9 +222,13 @@ const Events = ({ navigation, route }: any) => { // on the first screen the cards follow the header's steps; after a swap they start at once const itemOffset = initialListKey.current ? HEADER_STEPS : 0; const isInitialLoading = isFetching && !isFetchingNextPage && !isRefetching && !items.length; - // the count of the list on screen (the API gives no count for the other segments) - const countFor = (key: EventType) => - key === eventFilter && key === selectedFilter && !isInitialLoading ? total : undefined; + // every segment shows its count; until the counts arrive, the list on screen gives its own + const countFor = (key: EventType): number | undefined => { + if (counts) { + return counts[COUNT_FIELD[key]]; + } + return key === eventFilter && key === selectedFilter && !isInitialLoading ? total : undefined; + }; const onOpenEvent = (eventId: string) => { haptics.light(); diff --git a/mobile/src/screens/events.test.tsx b/mobile/src/screens/events.test.tsx index cec3b98a1..0fbb83828 100644 --- a/mobile/src/screens/events.test.tsx +++ b/mobile/src/screens/events.test.tsx @@ -23,6 +23,9 @@ jest.mock('expo-linking', () => ({ })); const mockCalls: any[][] = []; +const mockCountCalls: any[][] = []; +const mockRefetchCounts = jest.fn(); +let mockCounts: any = { open: 3, going: 2, organizations: 0 }; const mockSetRsvp = jest.fn(); const mockRefetchEvent = jest.fn(); let mockEventError: any = null; @@ -67,6 +70,16 @@ jest.mock('../services/event/event.service', () => ({ refetch: jest.fn(), }; }, + // the segment badges: remembers the search and filters they were asked for + useEventsCountsQuery: (...args: any[]) => { + mockCountCalls.push(args); + const [search, filters] = args; + const empty = search === 'nimic' || filters?.organizationId === 'org-2'; + return { + data: mockCounts && (empty ? { open: 0, going: 0, organizations: 0 } : mockCounts), + refetch: mockRefetchCounts, + }; + }, useEventQuery: () => ({ isFetching: false, error: mockEventError, refetch: mockRefetchEvent }), useSetRsvpEventMutation: () => ({ mutate: mockSetRsvp, isLoading: false }), useCancelRsvpMutation: () => ({ mutate: jest.fn(), isLoading: false }), @@ -94,6 +107,8 @@ const renderApp = (initialRouteName = 'events', params?: Record beforeEach(() => { mockEventError = null; mockCalls.length = 0; + mockCountCalls.length = 0; + mockCounts = { open: 3, going: 2, organizations: 0 }; jest.clearAllMocks(); useStore.setState({ userProfile: { @@ -141,6 +156,46 @@ describe('Evenimente', () => { expect(within(screen.getByTestId('events-tabs-OPEN')).getByText('3')).toBeTruthy(); }); + it('every segment shows its count, from the counts endpoint', async () => { + await renderApp(); + await screen.findByText('Eveniment 1'); + expect(within(screen.getByTestId('events-tabs-OPEN')).getByText('3')).toBeTruthy(); + expect(within(screen.getByTestId('events-tabs-GOING')).getByText('2')).toBeTruthy(); + expect(within(screen.getByTestId('events-tabs-ORGANIZATIONS')).getByText('0')).toBeTruthy(); + expect(screen.getByTestId('events-tabs-GOING').props.accessibilityLabel).toBe('Particip, 2'); + // asked with the list's search and filters + expect(mockCountCalls[mockCountCalls.length - 1]).toEqual(['', {}]); + }); + + it('until the counts arrive, only the segment on screen shows its list total', async () => { + mockCounts = undefined; + await renderApp(); + await screen.findByText('Eveniment 1'); + expect(within(screen.getByTestId('events-tabs-OPEN')).getByText('3')).toBeTruthy(); + expect(within(screen.getByTestId('events-tabs-GOING')).queryByText('2')).toBeNull(); + }); + + it('the counts follow the filters and refresh on pull, focus and RSVP changes', async () => { + await renderApp(); + await fireEvent.press(await screen.findByTestId('events-filter-button')); + await fireEvent.press(screen.getByTestId('filter-organization-org-2')); + expect(mockCountCalls[mockCountCalls.length - 1][1]).toEqual({ organizationId: 'org-2' }); + expect(within(screen.getByTestId('events-tabs-GOING')).getByText('0')).toBeTruthy(); + + mockRefetchCounts.mockClear(); + // an RSVP on the event page updates the store's event + await act(async () => { + useStore.setState({ event: { ...event('1'), description: 'x' } as any }); + }); + expect(mockRefetchCounts).toHaveBeenCalled(); + + mockRefetchCounts.mockClear(); + await act(async () => { + screen.getByTestId('events-list').props.refreshControl.props.onRefresh(); + }); + expect(mockRefetchCounts).toHaveBeenCalled(); + }); + it('"Particip" lists the events you go to, marked "Înscris"', async () => { await renderApp(); await fireEvent.press(await screen.findByText('Particip')); diff --git a/mobile/src/services/event/event.api.test.ts b/mobile/src/services/event/event.api.test.ts index 1292b4e77..d46d5bb13 100644 --- a/mobile/src/services/event/event.api.test.ts +++ b/mobile/src/services/event/event.api.test.ts @@ -1,5 +1,5 @@ import API from '../api'; -import { getEvents } from './event.api'; +import { getEvents, getEventsCounts } from './event.api'; import { OrderDirection } from '../../common/enums/order-direction.enum'; import { EventType } from '../../common/enums/event-type.enum'; @@ -30,3 +30,19 @@ describe('getEvents', () => { }); }); }); + +describe('getEventsCounts', () => { + it('asks for the segment badges with the search and filters, without segment or page', async () => { + (API.get as jest.Mock).mockResolvedValue({ data: { open: 4, going: 1, organizations: 2 } }); + + await expect( + getEventsCounts({ search: 'mar', organizationId: 'org-1', startFrom: '2026-11-20' }), + ).resolves.toEqual({ open: 4, going: 1, organizations: 2 }); + expect(API.get).toHaveBeenLastCalledWith('/mobile/event/counts', { + params: { search: 'mar', organizationId: 'org-1', startFrom: '2026-11-20' }, + }); + + await getEventsCounts({ search: '' }); + expect(API.get).toHaveBeenLastCalledWith('/mobile/event/counts', { params: {} }); + }); +}); diff --git a/mobile/src/services/event/event.api.ts b/mobile/src/services/event/event.api.ts index bcb9994e2..7e980494c 100644 --- a/mobile/src/services/event/event.api.ts +++ b/mobile/src/services/event/event.api.ts @@ -35,6 +35,23 @@ export const getEvents = async ({ }).then((res) => res.data); }; +/** How many events each Evenimente segment holds (Deschise, Particip, Organizațiile mele) */ +export interface EventsCounts { + open: number; + going: number; + organizations: number; +} + +// The segment badges, for the same search and filters as the list (no segment, no paging) +export const getEventsCounts = async ({ + search, + ...filters +}: EventsFilters & { search?: string }): Promise => { + return API.get('/mobile/event/counts', { + params: { ...(search ? { search } : {}), ...filters }, + }).then((res) => res.data); +}; + /** An event of the „Adaugă ore” picker; older backends send no `startDate`. */ export interface IEventOption extends IOrganizationStructureItem { startDate?: string | null; diff --git a/mobile/src/services/event/event.service.test.tsx b/mobile/src/services/event/event.service.test.tsx new file mode 100644 index 000000000..79c436791 --- /dev/null +++ b/mobile/src/services/event/event.service.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { act, renderHook } from '@testing-library/react-native'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { + EVENTS_COUNTS_KEY, + useCancelRsvpMutation, + useEventsCountsQuery, + useSetRsvpEventMutation, +} from './event.service'; +import { cancelRsvp, getEventsCounts, setRsvpEvent } from './event.api'; +import useStore from '../../store/store'; + +jest.mock('./event.api', () => ({ + getEventsCounts: jest.fn(), + setRsvpEvent: jest.fn(async () => undefined), + cancelRsvp: jest.fn(async () => undefined), +})); + +const setup = async (hook: () => T) => { + // no garbage-collection timers left behind, so jest can exit + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, cacheTime: Infinity }, + }, + }); + const invalidate = jest.spyOn(client, 'invalidateQueries'); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const { result } = await renderHook(hook, { wrapper }); + return { result, invalidate }; +}; + +describe('segment counts', () => { + it('asks for the counts with the search and filters', async () => { + (getEventsCounts as jest.Mock).mockResolvedValue({ open: 1, going: 2, organizations: 3 }); + const { result } = await setup(() => useEventsCountsQuery('mar', { organizationId: 'org-1' })); + await act(async () => undefined); + expect(getEventsCounts).toHaveBeenCalledWith({ search: 'mar', organizationId: 'org-1' }); + expect(result.current.data).toEqual({ open: 1, going: 2, organizations: 3 }); + }); + + it('an RSVP refreshes the badges', async () => { + const { result, invalidate } = await setup(() => useSetRsvpEventMutation()); + await act(async () => { + await result.current.mutateAsync({ eventId: 'e1', rsvp: { going: true } }); + }); + expect(setRsvpEvent).toHaveBeenCalledWith('e1', { going: true }); + expect(invalidate).toHaveBeenCalledWith(EVENTS_COUNTS_KEY); + }); + + it('withdrawing an RSVP refreshes the badges', async () => { + useStore.setState({ event: { id: 'e1', numberOfPersonsGoingToEvent: 3 } as any }); + const { result, invalidate } = await setup(() => useCancelRsvpMutation()); + await act(async () => { + await result.current.mutateAsync({ eventId: 'e1' }); + }); + expect(cancelRsvp).toHaveBeenCalledWith('e1'); + expect(invalidate).toHaveBeenCalledWith(EVENTS_COUNTS_KEY); + }); +}); diff --git a/mobile/src/services/event/event.service.ts b/mobile/src/services/event/event.service.ts index 7ba8e8950..f83f689fe 100644 --- a/mobile/src/services/event/event.service.ts +++ b/mobile/src/services/event/event.service.ts @@ -1,6 +1,13 @@ -import { useInfiniteQuery, useMutation, useQuery } from 'react-query'; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from 'react-query'; import { OrderDirection } from '../../common/enums/order-direction.enum'; -import { cancelRsvp, EventsFilters, getEvent, getEvents, setRsvpEvent } from './event.api'; +import { + cancelRsvp, + EventsFilters, + getEvent, + getEvents, + getEventsCounts, + setRsvpEvent, +} from './event.api'; import { EventType } from '../../common/enums/event-type.enum'; import useStore from '../../store/store'; import { IEvent } from '../../common/interfaces/event.interface'; @@ -29,6 +36,16 @@ export const useEventsInfiniteQuery = ( ); }; +/** query key of the segment badges; an RSVP invalidates it */ +export const EVENTS_COUNTS_KEY = 'events-counts'; + +/** The badges of the three Evenimente segments, for the list's search and filters */ +export const useEventsCountsQuery = (search: string, filters: EventsFilters = {}) => { + return useQuery([EVENTS_COUNTS_KEY, search, filters], () => + getEventsCounts({ search, ...filters }), + ); +}; + export const useEventQuery = (eventId: string) => { const { setEvent } = useStore(); return useQuery(['event', eventId], () => getEvent(eventId), { @@ -38,21 +55,30 @@ export const useEventQuery = (eventId: string) => { }; export const useSetRsvpEventMutation = () => { + const queryClient = useQueryClient(); return useMutation( ['rsvp-event'], ({ eventId, rsvp }: { eventId: string; rsvp: RsvpResponse }) => { return setRsvpEvent(eventId, rsvp); }, + { + // the "Particip" badge follows the answer + onSuccess: () => queryClient.invalidateQueries(EVENTS_COUNTS_KEY), + }, ); }; export const useCancelRsvpMutation = () => { const { canceRsvp } = useStore(); + const queryClient = useQueryClient(); return useMutation( ['cancel-rsvp-event'], ({ eventId }: { eventId: string }) => cancelRsvp(eventId), { - onSuccess: canceRsvp, + onSuccess: () => { + canceRsvp(); + queryClient.invalidateQueries(EVENTS_COUNTS_KEY); + }, }, ); };