Add SmartTaskInput with Custom Renderer.

Change to use `@tanstack/react-query`
Add Global ErrorProvider
Add limited Support for offline version
Fix issue with calendar display +1 day
This commit is contained in:
Mo Tarbin
2025-04-24 01:03:40 -04:00
parent 4f1715913e
commit f0e0c0e4fc
34 changed files with 1943 additions and 1302 deletions

165
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "donetick",
"version": "0.1.98",
"version": "0.1.99",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "donetick",
"version": "0.1.98",
"version": "0.1.99",
"dependencies": {
"@capacitor-community/sqlite": "^6.0.2",
"@capacitor/android": "^6.1.1",
@@ -42,7 +42,6 @@
"react": "^18.2.0",
"react-calendar": "^5.1.0",
"react-dom": "^18.2.0",
"react-query": "^3.39.3",
"react-router-dom": "^6.21.1",
"react-transition-group": "^4.4.5",
"reactjs-social-login": "^2.6.3",
@@ -5228,81 +5227,6 @@
"node": ">=8"
}
},
"node_modules/broadcast-channel": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz",
"integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.7.2",
"detect-node": "^2.1.0",
"js-sha3": "0.8.0",
"microseconds": "0.2.0",
"nano-time": "1.0.0",
"oblivious-set": "1.0.0",
"rimraf": "3.0.2",
"unload": "2.2.0"
}
},
"node_modules/broadcast-channel/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/broadcast-channel/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/broadcast-channel/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/broadcast-channel/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/browser-fs-access": {
"version": "0.35.0",
"resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.35.0.tgz",
@@ -6566,12 +6490,6 @@
"node": ">=8"
}
},
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"license": "MIT"
},
"node_modules/dezalgo": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
@@ -9341,12 +9259,6 @@
"node": ">=14"
}
},
"node_modules/js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -9807,16 +9719,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/match-sorter": {
"version": "6.3.4",
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.4.tgz",
"integrity": "sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.8",
"remove-accents": "0.5.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -10054,12 +9956,6 @@
"node": ">=8.6"
}
},
"node_modules/microseconds": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz",
"integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==",
"license": "MIT"
},
"node_modules/mimic-fn": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz",
@@ -10226,15 +10122,6 @@
"thenify-all": "^1.0.0"
}
},
"node_modules/nano-time": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz",
"integrity": "sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA==",
"license": "ISC",
"dependencies": {
"big-integer": "^1.6.16"
}
},
"node_modules/nanoid": {
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
@@ -10542,12 +10429,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oblivious-set": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz",
"integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==",
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -11409,32 +11290,6 @@
"integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==",
"license": "MIT"
},
"node_modules/react-query": {
"version": "3.39.3",
"resolved": "https://registry.npmjs.org/react-query/-/react-query-3.39.3.tgz",
"integrity": "sha512-nLfLz7GiohKTJDuT4us4X3h/8unOh+00MLb2yJoGTPjxKs2bc1iDhkNx2bd5MKklXnOD3NrVZ+J2UXujA5In4g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.5.5",
"broadcast-channel": "^3.4.1",
"match-sorter": "^6.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "6.28.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.1.tgz",
@@ -11882,12 +11737,6 @@
"node": ">=6"
}
},
"node_modules/remove-accents": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==",
"license": "MIT"
},
"node_modules/replace": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/replace/-/replace-1.2.2.tgz",
@@ -13855,16 +13704,6 @@
"node": ">= 10.0.0"
}
},
"node_modules/unload": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz",
"integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.6.2",
"detect-node": "^2.0.4"
}
},
"node_modules/untildify": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz",

View File

