Merge branch 'dev'
This commit is contained in:
2292
package-lock.json
generated
2292
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,10 @@
|
||||
"private": true,
|
||||
"version": "0.1.103",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"npm": ">=9.0.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"eslint --fix",
|
||||
@@ -13,6 +17,7 @@
|
||||
"start": "vite --host",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build-cf": "rm -rf node_modules package-lock.json && npm install && vite build",
|
||||
"build-selfhosted": "vite build --mode selfhosted",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
@@ -43,6 +48,8 @@
|
||||
"@mui/joy": "^5.0.0-beta.20",
|
||||
"@mui/material": "^5.15.2",
|
||||
"@openreplay/tracker": "^14.0.4",
|
||||
"@rollup/rollup-darwin-arm64": "^4.44.0",
|
||||
"@swc/core": "^1.12.5",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"aos": "^2.3.4",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
|
||||
@@ -15,23 +15,52 @@ import Typography from '@mui/joy/Typography'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
const timeUnits = [
|
||||
{ label: 'Mins', value: 'minutes' },
|
||||
{ label: 'Hours', value: 'hours' },
|
||||
{ label: 'Days', value: 'days' },
|
||||
{ label: 'Mins', value: 'm' },
|
||||
{ label: 'Hours', value: 'h' },
|
||||
{ label: 'Days', value: 'd' },
|
||||
]
|
||||
|
||||
const beforeAfterOptions = [
|
||||
const timingOptions = [
|
||||
{ label: 'Before', value: 'before' },
|
||||
{ label: 'On Due', value: 'ondue' },
|
||||
{ label: 'After', value: 'after' },
|
||||
]
|
||||
|
||||
function getRelativeLabel(notification) {
|
||||
const { value, unit, type } = notification
|
||||
if (type === 'ondue') {
|
||||
const { value, unit } = notification
|
||||
const numericValue = Number(value)
|
||||
if (numericValue === 0) {
|
||||
return 'On due date'
|
||||
}
|
||||
return `${value} ${unit} ${type === 'before' ? 'before' : 'after'} due`
|
||||
const unitName = unit === 'm' ? 'minutes' : unit === 'h' ? 'hours' : 'days'
|
||||
const absValue = Math.abs(numericValue)
|
||||
return `${absValue} ${unitName} ${numericValue < 0 ? 'before' : 'after'} due`
|
||||
}
|
||||
|
||||
// Helper functions to convert between internal value and UI representation
|
||||
function getUIRepresentation(notification) {
|
||||
const numericValue = Number(notification.value)
|
||||
if (numericValue === 0) {
|
||||
return { timing: 'ondue', displayValue: 0, unit: notification.unit }
|
||||
} else if (numericValue < 0) {
|
||||
return {
|
||||
timing: 'before',
|
||||
displayValue: Math.abs(numericValue),
|
||||
unit: notification.unit,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
timing: 'after',
|
||||
displayValue: numericValue,
|
||||
unit: notification.unit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getInternalValue(timing, displayValue) {
|
||||
if (timing === 'ondue') return 0
|
||||
if (timing === 'before') return -Math.abs(displayValue)
|
||||
return Math.abs(displayValue) // 'after'
|
||||
}
|
||||
|
||||
const NotificationTemplate = ({
|
||||
@@ -40,9 +69,6 @@ const NotificationTemplate = ({
|
||||
value,
|
||||
showTimeline = true,
|
||||
}) => {
|
||||
const [templateName, setTemplateName] = useState(
|
||||
value?.name || 'New Notification Template',
|
||||
)
|
||||
const [notifications, setNotifications] = useState(
|
||||
value?.templates ||
|
||||
JSON.parse(localStorage.getItem('defaultNotificationTemplate')) ||
|
||||
@@ -55,45 +81,33 @@ const NotificationTemplate = ({
|
||||
const [notificationIndexMap, setNotificationIndexMap] = useState({})
|
||||
|
||||
const updateNotificationIndices = useCallback(() => {
|
||||
// Sort notifications for consistent ordering
|
||||
// Convert notifications to minutes for proper chronological sorting
|
||||
const convertToMinutes = (value, unit) => {
|
||||
const numericValue = Number(value)
|
||||
if (numericValue === 0) return 0
|
||||
let minutes = Math.abs(numericValue)
|
||||
if (unit === 'h') minutes *= 60
|
||||
if (unit === 'd') minutes *= 24 * 60
|
||||
return numericValue < 0 ? -minutes : minutes
|
||||
}
|
||||
|
||||
// Sort notifications for consistent ordering by actual time duration
|
||||
const sorted = [...notifications].sort((a, b) => {
|
||||
// Always ensure correct ordering: Before Due -> On Due -> After Due
|
||||
if (a.type !== b.type) {
|
||||
// Before Due first
|
||||
if (a.type === 'before') return -1
|
||||
if (b.type === 'before') return 1
|
||||
|
||||
// On Due comes before After Due
|
||||
if (a.type === 'ondue') return -1
|
||||
if (b.type === 'ondue') return 1
|
||||
// DEFAULT CASE ( NOT SURE FOR FUTURE )?
|
||||
return 0
|
||||
}
|
||||
|
||||
const getMinutes = notif => {
|
||||
const { value, unit } = notif
|
||||
let minutes = value
|
||||
if (unit === 'hours') minutes *= 60
|
||||
if (unit === 'days') minutes *= 24 * 60
|
||||
return minutes
|
||||
}
|
||||
|
||||
// For Before Due: sort in descending order (furthest from due first)
|
||||
// For After Due: sort in ascending order (closest to due first)
|
||||
const aMinutes = getMinutes(a)
|
||||
const bMinutes = getMinutes(b)
|
||||
return a.type === 'before' ? bMinutes - aMinutes : aMinutes - bMinutes
|
||||
const aMinutes = convertToMinutes(a.value, a.unit)
|
||||
const bMinutes = convertToMinutes(b.value, b.unit)
|
||||
return aMinutes - bMinutes
|
||||
})
|
||||
|
||||
const indexMap = {}
|
||||
sorted.forEach((item, index) => {
|
||||
const originalIdx = notifications.findIndex(
|
||||
n =>
|
||||
n.value === item.value &&
|
||||
n.unit === item.unit &&
|
||||
n.type === item.type,
|
||||
// Map original array indices to their chronological position numbers
|
||||
notifications.forEach((originalNotification, originalIdx) => {
|
||||
const chronologicalPosition = sorted.findIndex(
|
||||
sortedNotification =>
|
||||
Number(sortedNotification.value) ===
|
||||
Number(originalNotification.value) &&
|
||||
sortedNotification.unit === originalNotification.unit,
|
||||
)
|
||||
indexMap[originalIdx] = index + 1
|
||||
indexMap[originalIdx] = chronologicalPosition + 1
|
||||
})
|
||||
|
||||
setNotificationIndexMap(indexMap)
|
||||
@@ -118,31 +132,48 @@ const NotificationTemplate = ({
|
||||
if (idx === currentIdx) return false
|
||||
|
||||
return (
|
||||
n.value === notification.value &&
|
||||
n.unit === notification.unit &&
|
||||
n.type === notification.type
|
||||
Number(n.value) === Number(notification.value) &&
|
||||
n.unit === notification.unit
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const handleChange = (idx, field, value) => {
|
||||
let updatedNotification = {
|
||||
...notifications[idx],
|
||||
[field]: value,
|
||||
const currentNotification = notifications[idx]
|
||||
const uiRep = getUIRepresentation(currentNotification)
|
||||
|
||||
let updatedUIRep = { ...uiRep }
|
||||
let updatedNotification = { ...currentNotification }
|
||||
|
||||
// Update the UI representation based on the field being changed
|
||||
if (field === 'timing') {
|
||||
updatedUIRep.timing = value
|
||||
// Reset display value when switching to "On Due"
|
||||
if (value === 'ondue') {
|
||||
updatedUIRep.displayValue = 0
|
||||
}
|
||||
} else if (field === 'displayValue') {
|
||||
updatedUIRep.displayValue = Math.max(0, Number(value))
|
||||
} else if (field === 'unit') {
|
||||
updatedUIRep.unit = value
|
||||
updatedNotification.unit = value
|
||||
}
|
||||
|
||||
// Special handling for "On Due" option
|
||||
if (field === 'type' && value === 'ondue') {
|
||||
// Set default values for On Due (not applicable)
|
||||
updatedNotification = {
|
||||
...updatedNotification,
|
||||
value: 1,
|
||||
unit: 'minutes',
|
||||
}
|
||||
// Convert back to internal representation
|
||||
const newInternalValue = getInternalValue(
|
||||
updatedUIRep.timing,
|
||||
updatedUIRep.displayValue,
|
||||
)
|
||||
updatedNotification = {
|
||||
...updatedNotification,
|
||||
value: newInternalValue,
|
||||
unit: updatedUIRep.unit,
|
||||
}
|
||||
|
||||
// Check if another notification is already "On Due"
|
||||
// Check if another notification is already "On Due" (value = 0)
|
||||
if (newInternalValue === 0) {
|
||||
const existingOnDue = notifications.findIndex(
|
||||
(n, i) => i !== idx && n.type === 'ondue',
|
||||
(n, i) => i !== idx && Number(n.value) === 0,
|
||||
)
|
||||
|
||||
if (existingOnDue !== -1) {
|
||||
@@ -177,28 +208,25 @@ const NotificationTemplate = ({
|
||||
case 'reminder':
|
||||
// Suggest common reminder times that don't exist
|
||||
suggestions = [
|
||||
{ value: 1, unit: 'hours', type: 'before' },
|
||||
{ value: 1, unit: 'days', type: 'before' },
|
||||
{ value: 30, unit: 'minutes', type: 'before' },
|
||||
{ value: 2, unit: 'hours', type: 'before' },
|
||||
{ value: 3, unit: 'days', type: 'before' },
|
||||
{ value: -1, unit: 'd' }, // 1 day before
|
||||
{ value: -3, unit: 'h' }, // 3 hours before
|
||||
{ value: -30, unit: 'm' }, // 3 days before
|
||||
]
|
||||
break
|
||||
|
||||
case 'due':
|
||||
if (notifications.some(n => n.type === 'ondue')) {
|
||||
if (notifications.some(n => Number(n.value) === 0)) {
|
||||
setError('Only one "Due Alert" notification is allowed.')
|
||||
return
|
||||
}
|
||||
newNotification = { value: 0, unit: 'minutes', type: 'ondue' }
|
||||
newNotification = { value: 0, unit: 'm' }
|
||||
break
|
||||
|
||||
case 'followup':
|
||||
suggestions = [
|
||||
{ value: 1, unit: 'hours', type: 'after' },
|
||||
{ value: 1, unit: 'days', type: 'after' },
|
||||
{ value: 3, unit: 'days', type: 'after' },
|
||||
{ value: 1, unit: 'weeks', type: 'after' },
|
||||
{ value: 1, unit: 'd' }, // 1 day after
|
||||
{ value: 3, unit: 'd' }, // 3 days after
|
||||
{ value: 7, unit: 'd' }, // 1 week after
|
||||
]
|
||||
break
|
||||
}
|
||||
@@ -213,24 +241,8 @@ const NotificationTemplate = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the new notification in the correct chronological position
|
||||
const updatedNotifications = [...notifications, newNotification].sort(
|
||||
(a, b) => {
|
||||
// Convert everything to minutes for consistent comparison
|
||||
const getMinutes = notif => {
|
||||
const { value, unit, type } = notif
|
||||
// On Due is exactly at due date (0 minutes)
|
||||
if (type === 'ondue') return 0
|
||||
|
||||
let minutes = value
|
||||
if (unit === 'hours') minutes *= 60
|
||||
if (unit === 'days') minutes *= 24 * 60
|
||||
return type === 'before' ? -minutes : minutes
|
||||
}
|
||||
|
||||
return getMinutes(a) - getMinutes(b)
|
||||
},
|
||||
)
|
||||
// Add the new notification to the end (don't sort, keep form order)
|
||||
const updatedNotifications = [...notifications, newNotification]
|
||||
|
||||
setNotifications(updatedNotifications)
|
||||
setError(null)
|
||||
@@ -240,61 +252,45 @@ const NotificationTemplate = ({
|
||||
const updated = notifications.filter((_, i) => i !== idx)
|
||||
setNotifications(updated)
|
||||
onChange && onChange(updated)
|
||||
setShowSaveDefault(true)
|
||||
}
|
||||
const renderTimeline = () => {
|
||||
// Sort notifications chronologically
|
||||
// Convert notifications to minutes for proper chronological sorting
|
||||
const convertToMinutes = (value, unit) => {
|
||||
const numericValue = Number(value)
|
||||
if (numericValue === 0) return 0
|
||||
let minutes = Math.abs(numericValue)
|
||||
if (unit === 'h') minutes *= 60
|
||||
if (unit === 'd') minutes *= 24 * 60
|
||||
return numericValue < 0 ? -minutes : minutes
|
||||
}
|
||||
|
||||
// Sort notifications chronologically by actual time (in minutes)
|
||||
const sorted = [...notifications].sort((a, b) => {
|
||||
// Convert everything to minutes for consistent comparison
|
||||
const getMinutes = notif => {
|
||||
const { value, unit, type } = notif
|
||||
// On Due is exactly at due date (0 minutes)
|
||||
if (type === 'ondue') return 0
|
||||
|
||||
let minutes = value
|
||||
if (unit === 'hours') minutes *= 60
|
||||
if (unit === 'days') minutes *= 24 * 60
|
||||
return type === 'before' ? -minutes : minutes
|
||||
}
|
||||
|
||||
return getMinutes(a) - getMinutes(b)
|
||||
})
|
||||
|
||||
// Create a map to track sorted indices for original notifications
|
||||
const notificationIndexMap = {}
|
||||
sorted.forEach((item, index) => {
|
||||
// Find the original index of this item in notifications array
|
||||
const originalIdx = notifications.findIndex(
|
||||
n =>
|
||||
n.value === item.value &&
|
||||
n.unit === item.unit &&
|
||||
n.type === item.type,
|
||||
)
|
||||
notificationIndexMap[originalIdx] = index + 1
|
||||
}) // Get min and max notification times for dynamic scaling
|
||||
const minutesValues = sorted.map(n => {
|
||||
if (n.type === 'ondue') return 0
|
||||
|
||||
let minutes = n.value
|
||||
if (n.unit === 'hours') minutes *= 60
|
||||
if (n.unit === 'days') minutes *= 24 * 60
|
||||
return n.type === 'before' ? -minutes : minutes
|
||||
const aMinutes = convertToMinutes(a.value, a.unit)
|
||||
const bMinutes = convertToMinutes(b.value, b.unit)
|
||||
return aMinutes - bMinutes
|
||||
})
|
||||
|
||||
// Get min and max notification times in minutes for dynamic scaling
|
||||
const minutesValues = sorted.map(n => convertToMinutes(n.value, n.unit))
|
||||
const minBefore = Math.min(0, ...minutesValues) // Default to 0 if no "before" notifications
|
||||
const maxAfter = Math.max(0, ...minutesValues) // Default to 0 if no "after" notifications
|
||||
|
||||
const getPositionPercent = minutes => {
|
||||
const getPositionPercent = (value, unit) => {
|
||||
const minutes = convertToMinutes(value, unit)
|
||||
|
||||
// Due date is always at center (50%)
|
||||
if (minutes === 0) return 50
|
||||
|
||||
// For notifications before due date
|
||||
// For notifications before due date (negative values)
|
||||
if (minutes < 0) {
|
||||
if (minBefore === 0) return 30 // Default position if no before notifications
|
||||
// Scale between 10% (furthest left) and 45% (closest to due)
|
||||
return 45 - (Math.abs(minutes) / Math.abs(minBefore)) * 35
|
||||
}
|
||||
|
||||
// For notifications after due date
|
||||
// For notifications after due date (positive values)
|
||||
if (maxAfter === 0) return 70 // Default position if no after notifications
|
||||
// Scale between 55% (closest to due) and 90% (furthest right)
|
||||
return 55 + (minutes / maxAfter) * 35
|
||||
@@ -361,18 +357,8 @@ const NotificationTemplate = ({
|
||||
|
||||
{/* Notification markers */}
|
||||
{sorted.map((n, i) => {
|
||||
// Convert to minutes for consistent scale
|
||||
let minutes = 0
|
||||
if (n.type !== 'ondue') {
|
||||
minutes = n.value
|
||||
if (n.unit === 'hours') minutes *= 60
|
||||
if (n.unit === 'days') minutes *= 24 * 60
|
||||
if (n.type === 'before') minutes = -minutes
|
||||
}
|
||||
// On Due notifications are always at the due date (0 minutes)
|
||||
|
||||
// Calculate position based on dynamic scaling
|
||||
const percent = getPositionPercent(minutes)
|
||||
// Calculate position based on actual time duration
|
||||
const percent = getPositionPercent(n.value, n.unit)
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -382,9 +368,9 @@ const NotificationTemplate = ({
|
||||
left: `${percent}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
color:
|
||||
n.type === 'before'
|
||||
Number(n.value) < 0
|
||||
? 'primary.600'
|
||||
: n.type === 'ondue'
|
||||
: Number(n.value) === 0
|
||||
? 'warning.600'
|
||||
: 'success.600',
|
||||
display: 'flex',
|
||||
@@ -402,13 +388,21 @@ const NotificationTemplate = ({
|
||||
title={getRelativeLabel(n)}
|
||||
>
|
||||
<Badge
|
||||
badgeContent={i + 1}
|
||||
badgeContent={
|
||||
notificationIndexMap[
|
||||
notifications.findIndex(
|
||||
original =>
|
||||
Number(original.value) === Number(n.value) &&
|
||||
original.unit === n.unit,
|
||||
)
|
||||
] || i + 1
|
||||
}
|
||||
size={'sm'}
|
||||
variant={'solid'}
|
||||
color={
|
||||
n.type === 'before'
|
||||
Number(n.value) < 0
|
||||
? 'success'
|
||||
: n.type === 'ondue'
|
||||
: Number(n.value) === 0
|
||||
? 'warning'
|
||||
: 'danger'
|
||||
}
|
||||
@@ -480,105 +474,108 @@ const NotificationTemplate = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{notifications.map((n, idx) => {
|
||||
// Get ordered badge number from timeline sorting
|
||||
const badgeNumber = notificationIndexMap[idx]
|
||||
{notifications
|
||||
.map((n, idx) => ({ notification: n, originalIndex: idx }))
|
||||
.sort((a, b) => {
|
||||
const aBadgeNumber = notificationIndexMap[a.originalIndex] || 0
|
||||
const bBadgeNumber = notificationIndexMap[b.originalIndex] || 0
|
||||
return aBadgeNumber - bBadgeNumber
|
||||
})
|
||||
.map(({ notification: n, originalIndex: idx }) => {
|
||||
// Get ordered badge number from timeline sorting
|
||||
const badgeNumber = notificationIndexMap[idx]
|
||||
const uiRep = getUIRepresentation(n)
|
||||
|
||||
return (
|
||||
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'start',
|
||||
width: 18,
|
||||
|
||||
flexShrink: 0,
|
||||
}}
|
||||
key={idx}
|
||||
sx={{ display: 'flex', alignItems: 'center', mb: 1 }}
|
||||
>
|
||||
<Badge
|
||||
badgeContent={badgeNumber}
|
||||
size={'sm'}
|
||||
<Box
|
||||
sx={{
|
||||
'--Badge-minHeight': '20px',
|
||||
'--Badge-fontSize': '0.75rem',
|
||||
// centering the badge:
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'start',
|
||||
width: 18,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
color={
|
||||
n.type === 'before'
|
||||
? 'success'
|
||||
: n.type === 'ondue'
|
||||
? 'warning'
|
||||
: 'danger'
|
||||
}
|
||||
>
|
||||
{/* Empty box to attach badge to */}
|
||||
</Badge>
|
||||
<Badge
|
||||
badgeContent={badgeNumber}
|
||||
size={'sm'}
|
||||
sx={{
|
||||
'--Badge-minHeight': '20px',
|
||||
'--Badge-fontSize': '0.75rem',
|
||||
}}
|
||||
color={
|
||||
Number(n.value) < 0
|
||||
? 'success'
|
||||
: Number(n.value) === 0
|
||||
? 'warning'
|
||||
: 'danger'
|
||||
}
|
||||
>
|
||||
{/* Empty box to attach badge to */}
|
||||
</Badge>
|
||||
</Box>
|
||||
<Select
|
||||
value={uiRep.timing}
|
||||
onChange={(_, value) => handleChange(idx, 'timing', value)}
|
||||
sx={{ mr: 1, minWidth: 100 }}
|
||||
size={'sm'}
|
||||
>
|
||||
{timingOptions.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
type={'number'}
|
||||
min={0}
|
||||
value={uiRep.displayValue}
|
||||
disabled={uiRep.timing === 'ondue'}
|
||||
onChange={e =>
|
||||
handleChange(idx, 'displayValue', e.target.value)
|
||||
}
|
||||
sx={{
|
||||
width: 80,
|
||||
mr: 1,
|
||||
opacity: uiRep.timing === 'ondue' ? 0.6 : 1,
|
||||
}}
|
||||
size={'sm'}
|
||||
placeholder='0'
|
||||
/>
|
||||
<Select
|
||||
value={n.unit}
|
||||
disabled={uiRep.timing === 'ondue'}
|
||||
onChange={(_, value) => handleChange(idx, 'unit', value)}
|
||||
sx={{
|
||||
mr: 1,
|
||||
minWidth: 80,
|
||||
opacity: uiRep.timing === 'ondue' ? 0.6 : 1,
|
||||
}}
|
||||
size={'sm'}
|
||||
>
|
||||
{timeUnits.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<IconButton
|
||||
onClick={() => removeNotification(idx)}
|
||||
disabled={notifications.length === 1}
|
||||
color={'danger'}
|
||||
size={'sm'}
|
||||
sx={{ mr: 1 }}
|
||||
variant={'soft'}
|
||||
>
|
||||
<DeleteIcon fontSize={'small'} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Select
|
||||
value={n.type}
|
||||
onChange={(_, value) => handleChange(idx, 'type', value)}
|
||||
sx={{ mr: 1, minWidth: 100 }}
|
||||
size={'sm'}
|
||||
>
|
||||
{beforeAfterOptions.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
{/* Show disabled fields for "On Due" option for visual consistency */}
|
||||
<Input
|
||||
type={'number'}
|
||||
min={1}
|
||||
disabled={n.type === 'ondue'}
|
||||
value={n.type === 'ondue' ? '—' : n.value}
|
||||
onChange={e =>
|
||||
handleChange(idx, 'value', Math.max(1, Number(e.target.value)))
|
||||
}
|
||||
sx={{
|
||||
width: 70,
|
||||
mr: 1,
|
||||
opacity: n.type === 'ondue' ? 0.6 : 1,
|
||||
...(n.type === 'ondue' && {
|
||||
'& input': {
|
||||
textAlign: 'center',
|
||||
},
|
||||
}),
|
||||
}}
|
||||
size={'sm'}
|
||||
placeholder={n.type === 'ondue' ? '—' : ''}
|
||||
/>
|
||||
<Select
|
||||
value={n.unit}
|
||||
disabled={n.type === 'ondue'}
|
||||
onChange={(_, value) => handleChange(idx, 'unit', value)}
|
||||
sx={{
|
||||
mr: 1,
|
||||
minWidth: 80,
|
||||
opacity: n.type === 'ondue' ? 0.6 : 1,
|
||||
}}
|
||||
size={'sm'}
|
||||
>
|
||||
{timeUnits.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<IconButton
|
||||
onClick={() => removeNotification(idx)}
|
||||
disabled={notifications.length === 1}
|
||||
color={'danger'}
|
||||
size={'sm'}
|
||||
sx={{ mr: 1 }}
|
||||
variant={'soft'}
|
||||
>
|
||||
<DeleteIcon fontSize={'small'} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1, mb: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
onClick={() => addSmartNotification('reminder')}
|
||||
@@ -594,7 +591,7 @@ const NotificationTemplate = ({
|
||||
onClick={() => addSmartNotification('due')}
|
||||
disabled={
|
||||
notifications.length >= maxNotifications ||
|
||||
notifications.some(n => n.type === 'ondue')
|
||||
notifications.some(n => Number(n.value) === 0)
|
||||
}
|
||||
startDecorator={<AddIcon />}
|
||||
size={'sm'}
|
||||
|
||||
@@ -98,7 +98,6 @@ const RealTimeSettings = () => {
|
||||
<Card sx={{ mt: 2, p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, mb: 2 }}>
|
||||
<Switch
|
||||
color='success'
|
||||
checked={realtimeType !== REALTIME_TYPES.DISABLED}
|
||||
onChange={e => {
|
||||
handleRealtimeTypeChange(
|
||||
@@ -106,6 +105,9 @@ const RealTimeSettings = () => {
|
||||
e.target.checked ? REALTIME_TYPES.SSE : REALTIME_TYPES.DISABLED,
|
||||
)
|
||||
}}
|
||||
color={
|
||||
realtimeType !== REALTIME_TYPES.DISABLED ? 'success' : 'neutral'
|
||||
}
|
||||
disabled={!isPlusAccount(userProfile)}
|
||||
inputProps={{ 'aria-label': 'Enable Real-time Updates' }}
|
||||
/>
|
||||
|
||||
@@ -9,9 +9,9 @@ const SSE_STATES = {
|
||||
CLOSED: 2,
|
||||
}
|
||||
|
||||
const RECONNECT_INTERVALS = [1000, 2000, 5000, 10000, 30000] // Progressive backoff
|
||||
const RECONNECT_INTERVALS = [2000, 5000, 10000, 30000, 360000, 600000, 900000] // 2s, 5s, 10s, 30s, 6m, 10m, 15m
|
||||
const MAX_RECONNECT_ATTEMPTS = 10 // Circuit breaker limit
|
||||
const CIRCUIT_BREAKER_RESET_TIME = 300000 // 5 minutes
|
||||
const CIRCUIT_BREAKER_RESET_TIME = 600000 // 10 minutes
|
||||
|
||||
export const useSSE = () => {
|
||||
const [connectionState, setConnectionState] = useState(SSE_STATES.CLOSED)
|
||||
@@ -104,7 +104,7 @@ export const useSSE = () => {
|
||||
|
||||
case 'heartbeat':
|
||||
// Heartbeat events don't need cache invalidation
|
||||
console.debug('SSE Heartbeat received')
|
||||
console.debug('SSE Heartbeat received at', new Date().toISOString())
|
||||
break
|
||||
|
||||
case 'connection.established':
|
||||
@@ -199,6 +199,12 @@ export const useSSE = () => {
|
||||
'Cache-Control': 'no-cache',
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
// Increase timeout to prevent premature disconnections
|
||||
// Default is 45000ms (45s), increasing to 2 minutes
|
||||
// TODO: send this in the resource object so it can be configured per instance
|
||||
heartbeatTimeout: 120000,
|
||||
// Enable silentTimeoutRetry to handle temporary network issues
|
||||
silentTimeoutRetry: true,
|
||||
})
|
||||
|
||||
eventSourceRef.current.onopen = () => {
|
||||
@@ -214,16 +220,23 @@ export const useSSE = () => {
|
||||
}
|
||||
heartbeatMonitorRef.current = setInterval(() => {
|
||||
const timeSinceLastHeartbeat = Date.now() - lastHeartbeatRef.current
|
||||
const heartbeatTimeout = 90000 // 90 seconds
|
||||
const heartbeatTimeout = 150000 // 2.5 minutes - should be longer than server heartbeat interval
|
||||
|
||||
if (timeSinceLastHeartbeat > heartbeatTimeout) {
|
||||
console.warn(
|
||||
'SSE: No heartbeat received, connection may be stale. Reconnecting...',
|
||||
`SSE: No heartbeat received for ${Math.round(timeSinceLastHeartbeat / 1000)}s, connection may be stale. Reconnecting...`,
|
||||
)
|
||||
if (!isManuallyClosedRef.current) {
|
||||
// Clear current heartbeat monitor before reconnecting
|
||||
stopHeartbeatMonitor()
|
||||
|
||||
// Close current connection gracefully
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
setConnectionState(SSE_STATES.CLOSED)
|
||||
|
||||
// Schedule reconnect
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
@@ -247,7 +260,7 @@ export const useSSE = () => {
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
}, 30000) // Check every 30 seconds
|
||||
}, 60000) // Check every minute
|
||||
}
|
||||
|
||||
eventSourceRef.current.onmessage = handleSSEMessage
|
||||
@@ -258,7 +271,17 @@ export const useSSE = () => {
|
||||
stopHeartbeatMonitor()
|
||||
|
||||
if (!isManuallyClosedRef.current) {
|
||||
setError('Connection error occurred')
|
||||
// Check if this is a timeout error specifically
|
||||
const isTimeoutError =
|
||||
error.error?.message?.includes('No activity within') ||
|
||||
error.error?.message?.includes('timeout')
|
||||
|
||||
if (isTimeoutError) {
|
||||
console.log('SSE timeout detected, attempting reconnection...')
|
||||
setError('Connection timeout - reconnecting...')
|
||||
} else {
|
||||
setError('Connection error occurred')
|
||||
}
|
||||
|
||||
// Schedule reconnect
|
||||
if (reconnectTimeoutRef.current) {
|
||||
@@ -417,5 +440,14 @@ export const useSSE = () => {
|
||||
return 'disconnected'
|
||||
}
|
||||
},
|
||||
// Additional debugging information
|
||||
getDebugInfo: () => ({
|
||||
connectionState,
|
||||
reconnectAttempts: reconnectAttemptsRef.current,
|
||||
isCircuitBreakerOpen,
|
||||
lastHeartbeat: lastHeartbeatRef.current,
|
||||
timeSinceLastHeartbeat: Date.now() - lastHeartbeatRef.current,
|
||||
isManuallyCloseRef: isManuallyClosedRef.current,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,11 +34,12 @@ export const useUserProfile = () => {
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['userProfile'],
|
||||
queryFn: async () => {
|
||||
const resp = await GetUserProfile()
|
||||
const result = await resp.json()
|
||||
if (!isTokenValid()) {
|
||||
return null // Token is invalid, return null to indicate no profile
|
||||
}
|
||||
const resp = await GetUserProfile()
|
||||
const result = await resp.json()
|
||||
|
||||
return result.res // Return the actual user profile data
|
||||
},
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes in milliseconds
|
||||
|
||||
@@ -77,7 +77,6 @@ export async function Fetch(url, options) {
|
||||
options = {}
|
||||
}
|
||||
// clone options to avoid mutation
|
||||
const cacheKey = { ...options }
|
||||
options.headers = { ...options.headers, ...HEADERS() }
|
||||
|
||||
const baseURL = apiManager.getApiURL()
|
||||
|
||||
@@ -22,8 +22,8 @@ import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
|
||||
import Logo from '../../Logo'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { login } from '../../utils/Fetcher'
|
||||
import { apiManager } from '../../utils/TokenManager'
|
||||
import { GetUserProfile, login } from '../../utils/Fetcher'
|
||||
import { apiManager, isTokenValid } from '../../utils/TokenManager'
|
||||
import MFAVerificationModal from './MFAVerificationModal'
|
||||
|
||||
const LoginView = () => {
|
||||
@@ -49,7 +49,19 @@ const LoginView = () => {
|
||||
}
|
||||
initializeSocialLogin()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isTokenValid()) {
|
||||
GetUserProfile().then(response => {
|
||||
if (response.status === 200) {
|
||||
return response.json().then(data => {
|
||||
setUserProfile(data.res)
|
||||
})
|
||||
} else {
|
||||
console.log('Failed to fetch user profile')
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
const handleSubmit = async e => {
|
||||
e.preventDefault()
|
||||
login(username, password)
|
||||
|
||||
@@ -61,7 +61,8 @@ const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
|
||||
const NO_DUE_DATE_REQUIRED_TYPE = ['no_repeat', 'once']
|
||||
const NO_DUE_DATE_ALLOWED_TYPE = ['trigger']
|
||||
const ChoreEdit = () => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
useUserProfile()
|
||||
|
||||
const [chore, setChore] = useState([])
|
||||
const [choresHistory, setChoresHistory] = useState([])
|
||||
@@ -331,14 +332,14 @@ const ChoreEdit = () => {
|
||||
}, [assignees])
|
||||
|
||||
useEffect(() => {
|
||||
if (performers.length > 0 && assignees.length === 0) {
|
||||
if (performers.length > 0 && assignees.length === 0 && userProfile) {
|
||||
setAssignees([
|
||||
{
|
||||
userId: userProfile.id,
|
||||
userId: userProfile?.id,
|
||||
},
|
||||
])
|
||||
}
|
||||
}, [performers])
|
||||
}, [performers, userProfile])
|
||||
|
||||
// if user resolve the error trigger validation to remove the error message from the respective field
|
||||
useEffect(() => {
|
||||
@@ -368,7 +369,11 @@ const ChoreEdit = () => {
|
||||
},
|
||||
})
|
||||
}
|
||||
if ((isChoreLoading && choreId) || isUserLabelsLoading) {
|
||||
if (
|
||||
(isChoreLoading && choreId) ||
|
||||
isUserLabelsLoading ||
|
||||
isUserProfileLoading
|
||||
) {
|
||||
return <LoadingComponent />
|
||||
}
|
||||
return (
|
||||
@@ -738,11 +743,13 @@ const ChoreEdit = () => {
|
||||
<Box sx={{ p: 0.5 }}>
|
||||
<NotificationTemplate
|
||||
onChange={metadata => {
|
||||
const newNotificaitonMetadata = {
|
||||
...notificationMetadata,
|
||||
templates: metadata.notifications,
|
||||
const newTemplates = metadata.notifications
|
||||
if (notificationMetadata.templates !== newTemplates) {
|
||||
setNotificationMetadata({
|
||||
...notificationMetadata,
|
||||
templates: newTemplates,
|
||||
})
|
||||
}
|
||||
setNotificationMetadata(newNotificaitonMetadata)
|
||||
}}
|
||||
value={notificationMetadata}
|
||||
/>
|
||||
@@ -1007,7 +1014,7 @@ const ChoreEdit = () => {
|
||||
<Typography level='body1'>
|
||||
Created by{' '}
|
||||
<Chip variant='solid'>
|
||||
{performers.find(f => f.id === createdBy)?.displayName}
|
||||
{performers.find(f => f.userId === createdBy)?.displayName}
|
||||
</Chip>{' '}
|
||||
{moment(chore.createdAt).fromNow()}
|
||||
</Typography>
|
||||
@@ -1018,7 +1025,7 @@ const ChoreEdit = () => {
|
||||
<Typography level='body1'>
|
||||
Updated by{' '}
|
||||
<Chip variant='solid'>
|
||||
{performers.find(f => f.id === updatedBy)?.displayName}
|
||||
{performers.find(f => f.userId === updatedBy)?.displayName}
|
||||
</Chip>{' '}
|
||||
{moment(chore.updatedAt).fromNow()}
|
||||
</Typography>
|
||||
|
||||
@@ -481,7 +481,7 @@ const CompactChoreCard = ({
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
variant='solid'
|
||||
variant='soft'
|
||||
color='success'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
@@ -494,6 +494,9 @@ const CompactChoreCard = ({
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
transform: 'scale(0.95)',
|
||||
|
||||
@@ -201,7 +201,7 @@ const ThingsView = () => {
|
||||
}
|
||||
showNotification({
|
||||
type: 'success',
|
||||
title: 'Thing Saved',
|
||||
title: 'Saved',
|
||||
message: 'Thing saved successfully',
|
||||
})
|
||||
})
|
||||
@@ -291,7 +291,7 @@ const ThingsView = () => {
|
||||
setThings(currentThings)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
title: 'Thing Updated',
|
||||
title: 'Updated',
|
||||
message: 'Thing state updated successfully',
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user