Merge branch 'develop' into feat/stats

This commit is contained in:
Théo LUDWIG 2024-05-20 23:56:21 +02:00
commit 2ab7413f32
Signed by: theoludwig
GPG Key ID: ADFE5A563D718F3B
84 changed files with 6678 additions and 3119 deletions

View File

@ -1,5 +1,5 @@
# Supabase - Local # 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='' # EXPO_PUBLIC_SUPABASE_ANON_KEY=''
# Supabase - Production # Supabase - Production

View File

@ -2,11 +2,9 @@
"extends": [ "extends": [
"conventions", "conventions",
"plugin:react/recommended", "plugin:react/recommended",
"plugin:react-hooks/recommended", "plugin:react-hooks/recommended"
"prettier"
], ],
"ignorePatterns": ["jest.setup.ts"], "ignorePatterns": ["jest.setup.ts"],
"plugins": ["prettier"],
"env": { "env": {
"browser": true, "browser": true,
"node": true, "node": true,
@ -21,7 +19,6 @@
"project": "./tsconfig.json" "project": "./tsconfig.json"
}, },
"rules": { "rules": {
"prettier/prettier": "error",
"react/react-in-jsx-scope": "off", "react/react-in-jsx-scope": "off",
"react/prop-types": "off", "react/prop-types": "off",
"react/self-closing-comp": [ "react/self-closing-comp": [
@ -37,7 +34,10 @@
"overrides": [ "overrides": [
{ {
"files": ["*.ts", "*.tsx"], "files": ["*.ts", "*.tsx"],
"parser": "@typescript-eslint/parser" "parser": "@typescript-eslint/parser",
"rules": {
"@typescript-eslint/member-delimiter-style": "off"
}
} }
] ]
} }

1
.gitignore vendored
View File

@ -3,6 +3,7 @@
# dependencies # dependencies
node_modules/ node_modules/
.npm/ .npm/
.temp/
# Expo # Expo
.expo/ .expo/

View File

@ -1,5 +1,5 @@
default: default:
image: "node:20.11.1" image: "node:20.13.1"
stages: stages:
- "test" - "test"

View File

@ -1,4 +1,4 @@
# P61 - Projet # Habits Tracker - P61 Projet
## À propos ## À 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. Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours.
<p align="center">
<img src="./docs/screenshots/habits.png" alt="Habits Tracker Screenshot" height="400px" />
</p>
### Membres du Groupe 7 ### Membres du Groupe 7
- [Théo LUDWIG](https://git.unistra.fr/t.ludwig) - [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 - [Node.js](https://nodejs.org/) >= 20.0.0
- [npm](https://www.npmjs.com/) >= 10.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) - [Docker](https://www.docker.com/) (facultatif, utilisé pour lancer [Supabase](https://supabase.io/) en local)
### Installation ### 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). 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 ```sh
npm run supabase npm run supabase-cli start
``` ```
#### Principales Commandes Supabase #### Principales Commandes Supabase
```sh ```sh
# Pour réinitialiser la base de données avec les données de test (seed.sql) # 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) # 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) # 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 # 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 `<name-of-migration>` avec le nom de la migration) # Crée un nouveau script de migration à partir des modifications déjà appliquées à votre base de données locale (remplacer `<name-of-migration>` avec le nom de la migration)
npm run supabase db diff -- -f <name-of-migration> npm run supabase-cli db diff -- -f <name-of-migration>
``` ```

View File

@ -1,26 +1,29 @@
{ {
"expo": { "expo": {
"name": "p61-project", "name": "Habits Tracker",
"slug": "p61-project", "slug": "p61-project",
"version": "1.0.0", "version": "1.0.0-staging.4",
"orientation": "portrait", "orientation": "portrait",
"icon": "./presentation/assets/images/icon.png", "icon": "./presentation/assets/images/icon.png",
"scheme": "p61-project", "scheme": "p61-project",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"splash": { "splash": {
"image": "./presentation/assets/images/splashscreen.jpg", "image": "./presentation/assets/images/splashscreen.png",
"resizeMode": "cover", "resizeMode": "cover",
"backgroundColor": "#74b6cb" "backgroundColor": "#74b6cb"
}, },
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": ["**/*"],
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true,
"buildNumber": "1.0.0"
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./presentation/assets/images/adaptive-icon.png", "foregroundImage": "./presentation/assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
} },
"package": "com.theoludwig.p61project",
"versionCode": 3
}, },
"web": { "web": {
"bundler": "metro", "bundler": "metro",
@ -30,6 +33,14 @@
"plugins": ["expo-router"], "plugins": ["expo-router"],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
},
"extra": {
"router": {
"origin": false
},
"eas": {
"projectId": "5c0a922a-564b-4d62-8231-ce5aef7ff978"
}
} }
} }
} }

View File

@ -1,4 +1,6 @@
import { Stack } from "expo-router" 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 * as SplashScreen from "expo-splash-screen"
import { import {
MD3LightTheme as DefaultTheme, MD3LightTheme as DefaultTheme,
@ -20,6 +22,8 @@ export const unstableSettings = {
initialRouteName: "index", initialRouteName: "index",
} }
library.add(fas)
SplashScreen.preventAutoHideAsync().catch((error) => { SplashScreen.preventAutoHideAsync().catch((error) => {
console.error(error) console.error(error)
}) })

View File

@ -1,14 +1,14 @@
import { Redirect, Tabs } from "expo-router" import { Redirect, Tabs } from "expo-router"
import React from "react" 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" import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const TabLayout: React.FC = () => { const TabLayout: React.FC = () => {
const { user } = useAuthentication() const { user } = useAuthentication()
if (user == null) { if (user == null) {
return <Redirect href="/authentication/login" /> return <Redirect href="/authentication/about" />
} }
return ( return (
@ -31,6 +31,7 @@ const TabLayout: React.FC = () => {
name="habits/new" name="habits/new"
options={{ options={{
title: "New Habit", title: "New Habit",
unmountOnBlur: true,
tabBarIcon: ({ color }) => { tabBarIcon: ({ color }) => {
return <TabBarIcon name="plus-square" color={color} /> return <TabBarIcon name="plus-square" color={color} />
}, },
@ -39,6 +40,7 @@ const TabLayout: React.FC = () => {
<Tabs.Screen <Tabs.Screen
name="habits/[habitId]" name="habits/[habitId]"
options={{ options={{
unmountOnBlur: true,
href: null, href: null,
}} }}
/> />

View File

@ -1,6 +1,6 @@
import { Redirect, useLocalSearchParams } from "expo-router" 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" import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
const HabitPage: React.FC = () => { const HabitPage: React.FC = () => {

View File

@ -1,7 +1,7 @@
import { SafeAreaView } from "react-native-safe-area-context" import { SafeAreaView } from "react-native-safe-area-context"
import { ActivityIndicator, Button, Text } from "react-native-paper" 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 { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
import { useAuthentication } from "@/presentation/react/contexts/Authentication" import { useAuthentication } from "@/presentation/react/contexts/Authentication"

View File

@ -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" import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const NewHabitPage: React.FC = () => { const NewHabitPage: React.FC = () => {

View File

@ -1,7 +1,6 @@
import { Text } from "react-native"
import { Button } from "react-native-paper" 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" import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const SettingsPage: React.FC = () => { const SettingsPage: React.FC = () => {
@ -12,26 +11,19 @@ const SettingsPage: React.FC = () => {
} }
return ( return (
<SafeAreaView <About
style={[ actionButton={
{ <Button
flex: 1, mode="contained"
alignItems: "center", labelStyle={{ fontSize: 18 }}
justifyContent: "center", onPress={handleLogout}
}, loading={logout.state === "loading"}
]} disabled={logout.state === "loading"}
> >
<Text>Settings</Text> Logout
</Button>
<Button }
mode="contained" />
onPress={handleLogout}
loading={logout.state === "loading"}
disabled={logout.state === "loading"}
>
Logout
</Button>
</SafeAreaView>
) )
} }

View File

@ -1,7 +1,7 @@
import { Redirect, Tabs } from "expo-router" import { Redirect, Tabs } from "expo-router"
import React from "react" 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" import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const TabLayout: React.FC = () => { const TabLayout: React.FC = () => {
@ -17,6 +17,15 @@ const TabLayout: React.FC = () => {
headerShown: false, headerShown: false,
}} }}
> >
<Tabs.Screen
name="about"
options={{
title: "About",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="info" color={color} />
},
}}
/>
<Tabs.Screen <Tabs.Screen
name="login" name="login"
options={{ options={{

View File

@ -0,0 +1,26 @@
import { Button } from "react-native-paper"
import { useRouter } from "expo-router"
import { About } from "@/presentation/react-native/components/About"
const AboutPage: React.FC = () => {
const router = useRouter()
return (
<About
actionButton={
<Button
mode="contained"
labelStyle={{ fontSize: 18 }}
onPress={() => {
router.push("/authentication/login")
}}
>
Get Started 🚀
</Button>
}
/>
)
}
export default AboutPage

View File

@ -67,6 +67,7 @@ const LoginPage: React.FC = () => {
<Button <Button
mode="contained" mode="contained"
labelStyle={{ fontSize: 18 }}
onPress={handleSubmit(onSubmit)} onPress={handleSubmit(onSubmit)}
loading={login.state === "loading"} loading={login.state === "loading"}
disabled={login.state === "loading"} disabled={login.state === "loading"}

View File

@ -107,6 +107,7 @@ const RegisterPage: React.FC = () => {
<Button <Button
mode="contained" mode="contained"
labelStyle={{ fontSize: 18 }}
onPress={handleSubmit(onSubmit)} onPress={handleSubmit(onSubmit)}
loading={register.state === "loading"} loading={register.state === "loading"}
disabled={register.state === "loading"} disabled={register.state === "loading"}

View File

@ -6,7 +6,7 @@ const HomePage: React.FC = () => {
const { user } = useAuthentication() const { user } = useAuthentication()
if (user == null) { if (user == null) {
return <Redirect href="/authentication/login" /> return <Redirect href="/authentication/about" />
} }
return <Redirect href="/application/habits/" /> return <Redirect href="/application/habits/" />

BIN
docs/screenshots/habits.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

@ -27,7 +27,7 @@ export const GoalCreateSchema = z.object({
z.object({ type: z.literal("boolean") }), z.object({ type: z.literal("boolean") }),
z.object({ z.object({
type: z.literal("numeric"), type: z.literal("numeric"),
value: z.number().int().min(0), value: z.number().int().min(1),
unit: z.string().min(1), unit: z.string().min(1),
}), }),
]), ]),

View File

@ -8,6 +8,7 @@ export const HabitSchema = EntitySchema.extend({
name: z.string().min(1).max(50), name: z.string().min(1).max(50),
color: z.string().min(4).max(9).regex(/^#/), color: z.string().min(4).max(9).regex(/^#/),
icon: z.string().min(1), icon: z.string().min(1),
endDate: z.date().optional(),
}) })
export const HabitCreateSchema = HabitSchema.extend({ export const HabitCreateSchema = HabitSchema.extend({
@ -29,7 +30,6 @@ export interface HabitData extends HabitBase {
export interface HabitJSON extends HabitBase { export interface HabitJSON extends HabitBase {
goal: GoalBaseJSON goal: GoalBaseJSON
startDate: string startDate: string
endDate?: string
} }
export class Habit extends Entity implements HabitData { export class Habit extends Entity implements HabitData {
@ -62,7 +62,7 @@ export class Habit extends Entity implements HabitData {
icon: this.icon, icon: this.icon,
goal: this.goal, goal: this.goal,
startDate: this.startDate.toISOString(), startDate: this.startDate.toISOString(),
endDate: this.endDate?.toISOString(), endDate: this?.endDate,
} }
} }
} }

View File

@ -1,8 +1,8 @@
import { getISODate, getWeekNumber } from "@/utils/dates" import { getISODate, getWeekNumber } from "@/utils/dates"
import type { Habit } from "./Habit"
import type { HabitProgress } from "./HabitProgress"
import type { GoalProgress } from "./Goal" import type { GoalProgress } from "./Goal"
import { GoalBooleanProgress, GoalNumericProgress } from "./Goal" import { GoalBooleanProgress, GoalNumericProgress } from "./Goal"
import type { Habit } from "./Habit"
import type { HabitProgress } from "./HabitProgress"
export interface HabitHistoryJSON { export interface HabitHistoryJSON {
habit: Habit habit: Habit

View File

@ -76,4 +76,24 @@ export class HabitsTracker implements HabitsTrackerData {
return habitHistory.habit.id === id return habitHistory.habit.id === id
}) })
} }
public getHabitsHistoriesByDate({
selectedDate,
frequency,
}: {
selectedDate: Date
frequency: GoalFrequency
}): HabitHistory[] {
return this.habitsHistory[frequency].filter((habitItem) => {
const startDate = new Date(habitItem.habit.startDate)
startDate.setHours(0, 0, 0, 0)
return (
startDate <= selectedDate &&
(habitItem.habit.endDate == null ||
(habitItem.habit.endDate != null &&
habitItem.habit.endDate >= selectedDate))
)
})
}
} }

View File

@ -0,0 +1,106 @@
import { HABIT_MOCK } from "@/tests/mocks/domain/Habit"
import { GOAL_FREQUENCIES } from "../Goal"
import { HabitsTracker } from "../HabitsTracker"
import { HabitHistory } from "../HabitHistory"
import { HABIT_PROGRESS_MOCK } from "@/tests/mocks/domain/HabitProgress"
describe("domain/entities/HabitsTracker", () => {
describe("HabitsTracker.default", () => {
for (const frequency of GOAL_FREQUENCIES) {
it(`should return empty habitsHistory for ${frequency}`, () => {
const habitsTracker = HabitsTracker.default()
expect(habitsTracker.habitsHistory[frequency]).toEqual([])
})
}
})
describe("getAllHabitsHistory", () => {
it("should return all habits history", () => {
const habitsTracker = HabitsTracker.default()
const habit = HABIT_MOCK.examplesByNames.Walk
habitsTracker.addHabit(habit)
expect(habitsTracker.getAllHabitsHistory()).toEqual([
new HabitHistory({
habit,
progressHistory: [],
}),
])
})
it("should return empty array when no habits are added", () => {
const habitsTracker = HabitsTracker.default()
expect(habitsTracker.getAllHabitsHistory()).toEqual([])
})
})
describe("getHabitHistoryById", () => {
it("should return habit history by id", () => {
const habitsTracker = HabitsTracker.default()
const habit = HABIT_MOCK.examplesByNames.Walk
habitsTracker.addHabit(habit)
expect(habitsTracker.getHabitHistoryById(habit.id)).toEqual(
new HabitHistory({
habit,
progressHistory: [],
}),
)
})
it("should return undefined when habit is not found", () => {
const habitsTracker = HabitsTracker.default()
expect(habitsTracker.getHabitHistoryById("invalid-id")).toBeUndefined()
})
})
describe("addHabit", () => {
it("should add habit to habitsHistory", () => {
const habitsTracker = HabitsTracker.default()
const habit = HABIT_MOCK.examplesByNames.Walk
habitsTracker.addHabit(habit)
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([
new HabitHistory({
habit,
progressHistory: [],
}),
])
})
})
describe("editHabit", () => {
it("should edit habit in habitsHistory", () => {
const habitsTracker = HabitsTracker.default()
const habit = HABIT_MOCK.examplesByNames.Walk
habitsTracker.addHabit(habit)
habit.name = "Run"
habitsTracker.editHabit(habit)
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([
new HabitHistory({
habit,
progressHistory: [],
}),
])
})
it("should not edit habit in habitsHistory when habit is not found", () => {
const habitsTracker = HabitsTracker.default()
const habit = HABIT_MOCK.examplesByNames.Walk
habitsTracker.editHabit(habit)
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([])
})
})
describe("updateHabitProgress", () => {
it("should update habit progress in habitsHistory (add new habit progress if not yet added)", () => {
const habitsTracker = HabitsTracker.default()
const habit = HABIT_MOCK.examplesByNames["Clean the house"]
habitsTracker.addHabit(habit)
habitsTracker.updateHabitProgress(HABIT_PROGRESS_MOCK.exampleByIds[1])
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([
new HabitHistory({
habit,
progressHistory: [HABIT_PROGRESS_MOCK.exampleByIds[1]],
}),
])
})
})
})

View File

@ -0,0 +1,24 @@
import type { Habit } from "../entities/Habit"
import type { HabitEditRepository } from "../repositories/HabitEdit"
export interface HabitStopUseCaseDependencyOptions {
habitEditRepository: HabitEditRepository
}
export class HabitStopUseCase implements HabitStopUseCaseDependencyOptions {
public habitEditRepository: HabitEditRepository
public constructor(options: HabitStopUseCaseDependencyOptions) {
this.habitEditRepository = options.habitEditRepository
}
public async execute(habitToStop: Habit): Promise<Habit> {
const habit = await this.habitEditRepository.execute({
habitEditData: {
...habitToStop,
endDate: new Date(),
},
})
return habit
}
}

15
eas.json Normal file
View File

@ -0,0 +1,15 @@
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"staging": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {}
}
}

View File

@ -1,18 +1,19 @@
import { AuthenticationUseCase } from "@/domain/use-cases/Authentication" import { AuthenticationUseCase } from "@/domain/use-cases/Authentication"
import { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
import { HabitEditUseCase } from "@/domain/use-cases/HabitEdit"
import { HabitGoalProgressUpdateUseCase } from "@/domain/use-cases/HabitGoalProgressUpdate"
import { HabitStopUseCase } from "@/domain/use-cases/HabitStop"
import { AuthenticationPresenter } from "@/presentation/presenters/Authentication"
import { RetrieveHabitsTrackerUseCase } from "../domain/use-cases/RetrieveHabitsTracker" import { RetrieveHabitsTrackerUseCase } from "../domain/use-cases/RetrieveHabitsTracker"
import { HabitsTrackerPresenter } from "../presentation/presenters/HabitsTracker" import { HabitsTrackerPresenter } from "../presentation/presenters/HabitsTracker"
import { AuthenticationSupabaseRepository } from "./supabase/repositories/Authentication" import { AuthenticationSupabaseRepository } from "./supabase/repositories/Authentication"
import { GetHabitProgressHistorySupabaseRepository } from "./supabase/repositories/GetHabitProgressHistory" import { GetHabitProgressHistorySupabaseRepository } from "./supabase/repositories/GetHabitProgressHistory"
import { GetHabitsByUserIdSupabaseRepository } from "./supabase/repositories/GetHabitsByUserId" import { GetHabitsByUserIdSupabaseRepository } from "./supabase/repositories/GetHabitsByUserId"
import { supabaseClient } from "./supabase/supabase"
import { AuthenticationPresenter } from "@/presentation/presenters/Authentication"
import { HabitCreateSupabaseRepository } from "./supabase/repositories/HabitCreate" import { HabitCreateSupabaseRepository } from "./supabase/repositories/HabitCreate"
import { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
import { HabitEditSupabaseRepository } from "./supabase/repositories/HabitEdit" import { HabitEditSupabaseRepository } from "./supabase/repositories/HabitEdit"
import { HabitEditUseCase } from "@/domain/use-cases/HabitEdit"
import { HabitProgressCreateSupabaseRepository } from "./supabase/repositories/HabitProgressCreate" import { HabitProgressCreateSupabaseRepository } from "./supabase/repositories/HabitProgressCreate"
import { HabitProgressUpdateSupabaseRepository } from "./supabase/repositories/HabitProgressUpdate" import { HabitProgressUpdateSupabaseRepository } from "./supabase/repositories/HabitProgressUpdate"
import { HabitGoalProgressUpdateUseCase } from "@/domain/use-cases/HabitGoalProgressUpdate" import { supabaseClient } from "./supabase/supabase"
/** /**
* Repositories * Repositories
@ -64,6 +65,9 @@ const habitGoalProgressUpdateUseCase = new HabitGoalProgressUpdateUseCase({
habitProgressCreateRepository, habitProgressCreateRepository,
habitProgressUpdateRepository, habitProgressUpdateRepository,
}) })
const habitStopUseCase = new HabitStopUseCase({
habitEditRepository,
})
/** /**
* Presenters * Presenters
@ -75,5 +79,6 @@ export const habitsTrackerPresenter = new HabitsTrackerPresenter({
retrieveHabitsTrackerUseCase, retrieveHabitsTrackerUseCase,
habitCreateUseCase, habitCreateUseCase,
habitEditUseCase, habitEditUseCase,
habitStopUseCase,
habitGoalProgressUpdateUseCase, habitGoalProgressUpdateUseCase,
}) })

View File

@ -0,0 +1,79 @@
import type { Goal } from "@/domain/entities/Goal"
import { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
import type { HabitCreateData, HabitEditData } from "@/domain/entities/Habit"
import { Habit } from "@/domain/entities/Habit"
import type {
SupabaseHabit,
SupabaseHabitInsert,
SupabaseHabitUpdate,
} from "../supabase"
export const habitSupabaseDTO = {
fromSupabaseToDomain: (supabaseHabit: SupabaseHabit): Habit => {
let goal: Goal
if (
supabaseHabit.goal_target != null &&
supabaseHabit.goal_target_unit != null
) {
goal = new GoalNumeric({
frequency: supabaseHabit.goal_frequency,
target: {
value: supabaseHabit.goal_target,
unit: supabaseHabit.goal_target_unit,
},
})
} else {
goal = new GoalBoolean({
frequency: supabaseHabit.goal_frequency,
})
}
const habit = new Habit({
id: supabaseHabit.id.toString(),
name: supabaseHabit.name,
color: supabaseHabit.color,
icon: supabaseHabit.icon,
userId: supabaseHabit.user_id.toString(),
startDate: new Date(supabaseHabit.start_date),
endDate:
supabaseHabit.end_date != null
? new Date(supabaseHabit.end_date)
: undefined,
goal,
})
return habit
},
fromDomainCreateDataToSupabaseInsert: (
habitCreateData: HabitCreateData,
): SupabaseHabitInsert => {
return {
name: habitCreateData.name,
color: habitCreateData.color,
icon: habitCreateData.icon,
goal_frequency: habitCreateData.goal.frequency,
...(habitCreateData.goal.target.type === "numeric"
? {
goal_target: habitCreateData.goal.target.value,
goal_target_unit: habitCreateData.goal.target.unit,
}
: {}),
}
},
fromDomainEditDataToSupabaseUpdate: (
habitEditData: HabitEditData,
): SupabaseHabitUpdate => {
return {
name: habitEditData.name,
color: habitEditData.color,
icon: habitEditData.icon,
end_date: habitEditData?.endDate?.toISOString(),
}
},
}
export const habitsSupabaseDTO = {
fromSupabaseToDomain: (supabaseHabits: SupabaseHabit[]): Habit[] => {
return supabaseHabits.map((supabaseHabit) => {
return habitSupabaseDTO.fromSupabaseToDomain(supabaseHabit)
})
},
}

View File

@ -0,0 +1,78 @@
import type { Goal, GoalProgress } from "@/domain/entities/Goal"
import {
GoalBooleanProgress,
GoalNumericProgress,
} from "@/domain/entities/Goal"
import { HabitProgress } from "@/domain/entities/HabitProgress"
import type { HabitProgressCreateOptions } from "@/domain/repositories/HabitProgressCreate"
import type { HabitProgressUpdateOptions } from "@/domain/repositories/HabitProgressUpdate"
import type {
SupabaseHabitProgress,
SupabaseHabitProgressInsert,
SupabaseHabitProgressUpdate,
} from "../supabase"
export const habitProgressSupabaseDTO = {
fromSupabaseToDomain: (
supabaseHabitProgress: SupabaseHabitProgress,
goal: Goal,
): HabitProgress => {
let goalProgress: GoalProgress | null = null
if (goal.isNumeric()) {
goalProgress = new GoalNumericProgress({
goal,
progress: supabaseHabitProgress.goal_progress,
})
} else if (goal.isBoolean()) {
goalProgress = new GoalBooleanProgress({
goal,
progress: supabaseHabitProgress.goal_progress === 1,
})
}
const habitProgress = new HabitProgress({
id: supabaseHabitProgress.id.toString(),
habitId: supabaseHabitProgress.habit_id.toString(),
goalProgress: goalProgress as GoalProgress,
date: new Date(supabaseHabitProgress.date),
})
return habitProgress
},
fromDomainDataToSupabaseInsert: (
habitProgressData: HabitProgressCreateOptions["habitProgressData"],
): SupabaseHabitProgressInsert => {
const { goalProgress, date, habitId } = habitProgressData
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
if (goalProgress.isNumeric()) {
goalProgressValue = goalProgress.progress
}
return {
habit_id: Number.parseInt(habitId, 10),
date: date.toISOString(),
goal_progress: goalProgressValue,
}
},
fromDomainDataToSupabaseUpdate: (
habitProgressData: HabitProgressUpdateOptions["habitProgressData"],
): SupabaseHabitProgressUpdate => {
const { goalProgress, date } = habitProgressData
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
if (goalProgress.isNumeric()) {
goalProgressValue = goalProgress.progress
}
return {
date: date.toISOString(),
goal_progress: goalProgressValue,
}
},
}
export const habitProgressHistorySupabaseDTO = {
fromSupabaseToDomain: (
supabaseHabitHistory: SupabaseHabitProgress[],
goal: Goal,
): HabitProgress[] => {
return supabaseHabitHistory.map((item) => {
return habitProgressSupabaseDTO.fromSupabaseToDomain(item, goal)
})
},
}

View File

@ -0,0 +1,100 @@
import type { GoalCreateData } from "@/domain/entities/Goal"
import { HABIT_MOCK } from "@/tests/mocks/domain/Habit"
import { SUPABASE_HABIT_MOCK } from "@/tests/mocks/supabase/Habit"
import { habitSupabaseDTO, habitsSupabaseDTO } from "../HabitDTO"
describe("infrastructure/supabase/data-transfer-objects/HabitDTO", () => {
describe("habitSupabaseDTO.fromSupabaseToDomain", () => {
for (const example of SUPABASE_HABIT_MOCK.examples) {
it(`should return correct Habit entity - ${example.name}`, () => {
expect(habitSupabaseDTO.fromSupabaseToDomain(example)).toEqual(
HABIT_MOCK.examplesByNames[
example.name as keyof typeof HABIT_MOCK.examplesByNames
],
)
})
}
})
describe("habitSupabaseDTO.fromDomainCreateDataToSupabaseInsert", () => {
for (const example of HABIT_MOCK.examples) {
it(`should return correct SupabaseHabitInsert entity - ${example.name}`, () => {
let goalData = {} as GoalCreateData
if (example.goal.isBoolean()) {
goalData = {
frequency: example.goal.frequency,
target: { type: "boolean" },
}
}
if (example.goal.isNumeric()) {
goalData = {
frequency: example.goal.frequency,
target: {
type: "numeric",
value: example.goal.target.value,
unit: example.goal.target.unit,
},
}
}
const supabaseData =
SUPABASE_HABIT_MOCK.examplesByNames[
example.name as keyof typeof SUPABASE_HABIT_MOCK.examplesByNames
]
expect(
habitSupabaseDTO.fromDomainCreateDataToSupabaseInsert({
userId: example.userId,
name: example.name,
color: example.color,
icon: example.icon,
goal: goalData,
}),
).toEqual({
name: supabaseData.name,
color: supabaseData.color,
icon: supabaseData.icon,
goal_frequency: supabaseData.goal_frequency,
...(supabaseData.goal_target != null &&
supabaseData.goal_target_unit != null
? {
goal_target: supabaseData.goal_target,
goal_target_unit: supabaseData.goal_target_unit,
}
: {}),
})
})
}
})
describe("habitSupabaseDTO.fromDomainEditDataToSupabaseUpdate", () => {
for (const example of HABIT_MOCK.examples) {
it(`should return correct SupabaseHabitUpdate entity - ${example.name}`, () => {
const supabaseData =
SUPABASE_HABIT_MOCK.examplesByNames[
example.name as keyof typeof SUPABASE_HABIT_MOCK.examplesByNames
]
expect(
habitSupabaseDTO.fromDomainEditDataToSupabaseUpdate({
name: example.name,
color: example.color,
icon: example.icon,
id: example.id,
userId: example.userId,
}),
).toEqual({
name: supabaseData.name,
color: supabaseData.color,
icon: supabaseData.icon,
})
})
}
})
describe("habitsSupabaseDTO.fromSupabaseToDomain", () => {
it("should return correct Habits entities", () => {
expect(
habitsSupabaseDTO.fromSupabaseToDomain(SUPABASE_HABIT_MOCK.examples),
).toEqual(HABIT_MOCK.examples)
})
})
})

View File

@ -0,0 +1,22 @@
import type { Habit } from "@/domain/entities/Habit"
import { HABIT_MOCK } from "@/tests/mocks/domain/Habit"
import { HABIT_PROGRESS_MOCK } from "@/tests/mocks/domain/HabitProgress"
import { SUPABASE_HABIT_PROGRESS_MOCK } from "@/tests/mocks/supabase/HabitProgress"
import { habitProgressSupabaseDTO } from "../HabitProgressDTO"
describe("infrastructure/supabase/data-transfer-objects/HabitProgressDTO", () => {
describe("habitProgressSupabaseDTO.fromSupabaseToDomain", () => {
for (const example of SUPABASE_HABIT_PROGRESS_MOCK.examples) {
it(`should return correct HabitProgress entity - ${example.id}`, () => {
const habit = HABIT_MOCK.examplesByIds[example.habit_id] as Habit
expect(
habitProgressSupabaseDTO.fromSupabaseToDomain(example, habit.goal),
).toEqual(
HABIT_PROGRESS_MOCK.exampleByIds[
example.id as keyof typeof HABIT_PROGRESS_MOCK.exampleByIds
],
)
})
}
})
})

View File

@ -1,8 +1,8 @@
import type { Session } from "@supabase/supabase-js" import type { Session } from "@supabase/supabase-js"
import type { AuthenticationRepository } from "@/domain/repositories/Authentication"
import { SupabaseRepository } from "./_SupabaseRepository"
import { User } from "@/domain/entities/User" import { User } from "@/domain/entities/User"
import type { AuthenticationRepository } from "@/domain/repositories/Authentication"
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
export class AuthenticationSupabaseRepository export class AuthenticationSupabaseRepository
extends SupabaseRepository extends SupabaseRepository

View File

@ -1,11 +1,6 @@
import type { GetHabitProgressHistoryRepository } from "@/domain/repositories/GetHabitProgressHistory" import type { GetHabitProgressHistoryRepository } from "@/domain/repositories/GetHabitProgressHistory"
import { SupabaseRepository } from "./_SupabaseRepository" import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
import { HabitProgress } from "@/domain/entities/HabitProgress" import { habitProgressHistorySupabaseDTO } from "../data-transfer-objects/HabitProgressDTO"
import type { GoalProgress } from "@/domain/entities/Goal"
import {
GoalBooleanProgress,
GoalNumericProgress,
} from "@/domain/entities/Goal"
export class GetHabitProgressHistorySupabaseRepository export class GetHabitProgressHistorySupabaseRepository
extends SupabaseRepository extends SupabaseRepository
@ -15,37 +10,15 @@ export class GetHabitProgressHistorySupabaseRepository
options, options,
) => { ) => {
const { habit } = options const { habit } = options
const { data, error } = await this.supabaseClient const { data } = await this.supabaseClient
.from("habits_progresses") .from("habits_progresses")
.select("*") .select("*")
.eq("habit_id", habit.id) .eq("habit_id", habit.id)
if (error != null) { .throwOnError()
throw new Error(error.message) const habitProgressHistory = data as NonNullable<typeof data>
} return habitProgressHistorySupabaseDTO.fromSupabaseToDomain(
const habitProgressHistory = data.map((item) => { habitProgressHistory,
let goalProgress: GoalProgress | null = null habit.goal,
if (habit.goal.isNumeric()) { )
goalProgress = new GoalNumericProgress({
goal: habit.goal,
progress: item.goal_progress,
})
} else if (habit.goal.isBoolean()) {
goalProgress = new GoalBooleanProgress({
goal: habit.goal,
progress: item.goal_progress === 1,
})
}
if (goalProgress == null) {
throw new Error("Goal progress is null.")
}
const habitProgress = new HabitProgress({
id: item.id.toString(),
habitId: item.habit_id.toString(),
goalProgress,
date: new Date(item.date),
})
return habitProgress
})
return habitProgressHistory
} }
} }

View File

@ -1,8 +1,6 @@
import type { GetHabitsByUserIdRepository } from "@/domain/repositories/GetHabitsByUserId" import type { GetHabitsByUserIdRepository } from "@/domain/repositories/GetHabitsByUserId"
import { SupabaseRepository } from "./_SupabaseRepository" import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
import { Habit } from "@/domain/entities/Habit" import { habitsSupabaseDTO } from "../data-transfer-objects/HabitDTO"
import type { Goal } from "@/domain/entities/Goal"
import { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
export class GetHabitsByUserIdSupabaseRepository export class GetHabitsByUserIdSupabaseRepository
extends SupabaseRepository extends SupabaseRepository
@ -10,39 +8,12 @@ export class GetHabitsByUserIdSupabaseRepository
{ {
public execute: GetHabitsByUserIdRepository["execute"] = async (options) => { public execute: GetHabitsByUserIdRepository["execute"] = async (options) => {
const { userId } = options const { userId } = options
const { data, error } = await this.supabaseClient const { data } = await this.supabaseClient
.from("habits") .from("habits")
.select("*") .select("*")
.eq("user_id", userId) .eq("user_id", userId)
if (error != null) { .throwOnError()
throw new Error(error.message) const habits = data as NonNullable<typeof data>
} return habitsSupabaseDTO.fromSupabaseToDomain(habits)
return data.map((item) => {
let goal: Goal
if (item.goal_target != null && item.goal_target_unit != null) {
goal = new GoalNumeric({
frequency: item.goal_frequency,
target: {
value: item.goal_target,
unit: item.goal_target_unit,
},
})
} else {
goal = new GoalBoolean({
frequency: item.goal_frequency,
})
}
const habit = new Habit({
id: item.id.toString(),
name: item.name,
color: item.color,
icon: item.icon,
userId: item.user_id.toString(),
startDate: new Date(item.start_date),
endDate: item.end_date != null ? new Date(item.end_date) : undefined,
goal,
})
return habit
})
} }
} }

View File

@ -1,7 +1,6 @@
import { Habit } from "@/domain/entities/Habit"
import type { HabitCreateRepository } from "@/domain/repositories/HabitCreate" import type { HabitCreateRepository } from "@/domain/repositories/HabitCreate"
import { SupabaseRepository } from "./_SupabaseRepository" import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
import { Goal } from "@/domain/entities/Goal" import { habitSupabaseDTO } from "../data-transfer-objects/HabitDTO"
export class HabitCreateSupabaseRepository export class HabitCreateSupabaseRepository
extends SupabaseRepository extends SupabaseRepository
@ -9,34 +8,15 @@ export class HabitCreateSupabaseRepository
{ {
public execute: HabitCreateRepository["execute"] = async (options) => { public execute: HabitCreateRepository["execute"] = async (options) => {
const { habitCreateData } = options const { habitCreateData } = options
const { data, error } = await this.supabaseClient const { data } = await this.supabaseClient
.from("habits") .from("habits")
.insert({ .insert(
name: habitCreateData.name, habitSupabaseDTO.fromDomainCreateDataToSupabaseInsert(habitCreateData),
color: habitCreateData.color, )
icon: habitCreateData.icon,
goal_frequency: habitCreateData.goal.frequency,
...(habitCreateData.goal.target.type === "numeric"
? {
goal_target: habitCreateData.goal.target.value,
goal_target_unit: habitCreateData.goal.target.unit,
}
: {}),
})
.select("*") .select("*")
const insertedHabit = data?.[0] .single()
if (error != null || insertedHabit == null) { .throwOnError()
throw new Error(error?.message ?? "Failed to create habit.") const insertedHabit = data as NonNullable<typeof data>
} return habitSupabaseDTO.fromSupabaseToDomain(insertedHabit)
const habit = new Habit({
id: insertedHabit.id.toString(),
userId: insertedHabit.user_id.toString(),
name: insertedHabit.name,
icon: insertedHabit.icon,
goal: Goal.create(habitCreateData.goal),
color: insertedHabit.color,
startDate: new Date(insertedHabit.start_date),
})
return habit
} }
} }

View File

@ -1,7 +1,6 @@
import { Habit } from "@/domain/entities/Habit"
import type { HabitEditRepository } from "@/domain/repositories/HabitEdit" import type { HabitEditRepository } from "@/domain/repositories/HabitEdit"
import { SupabaseRepository } from "./_SupabaseRepository" import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
import { Goal } from "@/domain/entities/Goal" import { habitSupabaseDTO } from "../data-transfer-objects/HabitDTO"
export class HabitEditSupabaseRepository export class HabitEditSupabaseRepository
extends SupabaseRepository extends SupabaseRepository
@ -9,41 +8,16 @@ export class HabitEditSupabaseRepository
{ {
public execute: HabitEditRepository["execute"] = async (options) => { public execute: HabitEditRepository["execute"] = async (options) => {
const { habitEditData } = options const { habitEditData } = options
const { data, error } = await this.supabaseClient const { data } = await this.supabaseClient
.from("habits") .from("habits")
.update({ .update(
name: habitEditData.name, habitSupabaseDTO.fromDomainEditDataToSupabaseUpdate(habitEditData),
color: habitEditData.color, )
icon: habitEditData.icon,
})
.eq("id", habitEditData.id) .eq("id", habitEditData.id)
.select("*") .select("*")
const updatedHabit = data?.[0] .single()
if (error != null || updatedHabit == null) { .throwOnError()
throw new Error(error?.message ?? "Failed to edit habit.") const updatedHabit = data as NonNullable<typeof data>
} return habitSupabaseDTO.fromSupabaseToDomain(updatedHabit)
const habit = new Habit({
id: updatedHabit.id.toString(),
userId: updatedHabit.user_id.toString(),
name: updatedHabit.name,
icon: updatedHabit.icon,
goal: Goal.create({
frequency: updatedHabit.goal_frequency,
target:
updatedHabit.goal_target != null &&
updatedHabit.goal_target_unit != null
? {
type: "numeric",
value: updatedHabit.goal_target,
unit: updatedHabit.goal_target_unit,
}
: {
type: "boolean",
},
}),
color: updatedHabit.color,
startDate: new Date(updatedHabit.start_date),
})
return habit
} }
} }

View File

@ -1,6 +1,6 @@
import type { HabitProgressCreateRepository } from "@/domain/repositories/HabitProgressCreate" import type { HabitProgressCreateRepository } from "@/domain/repositories/HabitProgressCreate"
import { SupabaseRepository } from "./_SupabaseRepository" import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
import { HabitProgress } from "@/domain/entities/HabitProgress" import { habitProgressSupabaseDTO } from "../data-transfer-objects/HabitProgressDTO"
export class HabitProgressCreateSupabaseRepository export class HabitProgressCreateSupabaseRepository
extends SupabaseRepository extends SupabaseRepository
@ -10,29 +10,20 @@ export class HabitProgressCreateSupabaseRepository
options, options,
) => { ) => {
const { habitProgressData } = options const { habitProgressData } = options
const { goalProgress, date, habitId } = habitProgressData const { data } = await this.supabaseClient
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
if (goalProgress.isNumeric()) {
goalProgressValue = goalProgress.progress
}
const { data, error } = await this.supabaseClient
.from("habits_progresses") .from("habits_progresses")
.insert({ .insert(
habit_id: Number(habitId), habitProgressSupabaseDTO.fromDomainDataToSupabaseInsert(
date: date.toISOString(), habitProgressData,
goal_progress: goalProgressValue, ),
}) )
.select("*") .select("*")
const insertedProgress = data?.[0] .single()
if (error != null || insertedProgress == null) { .throwOnError()
throw new Error(error?.message ?? "Failed to create habit progress.") const insertedProgress = data as NonNullable<typeof data>
} return habitProgressSupabaseDTO.fromSupabaseToDomain(
const habitProgress = new HabitProgress({ insertedProgress,
id: insertedProgress.id.toString(), habitProgressData.goalProgress.goal,
habitId: insertedProgress.habit_id.toString(), )
date: new Date(insertedProgress.date),
goalProgress,
})
return habitProgress
} }
} }

View File

@ -1,6 +1,6 @@
import type { HabitProgressUpdateRepository } from "@/domain/repositories/HabitProgressUpdate" import type { HabitProgressUpdateRepository } from "@/domain/repositories/HabitProgressUpdate"
import { SupabaseRepository } from "./_SupabaseRepository" import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
import { HabitProgress } from "@/domain/entities/HabitProgress" import { habitProgressSupabaseDTO } from "../data-transfer-objects/HabitProgressDTO"
export class HabitProgressUpdateSupabaseRepository export class HabitProgressUpdateSupabaseRepository
extends SupabaseRepository extends SupabaseRepository
@ -10,29 +10,21 @@ export class HabitProgressUpdateSupabaseRepository
options, options,
) => { ) => {
const { habitProgressData } = options const { habitProgressData } = options
const { id, goalProgress, date } = habitProgressData const { data } = await this.supabaseClient
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
if (goalProgress.isNumeric()) {
goalProgressValue = goalProgress.progress
}
const { data, error } = await this.supabaseClient
.from("habits_progresses") .from("habits_progresses")
.update({ .update(
date: date.toISOString(), habitProgressSupabaseDTO.fromDomainDataToSupabaseUpdate(
goal_progress: goalProgressValue, habitProgressData,
}) ),
.eq("id", id) )
.eq("id", habitProgressData.id)
.select("*") .select("*")
const insertedProgress = data?.[0] .single()
if (error != null || insertedProgress == null) { .throwOnError()
throw new Error(error?.message ?? "Failed to update habit progress.") const insertedProgress = data as NonNullable<typeof data>
} return habitProgressSupabaseDTO.fromSupabaseToDomain(
const habitProgress = new HabitProgress({ insertedProgress,
id: insertedProgress.id.toString(), habitProgressData.goalProgress.goal,
habitId: insertedProgress.habit_id.toString(), )
date: new Date(insertedProgress.date),
goalProgress,
})
return habitProgress
} }
} }

View File

@ -117,7 +117,7 @@ VALUES
'Wake up at 07h00', 'Wake up at 07h00',
'#006CFF', '#006CFF',
'bed', 'bed',
timezone('utc' :: text, NOW()), timezone('utc' :: text, NOW() - INTERVAL '3 days'),
NULL, NULL,
'daily', 'daily',
NULL, NULL,
@ -144,7 +144,7 @@ VALUES
'Learn English', 'Learn English',
'#EB4034', '#EB4034',
'language', 'language',
timezone('utc' :: text, NOW()), timezone('utc' :: text, NOW() - INTERVAL '3 days'),
NULL, NULL,
'daily', 'daily',
30, 30,
@ -171,7 +171,7 @@ VALUES
'Walk', 'Walk',
'#228B22', '#228B22',
'person-walking', 'person-walking',
timezone('utc' :: text, NOW()), timezone('utc' :: text, NOW() - INTERVAL '3 days'),
NULL, NULL,
'daily', 'daily',
5000, 5000,
@ -198,7 +198,7 @@ VALUES
'Clean the house', 'Clean the house',
'#808080', '#808080',
'broom', 'broom',
timezone('utc' :: text, NOW()), timezone('utc' :: text, NOW() - INTERVAL '3 days'),
NULL, NULL,
'weekly', 'weekly',
NULL, NULL,
@ -225,7 +225,7 @@ VALUES
'Solve Programming Challenges', 'Solve Programming Challenges',
'#DE3163', '#DE3163',
'code', 'code',
timezone('utc' :: text, NOW()), timezone('utc' :: text, NOW() - INTERVAL '3 days'),
NULL, NULL,
'monthly', 'monthly',
5, 5,
@ -263,5 +263,5 @@ VALUES
4733 4733
); );
-- SELECT setval('habits_id_seq', (SELECT coalesce(MAX(id) + 1, 1) FROM habits), false); SELECT setval('habits_id_seq', (SELECT coalesce(MAX(id) + 1, 1) FROM habits), false);
-- SELECT setval('habits_progresses_id_seq', (SELECT coalesce(MAX(id) + 1, 1) FROM habits_progresses), false); SELECT setval('habits_progresses_id_seq', (SELECT coalesce(MAX(id) + 1, 1) FROM habits_progresses), false);

View File

@ -1,10 +1,28 @@
import { createClient } from "@supabase/supabase-js" import {
createClient,
type User as SupabaseUserType,
} from "@supabase/supabase-js"
import { AppState, Platform } from "react-native" import { AppState, Platform } from "react-native"
import "react-native-url-polyfill/auto" import "react-native-url-polyfill/auto"
import AsyncStorage from "@react-native-async-storage/async-storage" import AsyncStorage from "@react-native-async-storage/async-storage"
import type { Database } from "./supabase-types" import type { Database } from "./supabase-types"
export type SupabaseUser = SupabaseUserType
export type SupabaseHabit = Database["public"]["Tables"]["habits"]["Row"]
export type SupabaseHabitInsert =
Database["public"]["Tables"]["habits"]["Insert"]
export type SupabaseHabitUpdate =
Database["public"]["Tables"]["habits"]["Update"]
export type SupabaseHabitProgress =
Database["public"]["Tables"]["habits_progresses"]["Row"]
export type SupabaseHabitProgressInsert =
Database["public"]["Tables"]["habits_progresses"]["Insert"]
export type SupabaseHabitProgressUpdate =
Database["public"]["Tables"]["habits_progresses"]["Update"]
const SUPABASE_URL = const SUPABASE_URL =
process.env["EXPO_PUBLIC_SUPABASE_URL"] ?? process.env["EXPO_PUBLIC_SUPABASE_URL"] ??
"https://wjtwtzxreersqfvfgxrz.supabase.co" "https://wjtwtzxreersqfvfgxrz.supabase.co"

View File

@ -1,7 +1,7 @@
{ {
"preset": "jest-expo", "preset": "jest-expo",
"roots": ["./"], "roots": ["./"],
"setupFilesAfterEnv": ["@testing-library/react-native/extend-expect"], "setupFilesAfterEnv": ["<rootDir>/tests/setup.ts"],
"fakeTimers": { "fakeTimers": {
"enableGlobally": true "enableGlobally": true
}, },
@ -10,7 +10,13 @@
"coverageReporters": ["text", "text-summary", "cobertura"], "coverageReporters": ["text", "text-summary", "cobertura"],
"collectCoverageFrom": [ "collectCoverageFrom": [
"<rootDir>/**/*.{ts,tsx}", "<rootDir>/**/*.{ts,tsx}",
"!<rootDir>/presentation/react/components/ExternalLink.tsx", "!<rootDir>/tests/**/*",
"!<rootDir>/domain/repositories/**/*",
"!<rootDir>/infrastructure/instances.ts",
"!<rootDir>/infrastructure/supabase/supabase-types.ts",
"!<rootDir>/infrastructure/supabase/supabase.ts",
"!<rootDir>/presentation/react-native/ui/ExternalLink.tsx",
"!<rootDir>/presentation/react/contexts/**/*",
"!<rootDir>/.expo", "!<rootDir>/.expo",
"!<rootDir>/app/+html.tsx", "!<rootDir>/app/+html.tsx",
"!<rootDir>/app/**/_layout.tsx", "!<rootDir>/app/**/_layout.tsx",

7199
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,86 +2,90 @@
"name": "p61-project", "name": "p61-project",
"private": true, "private": true,
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.0.0-staging.2", "version": "1.0.0-staging.4",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"android": "expo start --android", "android": "expo start --android",
"ios": "expo start --ios", "ios": "expo start --ios",
"web": "expo start --web", "web": "expo start --web",
"expo:typed-routes": "expo customize tsconfig.json", "expo:typed-routes": "expo customize tsconfig.json",
"build-staging:android": "eas build --platform=android --profile=staging",
"lint:commit": "commitlint", "lint:commit": "commitlint",
"lint:prettier": "prettier . --check", "lint:prettier": "prettier . --check",
"lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore", "lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore",
"lint:typescript": "tsc --noEmit", "lint:typescript": "tsc --noEmit",
"lint:staged": "lint-staged", "lint:staged": "lint-staged",
"test": "jest --reporters=default --reporters=jest-junit", "test": "jest --reporters=default --reporters=jest-junit",
"supabase": "supabase --workdir \"./infrastructure\"", "supabase-cli": "supabase --workdir \"./infrastructure\"",
"postinstall": "husky" "postinstall": "husky"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "14.0.0", "@expo/vector-icons": "14.0.2",
"@hookform/resolvers": "3.3.4", "@fortawesome/fontawesome-svg-core": "6.5.2",
"@react-native-async-storage/async-storage": "1.21.0", "@fortawesome/free-solid-svg-icons": "6.5.2",
"@react-navigation/native": "6.1.16", "@fortawesome/react-native-fontawesome": "0.3.1",
"@supabase/supabase-js": "2.42.1", "@hookform/resolvers": "3.4.0",
"expo": "50.0.15", "@react-native-async-storage/async-storage": "1.23.1",
"expo-font": "11.10.3", "@react-navigation/native": "6.1.17",
"expo-linking": "6.2.2", "@supabase/supabase-js": "2.43.2",
"expo-router": "3.4.8", "expo": "51.0.8",
"expo-splash-screen": "0.26.4", "expo-linking": "6.3.1",
"expo-status-bar": "1.11.1", "expo-router": "3.5.14",
"expo-system-ui": "2.9.3", "expo-splash-screen": "0.27.4",
"expo-web-browser": "12.8.2", "expo-status-bar": "1.12.1",
"immer": "10.0.4", "expo-system-ui": "3.0.4",
"lottie-react-native": "6.5.1", "expo-web-browser": "13.0.3",
"immer": "10.1.1",
"lottie-react-native": "6.7.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "7.51.2", "react-hook-form": "7.51.4",
"react-native": "0.73.6", "react-native": "0.74.1",
"react-native-calendars": "1.1304.1", "react-native-calendars": "1.1305.0",
"react-native-circular-progress-indicator": "4.4.2", "react-native-circular-progress-indicator": "4.4.2",
"react-native-elements": "3.4.3", "react-native-elements": "3.4.3",
"react-native-gesture-handler": "2.14.1", "react-native-gesture-handler": "2.16.2",
"react-native-paper": "5.12.3", "react-native-paper": "5.12.3",
"react-native-reanimated": "3.6.3", "react-native-reanimated": "3.10.1",
"react-native-safe-area-context": "4.8.2", "react-native-safe-area-context": "4.10.1",
"react-native-screens": "3.29.0", "react-native-screens": "3.31.1",
"react-native-svg": "15.2.0",
"react-native-svg-transformer": "1.4.0",
"react-native-url-polyfill": "2.0.0", "react-native-url-polyfill": "2.0.0",
"react-native-vector-icons": "10.0.3", "react-native-vector-icons": "10.1.0",
"react-native-web": "0.19.10", "react-native-web": "0.19.11",
"reanimated-color-picker": "3.0.3", "reanimated-color-picker": "3.0.3",
"zod": "3.22.4" "zod": "3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.24.4", "@babel/core": "7.24.5",
"@commitlint/cli": "19.1.0", "@commitlint/cli": "19.2.2",
"@commitlint/config-conventional": "19.1.0", "@commitlint/config-conventional": "19.2.2",
"@testing-library/react-native": "12.4.5", "@testing-library/react-native": "12.5.0",
"@total-typescript/ts-reset": "0.5.1", "@total-typescript/ts-reset": "0.5.1",
"@tsconfig/strictest": "2.0.5", "@tsconfig/strictest": "2.0.5",
"@types/jest": "29.5.12", "@types/jest": "29.5.12",
"@types/node": "20.12.7", "@types/node": "20.12.12",
"@types/react": "18.2.76", "@types/react": "18.2.79",
"@types/react-test-renderer": "18.0.7", "@types/react-test-renderer": "18.3.0",
"@typescript-eslint/eslint-plugin": "7.6.0", "@typescript-eslint/eslint-plugin": "7.9.0",
"@typescript-eslint/parser": "7.6.0", "@typescript-eslint/parser": "7.9.0",
"eslint": "8.57.0", "eslint": "8.57.0",
"eslint-config-conventions": "14.1.0", "eslint-config-conventions": "14.1.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.29.1", "eslint-plugin-import": "2.29.1",
"eslint-plugin-prettier": "5.1.3",
"eslint-plugin-promise": "6.1.1", "eslint-plugin-promise": "6.1.1",
"eslint-plugin-react": "7.34.1", "eslint-plugin-react": "7.34.1",
"eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-native": "4.1.0", "eslint-plugin-react-native": "4.1.0",
"eslint-plugin-unicorn": "51.0.1", "eslint-plugin-unicorn": "51.0.1",
"husky": "9.0.11", "husky": "9.0.11",
"jest": "29.7.0", "jest": "29.7.0",
"jest-expo": "50.0.4", "jest-expo": "51.0.2",
"jest-junit": "16.0.0", "jest-junit": "16.0.0",
"lint-staged": "15.2.2", "lint-staged": "15.2.2",
"prettier": "3.2.5",
"react-test-renderer": "18.2.0", "react-test-renderer": "18.2.0",
"supabase": "1.153.4", "supabase": "1.167.4",
"typescript": "5.4.5" "typescript": "5.3.3"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -7,10 +7,15 @@ import type {
RetrieveHabitsTrackerUseCase, RetrieveHabitsTrackerUseCase,
RetrieveHabitsTrackerUseCaseOptions, RetrieveHabitsTrackerUseCaseOptions,
} from "@/domain/use-cases/RetrieveHabitsTracker" } from "@/domain/use-cases/RetrieveHabitsTracker"
import type { HabitCreateData, HabitEditData } from "@/domain/entities/Habit" import type {
Habit,
HabitCreateData,
HabitEditData,
} from "@/domain/entities/Habit"
import { getErrorsFieldsFromZodError } from "../../utils/zod" import { getErrorsFieldsFromZodError } from "../../utils/zod"
import type { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate" import type { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
import type { HabitEditUseCase } from "@/domain/use-cases/HabitEdit" import type { HabitEditUseCase } from "@/domain/use-cases/HabitEdit"
import type { HabitStopUseCase } from "@/domain/use-cases/HabitStop"
import type { import type {
HabitGoalProgressUpdateUseCase, HabitGoalProgressUpdateUseCase,
HabitGoalProgressUpdateUseCaseOptions, HabitGoalProgressUpdateUseCaseOptions,
@ -39,6 +44,10 @@ export interface HabitsTrackerPresenterState {
} }
} }
habitStop: {
state: FetchState
}
habitGoalProgressUpdate: { habitGoalProgressUpdate: {
state: FetchState state: FetchState
} }
@ -48,6 +57,7 @@ export interface HabitsTrackerPresenterOptions {
retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
habitCreateUseCase: HabitCreateUseCase habitCreateUseCase: HabitCreateUseCase
habitEditUseCase: HabitEditUseCase habitEditUseCase: HabitEditUseCase
habitStopUseCase: HabitStopUseCase
habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase
} }
@ -58,6 +68,7 @@ export class HabitsTrackerPresenter
public retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase public retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
public habitCreateUseCase: HabitCreateUseCase public habitCreateUseCase: HabitCreateUseCase
public habitEditUseCase: HabitEditUseCase public habitEditUseCase: HabitEditUseCase
public habitStopUseCase: HabitStopUseCase
public habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase public habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase
public constructor(options: HabitsTrackerPresenterOptions) { public constructor(options: HabitsTrackerPresenterOptions) {
@ -65,6 +76,7 @@ export class HabitsTrackerPresenter
retrieveHabitsTrackerUseCase, retrieveHabitsTrackerUseCase,
habitCreateUseCase, habitCreateUseCase,
habitEditUseCase, habitEditUseCase,
habitStopUseCase,
habitGoalProgressUpdateUseCase, habitGoalProgressUpdateUseCase,
} = options } = options
const habitsTracker = HabitsTracker.default() const habitsTracker = HabitsTracker.default()
@ -85,6 +97,9 @@ export class HabitsTrackerPresenter
global: null, global: null,
}, },
}, },
habitStop: {
state: "idle",
},
habitGoalProgressUpdate: { habitGoalProgressUpdate: {
state: "idle", state: "idle",
}, },
@ -92,6 +107,7 @@ export class HabitsTrackerPresenter
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
this.habitCreateUseCase = habitCreateUseCase this.habitCreateUseCase = habitCreateUseCase
this.habitEditUseCase = habitEditUseCase this.habitEditUseCase = habitEditUseCase
this.habitStopUseCase = habitStopUseCase
this.habitGoalProgressUpdateUseCase = habitGoalProgressUpdateUseCase this.habitGoalProgressUpdateUseCase = habitGoalProgressUpdateUseCase
} }
@ -153,6 +169,25 @@ export class HabitsTrackerPresenter
} }
} }
public async habitStop(habitToStop: Habit): Promise<FetchState> {
try {
this.setState((state) => {
state.habitStop.state = "loading"
})
const habit = await this.habitStopUseCase.execute(habitToStop)
this.setState((state) => {
state.habitStop.state = "success"
state.habitsTracker.editHabit(habit)
})
return "success"
} catch (error) {
this.setState((state) => {
state.habitStop.state = "error"
})
return "error"
}
}
public async retrieveHabitsTracker( public async retrieveHabitsTracker(
options: RetrieveHabitsTrackerUseCaseOptions, options: RetrieveHabitsTrackerUseCaseOptions,
): Promise<void> { ): Promise<void> {

View File

@ -41,10 +41,7 @@ export abstract class Presenter<State> {
public unsubscribe(listener: Listener<State>): void { public unsubscribe(listener: Listener<State>): void {
const listenerIndex = this._listeners.indexOf(listener) const listenerIndex = this._listeners.indexOf(listener)
const listenerFound = listenerIndex !== -1 this._listeners.splice(listenerIndex, 1)
if (listenerFound) {
this._listeners.splice(listenerIndex, 1)
}
} }
private notifyListeners(): void { private notifyListeners(): void {

View File

@ -0,0 +1,81 @@
import { View } from "react-native"
import { Text } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import { ExternalLink } from "@/presentation/react-native/ui/ExternalLink"
import { getVersion } from "@/utils/version"
export interface AboutProps {
actionButton: React.ReactNode
}
export const About: React.FC<AboutProps> = (props) => {
const { actionButton } = props
const version = getVersion()
return (
<SafeAreaView
style={{
flex: 1,
paddingHorizontal: 20,
}}
>
<View style={{ alignItems: "center", marginVertical: 20 }}>
<Text
style={{
fontWeight: "bold",
fontSize: 28,
textAlign: "center",
}}
>
Habits Tracker
</Text>
<Text
style={{
marginTop: 6,
fontWeight: "bold",
fontSize: 18,
textAlign: "center",
}}
>
To perform at work and in everyday life.
</Text>
<Text
style={{
marginTop: 6,
fontWeight: "bold",
fontSize: 16,
textAlign: "center",
}}
>
v{version}
</Text>
</View>
<Text variant="bodyLarge" style={{ textAlign: "center" }}>
<ExternalLink href="https://unistra.fr" style={{ color: "#006CFF" }}>
Université de Strasbourg
</ExternalLink>
</Text>
<Text variant="bodyLarge" style={{ textAlign: "center" }}>
BUT Informatique - IUT Robert Schuman
</Text>
<Text variant="bodyLarge" style={{ textAlign: "center" }}>
P61 Mobile Development
</Text>
<View
style={{
justifyContent: "center",
alignItems: "center",
marginVertical: 20,
}}
>
{actionButton}
</View>
</SafeAreaView>
)
}

View File

@ -1,6 +1,9 @@
import type { IconName } from "@fortawesome/free-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form" import { Controller, useForm } from "react-hook-form"
import { ScrollView, StyleSheet } from "react-native" import { ScrollView, StyleSheet, View } from "react-native"
import { import {
Button, Button,
HelperText, HelperText,
@ -15,14 +18,15 @@ import ColorPicker, {
Panel1, Panel1,
Preview, Preview,
} from "reanimated-color-picker" } from "reanimated-color-picker"
import { useState } from "react"
import type { GoalFrequency, GoalType } from "@/domain/entities/Goal" import type { GoalFrequency, GoalType } from "@/domain/entities/Goal"
import { GOAL_FREQUENCIES, GOAL_TYPES } from "@/domain/entities/Goal" import { GOAL_FREQUENCIES, GOAL_TYPES } from "@/domain/entities/Goal"
import type { HabitCreateData } from "@/domain/entities/Habit" import type { HabitCreateData } from "@/domain/entities/Habit"
import { HabitCreateSchema } from "@/domain/entities/Habit" import { HabitCreateSchema } from "@/domain/entities/Habit"
import type { User } from "@/domain/entities/User" import type { User } from "@/domain/entities/User"
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 HabitCreateFormProps { export interface HabitCreateFormProps {
user: User user: User
@ -33,9 +37,10 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
const { const {
control, control,
formState: { errors, isValid },
handleSubmit, handleSubmit,
reset, reset,
formState: { errors }, watch,
} = useForm<HabitCreateData>({ } = useForm<HabitCreateData>({
mode: "onChange", mode: "onChange",
resolver: zodResolver(HabitCreateSchema), resolver: zodResolver(HabitCreateSchema),
@ -43,7 +48,7 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
userId: user.id, userId: user.id,
name: "", name: "",
color: "#006CFF", color: "#006CFF",
icon: "lightbulb", icon: "circle-question",
goal: { goal: {
frequency: "daily", frequency: "daily",
target: { target: {
@ -53,8 +58,16 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
}, },
}) })
const watchGoalType = watch("goal.target.type")
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false) const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
const {
value: isModalIconSelectorVisible,
setTrue: openModalIconSelector,
setFalse: closeModalIconSelector,
} = useBoolean()
const onDismissSnackbar = (): void => { const onDismissSnackbar = (): void => {
setIsVisibleSnackbar(false) setIsVisibleSnackbar(false)
} }
@ -62,6 +75,7 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
const onSubmit = async (data: HabitCreateData): Promise<void> => { const onSubmit = async (data: HabitCreateData): Promise<void> => {
await habitsTrackerPresenter.habitCreate(data) await habitsTrackerPresenter.habitCreate(data)
setIsVisibleSnackbar(true) setIsVisibleSnackbar(true)
closeModalIconSelector()
reset() reset()
} }
@ -117,7 +131,7 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
style={[ style={[
styles.spacing, styles.spacing,
{ {
width: "90%", width: "96%",
}, },
]} ]}
mode="outlined" mode="outlined"
@ -142,7 +156,7 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
<> <>
<Text style={[styles.spacing]}>Habit Frequency</Text> <Text style={[styles.spacing]}>Habit Frequency</Text>
<SegmentedButtons <SegmentedButtons
style={[{ width: "90%" }]} style={[{ width: "96%" }]}
onValueChange={onChange} onValueChange={onChange}
value={value} value={value}
buttons={GOAL_FREQUENCIES.map((frequency) => { buttons={GOAL_FREQUENCIES.map((frequency) => {
@ -164,9 +178,29 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
render={({ field: { onChange, value } }) => { render={({ field: { onChange, value } }) => {
return ( return (
<> <>
<Text style={[styles.spacing]}>Habit Type</Text> <Text
style={[
styles.spacing,
{ justifyContent: "center", alignContent: "center" },
]}
>
Habit Type
{/* <Tooltip
title="Routine habits are activities performed regularly, while Target habits involve setting specific objectives to be achieved through repeated actions."
enterTouchDelay={50}
leaveTouchDelay={25}
>
<IconButton
icon="chat-question-outline"
selected
size={24}
onPress={() => {}}
style={{ alignSelf: "center" }}
/>
</Tooltip> */}
</Text>
<SegmentedButtons <SegmentedButtons
style={[{ width: "90%" }]} style={[{ width: "96%" }]}
onValueChange={onChange} onValueChange={onChange}
value={value} value={value}
buttons={GOAL_TYPES.map((type) => { buttons={GOAL_TYPES.map((type) => {
@ -183,12 +217,74 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
name="goal.target.type" name="goal.target.type"
/> />
{watchGoalType === "numeric" ? (
<View
style={{
marginTop: 10,
flexDirection: "row",
gap: 10,
width: "96%",
}}
>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<TextInput
placeholder="Target (e.g: 5 000)"
onBlur={onBlur}
onChangeText={(text) => {
if (text.length <= 0) {
onChange("")
return
}
onChange(Number.parseInt(text, 10))
}}
value={value?.toString()}
style={[
styles.spacing,
{
width: "50%",
},
]}
mode="outlined"
keyboardType="numeric"
/>
)
}}
name="goal.target.value"
/>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<TextInput
placeholder="Unit (e.g: Steps)"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={[
styles.spacing,
{
width: "50%",
},
]}
mode="outlined"
/>
)
}}
name="goal.target.unit"
/>
</View>
) : null}
<Controller <Controller
control={control} control={control}
render={({ field: { onChange, value } }) => { render={({ field: { onChange, value } }) => {
return ( return (
<ColorPicker <ColorPicker
style={[styles.spacing, { width: "90%" }]} style={[{ marginVertical: 15, width: "96%" }]}
value={value} value={value}
onComplete={(value) => { onComplete={(value) => {
onChange(value.hex) onChange(value.hex)
@ -205,16 +301,30 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
<Controller <Controller
control={control} control={control}
render={({ field: { onChange, onBlur, value } }) => { render={({ field: { onChange, value } }) => {
return ( return (
<TextInput <View
placeholder="Icon" style={{
onBlur={onBlur} justifyContent: "center",
onChangeText={onChange} alignItems: "center",
value={value} flexDirection: "row",
style={[styles.spacing, { width: "90%" }]} gap: 20,
mode="outlined" marginVertical: 5,
/> }}
>
<FontAwesomeIcon size={36} icon={value as IconName} />
<Button mode="contained" onPress={openModalIconSelector}>
Choose an icon
</Button>
<IconSelectorModal
key={isModalIconSelectorVisible ? "visible" : "hidden"}
isVisible={isModalIconSelectorVisible}
selectedIcon={value}
handleCloseModal={closeModalIconSelector}
onIconSelect={onChange}
/>
</View>
) )
}} }}
name="icon" name="icon"
@ -224,8 +334,8 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
mode="contained" mode="contained"
onPress={handleSubmit(onSubmit)} onPress={handleSubmit(onSubmit)}
loading={habitCreate.state === "loading"} loading={habitCreate.state === "loading"}
disabled={habitCreate.state === "loading"} disabled={habitCreate.state === "loading" || !isValid}
style={[styles.spacing, { width: "90%" }]} style={[{ width: "100%", marginVertical: 15 }]}
> >
Create your habit! 🚀 Create your habit! 🚀
</Button> </Button>
@ -244,6 +354,6 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
spacing: { spacing: {
marginVertical: 16, marginVertical: 10,
}, },
}) })

View File

@ -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 { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react" import { useState } from "react"
import { Controller, useForm } from "react-hook-form" import { Controller, useForm } from "react-hook-form"
import { ScrollView, StyleSheet } from "react-native" import { ScrollView, StyleSheet, View } from "react-native"
import { Button, HelperText, Snackbar, TextInput } from "react-native-paper" import {
Button,
HelperText,
Snackbar,
Text,
TextInput,
} from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context" import { SafeAreaView } from "react-native-safe-area-context"
import ColorPicker, { import ColorPicker, {
HueSlider, HueSlider,
@ -12,19 +20,21 @@ import ColorPicker, {
import type { Habit, HabitEditData } from "@/domain/entities/Habit" import type { Habit, HabitEditData } from "@/domain/entities/Habit"
import { HabitEditSchema } 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 { export interface HabitEditFormProps {
habit: Habit habit: Habit
} }
export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => { export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
const { habitEdit, habitsTrackerPresenter } = useHabitsTracker() const { habitEdit, habitStop, habitsTrackerPresenter } = useHabitsTracker()
const { const {
control, control,
formState: { errors, isValid },
handleSubmit, handleSubmit,
formState: { errors },
} = useForm<HabitEditData>({ } = useForm<HabitEditData>({
mode: "onChange", mode: "onChange",
resolver: zodResolver(HabitEditSchema), resolver: zodResolver(HabitEditSchema),
@ -37,6 +47,12 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
}, },
}) })
const {
value: isModalIconSelectorVisible,
setTrue: openModalIconSelector,
setFalse: closeModalIconSelector,
} = useBoolean()
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false) const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
const onDismissSnackbar = (): void => { const onDismissSnackbar = (): void => {
@ -70,7 +86,7 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
style={[ style={[
styles.spacing, styles.spacing,
{ {
width: "90%", width: "96%",
}, },
]} ]}
mode="outlined" mode="outlined"
@ -93,7 +109,7 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
render={({ field: { onChange, value } }) => { render={({ field: { onChange, value } }) => {
return ( return (
<ColorPicker <ColorPicker
style={[styles.spacing, { width: "90%" }]} style={[styles.spacing, { width: "96%" }]}
value={value} value={value}
onComplete={(value) => { onComplete={(value) => {
onChange(value.hex) onChange(value.hex)
@ -110,16 +126,30 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
<Controller <Controller
control={control} control={control}
render={({ field: { onChange, onBlur, value } }) => { render={({ field: { onChange, value } }) => {
return ( return (
<TextInput <View
placeholder="Icon" style={{
onBlur={onBlur} justifyContent: "center",
onChangeText={onChange} alignItems: "center",
value={value} flexDirection: "row",
style={[styles.spacing, { width: "90%" }]} gap: 20,
mode="outlined" marginVertical: 30,
/> }}
>
<FontAwesomeIcon size={36} icon={value as IconName} />
<Button mode="contained" onPress={openModalIconSelector}>
Choose an icon
</Button>
<IconSelectorModal
key={isModalIconSelectorVisible ? "visible" : "hidden"}
isVisible={isModalIconSelectorVisible}
selectedIcon={value}
handleCloseModal={closeModalIconSelector}
onIconSelect={onChange}
/>
</View>
) )
}} }}
name="icon" name="icon"
@ -129,11 +159,35 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
mode="contained" mode="contained"
onPress={handleSubmit(onSubmit)} onPress={handleSubmit(onSubmit)}
loading={habitEdit.state === "loading"} loading={habitEdit.state === "loading"}
disabled={habitEdit.state === "loading"} disabled={habitEdit.state === "loading" || !isValid}
style={[styles.spacing, { width: "90%" }]} style={[styles.spacing, { width: "96%" }]}
> >
Save Save
</Button> </Button>
{habit.endDate == null ? (
<Button
mode="outlined"
onPress={async () => {
await habitsTrackerPresenter.habitStop(habit)
}}
loading={habitStop.state === "loading"}
disabled={habitStop.state === "loading"}
style={[styles.spacing, { width: "96%" }]}
>
🛑 Stop Habit (effective tomorrow)
</Button>
) : (
<Text
style={{
textAlign: "center",
marginVertical: 20,
fontSize: 20,
}}
>
🛑 The habit has been stopped! (No further progress can be saved)
</Text>
)}
</ScrollView> </ScrollView>
<Snackbar <Snackbar

View File

@ -0,0 +1,121 @@
import type { IconName } from "@fortawesome/fontawesome-svg-core"
import { fas } from "@fortawesome/free-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
import { memo, useCallback, useEffect, useState, useTransition } from "react"
import { Modal, ScrollView, View } from "react-native"
import { Button, List, Text, TextInput } from "react-native-paper"
import { IconsList } from "./IconsList"
export interface IconSelectorModalProps {
isVisible?: boolean
selectedIcon?: string
onIconSelect: (icon: string) => void
handleCloseModal?: () => void
}
interface SearchInputProps {
searchText: string
handleSearch: (text: string) => void
}
const SearchInputWithoutMemo: React.FC<SearchInputProps> = (props) => {
const { searchText, handleSearch } = props
return (
<TextInput label="Search" value={searchText} onChangeText={handleSearch} />
)
}
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<IconSelectorModalProps> = ({
isVisible = false,
selectedIcon,
onIconSelect,
handleCloseModal,
}) => {
const [possibleIcons, setPossibleIcons] = useState<string[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
const [searchText, setSearchText] = useState<string>("")
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 (
<Modal animationType="fade" transparent visible={isVisible}>
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<View
style={{
paddingHorizontal: 20,
paddingVertical: 5,
width: "96%",
height: "99%",
backgroundColor: "white",
borderColor: "black",
borderWidth: 1,
}}
>
<View
style={{
justifyContent: "center",
alignItems: "center",
marginVertical: 20,
}}
>
<Text style={{ marginVertical: 8 }}>Selected Icon:</Text>
<FontAwesomeIcon size={46} icon={selectedIcon as IconName} />
</View>
<SearchInput searchText={searchText} handleSearch={handleSearch} />
<ScrollView>
<List.Section title="Choose an icon:">
<IconsList
isLoading={isLoading}
selectedIcon={selectedIcon}
possibleIcons={possibleIcons}
handleIconSelect={handleIconSelect}
/>
</List.Section>
</ScrollView>
<View style={{ marginVertical: 15 }}>
<Button mode="contained" onPress={handleCloseModal}>
Save
</Button>
</View>
</View>
</View>
</Modal>
)
}

View File

@ -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<IconsListProps> = (props) => {
const {
selectedIcon,
possibleIcons,
isLoading = false,
handleIconSelect,
} = props
if (possibleIcons.length <= 0) {
return (
<View
style={{
marginTop: 20,
alignItems: "center",
}}
>
{isLoading ? (
<ActivityIndicator size="large" />
) : (
<Text>No results found</Text>
)}
</View>
)
}
return (
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
gap: 15,
justifyContent: "center",
alignItems: "center",
}}
>
{possibleIcons.map((icon) => {
return (
<IconButton
key={icon}
containerColor="white"
icon={({ size }) => {
return (
<FontAwesomeIcon
icon={icon as IconName}
size={size}
color={selectedIcon === icon ? "blue" : "black"}
/>
)
}}
size={30}
onPress={() => {
handleIconSelect(icon)
}}
/>
)
})}
</View>
)
}
export const IconsList = memo(IconsListWithoutMemo)

View File

@ -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 { useRouter } from "expo-router"
import type LottieView from "lottie-react-native"
import { useState } from "react" import { useState } from "react"
import { View } from "react-native" import { View } from "react-native"
import { Checkbox, List, Text } from "react-native-paper" import { Checkbox, List, Text } from "react-native-paper"
import type LottieView from "lottie-react-native"
import type { GoalBoolean } from "@/domain/entities/Goal" import type { GoalBoolean } from "@/domain/entities/Goal"
import { GoalBooleanProgress } from "@/domain/entities/Goal" import { GoalBooleanProgress } from "@/domain/entities/Goal"
import type { HabitHistory } from "@/domain/entities/HabitHistory" import type { HabitHistory } from "@/domain/entities/HabitHistory"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
import { getColorRGBAFromHex } from "@/utils/colors" import { getColorRGBAFromHex } from "@/utils/colors"
import { useHabitsTracker } from "../../contexts/HabitsTracker"
export interface HabitCardProps { export interface HabitCardProps {
habitHistory: HabitHistory habitHistory: HabitHistory
@ -65,9 +66,9 @@ export const HabitCard: React.FC<HabitCardProps> = (props) => {
left={() => { left={() => {
return ( return (
<View style={{ justifyContent: "center", alignItems: "center" }}> <View style={{ justifyContent: "center", alignItems: "center" }}>
<FontAwesome6 <FontAwesomeIcon
size={24} size={24}
name={habit.icon} icon={habit.icon as IconName}
style={[ style={[
{ {
width: 30, width: 30,

View File

@ -0,0 +1,157 @@
import LottieView from "lottie-react-native"
import { useRef, useState } from "react"
import { Dimensions, ScrollView, View } from "react-native"
import { Divider, List, Text } from "react-native-paper"
import { GOAL_FREQUENCIES, type GoalFrequency } from "@/domain/entities/Goal"
import type { HabitHistory } from "@/domain/entities/HabitHistory"
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
import { capitalize } from "@/utils/strings"
import confettiJSON from "../../../assets/confetti.json"
import { HabitCard } from "./HabitCard"
export interface HabitsListProps {
habitsTracker: HabitsTracker
selectedDate: Date
}
export const HabitsList: React.FC<HabitsListProps> = (props) => {
const { habitsTracker, selectedDate } = props
const [accordionExpanded, setAccordionExpanded] = useState<{
[key in GoalFrequency]: boolean
}>({
daily: true,
weekly: true,
monthly: true,
})
const confettiRef = useRef<LottieView | null>(null)
const habitsHistoriesByFrequency: Record<GoalFrequency, HabitHistory[]> = {
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 (
<>
<View
pointerEvents="none"
style={{
width: "100%",
height: "100%",
position: "absolute",
zIndex: 100,
justifyContent: "center",
alignItems: "center",
}}
>
<LottieView
ref={confettiRef}
source={confettiJSON}
autoPlay={false}
loop={false}
style={[
{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
]}
resizeMode="cover"
/>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
style={{
paddingHorizontal: 20,
width: Dimensions.get("window").width,
backgroundColor: "white",
}}
>
<Divider />
<Text
style={{
fontWeight: "bold",
fontSize: 22,
textAlign: "center",
marginTop: 20,
}}
>
{selectedDate.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</Text>
{frequenciesFiltered.length > 0 ? (
<List.Section>
{frequenciesFiltered.map((frequency) => {
return (
<List.Accordion
expanded={accordionExpanded[frequency]}
onPress={() => {
setAccordionExpanded((old) => {
return {
...old,
[frequency]: !old[frequency],
}
})
}}
key={frequency}
title={capitalize(frequency)}
titleStyle={[
{
fontSize: 26,
},
]}
>
{habitsHistoriesByFrequency[frequency].map((item) => {
return (
<HabitCard
habitHistory={item}
selectedDate={selectedDate}
key={item.habit.id + selectedDate.toISOString()}
confettiRef={confettiRef}
/>
)
})}
</List.Accordion>
)
})}
</List.Section>
) : (
<View
style={{
justifyContent: "center",
alignItems: "center",
marginVertical: 6,
}}
>
<Text variant="titleLarge">No habits for this date</Text>
</View>
)}
</ScrollView>
</>
)
}

View File

@ -3,7 +3,7 @@ import { Agenda } from "react-native-calendars"
import { GOAL_FREQUENCIES } from "@/domain/entities/Goal" import { GOAL_FREQUENCIES } from "@/domain/entities/Goal"
import type { HabitsTracker } from "@/domain/entities/HabitsTracker" import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
import { getISODate, getNowDate } from "@/utils/dates" import { getISODate, getNowDateUTC } from "@/utils/dates"
import { HabitsEmpty } from "./HabitsEmpty" import { HabitsEmpty } from "./HabitsEmpty"
import { HabitsList } from "./HabitsList" import { HabitsList } from "./HabitsList"
@ -14,7 +14,7 @@ export interface HabitsMainPageProps {
export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => { export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => {
const { habitsTracker } = props const { habitsTracker } = props
const today = getNowDate() const today = getNowDateUTC()
const todayISO = getISODate(today) const todayISO = getISODate(today)
const [selectedDate, setSelectedDate] = useState<Date>(today) const [selectedDate, setSelectedDate] = useState<Date>(today)
@ -45,7 +45,6 @@ export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => {
<HabitsList <HabitsList
habitsTracker={habitsTracker} habitsTracker={habitsTracker}
selectedDate={selectedDate} selectedDate={selectedDate}
frequenciesFiltered={frequenciesFiltered}
/> />
) )
}} }}

View File

@ -1,6 +1,6 @@
import renderer from "react-test-renderer" import renderer from "react-test-renderer"
import { ExternalLink } from "@/presentation/react/components/ExternalLink" import { ExternalLink } from "@/presentation/react-native/ui/ExternalLink"
describe("<ExternalLink />", () => { describe("<ExternalLink />", () => {
it("renders correctly", () => { it("renders correctly", () => {

View File

@ -1,6 +1,6 @@
import renderer from "react-test-renderer" import renderer from "react-test-renderer"
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon" import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
describe("<TabBarIcon />", () => { describe("<TabBarIcon />", () => {
it("renders correctly", () => { it("renders correctly", () => {

View File

@ -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<HabitsListProps> = (props) => {
const { habitsTracker, selectedDate, frequenciesFiltered } = props
const [accordionExpanded, setAccordionExpanded] = useState<{
[key in GoalFrequency]: boolean
}>({
daily: true,
weekly: true,
monthly: true,
})
const confettiRef = useRef<LottieView | null>(null)
return (
<>
<View
pointerEvents="none"
style={{
width: "100%",
height: "100%",
position: "absolute",
zIndex: 100,
justifyContent: "center",
alignItems: "center",
}}
>
<LottieView
ref={confettiRef}
source={confettiJSON}
autoPlay={false}
loop={false}
style={[
{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
]}
resizeMode="cover"
/>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
style={{
paddingHorizontal: 20,
width: Dimensions.get("window").width,
backgroundColor: "white",
}}
>
<Divider />
<List.Section>
{frequenciesFiltered.map((frequency) => {
return (
<List.Accordion
expanded={accordionExpanded[frequency]}
onPress={() => {
setAccordionExpanded((old) => {
return {
...old,
[frequency]: !old[frequency],
}
})
}}
key={frequency}
title={capitalize(frequency)}
titleStyle={[
{
fontSize: 26,
},
]}
>
{habitsTracker.habitsHistory[frequency].map((item) => {
return (
<HabitCard
habitHistory={item}
selectedDate={selectedDate}
key={item.habit.id + selectedDate.toISOString()}
confettiRef={confettiRef}
/>
)
})}
</List.Accordion>
)
})}
</List.Section>
</ScrollView>
</>
)
}

View File

@ -3,7 +3,7 @@ import CircularProgress from "react-native-circular-progress-indicator"
import { Agenda } from "react-native-calendars" import { Agenda } from "react-native-calendars"
import { useState } from "react" import { useState } from "react"
import { getNowDate, getISODate } from "@/utils/dates" import { getNowDateUTC, getISODate } from "@/utils/dates"
import type { HabitsTracker } from "@/domain/entities/HabitsTracker" import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
export interface StatsProps { export interface StatsProps {
@ -13,7 +13,7 @@ export interface StatsProps {
export const Stats: React.FC<StatsProps> = (props) => { export const Stats: React.FC<StatsProps> = (props) => {
const { habitsTracker } = props const { habitsTracker } = props
const today = getNowDate() const today = getNowDateUTC()
const todayISO = getISODate(today) const todayISO = getISODate(today)
const [selectedDate, setSelectedDate] = useState<Date>(today) const [selectedDate, setSelectedDate] = useState<Date>(today)

View File

@ -1,11 +1,11 @@
import { createContext, useContext, useEffect } from "react" import { createContext, useContext, useEffect } from "react"
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState" import { authenticationPresenter } from "@/infrastructure/instances"
import type { import type {
AuthenticationPresenter, AuthenticationPresenter,
AuthenticationPresenterState, AuthenticationPresenterState,
} from "@/presentation/presenters/Authentication" } from "@/presentation/presenters/Authentication"
import { authenticationPresenter } from "@/infrastructure/instances" import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
export interface AuthenticationContextValue export interface AuthenticationContextValue
extends AuthenticationPresenterState { extends AuthenticationPresenterState {

View File

@ -1,11 +1,11 @@
import { createContext, useContext, useEffect } from "react" import { createContext, useContext, useEffect } from "react"
import { habitsTrackerPresenter } from "@/infrastructure/instances"
import type { import type {
HabitsTrackerPresenter, HabitsTrackerPresenter,
HabitsTrackerPresenterState, HabitsTrackerPresenterState,
} from "@/presentation/presenters/HabitsTracker" } from "@/presentation/presenters/HabitsTracker"
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState" import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
import { habitsTrackerPresenter } from "@/infrastructure/instances"
import { useAuthentication } from "./Authentication" import { useAuthentication } from "./Authentication"
export interface HabitsTrackerContextValue extends HabitsTrackerPresenterState { export interface HabitsTrackerContextValue extends HabitsTrackerPresenterState {

View File

@ -2,8 +2,8 @@ import { act, renderHook } from "@testing-library/react-native"
import { useBoolean } from "@/presentation/react/hooks/useBoolean" import { useBoolean } from "@/presentation/react/hooks/useBoolean"
describe("hooks/useBoolean", () => { describe("presentation/react/hooks/useBoolean", () => {
beforeEach(() => { afterEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
@ -11,51 +11,76 @@ describe("hooks/useBoolean", () => {
for (const initialValue of initialValues) { for (const initialValue of initialValues) {
it(`should set the initial value to ${initialValue}`, () => { it(`should set the initial value to ${initialValue}`, () => {
// Arrange - Given
const { result } = renderHook(() => { const { result } = renderHook(() => {
return useBoolean({ initialValue }) return useBoolean({ initialValue })
}) })
// Assert - Then
expect(result.current.value).toBe(initialValue) expect(result.current.value).toBe(initialValue)
}) })
} }
it("should by default set the initial value to false", () => { it("should by default set the initial value to false", () => {
// Arrange - Given
const { result } = renderHook(() => { const { result } = renderHook(() => {
return useBoolean() return useBoolean()
}) })
// Assert - Then
expect(result.current.value).toBe(false) expect(result.current.value).toBe(false)
}) })
it("should toggle the value", async () => { it("should toggle the value", async () => {
// Arrange - Given
const { result } = renderHook(() => { const { result } = renderHook(() => {
return useBoolean({ initialValue: false }) return useBoolean({ initialValue: false })
}) })
// Act - When
await act(() => { await act(() => {
return result.current.toggle() return result.current.toggle()
}) })
// Assert - Then
expect(result.current.value).toBe(true) expect(result.current.value).toBe(true)
// Act - When
await act(() => { await act(() => {
return result.current.toggle() return result.current.toggle()
}) })
// Assert - Then
expect(result.current.value).toBe(false) expect(result.current.value).toBe(false)
}) })
it("should set the value to true", async () => { it("should set the value to true", async () => {
// Arrange - Given
const { result } = renderHook(() => { const { result } = renderHook(() => {
return useBoolean({ initialValue: false }) return useBoolean({ initialValue: false })
}) })
// Act - When
await act(() => { await act(() => {
return result.current.setTrue() return result.current.setTrue()
}) })
// Assert - Then
expect(result.current.value).toBe(true) expect(result.current.value).toBe(true)
}) })
it("should set the value to false", async () => { it("should set the value to false", async () => {
// Arrange - Given
const { result } = renderHook(() => { const { result } = renderHook(() => {
return useBoolean({ initialValue: true }) return useBoolean({ initialValue: true })
}) })
// Act - When
await act(() => { await act(() => {
return result.current.setFalse() return result.current.setFalse()
}) })
// Assert - Then
expect(result.current.value).toBe(false) expect(result.current.value).toBe(false)
}) })
}) })

View File

@ -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<MockCountPresenterState> {
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)
})
})

View File

@ -2,9 +2,10 @@ import { useState } from "react"
export interface UseBooleanResult { export interface UseBooleanResult {
value: boolean value: boolean
toggle: () => void setValue: React.Dispatch<React.SetStateAction<boolean>>
setTrue: () => void setTrue: () => void
setFalse: () => void setFalse: () => void
toggle: () => void
} }
export interface UseBooleanOptions { export interface UseBooleanOptions {
@ -43,6 +44,7 @@ export const useBoolean = (
return { return {
value, value,
setValue,
toggle, toggle,
setTrue, setTrue,
setFalse, setFalse,

115
tests/mocks/domain/Habit.ts Normal file
View File

@ -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<HabitData, "startDate"> {
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),
}

View File

@ -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<HabitProgressData, "date"> {
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),
}

View File

@ -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(),
}

View File

@ -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,
}

View File

@ -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),
}

View File

@ -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(),
}

1
tests/setup.ts Normal file
View File

@ -0,0 +1 @@
import "@testing-library/react-native/extend-expect"

View File

@ -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)
})
})
})

View File

@ -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)
})
})
})

View File

@ -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)
})
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})
})

View File

@ -11,7 +11,7 @@ export const getISODate = (date: Date): string => {
return date.toISOString().slice(0, 10) return date.toISOString().slice(0, 10)
} }
export const getNowDate = (): Date => { export const getNowDateUTC = (): Date => {
const date = new Date() const date = new Date()
const milliseconds = Date.UTC( const milliseconds = Date.UTC(
date.getFullYear(), date.getFullYear(),
@ -28,8 +28,8 @@ export const getNowDate = (): Date => {
* Get the week number [1-52] for a given date. * Get the week number [1-52] for a given date.
* @param {Date} date * @param {Date} date
* @returns {number} * @returns {number}
* @example getWeekNumber(new Date(2020, 0, 1)) // 1 * @example getWeekNumber(new Date("2020-01-01")) // 1
* @example getWeekNumber(new Date(2020, 0, 8)) // 2 * @example getWeekNumber(new Date("2020-01-08")) // 2
*/ */
export const getWeekNumber = (date: Date): number => { export const getWeekNumber = (date: Date): number => {
const dateCopy = new Date(date.getTime()) const dateCopy = new Date(date.getTime())

8
utils/version.ts Normal file
View File

@ -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
}

View File

@ -3,5 +3,8 @@ import type { ZodError } from "zod"
export const getErrorsFieldsFromZodError = <T>( export const getErrorsFieldsFromZodError = <T>(
error: ZodError<T>, error: ZodError<T>,
): Array<keyof T> => { ): Array<keyof T> => {
return Object.keys(error.format()) as Array<keyof T> const fields = Object.keys(error.format()) as Array<keyof T>
return fields.filter((field) => {
return field !== "_errors"
})
} }