@@ -1,13 +1,14 @@
import NavBar from '@/views/components/NavBar'
import { Button, Snackbar, Typography, useColorScheme } from '@mui/joy'
import Tracker from '@openreplay/tracker'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
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 { ErrorProvider } from './service/ErrorProvider'
import { GetUserProfile } from './utils/Fetcher'
import { apiManager, isTokenValid } from './utils/TokenManager'
import NetworkBanner from './views/components/NetworkBanner'
@@ -91,10 +92,12 @@ function App() {
<NetworkBanner />
<QueryClientProvider client={queryClient}>
<AuthenticationProvider />
<UserContext.Provider value={{ userProfile, setUserProfile }}>
<NavBar />
<Outlet />
</UserContext.Provider>
<ErrorProvider>
<UserContext.Provider value={{ userProfile, setUserProfile }}>
<NavBar />
<Outlet />
</UserContext.Provider>
</ErrorProvider>
{needRefresh && (
<Snackbar open={showUpdateSnackbar}>
<Typography level='body-md'>

View File

@@ -1,5 +1,5 @@
import { Network } from '@capacitor/network'
// import { localStore } from '../utils/LocalStore'
import { localStore } from '../utils/LocalStore'
import { syncManager } from '../utils/SyncManager.jsx' // Ensure you import syncManager if needed for syncing
class NetworkManager {
constructor() {
@@ -23,29 +23,33 @@ class NetworkManager {
this.isOnline = status.connected
}
})
const syncQueue = () => {
localStore
.syncQueuedRequests()
.then(hasMessages => {
console.log(
'Queued requests synced successfully. Queue has messaage is: ',
hasMessages,
)
if (hasMessages) {
this.notifyBackendSync()
}
})
.catch(error => {
console.error('Error syncing queued requests:', error)
})
}
this.registerNetworkListener(async isOnline => {
if (isOnline && this.isNetworkOn) {
// TODO: Delete when Sync manager Implemented
// localStore
// .syncQueuedRequests()
// .then(hasMessages => {
// console.log(
// 'Queued requests synced successfully. Queue has messaage is: ',
// hasMessages,
// )
// if (hasMessages) {
// this.notifyBackendSync()
// }
// })
// .catch(error => {
// console.error('Error syncing queued requests:', error)
// })
syncQueue()
console.log('NetworkManager: Network is back online. with SYNCMANAGER')
await syncManager.syncTasks()
console.log('Finished syncing queued requests.')
}
})
syncQueue()
}
setOffline() {

View File

@@ -67,19 +67,22 @@ export const useCreateChore = () => {
{ ...newTask, id: tempId, tempId }, // Use the tempId for offline tracking
]
await localStore.saveToCache('offlineTasks', updateOfflineTasks) // Save to local storage
// force useChores to refetch:
queryClient.invalidateQueries(['chores'])
// Force the chores query to refetch
queryClient.refetchQueries(['chores'])
// Update the chores query cache immediately
queryClient.setQueryData(['chores'], oldData => {
console.log('ATTEMPT TO SAVE OFFLINE TASKS:', updateOfflineTasks)
// queryClient.setQueryData(['chores'], oldData => {
// console.log('ATTEMPT TO SAVE OFFLINE TASKS:', updateOfflineTasks)
if (!oldData)
return {
res: [{ ...newTask, id: tempId, tempId }],
} // If no data, return offline tasks
return {
res: [...oldData.res, { ...newTask, id: tempId, tempId }],
}
})
// if (!oldData)
// return {
// res: [{ ...newTask, id: tempId, tempId }],
// } // If no data, return offline tasks
// return {
// res: [...oldData.res, { ...newTask, id: tempId, tempId }],
// }
// })
return { tempId }
}
return { tempId: null }
@@ -135,11 +138,11 @@ export const useUpdateChore = () => {
throw new Error('Failed to save chore')
}
const updatedChoreRes = await resp.json()
if (!updatedChoreRes || !updatedChoreRes.res) {
if (!updatedChoreRes) {
throw new Error('Failed to get updated chore data')
}
// Successfully updated the chore on the server, return the updated chore
return updatedChoreRes.res
return updatedChoreRes?.res || updatedChoreRes
}
},
onSuccess: (data, variables) => {
@@ -158,9 +161,13 @@ export const useUpdateChore = () => {
export const useChoresHistory = (initialLimit, includeMembers) => {
const [limit, setLimit] = useState(initialLimit) // Initially, no limit is selected
const { data, error, isLoading } = useQuery(['choresHistory', limit], () =>
GetChoresHistory(limit, includeMembers),
)
const { data, error, isLoading } = useQuery({
queryKey: ['choresHistory', limit],
queryFn: async () => {
const resp = await GetChoresHistory(limit, includeMembers)
return resp?.res || []
},
})
const handleLimitChange = newLimit => {
setLimit(newLimit)

View File

@@ -1,9 +1,15 @@
import { useQuery } from 'react-query'
import { useQuery } from '@tanstack/react-query'
import { GetResource } from '../utils/Fetcher'
export const useResource = () => {
const { data, isLoading, error } = useQuery('resource', GetResource, {
cacheTime: 1000 * 60 * 10, // 1 minute
const { data, isLoading, error } = useQuery({
queryKey: ['resource'],
queryFn: async () => {
const response = await GetResource()
return response.json()
},
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
return { data, isLoading, error }
}

View File

@@ -0,0 +1,94 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { networkManager } from '../hooks/NetworkManager'
import { CompleteSubTask, SaveChore } from '../utils/Fetcher'
import { localStore } from '../utils/LocalStore'
export const useUpdate = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async updatedChore => {
if (!networkManager.isOnline) {
updatedChore['updatedAt'] = new Date().toISOString()
if (!updatedChore['nextDueDate']) {
updatedChore['nextDueDate'] = updatedChore['dueDate']
}
const offlineTasks =
(await localStore.getFromCache('offlineTasks')) || []
for (const task of offlineTasks) {
// Find the task with the same id or tempId and update it
if (task.id === updatedChore.id || task.tempId === updatedChore.id) {
// Update the task in local storage
const updatedTask = { ...task, ...updatedChore }
const updatedOfflineTasks = offlineTasks.map(t =>
t.id === task.id ? updatedTask : t,
)
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
return new Promise((resolve, reject) => {
resolve(updatedTask)
})
}
}
const newTaskId = crypto.randomUUID()
const updatedChoreWithNewId = {
...updatedChore,
tempId: newTaskId,
}
await localStore.saveToCache('offlineTasks', [
...offlineTasks,
updatedChoreWithNewId,
])
return new Promise((resolve, reject) => {
// Resolve with the updated task
resolve(updatedChoreWithNewId)
})
} else {
// Call the API to update the chore
const resp = await SaveChore(updatedChore)
if (!resp || !resp.ok) {
throw new Error('Failed to save chore')
}
const updatedChoreRes = await resp.json()
if (!updatedChoreRes) {
throw new Error('Failed to get updated chore data')
}
// Successfully updated the chore on the server, return the updated chore
return updatedChoreRes?.res || updatedChoreRes
}
},
onSuccess: (data, variables) => {
// Invalidate the chores query to refresh the data
queryClient.invalidateQueries(['chores'])
},
onMutate: async updatedChore => {
if (!networkManager.isOnline) {
// Handle offline case here if needed
return
}
},
})
}
export const useCompleteSubTask = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (subTaskId, choreId, completedAt) => {
if (!networkManager.isOnline) {
throw new Error('Cannot complete subtask while offline')
}
const resp = await CompleteSubTask(subTaskId, choreId, completedAt)
if (!resp || !resp.ok) {
throw new Error('Failed to complete subtask')
}
const result = await resp.json()
if (!result || !result.res) {
throw new Error('Failed to get completed subtask data')
}
return result.res
},
onSuccess: (data, variables) => {},
})
}

View File

@@ -1,18 +1,21 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useQuery } from 'react-query'
import { GetAllCircleMembers, GetAllUsers } from '../utils/Fetcher'
export const useAllUsers = () => {
return useQuery('allUsers', GetAllUsers)
return useQuery({
queryKey: ['allUsers'],
queryFn: GetAllUsers,
})
}
export const useCircleMembers = () => {
const [refetchKey, setRefetchKey] = useState(0)
const { data, error, isLoading, refetch } = useQuery(
['allCircleMembers', refetchKey],
GetAllCircleMembers,
)
const { data, error, isLoading, refetch } = useQuery({
queryKey: ['allCircleMembers', refetchKey],
queryFn: GetAllCircleMembers,
})
const handleRefetch = () => {
setRefetchKey(prevKey => prevKey + 1)
refetch()

View File

@@ -0,0 +1,51 @@
import { Error } from '@mui/icons-material'
import { Box, Button, Snackbar, Typography } from '@mui/joy'
import React, { createContext, useContext, useState } from 'react'
const ErrorContext = createContext()
export const useError = () => useContext(ErrorContext)
export const ErrorProvider = ({ children }) => {
const [error, setError] = useState(null)
const showError = error => {
setError(error)
}
return (
<ErrorContext.Provider value={{ showError }}>
{children}
<Snackbar
open={Boolean(error)}
autoHideDuration={6000}
onClose={() => setError(null)}
startDecorator={<Error color='danger' />}
endDecorator={
<Button
variant='outlined'
color='danger'
onClick={() => setError(null)}
>
Dismiss
</Button>
}
>
{typeof error === 'string' ? (
<Typography color='danger' level='body-md'>
{error}
</Typography>
) : (
<Box>
<Typography color='danger' level='title-sm'>
{error?.title}
</Typography>
<Typography color='danger' level='body-sm'>
{error?.message}
</Typography>
</Box>
)}
</Snackbar>
</ErrorContext.Provider>
)
}

View File

@@ -1,5 +1,4 @@
import { CapacitorSQLite } from '@capacitor-community/sqlite'
import { Capacitor } from '@capacitor/core'
const CACHE_TABLE = 'offline_cache'
const QUEUE_TABLE = 'offline_request_queue'
@@ -8,7 +7,8 @@ const OFFLINE_TASK = 'offlineTasks' // For storing offline tasks
class LocalStore {
constructor() {
this.db = null
this.useLocalStorage = !Capacitor.isNativePlatform()
// this.useLocalStorage = !Capacitor.isNativePlatform()
this.useLocalStorage = true // default to localStorage for now.
}
async initDatabase() {
@@ -153,10 +153,15 @@ class LocalStore {
)
}
async queueRequest(requestId, requestBody) {
async queueRequest(requestId, requestPayload) {
if (this.useLocalStorage) {
const queue = JSON.parse(localStorage.getItem(QUEUE_TABLE)) || []
queue.push({ requestId, requestBody })
console.log('requestPayload', requestPayload)
if (typeof requestPayload?.options?.body['id'] === 'string') {
requestPayload['id'] = null
}
queue.push({ requestId, requestBody: requestPayload })
localStorage.setItem(QUEUE_TABLE, JSON.stringify(queue))
return
}
@@ -167,11 +172,13 @@ class LocalStore {
INSERT INTO ${QUEUE_TABLE} (url, requestBody)
VALUES (?, ?);
`,
[requestId, JSON.stringify(requestBody)],
[requestId, JSON.stringify(requestPayload)],
)
}
async syncQueuedRequests() {
console.log('Syncing queued requests...')
var queueSize = 0
if (this.useLocalStorage) {
const queue = JSON.parse(localStorage.getItem(QUEUE_TABLE)) || []

View File

@@ -137,7 +137,11 @@ async function handleOfflineRequest(url, options) {
const requestId = murmurhash.v3(JSON.stringify({ url, options }))
await localStore.queueRequest(requestId, { url, options })
console.log('Request queued for later processing:', requestId)
return Promise.reject(new Error('Offline and request queued: ' + requestId))
return Promise.reject({
error: 'Offline and request queued',
requestId,
queued: true,
})
}
}
async function attemptFetchFromCache(url, options) {
@@ -152,6 +156,7 @@ async function attemptFetchFromCache(url, options) {
json: async () => cachedData,
})
} else {
// TODO: change this to throw error instead of returning promise
return Promise.reject(
new Error(
'No cached data found for URL: ' +

View File

@@ -386,7 +386,7 @@ const ChoreEdit = () => {
},
})
}
if (isChoreLoading && choreId) {
if ((isChoreLoading && choreId) || isUserLabelsLoading) {
return <LoadingComponent />
}
return (
@@ -573,7 +573,6 @@ const ChoreEdit = () => {
}}
defaultChecked={dueDate !== null}
checked={dueDate !== null}
value={dueDate !== null}
overlay
label='Give this task a due date'
/>
@@ -711,7 +710,6 @@ const ChoreEdit = () => {
}}
defaultChecked={isNotificable}
checked={isNotificable}
value={isNotificable}
disabled={!isPlusAccount(userProfile)}
overlay
label='Notify for this task'
@@ -967,7 +965,6 @@ const ChoreEdit = () => {
}}
overlay
checked={subTasks != null}
value={subTasks != null}
label='Sub Tasks'
/>
<FormHelperText>Add sub tasks to this task</FormHelperText>
@@ -997,7 +994,6 @@ const ChoreEdit = () => {
}
}}
checked={points > -1}
value={points > -1}
overlay
label='Assign Points'
/>

View File

@@ -243,6 +243,7 @@ const ChoreView = () => {
justifyContent: 'center',
alignItems: 'center',
textAlign: 'center',
mb: 1,
}}
>
<Typography
@@ -260,21 +261,31 @@ const ChoreView = () => {
? `Due at ${moment(chore.nextDueDate).format('MM/DD/YYYY hh:mm A')}`
: 'N/A'}
</Chip>
{chore?.labelsV2?.map((label, index) => (
<Chip
key={index}
sx={{
position: 'relative',
ml: index === 0 ? 0 : 0.5,
top: 2,
zIndex: 1,
backgroundColor: label?.color,
color: getTextColorFromBackgroundColor(label?.color),
}}
>
{label?.name}
</Chip>
))}
<Box
sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
mb: 1,
}}
>
{chore?.labelsV2?.map((label, index) => (
<Chip
key={index}
sx={{
position: 'relative',
ml: index === 0 ? 0 : 0.5,
top: 2,
zIndex: 1,
backgroundColor: label?.color,
color: getTextColorFromBackgroundColor(label?.color),
}}
>
{label?.name}
</Chip>
))}
</Box>
</Box>
<Box>

View File

@@ -44,6 +44,7 @@ import moment from 'moment'
import React, { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { UserContext } from '../../contexts/UserContext'
import { useError } from '../../service/ErrorProvider'
import { notInCompletionWindow } from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import {
@@ -91,6 +92,7 @@ const ChoreCard = ({
const [secondsLeftToCancel, setSecondsLeftToCancel] = React.useState(null)
const [timeoutId, setTimeoutId] = React.useState(null)
const { userProfile } = React.useContext(UserContext)
const { showError } = useError()
useEffect(() => {
document.addEventListener('mousedown', handleMenuOutsideClick)
return () => {
@@ -176,6 +178,7 @@ const ChoreCard = ({
if (seconds <= 0) {
clearInterval(countdownInterval) // Stop the countdown when it reaches 0
setIsPendingCompletion(false) // Reset the state
}
}, 1000)
@@ -195,6 +198,25 @@ const ChoreCard = ({
setTimeoutId(null)
setSecondsLeftToCancel(null)
})
.catch(error => {
if (error?.queued) {
showError({
title: 'Update Failed',
message: 'Request will be reattempt when you are online',
})
} else {
showError({
title: 'Failed to update',
message: error,
})
}
setIsPendingCompletion(false)
clearTimeout(id)
clearInterval(countdownInterval) // Ensure to clear this interval as well
setTimeoutId(null)
setSecondsLeftToCancel(null)
})
}, 2000)
setTimeoutId(id)
@@ -676,15 +698,30 @@ const ChoreCard = ({
</MenuItem>
<MenuItem
onClick={() => {
SkipChore(chore.id).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
onChoreUpdate(newChore, 'skipped')
handleMenuClose()
})
}
})
SkipChore(chore.id)
.then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
onChoreUpdate(newChore, 'skipped')
handleMenuClose()
})
}
})
.catch(error => {
if (error?.queued) {
showError({
title: 'Failed to update',
message:
'Request will be processed when you are online',
})
} else {
showError({
title: 'Failed to update',
message: error,
})
}
})
}}
>
<SwitchAccessShortcut />

View File

@@ -0,0 +1,35 @@
import { Box, Checkbox, Typography } from '@mui/joy'
export const CompactCard = ({
chore,
performers,
onChoreUpdate,
onChoreRemove,
userLabels,
sx,
viewOnly,
onChipClick,
}) => {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flex: 1,
}}
>
<Checkbox
// checked={!!task.completedAt}
// onChange={() => handleToggle(task.id)}
/>
<Typography
sx={{
textDecoration: chore.completedAt ? 'line-through' : 'none',
}}
>
{chore.name}
</Typography>
</Box>
)
}

View File

@@ -1,11 +1,11 @@
import {
Add,
Bolt,
CancelRounded,
EditCalendar,
ExpandCircleDown,
Grain,
PriorityHigh,
Search,
Sort,
Style,
Unarchive,
@@ -63,11 +63,11 @@ const MyChores = () => {
const [filteredChores, setFilteredChores] = useState([])
const [searchFilter, setSearchFilter] = useState('All')
const [choreSections, setChoreSections] = useState([])
const [activeTextField, setActiveTextField] = useState(
localStorage.getItem('activeTextField') || 'task',
)
const [showSearchFilter, setShowSearchFilter] = useState(false)
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false)
const [taskInputFocus, setTaskInputFocus] = useState(0)
const searchInputRef = useRef()
const searchInputRef = useRef(null)
const [searchInputFocus, setSearchInputFocus] = useState(0)
const [selectedChoreSection, setSelectedChoreSection] = useState(
localStorage.getItem('selectedChoreSection') || 'due_date',
@@ -84,7 +84,11 @@ const MyChores = () => {
const menuRef = useRef(null)
const Navigate = useNavigate()
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
const { data: choresData, isLoading: choresLoading } = useChores()
const {
data: choresData,
isLoading: choresLoading,
refetch: refetchChores,
} = useChores()
useEffect(() => {
Promise.all([GetChores(), GetAllUsers(), GetUserProfile()]).then(
@@ -173,6 +177,21 @@ const MyChores = () => {
searchInputRef.current.selectionEnd = searchInputRef.current.value?.length
}
}, [searchInputFocus])
// add listern to Control/Command + K to focus on search input
useEffect(() => {
const handleKeyDown = event => {
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault()
setAddTaskModalOpen(true)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [])
const setSelectedChoreSectionWithCache = value => {
setSelectedChoreSection(value)
localStorage.setItem('selectedChoreSection', value)
@@ -185,10 +204,6 @@ const MyChores = () => {
setSelectedChoreFilter(value)
localStorage.setItem('selectedChoreFilter', value)
}
const setActiveTextFieldWithCache = value => {
setActiveTextField(value)
localStorage.setItem('activeTextField', value)
}
const updateChores = newChore => {
const newChores = chores
@@ -381,60 +396,36 @@ const MyChores = () => {
gap: 0.5,
}}
>
{activeTextField == 'task' && (
<TaskInput
autoFocus={taskInputFocus}
onChoreUpdate={updateChores}
/>
)}
<Input
ref={searchInputRef}
placeholder='Search'
value={searchTerm}
onFocus={() => {
setShowSearchFilter(true)
}}
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 == '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' && (
<IconButton
variant='outlined'
size='sm'
color='neutral'
sx={{
height: 24,
borderRadius: 24,
// minWidth: 100,
}}
onClick={() => {
setActiveTextFieldWithCache('task')
setTaskInputFocus(taskInputFocus + 1)
}}
>
<EditCalendar />
</IconButton>
)}
{activeTextField != 'search' && (
{/* {activeTextField != 'search' && (
<IconButton
variant='outlined'
color='neutral'
@@ -452,7 +443,7 @@ const MyChores = () => {
>
<Search />
</IconButton>
)}
)} */}
<SortAndGrouping
title='Group by'
k={'icon-menu-group-by'}
@@ -496,7 +487,7 @@ const MyChores = () => {
mouseClickHandler={handleMenuOutsideClick}
/>
</Box>
{activeTextField === 'search' && (
{showSearchFilter && (
<div className='flex gap-4'>
<div className='grid flex-1 grid-cols-3 gap-4'>
<IconButtonWithMenu
@@ -597,6 +588,23 @@ const MyChores = () => {
</Menu>
</List>
</div>
<IconButton
variant='outlined'
color='neutral'
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
onClick={() => {
setShowSearchFilter(false)
setSearchTerm('')
setFilteredChores(chores)
setSearchFilter('All')
}}
>
<CancelRounded />
</IconButton>
</div>
)}
{searchFilter !== 'All' && (
@@ -811,7 +819,7 @@ const MyChores = () => {
display: 'flex',
justifyContent: 'flex-end',
gap: 2,
'z-index': 1000,
'z-index': 100,
}}
>
<IconButton
@@ -821,6 +829,7 @@ const MyChores = () => {
borderRadius: '50%',
width: 50,
height: 50,
zIndex: 101,
}}
onClick={() => {
Navigate(`/chores/create`)
@@ -828,6 +837,27 @@ const MyChores = () => {
>
<Add />
</IconButton>
<IconButton
color='primary'
variant='soft'
sx={{
borderRadius: '50%',
width: 25,
height: 25,
position: 'relative',
left: -25,
top: 22,
}}
onClick={() => {
setAddTaskModalOpen(true)
}}
>
<Bolt
style={{
rotate: '20deg',
}}
/>
</IconButton>
</Box>
<Snackbar
open={isSnackbarOpen}
@@ -843,6 +873,19 @@ const MyChores = () => {
<Typography level='title-md'>{snackBarMessage}</Typography>
</Snackbar>
<NotificationAccessSnackbar />
{addTaskModalOpen && (
<TaskInput
autoFocus={taskInputFocus}
onChoreUpdate={updateChores}
isModalOpen={addTaskModalOpen}
onClose={forceRefresh => {
setAddTaskModalOpen(false)
if (forceRefresh) {
refetchChores()
}
}}
/>
)}
</Container>
<Sidepanel chores={chores} performers={performers} />

View File

@@ -26,9 +26,7 @@ import {
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { API_URL } from '../Config'
import { GetAllUsers, GetChores, MarkChoreComplete } from '../utils/Fetcher'
import { Fetch } from '../utils/TokenManager'
import DateModal from './Modals/Inputs/DateModal'
// import moment from 'moment'
@@ -263,21 +261,22 @@ const ChoresOverview = () => {
size='sm'
// sx={{ borderRadius: '50%' }}
onClick={() => {
MarkChoreComplete(chore.id,null,null,null)
.then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
const newChores = [...chores]
const index = newChores.findIndex(
c => c.id === chore.id,
)
newChores[index] = newChore
setChores(newChores)
setFilteredChores(newChores)
})
}
})
MarkChoreComplete(chore.id, null, null, null).then(
response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
const newChores = [...chores]
const index = newChores.findIndex(
c => c.id === chore.id,
)
newChores[index] = newChore
setChores(newChores)
setFilteredChores(newChores)
})
}
},
)
}}
aria-setsize={2}
>
@@ -325,19 +324,20 @@ const ChoresOverview = () => {
alert('Please select a performer')
return
}
MarkChoreComplete(choreId, null, date, activeUserId)
.then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
const newChores = [...chores]
const index = newChores.findIndex(c => c.id === chore.id)
newChores[index] = newChore
setChores(newChores)
setFilteredChores(newChores)
})
}
})
MarkChoreComplete(choreId, null, date, activeUserId).then(
response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
const newChores = [...chores]
const index = newChores.findIndex(c => c.id === chore.id)
newChores[index] = newChore
setChores(newChores)
setFilteredChores(newChores)
})
}
},
)
}}
/>
</Container>

View File

@@ -1,8 +1,16 @@
import { useQuery } from 'react-query'
import { GetLabels } from '../../utils/Fetcher'
import { useQuery } from '@tanstack/react-query'
import { CreateLabel, GetLabels } from '../../utils/Fetcher'
export const useLabels = () => {
return useQuery('labels', GetLabels, {
initialData: [],
return useQuery({
queryKey: ['labels'],
queryFn: GetLabels,
})
}
export const useCreateLabel = () => {
return useQuery({
queryKey: ['createLabel'],
queryFn: CreateLabel,
})
}

View File

@@ -11,26 +11,25 @@ import {
} from '@mui/joy'
import { useEffect, useState } from 'react'
import LabelModal from '../Modals/Inputs/LabelModal'
import { useLabels } from './LabelQueries'
// import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Add } from '@mui/icons-material'
import { useQueryClient } from 'react-query'
import { useNavigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
import { DeleteLabel } from '../../utils/Fetcher'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import { useLabels } from './LabelQueries'
const LabelView = () => {
const { data: labels, isLabelsLoading, isError } = useLabels()
const [userLabels, setUserLabels] = useState([labels])
const [userLabels, setUserLabels] = useState([])
const [modalOpen, setModalOpen] = useState(false)
const [currentLabel, setCurrentLabel] = useState(null)
const queryClient = useQueryClient()
const [confirmationModel, setConfirmationModel] = useState({})
const Navigate = useNavigate()
const handleAddLabel = () => {
setCurrentLabel(null)
setModalOpen(true)
@@ -53,7 +52,7 @@ const LabelView = () => {
color: 'danger',
cancelText: 'Cancel',
onClose: confirmed => {
if (confirmed) {
if (confirmed === true) {
handleDeleteLabel(id)
}
setConfirmationModel({})
@@ -140,7 +139,7 @@ const LabelView = () => {
<IconButton
size='sm'
variant='soft'
onClick={() => handleDeleteLabel(label.id)}
onClick={() => handleDeleteClicked(label.id)}
color='danger'
>
<DeleteIcon />

View File

@@ -11,7 +11,8 @@ import {
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useMutation, useQueryClient } from 'react-query'
import { useQueryClient } from '@tanstack/react-query'
import { useError } from '../../../service/ErrorProvider.jsx'
import LABEL_COLORS from '../../../utils/Colors.jsx'
import { CreateLabel, UpdateLabel } from '../../../utils/Fetcher'
import { useLabels } from '../../Labels/LabelQueries'
@@ -22,6 +23,7 @@ function LabelModal({ isOpen, onClose, label }) {
const [error, setError] = useState('')
const { data: userLabels = [] } = useLabels()
const queryClient = useQueryClient()
const { showError } = useError()
// Populate the form fields when editing
useEffect(() => {
@@ -57,26 +59,54 @@ function LabelModal({ isOpen, onClose, label }) {
}
// Mutation for saving labels
const saveLabelMutation = useMutation(
newLabel =>
label
? UpdateLabel({ id: label.id, ...newLabel })
: CreateLabel(newLabel),
{
onSuccess: () => {
queryClient.invalidateQueries('labels')
onClose()
},
onError: () => {
setError('Failed to save label. Please try again.')
},
},
)
// const saveLabelMutation = useMutation(
// newLabel =>
// label
// ? UpdateLabel({ id: label.id, ...newLabel })
// : CreateLabel(newLabel),
// {
// onSuccess: () => {
// queryClient.invalidateQueries('labels')
// onClose()
// },
// onError: () => {
// setError('Failed to save label. Please try again.')
// },
// },
// )
const handleSave = () => {
if (!validateLabel()) return
saveLabelMutation.mutate({ name: labelName, color })
const saveLabel = label?.id && label.id !== -1 ? UpdateLabel : CreateLabel
// ? { id: label.id, name: labelName, color }
// : { name: labelName, color }
// saveLabelMutation.mutate({ name: labelName, color })
saveLabel({
id: label?.id,
name: labelName,
color,
})
.then(res => {
if (res?.error) {
setError(res.error)
} else {
queryClient.invalidateQueries('labels')
onClose()
}
})
.catch(err => {
if (err.queued) {
showError({
title: 'Failed to save label',
message: 'Unable to save label. Please try again.',
})
} else {
showError({
title: 'Failed to save label',
message: 'Unable to save label. Please try again.',
})
}
})
}
return (
@@ -109,7 +139,7 @@ function LabelModal({ isOpen, onClose, label }) {
<Typography
startDecorator={
<Box
className='h-4 w-4'
className='size-4'
borderRadius={10}
sx={{ background: selected.value }}
/>

View File

@@ -14,6 +14,7 @@ import {
} from '@mui/joy'
import moment from 'moment'
import { useContext, useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { UserContext } from '../../contexts/UserContext'
import Logo from '../../Logo'
import {
@@ -498,6 +499,46 @@ const Settings = () => {
</Typography>
<ThemeToggle />
</div>
<div className='grid gap-4 py-4'>
<Typography level='h3'>Experimental Features </Typography>
<Divider />
<Typography level='body-md'>
Clean up some part of the local storage and cache. Only use if you
know are you doing.
</Typography>
<Button
variant='soft'
color='danger'
onClick={() => {
const confirmed = confirm(
`Are you sure you want to clear your local storage and cache? This will remove all your data. on device and require login`,
)
if (confirmed) {
localStorage.clear()
Navigate('/login')
}
}}
>
Clear Local Storage and Cache
</Button>
<Button
variant='outlined'
color='danger'
onClick={() => {
const confirmed = confirm(
`Are you sure you want to clear your local storage and cache? This will remove all your data.`,
)
if (confirmed) {
localStorage.removeItem('offline_cache')
localStorage.removeItem('offline_request_queue')
localStorage.removeItem('offlineTasks')
}
}}
>
Clear Offline Cache and Offline tasks
</Button>
</div>
</Container>
)
}

View File

@@ -0,0 +1,88 @@
// AutocompleteDropdown.jsx
import { Divider, Menu, MenuItem } from '@mui/joy'
import React, { useEffect } from 'react'
const AutocompleteDropdown = ({
currentValue,
suggestions,
selectedIndex,
onSelectSuggestion,
onMouseEnterSuggestion, // Added for hover selection
parentRefer, // Ref to the dropdown element
}) => {
// Scroll selected item into view
const dropdownMenuRef = React.useRef(null)
useEffect(() => {
const selectedElement = dropdownMenuRef.current?.querySelector('.selected')
if (selectedElement) {
selectedElement.scrollIntoView({
block: 'nearest',
inline: 'nearest',
})
}
}, [selectedIndex, dropdownMenuRef])
if (!suggestions || suggestions.options === 0) {
return null // Don't render if no suggestions
}
return (
<Menu
open={Boolean(suggestions?.options?.length)}
ref={dropdownMenuRef}
anchorEl={parentRefer.current}
onClose={() => {}}
sx={{
width: '300px',
maxHeight: '160px',
overflow: 'auto',
position: 'relative',
bottom: 0,
left: 0,
zIndex: 1300,
}}
>
{suggestions?.options
.filter(option => {
if (typeof option === 'string') {
return option.toLowerCase().includes(currentValue.toLowerCase())
}
return option[suggestions.display]
.toLowerCase()
.includes(currentValue.toLowerCase())
})
.map((option, index) => (
<MenuItem
key={suggestions.display ? option[suggestions.value] : option}
selected={selectedIndex === index}
onClick={() => onSelectSuggestion(option)}
onMouseEnter={() => onMouseEnterSuggestion(index)} // Update selected index on hover
className={selectedIndex === index ? 'selected' : ''} // Add class for selected item
sx={{
cursor: 'pointer',
backgroundColor: selectedIndex === index ? 'gray.800' : 'inherit',
}}
>
{suggestions.display ? option[suggestions.display] : option}
</MenuItem>
))}
<Divider orientation='horizontal' />
{/*
<MenuItem
selected={selectedIndex === suggestions?.options?.length}
onClick={() => {
onSelectSuggestion(currentValue)
}}
sx={{
cursor: 'pointer',
backgroundColor: 'gray.800',
}}
>
<Add />
Add new Label
</MenuItem> */}
</Menu>
)
}
export default AutocompleteDropdown

View File

@@ -1,152 +0,0 @@
.task-editor-container {
background-color: #2d2d2d;
color: #ffffff;
padding: 15px;
border-radius: 8px;
max-width: 500px;
margin: 20px auto;
}
.smart-task-input {
width: 100%;
padding: 10px;
margin-bottom: 10px;
border: none;
background-color: transparent;
color: #ffffff;
font-size: 1.2em;
outline: none;
}
.task-description-input {
width: 100%;
padding: 10px;
margin-bottom: 10px;
border: none;
background-color: transparent;
color: #ffffff;
font-size: 1em;
min-height: 80px;
resize: vertical;
outline: none;
}
.task-attributes {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 15px;
}
.attribute-tag {
background-color: #444;
padding: 5px 10px;
border-radius: 15px;
font-size: 0.9em;
display: flex;
align-items: center;
cursor: pointer;
}
.remove-tag {
margin-left: 5px;
cursor: pointer;
font-weight: bold;
}
.task-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.project-select select {
padding: 5px;
border-radius: 4px;
background-color: #444;
color: #ffffff;
border: none;
cursor: pointer;
}
.action-buttons button {
padding: 8px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
margin-left: 10px;
}
.cancel-button {
background-color: #666;
color: #ffffff;
}
.add-task-button {
background-color: #d9534f;
color: #ffffff;
}
.smart-task-input {
width: 100%;
position: absolute;
z-index: 1;
/* color: transparent;
caret-color: white;
padding: 0px;
margin: 0;
border: none;
outline: none;
box-sizing: border-box;
}
.smart-task-display {
position: absolute;
width: 100%;
/* top: 0;
left: 0; */
z-index: 10;
pointer-events: none;
padding: 0px;
margin: 0;
border: none;
overflow-y: auto;
word-wrap: break-word;
white-space: pre-wrap;
box-sizing: border-box;
}
.smart-task-common {
font-size: 1.2em;
line-height: 1.2em;
font-family: inherit;
}
.highlight-date {
color: #f08080;
background-color: #ffffff;
}
.highlight-label {
color: #add8e6;
}
.highlight-priority {
color: #ffb6c1;
}
.task-input {
position: relative;
width: 100%;
background-color: #2d2d2d;
color: #ffffff;
border-radius: 8px;
box-sizing: border-box;
}

View File

@@ -1,227 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import './MentionTextfield.css' // We'll assume a CSS file for basic styling
// Helper function to render text with highlighting
const MentionTextfield = ({ initialTask = {}, onCancel, onAddTask }) => {
// State to hold the task details
const [taskTitle, setTaskTitle] = useState(initialTask.title || '')
const [taskDescription, setTaskDescription] = useState(
initialTask.description || '',
)
const [selectedDate, setSelectedDate] = useState(initialTask.date || null)
const [selectedPriority, setSelectedPriority] = useState(
initialTask.priority || null,
)
const [selectedLabels, setSelectedLabels] = useState(initialTask.labels || [])
const [reminders, setReminders] = useState(initialTask.reminders || [])
const [selectedProject, setSelectedProject] = useState(
initialTask.project || 'Inbox',
)
const [cursorPosition, setCursorPosition] = useState(0) // State to track cursor position
const [showCursor, setShowCursor] = useState(false) // State to toggle cursor visibility
// Ref for the input field (if needed for more advanced handling)
const titleInputRef = useRef(null)
// Effect to update bottom tags based on title parsing
useEffect(() => {
// Basic parsing logic
const lowerCaseTitle = taskTitle.toLowerCase()
const extractedDate = lowerCaseTitle.includes('tomorrow')
? 'Tomorrow'
: null
const labelMatches = taskTitle.match(/@(\w+)/g)
const extractedLabels = labelMatches
? labelMatches.map(match => match.substring(1))
: []
const priorityMatch = taskTitle.match(/P(\d)/)
const extractedPriority = priorityMatch ? `P${priorityMatch[1]}` : null
setSelectedDate(extractedDate)
setSelectedLabels(extractedLabels)
setSelectedPriority(extractedPriority)
}, [taskTitle]) // Re-run effect when taskTitle changes
// Handlers for removing attributes (same as before)
const removeDate = () => setSelectedDate(null) // Note: This won't remove from the input field text
const removePriority = () => setSelectedPriority(null) // Note: This won't remove from the input field text
const removeLabel = labelToRemove => {
// Note: This won't remove from the input field text
setSelectedLabels(selectedLabels.filter(label => label !== labelToRemove))
}
const removeReminder = reminderToRemove => {
setReminders(reminders.filter(reminder => reminder !== reminderToRemove))
}
const handleAddTask = () => {
const newTask = {
title: taskTitle,
description: taskDescription,
date: selectedDate, // These come from the parsing effect
priority: selectedPriority, // These come from the parsing effect
labels: selectedLabels, // These come from the parsing effect
reminders: reminders,
project: selectedProject,
}
onAddTask(newTask) // Call the provided add task function
}
const renderHighlightedText = (text, position) => {
const parts = []
let lastIndex = 0
// Regex to find patterns: Tomorrow, @label, P followed by a digit
const regex = /(Tomorrow)|(@\w+)|(P\d)/gi
let match
while ((match = regex.exec(text)) !== null) {
const matchedText = match[0]
const matchIndex = match.index
// Add text before the match
if (matchIndex > lastIndex) {
parts.push(text.substring(lastIndex, matchIndex))
}
// Add the highlighted part
let className = ''
if (matchedText.toLowerCase() === 'tomorrow') {
className = 'highlight-date'
} else if (matchedText.startsWith('@')) {
className = 'highlight-label'
} else if (matchedText.startsWith('P')) {
className = 'highlight-priority'
}
parts.push(
<span key={matchIndex} className={className}>
{matchedText}
</span>,
)
lastIndex = regex.lastIndex
}
// Add any remaining text after the last match
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex))
}
return <div>{parts}</div>
}
return (
<div className='task-editor-container'>
<div className='task-input h-20 overflow-auto rounded border'>
<textarea
type='text'
className='smart-task-input smart-task-common'
rows={100}
placeholder='Tomorrow do some task with @test_label and P3'
value={taskTitle}
onChange={e => setTaskTitle(e.target.value)}
// onKeyUp={e => {
// setCursorPosition(e.target.selectionStart)
// }}
// onKeyDown={e => {
// setCursorPosition(e.target.selectionStart)
// }}
onBlur={() => setShowCursor(false)}
onFocus={() => setShowCursor(true)}
ref={titleInputRef}
// style={{ opacity: 0, position: 'absolute', zIndex: -1 }}
/>
{/* Display Area with Highlighting */}
<div
className='smart-task-display smart-task-common'
onClick={e => {
const range = document.caretRangeFromPoint(e.clientX, e.clientY)
if (range) {
setCursorPosition(range.startOffset)
const offset = range.startOffset
titleInputRef.current.focus()
titleInputRef.current.setSelectionRange(offset, offset)
}
}}
>
{renderHighlightedText(taskTitle, cursorPosition)}
</div>
</div>
{/* Description Area */}
<textarea
className='task-description-input'
placeholder='Description'
value={taskDescription}
onChange={e => setTaskDescription(e.target.value)}
/>
{/* Attributes/Tags Display (same as before, updated by useEffect) */}
<div className='task-attributes'>
{selectedDate && (
<div className='attribute-tag'>
🗓 {selectedDate}{' '}
<span className='remove-tag' onClick={removeDate}>
×
</span>
</div>
)}
{selectedPriority && (
<div className='attribute-tag'>
🚩 {selectedPriority}{' '}
<span className='remove-tag' onClick={removePriority}>
×
</span>
</div>
)}
{reminders.map((reminder, index) => (
<div key={index} className='attribute-tag'>
{reminder}{' '}
<span
className='remove-tag'
onClick={() => removeReminder(reminder)}
>
×
</span>
</div>
))}
{selectedLabels.map((label, index) => (
<div key={index} className='attribute-tag'>
🏷 {label}{' '}
<span className='remove-tag' onClick={() => removeLabel(label)}>
×
</span>
</div>
))}
{/* More options button - static for this example */}
<div className='attribute-tag'>...</div>
</div>
{/* Project Selection and Action Buttons (same as before) */}
<div className='task-actions'>
<div className='project-select'>
{/* In a real app, this would be a more robust dropdown */}
<select
value={selectedProject}
onChange={e => setSelectedProject(e.target.value)}
>
<option value='Inbox'>📥 Inbox</option>
{/* Add other project options here */}
</select>
</div>
<div className='action-buttons'>
<button className='cancel-button' onClick={onCancel}>
Cancel
</button>
<button className='add-task-button' onClick={handleAddTask}>
Add task
</button>
</div>
</div>
</div>
)
}
export default MentionTextfield

View File

@@ -0,0 +1,42 @@
.smart-task-display {
position: absolute;
width: 100%;
z-index: 10;
pointer-events: none;
padding: 0px;
margin: 0;
border: none;
overflow-y: auto;
word-wrap: break-word;
white-space: pre-wrap;
box-sizing: border-box;
}
.smart-task-common {
font-size: 1.2em;
line-height: 1.2em;
font-family: inherit;
caret-color: #f08080;
}
.highlight-date {
color: #f08080;
}
.highlight-repeat {
color: #90ee90;
}
.highlight-label {
color: #add8e6;
}
.highlight-priority {
color: #ffb6c1;
}
.task-input {
position: relative;
width: 100%;
border-radius: 8px;
box-sizing: border-box;
}

View File

@@ -0,0 +1,273 @@
import { useColorScheme } from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import AutocompleteDropdown from './AutocompleteDropdown'
import './SmartTaskTitleInput.css'
const renderHighlightedText = (text, cursorPosition) => {
const parts = []
let lastIndex = 0
const regex = /(Tomorrow)|(#\w+)|(P\d)/gi
let match
while ((match = regex.exec(text)) !== null) {
const matchedText = match[0]
const matchIndex = match.index
if (matchIndex > lastIndex) {
parts.push(text.substring(lastIndex, matchIndex))
}
let className = ''
if (matchedText.toLowerCase() === 'tomorrow') {
className = 'highlight-date'
} else if (matchedText.startsWith('#')) {
className = 'highlight-label'
} else if (matchedText.startsWith('P')) {
className = 'highlight-priority'
}
parts.push(
<span key={matchIndex} className={className}>
{matchedText}
</span>,
)
lastIndex = regex.lastIndex
}
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex))
}
return parts
}
const SmartTaskTitleInput = ({
value,
placeholder,
autoFocus,
onChange,
suggestions,
onEnterPressed,
customRenderer,
}) => {
const { mode, setMode } = useColorScheme()
const titleInputRef = useRef(null)
const [cursorPosition, setCursorPosition] = useState(value?.length)
const dropdownRef = useRef(null)
const [lastWord, setLastWord] = useState('')
const [suggestionTrigger, setSuggestionTrigger] = useState('P')
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(0)
useEffect(() => {
setCursorPosition(prevPos => Math.min(prevPos, value.length))
if (
titleInputRef.current &&
document.activeElement === titleInputRef.current
) {
requestAnimationFrame(() => {
titleInputRef.current.setSelectionRange(cursorPosition, cursorPosition)
})
}
}, [value])
useEffect(() => {
// set focus on the input when the component is mounted:
if (titleInputRef.current) {
titleInputRef.current.focus()
titleInputRef.current.setSelectionRange(cursorPosition, cursorPosition)
}
}, [])
const handleSuggestionChange = text => {
// if the last word start with '@' or '#' or 'P':
const lastWord = text.split(' ').pop()
if (
lastWord.startsWith('@') ||
lastWord.startsWith('#') ||
lastWord.startsWith('P')
) {
setSuggestionTrigger(lastWord[0])
// last word without the first character:
setLastWord(lastWord.slice(1))
setShowSuggestions(true)
} else {
setShowSuggestions(false)
}
}
const handleTextareaChange = e => {
handleSuggestionChange(e.target.value)
onChange(e.target.value)
setCursorPosition(e.target.selectionStart)
}
const handleTextareaKeyDown = e => {
if (showSuggestions) {
const currentSuggestions = suggestions[suggestionTrigger].options.filter(
option => {
if (typeof option === 'string') {
return option.toLowerCase().includes(lastWord.toLowerCase())
}
return option[suggestions[suggestionTrigger].display]
.toLowerCase()
.includes(lastWord.toLowerCase())
},
)
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
const newIndex =
e.key === 'ArrowDown'
? (selectedSuggestionIndex + 1) % currentSuggestions.length
: (selectedSuggestionIndex - 1 + currentSuggestions.length) %
currentSuggestions.length
setSelectedSuggestionIndex(newIndex)
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
const selectedSuggestion = currentSuggestions[selectedSuggestionIndex]
const suggestionValue = suggestions[suggestionTrigger].display
? selectedSuggestion[suggestions[suggestionTrigger].display]
: selectedSuggestion
if (suggestionValue) {
const newValue = `${value.slice(0, cursorPosition - lastWord.length)}${suggestionValue} ${value.slice(cursorPosition)}`
onChange(newValue)
titleInputRef.current.value = newValue
setShowSuggestions(false)
const newCursorPosition = cursorPosition + suggestionValue.length + 1
titleInputRef.current.setSelectionRange(
newCursorPosition,
newCursorPosition,
)
}
} else if (e.key === 'Escape') {
e.preventDefault()
setShowSuggestions(false)
}
} else {
if (e.key === 'Enter') {
e.preventDefault()
if (onEnterPressed) {
onEnterPressed(value)
}
}
}
const currentPos = e.target.selectionStart
setCursorPosition(currentPos)
}
const handleTextareaClick = e => {
const currentPos = e.target.selectionStart
setCursorPosition(currentPos)
}
const handleDisplayClick = e => {
const range = document.caretRangeFromPoint(e.clientX, e.clientY)
if (range) {
const offset = range.startOffset
setCursorPosition(offset)
if (titleInputRef.current) {
titleInputRef.current.focus()
titleInputRef.current.setSelectionRange(offset, offset)
}
}
}
return (
<div>
<div className='task-input h-20 overflow-auto rounded border'>
<textarea
ref={titleInputRef}
autoFocus={autoFocus}
rows={1}
value={value}
onChange={handleTextareaChange}
onKeyDown={handleTextareaKeyDown}
onClick={handleTextareaClick}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
// opacity: 100,
zIndex: 1,
resize: 'none',
overflow: 'hidden',
padding: '0.5rem',
boxSizing: 'border-box',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
backgroundColor: 'transparent',
color: mode === 'dark' ? '#cbd5e1' : '#1a202c',
caretColor: mode === 'dark' ? '#fff' : '#000',
}}
/>
<div
className='smart-task-display smart-task-common'
ref={dropdownRef}
style={{
position: 'relative',
zIndex: 1,
minHeight: '100%',
padding: '0.5rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
}}
onClick={handleDisplayClick}
>
{placeholder && !value && (
<span className='pointer-events-none text-gray-400'>
{placeholder}
</span>
)}
{customRenderer
? customRenderer
: renderHighlightedText(value, cursorPosition)}
</div>
</div>
{showSuggestions && (
<AutocompleteDropdown
currentValue={lastWord}
suggestions={suggestions[suggestionTrigger]}
selectedIndex={selectedSuggestionIndex}
onMouseEnterSuggestion={index => {
setSelectedSuggestionIndex(index)
}}
onSelectSuggestion={suggestion => {
const suggestionValue = suggestions[suggestionTrigger].display
? suggestion[suggestions[suggestionTrigger].display]
: suggestion
const newValue = `${value.slice(0, cursorPosition)}${suggestionValue}${value.slice(cursorPosition)}`
onChange(newValue)
titleInputRef?.current?.focus()
setCursorPosition(cursorPosition + suggestion.length)
titleInputRef.current.value = newValue
titleInputRef.current.setSelectionRange(
cursorPosition + suggestionValue.length,
cursorPosition + suggestionValue.length,
)
setShowSuggestions(false)
}}
parentRefer={dropdownRef}
/>
)}
</div>
)
}
export default SmartTaskTitleInput

View File

@@ -20,6 +20,7 @@ import {
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useError } from '../../service/ErrorProvider'
import {
CreateThing,
DeleteThing,
@@ -172,6 +173,7 @@ const ThingsView = () => {
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
const [snackbarMessage, setSnackbarMessage] = useState('')
const [snackbarColor, setSnackbarColor] = useState('success')
const { showError } = useError()
useEffect(() => {
// fetch things
@@ -187,25 +189,39 @@ const ThingsView = () => {
if (thing?.id) {
saveFunc = SaveThing
}
saveFunc(thing).then(result => {
result.json().then(data => {
if (thing?.id) {
const currentThings = [...things]
const thingIndex = currentThings.findIndex(
currentThing => currentThing.id === thing.id,
)
currentThings[thingIndex] = data.res
setThings(currentThings)
saveFunc(thing)
.then(result => {
result.json().then(data => {
if (thing?.id) {
const currentThings = [...things]
const thingIndex = currentThings.findIndex(
currentThing => currentThing.id === thing.id,
)
currentThings[thingIndex] = data.res
setThings(currentThings)
} else {
const currentThings = [...things]
currentThings.push(data.res)
setThings(currentThings)
}
setSnackbarMessage('Thing saved successfully')
setSnackbarColor('success')
setIsSnackbarOpen(true)
})
})
.catch(error => {
if (error?.queued) {
showError({
title: 'Unable to save thing',
message: 'You are offline and the request has been queued',
})
} else {
const currentThings = [...things]
currentThings.push(data.res)
setThings(currentThings)
showError({
title: 'Unable to save thing',
message: 'An error occurred while saving the thing',
})
}
})
})
setSnackbarMessage('Thing saved successfully')
setSnackbarColor('success')
setIsSnackbarOpen(true)
}
const handleEditClick = thing => {
setIsShowEditStateModal(true)
@@ -220,21 +236,37 @@ const ThingsView = () => {
message: 'Are you sure you want to delete this Thing?',
onClose: isConfirmed => {
if (isConfirmed === true) {
DeleteThing(thing.id).then(response => {
if (response.ok) {
const currentThings = [...things]
const thingIndex = currentThings.findIndex(
currentThing => currentThing.id === thing.id,
)
currentThings.splice(thingIndex, 1)
setThings(currentThings)
} else if (response.status === 405) {
setSnackbarMessage('Unable to delete thing with associated tasks')
setSnackbarColor('danger')
setIsSnackbarOpen(true)
}
// if method not allwo show snackbar:
})
DeleteThing(thing.id)
.then(response => {
if (response.ok) {
const currentThings = [...things]
const thingIndex = currentThings.findIndex(
currentThing => currentThing.id === thing.id,
)
currentThings.splice(thingIndex, 1)
setThings(currentThings)
} else if (response.status === 405) {
setSnackbarMessage(
'Unable to delete thing with associated tasks',
)
setSnackbarColor('danger')
setIsSnackbarOpen(true)
}
// if method not allwo show snackbar:
})
.catch(error => {
if (error?.queued) {
showError({
title: 'Unable to delete thing',
message: 'You are offline and the request has been queued',
})
} else {
showError({
title: 'Unable to delete thing',
message: 'An error occurred while deleting the thing',
})
}
})
}
setConfirmModelConfig({})
},
@@ -252,19 +284,32 @@ const ThingsView = () => {
}
}
UpdateThingState(thing).then(result => {
result.json().then(data => {
const currentThings = [...things]
const thingIndex = currentThings.findIndex(
currentThing => currentThing.id === thing.id,
)
currentThings[thingIndex] = data.res
setThings(currentThings)
UpdateThingState(thing)
.then(result => {
result.json().then(data => {
const currentThings = [...things]
const thingIndex = currentThings.findIndex(
currentThing => currentThing.id === thing.id,
)
currentThings[thingIndex] = data.res
setThings(currentThings)
setSnackbarMessage('Thing state updated successfully')
setIsSnackbarOpen(true)
})
})
.catch(error => {
if (error?.queued) {
showError({
title: 'Unable to update thing state',
message: 'You are offline and the request has been queued',
})
} else {
showError({
title: 'Unable to update thing state',
message: 'An error occurred while updating the thing state',
})
}
})
})
setSnackbarMessage('Thing state updated successfully')
setIsSnackbarOpen(true)
}
return (

View File

@@ -3,7 +3,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import CircleIcon from '@mui/icons-material/Circle'
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts'
import { EventBusy, Style, Toll } from '@mui/icons-material'
import { EventBusy, Toll } from '@mui/icons-material'
import {
Avatar,
Box,
@@ -28,7 +28,6 @@ import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
import { useCircleMembers } from '../../queries/UserQueries.jsx'
import { ChoresGrouper } from '../../utils/Chores'
import { TASK_COLOR } from '../../utils/Colors.jsx'
import IconButtonWithMenu from '../Chores/IconButtonWithMenu'
import LoadingComponent from '../components/Loading'
const groupByDate = history => {
@@ -204,7 +203,7 @@ const UserActivites = () => {
useEffect(() => {
if (!isChoresHistoryLoading && !isChoresLoading && choresHistory) {
const enrichedHistory = choresHistory.res.map(item => {
const enrichedHistory = choresHistory.map(item => {
const chore = choresData.res.find(chore => chore.id === item.choreId)
return {
...item,
@@ -235,7 +234,7 @@ const UserActivites = () => {
var assignedToMe = 0
var assignedToOthers = 0
chores.forEach(chore => {
if (chore.assignedTo === userProfile.id) {
if (chore.assignedTo === userProfile?.id) {
assignedToMe++
} else assignedToOthers++
})
@@ -342,7 +341,7 @@ const UserActivites = () => {
},
}
if (!choresData.res?.length > 0 || !choresHistory?.res?.length > 0) {
if (!choresData.res?.length > 0 || !choresHistory?.length > 0) {
return (
<Container
maxWidth='md'
@@ -386,7 +385,7 @@ const UserActivites = () => {
justifyContent: 'center',
}}
>
<Box>
<Box mb={1}>
<Typography mb={2} level='h4'>
Points Overview
</Typography>
@@ -487,21 +486,6 @@ const UserActivites = () => {
))}
</TabList>
</Tabs>
<Box sx={{ ml: 2 }}>
<IconButtonWithMenu
k={'icon-menu-labels-filter'}
// label={' Labels'}
icon={<Style />}
options={[{ name: 'Anyone' }, { name: 'Me' }]}
selectedItem={'Anyone'}
onItemSelect={selected => {
console.log(selected)
}}
// isActive={searchFilter.startsWith('Label: ')}
// mouseClickHandler={handleMenuOutsideClick}
// useChips
/>
</Box>
</Box>
<Box sx={{ mb: 4 }}>
<Typography level='h4' textAlign='center'>

View File

@@ -65,13 +65,13 @@ const UserPoints = () => {
if (choresHistoryData) {
var history
if (tabValue === 7) {
history = generateWeeklySummary(choresHistoryData.res, selectedUser)
history = generateWeeklySummary(choresHistoryData, selectedUser)
} else if (tabValue === 30) {
history = generateMonthSummary(choresHistoryData.res, selectedUser)
history = generateMonthSummary(choresHistoryData, selectedUser)
} else if (tabValue === 6 * 30) {
history = generateMonthlySummary(choresHistoryData.res, selectedUser)
history = generateMonthlySummary(choresHistoryData, selectedUser)
} else {
history = generateYearlySummary(choresHistoryData.res, selectedUser)
history = generateYearlySummary(choresHistoryData, selectedUser)
}
setSelectedHistory(history)
}
@@ -394,7 +394,7 @@ const UserPoints = () => {
{ label: '7 Days', value: 7 },
// { label: '3 Month', value: 30 },
{ label: '6 Months', value: 6 * 30 },
{ label: 'All Time', value: 90 },
{ label: 'All Time', value: 24 * 30 },
].map((tab, index) => (
<Tab
key={index}

View File

@@ -1,9 +1,8 @@
import { KeyboardReturnOutlined } from '@mui/icons-material'
import { Add } from '@mui/icons-material'
import {
Box,
Button,
Chip,
IconButton,
Input,
Modal,
ModalDialog,
@@ -18,63 +17,20 @@ 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 { useCreateChore } from '../../queries/ChoreQueries'
import { useAllUsers } from '../../queries/UserQueries'
import useDebounce from '../../utils/Debounce'
import { isPlusAccount } from '../../utils/Helpers'
import { useLabels } from '../Labels/LabelQueries'
import SmartTaskTitleInput from '../TestView/SmartTaskTitleInput'
import { parseLabels, parsePriority, parseRepeatV2 } from './CustomParsers'
import LearnMoreButton from './LearnMore'
import SubTasks from './SubTask'
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 TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
const { data: allUsers, isLoading: isAllUserLoading } = useAllUsers()
const createChoreMutation = useCreateChore()
const { userProfile } = useContext(UserContext)
@@ -82,24 +38,29 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
const [taskText, setTaskText] = useState('')
const debounceParsing = useDebounce(taskText, 30)
const [taskTitle, setTaskTitle] = useState('')
const [openModal, setOpenModal] = useState(false)
const [renderedParts, setRenderedParts] = useState([])
const textareaRef = useRef(null)
const mainInputRef = useRef(null)
const [priority, setPriority] = useState(0)
const [dueDate, setDueDate] = useState(null)
const [description, setDescription] = useState(null)
const [assignedTo, setAssignedTo] = useState(userProfile?.id)
const [assignees, setAssignees] = useState([])
const [labelsV2, setLabelsV2] = useState([])
const [frequency, setFrequency] = useState(null)
const [frequencyHumanReadable, setFrequencyHumanReadable] = useState(null)
const [subTasks, setSubTasks] = useState([])
const [subTasks, setSubTasks] = useState(null)
const [hasDescription, setHasDescription] = useState(false)
const [hasSubTasks, setHasSubTasks] = useState(false)
useEffect(() => {
if (openModal && textareaRef.current) {
if (isModalOpen && textareaRef.current) {
textareaRef.current.focus()
textareaRef.current.selectionStart = textareaRef.current.value?.length
textareaRef.current.selectionEnd = textareaRef.current.value?.length
}
}, [openModal])
}, [isModalOpen])
useEffect(() => {
if (autoFocus > 0 && mainInputRef.current) {
@@ -110,21 +71,19 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
}, [autoFocus])
useEffect(() => {
if (debounceParsing) {
processText(debounceParsing)
if (!isModalOpen || userLabelsLoading || isAllUserLoading) {
return
}
}, [debounceParsing])
const handleEnterPressed = e => {
if (e.key === 'Enter') {
createChore()
handleCloseModal()
setTaskText('')
}
processText(taskText)
}, [taskText, userLabelsLoading, isAllUserLoading])
const handleEnterPressed = () => {
createChore()
}
const handleCloseModal = () => {
setOpenModal(false)
const handleCloseModal = forceRefetch => {
onClose(forceRefetch)
setTaskText('')
setTaskTitle('')
setDueDate(null)
@@ -133,7 +92,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
setPriority(0)
setHasDescription(false)
setDescription(null)
setSubTasks([])
setSubTasks(null)
setHasSubTasks(false)
setLabelsV2([])
}
const handleSubmit = () => {
@@ -142,245 +103,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
setTaskText('')
}
const parsePriority = inputSentence => {
let sentence = inputSentence.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, ''),
inputSentence,
),
}
}
}
return { result: 0, cleanedSentence: inputSentence }
}
const parseLabels = inputSentence => {
let sentence = inputSentence.toLowerCase()
const currentLabels = []
// label will always be prefixed #:
for (const label of userLabels) {
if (sentence.includes(`#${label.name.toLowerCase()}`)) {
currentLabels.push(label)
sentence = sentence.replace(`#${label.name.toLowerCase()}`, '')
}
}
if (currentLabels.length > 0) {
return {
result: currentLabels,
cleanedSentence: sentence,
}
}
return { result: null, cleanedSentence: sentence }
}
const parseAssignee = inputSentence => {
let sentence = inputSentence.toLowerCase()
const assigneeMap = {}
}
const parseRepeatV2 = inputSentence => {
const sentence = inputSentence.toLowerCase()
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|everyday)$/i,
name: 'Every day',
},
{
frequencyType: 'daily:time',
regex: /every (morning|noon|afternoon|evening|night)$/i,
name: 'Every {time} daily',
},
{
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}',
},
{
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: inputSentence.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: inputSentence.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: inputSentence }
return {
result,
name: pattern.name.replace(
'{days}',
result.frequencyMetadata.days.join(', '),
),
cleanedSentence: inputSentence.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: inputSentence.replace(match[0], '').trim(),
}
case 'interval:2week':
result.frequency = 2
result.frequencyMetadata.unit = 'weeks'
result.frequencyType = 'interval'
return {
result,
name: pattern.name,
cleanedSentence: inputSentence.replace(match[0], '').trim(),
}
case 'daily:time':
result.frequency = 1
result.frequencyMetadata.unit = 'days'
result.frequencyType = 'daily'
return {
result,
name: pattern.name.replace('{time}', match[1]),
// replace every x with ''
cleanedSentence: inputSentence.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: inputSentence.replace(match[0], '').trim(),
}
case 'interval:every_other':
result.frequency = 2
result.frequencyMetadata.unit = match[1]
result.frequencyType = 'interval'
return {
result,
name: pattern.name.replace('{unit}', result.frequencyMetadata.unit),
cleanedSentence: inputSentence.replace(match[0], '').trim(),
}
}
}
return { result: null, name: null, cleanedSentence: inputSentence }
}
const handleTextChange = e => {
if (!e.target.value) {
setTaskText('')
@@ -394,18 +116,27 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
}
const processText = sentence => {
let cleanedSentence = sentence
const priority = parsePriority(cleanedSentence)
const priority = parsePriority(sentence)
if (priority.result) setPriority(priority.result)
cleanedSentence = priority.cleanedSentence
const labels = parseLabels(sentence, userLabels)
if (labels.result) {
cleanedSentence = labels.cleanedSentence
setLabelsV2(labels.result)
}
const repeat = parseRepeatV2(cleanedSentence)
const repeat = parseRepeatV2(sentence)
if (repeat.result) {
setFrequency(repeat.result)
setFrequencyHumanReadable(repeat.name)
cleanedSentence = repeat.cleanedSentence
}
const parsedDueDate = chrono.parse(cleanedSentence, new Date(), {
// const assignees = parseAssignees(sentence)
// if (assignees.result) {
// cleanedSentence = assignees.cleanedSentence
// set
// }
const parsedDueDate = chrono.parse(sentence, new Date(), {
forwardDate: true,
})
if (parsedDueDate[0]?.index > -1) {
@@ -428,14 +159,106 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
}
}
if (priority.result || parsedDueDate[0]?.index > -1 || repeat.result) {
setOpenModal(true)
}
setTaskText(sentence)
setTaskTitle(cleanedSentence.trim())
}
const rendered = renderText(
sentence,
repeat.highlight,
priority.highlight,
labels.highlight,
parsedDueDate && parsedDueDate[0]
? {
start: parsedDueDate[0].index,
end: parsedDueDate[0].index + parsedDueDate[0].text.length,
text: parsedDueDate[0].text,
}
: null,
)
setRenderedParts(rendered)
}
const renderText = (
sentence,
repeatHighlight,
priorityHighlight,
labelsHighlight,
dueDateHighlight,
) => {
const parts = []
let lastIndex = 0
// Combine all highlight ranges and sort them by their start index
const allHighlights = []
if (repeatHighlight) {
repeatHighlight.forEach(h => allHighlights.push({ ...h, type: 'repeat' }))
}
if (priorityHighlight) {
priorityHighlight.forEach(h =>
allHighlights.push({ ...h, type: 'priority' }),
)
}
if (labelsHighlight) {
labelsHighlight.forEach(h => allHighlights.push({ ...h, type: 'label' }))
}
if (dueDateHighlight) {
allHighlights.push({ ...dueDateHighlight, type: 'dueDate' })
}
allHighlights.sort((a, b) => a.start - b.start)
for (const highlight of allHighlights) {
// Add the text before the highlight
if (highlight.start > lastIndex) {
parts.push(sentence.substring(lastIndex, highlight.start))
}
// Determine the class name based on the highlight type
let className = ''
switch (highlight.type) {
case 'repeat':
className = 'highlight-repeat'
break
case 'priority':
className = 'highlight-priority'
break
case 'label':
className = 'highlight-label'
break
case 'dueDate':
className = 'highlight-date'
break
default:
break
}
// Add the highlighted span
parts.push(
<span
key={highlight.start}
className={className}
style={{
// text underline:
textDecoration: 'underline',
// textDecorationColor: 'red',
textDecorationThickness: '2px',
textDecorationStyle: 'dashed',
}}
>
{sentence.substring(highlight.start, highlight.end)}
</span>,
)
// Update the last index to the end of the current highlight
lastIndex = highlight.end
}
// Add any remaining text after the last highlight
if (lastIndex < sentence.length) {
parts.push(sentence.substring(lastIndex))
}
return parts
}
const createChore = () => {
const chore = {
name: taskTitle,
@@ -446,13 +269,13 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
isRolling: false,
notification: false,
description: description || null,
labelsV2: [],
labelsV2: labelsV2,
priority: priority ? Number(priority) : 0,
status: 0,
frequencyType: 'once',
frequencyMetadata: {},
notificationMetadata: {},
subTasks: subTasks.length > 0 ? subTasks : null,
subTasks: subTasks?.length > 0 ? subTasks : null,
}
if (frequency) {
@@ -469,78 +292,51 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
chore.nextDueDate = new Date(dueDate).toUTCString()
}
createChoreMutation.mutateAsync(chore).then(resp => {
resp.json().then(data => {
if (resp.status !== 200) {
console.error('Error creating chore:', data)
return
} else {
onChoreUpdate({ ...chore, id: data.res, nextDueDate: chore.dueDate })
createChoreMutation
.mutateAsync(chore)
.then(resp => {
resp.json().then(data => {
if (resp.status !== 200) {
console.error('Error creating chore:', data)
return
} else {
onChoreUpdate({
...chore,
id: data.res,
nextDueDate: chore.dueDate,
})
handleCloseModal(false)
}
})
})
.catch(error => {
if (error?.queued) {
handleCloseModal(true)
}
})
})
}
if (userLabelsLoading || isAllUserLoading) {
return <></>
}
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}>
<ModalOverflow>
<ModalDialog>
{/* <Button
size='sm'
onClick={() => navigate(`/chores/create`)}
variant='outlined'
sx={{ position: 'absolute', right: 20 }}
startDecorator={<OpenInFull />}
>
Full Editor
</Button> */}
<Typography level='h4'>Create new task</Typography>
<Chip startDecorator='🚧' variant='soft' color='warning' size='sm'>
Experimental Feature
</Chip>
<Box>
<Modal open={isModalOpen} onClose={handleCloseModal}>
<ModalOverflow>
<ModalDialog size='lg' sx={{ minWidth: '80%' }}>
<Typography level='h4'>Create new task</Typography>
<Chip startDecorator='🚧' variant='soft' color='warning' size='sm'>
Experimental Feature
</Chip>
<Box>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
}}
>
<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' }}
/>
<LearnMoreButton
content={
<>
@@ -585,58 +381,125 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
}
/>
</Box>
<Box>
<SmartTaskTitleInput
autoFocus
value={taskText}
placeholder='Type your full text here...'
onChange={text => {
setTaskText(text)
}}
customRenderer={renderedParts}
onEnterPressed={handleEnterPressed}
suggestions={{
'#': {
value: 'id',
display: 'name',
options: userLabels ? userLabels : [],
},
'!': ['P1', 'P2', 'P3', 'P4'],
'@': {
// value: 'userId',
// display: 'displayName',
// options: allUsers ? allUsers : [],
value: 'id',
display: 'name',
options: [
{ id: userProfile.id, name: userProfile.displayName },
],
},
}}
/>
</Box>
{/* <Box>
<Typography level='body-sm'>Title:</Typography>
<Input
value={taskTitle}
onChange={e => setTaskTitle(e.target.value)}
sx={{ width: '100%', fontSize: '16px' }}
/>
</Box>
{hasDescription ? (
<Box>
<Typography level='body-sm'>Description:</Typography>
<Textarea
minRows={2}
value={description}
onChange={e => setDescription(e.target.value)}
/>
</Box>
) : (
</Box> */}
<Box>
{!hasDescription && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
onClick={() => setHasDescription(true)}
>
Add Description
Description
</Button>
)}
<Typography level='body-sm'>Subtasks:</Typography>
<SubTasks editMode={true} tasks={subTasks} setTasks={setSubTasks} />
{!hasSubTasks && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
onClick={() => setHasSubTasks(true)}
>
Subtasks
</Button>
)}
{!dueDate && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
onClick={() => {
setDueDate(
moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00'),
)
}}
>
Due Date
</Button>
)}
</Box>
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
gap: 2,
}}
>
<FormControl>
<Typography level='body-sm'>Priority</Typography>
<Select
defaultValue={0}
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>
{hasDescription && (
<Box>
<Typography level='body-sm'>Description:</Typography>
<Textarea
minRows={2}
value={description}
onChange={e => setDescription(e.target.value)}
/>
</Box>
)}
{hasSubTasks && (
<Box>
<Typography level='body-sm'>Subtasks:</Typography>
<SubTasks
editMode={true}
tasks={subTasks ? subTasks : []}
setTasks={setSubTasks}
/>
</Box>
)}
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
gap: 2,
}}
>
<FormControl>
<Typography level='body-sm'>Priority</Typography>
<Select
defaultValue={0}
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>
{dueDate && (
<FormControl>
<Typography level='body-sm'>Due Date</Typography>
<Input
@@ -646,55 +509,52 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
sx={{ width: '100%', fontSize: '16px' }}
/>
</FormControl>
</Box>
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
justifyContent: 'start',
gap: 2,
}}
)}
</Box>
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
justifyContent: 'start',
gap: 2,
}}
>
<FormControl>
<Typography level='body-sm'>Assignee</Typography>
<Select value={'0'} disabled>
<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}
>
<FormControl>
<Typography level='body-sm'>Assignee</Typography>
<Select value={'0'} disabled>
<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>
</ModalOverflow>
</Modal>
</>
Cancel
</Button>
<Button variant='solid' color='primary' onClick={handleSubmit}>
Create
</Button>
</Box>
</ModalDialog>
</ModalOverflow>
</Modal>
)
}

