Ownware
Home›Trainora›Manual
Trainora · Manual

Trainora Manual, as shipped in the download

Trainora — User Manual

A training matrix and expiry register: who holds which competency, when they achieved it, when it runs out, and what needs attention today. Platform release 3.1.0 [src: app/src/Domain.php:22].

About this manual

Every statement here was written by reading Trainora's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug.

Nothing is described that the code does not do. Where something is deliberately limited, or currently wrong, that is said plainly rather than left out.

What the version number means

Trainora reports 3.1.0 on every surface — the ping, the OpenAPI document, the health check, the agent endpoint and the stamp inside a backup all read one constant [src: app/src/Domain.php:22], so they cannot drift apart.

The source is careful about what that number claims: it is the platform release the product ships on, the same number every product in the catalogue carries, so a buyer comparing two of them is comparing the same platform [src: app/src/Domain.php:18]. It is not a claim that Trainora had a 1.x — this is its first release.

⭐ The grid, and the four states

Every cell in the matrix is one subject against one requirement, and it holds one of four states [src: app/src/Matrix.php:31]: valid, expiring, expired, or none — meaning the person has never held it at all.

They are ordered for reading rather than alphabetically — worst first [src: app/src/Matrix.php:155]. Executed:

failed , expired , none , expiring , valid

The shared engine knows a fifth state, failed, for a record whose result is a fail [src: app/src/Matrix.php:38]. Only a product that records a result can write one; Trainora records completions, every record here is a pass, and no Trainora cell is ever failed.

Note that "never recorded" ranks above "expiring": a competency nobody ever had is a bigger hole than one that lapses next month.

⭐ The expiry day itself is still valid

This is the detail the engine is most emphatic about. A certificate that says "valid to 31 December" is valid on 31 December, and treating that day as expired is the kind of off-by-one that makes an operator distrust the whole grid [src: app/src/Matrix.php:109].

Executed against a warning window of thirty days, with today at 2026-09-05:

expires 2026-12-31  ->  valid      valid, 117d left
expires 2026-10-05  ->  expiring   in 30d
expires 2026-10-04  ->  expiring   in 29d
expires 2026-09-05  ->  expiring   expires today      ← the expiry day itself
expires 2026-09-04  ->  expired    1d overdue
expires 2026-08-01  ->  expired    35d overdue

Two more behaviours, both executed:

A record with no expiry date is valid forever [src: app/src/Matrix.php:109] — it comes back valid rather than being treated as missing data.

A cell with no record at all is none, phrased "never recorded" [src: app/src/Matrix.php:130], which is a different fact from an expired one and is coloured and sorted differently.

Setting the warning window to zero narrows "expiring" to the expiry day alone: 2026-09-05 is expiring and 2026-09-06 is valid.

The wording never invents a number it was not given — the phrase falls back to a bare "expired" or "expiring" when no day count is available [src: app/src/Matrix.php:141].

The engine does not know what it is counting

Matrix.php knows that a subject holds a requirement, that holding it has a date and may expire, that rounds are planned events producing outcomes, and that evidence attaches to a record. It does not know whether a subject is a person or a fire extinguisher [src: app/src/Matrix.php:14].

Every user-facing word and every table name arrives from Domain [src: app/src/Matrix.php:15]. For Trainora, subjects are people, requirements are competencies, rounds are sessions and outcomes are attendance [src: app/src/Domain.php:31].

That separation has a practical consequence for anyone customising the product: if you are adding a word to a screen, add it to Domain and read it from the view — a hardcoded "competency" in a template is how the vocabulary drifts [src: app/src/Domain.php:13].

The engine is byte-identical to another product's, and the suite enforces it

The source declares a canonical copy at tools/matrix-engine/Matrix.php and says both products' suites md5-compare their copy against it, so editing one product's copy turns both suites red [src: app/src/Matrix.php:5]. Verified independently:

7cdb042d4b4bffb17328d182ee31b57b  tools/matrix-engine/Matrix.php
7cdb042d4b4bffb17328d182ee31b57b  43-trainora/app/src/Matrix.php

The stated reason is worth repeating: two codebases for one engine is an expensive mistake, and the md5 check is the tripwire against making it by accident [src: app/src/Matrix.php:7].

SQL safety

Table names are the only identifiers interpolated into a query; everything else is a bound parameter [src: app/src/Matrix.php:17]. Those names come from a hardcoded map in Domain, never from input, and are whitelisted again before they reach a query — a Domain that returns rubbish fails in one place rather than producing a query nobody reads [src: app/src/Matrix.php:56].

Users and roles

Three roles [src: app/controllers/v3.php:24]. A viewer reads the grid and the reports; a member can also record a completion and run a round [src: app/controllers/v3.php:25]; admin holds everything [src: app/controllers/v3.php:26].

Backups are for administrators

Both backup downloads ask for the administrator role — the JSON export and the SQLite file [src: app/controllers/api.php, backup_json() and backup_sqlite()]. A viewer or member who opens either address is refused, and the Security page shows the backup buttons to administrators only [src: app/views/security.php].

A backup contains every table: the full training record for every person named in the system, including who failed to hold what and when. The JSON export redacts credential hashes and the SMTP password; the SQLite file is the database itself, byte for byte. Before 3.1.3 a sign-in alone was enough to download either.

Webhooks: one event, fired from every door

The one event offered for subscription is record.created [src: app/src/Webhook.php], and it fires from every path that records a completion: the grid, a session's attendance, the REST API and the agent tool. Before 3.1.3 the settings screen offered three leave-management events this product never fires, and record.created could not be ticked.

Reminders and the digest

Two switches under Settings → Email, both off on a fresh install, both sent only through the SMTP server you enter there:

  • "email me a digest of what is expiring" — one message a day, to the address in "Send my notifications to", listing everything expired or expiring within the warning window.
  • "tell them when something of theirs is expiring" — a note to each person in the register who has an email address and something expired or expiring, never more often than the "remind the same person at most every N days" setting.

They go out when cron/reminders.php runs. Schedule it once a day, for example 15 7 * * * php /path/to/app/cron/reminders.php, and try --dry-run first: it lists who would be written to and sends nothing. On a host without cron, Settings → Run reminders now runs the same pass. Running it twice in a day sends nothing the second time: every note is claimed in a ledger before the mail server is contacted, and a send the server refuses gives its claim back so the next run retries [src: app/controllers/app.php, reminder_run()].

Settings shows when the pass last ran and what it did. When a switch is on and nothing has run the pass for more than two days, the dashboard says so.

The API and agent access

Five tools [src: app/controllers/v3.php:64]: four read — the whole grid, what needs attention, one subject in detail and the requirement list — and exactly one write, recording a completion [src: app/controllers/v3.php:155].

The instructions tell an agent two things worth knowing yourself [src: app/controllers/v3.php:60]:

"Read the matrix before you write to it. Recording a completion is the only write here; it goes through the same engine call the screens use, so the refusals are identical."

That second clause is the useful guarantee: there is no separate API validation to drift out of step with what the screens enforce.

What Trainora does not do

It does not treat the expiry day as expired [src: app/src/Matrix.php:109].

It does not confuse "never recorded" with "expired" [src: app/src/Matrix.php:130] — they are different states, differently ranked.

It does not invent a day count it was not given [src: app/src/Matrix.php:141].

It does not let a template decide the vocabulary [src: app/src/Domain.php:13].

It does not interpolate anything into SQL except a whitelisted table name [src: app/src/Matrix.php:17].

← Back to Trainora · Quickstart · API · Test run

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →