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",
"private": true,
"version": "0.1.80",
"version": "0.1.82",
"type": "module",
"lint-staged": {
"*.{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 = () => {
return Fetch(API_URL + `/payments/create-subscription`, {
method: 'GET',
@ -373,6 +381,7 @@ export {
UpdateChoreHistory,
UpdateChorePriority,
UpdateLabel,
UpdateNotificationTarget,
UpdatePassword,
UpdateThingState,
UpdateUserDetails,

View file

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

View file

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

View file

@ -2,15 +2,20 @@ import {
Add,
CancelRounded,
EditCalendar,
ExpandCircleDown,
FilterAlt,
PriorityHigh,
Style,
} from '@mui/icons-material'
import {
Accordion,
AccordionDetails,
AccordionGroup,
Box,
Button,
Chip,
Container,
Divider,
IconButton,
Input,
List,
@ -38,6 +43,8 @@ const MyChores = () => {
const [chores, setChores] = useState([])
const [filteredChores, setFilteredChores] = useState([])
const [selectedFilter, setSelectedFilter] = useState('All')
const [choreSections, setChoreSections] = useState([])
const [openChoreSections, setOpenChoreSections] = useState({})
const [searchTerm, setSearchTerm] = useState('')
const [activeUserId, setActiveUserId] = useState(0)
const [performers, setPerformers] = useState([])
@ -74,6 +81,108 @@ const MyChores = () => {
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(() => {
if (userProfile === null) {
GetUserProfile()
@ -100,6 +209,14 @@ const MyChores = () => {
const sortedChores = choresData.res.sort(choreSorter)
setChores(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])
@ -231,11 +348,6 @@ const MyChores = () => {
return (
<Container maxWidth='md'>
{/* <Typography level='h3' mb={1.5}>
My Chores
</Typography> */}
{/* <Sheet> */}
{/* Search box to filter */}
<Box
sx={{
display: 'flex',
@ -253,7 +365,6 @@ const MyChores = () => {
mt: 1,
mb: 1,
borderRadius: 24,
// border: '1px solid',
height: 24,
borderColor: 'text.disabled',
padding: 1,
@ -271,7 +382,6 @@ const MyChores = () => {
}
/>
<IconButtonWithMenu
key={'icon-menu-labels-filter'}
icon={<PriorityHigh />}
options={Priorities}
selectedItem={selectedFilter}
@ -282,7 +392,6 @@ const MyChores = () => {
isActive={selectedFilter.startsWith('Priority: ')}
/>
<IconButtonWithMenu
key={'icon-menu-labels-filter'}
icon={<Style />}
// TODO : this need simplification we want to display both user labels and chore labels
// that why we are merging them here.
@ -395,7 +504,6 @@ const MyChores = () => {
Current Filter: {selectedFilter}
</Chip>
)}
{/* </Sheet> */}
{filteredChores.length === 0 && (
<Box
sx={{
@ -419,7 +527,10 @@ const MyChores = () => {
{chores.length > 0 && (
<>
<Button
onClick={() => setFilteredChores(chores)}
onClick={() => {
setFilteredChores(chores)
setSearchTerm('')
}}
variant='outlined'
color='neutral'
>
@ -429,19 +540,92 @@ const MyChores = () => {
)}
</Box>
)}
{filteredChores.map(chore => (
<ChoreCard
key={chore.id}
chore={chore}
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
/>
))}
{(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
// variant='outlined'
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 { useContext, useEffect, useState } from 'react'
import { UserContext } from '../../contexts/UserContext'
@ -13,6 +23,7 @@ import TextModal from '../Modals/Inputs/TextModal'
const APITokenSettings = () => {
const [tokens, setTokens] = useState([])
const [isGetTokenNameModalOpen, setIsGetTokenNameModalOpen] = useState(false)
const [showTokenId, setShowTokenId] = useState(null)
const { userProfile, setUserProfile } = useContext(UserContext)
useEffect(() => {
GetLongLiveTokens().then(resp => {
@ -61,19 +72,21 @@ const APITokenSettings = () => {
</Typography>
</Box>
<Box>
{token.token && (
<Button
variant='outlined'
color='primary'
sx={{ mr: 1 }}
onClick={() => {
navigator.clipboard.writeText(token.token)
alert('Token copied to clipboard')
}}
>
Copy Token
</Button>
)}
<Button
variant='outlined'
color='primary'
sx={{ mr: 1 }}
onClick={() => {
if (showTokenId === token.id) {
setShowTokenId(null)
return
}
setShowTokenId(token.id)
}}
>
{showTokenId === token?.id ? 'Hide' : 'Show'} Token
</Button>
<Button
variant='outlined'
@ -97,6 +110,28 @@ const APITokenSettings = () => {
</Button>
</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>
))}

View file

@ -1,85 +1,143 @@
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 { GetUserProfile, UpdateUserDetails } from '../../utils/Fetcher'
import { UpdateNotificationTarget } from '../../utils/Fetcher'
const NotificationSetting = () => {
const { userProfile, setUserProfile } = useContext(UserContext)
useEffect(() => {
if (!userProfile) {
GetUserProfile().then(resp => {
resp.json().then(data => {
setUserProfile(data.res)
setChatID(data.res.chatID)
})
})
}
}, [])
const [chatID, setChatID] = useState(userProfile?.chatID)
const [notificationTarget, setNotificationTarget] = useState(
userProfile?.notification_target
? String(userProfile.notification_target.type)
: '0',
)
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 (
<div className='grid gap-4 py-4' id='notifications'>
<Typography level='h3'>Notification Settings</Typography>
<Divider />
<Typography level='body-md'>Manage your notification settings</Typography>
<Select defaultValue='telegram' sx={{ maxWidth: '200px' }} disabled>
<Option value='telegram'>Telegram</Option>
<Option value='discord'>Discord</Option>
<Select
value={notificationTarget}
sx={{ maxWidth: '200px' }}
onChange={(e, selected) => setNotificationTarget(selected)}
>
<Option value='0'>None</Option>
<Option value='1'>Telegram</Option>
<Option value='2'>Pushover</Option>
</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'>
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-sm'>Chat ID</Typography>
<Input
value={chatID}
onChange={e => setChatID(e.target.value)}
placeholder='User ID / Chat ID'
sx={{
width: '200px',
}}
/>
<Typography mt={0} level='body-xs'>
If you don't know your Chat ID, start chat with userinfobot and it will
send you your Chat ID.{' '}
<a
style={{
textDecoration: 'underline',
color: '#0891b2',
}}
href='https://t.me/userinfobot'
>
Click here
</a>{' '}
to start chat with userinfobot{' '}
</Typography>
<Input
value={chatID}
onChange={e => setChatID(e.target.value)}
placeholder='User ID / Chat ID'
sx={{
width: '200px',
}}
/>
<Typography mt={0} level='body-xs'>
If you don't know your Chat ID, start chat with userinfobot and it
will send you your Chat ID.{' '}
<a
style={{
textDecoration: 'underline',
color: '#0891b2',
}}
href='https://t.me/userinfobot'
>
Click here
</a>{' '}
to start chat with userinfobot{' '}
</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
sx={{
width: '110px',
mb: 1,
}}
onClick={() => {
UpdateUserDetails({
chatID: Number(chatID),
}).then(resp => {
resp.json().then(data => {
setUserProfile(data)
})
})
}}
onClick={handleSave}
>
Save
</Button>