View File

@@ -0,0 +1,116 @@
import { Chip, List, ListItem, ListItemButton, Textarea } from '@mui/joy'
import React, { useEffect, useRef, useState } from 'react'
const AutocompleteInput = ({ options, ref, value, onChange, ...props }) => {
const [filteredOptions, setFilteredOptions] = useState([])
const [menuVisible, setMenuVisible] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [triggerKey, setTriggerKey] = useState(null)
// const inputRef = useRef(null)
const menuRef = useRef(null)
useEffect(() => {
if (!triggerKey || !value.includes(triggerKey)) {
setMenuVisible(false)
return
}
const query = value.split(triggerKey).pop().toLowerCase()
const matchedOptions = (options[triggerKey] || []).filter(option =>
option.label.toLowerCase().startsWith(query),
)
setFilteredOptions(matchedOptions)
setMenuVisible(matchedOptions.length > 0)
setHighlightedIndex(0)
}, [value, triggerKey, options])
const handleKeyDown = e => {
if (menuVisible) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlightedIndex(prev => (prev + 1) % filteredOptions.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlightedIndex(
prev => (prev - 1 + filteredOptions.length) % filteredOptions.length,
)
} else if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault()
if (filteredOptions[highlightedIndex]) {
selectOption(filteredOptions[highlightedIndex])
}
} else if (e.key === 'Escape') {
setMenuVisible(false)
}
} else if (Object.keys(options).includes(e.key)) {
setTriggerKey(e.key)
}
}
const selectOption = option => {
const parts = value.split(triggerKey)
parts.pop()
onChange(parts.join(triggerKey) + triggerKey + option.label + ' ')
setMenuVisible(false)
setTriggerKey(null)
}
const handleClickOutside = event => {
if (
menuRef.current &&
!menuRef.current.contains(event.target) &&
ref.current &&
!ref.current.contains(event.target)
) {
setMenuVisible(false)
}
}
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
return (
<div style={{ position: 'relative' }}>
<Textarea
{...props}
ref={ref}
value={value}
onChange={onChange}
onKeyDown={handleKeyDown}
placeholder='Type here...'
/>
{menuVisible && (
<List ref={menuRef} style={{ position: 'absolute', zIndex: 1000 }}>
{filteredOptions.map((option, index) => (
<ListItem key={option.value || option.label}>
<ListItemButton
selected={index === highlightedIndex}
onClick={() => selectOption(option)}
>
{option.color && (
<Chip
style={{
backgroundColor: option.color,
marginRight: '0.5rem',
color: '#fff',
}}
size='sm'
variant='soft'
/>
)}
{option.label}
</ListItemButton>
</ListItem>
))}
</List>
)}
</div>
)
}
export default AutocompleteInput

