Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions simulator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ INFLUXDB_URL=http://localhost:8086
INFLUXDB_TOKEN=tasks-token-secret
INFLUXDB_ORG=tasks
INFLUXDB_BUCKET=audit
SIMULATOR_INITIAL_MIN_PATIENTS=25
SIMULATOR_LOOP_SLEEP_SECONDS_MIN=0.12
SIMULATOR_LOOP_SLEEP_SECONDS_MAX=0.45
```

### Authentication Modes
Expand Down Expand Up @@ -110,16 +113,16 @@ The simulator is split into multiple modules:
- Non-Interactive: Direct Grant flow (if USE_DIRECT_GRANT=true and USERNAME/PASSWORD are set) - no browser required
2. Loads current state (locations, patients, tasks, users)
3. Displays the location structure hierarchy
4. Creates initial patients (some in waiting room, some admitted)
5. Continuously performs random actions:
- Create tasks (25%)
- Update tasks (20%)
- Create patients (15%)
- Admit patients (10%)
- Move patients (10%)
- Update positions (8%)
- Discharge patients (7%)
- Add teams (5%)
4. Seeds at least `SIMULATOR_INITIAL_MIN_PATIENTS` patients (default 25; some waiting, some admitted), each with diagnosis-based treatment tasks
5. Continuously performs random actions (approximate weights):
- Create tasks (32%)
- Update tasks (18%)
- Create patients (28%)
- Admit patients (6%)
- Move patients (6%)
- Update positions (4%)
- Discharge patients (3%)
- Add teams (3%)

## Diagnosis Types

Expand Down
23 changes: 23 additions & 0 deletions simulator/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,29 @@
USERNAME = os.getenv("USERNAME", "test")
PASSWORD = os.getenv("PASSWORD", "test")


def _env_int(key: str, default: int) -> int:
raw = os.getenv(key)
if raw is None or raw.strip() == "":
return default
return int(raw)


def _env_float(key: str, default: float) -> float:
raw = os.getenv(key)
if raw is None or raw.strip() == "":
return default
return float(raw)


SIMULATOR_INITIAL_MIN_PATIENTS = _env_int("SIMULATOR_INITIAL_MIN_PATIENTS", 25)
_loop_sleep_min = _env_float("SIMULATOR_LOOP_SLEEP_SECONDS_MIN", 0.12)
_loop_sleep_max = _env_float("SIMULATOR_LOOP_SLEEP_SECONDS_MAX", 0.45)
if _loop_sleep_min > _loop_sleep_max:
_loop_sleep_min, _loop_sleep_max = _loop_sleep_max, _loop_sleep_min
SIMULATOR_LOOP_SLEEP_SECONDS_MIN = _loop_sleep_min
SIMULATOR_LOOP_SLEEP_SECONDS_MAX = _loop_sleep_max

CALLBACK_PORT = 8999
REDIRECT_URI = f"http://localhost:{CALLBACK_PORT}/callback"

Expand Down
36 changes: 24 additions & 12 deletions simulator/simulator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
from typing import Optional
from graphql_client import GraphQLClient
from config import logger
from config import (
SIMULATOR_INITIAL_MIN_PATIENTS,
SIMULATOR_LOOP_SLEEP_SECONDS_MAX,
SIMULATOR_LOOP_SLEEP_SECONDS_MIN,
logger,
)
from location_manager import LocationManager
from patient_manager import PatientManager
from task_manager import TaskManager
Expand Down Expand Up @@ -55,8 +60,10 @@ def run(self) -> None:
self.location_manager.ensure_hospital_structure()
self.location_manager.print_structure()

logger.info("Creating initial patients...")
while len(self.patient_manager.patient_ids) < 5:
logger.info(
f"Creating initial patients (target at least {SIMULATOR_INITIAL_MIN_PATIENTS})...",
)
while len(self.patient_manager.patient_ids) < SIMULATOR_INITIAL_MIN_PATIENTS:
admit_directly = random.random() < 0.4
patient_id, diagnosis = self.patient_manager.create_patient(
admit_directly=admit_directly
Expand All @@ -68,14 +75,14 @@ def run(self) -> None:
logger.info("Starting continuous simulation loop...")

actions = [
(self._action_create_task, 0.25),
(self._action_update_task, 0.20),
(self._action_create_patient, 0.15),
(self._action_admit_patient, 0.10),
(self._action_move_patient, 0.10),
(self._action_update_position, 0.08),
(self._action_discharge_patient, 0.07),
(self._action_add_team, 0.05),
(self._action_create_task, 0.32),
(self._action_update_task, 0.18),
(self._action_create_patient, 0.28),
(self._action_admit_patient, 0.06),
(self._action_move_patient, 0.06),
(self._action_update_position, 0.04),
(self._action_discharge_patient, 0.03),
(self._action_add_team, 0.03),
]

while True:
Expand All @@ -86,7 +93,12 @@ def run(self) -> None:
)[0]

func()
time.sleep(random.uniform(2.0, 5.0))
time.sleep(
random.uniform(
SIMULATOR_LOOP_SLEEP_SECONDS_MIN,
SIMULATOR_LOOP_SLEEP_SECONDS_MAX,
),
)

except KeyboardInterrupt:
logger.info("Simulation stopped by user.")
Expand Down
5 changes: 3 additions & 2 deletions web/components/patients/LoadTaskPresetDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ import { useApplyTaskGraph, useTaskPresets } from '@/data'
import { GetPatientDocument, type TaskPresetsQuery } from '@/api/gql/generated'
import { useSystemSuggestionTasks } from '@/context/SystemSuggestionTasksContext'
import { presetGraphToTaskGraphInput } from '@/utils/taskGraph'
import type { PatientDetailListSuccessHint } from '@/components/patients/patientDetailListHint'

type PresetRow = TaskPresetsQuery['taskPresets'][number]

type LoadTaskPresetDialogProps = {
isOpen: boolean,
onClose: () => void,
patientId: string,
onSuccess?: () => void,
onSuccess?: (hint?: PatientDetailListSuccessHint) => void,
}

export function LoadTaskPresetDialog({
Expand Down Expand Up @@ -110,7 +111,7 @@ export function LoadTaskPresetDialog({
showToast(translation('tasksCreatedFromPreset'))
setConfirmOpen(false)
onClose()
onSuccess?.()
onSuccess?.({ needsPatientListRefetch: true })
},
[
applyTaskGraph,
Expand Down
18 changes: 10 additions & 8 deletions web/components/patients/PatientDataEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useLocations, usePatient } from '@/data'
import { Building2, CheckIcon, Locate, PlusIcon, Users, XIcon } from 'lucide-react'
import { formatLocationPath, formatLocationPathFromId } from '@/utils/location'
import { toISODate } from './PatientDetailView'
import type { PatientDetailListSuccessHint } from './patientDetailListHint'
import { LocationSelectionDialog } from '@/components/locations/LocationSelectionDialog'
import {
useCreatePatient,
Expand All @@ -34,7 +35,7 @@ type PatientFormValues = Omit<CreatePatientInput, 'clinicId' | 'teamIds' | 'posi
interface PatientDataEditorProps {
id: null | string,
initialCreateData?: Partial<CreatePatientInput>,
onSuccess?: () => void,
onSuccess?: (hint?: PatientDetailListSuccessHint) => void,
onClose?: () => void,
onCreateDraftDirtyChange?: (dirty: boolean) => void,
}
Expand Down Expand Up @@ -136,7 +137,7 @@ export const PatientDataEditor = ({
variables: { data },
onCompleted: () => {
onCreateDraftDirtyChange?.(false)
onSuccess?.()
onSuccess?.({ needsPatientListRefetch: true })
onClose?.()
},
onError: (error) => {
Expand Down Expand Up @@ -203,9 +204,10 @@ export const PatientDataEditor = ({
const samePosition = (data.positionId ?? current.position?.id) === current.position?.id
const sameDescription = (data.description ?? current.description ?? '') === (current.description ?? '')
if (sameFirstname && sameLastname && sameBirthdate && sameSex && sameAssignedIds && sameClinic && sameTeamIds && samePosition && sameDescription) return
const needsPatientListRefetch = !sameClinic || !sameTeamIds || !samePosition || !sameAssignedIds
updatePatient({
variables: { id: patientId, data },
onCompleted: () => onSuccess?.(),
onCompleted: () => onSuccess?.({ needsPatientListRefetch }),
})
}
})
Expand Down Expand Up @@ -412,7 +414,7 @@ export const PatientDataEditor = ({
<div className="flex gap-4 flex-wrap">
<Button
disabled={value === PatientState.Admitted}
onClick={() => admitPatient({ variables: { id: patientId! }, onCompleted: () => onSuccess?.() })}
onClick={() => admitPatient({ variables: { id: patientId! }, onCompleted: () => onSuccess?.({ needsPatientListRefetch: true }) })}
color={value === PatientState.Admitted ? 'positive' : 'neutral'}
>
<Visibility isVisible={value === PatientState.Admitted}>
Expand All @@ -432,7 +434,7 @@ export const PatientDataEditor = ({
</Button>
<Button
disabled={value === PatientState.Wait}
onClick={() => waitPatient({ variables: { id: patientId! }, onCompleted: () => onSuccess?.() })}
onClick={() => waitPatient({ variables: { id: patientId! }, onCompleted: () => onSuccess?.({ needsPatientListRefetch: true }) })}
color={value === PatientState.Wait ? 'warning' : 'neutral'}
>
<Visibility isVisible={value === PatientState.Admitted}>
Expand Down Expand Up @@ -605,7 +607,7 @@ export const PatientDataEditor = ({
onCancel={() => setIsMarkDeadDialogOpen(false)}
onConfirm={() => {
if (patientId && markPatientDead) {
markPatientDead({ variables: { id: patientId }, onCompleted: () => onSuccess?.() })
markPatientDead({ variables: { id: patientId }, onCompleted: () => onSuccess?.({ needsPatientListRefetch: true }) })
}
setIsMarkDeadDialogOpen(false)
}}
Expand All @@ -619,7 +621,7 @@ export const PatientDataEditor = ({
onCancel={() => setIsDischargeDialogOpen(false)}
onConfirm={() => {
if (patientId && dischargePatient) {
dischargePatient({ variables: { id: patientId }, onCompleted: () => onSuccess?.() })
dischargePatient({ variables: { id: patientId }, onCompleted: () => onSuccess?.({ needsPatientListRefetch: true }) })
}
setIsDischargeDialogOpen(false)
}}
Expand All @@ -636,7 +638,7 @@ export const PatientDataEditor = ({
deletePatient({
variables: { id: patientId },
onCompleted: () => {
onSuccess?.()
onSuccess?.({ needsPatientListRefetch: true })
onClose?.()
},
})
Expand Down
5 changes: 3 additions & 2 deletions web/components/patients/PatientDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { PatientStateChip } from '@/components/patients/PatientStateChip'
import { LocationChips } from '@/components/locations/LocationChips'
import { PatientTasksView } from './PatientTasksView'
import { PatientDataEditor } from './PatientDataEditor'
import type { PatientDetailListSuccessHint } from './patientDetailListHint'
import { AuditLogTimeline } from '@/components/AuditLogTimeline'
import { PropertyList, type PropertyValue } from '../tables/PropertyList'
import { useUpdatePatient } from '@/data'
Expand Down Expand Up @@ -50,7 +51,7 @@ export const localToUTCWithSameTime = (d: Date | null | undefined): Date | null
interface PatientDetailViewProps {
patientId?: string,
onClose: () => void,
onSuccess: () => void,
onSuccess?: (hint?: PatientDetailListSuccessHint) => void,
initialCreateData?: Partial<CreatePatientInput>,
onOpenSystemSuggestion?: (suggestion: SystemSuggestion, patientName: string) => void,
onCreateDraftDirtyChange?: (dirty: boolean) => void,
Expand Down Expand Up @@ -128,7 +129,7 @@ export const PatientDetailView = ({
properties: propertyInputs,
},
},
onCompleted: () => onSuccess(),
onCompleted: () => onSuccess?.({ needsPatientListRefetch: false }),
})
}, [isEditMode, patientId, patientData, convertPropertyValueToInput, updatePatient, onSuccess])

Expand Down
9 changes: 5 additions & 4 deletions web/components/patients/PatientTasksView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import type { GetPatientQuery } from '@/api/gql/generated'
import { TaskDetailView } from '@/components/tasks/TaskDetailView'
import { useCompleteTask, useReopenTask } from '@/data'
import { LoadTaskPresetDialog } from '@/components/patients/LoadTaskPresetDialog'
import type { PatientDetailListSuccessHint } from '@/components/patients/patientDetailListHint'

interface PatientTasksViewProps {
patientId: string,
patientData: GetPatientQuery | undefined,
onSuccess?: () => void,
onSuccess?: (hint?: PatientDetailListSuccessHint) => void,
}

const sortByDueDate = <T extends { dueDate?: string | Date | null }>(tasks: T[]): T[] => {
Expand Down Expand Up @@ -63,7 +64,7 @@ export const PatientTasksView = ({
if (done) {
completeTask({
variables: { id: taskId },
onCompleted: () => onSuccess?.(),
onCompleted: () => onSuccess?.({ needsPatientListRefetch: true }),
onError: () => {
setOptimisticTaskUpdates(prev => {
const next = new Map(prev)
Expand All @@ -75,7 +76,7 @@ export const PatientTasksView = ({
} else {
reopenTask({
variables: { id: taskId },
onCompleted: () => onSuccess?.(),
onCompleted: () => onSuccess?.({ needsPatientListRefetch: true }),
onError: () => {
setOptimisticTaskUpdates(prev => {
const next = new Map(prev)
Expand Down Expand Up @@ -190,7 +191,7 @@ export const PatientTasksView = ({
initialPatientId={isCreatingTask ? patientId : undefined}
initialPatientName={isCreatingTask ? initialPatientName : undefined}
onListSync={() => {
onSuccess?.()
onSuccess?.({ needsPatientListRefetch: true })
}}
onClose={() => {
setTaskId(null)
Expand Down
3 changes: 3 additions & 0 deletions web/components/patients/patientDetailListHint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type PatientDetailListSuccessHint = {
needsPatientListRefetch: boolean,
}
10 changes: 7 additions & 3 deletions web/components/tables/PatientList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { LocationType } from '@/api/gql/generated'
import { Sex, PatientState, type GetPatientsQuery, type TaskType, PropertyEntity, FieldType, type QueryableField } from '@/api/gql/generated'
import { usePropertyDefinitions, usePatientsPaginated, useQueryableFields, useRefreshingEntityIds } from '@/data'
import { PatientDetailView } from '@/components/patients/PatientDetailView'
import type { PatientDetailListSuccessHint } from '@/components/patients/patientDetailListHint'
import { LocationChips } from '@/components/locations/LocationChips'
import { LocationChipsBySetting } from '@/components/patients/LocationChipsBySetting'
import { PatientStateChip } from '@/components/patients/PatientStateChip'
Expand Down Expand Up @@ -1119,9 +1120,12 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
<PatientDetailView
patientId={selectedPatient?.id ?? openedPatientId ?? undefined}
onClose={closePatientDrawer}
onSuccess={() => {
embeddedOnRefetch?.()
void refetch()
onSuccess={(hint?: PatientDetailListSuccessHint) => {
const needsRefetch = hint?.needsPatientListRefetch ?? true
if (needsRefetch) {
embeddedOnRefetch?.()
void refetch()
}
onPatientUpdated?.()
}}
onOpenSystemSuggestion={openSuggestionModal}
Expand Down
7 changes: 5 additions & 2 deletions web/components/tables/TaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Drawer } from '@helpwave/hightide'
import { TaskDetailView } from '@/components/tasks/TaskDetailView'
import { AvatarStatusComponent } from '@/components/AvatarStatusComponent'
import { PatientDetailView } from '@/components/patients/PatientDetailView'
import type { PatientDetailListSuccessHint } from '@/components/patients/patientDetailListHint'
import { useTasksTranslation } from '@/i18n/useTasksTranslation'
import { useTasksContext } from '@/hooks/useTasksContext'
import { UserInfoPopup } from '@/components/UserInfoPopup'
Expand Down Expand Up @@ -1075,8 +1076,10 @@ export const TaskList = forwardRef<TaskListRef, TaskListProps>(({ tasks: initial
<PatientDetailView
patientId={selectedPatientId}
onClose={() => setSelectedPatientId(null)}
onSuccess={() => {
onRefetch?.()
onSuccess={(hint?: PatientDetailListSuccessHint) => {
if (hint?.needsPatientListRefetch ?? true) {
onRefetch?.()
}
}}
/>
)}
Expand Down
Loading