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) {
return <Redirect href="/application/habits/" />
}
return (
<SafeAreaView
style={[

View File

@ -1,43 +1,46 @@
import { useState } from "react"
import { Calendar } from "react-native-calendars"
import { useMemo, useState } from "react"
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 { getISODate } from "@/utils/dates"
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 (
<SafeAreaView
style={[
{
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "white",
},
]}
>
<Calendar
onDayPress={(day) => {
setSelected(day.dateString)
<Agenda
firstDay={1}
showClosingKnob
showOnlySelectedDayItems
onDayPress={(date) => {
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 (
<View>
<Text>{selectedDate.toISOString()}</Text>
</View>
)
}}
/>
</SafeAreaView>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<HabitCardProps> = (props) => {
const { habitHistory } = props
const { habit } = habitHistory
const { habit, goalProgress } = props
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 { 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<HabitsMainPageProps> = (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 (
<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>
)
}
const [selectedDate, setSelectedDate] = useState<Date>(today)
const selectedISODate = getISODate(selectedDate)
return (
<ScrollView
showsVerticalScrollIndicator={false}
style={{
paddingHorizontal: 20,
width: Dimensions.get("window").width,
<Agenda
firstDay={1}
showClosingKnob
showOnlySelectedDayItems
onDayPress={(date) => {
setSelectedDate(new Date(date.dateString))
}}
>
<List.Section>
{habitsByFrequency.map((frequency) => {
markedDates={{
[todayISO]: { marked: true },
}}
selected={selectedISODate}
renderList={() => {
return (
<List.Accordion
expanded={accordionExpanded[frequency]}
onPress={() => {
setAccordionExpanded((old) => {
return {
...old,
[frequency]: !old[frequency],
}
})
<HabitsList
habitsTracker={habitsTracker}
selectedDate={selectedDate}
/>
)
}}
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"
export const usePresenterState = <S>(presenter: Presenter<S>): S => {
const [state, setState] = useState<S>(presenter.initialState)
export const usePresenterState = <State>(
presenter: Presenter<State>,
): State => {
const [state, setState] = useState<State>(presenter.initialState)
useEffect(() => {
const presenterSubscription = (state: S): void => {
const presenterSubscription = (state: State): void => {
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,
)
)
}