diff --git a/backend/src/api/_mobile/event/event.controller.ts b/backend/src/api/_mobile/event/event.controller.ts index 191cdee84..16708b5ae 100644 --- a/backend/src/api/_mobile/event/event.controller.ts +++ b/backend/src/api/_mobile/event/event.controller.ts @@ -28,8 +28,7 @@ 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 { IdAndNamePresenter } from 'src/infrastructure/presenters/id-name.presenter'; -import { IEventModel } from 'src/modules/event/models/event.model'; +import { MobileEventOptionPresenter } from './presenters/mobile-event-option.presenter'; @ApiBearerAuth() @UseGuards(MobileJwtAuthGuard, MobileProfileGuard) // VolunteerGuard @@ -67,13 +66,13 @@ export class MobileEventController { async getEventsByOrganizationId( @Param('organizationId', UuidValidationPipe) organizationId: string, @ExtractUser() { id }: IRegularUserModel, - ): Promise[]> { + ): Promise { const events = await this.getEventsByOrganization.execute({ userId: id, organizationId, }); - return events.map((event) => new IdAndNamePresenter(event)); + return events.map((event) => new MobileEventOptionPresenter(event)); } @Get(':id') diff --git a/backend/src/api/_mobile/event/presenters/mobile-event-option.presenter.ts b/backend/src/api/_mobile/event/presenters/mobile-event-option.presenter.ts new file mode 100644 index 000000000..3dea629a0 --- /dev/null +++ b/backend/src/api/_mobile/event/presenters/mobile-event-option.presenter.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IEventModel } from 'src/modules/event/models/event.model'; +import { IdAndNamePresenter } from 'src/infrastructure/presenters/id-name.presenter'; + +// The event picker of „Adaugă ore”: id and name as before, plus the start date the menu shows +// on the right of each row („13 NOV”). +export class MobileEventOptionPresenter extends IdAndNamePresenter { + constructor(event: IEventModel) { + super(event); + this.startDate = event.startDate; + } + + @Expose() + @ApiProperty({ + description: 'When the event starts', + example: '2026-11-13T09:00:00.000Z', + }) + startDate: Date; +} diff --git a/backend/test/mobile-event-options.e2e-spec.ts b/backend/test/mobile-event-options.e2e-spec.ts new file mode 100644 index 000000000..eccab642a --- /dev/null +++ b/backend/test/mobile-event-options.e2e-spec.ts @@ -0,0 +1,90 @@ +import { + AuthIdentityStatus, + AuthRealm, +} from 'src/modules/auth/enums/auth.enums'; +import { IAuthIdentityModel } from 'src/modules/auth/models/auth-identity.model'; +import { AuthIdentityFacade } from 'src/modules/auth/services/auth-identity.facade'; +import * as request from 'supertest'; +import { createTestApp, TestApp } from './utils/test-app'; +import { truncate } from './utils/test-data-source'; + +// The event picker of „Adaugă ore” (GET /mobile/event/organization/:id): id and name as before, +// plus the start date shown on the right of each menu row. +describe('Mobile event options (e2e)', () => { + let testApp: TestApp; + let elena: IAuthIdentityModel; + let orgId: string; + + const options = async ( + organizationId: string, + ): Promise<{ id: string; name: string; startDate: string }[]> => + ( + await request(testApp.app.getHttpServer()) + .get(`/mobile/event/organization/${organizationId}`) + .set( + 'Authorization', + `Bearer ${await testApp.mintAccessToken(elena, AuthRealm.MOBILE)}`, + ) + .expect(200) + ).body; + + beforeAll(async () => { + testApp = await createTestApp(); + }); + + afterAll(async () => { + await testApp?.close(); + }); + + beforeEach(async () => { + await truncate(testApp.dataSource, [ + 'auth_identity', + 'event', + 'volunteer', + 'user', + 'organization', + ]); + const [org] = await testApp.dataSource.query( + `INSERT INTO organization (name, email, phone, status, validated_at) VALUES ('Crucea', 'contact@crucea.ro', '+40700000000', 'active', now()) RETURNING id`, + ); + orgId = org.id; + const [user] = await testApp.dataSource.query( + `INSERT INTO "user" (type, name, email, phone) VALUES ('regular', 'Elena Pop', 'elena@example.ro', '+40722000000') RETURNING id`, + ); + await testApp.dataSource.query( + `INSERT INTO volunteer (user_id, organization_id, status) VALUES ($1, $2, 'active')`, + [user.id, orgId], + ); + elena = await testApp.app.get(AuthIdentityFacade).createIdentity({ + realm: AuthRealm.MOBILE, + email: 'elena@example.ro', + status: AuthIdentityStatus.ACTIVE, + userId: user.id, + }); + await testApp.dataSource.query( + `INSERT INTO event (name, description, start_date, end_date, status, organization_id) VALUES + ('Atelier Comunicare', 'd', '2025-11-13T09:00:00Z', '2025-11-13T12:00:00Z', 'published', $1), + ('Viitor', 'd', now() + interval '30 days', now() + interval '31 days', 'published', $1), + ('Ciornă', 'd', '2025-11-01T09:00:00Z', NULL, 'draft', $1)`, + [orgId], + ); + }); + + it('lists the started published events with their start date', async () => { + const body = await options(orgId); + expect(body).toEqual([ + { + id: expect.any(String), + name: 'Atelier Comunicare', + startDate: '2025-11-13T09:00:00.000Z', + }, + ]); + }); + + it('stays empty for an organization the user does not volunteer in', async () => { + const [other] = await testApp.dataSource.query( + `INSERT INTO organization (name, email, phone, status, validated_at) VALUES ('Alta', 'contact@alta.ro', '+40700000001', 'active', now()) RETURNING id`, + ); + expect(await options(other.id)).toEqual([]); + }); +}); diff --git a/mobile/src/common/utils/event-format.test.ts b/mobile/src/common/utils/event-format.test.ts index 55bee8f4b..e92e77496 100644 --- a/mobile/src/common/utils/event-format.test.ts +++ b/mobile/src/common/utils/event-format.test.ts @@ -1,5 +1,5 @@ import '../config/i18n'; -import { formatPostedAt } from './event-format'; +import { formatDayMonth, formatPostedAt } from './event-format'; describe('formatPostedAt (audit #21, #22)', () => { const now = new Date(2026, 10, 10, 18, 0); @@ -19,3 +19,16 @@ describe('formatPostedAt (audit #21, #22)', () => { expect(formatPostedAt('not a date', now)).toBe(''); }); }); + +describe('formatDayMonth (event rows of „Adaugă ore”)', () => { + it('gives the day and the short month, shown as „13 NOV” in overline', () => { + expect(formatDayMonth(new Date(2026, 10, 13, 9, 0))).toBe('13 nov'); + expect(formatDayMonth(new Date(2026, 0, 2).toISOString())).toBe('2 ian'); + }); + + it('is empty when an older backend sends no date', () => { + expect(formatDayMonth(undefined)).toBe(''); + expect(formatDayMonth(null)).toBe(''); + expect(formatDayMonth('not a date')).toBe(''); + }); +}); diff --git a/mobile/src/common/utils/event-format.ts b/mobile/src/common/utils/event-format.ts index 69fdebe60..3c8ba7324 100644 --- a/mobile/src/common/utils/event-format.ts +++ b/mobile/src/common/utils/event-format.ts @@ -44,6 +44,15 @@ export const formatLongDay = (value: string | Date) => /** "09:00" */ export const formatTime = (value: string | Date) => format(new Date(value), 'HH:mm'); +/** "13 nov" for the right of an event row (shown as „13 NOV” in overline); '' without a date. */ +export const formatDayMonth = (value?: string | Date | null) => { + if (!value) { + return ''; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '' : `${format(date, 'd')} ${shortMonth(date)}`; +}; + /** Day number and short month for the date tile: { day: '22', month: 'NOV' }. */ export const dateTile = (value: string | Date) => ({ day: format(new Date(value), 'd'), diff --git a/mobile/src/components/FormSelect.tsx b/mobile/src/components/FormSelect.tsx index 40f22df3f..dc02abff3 100644 --- a/mobile/src/components/FormSelect.tsx +++ b/mobile/src/components/FormSelect.tsx @@ -13,6 +13,8 @@ import { colors } from '../common/theme/tokens'; import { PressableScale, Sheet, VText, VTextField } from './ui'; import { INLINE_MAX, Select } from '../design/components/Select'; import { EventIcon } from './events/EventIcons'; +import { AppText } from '../design/components/Text'; +import { palette } from '../design/tokens'; // Longer lists (counties, cities) open in a tall sheet with a search field. const LONG_LIST = 12; @@ -28,6 +30,8 @@ const normalize = (text: string) => export interface ISelectItem { label: string; key: string | number; + /** short text on the right of the row, e.g. an event's date („13 NOV”) */ + detail?: string; } export interface FormSelectProps { @@ -79,6 +83,11 @@ const Option = ({ {option.label} + {!!option.detail && ( + + {option.detail} + + )} {active && } ); diff --git a/mobile/src/components/hours/AddHoursSheet.tsx b/mobile/src/components/hours/AddHoursSheet.tsx index 3b47c1491..c1966a6ca 100644 --- a/mobile/src/components/hours/AddHoursSheet.tsx +++ b/mobile/src/components/hours/AddHoursSheet.tsx @@ -30,7 +30,7 @@ import ActivityLogForm, { ActivityLogFormTypes, activityLogSchema } from '../Act import { fontScaleCap, palette } from '../../design/tokens'; import { duration } from '../../design/motion'; import { haptics } from '../../design/haptics'; -import { CloseIcon, ICON_STROKE } from '../../design/icons'; +import { ArrowRightIcon, CloseIcon, ICON_STROKE } from '../../design/icons'; import { Button } from '../../design/components/Button'; import { Sheet } from '../../design/components/Sheet'; import { tight, useEffectiveFontScale } from '../../design/components/Text'; @@ -54,7 +54,7 @@ const defaultValues = (): Partial => ({ date: new Date(), /** * „Adaugă ore” as a 92% form sheet (prototype `.sheet`): Eveniment (optional), Activitate, Data with - * Azi / Ieri, the hours stepper, Mențiuni, and the sticky yellow „Trimite spre aprobare →”. On + * Azi / Ieri, the hours stepper, Mențiuni, and the sticky yellow „Trimite spre aprobare” with its arrow. On * success the sheet shows „Trimis spre aprobare”, then closes and hands the new entry to Ore. * Changed fields ask before the sheet is closed. */ @@ -205,8 +205,8 @@ export const AddHoursSheet = forwardRef const footer = (