diff --git a/src/contexts/RouterContext.jsx b/src/contexts/RouterContext.jsx index 18081c0..e97bb8b 100644 --- a/src/contexts/RouterContext.jsx +++ b/src/contexts/RouterContext.jsx @@ -23,6 +23,7 @@ import TestView from '../views/TestView/Test' import ThingsHistory from '../views/Things/ThingsHistory' import ThingsView from '../views/Things/ThingsView' import UserActivities from '../views/User/UserActivities' +import UserPoints from '../views/User/UserPoints' const getMainRoute = () => { if (import.meta.env.VITE_IS_LANDING_DEFAULT === 'true') { return @@ -71,6 +72,10 @@ const Router = createBrowserRouter([ path: '/activities', element: , }, + { + path: '/points', + element: , + }, { path: '/login', element: , diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index 8b63ae1..1b7b4c6 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -19,11 +19,11 @@ export const useCreateChore = () => { }) } -export const useChoresHistory = initialLimit => { +export const useChoresHistory = (initialLimit, includeMembers) => { const [limit, setLimit] = useState(initialLimit) // Initially, no limit is selected const { data, error, isLoading } = useQuery(['choresHistory', limit], () => - GetChoresHistory(limit), + GetChoresHistory(limit, includeMembers), ) const handleLimitChange = newLimit => { diff --git a/src/queries/UserQueries.jsx b/src/queries/UserQueries.jsx index 8cb1548..30dd203 100644 --- a/src/queries/UserQueries.jsx +++ b/src/queries/UserQueries.jsx @@ -1,6 +1,10 @@ import { useQuery } from 'react-query' -import { GetAllUsers } from '../utils/Fetcher' +import { GetAllCircleMembers, GetAllUsers } from '../utils/Fetcher' export const useAllUsers = () => { return useQuery('allUsers', GetAllUsers) } + +export const useCircleMembers = () => { + return useQuery('allCircleMembers', GetAllCircleMembers) +} diff --git a/src/utils/Chores.jsx b/src/utils/Chores.jsx index a295fb0..ad917b0 100644 --- a/src/utils/Chores.jsx +++ b/src/utils/Chores.jsx @@ -1,3 +1,4 @@ +import moment from 'moment' import { TASK_COLOR } from './Colors.jsx' export const ChoresGrouper = (groupBy, chores) => { @@ -158,3 +159,12 @@ export const ChoresGrouper = (groupBy, chores) => { } return groups } + +export const notInCompletionWindow = chore => { + return ( + chore.completionWindow && + chore.completionWindow > -1 && + chore.nextDueDate && + moment().add(chore.completionWindow, 'hours') < moment(chore.nextDueDate) + ) +} diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx index 2b5710b..9fedd62 100644 --- a/src/utils/Fetcher.jsx +++ b/src/utils/Fetcher.jsx @@ -194,11 +194,12 @@ const UpdateChoreHistory = (choreId, id, choreHistory) => { }) } -const GetAllCircleMembers = () => { - return Fetch(`/circles/members`, { +const GetAllCircleMembers = async () => { + const resp = await Fetch(`/circles/members`, { method: 'GET', headers: HEADERS(), }) + return resp.json() } const GetUserProfile = () => { diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index d6f5196..ec5625f 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -75,6 +75,7 @@ const ChoreEdit = () => { const [labels, setLabels] = useState([]) const [labelsV2, setLabelsV2] = useState([]) const [points, setPoints] = useState(-1) + const [completionWindow, setCompletionWindow] = useState(-1) const [allUserThings, setAllUserThings] = useState([]) const [thingTrigger, setThingTrigger] = useState(null) const [isThingValid, setIsThingValid] = useState(false) @@ -200,6 +201,7 @@ const ChoreEdit = () => { notificationMetadata: notificationMetadata, thingTrigger: thingTrigger, points: points < 0 ? null : points, + completionWindow: completionWindow < 0 ? null : completionWindow, } let SaveFunction = CreateChore if (choreId > 0) { @@ -216,11 +218,9 @@ const ChoreEdit = () => { } useEffect(() => { //fetch performers: - GetAllCircleMembers() - .then(response => response.json()) - .then(data => { - setPerformers(data.res) - }) + GetAllCircleMembers().then(data => { + setPerformers(data.res) + }) GetThings().then(response => { response.json().then(data => { setAllUserThings(data.res) @@ -254,7 +254,11 @@ const ChoreEdit = () => { setPoints( data.res.points && data.res.points > -1 ? data.res.points : -1, ) - // setLabels(data.res.labels ? data.res.labels.split(',') : []) + setCompletionWindow( + data.res.completionWindow && data.res.completionWindow > -1 + ? data.res.completionWindow + : -1, + ) setLabelsV2(data.res.labelsV2) setAssignStrategy( @@ -570,6 +574,70 @@ const ChoreEdit = () => { {errors.dueDate} )} + + +
+ {/* Completion window (hours) */} + Completion window (hours) + + + {"Set a time window that task can't be completed before"} + +
+ { + event.preventDefault() + if (completionWindow != -1) { + setCompletionWindow(-1) + } else { + setCompletionWindow(1) + } + }} + color={completionWindow !== -1 ? 'success' : 'neutral'} + variant={completionWindow !== -1 ? 'solid' : 'outlined'} + // endDecorator={points !== -1 ? 'On' : 'Off'} + slotProps={{ + endDecorator: { + sx: { + minWidth: 24, + }, + }, + }} + /> +
+ {completionWindow != -1 && ( + + + Hours: + + { + setCompletionWindow(parseInt(e.target.value)) + }} + /> + + + )} {!['once', 'no_repeat'].includes(frequencyType) && ( diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index 35f2efa..82e2828 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -36,6 +36,7 @@ import { Divider } from '@mui/material' import moment from 'moment' import { useEffect, useState } from 'react' import { useNavigate, useParams, useSearchParams } from 'react-router-dom' +import { notInCompletionWindow } from '../../utils/Chores.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { GetAllUsers, @@ -563,7 +564,7 @@ const ChoreView = () => { fullWidth size='lg' onClick={handleTaskCompletion} - disabled={isPendingCompletion} + disabled={isPendingCompletion || notInCompletionWindow(chore)} color={isPendingCompletion ? 'danger' : 'success'} startDecorator={} sx={{ diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx index 7eb33ea..bbf9503 100644 --- a/src/views/Chores/ChoreCard.jsx +++ b/src/views/Chores/ChoreCard.jsx @@ -43,6 +43,7 @@ import moment from 'moment' import React, { useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { UserContext } from '../../contexts/UserContext' +import { notInCompletionWindow } from '../../utils/Chores.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { ArchiveChore, @@ -412,7 +413,6 @@ const ChoreCard = ({ } return name } - return ( { Promise.all([ GetChoreHistory(choreId).then(res => res.json()), - GetAllCircleMembers().then(res => res.json()), + GetAllCircleMembers(), ]) .then(([historyData, usersData]) => { setChoresHistory(historyData.res) diff --git a/src/views/Settings/Settings.jsx b/src/views/Settings/Settings.jsx index 9d53380..bd445d5 100644 --- a/src/views/Settings/Settings.jsx +++ b/src/views/Settings/Settings.jsx @@ -54,11 +54,9 @@ const Settings = () => { setCircleMemberRequests(data.res ? data.res : []) }) }) - GetAllCircleMembers() - .then(res => res.json()) - .then(data => { - setCircleMembers(data.res ? data.res : []) - }) + GetAllCircleMembers().then(data => { + setCircleMembers(data.res ? data.res : []) + }) }, []) useEffect(() => { diff --git a/src/views/User/UserActivities.jsx b/src/views/User/UserActivities.jsx index af9f5e5..25918b5 100644 --- a/src/views/User/UserActivities.jsx +++ b/src/views/User/UserActivities.jsx @@ -3,14 +3,16 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle' import CircleIcon from '@mui/icons-material/Circle' import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts' -import { Toll } from '@mui/icons-material' +import { EventBusy, Toll } from '@mui/icons-material' import { Box, + Button, Card, Chip, Container, Divider, Grid, + Link, Stack, Tab, TabList, @@ -173,7 +175,7 @@ const UserActivites = () => { data: choresHistory, isChoresHistoryLoading, handleLimitChange: refetchHistory, - } = useChoresHistory(tabValue ? tabValue : 30) + } = useChoresHistory(tabValue ? tabValue : 30, false) useEffect(() => { if (!isChoresHistoryLoading && !isChoresLoading && choresHistory) { const enrichedHistory = choresHistory.res.map(item => { @@ -306,6 +308,40 @@ const UserActivites = () => { }, } + if (!choresData.res?.length > 0 || !choresHistory?.res?.length > 0) { + return ( + + + + + No activities + + + You have no activities for the selected period. + + + + ) + } + return ( { + const [tabValue, setTabValue] = useState(7) + + const { data: circleMembersData, isLoading: isCircleMembersLoading } = + useCircleMembers() + + const { + data: choresHistoryData, + isLoading: isChoresHistoryLoading, + handleLimitChange: handleChoresHistoryLimitChange, + } = useChoresHistory(7) + + const { userProfile } = useContext(UserContext) + const [selectedUser, setSelectedUser] = useState(userProfile?.id) + const [circleUsers, setCircleUsers] = useState([]) + const [selectedHistory, setSelectedHistory] = useState([]) + const [userPointsBarChartData, setUserPointsBarChartData] = useState([]) + + const [choresHistory, setChoresHistory] = useState([]) + + useEffect(() => { + if (circleMembersData && choresHistoryData && userProfile) { + setCircleUsers(circleMembersData.res) + setSelectedHistory(generateWeeklySummary(choresHistory, userProfile?.id)) + } + }, [circleMembersData, choresHistoryData]) + + useEffect(() => { + if (choresHistoryData) { + var history + if (tabValue === 7) { + history = generateWeeklySummary(choresHistoryData.res, selectedUser) + } else if (tabValue === 30) { + history = generateMonthSummary(choresHistoryData.res, selectedUser) + } else if (tabValue === 6 * 30) { + history = generateMonthlySummary(choresHistoryData.res, selectedUser) + } else { + history = generateYearlySummary(choresHistoryData.res, selectedUser) + } + setSelectedHistory(history) + } + }, [selectedUser, choresHistoryData]) + + useEffect(() => { + setSelectedUser(userProfile?.id) + }, [userProfile]) + + const generateUserPointsHistory = history => { + const userPoints = {} + for (let i = 0; i < history.length; i++) { + const chore = history[i] + if (!userPoints[chore.completedBy]) { + userPoints[chore.completedBy] = chore.points ? chore.points : 0 + } else { + userPoints[chore.completedBy] += chore.points ? chore.points : 0 + } + } + return userPoints + } + + const generateWeeklySummary = (history, userId) => { + const daysAggregated = [] + for (let i = 6; i > -1; i--) { + const currentDate = new Date() + currentDate.setDate(currentDate.getDate() - i) + daysAggregated.push({ + label: currentDate.toLocaleString('en-US', { weekday: 'short' }), + points: 0, + tasks: 0, + }) + } + history.forEach(chore => { + const dayName = new Date(chore.completedAt).toLocaleString('en-US', { + weekday: 'short', + }) + + const dayIndex = daysAggregated.findIndex(dayData => { + if (userId) + return dayData.label === dayName && chore.completedBy === userId + return dayData.label === dayName + }) + if (dayIndex !== -1) { + if (chore.points) daysAggregated[dayIndex].points += chore.points + daysAggregated[dayIndex].tasks += 1 + } + }) + return daysAggregated + } + + const generateMonthSummary = (history, userId) => { + const daysAggregated = [] + for (let i = 29; i > -1; i--) { + const currentDate = new Date() + currentDate.setDate(currentDate.getDate() - i) + daysAggregated.push({ + label: currentDate.toLocaleString('en-US', { day: 'numeric' }), + points: 0, + tasks: 0, + }) + } + history.forEach(chore => { + const dayName = new Date(chore.completedAt).toLocaleString('en-US', { + day: 'numeric', + }) + + const dayIndex = daysAggregated.findIndex(dayData => { + if (userId) + return dayData.label === dayName && chore.completedBy === userId + return dayData.label === dayName + }) + + if (dayIndex !== -1) { + if (chore.points) daysAggregated[dayIndex].points += chore.points + daysAggregated[dayIndex].tasks += 1 + } + }) + + return daysAggregated + } + + const generateMonthlySummary = (history, userId) => { + const monthlyAggregated = [] + for (let i = 5; i > -1; i--) { + const currentMonth = new Date() + currentMonth.setMonth(currentMonth.getMonth() - i) + monthlyAggregated.push({ + label: currentMonth.toLocaleString('en-US', { month: 'short' }), + points: 0, + tasks: 0, + }) + } + history.forEach(chore => { + const monthName = new Date(chore.completedAt).toLocaleString('en-US', { + month: 'short', + }) + + const monthIndex = monthlyAggregated.findIndex(monthData => { + if (userId) + return monthData.label === monthName && chore.completedBy === userId + return monthData.label === monthName + }) + + if (monthIndex !== -1) { + if (chore.points) monthlyAggregated[monthIndex].points += chore.points + monthlyAggregated[monthIndex].tasks += 1 + } + }) + return monthlyAggregated + } + + const generateYearlySummary = (history, userId) => { + const yearlyAggregated = [] + + for (let i = 11; i > -1; i--) { + const currentYear = new Date() + currentYear.setFullYear(currentYear.getFullYear() - i) + yearlyAggregated.push({ + label: currentYear.toLocaleString('en-US', { year: 'numeric' }), + points: 0, + tasks: 0, + }) + } + history.forEach(chore => { + const yearName = new Date(chore.completedAt).toLocaleString('en-US', { + year: 'numeric', + }) + + const yearIndex = yearlyAggregated.findIndex(yearData => { + if (userId) + return yearData.label === yearName && chore.completedBy === userId + return yearData.label === yearName + }) + + if (yearIndex !== -1) { + if (chore.points) yearlyAggregated[yearIndex].points += chore.points + yearlyAggregated[yearIndex].tasks += 1 + } + }) + return yearlyAggregated + } + + if (isChoresHistoryLoading || isCircleMembersLoading) { + return + } + + return ( + + + Points Overview + + + + + + {[ + { + title: 'Total', + value: circleMembersData.res.find( + user => user.userId === selectedUser, + )?.points, + color: 'primary', + }, + { + title: 'Available', + value: (function () { + const user = circleMembersData.res.find( + user => user.userId === selectedUser, + ) + if (!user) return 0 + return user.points - user.pointsRedeemed + })(), + + color: 'success', + }, + { + title: 'Redeemed', + value: circleMembersData.res.find( + user => user.userId === selectedUser, + )?.pointsRedeemed, + color: 'warning', + }, + ].map(card => ( + + + {card.title} + + + {card.value} + + + ))} + + Points History + + + { + setTabValue(tabValue) + handleChoresHistoryLimitChange(tabValue) + }} + defaultValue={tabValue} + sx={{ + py: 0.5, + borderRadius: 16, + maxWidth: 400, + mb: 1, + }} + > + + {[ + { label: '7 Days', value: 7 }, + // { label: '3 Month', value: 30 }, + { label: '6 Months', value: 6 * 30 }, + { label: 'All Time', value: 90 }, + ].map((tab, index) => ( + + {tab.label} + + ))} + + + + + + {[ + { + title: 'Points', + value: selectedHistory.reduce((acc, cur) => acc + cur.points, 0), + color: 'success', + }, + { + title: 'Tasks', + value: selectedHistory.reduce((acc, cur) => acc + cur.tasks, 0), + color: 'primary', + }, + ].map(card => ( + + + {card.title} + + + {card.value} + + + ))} + + {/* Bar Chart for points overtime : */} + + + + + + + + + + {/* Rounded top corners, blue fill, set bar width */} + {/* Add a slightly darker top section to the 'Jul' bar */} + + + + + + + ) +} + +export default UserPoints diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx index 46af5ce..c4c4276 100644 --- a/src/views/components/NavBar.jsx +++ b/src/views/components/NavBar.jsx @@ -9,6 +9,7 @@ import { Message, SettingsOutlined, ShareOutlined, + Toll, Widgets, } from '@mui/icons-material' import { @@ -43,15 +44,20 @@ const links = [ label: 'Things', icon: , }, + { + to: 'labels', + label: 'Labels', + icon: , + }, { to: 'activities', label: 'Activities', icon: , }, { - to: 'labels', - label: 'Labels', - icon: , + to: 'points', + label: 'Points', + icon: , }, { to: '/settings#sharing',