Developer Documentation

What DiPAgE stores, what it will accept from you, and what it talks to. Written for people building importers, exporters and integrations, or self-hosting the app.

Angular 22 · zoneless IndexedDB · no backend MIT PWA · offline-first

Press / to focus search · Esc to clear

If you are looking for help using the app, then please go to the user docs

Looking for the data standard or example exports? Go to downloads

Key developer resources

Record field reference

Every field of the PSM-Anwendungsdatensatz — 69 of them — with type, constraints, example and the behaviour that is not visible in the schema.

Open the field reference
Data standard & example files

The JSON Schema, an XSD variant, and example exports in CSV, JSON and XML — all in one place.

Go to downloads
Looking for the user guide?

Installing the app, recording an application, exporting for an audit — the task-oriented documentation lives on its own page.

Go to the user docs
Start here

Developer questions.

The questions that come up when someone first tries to integrate with DiPAgE, roughly in the order they come up.

10 questions

No. DiPAgE has no backend of its own and no server-side database — there is nothing to authenticate against and no endpoint that returns your records over the network. Every record lives in IndexedDB in the browser that entered it. Integration from outside the browser happens through files: export from the app, import into the app. The formats are documented under Import, export and interchange, and the record shape has its own reference page. Code running inside the page is a different matter — see the in-page JavaScript API in the next entry.

Yes — that is what window.dipageApi exists for. The app freezes a small object onto window at startup (currently version 1.1.0) so code running in the page can read and write records through methods instead of clicking through the interface, which is the intended path for an agent gathering or entering data. It exposes createRecord, openNewRecord, updateRecord, deleteRecord, getRecord, getRecords, searchPsm, searchCrops, getProfile, exportRecords and getTemplates. Calls go through the same storage layer as the user interface, and therefore the same schema sanitizer, so the API cannot write a record shape the form could not produce. It is an in-page surface only: no network endpoint sits behind it, it exists solely in a browser tab with DiPAgE open, and it disappears with that tab.

That is deliberate, not a typo. Latitude is bounded at ±100 and longitude at ±50 as a product decision, which has the side effect of rejecting coordinates far outside the intended area of use. The bounds are duplicated in two places — the JSON schema and a TypeScript constants file — because JSON cannot import TypeScript. If you fork the app and widen one, widen the other in the same change, or validation will disagree with itself.

Not as a schema field. The record format is fixed by the PSM standard and has no slot for it, so the app writes language-independent marker codes into notizen (PPS_EA, PPS_NZ, ŚOR_ZN) and reads them back when a record is edited or imported. That marker is the only durable signal that survives export and re-import. Two consequences for your own tooling: do not rewrite notizen wholesale, or you will erase it; and do not expect a zulassungsnummer on such a product, because an emergency-authorized product does not have one.

It is not stored — it is inferred. Several products applied in one pass become several records, one per product, which is how German record-keeping treats a mix. There is no tankmischung flag. The app recognises the group afterwards from three things the records share: the treatment site, the application date, and the record creation timestamp — which is why a batch save stamps records 10 ms apart from one common base value rather than using the real clock per record. The rate is deliberately excluded from that grouping key, since each product in the mix keeps its own dose.

Two different things, neither of them a schema field. A draft is a record the user saved with soft validation; it carries status: "draft" in IndexedDB, but the schema deliberately excludes status — the sanitizer strips it on every read and write, and the storage layer re-attaches it afterwards so a draft survives a reload. status never appears in an export. Incomplete is not stored at all — it is computed by re-checking a saved record for missing required fields. The export gate refuses incomplete records and drafts with a notification; full backups keep both. The UI-only product fields indikation, anwendungszeitpunkt and notfallzulassung are stripped by the sanitizer as well.

For JSON, the contract is the schema — validate against it and you are done. For the tabular formats there is one more rule worth knowing: CSV and the Excel workbook both carry a two-row header. Row 1 holds localized labels for humans, row 2 holds the machine field paths (anwendung_zeitpunkt.datum and so on), and data starts at row 3. The importer discovers the path row rather than assuming a position, so a file exported in German re-imports correctly into an English interface. Write row 2 correctly and the labels in row 1 can say anything you like.

