Merge branch 'develop' into feat/stats
This commit is contained in:
commit
2ab7413f32
@ -1,5 +1,5 @@
|
||||
# Supabase - Local
|
||||
# EXPO_PUBLIC_SUPABASE_URL='http://127.0.0.1:54321' # Replace `127.0.0.1` with local IP (`hostname -I` on Linux)
|
||||
# EXPO_PUBLIC_SUPABASE_URL='http://127.0.0.1:54321' # Replace `127.0.0.1` with local IP (e.g: `hostname -i` on GNU/Linux)
|
||||
# EXPO_PUBLIC_SUPABASE_ANON_KEY=''
|
||||
|
||||
# Supabase - Production
|
||||
|
@ -2,11 +2,9 @@
|
||||
"extends": [
|
||||
"conventions",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"prettier"
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"ignorePatterns": ["jest.setup.ts"],
|
||||
"plugins": ["prettier"],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
@ -21,7 +19,6 @@
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"react/self-closing-comp": [
|
||||
@ -37,7 +34,10 @@
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"parser": "@typescript-eslint/parser"
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"rules": {
|
||||
"@typescript-eslint/member-delimiter-style": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,6 +3,7 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
.npm/
|
||||
.temp/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
|
@ -1,5 +1,5 @@
|
||||
default:
|
||||
image: "node:20.11.1"
|
||||
image: "node:20.13.1"
|
||||
|
||||
stages:
|
||||
- "test"
|
||||
|
20
README.md
20
README.md
@ -1,4 +1,4 @@
|
||||
# P61 - Projet
|
||||
# Habits Tracker - P61 Projet
|
||||
|
||||
## À propos
|
||||
|
||||
@ -6,6 +6,10 @@ Application mobile en [React Native](https://reactnative.dev/) pour le projet du
|
||||
|
||||
Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours.
|
||||
|
||||
<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)
|
||||
@ -35,7 +39,7 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
|
||||
|
||||
- [Node.js](https://nodejs.org/) >= 20.0.0
|
||||
- [npm](https://www.npmjs.com/) >= 10.0.0
|
||||
- [Expo Go](https://expo.io/client)
|
||||
- [Expo Go](https://expo.io/client) ~2.31.0
|
||||
- [Docker](https://www.docker.com/) (facultatif, utilisé pour lancer [Supabase](https://supabase.io/) en local)
|
||||
|
||||
### Installation
|
||||
@ -65,24 +69,24 @@ npm run start
|
||||
Ce n'est pas strictement nécessaire pour le développement de l'application (même si recommandé), de lancer [Supabase](https://supabase.io/) en local, car l'application est déjà déployée sur un serveur [Supabase](https://supabase.io/) en production (`.env.example` est pré-configuré avec cet environnement).
|
||||
|
||||
```sh
|
||||
npm run supabase
|
||||
npm run supabase-cli start
|
||||
```
|
||||
|
||||
#### Principales Commandes Supabase
|
||||
|
||||
```sh
|
||||
# Pour réinitialiser la base de données avec les données de test (seed.sql)
|
||||
npm run supabase db reset
|
||||
npm run supabase-cli db reset
|
||||
|
||||
# Pour synchroniser le modèle (local) avec la base de données (remote)
|
||||
npm run supabase db pull
|
||||
npm run supabase-cli db pull
|
||||
|
||||
# Pour synchroniser la base de données (remote) avec le modèle (local)
|
||||
npm run supabase db push
|
||||
npm run supabase-cli db push
|
||||
|
||||
# Pour générer les types TypeScript
|
||||
npm run supabase gen types typescript -- --local > ./infrastructure/supabase/supabase-types.ts
|
||||
npm run supabase-cli gen types typescript -- --local > ./infrastructure/supabase/supabase-types.ts
|
||||
|
||||
# Crée un nouveau script de migration à partir des modifications déjà appliquées à votre base de données locale (remplacer `<name-of-migration>` avec le nom de la migration)
|
||||
npm run supabase db diff -- -f <name-of-migration>
|
||||
npm run supabase-cli db diff -- -f <name-of-migration>
|
||||
```
|
||||
|
21
app.json
21
app.json
@ -1,26 +1,29 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "p61-project",
|
||||
"name": "Habits Tracker",
|
||||
"slug": "p61-project",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.0-staging.4",
|
||||
"orientation": "portrait",
|
||||
"icon": "./presentation/assets/images/icon.png",
|
||||
"scheme": "p61-project",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./presentation/assets/images/splashscreen.jpg",
|
||||
"image": "./presentation/assets/images/splashscreen.png",
|
||||
"resizeMode": "cover",
|
||||
"backgroundColor": "#74b6cb"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"supportsTablet": true,
|
||||
"buildNumber": "1.0.0"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./presentation/assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
"package": "com.theoludwig.p61project",
|
||||
"versionCode": 3
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
@ -30,6 +33,14 @@
|
||||
"plugins": ["expo-router"],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "5c0a922a-564b-4d62-8231-ce5aef7ff978"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { Stack } from "expo-router"
|
||||
import { fas } from "@fortawesome/free-solid-svg-icons"
|
||||
import { library } from "@fortawesome/fontawesome-svg-core"
|
||||
import * as SplashScreen from "expo-splash-screen"
|
||||
import {
|
||||
MD3LightTheme as DefaultTheme,
|
||||
@ -20,6 +22,8 @@ export const unstableSettings = {
|
||||
initialRouteName: "index",
|
||||
}
|
||||
|
||||
library.add(fas)
|
||||
|
||||
SplashScreen.preventAutoHideAsync().catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { Redirect, Tabs } from "expo-router"
|
||||
import React from "react"
|
||||
|
||||
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
|
||||
import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
|
||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
|
||||
const TabLayout: React.FC = () => {
|
||||
const { user } = useAuthentication()
|
||||
|
||||
if (user == null) {
|
||||
return <Redirect href="/authentication/login" />
|
||||
return <Redirect href="/authentication/about" />
|
||||
}
|
||||
|
||||
return (
|
||||
@ -31,6 +31,7 @@ const TabLayout: React.FC = () => {
|
||||
name="habits/new"
|
||||
options={{
|
||||
title: "New Habit",
|
||||
unmountOnBlur: true,
|
||||
tabBarIcon: ({ color }) => {
|
||||
return <TabBarIcon name="plus-square" color={color} />
|
||||
},
|
||||
@ -39,6 +40,7 @@ const TabLayout: React.FC = () => {
|
||||
<Tabs.Screen
|
||||
name="habits/[habitId]"
|
||||
options={{
|
||||
unmountOnBlur: true,
|
||||
href: null,
|
||||
}}
|
||||
/>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Redirect, useLocalSearchParams } from "expo-router"
|
||||
|
||||
import { HabitEditForm } from "@/presentation/react/components/HabitEditForm/HabitEditForm"
|
||||
import { HabitEditForm } from "@/presentation/react-native/components/HabitForm/HabitEditForm"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
|
||||
const HabitPage: React.FC = () => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
import { ActivityIndicator, Button, Text } from "react-native-paper"
|
||||
|
||||
import { HabitsMainPage } from "@/presentation/react/components/HabitsMainPage/HabitsMainPage"
|
||||
import { HabitsMainPage } from "@/presentation/react-native/components/HabitsMainPage/HabitsMainPage"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { HabitCreateForm } from "@/presentation/react/components/HabitCreateForm/HabitCreateForm"
|
||||
import { HabitCreateForm } from "@/presentation/react-native/components/HabitForm/HabitCreateForm"
|
||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
|
||||
const NewHabitPage: React.FC = () => {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { Text } from "react-native"
|
||||
import { Button } from "react-native-paper"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
|
||||
import { About } from "@/presentation/react-native/components/About"
|
||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
@ -12,26 +11,19 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<About
|
||||
actionButton={
|
||||
<Button
|
||||
mode="contained"
|
||||
labelStyle={{ fontSize: 18 }}
|
||||
onPress={handleLogout}
|
||||
loading={logout.state === "loading"}
|
||||
disabled={logout.state === "loading"}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Redirect, Tabs } from "expo-router"
|
||||
import React from "react"
|
||||
|
||||
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
|
||||
import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
|
||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
|
||||
const TabLayout: React.FC = () => {
|
||||
@ -17,6 +17,15 @@ const TabLayout: React.FC = () => {
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="about"
|
||||
options={{
|
||||
title: "About",
|
||||
tabBarIcon: ({ color }) => {
|
||||
return <TabBarIcon name="info" color={color} />
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="login"
|
||||
options={{
|
||||
|
26
app/authentication/about.tsx
Normal file
26
app/authentication/about.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { Button } from "react-native-paper"
|
||||
import { useRouter } from "expo-router"
|
||||
|
||||
import { About } from "@/presentation/react-native/components/About"
|
||||
|
||||
const AboutPage: React.FC = () => {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<About
|
||||
actionButton={
|
||||
<Button
|
||||
mode="contained"
|
||||
labelStyle={{ fontSize: 18 }}
|
||||
onPress={() => {
|
||||
router.push("/authentication/login")
|
||||
}}
|
||||
>
|
||||
Get Started 🚀
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AboutPage
|
@ -67,6 +67,7 @@ const LoginPage: React.FC = () => {
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
labelStyle={{ fontSize: 18 }}
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
loading={login.state === "loading"}
|
||||
disabled={login.state === "loading"}
|
||||
|
@ -107,6 +107,7 @@ const RegisterPage: React.FC = () => {
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
labelStyle={{ fontSize: 18 }}
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
loading={register.state === "loading"}
|
||||
disabled={register.state === "loading"}
|
||||
|
@ -6,7 +6,7 @@ const HomePage: React.FC = () => {
|
||||
const { user } = useAuthentication()
|
||||
|
||||
if (user == null) {
|
||||
return <Redirect href="/authentication/login" />
|
||||
return <Redirect href="/authentication/about" />
|
||||
}
|
||||
|
||||
return <Redirect href="/application/habits/" />
|
||||
|
BIN
docs/screenshots/habits.png
Normal file
BIN
docs/screenshots/habits.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 158 KiB |
@ -27,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(0),
|
||||
value: z.number().int().min(1),
|
||||
unit: z.string().min(1),
|
||||
}),
|
||||
]),
|
||||
|
@ -8,6 +8,7 @@ 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({
|
||||
@ -29,7 +30,6 @@ 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?.toISOString(),
|
||||
endDate: this?.endDate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { getISODate, getWeekNumber } from "@/utils/dates"
|
||||
import type { Habit } from "./Habit"
|
||||
import type { HabitProgress } from "./HabitProgress"
|
||||
import type { GoalProgress } from "./Goal"
|
||||
import { GoalBooleanProgress, GoalNumericProgress } from "./Goal"
|
||||
import type { Habit } from "./Habit"
|
||||
import type { HabitProgress } from "./HabitProgress"
|
||||
|
||||
export interface HabitHistoryJSON {
|
||||
habit: Habit
|
||||
|
@ -76,4 +76,24 @@ 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))
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
106
domain/entities/__tests__/HabitsTracker.test.ts
Normal file
106
domain/entities/__tests__/HabitsTracker.test.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { HABIT_MOCK } from "@/tests/mocks/domain/Habit"
|
||||
import { GOAL_FREQUENCIES } from "../Goal"
|
||||
import { HabitsTracker } from "../HabitsTracker"
|
||||
import { HabitHistory } from "../HabitHistory"
|
||||
import { HABIT_PROGRESS_MOCK } from "@/tests/mocks/domain/HabitProgress"
|
||||
|
||||
describe("domain/entities/HabitsTracker", () => {
|
||||
describe("HabitsTracker.default", () => {
|
||||
for (const frequency of GOAL_FREQUENCIES) {
|
||||
it(`should return empty habitsHistory for ${frequency}`, () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
expect(habitsTracker.habitsHistory[frequency]).toEqual([])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe("getAllHabitsHistory", () => {
|
||||
it("should return all habits history", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
const habit = HABIT_MOCK.examplesByNames.Walk
|
||||
habitsTracker.addHabit(habit)
|
||||
expect(habitsTracker.getAllHabitsHistory()).toEqual([
|
||||
new HabitHistory({
|
||||
habit,
|
||||
progressHistory: [],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("should return empty array when no habits are added", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
expect(habitsTracker.getAllHabitsHistory()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getHabitHistoryById", () => {
|
||||
it("should return habit history by id", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
const habit = HABIT_MOCK.examplesByNames.Walk
|
||||
habitsTracker.addHabit(habit)
|
||||
expect(habitsTracker.getHabitHistoryById(habit.id)).toEqual(
|
||||
new HabitHistory({
|
||||
habit,
|
||||
progressHistory: [],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should return undefined when habit is not found", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
expect(habitsTracker.getHabitHistoryById("invalid-id")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("addHabit", () => {
|
||||
it("should add habit to habitsHistory", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
const habit = HABIT_MOCK.examplesByNames.Walk
|
||||
habitsTracker.addHabit(habit)
|
||||
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([
|
||||
new HabitHistory({
|
||||
habit,
|
||||
progressHistory: [],
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("editHabit", () => {
|
||||
it("should edit habit in habitsHistory", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
const habit = HABIT_MOCK.examplesByNames.Walk
|
||||
habitsTracker.addHabit(habit)
|
||||
habit.name = "Run"
|
||||
habitsTracker.editHabit(habit)
|
||||
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([
|
||||
new HabitHistory({
|
||||
habit,
|
||||
progressHistory: [],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("should not edit habit in habitsHistory when habit is not found", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
const habit = HABIT_MOCK.examplesByNames.Walk
|
||||
habitsTracker.editHabit(habit)
|
||||
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateHabitProgress", () => {
|
||||
it("should update habit progress in habitsHistory (add new habit progress if not yet added)", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
const habit = HABIT_MOCK.examplesByNames["Clean the house"]
|
||||
habitsTracker.addHabit(habit)
|
||||
habitsTracker.updateHabitProgress(HABIT_PROGRESS_MOCK.exampleByIds[1])
|
||||
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([
|
||||
new HabitHistory({
|
||||
habit,
|
||||
progressHistory: [HABIT_PROGRESS_MOCK.exampleByIds[1]],
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
24
domain/use-cases/HabitStop.ts
Normal file
24
domain/use-cases/HabitStop.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import type { Habit } from "../entities/Habit"
|
||||
import type { HabitEditRepository } from "../repositories/HabitEdit"
|
||||
|
||||
export interface HabitStopUseCaseDependencyOptions {
|
||||
habitEditRepository: HabitEditRepository
|
||||
}
|
||||
|
||||
export class HabitStopUseCase implements HabitStopUseCaseDependencyOptions {
|
||||
public habitEditRepository: HabitEditRepository
|
||||
|
||||
public constructor(options: HabitStopUseCaseDependencyOptions) {
|
||||
this.habitEditRepository = options.habitEditRepository
|
||||
}
|
||||
|
||||
public async execute(habitToStop: Habit): Promise<Habit> {
|
||||
const habit = await this.habitEditRepository.execute({
|
||||
habitEditData: {
|
||||
...habitToStop,
|
||||
endDate: new Date(),
|
||||
},
|
||||
})
|
||||
return habit
|
||||
}
|
||||
}
|
15
eas.json
Normal file
15
eas.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"staging": {
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {}
|
||||
}
|
||||
}
|
@ -1,18 +1,19 @@
|
||||
import { AuthenticationUseCase } from "@/domain/use-cases/Authentication"
|
||||
import { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
|
||||
import { HabitEditUseCase } from "@/domain/use-cases/HabitEdit"
|
||||
import { HabitGoalProgressUpdateUseCase } from "@/domain/use-cases/HabitGoalProgressUpdate"
|
||||
import { HabitStopUseCase } from "@/domain/use-cases/HabitStop"
|
||||
import { AuthenticationPresenter } from "@/presentation/presenters/Authentication"
|
||||
import { RetrieveHabitsTrackerUseCase } from "../domain/use-cases/RetrieveHabitsTracker"
|
||||
import { HabitsTrackerPresenter } from "../presentation/presenters/HabitsTracker"
|
||||
import { AuthenticationSupabaseRepository } from "./supabase/repositories/Authentication"
|
||||
import { GetHabitProgressHistorySupabaseRepository } from "./supabase/repositories/GetHabitProgressHistory"
|
||||
import { GetHabitsByUserIdSupabaseRepository } from "./supabase/repositories/GetHabitsByUserId"
|
||||
import { 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 { HabitGoalProgressUpdateUseCase } from "@/domain/use-cases/HabitGoalProgressUpdate"
|
||||
import { supabaseClient } from "./supabase/supabase"
|
||||
|
||||
/**
|
||||
* Repositories
|
||||
@ -64,6 +65,9 @@ const habitGoalProgressUpdateUseCase = new HabitGoalProgressUpdateUseCase({
|
||||
habitProgressCreateRepository,
|
||||
habitProgressUpdateRepository,
|
||||
})
|
||||
const habitStopUseCase = new HabitStopUseCase({
|
||||
habitEditRepository,
|
||||
})
|
||||
|
||||
/**
|
||||
* Presenters
|
||||
@ -75,5 +79,6 @@ export const habitsTrackerPresenter = new HabitsTrackerPresenter({
|
||||
retrieveHabitsTrackerUseCase,
|
||||
habitCreateUseCase,
|
||||
habitEditUseCase,
|
||||
habitStopUseCase,
|
||||
habitGoalProgressUpdateUseCase,
|
||||
})
|
||||
|
79
infrastructure/supabase/data-transfer-objects/HabitDTO.ts
Normal file
79
infrastructure/supabase/data-transfer-objects/HabitDTO.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import type { Goal } from "@/domain/entities/Goal"
|
||||
import { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
|
||||
import type { HabitCreateData, HabitEditData } from "@/domain/entities/Habit"
|
||||
import { Habit } from "@/domain/entities/Habit"
|
||||
import type {
|
||||
SupabaseHabit,
|
||||
SupabaseHabitInsert,
|
||||
SupabaseHabitUpdate,
|
||||
} from "../supabase"
|
||||
|
||||
export const habitSupabaseDTO = {
|
||||
fromSupabaseToDomain: (supabaseHabit: SupabaseHabit): Habit => {
|
||||
let goal: Goal
|
||||
if (
|
||||
supabaseHabit.goal_target != null &&
|
||||
supabaseHabit.goal_target_unit != null
|
||||
) {
|
||||
goal = new GoalNumeric({
|
||||
frequency: supabaseHabit.goal_frequency,
|
||||
target: {
|
||||
value: supabaseHabit.goal_target,
|
||||
unit: supabaseHabit.goal_target_unit,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
goal = new GoalBoolean({
|
||||
frequency: supabaseHabit.goal_frequency,
|
||||
})
|
||||
}
|
||||
const habit = new Habit({
|
||||
id: supabaseHabit.id.toString(),
|
||||
name: supabaseHabit.name,
|
||||
color: supabaseHabit.color,
|
||||
icon: supabaseHabit.icon,
|
||||
userId: supabaseHabit.user_id.toString(),
|
||||
startDate: new Date(supabaseHabit.start_date),
|
||||
endDate:
|
||||
supabaseHabit.end_date != null
|
||||
? new Date(supabaseHabit.end_date)
|
||||
: undefined,
|
||||
goal,
|
||||
})
|
||||
return habit
|
||||
},
|
||||
fromDomainCreateDataToSupabaseInsert: (
|
||||
habitCreateData: HabitCreateData,
|
||||
): SupabaseHabitInsert => {
|
||||
return {
|
||||
name: habitCreateData.name,
|
||||
color: habitCreateData.color,
|
||||
icon: habitCreateData.icon,
|
||||
goal_frequency: habitCreateData.goal.frequency,
|
||||
...(habitCreateData.goal.target.type === "numeric"
|
||||
? {
|
||||
goal_target: habitCreateData.goal.target.value,
|
||||
goal_target_unit: habitCreateData.goal.target.unit,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
},
|
||||
fromDomainEditDataToSupabaseUpdate: (
|
||||
habitEditData: HabitEditData,
|
||||
): SupabaseHabitUpdate => {
|
||||
return {
|
||||
name: habitEditData.name,
|
||||
color: habitEditData.color,
|
||||
icon: habitEditData.icon,
|
||||
end_date: habitEditData?.endDate?.toISOString(),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const habitsSupabaseDTO = {
|
||||
fromSupabaseToDomain: (supabaseHabits: SupabaseHabit[]): Habit[] => {
|
||||
return supabaseHabits.map((supabaseHabit) => {
|
||||
return habitSupabaseDTO.fromSupabaseToDomain(supabaseHabit)
|
||||
})
|
||||
},
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
import type { Goal, GoalProgress } from "@/domain/entities/Goal"
|
||||
import {
|
||||
GoalBooleanProgress,
|
||||
GoalNumericProgress,
|
||||
} from "@/domain/entities/Goal"
|
||||
import { HabitProgress } from "@/domain/entities/HabitProgress"
|
||||
import type { HabitProgressCreateOptions } from "@/domain/repositories/HabitProgressCreate"
|
||||
import type { HabitProgressUpdateOptions } from "@/domain/repositories/HabitProgressUpdate"
|
||||
import type {
|
||||
SupabaseHabitProgress,
|
||||
SupabaseHabitProgressInsert,
|
||||
SupabaseHabitProgressUpdate,
|
||||
} from "../supabase"
|
||||
|
||||
export const habitProgressSupabaseDTO = {
|
||||
fromSupabaseToDomain: (
|
||||
supabaseHabitProgress: SupabaseHabitProgress,
|
||||
goal: Goal,
|
||||
): HabitProgress => {
|
||||
let goalProgress: GoalProgress | null = null
|
||||
if (goal.isNumeric()) {
|
||||
goalProgress = new GoalNumericProgress({
|
||||
goal,
|
||||
progress: supabaseHabitProgress.goal_progress,
|
||||
})
|
||||
} else if (goal.isBoolean()) {
|
||||
goalProgress = new GoalBooleanProgress({
|
||||
goal,
|
||||
progress: supabaseHabitProgress.goal_progress === 1,
|
||||
})
|
||||
}
|
||||
const habitProgress = new HabitProgress({
|
||||
id: supabaseHabitProgress.id.toString(),
|
||||
habitId: supabaseHabitProgress.habit_id.toString(),
|
||||
goalProgress: goalProgress as GoalProgress,
|
||||
date: new Date(supabaseHabitProgress.date),
|
||||
})
|
||||
return habitProgress
|
||||
},
|
||||
fromDomainDataToSupabaseInsert: (
|
||||
habitProgressData: HabitProgressCreateOptions["habitProgressData"],
|
||||
): SupabaseHabitProgressInsert => {
|
||||
const { goalProgress, date, habitId } = habitProgressData
|
||||
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
|
||||
if (goalProgress.isNumeric()) {
|
||||
goalProgressValue = goalProgress.progress
|
||||
}
|
||||
return {
|
||||
habit_id: Number.parseInt(habitId, 10),
|
||||
date: date.toISOString(),
|
||||
goal_progress: goalProgressValue,
|
||||
}
|
||||
},
|
||||
fromDomainDataToSupabaseUpdate: (
|
||||
habitProgressData: HabitProgressUpdateOptions["habitProgressData"],
|
||||
): SupabaseHabitProgressUpdate => {
|
||||
const { goalProgress, date } = habitProgressData
|
||||
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
|
||||
if (goalProgress.isNumeric()) {
|
||||
goalProgressValue = goalProgress.progress
|
||||
}
|
||||
return {
|
||||
date: date.toISOString(),
|
||||
goal_progress: goalProgressValue,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const habitProgressHistorySupabaseDTO = {
|
||||
fromSupabaseToDomain: (
|
||||
supabaseHabitHistory: SupabaseHabitProgress[],
|
||||
goal: Goal,
|
||||
): HabitProgress[] => {
|
||||
return supabaseHabitHistory.map((item) => {
|
||||
return habitProgressSupabaseDTO.fromSupabaseToDomain(item, goal)
|
||||
})
|
||||
},
|
||||
}
|
@ -0,0 +1,100 @@
|
||||
import type { GoalCreateData } from "@/domain/entities/Goal"
|
||||
import { HABIT_MOCK } from "@/tests/mocks/domain/Habit"
|
||||
import { SUPABASE_HABIT_MOCK } from "@/tests/mocks/supabase/Habit"
|
||||
import { habitSupabaseDTO, habitsSupabaseDTO } from "../HabitDTO"
|
||||
|
||||
describe("infrastructure/supabase/data-transfer-objects/HabitDTO", () => {
|
||||
describe("habitSupabaseDTO.fromSupabaseToDomain", () => {
|
||||
for (const example of SUPABASE_HABIT_MOCK.examples) {
|
||||
it(`should return correct Habit entity - ${example.name}`, () => {
|
||||
expect(habitSupabaseDTO.fromSupabaseToDomain(example)).toEqual(
|
||||
HABIT_MOCK.examplesByNames[
|
||||
example.name as keyof typeof HABIT_MOCK.examplesByNames
|
||||
],
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe("habitSupabaseDTO.fromDomainCreateDataToSupabaseInsert", () => {
|
||||
for (const example of HABIT_MOCK.examples) {
|
||||
it(`should return correct SupabaseHabitInsert entity - ${example.name}`, () => {
|
||||
let goalData = {} as GoalCreateData
|
||||
if (example.goal.isBoolean()) {
|
||||
goalData = {
|
||||
frequency: example.goal.frequency,
|
||||
target: { type: "boolean" },
|
||||
}
|
||||
}
|
||||
if (example.goal.isNumeric()) {
|
||||
goalData = {
|
||||
frequency: example.goal.frequency,
|
||||
target: {
|
||||
type: "numeric",
|
||||
value: example.goal.target.value,
|
||||
unit: example.goal.target.unit,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const supabaseData =
|
||||
SUPABASE_HABIT_MOCK.examplesByNames[
|
||||
example.name as keyof typeof SUPABASE_HABIT_MOCK.examplesByNames
|
||||
]
|
||||
expect(
|
||||
habitSupabaseDTO.fromDomainCreateDataToSupabaseInsert({
|
||||
userId: example.userId,
|
||||
name: example.name,
|
||||
color: example.color,
|
||||
icon: example.icon,
|
||||
goal: goalData,
|
||||
}),
|
||||
).toEqual({
|
||||
name: supabaseData.name,
|
||||
color: supabaseData.color,
|
||||
icon: supabaseData.icon,
|
||||
goal_frequency: supabaseData.goal_frequency,
|
||||
...(supabaseData.goal_target != null &&
|
||||
supabaseData.goal_target_unit != null
|
||||
? {
|
||||
goal_target: supabaseData.goal_target,
|
||||
goal_target_unit: supabaseData.goal_target_unit,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe("habitSupabaseDTO.fromDomainEditDataToSupabaseUpdate", () => {
|
||||
for (const example of HABIT_MOCK.examples) {
|
||||
it(`should return correct SupabaseHabitUpdate entity - ${example.name}`, () => {
|
||||
const supabaseData =
|
||||
SUPABASE_HABIT_MOCK.examplesByNames[
|
||||
example.name as keyof typeof SUPABASE_HABIT_MOCK.examplesByNames
|
||||
]
|
||||
expect(
|
||||
habitSupabaseDTO.fromDomainEditDataToSupabaseUpdate({
|
||||
name: example.name,
|
||||
color: example.color,
|
||||
icon: example.icon,
|
||||
id: example.id,
|
||||
userId: example.userId,
|
||||
}),
|
||||
).toEqual({
|
||||
name: supabaseData.name,
|
||||
color: supabaseData.color,
|
||||
icon: supabaseData.icon,
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe("habitsSupabaseDTO.fromSupabaseToDomain", () => {
|
||||
it("should return correct Habits entities", () => {
|
||||
expect(
|
||||
habitsSupabaseDTO.fromSupabaseToDomain(SUPABASE_HABIT_MOCK.examples),
|
||||
).toEqual(HABIT_MOCK.examples)
|
||||
})
|
||||
})
|
||||
})
|
@ -0,0 +1,22 @@
|
||||
import type { Habit } from "@/domain/entities/Habit"
|
||||
import { HABIT_MOCK } from "@/tests/mocks/domain/Habit"
|
||||
import { HABIT_PROGRESS_MOCK } from "@/tests/mocks/domain/HabitProgress"
|
||||
import { SUPABASE_HABIT_PROGRESS_MOCK } from "@/tests/mocks/supabase/HabitProgress"
|
||||
import { habitProgressSupabaseDTO } from "../HabitProgressDTO"
|
||||
|
||||
describe("infrastructure/supabase/data-transfer-objects/HabitProgressDTO", () => {
|
||||
describe("habitProgressSupabaseDTO.fromSupabaseToDomain", () => {
|
||||
for (const example of SUPABASE_HABIT_PROGRESS_MOCK.examples) {
|
||||
it(`should return correct HabitProgress entity - ${example.id}`, () => {
|
||||
const habit = HABIT_MOCK.examplesByIds[example.habit_id] as Habit
|
||||
expect(
|
||||
habitProgressSupabaseDTO.fromSupabaseToDomain(example, habit.goal),
|
||||
).toEqual(
|
||||
HABIT_PROGRESS_MOCK.exampleByIds[
|
||||
example.id as keyof typeof HABIT_PROGRESS_MOCK.exampleByIds
|
||||
],
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
@ -1,8 +1,8 @@
|
||||
import type { Session } from "@supabase/supabase-js"
|
||||
|
||||
import type { AuthenticationRepository } from "@/domain/repositories/Authentication"
|
||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
||||
import { User } from "@/domain/entities/User"
|
||||
import type { AuthenticationRepository } from "@/domain/repositories/Authentication"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
|
||||
export class AuthenticationSupabaseRepository
|
||||
extends SupabaseRepository
|
||||
|
@ -1,11 +1,6 @@
|
||||
import type { GetHabitProgressHistoryRepository } from "@/domain/repositories/GetHabitProgressHistory"
|
||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
||||
import { HabitProgress } from "@/domain/entities/HabitProgress"
|
||||
import type { GoalProgress } from "@/domain/entities/Goal"
|
||||
import {
|
||||
GoalBooleanProgress,
|
||||
GoalNumericProgress,
|
||||
} from "@/domain/entities/Goal"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
import { habitProgressHistorySupabaseDTO } from "../data-transfer-objects/HabitProgressDTO"
|
||||
|
||||
export class GetHabitProgressHistorySupabaseRepository
|
||||
extends SupabaseRepository
|
||||
@ -15,37 +10,15 @@ export class GetHabitProgressHistorySupabaseRepository
|
||||
options,
|
||||
) => {
|
||||
const { habit } = options
|
||||
const { data, error } = await this.supabaseClient
|
||||
const { data } = await this.supabaseClient
|
||||
.from("habits_progresses")
|
||||
.select("*")
|
||||
.eq("habit_id", habit.id)
|
||||
if (error != null) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
const habitProgressHistory = data.map((item) => {
|
||||
let goalProgress: GoalProgress | null = null
|
||||
if (habit.goal.isNumeric()) {
|
||||
goalProgress = new GoalNumericProgress({
|
||||
goal: habit.goal,
|
||||
progress: item.goal_progress,
|
||||
})
|
||||
} else if (habit.goal.isBoolean()) {
|
||||
goalProgress = new GoalBooleanProgress({
|
||||
goal: habit.goal,
|
||||
progress: item.goal_progress === 1,
|
||||
})
|
||||
}
|
||||
if (goalProgress == null) {
|
||||
throw new Error("Goal progress is null.")
|
||||
}
|
||||
const habitProgress = new HabitProgress({
|
||||
id: item.id.toString(),
|
||||
habitId: item.habit_id.toString(),
|
||||
goalProgress,
|
||||
date: new Date(item.date),
|
||||
})
|
||||
return habitProgress
|
||||
})
|
||||
return habitProgressHistory
|
||||
.throwOnError()
|
||||
const habitProgressHistory = data as NonNullable<typeof data>
|
||||
return habitProgressHistorySupabaseDTO.fromSupabaseToDomain(
|
||||
habitProgressHistory,
|
||||
habit.goal,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,6 @@
|
||||
import type { GetHabitsByUserIdRepository } from "@/domain/repositories/GetHabitsByUserId"
|
||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
||||
import { Habit } from "@/domain/entities/Habit"
|
||||
import type { Goal } from "@/domain/entities/Goal"
|
||||
import { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
import { habitsSupabaseDTO } from "../data-transfer-objects/HabitDTO"
|
||||
|
||||
export class GetHabitsByUserIdSupabaseRepository
|
||||
extends SupabaseRepository
|
||||
@ -10,39 +8,12 @@ export class GetHabitsByUserIdSupabaseRepository
|
||||
{
|
||||
public execute: GetHabitsByUserIdRepository["execute"] = async (options) => {
|
||||
const { userId } = options
|
||||
const { data, error } = await this.supabaseClient
|
||||
const { data } = await this.supabaseClient
|
||||
.from("habits")
|
||||
.select("*")
|
||||
.eq("user_id", userId)
|
||||
if (error != null) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
return data.map((item) => {
|
||||
let goal: Goal
|
||||
if (item.goal_target != null && item.goal_target_unit != null) {
|
||||
goal = new GoalNumeric({
|
||||
frequency: item.goal_frequency,
|
||||
target: {
|
||||
value: item.goal_target,
|
||||
unit: item.goal_target_unit,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
goal = new GoalBoolean({
|
||||
frequency: item.goal_frequency,
|
||||
})
|
||||
}
|
||||
const habit = new Habit({
|
||||
id: item.id.toString(),
|
||||
name: item.name,
|
||||
color: item.color,
|
||||
icon: item.icon,
|
||||
userId: item.user_id.toString(),
|
||||
startDate: new Date(item.start_date),
|
||||
endDate: item.end_date != null ? new Date(item.end_date) : undefined,
|
||||
goal,
|
||||
})
|
||||
return habit
|
||||
})
|
||||
.throwOnError()
|
||||
const habits = data as NonNullable<typeof data>
|
||||
return habitsSupabaseDTO.fromSupabaseToDomain(habits)
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { Habit } from "@/domain/entities/Habit"
|
||||
import type { HabitCreateRepository } from "@/domain/repositories/HabitCreate"
|
||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
||||
import { Goal } from "@/domain/entities/Goal"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
import { habitSupabaseDTO } from "../data-transfer-objects/HabitDTO"
|
||||
|
||||
export class HabitCreateSupabaseRepository
|
||||
extends SupabaseRepository
|
||||
@ -9,34 +8,15 @@ export class HabitCreateSupabaseRepository
|
||||
{
|
||||
public execute: HabitCreateRepository["execute"] = async (options) => {
|
||||
const { habitCreateData } = options
|
||||
const { data, error } = await this.supabaseClient
|
||||
const { data } = await this.supabaseClient
|
||||
.from("habits")
|
||||
.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,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
.insert(
|
||||
habitSupabaseDTO.fromDomainCreateDataToSupabaseInsert(habitCreateData),
|
||||
)
|
||||
.select("*")
|
||||
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
|
||||
.single()
|
||||
.throwOnError()
|
||||
const insertedHabit = data as NonNullable<typeof data>
|
||||
return habitSupabaseDTO.fromSupabaseToDomain(insertedHabit)
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { Habit } from "@/domain/entities/Habit"
|
||||
import type { HabitEditRepository } from "@/domain/repositories/HabitEdit"
|
||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
||||
import { Goal } from "@/domain/entities/Goal"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
import { habitSupabaseDTO } from "../data-transfer-objects/HabitDTO"
|
||||
|
||||
export class HabitEditSupabaseRepository
|
||||
extends SupabaseRepository
|
||||
@ -9,41 +8,16 @@ export class HabitEditSupabaseRepository
|
||||
{
|
||||
public execute: HabitEditRepository["execute"] = async (options) => {
|
||||
const { habitEditData } = options
|
||||
const { data, error } = await this.supabaseClient
|
||||
const { data } = await this.supabaseClient
|
||||
.from("habits")
|
||||
.update({
|
||||
name: habitEditData.name,
|
||||
color: habitEditData.color,
|
||||
icon: habitEditData.icon,
|
||||
})
|
||||
.update(
|
||||
habitSupabaseDTO.fromDomainEditDataToSupabaseUpdate(habitEditData),
|
||||
)
|
||||
.eq("id", habitEditData.id)
|
||||
.select("*")
|
||||
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
|
||||
.single()
|
||||
.throwOnError()
|
||||
const updatedHabit = data as NonNullable<typeof data>
|
||||
return habitSupabaseDTO.fromSupabaseToDomain(updatedHabit)
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import type { HabitProgressCreateRepository } from "@/domain/repositories/HabitProgressCreate"
|
||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
||||
import { HabitProgress } from "@/domain/entities/HabitProgress"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
import { habitProgressSupabaseDTO } from "../data-transfer-objects/HabitProgressDTO"
|
||||
|
||||
export class HabitProgressCreateSupabaseRepository
|
||||
extends SupabaseRepository
|
||||
@ -10,29 +10,20 @@ export class HabitProgressCreateSupabaseRepository
|
||||
options,
|
||||
) => {
|
||||
const { habitProgressData } = options
|
||||
const { goalProgress, date, habitId } = habitProgressData
|
||||
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
|
||||
if (goalProgress.isNumeric()) {
|
||||
goalProgressValue = goalProgress.progress
|
||||
}
|
||||
const { data, error } = await this.supabaseClient
|
||||
const { data } = await this.supabaseClient
|
||||
.from("habits_progresses")
|
||||
.insert({
|
||||
habit_id: Number(habitId),
|
||||
date: date.toISOString(),
|
||||
goal_progress: goalProgressValue,
|
||||
})
|
||||
.insert(
|
||||
habitProgressSupabaseDTO.fromDomainDataToSupabaseInsert(
|
||||
habitProgressData,
|
||||
),
|
||||
)
|
||||
.select("*")
|
||||
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
|
||||
.single()
|
||||
.throwOnError()
|
||||
const insertedProgress = data as NonNullable<typeof data>
|
||||
return habitProgressSupabaseDTO.fromSupabaseToDomain(
|
||||
insertedProgress,
|
||||
habitProgressData.goalProgress.goal,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import type { HabitProgressUpdateRepository } from "@/domain/repositories/HabitProgressUpdate"
|
||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
||||
import { HabitProgress } from "@/domain/entities/HabitProgress"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
import { habitProgressSupabaseDTO } from "../data-transfer-objects/HabitProgressDTO"
|
||||
|
||||
export class HabitProgressUpdateSupabaseRepository
|
||||
extends SupabaseRepository
|
||||
@ -10,29 +10,21 @@ export class HabitProgressUpdateSupabaseRepository
|
||||
options,
|
||||
) => {
|
||||
const { habitProgressData } = options
|
||||
const { id, goalProgress, date } = habitProgressData
|
||||
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
|
||||
if (goalProgress.isNumeric()) {
|
||||
goalProgressValue = goalProgress.progress
|
||||
}
|
||||
const { data, error } = await this.supabaseClient
|
||||
const { data } = await this.supabaseClient
|
||||
.from("habits_progresses")
|
||||
.update({
|
||||
date: date.toISOString(),
|
||||
goal_progress: goalProgressValue,
|
||||
})
|
||||
.eq("id", id)
|
||||
.update(
|
||||
habitProgressSupabaseDTO.fromDomainDataToSupabaseUpdate(
|
||||
habitProgressData,
|
||||
),
|
||||
)
|
||||
.eq("id", habitProgressData.id)
|
||||
.select("*")
|
||||
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
|
||||
.single()
|
||||
.throwOnError()
|
||||
const insertedProgress = data as NonNullable<typeof data>
|
||||
return habitProgressSupabaseDTO.fromSupabaseToDomain(
|
||||
insertedProgress,
|
||||
habitProgressData.goalProgress.goal,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -117,7 +117,7 @@ VALUES
|
||||
'Wake up at 07h00',
|
||||
'#006CFF',
|
||||
'bed',
|
||||
timezone('utc' :: text, NOW()),
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
NULL,
|
||||
'daily',
|
||||
NULL,
|
||||
@ -144,7 +144,7 @@ VALUES
|
||||
'Learn English',
|
||||
'#EB4034',
|
||||
'language',
|
||||
timezone('utc' :: text, NOW()),
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
NULL,
|
||||
'daily',
|
||||
30,
|
||||
@ -171,7 +171,7 @@ VALUES
|
||||
'Walk',
|
||||
'#228B22',
|
||||
'person-walking',
|
||||
timezone('utc' :: text, NOW()),
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
NULL,
|
||||
'daily',
|
||||
5000,
|
||||
@ -198,7 +198,7 @@ VALUES
|
||||
'Clean the house',
|
||||
'#808080',
|
||||
'broom',
|
||||
timezone('utc' :: text, NOW()),
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
NULL,
|
||||
'weekly',
|
||||
NULL,
|
||||
@ -225,7 +225,7 @@ VALUES
|
||||
'Solve Programming Challenges',
|
||||
'#DE3163',
|
||||
'code',
|
||||
timezone('utc' :: text, NOW()),
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
NULL,
|
||||
'monthly',
|
||||
5,
|
||||
@ -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);
|
||||
|
@ -1,10 +1,28 @@
|
||||
import { createClient } from "@supabase/supabase-js"
|
||||
import {
|
||||
createClient,
|
||||
type User as SupabaseUserType,
|
||||
} from "@supabase/supabase-js"
|
||||
import { AppState, Platform } from "react-native"
|
||||
import "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"
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"preset": "jest-expo",
|
||||
"roots": ["./"],
|
||||
"setupFilesAfterEnv": ["@testing-library/react-native/extend-expect"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/tests/setup.ts"],
|
||||
"fakeTimers": {
|
||||
"enableGlobally": true
|
||||
},
|
||||
@ -10,7 +10,13 @@
|
||||
"coverageReporters": ["text", "text-summary", "cobertura"],
|
||||
"collectCoverageFrom": [
|
||||
"<rootDir>/**/*.{ts,tsx}",
|
||||
"!<rootDir>/presentation/react/components/ExternalLink.tsx",
|
||||
"!<rootDir>/tests/**/*",
|
||||
"!<rootDir>/domain/repositories/**/*",
|
||||
"!<rootDir>/infrastructure/instances.ts",
|
||||
"!<rootDir>/infrastructure/supabase/supabase-types.ts",
|
||||
"!<rootDir>/infrastructure/supabase/supabase.ts",
|
||||
"!<rootDir>/presentation/react-native/ui/ExternalLink.tsx",
|
||||
"!<rootDir>/presentation/react/contexts/**/*",
|
||||
"!<rootDir>/.expo",
|
||||
"!<rootDir>/app/+html.tsx",
|
||||
"!<rootDir>/app/**/_layout.tsx",
|
||||
|
7199
package-lock.json
generated
7199
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
88
package.json
88
package.json
@ -2,86 +2,90 @@
|
||||
"name": "p61-project",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0-staging.2",
|
||||
"version": "1.0.0-staging.4",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"expo:typed-routes": "expo customize tsconfig.json",
|
||||
"build-staging:android": "eas build --platform=android --profile=staging",
|
||||
"lint:commit": "commitlint",
|
||||
"lint:prettier": "prettier . --check",
|
||||
"lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore",
|
||||
"lint:typescript": "tsc --noEmit",
|
||||
"lint:staged": "lint-staged",
|
||||
"test": "jest --reporters=default --reporters=jest-junit",
|
||||
"supabase": "supabase --workdir \"./infrastructure\"",
|
||||
"supabase-cli": "supabase --workdir \"./infrastructure\"",
|
||||
"postinstall": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "14.0.0",
|
||||
"@hookform/resolvers": "3.3.4",
|
||||
"@react-native-async-storage/async-storage": "1.21.0",
|
||||
"@react-navigation/native": "6.1.16",
|
||||
"@supabase/supabase-js": "2.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",
|
||||
"@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.0",
|
||||
"@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",
|
||||
"immer": "10.1.1",
|
||||
"lottie-react-native": "6.7.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "7.51.2",
|
||||
"react-native": "0.73.6",
|
||||
"react-native-calendars": "1.1304.1",
|
||||
"react-hook-form": "7.51.4",
|
||||
"react-native": "0.74.1",
|
||||
"react-native-calendars": "1.1305.0",
|
||||
"react-native-circular-progress-indicator": "4.4.2",
|
||||
"react-native-elements": "3.4.3",
|
||||
"react-native-gesture-handler": "2.14.1",
|
||||
"react-native-gesture-handler": "2.16.2",
|
||||
"react-native-paper": "5.12.3",
|
||||
"react-native-reanimated": "3.6.3",
|
||||
"react-native-safe-area-context": "4.8.2",
|
||||
"react-native-screens": "3.29.0",
|
||||
"react-native-reanimated": "3.10.1",
|
||||
"react-native-safe-area-context": "4.10.1",
|
||||
"react-native-screens": "3.31.1",
|
||||
"react-native-svg": "15.2.0",
|
||||
"react-native-svg-transformer": "1.4.0",
|
||||
"react-native-url-polyfill": "2.0.0",
|
||||
"react-native-vector-icons": "10.0.3",
|
||||
"react-native-web": "0.19.10",
|
||||
"react-native-vector-icons": "10.1.0",
|
||||
"react-native-web": "0.19.11",
|
||||
"reanimated-color-picker": "3.0.3",
|
||||
"zod": "3.22.4"
|
||||
"zod": "3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.24.4",
|
||||
"@commitlint/cli": "19.1.0",
|
||||
"@commitlint/config-conventional": "19.1.0",
|
||||
"@testing-library/react-native": "12.4.5",
|
||||
"@babel/core": "7.24.5",
|
||||
"@commitlint/cli": "19.2.2",
|
||||
"@commitlint/config-conventional": "19.2.2",
|
||||
"@testing-library/react-native": "12.5.0",
|
||||
"@total-typescript/ts-reset": "0.5.1",
|
||||
"@tsconfig/strictest": "2.0.5",
|
||||
"@types/jest": "29.5.12",
|
||||
"@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",
|
||||
"@types/node": "20.12.12",
|
||||
"@types/react": "18.2.79",
|
||||
"@types/react-test-renderer": "18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.9.0",
|
||||
"@typescript-eslint/parser": "7.9.0",
|
||||
"eslint": "8.57.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.0",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"eslint-plugin-react-native": "4.1.0",
|
||||
"eslint-plugin-unicorn": "51.0.1",
|
||||
"husky": "9.0.11",
|
||||
"jest": "29.7.0",
|
||||
"jest-expo": "50.0.4",
|
||||
"jest-expo": "51.0.2",
|
||||
"jest-junit": "16.0.0",
|
||||
"lint-staged": "15.2.2",
|
||||
"prettier": "3.2.5",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"supabase": "1.153.4",
|
||||
"typescript": "5.4.5"
|
||||
"supabase": "1.167.4",
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 11 KiB |
Binary file not shown.
Before Width: | Height: | Size: 97 KiB |
BIN
presentation/assets/images/splashscreen.png
Normal file
BIN
presentation/assets/images/splashscreen.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 52 KiB |
@ -7,10 +7,15 @@ import type {
|
||||
RetrieveHabitsTrackerUseCase,
|
||||
RetrieveHabitsTrackerUseCaseOptions,
|
||||
} from "@/domain/use-cases/RetrieveHabitsTracker"
|
||||
import type { HabitCreateData, HabitEditData } from "@/domain/entities/Habit"
|
||||
import type {
|
||||
Habit,
|
||||
HabitCreateData,
|
||||
HabitEditData,
|
||||
} from "@/domain/entities/Habit"
|
||||
import { getErrorsFieldsFromZodError } from "../../utils/zod"
|
||||
import 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,
|
||||
@ -39,6 +44,10 @@ export interface HabitsTrackerPresenterState {
|
||||
}
|
||||
}
|
||||
|
||||
habitStop: {
|
||||
state: FetchState
|
||||
}
|
||||
|
||||
habitGoalProgressUpdate: {
|
||||
state: FetchState
|
||||
}
|
||||
@ -48,6 +57,7 @@ export interface HabitsTrackerPresenterOptions {
|
||||
retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
|
||||
habitCreateUseCase: HabitCreateUseCase
|
||||
habitEditUseCase: HabitEditUseCase
|
||||
habitStopUseCase: HabitStopUseCase
|
||||
habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase
|
||||
}
|
||||
|
||||
@ -58,6 +68,7 @@ export class HabitsTrackerPresenter
|
||||
public retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
|
||||
public habitCreateUseCase: HabitCreateUseCase
|
||||
public habitEditUseCase: HabitEditUseCase
|
||||
public habitStopUseCase: HabitStopUseCase
|
||||
public habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase
|
||||
|
||||
public constructor(options: HabitsTrackerPresenterOptions) {
|
||||
@ -65,6 +76,7 @@ export class HabitsTrackerPresenter
|
||||
retrieveHabitsTrackerUseCase,
|
||||
habitCreateUseCase,
|
||||
habitEditUseCase,
|
||||
habitStopUseCase,
|
||||
habitGoalProgressUpdateUseCase,
|
||||
} = options
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
@ -85,6 +97,9 @@ export class HabitsTrackerPresenter
|
||||
global: null,
|
||||
},
|
||||
},
|
||||
habitStop: {
|
||||
state: "idle",
|
||||
},
|
||||
habitGoalProgressUpdate: {
|
||||
state: "idle",
|
||||
},
|
||||
@ -92,6 +107,7 @@ export class HabitsTrackerPresenter
|
||||
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
|
||||
this.habitCreateUseCase = habitCreateUseCase
|
||||
this.habitEditUseCase = habitEditUseCase
|
||||
this.habitStopUseCase = habitStopUseCase
|
||||
this.habitGoalProgressUpdateUseCase = habitGoalProgressUpdateUseCase
|
||||
}
|
||||
|
||||
@ -153,6 +169,25 @@ export class HabitsTrackerPresenter
|
||||
}
|
||||
}
|
||||
|
||||
public async habitStop(habitToStop: Habit): Promise<FetchState> {
|
||||
try {
|
||||
this.setState((state) => {
|
||||
state.habitStop.state = "loading"
|
||||
})
|
||||
const habit = await this.habitStopUseCase.execute(habitToStop)
|
||||
this.setState((state) => {
|
||||
state.habitStop.state = "success"
|
||||
state.habitsTracker.editHabit(habit)
|
||||
})
|
||||
return "success"
|
||||
} catch (error) {
|
||||
this.setState((state) => {
|
||||
state.habitStop.state = "error"
|
||||
})
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
|
||||
public async retrieveHabitsTracker(
|
||||
options: RetrieveHabitsTrackerUseCaseOptions,
|
||||
): Promise<void> {
|
||||
|
@ -41,10 +41,7 @@ export abstract class Presenter<State> {
|
||||
|
||||
public unsubscribe(listener: Listener<State>): void {
|
||||
const listenerIndex = this._listeners.indexOf(listener)
|
||||
const listenerFound = listenerIndex !== -1
|
||||
if (listenerFound) {
|
||||
this._listeners.splice(listenerIndex, 1)
|
||||
}
|
||||
this._listeners.splice(listenerIndex, 1)
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
|
81
presentation/react-native/components/About.tsx
Normal file
81
presentation/react-native/components/About.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { View } from "react-native"
|
||||
import { Text } from "react-native-paper"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
|
||||
import { ExternalLink } from "@/presentation/react-native/ui/ExternalLink"
|
||||
import { getVersion } from "@/utils/version"
|
||||
|
||||
export interface AboutProps {
|
||||
actionButton: React.ReactNode
|
||||
}
|
||||
|
||||
export const About: React.FC<AboutProps> = (props) => {
|
||||
const { actionButton } = props
|
||||
|
||||
const version = getVersion()
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
>
|
||||
<View style={{ alignItems: "center", marginVertical: 20 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: 28,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Habits Tracker
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 6,
|
||||
fontWeight: "bold",
|
||||
fontSize: 18,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
To perform at work and in everyday life.
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 6,
|
||||
fontWeight: "bold",
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
v{version}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text variant="bodyLarge" style={{ textAlign: "center" }}>
|
||||
<ExternalLink href="https://unistra.fr" style={{ color: "#006CFF" }}>
|
||||
Université de Strasbourg
|
||||
</ExternalLink>
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={{ textAlign: "center" }}>
|
||||
BUT Informatique - IUT Robert Schuman
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={{ textAlign: "center" }}>
|
||||
P61 Mobile Development
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginVertical: 20,
|
||||
}}
|
||||
>
|
||||
{actionButton}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
@ -1,6 +1,9 @@
|
||||
import type { IconName } from "@fortawesome/free-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { ScrollView, StyleSheet } from "react-native"
|
||||
import { ScrollView, StyleSheet, View } from "react-native"
|
||||
import {
|
||||
Button,
|
||||
HelperText,
|
||||
@ -15,14 +18,15 @@ 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 "../../contexts/HabitsTracker"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
import { useBoolean } from "@/presentation/react/hooks/useBoolean"
|
||||
import { IconSelectorModal } from "./IconSelectorModal"
|
||||
|
||||
export interface HabitCreateFormProps {
|
||||
user: User
|
||||
@ -33,9 +37,10 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isValid },
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
watch,
|
||||
} = useForm<HabitCreateData>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(HabitCreateSchema),
|
||||
@ -43,7 +48,7 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
||||
userId: user.id,
|
||||
name: "",
|
||||
color: "#006CFF",
|
||||
icon: "lightbulb",
|
||||
icon: "circle-question",
|
||||
goal: {
|
||||
frequency: "daily",
|
||||
target: {
|
||||
@ -53,8 +58,16 @@ 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)
|
||||
}
|
||||
@ -62,6 +75,7 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
||||
const onSubmit = async (data: HabitCreateData): Promise<void> => {
|
||||
await habitsTrackerPresenter.habitCreate(data)
|
||||
setIsVisibleSnackbar(true)
|
||||
closeModalIconSelector()
|
||||
reset()
|
||||
}
|
||||
|
||||
@ -117,7 +131,7 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
||||
style={[
|
||||
styles.spacing,
|
||||
{
|
||||
width: "90%",
|
||||
width: "96%",
|
||||
},
|
||||
]}
|
||||
mode="outlined"
|
||||
@ -142,7 +156,7 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
||||
<>
|
||||
<Text style={[styles.spacing]}>Habit Frequency</Text>
|
||||
<SegmentedButtons
|
||||
style={[{ width: "90%" }]}
|
||||
style={[{ width: "96%" }]}
|
||||
onValueChange={onChange}
|
||||
value={value}
|
||||
buttons={GOAL_FREQUENCIES.map((frequency) => {
|
||||
@ -164,9 +178,29 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<>
|
||||
<Text style={[styles.spacing]}>Habit Type</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.spacing,
|
||||
{ justifyContent: "center", alignContent: "center" },
|
||||
]}
|
||||
>
|
||||
Habit Type
|
||||
{/* <Tooltip
|
||||
title="Routine habits are activities performed regularly, while Target habits involve setting specific objectives to be achieved through repeated actions."
|
||||
enterTouchDelay={50}
|
||||
leaveTouchDelay={25}
|
||||
>
|
||||
<IconButton
|
||||
icon="chat-question-outline"
|
||||
selected
|
||||
size={24}
|
||||
onPress={() => {}}
|
||||
style={{ alignSelf: "center" }}
|
||||
/>
|
||||
</Tooltip> */}
|
||||
</Text>
|
||||
<SegmentedButtons
|
||||
style={[{ width: "90%" }]}
|
||||
style={[{ width: "96%" }]}
|
||||
onValueChange={onChange}
|
||||
value={value}
|
||||
buttons={GOAL_TYPES.map((type) => {
|
||||
@ -183,12 +217,74 @@ 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={[styles.spacing, { width: "90%" }]}
|
||||
style={[{ marginVertical: 15, width: "96%" }]}
|
||||
value={value}
|
||||
onComplete={(value) => {
|
||||
onChange(value.hex)
|
||||
@ -205,16 +301,30 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
render={({ field: { onChange, onBlur, value } }) => {
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<TextInput
|
||||
placeholder="Icon"
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
style={[styles.spacing, { width: "90%" }]}
|
||||
mode="outlined"
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
gap: 20,
|
||||
marginVertical: 5,
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon size={36} icon={value as IconName} />
|
||||
<Button mode="contained" onPress={openModalIconSelector}>
|
||||
Choose an icon
|
||||
</Button>
|
||||
|
||||
<IconSelectorModal
|
||||
key={isModalIconSelectorVisible ? "visible" : "hidden"}
|
||||
isVisible={isModalIconSelectorVisible}
|
||||
selectedIcon={value}
|
||||
handleCloseModal={closeModalIconSelector}
|
||||
onIconSelect={onChange}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}}
|
||||
name="icon"
|
||||
@ -224,8 +334,8 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
||||
mode="contained"
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
loading={habitCreate.state === "loading"}
|
||||
disabled={habitCreate.state === "loading"}
|
||||
style={[styles.spacing, { width: "90%" }]}
|
||||
disabled={habitCreate.state === "loading" || !isValid}
|
||||
style={[{ width: "100%", marginVertical: 15 }]}
|
||||
>
|
||||
Create your habit! 🚀
|
||||
</Button>
|
||||
@ -244,6 +354,6 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
spacing: {
|
||||
marginVertical: 16,
|
||||
marginVertical: 10,
|
||||
},
|
||||
})
|
@ -1,8 +1,16 @@
|
||||
import type { IconName } from "@fortawesome/free-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { ScrollView, StyleSheet } from "react-native"
|
||||
import { Button, HelperText, Snackbar, TextInput } from "react-native-paper"
|
||||
import { ScrollView, StyleSheet, View } from "react-native"
|
||||
import {
|
||||
Button,
|
||||
HelperText,
|
||||
Snackbar,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "react-native-paper"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
import ColorPicker, {
|
||||
HueSlider,
|
||||
@ -12,19 +20,21 @@ import ColorPicker, {
|
||||
|
||||
import type { Habit, HabitEditData } from "@/domain/entities/Habit"
|
||||
import { HabitEditSchema } from "@/domain/entities/Habit"
|
||||
import { useHabitsTracker } from "../../contexts/HabitsTracker"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
import { useBoolean } from "@/presentation/react/hooks/useBoolean"
|
||||
import { IconSelectorModal } from "./IconSelectorModal"
|
||||
|
||||
export interface HabitEditFormProps {
|
||||
habit: Habit
|
||||
}
|
||||
|
||||
export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
|
||||
const { habitEdit, habitsTrackerPresenter } = useHabitsTracker()
|
||||
const { habitEdit, habitStop, habitsTrackerPresenter } = useHabitsTracker()
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isValid },
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<HabitEditData>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(HabitEditSchema),
|
||||
@ -37,6 +47,12 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
value: isModalIconSelectorVisible,
|
||||
setTrue: openModalIconSelector,
|
||||
setFalse: closeModalIconSelector,
|
||||
} = useBoolean()
|
||||
|
||||
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
|
||||
|
||||
const onDismissSnackbar = (): void => {
|
||||
@ -70,7 +86,7 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
|
||||
style={[
|
||||
styles.spacing,
|
||||
{
|
||||
width: "90%",
|
||||
width: "96%",
|
||||
},
|
||||
]}
|
||||
mode="outlined"
|
||||
@ -93,7 +109,7 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<ColorPicker
|
||||
style={[styles.spacing, { width: "90%" }]}
|
||||
style={[styles.spacing, { width: "96%" }]}
|
||||
value={value}
|
||||
onComplete={(value) => {
|
||||
onChange(value.hex)
|
||||
@ -110,16 +126,30 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
render={({ field: { onChange, onBlur, value } }) => {
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<TextInput
|
||||
placeholder="Icon"
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
style={[styles.spacing, { width: "90%" }]}
|
||||
mode="outlined"
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
gap: 20,
|
||||
marginVertical: 30,
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon size={36} icon={value as IconName} />
|
||||
<Button mode="contained" onPress={openModalIconSelector}>
|
||||
Choose an icon
|
||||
</Button>
|
||||
|
||||
<IconSelectorModal
|
||||
key={isModalIconSelectorVisible ? "visible" : "hidden"}
|
||||
isVisible={isModalIconSelectorVisible}
|
||||
selectedIcon={value}
|
||||
handleCloseModal={closeModalIconSelector}
|
||||
onIconSelect={onChange}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}}
|
||||
name="icon"
|
||||
@ -129,11 +159,35 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
|
||||
mode="contained"
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
loading={habitEdit.state === "loading"}
|
||||
disabled={habitEdit.state === "loading"}
|
||||
style={[styles.spacing, { width: "90%" }]}
|
||||
disabled={habitEdit.state === "loading" || !isValid}
|
||||
style={[styles.spacing, { width: "96%" }]}
|
||||
>
|
||||
Save
|
||||
</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
|
@ -0,0 +1,121 @@
|
||||
import type { IconName } from "@fortawesome/fontawesome-svg-core"
|
||||
import { fas } from "@fortawesome/free-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||
import { memo, useCallback, useEffect, useState, useTransition } from "react"
|
||||
import { Modal, ScrollView, View } from "react-native"
|
||||
import { Button, List, Text, TextInput } from "react-native-paper"
|
||||
|
||||
import { IconsList } from "./IconsList"
|
||||
|
||||
export interface IconSelectorModalProps {
|
||||
isVisible?: boolean
|
||||
selectedIcon?: string
|
||||
onIconSelect: (icon: string) => void
|
||||
handleCloseModal?: () => void
|
||||
}
|
||||
|
||||
interface SearchInputProps {
|
||||
searchText: string
|
||||
handleSearch: (text: string) => void
|
||||
}
|
||||
const SearchInputWithoutMemo: React.FC<SearchInputProps> = (props) => {
|
||||
const { searchText, handleSearch } = props
|
||||
return (
|
||||
<TextInput label="Search" value={searchText} onChangeText={handleSearch} />
|
||||
)
|
||||
}
|
||||
const SearchInput = memo(SearchInputWithoutMemo)
|
||||
|
||||
const iconNames = Object.keys(fas).map((key) => {
|
||||
return fas[key]?.iconName ?? key
|
||||
})
|
||||
|
||||
const findIconsInLibrary = (icon: string): string[] => {
|
||||
return iconNames
|
||||
.filter((name, index, self) => {
|
||||
return name.includes(icon) && self.indexOf(name) === index
|
||||
})
|
||||
.slice(0, 50)
|
||||
}
|
||||
|
||||
export const IconSelectorModal: React.FC<IconSelectorModalProps> = ({
|
||||
isVisible = false,
|
||||
selectedIcon,
|
||||
onIconSelect,
|
||||
handleCloseModal,
|
||||
}) => {
|
||||
const [possibleIcons, setPossibleIcons] = useState<string[]>([])
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
const [searchText, setSearchText] = useState<string>("")
|
||||
const [_isPending, startTransition] = useTransition()
|
||||
|
||||
const handleSearch = useCallback((text: string): void => {
|
||||
setSearchText(text)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handlePossibleIcons = (): void => {
|
||||
startTransition(() => {
|
||||
setPossibleIcons(findIconsInLibrary(searchText))
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
const debounceHandleSearch = setTimeout(handlePossibleIcons, 400)
|
||||
|
||||
return () => {
|
||||
return clearTimeout(debounceHandleSearch)
|
||||
}
|
||||
}, [searchText])
|
||||
|
||||
const handleIconSelect = useCallback(
|
||||
(icon: string): void => {
|
||||
onIconSelect(icon)
|
||||
},
|
||||
[onIconSelect],
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal animationType="fade" transparent visible={isVisible}>
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 5,
|
||||
width: "96%",
|
||||
height: "99%",
|
||||
backgroundColor: "white",
|
||||
borderColor: "black",
|
||||
borderWidth: 1,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginVertical: 20,
|
||||
}}
|
||||
>
|
||||
<Text style={{ marginVertical: 8 }}>Selected Icon:</Text>
|
||||
<FontAwesomeIcon size={46} icon={selectedIcon as IconName} />
|
||||
</View>
|
||||
<SearchInput searchText={searchText} handleSearch={handleSearch} />
|
||||
<ScrollView>
|
||||
<List.Section title="Choose an icon:">
|
||||
<IconsList
|
||||
isLoading={isLoading}
|
||||
selectedIcon={selectedIcon}
|
||||
possibleIcons={possibleIcons}
|
||||
handleIconSelect={handleIconSelect}
|
||||
/>
|
||||
</List.Section>
|
||||
</ScrollView>
|
||||
<View style={{ marginVertical: 15 }}>
|
||||
<Button mode="contained" onPress={handleCloseModal}>
|
||||
Save
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
74
presentation/react-native/components/HabitForm/IconsList.tsx
Normal file
74
presentation/react-native/components/HabitForm/IconsList.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import type { IconName } from "@fortawesome/fontawesome-svg-core"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||
import React, { memo } from "react"
|
||||
import { View } from "react-native"
|
||||
import { ActivityIndicator, IconButton, Text } from "react-native-paper"
|
||||
|
||||
export interface IconsListProps {
|
||||
selectedIcon?: string
|
||||
possibleIcons: string[]
|
||||
isLoading?: boolean
|
||||
handleIconSelect: (icon: string) => void
|
||||
}
|
||||
|
||||
const IconsListWithoutMemo: React.FC<IconsListProps> = (props) => {
|
||||
const {
|
||||
selectedIcon,
|
||||
possibleIcons,
|
||||
isLoading = false,
|
||||
handleIconSelect,
|
||||
} = props
|
||||
|
||||
if (possibleIcons.length <= 0) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 20,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="large" />
|
||||
) : (
|
||||
<Text>No results found</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 15,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{possibleIcons.map((icon) => {
|
||||
return (
|
||||
<IconButton
|
||||
key={icon}
|
||||
containerColor="white"
|
||||
icon={({ size }) => {
|
||||
return (
|
||||
<FontAwesomeIcon
|
||||
icon={icon as IconName}
|
||||
size={size}
|
||||
color={selectedIcon === icon ? "blue" : "black"}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
size={30}
|
||||
onPress={() => {
|
||||
handleIconSelect(icon)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export const IconsList = memo(IconsListWithoutMemo)
|
@ -1,15 +1,16 @@
|
||||
import FontAwesome6 from "@expo/vector-icons/FontAwesome6"
|
||||
import type { IconName } from "@fortawesome/free-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||
import { useRouter } from "expo-router"
|
||||
import type LottieView from "lottie-react-native"
|
||||
import { useState } from "react"
|
||||
import { View } from "react-native"
|
||||
import { Checkbox, List, Text } from "react-native-paper"
|
||||
import type LottieView from "lottie-react-native"
|
||||
|
||||
import type { GoalBoolean } from "@/domain/entities/Goal"
|
||||
import { GoalBooleanProgress } from "@/domain/entities/Goal"
|
||||
import type { HabitHistory } from "@/domain/entities/HabitHistory"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
import { getColorRGBAFromHex } from "@/utils/colors"
|
||||
import { useHabitsTracker } from "../../contexts/HabitsTracker"
|
||||
|
||||
export interface HabitCardProps {
|
||||
habitHistory: HabitHistory
|
||||
@ -65,9 +66,9 @@ export const HabitCard: React.FC<HabitCardProps> = (props) => {
|
||||
left={() => {
|
||||
return (
|
||||
<View style={{ justifyContent: "center", alignItems: "center" }}>
|
||||
<FontAwesome6
|
||||
<FontAwesomeIcon
|
||||
size={24}
|
||||
name={habit.icon}
|
||||
icon={habit.icon as IconName}
|
||||
style={[
|
||||
{
|
||||
width: 30,
|
@ -0,0 +1,157 @@
|
||||
import LottieView from "lottie-react-native"
|
||||
import { useRef, useState } from "react"
|
||||
import { Dimensions, ScrollView, View } from "react-native"
|
||||
import { Divider, List, Text } from "react-native-paper"
|
||||
|
||||
import { GOAL_FREQUENCIES, type GoalFrequency } from "@/domain/entities/Goal"
|
||||
import type { HabitHistory } from "@/domain/entities/HabitHistory"
|
||||
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
||||
import { capitalize } from "@/utils/strings"
|
||||
import confettiJSON from "../../../assets/confetti.json"
|
||||
import { HabitCard } from "./HabitCard"
|
||||
|
||||
export interface HabitsListProps {
|
||||
habitsTracker: HabitsTracker
|
||||
selectedDate: Date
|
||||
}
|
||||
|
||||
export const HabitsList: React.FC<HabitsListProps> = (props) => {
|
||||
const { habitsTracker, selectedDate } = props
|
||||
|
||||
const [accordionExpanded, setAccordionExpanded] = useState<{
|
||||
[key in GoalFrequency]: boolean
|
||||
}>({
|
||||
daily: true,
|
||||
weekly: true,
|
||||
monthly: true,
|
||||
})
|
||||
|
||||
const confettiRef = useRef<LottieView | null>(null)
|
||||
|
||||
const habitsHistoriesByFrequency: Record<GoalFrequency, HabitHistory[]> = {
|
||||
daily: habitsTracker.getHabitsHistoriesByDate({
|
||||
selectedDate,
|
||||
frequency: "daily",
|
||||
}),
|
||||
weekly: habitsTracker.getHabitsHistoriesByDate({
|
||||
selectedDate,
|
||||
frequency: "weekly",
|
||||
}),
|
||||
monthly: habitsTracker.getHabitsHistoriesByDate({
|
||||
selectedDate,
|
||||
frequency: "monthly",
|
||||
}),
|
||||
}
|
||||
|
||||
const frequenciesFiltered = GOAL_FREQUENCIES.filter((frequency) => {
|
||||
return habitsHistoriesByFrequency[frequency].length > 0
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
zIndex: 100,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<LottieView
|
||||
ref={confettiRef}
|
||||
source={confettiJSON}
|
||||
autoPlay={false}
|
||||
loop={false}
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
]}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={{
|
||||
paddingHorizontal: 20,
|
||||
width: Dimensions.get("window").width,
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
>
|
||||
<Divider />
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: 22,
|
||||
textAlign: "center",
|
||||
marginTop: 20,
|
||||
}}
|
||||
>
|
||||
{selectedDate.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{frequenciesFiltered.length > 0 ? (
|
||||
<List.Section>
|
||||
{frequenciesFiltered.map((frequency) => {
|
||||
return (
|
||||
<List.Accordion
|
||||
expanded={accordionExpanded[frequency]}
|
||||
onPress={() => {
|
||||
setAccordionExpanded((old) => {
|
||||
return {
|
||||
...old,
|
||||
[frequency]: !old[frequency],
|
||||
}
|
||||
})
|
||||
}}
|
||||
key={frequency}
|
||||
title={capitalize(frequency)}
|
||||
titleStyle={[
|
||||
{
|
||||
fontSize: 26,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{habitsHistoriesByFrequency[frequency].map((item) => {
|
||||
return (
|
||||
<HabitCard
|
||||
habitHistory={item}
|
||||
selectedDate={selectedDate}
|
||||
key={item.habit.id + selectedDate.toISOString()}
|
||||
confettiRef={confettiRef}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</List.Accordion>
|
||||
)
|
||||
})}
|
||||
</List.Section>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginVertical: 6,
|
||||
}}
|
||||
>
|
||||
<Text variant="titleLarge">No habits for this date</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</>
|
||||
)
|
||||
}
|
@ -3,7 +3,7 @@ import { Agenda } from "react-native-calendars"
|
||||
|
||||
import { GOAL_FREQUENCIES } from "@/domain/entities/Goal"
|
||||
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
||||
import { getISODate, getNowDate } from "@/utils/dates"
|
||||
import { getISODate, getNowDateUTC } from "@/utils/dates"
|
||||
import { HabitsEmpty } from "./HabitsEmpty"
|
||||
import { HabitsList } from "./HabitsList"
|
||||
|
||||
@ -14,7 +14,7 @@ export interface HabitsMainPageProps {
|
||||
export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => {
|
||||
const { habitsTracker } = props
|
||||
|
||||
const today = getNowDate()
|
||||
const today = getNowDateUTC()
|
||||
const todayISO = getISODate(today)
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(today)
|
||||
@ -45,7 +45,6 @@ export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => {
|
||||
<HabitsList
|
||||
habitsTracker={habitsTracker}
|
||||
selectedDate={selectedDate}
|
||||
frequenciesFiltered={frequenciesFiltered}
|
||||
/>
|
||||
)
|
||||
}}
|
@ -1,6 +1,6 @@
|
||||
import renderer from "react-test-renderer"
|
||||
|
||||
import { ExternalLink } from "@/presentation/react/components/ExternalLink"
|
||||
import { ExternalLink } from "@/presentation/react-native/ui/ExternalLink"
|
||||
|
||||
describe("<ExternalLink />", () => {
|
||||
it("renders correctly", () => {
|
@ -1,6 +1,6 @@
|
||||
import renderer from "react-test-renderer"
|
||||
|
||||
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
|
||||
import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
|
||||
|
||||
describe("<TabBarIcon />", () => {
|
||||
it("renders correctly", () => {
|
@ -1,110 +0,0 @@
|
||||
import LottieView from "lottie-react-native"
|
||||
import { useRef, useState } from "react"
|
||||
import { Dimensions, ScrollView, View } from "react-native"
|
||||
import { Divider, List } from "react-native-paper"
|
||||
|
||||
import type { GoalFrequency } from "@/domain/entities/Goal"
|
||||
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
||||
import confettiJSON from "../../../assets/confetti.json"
|
||||
import { capitalize } from "@/utils/strings"
|
||||
import { HabitCard } from "./HabitCard"
|
||||
|
||||
export interface HabitsListProps {
|
||||
habitsTracker: HabitsTracker
|
||||
selectedDate: Date
|
||||
frequenciesFiltered: GoalFrequency[]
|
||||
}
|
||||
|
||||
export const HabitsList: React.FC<HabitsListProps> = (props) => {
|
||||
const { habitsTracker, selectedDate, frequenciesFiltered } = props
|
||||
|
||||
const [accordionExpanded, setAccordionExpanded] = useState<{
|
||||
[key in GoalFrequency]: boolean
|
||||
}>({
|
||||
daily: true,
|
||||
weekly: true,
|
||||
monthly: true,
|
||||
})
|
||||
|
||||
const confettiRef = useRef<LottieView | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
zIndex: 100,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<LottieView
|
||||
ref={confettiRef}
|
||||
source={confettiJSON}
|
||||
autoPlay={false}
|
||||
loop={false}
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
]}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={{
|
||||
paddingHorizontal: 20,
|
||||
width: Dimensions.get("window").width,
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
>
|
||||
<Divider />
|
||||
|
||||
<List.Section>
|
||||
{frequenciesFiltered.map((frequency) => {
|
||||
return (
|
||||
<List.Accordion
|
||||
expanded={accordionExpanded[frequency]}
|
||||
onPress={() => {
|
||||
setAccordionExpanded((old) => {
|
||||
return {
|
||||
...old,
|
||||
[frequency]: !old[frequency],
|
||||
}
|
||||
})
|
||||
}}
|
||||
key={frequency}
|
||||
title={capitalize(frequency)}
|
||||
titleStyle={[
|
||||
{
|
||||
fontSize: 26,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{habitsTracker.habitsHistory[frequency].map((item) => {
|
||||
return (
|
||||
<HabitCard
|
||||
habitHistory={item}
|
||||
selectedDate={selectedDate}
|
||||
key={item.habit.id + selectedDate.toISOString()}
|
||||
confettiRef={confettiRef}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</List.Accordion>
|
||||
)
|
||||
})}
|
||||
</List.Section>
|
||||
</ScrollView>
|
||||
</>
|
||||
)
|
||||
}
|
@ -3,7 +3,7 @@ import CircularProgress from "react-native-circular-progress-indicator"
|
||||
import { Agenda } from "react-native-calendars"
|
||||
import { useState } from "react"
|
||||
|
||||
import { getNowDate, getISODate } from "@/utils/dates"
|
||||
import { getNowDateUTC, getISODate } from "@/utils/dates"
|
||||
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
||||
|
||||
export interface StatsProps {
|
||||
@ -13,7 +13,7 @@ export interface StatsProps {
|
||||
export const Stats: React.FC<StatsProps> = (props) => {
|
||||
const { habitsTracker } = props
|
||||
|
||||
const today = getNowDate()
|
||||
const today = getNowDateUTC()
|
||||
const todayISO = getISODate(today)
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(today)
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { createContext, useContext, useEffect } from "react"
|
||||
|
||||
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
|
||||
import { authenticationPresenter } from "@/infrastructure/instances"
|
||||
import type {
|
||||
AuthenticationPresenter,
|
||||
AuthenticationPresenterState,
|
||||
} from "@/presentation/presenters/Authentication"
|
||||
import { authenticationPresenter } from "@/infrastructure/instances"
|
||||
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
|
||||
|
||||
export interface AuthenticationContextValue
|
||||
extends AuthenticationPresenterState {
|
||||
|
@ -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 {
|
||||
|
@ -2,8 +2,8 @@ import { act, renderHook } from "@testing-library/react-native"
|
||||
|
||||
import { useBoolean } from "@/presentation/react/hooks/useBoolean"
|
||||
|
||||
describe("hooks/useBoolean", () => {
|
||||
beforeEach(() => {
|
||||
describe("presentation/react/hooks/useBoolean", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
@ -11,51 +11,76 @@ describe("hooks/useBoolean", () => {
|
||||
|
||||
for (const initialValue of initialValues) {
|
||||
it(`should set the initial value to ${initialValue}`, () => {
|
||||
// Arrange - Given
|
||||
const { result } = renderHook(() => {
|
||||
return useBoolean({ initialValue })
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.value).toBe(initialValue)
|
||||
})
|
||||
}
|
||||
|
||||
it("should by default set the initial value to false", () => {
|
||||
// Arrange - Given
|
||||
const { result } = renderHook(() => {
|
||||
return useBoolean()
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.value).toBe(false)
|
||||
})
|
||||
|
||||
it("should toggle the value", async () => {
|
||||
// Arrange - Given
|
||||
const { result } = renderHook(() => {
|
||||
return useBoolean({ initialValue: false })
|
||||
})
|
||||
|
||||
// Act - When
|
||||
await act(() => {
|
||||
return result.current.toggle()
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.value).toBe(true)
|
||||
|
||||
// Act - When
|
||||
await act(() => {
|
||||
return result.current.toggle()
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.value).toBe(false)
|
||||
})
|
||||
|
||||
it("should set the value to true", async () => {
|
||||
// Arrange - Given
|
||||
const { result } = renderHook(() => {
|
||||
return useBoolean({ initialValue: false })
|
||||
})
|
||||
|
||||
// Act - When
|
||||
await act(() => {
|
||||
return result.current.setTrue()
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.value).toBe(true)
|
||||
})
|
||||
|
||||
it("should set the value to false", async () => {
|
||||
// Arrange - Given
|
||||
const { result } = renderHook(() => {
|
||||
return useBoolean({ initialValue: true })
|
||||
})
|
||||
|
||||
// Act - When
|
||||
await act(() => {
|
||||
return result.current.setFalse()
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
75
presentation/react/hooks/__tests__/usePresenterState.test.ts
Normal file
75
presentation/react/hooks/__tests__/usePresenterState.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { act, renderHook } from "@testing-library/react-native"
|
||||
|
||||
import { Presenter } from "@/presentation/presenters/_Presenter"
|
||||
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
|
||||
|
||||
interface MockCountPresenterState {
|
||||
count: number
|
||||
}
|
||||
|
||||
class MockCountPresenter extends Presenter<MockCountPresenterState> {
|
||||
public constructor(initialState: MockCountPresenterState) {
|
||||
super(initialState)
|
||||
}
|
||||
|
||||
public increment(): void {
|
||||
this.setState((state) => {
|
||||
state.count = state.count + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe("presentation/react/hooks/usePresenterState", () => {
|
||||
it("should return the initial state from the presenter", async () => {
|
||||
// Arrange - Given
|
||||
const initialState = { count: 0 }
|
||||
const presenter = new MockCountPresenter(initialState)
|
||||
|
||||
// Act - When
|
||||
const { result } = renderHook(() => {
|
||||
return usePresenterState(presenter)
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current).toEqual(initialState)
|
||||
})
|
||||
|
||||
it("should update state when presenter state changes", async () => {
|
||||
// Arrange - Given
|
||||
const initialState = { count: 0 }
|
||||
const presenter = new MockCountPresenter(initialState)
|
||||
const subscribe = jest.spyOn(presenter, "subscribe")
|
||||
const { result } = renderHook(() => {
|
||||
return usePresenterState(presenter)
|
||||
})
|
||||
|
||||
// Act - When
|
||||
await act(() => {
|
||||
presenter.increment()
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.count).toBe(1)
|
||||
expect(subscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should unsubscribe from presenter on unmount", async () => {
|
||||
// Arrange - Given
|
||||
const initialState = { count: 0 }
|
||||
const presenter = new MockCountPresenter(initialState)
|
||||
const unsubscribe = jest.spyOn(presenter, "unsubscribe")
|
||||
const { result, unmount } = renderHook(() => {
|
||||
return usePresenterState(presenter)
|
||||
})
|
||||
|
||||
// Act - When
|
||||
unmount()
|
||||
await act(() => {
|
||||
presenter.increment()
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.count).toBe(0)
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
@ -2,9 +2,10 @@ import { useState } from "react"
|
||||
|
||||
export interface UseBooleanResult {
|
||||
value: boolean
|
||||
toggle: () => void
|
||||
setValue: React.Dispatch<React.SetStateAction<boolean>>
|
||||
setTrue: () => void
|
||||
setFalse: () => void
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
export interface UseBooleanOptions {
|
||||
@ -43,6 +44,7 @@ export const useBoolean = (
|
||||
|
||||
return {
|
||||
value,
|
||||
setValue,
|
||||
toggle,
|
||||
setTrue,
|
||||
setFalse,
|
||||
|
115
tests/mocks/domain/Habit.ts
Normal file
115
tests/mocks/domain/Habit.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
|
||||
import type { HabitData } from "@/domain/entities/Habit"
|
||||
import { Habit } from "@/domain/entities/Habit"
|
||||
import { USER_MOCK } from "./User"
|
||||
import { ONE_DAY_MILLISECONDS } from "@/utils/dates"
|
||||
|
||||
interface HabitMockCreateOptions extends Omit<HabitData, "startDate"> {
|
||||
startDate?: Date
|
||||
}
|
||||
const habitMockCreate = (options: HabitMockCreateOptions): Habit => {
|
||||
const {
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
goal,
|
||||
startDate = new Date(),
|
||||
endDate,
|
||||
} = options
|
||||
|
||||
return new Habit({
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
goal,
|
||||
startDate,
|
||||
endDate,
|
||||
})
|
||||
}
|
||||
|
||||
const examplesByNames = {
|
||||
"Wake up at 07h00": habitMockCreate({
|
||||
id: "1",
|
||||
userId: USER_MOCK.example.id,
|
||||
name: "Wake up at 07h00",
|
||||
color: "#006CFF",
|
||||
icon: "bed",
|
||||
goal: new GoalBoolean({
|
||||
frequency: "daily",
|
||||
}),
|
||||
}),
|
||||
"Learn English": habitMockCreate({
|
||||
id: "2",
|
||||
userId: USER_MOCK.example.id,
|
||||
name: "Learn English",
|
||||
color: "#EB4034",
|
||||
icon: "language",
|
||||
goal: new GoalNumeric({
|
||||
frequency: "daily",
|
||||
target: {
|
||||
value: 30,
|
||||
unit: "minutes",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
Walk: habitMockCreate({
|
||||
id: "3",
|
||||
userId: USER_MOCK.example.id,
|
||||
name: "Walk",
|
||||
color: "#228B22",
|
||||
icon: "person-walking",
|
||||
goal: new GoalNumeric({
|
||||
frequency: "daily",
|
||||
target: {
|
||||
value: 5000,
|
||||
unit: "steps",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
"Clean the house": habitMockCreate({
|
||||
id: "4",
|
||||
userId: USER_MOCK.example.id,
|
||||
name: "Clean the house",
|
||||
color: "#808080",
|
||||
icon: "broom",
|
||||
goal: new GoalBoolean({
|
||||
frequency: "weekly",
|
||||
}),
|
||||
}),
|
||||
"Solve Programming Challenges": habitMockCreate({
|
||||
id: "5",
|
||||
userId: USER_MOCK.example.id,
|
||||
name: "Solve Programming Challenges",
|
||||
color: "#DE3163",
|
||||
icon: "code",
|
||||
goal: new GoalNumeric({
|
||||
frequency: "monthly",
|
||||
target: {
|
||||
value: 5,
|
||||
unit: "challenges",
|
||||
},
|
||||
}),
|
||||
endDate: new Date(Date.now() + ONE_DAY_MILLISECONDS),
|
||||
}),
|
||||
} as const
|
||||
|
||||
export const examplesByIds = {
|
||||
[examplesByNames["Wake up at 07h00"].id]: examplesByNames["Wake up at 07h00"],
|
||||
[examplesByNames["Learn English"].id]: examplesByNames["Learn English"],
|
||||
[examplesByNames.Walk.id]: examplesByNames.Walk,
|
||||
[examplesByNames["Clean the house"].id]: examplesByNames["Clean the house"],
|
||||
[examplesByNames["Solve Programming Challenges"].id]:
|
||||
examplesByNames["Solve Programming Challenges"],
|
||||
} as const
|
||||
|
||||
export const HABIT_MOCK = {
|
||||
create: habitMockCreate,
|
||||
example: examplesByNames["Wake up at 07h00"],
|
||||
examplesByNames,
|
||||
examplesByIds,
|
||||
examples: Object.values(examplesByNames),
|
||||
}
|
51
tests/mocks/domain/HabitProgress.ts
Normal file
51
tests/mocks/domain/HabitProgress.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import type { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
|
||||
import {
|
||||
GoalBooleanProgress,
|
||||
GoalNumericProgress,
|
||||
} from "@/domain/entities/Goal"
|
||||
import type { HabitProgressData } from "@/domain/entities/HabitProgress"
|
||||
import { HabitProgress } from "@/domain/entities/HabitProgress"
|
||||
import { HABIT_MOCK } from "./Habit"
|
||||
|
||||
interface HabitProgressMockCreateOptions
|
||||
extends Omit<HabitProgressData, "date"> {
|
||||
date?: Date
|
||||
}
|
||||
|
||||
const habitProgressMockCreate = (
|
||||
options: HabitProgressMockCreateOptions,
|
||||
): HabitProgress => {
|
||||
const { id, habitId, goalProgress, date = new Date() } = options
|
||||
|
||||
return new HabitProgress({
|
||||
date,
|
||||
goalProgress,
|
||||
habitId,
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
const exampleByIds = {
|
||||
1: habitProgressMockCreate({
|
||||
id: "1",
|
||||
habitId: HABIT_MOCK.examplesByNames["Clean the house"].id,
|
||||
goalProgress: new GoalBooleanProgress({
|
||||
goal: HABIT_MOCK.examplesByNames["Clean the house"].goal as GoalBoolean,
|
||||
progress: true,
|
||||
}),
|
||||
}),
|
||||
2: habitProgressMockCreate({
|
||||
id: "2",
|
||||
habitId: HABIT_MOCK.examplesByNames.Walk.id,
|
||||
goalProgress: new GoalNumericProgress({
|
||||
goal: HABIT_MOCK.examplesByNames.Walk.goal as GoalNumeric,
|
||||
progress: 4_733,
|
||||
}),
|
||||
}),
|
||||
} as const
|
||||
|
||||
export const HABIT_PROGRESS_MOCK = {
|
||||
create: habitProgressMockCreate,
|
||||
exampleByIds,
|
||||
examples: Object.values(exampleByIds),
|
||||
}
|
30
tests/mocks/domain/User.ts
Normal file
30
tests/mocks/domain/User.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { UserData } from "@/domain/entities/User"
|
||||
import { User } from "@/domain/entities/User"
|
||||
|
||||
const USER_MOCK_ID = "ab054ee9-fbb4-473e-942b-bbf4415f4bef"
|
||||
const USER_MOCK_EMAIL = "test@test.com"
|
||||
const USER_MOCK_DISPLAY_NAME = "Test"
|
||||
|
||||
interface UserMockCreateOptions {
|
||||
id?: UserData["id"]
|
||||
email?: UserData["email"]
|
||||
displayName?: UserData["displayName"]
|
||||
}
|
||||
const userMockCreate = (options: UserMockCreateOptions = {}): User => {
|
||||
const {
|
||||
id = USER_MOCK_ID,
|
||||
email = USER_MOCK_EMAIL,
|
||||
displayName = USER_MOCK_DISPLAY_NAME,
|
||||
} = options
|
||||
|
||||
return new User({
|
||||
id,
|
||||
email,
|
||||
displayName,
|
||||
})
|
||||
}
|
||||
|
||||
export const USER_MOCK = {
|
||||
create: userMockCreate,
|
||||
example: userMockCreate(),
|
||||
}
|
79
tests/mocks/supabase/Habit.ts
Normal file
79
tests/mocks/supabase/Habit.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import type { SupabaseHabit } from "@/infrastructure/supabase/supabase"
|
||||
import { HABIT_MOCK } from "../domain/Habit"
|
||||
import { SUPABASE_USER_MOCK } from "./User"
|
||||
|
||||
interface SupabaseHabitMockCreateOptions {
|
||||
id: SupabaseHabit["id"]
|
||||
userId: SupabaseHabit["user_id"]
|
||||
name: SupabaseHabit["name"]
|
||||
color: SupabaseHabit["color"]
|
||||
icon: SupabaseHabit["icon"]
|
||||
startDate?: Date
|
||||
endDate: Date | null
|
||||
goalFrequency: SupabaseHabit["goal_frequency"]
|
||||
goalTarget: SupabaseHabit["goal_target"] | null
|
||||
goalTargetUnit: SupabaseHabit["goal_target_unit"] | null
|
||||
}
|
||||
const supabaseHabitMockCreate = (
|
||||
options: SupabaseHabitMockCreateOptions,
|
||||
): SupabaseHabit => {
|
||||
const {
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
startDate = new Date(),
|
||||
endDate,
|
||||
goalFrequency,
|
||||
goalTarget,
|
||||
goalTargetUnit,
|
||||
} = options
|
||||
|
||||
return {
|
||||
id,
|
||||
user_id: userId,
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
start_date: startDate.toISOString(),
|
||||
end_date: endDate?.toISOString() ?? null,
|
||||
goal_frequency: goalFrequency,
|
||||
goal_target: goalTarget,
|
||||
goal_target_unit: goalTargetUnit,
|
||||
}
|
||||
}
|
||||
|
||||
const examplesByNames = Object.fromEntries(
|
||||
Object.entries(HABIT_MOCK.examplesByNames).map(([name, habit]) => {
|
||||
const goalTarget = habit.goal.isNumeric() ? habit.goal.target.value : null
|
||||
const goalTargetUnit = habit.goal.isNumeric()
|
||||
? habit.goal.target.unit
|
||||
: null
|
||||
return [
|
||||
name,
|
||||
supabaseHabitMockCreate({
|
||||
id: Number.parseInt(habit.id, 10),
|
||||
userId: SUPABASE_USER_MOCK.example.id,
|
||||
name: habit.name,
|
||||
color: habit.color,
|
||||
icon: habit.icon,
|
||||
startDate: habit.startDate,
|
||||
endDate: habit.endDate ?? null,
|
||||
goalFrequency: habit.goal.frequency,
|
||||
goalTarget,
|
||||
goalTargetUnit,
|
||||
}),
|
||||
]
|
||||
}),
|
||||
) as {
|
||||
[key in keyof (typeof HABIT_MOCK)["examplesByNames"]]: SupabaseHabit
|
||||
}
|
||||
|
||||
export const SUPABASE_HABIT_MOCK = {
|
||||
create: supabaseHabitMockCreate,
|
||||
example:
|
||||
examplesByNames[HABIT_MOCK.example.name as keyof typeof examplesByNames],
|
||||
examples: Object.values(examplesByNames),
|
||||
examplesByNames,
|
||||
}
|
49
tests/mocks/supabase/HabitProgress.ts
Normal file
49
tests/mocks/supabase/HabitProgress.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import type { SupabaseHabitProgress } from "@/infrastructure/supabase/supabase"
|
||||
import { HABIT_PROGRESS_MOCK } from "../domain/HabitProgress"
|
||||
|
||||
interface SupabaseHabitProgressMockCreateOptions {
|
||||
id: SupabaseHabitProgress["id"]
|
||||
habitId: SupabaseHabitProgress["habit_id"]
|
||||
date?: Date
|
||||
goalProgress: SupabaseHabitProgress["goal_progress"]
|
||||
}
|
||||
const supabaseHabitProgressMockCreate = (
|
||||
options: SupabaseHabitProgressMockCreateOptions,
|
||||
): SupabaseHabitProgress => {
|
||||
const { id, habitId, date = new Date(), goalProgress } = options
|
||||
|
||||
return {
|
||||
id,
|
||||
habit_id: habitId,
|
||||
date: date.toISOString(),
|
||||
goal_progress: goalProgress,
|
||||
}
|
||||
}
|
||||
|
||||
const exampleByIds = Object.fromEntries(
|
||||
Object.entries(HABIT_PROGRESS_MOCK.exampleByIds).map(
|
||||
([id, habitProgress]) => {
|
||||
return [
|
||||
id,
|
||||
supabaseHabitProgressMockCreate({
|
||||
id: Number.parseInt(habitProgress.id, 10),
|
||||
habitId: Number.parseInt(habitProgress.habitId, 10),
|
||||
date: new Date(habitProgress.date),
|
||||
goalProgress: habitProgress.goalProgress.isNumeric()
|
||||
? habitProgress.goalProgress.progress
|
||||
: habitProgress.goalProgress.isCompleted()
|
||||
? 1
|
||||
: 0,
|
||||
}),
|
||||
]
|
||||
},
|
||||
),
|
||||
) as {
|
||||
[key in keyof (typeof HABIT_PROGRESS_MOCK)["exampleByIds"]]: SupabaseHabitProgress
|
||||
}
|
||||
|
||||
export const SUPABASE_HABIT_PROGRESS_MOCK = {
|
||||
create: supabaseHabitProgressMockCreate,
|
||||
exampleByIds,
|
||||
examples: Object.values(exampleByIds),
|
||||
}
|
63
tests/mocks/supabase/User.ts
Normal file
63
tests/mocks/supabase/User.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import type { SupabaseUser } from "@/infrastructure/supabase/supabase"
|
||||
import { USER_MOCK } from "../domain/User"
|
||||
|
||||
interface SupabaseUserMockCreateOptions {
|
||||
id?: SupabaseUser["id"]
|
||||
email?: SupabaseUser["email"]
|
||||
displayName?: SupabaseUser["user_metadata"]["display_name"]
|
||||
date?: Date
|
||||
}
|
||||
const supabaseUserMockCreate = (
|
||||
options: SupabaseUserMockCreateOptions = {},
|
||||
): SupabaseUser => {
|
||||
const {
|
||||
id = USER_MOCK.example.id,
|
||||
email = USER_MOCK.example.email,
|
||||
displayName = USER_MOCK.example.displayName,
|
||||
date = new Date(),
|
||||
} = options
|
||||
|
||||
return {
|
||||
id,
|
||||
app_metadata: { provider: "email", providers: ["email"] },
|
||||
user_metadata: { display_name: displayName },
|
||||
aud: "authenticated",
|
||||
email,
|
||||
confirmation_sent_at: undefined,
|
||||
recovery_sent_at: undefined,
|
||||
email_change_sent_at: undefined,
|
||||
new_email: "",
|
||||
new_phone: "",
|
||||
invited_at: undefined,
|
||||
action_link: "",
|
||||
created_at: date.toISOString(),
|
||||
confirmed_at: undefined,
|
||||
email_confirmed_at: date.toISOString(),
|
||||
phone_confirmed_at: undefined,
|
||||
last_sign_in_at: undefined,
|
||||
role: "authenticated",
|
||||
updated_at: date.toISOString(),
|
||||
identities: [
|
||||
{
|
||||
id,
|
||||
user_id: id,
|
||||
identity_data: {
|
||||
sub: id,
|
||||
email,
|
||||
},
|
||||
provider: "email",
|
||||
identity_id: id,
|
||||
last_sign_in_at: date.toISOString(),
|
||||
created_at: date.toISOString(),
|
||||
updated_at: date.toISOString(),
|
||||
},
|
||||
],
|
||||
is_anonymous: false,
|
||||
factors: [],
|
||||
}
|
||||
}
|
||||
|
||||
export const SUPABASE_USER_MOCK = {
|
||||
create: supabaseUserMockCreate,
|
||||
example: supabaseUserMockCreate(),
|
||||
}
|
1
tests/setup.ts
Normal file
1
tests/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import "@testing-library/react-native/extend-expect"
|
44
utils/__tests__/colors.test.ts
Normal file
44
utils/__tests__/colors.test.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { getColorRGBAFromHex } from "../colors"
|
||||
|
||||
describe("utils/colors", () => {
|
||||
describe("getColorRGBAFromHex", () => {
|
||||
it("should return the correct rgba value when given a hex color and opacity (black 0)", () => {
|
||||
// Arrange - Given
|
||||
const hexColor = "#000000"
|
||||
const opacity = 0
|
||||
|
||||
// Act - When
|
||||
const result = getColorRGBAFromHex({ hexColor, opacity })
|
||||
|
||||
// Assert - Then
|
||||
const expected = "rgba(0, 0, 0, 0)"
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it("should return the correct rgba value when given a hex color and opacity (red 255)", () => {
|
||||
// Arrange - Given
|
||||
const hexColor = "#FF0000"
|
||||
const opacity = 0.5
|
||||
|
||||
// Act - When
|
||||
const result = getColorRGBAFromHex({ hexColor, opacity })
|
||||
|
||||
// Assert - Then
|
||||
const expected = "rgba(255, 0, 0, 0.5)"
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it("should return the correct rgba value when given a hex color with 3 characters and opacity (red 255)", () => {
|
||||
// Arrange - Given
|
||||
const hexColor = "#F00"
|
||||
const opacity = 0.5
|
||||
|
||||
// Act - When
|
||||
const result = getColorRGBAFromHex({ hexColor, opacity })
|
||||
|
||||
// Assert - Then
|
||||
const expected = "rgba(255, 0, 0, 0.5)"
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
80
utils/__tests__/dates.test.ts
Normal file
80
utils/__tests__/dates.test.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { getISODate, getNowDateUTC, getWeekNumber } from "../dates"
|
||||
|
||||
describe("utils/dates", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
jest.resetAllMocks()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
describe("getISODate", () => {
|
||||
it("should return the correct date in ISO format (e.g: 2012-05-23)", () => {
|
||||
// Arrange - Given
|
||||
const date = new Date("2012-05-23")
|
||||
|
||||
// Act - When
|
||||
const result = getISODate(date)
|
||||
|
||||
// Assert - Then
|
||||
const expected = "2012-05-23"
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getNowDateUTC", () => {
|
||||
it("should return the current UTC date", () => {
|
||||
// Arrange - Given
|
||||
const mockDate = new Date("2024-05-01T12:00:00Z")
|
||||
jest.useFakeTimers({ now: mockDate })
|
||||
Date.UTC = jest.fn(() => {
|
||||
return mockDate.getTime()
|
||||
})
|
||||
|
||||
// Act - When
|
||||
const result = getNowDateUTC()
|
||||
|
||||
// Assert - Then
|
||||
const expected = new Date("2024-05-01T12:00:00.000Z")
|
||||
expect(result).toEqual(expected)
|
||||
expect(Date.UTC).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getWeekNumber", () => {
|
||||
it("should return the correct week number for a given date (e.g: 2020-01-01)", () => {
|
||||
// Arrange - Given
|
||||
const date = new Date("2020-01-01")
|
||||
|
||||
// Act - When
|
||||
const result = getWeekNumber(date)
|
||||
|
||||
// Assert - Then
|
||||
const expected = 1
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it("should return the correct week number for a given date (e.g: 2020-01-08)", () => {
|
||||
// Arrange - Given
|
||||
const date = new Date("2020-01-08")
|
||||
|
||||
// Act - When
|
||||
const result = getWeekNumber(date)
|
||||
|
||||
// Assert - Then
|
||||
const expected = 2
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it("should return the correct week number for a given date (e.g: 2020-12-31)", () => {
|
||||
// Arrange - Given
|
||||
const date = new Date("2020-12-31")
|
||||
|
||||
// Act - When
|
||||
const result = getWeekNumber(date)
|
||||
|
||||
// Assert - Then
|
||||
const expected = 53
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
17
utils/__tests__/strings.test.ts
Normal file
17
utils/__tests__/strings.test.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { capitalize } from "../strings"
|
||||
|
||||
describe("utils/strings", () => {
|
||||
describe("capitalize", () => {
|
||||
it("should capitalize the first letter of a string", () => {
|
||||
// Arrange - Given
|
||||
const string = "hello world"
|
||||
|
||||
// Act - When
|
||||
const result = capitalize(string)
|
||||
|
||||
// Assert - Then
|
||||
const expected = "Hello world"
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
39
utils/__tests__/version.test.ts
Normal file
39
utils/__tests__/version.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { getVersion } from "../version"
|
||||
import { version } from "@/package.json"
|
||||
|
||||
describe("utils/version", () => {
|
||||
const env = process.env
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules()
|
||||
process.env = { ...env }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = env
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should return '0.0.0-development' when NODE_ENV is 'development'", () => {
|
||||
// Arrange - Given
|
||||
process.env["NODE_ENV"] = "development"
|
||||
|
||||
// Act - When
|
||||
const result = getVersion()
|
||||
|
||||
// Assert - Then
|
||||
const expected = "0.0.0-development"
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it("should return the version from package.json when NODE_ENV is not 'development'", () => {
|
||||
// Arrange - Given
|
||||
process.env["NODE_ENV"] = "production"
|
||||
|
||||
// Act - When
|
||||
const result = getVersion()
|
||||
|
||||
// Assert - Then
|
||||
expect(result).toEqual(version)
|
||||
})
|
||||
})
|
39
utils/__tests__/zod.test.ts
Normal file
39
utils/__tests__/zod.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import type { ZodIssue } from "zod"
|
||||
import { ZodError } from "zod"
|
||||
|
||||
import { getErrorsFieldsFromZodError } from "../zod"
|
||||
|
||||
const zodIssue: ZodIssue = {
|
||||
code: "too_small",
|
||||
minimum: 1,
|
||||
type: "string",
|
||||
inclusive: true,
|
||||
exact: false,
|
||||
message: "String must contain at least 1 character(s)",
|
||||
path: ["name"],
|
||||
}
|
||||
|
||||
describe("utils/zod", () => {
|
||||
describe("getErrorsFieldsFromZodError", () => {
|
||||
it("should return an array of the fields that have errors", () => {
|
||||
// Arrange - Given
|
||||
const error = new ZodError([
|
||||
{
|
||||
...zodIssue,
|
||||
path: ["field1"],
|
||||
},
|
||||
{
|
||||
...zodIssue,
|
||||
path: ["field2"],
|
||||
},
|
||||
])
|
||||
|
||||
// Act - When
|
||||
const result = getErrorsFieldsFromZodError(error)
|
||||
|
||||
// Assert - Then
|
||||
const expected = ["field1", "field2"]
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
@ -11,7 +11,7 @@ export const getISODate = (date: Date): string => {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export const getNowDate = (): Date => {
|
||||
export const getNowDateUTC = (): Date => {
|
||||
const date = new Date()
|
||||
const milliseconds = Date.UTC(
|
||||
date.getFullYear(),
|
||||
@ -28,8 +28,8 @@ export const getNowDate = (): Date => {
|
||||
* Get the week number [1-52] for a given date.
|
||||
* @param {Date} date
|
||||
* @returns {number}
|
||||
* @example getWeekNumber(new Date(2020, 0, 1)) // 1
|
||||
* @example getWeekNumber(new Date(2020, 0, 8)) // 2
|
||||
* @example getWeekNumber(new Date("2020-01-01")) // 1
|
||||
* @example getWeekNumber(new Date("2020-01-08")) // 2
|
||||
*/
|
||||
export const getWeekNumber = (date: Date): number => {
|
||||
const dateCopy = new Date(date.getTime())
|
||||
|
8
utils/version.ts
Normal file
8
utils/version.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { version } from "@/package.json"
|
||||
|
||||
export const getVersion = (): string => {
|
||||
if (process.env["NODE_ENV"] === "development") {
|
||||
return "0.0.0-development"
|
||||
}
|
||||
return version
|
||||
}
|
@ -3,5 +3,8 @@ import type { ZodError } from "zod"
|
||||
export const getErrorsFieldsFromZodError = <T>(
|
||||
error: ZodError<T>,
|
||||
): Array<keyof T> => {
|
||||
return Object.keys(error.format()) as Array<keyof T>
|
||||
const fields = Object.keys(error.format()) as Array<keyof T>
|
||||
return fields.filter((field) => {
|
||||
return field !== "_errors"
|
||||
})
|
||||
}
|
||||
|
Reference in New Issue
Block a user