diff --git a/.env.example b/.env.example
index c53e3c2..0196b4d 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,5 @@
# Supabase - Local
-# EXPO_PUBLIC_SUPABASE_URL='http://127.0.0.1:54321' # Replace `127.0.0.1` with local IP (`hostname -I` on Linux)
+# EXPO_PUBLIC_SUPABASE_URL='http://127.0.0.1:54321' # Replace `127.0.0.1` with local IP (e.g: `hostname -i` on GNU/Linux)
# EXPO_PUBLIC_SUPABASE_ANON_KEY=''
# Supabase - Production
diff --git a/.eslintrc.json b/.eslintrc.json
index dc14263..743a7e9 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -2,11 +2,9 @@
"extends": [
"conventions",
"plugin:react/recommended",
- "plugin:react-hooks/recommended",
- "prettier"
+ "plugin:react-hooks/recommended"
],
"ignorePatterns": ["jest.setup.ts"],
- "plugins": ["prettier"],
"env": {
"browser": true,
"node": true,
@@ -21,7 +19,6 @@
"project": "./tsconfig.json"
},
"rules": {
- "prettier/prettier": "error",
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react/self-closing-comp": [
@@ -37,7 +34,10 @@
"overrides": [
{
"files": ["*.ts", "*.tsx"],
- "parser": "@typescript-eslint/parser"
+ "parser": "@typescript-eslint/parser",
+ "rules": {
+ "@typescript-eslint/member-delimiter-style": "off"
+ }
}
]
}
diff --git a/.gitignore b/.gitignore
index e67b479..41399b0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
# dependencies
node_modules/
.npm/
+.temp/
# Expo
.expo/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index f5d180c..b4680eb 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,5 +1,5 @@
default:
- image: "node:20.11.1"
+ image: "node:20.13.1"
stages:
- "test"
diff --git a/README.md b/README.md
index ff85d05..6ece0f5 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# P61 - Projet
+# Habits Tracker - P61 Projet
## À propos
@@ -6,6 +6,10 @@ Application mobile en [React Native](https://reactnative.dev/) pour le projet du
Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours.
+
+
+
+
### Membres du Groupe 7
- [Théo LUDWIG](https://git.unistra.fr/t.ludwig)
@@ -35,7 +39,7 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
- [Node.js](https://nodejs.org/) >= 20.0.0
- [npm](https://www.npmjs.com/) >= 10.0.0
-- [Expo Go](https://expo.io/client)
+- [Expo Go](https://expo.io/client) ~2.31.0
- [Docker](https://www.docker.com/) (facultatif, utilisé pour lancer [Supabase](https://supabase.io/) en local)
### Installation
@@ -65,24 +69,24 @@ npm run start
Ce n'est pas strictement nécessaire pour le développement de l'application (même si recommandé), de lancer [Supabase](https://supabase.io/) en local, car l'application est déjà déployée sur un serveur [Supabase](https://supabase.io/) en production (`.env.example` est pré-configuré avec cet environnement).
```sh
-npm run supabase
+npm run supabase-cli start
```
#### Principales Commandes Supabase
```sh
# Pour réinitialiser la base de données avec les données de test (seed.sql)
-npm run supabase db reset
+npm run supabase-cli db reset
# Pour synchroniser le modèle (local) avec la base de données (remote)
-npm run supabase db pull
+npm run supabase-cli db pull
# Pour synchroniser la base de données (remote) avec le modèle (local)
-npm run supabase db push
+npm run supabase-cli db push
# Pour générer les types TypeScript
-npm run supabase gen types typescript -- --local > ./infrastructure/supabase/supabase-types.ts
+npm run supabase-cli gen types typescript -- --local > ./infrastructure/supabase/supabase-types.ts
# Crée un nouveau script de migration à partir des modifications déjà appliquées à votre base de données locale (remplacer `` avec le nom de la migration)
-npm run supabase db diff -- -f
+npm run supabase-cli db diff -- -f
```
diff --git a/app.json b/app.json
index a1c67b0..6e3ee9d 100644
--- a/app.json
+++ b/app.json
@@ -1,26 +1,29 @@
{
"expo": {
- "name": "p61-project",
+ "name": "Habits Tracker",
"slug": "p61-project",
- "version": "1.0.0",
+ "version": "1.0.0-staging.4",
"orientation": "portrait",
"icon": "./presentation/assets/images/icon.png",
"scheme": "p61-project",
"userInterfaceStyle": "automatic",
"splash": {
- "image": "./presentation/assets/images/splashscreen.jpg",
+ "image": "./presentation/assets/images/splashscreen.png",
"resizeMode": "cover",
"backgroundColor": "#74b6cb"
},
"assetBundlePatterns": ["**/*"],
"ios": {
- "supportsTablet": true
+ "supportsTablet": true,
+ "buildNumber": "1.0.0"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./presentation/assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
- }
+ },
+ "package": "com.theoludwig.p61project",
+ "versionCode": 3
},
"web": {
"bundler": "metro",
@@ -30,6 +33,14 @@
"plugins": ["expo-router"],
"experiments": {
"typedRoutes": true
+ },
+ "extra": {
+ "router": {
+ "origin": false
+ },
+ "eas": {
+ "projectId": "5c0a922a-564b-4d62-8231-ce5aef7ff978"
+ }
}
}
}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 5130169..e4616e8 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,4 +1,6 @@
import { Stack } from "expo-router"
+import { fas } from "@fortawesome/free-solid-svg-icons"
+import { library } from "@fortawesome/fontawesome-svg-core"
import * as SplashScreen from "expo-splash-screen"
import {
MD3LightTheme as DefaultTheme,
@@ -20,6 +22,8 @@ export const unstableSettings = {
initialRouteName: "index",
}
+library.add(fas)
+
SplashScreen.preventAutoHideAsync().catch((error) => {
console.error(error)
})
diff --git a/app/application/_layout.tsx b/app/application/_layout.tsx
index 0bc2965..060d4bb 100644
--- a/app/application/_layout.tsx
+++ b/app/application/_layout.tsx
@@ -1,14 +1,14 @@
import { Redirect, Tabs } from "expo-router"
import React from "react"
-import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
+import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const TabLayout: React.FC = () => {
const { user } = useAuthentication()
if (user == null) {
- return
+ return
}
return (
@@ -31,6 +31,7 @@ const TabLayout: React.FC = () => {
name="habits/new"
options={{
title: "New Habit",
+ unmountOnBlur: true,
tabBarIcon: ({ color }) => {
return
},
@@ -39,6 +40,7 @@ const TabLayout: React.FC = () => {
diff --git a/app/application/habits/[habitId]/index.tsx b/app/application/habits/[habitId]/index.tsx
index 02c162c..34e8857 100644
--- a/app/application/habits/[habitId]/index.tsx
+++ b/app/application/habits/[habitId]/index.tsx
@@ -1,6 +1,6 @@
import { Redirect, useLocalSearchParams } from "expo-router"
-import { HabitEditForm } from "@/presentation/react/components/HabitEditForm/HabitEditForm"
+import { HabitEditForm } from "@/presentation/react-native/components/HabitForm/HabitEditForm"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
const HabitPage: React.FC = () => {
diff --git a/app/application/habits/index.tsx b/app/application/habits/index.tsx
index 9857a24..25ab7a7 100644
--- a/app/application/habits/index.tsx
+++ b/app/application/habits/index.tsx
@@ -1,7 +1,7 @@
import { SafeAreaView } from "react-native-safe-area-context"
import { ActivityIndicator, Button, Text } from "react-native-paper"
-import { HabitsMainPage } from "@/presentation/react/components/HabitsMainPage/HabitsMainPage"
+import { HabitsMainPage } from "@/presentation/react-native/components/HabitsMainPage/HabitsMainPage"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
diff --git a/app/application/habits/new.tsx b/app/application/habits/new.tsx
index f410ab0..5a4959a 100644
--- a/app/application/habits/new.tsx
+++ b/app/application/habits/new.tsx
@@ -1,4 +1,4 @@
-import { HabitCreateForm } from "@/presentation/react/components/HabitCreateForm/HabitCreateForm"
+import { HabitCreateForm } from "@/presentation/react-native/components/HabitForm/HabitCreateForm"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const NewHabitPage: React.FC = () => {
diff --git a/app/application/users/settings.tsx b/app/application/users/settings.tsx
index 1a4b9f9..414f11a 100644
--- a/app/application/users/settings.tsx
+++ b/app/application/users/settings.tsx
@@ -1,7 +1,6 @@
-import { Text } from "react-native"
import { Button } from "react-native-paper"
-import { SafeAreaView } from "react-native-safe-area-context"
+import { About } from "@/presentation/react-native/components/About"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const SettingsPage: React.FC = () => {
@@ -12,26 +11,19 @@ const SettingsPage: React.FC = () => {
}
return (
-
- Settings
-
-
-
+
+ Logout
+
+ }
+ />
)
}
diff --git a/app/authentication/_layout.tsx b/app/authentication/_layout.tsx
index cadac6f..b8325bc 100644
--- a/app/authentication/_layout.tsx
+++ b/app/authentication/_layout.tsx
@@ -1,7 +1,7 @@
import { Redirect, Tabs } from "expo-router"
import React from "react"
-import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
+import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const TabLayout: React.FC = () => {
@@ -17,6 +17,15 @@ const TabLayout: React.FC = () => {
headerShown: false,
}}
>
+ {
+ return
+ },
+ }}
+ />
{
+ const router = useRouter()
+
+ return (
+ {
+ router.push("/authentication/login")
+ }}
+ >
+ Get Started 🚀
+
+ }
+ />
+ )
+}
+
+export default AboutPage
diff --git a/app/authentication/login.tsx b/app/authentication/login.tsx
index d6fea8c..944c828 100644
--- a/app/authentication/login.tsx
+++ b/app/authentication/login.tsx
@@ -67,6 +67,7 @@ const LoginPage: React.FC = () => {
+
+ {habit.endDate == null ? (
+ {
+ await habitsTrackerPresenter.habitStop(habit)
+ }}
+ loading={habitStop.state === "loading"}
+ disabled={habitStop.state === "loading"}
+ style={[styles.spacing, { width: "96%" }]}
+ >
+ 🛑 Stop Habit (effective tomorrow)
+
+ ) : (
+
+ 🛑 The habit has been stopped! (No further progress can be saved)
+
+ )}
void
+ handleCloseModal?: () => void
+}
+
+interface SearchInputProps {
+ searchText: string
+ handleSearch: (text: string) => void
+}
+const SearchInputWithoutMemo: React.FC = (props) => {
+ const { searchText, handleSearch } = props
+ return (
+
+ )
+}
+const SearchInput = memo(SearchInputWithoutMemo)
+
+const iconNames = Object.keys(fas).map((key) => {
+ return fas[key]?.iconName ?? key
+})
+
+const findIconsInLibrary = (icon: string): string[] => {
+ return iconNames
+ .filter((name, index, self) => {
+ return name.includes(icon) && self.indexOf(name) === index
+ })
+ .slice(0, 50)
+}
+
+export const IconSelectorModal: React.FC = ({
+ isVisible = false,
+ selectedIcon,
+ onIconSelect,
+ handleCloseModal,
+}) => {
+ const [possibleIcons, setPossibleIcons] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [searchText, setSearchText] = useState("")
+ const [_isPending, startTransition] = useTransition()
+
+ const handleSearch = useCallback((text: string): void => {
+ setSearchText(text)
+ }, [])
+
+ useEffect(() => {
+ const handlePossibleIcons = (): void => {
+ startTransition(() => {
+ setPossibleIcons(findIconsInLibrary(searchText))
+ setIsLoading(false)
+ })
+ }
+ const debounceHandleSearch = setTimeout(handlePossibleIcons, 400)
+
+ return () => {
+ return clearTimeout(debounceHandleSearch)
+ }
+ }, [searchText])
+
+ const handleIconSelect = useCallback(
+ (icon: string): void => {
+ onIconSelect(icon)
+ },
+ [onIconSelect],
+ )
+
+ return (
+
+
+
+
+ Selected Icon:
+
+
+
+
+
+
+
+
+
+
+ Save
+
+
+
+
+
+ )
+}
diff --git a/presentation/react-native/components/HabitForm/IconsList.tsx b/presentation/react-native/components/HabitForm/IconsList.tsx
new file mode 100644
index 0000000..16b299a
--- /dev/null
+++ b/presentation/react-native/components/HabitForm/IconsList.tsx
@@ -0,0 +1,74 @@
+import type { IconName } from "@fortawesome/fontawesome-svg-core"
+import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
+import React, { memo } from "react"
+import { View } from "react-native"
+import { ActivityIndicator, IconButton, Text } from "react-native-paper"
+
+export interface IconsListProps {
+ selectedIcon?: string
+ possibleIcons: string[]
+ isLoading?: boolean
+ handleIconSelect: (icon: string) => void
+}
+
+const IconsListWithoutMemo: React.FC = (props) => {
+ const {
+ selectedIcon,
+ possibleIcons,
+ isLoading = false,
+ handleIconSelect,
+ } = props
+
+ if (possibleIcons.length <= 0) {
+ return (
+
+ {isLoading ? (
+
+ ) : (
+ No results found
+ )}
+
+ )
+ }
+
+ return (
+
+ {possibleIcons.map((icon) => {
+ return (
+ {
+ return (
+
+ )
+ }}
+ size={30}
+ onPress={() => {
+ handleIconSelect(icon)
+ }}
+ />
+ )
+ })}
+
+ )
+}
+
+export const IconsList = memo(IconsListWithoutMemo)
diff --git a/presentation/react/components/HabitsMainPage/HabitCard.tsx b/presentation/react-native/components/HabitsMainPage/HabitCard.tsx
similarity index 91%
rename from presentation/react/components/HabitsMainPage/HabitCard.tsx
rename to presentation/react-native/components/HabitsMainPage/HabitCard.tsx
index 8fa1a39..c7b40cd 100644
--- a/presentation/react/components/HabitsMainPage/HabitCard.tsx
+++ b/presentation/react-native/components/HabitsMainPage/HabitCard.tsx
@@ -1,15 +1,16 @@
-import FontAwesome6 from "@expo/vector-icons/FontAwesome6"
+import type { IconName } from "@fortawesome/free-solid-svg-icons"
+import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
import { useRouter } from "expo-router"
+import type LottieView from "lottie-react-native"
import { useState } from "react"
import { View } from "react-native"
import { Checkbox, List, Text } from "react-native-paper"
-import type LottieView from "lottie-react-native"
import type { GoalBoolean } from "@/domain/entities/Goal"
import { GoalBooleanProgress } from "@/domain/entities/Goal"
import type { HabitHistory } from "@/domain/entities/HabitHistory"
+import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
import { getColorRGBAFromHex } from "@/utils/colors"
-import { useHabitsTracker } from "../../contexts/HabitsTracker"
export interface HabitCardProps {
habitHistory: HabitHistory
@@ -65,9 +66,9 @@ export const HabitCard: React.FC = (props) => {
left={() => {
return (
- = (props) => {
+ const { habitsTracker, selectedDate } = props
+
+ const [accordionExpanded, setAccordionExpanded] = useState<{
+ [key in GoalFrequency]: boolean
+ }>({
+ daily: true,
+ weekly: true,
+ monthly: true,
+ })
+
+ const confettiRef = useRef(null)
+
+ const habitsHistoriesByFrequency: Record = {
+ daily: habitsTracker.getHabitsHistoriesByDate({
+ selectedDate,
+ frequency: "daily",
+ }),
+ weekly: habitsTracker.getHabitsHistoriesByDate({
+ selectedDate,
+ frequency: "weekly",
+ }),
+ monthly: habitsTracker.getHabitsHistoriesByDate({
+ selectedDate,
+ frequency: "monthly",
+ }),
+ }
+
+ const frequenciesFiltered = GOAL_FREQUENCIES.filter((frequency) => {
+ return habitsHistoriesByFrequency[frequency].length > 0
+ })
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {selectedDate.toLocaleDateString("en-US", {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })}
+
+
+ {frequenciesFiltered.length > 0 ? (
+
+ {frequenciesFiltered.map((frequency) => {
+ return (
+ {
+ setAccordionExpanded((old) => {
+ return {
+ ...old,
+ [frequency]: !old[frequency],
+ }
+ })
+ }}
+ key={frequency}
+ title={capitalize(frequency)}
+ titleStyle={[
+ {
+ fontSize: 26,
+ },
+ ]}
+ >
+ {habitsHistoriesByFrequency[frequency].map((item) => {
+ return (
+
+ )
+ })}
+
+ )
+ })}
+
+ ) : (
+
+ No habits for this date
+
+ )}
+
+ >
+ )
+}
diff --git a/presentation/react/components/HabitsMainPage/HabitsMainPage.tsx b/presentation/react-native/components/HabitsMainPage/HabitsMainPage.tsx
similarity index 90%
rename from presentation/react/components/HabitsMainPage/HabitsMainPage.tsx
rename to presentation/react-native/components/HabitsMainPage/HabitsMainPage.tsx
index eae9dc9..4546cea 100644
--- a/presentation/react/components/HabitsMainPage/HabitsMainPage.tsx
+++ b/presentation/react-native/components/HabitsMainPage/HabitsMainPage.tsx
@@ -3,7 +3,7 @@ import { Agenda } from "react-native-calendars"
import { GOAL_FREQUENCIES } from "@/domain/entities/Goal"
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
-import { getISODate, getNowDate } from "@/utils/dates"
+import { getISODate, getNowDateUTC } from "@/utils/dates"
import { HabitsEmpty } from "./HabitsEmpty"
import { HabitsList } from "./HabitsList"
@@ -14,7 +14,7 @@ export interface HabitsMainPageProps {
export const HabitsMainPage: React.FC = (props) => {
const { habitsTracker } = props
- const today = getNowDate()
+ const today = getNowDateUTC()
const todayISO = getISODate(today)
const [selectedDate, setSelectedDate] = useState(today)
@@ -45,7 +45,6 @@ export const HabitsMainPage: React.FC = (props) => {
)
}}
diff --git a/presentation/react/components/ExternalLink.tsx b/presentation/react-native/ui/ExternalLink.tsx
similarity index 100%
rename from presentation/react/components/ExternalLink.tsx
rename to presentation/react-native/ui/ExternalLink.tsx
diff --git a/presentation/react/components/TabBarIcon.tsx b/presentation/react-native/ui/TabBarIcon.tsx
similarity index 100%
rename from presentation/react/components/TabBarIcon.tsx
rename to presentation/react-native/ui/TabBarIcon.tsx
diff --git a/presentation/react/components/__tests__/ExternalLink.test.tsx b/presentation/react-native/ui/__tests__/ExternalLink.test.tsx
similarity index 81%
rename from presentation/react/components/__tests__/ExternalLink.test.tsx
rename to presentation/react-native/ui/__tests__/ExternalLink.test.tsx
index 768dbf4..db5855a 100644
--- a/presentation/react/components/__tests__/ExternalLink.test.tsx
+++ b/presentation/react-native/ui/__tests__/ExternalLink.test.tsx
@@ -1,6 +1,6 @@
import renderer from "react-test-renderer"
-import { ExternalLink } from "@/presentation/react/components/ExternalLink"
+import { ExternalLink } from "@/presentation/react-native/ui/ExternalLink"
describe("", () => {
it("renders correctly", () => {
diff --git a/presentation/react/components/__tests__/TabBarIcon.test.tsx b/presentation/react-native/ui/__tests__/TabBarIcon.test.tsx
similarity index 77%
rename from presentation/react/components/__tests__/TabBarIcon.test.tsx
rename to presentation/react-native/ui/__tests__/TabBarIcon.test.tsx
index 6e9fd0f..efcd33a 100644
--- a/presentation/react/components/__tests__/TabBarIcon.test.tsx
+++ b/presentation/react-native/ui/__tests__/TabBarIcon.test.tsx
@@ -1,6 +1,6 @@
import renderer from "react-test-renderer"
-import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
+import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
describe("", () => {
it("renders correctly", () => {
diff --git a/presentation/react/components/__tests__/__snapshots__/ExternalLink.test.tsx.snap b/presentation/react-native/ui/__tests__/__snapshots__/ExternalLink.test.tsx.snap
similarity index 100%
rename from presentation/react/components/__tests__/__snapshots__/ExternalLink.test.tsx.snap
rename to presentation/react-native/ui/__tests__/__snapshots__/ExternalLink.test.tsx.snap
diff --git a/presentation/react/components/__tests__/__snapshots__/TabBarIcon.test.tsx.snap b/presentation/react-native/ui/__tests__/__snapshots__/TabBarIcon.test.tsx.snap
similarity index 100%
rename from presentation/react/components/__tests__/__snapshots__/TabBarIcon.test.tsx.snap
rename to presentation/react-native/ui/__tests__/__snapshots__/TabBarIcon.test.tsx.snap
diff --git a/presentation/react/components/HabitsMainPage/HabitsList.tsx b/presentation/react/components/HabitsMainPage/HabitsList.tsx
deleted file mode 100644
index 8a49b8a..0000000
--- a/presentation/react/components/HabitsMainPage/HabitsList.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import LottieView from "lottie-react-native"
-import { useRef, useState } from "react"
-import { Dimensions, ScrollView, View } from "react-native"
-import { Divider, List } from "react-native-paper"
-
-import type { GoalFrequency } from "@/domain/entities/Goal"
-import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
-import confettiJSON from "../../../assets/confetti.json"
-import { capitalize } from "@/utils/strings"
-import { HabitCard } from "./HabitCard"
-
-export interface HabitsListProps {
- habitsTracker: HabitsTracker
- selectedDate: Date
- frequenciesFiltered: GoalFrequency[]
-}
-
-export const HabitsList: React.FC = (props) => {
- const { habitsTracker, selectedDate, frequenciesFiltered } = props
-
- const [accordionExpanded, setAccordionExpanded] = useState<{
- [key in GoalFrequency]: boolean
- }>({
- daily: true,
- weekly: true,
- monthly: true,
- })
-
- const confettiRef = useRef(null)
-
- return (
- <>
-
-
-
-
-
-
-
-
- {frequenciesFiltered.map((frequency) => {
- return (
- {
- setAccordionExpanded((old) => {
- return {
- ...old,
- [frequency]: !old[frequency],
- }
- })
- }}
- key={frequency}
- title={capitalize(frequency)}
- titleStyle={[
- {
- fontSize: 26,
- },
- ]}
- >
- {habitsTracker.habitsHistory[frequency].map((item) => {
- return (
-
- )
- })}
-
- )
- })}
-
-
- >
- )
-}
diff --git a/presentation/react/components/Stats/Stats.tsx b/presentation/react/components/Stats/Stats.tsx
index 3cbd071..bf3d9f8 100644
--- a/presentation/react/components/Stats/Stats.tsx
+++ b/presentation/react/components/Stats/Stats.tsx
@@ -3,7 +3,7 @@ import CircularProgress from "react-native-circular-progress-indicator"
import { Agenda } from "react-native-calendars"
import { useState } from "react"
-import { getNowDate, getISODate } from "@/utils/dates"
+import { getNowDateUTC, getISODate } from "@/utils/dates"
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
export interface StatsProps {
@@ -13,7 +13,7 @@ export interface StatsProps {
export const Stats: React.FC = (props) => {
const { habitsTracker } = props
- const today = getNowDate()
+ const today = getNowDateUTC()
const todayISO = getISODate(today)
const [selectedDate, setSelectedDate] = useState(today)
diff --git a/presentation/react/contexts/Authentication.tsx b/presentation/react/contexts/Authentication.tsx
index 85c507c..6809431 100644
--- a/presentation/react/contexts/Authentication.tsx
+++ b/presentation/react/contexts/Authentication.tsx
@@ -1,11 +1,11 @@
import { createContext, useContext, useEffect } from "react"
-import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
+import { authenticationPresenter } from "@/infrastructure/instances"
import type {
AuthenticationPresenter,
AuthenticationPresenterState,
} from "@/presentation/presenters/Authentication"
-import { authenticationPresenter } from "@/infrastructure/instances"
+import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
export interface AuthenticationContextValue
extends AuthenticationPresenterState {
diff --git a/presentation/react/contexts/HabitsTracker.tsx b/presentation/react/contexts/HabitsTracker.tsx
index a4b1948..bded388 100644
--- a/presentation/react/contexts/HabitsTracker.tsx
+++ b/presentation/react/contexts/HabitsTracker.tsx
@@ -1,11 +1,11 @@
import { createContext, useContext, useEffect } from "react"
+import { habitsTrackerPresenter } from "@/infrastructure/instances"
import type {
HabitsTrackerPresenter,
HabitsTrackerPresenterState,
} from "@/presentation/presenters/HabitsTracker"
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
-import { habitsTrackerPresenter } from "@/infrastructure/instances"
import { useAuthentication } from "./Authentication"
export interface HabitsTrackerContextValue extends HabitsTrackerPresenterState {
diff --git a/presentation/react/hooks/__tests__/useBoolean.test.ts b/presentation/react/hooks/__tests__/useBoolean.test.ts
index 0277c87..6e4dcfc 100644
--- a/presentation/react/hooks/__tests__/useBoolean.test.ts
+++ b/presentation/react/hooks/__tests__/useBoolean.test.ts
@@ -2,8 +2,8 @@ import { act, renderHook } from "@testing-library/react-native"
import { useBoolean } from "@/presentation/react/hooks/useBoolean"
-describe("hooks/useBoolean", () => {
- beforeEach(() => {
+describe("presentation/react/hooks/useBoolean", () => {
+ afterEach(() => {
jest.clearAllMocks()
})
@@ -11,51 +11,76 @@ describe("hooks/useBoolean", () => {
for (const initialValue of initialValues) {
it(`should set the initial value to ${initialValue}`, () => {
+ // Arrange - Given
const { result } = renderHook(() => {
return useBoolean({ initialValue })
})
+
+ // Assert - Then
expect(result.current.value).toBe(initialValue)
})
}
it("should by default set the initial value to false", () => {
+ // Arrange - Given
const { result } = renderHook(() => {
return useBoolean()
})
+
+ // Assert - Then
expect(result.current.value).toBe(false)
})
it("should toggle the value", async () => {
+ // Arrange - Given
const { result } = renderHook(() => {
return useBoolean({ initialValue: false })
})
+
+ // Act - When
await act(() => {
return result.current.toggle()
})
+
+ // Assert - Then
expect(result.current.value).toBe(true)
+
+ // Act - When
await act(() => {
return result.current.toggle()
})
+
+ // Assert - Then
expect(result.current.value).toBe(false)
})
it("should set the value to true", async () => {
+ // Arrange - Given
const { result } = renderHook(() => {
return useBoolean({ initialValue: false })
})
+
+ // Act - When
await act(() => {
return result.current.setTrue()
})
+
+ // Assert - Then
expect(result.current.value).toBe(true)
})
it("should set the value to false", async () => {
+ // Arrange - Given
const { result } = renderHook(() => {
return useBoolean({ initialValue: true })
})
+
+ // Act - When
await act(() => {
return result.current.setFalse()
})
+
+ // Assert - Then
expect(result.current.value).toBe(false)
})
})
diff --git a/presentation/react/hooks/__tests__/usePresenterState.test.ts b/presentation/react/hooks/__tests__/usePresenterState.test.ts
new file mode 100644
index 0000000..5c7d6e0
--- /dev/null
+++ b/presentation/react/hooks/__tests__/usePresenterState.test.ts
@@ -0,0 +1,75 @@
+import { act, renderHook } from "@testing-library/react-native"
+
+import { Presenter } from "@/presentation/presenters/_Presenter"
+import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
+
+interface MockCountPresenterState {
+ count: number
+}
+
+class MockCountPresenter extends Presenter {
+ public constructor(initialState: MockCountPresenterState) {
+ super(initialState)
+ }
+
+ public increment(): void {
+ this.setState((state) => {
+ state.count = state.count + 1
+ })
+ }
+}
+
+describe("presentation/react/hooks/usePresenterState", () => {
+ it("should return the initial state from the presenter", async () => {
+ // Arrange - Given
+ const initialState = { count: 0 }
+ const presenter = new MockCountPresenter(initialState)
+
+ // Act - When
+ const { result } = renderHook(() => {
+ return usePresenterState(presenter)
+ })
+
+ // Assert - Then
+ expect(result.current).toEqual(initialState)
+ })
+
+ it("should update state when presenter state changes", async () => {
+ // Arrange - Given
+ const initialState = { count: 0 }
+ const presenter = new MockCountPresenter(initialState)
+ const subscribe = jest.spyOn(presenter, "subscribe")
+ const { result } = renderHook(() => {
+ return usePresenterState(presenter)
+ })
+
+ // Act - When
+ await act(() => {
+ presenter.increment()
+ })
+
+ // Assert - Then
+ expect(result.current.count).toBe(1)
+ expect(subscribe).toHaveBeenCalledTimes(1)
+ })
+
+ it("should unsubscribe from presenter on unmount", async () => {
+ // Arrange - Given
+ const initialState = { count: 0 }
+ const presenter = new MockCountPresenter(initialState)
+ const unsubscribe = jest.spyOn(presenter, "unsubscribe")
+ const { result, unmount } = renderHook(() => {
+ return usePresenterState(presenter)
+ })
+
+ // Act - When
+ unmount()
+ await act(() => {
+ presenter.increment()
+ })
+
+ // Assert - Then
+ expect(result.current.count).toBe(0)
+ expect(unsubscribe).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/presentation/react/hooks/useBoolean.ts b/presentation/react/hooks/useBoolean.ts
index 04d6aec..047c610 100644
--- a/presentation/react/hooks/useBoolean.ts
+++ b/presentation/react/hooks/useBoolean.ts
@@ -2,9 +2,10 @@ import { useState } from "react"
export interface UseBooleanResult {
value: boolean
- toggle: () => void
+ setValue: React.Dispatch>
setTrue: () => void
setFalse: () => void
+ toggle: () => void
}
export interface UseBooleanOptions {
@@ -43,6 +44,7 @@ export const useBoolean = (
return {
value,
+ setValue,
toggle,
setTrue,
setFalse,
diff --git a/tests/mocks/domain/Habit.ts b/tests/mocks/domain/Habit.ts
new file mode 100644
index 0000000..5c2ddfb
--- /dev/null
+++ b/tests/mocks/domain/Habit.ts
@@ -0,0 +1,115 @@
+import { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
+import type { HabitData } from "@/domain/entities/Habit"
+import { Habit } from "@/domain/entities/Habit"
+import { USER_MOCK } from "./User"
+import { ONE_DAY_MILLISECONDS } from "@/utils/dates"
+
+interface HabitMockCreateOptions extends Omit {
+ startDate?: Date
+}
+const habitMockCreate = (options: HabitMockCreateOptions): Habit => {
+ const {
+ id,
+ userId,
+ name,
+ color,
+ icon,
+ goal,
+ startDate = new Date(),
+ endDate,
+ } = options
+
+ return new Habit({
+ id,
+ userId,
+ name,
+ color,
+ icon,
+ goal,
+ startDate,
+ endDate,
+ })
+}
+
+const examplesByNames = {
+ "Wake up at 07h00": habitMockCreate({
+ id: "1",
+ userId: USER_MOCK.example.id,
+ name: "Wake up at 07h00",
+ color: "#006CFF",
+ icon: "bed",
+ goal: new GoalBoolean({
+ frequency: "daily",
+ }),
+ }),
+ "Learn English": habitMockCreate({
+ id: "2",
+ userId: USER_MOCK.example.id,
+ name: "Learn English",
+ color: "#EB4034",
+ icon: "language",
+ goal: new GoalNumeric({
+ frequency: "daily",
+ target: {
+ value: 30,
+ unit: "minutes",
+ },
+ }),
+ }),
+ Walk: habitMockCreate({
+ id: "3",
+ userId: USER_MOCK.example.id,
+ name: "Walk",
+ color: "#228B22",
+ icon: "person-walking",
+ goal: new GoalNumeric({
+ frequency: "daily",
+ target: {
+ value: 5000,
+ unit: "steps",
+ },
+ }),
+ }),
+ "Clean the house": habitMockCreate({
+ id: "4",
+ userId: USER_MOCK.example.id,
+ name: "Clean the house",
+ color: "#808080",
+ icon: "broom",
+ goal: new GoalBoolean({
+ frequency: "weekly",
+ }),
+ }),
+ "Solve Programming Challenges": habitMockCreate({
+ id: "5",
+ userId: USER_MOCK.example.id,
+ name: "Solve Programming Challenges",
+ color: "#DE3163",
+ icon: "code",
+ goal: new GoalNumeric({
+ frequency: "monthly",
+ target: {
+ value: 5,
+ unit: "challenges",
+ },
+ }),
+ endDate: new Date(Date.now() + ONE_DAY_MILLISECONDS),
+ }),
+} as const
+
+export const examplesByIds = {
+ [examplesByNames["Wake up at 07h00"].id]: examplesByNames["Wake up at 07h00"],
+ [examplesByNames["Learn English"].id]: examplesByNames["Learn English"],
+ [examplesByNames.Walk.id]: examplesByNames.Walk,
+ [examplesByNames["Clean the house"].id]: examplesByNames["Clean the house"],
+ [examplesByNames["Solve Programming Challenges"].id]:
+ examplesByNames["Solve Programming Challenges"],
+} as const
+
+export const HABIT_MOCK = {
+ create: habitMockCreate,
+ example: examplesByNames["Wake up at 07h00"],
+ examplesByNames,
+ examplesByIds,
+ examples: Object.values(examplesByNames),
+}
diff --git a/tests/mocks/domain/HabitProgress.ts b/tests/mocks/domain/HabitProgress.ts
new file mode 100644
index 0000000..7496ba1
--- /dev/null
+++ b/tests/mocks/domain/HabitProgress.ts
@@ -0,0 +1,51 @@
+import type { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
+import {
+ GoalBooleanProgress,
+ GoalNumericProgress,
+} from "@/domain/entities/Goal"
+import type { HabitProgressData } from "@/domain/entities/HabitProgress"
+import { HabitProgress } from "@/domain/entities/HabitProgress"
+import { HABIT_MOCK } from "./Habit"
+
+interface HabitProgressMockCreateOptions
+ extends Omit {
+ date?: Date
+}
+
+const habitProgressMockCreate = (
+ options: HabitProgressMockCreateOptions,
+): HabitProgress => {
+ const { id, habitId, goalProgress, date = new Date() } = options
+
+ return new HabitProgress({
+ date,
+ goalProgress,
+ habitId,
+ id,
+ })
+}
+
+const exampleByIds = {
+ 1: habitProgressMockCreate({
+ id: "1",
+ habitId: HABIT_MOCK.examplesByNames["Clean the house"].id,
+ goalProgress: new GoalBooleanProgress({
+ goal: HABIT_MOCK.examplesByNames["Clean the house"].goal as GoalBoolean,
+ progress: true,
+ }),
+ }),
+ 2: habitProgressMockCreate({
+ id: "2",
+ habitId: HABIT_MOCK.examplesByNames.Walk.id,
+ goalProgress: new GoalNumericProgress({
+ goal: HABIT_MOCK.examplesByNames.Walk.goal as GoalNumeric,
+ progress: 4_733,
+ }),
+ }),
+} as const
+
+export const HABIT_PROGRESS_MOCK = {
+ create: habitProgressMockCreate,
+ exampleByIds,
+ examples: Object.values(exampleByIds),
+}
diff --git a/tests/mocks/domain/User.ts b/tests/mocks/domain/User.ts
new file mode 100644
index 0000000..40aa91c
--- /dev/null
+++ b/tests/mocks/domain/User.ts
@@ -0,0 +1,30 @@
+import type { UserData } from "@/domain/entities/User"
+import { User } from "@/domain/entities/User"
+
+const USER_MOCK_ID = "ab054ee9-fbb4-473e-942b-bbf4415f4bef"
+const USER_MOCK_EMAIL = "test@test.com"
+const USER_MOCK_DISPLAY_NAME = "Test"
+
+interface UserMockCreateOptions {
+ id?: UserData["id"]
+ email?: UserData["email"]
+ displayName?: UserData["displayName"]
+}
+const userMockCreate = (options: UserMockCreateOptions = {}): User => {
+ const {
+ id = USER_MOCK_ID,
+ email = USER_MOCK_EMAIL,
+ displayName = USER_MOCK_DISPLAY_NAME,
+ } = options
+
+ return new User({
+ id,
+ email,
+ displayName,
+ })
+}
+
+export const USER_MOCK = {
+ create: userMockCreate,
+ example: userMockCreate(),
+}
diff --git a/tests/mocks/supabase/Habit.ts b/tests/mocks/supabase/Habit.ts
new file mode 100644
index 0000000..e399e7f
--- /dev/null
+++ b/tests/mocks/supabase/Habit.ts
@@ -0,0 +1,79 @@
+import type { SupabaseHabit } from "@/infrastructure/supabase/supabase"
+import { HABIT_MOCK } from "../domain/Habit"
+import { SUPABASE_USER_MOCK } from "./User"
+
+interface SupabaseHabitMockCreateOptions {
+ id: SupabaseHabit["id"]
+ userId: SupabaseHabit["user_id"]
+ name: SupabaseHabit["name"]
+ color: SupabaseHabit["color"]
+ icon: SupabaseHabit["icon"]
+ startDate?: Date
+ endDate: Date | null
+ goalFrequency: SupabaseHabit["goal_frequency"]
+ goalTarget: SupabaseHabit["goal_target"] | null
+ goalTargetUnit: SupabaseHabit["goal_target_unit"] | null
+}
+const supabaseHabitMockCreate = (
+ options: SupabaseHabitMockCreateOptions,
+): SupabaseHabit => {
+ const {
+ id,
+ userId,
+ name,
+ color,
+ icon,
+ startDate = new Date(),
+ endDate,
+ goalFrequency,
+ goalTarget,
+ goalTargetUnit,
+ } = options
+
+ return {
+ id,
+ user_id: userId,
+ name,
+ color,
+ icon,
+ start_date: startDate.toISOString(),
+ end_date: endDate?.toISOString() ?? null,
+ goal_frequency: goalFrequency,
+ goal_target: goalTarget,
+ goal_target_unit: goalTargetUnit,
+ }
+}
+
+const examplesByNames = Object.fromEntries(
+ Object.entries(HABIT_MOCK.examplesByNames).map(([name, habit]) => {
+ const goalTarget = habit.goal.isNumeric() ? habit.goal.target.value : null
+ const goalTargetUnit = habit.goal.isNumeric()
+ ? habit.goal.target.unit
+ : null
+ return [
+ name,
+ supabaseHabitMockCreate({
+ id: Number.parseInt(habit.id, 10),
+ userId: SUPABASE_USER_MOCK.example.id,
+ name: habit.name,
+ color: habit.color,
+ icon: habit.icon,
+ startDate: habit.startDate,
+ endDate: habit.endDate ?? null,
+ goalFrequency: habit.goal.frequency,
+ goalTarget,
+ goalTargetUnit,
+ }),
+ ]
+ }),
+) as {
+ [key in keyof (typeof HABIT_MOCK)["examplesByNames"]]: SupabaseHabit
+}
+
+export const SUPABASE_HABIT_MOCK = {
+ create: supabaseHabitMockCreate,
+ example:
+ examplesByNames[HABIT_MOCK.example.name as keyof typeof examplesByNames],
+ examples: Object.values(examplesByNames),
+ examplesByNames,
+}
diff --git a/tests/mocks/supabase/HabitProgress.ts b/tests/mocks/supabase/HabitProgress.ts
new file mode 100644
index 0000000..0fdbd65
--- /dev/null
+++ b/tests/mocks/supabase/HabitProgress.ts
@@ -0,0 +1,49 @@
+import type { SupabaseHabitProgress } from "@/infrastructure/supabase/supabase"
+import { HABIT_PROGRESS_MOCK } from "../domain/HabitProgress"
+
+interface SupabaseHabitProgressMockCreateOptions {
+ id: SupabaseHabitProgress["id"]
+ habitId: SupabaseHabitProgress["habit_id"]
+ date?: Date
+ goalProgress: SupabaseHabitProgress["goal_progress"]
+}
+const supabaseHabitProgressMockCreate = (
+ options: SupabaseHabitProgressMockCreateOptions,
+): SupabaseHabitProgress => {
+ const { id, habitId, date = new Date(), goalProgress } = options
+
+ return {
+ id,
+ habit_id: habitId,
+ date: date.toISOString(),
+ goal_progress: goalProgress,
+ }
+}
+
+const exampleByIds = Object.fromEntries(
+ Object.entries(HABIT_PROGRESS_MOCK.exampleByIds).map(
+ ([id, habitProgress]) => {
+ return [
+ id,
+ supabaseHabitProgressMockCreate({
+ id: Number.parseInt(habitProgress.id, 10),
+ habitId: Number.parseInt(habitProgress.habitId, 10),
+ date: new Date(habitProgress.date),
+ goalProgress: habitProgress.goalProgress.isNumeric()
+ ? habitProgress.goalProgress.progress
+ : habitProgress.goalProgress.isCompleted()
+ ? 1
+ : 0,
+ }),
+ ]
+ },
+ ),
+) as {
+ [key in keyof (typeof HABIT_PROGRESS_MOCK)["exampleByIds"]]: SupabaseHabitProgress
+}
+
+export const SUPABASE_HABIT_PROGRESS_MOCK = {
+ create: supabaseHabitProgressMockCreate,
+ exampleByIds,
+ examples: Object.values(exampleByIds),
+}
diff --git a/tests/mocks/supabase/User.ts b/tests/mocks/supabase/User.ts
new file mode 100644
index 0000000..56257f9
--- /dev/null
+++ b/tests/mocks/supabase/User.ts
@@ -0,0 +1,63 @@
+import type { SupabaseUser } from "@/infrastructure/supabase/supabase"
+import { USER_MOCK } from "../domain/User"
+
+interface SupabaseUserMockCreateOptions {
+ id?: SupabaseUser["id"]
+ email?: SupabaseUser["email"]
+ displayName?: SupabaseUser["user_metadata"]["display_name"]
+ date?: Date
+}
+const supabaseUserMockCreate = (
+ options: SupabaseUserMockCreateOptions = {},
+): SupabaseUser => {
+ const {
+ id = USER_MOCK.example.id,
+ email = USER_MOCK.example.email,
+ displayName = USER_MOCK.example.displayName,
+ date = new Date(),
+ } = options
+
+ return {
+ id,
+ app_metadata: { provider: "email", providers: ["email"] },
+ user_metadata: { display_name: displayName },
+ aud: "authenticated",
+ email,
+ confirmation_sent_at: undefined,
+ recovery_sent_at: undefined,
+ email_change_sent_at: undefined,
+ new_email: "",
+ new_phone: "",
+ invited_at: undefined,
+ action_link: "",
+ created_at: date.toISOString(),
+ confirmed_at: undefined,
+ email_confirmed_at: date.toISOString(),
+ phone_confirmed_at: undefined,
+ last_sign_in_at: undefined,
+ role: "authenticated",
+ updated_at: date.toISOString(),
+ identities: [
+ {
+ id,
+ user_id: id,
+ identity_data: {
+ sub: id,
+ email,
+ },
+ provider: "email",
+ identity_id: id,
+ last_sign_in_at: date.toISOString(),
+ created_at: date.toISOString(),
+ updated_at: date.toISOString(),
+ },
+ ],
+ is_anonymous: false,
+ factors: [],
+ }
+}
+
+export const SUPABASE_USER_MOCK = {
+ create: supabaseUserMockCreate,
+ example: supabaseUserMockCreate(),
+}
diff --git a/tests/setup.ts b/tests/setup.ts
new file mode 100644
index 0000000..39b7612
--- /dev/null
+++ b/tests/setup.ts
@@ -0,0 +1 @@
+import "@testing-library/react-native/extend-expect"
diff --git a/utils/__tests__/colors.test.ts b/utils/__tests__/colors.test.ts
new file mode 100644
index 0000000..2bbcad1
--- /dev/null
+++ b/utils/__tests__/colors.test.ts
@@ -0,0 +1,44 @@
+import { getColorRGBAFromHex } from "../colors"
+
+describe("utils/colors", () => {
+ describe("getColorRGBAFromHex", () => {
+ it("should return the correct rgba value when given a hex color and opacity (black 0)", () => {
+ // Arrange - Given
+ const hexColor = "#000000"
+ const opacity = 0
+
+ // Act - When
+ const result = getColorRGBAFromHex({ hexColor, opacity })
+
+ // Assert - Then
+ const expected = "rgba(0, 0, 0, 0)"
+ expect(result).toEqual(expected)
+ })
+
+ it("should return the correct rgba value when given a hex color and opacity (red 255)", () => {
+ // Arrange - Given
+ const hexColor = "#FF0000"
+ const opacity = 0.5
+
+ // Act - When
+ const result = getColorRGBAFromHex({ hexColor, opacity })
+
+ // Assert - Then
+ const expected = "rgba(255, 0, 0, 0.5)"
+ expect(result).toEqual(expected)
+ })
+
+ it("should return the correct rgba value when given a hex color with 3 characters and opacity (red 255)", () => {
+ // Arrange - Given
+ const hexColor = "#F00"
+ const opacity = 0.5
+
+ // Act - When
+ const result = getColorRGBAFromHex({ hexColor, opacity })
+
+ // Assert - Then
+ const expected = "rgba(255, 0, 0, 0.5)"
+ expect(result).toEqual(expected)
+ })
+ })
+})
diff --git a/utils/__tests__/dates.test.ts b/utils/__tests__/dates.test.ts
new file mode 100644
index 0000000..98316ce
--- /dev/null
+++ b/utils/__tests__/dates.test.ts
@@ -0,0 +1,80 @@
+import { getISODate, getNowDateUTC, getWeekNumber } from "../dates"
+
+describe("utils/dates", () => {
+ afterEach(() => {
+ jest.clearAllMocks()
+ jest.resetAllMocks()
+ jest.useRealTimers()
+ })
+
+ describe("getISODate", () => {
+ it("should return the correct date in ISO format (e.g: 2012-05-23)", () => {
+ // Arrange - Given
+ const date = new Date("2012-05-23")
+
+ // Act - When
+ const result = getISODate(date)
+
+ // Assert - Then
+ const expected = "2012-05-23"
+ expect(result).toEqual(expected)
+ })
+ })
+
+ describe("getNowDateUTC", () => {
+ it("should return the current UTC date", () => {
+ // Arrange - Given
+ const mockDate = new Date("2024-05-01T12:00:00Z")
+ jest.useFakeTimers({ now: mockDate })
+ Date.UTC = jest.fn(() => {
+ return mockDate.getTime()
+ })
+
+ // Act - When
+ const result = getNowDateUTC()
+
+ // Assert - Then
+ const expected = new Date("2024-05-01T12:00:00.000Z")
+ expect(result).toEqual(expected)
+ expect(Date.UTC).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe("getWeekNumber", () => {
+ it("should return the correct week number for a given date (e.g: 2020-01-01)", () => {
+ // Arrange - Given
+ const date = new Date("2020-01-01")
+
+ // Act - When
+ const result = getWeekNumber(date)
+
+ // Assert - Then
+ const expected = 1
+ expect(result).toEqual(expected)
+ })
+
+ it("should return the correct week number for a given date (e.g: 2020-01-08)", () => {
+ // Arrange - Given
+ const date = new Date("2020-01-08")
+
+ // Act - When
+ const result = getWeekNumber(date)
+
+ // Assert - Then
+ const expected = 2
+ expect(result).toEqual(expected)
+ })
+
+ it("should return the correct week number for a given date (e.g: 2020-12-31)", () => {
+ // Arrange - Given
+ const date = new Date("2020-12-31")
+
+ // Act - When
+ const result = getWeekNumber(date)
+
+ // Assert - Then
+ const expected = 53
+ expect(result).toEqual(expected)
+ })
+ })
+})
diff --git a/utils/__tests__/strings.test.ts b/utils/__tests__/strings.test.ts
new file mode 100644
index 0000000..4d9e331
--- /dev/null
+++ b/utils/__tests__/strings.test.ts
@@ -0,0 +1,17 @@
+import { capitalize } from "../strings"
+
+describe("utils/strings", () => {
+ describe("capitalize", () => {
+ it("should capitalize the first letter of a string", () => {
+ // Arrange - Given
+ const string = "hello world"
+
+ // Act - When
+ const result = capitalize(string)
+
+ // Assert - Then
+ const expected = "Hello world"
+ expect(result).toEqual(expected)
+ })
+ })
+})
diff --git a/utils/__tests__/version.test.ts b/utils/__tests__/version.test.ts
new file mode 100644
index 0000000..f9e94b0
--- /dev/null
+++ b/utils/__tests__/version.test.ts
@@ -0,0 +1,39 @@
+import { getVersion } from "../version"
+import { version } from "@/package.json"
+
+describe("utils/version", () => {
+ const env = process.env
+
+ beforeEach(() => {
+ jest.resetModules()
+ process.env = { ...env }
+ })
+
+ afterEach(() => {
+ process.env = env
+ jest.clearAllMocks()
+ })
+
+ it("should return '0.0.0-development' when NODE_ENV is 'development'", () => {
+ // Arrange - Given
+ process.env["NODE_ENV"] = "development"
+
+ // Act - When
+ const result = getVersion()
+
+ // Assert - Then
+ const expected = "0.0.0-development"
+ expect(result).toEqual(expected)
+ })
+
+ it("should return the version from package.json when NODE_ENV is not 'development'", () => {
+ // Arrange - Given
+ process.env["NODE_ENV"] = "production"
+
+ // Act - When
+ const result = getVersion()
+
+ // Assert - Then
+ expect(result).toEqual(version)
+ })
+})
diff --git a/utils/__tests__/zod.test.ts b/utils/__tests__/zod.test.ts
new file mode 100644
index 0000000..b4f366f
--- /dev/null
+++ b/utils/__tests__/zod.test.ts
@@ -0,0 +1,39 @@
+import type { ZodIssue } from "zod"
+import { ZodError } from "zod"
+
+import { getErrorsFieldsFromZodError } from "../zod"
+
+const zodIssue: ZodIssue = {
+ code: "too_small",
+ minimum: 1,
+ type: "string",
+ inclusive: true,
+ exact: false,
+ message: "String must contain at least 1 character(s)",
+ path: ["name"],
+}
+
+describe("utils/zod", () => {
+ describe("getErrorsFieldsFromZodError", () => {
+ it("should return an array of the fields that have errors", () => {
+ // Arrange - Given
+ const error = new ZodError([
+ {
+ ...zodIssue,
+ path: ["field1"],
+ },
+ {
+ ...zodIssue,
+ path: ["field2"],
+ },
+ ])
+
+ // Act - When
+ const result = getErrorsFieldsFromZodError(error)
+
+ // Assert - Then
+ const expected = ["field1", "field2"]
+ expect(result).toEqual(expected)
+ })
+ })
+})
diff --git a/utils/dates.ts b/utils/dates.ts
index 4b0124a..d477a76 100644
--- a/utils/dates.ts
+++ b/utils/dates.ts
@@ -11,7 +11,7 @@ export const getISODate = (date: Date): string => {
return date.toISOString().slice(0, 10)
}
-export const getNowDate = (): Date => {
+export const getNowDateUTC = (): Date => {
const date = new Date()
const milliseconds = Date.UTC(
date.getFullYear(),
@@ -28,8 +28,8 @@ export const getNowDate = (): Date => {
* Get the week number [1-52] for a given date.
* @param {Date} date
* @returns {number}
- * @example getWeekNumber(new Date(2020, 0, 1)) // 1
- * @example getWeekNumber(new Date(2020, 0, 8)) // 2
+ * @example getWeekNumber(new Date("2020-01-01")) // 1
+ * @example getWeekNumber(new Date("2020-01-08")) // 2
*/
export const getWeekNumber = (date: Date): number => {
const dateCopy = new Date(date.getTime())
diff --git a/utils/version.ts b/utils/version.ts
new file mode 100644
index 0000000..8ddaefb
--- /dev/null
+++ b/utils/version.ts
@@ -0,0 +1,8 @@
+import { version } from "@/package.json"
+
+export const getVersion = (): string => {
+ if (process.env["NODE_ENV"] === "development") {
+ return "0.0.0-development"
+ }
+ return version
+}
diff --git a/utils/zod.ts b/utils/zod.ts
index 44b458e..fbb6e78 100644
--- a/utils/zod.ts
+++ b/utils/zod.ts
@@ -3,5 +3,8 @@ import type { ZodError } from "zod"
export const getErrorsFieldsFromZodError = (
error: ZodError,
): Array => {
- return Object.keys(error.format()) as Array
+ const fields = Object.keys(error.format()) as Array
+ return fields.filter((field) => {
+ return field !== "_errors"
+ })
}