The client is licensed under MIT. The repository on the JKI git server is not public yet — write to the team for access; forks for institute-specific compliance flows are explicitly welcome after that. If you plan to contribute back rather than diverge, two conventions matter: commits follow Conventional Commits (enforced by commitlint), and the project keeps a decision log of choices that look wrong but are not. It is not in the repository at the moment; ask the team for it before "fixing" one of those spots — several of the surprises on this page are entries in it.

Only lookups, and only to the origins pinned in its Content-Security-Policy: synops.julius-kuehn.de for products, indications, crops and emergency authorizations; nominatim.openstreetmap.org for postal-code geocoding; and the JKI GeoServer plus OSM/Esri tile hosts for the map and field-geometry pickers. Those origins are pinned in the Content-Security-Policy, so nothing else can be contacted even if a dependency tried. There is no telemetry and no analytics. Your records never leave the device unless you export them yourself.

No, and the naming is genuinely misleading: the .xml export is a SpreadsheetML 2003 workbook, not a domain XML document. It opens natively in Excel with styled headers and a reference sheet listing every allowed unit code, and the app re-imports its own export unchanged, which makes it usable as a hand-fillable template. There is no separate "excel" format — this is it. One caveat no automated test can cover: Trust Center file-block settings on a managed Windows install can refuse SpreadsheetML 2003 outright.

Technical topics.

Each topic is self-contained. Open the one you need; the rest stay collapsed.

The shape of the thing

DiPAgE is a single-page Angular application that runs entirely in the browser. There is no application server, no database to provision and no account system. The only server involved is the one serving the static bundle, and its job ends there. Everything the app knows lives in IndexedDB on the device that entered it.

Framework choices worth knowing

  • Angular 22 with standalone components and signals throughout. Change detection is zoneless and every component is OnPush.
  • TypeScript strict mode, including strictTemplates. Note the gap this leaves: tsc does not check templates, so a stale binding surfaces only at AOT build time.
  • Tailwind CSS 4 with DaisyUI 5. Custom CSS exists only where a utility cannot express the rule.
  • UUIDv7 for every generated ID, so identifiers sort by creation time.

What zoneless costs you

If you write tests against this codebase, the one trap worth naming up front: under zoneless change detection a debounceTime or delay subscription created at component construction cannot be flushed by tick() inside fakeAsync. The assertion then passes without exercising the debounce at all. Create the subscription inside the test body instead.

What local-first costs you

No sync, no multi-device merge, no server-side backup. Moving data between devices is an explicit export and import, and the conflict resolution that goes with it is a user-facing flow rather than an algorithm running behind the scenes. That is a deliberate trade for working with no connectivity and holding no personal data centrally.

The unit of storage

The stored entity is the PSM-Anwendungsdatensatz, identified as urn:psm:anwendung-datensatz:1.0.0 and defined in record.schema.json (JSON Schema draft 2020-12). One record describes one product applied once. Several products in one spray pass, or several treated parts, become several records — the form multiplies them out on save.

Where the complexity actually is

Most of the schema is flat and unsurprising. The exception is behandlungsort, an array whose standort_kennung_wert is a seven-way union discriminated by standort_kennung_art: coordinates, cadastral parcel, InVeKoS reference, field geometry, railway section, forestry address, or road. One of those — roads — nests a further union inside itself. The schema expresses this as chains of if/then under allOf rather than as named variants, so a generic validator handles it but hand-written validation has to reproduce the branching.

Two rules that are not in the schema

  1. The sanitizer is the shape gate. Every read from and write to storage is whitelisted against the schema. A field the schema does not know about is silently dropped — not rejected loudly. If a field you wrote is missing afterwards, this is why.
  2. Some fields exist on the model but never appear in an export. The product’s indikation, anwendungszeitpunkt and notfallzulassung are UI-only and are not stored either. The exception is the record’s status: the sanitizer strips it, but the storage layer re-attaches draft/saved afterwards so a draft survives a reload.

Field-by-field

