feat: get habit goal progress for a given date

This commit is contained in:
Théo LUDWIG 2024-04-08 23:21:36 +02:00
parent 2ab83dfc89
commit 20b4456245
Signed by: theoludwig
GPG Key ID: ADFE5A563D718F3B
17 changed files with 331 additions and 130 deletions

View File

@ -12,6 +12,7 @@ const HabitPage: React.FC = () => {
if (habitHistory == null) { if (habitHistory == null) {
return <Redirect href="/application/habits/" /> return <Redirect href="/application/habits/" />
} }
return ( return (
<SafeAreaView <SafeAreaView
style={[ style={[

View File

@ -1,43 +1,46 @@
import { useState } from "react" import { useMemo, useState } from "react"
import { Calendar } from "react-native-calendars" import { View } from "react-native"
import { Agenda } from "react-native-calendars"
import { Text } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context" import { SafeAreaView } from "react-native-safe-area-context"
import { getISODate } from "@/utils/dates"
const HistoryPage: React.FC = () => { const HistoryPage: React.FC = () => {
const [selected, setSelected] = useState("") const today = useMemo(() => {
return new Date()
}, [])
const todayISO = getISODate(today)
const [selectedDate, setSelectedDate] = useState<Date>(today)
const selectedISODate = getISODate(selectedDate)
return ( return (
<SafeAreaView <SafeAreaView
style={[ style={[
{ {
flex: 1, flex: 1,
alignItems: "center", backgroundColor: "white",
justifyContent: "center",
}, },
]} ]}
> >
<Calendar <Agenda
onDayPress={(day) => { firstDay={1}
setSelected(day.dateString) showClosingKnob
showOnlySelectedDayItems
onDayPress={(date) => {
setSelectedDate(new Date(date.dateString))
}} }}
markedDates={{ markedDates={{
"2023-03-01": { selected: true, marked: true, selectedColor: "blue" }, [todayISO]: { marked: true },
"2023-03-02": { marked: true },
"2023-03-03": { selected: true, marked: true, selectedColor: "blue" },
[selected]: {
selected: true,
disableTouchEvent: true,
selectedColor: "orange",
},
}} }}
theme={{ selected={selectedISODate}
backgroundColor: "#000000", renderList={() => {
calendarBackground: "#000000", return (
textSectionTitleColor: "#b6c1cd", <View>
selectedDayBackgroundColor: "#00adf5", <Text>{selectedDate.toISOString()}</Text>
selectedDayTextColor: "#ffffff", </View>
todayTextColor: "#00adf5", )
dayTextColor: "#2d4150",
textDisabledColor: "#d9efff",
}} }}
/> />
</SafeAreaView> </SafeAreaView>

View File

@ -16,6 +16,7 @@ const HabitsPage: React.FC = () => {
style={[ style={[
{ {
flex: 1, flex: 1,
backgroundColor: "white",
alignItems: "center", alignItems: "center",
justifyContent: justifyContent:
retrieveHabitsTracker.state === "loading" || retrieveHabitsTracker.state === "loading" ||

View File

@ -86,6 +86,24 @@ export abstract class GoalProgress implements GoalProgressBase {
public abstract isCompleted(): boolean public abstract isCompleted(): boolean
public abstract toJSON(): GoalProgressBase 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 { interface GoalNumericOptions extends GoalBase {

View File

@ -1,5 +1,8 @@
import { getISODate, getWeekNumber } from "@/utils/dates"
import type { Habit } from "./Habit" import type { Habit } from "./Habit"
import type { HabitProgress } from "./HabitProgress" import type { HabitProgress } from "./HabitProgress"
import type { GoalProgress } from "./Goal"
import { GoalBooleanProgress, GoalNumericProgress } from "./Goal"
export interface HabitHistoryJSON { export interface HabitHistoryJSON {
habit: Habit habit: Habit
@ -8,11 +11,70 @@ export interface HabitHistoryJSON {
export class HabitHistory implements HabitHistoryJSON { export class HabitHistory implements HabitHistoryJSON {
public habit: Habit public habit: Habit
public progressHistory: HabitProgress[]
private _progressHistory: HabitProgress[] = []
public constructor(options: HabitHistoryJSON) { public constructor(options: HabitHistoryJSON) {
const { habit, progressHistory } = options const { habit, progressHistory } = options
this.habit = habit this.habit = habit
this.progressHistory = progressHistory 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")
}
} }

View File

@ -39,9 +39,7 @@ export class RetrieveHabitsTrackerUseCase
}) })
return new HabitHistory({ return new HabitHistory({
habit, habit,
progressHistory: progressHistory.sort((a, b) => { progressHistory,
return a.date.getTime() - b.date.getTime()
}),
}) })
}), }),
) )

View File

@ -8,7 +8,7 @@ import type {
import type { AuthenticationUseCase } from "@/domain/use-cases/Authentication" import type { AuthenticationUseCase } from "@/domain/use-cases/Authentication"
import type { ErrorGlobal, FetchState } from "./_Presenter" import type { ErrorGlobal, FetchState } from "./_Presenter"
import { Presenter } from "./_Presenter" import { Presenter } from "./_Presenter"
import { getErrorsFieldsFromZodError } from "./utils/zod" import { getErrorsFieldsFromZodError } from "../../utils/zod"
export interface AuthenticationPresenterState { export interface AuthenticationPresenterState {
user: User | null user: User | null

View File

@ -8,7 +8,7 @@ import type {
RetrieveHabitsTrackerUseCaseOptions, RetrieveHabitsTrackerUseCaseOptions,
} from "@/domain/use-cases/RetrieveHabitsTracker" } from "@/domain/use-cases/RetrieveHabitsTracker"
import type { HabitCreateData } from "@/domain/entities/Habit" 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" import type { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
export interface HabitsTrackerPresenterState { export interface HabitsTrackerPresenterState {

View File

@ -1,17 +1,19 @@
import FontAwesome6 from "@expo/vector-icons/FontAwesome6" import FontAwesome6 from "@expo/vector-icons/FontAwesome6"
import { useRouter } from "expo-router" 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 type { GoalProgress } from "@/domain/entities/Goal"
import { getColorRGBAFromHex } from "@/presentation/presenters/utils/colors" import type { Habit } from "@/domain/entities/Habit"
import { getColorRGBAFromHex } from "@/utils/colors"
export interface HabitCardProps { export interface HabitCardProps {
habitHistory: HabitHistoryType habit: Habit
goalProgress: GoalProgress
} }
export const HabitCard: React.FC<HabitCardProps> = (props) => { export const HabitCard: React.FC<HabitCardProps> = (props) => {
const { habitHistory } = props const { habit, goalProgress } = props
const { habit } = habitHistory
const router = useRouter() const router = useRouter()
@ -63,6 +65,25 @@ export const HabitCard: React.FC<HabitCardProps> = (props) => {
/> />
) )
}} }}
right={() => {
if (goalProgress.isNumeric()) {
return (
<View>
<Text>
{goalProgress.progress.toLocaleString()} /{" "}
{goalProgress.goal.target.value.toLocaleString()}{" "}
{goalProgress.goal.target.unit}
</Text>
</View>
)
}
return (
<View>
<Text>{goalProgress.isCompleted() ? "true" : "false"}</Text>
</View>
)
}}
/> />
) )
} }

View File

@ -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 (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Text variant="titleLarge">{"Let's begin by adding habits! 🤩"}</Text>
<Button
mode="contained"
style={{
marginTop: 16,
width: 250,
height: 40,
}}
onPress={() => {
router.push("/application/habits/new")
}}
>
<Text
style={{
color: "white",
fontWeight: "bold",
fontSize: 16,
}}
>
Create your first habit! 🚀
</Text>
</Button>
</View>
)
}

View File

@ -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<HabitsListProps> = (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 <HabitsEmpty />
}
return (
<ScrollView
showsVerticalScrollIndicator={false}
style={{
paddingHorizontal: 20,
width: Dimensions.get("window").width,
}}
>
<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) => {
const goalProgress = item.getGoalProgressByDate(selectedDate)
return (
<HabitCard
habit={item.habit}
goalProgress={goalProgress}
key={item.habit.id}
/>
)
})}
</List.Accordion>
)
})}
</List.Section>
</ScrollView>
)
}

View File

@ -1,13 +1,9 @@
import { useRouter } from "expo-router" import { useMemo, useState } from "react"
import { useState } from "react" import { Agenda } from "react-native-calendars"
import { Dimensions, ScrollView, View } from "react-native"
import { Button, List, Text } 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 type { HabitsTracker } from "@/domain/entities/HabitsTracker"
import { capitalize } from "@/presentation/presenters/utils/strings" import { getISODate } from "@/utils/dates"
import { HabitCard } from "./HabitCard" import { HabitsList } from "./HabitsList"
export interface HabitsMainPageProps { export interface HabitsMainPageProps {
habitsTracker: HabitsTracker habitsTracker: HabitsTracker
@ -16,91 +12,34 @@ export interface HabitsMainPageProps {
export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => { export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => {
const { habitsTracker } = props const { habitsTracker } = props
const router = useRouter() const today = useMemo(() => {
return new Date()
}, [])
const todayISO = getISODate(today)
const habitsByFrequency = GOAL_FREQUENCIES.filter((frequency) => { const [selectedDate, setSelectedDate] = useState<Date>(today)
return habitsTracker.habitsHistory[frequency].length > 0 const selectedISODate = getISODate(selectedDate)
})
const [accordionExpanded, setAccordionExpanded] = useState<{
[key in GoalFrequency]: boolean
}>({
daily: true,
weekly: true,
monthly: true,
})
if (habitsByFrequency.length <= 0) {
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Text variant="titleLarge">{"Let's begin by adding habits! 🤩"}</Text>
<Button
mode="contained"
style={{
marginTop: 16,
width: 250,
height: 40,
}}
onPress={() => {
router.push("/application/habits/new")
}}
>
<Text
style={{
color: "white",
fontWeight: "bold",
fontSize: 16,
}}
>
Create your first habit! 🚀
</Text>
</Button>
</View>
)
}
return ( return (
<ScrollView <Agenda
showsVerticalScrollIndicator={false} firstDay={1}
style={{ showClosingKnob
paddingHorizontal: 20, showOnlySelectedDayItems
width: Dimensions.get("window").width, onDayPress={(date) => {
setSelectedDate(new Date(date.dateString))
}} }}
> markedDates={{
<List.Section> [todayISO]: { marked: true },
{habitsByFrequency.map((frequency) => { }}
return ( selected={selectedISODate}
<List.Accordion renderList={() => {
expanded={accordionExpanded[frequency]} return (
onPress={() => { <HabitsList
setAccordionExpanded((old) => { habitsTracker={habitsTracker}
return { selectedDate={selectedDate}
...old, />
[frequency]: !old[frequency], )
} }}
}) />
}}
key={frequency}
title={capitalize(frequency)}
titleStyle={[
{
fontSize: 26,
},
]}
>
{habitsTracker.habitsHistory[frequency].map((item) => {
return <HabitCard habitHistory={item} key={item.habit.id} />
})}
</List.Accordion>
)
})}
</List.Section>
</ScrollView>
) )
} }

View File

@ -2,11 +2,13 @@ import { useEffect, useState } from "react"
import type { Presenter } from "@/presentation/presenters/_Presenter" import type { Presenter } from "@/presentation/presenters/_Presenter"
export const usePresenterState = <S>(presenter: Presenter<S>): S => { export const usePresenterState = <State>(
const [state, setState] = useState<S>(presenter.initialState) presenter: Presenter<State>,
): State => {
const [state, setState] = useState<State>(presenter.initialState)
useEffect(() => { useEffect(() => {
const presenterSubscription = (state: S): void => { const presenterSubscription = (state: State): void => {
setState(state) setState(state)
} }

35
utils/dates.ts Normal file
View File

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