1
1
mirror of https://github.com/theoludwig/p61-project.git synced 2024-07-17 07:00:12 +02:00

Merge branch 'develop' into feat/stats

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

View File

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

View File

@ -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
View File

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

View File

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

View File

@ -1,4 +1,4 @@
# P61 - Projet
# Habits Tracker - P61 Projet
## À propos
@ -6,6 +6,10 @@ Application mobile en [React Native](https://reactnative.dev/) pour le projet du
Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours.
<p align="center">
<img src="./docs/screenshots/habits.png" alt="Habits Tracker Screenshot" height="400px" />
</p>
### Membres du Groupe 7
- [Théo LUDWIG](https://git.unistra.fr/t.ludwig)
@ -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>
```

View File

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

View File

@ -1,4 +1,6 @@
import { Stack } from "expo-router"
import { fas } from "@fortawesome/free-solid-svg-icons"
import { library } from "@fortawesome/fontawesome-svg-core"
import * as SplashScreen from "expo-splash-screen"
import {
MD3LightTheme as DefaultTheme,
@ -20,6 +22,8 @@ export const unstableSettings = {
initialRouteName: "index",
}
library.add(fas)
SplashScreen.preventAutoHideAsync().catch((error) => {
console.error(error)
})

View File

@ -1,14 +1,14 @@
import { Redirect, Tabs } from "expo-router"
import React from "react"
import { TabBarIcon } from "@/presentation/react/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,
}}
/>

View File

@ -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 = () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
docs/screenshots/habits.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

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

View File

@ -8,6 +8,7 @@ export const HabitSchema = EntitySchema.extend({
name: z.string().min(1).max(50),
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,
}
}
}

View File

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

View File

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

View File

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

View File

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

15
eas.json Normal file
View File

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

View File

@ -1,18 +1,19 @@
import { AuthenticationUseCase } from "@/domain/use-cases/Authentication"
import { 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,
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,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"

View File

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

7199
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

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

View File

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

View File

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

View File

@ -1,6 +1,9 @@
import type { IconName } from "@fortawesome/free-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
import { zodResolver } from "@hookform/resolvers/zod"
import { 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,
},
})

View File

@ -1,8 +1,16 @@
import type { IconName } from "@fortawesome/free-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
import { zodResolver } from "@hookform/resolvers/zod"
import { 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

View File

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

View File

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

View File

@ -1,15 +1,16 @@
import FontAwesome6 from "@expo/vector-icons/FontAwesome6"
import type { IconName } from "@fortawesome/free-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
import { useRouter } from "expo-router"
import 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,

View File

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

View File

@ -3,7 +3,7 @@ import { Agenda } from "react-native-calendars"
import { GOAL_FREQUENCIES } from "@/domain/entities/Goal"
import 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}
/>
)
}}

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import CircularProgress from "react-native-circular-progress-indicator"
import { Agenda } from "react-native-calendars"
import { 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)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,75 @@
import { act, renderHook } from "@testing-library/react-native"
import { Presenter } from "@/presentation/presenters/_Presenter"
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
interface MockCountPresenterState {
count: number
}
class MockCountPresenter extends Presenter<MockCountPresenterState> {
public constructor(initialState: MockCountPresenterState) {
super(initialState)
}
public increment(): void {
this.setState((state) => {
state.count = state.count + 1
})
}
}
describe("presentation/react/hooks/usePresenterState", () => {
it("should return the initial state from the presenter", async () => {
// Arrange - Given
const initialState = { count: 0 }
const presenter = new MockCountPresenter(initialState)
// Act - When
const { result } = renderHook(() => {
return usePresenterState(presenter)
})
// Assert - Then
expect(result.current).toEqual(initialState)
})
it("should update state when presenter state changes", async () => {
// Arrange - Given
const initialState = { count: 0 }
const presenter = new MockCountPresenter(initialState)
const subscribe = jest.spyOn(presenter, "subscribe")
const { result } = renderHook(() => {
return usePresenterState(presenter)
})
// Act - When
await act(() => {
presenter.increment()
})
// Assert - Then
expect(result.current.count).toBe(1)
expect(subscribe).toHaveBeenCalledTimes(1)
})
it("should unsubscribe from presenter on unmount", async () => {
// Arrange - Given
const initialState = { count: 0 }
const presenter = new MockCountPresenter(initialState)
const unsubscribe = jest.spyOn(presenter, "unsubscribe")
const { result, unmount } = renderHook(() => {
return usePresenterState(presenter)
})
// Act - When
unmount()
await act(() => {
presenter.increment()
})
// Assert - Then
expect(result.current.count).toBe(0)
expect(unsubscribe).toHaveBeenCalledTimes(1)
})
})

View File

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

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

@ -0,0 +1,115 @@
import { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
import type { HabitData } from "@/domain/entities/Habit"
import { Habit } from "@/domain/entities/Habit"
import { USER_MOCK } from "./User"
import { ONE_DAY_MILLISECONDS } from "@/utils/dates"
interface HabitMockCreateOptions extends Omit<HabitData, "startDate"> {
startDate?: Date
}
const habitMockCreate = (options: HabitMockCreateOptions): Habit => {
const {
id,
userId,
name,
color,
icon,
goal,
startDate = new Date(),
endDate,
} = options
return new Habit({
id,
userId,
name,
color,
icon,
goal,
startDate,
endDate,
})
}
const examplesByNames = {
"Wake up at 07h00": habitMockCreate({
id: "1",
userId: USER_MOCK.example.id,
name: "Wake up at 07h00",
color: "#006CFF",
icon: "bed",
goal: new GoalBoolean({
frequency: "daily",
}),
}),
"Learn English": habitMockCreate({
id: "2",
userId: USER_MOCK.example.id,
name: "Learn English",
color: "#EB4034",
icon: "language",
goal: new GoalNumeric({
frequency: "daily",
target: {
value: 30,
unit: "minutes",
},
}),
}),
Walk: habitMockCreate({
id: "3",
userId: USER_MOCK.example.id,
name: "Walk",
color: "#228B22",
icon: "person-walking",
goal: new GoalNumeric({
frequency: "daily",
target: {
value: 5000,
unit: "steps",
},
}),
}),
"Clean the house": habitMockCreate({
id: "4",
userId: USER_MOCK.example.id,
name: "Clean the house",
color: "#808080",
icon: "broom",
goal: new GoalBoolean({
frequency: "weekly",
}),
}),
"Solve Programming Challenges": habitMockCreate({
id: "5",
userId: USER_MOCK.example.id,
name: "Solve Programming Challenges",
color: "#DE3163",
icon: "code",
goal: new GoalNumeric({
frequency: "monthly",
target: {
value: 5,
unit: "challenges",
},
}),
endDate: new Date(Date.now() + ONE_DAY_MILLISECONDS),
}),
} as const
export const examplesByIds = {
[examplesByNames["Wake up at 07h00"].id]: examplesByNames["Wake up at 07h00"],
[examplesByNames["Learn English"].id]: examplesByNames["Learn English"],
[examplesByNames.Walk.id]: examplesByNames.Walk,
[examplesByNames["Clean the house"].id]: examplesByNames["Clean the house"],
[examplesByNames["Solve Programming Challenges"].id]:
examplesByNames["Solve Programming Challenges"],
} as const
export const HABIT_MOCK = {
create: habitMockCreate,
example: examplesByNames["Wake up at 07h00"],
examplesByNames,
examplesByIds,
examples: Object.values(examplesByNames),
}

View File

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

View File

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

View File

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

View File

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

View File

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

1
tests/setup.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ export const getISODate = (date: Date): string => {
return date.toISOString().slice(0, 10)
}
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
View File

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

View File

@ -3,5 +3,8 @@ import type { ZodError } from "zod"
export const getErrorsFieldsFromZodError = <T>(
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"
})
}