View File

@@ -21,15 +21,11 @@ const CalendarView = ({ chores }) => {
const tileContent = ({ date, view }) => {
if (view === 'month') {
const dayChores = chores.filter(chore => {
// Validate chore.nextDueDate before using it
if (!chore.nextDueDate) return false
const choreDate = new Date(chore.nextDueDate)
if (isNaN(choreDate)) return false // Check if the date is invalid
if (!date) return false
return choreDate.toLocaleDateString() === date.toLocaleDateString()
})
const dayChores = chores.filter(
chore =>
new Date(chore.nextDueDate)?.toISOString().split('T')[0] ===
date.toISOString().split('T')[0],
)
return (
<div className='dot-container'>
@@ -70,7 +66,7 @@ const CalendarView = ({ chores }) => {
<Calendar
tileContent={tileContent}
onChange={d => {
setSeletedDate(new Date(d.toLocaleDateString()))
setSeletedDate(new Date(d))
}}
/>
{!selectedDate && (
@@ -122,8 +118,8 @@ const CalendarView = ({ chores }) => {
{chores
.filter(
chore =>
new Date(chore.nextDueDate)?.toLocaleDateString() ===
selectedDate.toLocaleDateString(),
new Date(chore.nextDueDate)?.toISOString().split('T')[0] ===
selectedDate.toISOString().split('T')[0],
)
.map((chore, idx) => (
<Card

View File

@@ -0,0 +1,398 @@
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,
)
export const parsePriority = inputSentence => {
let sentence = inputSentence.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,
highlight: terms
.map(term => {
const index = sentence.indexOf(term)
return {
text: term,
start: index,
end: index + term.length,
}
})
.filter(term => term.start !== -1),
cleanedSentence: terms.reduce(
(s, t) => s.replace(t, ''),
inputSentence,
),
}
}
}
return { result: 0, cleanedSentence: inputSentence }
}
export const parseLabels = (inputSentence, userLabels) => {
let sentence = inputSentence.toLowerCase()
const currentLabels = []
// label will always be prefixed #:
for (const label of userLabels) {
if (sentence.includes(`#${label.name.toLowerCase()}`)) {
currentLabels.push(label)
sentence = sentence.replace(`#${label.name.toLowerCase()}`, '')
}
}
if (currentLabels.length > 0) {
return {
result: currentLabels,
highlight: currentLabels.map(label => {
const index = inputSentence
.toLowerCase()
.indexOf(`#${label.name.toLowerCase()}`)
return {
text: `#${label.name}`,
start: index,
end: index + label.name.length + 1,
}
}),
cleanedSentence: sentence,
}
}
return { result: null, cleanedSentence: sentence }
}
export const parseRepeatV2 = inputSentence => {
const sentence = inputSentence.toLowerCase()
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|everyday)/i,
name: 'Every day',
},
{
frequencyType: 'daily:time',
regex: /every (morning|noon|afternoon|evening|night)/i,
name: 'Every {time} daily',
},
{
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}',
},
{
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,
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.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),
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.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: inputSentence }
return {
result,
name: pattern.name.replace(
'{days}',
result.frequencyMetadata.days.join(', '),
),
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.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(', ')),
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.replace(match[0], '').trim(),
}
case 'interval:2week':
result.frequency = 2
result.frequencyMetadata.unit = 'weeks'
result.frequencyType = 'interval'
return {
result,
name: pattern.name,
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.replace(match[0], '').trim(),
}
case 'daily:time':
result.frequency = 1
result.frequencyMetadata.unit = 'days'
result.frequencyType = 'daily'
return {
result,
name: pattern.name.replace('{time}', match[1]),
// replace every x with ''
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.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(', ')),
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.replace(match[0], '').trim(),
}
case 'interval:every_other':
result.frequency = 2
result.frequencyMetadata.unit = match[1]
result.frequencyType = 'interval'
return {
result,
name: pattern.name.replace('{unit}', result.frequencyMetadata.unit),
highlight: [
{
text: pattern.name,
start: inputSentence.indexOf(match[0]),
end: inputSentence.indexOf(match[0]) + match[0].length,
},
],
cleanedSentence: inputSentence.replace(match[0], '').trim(),
}
}
}
return {
result: null,
name: null,
highlight: [],
cleanedSentence: inputSentence,
}
}
export const parseAssignees = (inputSentence, users) => {
const sentence = inputSentence.toLowerCase()
const result = []
const highlight = []
console.log('users:', users)
for (const user of users) {
if (sentence.includes(`@${user.username.toLowerCase()}`)) {
result.push(user)
const index = inputSentence
.toLowerCase()
.indexOf(`@${user.username.toLowerCase()}`)
highlight.push({
text: `@${user.username}`,
start: index,
end: index + user.username.length + 1,
})
}
}
if (result.length > 0) {
return {
result,
highlight,
cleanedSentence: sentence.replace(
new RegExp(`@(${users.map(u => u.username).join('|')})`, 'g'),
'',
),
}
}
return { result: null, cleanedSentence: sentence }
}

View File

View File

@@ -1,5 +1,5 @@
import { Info } from '@mui/icons-material'
import { Box, Button, Sheet } from '@mui/joy'
import { Box, IconButton, Sheet } from '@mui/joy'
import React, { useRef, useState } from 'react'
const LearnMoreButton = ({ content }) => {
@@ -29,16 +29,15 @@ const LearnMoreButton = ({ content }) => {
return (
<Box sx={{ position: 'relative', display: 'inline-block' }}>
<Button
<IconButton
ref={anchorRef}
variant='plain'
startDecorator={<Info />}
size='sm'
color='primary'
onClick={handleToggle}
>
Learn More
</Button>
<Info />
</IconButton>
{open && (
<Sheet
variant='outlined'