Every field, its type, its constraints, a pasteable example and the behaviour that is not visible in the schema are documented on a page of their own, along with a downloadable copy of the schema and a complete example record covering all seven location variants.

Layout

Everything lives in a single IndexedDB database, psm-application-db, currently at DB_VERSION 13. Six object stores: records (application records), partial_data (reusable master-data templates, indexed by type), settings, crops (the synced crop list), notfallzulassungen (synced emergency authorizations) and logs.

The sanitizer sits on every path

Reads and writes both pass through the schema whitelist. That is what keeps a foreign or stale field from reaching storage, and it is also why writing to IndexedDB by hand is not a supported integration route — your extra keys will not survive the next read. For debugging you can still open the database in any browser devtools panel (store records) — as a read-only diagnostic.

Persistence is requested, not guaranteed

The app calls navigator.storage.persist() on initialisation. If the user declines, or the browser ignores it, stored data becomes evictable under storage pressure — and mobile Safari is the usual offender. For a field-recording app that means real data loss, so if you are deploying this to users, make the backup export part of your rollout advice rather than a feature they discover later.

If you change the schema

There are three independent version numbers that must not be confused: datensatz_format_version is frozen at "1.0.0" as a const in the schema and describes the record format; an informal counter ("v12", "v13") in code comments and the changelog tracks schema revision passes and is not stored in any record; DB_VERSION is the IndexedDB upgrade version and never appears in an exported file. A schema change does not necessarily move DB_VERSION. The version numbers are coupled to places you will not find by running the test suite: the end-to-end helpers and the schema-integrity spec both hardcode DB_VERSION. Moving it in one place and not the others produces a few hundred failures that read like flakiness rather than like a version mismatch.

Export formats

  • JSON — pretty-printed records, exactly the schema shape. The format to build against.
  • XMLnot a domain XML document. It is a SpreadsheetML 2003 workbook that Excel opens natively, with styled headers, a frozen header row and a reference sheet listing all 284 allowed unit codes. There is no separate "excel" format; this is it.
  • CSV — flattened, BOM-prefixed, with formula-looking values neutralised against CSV injection.
  • PDF — a sectioned, audit-oriented layout carrying the operator imprint. Field geometry is summarised here as type, vertex count and centroid rather than raw coordinates; every other format keeps the full array.

The two-row header

CSV and the workbook share one contract worth understanding before you write a parser. Row 1 holds localized human labels. Row 2 holds machine field paths — anwendung_zeitpunkt.datum, behandlungsort[0].bezeichnung. Data starts at row 3. The importer locates the path row rather than assuming its position, and also handles a legacy single-header file. This is what lets a file exported from the German interface import correctly into the English one.

Import

Accepted: .json, .csv, .xml, and .zip containing those. Imports are normalised (dot-path unflattening, per-field type coercion, German decimal commas and DD.MM.YYYY dates) and then checked against the schema. Records missing required fields go to a review step where the user decides whether to import them anyway; structurally unusable entries are counted and reported. The three system fields are filled during normalisation when absent, so a hand-filled template with a blank ID column still imports.

Conflicts

A same-ID import with differing content opens a per-record version picker — imported version against current version, with a "keep both" option. A content-fingerprint match with differing IDs is treated as its own conflict class, which is what catches the same template imported twice. The fingerprint is stable across key order, ignores the ID, creation timestamp, notes and status, and is sensitive to product, rate, date, crop, location and operators.

Backups

A full backup is a ZIP containing records.json, master_data.json, profiles.json, settings.json and metadata.json. Restore is deliberately staged: an explicit confirmation first, then parsing with no writes, then conflict classification, then commit. Records are restored as they were saved — drafts and incomplete records included — because schema compliance is enforced at export, not at restore. If writes fail midway the app reports a partial restore rather than inviting a retry that would double-apply.

Two layers, and the gap between them

The first layer is the JSON schema: required fields, types, enums, formats, additionalProperties: false everywhere, and the conditional narrowings expressed as if/then chains. The second layer is application code, and it enforces rules the schema cannot express. If you build your own tooling, plan for the gap — a record can satisfy the schema and still be refused by the app.

