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. +

+ Habits Tracker Screenshot +

+ ### 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 = () => { + + + ) }} name="icon" @@ -224,8 +334,8 @@ export const HabitCreateForm: React.FC = ({ user }) => { mode="contained" onPress={handleSubmit(onSubmit)} loading={habitCreate.state === "loading"} - disabled={habitCreate.state === "loading"} - style={[styles.spacing, { width: "90%" }]} + disabled={habitCreate.state === "loading" || !isValid} + style={[{ width: "100%", marginVertical: 15 }]} > Create your habit! 🚀 @@ -244,6 +354,6 @@ export const HabitCreateForm: React.FC = ({ user }) => { const styles = StyleSheet.create({ spacing: { - marginVertical: 16, + marginVertical: 10, }, }) diff --git a/presentation/react/components/HabitEditForm/HabitEditForm.tsx b/presentation/react-native/components/HabitForm/HabitEditForm.tsx similarity index 56% rename from presentation/react/components/HabitEditForm/HabitEditForm.tsx rename to presentation/react-native/components/HabitForm/HabitEditForm.tsx index be69d7f..83faa2f 100644 --- a/presentation/react/components/HabitEditForm/HabitEditForm.tsx +++ b/presentation/react-native/components/HabitForm/HabitEditForm.tsx @@ -1,8 +1,16 @@ +import type { IconName } from "@fortawesome/free-solid-svg-icons" +import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome" import { zodResolver } from "@hookform/resolvers/zod" import { useState } from "react" import { Controller, useForm } from "react-hook-form" -import { ScrollView, StyleSheet } from "react-native" -import { Button, HelperText, Snackbar, TextInput } from "react-native-paper" +import { ScrollView, StyleSheet, View } from "react-native" +import { + Button, + HelperText, + Snackbar, + Text, + TextInput, +} from "react-native-paper" import { SafeAreaView } from "react-native-safe-area-context" import ColorPicker, { HueSlider, @@ -12,19 +20,21 @@ import ColorPicker, { import type { Habit, HabitEditData } from "@/domain/entities/Habit" import { HabitEditSchema } from "@/domain/entities/Habit" -import { useHabitsTracker } from "../../contexts/HabitsTracker" +import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker" +import { useBoolean } from "@/presentation/react/hooks/useBoolean" +import { IconSelectorModal } from "./IconSelectorModal" export interface HabitEditFormProps { habit: Habit } export const HabitEditForm: React.FC = ({ habit }) => { - const { habitEdit, habitsTrackerPresenter } = useHabitsTracker() + const { habitEdit, habitStop, habitsTrackerPresenter } = useHabitsTracker() const { control, + formState: { errors, isValid }, handleSubmit, - formState: { errors }, } = useForm({ mode: "onChange", resolver: zodResolver(HabitEditSchema), @@ -37,6 +47,12 @@ export const HabitEditForm: React.FC = ({ habit }) => { }, }) + const { + value: isModalIconSelectorVisible, + setTrue: openModalIconSelector, + setFalse: closeModalIconSelector, + } = useBoolean() + const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false) const onDismissSnackbar = (): void => { @@ -70,7 +86,7 @@ export const HabitEditForm: React.FC = ({ habit }) => { style={[ styles.spacing, { - width: "90%", + width: "96%", }, ]} mode="outlined" @@ -93,7 +109,7 @@ export const HabitEditForm: React.FC = ({ habit }) => { render={({ field: { onChange, value } }) => { return ( { onChange(value.hex) @@ -110,16 +126,30 @@ export const HabitEditForm: React.FC = ({ habit }) => { { + render={({ field: { onChange, value } }) => { return ( - + + + + + + ) }} name="icon" @@ -129,11 +159,35 @@ export const HabitEditForm: React.FC = ({ habit }) => { mode="contained" onPress={handleSubmit(onSubmit)} loading={habitEdit.state === "loading"} - disabled={habitEdit.state === "loading"} - style={[styles.spacing, { width: "90%" }]} + disabled={habitEdit.state === "loading" || !isValid} + style={[styles.spacing, { width: "96%" }]} > Save + + {habit.endDate == null ? ( + + ) : ( + + 🛑 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: + + + + + + + + + + + + + + + ) +} 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" + }) }