diff --git a/README.md b/README.md index 760e138..c40f794 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,6 @@ history, code and binary blobs related to React Native functionality filtered ou token, so drop redux for [TanStack Query](https://tanstack.com/query/latest) for fetching/caching, with the token in React context + AsyncStorage — replaces `src/reducers.ts`, the fetch thunks in `src/api.ts` and the `isFetching*`/error flags -- replace [moment](https://momentjs.com/docs/#/-project-status/) (maintenance mode) with - `date-fns` - follow-ups from the [Expo SDK 54 upgrade](https://expo.dev/changelog/sdk-54): - decide on Sentry: `@sentry/react-native` is installed but never initialized (see the commented-out init in `index.ts`), and its config plugin (now applied via `app.json`) diff --git a/jest.setup.js b/jest.setup.js index aa9dd89..bbe87b0 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,5 +1,9 @@ /* eslint-disable no-undef */ +// Deliberately not Europe/Oslo: event times must render as Oslo wall-clock no +// matter the device timezone, so tests assert Oslo strings under a foreign TZ. +process.env.TZ = 'America/New_York'; + process.env.EXPO_PUBLIC_EVENT_API_URL = 'https://events.example.test/api/v1/events'; process.env.EXPO_PUBLIC_DUSKEN_API_URL = 'https://dusken.example.test'; process.env.EXPO_PUBLIC_DUSKEN_FORGOT_PASSWORD_URL = 'https://dusken.example.test/auth/password_reset/'; diff --git a/package-lock.json b/package-lock.json index c4e81b4..bfab2a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.0.1", "dependencies": { "@apollo/client": "^3.14.1", + "@date-fns/tz": "^1.5.0", "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.7", @@ -17,6 +18,7 @@ "@sentry/react-native": "~7.11.0", "@shopify/react-native-skia": "2.6.2", "@stripe/stripe-react-native": "0.64.0", + "date-fns": "^4.4.0", "email-validator": "^2.0.4", "expo": "^56.0.0", "expo-asset": "~56.0.21", @@ -27,7 +29,6 @@ "expo-splash-screen": "~56.0.14", "expo-status-bar": "~56.0.4", "graphql": "^16.9.0", - "moment": "^2.30.1", "react": "19.2.3", "react-hook-form": "^7.52.1", "react-native": "0.85.3", @@ -1405,6 +1406,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@date-fns/tz": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", + "license": "MIT" + }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -6260,6 +6267,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -12222,15 +12239,6 @@ "node": ">=10" } }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index fc96227..9731875 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@apollo/client": "^3.14.1", + "@date-fns/tz": "^1.5.0", "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.7", @@ -24,6 +25,7 @@ "@sentry/react-native": "~7.11.0", "@shopify/react-native-skia": "2.6.2", "@stripe/stripe-react-native": "0.64.0", + "date-fns": "^4.4.0", "email-validator": "^2.0.4", "expo": "^56.0.0", "expo-asset": "~56.0.21", @@ -34,7 +36,6 @@ "expo-splash-screen": "~56.0.14", "expo-status-bar": "~56.0.4", "graphql": "^16.9.0", - "moment": "^2.30.1", "react": "19.2.3", "react-hook-form": "^7.52.1", "react-native": "0.85.3", diff --git a/src/modules/events/EventList.test.tsx b/src/modules/events/EventList.test.tsx index 474e109..55b4cda 100644 --- a/src/modules/events/EventList.test.tsx +++ b/src/modules/events/EventList.test.tsx @@ -30,6 +30,46 @@ const event = { }, }; +it('formats the next occurrence as a Norwegian date with the venue', async () => { + mockFetch.mockResolvedValue({ json: async () => ({ events: [event] }) }); + + const { findByText } = renderWithProviders(); + + expect(await findByText('lø. 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy(); +}); + +it('formats weekday and month abbreviations across the year', async () => { + const events = [ + { ...event, id: '2', nextOccurrence: { ...event.nextOccurrence, start: '2026-12-30T18:00:00+01:00' } }, + { ...event, id: '3', nextOccurrence: { ...event.nextOccurrence, start: '2027-05-17T13:00:00+02:00' } }, + ]; + mockFetch.mockResolvedValue({ json: async () => ({ events }) }); + + const { findByText } = renderWithProviders(); + + expect(await findByText('on. 30. des. 2026 kl. 18:00 i Storsalen')).toBeTruthy(); + expect(await findByText('ma. 17. mai 2027 kl. 13:00 i Storsalen')).toBeTruthy(); +}); + +it('renders venue-local (Oslo) time whatever the API offset and device timezone', async () => { + // 17:00 UTC = 19:00 in Oslo (CEST); the suite runs under TZ=America/New_York + const events = [{ ...event, nextOccurrence: { ...event.nextOccurrence, start: '2026-08-01T17:00:00Z' } }]; + mockFetch.mockResolvedValue({ json: async () => ({ events }) }); + + const { findByText } = renderWithProviders(); + + expect(await findByText('lø. 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy(); +}); + +it('prefixes the time with Neste: when the event has more occurrences', async () => { + mockFetch.mockResolvedValue({ json: async () => ({ events: [{ ...event, futureOccurrencesCount: 3 }] }) }); + + const { findByText } = renderWithProviders(); + + expect(await findByText('Neste: lø. 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy(); + expect(await findByText('Se flere tider (2)')).toBeTruthy(); +}); + it('renders events from the API', async () => { mockFetch.mockResolvedValue({ json: async () => ({ events: [event] }) }); diff --git a/src/modules/events/EventList.tsx b/src/modules/events/EventList.tsx index cc3a431..87ab235 100644 --- a/src/modules/events/EventList.tsx +++ b/src/modules/events/EventList.tsx @@ -1,8 +1,9 @@ +import { TZDate } from '@date-fns/tz'; import { SimpleLineIcons as Icon } from '@expo/vector-icons'; -import moment from 'moment'; +import { format, parseISO } from 'date-fns'; +import { nb } from 'date-fns/locale'; import React from 'react'; import { StyleSheet, View, Platform, FlatList, TouchableOpacity, Text, Linking } from 'react-native'; -import 'moment/locale/nb'; import Button from '@/components/Button'; import theme from '@/theme'; @@ -10,10 +11,10 @@ import { EventType } from '@/types/data'; import useEventList from './useEventList'; -moment.locale('nb'); - +// Venue-local (Europe/Oslo) wall-clock time regardless of device timezone, +// e.g. "lø. 1. aug. 2026 kl. 19:00" const formatTime = (time: string) => { - return moment(time).format('llll'); + return format(new TZDate(parseISO(time), 'Europe/Oslo'), "EEEEEE. d. MMM y 'kl.' HH:mm", { locale: nb }); }; const Header = () => ; diff --git a/src/modules/events/useEventList.tsx b/src/modules/events/useEventList.tsx index a29db85..72f75d3 100644 --- a/src/modules/events/useEventList.tsx +++ b/src/modules/events/useEventList.tsx @@ -1,12 +1,8 @@ -import moment from 'moment'; import { useEffect, useState } from 'react'; -import 'moment/locale/nb'; import type { EventType } from '@/types/data'; import { fetchWithTimeout } from '@/utils'; -moment.locale('nb'); - /* Ref: https://medium.com/react-native-development/how-to-use-the-flatlist-component-react-native-basics-92c482816fe6 */ const useEventList = () => { const [loading, setLoading] = useState(false); diff --git a/src/types/moment.d.ts b/src/types/moment.d.ts deleted file mode 100644 index 179e0e8..0000000 --- a/src/types/moment.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// moment ships its locale files as plain JS with no type declarations. TypeScript 6 -// (Expo SDK 56) rejects side-effect imports it cannot type, so declare them as untyped. -declare module 'moment/locale/*';