Rules that live only in application code

  • Geometry nesting depth must match the geometry type. The schema only requires that koordinaten is an array.
  • Rate must be greater than zero. The schema says number.
  • Application time becomes mandatory when the selected product carries a bee-hazard classification.
  • Per-location-type field checks beyond what the if/then chain covers.

Two traps in the schema itself

Worth knowing before you debug a validator disagreement. First, anwendung_zeitpunkt.uhrzeit has format: time, which means RFC 3339 full-time — an offset is mandatory. "06:15:00" fails; "06:15:00Z" passes. The schema’s own examples entry shows the offset-less form, so do not copy it. Second, the railway kilometre fields use multipleOf: 0.1, which behaves badly in binary floating point: a typical validator computes value / 0.1 and checks for an integer, and 0.3 / 0.1 yields 2.9999999999999996. The value 0.3 — which the schema also carries as an example — therefore fails its own constraint.

DiPAgE’s own validator is deliberately looser on both points: the offset is optional, and multipleOf is compared with a tolerance. Files the app exports can therefore contain values a strict validator rejects — configure yours the same way.

Hard versus soft

Not every rule blocks. Master-data templates use soft validation throughout: warnings never prevent a save. In the record form, several cross-field checks are advisory only — a harvest year that disagrees with the application date, a treated size larger than the site, a reversed BBCH range. They surface as warnings and let the save through.

Incomplete records

"Incomplete" is computed, not stored: a saved record is re-checked for missing required fields and flagged with a list of what is missing. Incomplete records are pinned to the top of the list and refused by the export gate with a notification, but they are kept in full backups so a restore is lossless.

SynOPS — the substantive one

synops.julius-kuehn.de provides four things: product search by trade name or authorization number, scoped to the selected crop and grouped by approval status; indications for a product/crop pair, including hazard codes and dosing suggestions that auto-fill the rate; the crop list, synced per language into IndexedDB so lookup works offline; and emergency authorizations, likewise synced locally. Product and indication lookups are live and debounced, with stale responses cancelled. Crops and emergency authorizations are synced datasets with a staleness check rather than per-request fetches.

Geocoding and map data

nominatim.openstreetmap.org resolves postal codes for the map picker. The JKI GeoServer serves the field-geometry picker over WFS: official field blocks with FLIK and plot numbers for North Rhine-Westphalia (inv:NRW_FB_<year>), and crop-classification outlines without any field identity for the rest of Germany (cora:CORA_<year>); OSM and Esri hosts serve the tiles. After picking a polygon, the user ticks which extra identity entries (InVeKoS reference and/or centroid coordinates) are added — nothing is preselected. They are linked to the polygon only by a shared bezeichnung, since the schema has no link field.

All of it is pinned

Every one of those origins is listed in the Content-Security-Policy. Nothing else can be contacted, which is the mechanism behind the "no telemetry" claim rather than a promise. If you fork the app and point it at your own hosts, add each of those origins in every place listed under Self-hosting, or the requests fail silently.

Degradation

Failures and offline state flip a global banner between offline and degraded. Nothing local is affected: every field can still be filled in by hand, records still save, exports still run. Only the lookups go quiet.

If you are integrating

These are the app’s upstreams, not an API surface DiPAgE offers you. There is no DiPAgE endpoint to call — see the first FAQ entry.

Caching model

Offline capability comes from the Angular service worker, configured in ngsw-config.json. The app shell and assets are precached; the external API origins get their own data groups. After the first successful load the app is fully operable with no network — record entry, validation, storage, export and PDF generation all run locally.

Synced datasets versus live lookups

Two different mechanisms, easy to conflate. Crops, emergency authorizations and bee-hazard codes are synced into IndexedDB and read from there, with a staleness check on boot rather than a fetch per query — which is why crop autocomplete works with no signal. Product search and indications are live and degrade when offline.

Map tiles bypass the service worker

Every map tile URL carries a constant ngsw-bypass parameter, so tiles are not routed through the service worker at all, and tile loads retry through OpenLayers’ own re-entry point rather than through the cache layer. Worth knowing if you are debugging why tiles behave differently from every other request.

