diff --git a/README.md b/README.md
index 85b489c..f6c6a77 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,10 @@ This project's API integration uses the simulated REST endpoints made available
When running the application, you may sign in with any of the JSON Placeholder [Users](https://jsonplaceholder.typicode.com/users). Simply enter the _Username_ value from any user in the API and use any value for the _Password_. For example, try username `Bret` and password `abc123`.
+### Diagnostics
+
+Many applications, particularly mobile applications, have a hidden page which displays content useful for troubleshooting and support. To access the diagnostics page, go to the _Account_ page. Locate the _About_ section and click or tap the _Version_ item 7 times.
+
## About
This project was bootstrapped with the [Ionic CLI](https://ionicframework.com/docs/cli/commands/start).
diff --git a/src/__fixtures__/appinfo.ts b/src/__fixtures__/appinfo.ts
new file mode 100644
index 0000000..600afed
--- /dev/null
+++ b/src/__fixtures__/appinfo.ts
@@ -0,0 +1,8 @@
+import { AppInfo } from '@capacitor/app';
+
+export const appInfoFixture: AppInfo = {
+ name: 'app-info-name',
+ id: 'app-info-id',
+ build: 'app-info-build',
+ version: 'app-info-version',
+};
diff --git a/src/common/components/Badge/Badges.scss b/src/common/components/Badge/Badges.scss
new file mode 100644
index 0000000..20e4c3f
--- /dev/null
+++ b/src/common/components/Badge/Badges.scss
@@ -0,0 +1,11 @@
+.ls-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ justify-content: end;
+ align-items: center;
+
+ ion-badge {
+ min-width: auto;
+ }
+}
diff --git a/src/common/components/Badge/Badges.tsx b/src/common/components/Badge/Badges.tsx
new file mode 100644
index 0000000..610d87e
--- /dev/null
+++ b/src/common/components/Badge/Badges.tsx
@@ -0,0 +1,28 @@
+import { PropsWithChildren } from 'react';
+import classNames from 'classnames';
+
+import './Badges.scss';
+import { BaseComponentProps } from '../types';
+
+/**
+ * Properties for the `Badges` component.
+ * @see {@link BaseComponentProps}
+ * @see {@link PropsWithChildren}
+ */
+interface BadgesProps extends BaseComponentProps, PropsWithChildren {}
+
+/**
+ * The `Badges` component renders a collection of `IonBadge` components in a
+ * flexbox. The badges will wrap as needed.
+ * @param {BadgesProps} props - Component properties.
+ * @returns {JSX.Element} JSX
+ */
+const Badges = ({ children, className, testid = 'badges' }: BadgesProps): JSX.Element => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default Badges;
diff --git a/src/common/components/Badge/__tests__/Badges.test.tsx b/src/common/components/Badge/__tests__/Badges.test.tsx
new file mode 100644
index 0000000..59e6bd3
--- /dev/null
+++ b/src/common/components/Badge/__tests__/Badges.test.tsx
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+
+import { render, screen } from 'test/test-utils';
+
+import Badges from '../Badges';
+
+describe('Badges', () => {
+ it('should render successfully', async () => {
+ // ARRANGE
+ render(
+
+
+ ,
+ );
+ await screen.findByTestId('badges');
+
+ // ASSERT
+ expect(screen.getByTestId('badges')).toBeDefined();
+ expect(screen.getByTestId('badges')).toHaveClass('ls-badges');
+ expect(screen.getByTestId('child')).toBeDefined();
+ });
+});
diff --git a/src/common/components/List/List.scss b/src/common/components/List/List.scss
new file mode 100644
index 0000000..99cd68a
--- /dev/null
+++ b/src/common/components/List/List.scss
@@ -0,0 +1,10 @@
+ion-list.ls-list {
+ ion-list-header {
+ --border-width: 0 0 2px 0;
+
+ font-size: 1rem;
+ font-weight: 700;
+ line-height: 1.5rem;
+ text-transform: uppercase;
+ }
+}
diff --git a/src/common/components/List/List.tsx b/src/common/components/List/List.tsx
new file mode 100644
index 0000000..7b5edba
--- /dev/null
+++ b/src/common/components/List/List.tsx
@@ -0,0 +1,29 @@
+import { ComponentPropsWithoutRef } from 'react';
+import { IonList } from '@ionic/react';
+import classNames from 'classnames';
+
+import './List.scss';
+import { PropsWithTestId } from '../types';
+
+/**
+ * Properties for the `List` component.
+ * @see {@link PropsWithTestId}
+ * @see {@link IonList}
+ */
+interface ListProps extends PropsWithTestId, ComponentPropsWithoutRef {}
+
+/**
+ * The `List` component is a wrapper for the Ionic `IonList` component. Renders
+ * and standardized implementation of `IonList`.
+ *
+ * @param {ListProps} props - Component properties.
+ * @returns {JSX.Element} JSX
+ * @see {@link IonList}
+ */
+const List = ({ className, testid = 'list', ...listProps }: ListProps): JSX.Element => {
+ return (
+
+ );
+};
+
+export default List;
diff --git a/src/common/components/Router/TabNavigation.tsx b/src/common/components/Router/TabNavigation.tsx
index bcf3f91..8f810d6 100644
--- a/src/common/components/Router/TabNavigation.tsx
+++ b/src/common/components/Router/TabNavigation.tsx
@@ -11,6 +11,7 @@ import UserEditPage from 'pages/Users/components/UserEdit/UserEditPage';
import UserAddPage from 'pages/Users/components/UserAdd/UserAddPage';
import AccountPage from 'pages/Account/AccountPage';
import ProfilePage from 'pages/Account/components/Profile/ProfilePage';
+import DiagnosticsPage from 'pages/Account/components/Diagnostics/DiagnosticsPage';
/**
* The `TabNavigation` component provides a router outlet for all of the
@@ -56,6 +57,9 @@ const TabNavigation = (): JSX.Element => {
+
+
+
diff --git a/src/common/hooks/usePlatform.ts b/src/common/hooks/usePlatform.ts
index 9535361..b9bfdc5 100644
--- a/src/common/hooks/usePlatform.ts
+++ b/src/common/hooks/usePlatform.ts
@@ -24,9 +24,7 @@ type Platform = {
*/
export const usePlatform = (): Platform => {
const isNativePlatform = Capacitor.isNativePlatform();
- console.log(`usePlatform::isNativePlatform::${isNativePlatform}`);
const platforms = getPlatforms();
- console.log(`usePlatform::platforms::${platforms}`);
return {
isNativePlatform,
diff --git a/src/common/utils/constants.ts b/src/common/utils/constants.ts
index ed9b22d..bf73340 100644
--- a/src/common/utils/constants.ts
+++ b/src/common/utils/constants.ts
@@ -4,6 +4,7 @@ import { Settings } from 'common/models/settings';
* React Query cache keys.
*/
export enum QueryKey {
+ AppInfo = 'AppInfo',
Settings = 'Settings',
Users = 'Users',
UserTokens = 'UserTokens',
diff --git a/src/pages/Account/AccountPage.tsx b/src/pages/Account/AccountPage.tsx
index 315bb90..df2ce21 100644
--- a/src/pages/Account/AccountPage.tsx
+++ b/src/pages/Account/AccountPage.tsx
@@ -8,7 +8,9 @@ import {
IonListHeader,
IonPage,
IonRow,
+ useIonRouter,
} from '@ionic/react';
+import { useState } from 'react';
import dayjs from 'dayjs';
import './AccountPage.scss';
@@ -25,12 +27,23 @@ import SettingsForm from './components/Settings/SettingsForm';
* @returns {JSX.Element} JSX
*/
const AccountPage = ({ testid = 'page-account' }: PropsWithTestId): JSX.Element => {
+ const [diagnosticsCount, setDiagnosticsCount] = useState(0);
const config = useConfig();
+ const router = useIonRouter();
const versionTs = dayjs(config.VITE_BUILD_TS).format('YY.MM.DD.hhmm');
const sha = config.VITE_BUILD_COMMIT_SHA.substring(0, 7);
const version = `${versionTs}.${sha}`;
+ const onDiagnosticsClick = () => {
+ if (diagnosticsCount >= 6) {
+ setDiagnosticsCount(0);
+ router.push('/tabs/account/diagnostics');
+ } else {
+ setDiagnosticsCount((value) => value + 1);
+ }
+ };
+
return (
@@ -44,10 +57,10 @@ const AccountPage = ({ testid = 'page-account' }: PropsWithTestId): JSX.Element
Account
-
+
Profile
-
+
Sign Out
@@ -76,7 +89,7 @@ const AccountPage = ({ testid = 'page-account' }: PropsWithTestId): JSX.Element
About
-
+ onDiagnosticsClick()}>
Version {version}
diff --git a/src/pages/Account/api/useGetAppInfo.ts b/src/pages/Account/api/useGetAppInfo.ts
new file mode 100644
index 0000000..06153bf
--- /dev/null
+++ b/src/pages/Account/api/useGetAppInfo.ts
@@ -0,0 +1,20 @@
+import { useQuery } from '@tanstack/react-query';
+import { App } from '@capacitor/app';
+
+import { QueryKey } from 'common/utils/constants';
+
+/**
+ * A query hook which returns the `AppInfo` value from the `App` capacitor.
+ *
+ * Note: The capacitor only returns a value when the app is running as a
+ * native mobile application. Otherwise returns an error.
+ *
+ * @returns Returns a `UseQueryResult` whose data is an `AppInfo` object.
+ * @see {@link https://capacitorjs.com/docs/apis/app}
+ */
+export const useGetAppInfo = () => {
+ return useQuery({
+ queryKey: [QueryKey.AppInfo],
+ queryFn: () => App.getInfo(),
+ });
+};
diff --git a/src/pages/Account/components/Diagnostics/AppDiagnostics.tsx b/src/pages/Account/components/Diagnostics/AppDiagnostics.tsx
new file mode 100644
index 0000000..daee753
--- /dev/null
+++ b/src/pages/Account/components/Diagnostics/AppDiagnostics.tsx
@@ -0,0 +1,82 @@
+import { IonItem, IonLabel, IonListHeader } from '@ionic/react';
+import classNames from 'classnames';
+
+import { BaseComponentProps } from 'common/components/types';
+import { usePlatform } from 'common/hooks/usePlatform';
+import { useGetAppInfo } from 'pages/Account/api/useGetAppInfo';
+import List from 'common/components/List/List';
+import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton';
+
+/**
+ * The `AppDiagnostics` component displays application diagnostic information
+ * as a list of key/value pairs. The attributes are obtained from the `App`
+ * capacitor.
+ *
+ * @param {BaseComponentProps} props - Component properties.
+ * @returns {JSX.Element} JSX
+ */
+const AppDiagnostics = ({
+ className,
+ testid = 'diagnostics-app',
+}: BaseComponentProps): JSX.Element => {
+ const { isNativePlatform } = usePlatform();
+ const { data: appInfo, isLoading } = useGetAppInfo();
+
+ if (isNativePlatform) {
+ return (
+
+
+ App
+
+ {isLoading && (
+
+
+
+ )}
+ {appInfo && (
+ <>
+
+ Name
+
+ {appInfo.name}
+
+
+
+ ID
+
+ {appInfo.id}
+
+
+
+ Build
+
+ {appInfo.build}
+
+
+
+ Version
+
+ {appInfo.version}
+
+
+ >
+ )}
+
+ );
+ } else {
+ return (
+
+
+ App
+
+
+
+ Information available on mobile devices.
+
+
+
+ );
+ }
+};
+
+export default AppDiagnostics;
diff --git a/src/pages/Account/components/Diagnostics/BuildDiagnostics.tsx b/src/pages/Account/components/Diagnostics/BuildDiagnostics.tsx
new file mode 100644
index 0000000..1629114
--- /dev/null
+++ b/src/pages/Account/components/Diagnostics/BuildDiagnostics.tsx
@@ -0,0 +1,56 @@
+import { IonItem, IonLabel, IonListHeader, IonText } from '@ionic/react';
+import classNames from 'classnames';
+import dayjs from 'dayjs';
+
+import { BaseComponentProps } from 'common/components/types';
+import List from 'common/components/List/List';
+import { useConfig } from 'common/hooks/useConfig';
+
+/**
+ * The `BuildDiagnostics` component displays application diagnostic information
+ * as a list of key/value pairs. The attributes are obtained from the application
+ * configuration with values obtained from the DevOps automation pipeline.
+ *
+ * @param {BaseComponentProps} props - Component properties.
+ * @returns {JSX.Element} JSX
+ */
+const BuildDiagnostics = ({
+ className,
+ testid = 'diagnostics-build',
+}: BaseComponentProps): JSX.Element => {
+ const config = useConfig();
+
+ return (
+
+
+ Build
+
+
+
+ Environment
+ {config.VITE_BUILD_ENV_CODE}
+
+
+ Time
+
+ {dayjs(config.VITE_BUILD_TS).format('YYYY-MM-DD HH:mm:ss Z')}
+
+
+
+ SHA
+
+ {config.VITE_BUILD_COMMIT_SHA}
+
+
+
+ Workflow
+
+ {config.VITE_BUILD_WORKFLOW_NAME} {config.VITE_BUILD_WORKFLOW_RUN_NUMBER}.
+ {config.VITE_BUILD_WORKFLOW_RUN_ATTEMPT}
+
+
+
+ );
+};
+
+export default BuildDiagnostics;
diff --git a/src/pages/Account/components/Diagnostics/DiagnosticsPage.tsx b/src/pages/Account/components/Diagnostics/DiagnosticsPage.tsx
new file mode 100644
index 0000000..b354d44
--- /dev/null
+++ b/src/pages/Account/components/Diagnostics/DiagnosticsPage.tsx
@@ -0,0 +1,51 @@
+import { IonCol, IonContent, IonGrid, IonPage, IonRow } from '@ionic/react';
+
+import { PropsWithTestId } from 'common/components/types';
+import Header from 'common/components/Header/Header';
+import PageHeader from 'common/components/Content/PageHeader';
+import AppDiagnostics from './AppDiagnostics';
+import PlatformDiagnostics from './PlatformDiagnostics';
+import BuildDiagnostics from './BuildDiagnostics';
+
+/**
+ * The `DiagnosticsPage` renders a layout of components that display information
+ * about the application to aid in support and problem resolution.
+ *
+ * @param {PropsWithTestId} props - Component properties.
+ * @returns {JSX.Element} JSX
+ */
+const DiagnosticsPage = ({ testid = 'page-diagnostics' }: PropsWithTestId): JSX.Element => {
+ return (
+
+
+
+
+
+
+
+
+ Diagnostics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DiagnosticsPage;
diff --git a/src/pages/Account/components/Diagnostics/PlatformDiagnostics.tsx b/src/pages/Account/components/Diagnostics/PlatformDiagnostics.tsx
new file mode 100644
index 0000000..8b53d74
--- /dev/null
+++ b/src/pages/Account/components/Diagnostics/PlatformDiagnostics.tsx
@@ -0,0 +1,52 @@
+import { IonBadge, IonItem, IonLabel, IonListHeader } from '@ionic/react';
+import classNames from 'classnames';
+
+import { BaseComponentProps } from 'common/components/types';
+import { usePlatform } from 'common/hooks/usePlatform';
+import List from 'common/components/List/List';
+import Badges from 'common/components/Badge/Badges';
+
+/**
+ * The `PlatformDiagnostics` component displays application diagnostic information
+ * as a list of key/value pairs. The attributes are obtained from the Ionic
+ * framework.
+ *
+ * @param {BaseComponentProps} props - Component properties.
+ * @returns {JSX.Element} JSX
+ */
+const PlatformDiagnostics = ({
+ className,
+ testid = 'diagnostics-platform',
+}: BaseComponentProps): JSX.Element => {
+ const { isNativePlatform, platforms } = usePlatform();
+
+ return (
+
+
+ Platform
+
+
+ Native
+ {isNativePlatform ? (
+ YES
+ ) : (
+
+ NO
+
+ )}
+
+
+ Platforms
+
+ {platforms.map((platform) => (
+
+ {platform}
+
+ ))}
+
+
+
+ );
+};
+
+export default PlatformDiagnostics;
diff --git a/src/pages/Account/components/Diagnostics/__tests__/AppDiagnostics.test.tsx b/src/pages/Account/components/Diagnostics/__tests__/AppDiagnostics.test.tsx
new file mode 100644
index 0000000..3912c0c
--- /dev/null
+++ b/src/pages/Account/components/Diagnostics/__tests__/AppDiagnostics.test.tsx
@@ -0,0 +1,75 @@
+import { describe, expect, it, vi } from 'vitest';
+import { UseQueryResult } from '@tanstack/react-query';
+import { AppInfo } from '@capacitor/app';
+
+import { render, screen } from 'test/test-utils';
+import * as UsePlatform from 'common/hooks/usePlatform';
+import * as UseGetAppInfo from 'pages/Account/api/useGetAppInfo';
+import { appInfoFixture } from '__fixtures__/appinfo';
+
+import AppDiagnostics from '../AppDiagnostics';
+
+describe('AppDiagnostics', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should render successfully', async () => {
+ // ARRANGE
+ render();
+ await screen.findByTestId('diagnostics-app');
+
+ // ASSERT
+ expect(screen.getByTestId('diagnostics-app')).toBeDefined();
+ });
+
+ it('should render non-native values', async () => {
+ // ARRANGE
+ const usePlatformSpy = vi.spyOn(UsePlatform, 'usePlatform');
+ usePlatformSpy.mockReturnValue({ isNativePlatform: false, platforms: [] });
+ render();
+ await screen.findByTestId('diagnostics-app');
+
+ // ASSERT
+ expect(screen.getByTestId('diagnostics-app')).toBeDefined();
+ expect(screen.getByTestId('diagnostics-app-not-native')).toBeDefined();
+ expect(screen.getByTestId('diagnostics-app-not-native')).toHaveTextContent(
+ /Information available on mobile devices./,
+ );
+ });
+
+ it('should display app info', async () => {
+ // ARRANGE
+ const usePlatformSpy = vi.spyOn(UsePlatform, 'usePlatform');
+ usePlatformSpy.mockReturnValue({ isNativePlatform: true, platforms: [] });
+ const useGetAppInfoSpy = vi.spyOn(UseGetAppInfo, 'useGetAppInfo');
+ useGetAppInfoSpy.mockReturnValue({
+ data: appInfoFixture,
+ isLoading: false,
+ } as unknown as UseQueryResult);
+ render();
+ await screen.findByTestId('diagnostics-app-name');
+
+ // ASSERT
+ expect(screen.getByTestId('diagnostics-app-name')).toHaveTextContent(appInfoFixture.name);
+ expect(screen.getByTestId('diagnostics-app-id')).toHaveTextContent(appInfoFixture.id);
+ expect(screen.getByTestId('diagnostics-app-build')).toHaveTextContent(appInfoFixture.build);
+ expect(screen.getByTestId('diagnostics-app-version')).toHaveTextContent(appInfoFixture.version);
+ });
+
+ it('should display loading state', async () => {
+ // ARRANGE
+ const usePlatformSpy = vi.spyOn(UsePlatform, 'usePlatform');
+ usePlatformSpy.mockReturnValue({ isNativePlatform: true, platforms: [] });
+ const useGetAppInfoSpy = vi.spyOn(UseGetAppInfo, 'useGetAppInfo');
+ useGetAppInfoSpy.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ } as unknown as UseQueryResult);
+ render();
+ await screen.findByTestId('diagnostics-app-loading');
+
+ // ASSERT
+ expect(screen.getByTestId('diagnostics-app-loading')).toBeDefined();
+ });
+});
diff --git a/src/pages/Account/components/Diagnostics/__tests__/BuildDiagnostics.test.tsx b/src/pages/Account/components/Diagnostics/__tests__/BuildDiagnostics.test.tsx
new file mode 100644
index 0000000..dfd7f13
--- /dev/null
+++ b/src/pages/Account/components/Diagnostics/__tests__/BuildDiagnostics.test.tsx
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+
+import { render, screen } from 'test/test-utils';
+
+import BuildDiagnostics from '../BuildDiagnostics';
+
+describe('BuildDiagnostics', () => {
+ it('should render successfully', async () => {
+ // ARRANGE
+ render();
+ await screen.findByTestId('diagnostics-build');
+
+ // ASSERT
+ expect(screen.getByTestId('diagnostics-build')).toBeDefined();
+ });
+
+ it('should display build values', async () => {
+ // ARRANGE
+ render();
+ await screen.findByTestId('build');
+
+ // ASSERT
+ expect(screen.getByTestId('build')).toBeDefined();
+ expect(screen.getByTestId('build-env')).toHaveTextContent('test');
+ expect(screen.getByTestId('build-time')).toHaveTextContent('1970-01-01 00:00:00 +00:00');
+ expect(screen.getByTestId('build-sha')).toHaveTextContent('test');
+ expect(screen.getByTestId('build-workflow')).toHaveTextContent('test 1.1');
+ });
+});
diff --git a/src/pages/Account/components/Diagnostics/__tests__/DiagnosticsPage.test.tsx b/src/pages/Account/components/Diagnostics/__tests__/DiagnosticsPage.test.tsx
new file mode 100644
index 0000000..4b80379
--- /dev/null
+++ b/src/pages/Account/components/Diagnostics/__tests__/DiagnosticsPage.test.tsx
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+
+import { render, screen } from 'test/test-utils';
+
+import DiagnosticsPage from '../DiagnosticsPage';
+
+describe('DiagnosticsPage', () => {
+ it('should render successfully', async () => {
+ // ARRANGE
+ render();
+ await screen.findByTestId('page-diagnostics');
+
+ // ASSERT
+ expect(screen.getByTestId('page-diagnostics')).toBeDefined();
+ });
+});
diff --git a/src/pages/Account/components/Diagnostics/__tests__/PlatformDiagnostics.test.tsx b/src/pages/Account/components/Diagnostics/__tests__/PlatformDiagnostics.test.tsx
new file mode 100644
index 0000000..1e476f6
--- /dev/null
+++ b/src/pages/Account/components/Diagnostics/__tests__/PlatformDiagnostics.test.tsx
@@ -0,0 +1,49 @@
+import { expect, it, vi } from 'vitest';
+
+import { render, screen } from 'test/test-utils';
+import * as UsePlatform from 'common/hooks/usePlatform';
+
+import PlatformDiagnostics from '../PlatformDiagnostics';
+
+describe('PlatformDiagnostics', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should render successfully', async () => {
+ // ARRANGE
+ render();
+ await screen.findByTestId('diagnostics-platform');
+
+ // ASSERT
+ expect(screen.getByTestId('diagnostics-platform')).toBeDefined();
+ });
+
+ it('should display platform values', async () => {
+ // ARRANGE
+ const usePlatformSpy = vi.spyOn(UsePlatform, 'usePlatform');
+ usePlatformSpy.mockReturnValue({ isNativePlatform: true, platforms: ['test'] });
+ render();
+ await screen.findByTestId('diagnostics-platform');
+
+ // ASSERT
+ expect(screen.getByTestId('diagnostics-platform')).toBeDefined();
+ expect(screen.getByTestId('diagnostics-platform-is-native')).toBeDefined();
+ expect(screen.queryByTestId('diagnostics-platform-not-native')).toBeNull();
+ expect(screen.getAllByTestId('diagnostics-platform-platform').length).toBe(1);
+ });
+
+ it('should display non-native badge', async () => {
+ // ARRANGE
+ const usePlatformSpy = vi.spyOn(UsePlatform, 'usePlatform');
+ usePlatformSpy.mockReturnValue({ isNativePlatform: false, platforms: ['test', 'test2'] });
+ render();
+ await screen.findByTestId('diagnostics-platform');
+
+ // ASSERT
+ expect(screen.getByTestId('diagnostics-platform')).toBeDefined();
+ expect(screen.getByTestId('diagnostics-platform-not-native')).toBeDefined();
+ expect(screen.queryByTestId('diagnostics-platform-is-native')).toBeNull();
+ expect(screen.getAllByTestId('diagnostics-platform-platform').length).toBe(2);
+ });
+});
diff --git a/src/setupTests.ts b/src/setupTests.ts
index a4624ce..70ff073 100644
--- a/src/setupTests.ts
+++ b/src/setupTests.ts
@@ -8,6 +8,9 @@ import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from 'test/mocks/server';
import { queryClient } from 'test/query-client';
+// Tests always run in this timezone
+process.env.TZ = 'UTC';
+
// Mock matchmedia
window.matchMedia =
window.matchMedia ||
diff --git a/src/theme/main.scss b/src/theme/main.scss
index 52442ea..50a3c37 100644
--- a/src/theme/main.scss
+++ b/src/theme/main.scss
@@ -30,3 +30,6 @@
/* Fonts */
@import './fonts.scss';
+
+/* Typography */
+@import './typography.scss';
diff --git a/src/theme/typography.scss b/src/theme/typography.scss
new file mode 100644
index 0000000..28f5c2f
--- /dev/null
+++ b/src/theme/typography.scss
@@ -0,0 +1,80 @@
+// Typography styles inspired by Tailwind
+:root {
+ // font size
+ .text-xs {
+ font-size: 0.75rem;
+ line-height: 1rem;
+ }
+ .text-sm {
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+ }
+ .text-base {
+ font-size: 1rem;
+ line-height: 1.5rem;
+ }
+ .text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+ }
+ .text-xl {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+ }
+ .text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+ }
+ .text-3xl {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+ }
+ .text-4xl {
+ font-size: 2.25rem;
+ line-height: 2.5rem;
+ }
+
+ // font weight
+ .font-thin {
+ font-weight: 100;
+ }
+ .font-extralight {
+ font-weight: 200;
+ }
+ .font-light {
+ font-weight: 300;
+ }
+ .font-normal {
+ font-weight: 400;
+ }
+ .font-medium {
+ font-weight: 500;
+ }
+ .font-semibold {
+ font-weight: 600;
+ }
+ .font-bold {
+ font-weight: 700;
+ }
+ .font-extrabold {
+ font-weight: 800;
+ }
+ .font-black {
+ font-weight: 900;
+ }
+
+ // word break
+ .break-normal {
+ overflow-wrap: normal;
+ word-break: normal;
+ }
+ .break-words {
+ overflow-wrap: break-word;
+ }
+ .break-all {
+ word-break: break-all;
+ }
+ .break-keep {
+ word-break: keep-all;
+ }
+}