diff --git a/public/mobile-app/package-lock.json b/public/mobile-app/package-lock.json index 24980a2b..2f5a878f 100644 --- a/public/mobile-app/package-lock.json +++ b/public/mobile-app/package-lock.json @@ -19,6 +19,7 @@ "@sveltejs/vite-plugin-svelte": "6.2.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/svelte": "5.2.9", + "dompurify": "3.4.5", "eslint": "9.39.1", "eslint-config-prettier": "10.1.8", "eslint-plugin-svelte": "3.13.1", @@ -2630,6 +2631,15 @@ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz", + "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", diff --git a/public/mobile-app/package.json b/public/mobile-app/package.json index 44e2fc5f..f2999afa 100644 --- a/public/mobile-app/package.json +++ b/public/mobile-app/package.json @@ -29,6 +29,7 @@ "@sveltejs/vite-plugin-svelte": "6.2.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/svelte": "5.2.9", + "dompurify": "3.4.5", "eslint": "9.39.1", "eslint-config-prettier": "10.1.8", "eslint-plugin-svelte": "3.13.1", diff --git a/public/mobile-app/src/lib/agenda.test.ts b/public/mobile-app/src/lib/agenda.test.ts index a7cee635..0c78cd56 100644 --- a/public/mobile-app/src/lib/agenda.test.ts +++ b/public/mobile-app/src/lib/agenda.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import '@testing-library/jest-dom/vitest'; +import { Address } from '$lib/address'; import { Agenda, buildAgenda, Item } from '$lib/agenda'; import * as catalogMethods from '$lib/api-catalog'; import * as scheduledNotificationsMethods from '$lib/scheduled-notifications'; @@ -125,6 +126,39 @@ describe('/agenda.ts', () => { expect(name2).equal('Novembre'); }); }); + describe('endDate', () => { + test('should return the max endDate of subitems', async () => { + // Given + const item1 = new Item('holiday', 'title', 'description', null, null, null); + const item2 = new Item( + 'holiday', + 'title', + 'description', + null, + null, + new Date('2025-11-15') + ); + const item3 = new Item( + 'holiday', + 'title', + 'description', + null, + null, + new Date('2025-11-15') + ); + item3.addSubItem('description', null, null, new Date('2025-11-14')); + + // When + const endDate1 = item1.endDate; + const endDate2 = item2.endDate; + const endDate3 = item3.endDate; + + // Then + expect(endDate1).equal(null); + expect(endDate2?.getTime()).equal(new Date('2025-11-15').getTime()); + expect(endDate3?.getTime()).equal(new Date('2025-11-15').getTime()); + }); + }); describe('period', () => { test('should not mention start date year', async () => { // Given @@ -535,18 +569,17 @@ describe('/agenda.ts', () => { new Item( 'holiday', 'Holiday 5', - 'Paris (75) 🏠', + 'Zone C : Paris (75) 🏠', null, holiday5.start_date, - holiday5.end_date, - true + holiday5.end_date ) ) ).toBe(true); }); }); describe('Zones', () => { - test('should mark holidays as custom if zones match user zone', async () => { + test('should ignore some zones', async () => { // Given vi.stubEnv('TZ', 'Europe/Paris'); localStorage.setItem('user_identity', JSON.stringify(mockUserIdentity)); @@ -557,7 +590,7 @@ describe('/agenda.ts', () => { date: null, start_date: new Date('2026-02-06T23:00:00Z'), end_date: new Date('2026-02-22T23:00:00Z'), - zones: ['Zone A'], + zones: ['Zone A', 'Zone foo'], emoji: 'foo', }; const holiday2 = { @@ -567,65 +600,32 @@ describe('/agenda.ts', () => { date: null, start_date: new Date('2026-02-13T23:00:00Z'), end_date: new Date('2026-03-01T23:00:00Z'), - zones: ['Zone C'], + zones: ['Zone foo'], emoji: 'foo', }; - const holiday3 = { - kind: 'holiday', - title: 'Summer Holiday', - description: '', - date: null, - start_date: new Date('2026-07-01T23:00:00Z'), - end_date: new Date('2026-08-31T23:00:00Z'), - zones: ['Zone A', 'Zone B', 'Zone C'], - emoji: 'bar', - }; - const holiday4 = { - kind: 'holiday', - title: 'Day', - description: '', - date: new Date('2026-02-15T23:00:00Z'), - start_date: null, - end_date: null, - zones: [], - emoji: '', - }; await userStore.login(mockUserInfo); const spyIsConcerned = vi .spyOn(Preferences.prototype, 'isSchoolHolidayConcerned') - .mockReturnValue(true); + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); const spyGetDescription = vi .spyOn(Preferences.prototype, 'getSchoolHolidayDescription') - .mockReturnValueOnce('desc 1') - .mockReturnValueOnce('desc 2') - .mockReturnValueOnce('desc 3'); + .mockReturnValueOnce('desc 1'); // When const agenda = new Agenda( { - school_holidays: [holiday1, holiday2, holiday3], - public_holidays: [holiday4], + school_holidays: [holiday1, holiday2], + public_holidays: [], elections: [], }, new Date('2026-02-01T12:00:00Z') ); // Then - expect(agenda.now.length).equal(4); + expect(agenda.now.length).equal(1); expect( agenda.now[0].equals( - new Item( - 'otv', - 'Opération Tranquillité Vacances 🏠', - 'Inscrivez-vous pour protéger votre domicile pendant votre absence', - null, - new Date('2026-01-23T23:00:00Z'), - null - ) - ) - ).toBe(true); - expect( - agenda.now[1].equals( new Item( 'holiday', 'Holiday foo', @@ -636,61 +636,23 @@ describe('/agenda.ts', () => { ) ) ).toBe(true); - expect( - agenda.now[2].equals( - new Item( - 'holiday', - 'Holiday foo', - 'desc 2', - null, - holiday2.start_date, - holiday2.end_date, - true - ) - ) - ).toBe(true); - expect( - agenda.now[3].equals( - new Item('holiday', 'Day', null, holiday4.date, null, null) - ) - ).toBe(true); - expect(agenda.next.length).equal(1); - expect( - agenda.next[0].equals( - new Item( - 'holiday', - 'Summer Holiday bar', - 'desc 3', - null, - holiday3.start_date, - holiday3.end_date, - true - ) - ) - ).toBe(true); - expect(spyIsConcerned).toHaveBeenCalledTimes(3); + expect(agenda.next.length).equal(0); + expect(spyIsConcerned).toHaveBeenCalledTimes(2); expect(spyIsConcerned).toHaveBeenCalledWith(holiday1); expect(spyIsConcerned).toHaveBeenCalledWith(holiday2); - expect(spyIsConcerned).toHaveBeenCalledWith(holiday3); - expect(spyGetDescription).toHaveBeenCalledTimes(3); + expect(spyGetDescription).toHaveBeenCalledTimes(1); expect(spyGetDescription).toHaveBeenCalledWith( holiday1, userStore.connected?.identity.address ); - expect(spyGetDescription).toHaveBeenCalledWith( - holiday2, - userStore.connected?.identity.address - ); - expect(spyGetDescription).toHaveBeenCalledWith( - holiday3, - userStore.connected?.identity.address - ); // Cleanup spyIsConcerned.mockRestore(); spyGetDescription.mockRestore(); }); - test('should ignore some zones', async () => { + }); + describe('Multitiles', () => { + test('should stack holidays with the same title - but only for the same year', async () => { // Given vi.stubEnv('TZ', 'Europe/Paris'); localStorage.setItem('user_identity', JSON.stringify(mockUserIdentity)); @@ -701,7 +663,7 @@ describe('/agenda.ts', () => { date: null, start_date: new Date('2026-02-06T23:00:00Z'), end_date: new Date('2026-02-22T23:00:00Z'), - zones: ['Zone A', 'Zone foo'], + zones: ['Zone A'], emoji: 'foo', }; const holiday2 = { @@ -711,22 +673,35 @@ describe('/agenda.ts', () => { date: null, start_date: new Date('2026-02-13T23:00:00Z'), end_date: new Date('2026-03-01T23:00:00Z'), - zones: ['Zone foo'], + zones: ['Zone B'], + emoji: 'foo', + }; + const holiday3 = { + kind: 'holiday', + title: 'Holiday', + description: '', + date: null, + start_date: new Date('2027-02-06T23:00:00Z'), + end_date: new Date('2027-02-22T23:00:00Z'), + zones: ['Zone A'], + emoji: 'foo', + }; + const holiday4 = { + kind: 'holiday', + title: 'Holiday', + description: '', + date: null, + start_date: new Date('2027-02-06T23:00:00Z'), + end_date: new Date('2027-02-21T23:00:00Z'), + zones: ['Zone B'], emoji: 'foo', }; await userStore.login(mockUserInfo); - const spyIsConcerned = vi - .spyOn(Preferences.prototype, 'isSchoolHolidayConcerned') - .mockReturnValueOnce(true) - .mockReturnValueOnce(false); - const spyGetDescription = vi - .spyOn(Preferences.prototype, 'getSchoolHolidayDescription') - .mockReturnValueOnce('desc 1'); // When const agenda = new Agenda( { - school_holidays: [holiday1, holiday2], + school_holidays: [holiday1, holiday2, holiday3, holiday4], public_holidays: [], elections: [], }, @@ -735,31 +710,29 @@ describe('/agenda.ts', () => { // Then expect(agenda.now.length).equal(1); - expect( - agenda.now[0].equals( - new Item( - 'holiday', - 'Holiday foo', - 'desc 1', - null, - holiday1.start_date, - holiday1.end_date - ) - ) - ).toBe(true); - expect(agenda.next.length).equal(0); - expect(spyIsConcerned).toHaveBeenCalledTimes(2); - expect(spyIsConcerned).toHaveBeenCalledWith(holiday1); - expect(spyIsConcerned).toHaveBeenCalledWith(holiday2); - expect(spyGetDescription).toHaveBeenCalledTimes(1); - expect(spyGetDescription).toHaveBeenCalledWith( - holiday1, - userStore.connected?.identity.address + const item1 = new Item( + 'holiday', + 'Holiday foo', + 'Zone A', + null, + holiday1.start_date, + holiday1.end_date ); - - // Cleanup - spyIsConcerned.mockRestore(); - spyGetDescription.mockRestore(); + item1.addSubItem('Zone B', null, holiday2.start_date, holiday2.end_date); + expect(agenda.now[0].equals(item1)).toBe(true); + expect(agenda.next.length).equal(1); + const item2 = new Item( + 'holiday', + 'Holiday foo', + 'Zone A', + null, + holiday3.start_date, + holiday3.end_date + ); + item2.addSubItem('Zone B', null, holiday4.start_date, holiday4.end_date); + expect(agenda.next[0].equals(item2)).toBe(true); + expect(agenda.next[0].subitems[0].period).toEqual('Du 7 au 22 février 2027'); + expect(agenda.next[0].subitems[1].period).toEqual('Du 7 au 23 février 2027'); }); }); describe('OTV', () => { @@ -767,7 +740,14 @@ describe('/agenda.ts', () => { // Given vi.stubEnv('TZ', 'Europe/Paris'); const newMockUserIdentity = JSON.parse(JSON.stringify(mockUserIdentity)); - newMockUserIdentity.dataDetails.address.postcode = '20000'; // Corse + newMockUserIdentity.address = new Address( + 'Bastia', + '2B, Haute-Corse, Corse', + '2B033', + 'Bastia', + 'Bastia', + '20200' + ); localStorage.setItem('user_identity', JSON.stringify(newMockUserIdentity)); const holiday1 = { kind: 'holiday', @@ -807,6 +787,47 @@ describe('/agenda.ts', () => { ).toBe(true); expect(agenda.next.length).equal(0); }); + test('should not display OTV if holiday zones does not match user preferences', async () => { + // Given + vi.stubEnv('TZ', 'Europe/Paris'); + const preferences = new Preferences(['Zone A'], []); + const newMockUserIdentity = JSON.parse(JSON.stringify(mockUserIdentity)); + newMockUserIdentity.preferences = preferences; + newMockUserIdentity.address = new Address( + 'Bastia', + '2B, Haute-Corse, Corse', + '2B033', + 'Bastia', + 'Bastia', + '20200' + ); + localStorage.setItem('user_identity', JSON.stringify(newMockUserIdentity)); + const holiday1 = { + kind: 'holiday', + title: 'Holiday', + description: '', + date: null, + start_date: new Date('2026-02-06T23:00:00Z'), + end_date: new Date('2026-02-22T23:00:00Z'), + zones: ['Corse'], + emoji: 'foo', + }; + await userStore.login(mockUserInfo); + + // When + const agenda = new Agenda( + { + school_holidays: [holiday1], + public_holidays: [], + elections: [], + }, + new Date('2026-02-01T12:00:00Z') + ); + + // Then + expect(agenda.now.length).equal(0); + expect(agenda.next.length).equal(0); + }); test('should not display past items or OTV related to past holidays', async () => { // Given vi.stubEnv('TZ', 'Europe/Paris'); @@ -904,7 +925,9 @@ describe('/agenda.ts', () => { ); // Then - expect(agenda.now.length).equal(3); + expect(agenda.now.length).equal(2); + expect(agenda.now[0].title).toEqual('Opération Tranquillité Vacances 🏠'); + expect(agenda.now[1].title).toEqual('Holiday foo'); }); }); describe('Scheduled notifications', () => { @@ -914,7 +937,11 @@ describe('/agenda.ts', () => { const spy = vi .spyOn(scheduledNotificationsMethods, 'createScheduledNotification') .mockResolvedValue(true); - localStorage.setItem('user_identity', JSON.stringify(mockUserIdentity)); + // holidays are not displayed but otv have to be sent + const preferences = new Preferences(['Réunion'], []); + const newMockUserIdentity = JSON.parse(JSON.stringify(mockUserIdentity)); + newMockUserIdentity.preferences = preferences; + localStorage.setItem('user_identity', JSON.stringify(newMockUserIdentity)); const holiday1 = { kind: 'holiday', title: 'Holiday', @@ -958,7 +985,7 @@ describe('/agenda.ts', () => { ); // Then - expect(agenda.now.length).equal(3); + expect(agenda.now.length).equal(0); expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledWith({ content_body: @@ -1035,7 +1062,9 @@ describe('/agenda.ts', () => { ); // Then - expect(agenda.now.length).equal(3); + expect(agenda.now.length).equal(2); + expect(agenda.now[0].title).toEqual('Opération Tranquillité Vacances 🏠'); + expect(agenda.now[1].title).toEqual('Holiday foo'); expect(spy).toHaveBeenCalledTimes(0); }); }); diff --git a/public/mobile-app/src/lib/agenda.ts b/public/mobile-app/src/lib/agenda.ts index 35e1d0bb..8916f05e 100644 --- a/public/mobile-app/src/lib/agenda.ts +++ b/public/mobile-app/src/lib/agenda.ts @@ -27,36 +27,19 @@ const slugify = (str: string): string => { const oneday_in_ms = 24 * 60 * 60 * 1000; -export class Item { +export class SubItem { constructor( - private _kind: Kind, - private _title: string, private _description: string | null, private _date: Date | null = null, private _start_date: Date | null = null, - private _end_date: Date | null = null, - private _custom: boolean = false + private _end_date: Date | null = null ) {} - equals(other: Item): boolean { - if (!(other instanceof Item)) { + equals(other: SubItem): boolean { + if (!(other instanceof SubItem)) { return false; } - return Object.entries(this).every(([key, thisValue]) => { - const otherValue = other[key as keyof Item]; - // Special handling for Date objects - if (thisValue instanceof Date || otherValue instanceof Date) { - return ( - (thisValue as Date | null)?.getTime() === - (otherValue as Date | null)?.getTime() - ); - } - return thisValue === otherValue; - }); - } - - get title(): string { - return this._title; + return JSON.stringify(this) === JSON.stringify(other); } get description(): string | null { @@ -67,28 +50,8 @@ export class Item { return this._start_date || this._date; } - get dayName(): string | null { - return this.date - ? capitalizeFirstLetter( - this.date.toLocaleDateString('fr-FR', { weekday: 'short' }).replace('.', '') - ) - : null; - } - - get fullDayName(): string | null { - return this.date - ? capitalizeFirstLetter( - this.date.toLocaleDateString('fr-FR', { weekday: 'long' }) - ) - : null; - } - - get dayNum(): number | null { - return this.date ? this.date.getDate() : null; - } - - get monthName(): string | null { - return this.date ? monthName(this.date) : null; + get endDate(): Date | null { + return this._end_date; } get period(): string | undefined { @@ -121,6 +84,101 @@ export class Item { const date = this._date?.toLocaleDateString(locale, dateFormat); return date; } +} + +export class Item { + private _subitems: SubItem[]; + + constructor( + private _kind: Kind, + private _title: string, + _description: string | null, + _date: Date | null = null, + _start_date: Date | null = null, + _end_date: Date | null = null + ) { + this._subitems = []; + this.addSubItem(_description, _date, _start_date, _end_date); + } + + addSubItem( + _description: string | null, + _date: Date | null = null, + _start_date: Date | null = null, + _end_date: Date | null = null + ) { + this._subitems.push(new SubItem(_description, _date, _start_date, _end_date)); + this._subitems.sort((a, b) => { + const dateComparison = (a.date?.getTime() || 0) - (b.date?.getTime() || 0); + if (dateComparison !== 0) { + return dateComparison; + } + return (a.endDate?.getTime() || 0) - (b.endDate?.getTime() || 0); + }); + } + + equals(other: Item): boolean { + if (!(other instanceof Item)) { + return false; + } + return JSON.stringify(this) === JSON.stringify(other); + } + + get title(): string { + return this._title; + } + + get description(): string | null { + return this._subitems[0].description; + } + + get subitems(): SubItem[] { + return this._subitems; + } + + get date(): Date | null { + return this._subitems[0].date; + } + + get endDate(): Date | null { + const endDates = this._subitems + .map((subitem) => subitem.endDate) + .filter((date): date is Date => date != null); + + if (!endDates.length) { + return null; + } + + return new Date(Math.max(...endDates.map((d) => d.getTime()))); + } + + get dayName(): string | null { + return this.date + ? capitalizeFirstLetter( + this.date.toLocaleDateString('fr-FR', { weekday: 'short' }).replace('.', '') + ) + : null; + } + + get fullDayName(): string | null { + return this.date + ? capitalizeFirstLetter( + this.date.toLocaleDateString('fr-FR', { weekday: 'long' }) + ) + : null; + } + + get dayNum(): number | null { + return this.date ? this.date.getDate() : null; + } + + get monthName(): string | null { + return this.date ? monthName(this.date) : null; + } + + get period(): string | undefined { + return this._subitems[0].period; + } private static readonly KindInfo: Record< Kind, @@ -147,10 +205,6 @@ export class Item { return this._kind; } - get custom(): boolean { - return this._custom; - } - get label(): string { const info = Item.KindInfo[this._kind]; if (info === undefined) { @@ -226,9 +280,39 @@ export class Agenda { school_holidays: CatalogItem[], date: Date ) { + const result: Item[] = []; school_holidays.forEach((holiday) => { - const item = this.createSchoolHolidayItem(holiday, date); + const item = this.createSchoolHolidayItem(holiday); if (item !== null) { + // check if an item whith this description already exists + const key = JSON.stringify({ + desc: item.title, + year: item.date?.getFullYear(), + }); + let seen = false; + result.forEach((_item) => { + const _key = JSON.stringify({ + desc: _item.title, + year: _item.date?.getFullYear(), + }); + if (key === _key) { + _item.addSubItem( + item.description, + null, + holiday.start_date, + holiday.end_date + ); + seen = true; + } + }); + if (!seen) { + result.push(item); + } + } + }); + result.forEach((item) => { + if (item.endDate !== null && item.endDate >= date) { + // exclude past school holiday items.push(item); } }); @@ -241,23 +325,11 @@ export class Agenda { return userStore.connected.getSchoolHolidayDescriptionFromPreferences(holiday); } - private getSchoolHolidayItemCustom(holiday: CatalogItem): boolean { - const userZone = userStore.connected?.identity.address?.zone; - if (userZone !== undefined && holiday.zones.includes(userZone)) { - return true; - } - return false; - } - - private createSchoolHolidayItem(holiday: CatalogItem, date: Date): Item | null { + private createSchoolHolidayItem(holiday: CatalogItem): Item | null { if (!holiday.start_date || !holiday.end_date) { // should not happen for school holiday return null; } - if (holiday.end_date < date) { - // exclude past school holiday - return null; - } let title = holiday.title; if (holiday.emoji) { title += ` ${holiday.emoji}`; @@ -271,8 +343,7 @@ export class Agenda { this.getSchoolHolidayItemDescription(holiday), null, holiday.start_date, - holiday.end_date, - this.getSchoolHolidayItemCustom(holiday) + holiday.end_date ); } @@ -302,7 +373,7 @@ export class Agenda { if (holiday.emoji) { title += ` ${holiday.emoji}`; } - return new Item('holiday', title, null, holiday.date, null, null, false); + return new Item('holiday', title, null, holiday.date, null, null); } private createOTVItems(items: Item[], school_holidays: CatalogItem[], date: Date) { @@ -356,8 +427,7 @@ export class Agenda { 'Inscrivez-vous pour protéger votre domicile pendant votre absence', null, startDate, - null, - false + null ); const scheduledNotificationKey = `ami-otv:d-3w:${holiday.start_date.getFullYear()}:${slugify(holiday.title)}`; if (!scheduledNotificationsCreatedKeys.has(scheduledNotificationKey)) { @@ -372,6 +442,10 @@ export class Agenda { }); userStore.connected?.addScheduledNotificationCreatedKey(scheduledNotificationKey); } + if (!userStore.connected?.isSchoolHolidayConcernedByPreferences(holiday)) { + // don't display OTV if holiday match user preferences + return null; + } if (startDate > date) { // don't display OTV too early, only display them when they're close enough to their associated holiday return null; @@ -401,15 +475,7 @@ export class Agenda { if (election.emoji) { title += ` ${election.emoji}`; } - return new Item( - 'election', - title, - election.description, - election.date, - null, - null, - false - ); + return new Item('election', title, election.description, election.date, null, null); } get now(): Item[] { diff --git a/public/mobile-app/src/lib/components/AgendaItem.svelte b/public/mobile-app/src/lib/components/AgendaItem.svelte index 7e9114dc..da302211 100644 --- a/public/mobile-app/src/lib/components/AgendaItem.svelte +++ b/public/mobile-app/src/lib/components/AgendaItem.svelte @@ -1,4 +1,5 @@