Install and detection

The app is installable as a PWA and detects standalone mode. Installability varies by platform in ways that are a support question rather than a code question — the user documentation covers which browsers and operating systems allow it.

Where the risk actually is

There is no server, no session and no multi-tenancy, so most of the usual web attack surface is simply absent. What remains is that the app parses files the user hands it — JSON, CSV, XML, ZIP — from sources it cannot vouch for. That is the boundary the hardening is built around.

Import hardening

  • ZIP: entry-name whitelist, size and count limits, per-entry decompressed-size cap. Full backup archives are structurally rejected by the record importer before any entry is parsed.
  • XML: <!DOCTYPE> rejected outright, with a 2 MB cap.
  • Prototype pollution: every iteration over untrusted keys is guarded. This matters more than usual here, because dot-path unflattening walks attacker-supplied key paths by construction.
  • Backup restore: records are restored as they were saved (only entries that are not objects are skipped) and pass through the sanitizer on every read; templates are shape-validated and profiles field-whitelisted. Anything rejected is counted and reported rather than silently dropped.

Export hardening

CSV injection is neutralised on export: a value that looks like a formula is escaped so it cannot execute when the file is opened in a spreadsheet. Formula-looking values are still preserved verbatim in the workbook path, so the round trip does not corrupt data.

Content-Security-Policy

The CSP pins the external origins listed under external APIs. It is defined in the index.html meta tag and in the response header of whatever serves the app — the two are enforced separately and must agree; frame-ancestors is header-only, since a meta CSP cannot express it. Alongside it: X-Frame-Options, nosniff, a referrer policy, and a path-traversal guard in the static server.

Consent before storage

The router outlet renders only after storage consent is given, so nothing is written before the user agrees. If you are writing tests that seed consent directly into IndexedDB, reload afterwards — the app does not re-read consent reactively, and skipping that reload has broken entire suites.

Where CSP and visual tests disagree

One trap worth naming: a CSP-blocked resource renders consistently broken, so a visual snapshot of it still matches a consistently broken baseline. A new external origin has to be verified in a served build; green end-to-end tests do not prove it works.

Translation

Three locales — de (default), en and pl — held at full key parity, with dot-notation keys and {param} interpolation. Locale-aware formatting covers dates, times and generated filenames. The language preference is stored in IndexedDB and is independent of the device language.

Two rules that shape the code

  1. No raw user-facing literals. Even error messages surface as translated keys; the detail goes to the log store instead.
  2. translate() returns the key on a miss, never a falsy value. So a missing translation shows up as a visible key rather than an empty element — deliberate, because an empty label is much easier to miss in review.

Accessibility

The target is WCAG 2.2, enforced from three directions: ESLint accessibility rules on templates, axe-core end-to-end runs in both themes, and a keyboard tab-walk test. Screen-reader announcements go through two ARIA live regions, one polite and one assertive, owned by a single service. Route changes, theme, language and font-scale switches, and modal appearances all announce.

Theming and scaling

Two themes, jkiLight and jkiDark, applied via data-theme and persisted. Font scale is 100, 150 or 200 percent, applied at the root font size, also persisted. If you add a component, it has to hold up at 200 percent in both themes — that combination is where layout breaks first.

The layers

  • Lint — style and template accessibility rules.
  • Type-check — TypeScript in .ts files. Not templates.
  • Invariants — a cross-file consistency script: the DB_VERSION triple, the CSP meta/header pair, the service-worker data groups, the geo bounds in TypeScript against the schema, the notizen max length, the generated schema validator being in sync, additionalProperties: false on every object, i18n parity and orphan keys, and a minimum release age for new pnpm packages.
  • Unit — over 3,400 Karma/Jasmine specs, around 92% statement and 85% branch coverage. No threshold is configured, so these are a measurement rather than a gate.
  • Build (AOT) — the only gate that catches a stale template binding.
  • End-to-end — real flows plus visual snapshots across desktop, mobile Chrome and mobile Safari.
  • Accessibility — axe-core in both themes, plus a keyboard tab-walk.
  • Server security — path-traversal rejection and security headers.

