feat: habit create use case

This commit is contained in:
Théo LUDWIG 2024-04-05 00:08:40 +02:00
parent a2d187a27a
commit e4fcb1894c
Signed by: theoludwig
GPG Key ID: ADFE5A563D718F3B
17 changed files with 648 additions and 506 deletions

View File

@ -6,6 +6,7 @@ import {
} from "react-native-paper"
import { StatusBar } from "expo-status-bar"
import { useEffect } from "react"
import { GestureHandlerRootView } from "react-native-gesture-handler"
import { HabitsTrackerProvider } from "@/presentation/react/contexts/HabitsTracker"
import {
@ -61,7 +62,9 @@ const RootLayout: React.FC = () => {
},
}}
>
<StackLayout />
<GestureHandlerRootView style={{ flex: 1 }}>
<StackLayout />
</GestureHandlerRootView>
<StatusBar style="dark" />
</PaperProvider>

View File

@ -30,7 +30,8 @@ const HabitsPage: React.FC = () => {
) : retrieveHabitsTracker.state === "error" ? (
<>
<Text variant="titleLarge">
Error: There was an issue while retrieving habits, please try again.
Error: There was an issue while retrieving habits, please try again
later.
</Text>
<Button
mode="contained"

View File

@ -12,8 +12,8 @@ export const GOAL_FREQUENCIES = GOAL_FREQUENCIES_ZOD.map((frequency) => {
export type GoalFrequency = (typeof GOAL_FREQUENCIES)[number]
export const GOAL_TYPES_ZOD = [
z.literal("numeric"),
z.literal("boolean"),
z.literal("numeric"),
] as const
export const goalTypeZod = z.union(GOAL_TYPES_ZOD)
export const GOAL_TYPES = GOAL_TYPES_ZOD.map((type) => {
@ -51,6 +51,16 @@ export abstract class Goal implements GoalBase {
this.frequency = frequency
}
public static create(options: GoalCreateData): Goal {
if (options.target.type === "boolean") {
return new GoalBoolean(options)
}
return new GoalNumeric({
frequency: options.frequency,
target: options.target,
})
}
public static isNumeric(goal: Goal): goal is GoalNumeric {
return goal.type === "numeric"
}

View File

@ -15,15 +15,15 @@ export const HabitCreateSchema = HabitSchema.extend({
}).omit({ id: true })
export type HabitCreateData = z.infer<typeof HabitCreateSchema>
type HabitDataBase = z.infer<typeof HabitSchema>
type HabitBase = z.infer<typeof HabitSchema>
export interface HabitData extends HabitDataBase {
export interface HabitData extends HabitBase {
goal: Goal
startDate: Date
endDate?: Date
}
export interface HabitJSON extends HabitDataBase {
export interface HabitJSON extends HabitBase {
goal: GoalBaseJSON
startDate: string
endDate?: string

View File

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

View File

@ -1,5 +1,6 @@
import type { GoalFrequency } from "./Goal"
import type { HabitHistory } from "./HabitHistory"
import type { Habit } from "./Habit"
import { HabitHistory } from "./HabitHistory"
export interface HabitsTrackerData {
habitsHistory: {
@ -24,4 +25,13 @@ export class HabitsTracker implements HabitsTrackerData {
},
})
}
public addHabit(habit: Habit): void {
this.habitsHistory[habit.goal.frequency].push(
new HabitHistory({
habit,
progressHistory: [],
}),
)
}
}

View File

@ -0,0 +1,9 @@
import type { Habit, HabitCreateData } from "../entities/Habit"
export interface HabitCreateOptions {
habitCreateData: HabitCreateData
}
export interface HabitCreateRepository {
execute: (options: HabitCreateOptions) => Promise<Habit>
}

View File

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

View File

@ -6,6 +6,8 @@ import { GetHabitProgressHistorySupabaseRepository } from "./supabase/repositori
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"
/**
* Repositories
@ -20,6 +22,9 @@ const getHabitProgressesRepository =
const getHabitsByUserIdRepository = new GetHabitsByUserIdSupabaseRepository({
supabaseClient,
})
const habitCreateRepository = new HabitCreateSupabaseRepository({
supabaseClient,
})
/**
* Use Cases
@ -27,6 +32,9 @@ const getHabitsByUserIdRepository = new GetHabitsByUserIdSupabaseRepository({
const authenticationUseCase = new AuthenticationUseCase({
authenticationRepository,
})
const habitCreateUseCase = new HabitCreateUseCase({
habitCreateRepository,
})
const retrieveHabitsTrackerUseCase = new RetrieveHabitsTrackerUseCase({
getHabitProgressHistoryRepository: getHabitProgressesRepository,
getHabitsByUserIdRepository,
@ -40,4 +48,5 @@ export const authenticationPresenter = new AuthenticationPresenter({
})
export const habitsTrackerPresenter = new HabitsTrackerPresenter({
retrieveHabitsTrackerUseCase,
habitCreateUseCase,
})

View File

@ -0,0 +1,42 @@
import { Habit } from "@/domain/entities/Habit"
import type { HabitCreateRepository } from "@/domain/repositories/HabitCreate"
import { SupabaseRepository } from "./_SupabaseRepository"
import { Goal } from "@/domain/entities/Goal"
export class HabitCreateSupabaseRepository
extends SupabaseRepository
implements HabitCreateRepository
{
public execute: HabitCreateRepository["execute"] = async (options) => {
const { habitCreateData } = options
const { data, error } = 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,
}
: {}),
})
.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
}
}

View File

@ -262,3 +262,6 @@ VALUES
timezone('utc' :: text, NOW()),
4733
);
-- SELECT setval('habits_id_seq', (SELECT coalesce(MAX(id) + 1, 1) FROM habits), false);
-- SELECT setval('habits_progresses_id_seq', (SELECT coalesce(MAX(id) + 1, 1) FROM habits_progresses), false);

View File

@ -5,8 +5,12 @@ import AsyncStorage from "@react-native-async-storage/async-storage"
import type { Database } from "./supabase-types"
const SUPABASE_URL = process.env["EXPO_PUBLIC_SUPABASE_URL"] ?? ""
const SUPABASE_ANON_KEY = process.env["EXPO_PUBLIC_SUPABASE_ANON_KEY"] ?? ""
const SUPABASE_URL =
process.env["EXPO_PUBLIC_SUPABASE_URL"] ??
"https://wjtwtzxreersqfvfgxrz.supabase.co"
const SUPABASE_ANON_KEY =
process.env["EXPO_PUBLIC_SUPABASE_ANON_KEY"] ??
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndqdHd0enhyZWVyc3FmdmZneHJ6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTExNDcyNzAsImV4cCI6MjAyNjcyMzI3MH0.AglONhsMvmcCRkqwrZsB4Ws9u3o1FAbLlpKJmqeUv8I"
export const supabaseClient = createClient<Database>(
SUPABASE_URL,

596
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -23,7 +23,7 @@
"@hookform/resolvers": "3.3.4",
"@react-native-async-storage/async-storage": "1.21.0",
"@react-navigation/native": "6.1.16",
"@supabase/supabase-js": "2.39.8",
"@supabase/supabase-js": "2.42.0",
"expo": "50.0.14",
"expo-font": "11.10.3",
"expo-linking": "6.2.2",
@ -35,13 +35,13 @@
"immer": "10.0.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "7.51.1",
"react-hook-form": "7.51.2",
"react-native": "0.73.6",
"react-native-calendars": "1.1304.1",
"react-native-elements": "3.4.3",
"react-native-gesture-handler": "2.16.0",
"react-native-gesture-handler": "2.14.1",
"react-native-paper": "5.12.3",
"react-native-reanimated": "~3.6.2",
"react-native-reanimated": "3.6.3",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "3.29.0",
"react-native-url-polyfill": "2.0.0",
@ -51,18 +51,18 @@
"zod": "3.22.4"
},
"devDependencies": {
"@babel/core": "7.24.3",
"@babel/core": "7.24.4",
"@commitlint/cli": "19.1.0",
"@commitlint/config-conventional": "19.1.0",
"@testing-library/react-native": "12.4.4",
"@testing-library/react-native": "12.4.5",
"@total-typescript/ts-reset": "0.5.1",
"@tsconfig/strictest": "2.0.3",
"@tsconfig/strictest": "2.0.5",
"@types/jest": "29.5.12",
"@types/node": "20.11.30",
"@types/react": "18.2.69",
"@types/node": "20.12.4",
"@types/react": "18.2.74",
"@types/react-test-renderer": "18.0.7",
"@typescript-eslint/eslint-plugin": "7.3.1",
"@typescript-eslint/parser": "7.3.1",
"@typescript-eslint/eslint-plugin": "7.5.0",
"@typescript-eslint/parser": "7.5.0",
"eslint": "8.57.0",
"eslint-config-conventions": "14.1.0",
"eslint-config-prettier": "9.1.0",
@ -79,7 +79,7 @@
"jest-junit": "16.0.0",
"lint-staged": "15.2.2",
"react-test-renderer": "18.2.0",
"supabase": "1.150.0",
"typescript": "5.4.3"
"supabase": "1.153.4",
"typescript": "5.4.4"
}
}

View File

@ -1,10 +1,15 @@
import { ZodError } from "zod"
import { HabitsTracker } from "@/domain/entities/HabitsTracker"
import type { FetchState } from "./_Presenter"
import type { ErrorGlobal, FetchState } from "./_Presenter"
import { Presenter } from "./_Presenter"
import type {
RetrieveHabitsTrackerUseCase,
RetrieveHabitsTrackerUseCaseOptions,
} from "@/domain/use-cases/RetrieveHabitsTracker"
import type { HabitCreateData } from "@/domain/entities/Habit"
import { getErrorsFieldsFromZodError } from "./utils/zod"
import type { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
export interface HabitsTrackerPresenterState {
habitsTracker: HabitsTracker
@ -12,10 +17,19 @@ export interface HabitsTrackerPresenterState {
retrieveHabitsTracker: {
state: FetchState
}
habitCreate: {
state: FetchState
errors: {
fields: Array<keyof HabitCreateData>
global: ErrorGlobal
}
}
}
export interface HabitsTrackerPresenterOptions {
retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
habitCreateUseCase: HabitCreateUseCase
}
export class HabitsTrackerPresenter
@ -23,12 +37,53 @@ export class HabitsTrackerPresenter
implements HabitsTrackerPresenterOptions
{
public retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
public habitCreateUseCase: HabitCreateUseCase
public constructor(options: HabitsTrackerPresenterOptions) {
const { retrieveHabitsTrackerUseCase } = options
const { retrieveHabitsTrackerUseCase, habitCreateUseCase } = options
const habitsTracker = HabitsTracker.default()
super({ habitsTracker, retrieveHabitsTracker: { state: "idle" } })
super({
habitsTracker,
retrieveHabitsTracker: { state: "idle" },
habitCreate: {
state: "idle",
errors: {
fields: [],
global: null,
},
},
})
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
this.habitCreateUseCase = habitCreateUseCase
}
public async habitCreate(data: unknown): Promise<FetchState> {
try {
this.setState((state) => {
state.habitCreate.state = "loading"
state.habitCreate.errors = {
fields: [],
global: null,
}
})
const habit = await this.habitCreateUseCase.execute(data)
this.setState((state) => {
state.habitCreate.state = "success"
state.habitsTracker.addHabit(habit)
})
return "success"
} catch (error) {
this.setState((state) => {
state.habitCreate.state = "error"
if (error instanceof ZodError) {
state.habitCreate.errors.fields =
getErrorsFieldsFromZodError<HabitCreateData>(error)
} else {
state.habitCreate.errors.global = "unknown"
}
})
return "error"
}
}
public async retrieveHabitsTracker(

View File

@ -1,10 +1,11 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import { ScrollView, StyleSheet } from "react-native"
import {
Appbar,
Button,
HelperText,
SegmentedButtons,
Snackbar,
Text,
TextInput,
} from "react-native-paper"
@ -14,50 +15,26 @@ 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 { capitalize } from "@/presentation/presenters/utils/strings"
import { useHabitsTracker } from "../../contexts/HabitsTracker"
export interface HabitCreateFormProps {
user: User
}
export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
// const {createHabit, habitPresenter} = useHabitCreate()
const onSubmit = async (data: HabitCreateData): Promise<void> => {
// await habitPresenter.createHabit(data)
console.log(data)
}
const frequenciesIcons: {
[key in GoalFrequency]: string
} = {
daily: "calendar",
weekly: "calendar-week",
monthly: "calendar-month",
}
const habitTypesTranslations: {
[key in GoalType]: { label: string; icon: string }
} = {
boolean: {
label: "Routine",
icon: "clock",
},
numeric: {
label: "Target",
icon: "target",
},
}
const { habitCreate, habitsTrackerPresenter } = useHabitsTracker()
const {
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<HabitCreateData>({
mode: "onChange",
@ -76,141 +53,197 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
},
})
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
const onDismissSnackbar = (): void => {
setIsVisibleSnackbar(false)
}
const onSubmit = async (data: HabitCreateData): Promise<void> => {
await habitsTrackerPresenter.habitCreate(data)
setIsVisibleSnackbar(true)
reset()
}
const habitFrequenciesTranslations: {
[key in GoalFrequency]: { label: string; icon: string }
} = {
daily: {
label: "Daily",
icon: "calendar",
},
weekly: {
label: "Weekly",
icon: "calendar-week",
},
monthly: {
label: "Monthly",
icon: "calendar-month",
},
}
const habitTypesTranslations: {
[key in GoalType]: { label: string; icon: string }
} = {
boolean: {
label: "Routine",
icon: "clock",
},
numeric: {
label: "Target",
icon: "target",
},
}
return (
<SafeAreaView>
<Appbar.Header>
<Appbar.Content
title="New Habit"
style={{
alignItems: "center",
justifyContent: "center",
<ScrollView
contentContainerStyle={{
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 20,
}}
>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<>
<TextInput
placeholder="Name"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={[
styles.spacing,
{
width: "90%",
},
]}
mode="outlined"
/>
{errors.name != null ? (
<HelperText type="error" visible style={[{ paddingTop: 0 }]}>
{errors.name.type === "too_big"
? "Name is too long"
: "Name is required"}
</HelperText>
) : null}
</>
)
}}
name="name"
/>
</Appbar.Header>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<>
<Controller
control={control}
render={({ field: { onChange, value } }) => {
return (
<>
<Text style={[styles.spacing]}>Habit Frequency</Text>
<SegmentedButtons
style={[{ width: "90%" }]}
onValueChange={onChange}
value={value}
buttons={GOAL_FREQUENCIES.map((frequency) => {
return {
label: habitFrequenciesTranslations[frequency].label,
value: frequency,
icon: habitFrequenciesTranslations[frequency].icon,
}
})}
/>
</>
)
}}
name="goal.frequency"
/>
<Controller
control={control}
render={({ field: { onChange, value } }) => {
return (
<>
<Text style={[styles.spacing]}>Habit Type</Text>
<SegmentedButtons
style={[{ width: "90%" }]}
onValueChange={onChange}
value={value}
buttons={GOAL_TYPES.map((type) => {
return {
label: habitTypesTranslations[type].label,
value: type,
icon: habitTypesTranslations[type].icon,
}
})}
/>
</>
)
}}
name="goal.target.type"
/>
<Controller
control={control}
render={({ field: { onChange, value } }) => {
return (
<ColorPicker
style={[styles.spacing, { width: "90%" }]}
value={value}
onComplete={(value) => {
onChange(value.hex)
}}
>
<Preview hideInitialColor />
<Panel1 />
<HueSlider />
</ColorPicker>
)
}}
name="color"
/>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<TextInput
placeholder="Name"
placeholder="Icon"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={[styles.input]}
style={[styles.spacing, { width: "90%" }]}
mode="outlined"
/>
{errors.name != null ? (
<HelperText type="error" visible>
{errors.name.type === "too_big"
? "Name is too long"
: "Name is required"}
</HelperText>
) : null}
</>
)
}}
name="name"
/>
)
}}
name="icon"
/>
<Controller
control={control}
render={({ field: { onChange, value } }) => {
return (
<ColorPicker
style={{ width: "70%" }}
value={value}
onChange={(value) => {
onChange(value.hex)
}}
>
<Preview hideInitialColor />
<Panel1 />
<HueSlider />
</ColorPicker>
)
}}
name="color"
/>
<Button
mode="contained"
onPress={handleSubmit(onSubmit)}
loading={habitCreate.state === "loading"}
disabled={habitCreate.state === "loading"}
style={[styles.spacing, { width: "90%" }]}
>
Create your habit! 🚀
</Button>
</ScrollView>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<TextInput
placeholder="Icon"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={[styles.input]}
mode="outlined"
/>
)
}}
name="icon"
/>
<Controller
control={control}
render={({ field: { onChange, value } }) => {
return (
<>
<Text style={{ margin: 8 }}>Habit frequency</Text>
<SegmentedButtons
onValueChange={onChange}
value={value}
buttons={GOAL_FREQUENCIES.map((frequency) => {
return {
label: capitalize(frequency),
value: frequency,
icon: frequenciesIcons[frequency],
}
})}
/>
</>
)
}}
name="goal.frequency"
/>
<Controller
control={control}
render={({ field: { onChange, value } }) => {
return (
<>
<Text style={{ margin: 8 }}>Habit type</Text>
<SegmentedButtons
onValueChange={onChange}
value={value}
buttons={GOAL_TYPES.map((type) => {
return {
label: habitTypesTranslations[type].label,
value: type,
icon: habitTypesTranslations[type].icon,
}
})}
/>
</>
)
}}
name="goal.target.type"
/>
<Button
mode="contained"
onPress={handleSubmit(onSubmit)}
// loading={createHabit.state === "loading"}
// disabled={createHabit.state === "loading"}
<Snackbar
visible={isVisibleSnackbar}
onDismiss={onDismissSnackbar}
duration={2_000}
>
Create your habit
</Button>
Habit created successfully!
</Snackbar>
</SafeAreaView>
)
}
const styles = {
input: {
margin: 8,
const styles = StyleSheet.create({
spacing: {
marginVertical: 16,
},
}
})

View File

@ -1,7 +1,7 @@
import { useRouter } from "expo-router"
import { useMemo, useState } from "react"
import { FlatList, View } from "react-native"
import { Button, List, Text } from "react-native-paper"
import { useMemo, useState } from "react"
import { useRouter } from "expo-router"
import type { GoalFrequency } from "@/domain/entities/Goal"
import { GOAL_FREQUENCIES } from "@/domain/entities/Goal"