mirror of
https://github.com/theoludwig/p61-project.git
synced 2024-07-17 07:00:12 +02:00
feat: get habit goal progress for a given date
This commit is contained in:
parent
2ab83dfc89
commit
20b4456245
@ -12,6 +12,7 @@ const HabitPage: React.FC = () => {
|
||||
if (habitHistory == null) {
|
||||
return <Redirect href="/application/habits/" />
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={[
|
||||
|
@ -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>
|
||||
|
@ -16,6 +16,7 @@ const HabitsPage: React.FC = () => {
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor: "white",
|
||||
alignItems: "center",
|
||||
justifyContent:
|
||||
retrieveHabitsTracker.state === "loading" ||
|
||||
|
@ -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 {
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
@ -39,9 +39,7 @@ export class RetrieveHabitsTrackerUseCase
|
||||
})
|
||||
return new HabitHistory({
|
||||
habit,
|
||||
progressHistory: progressHistory.sort((a, b) => {
|
||||
return a.date.getTime() - b.date.getTime()
|
||||
}),
|
||||
progressHistory,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
40
presentation/react/components/HabitsMainPage/HabitsEmpty.tsx
Normal file
40
presentation/react/components/HabitsMainPage/HabitsEmpty.tsx
Normal 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>
|
||||
)
|
||||
}
|
81
presentation/react/components/HabitsMainPage/HabitsList.tsx
Normal file
81
presentation/react/components/HabitsMainPage/HabitsList.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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 [selectedDate, setSelectedDate] = useState<Date>(today)
|
||||
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",
|
||||
<Agenda
|
||||
firstDay={1}
|
||||
showClosingKnob
|
||||
showOnlySelectedDayItems
|
||||
onDayPress={(date) => {
|
||||
setSelectedDate(new Date(date.dateString))
|
||||
}}
|
||||
>
|
||||
<Text variant="titleLarge">{"Let's begin by adding habits! 🤩"}</Text>
|
||||
<Button
|
||||
mode="contained"
|
||||
style={{
|
||||
marginTop: 16,
|
||||
width: 250,
|
||||
height: 40,
|
||||
markedDates={{
|
||||
[todayISO]: { marked: true },
|
||||
}}
|
||||
onPress={() => {
|
||||
router.push("/application/habits/new")
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
Create your first habit! 🚀
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
selected={selectedISODate}
|
||||
renderList={() => {
|
||||
return (
|
||||
<HabitsList
|
||||
habitsTracker={habitsTracker}
|
||||
selectedDate={selectedDate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={{
|
||||
paddingHorizontal: 20,
|
||||
width: Dimensions.get("window").width,
|
||||
}}
|
||||
>
|
||||
<List.Section>
|
||||
{habitsByFrequency.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} key={item.habit.id} />
|
||||
})}
|
||||
</List.Accordion>
|
||||
)
|
||||
})}
|
||||
</List.Section>
|
||||
</ScrollView>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -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
35
utils/dates.ts
Normal 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,
|
||||
)
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue
Block a user