Compare commits

..

No commits in common. "develop" and "v1.0.0-staging.2" have entirely different histories.

96 changed files with 3121 additions and 7760 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 (e.g: `hostname -i` on GNU/Linux)
# 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_ANON_KEY=''
# Supabase - Production

View File

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

View File

@ -1,27 +0,0 @@
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,7 +3,6 @@
# dependencies
node_modules/
.npm/
.temp/
# Expo
.expo/

28
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,28 @@
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
View File

@ -1,24 +0,0 @@
# 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 @@
# Habits Tracker - P61 Projet
# P61 - Projet
## À propos
@ -6,10 +6,6 @@ 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)
@ -21,6 +17,7 @@ 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)
@ -38,14 +35,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) ~2.31.0
- [Expo Go](https://expo.io/client)
- [Docker](https://www.docker.com/) (facultatif, utilisé pour lancer [Supabase](https://supabase.io/) en local)
### Installation
```sh
# Cloner le projet
git clone git@github.com:theoludwig/p61-project.git
git clone git@git.unistra.fr:rrll/p61-project.git
# Se déplacer dans le répertoire du projet
cd p61-project
@ -68,24 +65,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-cli start
npm run supabase
```
#### Principales Commandes Supabase
```sh
# Pour réinitialiser la base de données avec les données de test (seed.sql)
npm run supabase-cli db reset
npm run supabase db reset
# Pour synchroniser le modèle (local) avec la base de données (remote)
npm run supabase-cli db pull
npm run supabase db pull
# Pour synchroniser la base de données (remote) avec le modèle (local)
npm run supabase-cli db push
npm run supabase db push
# Pour générer les types TypeScript
npm run supabase-cli gen types typescript -- --local > ./infrastructure/supabase/supabase-types.ts
npm run supabase 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-cli db diff -- -f <name-of-migration>
npm run supabase db diff -- -f <name-of-migration>
```

View File

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

View File

@ -1,6 +1,4 @@
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,
@ -22,8 +20,6 @@ export const unstableSettings = {
initialRouteName: "index",
}
library.add(fas)
SplashScreen.preventAutoHideAsync().catch((error) => {
console.error(error)
})

View File

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

View File

@ -1,6 +1,6 @@
import { Redirect, useLocalSearchParams } from "expo-router"
import { HabitEditForm } from "@/presentation/react-native/components/HabitForm/HabitEditForm"
import { HabitEditForm } from "@/presentation/react/components/HabitEditForm/HabitEditForm"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
const HabitPage: React.FC = () => {

View File

@ -1,26 +0,0 @@
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

@ -0,0 +1,50 @@
import { useMemo, useState } from "react"
import { View } from "react-native"
import { Agenda } from "react-native-calendars"
import { Text } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import { getISODate, getNowDate } from "@/utils/dates"
const HistoryPage: React.FC = () => {
const today = useMemo(() => {
return getNowDate()
}, [])
const todayISO = getISODate(today)
const [selectedDate, setSelectedDate] = useState<Date>(today)
const selectedISODate = getISODate(selectedDate)
return (
<SafeAreaView
style={[
{
flex: 1,
backgroundColor: "white",
},
]}
>
<Agenda
firstDay={1}
showClosingKnob
showOnlySelectedDayItems
onDayPress={(date) => {
setSelectedDate(new Date(date.dateString))
}}
markedDates={{
[todayISO]: { marked: true },
}}
selected={selectedISODate}
renderList={() => {
return (
<View>
<Text>{selectedDate.toISOString()}</Text>
</View>
)
}}
/>
</SafeAreaView>
)
}
export default HistoryPage

View File

@ -1,7 +1,7 @@
import { SafeAreaView } from "react-native-safe-area-context"
import { ActivityIndicator, Button, Text } from "react-native-paper"
import { HabitsMainPage } from "@/presentation/react-native/components/HabitsMainPage/HabitsMainPage"
import { HabitsMainPage } from "@/presentation/react/components/HabitsMainPage/HabitsMainPage"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"

View File

@ -1,4 +1,4 @@
import { HabitCreateForm } from "@/presentation/react-native/components/HabitForm/HabitCreateForm"
import { HabitCreateForm } from "@/presentation/react/components/HabitCreateForm/HabitCreateForm"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const NewHabitPage: React.FC = () => {

View File

@ -1,23 +0,0 @@
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,58 +1,37 @@
import { Button, Text } from "react-native-paper"
import { View } from "react-native"
import { Text } from "react-native"
import { Button } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import { About } from "@/presentation/react-native/components/About"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const SettingsPage: React.FC = () => {
const { logout, authenticationPresenter, user } = useAuthentication()
const { logout, authenticationPresenter } = useAuthentication()
const handleLogout = async (): Promise<void> => {
await authenticationPresenter.logout()
}
return (
<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>
}
/>
<SafeAreaView
style={[
{
flex: 1,
alignItems: "center",
justifyContent: "center",
},
]}
>
<Text>Settings</Text>
<Button
mode="contained"
onPress={handleLogout}
loading={logout.state === "loading"}
disabled={logout.state === "loading"}
>
Logout
</Button>
</SafeAreaView>
)
}

View File

@ -1,7 +1,7 @@
import { Redirect, Tabs } from "expo-router"
import React from "react"
import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const TabLayout: React.FC = () => {
@ -17,15 +17,6 @@ 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

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

View File

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

View File

@ -107,7 +107,6 @@ const RegisterPage: React.FC = () => {
<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/about" />
return <Redirect href="/authentication/login" />
}
return <Redirect href="/application/habits/" />

1
docs/ARCHITECTURE.md Normal file
View File

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

View File

@ -19,7 +19,7 @@ npm run test
npm run test -- --u
```
Une pipeline CI est en place pour vérifier que le code respecte ces bonnes pratiques et que les tests passent.
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.
## GitFlow

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

View File

@ -11,12 +11,6 @@ export const GOAL_FREQUENCIES = GOAL_FREQUENCIES_ZOD.map((frequency) => {
})
export type GoalFrequency = (typeof GOAL_FREQUENCIES)[number]
export const GOAL_FREQUENCIES_TYPES = {
daily: "day",
weekly: "week",
monthly: "month",
} as const
export const GOAL_TYPES_ZOD = [
z.literal("boolean"),
z.literal("numeric"),
@ -33,7 +27,7 @@ export const GoalCreateSchema = z.object({
z.object({ type: z.literal("boolean") }),
z.object({
type: z.literal("numeric"),
value: z.number().int().min(1),
value: z.number().int().min(0),
unit: z.string().min(1),
}),
]),

View File

@ -8,7 +8,6 @@ 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({
@ -30,6 +29,7 @@ export interface HabitData extends HabitBase {
export interface HabitJSON extends HabitBase {
goal: GoalBaseJSON
startDate: string
endDate?: string
}
export class Habit extends Entity implements HabitData {
@ -62,7 +62,7 @@ export class Habit extends Entity implements HabitData {
icon: this.icon,
goal: this.goal,
startDate: this.startDate.toISOString(),
endDate: this?.endDate,
endDate: this.endDate?.toISOString(),
}
}
}

View File

@ -1,8 +1,8 @@
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"
import type { GoalProgress } from "./Goal"
import { GoalBooleanProgress, GoalNumericProgress } from "./Goal"
export interface HabitHistoryJSON {
habit: Habit

View File

@ -76,49 +76,4 @@ export class HabitsTracker implements HabitsTrackerData {
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

@ -1,106 +0,0 @@
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

@ -51,16 +51,6 @@ export class HabitGoalProgressUpdateUseCase
})
}
if (goalProgress.isNumeric()) {
return await this.habitProgressCreateRepository.execute({
habitProgressData: {
date,
goalProgress,
habitId: habitHistory.habit.id,
},
})
}
throw new Error("Not implemented")
}
}

View File

@ -1,24 +0,0 @@
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,15 +0,0 @@
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"staging": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {}
}
}

View File

@ -1,19 +1,18 @@
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 { supabaseClient } from "./supabase/supabase"
import { AuthenticationPresenter } from "@/presentation/presenters/Authentication"
import { HabitCreateSupabaseRepository } from "./supabase/repositories/HabitCreate"
import { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
import { HabitEditSupabaseRepository } from "./supabase/repositories/HabitEdit"
import { HabitEditUseCase } from "@/domain/use-cases/HabitEdit"
import { HabitProgressCreateSupabaseRepository } from "./supabase/repositories/HabitProgressCreate"
import { HabitProgressUpdateSupabaseRepository } from "./supabase/repositories/HabitProgressUpdate"
import { supabaseClient } from "./supabase/supabase"
import { HabitGoalProgressUpdateUseCase } from "@/domain/use-cases/HabitGoalProgressUpdate"
/**
* Repositories
@ -65,9 +64,6 @@ const habitGoalProgressUpdateUseCase = new HabitGoalProgressUpdateUseCase({
habitProgressCreateRepository,
habitProgressUpdateRepository,
})
const habitStopUseCase = new HabitStopUseCase({
habitEditRepository,
})
/**
* Presenters
@ -79,6 +75,5 @@ export const habitsTrackerPresenter = new HabitsTrackerPresenter({
retrieveHabitsTrackerUseCase,
habitCreateUseCase,
habitEditUseCase,
habitStopUseCase,
habitGoalProgressUpdateUseCase,
})

View File

@ -1,79 +0,0 @@
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

@ -1,78 +0,0 @@
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

@ -1,100 +0,0 @@
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

@ -1,22 +0,0 @@
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 { User } from "@/domain/entities/User"
import type { AuthenticationRepository } from "@/domain/repositories/Authentication"
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
import { SupabaseRepository } from "./_SupabaseRepository"
import { User } from "@/domain/entities/User"
export class AuthenticationSupabaseRepository
extends SupabaseRepository

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@ -1,28 +1,10 @@
import {
createClient,
type User as SupabaseUserType,
} from "@supabase/supabase-js"
import { createClient } 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"
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"

View File

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

7493
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,91 +2,85 @@
"name": "p61-project",
"private": true,
"main": "expo-router/entry",
"version": "1.1.1",
"version": "1.0.0-staging.2",
"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-cli": "supabase --workdir \"./infrastructure\"",
"supabase": "supabase --workdir \"./infrastructure\"",
"postinstall": "husky"
},
"dependencies": {
"@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",
"@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.42.1",
"expo": "50.0.15",
"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",
"lottie-react-native": "6.5.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"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-hook-form": "7.51.2",
"react-native": "0.73.6",
"react-native-calendars": "1.1304.1",
"react-native-elements": "3.4.3",
"react-native-gesture-handler": "2.16.2",
"react-native-gesture-handler": "2.14.1",
"react-native-paper": "5.12.3",
"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-reanimated": "3.6.3",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "3.29.0",
"react-native-url-polyfill": "2.0.0",
"react-native-vector-icons": "10.1.0",
"react-native-web": "0.19.11",
"react-native-vector-icons": "10.0.3",
"react-native-web": "0.19.10",
"reanimated-color-picker": "3.0.3",
"zod": "3.23.8"
"zod": "3.22.4"
},
"devDependencies": {
"@babel/core": "7.24.5",
"@commitlint/cli": "19.2.2",
"@commitlint/config-conventional": "19.2.2",
"@testing-library/react-native": "12.5.0",
"@babel/core": "7.24.4",
"@commitlint/cli": "19.1.0",
"@commitlint/config-conventional": "19.1.0",
"@testing-library/react-native": "12.4.5",
"@total-typescript/ts-reset": "0.5.1",
"@tsconfig/strictest": "2.0.5",
"@types/jest": "29.5.12",
"@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",
"@types/node": "20.12.7",
"@types/react": "18.2.76",
"@types/react-test-renderer": "18.0.7",
"@typescript-eslint/eslint-plugin": "7.6.0",
"@typescript-eslint/parser": "7.6.0",
"eslint": "8.57.0",
"eslint-config-conventions": "14.2.0",
"eslint-config-conventions": "14.1.0",
"eslint-config-prettier": "9.1.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.2",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-react-native": "4.1.0",
"eslint-plugin-unicorn": "53.0.0",
"eslint-plugin-unicorn": "51.0.1",
"husky": "9.0.11",
"jest": "29.7.0",
"jest-expo": "51.0.2",
"jest-expo": "50.0.4",
"jest-junit": "16.0.0",
"lint-staged": "15.2.4",
"prettier": "3.2.5",
"lint-staged": "15.2.2",
"react-test-renderer": "18.2.0",
"supabase": "1.167.4",
"typescript": "5.3.3"
"supabase": "1.153.4",
"typescript": "5.4.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View File

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

View File

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

View File

@ -1,90 +0,0 @@
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

@ -1,129 +0,0 @@
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

@ -1,74 +0,0 @@
import type { IconName } from "@fortawesome/fontawesome-svg-core"
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
import React, { memo } from "react"
import { View } from "react-native"
import { ActivityIndicator, IconButton, Text } from "react-native-paper"
export interface IconsListProps {
selectedIcon?: string
possibleIcons: string[]
isLoading?: boolean
handleIconSelect: (icon: string) => void
}
const IconsListWithoutMemo: React.FC<IconsListProps> = (props) => {
const {
selectedIcon,
possibleIcons,
isLoading = false,
handleIconSelect,
} = props
if (possibleIcons.length <= 0) {
return (
<View
style={{
marginTop: 20,
alignItems: "center",
}}
>
{isLoading ? (
<ActivityIndicator size="large" />
) : (
<Text>No results found</Text>
)}
</View>
)
}
return (
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
gap: 15,
justifyContent: "center",
alignItems: "center",
}}
>
{possibleIcons.map((icon) => {
return (
<IconButton
key={icon}
containerColor="white"
icon={({ size }) => {
return (
<FontAwesomeIcon
icon={icon as IconName}
size={size}
color={selectedIcon === icon ? "blue" : "black"}
/>
)
}}
size={30}
onPress={() => {
handleIconSelect(icon)
}}
/>
)
})}
</View>
)
}
export const IconsList = memo(IconsListWithoutMemo)

View File

@ -1,268 +0,0 @@
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

@ -1,157 +0,0 @@
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

@ -1,116 +0,0 @@
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

@ -1,9 +1,6 @@
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 { ScrollView, StyleSheet } from "react-native"
import {
Button,
HelperText,
@ -18,15 +15,14 @@ import ColorPicker, {
Panel1,
Preview,
} from "reanimated-color-picker"
import { useState } from "react"
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"
import { useHabitsTracker } from "../../contexts/HabitsTracker"
export interface HabitCreateFormProps {
user: User
@ -37,10 +33,9 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
const {
control,
formState: { errors, isValid },
handleSubmit,
reset,
watch,
formState: { errors },
} = useForm<HabitCreateData>({
mode: "onChange",
resolver: zodResolver(HabitCreateSchema),
@ -48,7 +43,7 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
userId: user.id,
name: "",
color: "#006CFF",
icon: "circle-question",
icon: "lightbulb",
goal: {
frequency: "daily",
target: {
@ -58,16 +53,8 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
},
})
const watchGoalType = watch("goal.target.type")
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
const {
value: isModalIconSelectorVisible,
setTrue: openModalIconSelector,
setFalse: closeModalIconSelector,
} = useBoolean()
const onDismissSnackbar = (): void => {
setIsVisibleSnackbar(false)
}
@ -75,7 +62,6 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
const onSubmit = async (data: HabitCreateData): Promise<void> => {
await habitsTrackerPresenter.habitCreate(data)
setIsVisibleSnackbar(true)
closeModalIconSelector()
reset()
}
@ -131,7 +117,7 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
style={[
styles.spacing,
{
width: "96%",
width: "90%",
},
]}
mode="outlined"
@ -156,7 +142,7 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
<>
<Text style={[styles.spacing]}>Habit Frequency</Text>
<SegmentedButtons
style={[{ width: "96%" }]}
style={[{ width: "90%" }]}
onValueChange={onChange}
value={value}
buttons={GOAL_FREQUENCIES.map((frequency) => {
@ -178,16 +164,9 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
render={({ field: { onChange, value } }) => {
return (
<>
<Text
style={[
styles.spacing,
{ justifyContent: "center", alignContent: "center" },
]}
>
Habit Type
</Text>
<Text style={[styles.spacing]}>Habit Type</Text>
<SegmentedButtons
style={[{ width: "96%" }]}
style={[{ width: "90%" }]}
onValueChange={onChange}
value={value}
buttons={GOAL_TYPES.map((type) => {
@ -204,74 +183,12 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
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%" }]}
style={[styles.spacing, { width: "90%" }]}
value={value}
onComplete={(value) => {
onChange(value.hex)
@ -288,30 +205,16 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
<Controller
control={control}
render={({ field: { onChange, value } }) => {
render={({ field: { onChange, onBlur, 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>
<TextInput
placeholder="Icon"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={[styles.spacing, { width: "90%" }]}
mode="outlined"
/>
)
}}
name="icon"
@ -321,8 +224,8 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
mode="contained"
onPress={handleSubmit(onSubmit)}
loading={habitCreate.state === "loading"}
disabled={habitCreate.state === "loading" || !isValid}
style={[{ width: "100%", marginVertical: 15 }]}
disabled={habitCreate.state === "loading"}
style={[styles.spacing, { width: "90%" }]}
>
Create your habit! 🚀
</Button>
@ -341,6 +244,6 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
const styles = StyleSheet.create({
spacing: {
marginVertical: 10,
marginVertical: 16,
},
})

View File

@ -1,16 +1,8 @@
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 { ScrollView, StyleSheet } from "react-native"
import { Button, HelperText, Snackbar, TextInput } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import ColorPicker, {
HueSlider,
@ -20,21 +12,19 @@ import ColorPicker, {
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"
import { useHabitsTracker } from "../../contexts/HabitsTracker"
export interface HabitEditFormProps {
habit: Habit
}
export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
const { habitEdit, habitStop, habitsTrackerPresenter } = useHabitsTracker()
const { habitEdit, habitsTrackerPresenter } = useHabitsTracker()
const {
control,
formState: { errors, isValid },
handleSubmit,
formState: { errors },
} = useForm<HabitEditData>({
mode: "onChange",
resolver: zodResolver(HabitEditSchema),
@ -47,12 +37,6 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
},
})
const {
value: isModalIconSelectorVisible,
setTrue: openModalIconSelector,
setFalse: closeModalIconSelector,
} = useBoolean()
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
const onDismissSnackbar = (): void => {
@ -86,7 +70,7 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
style={[
styles.spacing,
{
width: "96%",
width: "90%",
},
]}
mode="outlined"
@ -109,7 +93,7 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
render={({ field: { onChange, value } }) => {
return (
<ColorPicker
style={[styles.spacing, { width: "96%" }]}
style={[styles.spacing, { width: "90%" }]}
value={value}
onComplete={(value) => {
onChange(value.hex)
@ -126,30 +110,16 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
<Controller
control={control}
render={({ field: { onChange, value } }) => {
render={({ field: { onChange, onBlur, 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>
<TextInput
placeholder="Icon"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={[styles.spacing, { width: "90%" }]}
mode="outlined"
/>
)
}}
name="icon"
@ -159,35 +129,11 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
mode="contained"
onPress={handleSubmit(onSubmit)}
loading={habitEdit.state === "loading"}
disabled={habitEdit.state === "loading" || !isValid}
style={[styles.spacing, { width: "96%" }]}
disabled={habitEdit.state === "loading"}
style={[styles.spacing, { width: "90%" }]}
>
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

View File

@ -1,17 +1,15 @@
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 FontAwesome6 from "@expo/vector-icons/FontAwesome6"
import { useRouter } from "expo-router"
import { useState } from "react"
import { View } from "react-native"
import { Button, Checkbox, List, Text } from "react-native-paper"
import { Checkbox, List, Text } from "react-native-paper"
import type LottieView from "lottie-react-native"
import type { GoalBoolean } from "@/domain/entities/Goal"
import { 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"
import { useHabitsTracker } from "../../contexts/HabitsTracker"
export interface HabitCardProps {
habitHistory: HabitHistory
@ -67,9 +65,9 @@ export const HabitCard: React.FC<HabitCardProps> = (props) => {
left={() => {
return (
<View style={{ justifyContent: "center", alignItems: "center" }}>
<FontAwesomeIcon
<FontAwesome6
size={24}
icon={habit.icon as IconName}
name={habit.icon}
style={[
{
width: 30,
@ -81,32 +79,14 @@ export const HabitCard: React.FC<HabitCardProps> = (props) => {
}}
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>
<View>
<Text>
{goalProgress.progress.toLocaleString()} /{" "}
{goalProgress.goal.target.value.toLocaleString()}{" "}
{goalProgress.goal.target.unit}
</Text>
</View>
)
}

View File

@ -0,0 +1,110 @@
import LottieView from "lottie-react-native"
import { useRef, useState } from "react"
import { Dimensions, ScrollView, View } from "react-native"
import { Divider, List } from "react-native-paper"
import type { GoalFrequency } from "@/domain/entities/Goal"
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
import confettiJSON from "../../../assets/confetti.json"
import { capitalize } from "@/utils/strings"
import { HabitCard } from "./HabitCard"
export interface HabitsListProps {
habitsTracker: HabitsTracker
selectedDate: Date
frequenciesFiltered: GoalFrequency[]
}
export const HabitsList: React.FC<HabitsListProps> = (props) => {
const { habitsTracker, selectedDate, frequenciesFiltered } = props
const [accordionExpanded, setAccordionExpanded] = useState<{
[key in GoalFrequency]: boolean
}>({
daily: true,
weekly: true,
monthly: true,
})
const confettiRef = useRef<LottieView | null>(null)
return (
<>
<View
pointerEvents="none"
style={{
width: "100%",
height: "100%",
position: "absolute",
zIndex: 100,
justifyContent: "center",
alignItems: "center",
}}
>
<LottieView
ref={confettiRef}
source={confettiJSON}
autoPlay={false}
loop={false}
style={[
{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
]}
resizeMode="cover"
/>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
style={{
paddingHorizontal: 20,
width: Dimensions.get("window").width,
backgroundColor: "white",
}}
>
<Divider />
<List.Section>
{frequenciesFiltered.map((frequency) => {
return (
<List.Accordion
expanded={accordionExpanded[frequency]}
onPress={() => {
setAccordionExpanded((old) => {
return {
...old,
[frequency]: !old[frequency],
}
})
}}
key={frequency}
title={capitalize(frequency)}
titleStyle={[
{
fontSize: 26,
},
]}
>
{habitsTracker.habitsHistory[frequency].map((item) => {
return (
<HabitCard
habitHistory={item}
selectedDate={selectedDate}
key={item.habit.id + selectedDate.toISOString()}
confettiRef={confettiRef}
/>
)
})}
</List.Accordion>
)
})}
</List.Section>
</ScrollView>
</>
)
}

View File

@ -3,7 +3,7 @@ import { 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 { getISODate, getNowDate } from "@/utils/dates"
import { HabitsEmpty } from "./HabitsEmpty"
import { HabitsList } from "./HabitsList"
@ -14,7 +14,7 @@ export interface HabitsMainPageProps {
export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => {
const { habitsTracker } = props
const today = getNowDateUTC()
const today = getNowDate()
const todayISO = getISODate(today)
const [selectedDate, setSelectedDate] = useState<Date>(today)
@ -45,6 +45,7 @@ export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => {
<HabitsList
habitsTracker={habitsTracker}
selectedDate={selectedDate}
frequenciesFiltered={frequenciesFiltered}
/>
)
}}

View File

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

View File

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

View File

@ -1,11 +1,11 @@
import { createContext, useContext, useEffect } from "react"
import { authenticationPresenter } from "@/infrastructure/instances"
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
import type {
AuthenticationPresenter,
AuthenticationPresenterState,
} from "@/presentation/presenters/Authentication"
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
import { authenticationPresenter } from "@/infrastructure/instances"
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("presentation/react/hooks/useBoolean", () => {
afterEach(() => {
describe("hooks/useBoolean", () => {
beforeEach(() => {
jest.clearAllMocks()
})
@ -11,76 +11,51 @@ describe("presentation/react/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

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

View File

@ -1,115 +0,0 @@
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

@ -1,51 +0,0 @@
import type { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
import {
GoalBooleanProgress,
GoalNumericProgress,
} from "@/domain/entities/Goal"
import type { HabitProgressData } from "@/domain/entities/HabitProgress"
import { HabitProgress } from "@/domain/entities/HabitProgress"
import { HABIT_MOCK } from "./Habit"
interface HabitProgressMockCreateOptions
extends Omit<HabitProgressData, "date"> {
date?: Date
}
const habitProgressMockCreate = (
options: HabitProgressMockCreateOptions,
): HabitProgress => {
const { id, habitId, goalProgress, date = new Date() } = options
return new HabitProgress({
date,
goalProgress,
habitId,
id,
})
}
const exampleByIds = {
1: habitProgressMockCreate({
id: "1",
habitId: HABIT_MOCK.examplesByNames["Clean the house"].id,
goalProgress: new GoalBooleanProgress({
goal: HABIT_MOCK.examplesByNames["Clean the house"].goal as GoalBoolean,
progress: true,
}),
}),
2: habitProgressMockCreate({
id: "2",
habitId: HABIT_MOCK.examplesByNames.Walk.id,
goalProgress: new GoalNumericProgress({
goal: HABIT_MOCK.examplesByNames.Walk.goal as GoalNumeric,
progress: 4_733,
}),
}),
} as const
export const HABIT_PROGRESS_MOCK = {
create: habitProgressMockCreate,
exampleByIds,
examples: Object.values(exampleByIds),
}

View File

@ -1,30 +0,0 @@
import type { UserData } from "@/domain/entities/User"
import { User } from "@/domain/entities/User"
const USER_MOCK_ID = "ab054ee9-fbb4-473e-942b-bbf4415f4bef"
const USER_MOCK_EMAIL = "test@test.com"
const USER_MOCK_DISPLAY_NAME = "Test"
interface UserMockCreateOptions {
id?: UserData["id"]
email?: UserData["email"]
displayName?: UserData["displayName"]
}
const userMockCreate = (options: UserMockCreateOptions = {}): User => {
const {
id = USER_MOCK_ID,
email = USER_MOCK_EMAIL,
displayName = USER_MOCK_DISPLAY_NAME,
} = options
return new User({
id,
email,
displayName,
})
}
export const USER_MOCK = {
create: userMockCreate,
example: userMockCreate(),
}

View File

@ -1,79 +0,0 @@
import type { SupabaseHabit } from "@/infrastructure/supabase/supabase"
import { HABIT_MOCK } from "../domain/Habit"
import { SUPABASE_USER_MOCK } from "./User"
interface SupabaseHabitMockCreateOptions {
id: SupabaseHabit["id"]
userId: SupabaseHabit["user_id"]
name: SupabaseHabit["name"]
color: SupabaseHabit["color"]
icon: SupabaseHabit["icon"]
startDate?: Date
endDate: Date | null
goalFrequency: SupabaseHabit["goal_frequency"]
goalTarget: SupabaseHabit["goal_target"] | null
goalTargetUnit: SupabaseHabit["goal_target_unit"] | null
}
const supabaseHabitMockCreate = (
options: SupabaseHabitMockCreateOptions,
): SupabaseHabit => {
const {
id,
userId,
name,
color,
icon,
startDate = new Date(),
endDate,
goalFrequency,
goalTarget,
goalTargetUnit,
} = options
return {
id,
user_id: userId,
name,
color,
icon,
start_date: startDate.toISOString(),
end_date: endDate?.toISOString() ?? null,
goal_frequency: goalFrequency,
goal_target: goalTarget,
goal_target_unit: goalTargetUnit,
}
}
const examplesByNames = Object.fromEntries(
Object.entries(HABIT_MOCK.examplesByNames).map(([name, habit]) => {
const goalTarget = habit.goal.isNumeric() ? habit.goal.target.value : null
const goalTargetUnit = habit.goal.isNumeric()
? habit.goal.target.unit
: null
return [
name,
supabaseHabitMockCreate({
id: Number.parseInt(habit.id, 10),
userId: SUPABASE_USER_MOCK.example.id,
name: habit.name,
color: habit.color,
icon: habit.icon,
startDate: habit.startDate,
endDate: habit.endDate ?? null,
goalFrequency: habit.goal.frequency,
goalTarget,
goalTargetUnit,
}),
]
}),
) as {
[key in keyof (typeof HABIT_MOCK)["examplesByNames"]]: SupabaseHabit
}
export const SUPABASE_HABIT_MOCK = {
create: supabaseHabitMockCreate,
example:
examplesByNames[HABIT_MOCK.example.name as keyof typeof examplesByNames],
examples: Object.values(examplesByNames),
examplesByNames,
}

View File

@ -1,49 +0,0 @@
import type { SupabaseHabitProgress } from "@/infrastructure/supabase/supabase"
import { HABIT_PROGRESS_MOCK } from "../domain/HabitProgress"
interface SupabaseHabitProgressMockCreateOptions {
id: SupabaseHabitProgress["id"]
habitId: SupabaseHabitProgress["habit_id"]
date?: Date
goalProgress: SupabaseHabitProgress["goal_progress"]
}
const supabaseHabitProgressMockCreate = (
options: SupabaseHabitProgressMockCreateOptions,
): SupabaseHabitProgress => {
const { id, habitId, date = new Date(), goalProgress } = options
return {
id,
habit_id: habitId,
date: date.toISOString(),
goal_progress: goalProgress,
}
}
const exampleByIds = Object.fromEntries(
Object.entries(HABIT_PROGRESS_MOCK.exampleByIds).map(
([id, habitProgress]) => {
return [
id,
supabaseHabitProgressMockCreate({
id: Number.parseInt(habitProgress.id, 10),
habitId: Number.parseInt(habitProgress.habitId, 10),
date: new Date(habitProgress.date),
goalProgress: habitProgress.goalProgress.isNumeric()
? habitProgress.goalProgress.progress
: habitProgress.goalProgress.isCompleted()
? 1
: 0,
}),
]
},
),
) as {
[key in keyof (typeof HABIT_PROGRESS_MOCK)["exampleByIds"]]: SupabaseHabitProgress
}
export const SUPABASE_HABIT_PROGRESS_MOCK = {
create: supabaseHabitProgressMockCreate,
exampleByIds,
examples: Object.values(exampleByIds),
}

View File

@ -1,63 +0,0 @@
import type { SupabaseUser } from "@/infrastructure/supabase/supabase"
import { USER_MOCK } from "../domain/User"
interface SupabaseUserMockCreateOptions {
id?: SupabaseUser["id"]
email?: SupabaseUser["email"]
displayName?: SupabaseUser["user_metadata"]["display_name"]
date?: Date
}
const supabaseUserMockCreate = (
options: SupabaseUserMockCreateOptions = {},
): SupabaseUser => {
const {
id = USER_MOCK.example.id,
email = USER_MOCK.example.email,
displayName = USER_MOCK.example.displayName,
date = new Date(),
} = options
return {
id,
app_metadata: { provider: "email", providers: ["email"] },
user_metadata: { display_name: displayName },
aud: "authenticated",
email,
confirmation_sent_at: undefined,
recovery_sent_at: undefined,
email_change_sent_at: undefined,
new_email: "",
new_phone: "",
invited_at: undefined,
action_link: "",
created_at: date.toISOString(),
confirmed_at: undefined,
email_confirmed_at: date.toISOString(),
phone_confirmed_at: undefined,
last_sign_in_at: undefined,
role: "authenticated",
updated_at: date.toISOString(),
identities: [
{
id,
user_id: id,
identity_data: {
sub: id,
email,
},
provider: "email",
identity_id: id,
last_sign_in_at: date.toISOString(),
created_at: date.toISOString(),
updated_at: date.toISOString(),
},
],
is_anonymous: false,
factors: [],
}
}
export const SUPABASE_USER_MOCK = {
create: supabaseUserMockCreate,
example: supabaseUserMockCreate(),
}

View File

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

View File

@ -1,44 +0,0 @@
import { getColorRGBAFromHex } from "../colors"
describe("utils/colors", () => {
describe("getColorRGBAFromHex", () => {
it("should return the correct rgba value when given a hex color and opacity (black 0)", () => {
// Arrange - Given
const hexColor = "#000000"
const opacity = 0
// Act - When
const result = getColorRGBAFromHex({ hexColor, opacity })
// Assert - Then
const expected = "rgba(0, 0, 0, 0)"
expect(result).toEqual(expected)
})
it("should return the correct rgba value when given a hex color and opacity (red 255)", () => {
// Arrange - Given
const hexColor = "#FF0000"
const opacity = 0.5
// Act - When
const result = getColorRGBAFromHex({ hexColor, opacity })
// Assert - Then
const expected = "rgba(255, 0, 0, 0.5)"
expect(result).toEqual(expected)
})
it("should return the correct rgba value when given a hex color with 3 characters and opacity (red 255)", () => {
// Arrange - Given
const hexColor = "#F00"
const opacity = 0.5
// Act - When
const result = getColorRGBAFromHex({ hexColor, opacity })
// Assert - Then
const expected = "rgba(255, 0, 0, 0.5)"
expect(result).toEqual(expected)
})
})
})

View File

@ -1,80 +0,0 @@
import { getISODate, getNowDateUTC, getWeekNumber } from "../dates"
describe("utils/dates", () => {
afterEach(() => {
jest.clearAllMocks()
jest.resetAllMocks()
jest.useRealTimers()
})
describe("getISODate", () => {
it("should return the correct date in ISO format (e.g: 2012-05-23)", () => {
// Arrange - Given
const date = new Date("2012-05-23")
// Act - When
const result = getISODate(date)
// Assert - Then
const expected = "2012-05-23"
expect(result).toEqual(expected)
})
})
describe("getNowDateUTC", () => {
it("should return the current UTC date", () => {
// Arrange - Given
const mockDate = new Date("2024-05-01T12:00:00Z")
jest.useFakeTimers({ now: mockDate })
Date.UTC = jest.fn(() => {
return mockDate.getTime()
})
// Act - When
const result = getNowDateUTC()
// Assert - Then
const expected = new Date("2024-05-01T12:00:00.000Z")
expect(result).toEqual(expected)
expect(Date.UTC).toHaveBeenCalledTimes(1)
})
})
describe("getWeekNumber", () => {
it("should return the correct week number for a given date (e.g: 2020-01-01)", () => {
// Arrange - Given
const date = new Date("2020-01-01")
// Act - When
const result = getWeekNumber(date)
// Assert - Then
const expected = 1
expect(result).toEqual(expected)
})
it("should return the correct week number for a given date (e.g: 2020-01-08)", () => {
// Arrange - Given
const date = new Date("2020-01-08")
// Act - When
const result = getWeekNumber(date)
// Assert - Then
const expected = 2
expect(result).toEqual(expected)
})
it("should return the correct week number for a given date (e.g: 2020-12-31)", () => {
// Arrange - Given
const date = new Date("2020-12-31")
// Act - When
const result = getWeekNumber(date)
// Assert - Then
const expected = 53
expect(result).toEqual(expected)
})
})
})

View File

@ -1,83 +0,0 @@
import { calculateRatio } from "../maths"
describe("utils/maths", () => {
describe("calculateRatio", () => {
it("should calculate the ratio of a value to a total", () => {
// Arrange - Given
const value = 3
const total = 10
// Act - When
const result = calculateRatio(value, total)
// Assert - Then
const expected = 0.3
expect(result).toEqual(expected)
})
it("should return 0 if the total is 0", () => {
// Arrange - Given
const value = 3
const total = 0
// Act - When
const result = calculateRatio(value, total)
// Assert - Then
const expected = 0
expect(result).toEqual(expected)
})
it("should return 0 if the total is negative", () => {
// Arrange - Given
const value = 3
const total = -1
// Act - When
const result = calculateRatio(value, total)
// Assert - Then
const expected = 0
expect(result).toEqual(expected)
})
it("should return 0 if the value is 0", () => {
// Arrange - Given
const value = 0
const total = 10
// Act - When
const result = calculateRatio(value, total)
// Assert - Then
const expected = 0
expect(result).toEqual(expected)
})
it("should return 1 if the value is equal to the total", () => {
// Arrange - Given
const value = 10
const total = 10
// Act - When
const result = calculateRatio(value, total)
// Assert - Then
const expected = 1
expect(result).toEqual(expected)
})
it("should return 1 if the value is greater than the total", () => {
// Arrange - Given
const value = 11
const total = 10
// Act - When
const result = calculateRatio(value, total)
// Assert - Then
const expected = 1
expect(result).toEqual(expected)
})
})
})

View File

@ -1,17 +0,0 @@
import { capitalize } from "../strings"
describe("utils/strings", () => {
describe("capitalize", () => {
it("should capitalize the first letter of a string", () => {
// Arrange - Given
const string = "hello world"
// Act - When
const result = capitalize(string)
// Assert - Then
const expected = "Hello world"
expect(result).toEqual(expected)
})
})
})

View File

@ -1,39 +0,0 @@
import { getVersion } from "../version"
import { version } from "@/package.json"
describe("utils/version", () => {
const env = process.env
beforeEach(() => {
jest.resetModules()
process.env = { ...env }
})
afterEach(() => {
process.env = env
jest.clearAllMocks()
})
it("should return '0.0.0-development' when NODE_ENV is 'development'", () => {
// Arrange - Given
process.env["NODE_ENV"] = "development"
// Act - When
const result = getVersion()
// Assert - Then
const expected = "0.0.0-development"
expect(result).toEqual(expected)
})
it("should return the version from package.json when NODE_ENV is not 'development'", () => {
// Arrange - Given
process.env["NODE_ENV"] = "production"
// Act - When
const result = getVersion()
// Assert - Then
expect(result).toEqual(version)
})
})

View File

@ -1,39 +0,0 @@
import type { ZodIssue } from "zod"
import { ZodError } from "zod"
import { getErrorsFieldsFromZodError } from "../zod"
const zodIssue: ZodIssue = {
code: "too_small",
minimum: 1,
type: "string",
inclusive: true,
exact: false,
message: "String must contain at least 1 character(s)",
path: ["name"],
}
describe("utils/zod", () => {
describe("getErrorsFieldsFromZodError", () => {
it("should return an array of the fields that have errors", () => {
// Arrange - Given
const error = new ZodError([
{
...zodIssue,
path: ["field1"],
},
{
...zodIssue,
path: ["field2"],
},
])
// Act - When
const result = getErrorsFieldsFromZodError(error)
// Assert - Then
const expected = ["field1", "field2"]
expect(result).toEqual(expected)
})
})
})

View File

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

View File

@ -1,9 +0,0 @@
export const calculateRatio = (value: number, total: number): number => {
if (total <= 0) {
return 0
}
if (value >= total) {
return 1
}
return value / total
}

View File

@ -1,5 +1,3 @@
export const LOCALE = "en-US"
export const capitalize = (string: string): string => {
return string.charAt(0).toUpperCase() + string.slice(1)
}

View File

@ -1,8 +0,0 @@
import { version } from "@/package.json"
export const getVersion = (): string => {
if (process.env["NODE_ENV"] === "development") {
return "0.0.0-development"
}
return version
}

View File

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