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
7 changes: 3 additions & 4 deletions backend/src/api/_mobile/event/event.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -67,13 +66,13 @@ export class MobileEventController {
async getEventsByOrganizationId(
@Param('organizationId', UuidValidationPipe) organizationId: string,
@ExtractUser() { id }: IRegularUserModel,
): Promise<IdAndNamePresenter<IEventModel>[]> {
): Promise<MobileEventOptionPresenter[]> {
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')
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IEventModel> {
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;
}
90 changes: 90 additions & 0 deletions backend/test/mobile-event-options.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
15 changes: 14 additions & 1 deletion mobile/src/common/utils/event-format.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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('');
});
});
9 changes: 9 additions & 0 deletions mobile/src/common/utils/event-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
9 changes: 9 additions & 0 deletions mobile/src/components/FormSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -79,6 +83,11 @@ const Option = ({
<VText variant={active ? 'bodyStrong' : 'body'} style={styles.text}>
{option.label}
</VText>
{!!option.detail && (
<AppText variant="overline" color={palette.ink2}>
{option.detail}
</AppText>
)}
{active && <Check />}
</PressableScale>
);
Expand Down
8 changes: 4 additions & 4 deletions mobile/src/components/hours/AddHoursSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -54,7 +54,7 @@ const defaultValues = (): Partial<ActivityLogFormTypes> => ({ 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.
*/
Expand Down Expand Up @@ -205,8 +205,8 @@ export const AddHoursSheet = forwardRef<AddHoursSheetHandle, AddHoursSheetProps>
const footer = (
<Animated.View style={footerStyle} pointerEvents={sent ? 'none' : 'auto'}>
<Button
label={`${t('submit')} →`}
accessibilityLabel={`${t('submit')}`}
label={`${t('submit')}`}
trailingIcon={ArrowRightIcon}
onPress={handleSubmit(onSubmit)}
loading={isLoggingHours}
disabled={isLoggingHours || !!sent}
Expand Down
2 changes: 2 additions & 0 deletions mobile/src/components/hours/HoursSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export const HoursSummary = ({
pending={pending || 0}
rejected={rejected || 0}
size={120}
// Ore: navy / white pending stripes (Acasă's navy card uses yellow on #012A40)
tone="onLight"
testID="impact-hexagon"
/>
</View>
Expand Down
9 changes: 6 additions & 3 deletions mobile/src/containers/EventSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import React, { useEffect, useState } from 'react';
import FormSelect, { FormSelectProps, ISelectItem } from '../components/FormSelect';
import { getEventsByOrganizationId } from '../services/event/event.api';
import { formatDayMonth } from '../common/utils/event-format';

interface EventSelectProps extends Omit<FormSelectProps, 'options'> {
organizationId: string;
Expand All @@ -19,9 +20,11 @@ const EventSelect = ({ organizationId, noneLabel, ...props }: EventSelectProps)
const loadEvents = async () => {
try {
const rawEvents = await getEventsByOrganizationId(organizationId);
const mappedStructures = rawEvents.map((structure) => ({
label: structure.name,
key: structure.id,
const mappedStructures = rawEvents.map((event) => ({
label: event.name,
key: event.id,
// „13 NOV” on the right of the row, like the prototype's menu
detail: formatDayMonth(event.startDate) || undefined,
}));
setStructures(mappedStructures);
} catch {
Expand Down
46 changes: 41 additions & 5 deletions mobile/src/design/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Button „Îndrăzneț”. Variants: primary (yellow, one per screen), secondary (navy), outline,
// destructive. Sizes L 56 / M 44 / S 34, radius 20 / 16 / 14. Pressed: scale .96 with the snappy
// spring, a darker fill and a tighter shadow. Loading: a small rotating hexagon replaces the label
// and the button keeps its width. Disabled: 45% opacity.
import React, { ReactNode, useEffect } from 'react';
// and the button keeps its width. Disabled: 45% opacity. Icons: a lucide icon before the label
// (`icon`, e.g. Plus on „Adaugă ore”) and/or after it (`trailingIcon`, e.g. ArrowRight on
// „Trimite spre aprobare”), drawn in the label colour at 20 / 18 / 16pt (L / M / S).
import React, { ComponentType, isValidElement, ReactNode, useEffect } from 'react';
import { Insets, Pressable, StyleProp, View, ViewStyle } from 'react-native';
import Animated, {
cancelAnimation,
Expand All @@ -23,13 +25,24 @@ import { AppText } from './Text';
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'destructive';
export type ButtonSize = 'L' | 'M' | 'S';

/** A lucide icon component (from design/icons), drawn by the button in its size and colour. */
export type ButtonIconComponent = ComponentType<{
size?: number;
color?: string;
strokeWidth?: number;
}>;
/** A lucide icon component, or an element the caller draws itself. */
export type ButtonIcon = ButtonIconComponent | ReactNode;

export interface ButtonProps {
label: string;
onPress?: () => void;
variant?: ButtonVariant;
size?: ButtonSize;
/** element before the label, e.g. a lucide icon */
icon?: ReactNode;
/** before the label, e.g. `PlusIcon` (or an element drawn by the caller) */
icon?: ButtonIcon;
/** after the label, e.g. `ArrowRightIcon` (or an element drawn by the caller) */
trailingIcon?: ButtonIcon;
loading?: boolean;
disabled?: boolean;
/** stretch to the width of the parent */
Expand Down Expand Up @@ -64,6 +77,27 @@ const SIZES: Record<ButtonSize, { height: number; radius: number; padding: numbe
S: { height: 34, radius: 14, padding: 12, font: 13 },
};

/** Icon size per button size: 20pt like the prototype's `.btn svg`, smaller on M and S. */
export const BUTTON_ICON_SIZE: Record<ButtonSize, number> = { L: 20, M: 18, S: 16 };
// the prototype's arrow and chevrons are drawn at stroke 2.4
const ICON_STROKE = 2.4;

const isIconComponent = (icon: ButtonIcon): icon is ButtonIconComponent =>
typeof icon === 'function' ||
(typeof icon === 'object' && icon !== null && !isValidElement(icon) && '$$typeof' in icon);

/** A lucide component in the button's size and label colour; an element as the caller drew it. */
const renderIcon = (icon: ButtonIcon | undefined, size: number, color: string): ReactNode => {
if (icon === undefined || icon === null || icon === false) {
return null;
}
if (isIconComponent(icon)) {
const Icon = icon;
return <Icon size={size} color={color} strokeWidth={ICON_STROKE} />;
}
return icon as ReactNode;
};

const PRESSED_SCALE = 0.96;
const SPIN_MS = 900;

Expand Down Expand Up @@ -91,6 +125,7 @@ export const Button = ({
variant = 'primary',
size = 'L',
icon,
trailingIcon,
loading = false,
disabled = false,
full = false,
Expand Down Expand Up @@ -185,7 +220,7 @@ export const Button = ({
accessibilityElementsHidden={loading}
importantForAccessibility={loading ? 'no-hide-descendants' : 'auto'}
>
{icon}
{renderIcon(icon, BUTTON_ICON_SIZE[size], look.fg)}
<AppText
variant="button"
color={look.fg}
Expand All @@ -194,6 +229,7 @@ export const Button = ({
>
{label}
</AppText>
{renderIcon(trailingIcon, BUTTON_ICON_SIZE[size], look.fg)}
</View>
{loading && (
<View style={styles.spinner} pointerEvents="none">
Expand Down
Loading
Loading