diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e62f67af --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,277 @@ +# AGENTS.md + +This document is the operational guide for AI agents contributing to `helpwave/tasks`. + +Use it as the source of truth for how to understand the repository, make safe changes, and deliver high-quality contributions with minimal back-and-forth. + +## 1) Mission and Product Context + +`helpwave/tasks` is an open-source healthcare operations platform for ward and task management. + +Primary goals: +- Organize clinical tasks and patient workflows +- Reflect ward/location hierarchy and team context +- Support real-time collaborative updates +- Maintain auditability and operational reliability + +Core architecture: +- `backend/`: FastAPI + Strawberry GraphQL API +- `web/`: Next.js (Pages Router) frontend +- `tests/`: Playwright E2E package +- `simulator/`: traffic/workflow simulator +- `proxy/`: Nginx reverse proxy for production-style routing +- `keycloak/`: identity provider config +- `scaffold/`: initial location tree and seed-like structure + +## 2) Ground Rules for All Agents + +1. Prefer precise, minimal changes over broad refactors unless explicitly requested. +2. Preserve existing behavior unless the task requires changing it. +3. Never commit secrets (`.env`, credentials, tokens, private keys). +4. Never run destructive git commands unless explicitly instructed. +5. Do not edit generated files manually unless the task explicitly asks for it. +6. Keep code clean, explicit, and reusable. +7. Do not add code comments unless explicitly requested. +8. Validate changes locally with relevant checks before finalizing. + +## 3) Repository Map and Ownership Boundaries + +### Backend (`backend/`) +- Language: Python (FastAPI, Strawberry GraphQL, SQLAlchemy async, Alembic) +- Responsibilities: + - GraphQL schema and resolvers + - Auth and request context + - Authorization logic (location-scoped visibility/access) + - Database persistence and migrations + - Redis-backed pub/sub notifications + - InfluxDB-backed audit logging + +Important paths: +- `backend/main.py` +- `backend/config.py` +- `backend/auth.py` +- `backend/api/context.py` +- `backend/api/resolvers/` +- `backend/api/services/authorization.py` +- `backend/database/models/` +- `backend/database/migrations/` + +### Frontend (`web/`) +- Language: TypeScript (Next.js + React) +- Responsibilities: + - UI routes/pages and app shell + - OIDC session handling and auth redirects + - GraphQL operations and cache synchronization + - Realtime subscriptions and optimistic updates + - Localization and theme/UX settings + +Important paths: +- `web/pages/_app.tsx` +- `web/hooks/useAuth.tsx` +- `web/api/auth/authService.ts` +- `web/providers/ApolloProviderWithData.tsx` +- `web/data/` +- `web/components/layout/Page.tsx` +- `web/utils/config.ts` +- `web/api/graphql/` +- `web/api/gql/generated.ts` (generated) +- `web/locales/` and `web/i18n/translations.ts` (generated output) + +### End-to-End Tests (`tests/`) +- Playwright tests and config for full user-flow verification. + +### Simulator (`simulator/`) +- Python tool to simulate realistic workflow traffic in development/testing. + +## 4) Local Development Workflow + +## Start infrastructure +From repository root: + +```bash +docker compose -f docker-compose.dev.yml up -d postgres redis keycloak influxdb +``` + +## Backend +```bash +cd backend +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +alembic upgrade head +uvicorn main:app --reload +``` + +## Frontend +```bash +cd web +npm install +npm run dev +``` + +## Optional simulator +```bash +cd simulator +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +## 5) Required Generation Steps + +These are mandatory when relevant files change: + +1. If any `*.graphql` file changed in `web/`: +```bash +cd web +npm run generate-graphql +``` + +2. If any `*.arb` file changed in `web/locales/`: +```bash +cd web +npm run build-intl +``` + +Never skip these steps when applicable. + +## 6) Validation Checklist Before Finalizing + +Run only what is relevant to your change scope. + +### Frontend changes +```bash +cd web +npm run lint +npm run check-translations +``` + +### Backend changes +```bash +cd backend +pytest tests/unit -v +pytest tests/integration -v +``` + +### E2E-impacting changes +```bash +cd tests +npm install +npx playwright test +``` + +If you cannot run a check, explicitly state what you could not run and why. + +## 7) Change Strategy by Task Type + +### Feature work +- Follow existing domain patterns. +- Prefer extending existing service/hook/provider layers over introducing parallel systems. + +### Bug fixes +- Reproduce mentally or with tests. +- Fix at the narrowest reliable layer. +- Verify no regression in adjacent flows. + +### Refactors +- Keep behavior stable. +- Make incremental commits when requested. +- Avoid mixing refactor and feature changes unless required. + +### API and schema changes +- Update backend schema/resolvers/types coherently. +- Regenerate frontend GraphQL types/documents. +- Update consuming hooks/components. + +## 8) Architecture-Specific Guidance + +### Backend request flow +Standard flow: +1. Auth token/cookie extraction +2. User/context hydration +3. Authorization checks (location-scope) +4. Resolver/service execution +5. DB changes and optional audit emission +6. Redis pub/sub notifications for subscribers + +When changing behavior, identify which stage is affected and keep boundaries clear. + +### Frontend data flow +Standard flow: +1. UI event from page/component +2. Hook/provider mutation or query execution +3. Auth header attachment +4. GraphQL request over HTTP or subscription over WS +5. Cache update, optimistic reconciliation, and UI refresh + +When adding data access: +- Prefer existing patterns in `web/data/hooks/` and current provider setup. +- Keep cache consistency and subscription behavior in mind. + +## 9) Environment and Configuration Rules + +### Backend +- Review `backend/config.py` for canonical env contracts. +- Ensure local Redis URL matches compose password settings. +- Do not hardcode environment-specific URLs in source. + +### Frontend +- Use runtime config through `web/utils/config.ts`. +- Treat `RUNTIME_*` values as public runtime configuration. +- Keep local defaults and production runtime injection behavior intact. + +## 10) Code Quality and Style Expectations + +1. Use explicit typing where practical. +2. Keep functions/components focused and reusable. +3. Favor existing utility modules before adding new helpers. +4. Avoid dead code, hidden side effects, and unnecessary indirection. +5. Keep naming aligned with existing domain language (`task`, `patient`, `location`, `property`, `view`, `preset`). +6. Do not introduce comments unless asked; write self-explanatory code. + +## 11) Safe Git Practices for Agents + +1. Inspect current git status before making edits. +2. Do not revert unrelated user changes. +3. Stage only files relevant to the requested task. +4. Never force-push unless explicitly instructed. +5. Do not amend commits unless explicitly requested. +6. Do not create commits unless explicitly requested. + +## 12) Common Pitfalls + +1. Forgetting to run `npm run generate-graphql` after GraphQL document changes. +2. Forgetting to run `npm run build-intl` after locale ARB changes. +3. Assuming auth/permissions are purely frontend concerns instead of backend-enforced. +4. Breaking Redis-backed realtime paths by changing event channels without updating subscribers. +5. Mixing broad refactors with urgent bug fixes in one change. +6. Editing generated files directly. + +## 13) High-Value Files to Read First + +1. `README.md` +2. `backend/main.py` +3. `backend/config.py` +4. `backend/api/context.py` +5. `backend/api/services/authorization.py` +6. `backend/api/resolvers/__init__.py` +7. `backend/database/models/` +8. `web/package.json` +9. `web/pages/_app.tsx` +10. `web/hooks/useAuth.tsx` +11. `web/api/auth/authService.ts` +12. `web/providers/ApolloProviderWithData.tsx` +13. `web/data/client.ts` +14. `web/utils/config.ts` +15. `.github/workflows/tests.yml` + +## 14) Definition of Done for Agent Tasks + +A task is done when: +1. Requested behavior is implemented correctly. +2. Relevant generation steps are completed. +3. Relevant tests/lint/type checks pass (or limitations are clearly reported). +4. No unrelated files are modified. +5. Final handoff clearly states what changed and how it was validated. + diff --git a/backend/api/query/adapters/patient.py b/backend/api/query/adapters/patient.py index d153e613..d93c9b84 100644 --- a/backend/api/query/adapters/patient.py +++ b/backend/api/query/adapters/patient.py @@ -48,6 +48,15 @@ def _ensure_position_join(query: Select[Any], ctx: dict[str, Any]) -> tuple[Sele return query, ln +def _ensure_clinic_join(query: Select[Any], ctx: dict[str, Any]) -> tuple[Select[Any], Any]: + if "clinic_node" in ctx: + return query, ctx["clinic_node"] + ln = aliased(models.LocationNode) + ctx["clinic_node"] = ln + query = query.outerjoin(ln, models.Patient.clinic_id == ln.id) + return query, ln + + def _parse_property_key(field_key: str) -> str | None: if not field_key.startswith("property_"): return None @@ -55,7 +64,6 @@ def _parse_property_key(field_key: str) -> str | None: LOCATION_SORT_KEY_KINDS: dict[str, tuple[str, ...]] = { - "location-CLINIC": ("CLINIC", "PRACTICE"), "location-WARD": ("WARD",), "location-ROOM": ("ROOM",), "location-BED": ("BED",), @@ -63,7 +71,6 @@ def _parse_property_key(field_key: str) -> str | None: LOCATION_SORT_KEY_LABELS: dict[str, str] = { - "location-CLINIC": "Clinic", "location-WARD": "Ward", "location-ROOM": "Room", "location-BED": "Bed", @@ -233,6 +240,14 @@ def apply_patient_filter_clause( return query return query + if key == "clinic": + query, ln = _ensure_clinic_join(query, ctx) + expr = location_title_expr(ln) + c = apply_ops_to_column(expr, op, val) + if c is not None: + query = query.where(c) + return query + return query @@ -275,10 +290,12 @@ def apply_patient_sorts( else models.Patient.lastname.asc() ) elif key == "name": - expr = patient_display_name_expr(models.Patient) - order_parts.append( - expr.desc().nulls_last() if desc_order else expr.asc().nulls_first() - ) + if desc_order: + order_parts.append(models.Patient.lastname.desc().nulls_last()) + order_parts.append(models.Patient.firstname.desc().nulls_last()) + else: + order_parts.append(models.Patient.lastname.asc().nulls_first()) + order_parts.append(models.Patient.firstname.asc().nulls_first()) elif key == "state": order_parts.append( _state_order_case().desc() @@ -307,6 +324,12 @@ def apply_patient_sorts( order_parts.append( t.desc().nulls_last() if desc_order else t.asc().nulls_first() ) + elif key == "clinic": + query, ln = _ensure_clinic_join(query, ctx) + t = location_title_expr(ln) + order_parts.append( + t.desc().nulls_last() if desc_order else t.asc().nulls_first() + ) elif key in LOCATION_SORT_KEY_KINDS: query, lineage_nodes = _ensure_position_lineage_joins(query, ctx) t = _location_title_for_kind(lineage_nodes, LOCATION_SORT_KEY_KINDS[key]) @@ -471,6 +494,16 @@ def build_patient_queryable_fields_static() -> list[QueryableField]: sort_directions=sort_directions_for(True), searchable=True, ), + QueryableField( + key="clinic", + label="Clinic", + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.STRING, + allowed_operators=str_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + ), QueryableField( key="position", label="Location", diff --git a/web/components/tables/PatientList.tsx b/web/components/tables/PatientList.tsx index 65bb7d32..393988a4 100644 --- a/web/components/tables/PatientList.tsx +++ b/web/components/tables/PatientList.tsx @@ -61,6 +61,7 @@ export type PatientViewModel = { name: string, firstname: string, lastname: string, + clinic: GetPatientsQuery['patients'][0]['clinic'] | null, position: GetPatientsQuery['patients'][0]['position'], openTasksCount: number, closedTasksCount: number, @@ -407,6 +408,7 @@ export const PatientList = forwardRef(({ initi name: p.name, firstname: p.firstname, lastname: p.lastname, + clinic: p.clinic ?? null, birthdate: new Date(p.birthdate), sex: p.sex, state: p.state, @@ -579,6 +581,28 @@ export const PatientList = forwardRef(({ initi size: 160, maxSize: 200, }, + { + id: 'clinic', + header: translation('clinic'), + accessorFn: ({ clinic }: PatientViewModel) => clinic?.title, + cell: ({ row }: { row: Row }) => { + if (refreshingPatientIds.has(row.original.id)) return rowLoadingCell + const clinic = row.original.clinic + return ( + <> + {clinic?.title} + + + ) + }, + minSize: 160, + size: 220, + maxSize: 300, + }, { id: 'position', header: translation('location'), @@ -596,7 +620,7 @@ export const PatientList = forwardRef(({ initi size: 260, maxSize: 320, }, - ...(['CLINIC', 'WARD', 'ROOM', 'BED'] as const).map((kind): ColumnDef => ({ + ...(['WARD', 'ROOM', 'BED'] as const).map((kind): ColumnDef => ({ id: `location-${kind}`, header: translation(LOCATION_KIND_HEADERS[kind] as 'locationClinic' | 'locationWard' | 'locationRoom' | 'locationBed'), accessorFn: (row: PatientViewModel) => { @@ -760,8 +784,8 @@ export const PatientList = forwardRef(({ initi 'birthdate': translation('birthdate'), 'sex': translation('sex'), 'state': translation('status'), + 'clinic': translation('clinic'), 'position': translation('location'), - 'location-CLINIC': translation('locationClinic'), 'location-WARD': translation('locationWard'), 'location-ROOM': translation('locationRoom'), 'location-BED': translation('locationBed'), @@ -807,6 +831,12 @@ export const PatientList = forwardRef(({ initi dataType: 'singleTag', tags: allPatientStates.map(state => ({ label: translation('patientState', { state: state as string }), tag: state })), }, + { + id: 'clinic', + label: translation('clinic'), + dataType: 'text', + tags: [], + }, { id: 'sex', label: translation('sex'), @@ -817,7 +847,7 @@ export const PatientList = forwardRef(({ initi { label: translation('diverse'), tag: Sex.Unknown }, ], }, - ...(['CLINIC', 'WARD', 'ROOM', 'BED'] as const).map((kind): FilterListItem => ({ + ...(['WARD', 'ROOM', 'BED'] as const).map((kind): FilterListItem => ({ id: `location-${kind}`, label: translation(LOCATION_KIND_HEADERS[kind] as 'locationClinic' | 'locationWard' | 'locationRoom' | 'locationBed'), dataType: 'text', diff --git a/web/components/views/TaskViewPatientsPanel.tsx b/web/components/views/TaskViewPatientsPanel.tsx index 70538501..290ec0a7 100644 --- a/web/components/views/TaskViewPatientsPanel.tsx +++ b/web/components/views/TaskViewPatientsPanel.tsx @@ -41,6 +41,7 @@ function buildEmbeddedPatientsFromTasks(tasks: GetTasksQuery['tasks']): PatientV birthdate: new Date(patient.birthdate), sex: patient.sex, state: patient.state, + clinic: patient.clinic, position: patient.position, openTasksCount: countForAggregate ? open : 0, closedTasksCount: countForAggregate ? closed : 0, diff --git a/web/data/mockPatients.ts b/web/data/mockPatients.ts index 4971daef..6867bd09 100644 --- a/web/data/mockPatients.ts +++ b/web/data/mockPatients.ts @@ -18,6 +18,7 @@ export const MOCK_PATIENT_A: PatientViewModel = { name: 'Patient A', firstname: 'Patient', lastname: 'A', + clinic: null, position: null, openTasksCount: 0, closedTasksCount: 0, @@ -33,6 +34,7 @@ export const MOCK_PATIENT_B: PatientViewModel = { name: 'Patient B', firstname: 'Patient', lastname: 'B', + clinic: null, position: null, openTasksCount: 0, closedTasksCount: 0, @@ -48,6 +50,7 @@ export const MOCK_PATIENT_C: PatientViewModel = { name: 'Patient C', firstname: 'Patient', lastname: 'C', + clinic: null, position: null, openTasksCount: 0, closedTasksCount: 0, @@ -63,6 +66,7 @@ export const MOCK_PATIENT_D: PatientViewModel = { name: 'Patient D', firstname: 'Patient', lastname: 'D', + clinic: null, position: null, openTasksCount: 0, closedTasksCount: 0, @@ -78,6 +82,7 @@ export const MOCK_PATIENT_E: PatientViewModel = { name: 'Patient E', firstname: 'Patient', lastname: 'E', + clinic: null, position: null, openTasksCount: 0, closedTasksCount: 0, diff --git a/web/package-lock.json b/web/package-lock.json index bf46f8d9..4c0eea1e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -6829,6 +6829,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/web/utils/overviewRecentPatientToPatientViewModel.ts b/web/utils/overviewRecentPatientToPatientViewModel.ts index 374aadf1..b4e38a1b 100644 --- a/web/utils/overviewRecentPatientToPatientViewModel.ts +++ b/web/utils/overviewRecentPatientToPatientViewModel.ts @@ -17,6 +17,7 @@ export function overviewRecentPatientToPatientViewModel(p: OverviewRecentPatient birthdate: new Date(p.birthdate), sex: p.sex, state: p.state, + clinic: null, position: p.position as PatientViewModel['position'], openTasksCount: countForAggregate ? tasks.filter(t => !t.done).length : 0, closedTasksCount: countForAggregate ? tasks.filter(t => t.done).length : 0, diff --git a/web/utils/virtualDerivedTableState.ts b/web/utils/virtualDerivedTableState.ts index fc524f76..04838370 100644 --- a/web/utils/virtualDerivedTableState.ts +++ b/web/utils/virtualDerivedTableState.ts @@ -271,6 +271,9 @@ function patientMatchesColumnFilter(patient: PatientViewModel, filter: ColumnFil } return matchesTextOperator(patient.position?.title ?? '', op, fv.parameter.stringValue ?? '') } + if (id === 'clinic') { + return matchesTextOperator(patient.clinic?.title ?? '', op, fv.parameter.stringValue ?? '') + } if (id === 'tasks') { const open = patient.openTasksCount const closed = patient.closedTasksCount @@ -392,7 +395,9 @@ function comparePatientBySortId( const cmp = (x: number) => x * dir if (sortId === 'name') { - return cmp(a.name.localeCompare(b.name)) + const byLast = a.lastname.localeCompare(b.lastname) + if (byLast !== 0) return cmp(byLast) + return cmp(a.firstname.localeCompare(b.firstname)) } if (sortId === 'state') { return cmp(a.state.localeCompare(b.state)) @@ -409,6 +414,9 @@ function comparePatientBySortId( if (sortId === 'position') { return cmp((a.position?.title ?? '').localeCompare(b.position?.title ?? '')) } + if (sortId === 'clinic') { + return cmp((a.clinic?.title ?? '').localeCompare(b.clinic?.title ?? '')) + } if (sortId === 'tasks') { const ta = a.openTasksCount + a.closedTasksCount const tb = b.openTasksCount + b.closedTasksCount