diff --git a/web/components/Date/DateDisplay.tsx b/web/components/Date/DateDisplay.tsx index fc56e44..7c2a64d 100644 --- a/web/components/Date/DateDisplay.tsx +++ b/web/components/Date/DateDisplay.tsx @@ -1,5 +1,11 @@ -import { Tooltip, useUpdatingDateString } from '@helpwave/hightide' +import { Tooltip, useLocale } from '@helpwave/hightide' import clsx from 'clsx' +import { useMemo } from 'react' +import { + formatAbsoluteHightide, + formatRelativeHightide, + type DateTimeFormat +} from '@/utils/hightideDateFormat' type DateDisplayProps = { date: Date, @@ -8,11 +14,22 @@ type DateDisplayProps = { mode?: 'relative' | 'absolute', } +function toAbsoluteFormat(showTime: boolean): DateTimeFormat { + return showTime ? 'dateTime' : 'date' +} + export const DateDisplay = ({ date, className, showTime = true, mode = 'relative' }: DateDisplayProps) => { - const { absolute, relative } = useUpdatingDateString({ - date: date ?? new Date(), - absoluteFormat: showTime ? 'dateTime' : 'date', - }) + const { locale } = useLocale() + const absoluteFormat = toAbsoluteFormat(showTime) + const absolute = useMemo( + () => (date ? formatAbsoluteHightide(date, locale, absoluteFormat) : ''), + [date, locale, absoluteFormat] + ) + const relative = useMemo( + () => (date ? formatRelativeHightide(date, locale) : ''), + [date, locale] + ) + if (!date) return null const displayString = mode === 'relative' ? relative : absolute diff --git a/web/components/patients/PatientTasksView.tsx b/web/components/patients/PatientTasksView.tsx index bdd2e47..69bad5c 100644 --- a/web/components/patients/PatientTasksView.tsx +++ b/web/components/patients/PatientTasksView.tsx @@ -166,9 +166,8 @@ export const PatientTasksView = ({ { + onListSync={() => { onSuccess?.() - setIsCreatingTask(false) }} onClose={() => { setTaskId(null) diff --git a/web/components/properties/PropertyCell.tsx b/web/components/properties/PropertyCell.tsx index 2cab4cb..170f7f1 100644 --- a/web/components/properties/PropertyCell.tsx +++ b/web/components/properties/PropertyCell.tsx @@ -44,7 +44,7 @@ export const PropertyCell = ({ return } return ( - + ) } case FieldType.FieldTypeDateTime: { @@ -58,7 +58,7 @@ export const PropertyCell = ({ return } return ( - + ) } case FieldType.FieldTypeSelect: { diff --git a/web/components/properties/PropertyEntry.tsx b/web/components/properties/PropertyEntry.tsx index 537d338..a34e17f 100644 --- a/web/components/properties/PropertyEntry.tsx +++ b/web/components/properties/PropertyEntry.tsx @@ -1,6 +1,7 @@ import { CheckboxProperty, DateProperty, + MultiSelectOption, MultiSelectProperty, NumberProperty, PropertyBase, @@ -121,7 +122,7 @@ export const PropertyEntry = ({ onEditComplete={multiSelectValue => onEditComplete({ ...value, multiSelectValue })} > {selectData?.options.map(option => ( - + ))} ) diff --git a/web/components/tables/RecentTasksTable.tsx b/web/components/tables/RecentTasksTable.tsx index 4e905a2..2b75753 100644 --- a/web/components/tables/RecentTasksTable.tsx +++ b/web/components/tables/RecentTasksTable.tsx @@ -129,7 +129,7 @@ export const RecentTasksTable = ({ return ( ) @@ -211,7 +211,7 @@ export const RecentTasksTable = ({ const date = getValue() as Date | undefined if (!date) return return ( - + ) }, minSize: 220, diff --git a/web/components/tables/TaskList.tsx b/web/components/tables/TaskList.tsx index 4d651df..32634a5 100644 --- a/web/components/tables/TaskList.tsx +++ b/web/components/tables/TaskList.tsx @@ -81,6 +81,10 @@ const TaskAssigneeTableCell = memo(function TaskAssigneeTableCell({ ) }) +function taskListDataSyncKey(tasks: TaskViewModel[]): string { + return tasks.map(t => `${t.id}:${t.done}:${t.updateDate.getTime()}`).join('\0') +} + export type TaskViewModel = { id: string, name: string, @@ -212,15 +216,22 @@ export const TaskList = forwardRef(({ tasks: initial } })) + const initialTaskPresent = Boolean(initialTaskId && initialTasks.some(t => t.id === initialTaskId)) + + const initialTasksSyncKey = useMemo( + () => taskListDataSyncKey(initialTasks), + [initialTasks] + ) + useEffect(() => { - if (initialTaskId && initialTasks.length > 0 && openedTaskId !== initialTaskId) { + if (initialTaskId && initialTaskPresent && openedTaskId !== initialTaskId) { setTaskDialogState({ isOpen: true, taskId: initialTaskId }) setOpenedTaskId(initialTaskId) onInitialTaskOpened?.() } else if (!initialTaskId) { setOpenedTaskId(null) } - }, [initialTaskId, initialTasks, openedTaskId, onInitialTaskOpened]) + }, [initialTaskId, initialTaskPresent, openedTaskId, onInitialTaskOpened]) useEffect(() => { setOptimisticUpdates(prev => { @@ -237,7 +248,7 @@ export const TaskList = forwardRef(({ tasks: initial return hasChanges ? next : prev }) - }, [initialTasks]) + }, [initialTasksSyncKey, initialTasks]) const isServerDriven = totalCount != null @@ -276,7 +287,7 @@ export const TaskList = forwardRef(({ tasks: initial useEffect(() => { if (isServerDriven) return setClientVisibleCount(LIST_PAGE_SIZE) - }, [initialTasks, searchQuery, isServerDriven]) + }, [initialTasksSyncKey, searchQuery, isServerDriven]) const displayedTasks = useMemo(() => { if (isServerDriven) return tasks @@ -466,12 +477,10 @@ export const TaskList = forwardRef(({ tasks: initial if (checked) { completeTask({ variables: { id: task.id }, - onCompleted: () => onRefetch?.(), }) } else { reopenTask({ variables: { id: task.id }, - onCompleted: () => onRefetch?.(), }) } }} @@ -528,7 +537,7 @@ export const TaskList = forwardRef(({ tasks: initial @@ -658,7 +667,7 @@ export const TaskList = forwardRef(({ tasks: initial ] return colsWithRefreshing }, - [translation, completeTask, reopenTask, showAssignee, taskPropertyColumns, onRefetch]) + [translation, completeTask, reopenTask, showAssignee, taskPropertyColumns]) const taskCardPrimaryColumnIds = useMemo(() => { const s = new Set(['done', 'title', 'dueDate', 'patient']) @@ -843,7 +852,6 @@ export const TaskList = forwardRef(({ tasks: initial task={task} showAssignee={showAssignee} showPatient={true} - onRefetch={onRefetch} onClick={() => setTaskDialogState({ isOpen: true, taskId: task.id })} extraContent={renderTaskCardExtras(task)} /> @@ -866,8 +874,7 @@ export const TaskList = forwardRef(({ tasks: initial setTaskDialogState({ isOpen: false })} - onSuccess={onRefetch || (() => { - })} + onListSync={onRefetch} /> (({ tasks: initial setSelectedPatientId(null)} - onSuccess={onRefetch || (() => { - })} + onSuccess={() => {}} /> )} diff --git a/web/components/tables/TaskRowRefreshingGate.tsx b/web/components/tables/TaskRowRefreshingGate.tsx index 74abdfc..e79566e 100644 --- a/web/components/tables/TaskRowRefreshingGate.tsx +++ b/web/components/tables/TaskRowRefreshingGate.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, type ReactNode } from 'react' -import { LoadingContainer } from '@helpwave/hightide' +import { Loader2 } from 'lucide-react' const EMPTY_TASK_IDS = new Set() @@ -12,8 +12,21 @@ type TaskRowRefreshingGateProps = { export function TaskRowRefreshingGate({ taskId, children }: TaskRowRefreshingGateProps) { const ids = useContext(RefreshingTaskIdsContext) - if (ids.has(taskId)) { - return - } - return children + const refreshing = ids.has(taskId) + return ( +
+
+ {children} +
+ {refreshing && ( +
+ +
+ )} +
+ ) } diff --git a/web/components/tasks/AssigneeSelectDialog.tsx b/web/components/tasks/AssigneeSelectDialog.tsx index 4d8066f..8fe02ac 100644 --- a/web/components/tasks/AssigneeSelectDialog.tsx +++ b/web/components/tasks/AssigneeSelectDialog.tsx @@ -6,6 +6,8 @@ import { Users, Info } from 'lucide-react' import { useUsers, useLocations } from '@/data' import clsx from 'clsx' +const EMPTY_MULTI_USER_IDS: string[] = [] + interface AssigneeSelectDialogProps { value: string, onValueChanged: (value: string) => void, @@ -33,12 +35,13 @@ export const AssigneeSelectDialog = ({ onUserInfoClick, multiUserSelect = false, onMultiUserIdsSelected, - initialMultiUserIds = [], + initialMultiUserIds, }: AssigneeSelectDialogProps) => { const translation = useTasksTranslation() const [searchQuery, setSearchQuery] = useState('') const [pendingUserIds, setPendingUserIds] = useState>(new Set()) const searchInputRef = useRef(null) + const initialMultiIds = initialMultiUserIds ?? EMPTY_MULTI_USER_IDS const { data: usersData } = useUsers() const { data: locationsData } = useLocations() @@ -96,9 +99,15 @@ export const AssigneeSelectDialog = ({ setSearchQuery('') } if (isOpen && multiUserSelect) { - setPendingUserIds(new Set(initialMultiUserIds)) + setPendingUserIds(prev => { + const next = new Set(initialMultiIds) + if (prev.size === next.size && [...next].every((id) => prev.has(id))) { + return prev + } + return next + }) } - }, [isOpen, multiUserSelect, initialMultiUserIds]) + }, [isOpen, multiUserSelect, initialMultiIds]) const handleSelect = (selectedValue: string) => { onValueChanged(selectedValue) diff --git a/web/components/tasks/TaskCardView.tsx b/web/components/tasks/TaskCardView.tsx index b048100..014ddc9 100644 --- a/web/components/tasks/TaskCardView.tsx +++ b/web/components/tasks/TaskCardView.tsx @@ -50,7 +50,6 @@ type TaskCardViewProps = { onClick: (task: FlexibleTask | TaskViewModel) => void, showAssignee?: boolean, showPatient?: boolean, - onRefetch?: () => void, className?: string, fullWidth?: boolean, extraContent?: ReactNode, @@ -75,7 +74,7 @@ const toDate = (date: Date | string | null | undefined): Date | undefined => { return new Date(date) } -export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showAssignee: _showAssignee = false, showPatient = true, onRefetch, className, fullWidth: _fullWidth = false, extraContent }: TaskCardViewProps) => { +export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showAssignee: _showAssignee = false, showPatient = true, className, fullWidth: _fullWidth = false, extraContent }: TaskCardViewProps) => { const router = useRouter() const [selectedUserId, setSelectedUserId] = useState(null) const [optimisticDone, setOptimisticDone] = useState(null) @@ -125,7 +124,6 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA onCompleted: () => { pendingCheckedRef.current = null setOptimisticDone(null) - onRefetch?.() }, onError: () => { pendingCheckedRef.current = null @@ -138,7 +136,6 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA onCompleted: () => { pendingCheckedRef.current = null setOptimisticDone(null) - onRefetch?.() }, onError: () => { pendingCheckedRef.current = null @@ -259,14 +256,14 @@ export const TaskCardView = ({ task, onToggleDone: _onToggleDone, onClick, showA {dueDate && (
- +
)} {expectedFinishDate && (
- +
)} diff --git a/web/components/tasks/TaskDataEditor.tsx b/web/components/tasks/TaskDataEditor.tsx index b5a8979..5be4800 100644 --- a/web/components/tasks/TaskDataEditor.tsx +++ b/web/components/tasks/TaskDataEditor.tsx @@ -43,14 +43,14 @@ type TaskFormValues = CreateTaskInput & { interface TaskDataEditorProps { id: null | string, initialPatientId?: string, - onSuccess?: () => void, + onListSync?: () => void, onClose?: () => void, } export const TaskDataEditor = ({ id, initialPatientId, - onSuccess, + onListSync, onClose, }: TaskDataEditorProps) => { const translation = useTasksTranslation() @@ -83,7 +83,6 @@ export const TaskDataEditor = ({ const updateTask = (vars: { id: string, data: UpdateTaskInput }) => { updateTaskMutate({ variables: vars, - onCompleted: () => onSuccess?.(), onError: (err) => { setErrorDialog({ isOpen: true, @@ -123,7 +122,7 @@ export const TaskDataEditor = ({ } as CreateTaskInput & { priority?: TaskPriority | null, estimatedTime?: number | null } }, onCompleted: () => { - onSuccess?.() + onListSync?.() onClose?.() }, onError: (error) => { @@ -501,7 +500,7 @@ export const TaskDataEditor = ({ deleteTask({ variables: { id: taskId }, onCompleted: () => { - onSuccess?.() + onListSync?.() onClose?.() }, }) diff --git a/web/components/tasks/TaskDetailView.tsx b/web/components/tasks/TaskDetailView.tsx index ec9d8ce..a8d1f7c 100644 --- a/web/components/tasks/TaskDetailView.tsx +++ b/web/components/tasks/TaskDetailView.tsx @@ -14,11 +14,11 @@ import { useUpdateTask } from '@/data' interface TaskDetailViewProps { taskId: string | null, onClose: () => void, - onSuccess: () => void, + onListSync?: () => void, initialPatientId?: string, } -export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: TaskDetailViewProps) => { +export const TaskDetailView = ({ taskId, onClose, onListSync, initialPatientId }: TaskDetailViewProps) => { const translation = useTasksTranslation() const isEditMode = !!taskId @@ -83,9 +83,8 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: properties: propertyInputs, }, }, - onCompleted: () => onSuccess(), }) - }, [isEditMode, taskId, taskData, convertPropertyValueToInput, updateTask, onSuccess]) + }, [isEditMode, taskId, taskData, convertPropertyValueToInput, updateTask]) return ( @@ -96,7 +95,7 @@ export const TaskDetailView = ({ taskId, onClose, onSuccess, initialPatientId }: diff --git a/web/data/subscriptions/useApolloGlobalSubscriptions.ts b/web/data/subscriptions/useApolloGlobalSubscriptions.ts index dbe22ba..bcc56e1 100644 --- a/web/data/subscriptions/useApolloGlobalSubscriptions.ts +++ b/web/data/subscriptions/useApolloGlobalSubscriptions.ts @@ -1,6 +1,8 @@ import { useEffect, useRef } from 'react' import { parse } from 'graphql' import type { ApolloClient } from '@apollo/client/core' +import { GetPatientsDocument, GetTasksDocument } from '@/api/gql/generated' +import { getParsedDocument } from '@/data/hooks/queryHelpers' import { mergeTaskUpdatedIntoCache, mergePatientUpdatedIntoCache, @@ -101,7 +103,9 @@ export function useApolloGlobalSubscriptions( await mergeTaskUpdatedIntoCache(client, taskId, payloadObj, optionsRef.current).catch( () => {} ) - client.refetchQueries({ include: 'active' }) + await client.refetchQueries({ + include: [getParsedDocument(GetPatientsDocument)], + }) } finally { removeRefreshingTask(taskId) } @@ -132,7 +136,9 @@ export function useApolloGlobalSubscriptions( await mergePatientUpdatedIntoCache(client, patientId, payloadObj, optionsRef.current).catch( () => {} ) - client.refetchQueries({ include: 'active' }) + await client.refetchQueries({ + include: [getParsedDocument(GetTasksDocument)], + }) } finally { removeRefreshingPatient(patientId) } @@ -163,7 +169,9 @@ export function useApolloGlobalSubscriptions( await mergePatientUpdatedIntoCache(client, patientId, payloadObj, optionsRef.current).catch( () => {} ) - client.refetchQueries({ include: 'active' }) + await client.refetchQueries({ + include: [getParsedDocument(GetTasksDocument)], + }) } finally { removeRefreshingPatient(patientId) } diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 2c769d5..5a98751 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -167,7 +167,6 @@ const Dashboard: NextPage = () => { setSelectedTaskId(null)} - onSuccess={() => {}} /> )} diff --git a/web/utils/hightideDateFormat.ts b/web/utils/hightideDateFormat.ts new file mode 100644 index 0000000..e2f9d6e --- /dev/null +++ b/web/utils/hightideDateFormat.ts @@ -0,0 +1,57 @@ +export type DateTimeFormat = 'date' | 'time' | 'dateTime' + +const timesInSeconds = { + second: 1, + minute: 60, + hour: 3600, + day: 86400, + week: 604800, + monthImprecise: 2629800, + yearImprecise: 31557600, +} as const + +export function formatAbsoluteHightide(date: Date, locale: string, format: DateTimeFormat): string { + let options: Intl.DateTimeFormatOptions + + switch (format) { + case 'date': + options = { + year: '2-digit', + month: '2-digit', + day: '2-digit', + } + break + case 'time': + options = { + hour: '2-digit', + minute: '2-digit', + } + break + case 'dateTime': + options = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + } + break + } + + return new Intl.DateTimeFormat(locale, options).format(date) +} + +export function formatRelativeHightide(date: Date, locale: string): string { + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) + const now = new Date() + const diffInSeconds = (date.getTime() - now.getTime()) / 1000 + + if (Math.abs(diffInSeconds) < timesInSeconds.minute) return rtf.format(Math.round(diffInSeconds), 'second') + if (Math.abs(diffInSeconds) < timesInSeconds.hour) return rtf.format(Math.round(diffInSeconds / timesInSeconds.minute), 'minute') + if (Math.abs(diffInSeconds) < timesInSeconds.day) return rtf.format(Math.round(diffInSeconds / timesInSeconds.hour), 'hour') + if (Math.abs(diffInSeconds) < timesInSeconds.week) return rtf.format(Math.round(diffInSeconds / timesInSeconds.day), 'day') + if (Math.abs(diffInSeconds) < timesInSeconds.monthImprecise) return rtf.format(Math.round(diffInSeconds / timesInSeconds.week), 'week') + if (Math.abs(diffInSeconds) < timesInSeconds.yearImprecise) return rtf.format(Math.round(diffInSeconds / timesInSeconds.monthImprecise), 'month') + + return rtf.format(Math.round(diffInSeconds / timesInSeconds.yearImprecise), 'year') +}