From 20b4456245b15bd5d14b77d78109358fa6cb3430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LUDWIG?= Date: Mon, 8 Apr 2024 23:21:36 +0200 Subject: [PATCH] feat: get habit goal progress for a given date --- app/application/habits/[habitId]/index.tsx | 1 + app/application/habits/history.tsx | 53 ++++---- app/application/habits/index.tsx | 1 + domain/entities/Goal.ts | 18 +++ domain/entities/HabitHistory.ts | 64 +++++++++- domain/use-cases/RetrieveHabitsTracker.ts | 4 +- presentation/presenters/Authentication.ts | 2 +- presentation/presenters/HabitsTracker.ts | 2 +- .../components/HabitsMainPage/HabitCard.tsx | 33 ++++- .../components/HabitsMainPage/HabitsEmpty.tsx | 40 ++++++ .../components/HabitsMainPage/HabitsList.tsx | 81 ++++++++++++ .../HabitsMainPage/HabitsMainPage.tsx | 119 +++++------------- presentation/react/hooks/usePresenterState.ts | 8 +- .../presenters/utils => utils}/colors.ts | 0 utils/dates.ts | 35 ++++++ .../presenters/utils => utils}/strings.ts | 0 .../presenters/utils => utils}/zod.ts | 0 17 files changed, 331 insertions(+), 130 deletions(-) create mode 100644 presentation/react/components/HabitsMainPage/HabitsEmpty.tsx create mode 100644 presentation/react/components/HabitsMainPage/HabitsList.tsx rename {presentation/presenters/utils => utils}/colors.ts (100%) create mode 100644 utils/dates.ts rename {presentation/presenters/utils => utils}/strings.ts (100%) rename {presentation/presenters/utils => utils}/zod.ts (100%) diff --git a/app/application/habits/[habitId]/index.tsx b/app/application/habits/[habitId]/index.tsx index 6236764..a7fcdec 100644 --- a/app/application/habits/[habitId]/index.tsx +++ b/app/application/habits/[habitId]/index.tsx @@ -12,6 +12,7 @@ const HabitPage: React.FC = () => { if (habitHistory == null) { return } + return ( { - const [selected, setSelected] = useState("") + const today = useMemo(() => { + return new Date() + }, []) + const todayISO = getISODate(today) + + const [selectedDate, setSelectedDate] = useState(today) + const selectedISODate = getISODate(selectedDate) return ( - { - setSelected(day.dateString) + { + setSelectedDate(new Date(date.dateString)) }} markedDates={{ - "2023-03-01": { selected: true, marked: true, selectedColor: "blue" }, - "2023-03-02": { marked: true }, - "2023-03-03": { selected: true, marked: true, selectedColor: "blue" }, - [selected]: { - selected: true, - disableTouchEvent: true, - selectedColor: "orange", - }, + [todayISO]: { marked: true }, }} - theme={{ - backgroundColor: "#000000", - calendarBackground: "#000000", - textSectionTitleColor: "#b6c1cd", - selectedDayBackgroundColor: "#00adf5", - selectedDayTextColor: "#ffffff", - todayTextColor: "#00adf5", - dayTextColor: "#2d4150", - textDisabledColor: "#d9efff", + selected={selectedISODate} + renderList={() => { + return ( + + {selectedDate.toISOString()} + + ) }} /> diff --git a/app/application/habits/index.tsx b/app/application/habits/index.tsx index adcc933..9857a24 100644 --- a/app/application/habits/index.tsx +++ b/app/application/habits/index.tsx @@ -16,6 +16,7 @@ const HabitsPage: React.FC = () => { style={[ { flex: 1, + backgroundColor: "white", alignItems: "center", justifyContent: retrieveHabitsTracker.state === "loading" || diff --git a/domain/entities/Goal.ts b/domain/entities/Goal.ts index 1f1cad1..ded0b30 100644 --- a/domain/entities/Goal.ts +++ b/domain/entities/Goal.ts @@ -86,6 +86,24 @@ export abstract class GoalProgress implements GoalProgressBase { public abstract isCompleted(): boolean public abstract toJSON(): GoalProgressBase + + public static isNumeric( + goalProgress: GoalProgress, + ): goalProgress is GoalNumericProgress { + return goalProgress.goal.isNumeric() + } + public isNumeric(): this is GoalNumericProgress { + return GoalProgress.isNumeric(this) + } + + public static isBoolean( + goalProgress: GoalProgress, + ): goalProgress is GoalBooleanProgress { + return goalProgress.goal.isBoolean() + } + public isBoolean(): this is GoalBooleanProgress { + return GoalProgress.isBoolean(this) + } } interface GoalNumericOptions extends GoalBase { diff --git a/domain/entities/HabitHistory.ts b/domain/entities/HabitHistory.ts index 8241b5c..b7f7a08 100644 --- a/domain/entities/HabitHistory.ts +++ b/domain/entities/HabitHistory.ts @@ -1,5 +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" export interface HabitHistoryJSON { habit: Habit @@ -8,11 +11,70 @@ export interface HabitHistoryJSON { export class HabitHistory implements HabitHistoryJSON { public habit: Habit - public progressHistory: HabitProgress[] + + private _progressHistory: HabitProgress[] = [] public constructor(options: HabitHistoryJSON) { const { habit, progressHistory } = options this.habit = habit this.progressHistory = progressHistory } + + /** + * Progress History sorted chronologically (from old to most recent progress at the end). + */ + public get progressHistory(): HabitProgress[] { + return this._progressHistory + } + + public set progressHistory(progressHistory: HabitProgress[]) { + this._progressHistory = [...progressHistory] + this._progressHistory.sort((a, b) => { + return a.date.getTime() - b.date.getTime() + }) + } + + private getProgressesByDate(date: Date): HabitProgress[] { + return this._progressHistory.filter((progress) => { + if (this.habit.goal.frequency === "monthly") { + return ( + date.getFullYear() === progress.date.getFullYear() && + date.getMonth() === progress.date.getMonth() + ) + } + if (this.habit.goal.frequency === "weekly") { + return ( + getWeekNumber(date) === getWeekNumber(progress.date) && + date.getFullYear() === progress.date.getFullYear() + ) + } + if (this.habit.goal.frequency === "daily") { + return getISODate(date) === getISODate(progress.date) + } + return false + }) + } + + public getGoalProgressByDate(date: Date): GoalProgress { + const progresses = this.getProgressesByDate(date) + if (this.habit.goal.isBoolean()) { + const lastSavedProgress = progresses[progresses.length - 1] + return new GoalBooleanProgress({ + goal: this.habit.goal, + progress: lastSavedProgress?.goalProgress?.isCompleted() ?? false, + }) + } + + if (this.habit.goal.isNumeric()) { + return new GoalNumericProgress({ + goal: this.habit.goal, + progress: progresses.reduce((sum, current) => { + const goalProgress = current.goalProgress as GoalNumericProgress + return sum + goalProgress.progress + }, 0), + }) + } + + throw new Error("Invalid") + } } diff --git a/domain/use-cases/RetrieveHabitsTracker.ts b/domain/use-cases/RetrieveHabitsTracker.ts index dd04f40..58b4151 100644 --- a/domain/use-cases/RetrieveHabitsTracker.ts +++ b/domain/use-cases/RetrieveHabitsTracker.ts @@ -39,9 +39,7 @@ export class RetrieveHabitsTrackerUseCase }) return new HabitHistory({ habit, - progressHistory: progressHistory.sort((a, b) => { - return a.date.getTime() - b.date.getTime() - }), + progressHistory, }) }), ) diff --git a/presentation/presenters/Authentication.ts b/presentation/presenters/Authentication.ts index 97acf31..5632043 100644 --- a/presentation/presenters/Authentication.ts +++ b/presentation/presenters/Authentication.ts @@ -8,7 +8,7 @@ import type { import type { AuthenticationUseCase } from "@/domain/use-cases/Authentication" import type { ErrorGlobal, FetchState } from "./_Presenter" import { Presenter } from "./_Presenter" -import { getErrorsFieldsFromZodError } from "./utils/zod" +import { getErrorsFieldsFromZodError } from "../../utils/zod" export interface AuthenticationPresenterState { user: User | null diff --git a/presentation/presenters/HabitsTracker.ts b/presentation/presenters/HabitsTracker.ts index f1dc56a..b619559 100644 --- a/presentation/presenters/HabitsTracker.ts +++ b/presentation/presenters/HabitsTracker.ts @@ -8,7 +8,7 @@ import type { RetrieveHabitsTrackerUseCaseOptions, } from "@/domain/use-cases/RetrieveHabitsTracker" import type { HabitCreateData } from "@/domain/entities/Habit" -import { getErrorsFieldsFromZodError } from "./utils/zod" +import { getErrorsFieldsFromZodError } from "../../utils/zod" import type { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate" export interface HabitsTrackerPresenterState { diff --git a/presentation/react/components/HabitsMainPage/HabitCard.tsx b/presentation/react/components/HabitsMainPage/HabitCard.tsx index 6526d38..3cf9979 100644 --- a/presentation/react/components/HabitsMainPage/HabitCard.tsx +++ b/presentation/react/components/HabitsMainPage/HabitCard.tsx @@ -1,17 +1,19 @@ import FontAwesome6 from "@expo/vector-icons/FontAwesome6" import { useRouter } from "expo-router" -import { List } from "react-native-paper" +import { View } from "react-native" +import { List, Text } from "react-native-paper" -import type { HabitHistory as HabitHistoryType } from "@/domain/entities/HabitHistory" -import { getColorRGBAFromHex } from "@/presentation/presenters/utils/colors" +import type { GoalProgress } from "@/domain/entities/Goal" +import type { Habit } from "@/domain/entities/Habit" +import { getColorRGBAFromHex } from "@/utils/colors" export interface HabitCardProps { - habitHistory: HabitHistoryType + habit: Habit + goalProgress: GoalProgress } export const HabitCard: React.FC = (props) => { - const { habitHistory } = props - const { habit } = habitHistory + const { habit, goalProgress } = props const router = useRouter() @@ -63,6 +65,25 @@ export const HabitCard: React.FC = (props) => { /> ) }} + right={() => { + if (goalProgress.isNumeric()) { + return ( + + + {goalProgress.progress.toLocaleString()} /{" "} + {goalProgress.goal.target.value.toLocaleString()}{" "} + {goalProgress.goal.target.unit} + + + ) + } + + return ( + + {goalProgress.isCompleted() ? "true" : "false"} + + ) + }} /> ) } diff --git a/presentation/react/components/HabitsMainPage/HabitsEmpty.tsx b/presentation/react/components/HabitsMainPage/HabitsEmpty.tsx new file mode 100644 index 0000000..3c5b33f --- /dev/null +++ b/presentation/react/components/HabitsMainPage/HabitsEmpty.tsx @@ -0,0 +1,40 @@ +import { useRouter } from "expo-router" +import { View } from "react-native" +import { Button, Text } from "react-native-paper" + +export const HabitsEmpty: React.FC = () => { + const router = useRouter() + + return ( + + {"Let's begin by adding habits! 🤩"} + + + ) +} diff --git a/presentation/react/components/HabitsMainPage/HabitsList.tsx b/presentation/react/components/HabitsMainPage/HabitsList.tsx new file mode 100644 index 0000000..abd4109 --- /dev/null +++ b/presentation/react/components/HabitsMainPage/HabitsList.tsx @@ -0,0 +1,81 @@ +import { useState } from "react" +import { Dimensions, ScrollView } from "react-native" +import { List } from "react-native-paper" + +import type { GoalFrequency } from "@/domain/entities/Goal" +import { GOAL_FREQUENCIES } from "@/domain/entities/Goal" +import type { HabitsTracker } from "@/domain/entities/HabitsTracker" +import { capitalize } from "@/utils/strings" +import { HabitCard } from "./HabitCard" +import { HabitsEmpty } from "./HabitsEmpty" + +export interface HabitsListProps { + habitsTracker: HabitsTracker + selectedDate: Date +} + +export const HabitsList: React.FC = (props) => { + const { habitsTracker, selectedDate } = props + + const frequenciesFiltered = GOAL_FREQUENCIES.filter((frequency) => { + return habitsTracker.habitsHistory[frequency].length > 0 + }) + + const [accordionExpanded, setAccordionExpanded] = useState<{ + [key in GoalFrequency]: boolean + }>({ + daily: true, + weekly: true, + monthly: true, + }) + + if (frequenciesFiltered.length <= 0) { + return + } + + return ( + + + {frequenciesFiltered.map((frequency) => { + return ( + { + setAccordionExpanded((old) => { + return { + ...old, + [frequency]: !old[frequency], + } + }) + }} + key={frequency} + title={capitalize(frequency)} + titleStyle={[ + { + fontSize: 26, + }, + ]} + > + {habitsTracker.habitsHistory[frequency].map((item) => { + const goalProgress = item.getGoalProgressByDate(selectedDate) + return ( + + ) + })} + + ) + })} + + + ) +} diff --git a/presentation/react/components/HabitsMainPage/HabitsMainPage.tsx b/presentation/react/components/HabitsMainPage/HabitsMainPage.tsx index 540df7f..ebe5492 100644 --- a/presentation/react/components/HabitsMainPage/HabitsMainPage.tsx +++ b/presentation/react/components/HabitsMainPage/HabitsMainPage.tsx @@ -1,13 +1,9 @@ -import { useRouter } from "expo-router" -import { useState } from "react" -import { Dimensions, ScrollView, View } from "react-native" -import { Button, List, Text } from "react-native-paper" +import { useMemo, useState } from "react" +import { Agenda } from "react-native-calendars" -import type { GoalFrequency } from "@/domain/entities/Goal" -import { GOAL_FREQUENCIES } from "@/domain/entities/Goal" import type { HabitsTracker } from "@/domain/entities/HabitsTracker" -import { capitalize } from "@/presentation/presenters/utils/strings" -import { HabitCard } from "./HabitCard" +import { getISODate } from "@/utils/dates" +import { HabitsList } from "./HabitsList" export interface HabitsMainPageProps { habitsTracker: HabitsTracker @@ -16,91 +12,34 @@ export interface HabitsMainPageProps { export const HabitsMainPage: React.FC = (props) => { const { habitsTracker } = props - const router = useRouter() + const today = useMemo(() => { + return new Date() + }, []) + const todayISO = getISODate(today) - const habitsByFrequency = GOAL_FREQUENCIES.filter((frequency) => { - return habitsTracker.habitsHistory[frequency].length > 0 - }) - - const [accordionExpanded, setAccordionExpanded] = useState<{ - [key in GoalFrequency]: boolean - }>({ - daily: true, - weekly: true, - monthly: true, - }) - - if (habitsByFrequency.length <= 0) { - return ( - - {"Let's begin by adding habits! 🤩"} - - - ) - } + const [selectedDate, setSelectedDate] = useState(today) + const selectedISODate = getISODate(selectedDate) return ( - { + setSelectedDate(new Date(date.dateString)) }} - > - - {habitsByFrequency.map((frequency) => { - return ( - { - setAccordionExpanded((old) => { - return { - ...old, - [frequency]: !old[frequency], - } - }) - }} - key={frequency} - title={capitalize(frequency)} - titleStyle={[ - { - fontSize: 26, - }, - ]} - > - {habitsTracker.habitsHistory[frequency].map((item) => { - return - })} - - ) - })} - - + markedDates={{ + [todayISO]: { marked: true }, + }} + selected={selectedISODate} + renderList={() => { + return ( + + ) + }} + /> ) } diff --git a/presentation/react/hooks/usePresenterState.ts b/presentation/react/hooks/usePresenterState.ts index 55717ce..6828ccd 100644 --- a/presentation/react/hooks/usePresenterState.ts +++ b/presentation/react/hooks/usePresenterState.ts @@ -2,11 +2,13 @@ import { useEffect, useState } from "react" import type { Presenter } from "@/presentation/presenters/_Presenter" -export const usePresenterState = (presenter: Presenter): S => { - const [state, setState] = useState(presenter.initialState) +export const usePresenterState = ( + presenter: Presenter, +): State => { + const [state, setState] = useState(presenter.initialState) useEffect(() => { - const presenterSubscription = (state: S): void => { + const presenterSubscription = (state: State): void => { setState(state) } diff --git a/presentation/presenters/utils/colors.ts b/utils/colors.ts similarity index 100% rename from presentation/presenters/utils/colors.ts rename to utils/colors.ts diff --git a/utils/dates.ts b/utils/dates.ts new file mode 100644 index 0000000..43da5a0 --- /dev/null +++ b/utils/dates.ts @@ -0,0 +1,35 @@ +export const ONE_DAY_MILLISECONDS = 1_000 * 60 * 60 * 24 + +/** + * Returns a date as a string value in ISO format (without time information). + * + * @param date + * @returns + * @example getISODate(new Date("2012-05-23")) // "2012-05-23" + */ +export const getISODate = (date: Date): string => { + return date.toISOString().slice(0, 10) +} + +/** + * 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 + */ +export const getWeekNumber = (date: Date): number => { + const dateCopy = new Date(date.getTime()) + dateCopy.setHours(0, 0, 0, 0) + dateCopy.setDate(dateCopy.getDate() + 3 - ((dateCopy.getDay() + 6) % 7)) + const week1 = new Date(dateCopy.getFullYear(), 0, 4) + return ( + 1 + + Math.round( + ((dateCopy.getTime() - week1.getTime()) / ONE_DAY_MILLISECONDS - + 3 + + ((week1.getDay() + 6) % 7)) / + 7, + ) + ) +} diff --git a/presentation/presenters/utils/strings.ts b/utils/strings.ts similarity index 100% rename from presentation/presenters/utils/strings.ts rename to utils/strings.ts diff --git a/presentation/presenters/utils/zod.ts b/utils/zod.ts similarity index 100% rename from presentation/presenters/utils/zod.ts rename to utils/zod.ts