Compare commits

..

80 Commits

Author SHA1 Message Date
fbbe74a082
docs: update to use GitHub instead of GitLab 2024-05-27 18:42:52 +02:00
8029204d44
chore: add MIT License 2024-05-23 23:13:39 +02:00
15ab592513
ci: usage of GitHub Actions 2024-05-23 23:03:36 +02:00
beac8b37dc
docs: delete ARCHITECTURE.md file as not done yet 2024-05-23 22:53:16 +02:00
0793720f70
chore(release): v1.1.1 2024-05-23 15:56:22 +02:00
Maxime RICHARD
b789fad149 fix: center 2024-05-23 15:55:00 +02:00
Maxime RICHARD
671639862c fix: added scrollview to the stats page 2024-05-23 15:46:25 +02:00
5099e472bc
chore(release): v1.1.0 2024-05-23 14:00:04 +02:00
Xc165543337
ab6af07a31 feat: user name display in settings 2024-05-23 13:51:22 +02:00
Xc165543337
66501cc595 feat: numeric habit progress with history 2024-05-23 13:42:31 +02:00
Xc165543337
fdbbec3e11 feat: numeric habit progress with icon 2024-05-23 13:22:02 +02:00
89aab00e6e
chore(release): v1.0.0 2024-05-23 00:36:52 +02:00
47bf926fd8
fix: habits statistics center CircularProgress 2024-05-23 00:33:29 +02:00
c455326f8e
feat: update habit progress numeric 2024-05-23 00:31:21 +02:00
dbc19d7056 Merge branch 'perf/search-icons-with-fuse-js' into 'develop'
perf: search icons with Fuse.js

See merge request rrll/p61-project!7
2024-05-22 17:24:09 +00:00
Xc165543337
35b3c5b965 fix: duplicated icons in list 2024-05-22 19:18:54 +02:00
Xc165543337
1bf5fdeaca perf: implementation of Fuse.js for fuzzy searching icons 2024-05-22 19:05:01 +02:00
d98f3144cb Merge branch 'feat/stats' into 'develop'
feat: habits statistics

See merge request rrll/p61-project!6
2024-05-22 11:46:14 +00:00
e15a3982fd
fix: habits statistics 2024-05-22 13:44:54 +02:00
ca122e9fce
Revert "feat: finish stas"
This reverts commit 42f5623c92ff8d2950ff6e48d6058af208c22c1c.
2024-05-22 12:46:51 +02:00
Maxime RICHARD
42f5623c92 feat: finish stas 2024-05-21 23:35:04 +02:00
2ab7413f32
Merge branch 'develop' into feat/stats 2024-05-20 23:56:21 +02:00
2b15e9f28e
fix: usage of svg in production build 2024-05-20 16:22:58 +02:00
cd7aa235ab
chore(release): v1.0.0-staging.4 2024-05-20 15:47:19 +02:00
5462b47112
chore: config for production builds with expo 2024-05-20 15:45:57 +02:00
Maxime RICHARD
e68fe6075e feat: stats v1 2024-05-20 15:35:49 +02:00
651e8e2633
feat: add about page 2024-05-20 15:05:34 +02:00
49dbd18606
docs: add screenshot of the main screen 2024-05-20 12:38:35 +02:00
d596b37be5
fix: handle empty habits list for a selected date 2024-05-20 12:23:17 +02:00
e9afc81bab
chore: update seed data to change habits start dates 2024-05-20 11:52:27 +02:00
71987799c0
fix: should filter habits by start date 2024-05-20 11:41:04 +02:00
3c0c34d187
build(deps): update React Native and Expo dependencies 2024-05-20 11:40:38 +02:00
b95d466a77
chore(release): v1.0.0-staging.3 2024-05-02 23:54:15 +02:00
06ef8515cb
refactor: mocks data for tests 2024-05-02 23:48:47 +02:00
f3156eee61
refactor: separate react/react-native 2024-05-02 01:08:27 +02:00
748ac2476c
fix: habit end date filtering + habit stop wording 2024-05-01 14:15:54 +02:00
172e8edf78
test: add utils unit tests 2024-05-01 14:03:25 +02:00
094b581949
build(deps): update latest 2024-05-01 13:50:34 +02:00
c00b96a8e2
feat: handle creation of habit target type 2024-04-13 17:57:58 +02:00
97ae14d182
Merge branch 'feat/icon-picker' into develop 2024-04-12 23:14:39 +02:00
26b5a18edd
feat: icon picker complete 2024-04-12 23:13:38 +02:00
Maxime RICHARD
c03cd2b96d feat: stats continue 2024-04-12 15:27:11 +02:00
1a89fa03c5 Merge branch 'feat/habit-stop-use-case' into 'develop'
feat: habit stop use case

See merge request rrll/p61-project!5
2024-04-12 13:25:14 +00:00
Xc165543337
dffadb47d0 feat: habit stop use case 2024-04-12 15:20:08 +02:00
Maxime RICHARD
b6395b71b9 feat: debut page stats 2024-04-12 13:43:49 +02:00
Xc165543337
5e3cee079b perf: optimize icon selector 2024-04-12 10:29:23 +02:00
cf959c7088
Merge branch 'develop' into feat/icon-picker 2024-04-11 23:29:42 +02:00
1ab504324a
chore(release): v1.0.0-staging.2 2024-04-11 23:25:36 +02:00
4062ad268b
build(deps): clean up dependencies 2024-04-11 23:24:35 +02:00
867667f4c7
feat: update habit progress boolean 2024-04-11 23:03:45 +02:00
Xc165543337
1fca00addb feat: basic implementation of IconPicker 2024-04-11 15:26:20 +02:00
d75a8ab2cd
Merge branch 'develop' into feat/habit-progress-update 2024-04-11 14:02:17 +02:00
RUMPLER MAXIME
49e8460e5c Merge branch 'feat/habit-edit-page' into 'develop'
feat: habit edit page

See merge request rrll/p61-project!4
2024-04-11 11:58:46 +00:00
246cbe918a
feat: edit habit working version 2024-04-11 13:07:17 +02:00
Maxime RICHARD
a411f91c8e feat: add habit progress update 2024-04-11 12:32:09 +02:00
Maxime Rumpler
3fa3681c9b feat: progress made on the edit page and its implementation 2024-04-11 12:31:45 +02:00
c11f7c1474
chore: fixes 2024-04-09 23:53:55 +02:00
20b4456245
feat: get habit goal progress for a given date 2024-04-08 23:21:36 +02:00
2ab83dfc89
Merge branch 'feat/habit-edit-page' into develop 2024-04-05 15:38:35 +02:00
Maxime Rumpler
fa9431b788 feat: started work on the edit page. added methods to habittracker 2024-04-05 15:31:41 +02:00
RICHARD MAXIME
a5de568bf4 Merge branch 'fix/FirstAdd' into 'develop'
fix: first add in daily, weekly or monthly

See merge request rrll/p61-project!3
2024-04-05 12:48:57 +00:00
Maxime RICHARD
a5455f3948 fix: first add in daily, weekly or monthly 2024-04-05 14:46:50 +02:00
17c1c1041e
refactor: rename correctly folder HabitsMainPage 2024-04-05 14:28:45 +02:00
RICHARD MAXIME
e367c09b34 Merge branch 'fix/scrollMainPage' into 'develop'
fix: scroll is working on main page and rename some file

See merge request rrll/p61-project!2
2024-04-05 11:57:45 +00:00
Maxime RICHARD
2452e3dedd fix: scroll is working on main page and rename some file 2024-04-05 13:50:51 +02:00
e4fcb1894c
feat: habit create use case 2024-04-05 00:08:40 +02:00
Xc165543337
a2d187a27a feat: habit frequency & type labels 2024-04-04 17:33:51 +02:00
Xc165543337
bc9d7ae1af feat: habit type 2024-04-04 17:26:48 +02:00
Xc165543337
6c95386666 feat: add basic habit create form 2024-04-04 17:06:42 +02:00
Xc165543337
f17c7d6e11 feat: habit create zod schema 2024-04-04 12:31:56 +02:00
c9e720cb13 Merge branch 'fix/retrieve-habits-status' into 'develop'
fix: handle retrieve habits statuses

See merge request rrll/p61-project!1
2024-04-04 09:35:51 +00:00
Xc165543337
1dc3939d42 fix: suggestion for creating the first habit 2024-04-04 11:21:56 +02:00
Xc165543337
eea32ec256 fix: retrieve habits error case 2024-04-04 11:01:49 +02:00
7543a4f820
docs: update 2024-04-03 19:05:04 +02:00
8fa50c3954
docs: add UI examples 2024-03-25 13:55:46 +01:00
bb126e907c
feat: group habits history by frequencies (daily, weekly, monthly) 2024-03-25 13:05:15 +01:00
8774bc735a
docs: fix typo supabase path 2024-03-25 12:27:18 +01:00
57058c97b1
feat: add loading state while retrieving habits 2024-03-24 23:51:29 +01:00
1c648972d5
refactor: habits history component 2024-03-24 23:41:23 +01:00
39ebe3a152
feat: list habits basic UI 2024-03-24 19:39:15 +01:00
117 changed files with 14717 additions and 3079 deletions

View File

@ -1,5 +1,5 @@
# Supabase - Local
# EXPO_PUBLIC_SUPABASE_URL='http://127.0.0.1:54321' # Replace `127.0.0.1` with local IP (`hostname -I` on Linux)
# EXPO_PUBLIC_SUPABASE_URL='http://127.0.0.1:54321' # Replace `127.0.0.1` with local IP (e.g: `hostname -i` on GNU/Linux)
# EXPO_PUBLIC_SUPABASE_ANON_KEY=''
# Supabase - Production

View File

@ -1,12 +1,11 @@
{
"root": true,
"extends": [
"conventions",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier"
"plugin:react-hooks/recommended"
],
"ignorePatterns": ["jest.setup.ts"],
"plugins": ["prettier"],
"env": {
"browser": true,
"node": true,
@ -17,11 +16,7 @@
"version": "detect"
}
},
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
"prettier/prettier": "error",
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react/self-closing-comp": [
@ -37,7 +32,11 @@
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"parser": "@typescript-eslint/parser"
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"parserOptions": {
"project": "./tsconfig.json"
}
}
]
}

27
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: "ci"
on:
push:
branches: [develop]
pull_request:
branches: [main, develop, staging]
jobs:
ci:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4.1.6"
- name: "Setup Node.js"
uses: "actions/setup-node@v4.0.2"
with:
node-version: "20.x"
cache: "npm"
- run: "npm clean-install"
- run: "npm run expo:typed-routes"
- run: 'npm run lint:commit -- --to "${{ github.sha }}"'
- run: "npm run lint:prettier"
- run: "npm run lint:eslint"
- run: "npm run lint:typescript"
- run: "npm run test"

1
.gitignore vendored
View File

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

View File

@ -1,28 +0,0 @@
default:
image: "node:20.11.1"
stages:
- "test"
test:
stage: "test"
only:
- "merge_requests"
- "develop"
script:
- "npm clean-install"
- "npm run expo:typed-routes"
- 'echo "${CI_COMMIT_MESSAGE}" | npm run lint:commit'
- "npm run lint:prettier"
- "npm run lint:eslint"
- "npm run lint:typescript"
- "npm run test"
# artifacts:
# paths:
# - "coverage/"
# reports:
# coverage_report:
# coverage_format: "cobertura"
# path: "coverage/cobertura-coverage.xml"
# junit: "junit.xml"
# coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'

24
LICENSE Normal file
View File