Where a green run lies

Worth internalising before you trust a passing pipeline:

  1. A green type-check does not mean a rename landed — templates are only checked at AOT build.
  2. Compiler-silenced fixtures (as unknown as casts on record shapes) keep passing against a stale schema after a field change.
  3. A spy list missing a newly added method returns undefined and produces a misleading "never called" failure.
  4. A CSP-blocked resource still matches its broken baseline, so visual tests do not prove a new origin works.

Snapshot policy

Visual baselines are updated by a human, never automatically and never by CI. The diff is the review signal; regenerating it on failure throws away the only thing the test was measuring.

Prerequisites

Node.js 22 or newer and pnpm 12 or newer to build. Nothing else — no database, no message broker, no external service to register with. The runtime target is any modern browser with ES2022, IndexedDB and service worker support.

Serving

The build produces a static bundle. A small Node server is included for standalone hosting, for example under PM2; the JKI reference deployment does not use it, though — it serves the bundle as static files under /app/ of a parent website that sets its own security headers. The bundled server supports a configurable base path, so deploying under a subdirectory works without rewriting asset URLs. It enforces a path-traversal guard, sets the security headers, serves hashed assets as immutable and the app shell as no-cache.

HTTPS is not optional

Service workers require a secure context. Without HTTPS you do not get a degraded install experience — you get no offline capability at all, which removes the app’s central premise.

The Content-Security-Policy is the real task

A new external origin has to be added in three places: the index.html meta tag, the response header of whatever serves the app (the bundled server or the parent website), and — for API hosts — the dataGroups in ngsw-config.json; if it is missing there, the service worker answers a failed fetch with a 504. The meta and header CSPs are enforced separately; frame-ancestors lives only in the header, because a meta-tag CSP cannot express it. If you point the app at your own SynOPS mirror, your own geocoder or your own tile server, every one of those origins has to be added in all three places. A missed origin fails at runtime, quietly, and a passing test suite will not tell you — verify against a served build.

Pipeline

In the reference CI, lint and unit tests gate the AOT build, and the build gates the SSH deploy. End-to-end tests run alongside but do not block a deploy; the invariants script and the server-security test are not run in CI. Worth mirroring the order rather than the tooling: the AOT build sits after the unit tests deliberately, because it is the gate that catches template breakage the earlier gates cannot see.

Licence and repository

The client is licensed under MIT. The repository on the JKI git server is not public yet — write to the team for access. Forks for institute-specific compliance flows are explicitly welcome after that; issues and patches equally so.

Conventions

Commits follow Conventional Commits, enforced by commitlint through a Husky hook. Linting is ESLint with the Angular and TypeScript plugin sets. Neither is negotiable in CI, so configure them locally before your first push rather than after your first rejected pipeline.

Ask for the decision log first

The project keeps a log of choices that look wrong but are not, each with its reasoning and an explicit "do not do this" note. Several of the surprises on this page are entries in it: the non-standard coordinate bounds, the emergency-authorization marker living in a free-text field, the tank mix being inferred rather than stored, the deliberately trimmed unit vocabulary. The log is not in the repository at the moment; ask the team for it. If something in the codebase looks like an obvious bug, clarify it before opening a fix — it may be load-bearing.

If you change the record shape

A field change has mirror sites that no single test failure will enumerate for you: the schema, the TypeScript interfaces, the form builders and their validators, the flat-to-nested mappers, the import coercion, the export column sets, the end-to-end seed helpers, and the localized column labels in all three languages. The project maintains a map of these, which is not in the repository yet either — ask for it and work through it rather than following red pipelines one at a time.

Looking for a specific field? Open the record field reference

Get started immediately

Use DiPAgE directly in your browser. No registration and free of charge.

Open the application and start documenting your plant protection product applications right away. No installation is required, but on supported devices DiPAgE can additionally be installed and used offline.

Directly in browser
No installation required. Open the application and get started immediately.
No registration
DiPAgE can be used without a user account or registration.
Stored locally
Your documentation data remains on the device you use.
Usable offline
On supported devices, DiPAgE can be installed and used without an internet connection.