Support Sortinging in My Chore by Date

Show API Token and allow manual copy
Show Error when sign up disabled
Support Pushover
This commit is contained in:
Mo Tarbin 2024-12-14 02:13:25 -05:00
commit 4fd959b277
7 changed files with 389 additions and 104 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "donetick", "name": "donetick",
"private": true, "private": true,
"version": "0.1.80", "version": "0.1.82",
"type": "module", "type": "module",
"lint-staged": { "lint-staged": {
"*.{js,jsx,ts,tsx}": [ "*.{js,jsx,ts,tsx}": [

View file

@ -225,6 +225,14 @@ const UpdateUserDetails = userDetails => {
}) })
} }
const UpdateNotificationTarget = notificationTarget => {
return Fetch(`${API_URL}/users/targets`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify(notificationTarget),
})
}
const GetSubscriptionSession = () => { const GetSubscriptionSession = () => {
return Fetch(API_URL + `/payments/create-subscription`, { return Fetch(API_URL + `/payments/create-subscription`, {
method: 'GET', method: 'GET',
@ -373,6 +381,7 @@ export {
UpdateChoreHistory, UpdateChoreHistory,
UpdateChorePriority, UpdateChorePriority,
UpdateLabel, UpdateLabel,
UpdateNotificationTarget,
UpdatePassword, UpdatePassword,
UpdateThingState, UpdateThingState,
UpdateUserDetails, UpdateUserDetails,

View file

@ -103,12 +103,13 @@ const SignupView = () => {
signUp(username, password, displayName, email).then(response => { signUp(username, password, displayName, email).then(response => {
if (response.status === 201) { if (response.status === 201) {
handleLogin(username, password) handleLogin(username, password)
} else if (response.status === 403) {
setError('Signup disabled, please contact admin')
} else { } else {
console.log('Signup failed') console.log('Signup failed')
response.json().then(res => { response.json().then(res => {
setError(res.error) setError(res.error)
} })
)
} }
}) })
} }

View file

@ -466,7 +466,6 @@ const ChoreCard = ({
> >
<Grid container> <Grid container>
<Grid <Grid
item
xs={9} xs={9}
onClick={() => { onClick={() => {
navigate(`/chores/${chore.id}`) navigate(`/chores/${chore.id}`)
@ -556,7 +555,6 @@ const ChoreCard = ({
</Box> */} </Box> */}
</Grid> </Grid>
<Grid <Grid
item
xs={3} xs={3}
sx={{ sx={{
display: 'flex', display: 'flex',

View file

@ -2,15 +2,20 @@ import {
Add, Add,
CancelRounded, CancelRounded,
EditCalendar, EditCalendar,
ExpandCircleDown,
FilterAlt, FilterAlt,
PriorityHigh, PriorityHigh,
Style, Style,
} from '@mui/icons-material' } from '@mui/icons-material'
import { import {
Accordion,
AccordionDetails,
AccordionGroup,
Box, Box,
Button, Button,
Chip, Chip,
Container, Container,
Divider,
IconButton, IconButton,
Input, Input,
List, List,
@ -38,6 +43,8 @@ const MyChores = () => {
const [chores, setChores] = useState([]) const [chores, setChores] = useState([])
const [filteredChores, setFilteredChores] = useState([]) const [filteredChores, setFilteredChores] = useState([])
const [selectedFilter, setSelectedFilter] = useState('All') const [selectedFilter, setSelectedFilter] = useState('All')
const [choreSections, setChoreSections] = useState([])
const [openChoreSections, setOpenChoreSections] = useState({})
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [activeUserId, setActiveUserId] = useState(0) const [activeUserId, setActiveUserId] = useState(0)
const [performers, setPerformers] = useState([]) const [performers, setPerformers] = useState([])
@ -74,6 +81,108 @@ const MyChores = () => {
return aDueDate - bDueDate // Sort ascending by due date return aDueDate - bDueDate // Sort ascending by due date
} }
const sectionSorter = (t, chores) => {
// sort by priority then due date:
chores.sort((a, b) => {
// no priority is lowest priority:
if (a.priority === 0) {
return 1
}
if (a.priority !== b.priority) {
return a.priority - b.priority
}
if (a.nextDueDate === null) {
return 1
}
if (b.nextDueDate === null) {
return -1
}
return new Date(a.nextDueDate) - new Date(b.nextDueDate)
})
var groups = []
switch (t) {
case 'due_date':
var groupRaw = {
Today: [],
'In a week': [],
'This month': [],
Later: [],
Overdue: [],
Anytime: [],
}
chores.forEach(chore => {
if (chore.nextDueDate === null) {
groupRaw['Anytime'].push(chore)
} else if (new Date(chore.nextDueDate) < new Date()) {
groupRaw['Overdue'].push(chore)
} else if (
new Date(chore.nextDueDate).toDateString() ===
new Date().toDateString()
) {
groupRaw['Today'].push(chore)
} else if (
new Date(chore.nextDueDate) <
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) &&
new Date(chore.nextDueDate) > new Date()
) {
groupRaw['In a week'].push(chore)
} else if (
new Date(chore.nextDueDate).getMonth() === new Date().getMonth()
) {
groupRaw['This month'].push(chore)
} else {
groupRaw['Later'].push(chore)
}
})
groups = [
{ name: 'Overdue', content: groupRaw['Overdue'] },
{ name: 'Today', content: groupRaw['Today'] },
{ name: 'In a week', content: groupRaw['In a week'] },
{ name: 'This month', content: groupRaw['This month'] },
{ name: 'Later', content: groupRaw['Later'] },
{ name: 'Anytime', content: groupRaw['Anytime'] },
]
break
case 'priority':
groupRaw = {
p1: [],
p2: [],
p3: [],
p4: [],
no_priority: [],
}
chores.forEach(chore => {
switch (chore.priority) {
case 1:
groupRaw['p1'].push(chore)
break
case 2:
groupRaw['p2'].push(chore)
break
case 3:
groupRaw['p3'].push(chore)
break
case 4:
groupRaw['p4'].push(chore)
break
}
})
break
case 'labels':
groupRaw = {}
chores.forEach(chore => {
chore.labelsV2.forEach(label => {
if (groupRaw[label.id] === undefined) {
groupRaw[label.id] = []
}
groupRaw[label.id].push(chore)
})
})
}
return groups
}
useEffect(() => { useEffect(() => {
if (userProfile === null) { if (userProfile === null) {
GetUserProfile() GetUserProfile()
@ -100,6 +209,14 @@ const MyChores = () => {
const sortedChores = choresData.res.sort(choreSorter) const sortedChores = choresData.res.sort(choreSorter)
setChores(sortedChores) setChores(sortedChores)
setFilteredChores(sortedChores) setFilteredChores(sortedChores)
const sections = sectionSorter('due_date', sortedChores)
setChoreSections(sections)
setOpenChoreSections(
Object.keys(sections).reduce((acc, key) => {
acc[key] = true
return acc
}, {}),
)
} }
}, [choresData, choresLoading]) }, [choresData, choresLoading])
@ -231,11 +348,6 @@ const MyChores = () => {
return ( return (
<Container maxWidth='md'> <Container maxWidth='md'>
{/* <Typography level='h3' mb={1.5}>
My Chores
</Typography> */}
{/* <Sheet> */}
{/* Search box to filter */}
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@ -253,7 +365,6 @@ const MyChores = () => {
mt: 1, mt: 1,
mb: 1, mb: 1,
borderRadius: 24, borderRadius: 24,
// border: '1px solid',
height: 24, height: 24,
borderColor: 'text.disabled', borderColor: 'text.disabled',
padding: 1, padding: 1,
@ -271,7 +382,6 @@ const MyChores = () => {
} }
/> />
<IconButtonWithMenu <IconButtonWithMenu
key={'icon-menu-labels-filter'}
icon={<PriorityHigh />} icon={<PriorityHigh />}
options={Priorities} options={Priorities}
selectedItem={selectedFilter} selectedItem={selectedFilter}
@ -282,7 +392,6 @@ const MyChores = () => {
isActive={selectedFilter.startsWith('Priority: ')} isActive={selectedFilter.startsWith('Priority: ')}
/> />
<IconButtonWithMenu <IconButtonWithMenu
key={'icon-menu-labels-filter'}
icon={<Style />} icon={<Style />}
// TODO : this need simplification we want to display both user labels and chore labels // TODO : this need simplification we want to display both user labels and chore labels
// that why we are merging them here. // that why we are merging them here.
@ -395,7 +504,6 @@ const MyChores = () => {
Current Filter: {selectedFilter} Current Filter: {selectedFilter}
</Chip> </Chip>
)} )}
{/* </Sheet> */}
{filteredChores.length === 0 && ( {filteredChores.length === 0 && (
<Box <Box
sx={{ sx={{
@ -419,7 +527,10 @@ const MyChores = () => {
{chores.length > 0 && ( {chores.length > 0 && (
<> <>
<Button <Button
onClick={() => setFilteredChores(chores)} onClick={() => {
setFilteredChores(chores)
setSearchTerm('')
}}
variant='outlined' variant='outlined'
color='neutral' color='neutral'
> >
@ -429,19 +540,92 @@ const MyChores = () => {
)} )}
</Box> </Box>
)} )}
{(searchTerm?.length > 0 || selectedFilter !== 'All') &&
{filteredChores.map(chore => ( filteredChores.map(chore => (
<ChoreCard <ChoreCard
key={chore.id} key={`filtered-${chore.id} `}
chore={chore} chore={chore}
onChoreUpdate={handleChoreUpdated} onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted} onChoreRemove={handleChoreDeleted}
performers={performers} performers={performers}
userLabels={userLabels} userLabels={userLabels}
onChipClick={handleLabelFiltering} 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 <Box
// variant='outlined' // variant='outlined'
sx={{ sx={{

View file

@ -1,4 +1,14 @@
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy' import { CopyAll } from '@mui/icons-material'
import {
Box,
Button,
Card,
Chip,
Divider,
IconButton,
Input,
Typography,
} from '@mui/joy'
import moment from 'moment' import moment from 'moment'
import { useContext, useEffect, useState } from 'react' import { useContext, useEffect, useState } from 'react'
import { UserContext } from '../../contexts/UserContext' import { UserContext } from '../../contexts/UserContext'
@ -13,6 +23,7 @@ import TextModal from '../Modals/Inputs/TextModal'
const APITokenSettings = () => { const APITokenSettings = () => {
const [tokens, setTokens] = useState([]) const [tokens, setTokens] = useState([])
const [isGetTokenNameModalOpen, setIsGetTokenNameModalOpen] = useState(false) const [isGetTokenNameModalOpen, setIsGetTokenNameModalOpen] = useState(false)
const [showTokenId, setShowTokenId] = useState(null)
const { userProfile, setUserProfile } = useContext(UserContext) const { userProfile, setUserProfile } = useContext(UserContext)
useEffect(() => { useEffect(() => {
GetLongLiveTokens().then(resp => { GetLongLiveTokens().then(resp => {
@ -61,19 +72,21 @@ const APITokenSettings = () => {
</Typography> </Typography>
</Box> </Box>
<Box> <Box>
{token.token && ( <Button
<Button variant='outlined'
variant='outlined' color='primary'
color='primary' sx={{ mr: 1 }}
sx={{ mr: 1 }} onClick={() => {
onClick={() => { if (showTokenId === token.id) {
navigator.clipboard.writeText(token.token) setShowTokenId(null)
alert('Token copied to clipboard') return
}} }
>
Copy Token setShowTokenId(token.id)
</Button> }}
)} >
{showTokenId === token?.id ? 'Hide' : 'Show'} Token
</Button>
<Button <Button
variant='outlined' variant='outlined'
@ -97,6 +110,28 @@ const APITokenSettings = () => {
</Button> </Button>
</Box> </Box>
</Box> </Box>
{showTokenId === token?.id && (
<Box>
<Input
value={token.token}
sx={{ width: '100%', mt: 2 }}
readOnly
endDecorator={
<IconButton
variant='outlined'
color='primary'
onClick={() => {
navigator.clipboard.writeText(token.token)
alert('Token copied to clipboard')
setShowTokenId(null)
}}
>
<CopyAll />
</IconButton>
}
/>
</Box>
)}
</Card> </Card>
))} ))}

View file

@ -1,85 +1,143 @@
import { Button, Divider, Input, Option, Select, Typography } from '@mui/joy' import { Button, Divider, Input, Option, Select, Typography } from '@mui/joy'
import { useContext, useEffect, useState } from 'react' import { useContext, useState } from 'react'
import { UserContext } from '../../contexts/UserContext' import { UserContext } from '../../contexts/UserContext'
import { GetUserProfile, UpdateUserDetails } from '../../utils/Fetcher' import { UpdateNotificationTarget } from '../../utils/Fetcher'
const NotificationSetting = () => { const NotificationSetting = () => {
const { userProfile, setUserProfile } = useContext(UserContext) const { userProfile, setUserProfile } = useContext(UserContext)
useEffect(() => { const [notificationTarget, setNotificationTarget] = useState(
if (!userProfile) { userProfile?.notification_target
GetUserProfile().then(resp => { ? String(userProfile.notification_target.type)
resp.json().then(data => { : '0',
setUserProfile(data.res) )
setChatID(data.res.chatID)
})
})
}
}, [])
const [chatID, setChatID] = useState(userProfile?.chatID)
const [chatID, setChatID] = useState(
userProfile?.notification_target?.target_id,
)
const [error, setError] = useState('')
const SaveValidation = () => {
switch (notificationTarget) {
case '1':
if (chatID === '') {
setError('Chat ID is required')
return false
} else if (isNaN(chatID) || chatID === '0') {
setError('Invalid Chat ID')
return false
}
break
case '2':
if (chatID === '') {
setError('User key is required')
return false
}
break
default:
break
}
setError('')
return true
}
const handleSave = () => {
if (!SaveValidation()) return
UpdateNotificationTarget({
target: chatID,
type: Number(notificationTarget),
}).then(resp => {
alert('Notification target updated')
setUserProfile({
...userProfile,
notification_target: {
target: chatID,
type: Number(notificationTarget),
},
})
})
}
return ( return (
<div className='grid gap-4 py-4' id='notifications'> <div className='grid gap-4 py-4' id='notifications'>
<Typography level='h3'>Notification Settings</Typography> <Typography level='h3'>Notification Settings</Typography>
<Divider /> <Divider />
<Typography level='body-md'>Manage your notification settings</Typography> <Typography level='body-md'>Manage your notification settings</Typography>
<Select defaultValue='telegram' sx={{ maxWidth: '200px' }} disabled> <Select
<Option value='telegram'>Telegram</Option> value={notificationTarget}
<Option value='discord'>Discord</Option> sx={{ maxWidth: '200px' }}
onChange={(e, selected) => setNotificationTarget(selected)}
>
<Option value='0'>None</Option>
<Option value='1'>Telegram</Option>
<Option value='2'>Pushover</Option>
</Select> </Select>
{notificationTarget === '1' && (
<>
<Typography level='body-xs'>
You need to initiate a message to the bot in order for the Telegram
notification to work{' '}
<a
style={{
textDecoration: 'underline',
color: '#0891b2',
}}
href='https://t.me/DonetickBot'
>
Click here
</a>{' '}
to start a chat
</Typography>
<Typography level='body-xs'> <Typography level='body-sm'>Chat ID</Typography>
You need to initiate a message to the bot in order for the Telegram
notification to work{' '}
<a
style={{
textDecoration: 'underline',
color: '#0891b2',
}}
href='https://t.me/DonetickBot'
>
Click here
</a>{' '}
to start a chat
</Typography>
<Input <Input
value={chatID} value={chatID}
onChange={e => setChatID(e.target.value)} onChange={e => setChatID(e.target.value)}
placeholder='User ID / Chat ID' placeholder='User ID / Chat ID'
sx={{ sx={{
width: '200px', width: '200px',
}} }}
/> />
<Typography mt={0} level='body-xs'> <Typography mt={0} level='body-xs'>
If you don't know your Chat ID, start chat with userinfobot and it will If you don't know your Chat ID, start chat with userinfobot and it
send you your Chat ID.{' '} will send you your Chat ID.{' '}
<a <a
style={{ style={{
textDecoration: 'underline', textDecoration: 'underline',
color: '#0891b2', color: '#0891b2',
}} }}
href='https://t.me/userinfobot' href='https://t.me/userinfobot'
> >
Click here Click here
</a>{' '} </a>{' '}
to start chat with userinfobot{' '} to start chat with userinfobot{' '}
</Typography> </Typography>
</>
)}
{notificationTarget === '2' && (
<>
<Typography level='body-sm'>User key</Typography>
<Input
value={chatID}
onChange={e => setChatID(e.target.value)}
placeholder='User ID'
sx={{
width: '200px',
}}
/>
</>
)}
{error && (
<Typography color='warning' level='body-sm'>
{error}
</Typography>
)}
<Button <Button
sx={{ sx={{
width: '110px', width: '110px',
mb: 1, mb: 1,
}} }}
onClick={() => { onClick={handleSave}
UpdateUserDetails({
chatID: Number(chatID),
}).then(resp => {
resp.json().then(data => {
setUserProfile(data)
})
})
}}
> >
Save Save
</Button> </Button>