@ -0,0 +1,24 @@
# MIT License
Copyright (c) Théo LUDWIG <contact@theoludwig.fr>
Copyright (c) Haoxuan LI <haoxuan.li@etu.unistra.fr>
Copyright (c) Maxime RUMPLER <mrumpler68@gmail.com>
Copyright (c) Maxime RICHARD <maxime.richard2@etu.unistra.fr>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,4 +1,4 @@
# P61 - Projet
# Habits Tracker - P61 Projet
## À propos
@ -6,6 +6,10 @@ Application mobile en [React Native](https://reactnative.dev/) pour le projet du
Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours.
<p align="center">
<img src="./docs/screenshots/habits.png" alt="Habits Tracker Screenshot" height="400px" />
</p>
### Membres du Groupe 7
- [Théo LUDWIG](https://git.unistra.fr/t.ludwig)
@ -17,7 +21,6 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
- [Sujet](./docs/SUJET.md) + [Cahier des charges](./docs/CAHIER-DES-CHARGES.md)
- [Modèle Logique des Données (MLD)](./docs/MLD.md)
- [Architecture](./docs/ARCHITECTURE.md)
- [Conventions développement informatique](./docs/CONVENTIONS.md)
- [Kanban Board (Trello)](https://trello.com/b/8kYlcLA8/habits-tracker)
@ -28,9 +31,6 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
- [React Native Paper](https://callstack.github.io/react-native-paper/): Bibliothèque de composants pour React Native.
- [React Hook Form](https://react-hook-form.com/) + [Zod](https://zod.dev/): Gestion des formulaires et validation des données.
- [Supabase](https://supabase.io/): Backend, serveur d'API pour le stockage des données.
<!--
- [WatermelonDB](https://nozbe.github.io/WatermelonDB/): Base de données locale, pour permettre une utilisation hors-ligne de l'application.
-->
## Développement du projet en local
@ -38,14 +38,14 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
- [Node.js](https://nodejs.org/) >= 20.0.0
- [npm](https://www.npmjs.com/) >= 10.0.0
- [Expo Go](https://expo.io/client)
- [Expo Go](https://expo.io/client) ~2.31.0
- [Docker](https://www.docker.com/) (facultatif, utilisé pour lancer [Supabase](https://supabase.io/) en local)
### Installation
```sh
# Cloner le projet
git clone git@git.unistra.fr:rrll/p61-project.git
git clone git@github.com:theoludwig/p61-project.git
# Se déplacer dans le répertoire du projet
cd p61-project
@ -68,24 +68,24 @@ npm run start
Ce n'est pas strictement nécessaire pour le développement de l'application (même si recommandé), de lancer [Supabase](https://supabase.io/) en local, car l'application est déjà déployée sur un serveur [Supabase](https://supabase.io/) en production (`.env.example` est pré-configuré avec cet environnement).
```sh
npm run supabase
npm run supabase-cli start
```
#### Principales Commandes Supabase
```sh
# Pour réinitialiser la base de données avec les données de test (seed.sql)
npm run supabase db reset
npm run supabase-cli db reset
# Pour synchroniser le modèle (local) avec la base de données (remote)
npm run supabase db pull
npm run supabase-cli db pull
# Pour synchroniser la base de données (remote) avec le modèle (local)
npm run supabase db push
npm run supabase-cli db push
# Pour générer les types TypeScript
npm run supabase gen types typescript -- --local > ./infrastructure/repositories/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)
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": {
"name": "p61-project",
"name": "Habits Tracker",
"slug": "p61-project",
"version": "1.0.0",
"version": "1.1.1",
"orientation": "portrait",
"icon": "./presentation/assets/images/icon.png",
"scheme": "p61-project",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./presentation/assets/images/splashscreen.jpg",
"image": "./presentation/assets/images/splashscreen.png",
"resizeMode": "cover",
"backgroundColor": "#74b6cb"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true
"supportsTablet": true,
"buildNumber": "1.1.1"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./presentation/assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"package": "com.theoludwig.p61project",
"versionCode": 6
},
"web": {
"bundler": "metro",
@ -30,6 +33,14 @@
"plugins": ["expo-router"],
"experiments": {
"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 { fas } from "@fortawesome/free-solid-svg-icons"
import { library } from "@fortawesome/fontawesome-svg-core"
import * as SplashScreen from "expo-splash-screen"
import {
MD3LightTheme as DefaultTheme,
@ -6,6 +8,7 @@ import {
} from "react-native-paper"
import { StatusBar } from "expo-status-bar"
import { useEffect } from "react"
import { GestureHandlerRootView } from "react-native-gesture-handler"
import { HabitsTrackerProvider } from "@/presentation/react/contexts/HabitsTracker"
import {
@ -19,6 +22,8 @@ export const unstableSettings = {
initialRouteName: "index",
}
library.add(fas)
SplashScreen.preventAutoHideAsync().catch((error) => {
console.error(error)
})
@ -61,7 +66,9 @@ const RootLayout: React.FC = () => {
},
}}
>
<StackLayout />
<GestureHandlerRootView style={{ flex: 1 }}>
<StackLayout />
</GestureHandlerRootView>
<StatusBar style="dark" />
</PaperProvider>

View File

@ -1,14 +1,14 @@
import { Redirect, Tabs } from "expo-router"
import React from "react"
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const TabLayout: React.FC = () => {
const { user } = useAuthentication()
if (user == null) {
return <Redirect href="/authentication/login" />
return <Redirect href="/authentication/about" />
}
return (
@ -31,17 +31,25 @@ const TabLayout: React.FC = () => {
name="habits/new"
options={{
title: "New Habit",
unmountOnBlur: true,
tabBarIcon: ({ color }) => {
return <TabBarIcon name="plus-square" color={color} />
},
}}
/>
<Tabs.Screen
name="habits/history"
name="habits/[habitId]"
options={{
title: "History",
unmountOnBlur: true,
href: null,
}}
/>
<Tabs.Screen
name="habits/statistics"
options={{
title: "Statistics",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="history" color={color} />
return <TabBarIcon name="line-chart" color={color} />
},
}}
/>

View File

@ -0,0 +1,7 @@
import { Slot } from "expo-router"
const HabitLayout: React.FC = () => {
return <Slot />
}
export default HabitLayout

View File

@ -0,0 +1,21 @@
import { Redirect, useLocalSearchParams } from "expo-router"
import { HabitEditForm } from "@/presentation/react-native/components/HabitForm/HabitEditForm"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
const HabitPage: React.FC = () => {
const { habitId } = useLocalSearchParams()
const { habitsTracker } = useHabitsTracker()
const habitHistory = habitsTracker.getHabitHistoryById(habitId as string)
if (habitHistory == null) {
return <Redirect href="/application/habits/" />
}
return (
<HabitEditForm habit={habitHistory.habit} key={habitHistory.habit.id} />
)
}
export default HabitPage

View File

@ -0,0 +1,26 @@
import { Redirect, useLocalSearchParams } from "expo-router"
import { HabitProgress } from "@/presentation/react-native/components/HabitProgress"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
const HabitProgressPage: React.FC = () => {
const { habitId, selectedDate } = useLocalSearchParams()
const { habitsTracker } = useHabitsTracker()
const habitHistory = habitsTracker.getHabitHistoryById(habitId as string)
const selectedDateParsed = new Date(selectedDate as string)
if (habitHistory == null) {
return <Redirect href="/application/habits/" />
}
return (
<HabitProgress
habitHistory={habitHistory}
key={habitHistory.habit.id}
selectedDate={selectedDateParsed}
/>
)
}
export default HabitProgressPage

View File

@ -1,48 +0,0 @@
import { useState } from "react"
import { StyleSheet } from "react-native"
import { Calendar } from "react-native-calendars"
import { SafeAreaView } from "react-native-safe-area-context"
const HistoryPage: React.FC = () => {
const [selected, setSelected] = useState("")
return (
<SafeAreaView style={styles.container}>
<Calendar
onDayPress={(day) => {
setSelected(day.dateString)
}}
markedDates={{
"2023-03-01": { selected: true, marked: true, selectedColor: "blue" },
"2023-03-02": { marked: true },
"2023-03-03": { selected: true, marked: true, selectedColor: "blue" },
[selected]: {
selected: true,
disableTouchEvent: true,
selectedColor: "orange",
},
}}
theme={{
backgroundColor: "#000000",
calendarBackground: "#000000",
textSectionTitleColor: "#b6c1cd",
selectedDayBackgroundColor: "#00adf5",
selectedDayTextColor: "#ffffff",
todayTextColor: "#00adf5",
dayTextColor: "#2d4150",
textDisabledColor: "#d9efff",
}}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
})
export default HistoryPage

View File

@ -1,34 +1,71 @@
import { StyleSheet, Text, View } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"
import { ActivityIndicator, Button, Text } from "react-native-paper"
import { HabitsMainPage } from "@/presentation/react-native/components/HabitsMainPage/HabitsMainPage"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const HabitsPage: React.FC = () => {
const { habitsTracker } = useHabitsTracker()
const { habitsTracker, retrieveHabitsTracker, habitsTrackerPresenter } =
useHabitsTracker()
const { user } = useAuthentication()
return (
<SafeAreaView style={styles.container}>
{habitsTracker.habitsHistory.map((progressHistory) => {
const { habit } = progressHistory
return (
<View key={habit.id}>
<Text>
{habit.name} ({habit.goal.type})
<SafeAreaView
style={[
{
flex: 1,
backgroundColor: "white",
alignItems: "center",
justifyContent:
retrieveHabitsTracker.state === "loading" ||
retrieveHabitsTracker.state === "error"
? "center"
: "flex-start",
},
]}
>
{retrieveHabitsTracker.state === "loading" ? (
<ActivityIndicator animating size="large" />
) : retrieveHabitsTracker.state === "error" ? (
<>
<Text variant="titleLarge">
Error: There was an issue while retrieving habits, please try again
later.
</Text>
<Button
mode="contained"
style={{
marginTop: 16,
width: 150,
height: 40,
}}
onPress={async () => {
if (user === null) {
return
}
await habitsTrackerPresenter.retrieveHabitsTracker({
userId: user.id,
})
}}
>
<Text
style={{
color: "white",
fontWeight: "bold",
fontSize: 16,
}}
>
Retry
</Text>
</View>
)
})}
</Button>
</>
) : (
<HabitsMainPage habitsTracker={habitsTracker} />
)}
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
})
export default HabitsPage

View File

@ -1,21 +1,14 @@
import { StyleSheet } from "react-native"
import { Text } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import { HabitCreateForm } from "@/presentation/react-native/components/HabitForm/HabitCreateForm"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const NewHabitPage: React.FC = () => {
return (
<SafeAreaView style={styles.container}>
<Text>New Habit</Text>
</SafeAreaView>
)
const { user } = useAuthentication()
if (user == null) {
return null
}
return <HabitCreateForm user={user} />
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
})
export default NewHabitPage

View File

@ -0,0 +1,23 @@
import { SafeAreaView } from "react-native-safe-area-context"
import { HabitsStatistics } from "@/presentation/react-native/components/HabitsStatistics"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
const StatisticsPage: React.FC = () => {
const { habitsTracker } = useHabitsTracker()
return (
<SafeAreaView
style={[
{
flex: 1,
backgroundColor: "white",
},
]}
>
<HabitsStatistics habitsTracker={habitsTracker} />
</SafeAreaView>
)
}
export default StatisticsPage

View File

@ -1,38 +1,59 @@
import { StyleSheet, Text } from "react-native"
import { Button } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import { Button, Text } from "react-native-paper"
import { View } from "react-native"
import { About } from "@/presentation/react-native/components/About"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const SettingsPage: React.FC = () => {
const { logout, authenticationPresenter } = useAuthentication()
const { logout, authenticationPresenter, user } = useAuthentication()
const handleLogout = async (): Promise<void> => {
await authenticationPresenter.logout()
}
return (
<SafeAreaView style={styles.container}>
<Text>Settings</Text>
<Button
mode="contained"
onPress={handleLogout}
loading={logout.state === "loading"}
disabled={logout.state === "loading"}
>
Logout
</Button>
</SafeAreaView>
<About
actionButton={
<Button
mode="contained"
labelStyle={{ fontSize: 18 }}
onPress={handleLogout}
loading={logout.state === "loading"}
disabled={logout.state === "loading"}
>
Logout
</Button>
}
footer={
<View
style={{
alignItems: "center",
marginVertical: 20,
}}
>
<Text
style={{
fontWeight: "bold",
fontSize: 18,
textAlign: "center",
}}
>
Currenty logged in as
</Text>
<Text
style={{
marginTop: 6,
fontWeight: "bold",
fontSize: 16,
textAlign: "center",
}}
>
{user?.displayName}
</Text>
</View>
}
/>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
})
export default SettingsPage

View File

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

@ -21,7 +21,7 @@ const LoginPage: React.FC = () => {
}
return (
<SafeAreaView style={styles.container}>
<SafeAreaView style={[styles.container]}>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
@ -31,7 +31,7 @@ const LoginPage: React.FC = () => {
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={styles.input}
style={[styles.input]}
mode="outlined"
/>
)
@ -48,7 +48,7 @@ const LoginPage: React.FC = () => {
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={styles.input}
style={[styles.input]}
mode="outlined"
secureTextEntry
/>
@ -60,13 +60,14 @@ const LoginPage: React.FC = () => {
<HelperText
type="error"
visible={login.state === "error"}
style={styles.helperText}
style={[styles.helperText]}
>
Invalid credentials.
</HelperText>
<Button
mode="contained"
labelStyle={{ fontSize: 18 }}
onPress={handleSubmit(onSubmit)}
loading={login.state === "loading"}
disabled={login.state === "loading"}

View File

@ -24,13 +24,13 @@ const RegisterPage: React.FC = () => {
const helperMessage = useMemo(() => {
if (register.state === "error") {
if (register.errorsFields.includes("displayName")) {
if (register.errors.fields.includes("displayName")) {
return "Display Name is required."
}
if (register.errorsFields.includes("email")) {
if (register.errors.fields.includes("email")) {
return "Invalid email."
}
if (register.errorsFields.includes("password")) {
if (register.errors.fields.includes("password")) {
return "Password must be at least 6 characters."
}
return "Invalid credentials."
@ -41,10 +41,10 @@ const RegisterPage: React.FC = () => {
// }
return ""
}, [register.errorsFields, register.state])
}, [register.errors.fields, register.state])
return (
<SafeAreaView style={styles.container}>
<SafeAreaView style={[styles.container]}>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
@ -54,7 +54,7 @@ const RegisterPage: React.FC = () => {
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={styles.input}
style={[styles.input]}
mode="outlined"
/>
)
@ -71,7 +71,7 @@ const RegisterPage: React.FC = () => {
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={styles.input}
style={[styles.input]}
mode="outlined"
/>
)
@ -88,7 +88,7 @@ const RegisterPage: React.FC = () => {
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={styles.input}
style={[styles.input]}
mode="outlined"
secureTextEntry
/>
@ -100,13 +100,14 @@ const RegisterPage: React.FC = () => {
<HelperText
type={register.state === "error" ? "error" : "info"}
visible={register.state === "error" || register.state === "success"}
style={styles.helperText}
style={[styles.helperText]}
>
{helperMessage}
</HelperText>
<Button
mode="contained"
labelStyle={{ fontSize: 18 }}
onPress={handleSubmit(onSubmit)}
loading={register.state === "loading"}
disabled={register.state === "loading"}

View File

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

View File

@ -1 +0,0 @@
# Clean Architecture

View File

@ -4,15 +4,15 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
## Contexte
On voit ces derniers temps une émergence de nombreuses applications de suivi d'habitudes sur le marché. Malheureusement, les solutions proposées sont souvent soit trop complexes et encombrées, soit trop basiques et limitées par des modèles de tarification agressive (après tout cest normal, les gens sont prêts à payer alors pourquoi sen priver).
On voit ces derniers temps une émergence de nombreuses applications de suivi d'habitudes sur le marché. Malheureusement, les solutions proposées sont souvent soit trop complexes et encombrées, soit trop basiques et limitées par des modèles de tarification agressive (après tout c'est normal, les gens sont prêts à payer alors pourquoi s'en priver).
Relevons le défi de créer une application qui équilibre parfaitement fonctionnalité et simplicité !
## Objectif principal
L'objectif de ce projet de groupe est de développer une application nommée “**Habits Tracker**” qui trouve le juste équilibre entre <ins>richesse fonctionnelle</ins> et <ins>simplicité</ins> d'utilisation (ne faites pas dusine à gaz sil vous plaît !).
L'objectif de ce projet de groupe est de développer une application nommée “**Habits Tracker**” qui trouve le juste équilibre entre <ins>richesse fonctionnelle</ins> et <ins>simplicité</ins> d'utilisation (ne faites pas d'usine à gaz s'il vous plaît !).
L'application doit permettre aux utilisateurs de suivre efficacement leurs habitudes quotidiennes et d'analyser leurs progrès dans le but daméliorer leur performance au travail et leur bien-être général.
L'application doit permettre aux utilisateurs de suivre efficacement leurs habitudes quotidiennes et d'analyser leurs progrès dans le but d'améliorer leur performance au travail et leur bien-être général.
## Conseils
@ -22,7 +22,7 @@ Si votre application atteint une bonne qualité, avec une interface attrayante e
## Fonctionnalités de base (noyau commun)
- **Gestion des utilisateurs :** inscription, création dun profil utilisateur avec identifiant unique, et fonctionnalités de connexion/déconnexion.
- **Gestion des utilisateurs :** inscription, création d'un profil utilisateur avec identifiant unique, et fonctionnalités de connexion/déconnexion.
- **Gestion des habitudes :** création et modification d'habitudes, avec personnalisation d'objectif (+ rappels et fréquences en bonus…)
- **Suivi quotidien :** interface intuitive pour le suivi des habitudes depuis l'écran d'accueil, incluant un calendrier pour consulter l'historique.
- **Analyse et statistiques :** graphiques simplifiés (librairie externe) pour visualiser les progrès et les tendances des habitudes sur des périodes définies
@ -49,6 +49,12 @@ Vous serez évalués sur les aspects suivants :
- Design UI : (esthétique de l'application) **1 point**
- Expérience utilisateur UX : (facilité d'utilisation) **1 point**
## Exemple dinterfaces
## Exemple d'interfaces
![UI Example](../presentation/assets/images/ui-example.png)
![UI Example 2](../presentation/assets/images/ui-example-2.jpg)
![UI Example 3](../presentation/assets/images/ui-example-3.jpg)
![UI Example 4](../presentation/assets/images/ui-example-4.png)

View File

@ -19,17 +19,17 @@ npm run test
npm run test -- --u
```
Une pipeline CI ([`.gitlab-ci.yml`](../.gitlab-ci.yml)) est en place pour vérifier que le code respecte ces bonnes pratiques et que les tests passent.
Une pipeline CI est en place pour vérifier que le code respecte ces bonnes pratiques et que les tests passent.
## GitFlow
Le projet suit la convention [GitFlow](https://nvie.com/posts/a-successful-git-branching-model/) reposant sur 2 branches principales:
Le projet suit la convention [GitFlow](https://nvie.com/posts/a-successful-git-branching-model/) reposant sur 3 branches principales:
- `main`: Contient le code de la dernière version stable et déployé en production.
- `staging`: Contient le code en cours de test avant déploiement en production, Quality Assurance (QA).
- `develop`: Contient le code en cours de développement. Les nouvelles fonctionnalités et les correctifs de bugs sont fusionnés ici.
- `develop`: Contient le code en cours de développement. Les nouvelles fonctionnalités et les correctifs de bugs sont fusionnés ici régulièrement.
Chaque nouvelle fonctionnalité ou correctif de bug est développé dans une branche dédiée à partir de `develop`, nommée `feat/<nom-de-la-fonctionnalité>` ou `fix/<nom-du-bug>`. Une fois le développement terminé, une merge request est créée pour demander une revue de code, et une fois validée, la branche est fusionnée dans `develop`, puis supprimée.
Idéalement, chaque nouvelle fonctionnalité ou correctif de bug est développé dans une branche dédiée à partir de `develop`, nommée `feat/<nom-de-la-fonctionnalité>` ou `fix/<nom-du-bug>`. Une fois le développement terminé, une merge request est créée pour demander une revue de code, et une fois validée, la branche est fusionnée dans `develop`, puis supprimée.
## Convention des commits
@ -39,4 +39,4 @@ Les commits doivent être **atomiques** c'est à dire qu'il respecte 3 règles:
- Ne concerne qu'un seul sujet (une fonctionnalité, une correction de bug, etc.).
- Doit avoir un message clair et concis.
- Ne doit pas rendre de dépôt "incohérent" (bloque la CI du projet).
- Ne doit pas rendre de dépôt "incohérent" (ne bloque pas la CI du projet).

View File

@ -4,14 +4,6 @@
Le **Modèle Logique des Données (MLD)** est une représentation de la structure de la base de données de l'application.
On représente ainsi les données sous la forme suivante:
- Chaque table est représentée par un bloc.
- Le nom de la table est écrit en **gras**.
- Les champs sont listés en dessous du nom de la table.
- Les clés primaires sont <u>soulignées</u> et placées au début de la liste des champs.
- Les clés étrangères sont préfixées par un dièse (#), et placées après les clés primaires. Les clés étrangères sont suivies entre parenthèses du nom de la table suivi d'une flèche (->) et du nom du champ de la table référencée.
## Modèle
- **users**

View File

@ -77,7 +77,7 @@ Quelques liens utiles pour comprendre... :
- [Authentication in React Native, Easy, Secure, and Reusable solution.](https://www.obytes.com/blog/authentication-in-react-native-easy-secure-and-reusable-solution)
- [How To Add Authentication to Your React Native App](https://betterprogramming.pub/how-to-add-authentication-to-your-react-native-app-with-react-hooks-and-react-context-api-46f57aedbbd)
- [Github | mesan-react-native-authentication-app](https://github.com/MosesEsan/mesan-react-native-authentication-app/tree/auth)
- [React Native: Implementing Browser-Based Authentication using Expos AuthSession Component](https://levelup.gitconnected.com/react-native-implementing-browser-based-authentication-using-expos-authsession-component-ffee25b50ae8)
- [React Native: Implementing Browser-Based Authentication using Expo's AuthSession Component](https://levelup.gitconnected.com/react-native-implementing-browser-based-authentication-using-expos-authsession-component-ffee25b50ae8)
- [Build a great login experience with React Native, Axios and JSONWebToken](https://www.willandskill.se/en/build-a-great-login-experience-with-react-native/)
- [Adding Authentication to Your React Native App Using JSON Web Tokens](https://dzone.com/articles/adding-authentication-to-your-react-native-app-usi-1)

BIN
docs/screenshots/habits.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

@ -1,9 +1,46 @@
export const GOAL_FREQUENCIES = ["daily", "weekly", "monthly"] as const
import { z } from "zod"
export const GOAL_FREQUENCIES_ZOD = [
z.literal("daily"),
z.literal("weekly"),
z.literal("monthly"),
] as const
export const goalFrequencyZod = z.union(GOAL_FREQUENCIES_ZOD)
export const GOAL_FREQUENCIES = GOAL_FREQUENCIES_ZOD.map((frequency) => {
return frequency.value
})
export type GoalFrequency = (typeof GOAL_FREQUENCIES)[number]
export const GOAL_TYPES = ["numeric", "boolean"] as const
export const GOAL_FREQUENCIES_TYPES = {
daily: "day",
weekly: "week",
monthly: "month",
} as const
export const GOAL_TYPES_ZOD = [
z.literal("boolean"),
z.literal("numeric"),
] as const
export const goalTypeZod = z.union(GOAL_TYPES_ZOD)
export const GOAL_TYPES = GOAL_TYPES_ZOD.map((type) => {
return type.value
})
export type GoalType = (typeof GOAL_TYPES)[number]
export const GoalCreateSchema = z.object({
frequency: goalFrequencyZod,
target: z.discriminatedUnion("type", [
z.object({ type: z.literal("boolean") }),
z.object({
type: z.literal("numeric"),
value: z.number().int().min(1),
unit: z.string().min(1),
}),
]),
})
export type GoalCreateData = z.infer<typeof GoalCreateSchema>
interface GoalBase {
frequency: GoalFrequency
}
@ -20,6 +57,16 @@ export abstract class Goal implements GoalBase {
this.frequency = frequency
}
public static create(options: GoalCreateData): Goal {
if (options.target.type === "boolean") {
return new GoalBoolean(options)
}
return new GoalNumeric({
frequency: options.frequency,
target: options.target,
})
}
public static isNumeric(goal: Goal): goal is GoalNumeric {
return goal.type === "numeric"
}
@ -45,6 +92,24 @@ export abstract class GoalProgress implements GoalProgressBase {
public abstract isCompleted(): boolean
public abstract toJSON(): GoalProgressBase
public static isNumeric(
goalProgress: GoalProgress,
): goalProgress is GoalNumericProgress {
return goalProgress.goal.isNumeric()
}
public isNumeric(): this is GoalNumericProgress {
return GoalProgress.isNumeric(this)
}
public static isBoolean(
goalProgress: GoalProgress,
): goalProgress is GoalBooleanProgress {
return goalProgress.goal.isBoolean()
}
public isBoolean(): this is GoalBooleanProgress {
return GoalProgress.isBoolean(this)
}
}
interface GoalNumericOptions extends GoalBase {

View File

@ -1,6 +1,6 @@
import { z } from "zod"
import type { Goal, GoalBaseJSON } from "./Goal"
import { GoalCreateSchema, type Goal, type GoalBaseJSON } from "./Goal"
import { Entity, EntitySchema } from "./_Entity"
export const HabitSchema = EntitySchema.extend({
@ -8,23 +8,28 @@ export const HabitSchema = EntitySchema.extend({
name: z.string().min(1).max(50),
color: z.string().min(4).max(9).regex(/^#/),
icon: z.string().min(1),
endDate: z.date().optional(),
})
export const HabitCreateSchema = HabitSchema.extend({}).omit({ id: true })
export const HabitCreateSchema = HabitSchema.extend({
goal: GoalCreateSchema,
}).omit({ id: true })
export type HabitCreateData = z.infer<typeof HabitCreateSchema>
type HabitDataBase = z.infer<typeof HabitSchema>
export const HabitEditSchema = HabitSchema.extend({})
export type HabitEditData = z.infer<typeof HabitEditSchema>
export interface HabitData extends HabitDataBase {
type HabitBase = z.infer<typeof HabitSchema>
export interface HabitData extends HabitBase {
goal: Goal
startDate: Date
endDate?: Date
}
export interface HabitJSON extends HabitDataBase {
export interface HabitJSON extends HabitBase {
goal: GoalBaseJSON
startDate: string
endDate?: string
}
export class Habit extends Entity implements HabitData {
@ -57,7 +62,7 @@ export class Habit extends Entity implements HabitData {
icon: this.icon,
goal: this.goal,
startDate: this.startDate.toISOString(),
endDate: this.endDate?.toISOString(),
endDate: this?.endDate,
}
}
}

View File

@ -1,3 +1,6 @@
import { getISODate, getWeekNumber } from "@/utils/dates"
import type { GoalProgress } from "./Goal"
import { GoalBooleanProgress, GoalNumericProgress } from "./Goal"
import type { Habit } from "./Habit"
import type { HabitProgress } from "./HabitProgress"
@ -8,11 +11,70 @@ export interface HabitHistoryJSON {
export class HabitHistory implements HabitHistoryJSON {
public habit: Habit
public progressHistory: HabitProgress[]
private _progressHistory: HabitProgress[] = []
public constructor(options: HabitHistoryJSON) {
const { habit, progressHistory } = options
this.habit = habit
this.progressHistory = progressHistory
}
/**
* Progress History sorted chronologically (from old to most recent progress at the end).
*/
public get progressHistory(): HabitProgress[] {
return this._progressHistory
}
public set progressHistory(progressHistory: HabitProgress[]) {
this._progressHistory = [...progressHistory]
this._progressHistory.sort((a, b) => {
return a.date.getTime() - b.date.getTime()
})
}
public getProgressesByDate(date: Date): HabitProgress[] {
return this._progressHistory.filter((progress) => {
if (this.habit.goal.frequency === "monthly") {
return (
date.getFullYear() === progress.date.getFullYear() &&
date.getMonth() === progress.date.getMonth()
)
}
if (this.habit.goal.frequency === "weekly") {
return (
getWeekNumber(date) === getWeekNumber(progress.date) &&
date.getFullYear() === progress.date.getFullYear()
)
}
if (this.habit.goal.frequency === "daily") {
return getISODate(date) === getISODate(progress.date)
}
return false
})
}
public getGoalProgressByDate(date: Date): GoalProgress {
const progresses = this.getProgressesByDate(date)
if (this.habit.goal.isBoolean()) {
const lastSavedProgress = progresses[progresses.length - 1]
return new GoalBooleanProgress({
goal: this.habit.goal,
progress: lastSavedProgress?.goalProgress?.isCompleted() ?? false,
})
}
if (this.habit.goal.isNumeric()) {
return new GoalNumericProgress({
goal: this.habit.goal,
progress: progresses.reduce((sum, current) => {
const goalProgress = current.goalProgress as GoalNumericProgress
return sum + goalProgress.progress
}, 0),
})
}
throw new Error("Invalid")
}
}

View File

@ -3,16 +3,16 @@ import type { Habit } from "./Habit"
import type { EntityData } from "./_Entity"
import { Entity } from "./_Entity"
interface HabitProgressDataBase extends EntityData {
interface HabitProgressBase extends EntityData {
habitId: Habit["id"]
}
export interface HabitProgressData extends HabitProgressDataBase {
export interface HabitProgressData extends HabitProgressBase {
goalProgress: GoalProgress
date: Date
}
export interface HabitProgressJSON extends HabitProgressDataBase {
export interface HabitProgressJSON extends HabitProgressBase {
goalProgress: GoalProgressBase
date: string
}

View File

@ -1,11 +1,16 @@
import type { HabitHistory } from "./HabitHistory"
import type { GoalFrequency } from "./Goal"
import type { Habit } from "./Habit"
import { HabitHistory } from "./HabitHistory"
import type { HabitProgress } from "./HabitProgress"
export interface HabitsTrackerData {
habitsHistory: HabitHistory[]
habitsHistory: {
[key in GoalFrequency]: HabitHistory[]
}
}
export class HabitsTracker implements HabitsTrackerData {
public habitsHistory: HabitHistory[]
public habitsHistory: HabitsTrackerData["habitsHistory"]
public constructor(options: HabitsTrackerData) {
const { habitsHistory } = options
@ -13,6 +18,107 @@ export class HabitsTracker implements HabitsTrackerData {
}
public static default(): HabitsTracker {
return new HabitsTracker({ habitsHistory: [] })
return new HabitsTracker({
habitsHistory: {
daily: [],
weekly: [],
monthly: [],
},
})
}
public addHabit(habit: Habit): void {
this.habitsHistory[habit.goal.frequency].push(
new HabitHistory({
habit,
progressHistory: [],
}),
)
}
public editHabit(habit: Habit): void {
const habitHistory = this.getHabitHistoryById(habit.id)
if (habitHistory == null) {
return
}
habitHistory.habit = habit
}
public updateHabitProgress(habitProgress: HabitProgress): void {
const habitHistory = this.getHabitHistoryById(habitProgress.habitId)
if (habitHistory == null) {
return
}
const habitProgressSaved = habitHistory.progressHistory.find((progress) => {
return progress.id === habitProgress.id
})
if (habitProgressSaved == null) {
habitHistory.progressHistory = [
...habitHistory.progressHistory,
habitProgress,
]
return
}
habitProgressSaved.goalProgress = habitProgress.goalProgress
habitProgressSaved.date = habitProgress.date
}
public getAllHabitsHistory(): HabitHistory[] {
return [
...this.habitsHistory.daily,
...this.habitsHistory.weekly,
...this.habitsHistory.monthly,
]
}
public getHabitHistoryById(id: Habit["id"]): HabitHistory | undefined {
return this.getAllHabitsHistory().find((habitHistory) => {
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))
)
})
}
public getHabitsStatisticsByDateAndFrequency({
selectedDate,
frequency,
}: {
selectedDate: Date
frequency: GoalFrequency
}): {
totalGoalsSuccess: number
totalGoals: number
} {
const habitsHistory = this.getHabitsHistoriesByDate({
selectedDate,
frequency,
})
let totalGoalsSuccess = 0
const totalGoals = habitsHistory.length
for (const habitHistory of habitsHistory) {
const goalProgress = habitHistory.getGoalProgressByDate(selectedDate)
if (goalProgress.isCompleted()) {
totalGoalsSuccess++
}
}
return { totalGoalsSuccess, totalGoals }
}
}

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,9 @@
import type { Habit, HabitCreateData } from "../entities/Habit"
export interface HabitCreateOptions {
habitCreateData: HabitCreateData
}
export interface HabitCreateRepository {
execute: (options: HabitCreateOptions) => Promise<Habit>
}

View File

@ -0,0 +1,9 @@
import type { Habit, HabitEditData } from "../entities/Habit"
export interface HabitEditOptions {
habitEditData: HabitEditData
}
export interface HabitEditRepository {
execute: (options: HabitEditOptions) => Promise<Habit>
}

View File

@ -0,0 +1,12 @@
import type {
HabitProgress,
HabitProgressData,
} from "../entities/HabitProgress"
export interface HabitProgressCreateOptions {
habitProgressData: Omit<HabitProgressData, "id">
}
export interface HabitProgressCreateRepository {
execute: (options: HabitProgressCreateOptions) => Promise<HabitProgress>
}

View File

@ -0,0 +1,12 @@
import type {
HabitProgress,
HabitProgressData,
} from "../entities/HabitProgress"
export interface HabitProgressUpdateOptions {
habitProgressData: Omit<HabitProgressData, "habitId">
}
export interface HabitProgressUpdateRepository {
execute: (options: HabitProgressUpdateOptions) => Promise<HabitProgress>
}

View File

@ -1,8 +1,5 @@
import {
UserRegisterSchema,
type User,
UserLoginSchema,
} from "../entities/User"
import type { User } from "../entities/User"
import { UserLoginSchema, UserRegisterSchema } from "../entities/User"
import type { AuthenticationRepository } from "../repositories/Authentication"
export interface AuthenticationUseCaseDependencyOptions {
@ -42,16 +39,16 @@ export class AuthenticationUseCase
return await this.authenticationRepository.login(userData)
}
public async logout(): Promise<void> {
public logout: AuthenticationRepository["logout"] = async () => {
return await this.authenticationRepository.logout()
}
public getUser: AuthenticationRepository["getUser"] = async (...args) => {
return await this.authenticationRepository.getUser(...args)
public getUser: AuthenticationRepository["getUser"] = async () => {
return await this.authenticationRepository.getUser()
}
public onUserStateChange: AuthenticationRepository["onUserStateChange"] =
async (...args) => {
return this.authenticationRepository.onUserStateChange(...args)
async (callback) => {
return this.authenticationRepository.onUserStateChange(callback)
}
}

View File

@ -0,0 +1,23 @@
import type { Habit } from "../entities/Habit"
import { HabitCreateSchema } from "../entities/Habit"
import type { HabitCreateRepository } from "../repositories/HabitCreate"
export interface HabitCreateUseCaseDependencyOptions {
habitCreateRepository: HabitCreateRepository
}
export class HabitCreateUseCase implements HabitCreateUseCaseDependencyOptions {
public habitCreateRepository: HabitCreateRepository
public constructor(options: HabitCreateUseCaseDependencyOptions) {
this.habitCreateRepository = options.habitCreateRepository
}
public async execute(data: unknown): Promise<Habit> {
const habitCreateData = await HabitCreateSchema.parseAsync(data)
const habit = await this.habitCreateRepository.execute({
habitCreateData,
})
return habit
}
}

View File

@ -0,0 +1,23 @@
import type { Habit } from "../entities/Habit"
import { HabitEditSchema } from "../entities/Habit"
import type { HabitEditRepository } from "../repositories/HabitEdit"
export interface HabitEditUseCaseDependencyOptions {
habitEditRepository: HabitEditRepository
}
export class HabitEditUseCase implements HabitEditUseCaseDependencyOptions {
public habitEditRepository: HabitEditRepository
public constructor(options: HabitEditUseCaseDependencyOptions) {
this.habitEditRepository = options.habitEditRepository
}
public async execute(data: unknown): Promise<Habit> {
const habitEditData = await HabitEditSchema.parseAsync(data)
const habit = await this.habitEditRepository.execute({
habitEditData,
})
return habit
}
}

View File

@ -0,0 +1,66 @@
import type { GoalProgress } from "../entities/Goal"
import type { HabitHistory } from "../entities/HabitHistory"
import type { HabitProgress } from "../entities/HabitProgress"
import type { HabitProgressCreateRepository } from "../repositories/HabitProgressCreate"
import type { HabitProgressUpdateRepository } from "../repositories/HabitProgressUpdate"
export interface HabitGoalProgressUpdateUseCaseOptions {
date: Date
goalProgress: GoalProgress
habitHistory: HabitHistory
}
export interface HabitGoalProgressUpdateUseCaseDependencyOptions {
habitProgressCreateRepository: HabitProgressCreateRepository
habitProgressUpdateRepository: HabitProgressUpdateRepository
}
export class HabitGoalProgressUpdateUseCase
implements HabitGoalProgressUpdateUseCaseDependencyOptions
{
public habitProgressCreateRepository: HabitProgressCreateRepository
public habitProgressUpdateRepository: HabitProgressUpdateRepository
public constructor(options: HabitGoalProgressUpdateUseCaseDependencyOptions) {
this.habitProgressCreateRepository = options.habitProgressCreateRepository
this.habitProgressUpdateRepository = options.habitProgressUpdateRepository
}
public async execute(
options: HabitGoalProgressUpdateUseCaseOptions,
): Promise<HabitProgress> {
const { date, goalProgress, habitHistory } = options
if (goalProgress.isBoolean()) {
const currentHabitProgress = habitHistory.getProgressesByDate(date)[0]
if (currentHabitProgress == null) {
return await this.habitProgressCreateRepository.execute({
habitProgressData: {
date,
goalProgress,
habitId: habitHistory.habit.id,
},
})
}
return await this.habitProgressUpdateRepository.execute({
habitProgressData: {
date,
goalProgress,
id: currentHabitProgress.id,
},
})
}
if (goalProgress.isNumeric()) {
return await this.habitProgressCreateRepository.execute({
habitProgressData: {
date,
goalProgress,
habitId: habitHistory.habit.id,
},
})
}
throw new Error("Not implemented")
}
}

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

View File

@ -1,4 +1,5 @@
import { HabitHistory } from "../entities/HabitHistory"
import type { HabitsTrackerData } from "../entities/HabitsTracker"
import { HabitsTracker } from "../entities/HabitsTracker"
import type { User } from "../entities/User"
import type { GetHabitProgressHistoryRepository } from "../repositories/GetHabitProgressHistory"
@ -38,14 +39,27 @@ export class RetrieveHabitsTrackerUseCase
})
return new HabitHistory({
habit,
progressHistory: progressHistory.sort((a, b) => {
return a.date.getTime() - b.date.getTime()
}),
progressHistory,
})
}),
)
const habitsHistory = habitProgressHistories.reduce<
HabitsTrackerData["habitsHistory"]
>(
(accumulator, habitHistory) => {
const { habit } = habitHistory
const frequency = habit.goal.frequency
accumulator[frequency].push(habitHistory)
return accumulator
},
{
daily: [],
weekly: [],
monthly: [],
},
)
const habitsTracker = new HabitsTracker({
habitsHistory: habitProgressHistories,
habitsHistory,
})
return habitsTracker
}

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,11 +1,19 @@
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 { HabitsTrackerPresenter } from "../presentation/presenters/HabitsTracker"
import { AuthenticationSupabaseRepository } from "./supabase/repositories/Authentication"
import { GetHabitProgressHistorySupabaseRepository } from "./supabase/repositories/GetHabitProgressHistory"
import { GetHabitsByUserIdSupabaseRepository } from "./supabase/repositories/GetHabitsByUserId"
import { HabitCreateSupabaseRepository } from "./supabase/repositories/HabitCreate"
import { HabitEditSupabaseRepository } from "./supabase/repositories/HabitEdit"
import { HabitProgressCreateSupabaseRepository } from "./supabase/repositories/HabitProgressCreate"
import { HabitProgressUpdateSupabaseRepository } from "./supabase/repositories/HabitProgressUpdate"
import { supabaseClient } from "./supabase/supabase"
import { AuthenticationPresenter } from "@/presentation/presenters/Authentication"
/**
* Repositories
@ -20,6 +28,22 @@ const getHabitProgressesRepository =
const getHabitsByUserIdRepository = new GetHabitsByUserIdSupabaseRepository({
supabaseClient,
})
const habitCreateRepository = new HabitCreateSupabaseRepository({
supabaseClient,
})
const habitEditRepository = new HabitEditSupabaseRepository({
supabaseClient,
})
const habitProgressCreateRepository = new HabitProgressCreateSupabaseRepository(
{
supabaseClient,
},
)
const habitProgressUpdateRepository = new HabitProgressUpdateSupabaseRepository(
{
supabaseClient,
},
)
/**
* Use Cases
@ -27,10 +51,23 @@ const getHabitsByUserIdRepository = new GetHabitsByUserIdSupabaseRepository({
const authenticationUseCase = new AuthenticationUseCase({
authenticationRepository,
})
const habitCreateUseCase = new HabitCreateUseCase({
habitCreateRepository,
})
const retrieveHabitsTrackerUseCase = new RetrieveHabitsTrackerUseCase({
getHabitProgressHistoryRepository: getHabitProgressesRepository,
getHabitsByUserIdRepository,
})
const habitEditUseCase = new HabitEditUseCase({
habitEditRepository,
})
const habitGoalProgressUpdateUseCase = new HabitGoalProgressUpdateUseCase({
habitProgressCreateRepository,
habitProgressUpdateRepository,
})
const habitStopUseCase = new HabitStopUseCase({
habitEditRepository,
})
/**
* Presenters
@ -40,4 +77,8 @@ export const authenticationPresenter = new AuthenticationPresenter({
})
export const habitsTrackerPresenter = new HabitsTrackerPresenter({
retrieveHabitsTrackerUseCase,
habitCreateUseCase,
habitEditUseCase,
habitStopUseCase,
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 { AuthenticationRepository } from "@/domain/repositories/Authentication"
import { SupabaseRepository } from "./_SupabaseRepository"
import { User } from "@/domain/entities/User"
import type { AuthenticationRepository } from "@/domain/repositories/Authentication"
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
export class AuthenticationSupabaseRepository
extends SupabaseRepository

View File

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

View File

@ -1,8 +1,6 @@
import type { GetHabitsByUserIdRepository } from "@/domain/repositories/GetHabitsByUserId"
import { SupabaseRepository } from "./_SupabaseRepository"
import { Habit } from "@/domain/entities/Habit"
import type { Goal } from "@/domain/entities/Goal"
import { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
import { habitsSupabaseDTO } from "../data-transfer-objects/HabitDTO"
export class GetHabitsByUserIdSupabaseRepository
extends SupabaseRepository
@ -10,39 +8,12 @@ export class GetHabitsByUserIdSupabaseRepository
{
public execute: GetHabitsByUserIdRepository["execute"] = async (options) => {
const { userId } = options
const { data, error } = await this.supabaseClient
const { data } = await this.supabaseClient
.from("habits")
.select("*")
.eq("user_id", userId)
if (error != null) {
throw new Error(error.message)
}
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
})
.throwOnError()
const habits = data as NonNullable<typeof data>
return habitsSupabaseDTO.fromSupabaseToDomain(habits)
}
}

View File

@ -0,0 +1,22 @@
import type { HabitCreateRepository } from "@/domain/repositories/HabitCreate"
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
import { habitSupabaseDTO } from "../data-transfer-objects/HabitDTO"
export class HabitCreateSupabaseRepository
extends SupabaseRepository
implements HabitCreateRepository
{
public execute: HabitCreateRepository["execute"] = async (options) => {
const { habitCreateData } = options
const { data } = await this.supabaseClient
.from("habits")
.insert(
habitSupabaseDTO.fromDomainCreateDataToSupabaseInsert(habitCreateData),
)
.select("*")
.single()
.throwOnError()
const insertedHabit = data as NonNullable<typeof data>
return habitSupabaseDTO.fromSupabaseToDomain(insertedHabit)
}
}

View File

@ -0,0 +1,23 @@
import type { HabitEditRepository } from "@/domain/repositories/HabitEdit"
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
import { habitSupabaseDTO } from "../data-transfer-objects/HabitDTO"
export class HabitEditSupabaseRepository
extends SupabaseRepository
implements HabitEditRepository
{
public execute: HabitEditRepository["execute"] = async (options) => {
const { habitEditData } = options
const { data } = await this.supabaseClient
.from("habits")
.update(
habitSupabaseDTO.fromDomainEditDataToSupabaseUpdate(habitEditData),
)
.eq("id", habitEditData.id)
.select("*")
.single()
.throwOnError()
const updatedHabit = data as NonNullable<typeof data>
return habitSupabaseDTO.fromSupabaseToDomain(updatedHabit)
}
}

View File

@ -0,0 +1,29 @@
import type { HabitProgressCreateRepository } from "@/domain/repositories/HabitProgressCreate"
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
import { habitProgressSupabaseDTO } from "../data-transfer-objects/HabitProgressDTO"
export class HabitProgressCreateSupabaseRepository
extends SupabaseRepository
implements HabitProgressCreateRepository
{
public execute: HabitProgressCreateRepository["execute"] = async (
options,
) => {
const { habitProgressData } = options
const { data } = await this.supabaseClient
.from("habits_progresses")
.insert(
habitProgressSupabaseDTO.fromDomainDataToSupabaseInsert(
habitProgressData,
),
)
.select("*")
.single()
.throwOnError()
const insertedProgress = data as NonNullable<typeof data>
return habitProgressSupabaseDTO.fromSupabaseToDomain(
insertedProgress,
habitProgressData.goalProgress.goal,
)
}
}

View File

@ -0,0 +1,30 @@
import type { HabitProgressUpdateRepository } from "@/domain/repositories/HabitProgressUpdate"
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
import { habitProgressSupabaseDTO } from "../data-transfer-objects/HabitProgressDTO"
export class HabitProgressUpdateSupabaseRepository
extends SupabaseRepository
implements HabitProgressUpdateRepository
{
public execute: HabitProgressUpdateRepository["execute"] = async (
options,
) => {
const { habitProgressData } = options
const { data } = await this.supabaseClient
.from("habits_progresses")
.update(
habitProgressSupabaseDTO.fromDomainDataToSupabaseUpdate(
habitProgressData,
),
)
.eq("id", habitProgressData.id)
.select("*")
.single()
.throwOnError()
const insertedProgress = data as NonNullable<typeof data>
return habitProgressSupabaseDTO.fromSupabaseToDomain(
insertedProgress,
habitProgressData.goalProgress.goal,
)
}
}

View File

@ -117,7 +117,7 @@ VALUES
'Wake up at 07h00',
'#006CFF',
'bed',
timezone('utc' :: text, NOW()),
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
NULL,
'daily',
NULL,
@ -144,7 +144,7 @@ VALUES
'Learn English',
'#EB4034',
'language',
timezone('utc' :: text, NOW()),
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
NULL,
'daily',
30,
@ -171,7 +171,7 @@ VALUES
'Walk',
'#228B22',
'person-walking',
timezone('utc' :: text, NOW()),
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
NULL,
'daily',
5000,
@ -198,7 +198,7 @@ VALUES
'Clean the house',
'#808080',
'broom',
timezone('utc' :: text, NOW()),
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
NULL,
'weekly',
NULL,
@ -225,7 +225,7 @@ VALUES
'Solve Programming Challenges',
'#DE3163',
'code',
timezone('utc' :: text, NOW()),
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
NULL,
'monthly',
5,
@ -262,3 +262,6 @@ VALUES
timezone('utc' :: text, NOW()),
4733
);
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);

View File

@ -266,7 +266,7 @@ export interface Database {
Args: {
name: string
}
Returns: unknown
Returns: string[]
}
get_size_by_bucket: {
Args: Record<PropertyKey, never>

View File

@ -1,12 +1,34 @@
import { createClient } from "@supabase/supabase-js"
import {
createClient,
type User as SupabaseUserType,
} from "@supabase/supabase-js"
import { AppState, Platform } from "react-native"
import "react-native-url-polyfill/auto"
import AsyncStorage from "@react-native-async-storage/async-storage"
import type { Database } from "./supabase-types"
const SUPABASE_URL = process.env["EXPO_PUBLIC_SUPABASE_URL"] ?? ""
const SUPABASE_ANON_KEY = process.env["EXPO_PUBLIC_SUPABASE_ANON_KEY"] ?? ""
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 =
process.env["EXPO_PUBLIC_SUPABASE_URL"] ??
"https://wjtwtzxreersqfvfgxrz.supabase.co"
const SUPABASE_ANON_KEY =
process.env["EXPO_PUBLIC_SUPABASE_ANON_KEY"] ??
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndqdHd0enhyZWVyc3FmdmZneHJ6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTExNDcyNzAsImV4cCI6MjAyNjcyMzI3MH0.AglONhsMvmcCRkqwrZsB4Ws9u3o1FAbLlpKJmqeUv8I"
export const supabaseClient = createClient<Database>(
SUPABASE_URL,

View File

@ -1,7 +1,7 @@
{
"preset": "jest-expo",
"roots": ["./"],
"setupFilesAfterEnv": ["@testing-library/react-native/extend-expect"],
"setupFilesAfterEnv": ["<rootDir>/tests/setup.ts"],
"fakeTimers": {
"enableGlobally": true
},
@ -10,7 +10,13 @@
"coverageReporters": ["text", "text-summary", "cobertura"],
"collectCoverageFrom": [
"<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>/app/+html.tsx",
"!<rootDir>/app/**/_layout.tsx",

7779
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,81 +2,91 @@
"name": "p61-project",
"private": true,
"main": "expo-router/entry",
"version": "1.0.0-staging.1",
"version": "1.1.1",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"expo:typed-routes": "expo customize tsconfig.json",
"build-staging:android": "eas build --platform=android --profile=staging",
"lint:commit": "commitlint",
"lint:prettier": "prettier . --check",
"lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore",
"lint:typescript": "tsc --noEmit",
"lint:staged": "lint-staged",
"test": "jest --reporters=default --reporters=jest-junit",
"supabase": "supabase --workdir \"./infrastructure\"",
"supabase-cli": "supabase --workdir \"./infrastructure\"",
"postinstall": "husky"
},
"dependencies": {
"@expo/vector-icons": "14.0.0",
"@hookform/resolvers": "3.3.4",
"@react-native-async-storage/async-storage": "1.21.0",
"@react-navigation/native": "6.1.16",
"@supabase/supabase-js": "2.39.8",
"expo": "50.0.14",
"expo-font": "11.10.3",
"expo-linking": "6.2.2",
"expo-router": "3.4.8",
"expo-splash-screen": "0.26.4",
"expo-status-bar": "1.11.1",
"expo-system-ui": "2.9.3",
"expo-web-browser": "12.8.2",
"immer": "10.0.4",
"@expo/vector-icons": "14.0.2",
"@fortawesome/fontawesome-svg-core": "6.5.2",
"@fortawesome/free-solid-svg-icons": "6.5.2",
"@fortawesome/react-native-fontawesome": "0.3.1",
"@hookform/resolvers": "3.4.2",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-navigation/native": "6.1.17",
"@supabase/supabase-js": "2.43.2",
"expo": "51.0.8",
"expo-linking": "6.3.1",
"expo-router": "3.5.14",
"expo-splash-screen": "0.27.4",
"expo-status-bar": "1.12.1",
"expo-system-ui": "3.0.4",
"expo-web-browser": "13.0.3",
"fuse.js": "7.0.0",
"immer": "10.1.1",
"lottie-react-native": "6.7.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "7.51.1",
"react-native": "0.73.6",
"react-native-calendars": "1.1304.1",
"react-hook-form": "7.51.5",
"react-native": "0.74.1",
"react-native-calendars": "1.1305.0",
"react-native-circular-progress-indicator": "4.4.2",
"react-native-elements": "3.4.3",
"react-native-gesture-handler": "2.16.2",
"react-native-paper": "5.12.3",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "3.29.0",
"react-native-reanimated": "3.10.1",
"react-native-safe-area-context": "4.10.1",
"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-vector-icons": "10.0.3",
"react-native-web": "0.19.10",
"zod": "3.22.4"
"react-native-vector-icons": "10.1.0",
"react-native-web": "0.19.11",
"reanimated-color-picker": "3.0.3",
"zod": "3.23.8"
},
"devDependencies": {
"@babel/core": "7.24.3",
"@commitlint/cli": "19.1.0",
"@commitlint/config-conventional": "19.1.0",
"@testing-library/react-native": "12.4.4",
"@babel/core": "7.24.5",
"@commitlint/cli": "19.2.2",
"@commitlint/config-conventional": "19.2.2",
"@testing-library/react-native": "12.5.0",
"@total-typescript/ts-reset": "0.5.1",
"@tsconfig/strictest": "2.0.3",
"@tsconfig/strictest": "2.0.5",
"@types/jest": "29.5.12",
"@types/node": "20.11.30",
"@types/react": "18.2.69",
"@types/react-test-renderer": "18.0.7",
"@typescript-eslint/eslint-plugin": "7.3.1",
"@typescript-eslint/parser": "7.3.1",
"@types/node": "20.12.12",
"@types/react": "18.2.79",
"@types/react-test-renderer": "18.3.0",
"@typescript-eslint/eslint-plugin": "7.10.0",
"@typescript-eslint/parser": "7.10.0",
"eslint": "8.57.0",
"eslint-config-conventions": "14.1.0",
"eslint-config-prettier": "9.1.0",
"eslint-config-conventions": "14.2.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-prettier": "5.1.3",
"eslint-plugin-promise": "6.1.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-unicorn": "51.0.1",
"eslint-plugin-unicorn": "53.0.0",
"husky": "9.0.11",
"jest": "29.7.0",
"jest-expo": "50.0.4",
"jest-expo": "51.0.2",
"jest-junit": "16.0.0",
"lint-staged": "15.2.2",
"lint-staged": "15.2.4",
"prettier": "3.2.5",
"react-test-renderer": "18.2.0",
"supabase": "1.150.0",
"typescript": "5.4.3"
"supabase": "1.167.4",
"typescript": "5.3.3"
}
}

File diff suppressed because it is too large Load Diff

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -8,7 +8,7 @@ import type {
import type { AuthenticationUseCase } from "@/domain/use-cases/Authentication"
import type { ErrorGlobal, FetchState } from "./_Presenter"
import { Presenter } from "./_Presenter"
import { zodPresenter } from "./utils/ZodPresenter"
import { getErrorsFieldsFromZodError } from "../../utils/zod"
export interface AuthenticationPresenterState {
user: User | null
@ -20,14 +20,18 @@ export interface AuthenticationPresenterState {
register: {
state: FetchState
errorsFields: Array<keyof UserRegisterData>
errorGlobal: ErrorGlobal
errors: {
fields: Array<keyof UserRegisterData>
global: ErrorGlobal
}
}
login: {
state: FetchState
errorsFields: Array<keyof UserLoginData>
errorGlobal: ErrorGlobal
errors: {
fields: Array<keyof UserLoginData>
global: ErrorGlobal
}
}
logout: {
@ -52,13 +56,17 @@ export class AuthenticationPresenter
hasLoaded: true,
register: {
state: "idle",
errorsFields: [],
errorGlobal: null,
errors: {
fields: [],
global: null,
},
},
login: {
state: "idle",
errorsFields: [],
errorGlobal: null,
errors: {
fields: [],
global: null,
},
},
logout: {
state: "idle",
@ -71,8 +79,10 @@ export class AuthenticationPresenter
try {
this.setState((state) => {
state.register.state = "loading"
state.register.errorsFields = []
state.register.errorGlobal = null
state.register.errors = {
fields: [],
global: null,
}
})
const user = await this.authenticationUseCase.register(data)
this.setState((state) => {
@ -83,10 +93,10 @@ export class AuthenticationPresenter
this.setState((state) => {
state.register.state = "error"
if (error instanceof ZodError) {
state.register.errorsFields =
zodPresenter.getErrorsFieldsFromZodError<UserRegisterData>(error)
state.register.errors.fields =
getErrorsFieldsFromZodError<UserRegisterData>(error)
} else {
state.register.errorGlobal = "unknown"
state.register.errors.global = "unknown"
}
})
}
@ -96,8 +106,10 @@ export class AuthenticationPresenter
try {
this.setState((state) => {
state.login.state = "loading"
state.login.errorsFields = []
state.login.errorGlobal = null
state.login.errors = {
fields: [],
global: null,
}
})
const user = await this.authenticationUseCase.login(data)
this.setState((state) => {
@ -108,10 +120,10 @@ export class AuthenticationPresenter
this.setState((state) => {
state.login.state = "error"
if (error instanceof ZodError) {
state.login.errorsFields =
zodPresenter.getErrorsFieldsFromZodError<UserLoginData>(error)
state.login.errors.fields =
getErrorsFieldsFromZodError<UserLoginData>(error)
} else {
state.login.errorGlobal = "unknown"
state.login.errors.global = "unknown"
}
})
}

View File

@ -1,10 +1,25 @@
import { ZodError } from "zod"
import { HabitsTracker } from "@/domain/entities/HabitsTracker"
import type { FetchState } from "./_Presenter"
import type { ErrorGlobal, FetchState } from "./_Presenter"
import { Presenter } from "./_Presenter"
import type {
RetrieveHabitsTrackerUseCase,
RetrieveHabitsTrackerUseCaseOptions,
} from "@/domain/use-cases/RetrieveHabitsTracker"
import type {
Habit,
HabitCreateData,
HabitEditData,
} from "@/domain/entities/Habit"
import { getErrorsFieldsFromZodError } from "../../utils/zod"
import type { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
import type { HabitEditUseCase } from "@/domain/use-cases/HabitEdit"
import type { HabitStopUseCase } from "@/domain/use-cases/HabitStop"
import type {
HabitGoalProgressUpdateUseCase,
HabitGoalProgressUpdateUseCaseOptions,
} from "@/domain/use-cases/HabitGoalProgressUpdate"
export interface HabitsTrackerPresenterState {
habitsTracker: HabitsTracker
@ -12,10 +27,38 @@ export interface HabitsTrackerPresenterState {
retrieveHabitsTracker: {
state: FetchState
}
habitCreate: {
state: FetchState
errors: {
fields: Array<keyof HabitCreateData>
global: ErrorGlobal
}
}
habitEdit: {
state: FetchState
errors: {
fields: Array<keyof HabitEditData>
global: ErrorGlobal
}
}
habitStop: {
state: FetchState
}
habitGoalProgressUpdate: {
state: FetchState
}
}
export interface HabitsTrackerPresenterOptions {
retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
habitCreateUseCase: HabitCreateUseCase
habitEditUseCase: HabitEditUseCase
habitStopUseCase: HabitStopUseCase
habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase
}
export class HabitsTrackerPresenter
@ -23,12 +66,126 @@ export class HabitsTrackerPresenter
implements HabitsTrackerPresenterOptions
{
public retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
public habitCreateUseCase: HabitCreateUseCase
public habitEditUseCase: HabitEditUseCase
public habitStopUseCase: HabitStopUseCase
public habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase
public constructor(options: HabitsTrackerPresenterOptions) {
const { retrieveHabitsTrackerUseCase } = options
const {
retrieveHabitsTrackerUseCase,
habitCreateUseCase,
habitEditUseCase,
habitStopUseCase,
habitGoalProgressUpdateUseCase,
} = options
const habitsTracker = HabitsTracker.default()
super({ habitsTracker, retrieveHabitsTracker: { state: "idle" } })
super({
habitsTracker,
retrieveHabitsTracker: { state: "idle" },
habitCreate: {
state: "idle",
errors: {
fields: [],
global: null,
},
},
habitEdit: {
state: "idle",
errors: {
fields: [],
global: null,
},
},
habitStop: {
state: "idle",
},
habitGoalProgressUpdate: {
state: "idle",
},
})
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
this.habitCreateUseCase = habitCreateUseCase
this.habitEditUseCase = habitEditUseCase
this.habitStopUseCase = habitStopUseCase
this.habitGoalProgressUpdateUseCase = habitGoalProgressUpdateUseCase
}
public async habitCreate(data: unknown): Promise<FetchState> {
try {
this.setState((state) => {
state.habitCreate.state = "loading"
state.habitCreate.errors = {
fields: [],
global: null,
}
})
const habit = await this.habitCreateUseCase.execute(data)
this.setState((state) => {
state.habitCreate.state = "success"
state.habitsTracker.addHabit(habit)
})
return "success"
} catch (error) {
this.setState((state) => {
state.habitCreate.state = "error"
if (error instanceof ZodError) {
state.habitCreate.errors.fields =
getErrorsFieldsFromZodError<HabitCreateData>(error)
} else {
state.habitCreate.errors.global = "unknown"
}
})
return "error"
}
}
public async habitEdit(data: unknown): Promise<FetchState> {
try {
this.setState((state) => {
state.habitEdit.state = "loading"
state.habitEdit.errors = {
fields: [],
global: null,
}
})
const habit = await this.habitEditUseCase.execute(data)
this.setState((state) => {
state.habitEdit.state = "success"
state.habitsTracker.editHabit(habit)
})
return "success"
} catch (error) {
this.setState((state) => {
state.habitEdit.state = "error"
if (error instanceof ZodError) {
state.habitEdit.errors.fields =
getErrorsFieldsFromZodError<HabitEditData>(error)
} else {
state.habitEdit.errors.global = "unknown"
}
})
return "error"
}
}
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(
@ -51,4 +208,26 @@ export class HabitsTrackerPresenter
})
}
}
public async habitUpdateProgress(
options: HabitGoalProgressUpdateUseCaseOptions,
): Promise<FetchState> {
try {
this.setState((state) => {
state.habitGoalProgressUpdate.state = "loading"
})
const habitProgress =
await this.habitGoalProgressUpdateUseCase.execute(options)
this.setState((state) => {
state.habitsTracker.updateHabitProgress(habitProgress)
state.habitGoalProgressUpdate.state = "success"
})
return "success"
} catch (error) {
this.setState((state) => {
state.habitGoalProgressUpdate.state = "error"
})
return "error"
}
}
}

View File

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

View File

@ -1,7 +0,0 @@
import type { ZodError } from "zod"
export const zodPresenter = {
getErrorsFieldsFromZodError: <T>(error: ZodError<T>): Array<keyof T> => {
return Object.keys(error.format()) as Array<keyof T>
},
}

View File

@ -0,0 +1,90 @@
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
footer?: React.ReactNode
}
export const About: React.FC<AboutProps> = (props) => {
const { actionButton, footer } = props
const version = getVersion()
return (
<SafeAreaView
style={{
flex: 1,
paddingHorizontal: 20,
justifyContent: "center",
}}
>
<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>
{footer}
<View
style={{
justifyContent: "center",
alignItems: "center",
marginVertical: 20,
}}
>
{actionButton}
</View>
</SafeAreaView>
)
}

View File

@ -0,0 +1,346 @@
import type { IconName } from "@fortawesome/free-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { ScrollView, StyleSheet, View } from "react-native"
import {
Button,
HelperText,
SegmentedButtons,
Snackbar,
Text,
TextInput,
} from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import ColorPicker, {
HueSlider,
Panel1,
Preview,
} from "reanimated-color-picker"
import type { GoalFrequency, GoalType } from "@/domain/entities/Goal"
import { GOAL_FREQUENCIES, GOAL_TYPES } from "@/domain/entities/Goal"
import type { HabitCreateData } from "@/domain/entities/Habit"
import { HabitCreateSchema } from "@/domain/entities/Habit"
import type { User } from "@/domain/entities/User"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
import { useBoolean } from "@/presentation/react/hooks/useBoolean"
import { IconSelectorModal } from "./IconSelectorModal"
export interface HabitCreateFormProps {
user: User
}
export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
const { habitCreate, habitsTrackerPresenter } = useHabitsTracker()
const {
control,
formState: { errors, isValid },
handleSubmit,
reset,
watch,
} = useForm<HabitCreateData>({
mode: "onChange",
resolver: zodResolver(HabitCreateSchema),
defaultValues: {
userId: user.id,
name: "",
color: "#006CFF",
icon: "circle-question",
goal: {
frequency: "daily",
target: {
type: "boolean",
},
},
},
})
const watchGoalType = watch("goal.target.type")
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
const {
value: isModalIconSelectorVisible,
setTrue: openModalIconSelector,
setFalse: closeModalIconSelector,
} = useBoolean()
const onDismissSnackbar = (): void => {
setIsVisibleSnackbar(false)
}
const onSubmit = async (data: HabitCreateData): Promise<void> => {
await habitsTrackerPresenter.habitCreate(data)
setIsVisibleSnackbar(true)
closeModalIconSelector()
reset()
}
const habitFrequenciesTranslations: {
[key in GoalFrequency]: { label: string; icon: string }
} = {
daily: {
label: "Daily",
icon: "calendar",
},
weekly: {
label: "Weekly",
icon: "calendar-week",
},
monthly: {
label: "Monthly",
icon: "calendar-month",
},
}
const habitTypesTranslations: {
[key in GoalType]: { label: string; icon: string }
} = {
boolean: {
label: "Routine",
icon: "clock",
},
numeric: {
label: "Target",
icon: "target",
},
}
return (
<SafeAreaView>
<ScrollView
contentContainerStyle={{
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 20,
}}
>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<>
<TextInput
placeholder="Name"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={[
styles.spacing,
{
width: "96%",
},
]}
mode="outlined"
/>
{errors.name != null ? (
<HelperText type="error" visible style={[{ paddingTop: 0 }]}>
{errors.name.type === "too_big"
? "Name is too long"
: "Name is required"}
</HelperText>
) : null}
</>
)
}}
name="name"
/>
<Controller
control={control}
render={({ field: { onChange, value } }) => {
return (
<>
<Text style={[styles.spacing]}>Habit Frequency</Text>
<SegmentedButtons
style={[{ width: "96%" }]}
onValueChange={onChange}
value={value}
buttons={GOAL_FREQUENCIES.map((frequency) => {
return {
label: habitFrequenciesTranslations[frequency].label,
value: frequency,
icon: habitFrequenciesTranslations[frequency].icon,
}
})}
/>
</>
)
}}
name="goal.frequency"
/>
<Controller
control={control}
render={({ field: { onChange, value } }) => {
return (
<>
<Text
style={[
styles.spacing,
{ justifyContent: "center", alignContent: "center" },
]}
>
Habit Type
</Text>
<SegmentedButtons
style={[{ width: "96%" }]}
onValueChange={onChange}
value={value}
buttons={GOAL_TYPES.map((type) => {
return {
label: habitTypesTranslations[type].label,
value: type,
icon: habitTypesTranslations[type].icon,
}
})}
/>
</>
)
}}
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
control={control}
render={({ field: { onChange, value } }) => {
return (
<ColorPicker
style={[{ marginVertical: 15, width: "96%" }]}
value={value}
onComplete={(value) => {
onChange(value.hex)
}}
>
<Preview hideInitialColor />
<Panel1 />
<HueSlider />
</ColorPicker>
)
}}
name="color"
/>
<Controller
control={control}
render={({ field: { onChange, value } }) => {
return (
<View
style={{
justifyContent: "center",
alignItems: "center",
flexDirection: "row",
gap: 20,
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"
/>
<Button
mode="contained"
onPress={handleSubmit(onSubmit)}
loading={habitCreate.state === "loading"}
disabled={habitCreate.state === "loading" || !isValid}
style={[{ width: "100%", marginVertical: 15 }]}
>
Create your habit! 🚀
</Button>
</ScrollView>
<Snackbar
visible={isVisibleSnackbar}
onDismiss={onDismissSnackbar}
duration={2_000}
>
Habit created successfully!
</Snackbar>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
spacing: {
marginVertical: 10,
},
})

View File

@ -0,0 +1,208 @@
import type { IconName } from "@fortawesome/free-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { ScrollView, StyleSheet, View } from "react-native"
import {
Button,
HelperText,
Snackbar,
Text,
TextInput,
} from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import ColorPicker, {
HueSlider,
Panel1,
Preview,
} from "reanimated-color-picker"
import type { Habit, HabitEditData } from "@/domain/entities/Habit"
import { HabitEditSchema } from "@/domain/entities/Habit"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
import { useBoolean } from "@/presentation/react/hooks/useBoolean"
import { IconSelectorModal } from "./IconSelectorModal"
export interface HabitEditFormProps {
habit: Habit
}
export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
const { habitEdit, habitStop, habitsTrackerPresenter } = useHabitsTracker()
const {
control,
formState: { errors, isValid },
handleSubmit,
} = useForm<HabitEditData>({
mode: "onChange",
resolver: zodResolver(HabitEditSchema),
defaultValues: {
id: habit.id,
userId: habit.userId,
name: habit.name,
color: habit.color,
icon: habit.icon,
},
})
const {
value: isModalIconSelectorVisible,
setTrue: openModalIconSelector,
setFalse: closeModalIconSelector,
} = useBoolean()
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
const onDismissSnackbar = (): void => {
setIsVisibleSnackbar(false)
}
const onSubmit = async (data: HabitEditData): Promise<void> => {
await habitsTrackerPresenter.habitEdit(data)
setIsVisibleSnackbar(true)
}
return (
<SafeAreaView>
<ScrollView
contentContainerStyle={{
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 20,
}}
>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<>
<TextInput
placeholder="Name"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={[
styles.spacing,
{
width: "96%",
},
]}
mode="outlined"
/>
{errors.name != null ? (
<HelperText type="error" visible style={[{ paddingTop: 0 }]}>
{errors.name.type === "too_big"
? "Name is too long"
: "Name is required"}
</HelperText>
) : null}
</>
)
}}
name="name"
/>
<Controller
control={control}
render={({ field: { onChange, value } }) => {
return (
<ColorPicker
style={[styles.spacing, { width: "96%" }]}
value={value}
onComplete={(value) => {
onChange(value.hex)
}}
>
<Preview hideInitialColor />
<Panel1 />
<HueSlider />
</ColorPicker>
)
}}
name="color"
/>
<Controller
control={control}
render={({ field: { onChange, value } }) => {
return (
<View
style={{
justifyContent: "center",
alignItems: "center",
flexDirection: "row",
gap: 20,
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"
/>
<Button
mode="contained"
onPress={handleSubmit(onSubmit)}
loading={habitEdit.state === "loading"}
disabled={habitEdit.state === "loading" || !isValid}
style={[styles.spacing, { width: "96%" }]}
>
Save
</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>
<Snackbar
visible={isVisibleSnackbar}
onDismiss={onDismissSnackbar}
duration={2_000}
>
Habit Saved successfully!
</Snackbar>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
spacing: {
marginVertical: 16,
},
})

View File

@ -0,0 +1,129 @@
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 Fuse from "fuse.js"
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 fuseOptions = {
keys: ["iconName"],
threshold: 0.4,
}
const fuse = new Fuse(iconNames, fuseOptions)
const findIconsInLibrary = (icon: string): string[] => {
const results = fuse.search(icon).map((result) => {
return result.item
})
const uniqueResults = Array.from(new Set(results))
return uniqueResults.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

@ -0,0 +1,268 @@
import { useState } from "react"
import { ScrollView, StyleSheet, View } from "react-native"
import { Button, Snackbar, Text, TextInput } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import type { IconName } from "@fortawesome/free-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
import type { GoalNumeric } from "@/domain/entities/Goal"
import { GoalNumericProgress } from "@/domain/entities/Goal"
import type { HabitHistory } from "@/domain/entities/HabitHistory"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
import { LOCALE, capitalize } from "@/utils/strings"
export interface HabitProgressProps {
habitHistory: HabitHistory
selectedDate: Date
}
export const HabitProgress: React.FC<HabitProgressProps> = ({
habitHistory,
selectedDate,
}) => {
const { habitsTrackerPresenter, habitGoalProgressUpdate } = useHabitsTracker()
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
const onDismissSnackbar = (): void => {
setIsVisibleSnackbar(false)
}
const goalProgress = habitHistory.getGoalProgressByDate(selectedDate)
const goalProgresses = habitHistory.getProgressesByDate(selectedDate)
const values = {
progress: 0,
min: 0,
max: 0,
}
if (goalProgress.isNumeric()) {
values.max = goalProgress.goal.target.value
}
const [progressValue, setProgressValue] = useState(values.progress)
if (!goalProgress.isNumeric()) {
return <></>
}
const progressTotal = goalProgress.progress + progressValue
const handleSave = async (): Promise<void> => {
setIsVisibleSnackbar(true)
await habitsTrackerPresenter.habitUpdateProgress({
date: selectedDate,
habitHistory,
goalProgress: new GoalNumericProgress({
goal: habitHistory.habit.goal as GoalNumeric,
progress: progressValue,
}),
})
setProgressValue(0)
}
return (
<SafeAreaView style={{ flex: 1, justifyContent: "space-between" }}>
<View
style={{
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 20,
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 15,
}}
>
<FontAwesomeIcon
size={24}
icon={habitHistory.habit.icon as IconName}
style={[
{
width: 30,
},
]}
/>
<Text
style={{
fontWeight: "bold",
fontSize: 28,
textAlign: "center",
}}
>
{habitHistory.habit.name}
</Text>
</View>
<Text
style={{
marginTop: 10,
fontWeight: "bold",
fontSize: 18,
textAlign: "center",
}}
>
{capitalize(habitHistory.habit.goal.frequency)} Progress
</Text>
<Text
style={{
fontSize: 16,
textAlign: "center",
marginBottom: 15,
}}
>
{selectedDate.toLocaleDateString(LOCALE, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</Text>
<View
style={{
width: "100%",
borderBottomWidth: 1,
borderColor: "#f57c00",
marginVertical: 10,
}}
/>
<Text style={{ marginVertical: 10, fontWeight: "bold", fontSize: 18 }}>
{goalProgress.progress.toLocaleString(LOCALE)} /{" "}
{goalProgress.goal.target.value.toLocaleString(LOCALE)}{" "}
{goalProgress.goal.target.unit}
</Text>
<TextInput
placeholder="Progress to add (e.g: 5 000)"
value={progressValue === 0 ? "" : progressValue.toString()}
onChangeText={(text) => {
const hasDigits = /\d+$/.test(text)
if (text.length <= 0 || !hasDigits) {
setProgressValue(0)
return
}
setProgressValue(Number.parseInt(text, 10))
}}
style={[
styles.spacing,
{
width: "80%",
},
]}
mode="outlined"
keyboardType="numeric"
/>
{goalProgress.progress > 0 && progressValue > 0 ? (
<Text
style={{
fontSize: 16,
textAlign: "center",
marginBottom: 15,
}}
>
{goalProgress.progress.toLocaleString()} +{" "}
{progressValue.toLocaleString()} = {progressTotal.toLocaleString()}{" "}
{goalProgress.goal.target.unit}
</Text>
) : (
<></>
)}
<Button
mode="contained"
onPress={handleSave}
loading={habitGoalProgressUpdate.state === "loading"}
disabled={
habitGoalProgressUpdate.state === "loading" || progressValue === 0
}
style={[styles.spacing, { width: "80%" }]}
>
Save Progress
</Button>
<View
style={{
width: "100%",
borderBottomWidth: 1,
borderColor: "#f57c00",
marginVertical: 10,
}}
/>
<Text
style={{
fontWeight: "bold",
fontSize: 18,
margin: 15,
}}
>
Progress History
</Text>
<ScrollView
style={{
width: "100%",
marginVertical: 20,
}}
>
{goalProgresses.map((habitProgress, index) => {
if (!habitProgress.goalProgress.isNumeric()) {
return <></>
}
return (
<View
key={habitProgress.id + index}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 10,
paddingHorizontal: 20,
borderBottomWidth: 1,
borderColor: "#f57c00",
width: "100%",
}}
>
<Text>
{habitProgress.date.toLocaleDateString(LOCALE, {
year: "numeric",
month: "long",
day: "numeric",
})}
</Text>
<Text>
{habitProgress.goalProgress.progress.toLocaleString(LOCALE)}{" "}
{habitProgress.goalProgress.goal.target.unit}
</Text>
</View>
)
})}
</ScrollView>
</View>
<Snackbar
visible={isVisibleSnackbar}
onDismiss={onDismissSnackbar}
duration={2_000}
>
Habit Saved successfully!
</Snackbar>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
spacing: {
marginVertical: 16,
},
})

View File

@ -0,0 +1,137 @@
import type { IconName } from "@fortawesome/free-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
import { Link, useRouter } from "expo-router"
import type LottieView from "lottie-react-native"
import { useState } from "react"
import { View } from "react-native"
import { Button, Checkbox, List, Text } from "react-native-paper"
import type { GoalBoolean } from "@/domain/entities/Goal"
import { GoalBooleanProgress } from "@/domain/entities/Goal"
import type { HabitHistory } from "@/domain/entities/HabitHistory"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
import { getColorRGBAFromHex } from "@/utils/colors"
import { getISODate } from "@/utils/dates"
export interface HabitCardProps {
habitHistory: HabitHistory
selectedDate: Date
confettiRef: React.MutableRefObject<LottieView | null>
}
export const HabitCard: React.FC<HabitCardProps> = (props) => {
const { habitHistory, selectedDate, confettiRef } = props
const { habit } = habitHistory
const router = useRouter()
const { habitsTrackerPresenter } = useHabitsTracker()
const goalProgress = habitHistory.getGoalProgressByDate(selectedDate)
const [checked, setChecked] = useState(goalProgress.isCompleted())
const habitColor = getColorRGBAFromHex({
hexColor: habit.color,
opacity: 0.4,
})
return (
<List.Item
onPress={() => {
router.push({
pathname: "/application/habits/[habitId]/",
params: {
habitId: habit.id,
},
})
}}
title={habit.name}
style={[
{
paddingVertical: 20,
paddingHorizontal: 10,
marginVertical: 10,
borderRadius: 10,
backgroundColor: habitColor,
},
]}
contentStyle={[
{
paddingLeft: 12,
},
]}
titleStyle={[
{
fontSize: 18,
},
]}
left={() => {
return (
<View style={{ justifyContent: "center", alignItems: "center" }}>
<FontAwesomeIcon
size={24}
icon={habit.icon as IconName}
style={[
{
width: 30,
},
]}
/>
</View>
)
}}
right={() => {
if (goalProgress.isNumeric()) {
const href = {
pathname: "/application/habits/[habitId]/progress/[selectedDate]/",
params: {
habitId: habit.id,
selectedDate: getISODate(selectedDate),
},
}
return (
<Link href={href}>
<View>
<Text>
{goalProgress.progress.toLocaleString()} /{" "}
{goalProgress.goal.target.value.toLocaleString()}{" "}
{goalProgress.goal.target.unit}
</Text>
<Button
mode="elevated"
onPress={() => {
router.push(href)
}}
>
Edit
</Button>
</View>
</Link>
)
}
return (
<Checkbox
color="black"
status={checked ? "checked" : "unchecked"}
onPress={async () => {
const isCheckedNew = !checked
setChecked(isCheckedNew)
if (isCheckedNew) {
confettiRef.current?.play()
}
await habitsTrackerPresenter.habitUpdateProgress({
date: selectedDate,
habitHistory,
goalProgress: new GoalBooleanProgress({
goal: habit.goal as GoalBoolean,
progress: isCheckedNew,
}),
})
}}
/>
)
}}
/>
)
}

View File

@ -0,0 +1,40 @@
import { useRouter } from "expo-router"
import { View } from "react-native"
import { Button, Text } from "react-native-paper"
export const HabitsEmpty: React.FC = () => {
const router = useRouter()
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Text variant="titleLarge">{"Let's begin by adding habits! 🤩"}</Text>
<Button
mode="contained"
style={{
marginTop: 16,
width: 250,
height: 40,
}}
onPress={() => {
router.push("/application/habits/new")
}}
>
<Text
style={{
color: "white",
fontWeight: "bold",
fontSize: 16,
}}
>
Create your first habit! 🚀
</Text>
</Button>
</View>
)
}

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 { LOCALE, 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(LOCALE, {
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

@ -0,0 +1,53 @@
import { useState } from "react"
import { Agenda } from "react-native-calendars"
import { GOAL_FREQUENCIES } from "@/domain/entities/Goal"
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
import { getISODate, getNowDateUTC } from "@/utils/dates"
import { HabitsEmpty } from "./HabitsEmpty"
import { HabitsList } from "./HabitsList"
export interface HabitsMainPageProps {
habitsTracker: HabitsTracker
}
export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => {
const { habitsTracker } = props
const today = getNowDateUTC()
const todayISO = getISODate(today)
const [selectedDate, setSelectedDate] = useState<Date>(today)
const selectedDateISO = getISODate(selectedDate)
const frequenciesFiltered = GOAL_FREQUENCIES.filter((frequency) => {
return habitsTracker.habitsHistory[frequency].length > 0
})
if (frequenciesFiltered.length <= 0) {
return <HabitsEmpty />
}
return (
<Agenda
firstDay={1}
showClosingKnob
onDayPress={(date) => {
setSelectedDate(new Date(date.dateString))
}}
markedDates={{
[todayISO]: { marked: true, today: true },
}}
maxDate={todayISO}
selected={selectedDateISO}
renderList={() => {
return (
<HabitsList
habitsTracker={habitsTracker}
selectedDate={selectedDate}
/>
)
}}
/>
)
}

View File

@ -0,0 +1,116 @@
import { Card, Divider, Text } from "react-native-paper"
import CircularProgress from "react-native-circular-progress-indicator"
import { Agenda } from "react-native-calendars"
import { useState } from "react"
import { ScrollView } from "react-native"
import { getNowDateUTC, getISODate } from "@/utils/dates"
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
import { LOCALE } from "@/utils/strings"
import {
GOAL_FREQUENCIES,
GOAL_FREQUENCIES_TYPES,
} from "@/domain/entities/Goal"
import { calculateRatio } from "@/utils/maths"
export interface HabitsStatisticsProps {
habitsTracker: HabitsTracker
}
export const HabitsStatistics: React.FC<HabitsStatisticsProps> = (props) => {
const { habitsTracker } = props
const today = getNowDateUTC()
const todayISO = getISODate(today)
const [selectedDate, setSelectedDate] = useState<Date>(today)
const selectedDateISO = getISODate(selectedDate)
return (
<Agenda
firstDay={1}
showClosingKnob
onDayPress={(date) => {
setSelectedDate(new Date(date.dateString))
}}
markedDates={{
[todayISO]: { marked: true, today: true },
}}
maxDate={todayISO}
selected={selectedDateISO}
renderList={() => {
return (
<ScrollView>
<Divider />
<Text
style={{
fontWeight: "bold",
fontSize: 22,
textAlign: "center",
marginVertical: 10,
}}
>
{selectedDate.toLocaleDateString(LOCALE, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</Text>
{GOAL_FREQUENCIES.map((frequency) => {
const { totalGoalsSuccess, totalGoals } =
habitsTracker.getHabitsStatisticsByDateAndFrequency({
selectedDate,
frequency,
})
const percentage =
calculateRatio(totalGoalsSuccess, totalGoals) * 100
return {
totalGoalsSuccess,
totalGoals,
percentage,
frequency,
}
})
.filter(({ totalGoals }) => {
return totalGoals > 0
})
.map(
({ frequency, totalGoals, totalGoalsSuccess, percentage }) => {
return (
<Card
key={frequency}
mode="elevated"
style={{ marginVertical: 8, marginHorizontal: 10 }}
>
<Card.Content
style={{
justifyContent: "center",
alignItems: "center",
}}
>
<Text variant="bodyMedium" style={{ marginBottom: 5 }}>
{totalGoalsSuccess} achieved goals in the{" "}
{GOAL_FREQUENCIES_TYPES[frequency]} out of{" "}
{totalGoals}.
</Text>
<CircularProgress
value={percentage}
progressValueColor={"#ecf0f1"}
circleBackgroundColor="black"
titleColor="white"
title="%"
/>
</Card.Content>
</Card>
)
},
)}
</ScrollView>
)
}}
/>
)
}

View File

@ -9,5 +9,5 @@ export const TabBarIcon: React.FC<{
name: React.ComponentProps<typeof FontAwesome>["name"]
color: string
}> = (props) => {
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />
return <FontAwesome size={28} style={[{ marginBottom: -3 }]} {...props} />
}

View File

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

View File

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

View File

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

View File

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

View File

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

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 {
value: boolean
toggle: () => void
setValue: React.Dispatch<React.SetStateAction<boolean>>
setTrue: () => void
setFalse: () => void
toggle: () => void
}
export interface UseBooleanOptions {
@ -43,6 +44,7 @@ export const useBoolean = (
return {
value,
setValue,
toggle,
setTrue,
setFalse,

View File

@ -2,11 +2,13 @@ import { useEffect, useState } from "react"
import type { Presenter } from "@/presentation/presenters/_Presenter"
export const usePresenterState = <S>(presenter: Presenter<S>): S => {
const [state, setState] = useState<S>(presenter.initialState)
export const usePresenterState = <State>(
presenter: Presenter<State>,
): State => {
const [state, setState] = useState<State>(presenter.initialState)
useEffect(() => {
const presenterSubscription = (state: S): void => {
const presenterSubscription = (state: State): void => {
setState(state)
}

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

Some files were not shown because too many files have changed in this diff Show More