Experiment: Add Sidepanel with calendar view

Support redeem points
Experiment: task in sentences
Fix small bugs in the chip when loading the chore card
This commit is contained in:
Mo Tarbin 2025-01-14 10:45:41 -05:00
commit 3706c66f5f
17 changed files with 1665 additions and 478 deletions

23
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "donetick",
"version": "0.1.83",
"version": "0.1.91",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "donetick",
"version": "0.1.83",
"version": "0.1.91",
"dependencies": {
"@capacitor/android": "^6.1.1",
"@capacitor/app": "^6.0.0",
@ -26,6 +26,7 @@
"@openreplay/tracker": "^14.0.4",
"@tanstack/react-query": "^5.17.0",
"aos": "^2.3.4",
"chrono-node": "^2.7.7",
"dotenv": "^16.4.5",
"esm": "^3.2.25",
"fuse.js": "^7.0.0",
@ -5362,6 +5363,18 @@
"node": ">=10"
}
},
"node_modules/chrono-node": {
"version": "2.7.7",
"resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.7.7.tgz",
"integrity": "sha512-p3S7gotuTPu5oqhRL2p1fLwQXGgdQaRTtWR3e8Di9P1Pa9mzkK5DWR5AWBieMUh2ZdOnPgrK+zCrbbtyuA+D/Q==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.10.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/classlist-polyfill": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/classlist-polyfill/-/classlist-polyfill-1.2.0.tgz",
@ -6060,6 +6073,12 @@
"node": "*"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",

View file

@ -40,6 +40,7 @@
"@openreplay/tracker": "^14.0.4",
"@tanstack/react-query": "^5.17.0",
"aos": "^2.3.4",
"chrono-node": "^2.7.7",
"dotenv": "^16.4.5",
"esm": "^3.2.25",
"fuse.js": "^7.0.0",

View file

@ -5,12 +5,11 @@ import { useEffect, useState } from 'react'
import { QueryClient, QueryClientProvider } from 'react-query'
import { Outlet } from 'react-router-dom'
import { useRegisterSW } from 'virtual:pwa-register/react'
import { registerCapacitorListeners } from './CapacitorListener'
import { UserContext } from './contexts/UserContext'
import { AuthenticationProvider } from './service/AuthenticationService'
import { GetUserProfile } from './utils/Fetcher'
import { isTokenValid } from './utils/TokenManager'
import { registerCapacitorListeners } from './CapacitorListener'
import {apiManager} from './utils/TokenManager'
import { apiManager, isTokenValid } from './utils/TokenManager'
const add = className => {
document.getElementById('root').classList.add(className)
@ -22,9 +21,7 @@ const remove = className => {
// TODO: Update the interval to at 60 minutes
const intervalMS = 5 * 60 * 1000 // 5 minutes
function App() {
startApiManager()
startOpenReplay()
const queryClient = new QueryClient()
@ -137,5 +134,5 @@ const startOpenReplay = () => {
export default App
const startApiManager = () => {
apiManager.init();
}
apiManager.init()
}

View file

@ -1,3 +1,4 @@
import { useState } from 'react'
import { useQuery } from 'react-query'
import { GetAllCircleMembers, GetAllUsers } from '../utils/Fetcher'
@ -6,5 +7,16 @@ export const useAllUsers = () => {
}
export const useCircleMembers = () => {
return useQuery('allCircleMembers', GetAllCircleMembers)
const [refetchKey, setRefetchKey] = useState(0)
const { data, error, isLoading, refetch } = useQuery(
['allCircleMembers', refetchKey],
GetAllCircleMembers,
)
const handleRefetch = () => {
setRefetchKey(prevKey => prevKey + 1)
refetch()
}
return { data, error, isLoading, handleRefetch }
}

View file

@ -195,6 +195,14 @@ const UpdateChoreHistory = (choreId, id, choreHistory) => {
})
}
const UpdateChoreStatus = (choreId, status) => {
return Fetch(`/chores/${choreId}/status`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify({ status }),
})
}
const GetAllCircleMembers = async () => {
const resp = await Fetch(`/circles/members`, {
method: 'GET',
@ -415,6 +423,14 @@ const UpdateDueDate = (id, dueDate) => {
})
}
const RedeemPoints = (userId, points, circleID) => {
return Fetch(`/circles/${circleID}/members/points/redeem`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({ points, userId }),
})
}
const RefreshToken = () => {
const basedURL = apiManager.getApiURL()
return fetch(`${basedURL}/auth/refresh`, {
@ -474,6 +490,7 @@ export {
LeaveCircle,
MarkChoreComplete,
PutNotificationTarget,
RedeemPoints,
RefreshToken,
ResetPassword,
SaveChore,
@ -483,6 +500,7 @@ export {
UpdateChoreAssignee,
UpdateChoreHistory,
UpdateChorePriority,
UpdateChoreStatus,
UpdateDueDate,
UpdateLabel,
UpdateNotificationTarget,

View file

@ -1,3 +1,6 @@
import { Capacitor } from '@capacitor/core'
import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth'
import { Settings } from '@mui/icons-material'
import GoogleIcon from '@mui/icons-material/Google'
import {
Avatar,
@ -15,15 +18,11 @@ import Cookies from 'js-cookie'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { LoginSocialGoogle } from 'reactjs-social-login'
import { API_URL, GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
import { UserContext } from '../../contexts/UserContext'
import Logo from '../../Logo'
import { GetUserProfile, login } from '../../utils/Fetcher'
import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth';
import { Capacitor } from '@capacitor/core'
import { Settings } from '@mui/icons-material'
import { apiManager } from '../../utils/TokenManager'
const LoginView = () => {
const { userProfile, setUserProfile } = React.useContext(UserContext)
@ -34,7 +33,6 @@ const LoginView = () => {
const handleSubmit = async e => {
e.preventDefault()
login(username, password)
.then(response => {
if (response.status === 200) {
return response.json().then(data => {
@ -63,7 +61,8 @@ const LoginView = () => {
}
const loggedWithProvider = function (provider, data) {
return fetch(API_URL + `/auth/${provider}/callback`, {
const baseURL = apiManager.getApiURL()
return fetch(`${baseURL}/auth/${provider}/callback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -141,20 +140,21 @@ const LoginView = () => {
boxShadow: 'md',
}}
>
<IconButton
// on top right of the screen:
sx={{
position: 'absolute',
top: 2,
right: 2,
color: 'black',
}}
onClick={() => {
Navigate('/login/settings')
}
}
> <Settings /></IconButton>
<IconButton
// on top right of the screen:
sx={{
position: 'absolute',
top: 2,
right: 2,
color: 'black',
}}
onClick={() => {
Navigate('/login/settings')
}}
>
{' '}
<Settings />
</IconButton>
{/* <img
src='/src/assets/logo.svg'
alt='logo'
@ -301,32 +301,61 @@ const LoginView = () => {
)}
<Divider> or </Divider>
{!Capacitor.isNativePlatform() && (
<Box sx={{ width: '100%' }}>
<LoginSocialGoogle
client_id={GOOGLE_CLIENT_ID}
redirect_uri={REDIRECT_URL}
scope='openid profile email'
discoveryDocs='claims_supported'
access_type='online'
isOnlyGetToken={true}
onResolve={({ provider, data }) => {
loggedWithProvider(provider, data)
}}
onReject={err => {
setError("Couldn't log in with Google, please try again")
}}
>
<Box sx={{ width: '100%' }}>
<LoginSocialGoogle
client_id={GOOGLE_CLIENT_ID}
redirect_uri={REDIRECT_URL}
scope='openid profile email'
discoveryDocs='claims_supported'
access_type='online'
isOnlyGetToken={true}
onResolve={({ provider, data }) => {
loggedWithProvider(provider, data)
}}
onReject={err => {
setError("Couldn't log in with Google, please try again")
}}
>
<Button
variant='soft'
color='neutral'
size='lg'
fullWidth
sx={{
width: '100%',
mt: 1,
mb: 1,
border: 'moccasin',
borderRadius: '8px',
}}
>
<div className='flex gap-2'>
<GoogleIcon />
Continue with Google
</div>
</Button>
</LoginSocialGoogle>
</Box>
)}
{Capacitor.isNativePlatform() && (
<Box sx={{ width: '100%' }}>
<Button
variant='soft'
color='neutral'
size='lg'
fullWidth
sx={{
width: '100%',
mt: 1,
mb: 1,
border: 'moccasin',
borderRadius: '8px',
variant='soft'
size='lg'
sx={{ mt: 3, mb: 2 }}
onClick={() => {
GoogleAuth.initialize({
clientId: import.meta.env.VITE_APP_GOOGLE_CLIENT_ID,
scopes: ['profile', 'email', 'openid'],
grantOfflineAccess: true,
})
GoogleAuth.signIn().then(user => {
console.log('Google user', user)
loggedWithProvider('google', user.authentication)
})
}}
>
<div className='flex gap-2'>
@ -334,33 +363,8 @@ const LoginView = () => {
Continue with Google
</div>
</Button>
</LoginSocialGoogle>
</Box> )}
{Capacitor.isNativePlatform() && (
<Box sx={{ width: '100%' }}>
<Button fullWidth variant='soft' size='lg' sx={{ mt: 3, mb: 2 }}
onClick={()=>{
GoogleAuth.initialize({
clientId: import.meta.env.VITE_APP_GOOGLE_CLIENT_ID,
scopes: ['profile', 'email','openid'],
grantOfflineAccess: true,
});
GoogleAuth.signIn().then((user) => {
console.log("Google user", user);
loggedWithProvider("google", user.authentication)
});
}}>
<div className='flex gap-2'>
<GoogleIcon />
Continue with Google
</div>
</Button>
</Box>
</Box>
)}
<Button
onClick={() => {

View file

@ -378,7 +378,7 @@ const ChoreEdit = () => {
</Typography> */}
<Box>
<FormControl error={errors.name}>
<Typography level='h4'>Description :</Typography>
<Typography level='h4'>Title :</Typography>
<Typography level='h5'>What is this chore about?</Typography>
<Input value={name} onChange={e => setName(e.target.value)} />
<FormHelperText error>{errors.name}</FormHelperText>

View file

@ -377,9 +377,13 @@ const ChoreCard = ({
const notSelectedShortMonths = notSelectedMonth.map(m =>
moment().month(m).format('MMM'),
)
return `${chore.frequency}${dayOfMonthSuffix(
let result = `Monthly ${chore.frequency}${dayOfMonthSuffix(
chore.frequency,
)} except ${notSelectedShortMonths.join(', ')}`
)}`
if (notSelectedShortMonths.length > 0)
result += `
except ${notSelectedShortMonths.join(', ')}`
return result
} else {
let freqData = JSON.parse(chore.frequencyMetadata)
const months = freqData.months.map(m => moment().month(m).format('MMM'))

View file

@ -5,6 +5,7 @@ import {
ExpandCircleDown,
FilterAlt,
PriorityHigh,
Search,
Sort,
Style,
Unarchive,
@ -44,11 +45,13 @@ import ChoreCard from './ChoreCard'
import IconButtonWithMenu from './IconButtonWithMenu'
import { ChoresGrouper } from '../../utils/Chores'
import TaskInput from '../components/AddTaskModal'
import {
canScheduleNotification,
scheduleChoreNotification,
} from './LocalNotificationScheduler'
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
import Sidepanel from './Sidepanel'
const MyChores = () => {
const { userProfile, setUserProfile } = useContext(UserContext)
@ -59,6 +62,10 @@ const MyChores = () => {
const [filteredChores, setFilteredChores] = useState([])
const [selectedFilter, setSelectedFilter] = useState('All')
const [choreSections, setChoreSections] = useState([])
const [activeTextField, setActiveTextField] = useState('task')
const [taskInputFocus, setTaskInputFocus] = useState(0)
const searchInputRef = useRef()
const [searchInputFocus, setSearchInputFocus] = useState(0)
const [selectedChoreSection, setSelectedChoreSection] = useState('due_date')
const [openChoreSections, setOpenChoreSections] = useState({})
const [searchTerm, setSearchTerm] = useState('')
@ -169,6 +176,22 @@ const MyChores = () => {
}
}, [anchorEl])
useEffect(() => {
if (searchInputFocus > 0 && searchInputRef.current) {
searchInputRef.current.focus()
searchInputRef.current.selectionStart =
searchInputRef.current.value?.length
searchInputRef.current.selectionEnd = searchInputRef.current.value?.length
}
}, [searchInputFocus])
const updateChores = newChore => {
const newChores = chores
newChores.push(newChore)
setChores(newChores)
setFilteredChores(newChores)
setChoreSections(ChoresGrouper('due_date', newChores))
setSelectedFilter('All')
}
const handleMenuOutsideClick = event => {
if (
anchorEl &&
@ -315,408 +338,477 @@ const MyChores = () => {
}
return (
<Container maxWidth='md'>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignContent: 'center',
alignItems: 'center',
gap: 0.5,
}}
>
<Input
placeholder='Search'
value={searchTerm}
fullWidth
sx={{
mt: 1,
mb: 1,
borderRadius: 24,
height: 24,
borderColor: 'text.disabled',
padding: 1,
}}
onChange={handleSearchChange}
endDecorator={
searchTerm && (
<CancelRounded
onClick={() => {
setSearchTerm('')
setFilteredChores(chores)
}}
/>
)
}
/>
<IconButtonWithMenu
icon={<PriorityHigh />}
title='Filter by Priority'
options={Priorities}
selectedItem={selectedFilter}
onItemSelect={selected => {
handleLabelFiltering({ priority: selected.value })
}}
mouseClickHandler={handleMenuOutsideClick}
isActive={selectedFilter.startsWith('Priority: ')}
/>
<IconButtonWithMenu
icon={<Style />}
// TODO : this need simplification we want to display both user labels and chore labels
// that why we are merging them here.
// we also filter out the labels that user created as those will be part of user labels
title='Filter by Label'
options={[
...userLabels,
...chores
.map(c => c.labelsV2)
.flat()
.filter(l => l.created_by !== userProfile.id)
.map(l => {
// if user created it don't show it:
return {
...l,
name: l.name + ' (Shared Label)',
}
}),
]}
selectedItem={selectedFilter}
onItemSelect={selected => {
handleLabelFiltering({ label: selected })
}}
isActive={selectedFilter.startsWith('Label: ')}
mouseClickHandler={handleMenuOutsideClick}
useChips
/>
<IconButton
onClick={handleFilterMenuOpen}
variant='outlined'
color={
selectedFilter && FILTERS[selectedFilter] && selectedFilter != 'All'
? 'primary'
: 'neutral'
}
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
>
<FilterAlt />
</IconButton>
<List
orientation='horizontal'
wrap
sx={{
mt: 0.2,
}}
>
<Menu
ref={menuRef}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleFilterMenuClose}
>
{Object.keys(FILTERS).map((filter, index) => (
<MenuItem
key={`filter-list-${filter}-${index}`}
onClick={() => {
const filterFunction = FILTERS[filter]
const filteredChores =
filterFunction.length === 2
? filterFunction(chores, userProfile.id)
: filterFunction(chores)
setFilteredChores(filteredChores)
setSelectedFilter(filter)
handleFilterMenuClose()
}}
>
{filter}
<Chip color={selectedFilter === filter ? 'primary' : 'neutral'}>
{FILTERS[filter].length === 2
? FILTERS[filter](chores, userProfile.id).length
: FILTERS[filter](chores).length}
</Chip>
</MenuItem>
))}
{selectedFilter.startsWith('Label: ') ||
(selectedFilter.startsWith('Priority: ') && (
<MenuItem
key={`filter-list-cancel-all-filters`}
onClick={() => {
setFilteredChores(chores)
setSelectedFilter('All')
}}
>
Cancel All Filters
</MenuItem>
))}
</Menu>
</List>
<Divider orientation='vertical' />
<IconButtonWithMenu
title='Group by'
icon={<Sort />}
options={[
{ name: 'Due Date', value: 'due_date' },
{ name: 'Priority', value: 'priority' },
{ name: 'Labels', value: 'labels' },
]}
selectedItem={selectedChoreSection}
onItemSelect={selected => {
const section = ChoresGrouper(selected.value, chores)
setChoreSections(section)
setSelectedChoreSection(selected.value)
setFilteredChores(chores)
setSelectedFilter('All')
}}
mouseClickHandler={handleMenuOutsideClick}
/>
</Box>
{selectedFilter !== 'All' && (
<Chip
level='title-md'
gutterBottom
color='warning'
label={selectedFilter}
onDelete={() => {
setFilteredChores(chores)
setSelectedFilter('All')
}}
endDecorator={<CancelRounded />}
onClick={() => {
setFilteredChores(chores)
setSelectedFilter('All')
}}
>
Current Filter: {selectedFilter}
</Chip>
)}
{filteredChores.length === 0 && (
<div
style={{
display: 'flex',
flexDirection: 'row',
}}
>
<Container maxWidth='md'>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
justifyContent: 'space-between',
alignContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
gap: 0.5,
}}
>
<EditCalendar
sx={{
fontSize: '4rem',
// color: 'text.disabled',
mb: 1,
{activeTextField == 'task' && (
<TaskInput
autoFocus={taskInputFocus}
onChoreUpdate={updateChores}
/>
)}
{activeTextField == 'search' && (
<Input
ref={searchInputRef}
autoFocus={searchInputFocus > 0}
placeholder='Search'
value={searchTerm}
fullWidth
sx={{
mt: 1,
mb: 1,
borderRadius: 24,
height: 24,
borderColor: 'text.disabled',
padding: 1,
}}
onChange={handleSearchChange}
endDecorator={
searchTerm && (
<CancelRounded
onClick={() => {
setSearchTerm('')
setFilteredChores(chores)
}}
/>
)
}
/>
)}
{activeTextField != 'task' && (
<Button
variant='outlined'
size='sm'
color='neutral'
sx={{
height: 24,
borderRadius: 24,
minWidth: 100,
}}
startDecorator={<EditCalendar />}
onClick={() => {
setActiveTextField('task')
setTaskInputFocus(taskInputFocus + 1)
}}
>
Task
</Button>
)}
{activeTextField != 'search' && (
<Button
variant='outlined'
color='neutral'
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
startDecorator={<Search />}
onClick={() => {
setActiveTextField('search')
setSearchInputFocus(searchInputFocus + 1)
searchInputRef.current.focus()
searchInputRef.current.selectionStart =
searchInputRef.current.value?.length
searchInputRef.current.selectionEnd =
searchInputRef.current.value?.length
}}
>
Search
</Button>
)}
<Divider orientation='vertical' />
<IconButtonWithMenu
icon={<PriorityHigh />}
title='Filter by Priority'
options={Priorities}
selectedItem={selectedFilter}
onItemSelect={selected => {
handleLabelFiltering({ priority: selected.value })
}}
mouseClickHandler={handleMenuOutsideClick}
isActive={selectedFilter.startsWith('Priority: ')}
/>
<Typography level='title-md' gutterBottom>
Nothing scheduled
</Typography>
{chores.length > 0 && (
<>
<IconButtonWithMenu
icon={<Style />}
// TODO : this need simplification we want to display both user labels and chore labels
// that why we are merging them here.
// we also filter out the labels that user created as those will be part of user labels
title='Filter by Label'
options={[
...userLabels,
...chores
.map(c => c.labelsV2)
.flat()
.filter(l => l.created_by !== userProfile.id)
.map(l => {
// if user created it don't show it:
return {
...l,
name: l.name + ' (Shared Label)',
}
}),
]}
selectedItem={selectedFilter}
onItemSelect={selected => {
handleLabelFiltering({ label: selected })
}}
isActive={selectedFilter.startsWith('Label: ')}
mouseClickHandler={handleMenuOutsideClick}
useChips
/>
<IconButton
onClick={handleFilterMenuOpen}
variant='outlined'
color={
selectedFilter &&
FILTERS[selectedFilter] &&
selectedFilter != 'All'
? 'primary'
: 'neutral'
}
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
>
<FilterAlt />
</IconButton>
<List
orientation='horizontal'
wrap
sx={{
mt: 0.2,
}}
>
<Menu
ref={menuRef}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleFilterMenuClose}
>
{Object.keys(FILTERS).map((filter, index) => (
<MenuItem
key={`filter-list-${filter}-${index}`}
onClick={() => {
const filterFunction = FILTERS[filter]
const filteredChores =
filterFunction.length === 2
? filterFunction(chores, userProfile.id)
: filterFunction(chores)
setFilteredChores(filteredChores)
setSelectedFilter(filter)
handleFilterMenuClose()
}}
>
{filter}
<Chip
color={selectedFilter === filter ? 'primary' : 'neutral'}
>
{FILTERS[filter].length === 2
? FILTERS[filter](chores, userProfile.id).length
: FILTERS[filter](chores).length}
</Chip>
</MenuItem>
))}
{selectedFilter.startsWith('Label: ') ||
(selectedFilter.startsWith('Priority: ') && (
<MenuItem
key={`filter-list-cancel-all-filters`}
onClick={() => {
setFilteredChores(chores)
setSelectedFilter('All')
}}
>
Cancel All Filters
</MenuItem>
))}
</Menu>
</List>
<Divider orientation='vertical' />
<IconButtonWithMenu
title='Group by'
icon={<Sort />}
options={[
{ name: 'Due Date', value: 'due_date' },
{ name: 'Priority', value: 'priority' },
{ name: 'Labels', value: 'labels' },
]}
selectedItem={selectedChoreSection}
onItemSelect={selected => {
const section = ChoresGrouper(selected.value, chores)
setChoreSections(section)
setSelectedChoreSection(selected.value)
setFilteredChores(chores)
setSelectedFilter('All')
}}
mouseClickHandler={handleMenuOutsideClick}
/>
</Box>
{selectedFilter !== 'All' && (
<Chip
level='title-md'
gutterBottom
color='warning'
label={selectedFilter}
onDelete={() => {
setFilteredChores(chores)
setSelectedFilter('All')
}}
endDecorator={<CancelRounded />}
onClick={() => {
setFilteredChores(chores)
setSelectedFilter('All')
}}
>
Current Filter: {selectedFilter}
</Chip>
)}
{filteredChores.length === 0 && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
}}
>
<EditCalendar
sx={{
fontSize: '4rem',
// color: 'text.disabled',
mb: 1,
}}
/>
<Typography level='title-md' gutterBottom>
Nothing scheduled
</Typography>
{chores.length > 0 && (
<>
<Button
onClick={() => {
setFilteredChores(chores)
setSearchTerm('')
}}
variant='outlined'
color='neutral'
>
Reset filters
</Button>
</>
)}
</Box>
)}
{(searchTerm?.length > 0 || selectedFilter !== 'All') &&
filteredChores.map(chore => (
<ChoreCard
key={`filtered-${chore.id} `}
chore={chore}
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
/>
))}
{searchTerm.length === 0 && selectedFilter === 'All' && (
<AccordionGroup transition='0.2s ease' disableDivider>
{choreSections.map((section, index) => {
if (section.content.length === 0) return null
return (
<Accordion
title={section.name}
key={section.name + index}
sx={{
my: 0,
}}
expanded={Boolean(openChoreSections[index])}
>
<Divider orientation='horizontal'>
<Chip
variant='soft'
color='neutral'
size='md'
onClick={() => {
if (openChoreSections[index]) {
const newOpenChoreSections = {
...openChoreSections,
}
delete newOpenChoreSections[index]
setOpenChoreSections(newOpenChoreSections)
} else {
setOpenChoreSections({
...openChoreSections,
[index]: true,
})
}
}}
endDecorator={
openChoreSections[index] ? (
<ExpandCircleDown
color='primary'
sx={{ transform: 'rotate(180deg)' }}
/>
) : (
<ExpandCircleDown color='primary' />
)
}
startDecorator={
<>
<Chip color='primary' size='sm' variant='soft'>
{section?.content?.length}
</Chip>
</>
}
>
{section.name}
</Chip>
</Divider>
<AccordionDetails
sx={{
flexDirection: 'column',
my: 0,
}}
>
{section.content?.map(chore => (
<ChoreCard
key={chore.id}
chore={chore}
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
/>
))}
</AccordionDetails>
</Accordion>
)
})}
</AccordionGroup>
)}
<Box
sx={{
// center the button
justifyContent: 'center',
mt: 2,
}}
>
{archivedChores === null && (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Button
sx={{}}
onClick={() => {
setFilteredChores(chores)
setSearchTerm('')
GetArchivedChores()
.then(response => response.json())
.then(data => {
setArchivedChores(data.res)
})
}}
variant='outlined'
color='neutral'
startDecorator={<Unarchive />}
>
Reset filters
Show Archived
</Button>
</Box>
)}
{archivedChores !== null && (
<>
<Divider orientation='horizontal'>
<Chip
variant='soft'
color='danger'
size='md'
startDecorator={
<>
<Chip color='danger' size='sm' variant='plain'>
{archivedChores?.length}
</Chip>
</>
}
>
Archived
</Chip>
</Divider>
{archivedChores?.map(chore => (
<ChoreCard
key={chore.id}
chore={chore}
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
/>
))}
</>
)}
</Box>
)}
{(searchTerm?.length > 0 || selectedFilter !== 'All') &&
filteredChores.map(chore => (
<ChoreCard
key={`filtered-${chore.id} `}
chore={chore}
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
/>
))}
{searchTerm.length === 0 && selectedFilter === 'All' && (
<AccordionGroup transition='0.2s ease' disableDivider>
{choreSections.map((section, index) => {
if (section.content.length === 0) return null
return (
<Accordion
title={section.name}
key={section.name + index}
sx={{
my: 0,
}}
expanded={Boolean(openChoreSections[index])}
>
<Divider orientation='horizontal'>
<Chip
variant='soft'
color='neutral'
size='md'
onClick={() => {
if (openChoreSections[index]) {
const newOpenChoreSections = { ...openChoreSections }
delete newOpenChoreSections[index]
setOpenChoreSections(newOpenChoreSections)
} else {
setOpenChoreSections({
...openChoreSections,
[index]: true,
})
}
}}
endDecorator={
openChoreSections[index] ? (
<ExpandCircleDown
color='primary'
sx={{ transform: 'rotate(180deg)' }}
/>
) : (
<ExpandCircleDown color='primary' />
)
}
startDecorator={
<>
<Chip color='primary' size='sm' variant='soft'>
{section?.content?.length}
</Chip>
</>
}
>
{section.name}
</Chip>
</Divider>
<AccordionDetails
sx={{
flexDirection: 'column',
my: 0,
}}
>
{section.content?.map(chore => (
<ChoreCard
key={chore.id}
chore={chore}
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
/>
))}
</AccordionDetails>
</Accordion>
)
})}
</AccordionGroup>
)}
<Box
sx={{
// center the button
justifyContent: 'center',
mt: 2,
}}
>
{archivedChores === null && (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Button
sx={{}}
onClick={() => {
GetArchivedChores()
.then(response => response.json())
.then(data => {
setArchivedChores(data.res)
})
}}
variant='outlined'
color='neutral'
startDecorator={<Unarchive />}
>
Show Archived
</Button>
</Box>
)}
{archivedChores !== null && (
<>
<Divider orientation='horizontal'>
<Chip
variant='soft'
color='danger'
size='md'
startDecorator={
<>
<Chip color='danger' size='sm' variant='plain'>
{archivedChores?.length}
</Chip>
</>
}
>
Archived
</Chip>
</Divider>
{archivedChores?.map(chore => (
<ChoreCard
key={chore.id}
chore={chore}
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
/>
))}
</>
)}
</Box>
<Box
// variant='outlined'
sx={{
position: 'fixed',
bottom: 0,
left: 10,
p: 2, // padding
display: 'flex',
justifyContent: 'flex-end',
gap: 2,
'z-index': 1000,
}}
>
<IconButton
color='primary'
variant='solid'
<Box
// variant='outlined'
sx={{
borderRadius: '50%',
width: 50,
height: 50,
}}
onClick={() => {
Navigate(`/chores/create`)
position: 'fixed',
bottom: 0,
left: 10,
p: 2, // padding
display: 'flex',
justifyContent: 'flex-end',
gap: 2,
'z-index': 1000,
}}
>
<Add />
</IconButton>
</Box>
<Snackbar
open={isSnackbarOpen}
onClose={() => {
setIsSnackbarOpen(false)
}}
autoHideDuration={3000}
variant='soft'
color='success'
size='lg'
invertedColors
>
<Typography level='title-md'>{snackBarMessage}</Typography>
</Snackbar>
<NotificationAccessSnackbar />
</Container>
<IconButton
color='primary'
variant='solid'
sx={{
borderRadius: '50%',
width: 50,
height: 50,
}}
onClick={() => {
Navigate(`/chores/create`)
}}
>
<Add />
</IconButton>
</Box>
<Snackbar
open={isSnackbarOpen}
onClose={() => {
setIsSnackbarOpen(false)
}}
autoHideDuration={3000}
variant='soft'
color='success'
size='lg'
invertedColors
>
<Typography level='title-md'>{snackBarMessage}</Typography>
</Snackbar>
<NotificationAccessSnackbar />
</Container>
<Sidepanel chores={chores} performers={performers} />
</div>
)
}

View file

@ -0,0 +1,91 @@
import { Box, Sheet } from '@mui/joy'
import { useMediaQuery } from '@mui/material'
import { useEffect, useState } from 'react'
import { ChoresGrouper } from '../../utils/Chores'
import CalendarView from '../components/CalendarView'
const Sidepanel = ({ chores }) => {
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
const [dueDatePieChartData, setDueDatePieChartData] = useState([])
useEffect(() => {
setDueDatePieChartData(generateChoreDuePieChartData(chores))
}, [])
const generateChoreDuePieChartData = chores => {
const groups = ChoresGrouper('due_date', chores)
return groups
.map(group => {
return {
label: group.name,
value: group.content.length,
color: group.color,
id: group.name,
}
})
.filter(item => item.value > 0)
}
if (!isLargeScreen) {
return null
}
return (
<Sheet
variant='outlined'
sx={{
p: 2,
// borderRadius: 'sm',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
mr: 10,
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
// minimum height to fit the content:
height: '80vh',
width: '290px',
}}
>
{/* <Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<PieChart width={200} height={200}>
<Pie
data={dueDatePieChartData}
dataKey='value'
nameKey='label'
innerRadius={30}
paddingAngle={5}
cornerRadius={5}
>
{dueDatePieChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Legend
layout='horizontal'
align='center'
iconType='circle'
iconSize={8}
fontSize={12}
formatter={(label, value) => `${label}: ${value.payload.value}`}
wrapperStyle={{ paddingTop: 0, marginTop: 0 }} // Adjust padding and margin
/>
<Tooltip />
</PieChart>
</Box> */}
<Box sx={{ width: '100%' }}>
<CalendarView chores={chores} />
</Box>
</Sheet>
)
}
export default Sidepanel

View file

@ -0,0 +1,89 @@
import {
Box,
Button,
FormLabel,
IconButton,
Input,
Modal,
ModalDialog,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
function RedeemPointsModal({ config }) {
useEffect(() => {
setPoints(0)
}, [config])
const [points, setPoints] = useState(0)
const predefinedPoints = [1, 5, 10, 25]
return (
<Modal open={config?.isOpen} onClose={config?.onClose}>
<ModalDialog>
<Typography level='h4' mb={1}>
Redeem Points
</Typography>
<FormLabel>
Points to Redeem ({config.available ? config.available : 0} points
available)
</FormLabel>
<Input
type='number'
value={points}
slotProps={{
input: { min: 0, max: config.available ? config.available : 0 },
}}
onChange={e => {
if (e.target.value > config.available) {
setPoints(config.available)
return
}
setPoints(e.target.value)
}}
/>
<FormLabel>Or select from predefined points:</FormLabel>
<Box display='flex' justifyContent='space-evenly' mb={1}>
{predefinedPoints.map(point => (
<IconButton
variant='outlined'
sx={{ borderRadius: '50%' }}
key={point}
onClick={() => {
const newPoints = points + point
if (newPoints > config.available) {
setPoints(config.available)
return
}
setPoints(newPoints)
}}
>
{point}
</IconButton>
))}
</Box>
{/* 3 button save , cancel and delete */}
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button
onClick={() =>
config.onSave({
points,
userId: config.user.userId,
})
}
fullWidth
sx={{ mr: 1 }}
>
Redeem
</Button>
<Button onClick={config.onClose} variant='outlined'>
Cancel
</Button>
</Box>
</ModalDialog>
</Modal>
)
}
export default RedeemPointsModal

View file

@ -259,26 +259,31 @@ const UserActivites = () => {
}
const generateHistoryPieChartData = history => {
const totalCompleted = history.filter(
item => item.dueDate > item.completedAt,
).length
const totalLate = history.filter(
item => item.dueDate < item.completedAt,
).length
const totalCompleted =
history.filter(item => item.dueDate > item.completedAt).length || 0
const totalLate =
history.filter(item => item.dueDate < item.completedAt).length || 0
const totalNoDueDate = history.filter(item => !item.dueDate).length || 0
return [
{
label: `On time`,
value: totalCompleted,
color: '#4ec1a2',
color: TASK_COLOR.COMPLETED,
id: 1,
},
{
label: `Late`,
value: totalLate,
color: '#f6ad55',
color: TASK_COLOR.LATE,
id: 2,
},
{
label: `Completed`,
value: totalNoDueDate,
color: TASK_COLOR.ANYTIME,
id: 3,
},
]
}
if (isChoresHistoryLoading || isChoresLoading) {

View file

@ -7,10 +7,11 @@ import {
YAxis,
} from 'recharts'
import { Toll } from '@mui/icons-material'
import { CreditCard, Toll } from '@mui/icons-material'
import {
Avatar,
Box,
Button,
Card,
Chip,
Container,
@ -27,11 +28,17 @@ import LoadingComponent from '../components/Loading.jsx'
import { useChoresHistory } from '../../queries/ChoreQueries.jsx'
import { useCircleMembers } from '../../queries/UserQueries.jsx'
import { RedeemPoints } from '../../utils/Fetcher.jsx'
import RedeemPointsModal from '../Modals/RedeemPointsModal'
const UserPoints = () => {
const [tabValue, setTabValue] = useState(7)
const [isRedeemModalOpen, setIsRedeemModalOpen] = useState(false)
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
useCircleMembers()
const {
data: circleMembersData,
isLoading: isCircleMembersLoading,
handleRefetch: handleCircleMembersRefetch,
} = useCircleMembers()
const {
data: choresHistoryData,
@ -208,7 +215,7 @@ const UserPoints = () => {
return yearlyAggregated
}
if (isChoresHistoryLoading || isCircleMembersLoading) {
if (isChoresHistoryLoading || isCircleMembersLoading || !userProfile) {
return <LoadingComponent />
}
@ -233,6 +240,8 @@ const UserPoints = () => {
sx={{
gap: 1,
my: 2,
display: 'flex',
justifyContent: 'start',
}}
>
<Select
@ -278,6 +287,19 @@ const UserPoints = () => {
</Option>
))}
</Select>
{circleUsers.find(user => user.userId === userProfile.id)?.role ===
'admin' && (
<Button
variant='soft'
size='md'
startDecorator={<CreditCard />}
onClick={() => {
setIsRedeemModalOpen(true)
}}
>
Redeem Points
</Button>
)}
</Box>
<Box
@ -458,6 +480,27 @@ const UserPoints = () => {
</ResponsiveContainer>
</Box>
</Box>
<RedeemPointsModal
config={{
onClose: () => {
setIsRedeemModalOpen(false)
},
isOpen: isRedeemModalOpen,
available: circleUsers.find(user => user.userId === selectedUser)
?.points,
user: circleUsers.find(user => user.userId === selectedUser),
onSave: ({ userId, points }) => {
RedeemPoints(userId, points, userProfile.circleID)
.then(res => {
setIsRedeemModalOpen(false)
handleCircleMembersRefetch()
})
.catch(err => {
console.log(err)
})
},
}}
/>
</Container>
)
}

View file

@ -0,0 +1,532 @@
import { KeyboardReturnOutlined, OpenInFull } from '@mui/icons-material'
import {
Box,
Button,
Chip,
IconButton,
Input,
Modal,
ModalDialog,
Option,
Select,
Textarea,
Typography,
} from '@mui/joy'
import { FormControl } from '@mui/material'
import * as chrono from 'chrono-node'
import moment from 'moment'
import { useContext, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { CSSTransition } from 'react-transition-group'
import { UserContext } from '../../contexts/UserContext'
import { CreateChore } from '../../utils/Fetcher'
const VALID_DAYS = {
monday: 'Monday',
mon: 'Monday',
tuesday: 'Tuesday',
tue: 'Tuesday',
wednesday: 'Wednesday',
wed: 'Wednesday',
thursday: 'Thursday',
thu: 'Thursday',
friday: 'Friday',
fri: 'Friday',
saturday: 'Saturday',
sat: 'Saturday',
sunday: 'Sunday',
sun: 'Sunday',
}
const VALID_MONTHS = {
january: 'January',
jan: 'January',
february: 'February',
feb: 'February',
march: 'March',
mar: 'March',
april: 'April',
apr: 'April',
may: 'May',
june: 'June',
jun: 'June',
july: 'July',
jul: 'July',
august: 'August',
aug: 'August',
september: 'September',
sep: 'September',
october: 'October',
oct: 'October',
november: 'November',
nov: 'November',
december: 'December',
dec: 'December',
}
const ALL_MONTHS = Object.values(VALID_MONTHS).filter(
(v, i, a) => a.indexOf(v) === i,
)
const TaskInput = ({ autoFocus, onChoreUpdate }) => {
const { userProfile } = useContext(UserContext)
const navigate = useNavigate()
const [taskText, setTaskText] = useState('')
const [taskTitle, setTaskTitle] = useState('')
const [openModal, setOpenModal] = useState(false)
const textareaRef = useRef(null)
const mainInputRef = useRef(null)
const [priority, setPriority] = useState('0')
const [dueDate, setDueDate] = useState(null)
const [description, setDescription] = useState(null)
const [frequency, setFrequency] = useState(null)
const [frequencyHumanReadable, setFrequencyHumanReadable] = useState(null)
useEffect(() => {
if (openModal && textareaRef.current) {
textareaRef.current.focus()
textareaRef.current.selectionStart = textareaRef.current.value?.length
textareaRef.current.selectionEnd = textareaRef.current.value?.length
}
}, [openModal])
useEffect(() => {
if (autoFocus > 0 && mainInputRef.current) {
mainInputRef.current.focus()
mainInputRef.current.selectionStart = mainInputRef.current.value?.length
mainInputRef.current.selectionEnd = mainInputRef.current.value?.length
}
}, [autoFocus])
const handleEnterPressed = e => {
if (e.key === 'Enter') {
createChore()
handleCloseModal()
setTaskText('')
}
}
const handleCloseModal = () => {
setOpenModal(false)
setTaskText('')
}
const handleSubmit = () => {
createChore()
handleCloseModal()
setTaskText('')
}
const parsePriority = sentence => {
sentence = sentence.toLowerCase()
const priorityMap = {
1: ['p1', 'priority 1', 'high priority', 'urgent', 'asap', 'important'],
2: ['p2', 'priority 2', 'medium priority'],
3: ['p3', 'priority 3', 'low priority'],
4: ['p4', 'priority 4'],
}
for (const [priority, terms] of Object.entries(priorityMap)) {
if (terms.some(term => sentence.includes(term))) {
return {
result: priority,
cleanedSentence: terms.reduce((s, t) => s.replace(t, ''), sentence),
}
}
}
return { result: 0, cleanedSentence: sentence }
}
const parseRepeatV2 = sentence => {
const result = {
frequency: 1,
frequencyType: null,
frequencyMetadata: {
days: [],
months: [],
unit: null,
time: new Date().toISOString(),
},
}
const patterns = [
{
frequencyType: 'day_of_the_month:every',
regex: /(\d+)(?:th|st|nd|rd)? of every month$/i,
name: 'Every {day} of every month',
},
{
frequencyType: 'daily',
regex: /(every day|daily)$/i,
name: 'Every day',
},
{
frequencyType: 'weekly',
regex: /(every week|weekly)$/i,
name: 'Every week',
},
{
frequencyType: 'monthly',
regex: /(every month|monthly)$/i,
name: 'Every month',
},
{
frequencyType: 'yearly',
regex: /every year$/i,
name: 'Every year',
},
{
frequencyType: 'monthly',
regex: /every (?:other )?month$/i,
name: 'Bi Monthly',
value: 2,
},
{
frequencyType: 'interval:2week',
regex: /(bi-?weekly|every other week)/i,
value: 2,
name: 'Bi Weekly',
},
{
frequencyType: 'interval',
regex: /every (\d+) (days?|weeks?|months?|years?).*$/i,
name: 'Every {frequency} {unit}',
},
{
frequencyType: 'interval:every_other',
regex: /every other (days?|weeks?|months?|years?)$/i,
name: 'Every other {unit}',
},
{
frequencyType: 'days_of_the_week',
regex: /every ([\w, ]+(?:day)?(?:, [\w, ]+(?:day)?)*)$/i,
name: 'Every {days} of the week',
},
{
frequencyType: 'day_of_the_month',
regex: /(\d+)(?:st|nd|rd|th)? of ([\w ]+(?:(?:,| and |\s)[\w ]+)*)/i,
name: 'Every {day} days of {months}',
},
]
for (const pattern of patterns) {
const match = sentence.match(pattern.regex)
if (!match) continue
result.frequencyType = pattern.frequencyType
const unitMap = {
daily: 'days',
weekly: 'weeks',
monthly: 'months',
yearly: 'years',
}
switch (pattern.frequencyType) {
case 'daily':
case 'weekly':
case 'monthly':
case 'yearly':
result.frequencyType = 'interval'
result.frequency = pattern.value || 1
result.frequencyMetadata.unit = unitMap[pattern.frequencyType]
return {
result,
name: pattern.name,
cleanedSentence: sentence.replace(match[0], '').trim(),
}
case 'interval':
result.frequency = parseInt(match[1], 10)
result.frequencyMetadata.unit = match[2]
return {
result,
name: pattern.name
.replace('{frequency}', result.frequency)
.replace('{unit}', result.frequencyMetadata.unit),
cleanedSentence: sentence.replace(match[0], '').trim(),
}
case 'days_of_the_week':
result.frequencyMetadata.days = match[1]
.toLowerCase()
.split(/ and |,|\s/)
.map(day => day.trim())
.filter(day => VALID_DAYS[day])
.map(day => VALID_DAYS[day])
if (!result.frequencyMetadata.days.length)
return { result: null, name: null, cleanedSentence: sentence }
return {
result,
name: pattern.name.replace(
'{days}',
result.frequencyMetadata.days.join(', '),
),
cleanedSentence: sentence.replace(match[0], '').trim(),
}
case 'day_of_the_month':
result.frequency = parseInt(match[1], 10)
result.frequencyMetadata.months = match[2]
.toLowerCase()
.split(/ and |,|\s/)
.map(month => month.trim())
.filter(month => VALID_MONTHS[month])
.map(month => VALID_MONTHS[month])
result.frequencyMetadata.unit = 'days'
return {
result,
name: pattern.name
.replace('{day}', result.frequency)
.replace('{months}', result.frequencyMetadata.months.join(', ')),
cleanedSentence: sentence.replace(match[0], '').trim(),
}
case 'interval:every_other':
case 'interval:2week':
result.frequency = 2
result.frequencyMetadata.unit = 'weeks'
result.frequencyType = 'interval'
return {
result,
name: pattern.name,
cleanedSentence: sentence.replace(match[0], '').trim(),
}
case 'day_of_the_month:every':
result.frequency = parseInt(match[1], 10)
result.frequencyMetadata.months = ALL_MONTHS
result.frequencyMetadata.unit = 'days'
return {
result,
name: pattern.name
.replace('{day}', result.frequency)
.replace('{months}', result.frequencyMetadata.months.join(', ')),
cleanedSentence: sentence.replace(match[0], '').trim(),
}
}
}
return { result: null, name: null, cleanedSentence: sentence }
}
const handleTextChange = e => {
if (!e.target.value) {
setTaskText('')
setOpenModal(false)
setDueDate(null)
setFrequency(null)
setFrequencyHumanReadable(null)
setPriority(0)
return
}
let cleanedSentence = e.target.value
const priority = parsePriority(cleanedSentence)
if (priority.result) setPriority(priority.result)
cleanedSentence = priority.cleanedSentence
const parsedDueDate = chrono.parse(cleanedSentence, new Date(), {
forwardDate: true,
})
if (parsedDueDate[0]?.index > -1) {
setDueDate(
moment(parsedDueDate[0].start.date()).format('YYYY-MM-DDTHH:mm:ss'),
)
cleanedSentence = cleanedSentence.replace(parsedDueDate[0].text, '')
}
const repeat = parseRepeatV2(cleanedSentence)
if (repeat.result) {
setFrequency(repeat.result)
setFrequencyHumanReadable(repeat.name)
cleanedSentence = repeat.cleanedSentence
}
if (priority.result || parsedDueDate[0]?.index > -1 || repeat.result) {
setOpenModal(true)
}
setTaskText(e.target.value)
setTaskTitle(cleanedSentence.trim())
}
const createChore = () => {
const chore = {
name: taskTitle,
assignees: [{ userId: userProfile.id }],
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
assignedTo: userProfile.id,
assignStrategy: 'random',
isRolling: false,
description: description || null,
labelsV2: [],
priority: priority || 0,
status: 0,
}
if (frequency) {
chore.frequencyType = frequency.frequencyType
chore.frequencyMetadata = frequency.frequencyMetadata
chore.frequency = frequency.frequency
}
CreateChore(chore).then(resp => {
resp.json().then(data => {
onChoreUpdate({ ...chore, id: data.res, nextDueDate: chore.dueDate })
})
})
}
return (
<>
{!openModal && (
<CSSTransition in={!openModal} timeout={300} classNames='fade'>
<Input
autoFocus={autoFocus > 0}
ref={mainInputRef}
placeholder='Add a task...'
value={taskText}
onChange={handleTextChange}
sx={{
fontSize: '16px',
mt: 1,
mb: 1,
borderRadius: 24,
height: 24,
borderColor: 'text.disabled',
padding: 1,
width: '100%',
}}
onKeyUp={handleEnterPressed}
endDecorator={
<IconButton
variant='outlined'
sx={{ borderRadius: 24, marginRight: -0.5 }}
>
<KeyboardReturnOutlined />
</IconButton>
}
/>
</CSSTransition>
)}
<Modal open={openModal} onClose={handleCloseModal}>
<ModalDialog>
<Button
size='sm'
onClick={() => navigate(`/chores/create`)}
variant='outlined'
sx={{ position: 'absolute', right: 20 }}
startDecorator={<OpenInFull />}
>
Advance Mode
</Button>
<Typography level='h4'>
Create new task
<Chip startDecorator='🚧' variant='soft' color='warning' size='sm'>
Experimental
</Chip>
</Typography>
<Box>
<Typography level='body-sm'>Task in a sentence:</Typography>
<Input
autoFocus
ref={textareaRef}
value={taskText}
onChange={handleTextChange}
onKeyUp={handleEnterPressed}
placeholder='Type your full text here...'
sx={{ width: '100%', fontSize: '16px' }}
/>
</Box>
<Box>
<Typography level='body-sm'>Title:</Typography>
<Input
value={taskTitle}
onChange={e => setTaskTitle(e.target.value)}
placeholder='Type your full text here...'
sx={{ width: '100%', fontSize: '16px' }}
/>
</Box>
<Box>
<Typography level='body-sm'>Description:</Typography>
<Textarea
minRows={2}
value={description}
onChange={e => setDescription(e.target.value)}
/>
</Box>
<Box
sx={{ marginTop: 2, display: 'flex', flexDirection: 'row', gap: 2 }}
>
<FormControl>
<Typography level='body-sm'>Priority</Typography>
<Select
value={priority}
onChange={(e, value) => setPriority(value)}
>
<Option value='0'>No Priority</Option>
<Option value='1'>P1</Option>
<Option value='2'>P2</Option>
<Option value='3'>P3</Option>
<Option value='4'>P4</Option>
</Select>
</FormControl>
<FormControl>
<Typography level='body-sm'>Due Date</Typography>
<Input
type='datetime-local'
value={dueDate}
onChange={e => setDueDate(e.target.value)}
sx={{ width: '100%', fontSize: '16px' }}
/>
</FormControl>
</Box>
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
justifyContent: 'start',
gap: 2,
}}
>
<FormControl>
<Typography level='body-sm'>Assignee</Typography>
<Select value={'0'}>
<Option value='0'>Me</Option>
<Option value='1'>Other</Option>
</Select>
</FormControl>
<FormControl>
<Typography level='body-sm'>Frequency</Typography>
<Input value={frequencyHumanReadable || 'Once'} variant='plain' />
</FormControl>
</Box>
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
justifyContent: 'end',
gap: 1,
}}
>
<Button
variant='outlined'
color='neutral'
onClick={handleCloseModal}
>
Cancel
</Button>
<Button variant='solid' color='primary' onClick={handleSubmit}>
Create
</Button>
</Box>
</ModalDialog>
</Modal>
</>
)
}
export default TaskInput

View file

@ -0,0 +1,70 @@
.react-calendar {
width: 100%;
max-width: 600px;
font-family: 'Roboto', sans-serif;
background-color: transparent;
border: none;
}
.react-calendar__tile {
padding: 10px;
min-height: 45px;
text-align: center;
border-radius: 8px;
transition: background-color 0.3s ease;
}
.react-calendar__tile:enabled:hover {
background-color: #cbcbcb;
}
.react-calendar__tile--active {
background-color: #007bff !important;
}
.react-calendar__tile--now {
border: 1px dotted #4ec1a2e3 !important;
border-radius: 8px;
background-color: inherit;
}
.react-calendar__tile--now:enabled:hover {
background-color: rgba(0, 123, 255, 0.1);
}
.dot-container {
display: flex;
justify-content: center;
align-items: center;
height: 16px;
flex-wrap: wrap;
position: relative;
}
.dot {
width: 0.3em;
height: 0.3em;
border-radius: 50%;
margin: 0 1px;
}
.chore-tooltip {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
background-color: #333;
color: white;
padding: 5px 10px;
border-radius: 4px;
z-index: 10;
visibility: hidden;
opacity: 0;
transition:
opacity 0.2s ease,
visibility 0.2s ease;
}
.react-calendar__month-view__days__day--weekend {
color: inherit;
}

View file

@ -0,0 +1,176 @@
import { Box, Card, CardContent, Chip, Grid, Typography } from '@mui/joy'
import moment from 'moment'
import React, { useState } from 'react'
import Calendar from 'react-calendar'
import 'react-calendar/dist/Calendar.css'
import { useNavigate } from 'react-router-dom'
import { UserContext } from '../../contexts/UserContext'
import { getTextColorFromBackgroundColor, TASK_COLOR } from '../../utils/Colors'
import './Calendar.css'
const getAssigneeColor = (assignee, userProfile) => {
return assignee === userProfile.id
? TASK_COLOR.ASSIGNED_TO_ME
: TASK_COLOR.ASSIGNED_TO_OTHER
}
const CalendarView = ({ chores }) => {
const { userProfile } = React.useContext(UserContext)
const [selectedDate, setSeletedDate] = useState(null)
const Navigate = useNavigate()
const tileContent = ({ date, view }) => {
if (view === 'month') {
const dayChores = chores.filter(
chore =>
new Date(chore.nextDueDate)?.toISOString().split('T')[0] ===
date.toISOString().split('T')[0],
)
return (
<div className='dot-container'>
{dayChores.map((chore, index) => {
if (index > 6) {
return null
}
return (
<span
key={index}
className='dot'
style={{
backgroundColor: getAssigneeColor(
chore.assignedTo,
userProfile,
),
}}
></span>
)
})}
</div>
)
}
return null
}
return (
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Calendar
tileContent={tileContent}
onChange={d => {
setSeletedDate(new Date(d))
}}
/>
{!selectedDate && (
<Grid container ml={-3}>
{[
{ name: 'Assigned to me', color: TASK_COLOR.ASSIGNED_TO_ME },
{ name: 'Assigned to other', color: TASK_COLOR.ASSIGNED_TO_OTHER },
].map((item, index) => (
<Grid
key={index}
item
xs={12}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'start',
}}
>
<Box
sx={{
display: 'inline-block',
width: 5,
height: 5,
borderRadius: '50%',
backgroundColor: item.color,
}}
/>
<Typography level='body-xs' ml={0.3}>
{item.name}
</Typography>
</Grid>
))}
</Grid>
)}
{selectedDate && (
<Box
variant='outlined'
sx={{
// p: 2,
// borderRadius: 20,
// if exceed the height, scroll:
maxHeight: '160px',
overflowY: 'auto',
// minimum height to fit the content:
height: '50vh',
width: '100%',
}}
>
{chores
.filter(
chore =>
new Date(chore.nextDueDate)?.toISOString().split('T')[0] ===
selectedDate.toISOString().split('T')[0],
)
.map((chore, idx) => (
<Card
key={idx}
variant='soft'
onClick={() => {
Navigate('/chores/' + chore.id)
}}
sx={{
mb: 0.4,
py: 1,
px: 1,
// backgroundColor: getAssigneeColor(
// chore.assignedTo,
// userProfile,
// ),
// everything show in one row:
}}
>
<CardContent>
<Typography
key={chore.id}
className='truncate'
maxWidth='100%'
>
<Chip
variant='plain'
size='sm'
sx={{
backgroundColor: getAssigneeColor(
chore.assignedTo,
userProfile,
),
mr: 0.5,
color: getTextColorFromBackgroundColor(
getAssigneeColor(chore.assignedTo, userProfile),
),
}}
>
{moment(chore.nextDueDate).format('hh:mm A')}
</Chip>
{chore.name}
</Typography>
</CardContent>
</Card>
))}
</Box>
)}
</div>
)
}
export default CalendarView

View file

@ -0,0 +1,34 @@
import IconButton from '@mui/joy/IconButton'
import React, { useRef, useState } from 'react'
const IconButtonTouchable = ({ onHold, onClick, ...props }) => {
const [holdTimeout, setHoldTimeout] = useState(null)
const holdRef = useRef(false)
const handleMouseDown = () => {
holdRef.current = false
setHoldTimeout(
setTimeout(() => {
holdRef.current = true
onHold && onHold()
}, 1000),
)
}
const handleMouseUp = () => {
clearTimeout(holdTimeout)
if (!holdRef.current) {
onClick && onClick()
}
}
return (
<IconButton
{...props}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
/>
)
}
export default IconButtonTouchable