This is a DHIS2 Event Visualizer application built with React, TypeScript, Vite, Redux Toolkit, and DHIS2 UI components. The application provides event data visualization capabilities within the DHIS2 ecosystem.
- Framework: React 18 with TypeScript (strict mode)
- Build Tool: Vite with DHIS2 Application Platform (v12.10.2)
- State Management: Redux Toolkit with typed hooks
- UI Components: DHIS2 UI (@dhis2/ui), DND Kit for drag-and-drop
- Testing: Vitest (unit tests) + Cypress (component & E2E tests)
- Styling: CSS modules with TypeScript plugin, styled components
- Linting/Formatting: ESLint (
@dhis2/config-eslint), Prettier (@dhis2/config-prettier), Stylelint - i18n: DHIS2 i18n utilities for internationalization
This is an unreleased app under active development. Some defaults that suit stable codebases are loosened:
- Refactor freely when it improves clarity or reduces tech debt, even outside the strict scope of the current task. Scope creep is fine here.
- Keep README and other documentation in sync with code changes: when you change behavior, structure, or setup steps, update the relevant docs in the same change rather than waiting for an explicit ask.
This is a DHIS2 "App Shell App" with a special build structure:
- Location:
.d2/shell/contains generated files for the App Shell - Purpose: Files in
.d2/shell/src/are duplicates of the mainsrc/directory but wrapped in the DHIS2 AppShell - AppShell Context: Components in
.d2/have access to additional React Contexts provided by DHIS2 (authentication, data engine, etc.) - IMPORTANT: Never write or modify files in
.d2/directory - they are generated automatically during build - Editing: Always edit files in the main
src/directory, changes will be reflected in.d2/during rebuild
- Location:
src/types/dhis2-openapi-schemas/ - Contents: Auto-generated TypeScript types from DHIS2 OpenAPI specifications
- Files:
generated.ts(main types) andindex.ts(exports) - Usage: Import types from
@typesalias, not directly from this directory - Regeneration: Run
pnpm generate-typesto regenerate from OpenAPI specs - DO NOT: Edit these files manually - they will be overwritten
- Source code in
src/is the primary development location - During build, files are copied to
.d2/shell/and wrapped with AppShell - AppShell provides DHIS2-specific contexts and runtime
- Generated types provide TypeScript definitions for DHIS2 API
src/ # Source code (primary development location)
├── api/ # API clients and services
├── assets/ # Static assets
├── components/ # React components
├── constants/ # Constants and configuration
├── hooks/ # Custom React hooks (including typed Redux hooks)
├── modules/ # Feature modules
├── store/ # Redux store and slices
├── test-utils/ # Testing utilities
├── types/ # TypeScript type definitions
│ └── dhis2-openapi-schemas/ # GENERATED - Do not edit
└── ...
.d2/ # GENERATED App Shell directory - Do not edit
└── shell/ # App Shell wrapped version with DHIS2 contexts
cypress/ # E2E and component tests
scripts/ # Build and utility scripts
i18n/ # Internationalization files
types/ # Additional auto-generated DHIS2 API types
A helper lives in the domain of what it produces, not the domains it reads from (a function
that reads a visualization but returns dimensions belongs with the dimension helpers). Code not
owned by any one domain — generic, cross-cutting utilities — goes in modules/utils; keep that
bar high so it stays a small set of genuine utilities, not a catch-all for anything awkward to
place. A domain that outgrows a single file becomes a folder of sibling files; import the specific
file you need (@modules/<domain>/<file>). Avoid index.ts barrels — Vite's performance guide
advises against them, since importing one API forces every re-exported module (and its side
effects) to load. This is a guideline, not an absolute — the occasional helper won't have a clean
"output" domain.
pnpm start # Development server on http://localhost:3000
pnpm build # Production build
pnpm deploy # Deploy to DHIS2 instancepnpm test # Run unit tests (vitest)
pnpm test:watch # Run tests in watch mode
pnpm cy:open # Open Cypress E2E GUI
pnpm cy:run # Run E2E tests headless
pnpm cy:comp:open # Open Cypress component testing
pnpm cy:comp:run # Run component tests headlesspnpm lint # Check for lint errors
pnpm format # Fix/format code violationspnpm generate-types # Regenerate DHIS2 API types from OpenAPI specs- Write plainly. Short sentences, common words, no filler.
- Explain things the way you would to a colleague, not in dense prose.
- No hedging or clever phrasing that has to be re-read to parse.
- Prefer a few clear lines over a wall of text.
- Self-documenting code over comments: Prefer well-named intermediate variables and small helpers over explanatory comments. If a block needs a comment to explain what it does, first ask whether extracting a named variable or function would make the comment unnecessary.
- When to comment: Before writing any comment (file-level, function-level, or inline), name which category it falls in: (a) domain/business context that can't be inferred from the code, or (b) code that is genuinely hard to comprehend on its own. If you can't pin it to (a) or (b), don't write it. Default position is no comment. Never write a comment that restates what the next line does, and never write prose that simply summarises a file's or function's purpose — the name and types do that.
- Never include time-bound information: No references to previous implementations, refactor history, future plans, removed alternatives, or comparisons to other helpers that may move/disappear. Comments describe what's there and why — not how the code evolved.
- Multi-line comments always use
/* */. Never stack multiple//lines for a block comment. - JSDoc: Reserve for public API surfaces that genuinely benefit from a brief description.
- Strict mode: TypeScript strict mode is enabled in tsconfig
- Path aliases: Always use path aliases (
@hooks,@components,@api/*,@types) - never use relative parent imports (../) - Type imports: Use
import typefor type-only imports (@typescript-eslint/consistent-type-imports) - No default exports: Never use default exports (
import/no-default-exporterror) - Generated types: Import from
@typesalias, not directly fromsrc/types/dhis2-openapi-schemas/ - No
any: Avoidanyunless absolutely necessary - use proper typing
- Functional components: Use functional components with hooks
- Restricted hooks:
- DHIS2 app-runtime hooks (
useDataQuery,useDataMutation) are restricted - use RTK hooks fromsrc/hooksinstead - React Redux hooks are restricted - use typed hooks from
src/hooks(useAppDispatch,useAppSelector)
- DHIS2 app-runtime hooks (
- DHIS2 UI components: Use DHIS2 UI components where possible for consistency
- Component size: Keep components focused and small
- Styling: Use CSS modules for styling
- Redux Toolkit: Use Redux Toolkit slices in
src/store/ - Typed hooks: Use typed hooks from
src/hooksfor Redux access (useAppDispatch,useAppSelector) - Async logic: Follow RTK best practices for async logic (createAsyncThunk or RTK Query)
- Custom hooks: Extract reusable logic into custom hooks (see
src/hooks/)
- Components: PascalCase (
EventChart.tsx) - Hooks: camelCase with
useprefix (useEventData.ts) - Types: PascalCase (
EventDataItem) - Files: kebab-case for utilities, PascalCase for components
- CSS Modules: kebab-case (e.g.,
event-chart.module.css)
- Test behavior, not implementation details: assert on what callers observe, not on private state, internal call sequences, or how a result was produced.
- Cover new functionality: when adding or changing logic, add tests in the same change — including edge cases and error conditions, not just the happy path.
- Files:
*.spec.tsor*.spec.tsx - Location: Co-located with source or in
__tests__directories - Import: Import testing utilities from 'vitest', not global
- Testing Library: Use
@testing-library/reactfor component testing - Coverage: Write tests for utilities and complex logic
- Mocking: Mock DHIS2 API calls appropriately
- Files:
*.cy.tsx - Commands: Use
@testing-library/cypresscommands - Purpose: Test component behavior in isolation
- Files:
*.cy.ts - Commands: Use
@dhis2/cypress-commandsfor DHIS2-specific commands - Purpose: Test critical user flows
- Environment: Test against DHIS2 instances (see
cypress.env.json)
When to use fake timers:
Use fake timers when you need to:
- Make assertions at specific points in time - When testing debounce logic, loading states, or other time-dependent behavior
- Skip waiting for long timeouts - When tests involve waiting for delays (e.g., API calls, debounce periods) and you want to advance time programmatically
Common scenarios:
- Hook tests with debounce logic (e.g.,
useDebounceValue) - Tests with
setTimeout,setInterval, or other timing-based logic - Tests that would otherwise use
waitFor()with long timeouts
How to use fake timers in hook tests:
Key Principle: Avoid waitFor() - Testing Library's waitFor() uses real timers internally and conflicts with fake timers.
- DO NOT use
renderHookWithAppWrapper- It useswaitFor()internally which conflicts with fake timers - Choose the right test wrapper based on dependencies:
- If the hook uses Redux: Use
renderHookWithReduxStoreProviderwith a real store created viasetupStore() - If the hook doesn't use Redux: Use
renderHook()directly or create a minimal wrapper withoutwaitFor() - If the hook needs other contexts: Create a custom wrapper that doesn't use
waitFor()
- If the hook uses Redux: Use
- Enable fake timers in
beforeEach()and restore inafterEach() - Control time with
vi.advanceTimersByTimeAsync(ms)instead ofwaitFor()
Example with Redux:
import { renderHookWithReduxStoreProvider } from '@test-utils/render-with-redux-store-provider'
import { setupStore } from '@test-utils/setup-store'
describe('useMyHook', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('debounces the API call', async () => {
const store = setupStore(
{ mySlice: mySliceReducer },
{ mySlice: { someState: 'value' } }
)
const { result } = renderHookWithReduxStoreProvider(
() => useMyHook(),
store
)
// Trigger action
act(() => {
store.dispatch(someAction())
})
// Advance timers to complete debounce + API call
await act(() => vi.advanceTimersByTimeAsync(300 + apiDelay))
// Assert
expect(result.current.data).toBeDefined()
})
})Example without Redux (unit test):
import { renderHook } from '@testing-library/react'
describe('useMyUtilityHook', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('delays execution', async () => {
const { result } = renderHook(() => useMyUtilityHook())
// Trigger effect
act(() => {
result.current.triggerDelay()
})
// Advance timers
await act(() => vi.advanceTimersByTimeAsync(1000))
// Assert delayed behavior
expect(result.current.isComplete).toBe(true)
})
})Reference: See use-dimension-list.spec.ts for a comprehensive example with 41 hook tests using fake timers (runs in 82ms vs 11.26s with real timers). Also see use-delayed-is-loading-more.spec.ts for a simpler example testing a custom hook with debounce-like timing behavior.
- App runtime hooks: Use DHIS2 app runtime hooks (
useDataQuery,useDataMutation,useConfig, etc.) through RTK wrappers - Design system: Follow DHIS2 design system and patterns
- Internationalization: Use DHIS2 i18n utilities for all user-facing strings
- Testing environments: Test against DHIS2 instances (development and production)
- Authentication: Handle DHIS2 authentication and authorization properly
- Browser testing: On the host, drive the running app with claude-in-chrome (works in both terminal and desktop Claude Code; the developer sets it up via the Claude browser extension — see README). If you need browser automation on the host and it isn't connected, ask the human to set it up. In the AI sandboxes, use the
playwright-clitool instead (see docs/claude-sandboxes.md). Readcypress.env.json(gitignored) for the DHIS2 server URL and login credentials. The dev server onlocalhost:3000shows a login form requiring Server, Username, and Password - Deployment: App can be deployed as both a standalone app and a plugin
AI models frequently hallucinate DHIS2 API details — endpoint paths, query parameters, response shapes, and filter syntax all evolve between versions. Do not rely on training data for DHIS2 API specifics. Instead, consult the actual API of the target instance using the approaches below.
Read cypress.env.json (gitignored) for the DHIS2 server URL and credentials. Use these for all
API interactions described in this section.
Fetch the scoped OpenAPI spec for the endpoint you need. The DHIS2 API supports path-filtered specs so you get only the relevant section:
curl -u <user>:<pass> "<server>/api/openapi.yaml?path=/<resource>"Examples:
/api/openapi.yaml?path=/analytics— analytics endpoints/api/openapi.yaml?path=/trackedEntities— tracker endpoints/api/openapi.yaml?path=/organisationUnits— org unit endpoints
This gives you endpoint paths, HTTP methods, query parameters, and request/response schemas — accurate for the exact server version. Use this as the first step when working with any DHIS2 API endpoint.
When the OpenAPI spec doesn't fully answer the question — especially for complex endpoints like
/api/analytics where responses vary based on query parameters — make GET requests against the
dev instance to see actual data:
curl -u <user>:<pass> "<server>/api/<resource>?<params>"Rules:
- GET requests only — never POST, PUT, PATCH, or DELETE against the instance
- Use the dev/test instance from
cypress.env.json, never a production server - Limit response size — use
pageSize=1orpageSize=5andfields=filtering to avoid flooding context with large responses
This is useful for understanding actual response shapes, testing filter syntax, verifying which fields are returned, and exploring dimension/analytics data structures.
When you need to understand how the API works — filter combination logic, validation rules, side effects, or behavior not captured in specs — read the Java source code of the DHIS2 backend.
npx opensrc dhis2/dhis2-core --modifyThis clones the DHIS2 backend source into ./opensrc/repos/github.com/dhis2/dhis2-core/.
The directory is gitignored. Controllers are in dhis-2/dhis-web-api/, DTOs and models in
dhis-2/dhis-api/. Search for the controller class (e.g. AnalyticsController,
OrganisationUnitController).
If the user specifies a DHIS2 version, target that branch: npx opensrc dhis2/dhis2-core#2.41 --modify.
Use an Explore subagent to search the cloned source — the codebase is large and reading Java inline floods context. The subagent can extract the API contract and return a compact summary.
package.json- Dependencies and scriptstsconfig.json- TypeScript configuration (strict mode enabled)vite.config.ts/vite-extensions.config.mts- Vite build configurationcypress.config.ts- Cypress E2E configurationcypress.env.json- Cypress environment (gitignored, see template)eslint.config.mjs- ESLint rules (extends@dhis2/config-eslint).prettierrc.mjs- Prettier formatting rules (re-exports@dhis2/config-prettier).stylelintrc.js- Stylelint CSS/SCSS rules (self-contained)commitlint.config.mjs- Commitlint rules (extends@commitlint/config-conventional)d2.config.js- DHIS2 app configuration
The following are enabled for this project via .claude/settings.json:
- TypeScript LSP (
typescript-lsp): Automatic TypeScript diagnostics after file edits with full project context. See Claude Code Setup in README for per-developer installation. - Context7 (
context7): Library and framework documentation search. Usage: Use proactively when answering questions about specific library/framework APIs; otherwise trigger withuse context7.
- Grep by Vercel (
grep_*): Fast code search across GitHub repositories. Usage: Trigger-based only — adduse the grep toolto your prompts when you want cross-repo search.
Browser automation: On the host, use claude-in-chrome (terminal or desktop Claude Code; requires the developer to install and connect the Claude browser extension — see README). If you need to drive the browser on the host and it isn't set up, you may ask the human to set it up for you. In the AI sandboxes, browser automation is provided by the baked-in playwright-cli (see docs/claude-sandboxes.md).
GitHub: Use the gh CLI via Bash for all GitHub operations (issues, PRs, code search, actions). Requires the GitHub CLI to be installed and authenticated (gh auth login). In the AI sandboxes gh is authenticated read-only (writes fail by design).
Linting: ESLint, Stylelint, and Prettier are run automatically via PostToolUse hooks. For manual checks, use pnpm exec eslint <file-path>.
IMPORTANT FOR AI AGENTS: DO NOT stage files or create commits. The user reviews diffs, stages changes, and commits. Your role is to modify code files only - all git operations (staging, committing, pushing) are the user's responsibility.
The project has lint-staged configured in package.json for pre-commit checks (ESLint, Prettier, Stylelint). Git hooks live in the tracked .hooks/ directory; scripts/postinstall.sh wires them up by pointing core.hooksPath at it.
- Format: Follow conventional commits (
feat:,fix:,refactor:, etc.) - Branches: Use feature branches for development
- PRs: Pull requests should include tests for new functionality
- Atomicity: Keep commits focused and atomic
- Testing: Run tests before committing (
pnpm test,pnpm lint)
- Secrets: Never commit secrets, API keys, or credentials (use environment variables)
- Input validation: Validate and sanitize all user input
- DHIS2 patterns: Use DHIS2 authentication and authorization patterns
- Dependencies: Keep dependencies updated to address security vulnerabilities
- Code splitting: Lazy load heavy components with
React.lazyand dynamic imports - Optimization: Use
memo,useCallback,useMemowhen needed to optimize re-renders - Selectors: Use Redux selectors efficiently
- Bundle optimization: Leverage Vite's bundle splitting via dynamic imports
- DHIS2 API: Optimize DHIS2 API requests (pagination, field filtering, caching)
- Generated files: Files in
.d2/andsrc/types/dhis2-openapi-schemas/are auto-generated - do not edit manually - Cypress environment:
cypress.env.jsonis gitignored - usecypress.env.template.jsonas a reference - Dual deployment: The app can be deployed as both a standalone app and a plugin
- Path aliases: Always prefer path aliases over relative imports for better maintainability
- DHIS2 platform: Leverage DHIS2 Platform capabilities and conventions throughout development
Dimensions in DHIS2 programs are identified by compound IDs — dot-separated strings that
encode program, stage, and dimension context. This system surfaces throughout the codebase: in
visualization objects, the layout, the sidebar, the Redux store (visUiConfig slice), and the
metadata provider. Understanding it is essential when working with any program dimension.
- Tracked entity attribute dimensions are independent of program/stage context — they always have the same properties across tracked entity types and are identified by a plain (non-compound) ID. No special handling is needed.
- Program dimensions are context-dependent and use compound IDs (see below).
- Event programs (
programType: 'WITHOUT_REGISTRATION') always have exactly one stage. - Tracker programs (
WITH_REGISTRATION) may have many stages. A bareprogramId.dimensionIdkey is ambiguous for tracker programs — preferstageId.dimensionIdor the fully explicitprogramId.stageId.dimensionIdform. ProgramStagealways carries aprogram: { id: string }back-reference, so the owning program can be resolved from a stage without a separate lookup.
The DHIS2 backend uses different compound ID formats across endpoints. The frontend normalizes this inconsistency by adopting the analytics format as its canonical internal representation.
Analytics API (/api/analytics/events/query/{programId}): returns stageId.dimensionId in
response headers and metaData.dimensions keys. The program is implicit in the request URL, so
the stage is the only prefix needed to disambiguate dimensions.
eventVisualizations API (/api/eventVisualizations): uses programId.stageId.dimensionId
(or programId.dimensionId) in the persisted columnDimensions/rowDimensions/filterDimensions
string arrays. On the populated columns/rows/filters objects, program and programStage
are transient fields (not persisted) — they are resolved at read time from the qualified dimension
strings and from hydrated Hibernate associations.
Frontend canonical form: stageId.dimensionId — matching the analytics API. This is the
format used in Redux state (metadata keys, layout arrays, visUiConfig). The frontend chose this
format because analytics data flows continuously during rendering, while the visualization API is
only hit on save/load. The translation cost is paid once at the API boundary (see below).
| Form | Example | When used |
|---|---|---|
stageId.dimensionId |
Zj7UnCAulEk.ou |
EVENT/ENROLLMENT — this is the canonical form |
programId.dimensionId |
eBAyeGv0exc.ou |
TRACKED_ENTITY — enrollment-level dimensions (e.g. enrollment date, org unit, status) |
programId.stageId.dimensionId |
eBAyeGv0exc.Zj7UnCAulEk.ou |
TRACKED_ENTITY — stage-level dimensions; collapsed to canonical on ingest |
A repetition index [n] may be appended to the stage segment: ps1[0].ou.
The interpretation of a 2-segment ID depends on outputType:
- EVENT/ENROLLMENT:
part1.part2→stageId.dimensionId(no programId) - TRACKED_ENTITY:
part1.part2→programId.dimensionId(no stageId)
3-segment keys (programId.stageId.dimensionId) are collapsed to stageId.dimensionId on ingest
for EVENT/ENROLLMENT via pure string manipulation (drop the first segment). For TRACKED_ENTITY,
the programId is preserved. programId.dimensionId keys in TRACKED_ENTITY context are stored
as-is because they are semantically tied to the program (enrollment scope), not to any stage.
Fixed dimensions are the structural dimensions that exist for every program/stage (org units,
dates, statuses). They are built by shared helpers in src/modules/dimension/fixed.ts
(getStageFixedDimensions, getEnrollmentFixedDimensions, getTrackedEntityTypeFixedDimensions)
and consumed by both the sidebar cards and the metadata provider.
| Scope | Dimension ID | Compound ID notation | Display name source | Sidebar card |
|---|---|---|---|---|
| Stage | ou |
stageId.ou |
program.displayOrgUnitLabel or "Event org. unit" |
Stage |
| Stage | eventDate |
stageId.eventDate |
stage.displayExecutionDateLabel or "Event date" |
Stage |
| Stage | scheduledDate |
stageId.scheduledDate |
stage.displayDueDateLabel or "Scheduled date" |
Stage |
| Stage | eventStatus |
stageId.eventStatus |
"Event status" | Stage |
| Enrollment | ou |
programId.ou |
program.displayOrgUnitLabel or "Enrollment org. unit" |
Enrollment |
| Enrollment | enrollmentDate |
programId.enrollmentDate |
program.displayEnrollmentDateLabel or "Date of enrollment" |
Enrollment |
| Enrollment | incidentDate |
programId.incidentDate |
program.displayIncidentDateLabel or "Incident date" |
Enrollment |
| Enrollment | programStatus |
programId.programStatus |
"Enrollment status" | Enrollment |
| TEI | enrollmentOu |
trackedEntityTypeId.enrollmentOu |
"Registration org. unit" | Registration |
| TEI | created |
trackedEntityTypeId.created |
"Registration date" | Registration |
Non-fixed dimensions use compound or plain IDs depending on their type:
- Data elements, categories, COGS → compound:
stageId.dimensionId - Program indicators, tracked entity attributes → plain
dimensionId(no prefix, even though their dimension records carryprogram/programStagecontext) - Metadata dims (
lastUpdated,createdBy,lastUpdatedBy,created,completed) → plaindimensionId
getCompoundDimensionId in src/modules/dimension/ids.ts constructs the canonical app-local
compound ID from a DimensionRecord. It applies these rules in order:
PROGRAM_INDICATOR/PROGRAM_ATTRIBUTE→ always plaindimensionId- Enrollment-scoped IDs (
enrollmentOu,enrollmentDate,incidentDate,programStatus) →programId.dimensionId - Has
programStage→stageId.dimensionId(orprogramId.stageId.dimensionIdfor TEI) - Has
program→programId.dimensionId - TEI with
trackedEntityTypeId→trackedEntityTypeId.dimensionId - Otherwise → plain
dimensionId
Org unit scopes: the app uses distinct dimension IDs for different org unit scopes:
- Event org unit:
ouwithprogramStage→ compoundstageId.ou - Enrollment org unit:
enrollmentOu→ compoundprogramId.enrollmentOu.toAppLocalDimensionsrenames APIou(with program, no programStage) toenrollmentOuat the API → app-local boundary.toApiDimensionIddoes the inverse on save — but only in some outputType/visType combinations (see table below). - Registration org unit:
enrollmentOuwithtrackedEntityType(no program/stage) → compoundtetId.enrollmentOu. The TEI registration OU shares theenrollmentOudimension ID with the program-scope enrollment OU; the prefix (programId vs trackedEntityTypeId) distinguishes them.
enrollmentOu POST translation by outputType/visType/scope: the eventVisualizations
POST endpoint accepts enrollmentOu verbatim only when the dim carries a program qualifier
AND the visualization is in EVENT/TEI LINE_LIST mode. Other combinations — including the
TEI registration OU, which has no program qualifier — must be sent as bare ou.
toEventVisualizationDimensionId applies this mapping on save:
| outputType | visType | dim has programId? |
POST dimension | Rewrite enrollmentOu → ou? |
|---|---|---|---|---|
EVENT |
LINE_LIST |
yes | enrollmentOu |
no |
ENROLLMENT |
LINE_LIST |
yes | ou |
yes |
TRACKED_ENTITY_INSTANCE |
LINE_LIST |
yes (program-scope) | enrollmentOu |
no |
TRACKED_ENTITY_INSTANCE |
LINE_LIST |
no (registration) | ou |
yes |
EVENT |
PIVOT_TABLE |
yes | ou |
yes |
ENROLLMENT |
PIVOT_TABLE |
yes | ou |
yes |
Rule: rewrite to ou when the dim has no programId (i.e. TEI registration scope)
OR outputType === 'ENROLLMENT' OR visType === 'PIVOT_TABLE'. Keep as enrollmentOu
only for program-scope dims in EVENT/TRACKED_ENTITY_INSTANCE LINE_LIST.
The reverse (load) direction is shape-based — toAppLocalDimensions rewrites
dim.dimension === 'ou' && !dim.programStage to enrollmentOu. This catches both
program-scope ({dimension: 'ou', program: {id}}) and TEI registration
({dimension: 'ou'}, no program/stage) and leaves stage event OU
({dimension: 'ou', programStage: {id}}) untouched.
Loading (API → frontend): acSetVisualization reads each dimension's program and
programStage from the populated columns/rows/filters objects and calls getFullDimensionId
(or formatDimensionId in the line-listing-app). For EVENT/ENROLLMENT this produces
stageId.dimensionId (dropping the programId). For TRACKED_ENTITY it produces
programId.stageId.dimensionId or programId.dimensionId.
Saving (frontend → API): getAxesFromUi (or equivalent) decomposes the internal compound ID
via getDimensionIdParts (extractDimensionIdParts in the line-listing-app) and sends each
dimension to the API with a plain dimension ID plus separate program and programStage
objects. The backend's mergeAnalyticalObject hydrates the stage from the database (including its
parent program via loadProgramForStage), then getQualifiedDimension rebuilds the persisted
string as programId.stageId.dimensionId.
programDimensions is a computed, read-only field — not persisted. On each GET, the backend
(EventVisualizationController.postProcessResponseEntity) iterates all DimensionalObjects in
columns, rows, and filters, extracts distinct program references, and fetches the full
Program objects. It provides clients with a convenience list of all programs referenced in the
layout. POSTing this field has no effect.
When adding metadata to the store in a single batch (via addMetadata), plain items (programs,
stages) are always processed before compound-key items, so context is available for field
enrichment. When adding items one at a time, add programs and stages before any dimensions that
reference them.
| Field | Description |
|---|---|
id |
The compound ID (canonical form: stageId.dimId), or plain ID for non-compound dimensions |
dimensionId |
The plain (last) segment — always set on DimensionMetadataItem |
programId |
ID of the owning program (if applicable) |
programStageId |
ID of the owning stage (if applicable) |
repetitionIndex |
Repetition index extracted from [n] suffix |
optionSetId |
ID reference to the option set (if applicable) |
legendSetId |
ID reference to the legend set (if applicable) |
Golden rule: during development, lint/test only the files you touched. Before finishing, always run pnpm test and pnpm lint.
- Vitest:
pnpm exec vitest run <file-path> - ESLint:
pnpm exec eslint <file-path>(add--fixto auto-fix) - Stylelint:
pnpm exec stylelint <file-path> --max-warnings=0(add--fixto auto-fix) - Prettier:
pnpm exec prettier --write <file-path>
ESLint, Stylelint, and Prettier run automatically via PostToolUse hooks after Edit/Write. Files modified via Bash are not auto-formatted — run Prettier manually after.
File-specific tsc is not possible (path aliases, project references). Use the typescript-lsp plugin for diagnostics, or run ./scripts/check-typescript.sh (covers both tsconfig.json and cypress/tsconfig.json).
pnpm test # all unit tests
pnpm lint # ESLint, Stylelint, Prettier, TypeScript, ls-lintIf lint fails on formatting/auto-fixable issues, run pnpm format then re-run pnpm lint. Type errors and logic issues require manual fixes.