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/*';