A new Participant Trends page charts how participant numbers move across a programme's recent distributions, split UK vs non-UK by delivery address
New
Participant Trends (admin nav → Trends, or /admin/participant-trends) plots how many participants took part in each of a programme's most recent distributions over time. The count is split into UK and non-UK using each lab's default delivery address (a GB address counts as UK, everything else as non-UK). Pick a programme, switch the UK / Non-UK / Total lines on or off independently, and choose how many of the latest distributions to show (6–24). A figures table beneath the chart lists the exact UK, non-UK and total counts plus UK share for every distribution plotted.
v0.67.0
Any reply to a ticket can now be sent to a colleague for approval before it goes out
New
Draft replies with peer review. Next to Send on both the inline reply and the Email-participant composer there's now a "Send for review" option: pick a colleague, and the reply is staged as a draft (it never reaches the lab) and the reviewer is pinged. The reviewer gets Approve & send — which sends it through the normal reply/email path — or Return with comments, which hands it back to the author to edit and resend.
v0.66.0
Tickets raised by an unidentified email sender are now held off the lab portal until an operator confirms who they are
New
Unverified email tickets are hidden from the lab. When someone emails in citing a PRN but isn't a known contact of that lab (or is a known contact citing the wrong PRN), the ticket no longer appears on the lab's portal. An "Identify sender" panel on the admin ticket lets the operator confirm the sender — optionally linking them to a registered contact — which reveals the ticket to the lab. Existing email tickets were reclassified the same way.
v0.65.0
Membership-intent detection is more reliable (it correctly reads a scheme removal raised as a billing complaint), and a ticket's linked distribution now surfaces its exercise instructions, empty results sheet and data-entry instructions inline
New
The admin ticket view now shows the on-the-fly Exercise instructions plus the Empty results sheet and Data entry instructions for each linked distribution, in the Linked distribution panel — the same set the lab sees, draggable into a reply.
Improved
Membership-intent (the "Membership request?" card) now reads correctly when a scheme removal is raised inside a billing/invoice complaint: the payload sent to the model keeps the actual request sentence (it was being truncated to the billing preamble), the prompt states that billing-context removals still count, and the model call retries on transient overloads (429/5xx) before falling back to the deterministic scan.
The verdict is cached per ticket (text-hashed, so a new note busts it) instead of re-calling the model on every render — with a "Re-check" button to force a fresh run and a line on the card showing whether the verdict came from the model or the deterministic fallback. A degraded fallback is never cached, so it retries and can upgrade once the model is reachable.
v0.64.0
Super-admins can now promote a user to system administrator straight from the user-management screen, instead of only via the CLI — gated to an allowlisted account, with an inline confirmation
New
On /admin/users, expanding a user to edit now shows an amber "System access" panel for super-admins only, set apart from the normal edit controls. It promotes the user to system administrator (matching the user:promote-admin command) behind a two-step confirmation, shows a "System administrator" badge for accounts that already have it, and offers a quiet Revoke.
Improved
The capability is gated by a config allowlist (config/admin.php → super_admins, env ADMIN_SUPER_ADMINS) rather than the database, so admin rights can't be self-escalated from inside the app. Every action re-checks the allowlist on the server (not just blade visibility), a super-admin can't revoke their own access mid-session, and all promotions/revocations are logged.
v0.63.0
Tickets that read like a membership change now surface a one-click "Membership request?" shortcut that opens the participant's membership screen on the right programme, with the change pre-staged for the agent to confirm
New
The participant-query editor and the live-chat dock now scan a ticket's text for membership-change requests — suspend, cancel/remove, add, or swap a scheme (e.g. "please suspend our G6PD membership", "switch us from ES to PV"). When one is found, a "Membership request?" card appears with a button that opens the participant's membership screen pre-selected on the right programme tab, with the suggested action highlighted. Nothing is changed automatically — the agent reviews and applies it.
Improved
Detection is deterministic (a scheme synonym + action-verb map, no external calls) and only fires when an action verb sits near a named scheme or clear membership context, so a passing mention of a scheme — or an unrelated "cancel the invoice" — does not trigger it. The engine sits behind a swappable interface so a model-backed classifier can be added later for unusual phrasing.
v0.62.0
Dispatch logistics: DHL Express air waybills + live tracking for sample shipments, and on-the-fly instruction sheets generated per distribution (and bundle) instead of stored placeholder files
New
DHL Express integration (MyDHL API) — admins can raise an air waybill for a participant's distribution from the distribution table, the shipments screen and the query editor, then see the AWB number, tracking number and live delivery status. The distribution dashboard gains a delivery-status filter (in transit / customs / delivered / failed) and participants see their round's tracking inline on the raise-a-query screen.
Instruction sheets are now rendered on the fly per distribution (and per bundle — a distribution_group lists its sibling surveys) rather than attached as placeholder files, so the link is never a dead "not attached yet" document; participants get an "Instruction sheet" link once one is available.
Improved
The distribution-document download gate shows a friendly "not attached yet" page for placeholder rows instead of a hard 404, and the placeholder seeder no longer creates a stored instructions document (instruction sheets supersede it).
v0.61.0
Each scheme can now switch live chat off for participant queries — its labs then raise tickets only (web, email, or phoned-in and logged by admin), with no "Start live chat" option
New
Organisation settings gain a Live chat toggle. When a scheme turns it off, its participants no longer see the "Start live chat" option on the raise-a-query screen — queries become tickets handled by the team, exactly as before live chat existed.
The participant raise form honours the setting per lab: choosing a lab whose scheme has chat off swaps the green "Start live chat" button for the plain "Send query" flow and explains that chat isn't available for that scheme. The floating chat launcher's status light also stops showing green for those participants.
Improved
Turning chat off changes nothing about ticket creation: web tickets are still written directly (the team email is only a notification, never the thing that creates the ticket), inbound emails still open tickets, and admins still log phone calls as tickets — so a scheme can run purely on phone/email/manual queries.
v0.60.0
The admin Queries cohort explorer gains one-click presets — common extractions mined from the legacy internal site (non-returners, active/lapsed lists, new registrations, agent contacts, mailing lists) that load the filters for you and just ask for the constraint — plus a new Contacts subject for address sheets
New
Queries (/admin/queries) now opens with a "Start from a common query" picker grouped into Participants & membership, Distribution completeness, Logistics & mailing, and Contacts & addresses. Picking a preset sets the subject, pre-fills the filters, and highlights the one constraint you still need to choose (distribution, programme, dates) before Running.
Fourteen presets ship in this first cut — active participant list, inactive / lapsed, lapsed since a date, new registrations, non-returners / returners / in-progress / full roster for a distribution, cold-chain participants, consented mailing list, agent contact details, contact changes, contact details for PRNs and a mailing list with address — each modelled on an extraction the team runs day-to-day in the legacy internal website.
A new Contacts subject extracts one row per participant contact (main / consultant / invoice) with name, email, phone, address and country — the basis for address sheets, agent-contact lists and mailing extracts.
Improved
Nine new filters back the presets and are also available standalone: no active membership, no membership activity since a date, registered between dates, cold-chain only, marketing consent, agent-managed, a specific-country picker (alongside the existing UK / Non-UK toggle), contact type, and contact changed between dates. The filter editor now supports single-date and date-range inputs.
v0.59.0
The Performance Dashboard is reskinned in the facelift design — a four-stat KPI strip, a cleaner filter sidebar with Instruments/Parameters plot controls, and a calmer chart where plotted lines never read as the red alert line
New
The Performance Dashboard gains a headline KPI strip — within-tolerance distributions, open CAPAs, performance letters in the last 90 days, and the average deviation index — all computed live from whatever PRNs, instruments, parameters and period are in scope.
Instruments and parameters move into compact "Plotting" multi-select controls above the chart (each with search, All/None and a live count), freeing the sidebar for programme, period, distributions-in-scope and PRN selection.
Improved
The whole page is rebuilt in the facelift language (Inter Tight + JetBrains Mono, square corners, hairline borders) consistent with the rest of the redesign, with Graph / Scores / CAPAs as clean underlined tabs and CAPAs shown as cards.
Plotted series now use a deliberately non-red colour palette, so a line can never be mistaken for the red (100) alert threshold; the amber-80 and red-100 threshold lines are tuned to the new palette.
v0.58.0
The admin Programme Management screen is rebuilt as a single dense, scheme-colour-coded "ledger" — every programme on one filterable table, each row expanding inline to its component structure
New
Programme Management is now one dense table instead of a card grid: every assessment programme on a single page, each row carrying a scheme stripe and pill (Haematology red / BTLP navy), its type, a live leaf-component count, and when it was created.
Clicking a row expands its full component structure inline as a nested tree — parent components with their data-type, and child components with their response type and option counts — so an admin can read a programme's shape without leaving the list.
A filter bar searches programmes by name or component code (e.g. ESR, TITRE) and narrows by scheme and type instantly, with a live "N shown" count; each row keeps its Manage shortcut and an overflow menu for Components, Automation, Scoring parameters, Questionnaire, Edit and Delete.
v0.57.0
The Blood Films schemes come online: morphology and parasitology results can now be entered, with the full history of past rounds migrated across so labs see their record from day one
New
Blood Films — Morphology is now a live scheme: for each film a lab records the morphological features it sees, picked from the full UK NEQAS feature catalogue grouped by category (erythrocytes, leucocytes, platelets, parasites and general), plus free-text "other morphology" and additional information.
Blood Films — Parasitology is now live alongside it: each film is worked in two escalating phases — screening (is a parasite present, and broadly what kind) and, for labs subscribed at that level, identification (which species, the percentage parasitaemia and a coded comment) — matching the bundled subscription tiers.
Both schemes use the new data-entry design (monospace references, navy accents, square cards) consistent with the rest of the facelifted entry pages.
Improved
Every closed and scored historical round of both schemes is migrated from the legacy system with full fidelity — verified field-for-field against the source — so participants and operators see the complete past record, not a blank slate. This includes correctly reuniting a distribution that the legacy system had split across two batches.
v0.56.0
The participant queries list and the raise-a-query / conversation pages are rebuilt in the ticketing design, and distributions can now carry documents — surfaced to participants as they raise a query, to head off the call
New
Distributions now carry documents: operators attach instruction sheets, result templates and data-entry guides (file or link) right in the distribution workflow, mark each visible or hidden to participants, and remove them as needed.
When a participant raises a query and picks the distribution it's about, a "this might save you a message" panel surfaces what they probably came to ask for — a link to enter or view their results, their report (once issued), the round's documents, and their recent invoices — so a lot of routine questions never become tickets.
The admin queries list now shows the linked distribution as a column — one of the most useful fields for triage — alongside a rebuild in the ticketing design (navy, monospace references, square cards).
The participant raise-a-query form and the query conversation page are rebuilt in the same ticketing design, with the reference, status and the full back-and-forth laid out consistently with the admin side.
Improved
Existing per-exercise instruction sheets from the legacy system are linked to their distributions automatically (matched by the code in the filename), and rounds without one get placeholder entries so every exercise looks complete while the real files are attached.
v0.55.0
Two query-desk fixes: the amber colour scale that had silently gone missing is restored across the app, and abandoned live-chat requests stop lingering in the agent dock
Fixed
The amber colour scale had been accidentally overridden, which silently stripped the colour from every amber status chip, warning and accent across the app — most visibly the live-chat "Chat waiting" pills, which were rendering as invisible white-on-white. The full scale is restored.
An unaccepted live-chat request used to sit in the agent dock as an "Accept" pill for two hours — long after the participant had given up and left — so accepting it opened a dead conversation. Requests now drop out of the dock shortly after the participant's wait window closes; the query remains in the queue as a normal ticket.
v0.54.0
Distributions can now carry a participant questionnaire — a per-programme question bank and visual builder on the admin side, presented to participants before they enter results
New
A questionnaire builder lets operators curate a per-programme bank of questions — multiple choice, single choice and free text — with a live preview as you build and conditional branching (a question can appear only when an earlier answer warrants it).
A per-distribution picker chooses which of the bank's questions a given round asks, so the same library is reused round to round without re-authoring.
Participants answer the questionnaire before entering their results; completion is recorded against their distribution snapshot, and a re-entry banner lets them review or revisit their answers.
v0.53.0
The public Documents page is rebuilt as a search-first library — type and collection filters, quick-access tiles, and a proper document taxonomy replacing the old accordions
New
The Documents page is now a search-first library instead of nested accordions. A live search box, a left rail of document-type filters (Result sheets, Data-entry guides, Instructions, Schedules, Manuals, Forms, Presentations, Posters, Newsletter, Reference) and the original collections — each with live counts — and a Quick-access row of the documents labs reach for every distribution.
Results can be sorted (Recent / A–Z), switched between a grouped or flat list, and narrowed with removable filter chips. The page is fully public — no sign-in needed — and the scheme switch flips between the Blood Transfusion and Haematology document sets.
The old "Surveys" grab-bag is broken into a real document-type taxonomy, so a file is found by what it is (a blank result sheet, a data-entry guide) rather than which accordion it was filed under.
Improved
Documents past their display-until date are hidden automatically, and recently-updated files carry a "this week / this month" tag.
Documents that are external links (manuals hosted in iPassport, files in SharePoint) open directly; the rest will download once the document store is migrated to the new server.
v0.52.0
The query editor is rebuilt as a proper ticketing page — a conversation-first layout with a Linear-style properties rail — and participants can now see their own invoices and quotes
New
The query editor is rebuilt around the conversation: the back-and-forth sits front and centre (participant replies in green, ours in blue), with the opening message and the resolution folded into the thread as its first and last entries. A mode-tabbed composer switches between emailing the participant, adding an internal note, and logging a call — each with its own colour — and Ctrl+↵ sends.
A Linear-style properties rail down the side: status, priority, category, team and assignee are inline selects that save the moment you change them, with status and priority dots. Below them, the participant card (with registered contacts and live invoice rows), the linked-distribution card, and a one-click Resolve.
Internal helper notes and the actual conversation can be toggled independently, so you can read a clean back-and-forth or see the system's working.
Drag the data-entry link, a report link, or an invoice straight from the sidebar into the reply box — it drops in as a participant-facing link (admin-only links resolve when clicked, not dragged).
Participants now have an Invoices page (linked from the dashboard) showing their invoices and quotes with plain-English statuses — Being prepared, Issued, Sent, Paid, Cancelled — and a link to raise a query about any of them.
Improved
Invoice status chips on tickets are simplified to DRAFT / RAISED / SENT / PAID / VOID, with the precise internal state on hover, and OVERDUE shown in red from live Xero data (amount still due past the due date).
Send-and-resolve: tick "…and mark resolved" on a reply to answer and close a query in one action.
v0.51.0
Email joins the query desk: the team Gmail mailboxes are now mirrored two-way — every email becomes (or joins) a ticket automatically, replies work from either side, and the desk and the mailbox always agree
New
Emails sent to the team mailboxes now become queries automatically, checked every two minutes. The sender, PRN, organisation and any mentioned distribution are linked using the same matching the drag-and-drop flow uses — and an internal note on each ticket shows exactly how the links were made.
Replies thread onto the right ticket from both directions: a participant answering our email, or a colleague replying from within Gmail itself — both appear in the ticket conversation (Gmail-made replies are marked "via Gmail" and set the ticket to waiting-on-participant, so statuses stay truthful no matter where the answer happened).
Email a participant straight from any ticket — including phone-born ones. "Email participant" pre-fills the recipient and a subject built from the nature, PRN and distribution; one tick includes the conversation history (internal notes can never be included). The reply box on email tickets sends the same way: a real email from the team mailbox, threaded into the participant's own conversation.
Desk-sent emails appear in the team mailbox's Sent folder and the thread is marked read — someone working in Gmail sees an answered conversation, never a false "still outstanding".
Gmail stays tidy from desk work alone: ingested mail is labelled Desk/Processed (blue), resolving a ticket labels the conversation Desk/Resolved (green) and archives it out of the inbox.
The team's own Gmail labels carry over: labels on an incoming email show on the ticket in their actual Gmail colours, and a "Label in Gmail" picker on email tickets applies any of the mailbox's labels to the conversation from the desk.
Safety guards on every ingested email: out-of-office replies, bounces and service notifications never become tickets; a known sender citing a PRN that isn't theirs, an unknown sender's PRN taken at face value, or a distribution that was never sent to that lab each put a clear warning headline on the ticket before anyone replies.
Reply emails to registered portal users include a direct link to view and answer the query online.
Connecting a mailbox needs no Workspace admin: an admin visits /admin/gmail-oauth, signs in as the team mailbox once, and it's wired (a service-account route also exists for whole-domain setup). New terminal tools: queries:gmail-status (connection check, --labels lists the mailbox's tags), queries:ingest-email (manual poll) and queries:gmail-label-colour (paint any label).
Improved
New email tickets fire the same toast, chime and desktop notification as web tickets ("New email query"), and the queries list now refreshes itself every 45 seconds — no browser refresh needed to see new arrivals.
Email tickets open conversation-first like web and chat tickets, and the conversation history can be collapsed behind an arrow to keep the reply box at hand.
The email composer suggests the laboratory's registered contacts as one-click CC chips.
Caller names are guessed from the email sign-off when the sender's mail client provides no display name.
Distribution codes cited explicitly in an email now match however old the round is (previously limited to a 60-day window), which also resolves the Haem/BTLP choice for dual-organisation PRNs.
v0.50.0
A real distribution calendar: the /schedules page becomes a public Month/Annual Calendar of Distributions with scheme-tinted chips and filters, the landing page's schedule strip comes alive, both are subscribable as an iCal feed — and admins can drag upcoming distributions to new dates
New
The Schedules page is now a full Calendar of Distributions. Month view shows every dispatch and returns-deadline as a colour-coded chip (terracotta = Haematology, navy = BTLP); Annual view is a whole-year mosaic of mini-months — hover any day for its distributions, click a month to open it.
The calendar is public: anyone can see the published schedule without signing in (with a clear banner). Signed-in participants see only the programmes their laboratories are enrolled in, with a PRN selector when they cover several sites. Filters for scheme, individual programmes and status recompute the calendar live.
"Open" on the calendar means open in the participant sense — results can be entered right now. A distribution that's been prepared but not yet opened shows as upcoming, however far along the admin pipeline it is.
Returns-close dates appear in the same colour as their scheme but fainter, with a ▣ marker — a dispatch and its deadline read as one family at a glance.
Subscribe to the schedule from the calendar: a live iCal feed (webcal link with one-click Google/Outlook/Apple setup) keeps Outlook and phone calendars up to date as dates move. Export downloads the same feed.
Admins can reschedule by drag-and-drop: pick up an upcoming chip and drop it on a new day — the planned date is written back to the schedule immediately, keeping its time of day. Bundled same-day chips can be moved together, or hover the chip and drag a single distribution out on its own.
Hard rule built in: open and closed distributions cannot be moved — their chips aren't draggable and the server refuses any attempt, with a clear "locked" message when part of a bundle stays put.
The landing page's "Upcoming exercises & events" strip is now live too: the 4-week timeline navigates week by week, the organisation tabs actually filter, the agenda follows the visible window, and signed-in users see their own laboratories' programmes.
Improved
Event chips on busy days collapse into one chip with a count badge — hover to see everything inside. (Known limitation, parked for a future pass: the grouping is by shared dispatch day, not by real shipping bundles, so the chip's label is just the first distribution's code.)
v0.49.0
The live-chat desk grows up: end and transfer chats, a one-chat-at-a-time rule with a visible waiting queue, a floating chat window that follows you around the admin, and a context card on every query with one-click jumps to the caller's data, reports, invoices and a printable repeat-samples label
New
Agents can now end a live chat (from the chat window or the editor). The query stays open for triage — the participant sees "the live chat has ended" and replies flow by email again, exactly like a normal ticket.
One live chat per agent, enforced everywhere: while you're in a conversation you aren't interrupted by new chat requests — they wait as amber "Chat waiting" pills in your chat dock, queued from the database so a missed notification can never lose one. End your chat and Accept the next.
Transfer a live chat to a colleague mid-conversation ("this is really a scientific question"). If they're taking chats and free, the conversation moves live — the participant sees the new agent join. If not, the session ends gracefully and the query lands with them as a normal assigned ticket. Reassigning via the form follows the same rules — no back door that strands the participant.
The chat follows you around: accepted chats live in a floating dock at the bottom of every admin page, with unread counts when collapsed, a context strip (data entry / report / invoice shortcuts), Enter-to-send, and an End button — so opening the caller's results no longer means abandoning the conversation. Context links open in new tabs for the same reason.
Every query now gets a context & actions card in the editor, whatever its source: the lab (correctable — callers covering many sites sometimes pick the wrong PRN), the reason, each linked distribution with one-click jumps to that participant's data entry and report pages, and their recent invoices with statuses. Portal-raised queries additionally show the participant's own message verbatim.
A "Check Xero" button on the card asks Xero live for the lab's recent invoices — including ones raised directly in Xero that never touched this system — and quietly updates local statuses while it's at it.
Printable repeat-samples shipping labels: one click on a query opens a print-ready label (Zebra-sized) with the lab's delivery address, PRN and the distribution codes — the button lights up when the query's nature looks like a repeat/replacement request.
Fixed
The global search palette no longer appears stuck open after signing in (and Esc closes it reliably).
Results-entry pages no longer crash with a server error introduced by a version-toolbar check.
Linking a distribution to a query by code now searches the correct fields — previously it could error and the linked distribution's code wasn't displayed on the query.
v0.48.0
Live chat: participants can talk to an available agent in real time (and if nobody's free, the conversation is already a ticket), plus a consolidated admin top bar and a personalised dashboard
New
Participants can start a live chat from the portal. The front page shows a "Chat with us" button with a traffic light — green when an agent is available, amber in office hours when nobody is, red out of hours. Clicking it (after signing in) opens the familiar raise-a-query form with a "Start live chat" option whenever the light is green.
A chat is a query from the first message — there is no separate chat system. If no agent accepts within a minute, the participant is told the team will reply by email and the conversation is already sitting in the right team's queue as a normal ticket, mailbox email included. Nothing needs converting or cleaning up.
Agents flip an "Available for chat" strip at the top of the admin dashboard each morning (it clears automatically at end of day and on sign-out). Chat requests pop an "Accept chat" notification only for available agents on the right team — first to accept gets it, everyone else's notification quietly retracts.
Live conversations update in real time on both sides: the participant's query page and the admin editor append messages instantly, with a clear "live chat" indicator and no email noise while the chat is running. If a chat falls back to a ticket, the normal email loop takes over exactly as before.
Desktop notifications (opt-in): when a query is passed to you or a web/chat query lands in your team's pool while you're in another window, you get a real Windows/macOS notification — clicking it jumps straight to the query. Enable it from the queries page or just by going available for chat.
Participants get a "Waiting on you · N" pill in their top bar whenever UK NEQAS has replied and the ball is in their court — it links straight to My Queries and disappears once they respond.
Admins now land directly on the admin dashboard after signing in (deep links still go where they pointed), and the dashboard greets you by name — "May's Dashboard" — with your live-chat status pill in the header.
Improved
The admin top bar was decluttered: the three theme buttons collapsed into a single icon with a Light/Dark/System menu, and the scope selector and sign-out moved into a new user menu under your name — which also carries your live-chat status with a toggle. A green/red dot on your avatar shows at a glance, on every page, whether you're taking chats.
The dashboard's live-chat banner became a slim strip: green and quiet when you're taking chats, and a clear "Start taking chats" prompt when you're not — so it stays useful after the morning decision instead of taking up space.
Fixed
The organisation scope selector crashed pages for admin accounts that only have access to a single organisation — it now simply renders nothing to switch.
v0.47.0
Participant queries go self-service: labs can raise queries on the portal and hold a conversation on them, and every query now routes to a team (Scientific Haem/BTLP, Admin, IT) with its own mailbox, real-time pings and a queue badge
New
Participants can raise a query from My Queries → "Raise a query". They pick what it's about (and which lab, if they cover several), optionally point at a recent distribution, and get a Q-reference immediately. The query lands in the right team's queue automatically.
Every query now has a conversation: participants see UK NEQAS replies on the portal and can respond; their reply reopens the query and bumps it back into the team's queue. Admin replies are emailed to the participant with a link to the thread; internal notes remain internal and are never shown or sent.
Queries are routed to a team — Scientific (Haem or BTLP), Admin (registration/finance) or IT (web account/system). The nature pre-fills the team, the editor lets you change it (which doubles as handing a query to another team), and anything unclassifiable lands with the Admin team for triage. Roughly 17,600 of the ported legacy queries were classified automatically.
When a participant raises a query, the owning team's mailbox receives an email with the details and a link into the queue — each team works from its own inbox. Participant replies are emailed to the query's assignee, or to the team mailbox when nobody owns it yet.
New web queries pop a real-time notification for the right team's online admins — with a chime and a flashing tab title if you're in another tab — and a new inbox badge in the top bar counts the open, unassigned queries in your team's pool (updates every minute).
User Management gains a "Query team" setting for staff accounts (Admin / IT) that routes those teams' pings and pre-selects their queue filter.
Improved
The Participant Queries queue is now scoped: you see your organisations' queries plus the org-agnostic Admin/IT streams — Haem staff no longer wade through BTLP tickets and vice versa. The organisation switcher in the top bar narrows the queue (and the badge) further, and a Team filter + column were added.
The query editor gained a "Visible to participant" toggle — admin-logged queries stay off the portal unless shared (sending a reply shares automatically). The note box now distinguishes "Send reply" from "Add note" so it's obvious which ones the participant will see.
v0.46.0
Notes upgrade: add follow-ups to existing notes, point at the exact element on a screenshot, and a light/dark theme for the Notes page
New
You can now add to an existing note instead of editing it or filing a new one. Each note has an "+ add_entry" action that appends a follow-up underneath — with its own author and timestamp — so a note reads as a small thread ("reproduced on staging", "fixed, awaiting verify", …). Anyone can add to any note; you can edit or remove only your own entries, and the original note text is never touched.
When filing a note from the floating widget you can now highlight the exact element the problem is about — like the browser inspector, but without dev tools. Click "highlight problem element" (or press Alt+H anywhere), hover to outline elements on the page, click one or more to mark them, then press Esc: the screenshot is captured with numbered red boxes drawn on it, and each box's location in the page (its CSS selector) is recorded in the note so the developer can find the exact element.
The Notes page now has a light theme alongside the original dark one — same terminal character, lighter palette — with a light/dark/system switch in the header. Your choice is remembered per browser, and "system" follows your OS setting live. The default stays dark, so nothing changes unless you switch.
Improved
All dropdowns on the Notes page (status, filters, type, priority) were replaced with themed ones that match the terminal design, instead of the browser's default look.
The keyboard-shortcuts box on admin screens now lists the new flow: Alt+H to pick elements, then Esc to capture.
v0.45.0
Big POCTD bug-fix round from the feedback notes: distribution create/update/delete repaired, dashboard counts corrected, scoring choices stick, and amended results show the right score
Improved
Distributions list: new "Sort by" switch — exercise code (default) or issue date. (note #47)
New distributions default to issue time 00:01 and close time 23:59. (note #34)
Result entry on POCTD is locked while results are submitted — click Amend to unlock, preventing accidental edits. (note #41)
The samples tile on the distribution dashboard lists all sample names instead of just the first two. (note #40)
The results search box says simply "Search…", and the instruments count is hidden for programmes without instruments. (notes #35, #42)
Notes now display their reference number (#id) on the Notes page and in the per-page notes panel, so fixes can be tracked against specific notes.
Fixed
Creating a POCTD (or any in-house programme) distribution failed part-way with a "Failed component snapshots" error that then reappeared on every visit to the page. The underlying error is fixed, and a failed creation no longer locks the page — it shows the error once and lets you delete the broken distribution and try again. (notes #49, #56, #32)
Updating a distribution could hang the page until a refresh — the save was crashing the server mid-request after writing the change. Fixed; updates now complete normally. Editing a distribution also no longer silently renumbers its sequence/code. (notes #53, #29)
Deleting a distribution that had expert answers entered never finished — it removed the participants and then failed quietly. The delete now clears everything it needs to and completes. (note #54)
The "you'll be notified when it's done" messages for background work (deleting/creating distributions, report generation) never arrived — every in-app notification of that kind was failing to save. They now land in the notification bell. (note #54)
The scoring method chosen for a component (e.g. expert consensus on POCTD D-interpretation) was reset to the default whenever the programme's setup was refreshed after a fix. Admin scoring choices now always survive. (note #33)
Distribution dashboard counts were wrong: it could say "all results submitted" / "0 results" / "24 outstanding" regardless of reality, and disagree with the Participants page. All of these now read the true submission status — legacy non-returns no longer count as submitted. (notes #48, #39, #44)
The distribution lifecycle panel said "not scored" even when statistics sets existed. It now reflects actual scoring. (note #46)
After amending a result and rescoring, the Results & Scores grid kept showing the old score (the report was right). The grid now always shows the score of the latest version. (note #37)
The score version label in the Results header was stuck on "v1" for consensus programmes. (note #43)
Export to Excel on the Results tab produced an empty sheet for POCTD — it now exports the consensus grid. (note #36)
The Patient 1/2/3 sample buttons and performance filters on the Results tab did nothing for POCTD — they now filter the grid. (note #38)
POCTD reports showed "RhD Unknown" in the materials section even when the answer was set on the distribution dashboard. (note #55)
The "eligible participants" count on the create-distribution form said 5 when 28 would be (correctly) enrolled — the count now matches what creation actually does. (note #52)
"Seed test data" now marks the seeded participants as submitted so the dashboard reflects the test data. (notes #32, #56)
v0.44.1
Fix the notes author backfill so it actually matches notes to their author on the live database
Fixed
The `notes:backfill-authors` maintenance command matched zero notes on the live (SQL Server) database even when an author name clearly matched a user. A per-name count came back as text rather than a number, so a strict comparison treated every name as "ambiguous" and skipped it. Fixed — it now claims notes for their author as intended.
v0.44.0
Notes: edit your own notes inline, and note screenshots now show on the live site (and stay small)
New
On the Notes page you can now edit a note you created — its text, type and priority — inline, without deleting and re-adding it. Only the original author can edit a note; status changes and task links stay open to the whole team for triage. Notes now record who created them so this can be enforced.
Fixed
Screenshots attached to notes now display reliably on the live site and survive deploys. They are now kept in the database (which persists) instead of on the server container's temporary disk, which was wiped on every redeploy — so previously, screenshots silently disappeared. Existing screenshots from before this change cannot be recovered, but everything captured from now on will stick.
Note screenshots are compressed in the browser before upload (bounded to roughly under 2MB) instead of being saved full-size, so they stay small and quick to load. The server's upload headroom was also raised so a capture is never silently dropped.
v0.43.0
Your dashboard rebuilt as a two-column workspace with a "Viewing" laboratory selector and one-tap performance access; on-brand impersonation banner; keyboard-shortcut rail no longer overlaps content
New
Your dashboard ("My Dashboard") has been rebuilt to match the rest of the portal — a two-column workspace with a summary strip across the top, your distributions and performance follow-ups (CAPAs) in the main column, and lighter summary cards (support queries, account & access, memberships, performance) down the right. A single "Viewing" selector at the top switches between all your laboratories and any one PRN, instantly re-filtering the whole page.
The distributions list is focused on what is actionable — currently issued and upcoming exercises, plus recently closed ones so you can still open their reports — rather than your entire history.
Every distribution row now shows its status and whether you have submitted at a glance, with one clear action per row — Enter data, Amend, or open the Report.
A dedicated Performance card on the dashboard (plus a link on the performance follow-ups panel) gives one-tap access to the full performance dashboard, which was previously easy to miss.
Improved
The banner an administrator sees while viewing the portal as a participant ("impersonating") has been redesigned to an on-brand band — still clearly flagged with a warning stripe and a one-click "Stop impersonating" — replacing the old bright-orange bar.
Admins can now open the impersonate-user dialog with the Alt+I keyboard shortcut from any admin screen (it is listed in the on-screen shortcut reminder), replacing the floating purple impersonate button, which has been removed.
The dashboard right rail was tightened so it reads as a summary, not a second navigation: the duplicate Quick links card was removed (the sidebar already covers it), the Account & access and Memberships cards now show headline counts only, and every rail card uses the same compact footer-link style.
Fixed
The keyboard-shortcut reminder pinned to the left of admin screens now collapses to a small tab on narrow windows and only opens fully when there is genuinely room beside the page, so it no longer overlaps page content on wide screens or full-width pages.
v0.42.0
Branded, deploy-aware maintenance page at the Cloudflare edge — no more raw "web server is down" during deploys
New
During a deploy the portal now shows a branded "Cooking up an update…" page instead of Cloudflare's default 521/522 error. A Cloudflare Worker (cloudflare/) serves it while the Bitbucket pipeline has flipped a KV flag, so it covers the whole deploy window — and also acts as a safety net any time the origin is unreachable. Added a DB-free /up health route the pipeline polls to know when the new container is live (replacing the blind 60s wait). Pipeline changes are guarded, so they are a no-op until the Cloudflare variables are configured.
v0.41.0
Admin user list rebuilt as interactive cards (inline edit / password reset / impersonate) and the notes widget now auto-captures the current screen
New
Redesigned /admin/users to the portal design system: each user is a compact card (square avatar, name, email, attached PRN chips) with three inline actions — Edit, Reset password, and Impersonate (participants) / Organisation access (admins). Only one panel opens at a time.
Edit expands the card in place to change name & email, add/remove participant roles (with an inline PRN/role picker), review organisation access, and save or delete — no separate modal.
Reset password expands an inline panel offering a secure email link (user sets their own) or a manual passphrase with generate / show / copy.
User-access page gained a header band matching the admin dashboard; the system-administrator note now reads "created by IT — let us know" with the create-admin command on its own line styled as code, flagged to run on the Azure container.
Notes widget now auto-captures the current screen when a note is started — no take-screenshot / find-file / upload steps. Uses html2canvas to snapshot the live viewport client-side (no OS screenshot, no permission prompt), hides the widget during capture, shows an inline preview with recapture / remove, and keeps a manual upload fallback. html2canvas is lazy-loaded as its own chunk.
Left-gutter keyboard-shortcut reminder ("/" search, Ctrl+K log query, Alt+N new note) now shows on every admin screen, not just the dashboard — lifted into a shared x-admin.shortcuts-rail component included in the app and admin-full-width layouts (gated to admins, wide screens only so it never overlaps content).
v0.40.0
Admin dashboard brought to the redesign spec — full-bleed header band, restored Awaiting-snapshot columns, inline Smart Search, customisable quick actions (+ Invoices), top-bar search icon, left-gutter shortcut reminders, and an Alt+N notes shortcut
New
Dashboard Smart Search is now an inline search — type a PRN / hospital / contact name / programme / distribution and matching results drop down in the panel (same /admin/search backend as the global palette), rather than just opening the spotlight overlay.
Customise dashboard dialog can now show/hide individual quick actions, persisted per user; added a new Invoices quick action linking to /admin/invoices.
Added a magnifier icon to the portal top bar (admin only) that opens the global search palette, so admins need not know the "/" shortcut.
Added a keyboard-shortcut reminder pinned in the dashboard left gutter on wide screens: "/" search, Ctrl+K log query, Alt+N new note.
Alt+N opens the floating notes widget straight into its new-note form — placement and the rotating "+note" label are unchanged.
Fixed
Admin dashboard header is now a full-bleed white band below the top bar (it was floating transparent on the grey page), matching the rest of the portal; tightened the eyebrow colour, title weight and meta row to spec.
Awaiting-snapshot table restored to its full five-column layout — the HAEM/BTLP scheme tag and Results columns had been dropped. Eager-loads programme.organisation + distribution to avoid N+1.
Notes widget no longer pops open on page load. Root cause: an x-init expression returned a function, which Alpine auto-invokes, flipping the widget open on every load. The Alt+N shortcut now drives the widget via Alpine.$data on keypress, so nothing runs on load.
v0.39.0
Organisation settings dashboard + Xero connection surfacing; EORI checker 500 fix with tab-out auto-verify; editable distributor on the participant Logistics tab
New
New Organisation settings dashboard at /admin/organisations — index card per scheme plus a per-org settings page to manage brand (name, short code, primary/background colours, description, active), contact details, and the report-footer sign-off block (scheme director, authorised-by, UKAS number, operating entity, copyright). Previously none of these ~17 organisation fields had any admin UI. Added "Organisations" to the admin nav.
Each organisation card and the settings page surface the scheme's Xero connection status (Connected / Expired / Not connected) with a Connect / Re-connect link, making the previously-hidden /admin/xero page discoverable.
EORI number field now auto-verifies on tab-out (blur), guarded to skip empty input and unchanged already-verified numbers.
Distributor / agent is now editable directly on the participant Logistics tab — the picker reveals a real dropdown (it was previously a hidden field behind a dead "Change" button).
Fixed
EORI verification always returned "EU EORI returned HTTP 500". Root cause: the SOAP envelope namespace omitted the trailing slash required by the EU WSDL targetNamespace ("http://eori.ws.eos.dds.s/"), so the EU server rejected every request with a SOAP Fault. Adding the trailing slash returns HTTP 200 with a parseable result — the check now actually works.
Participant Xero contact picker no longer shows a dead "Connect via /admin/xero" message when the organisation has no live Xero connection — it renders a real "Connect Xero" button (via new non-throwing XeroService::hasLiveConnection) and skips the failing API call.
Review CAPA modal — added a "Remove CAPA" action so a CAPA raised in error or judged not required can be deleted; the row chip clears and the host tables refresh (CapaReviewModal::removeCapa + capa-updated event).
Director's comment modal redesigned. It now surfaces EVERY red/amber performance flag on the snapshot (e.g. a high-DI flag AND a WBC cumulative of 126) rather than a single hard-coded rule. The director chooses which flag(s) the comment relates to via checkboxes — nothing is pre-selected, and a comment can stand on its own (no flags ticked) when the director simply noticed something. The chosen components persist to director_comments.triggered_components; trigger_rule is no longer auto-defaulted.
Fixed
Performance tab component headers now use the curated component codes (WBC, RBC, Hb, PCV/Hct, MCV, MCH, MCHC, PLT, MPV, RDW) instead of an arbitrary 5-letter truncation of the name. Root cause: the components() computed selected only id+name (so $comp->code was always null) and ordered alphabetically — it now selects code+display_order and orders by display_order, so columns follow the curated order.
Participant name is treated as optional decoration (it is non-identifying and frequently blank for overseas clinics / POCT sites). The performance row subtitle and director-comment modal header now render the hospital name only when present and degrade cleanly otherwise — no empty "()" brackets and no dangling "·" separator before the instrument.
Apply assessment recomputes flags only for the snapshots it touched rather than walking the whole distribution (removes apply timeouts on large distributions); cumulative scores no longer zero out when the latest stat set is open against a stale closed set (prefetchAllHistoricalScores).
Participant-facing /participant/queries — read-only paginated list of queries linked to the labs the logged-in user belongs to. Honours the participant-switcher event so multi-lab users can narrow to one PRN. Status filter (all / open / waiting / resolved). New Livewire\Participant\Queries component + view; route + nav entry added. Internal admin notes are not surfaced here (they were always marked is_internal_note in Phase 1) — reply UI is Phase 4.5.
legacy:port-queries — first pass at porting tblIssue from the legacy DB into participant_queries + participant_query_threads + query_natures. Adds legacy_id columns on query_natures and participant_query_threads (the parent participant_queries.legacy_id landed earlier), so re-running the porter is idempotent across natures, queries, and thread notes. Test coverage in tests/Feature/Legacy/PortQueriesCommandTest.php.
Log-query modal contact picker — when a User is picked from the suggestion list, manual name/phone/email inputs hide in favour of a chip showing the linked contact (operator can hit a Clear button to fall back to typed caller details). Prevents accidental overwrites when forwarding-on-behalf-of style emails land via the .eml drop zone.
EmailParser + QueryAutopopulator — refinements on the dual-org disambiguation waterfall + sender-precedence (Reply-To / X-Original-From) flow. Test additions in tests/Feature/Services/EmailParserTest.php + QueryAutopopulatorTest.php.
v0.37.0
Distribution Dashboard — full editorial redesign across every tab + Performance modal set
New
Distribution Dashboard page chrome rewritten in the editorial design language: navy #1a3658 primary, signal blue #3b6ea5 accents, Inter Tight + JetBrains Mono fonts, square corners, 1px hairline borders, no Tailwind rounded utilities. Page header (breadcrumb · code · status · action cluster) now lives inside the workflow blade — the old x-slot header is gone. Tab strip restyled with 2px signal underline on active tab.
Participants tab: filter chip row (All / Submitted / Pending / Non-return) with live counts wired to a new $participantFilter + getParticipantChipCountsProperty + participantAggregates() helper on DistributionDashboard. Grid table replaces the old <table>, snap-state pill (Submitted / Pending) per row, mono PRN + report column dropdown for multi-version reports, search + pager footer. Remove Participant + Remove Snapshot modals reskinned to ConfirmModal pattern (36×36 icon tile, square bullets, ↵ keycap on primary). Add Participants modal newly built — the button was previously dead.
Statistics & Reports tab fully rewritten (statistics-table.blade.php 761 → ~500 lines): CSS grid instead of <table>, stat-set rows with 3px signal stripe + tint bg for the latest set, LockBadge (LOCKED coral / UNLOCKED green), score-version sub-rows with L-connector lines, 3px progress bar. Publish / Unpublish / Regenerate confirms ported to the ConfirmModal pattern (Publish green→navy, Unpublish warn→navy with "emails sent" danger bullet, Regenerate coral). Foundation Template inset card surfaces three explicit states (none / draft / ready) and the stale-exclusions InfoNote uses the 3px-stripe pattern.
Results & Scores tab restyled: section header with mono summary, filter bar combining a Performance chip row (All / Acceptable / Watch / Critical with live counts wired to getResultsChipCountsProperty), Sample segmented switcher (getResultsSampleOptionsProperty), and search. Quantitative grid uses the 8-column JSX layout; consensus path keeps existing semantics but adopts the design tokens (deferred full consensus-grid redesign per memory project_consensus_grid_redesign). Score legend in the footer.
Performance tab fully rewritten (distribution-performance-monitor.blade.php ~660 lines): tab-style panel switcher (Per-panel / DI / CV / NR), Recompute (ghost) + Run assessment (navy) action cluster, assessment summary InfoNote with flagged preview list, panel actions row with Show chips + segmented threshold preset (80/100/120/150) + custom field + Send letters coral primary. Grid uses CSS grid with the JSX column template (180px + N×64px + 56px + 56px + 132px); component abbreviations come from $comp->code (WBC / RBC / Hb / PCV-Hct / MCV / MCH / MCHC / PLT / MPV / RDW). ScoreChip tinting follows the threshold (faint baseline / amber tinted ≥ threshold / coral tinted ≥ 150), FlagSummary in its own 56px column so it can't bleed into NR/Actions, row kebab dropdown (Override flags / Add-or-Edit director comment / Raise CAPA / View score history) with @click.outside Alpine guard. Component glossary + Score legend footer reflect the current threshold.
Performance modal set rewrites (6 modals): ManageFlagsModal (flag-override-modal, 680px) with reason input + scrollable FlagRow list + Green/Amber/Red tri-toggle (selected box-shadow:inset 0 -2px underline) + Auto button when manual override exists; DirectorCommentModal (580px) with RULE A1/A2/A5/A6 InfoNote + required textarea; RaiseCapaModal (580px) with coral flagged-component chips + severity select + required description; ReviewCapaModal (capa-review-modal, 720px) with header status + severity mono pills, 2-col raised/flagged grid, read-only issue description, admin response + sign-off textareas, Close / Save draft / green Sign-off primary; SendLettersModal (640px) with recipient list (PRN + mono component-code chips) + optional message + signal InfoNote + navy Send-N-letters; NrWaiverModal (520px) with success-green stripe + reason textarea + navy Waive. All share the shell: 6×6 signal square + title + esc keycap header, hairline body sections, #f6f8fb panel footer with ↵ glyph, navy-ink 40% backdrop, square corners.
sync-participant-instruments modal restyled to the same shell (680px, signal stripe, issued-distribution warn InfoNote, select-all bar, registration list). Animation keyframe renamed to sync-spin to avoid global collisions. Toast notifications: structured monoCode / prn / scheme fields plumbed through resources/js/app.js so the dashboard toasts can render inline mono codes; the transparent-background bug from the previous toast pass fixed by switching the affected style binding from Alpine :style="..." (which broke on the literal single quotes in the font-family value) to plain style="...".
Fixed
DistributionDashboard Participants + Results filter chips cache-key on the active filter so switching between Submitted / Pending / Watch / Critical no longer returns the previous filter's cached data. Quantitative results chip filter applies an ABS(rsc.deviation_index) SQL band directly in the query so Watch (≥80) and Critical (≥150) survive paging.
v0.36.0
Log-query modal: drop .eml/.msg to autopopulate; dual-org PRN toggle; persistent org chip
New
Drop zone at top of the log-query modal accepts .eml (Gmail / Apple Mail / Outlook Web) and .msg (Outlook Desktop, via the new libemail-outlook-message-perl msgconvert shell-out in Dockerfile + docker/8.4/Dockerfile). Parser extracts From / Reply-To / X-Original-From, subject, body, date via zbateson/mail-mime-parser. A dismissable trace banner shows what got matched ("Matched contact: Jane Smith → PRN 12345 → HAEM via distribution HZQ77") so operators can audit the autofill.
New QueryAutopopulator service: contact → PRN → org → distribution waterfall. Sender email looks up Users (Reply-To beats From for forwarded mail), the 5-digit PRN regex hits get intersected with the contact's participant set (kills postcode/order-number collisions), dual-org PRNs are disambiguated by any distribution code mentioned in subject+body (last 90d issued + next 45d scheduled, per programme enrolment). Description fills with raw body — no AI call.
Modal participant search is now deduplicated by PRN: dual-org PRNs render once with both Haem + BTLP badges and a DUAL-ORG tag, instead of two near-identical rows. Picking a dual-org row lands on the PRN with the Haem|BTLP segmented toggle styled "PICK ORG" (signal-red accent) until the operator chooses. Single-sibling PRNs preselect the toggle and lock it — same control, consistent affordance.
Save is blocked when a dual-org PRN has no org chosen, with an inline error on the toggle. Prevents queries from landing against the wrong participant record and corrupting downstream reporting splits.
Persistent org chip on the queries index (now on the Reference column for at-a-glance scanning) and the query editor header. Chip reads from query.organisation_id first, falls back to participant.organisation for legacy rows that pre-date the column.
v0.35.2
Log-query modal + handoff toast redesigned to new design system
New
log-query-modal.blade.php fully redesigned: navy #1a3658 primary, signal blue #3b6ea5 accents, Inter Tight + JetBrains Mono fonts, zero border-radius policy, uppercase tracked field labels, scheme tags (HAEM coral / BTLP blue), segmented source control (Phone / Email / Portal), segmented category strip, distribution DistPill selection (navy selected state), sample sub-strip (2px signal-blue left accent), caller chips with Other dashed, handoff picker with online dots (50% border-radius only), footer with ↵ glyph + keycap hints.
handoff-toasts.blade.php fully redesigned: 380px width, 3px signal-blue left accent stripe, BellIcon header, mono reference + scheme tag + PRN, quoted body with dashed top border, keycap hints (O/esc), navy Open button, slide-in transition via Alpine x-transition, keyboard O/esc shortcuts scoped to hovered toast via Alpine x-data.
v0.35.1
Fix POCT-D Results & Scores tab disappearing after amendment
Fixed
getConsensusResultsWithScoresProperty() was filtering/selecting on pc2 (results.programme_component_id join) instead of pc (DCRS join). The POCT-D porter sets both columns, so initial ported results showed correctly, but ResultsEntry sets programme_component_id = null on all new/amended results. After any amendment the pc2 join is null → whereIn(pc2.scoring_method) matched nothing → all results vanished from the admin Results & Scores tab. Fixed by switching the WHERE, SELECT, and ORDER BY to pc throughout.
POCTDReportDataService::getScoredResultsFromSnapshot() — added orderBy(crs.id) before keyBy(sample_id) so that if is_latest ever has duplicates the most recently created score record wins rather than a random one from the query plan.
FY year dropdown native browser arrow was overlapping the text. Fixed px-2 → pl-2 pr-6 to give the right side clearance.
v0.34.0
Agent ledger snapshot-based quarter bills + FY breakdown; cross-org contact porter fix + repair; administrator role locked down on participant edit page
New
Agent ledger replaces the "Next cycle invoice" projection card with a snapshot-derived per-quarter view for actual_distributions agents. AgentInvoicingService::currentBillQuarter(today) picks the most recent quarter whose last month is ≤ today (Jan–Mar shows during Mar–May; flips to Apr–Jun once we tick into June). actualQuarterBill() bills past quarters from distribution_snapshots only — per (lab × programme), annualPrice ÷ fyDistributionCount × distinct distribution count, with instrument count derived from the distinct enrollment_id values in the lab's snapshots so retired instruments still bill for the quarter they were active in.
projectedQuarterBill() — hybrid for in-progress / future quarters. Confirmed portion reads agent-scoped snapshots; projected portion is active memberships × schedules still to open (no linked distribution or no issued_at). Each half uses its own at-the-time vs current instrument count. Quarter assignment everywhere uses distributions.issued_at (the survey opening date), not the planned issue_date — a Q4 schedule that slipped to a Q1 open belongs to Q1's bill.
FY quarter breakdown section renders Q1–Q4 of the selected FY, each row showing confirmed / projected splits and a Distributions diagnostic listing every distribution feeding that quarter's bill (programme, code, date, kind, lab count) — surfaces what's feeding each total so admins can trace any £ back to actual rows. Immediately exposed the legacy `-TEST-` schedule rows clustering in early April and stale DL schedule duplicates that were inflating Q1 reads.
AgentInvoicingService::fyDistributionCount() — robust FY divisor for per-distribution price. Takes max(distributions count, distribution_schedules count) per programme so past FYs that dropped schedules don't under-count and in-progress FYs with the occasional ad-hoc distribution don't inflate the per-distribution rate.
raiseActualDistributionsInvoice() — materialises a snapshot-derived quarter bill as a draft DistributorInvoice (idempotent on (distributor × quarter × org); refuses to raise £0).
legacy:repair-cross-org-contacts — finds and removes the wrong-org rows the pre-fix PortContactsCommand left behind across participant_contacts, participant_user_role, and user_participant. Dry-run by default; preserves UI-granted role rows (created_by IS NOT NULL) and the pivots that anchor them. Nulls cohort_email_send_recipients.participant_contact_id before deleting contacts so audit-log rows survive the FK.
Fixed
PortContactsCommand respects tblContact.OrganisationID. The pre-fix porter fanned every contact row's writes into both our-side participant halves of a dual-org PRN regardless of the legacy contact's own organisation, then collapsed N legacy consultants into one slot row via a single-row upsert key. The fixed porter maps legacy OrganisationID via LEGACY_TO_OUR_ORG (1→1, 2→2, skip 3=FMH), filters ourRows to the matching org before writing, keys consultant (multi-instance) slot rows on legacy_contact_id, and walks legacy contact rows in a deterministic order so single-instance slot collisions resolve to the most recently modified row.
PortContactsCommand::upsertUser now looks up by legacy_id first — a legacy contact whose email changed since the last port (e.g. legacy 85747 going from [email protected] to [email protected]) updates the existing user instead of colliding on the users_legacy_id_unique constraint.
Per-participant Users & Access section (UserManager Livewire component, separate from /admin/users) was rendering every role including administrator in four assignment surfaces (Quick assign chip, Create / Attach / Edit User modals). Mirrors the six-slug whitelist already used by the standalone /admin/users page (UserManagement::$availableRoles) and adds a server-side filter in toggleUserRole / createUser / attachUser / updateUser so a crafted Livewire payload can't bypass the UI. updateUser preserves any pre-existing CLI-granted administrator link instead of stripping it on a name-edit save.
GET /admin/impersonation/stop — soft fallback redirecting to the admin dashboard so back-button traversal after an impersonation session no longer hits a 405.
v0.33.0
Participant Queries Phase 1 — schema, models, admin index + editor, contextual launch from participant + distribution pages, global Ctrl+K modal
/admin/participant-queries index with status / category / nature / assignee / priority filters, free-text search across reference + caller + PRN + description, snapshot bar showing open / waiting-on-participant / resolved-today counts.
/admin/participant-queries/{create,edit} editor with full form, nature dropdown, participant search picker, distribution-by-batch-number link picker, threaded notes (internal vs reply), workflow sidebar with status + assignee + auto-stamped resolved_at. Status transition to resolved auto-fills resolved_at; non-resolved clears it.
Contextual launch from the participant edit page header (Queries badge with open count + "Log query" pre-fills participant). Same on the distribution dashboard header (badge counts queries linked to that distribution; launch pre-creates a Distribution link on save).
Global "Log a query" modal mounted in the layout — admin-only, fires on Ctrl+K (Cmd+K on Mac, since most of the operator team is on Windows). Stripped-down form (description, category, source, nature, participant picker, optional caller details) so an operator can capture an inbound call mid-conversation without leaving the current page; save redirects to the full editor for follow-up notes and links. Shortcut ignores keystrokes typed inside inputs/textareas so it can't hijack typing.
Admin nav renamed legacy "Queries" entry to "Cohort Explorer" (its actual semantics) and added "Participant Queries" as a sibling — no more collision between the two unrelated query concepts.
v0.32.5
Per-agent invoicing Phase 2.5 — push draft DistributorInvoice to Xero
New
XeroService::pushDistributorInvoice — mirrors pushInvoice for participants but routed through the distributor's pre-linked Xero contact (no find-or-create — agents must be picked via Settings tab first). Single line item describing the cycle window + billing method. Defaults Xero tenant to Haematology org (distributors aren't bound to a NEQAS org). Idempotent on xero_invoice_id.
"Push to Xero" button per draft row in the agent ledger's "Invoices raised" table. Disabled when the distributor has no linked Xero contact — surfaces "no Xero contact" with a tooltip pointing back to the Settings tab. After push the invoice moves to status=raised_in_xero (or sent if xero.auto_send is on) and the Xero invoice number replaces the placeholder.
v0.32.4
Results & Scores grid keys cells by (sample, component) so multi-component consensus programmes no longer collapse
Fixed
getConsensusResultsWithScoresProperty now keys per-cell data by `"{sample}|{component}"` instead of `sample` alone. For single-consensus-component programmes (POCT-D) the table looks identical; for multi-component ones (RCG with 19 phe/gen pairs) each (sample, component) gets its own column instead of silently dropping all but one per cell.
New getConsensusGridColumnsProperty drives the table headers — one column per (sample, consensus-scored component), with the component name underneath the sample code whenever there's more than one component (so POCT-D headers don't change but RCG headers gain the component label). max-score roll-up uses the column count too.
v0.32.3
Results & Scores grid filtered to consensus-scored components only — POCT-D no longer flickers between reagent-reading and D-Interpretation answers per cell
Fixed
DistributionDashboard::getConsensusResultsWithScoresProperty was joining every component's results into the grid, then collapsing per (participant, sample) via keyBy('sample_code'). For programmes with mixed scoring (POCT-D has one consensus component + two `none`-scored reagent-reading fields), the per-cell winner was effectively random — one cell would show "Positive" (D-Interpretation) and the next "Strong Positive" (Anti-D Reagent reading). Added a whereIn filter so only majority_consensus + predetermined_consensus components feed the grid. Verified on 26R2B: 40 Positive + 23 Negative D-Interpretation entries display cleanly instead of mixing with the 39 Strong Positive Anti-D readings.
v0.32.2
POCT-D port backfills expert answers (DCCA) from legacy sample_answers — re-port any POCT-D trial and the panel-set correct answers populate automatically
v0.32.1
Per-agent FY baseline — projected-annual computation + commit-as-charges action so agents start with a populated running total
New
AgentInvoicingService::previewFyBaseline sums each managed lab's active subscriptions × annual price (uses PricingService::annualPrice with proper variant + instrument-count resolution). Pure preview; no DB writes.
AgentInvoicingService::generateFyBaseline materialises the preview as participant_charges rows of charge_type=fy_baseline tagged routed_to_agent=true. Idempotent: previous fy_baseline rows for the same (FY, agent) get cleared before re-insert so the action always reflects current subscription state. Doesn't touch other charge_types (subscribe / unsubscribe / instrument_add) — those remain the audit trail for mid-year changes.
Agent Ledger gains a "Projected FY total" card at the top of the Ledger tab: shows the projected annual sum, an expandable breakdown of every contributing (lab, programme, variant) line, and a "Set FY baseline" button. Once committed, the running_charges_total used by the cycle preview lights up against the new baseline.
AgentInvoicingService computes the next cycle invoice from two running totals: running_charges_total (sum of agent participants' routed_to_agent charges in the FY) and running_invoiced_total (sum of distributor_invoices raised). outstanding = charges − invoiced. Mode `projected_annual_split` divides outstanding by remaining cycles in the FY; mode `actual_distributions` weights by (cycle distributions / FY-remaining distributions) so the cycle bill tracks service actually delivered. FY-aligned cycle windows derived from billing_interval_months (Apr 1 start).
distributor_invoices table records each raised cycle invoice: cycle_start/end, billing_type, total, status (draft → raised_in_xero → sent), Xero ids, audit fields, metadata snapshot of the calc inputs.
Agent Ledger tab gains a "Next cycle invoice" preview card showing cycle window + amount + the running totals that produced it + a "Raise draft invoice" button. After raising, an "Invoices raised" table tracks each cycle's status / Xero linkage.
Worked example verified end-to-end: £10,000 annual ÷ 4 = £2,500 for Q1; after Q1 raised and +£2,000 mid-year charges, outstanding = £9,500 / 3 = £3,166.67 for Q2. Exactly the operator-spec'd rebalance.
v0.31.4
Agent ledger Settings tab — billing cadence, billing type, Xero contact link (Phase 1 of per-agent invoicing)
New
distributors gain billing_interval_months + billing_type + xero_contact_id / xero_contact_name / xero_linked_at / xero_linked_by_user_id columns. CK_distributors_billing_type CHECK constraint scopes billing_type to projected_annual_split or actual_distributions.
/admin/agents/{id} now has two tabs — Ledger (existing) and Settings. Settings exposes the billing cadence (every N months, with a smart label preview: 1=Monthly, 2=Bimonthly, 3=Quarterly, 6=Semi-annual, 12=Annual, else "Every N months"), billing method picker (projected annual ÷ remaining cycles vs per actual distributions delivered), and a live-search Xero contact picker that mirrors the participant-side XeroContactLink flow.
Agent header chips surface the configured cadence + Xero contact name at a glance. The Settings tab opens by default when an agent hasn't been configured yet, so operators land on the missing config rather than an empty ledger.
v0.31.3
Pricing kept off agent-managed participants — Pending Quote tab + dock + BillingTab all hidden from labs whose distributor is the billing agent
Fixed
Participant\Memberships forces pendingBadge=null when the lab is agent-managed, so the Pending Quote tab never appears on the participant side. Admin view is unaffected.
participant/memberships.blade.php @unless guards on the BillingTab + PendingDock includes — both surfaces are skipped entirely for agent-managed labs.
BillingTab Blade got a defence-in-depth guard at the top: if ever mounted in participantMode against an agent-managed participant it renders only a "Managed by your distributor" placeholder, no charges, no £ amounts.
Agent has their own (likely marked-up) pricing model; leaking UK NEQAS base prices would undermine the agent relationship. The data-side routing (routed_to_agent flag on participant_charges) already handles invoicing routing; this commit closes the UI-side disclosure path.
/admin/agents — Billing Agents index. Lists every distributor flagged is_agent with per-agent counts (participants managed, charges raised) and total accumulated charge amount, FY-filterable. Surfaced in the admin nav between Participants and Programmes.
/admin/agents/{distributor} — per-agent ledger. Shows totals, every participant under the agent with their accumulated total, and a flat paginated ledger of every routed-to-agent ParticipantCharge row, newest first. Read-only for now — per-agent invoicing actions are future work.
legacy:port-distributors — port dbo.tblDistributorType into our distributors table with the agent / ship_to_agent / show_consent flags. Idempotent on legacy_id; adopts existing rows by name on the first run so seeded distributors keep their participants.
legacy:port-participants now sets participants.distributor_id from tblParticipantOrganisationDetails.DistributorTypeID. participants whose distributor is flagged is_agent flow charges to the agent ledger automatically via the existing PricingService.recordCharge → routed_to_agent path.
Fixed
Admin amend on closed distributions — ResultsEntry::submitResults / ::amendResults no longer block when the distribution is past its close date. Admin route is already behind admin middleware; the open-window gate was redundant on that path. Participant-side flow still enforces it.
POCT-D report rendered raw option IDs (e.g. "39") instead of labels ("Positive") — POCTDReportDataService had two hardcoded `programme_id = 14` lookups. Switched to a join on programmes.short_code = "POCTD" so the lookup is environment-agnostic.
v0.31.0
Consensus scoring methods aligned with domain — predetermined = panel sets answer, majority = strict >50% from labs
v0.30.4
Distribution dashboard Overview tab — per-sample card with inline expert-answer picker; POCT-D D-Interpretation flipped to expert_consensus
New
Distribution dashboard Overview — new Samples section listing each sample with code, optional description, and submission count. For programmes with expert_consensus components, an inline per-sample answer picker renders next to each sample with a single "Save correct answers" button at the bottom. No more chasing a separate scoring tab — set answers right where you see the samples.
Fixed
Sign in button — replaced the swap-span layout (idle vs loading spans) that visually stacked the spinner above the text in some browsers. Now a static "Sign in →" label with an inline spinner SVG that fades in during the request.
stats:recompute-programme {short_code} [--from=] — programme-mode wrapper around stats:recompute. Mirrors scoring:rescore-programme: enumerates every distribution in chronological order, runs each in its own PHP sub-process for memory isolation.
Fixed
Login form — drop wire:model.live on email/password/remember. Inputs no longer round-trip to the server on every keystroke (privacy: partial password stays in the browser; UX: no spinner flash mid-typing).
Sign in button — collapsed three sibling spans into two atomic loading spans (idle: "Sign in →" / loading: spinner + "Signing in…"), each scoped via wire:target="login" so unrelated Livewire requests don't trigger the loading state.
Dockerfile — create storage/framework/cache/data on container build and startup. Laravel's file cache driver shards into that subdir; without it RateLimiter (and any other file-cache write) blew up with "Failed to open stream" on first use after a fresh container.
scoring:rescore-programme + stats:recompute-programme — forward parent ini_get('memory_limit') to each spawned PHP sub-process via -d memory_limit=…, so children honour the operator's -d 2048M flag instead of booting with the 128M default.
scoring:rescore-programme + stats:recompute-programme — tolerant short_code lookup (FB↔FBC, DL↔ADLC, RE↔RETIC, REHb↔RET-HB, DN↔DNA). Prod still carries the legacy short_codes; commands now accept either form.
v0.30.2
PV programme — full port including hybrid (quantitative + qualitative interpretation) result handling
New
legacy:port-pv-memberships — write instrument_enrollments + lab_component_registrations for PV from the shared tblFBCMembershipDetail table. Each enrolment registers BOTH the PV (quantitative) and PV-INT (qualitative interpretation) components.
legacy:port-pv-trial / legacy:port-pv-trials — port historic PV trial results. Per sample, writes two results rows: the TestValue against component PV, and the interpretation text (Low/Normal/High/Indeterminate, decoded from tblPVSResult.InterpretationID) against PV-INT. Sample quality flags (UnSatisfactory + ReasonID + OtherReason) flow into result_sets. Idempotent.
PV-INT (Plasma Viscosity Interpretation) programme_component — majority_consensus scoring, four valid answers. Per the "each answer is its own component" rule: don't pack qualitative interpretation into PV-quantitative result metadata.
v0.30.1
Legacy port hardening — instrument_enrollments unique key swap (identifier not serial), port-trials reads DistributionNo directly, orphan-sweep applied across all trial porters
Fixed
instrument_enrollments unique index moved from (participant, instrument, serial_number) to filtered (participant, instrument, identifier). Identifier is the physical machine; serial is informational and only soft-checked at re-registration. Legacy port no longer needs to invent placeholder "legacy-pmid-N" serials when an unrelated identifier shares a serial — real legacy SerialNumber now stays on the row.
legacy:port-trials — read distribution_code from tblTrial.DistributionNo instead of synthesising YYMMSC+suffix. Every programme-specific trial porter (FBC/ADLC/HB/RETIC/POCT/BCM) already used DistributionNo; the synthesised codes were getting overwritten and tripping the distribution_code unique index when the synthesised value disagreed with the legacy label.
All trial porters (FBC/ADLC/HB/RETIC/POCT/BCM) — orphan-sweep before saving. If the row matched by legacy_id has a different distribution_code than the legacy DistributionNo and an orphan (NULL legacy_id) row owns the target code, delete the orphan first so the UPDATE does not collide.
BCM trial porter — drop the synthesised "bcm-{p}-{i}" serial. identifier="BCM" now carries enrolment uniqueness (one BCM analyser per lab); serial stays NULL.
Distribution model — drop broken unreported_results_count accessor that called non-existent $this->results() relationship. The column itself is a real DB column maintained by updateUnreportedResultsCount(); the accessor + $appends combo broke toArray() / tinker / API serialisation whenever the column was not in the SELECT.
v0.30.0
Pricing pipeline — schedule porter, PricingService with pro-rata + Jan-Mar credit block, participant_charges ledger, Add Instrument programme picker, wired across every membership / instrument action
New
PricingService — pure pricing computation in app/Services. annualPrice, subscribeCharge (with pro-rated tier-delta), unsubscribeCredit (Jan-Mar credit block per UK NEQAS policy), resolveVariantFor (DNA via subscription_types, ES via instruments.es_sample_type), currentInstrumentCount, recordCharge seam.
participant_charges ledger table — every pricing event written immediately on action (subscribe / unsubscribe / instrument_add / instrument_remove / variant_change). Signed `amount`, full `metadata` JSON snapshot, `routed_to_agent` flag set automatically for agent-managed labs (those rows are preserved for the eventual agent ledger but excluded from invoicing).
Auto Counting Add Instrument modal — programme picker. Checkboxes per compatible programme, pre-ticked, untickable. Each ticked programme generates one charge with correct tier-counting. Replaces the previous "auto-register everything" behaviour that silently enrolled labs in up to 7 programmes per click.
legacy:port-trials — port tblTrial → distributions for the current FY (default behaviour). Maps legacy short codes to ours (FB → FBC, DL → ADLC, RE → RETIC, REHb → RET-HB, DN → DNA), synthesises distribution_code as YYMMSC with A/B suffix on collisions, status enum mapped (pending / open / closed / analysed). Retries on Azure SQL login timeouts (3 attempts, 2s backoff). Idempotent on legacy_id.
v0.29.0
Pricing catalogue — financial_years, currencies, per-FY exchange rates, programme_prices with main/extra instrument tiering; full port from legacy vrtblPrices verified against rereg 26 (Haematology 2026/27)
New
financial_years table — Apr–Mar ranges, label "2026/27" style, is_locked gate for invoice-time integrity, legacy_id for porter reconciliation. FinancialYear::containing($date) resolves "what FY does this date fall in".
currencies lookup table (GBP / USD / EUR) — programme prices are stored in GBP only; foreign-currency invoicing applies the FY-locked exchange rate at invoice time.
financial_year_exchange_rates table — per-(FY, currency) rate (foreign units per 1 GBP, mirrors legacy convention). Fixes legacy's "single ever-updating rate" problem so historical invoicing is audit-faithful.
programme_prices table — keyed by (financial_year, programme, variant_label). variant_label is the connective tissue: "Alpha" / "Beta" / "Alpha & Beta" for DNA (matches subscription_types.description), "ESR" / "ESX" / "ESI" for ES (matches instruments.es_sample_type), NULL for single-price programmes. Tiered instrument pricing via base_price + main_instruments_count + extra_price + extra_instruments_count — matches the legacy MainPrice / ExtraPrice model for FBC, ADLC, RETIC, HB.
ProgrammePrice::annualPriceFor($instrumentCount) computes the correct annual charge — verified e.g. FBC 1 inst = £582, 8 inst = £1,332 (£582 base for first 3 + 5 × £150 extras).
v0.28.0
Participant-side membership parity (Guardrail 1 + agent-managed lockout) and Auto Counting per-component editor + CM compatibility backfill
New
Participant-facing /participant/memberships rebuilt with full per-programme tab parity to the admin edit page (Automated Counting · AH · G6 · NH · PV · ES · MN · BF/PA/DF · DNA). HaemMembershipTabs + AhMembershipTab now take a $participantMode flag that wires per-user edit gates (participant-admin or edit-membership role), agent-managed lockout (read-only when distributor handles billing), and hides admin-only controls (NH Welsh-protocol flag).
Distribution-aware messaging on subscribe/unsubscribe: surfaces the next available distribution (the earliest one whose snapshots haven't been created yet) so participants see "you'll be in 2607FB issuing 24 Jul" before they confirm. Implemented by Distribution::nextAvailableForProgramme() — snapshot creation is the lock point, ~3 weeks before issue, matching the packing-prep window.
Auto Counting matrix fully wired: new Admin\AutoCountingTab Livewire component replaces the previous read-only placeholder buttons. Add instrument (modal with compatible-only selector + auto-register for every compatible programme), Suspend / Reactivate per row, Remove (full enrolment deletion + cascade), per-cell Config modal with per-component checklist (essential for ADLC where labs typically run only a subset — WBC + NEU + LYM + MON, no EOS/BAS/NRB). Cell visual now communicates partial enrolment as "✓ M/N" haem-tinted badge.
Fixed
Auto Counting cell click no longer silently no-ops when an instrument has no recorded compatibility for a programme — surfaces a flash explaining the missing compatibility data so admins can backfill it.
Per-component Config modal pre-fill: cast pluck() results to int so strict in_array comparison matches correctly and existing registrations show as ticked.
New public landing page at /home — modern lab aesthetic (Direction C): abstract microscopy hero, navy palette, at-a-glance stats card, "For participants" quick-jump strip, 4-week schedule + news/wire panel side-by-side, scheme cards, and a dark footer. Lives in resources/views/home.blade.php.
Login screen redesigned with a discreet A/B toggle at the bottom — variants live in resources/views/auth/_login/ and the standalone references are in docs/design_handoff_login_redesign/ (standalone HTML + light/dark screenshots).
New editorial UI primitive components in resources/views/components/ui/ (kicker · pill · status-dot · hairline-card · counter-cell · segmented · summary-card) — the design-system shorthand the participant edit page and the Haem membership tabs rely on. Preview gallery at /dev/ui-preview.
Admin chrome split into reusable partials in resources/views/livewire/admin/_entry/ (header · sidebar · action-bar · toolbar). Layouts (admin-full-width, app, guest) updated to consume them; navigation, organisation-switcher, contact/address/user managers refreshed to match.
Participant edit page tab partials extracted to resources/views/admin/participants/_tabs/ (logistics, users) — pre-work for the per-programme tab system landed in the previous commit.
Tailwind config gains the editorial palette tokens (navy / signal / ink / ink2 / ink3 / rule / panel / ok / due / warn / haem) plus haem-tint, ok-tint, warn-tint, due-tint accents.
v0.26.0
Full Haematology membership pages — per-programme tabs (MN · DNA · NH · G6 · BF/PA/DF · PV · ES · AH), legacy PV + ES instrument ports, and a CHECK-constrained ES sample-type tag to prevent silent mis-routing
New
Participant edit page Programme Memberships tab now has one inner tab per Haematology programme instead of three programme-family groups: Automated Counting · AH · G6 · NH · PV · ES · MN · BF / PA / DF · DNA. Automated Counting keeps the existing instrument matrix; every other tab gets a dedicated body.
MN, DNA, NH, G6, BF/PA/DF tab bodies hosted by a single Livewire component (HaemMembershipTabs). DNA writes subscription_type_id to a new generic subscription_types lookup (3 ESR variants seeded for now). NH stores welsh_protocol in the membership settings JSONB. G6 covers independent screening / assay channels with method picks + lab reference, all on a new g6_membership_details table. BF/PA/DF render side-by-side; BF activation auto-subscribes DF via a model boot hook so distribution scheduling can treat them as independent rows.
AH (Abnormal Haemoglobins) re-skinned: dedicated AhMembershipTab Livewire component with all 12 AH-specific methods ported from MembershipManager. Component cards, SCS / FQ instrument modals, FQ subcomponent toggles (HbA · HbF · HbS · …) — full logic preserved, visual chrome matches the new tab aesthetic.
PV instruments ported from legacy tblPVInstrument (10 analysers, reconciled by manufacturer + description against existing instruments). PV tab body renders a one-column instrument matrix with an Add modal scoped to PV-compatible instruments. Enrolling an instrument auto-creates the PV LabProgrammeMembership.
ES (Erythrocyte Sedimentation Rate) shipped end-to-end with a safer sample-type model than legacy. instruments.es_sample_type is a nullable enum (es | esx | esi) pinned by a SQL Server CHECK constraint. Legacy SubscriptionType IDs 50 / 51 / 63 are mapped at port time; unknown subscription types surface as warnings instead of silent skips. ES tab has three stacked sections (ESR / ESX / ESI) each with its own Add button — the Add modal is sample-type-scoped so an admin can only enrol instruments tagged for that section, with a server-side guard that rejects mismatched submissions.
Fixed
DFMembershipDetail / PAMembershipDetail / G6MembershipDetail now declare explicit $table because Eloquent's snake_case converter mangles consecutive uppercase ("DF" → "d_f") and would otherwise look for d_f_membership_details on read.
Inline @php block with apostrophe-heavy strings in resources/views/admin/participants/edit.blade.php broke Blade's @php directive matcher — moved $phaseCStubs (now obsolete) into the top-level @php block, and the followup ES tab body lives in a Livewire view so the issue can't recur there.
Removed `wire:transition.duration.220ms.opacity` from task rows. Wire:transition is meant for elements that are conditionally rendered with @if or wire:show; on every row in a @foreach it caused Livewire to apply the leave transition during DOM morphs (e.g. on a status update), which left rows invisible until refresh.
Republished Livewire assets via `php artisan livewire:publish --assets` — the Composer-installed Livewire was newer than what was in `public/vendor/livewire/`, so `@assets` and `@script` directives weren't being recognised by the runtime. (Subsequently moved off both directives in favour of Alpine x-init for the Gantt mount, but the asset republish stays so other components using @script/@assets work properly.)
Added `wire:key` to the list-view root (`list-view-root`) and gantt panel (`gantt-panel`) so Livewire's morph algorithm cleanly removes the entire view tree on toggle instead of trying to morph individual phase blocks against the gantt panel.
JS block comments inside `x-data="..."` no longer use double-quote characters — those would prematurely close the HTML attribute and leak the rest of the Alpine code as visible page text.
v0.24.0
New /projects in-app project tracker — phases + tasks + Gantt, fed by validation docs in docs/testing/, two-way linked to /notes
New
New `/projects` (admin only) — phases-as-first-class project tracker that lives next to /notes. Each phase has a name, assignee (admin user), status (planned/active/on_hold/done/dismissed), start/end dates, and a derived progress percentage averaged from its child tasks. Phase headers are inline-editable; setting a phase's assignee cascades to its child tasks (skipping individually-assigned tasks by default; opt-in checkbox to overwrite).
Tasks render as a clean per-phase table with columns Task / Expected outcome / Assignee / Deadline / Status, and a subtle row-background tint per status (todo white, doing blue, blocked amber, done green, dismissed faded rose). Inline edit per row, quick-status dropdown, "📝 file note" button that deep-links to /notes with the task pre-attached.
Gantt view (frappe-gantt CDN; no new packages) renders one bar per phase with progress fill — phases are the unit of project planning. Click a bar to edit the phase. Tasks without dates aren't cluttered onto it.
New `tasks:sync-from-test-docs` artisan command — walks docs/testing/*.md, parses the standardised "## Test Cases" markdown table from the CLAUDE.md template, and idempotently upserts one Task row per case, plus one Phase row per doc (slug-keyed). On re-sync only structural fields (title, description, priority, phase link) are updated — line-manager triage (assignee, status, due_date) is preserved. Tasks whose source row vanishes get marked `dismissed` (audit trail), not deleted. Re-appearance flips them back to `todo`. Initial sync ingested 148 cases across 13 phases from the existing validation docs.
Scheduled to run every 15 minutes via routes/console.php so a new validation doc shows up on the board without anyone running anything manually.
Promote-to-phase workflow — manually-created tasks can carry a free-form `phase_label` string. The dashboard surfaces orphan labels (tasks with a label but no Phase row) and a one-click "↑ promote" action creates the Phase and links its tasks.
Two-way linkage between /notes and tasks. New `note_task` pivot. Notes UI gets: a multi-select task picker on the new-note modal, inline display of linked task chips on each note card, and an "+ link task" picker per note that opens a modal listing active tasks. Notes controller gains `task_ids[]` on store/update + a new `/api/notes/tasks-picker` endpoint feeding the picker dropdown.
v0.23.0
Panel-tabbed data-entry blades for RCG + ANT — replaces the flat list with grouped antigen panels / dilution series / scenario questions
New
New resources/views/livewire/admin/results-entry-rcg.blade.php — RCG entry blade groups the 27 child components under their 8 antigen-panel parents (D / CcEe / MN / Ss / Kk / Fya-Fyb / Jka-Jkb / Doa-Dob). Each panel block carries its Genotype + Predicted-phenotype dropdowns and (where present) the unscored Other-terminology wrapper. wire:model bindings unchanged (`results.{sample.id}.{component.id}`) — the blade is a pure visual reorganisation, no back-end changes required.
New resources/views/livewire/admin/results-entry-ant.blade.php — ANT entry blade renders the TITRE block at the top (12 dilution-row dropdowns plus the Titre reported / score / last-positive triple), the Q5 "Triggered actions" yes/no checklist beneath, and the standalone Q1-Q7 scenario questions in their own block. Q7 is JS-gated to only display when Q1=Homozygous (Alpine x-show entangled to Q1's wire:model; server-side storage is unconditional so admin edits don't accidentally drop existing Q7 values).
ResultsEntry::render() now dispatches `RCG` to results-entry-rcg.blade.php and `ANT` to results-entry-ant.blade.php; everything else still routes via the existing chain.
Both blades load programme components directly (with eager-loaded responseType.options) because component-registration-snapshots only carry leaf children, not panel parents. Children rendered are still scoped to $this->tests so a partial snapshot narrows correctly.
v0.22.0
DAT distribution auto-enrolment splits by UK / non-UK cohort from the distribution_code suffix
New
New App\\Services\\DatCohortFilter — when a DAT distribution_code ends in U the auto-enrolled participant list is intersected with the UK cohort; ending in N it's intersected with non-UK (participants with no resolvable delivery-address country are excluded from the non-UK cohort to avoid shipping foreign material to a UK lab whose address row is missing). Country source is `addresses.country` on the delivery row, lower-cased and trimmed; recognises UK / GB / GBR / U.K. / United Kingdom / England / Scotland / Wales / Northern Ireland.
Hooked into App\\Jobs\\CreateDistribution::createNonInstrumentComponentSnapshotBatches between the lab_programme_memberships pluck and the empty-list bail-out — non-DAT distributions and DAT distributions without a recognised suffix are pass-through. Admins can still manually add or remove participants via the existing DistributionParticipants UI after auto-assignment.
Background: DAT only ships enough sample material for one geographic cohort per panel run, so legacy issues two parallel trials per quarter (e.g. 26DAT1U + 26DAT1N). This used to be per-organiser manual selection at distribution scheduling time.
v0.21.0
ANT (Antenatal Titration) ported — first programme using snapshot.metadata JSON for distribution-level state
New
New ANT programme — legacy programme 31. In-house qualitative scheme with a dilution-series titre block + scenario questions Q1–Q7. Most ANT trials run 1 sample, some run 2 (each with a different antibody specificity). Per-result is NOT scored — the report shows the participant's titre alongside median + range across all returning labs.
New `metadata` JSON column on `distribution_snapshots` (migration). Generic per-snapshot JSON slot for distribution-level state that doesn't fit the per-sample result_set / results model. ANT uses it to store the IAT technique selection (technique_id + label + free-text "other"). DistributionSnapshot model gets the column in fillable + an `array` cast.
AntProgrammeSeeder configures programme + 27 components organised as: TITRE parent panel (12 dilution rows + Titre reported + Titre score + Last positive) + Q5 parent panel (4 triggered-action sub-questions including a free-text "Other") + 6 standalone scenario questions (Q1 zygosity / Q2 refer to fetal medicine / Q3 referral titre / Q4 refer to reference lab / Q6 repeat-sample timing / Q7 homozygote follow-up). 104 response options across the dropdown components. Inline COMPONENT_SPEC keeps the seeder self-contained — same pattern as RCG.
New AntSubmissionValidator: TITRE_REPORTED must be a valid base-2 titre when present (1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024 or 2048). Renders inline via the existing $sanityErrors red-border + message UX in the generic results-entry blade.
New legacy:port-ant-trial + legacy:port-ant-trials commands. Honours `distributions.complete = 1` per the cross-port InputComplete rule. Ports `distribution_technique` rows into `distribution_snapshots.metadata`. Handles the legacy free-text storage hack: numeric titre values are stored in legacy as `results.testanswer_id = N` even though that "N" maps to an unrelated test's answer row — we extract the literal integer and store it on our side. Q5_OTHER specifically uses legacy `otherresult` since its testanswer_id is always 396 ("Other"). Reaction-grade option mapping queries legacy testanswers per-test-id and matches by label so we don't hardcode all 84 (12 dilutions × 7 grades) legacy answer ids.
New ant-v1 report template. Per-snapshot summary page: titre vs population median + range table across both samples (or one). Per-sample pages: dilution-series breakdown + scenario question responses + Q5 triggered-actions multi-select. IAT technology shown in the info strip from snapshot.metadata.
ANT data entry reuses the generic results-entry blade for v1 — works structurally (renders all 27 components as a flat list of dropdowns / text inputs) but visually flat. Conditional Q7 (only show when Q1=Homozygous) and grouped Q5 multi-select styling are follow-ups.
legacy:sync-all gets --skip-ant + --from-ant=24ANT1. ANT runs after RCG in the chain.
v0.20.0
RCG (Red Cell Genotyping) ported — first majority-consensus programme with a parent-child antigen panel hierarchy + D Genotype zygosity cross-rule
New
New RCG programme — legacy programme 29. In-house qualitative scheme covering 8 antigen panels (D / CcEe / MN / Ss / Kk / Fya-Fyb / Jka-Jkb / Doa-Dob); 27 child tests organised under panel parent components for the first use of `parent_component_id` in the RCG / report-rendering flow. 2 samples per distribution.
RcgProgrammeSeeder is self-contained — inline COMPONENT_SPEC defines all 35 components (8 panels + 27 children) and 111 response options with their legacy_id keys, so the trial port can map (legacy_test_id, legacy_answer_id) → (our component_id, option_id) without a separate maintained table.
Scoring: majority_consensus on Genotype + Predicted-phenotype components (19 scored, computed live from submissions). The `_OTHER` wrappers under each panel are unscored alternative-terminology free-text fallbacks.
New App\\Services\\Scoring\\RcgScoring helper for the D Genotype (D_GEN) zygosity cross-rule — generic-positive answer (`rhd_01_zygosity_undetermined`) is correct iff it matches the popular zygosity-specific consensus computed from rhd_01_01 vs rhd_01_01n_01 submission counts. Plus the converse "predetermined-specific, submitted-generic = OK" rule. Mirrors legacy PredeterminedResultScore::handle programme_id==29 verbatim. Wired into both DistributionScoringService (on-demand UI rescore) and ConsensusStatisticsStrategy (stats:recompute path that writes results.score) so per-result scores agree.
New legacy:port-rcg-trial + legacy:port-rcg-trials commands. Honours `distributions.complete = 1` as the gate for submission_status=submitted (per the cross-port InputComplete rule). Sources the legacy → component / option mapping from RcgProgrammeSeeder::spec(). RCG uses majority_consensus so we deliberately don't port `sample_answers` into `distribution_component_correct_answers` — the consensus is computed live, not stored.
New rcg-v1 report template. Per-snapshot intro page + per-sample antigen-panel grids comparing your answer to the popular consensus, with out-of-consensus rows highlighted in a soft amber band (#fef3c7 / #92400e — pairs with our existing borderline-performance signature, easier on the eye than the legacy salmon). Each sample page shows an "X results outside consensus" banner derived from consensus_result_scores.is_correct.
RCG data entry reuses the generic results-entry blade for v1 — works structurally (renders all 35 components as a flat list of dropdowns) but visually flat. Panel-tabbed entry UI matching the legacy _rcgsampletab structure is a follow-up.
legacy:sync-all gets --skip-rcg + --from-rcg=24RCG1. RCG runs after DAT in the chain.
v0.19.0
DAT (Direct Antiglobulin Test) ported — first qualitative programme with a custom-weighted predetermined-consensus matrix
New
New DAT programme — legacy programme 27. In-house qualitative scheme run on manual lab interpretation, scored against a predetermined consensus answer with the custom DAT matrix (false-negative=60, false-positive=30, IgG+C3d specificity miss=30, generic-Positive vs specific=0, Uninterpretable consults the reagent-control answer for 0/30). Mirrors PredeterminedResultScore::handle() programme_id==27 in legacy verbatim.
New App\\Services\\Scoring\\DatScoring helper class with the full matrix. Both consensus-scoring paths (DistributionScoringService for the on-demand UI rescore + ConsensusStatisticsStrategy for the stats:recompute pass that writes results.score) call into it so per-result scores agree on every code path. Verified: trial 26DAT1N rescores reproduce the legacy distribution exactly (250 zeros + 4 thirties + 12 sixties).
New legacy:port-dat-trial + legacy:port-dat-trials commands. Reads the lowercase in-house schema; honours `distributions.complete = 1` as the gate for submission_status=submitted (per the cross-port InputComplete rule). Ports legacy `sample_answers` (plural — the per-sample predetermined consensus) into our `distribution_component_correct_answers` so the scoring service can find the expected answer.
New DatSubmissionValidator wired into ResultsEntry::submitResults. Two cross-test rules enforced server-side: (1) Polyspecific AHG required when both Anti-IgG AND Anti-C3d are empty for a sample; (2) Reagent control always required regardless of sample quality. Errors render via the existing $sanityErrors red-border + inline message UX in the generic results-entry blade.
New dat-v1 report template. Per-snapshot summary page (DAT exercise score + cumulative-3 score + band: Satisfactory / Borderline / Unsatisfactory). Per-sample pages: reaction grades table, interpretation-vs-consensus table, population breakdown for the Interpretation component (count + % per option, consensus row highlighted).
DAT data entry reuses the generic results-entry blade — it already loops $this->tests and renders <select> for components with response options. Generic blade now also renders sanityErrors inline with red border (previously only the BCM blade did this).
legacy:sync-all gets --skip-dat + --from-dat=24DAT1U. DAT runs after POCT in the chain. UK / non-UK trials (codes ending U / N) port as separate distributions on the same DAT programme; auto-assign-by-country scheduling is parked for after all qualitative ports are in.
v0.18.0
POCT (Point-of-care testing — Haematology) ported — first quantitative programme using linear (no log transform) stats
New
New POCT programme — legacy programme 36, FBC-shaped (instrument-based, per-sample numeric values, full FBC scoring service) but with calculation_strategy=linear_trimmed_mean instead of log_transformed_trimmed_mean. POCT analyser distributions on POCT analysers are not reliably log-normal so the dl/ADLC stats paradigm applies, while everything else (data flow, reports, scoring infrastructure) follows FBC.
New legacy:port-poct-instruments command — manufacturers (123) reused from FBC by name, machines (405) reused via (manufacturer, name), 23 matrices created as instrument_groups, 832 per-machine compatibilities written from the bit columns on tblPOCTMachine (WBC / RBC / Hb / Hct / MCV / MCH / MCHC / PLT / NEU / LYM / MON / EOS / BAS).
New legacy:port-poct-memberships command — tblProgrammeMembership ProgrammeID=36 + tblPOCTMembershipDetail → 151 new lab_programme_memberships, 183 instrument enrollments, 2327 lab_component_registrations (one per analyser × test the analyser actually measures).
New legacy:port-poct-trial + legacy:port-poct-trials commands — read tblPOCTResult / tblPOCTSResult, port AbsoluteValue as the result value (PercentValue is unused). Honours InputComplete=1 as the gate for submission_status=submitted; everything else stays not_started (non-return). Per-machine component compatibility means DCRS rows are inserted only for the components the analyser actually measures.
POCT v1 report template registered — uses the FBC v5 blade and scoring service. The linear-stats difference is invisible to the report layout.
PoctProgrammeSeeder configures samples_per_distribution=2 + linear_trimmed_mean strategy + 13 components (mandatory + monitored + quantitative). Cleans up the orphan _PCT differential components (NEU_PCT / LYM_PCT / MON_PCT / EOS_PCT / BAS_PCT) seeded by an earlier iteration of AllProgrammeComponentsSeeder; those had no `results` rows and the canonical NEU / LYM / MON / EOS / BAS variants are what legacy data actually uses.
AllProgrammeComponentsSeeder updated — POCT seedPOCT() now creates the canonical 13 components without the _PCT suffix on differentials; matches what legacy stores in AbsoluteValue.
results-entry-poct.blade.php data-entry blade rewires NEU_PCT / LYM_PCT / MON_PCT / EOS_PCT / BAS_PCT to NEU / LYM / MON / EOS / BAS so wire:model keys match the ported components.
legacy:sync-all wires POCT into the chain — new --skip-poct flag and --from-poct option. POCT runs after BCM since POCT manufacturers / instruments come from FBC, which has to be ported first.
v0.17.5
Sanity-check inline error display rolled out to all quantitative data-entry blades (FBC / Hb / ADLC / Retic / RetiCHb / POCT)
New
FBC, ADLC, Reticulocyte, RetiCHb and POCT data-entry blades now render the same inline sanity-error UX as BCM: red border + per-cell message when a value contains letters / symbols / a negative sign. Inputs swap from type=number to type=text + inputmode=decimal so the browser stops silently stripping non-numeric characters before the server sees them. Qualitative programmes (POCT-D / MN / ERP) keep their dropdown UX and skip the rule entirely.
ResultSanityValidator now preserves the original component key instead of casting to int, so the blades that key on string codes (RetiCHb uses ReHb; POCT uses WBC / RBC / Hb / Hct / PLT / MCV / MCH / MCHC / NEU_PCT / LYM_PCT / MON_PCT / EOS_PCT / BAS_PCT) light up the same way as integer-keyed components.
v0.17.4
Result sanity validator (no letters / no negatives / no symbols) on every save + submit; Sample Quality block on the BCM report
New
New ResultSanityValidator service runs on every saveDraft and submitResults call from the Livewire results-entry component. Rejects values that cannot possibly be a measurement (letters, special symbols, negative numbers); deliberately lets outliers through (PLT 35 instead of 3500) so they show up as deviation-index spikes on the report rather than being silently rewritten or rejected. No range / type bounds — the report is the place to flag stupid numerical values, not the form.
BCM data-entry blade swaps the Hb/Hct/PLT inputs from type=number to type=text + inputmode=decimal so the browser doesn't silently strip non-numeric characters before our server-side check sees them. Bad cells get a red border + inline error message via the new $sanityErrors keyed by "{sampleId}.{componentId}".
BCM report now renders a per-sample Sample Quality panel above the Reference Method panel: "You reported: Satisfactory/Unsatisfactory" on the left, "All participants (N) — Sat X% / Unsat Y%" on the right with a horizontal bar visualisation. Mirrors the legacy _bcmv03 sample-quality box. Group totals come from the latest result_set per snapshot+sample so re-submitted drafts don't double-count.
v0.17.3
BCM report — add legacy-parity per-sample results table (Parameter / Result / Trimmed Mean / DI / N / N trimmed / CV% / Uncertainty)
New
Each BCM sample page now ends with a tabular summary mirroring the legacy _bcmv03 layout: one row per applicable component with Result, Trimmed Mean, DI, N total, N trimmed, CV%, and Uncertainty. Sits below the existing chart-based component cards rather than replacing them, so the visual analysis stays alongside the numerical record.
Uncertainty derived as SD/√N per legacy convention. FBCScoringService returns sd as a fraction of the mean (≈ CV/100), so the absolute SD is reconstructed from CV × mean before the division.
DI cell colour-codes amber (≥ 2) and red (≥ 3) at the table level. Units + decimal places per parameter (Hb 0dp g/L, Hct 3dp L/L, PLT 0dp ×10⁹/L) hardcoded in the template since programme_components doesn't carry a units column.
v0.17.2
Fix BCM port — write instrument_enrollment_id + per-enrolment identifier on snapshots so DI / cumulative scoring produce records
Fixed
PortBcmTrialCommand now lazy-creates instrument_enrollments per (participant, instrument) tuple and writes instrument_enrollment_id on every snapshot. Without this, DeviationIndexService's base query INNER JOIN on instrument_enrollments dropped every BCM result row — rescore-programme reported success but produced zero result_scores and zero cumulative_scores.
PortBcmTrialCommand also writes a stable per-enrolment identifier (`bcm-{participant_id}-{instrument_id}`) on both distribution_snapshots and non_returns. The cumulative-score service's NR-skip logic keys its lookup by snapshot.identifier — empty strings on every snapshot collided in the lookup and marked every score as NR, so 102 cumulative scores became 0.
New blood-component-monitoring-v1.blade.php — the active BCM report template. Cloned from FBC v5 with three BCM-specific changes: (1) two-column cumulative section showing BCM scores on the left and the participant's FBC scores for the equivalent components on the right (Haemoglobin / Haematocrit / Platelets), pulled from cumulative_scores filtered to the same instrument_enrollment so the comparison is like-for-like; (2) per-sample Component Analysis grid filtered through Sample::applicableComponentIds() so samples 1+2 show Hb + Haematocrit only and samples 3+4 show Platelets only, mirroring the legacy _bcmv03 sample-test gating; (3) Reference Method Results panel above each sample's component grid, reading reference_value + reference_method off the sample_components pivot.
ReportTemplateSeeder now points BCM v1 at reports.templates.blood-component-monitoring-v1 (was previously aliased to full-blood-count-v5 as a placeholder).
v0.17.0
Blood Component Monitoring (BCM) ported — first programme with per-sample-component restrictions, new sample_components pivot table
New
New sample_components pivot table — represents which programme components apply to which sample. Lazy-filled with the full programme component set the first time a sample is queried, so all existing programmes (FBC / Hb / ADLC / Retic / MN / POCT-D / ERP) keep their behaviour unchanged. BCM populates the table up front from the legacy sample_test pivot, so samples 1+2 only carry Hb + Haematocrit and samples 3+4 only carry Platelets — data entry skips non-applicable cells, reports show per-sample reference values.
Sample model gains components() many-to-many relation (with reference_value / reference_method on the pivot), plus an applicableComponents() helper that materialises the row set on first read.
New legacy:port-bcm-trial + legacy:port-bcm-trials commands. BCM lives in the same lowercase in-house legacy schema MN / POCT-D / ERP use (distributions / distributionsamples / results / sample_test / distribution_technique), but is quantitative — legacy result.score is preserved on our results.score for diff-against-recompute. distribution_technique.machine_id resolves to our instruments.legacy_id so each snapshot picks up the correct instrument.
BcmProgrammeSeeder updates the existing CM programme (id 5) to samples_per_distribution=4 + calculation_strategy=log_transformed_trimmed_mean. Components Hb / PCV/Hct / PLT already present from AllProgrammeComponentsSeeder.
New livewire/admin/results-entry-bcm.blade.php — fixed 2-column grid (samples 1+2 on top row, 3+4 below). Per-sample component filter via Sample::applicableComponents(). Reference value (and method) shown beneath each input from the sample_components pivot.
BCM v1 report template registered against the FBC v5 blade for now; a BCM-specific blade with the FBC-comparison side panel (legacy report shows the participant's FBC scores for the same components on the same instrument alongside their BCM scores) is the next step.
legacy:sync-all wires BCM into the chain — new --skip-bcm flag. BCM steps run after the FBC steps (memberships then trials), since BCM membership detail uses tblFBCMembershipDetail by joining via the lowercase distributions table.
v0.16.0
Reticulocyte Count programme ported (legacy programme 5 / WBRC) — first programme with two-level Method/MethodGroup hierarchy
New
New Reticulocyte Count programme — single Ret component, but with a two-level instrument-grouping hierarchy that's a first for the system. Each WBRC analyser belongs to a Method (e.g. "Sysmex XN Series", "Beckman Coulter DxH series") and each Method belongs to a Method Group (e.g. "Automated Methods Group", "Manual Methods Group"). Stats are computed at both levels and shown on the report; participant DI scoring uses the Method Group level per legacy convention.
New legacy:port-retic-instruments command — clones tblWBRCManufacturer + tblWBRCMachine + tblWBRCMethodGroup + tblWBRCMethod into our manufacturers / instruments / instrument_groups (with parent_group_id). Reuses existing FBC instruments by (manufacturer, name) instead of duplicating analyser rows. Writes Ret-component compatibilities for every WBRC machine.
New legacy:port-retic-memberships and legacy:port-retic-trial / legacy:port-retic-trials commands — same shape as the FBC/Hb equivalents, filtered to ProgrammeID 5 and reading the WBRC tables. Snapshot.instrument_group_id resolves to the leaf Method group; the parent walk happens at stats / scoring time.
ReticProgrammeSeeder cleans up the three pre-port placeholder components (RETIC_PCT / RETIC_ABS / IRF) plus an earlier "Retic" attempt and creates the canonical single Ret component. Wired into ProductionStructureSeeder.
Retic v1 report template registered — uses the FBC v5 blade. The hierarchy is invisible to the report layout because the FBC blade pulls Population stats keyed by the snapshot's instrument_group_id, and DI is calculated against the parent group inside DeviationIndexService.
legacy:sync-all wires Retic into the chain — new --skip-retic flag and --from-retic option. Three Retic steps run before Hb: instruments → memberships → trials.
Improved
instrument_groups gains a parent_group_id self-FK. Single-level programmes (FBC / Hb / ADLC) leave it NULL on every row, behaviour unchanged.
instrument_groups' legacy_id uniqueness scoped per programme. The previous global UNIQUE blocked WBRC Method IDs (1..25) from coexisting with FBC group IDs (also from 1). New filtered unique on (programme_id, legacy_id) WHERE legacy_id IS NOT NULL.
StatisticsCalculationService now computes stats for both leaf and parent instrument_groups when a hierarchy exists. The set of groups iterated is the union of (a) groups present on snapshots and (b) their parent_group_ids. Aggregation for parent rows pulls results across all child leaves whose snapshots roll up to it.
DeviationIndexService now scores against the parent group when present. A leaf-group → score-group map is built once at the start of calculateForDistribution; for hierarchical programmes (Retic / WBRC, REHb) the lookup redirects to the parent. For single-level programmes the map returns the leaf id unchanged.
Fixed
InstrumentGroup model gains parent_group_id in fillable plus parentGroup() / childGroups() relations. Without the fillable entry Eloquent silently dropped the value on updateOrCreate, which initially produced a flat hierarchy on Retic.
Per-group iteration in StatisticsCalculationService casts ids to int when filtering by parent_group_id. SQL Server returns unsignedBigInteger columns as PHP strings; strict === comparison silently matched nothing.
New Hb (Haemoglobin) programme — single-component quantitative scheme that runs on the same analyser fleet as FBC. Ported the legacy "Hb only" programme (legacy ProgrammeID=18). Same FB shape, same log-transformed trimmed-mean stats, just one component per snapshot. HbProgrammeSeeder creates the programme + Hb component; wired into ProductionStructureSeeder.
New legacy:port-hb-memberships command — clone of legacy:port-fbc-memberships, filters tblProgrammeMembership ProgrammeID=18 and reads from the SHARED tblFBCMembershipDetail (the _HB-suffixed sibling is empty in legacy; PMIDs in the shared table belong to multiple programmes, so we filter by PMID join). Auto-creates lab_programme_memberships rows for any Hb-enrolled legacy lab missing on our side. Writes Hb-component instrument_component_compatibilities on the fly because PortInstrumentsCommand only handles FBC components.
New legacy:port-hb-trial + legacy:port-hb-trials commands — clones of the FBC trial ports, reads from the _HB-suffixed result tables (tblFBCResult_HB, tblFBCSResult_HB, tblFBCParticipantPenalty_HB). Single-component map (TestID 13 → Hb). Default --from=2401HB.
legacy:sync-all wires Hb into the chain — new --skip-hb flag and --from-hb option. Hb runs after ERP and uses the dedupe pass at the end like every other programme.
Hb report template registered — uses the FBC v5 blade directly (template_path = reports.templates.full-blood-count-v5) since the FBC blade iterates components and renders cleanly with one. ReportViewController branches on short_code in [FBC, ADLC, HB] to use FBCScoringService for the report data.
Hb data entry — ResultsEntry component dispatches HB programme to the existing results-entry-fbc blade; tests come from componentRegistrationSnapshots so a single-Hb programme renders one analyte column.
Improved
StatisticsCalculationService now finds per-group statistics via the snapshots' instrument_group_id rather than via a programme_id join. Programmes that share the InstrumentGroup catalog (Hb reuses FBC's HemoCue / DiaSpect / Miscellaneous etc.) now get correct per-group stats coverage; FBC behaviour is unchanged because its snapshots reference the same FBC-programme groups.
Performance Monitor: cumulative panel rebuilt as a Scored Participants table — shows participants flagged for analytical performance (cumulative ≥ threshold) and/or persistent non-return (NR ≥ 100, P5/P7 letter trigger). New NR penalty column shows the rolling 3-distribution score (50 per NR, capped at 150). New filter dropdown: All / Analytical only / Non-return only. Pagination at 50 rows per page so distributions with 700+ flagged participants stop generating multi-MB HTML responses on every click.
ADLC + FBC reports show the matrix / group name in parentheses next to the instrument (e.g. "Sysmex XN-1000 (Matrix JA)"). Pulled from the snapshot's frozen instrument_group_name so historical reports keep the matrix name they had at publish time.
New scoring:resolve-duplicate-latest and scoring:rescore-programme artisan commands for cleaning up duplicate is_latest result_scores rows and rescoring whole programmes (used to recap historical ADLC scores from before the 3.5 |DI| cap landed).
Improved
Performance Monitor click responsiveness: per-render SQL dropped from 1.07s to 350ms, rendered HTML from 27 MB to 2.1 MB, modal open/close from 1.1-3s to ~10ms (effectively instant) on ADLC 2602DL with 719 flagged participants.
Heavy computed properties (cumulativeScores, nonReturns, letters, capas, highDiScores, etc.) memoised per request via Livewire 3 #[Computed] so the blade no longer pays them twice on every render.
Badge counts (Cumulative / High DI / Non-Returns) now backed by cheap COUNT(*) queries instead of pulling thousands of rows just to count them.
All six modals — CAPA review, raise CAPA, send letters, NR waiver, director comment, flag override — extracted into standalone Livewire components under App\Http\Livewire\Admin\Performance. Modal open/close no longer re-renders the parent; close is handled client-side via Alpine so it's instant. Triggers in the parent table use $dispatch('open-…') events.
Scored Participants table's expanded detail row wrapped in <template x-if> so collapsed rows don't ship their detail HTML in the response.
CumulativeScores query: dropped the joinSub/MAX(version) deduplication that was pure overhead (CumulativeScoreCalculationService DELETEs before reinserting; only one version per (snapshot, component) ever exists) and dropped the SQL ORDER BY since we re-sort in PHP after pivoting.
Fixed
Dockerfile rewritten on top of the official php:8.4-cli-bookworm + php:8.4-apache-bookworm images. Eliminates the Ondrej PPA / Launchpad dependency that knocked out two consecutive Bitbucket pipeline runs. MS ODBC repo URL updated to the Debian/bookworm equivalent. Extension installation switched from apt-install php8.4-foo to docker-php-ext-install / pecl install. readline now provided by the base image.
ReportTemplateSeeder no longer attaches a "Clinical Chemistry Report" template to ADLC by accident — the previous fallback chain ended in $programmes->skip(1)->first() which grabbed whatever the second programme happened to be. Tightened to a strict name match plus an idempotent cleanup that drops orphan chemistry-v1 templates on the next deploy.
DistributionPerformanceMonitor::previousLetters always returns array. The component crashed with in_array(string, Collection) when viewing the first distribution of any new programme (no previous-distribution branch returned collect() while the with-previous branch returned ->toArray()).
Histogram panel uses group stats and never falls back to all_instruments. When a participant's instrument group lacked persisted stat rows (small groups under the per-component min-N threshold), the bars were group-scoped but the panel reported all-instruments n/mean/sd. Now $dbStats stays null in that case and the panel recomputes from the group's actual results — matches the bars.
DeviationIndexService always flips is_latest=false on prior result_score versions before inserting a new is_latest row. The "Rescore Amended" path used to skip this on the assumption that amended ids were always brand-new, which broke once a result_id could legitimately be re-scored (e.g. after the 3.5 |DI| cap landed). Without the flip, both the pre-cap (v=1) and capped (v=2) rows ended up flagged is_latest=true and the cumulative-score join happily summed the uncapped one — producing inflated cumulatives in the thousands on ADLC NRBC.
PerformanceAssessmentService::getDiScores selects programme_component_id from dcrs. The query was missing the column entirely; the assessSnapshot filter at line 196 read $di->component_id and crashed with "Undefined property stdClass::$component_id" the moment ADLC (the first programme with a non-empty monitored components list) was scored. Pulls programme_component_id via the dcrs join because ADLC/MN/ERP results have r.programme_component_id NULL.
Flag-override modal closes on outside click like the rest. Its different DOM structure (a min-h-screen flex wrapper around the content) was swallowing clicks before they reached the backdrop.
v0.13.1
AdminUserSeeder — firstOrCreate so deploys stop resetting the admin password
Fixed
AdminUserSeeder switched from updateOrCreate to firstOrCreate. ProductionStructureSeeder runs on every container deploy, and the previous updateOrCreate overwrote the admin password back to "password" each time — so any password the admin had rotated to was reset on the next push. Now password (and name) are only set on initial creation; user_type=admin is still re-asserted on re-runs in case it drifts.
v0.13.0
ERP data entry — first real participant data entry component in the new system, validation mirroring legacy DATResultsRequest
New
ErpDataEntry Livewire component rewired from mock-stub to real: mount(distribution, snapshot) loads samples + components + techniques + technologies + options, hydrates form state from existing draft results + result_extras. saveDraft() and submit() write back to results + result_extras + update snapshot status. New /erp/distributions/{distribution}/snapshot/{snapshot} route + updated pages/erp.blade.php pass the bound models through.
Validation rules mirror legacy DATResultsRequest for programme 32 (ERP carve-out): no blanket "answer required" — answer becomes required only when technique / technology / reagent is filled in for that cell, "Other" Rh genotype requires the Other-text field, UnsatisfactoryReason required when sample marked unsat, reagent text max 255, dates required + chronologically valid (assayed ≥ received, both ≤ now).
erp-data-entry.blade.php rewritten to loop over real $samples / $components / $techniques / $technologies / $options instead of hardcoded mock data. Dropped the mock-only "Interpretation" column (not in the data model). Added @error blocks per cell, read-only mode when snapshot is submitted, save/submit buttons with Livewire loading states.
New columns distribution_snapshots.received_at + assayed_at (legacy parity for in-house programmes that capture dates).
New ResultExtras model + extras() relation on Result for the result_extras pivot.
ProductionStructureSeeder calls ErpProgrammeSeeder so fresh deploys auto-create the ERP programme structure.
legacy:sync-all wires ERP into the chain: --skip-erp flag, ERP memberships + trials run between MN and the dedupe pass.
v0.12.10
ERP report histograms — one tick of headroom above the tallest bar
Improved
erp-v1 histogram y-axis now computes a per-chart max + step size that's always one full tick above the data's max value, so the tallest bar never touches the top gridline. Step size snaps to a "nice" value (1, 2, 5, 10, 20, 25, 50, 100…) ≥ ceil(dataMax / 8). E.g. for D antigen with 206 Pos responses on 26ERP1, step is 25 and max is 225 → ticks at 0, 25, …, 200, 225.
v0.12.9
ERP report histograms — taller (170px → 220px)
Improved
erp-v1 histogram canvases bumped 170px → 220px so each chart has more vertical room for the bars to read meaningfully. Cell + grid gaps tightened slightly to compensate so the 5×3 grid still fits A4 portrait. Right-column overflow safeguards from the previous patch (minmax/min-width/canvas width 100%) stay in place.
v0.12.8
ERP report histograms — abbreviated labels + cell-width clamp so right column doesn't overflow the page
Improved
ErpReportDataService::histograms() now abbreviates antigen-option labels (Positive→Pos, Negative→Neg, Uninterpretable→UI, "Not tested / Unable to test"→NT) so the x-axis ticks fit horizontally without wrapping wider than the chart cell.
erp-v1 .histogram-grid uses minmax(0, 1fr) columns + min-width: 0 + overflow: hidden on the cell, plus width: 100% !important on the inner canvas. Chart.js was setting an inline width attribute on the canvas that pushed past the column edge — these rules clamp it so the rightmost column stays inside the page margin.
v0.12.7
ERP report layout — pivoted antigen table fits A4 + histograms get their own page per sample
Improved
erp-v1 antigen-results table pivoted from 16-col-wide-on-one-row to 4-col-by-16-row (Antigen | Your reaction grade | Consensus result | Score). Fits A4 portrait comfortably; outwith-consensus rows shaded red across the full row instead of just one cell.
Per-antigen histograms moved to their own page per sample (page-break-before). Each chart canvas now 160px tall (was 100px — was unreadable) inside a 3 cols × 5 rows grid that fits A4 portrait without overflowing the right margin.
v0.12.6
ERP report renders REAL data — ErpReportDataService wired into ReportViewController
New
New ErpReportDataService — builds the $reportData blob the erp-v1 template consumes: per-sample lab answers + consensus answers (16 components × 2 samples), per-result lab scores with NS marker for no-consensus cells, sample penalties, exercise total, cumulative score over last 3 returns, performance category, NR penalty + cumulative NR, separate Rh genotype consensus + answer, per-antigen histograms (Chart.js shape), cumulative-trend chart data, return rate stats. Predetermined-consensus rules applied: ui/nt excluded from consensus base, strict >50% required.
ReportViewController branches on programme.short_code === 'ERP' and calls ErpReportDataService::generateReportData(), same pattern as MN. The placeholder messages in erp-v1 only show when this service hasn't populated $reportData (now they should never show for any ported ERP distribution).
Validated locally on 26ERP1 / participant 20008: 2 samples, 16 components, exercise_total=0 (matched consensus on every antigen), cumulative_score=0, category=Satisfactory; consensus + lab answers both populated cleanly.
v0.12.5
ERP — DistributionScoringService recognises predetermined_consensus so consensus_result_scores rows get written + score-version UI appears
Fixed
DistributionScoringService now treats predetermined_consensus as equivalent to majority_consensus for score generation. Without this, the strategy-level scoring (results.score values) ran fine but the parallel DistributionScoringService path — which writes consensus_result_scores rows the StatisticsTable UI gates the "Generate Report" button on — silently skipped ERP components, leaving stat-set rows with no nested score-version rows and no create-report action. Now ERP score-version creation flow is unblocked end to end.
ERP-specific rules (strict >50% consensus, exclude ui/nt from the consensus base) live in ConsensusStatisticsStrategy::getCorrectAnswer for the artisan stats:recompute path. A future cleanup could mirror those rules into DistributionScoringService::scoreMajorityConsensusComponent for full parity, but the structural majority-vote logic is identical and the differences are marginal at the per-result level.
v0.12.4
ERP scoring — predetermined_consensus support in ConsensusStatisticsStrategy unlocks stats:recompute
New
ConsensusStatisticsStrategy now recognises predetermined_consensus alongside expert_consensus + majority_consensus. Same per-(sample, component) majority calculation, with two ERP-specific tweaks: (1) Pos/Neg-only denominator — Uninterpretable + Not tested are excluded from the consensus base, matching the legacy ERP rule; (2) strict >50% rule — exactly 50% means "no consensus" and no scoring for that pair, matching legacy ERP / DistributionsController.php logic.
ERP option_values "ui" (Uninterpretable) and "nt" (Not tested) recognised as no-penalty cases — both score 0 (lab attempted but couldn't read it / didn't test). Distinct from POCT-D's unable_to_interpret which scored 50 for honest non-call partial credit.
Validated locally on 26ERP1: 6597 results, 6193 scored (404 Rh-genotype rows kept null as designed). 6165 scored 0 (within consensus), 28 scored 40 (out-of-consensus penalty per ERP's scoring_multiplier=40).
Side-effect: distribution dashboard's score-version creation UI was gated on statisticSets()->count() > 0 — now appears for ERP after stats:recompute runs.
v0.12.3
ERP report template — full content layout from legacy _erpv4 (placeholders where data service is still pending)
New
erp-v1.blade.php expanded to bring across every section legacy _erpv4 has: introduction, performance categories, performance summary (Interpretation + Non-Return rows), cumulative-scores chart panel (last 3 returns) with category legend, return rate paragraph, data-analysis + discussion + results-of-testing text sections, per-sample antigen-vs-consensus + score table with consensus-error highlighting, separate Rh genotype panel (informational), per-antigen labs-vs-reaction-grade histograms, and expert-comments overview/general-notes blocks. Page-break-before on each per-sample section matches the legacy print layout.
Chart.js wired for the cumulative line chart and per-antigen bar charts; only renders when ErpReportDataService has supplied the data so the placeholder state stays clean for distributions that haven't been recomputed yet.
v0.12.2
ERP foundation report template — registered in seeder so DistributionDashboard auto-attach works
New
New erp-v1 report template registered via ReportTemplateSeeder::seedErpTemplate(). Without this the DistributionDashboard's auto-create-foundation-report logic finds no template for ERP and silently skips, leaving freshly-loaded ERP distributions without a foundation report.
erp-v1.blade.php — visual chrome matching FBC v5 / MN v1 (header, info strip, sectioned tables) with placeholder messages where the rich data needs ErpReportDataService (Phase 4 polish). Renders cleanly today: performance categories table, performance summary placeholder, per-sample antigen-vs-consensus table when data is present, separate Rh genotype panel, expert-comments sections.
v0.12.1
ERP Phase 2 — port commands (legacy:port-erp-trial + bulk wrapper)
New
PortErpTrialCommand — clones the POCT-D trial port shape with three additions specific to ERP: (1) per-result extras read from legacy extra_distribution_information (technique + technology + reagent + reaction strength) and written to result_extras; (2) Rh-genotype results (test_id 49) are kept in the standard results table with score=null, so the unscored info round-trips and can become scored later without a re-port; (3) HTML-stripped option label match handles Rh haplotypes (R<sub>1</sub>r → r1r etc.) when mapping legacy testanswer_ids to our component_response_options.
PortErpTrialsCommand — bulk wrapper, defaults to latest 8 trials.
New ERP programme structure: 15 scored antigen components (D, C, E, c, e, M, N, S, s, K, k, Fya, Fyb, Jka, Jkb) + 1 unscored Rh genotype, sourced from legacy ProgrammeID=32. Codes use _lo suffix for lowercase Rh antigen pairs (c_lo, e_lo, s_lo, k_lo) since SQL Server's default nvarchar collation is case-insensitive.
Two new tables: technologies (programme-scoped reference list parallel to techniques) and result_extras (per-result metadata: technique + technology + reagent + reaction strength). Mirrors legacy extra_distribution_information for ERP's per-test extras.
New scoring strategy enum value: predetermined_consensus (consensus = answer with >50% agreement among returning labs; lab matching scores 0, lab differing scores 40). Schema CHECK constraint widened to allow it plus the previously-undeclared linear/log_transformed_trimmed_mean strategies FBC + ADLC have been quietly using.
scoring_method column widened from 20 → 50 chars to fit longer strategy names.
Separate is_performance_monitored from is_mandatory on programme_components — completeness vs escalation are distinct concerns
New
New programme_components.is_performance_monitored boolean column (default true). Distinct from is_mandatory: mandatory governs completeness (lab MUST register/submit), monitored governs escalation policy (poor performance fires letters/CAPAs). They happen to align for ADLC (NEU+LYM only on both) but the concepts are separate.
AdlcProgrammeSeeder marks NEU + LYM as both mandatory + monitored; the other six analytes (WBC, MON, EOS, BAS, LUC, NRB) get neither flag — they're recorded for context but don't escalate.
AllProgrammeComponentsSeeder defaults all components to mandatory + monitored, which is what FBC wants (every analyte counts on both axes).
PerformanceAssessmentService switched from is_mandatory to is_performance_monitored for escalation filtering — more accurate semantic, allows future programmes to have optional analytes that still drive CAPAs (or vice versa).
v0.11.26
All FBC components now mandatory — MPV + RDW added to AllProgrammeComponentsSeeder, seeder wired into deploy chain
Fixed
AllProgrammeComponentsSeeder — FBC component list now includes MPV and RDW (previously they were created later by legacy:port-instruments without an is_mandatory flag and stayed non-mandatory by accident). All 10 FBC components are now mandatory and drive performance escalation.
ProductionStructureSeeder — added AllProgrammeComponentsSeeder to the auto-deploy chain so FBC component definitions are re-asserted on every deploy. Order matters: it runs BEFORE AdlcProgrammeSeeder so the latter can correctly override ADLC components to keep only NEU + LYM mandatory.
v0.11.25
PerformanceAssessmentService respects programme_components.is_mandatory — only mandatory analytes drive escalation
Fixed
PerformanceAssessmentService — assessment escalation (max cumulative, flagged component IDs, high-DI check) now filters to is_mandatory=true components when the programme defines any. ADLC: only NEU + LYM count toward letters/CAPAs; WBC/MON/EOS/BAS/LUC/NRB are reported but no longer escalate. Programmes without any mandatory flag (legacy behaviour) keep the all-components rule. Fixes "DL raises CAPAs for everything" complaint.
v0.11.24
Performance Monitor — Run Assessment / Apply Flags / Recompute Flags buttons get spinners + disabled state while processing
Improved
distribution-performance-monitor.blade.php — three slow buttons (Run Assessment, Apply Flags, Recompute Flags) now show a spinner icon and "Running…/Applying…/Recomputing…" label while the request is in flight, with disabled state preventing double-click. Without this the buttons just sat there during multi-second computations and looked unresponsive until the toast notification appeared.
v0.11.23
legacy:sync-all auto-recomputes stats + scores + flags for any newly-ported distributions
New
SyncAllCommand — final step now finds legacy-ported distributions with snapshot data but no statistic_set, runs stats:recompute --with-scores on them, then performance:recompute-flags. Without this, freshly-ported trials show empty Performance tab and "Run Assessment" produces no flags. On steady-state syncs there's usually only 1-2 fresh distros per programme so the extra time is bounded; on initial seeds it'll be all of them and that's the point.
v0.11.22
ProductionStructureSeeder runs automatically on every deploy — no more hand-running db:seed for new programmes
New
database/seeders/ProductionStructureSeeder.php — calls only the canonical-structure seeders (roles, admin user, organisations, programme components, programme rows for ADLC/MN/POCT-D, report templates, admin org access). Explicitly excludes ComplexSeeder/ComprehensiveTestDataSeeder/BulkResultsSeeder/etc — those are dev-only fixtures.
Dockerfile entrypoint runs `php artisan db:seed --class=ProductionStructureSeeder --force` right after `migrate --force`. Idempotent — every underlying seeder uses firstOrCreate / updateOrCreate, so re-running on a populated DB is a no-op for unchanged rows. Failures are visible (banner + tee to /var/log/seeders.log) but non-fatal so a half-broken seed doesn't bring the whole container down.
OrganisationSeeder switched from create() to updateOrCreate() so it can run on every deploy without exploding on the unique index.
v0.11.21
legacy:port-adlc-machines now ports ADLC matrices from tblADLCMatrix (no more seeder dependency)
New
PortAdlcMachinesCommand reads tblADLCMatrix from legacy and firstOrCreates instrument_groups for each matrix at the start of the apply phase. Removes the dependency on AdlcProgrammeSeeder pre-creating them; new matrices added in legacy after a deploy now flow through automatically on the next sync. Hardcoded matrixRefs() helper is gone — legacy is the single source of truth for matrix existence. Seeder block kept as local-dev convenience for fresh Sail DBs without VPN access. First step toward the broader "everything ported, nothing seeded" model for legacy-derived data — see chat for full audit list.
v0.11.20
.dockerignore — exempt docker/keys/*.gpg so the COPY in Dockerfile actually finds the file
Fixed
.dockerignore had a blanket docker/ exclusion (intended for Sail dev configs) which also blocked docker/keys/ondrej-php.gpg from reaching the build context. Added a !docker/keys/ exception. Without this the previous patch fails at COPY with "not found" — yes, two patches in a row chasing the same goal, but this one closes it.
v0.11.19
Dockerfile — commit ondrej/php signing key in repo + COPY into image, no external keyserver dependency
Fixed
docker/keys/ondrej-php.gpg — committed the 1154-byte ondrej/php public key (extracted from the working Sail container). Dockerfile now COPYs it into /etc/apt/keyrings/ instead of curl-fetching from external keyservers. Both keyserver.ubuntu.com AND keys.openpgp.org AND pgp.mit.edu had blocked the build at various times today; baking the key in removes the failure mode entirely. Refresh procedure documented inline if the key ever rotates.
v0.11.18
Dockerfile — fall back through multiple keyservers for the ondrej/php PPA so a flaky keyserver.ubuntu.com doesn't break the build
Fixed
Dockerfile — both ondrej/php key fetches now try keyserver.ubuntu.com → keys.openpgp.org → pgp.mit.edu in sequence with --connect-timeout 10 / --max-time 30. The Bitbucket pipeline build had been failing intermittently when keyserver.ubuntu.com hung for 4+ minutes, leaving the apt repo unsigned and the php8.4 install dead. Multi-keyserver fallback turns a transient outage into a slower-but-successful build.
v0.11.17
Trial ports use saveQuietly so DistributionObserver doesn't hang the wipe with a 9-min unreported-results aggregate
Fixed
PortFbcTrialCommand + PortAdlcTrialCommand + PortPoctdTrialCommand + PortMnTrialCommand — switch $distribution->save() to ->saveQuietly() so DistributionObserver::updated doesn't fire updateUnreportedResultsCount() during the port. The observer runs a heavy aggregate over results × snapshots × distribution_report_templates; when the trial port adopted a test distribution that already had ~140k results attached (e.g. 2603FB), SQL Server's parallel plan stalled on CXSYNC_PORT for 9+ minutes per save, blocking the actual wipe-and-reimport from ever starting. The observer is for the live UI; the port owns its own writes.
v0.11.16
Trial-port resolveEnrollments — synthetic serial fallback when legacy has two identifiers sharing one serial
Fixed
PortFbcTrialCommand + PortAdlcTrialCommand resolveEnrollments — when bulk-inserting fresh enrolments, fall back to a "trial-port-{identifier}" synthetic serial whenever the legacy SerialNumber is already owned by another (participant, instrument, identifier) row. Same pattern legacy:port-fbc-memberships already uses; closes the gap in the trial-port path that crashed bulk imports for trials with the rare-but-real "two identifiers same serial" pattern (e.g. 20028T + 20028U on serial EV2B195005 in 2512FB).
v0.11.15
Trial port wipes — also clear cumulative_scores + distribution_snapshot_techniques before deleting snapshots
Fixed
PortFbcTrialCommand + PortAdlcTrialCommand + PortPoctdTrialCommand + PortMnTrialCommand — wipe sequence missed two FK referrers (cumulative_scores.distribution_snapshot_id and distribution_snapshot_techniques.distribution_snapshot_id), so adopting a test distribution that already had cumulative scores or technique-pivot rows would fail with a FK reference constraint error on the snapshots delete. Now wipes both before the snapshots delete.
v0.11.14
legacy:sync-all writes per-step start/done breadcrumbs to laravel.log so we can see progression even on worker timeout
Fixed
SyncAllCommand — Log::info on each step start and done. The queue captures Artisan output only on normal completion; when the worker --timeout fires mid-sync, the captured output is empty and we have zero insight into where the chain stalled. The log breadcrumbs survive timeout-kill because they hit the file synchronously.
v0.11.13
deploy script — hardcode legacy SQL MI public endpoint host so Bitbucket var drift can't break sync
Fixed
deploy-with-public-endpoint.sh — LEGACY_DB_HOST hardcoded to qedhtdbserver.public.a864c2659245.database.windows.net (the SQL MI's public endpoint, paired with port 3342 from the previous patch). Bitbucket pipeline no longer exports LEGACY_DB_HOST. Username + password remain in Bitbucket repo variables (those are the actual secrets). Hostname is publicly resolvable and useless without credentials, so trading the small disclosure for less env-var coordination is fine for the validation env.
v0.11.12
deploy script — switch LEGACY_DB_PORT 1433 → 3342 for the SQL MI public endpoint
Fixed
deploy-with-public-endpoint.sh — LEGACY_DB_PORT hardcoded value bumped from 1433 (private endpoint) to 3342 (public endpoint). The container can't reach the legacy SQL MI's private endpoint without VNet peering, hence the long-running "Login timeout expired" hangs. Public endpoint via 3342 is reachable from anywhere (subject to MI firewall rules). Pair with updating LEGACY_DB_HOST in Bitbucket to insert ".public." between the server name and the DNS zone.
v0.11.11
supervisord queue worker — bump --timeout 1800→3600 + --max-time 3600→7200 so the legacy sync isn't killed at 30min
Fixed
supervisord.conf — queue:work CLI flags `--timeout` and `--max-time` were overriding the job class's $timeout property (CLI wins). Worker was killing every job at exactly 30 minutes regardless of what we set on RunLegacySync. The MaxAttemptsExceeded errors on Azure were the queue's retry_after firing on the orphan job after the worker killed it. Bumping --timeout to 3600 (matches RunLegacySync::$timeout) and --max-time to 7200 (so the worker process itself doesn't recycle mid-sync).
v0.11.10
Bump RunLegacySync timeout 30min→60min + queue retry_after 40min→70min so legacy sync has headroom over the WAN
Fixed
RunLegacySync::$timeout 1800s → 3600s and DB_QUEUE_RETRY_AFTER 2400s → 4200s. With the LoginTimeout bump (60s per cold legacy connection) and ~20 sync steps, the chain can take 30+ minutes — exceeded the prior 1800s timeout, the worker killed the job at 30 min, then retry_after kicked in at 40 min and the orphan re-pickup with tries=1 raised MaxAttemptsExceededException. 60 min ceiling for sync + 70 min visibility timeout matches the field-observed runtime with safety margin.
v0.11.9
Legacy DB connection — bump LoginTimeout 15s→60s + add per-query timeout; dedupe lazy-skips legacy lookups when there are no local dupes
Fixed
config/database.php — legacy connection gains explicit login_timeout=60 and SQLSRV_ATTR_QUERY_TIMEOUT=120. Default ODBC LoginTimeout is 15s, which on a cold Azure-container ↔ Watford SQL connection routinely fires before the handshake completes, breaking sync runs at random steps. 60s gives the cold-start enough headroom; both values overridable via LEGACY_DB_LOGIN_TIMEOUT / LEGACY_DB_QUERY_TIMEOUT env vars.
legacy:dedupe-enrollments now counts duplicate (participant, instrument, identifier) triples in OUR DB FIRST and short-circuits with success when zero — no legacy round-trip needed. Combined with the trial-port identifier-keying fix, the steady-state behaviour is "skip everything", so the dedupe step in legacy:sync-all stays as a defensive no-op without burning a slow WAN query every nightly run.
v0.11.8
FBC + ADLC trial ports now resolve enrolments by identifier instead of serial — eliminates the "messy SN spawns duplicate enrolment" class of bug at the root
Fixed
legacy:port-fbc-trial + legacy:port-adlc-trial — enrolment lookup keyed by (participant, instrument, IDENTIFIER) instead of (participant, instrument, serial). Identifier IS the physical-machine key in the new system; SerialNumber is metadata the membership port owns canonically. Without this, every typo / "Krusty" / "Moony" variant a lab typed in result rows spawned a new enrolment, leading to the 281 duplicate (participant, instrument, identifier) rows the dedupe command had to clean up. Now trial-port results attach to the canonical enrolment created by the membership port; the SN in the result row is ignored.
Removed the "two identifiers same serial collapse to one enrolment" comment in the FBC trial port snapshot dedup — that was a side-effect of serial-keying. With identifier-keying the dedup only fires on genuine repeat-result rows for the same identifier, which is what we actually want.
v0.11.7
Bump database queue retry_after from 90s → 2400s so the long-running RunLegacySync job doesn't hit MaxAttemptsExceededException
Fixed
config/queue.php — DB_QUEUE_RETRY_AFTER default raised from 90 to 2400 seconds. The legacy sync job declares $timeout = 1800 (30 minutes), but the queue's visibility timeout was 90s — so a worker handing the job back to the queue every 90 seconds, while the original was still running, eventually tripped MaxAttemptsExceededException because $tries=1. retry_after must always exceed the longest job's $timeout.
v0.11.6
legacy:dedupe-enrollments wired into legacy:sync-all so the admin "Run sync" button cleans + dedupes in one go
Fixed
legacy:sync-all now ends with a legacy:dedupe-enrollments pass — idempotent, no-op when there are no duplicates, so safe to run on every sync. Without this, clicking "Run sync now" on the admin UI would skip the dedupe entirely (it's a separate command), leaving any same-identifier dupes the trial ports created from typed-on-result-row serials in place.
v0.11.5
One-off legacy:dedupe-enrollments — collapses (participant, instrument, identifier) duplicates and canonicalises serials from the legacy MembershipDetail register
New
New legacy:dedupe-enrollments command (default dry-run, --apply to commit). Same identifier on the same instrument means the same physical machine — but the trial port had been creating enrolments lazily from whatever SerialNumber labs typed into result rows, while the membership port created another row from the canonical tblXxxMembershipDetail register, leaving 281 duplicate rows across 280 (participant, instrument, identifier) triples. Survivor = row referenced by the most distribution_snapshots (preserves history); tiebreak lowest id. Survivor's serial is then overwritten with the legacy MembershipDetail value (when one exists) so the SN matches the canonical register. Re-points snapshots and lab_component_registrations onto the survivor (deduping component regs by membership/component).
Local validation: 280 triples merged, 2,807 snapshots re-pointed, 681 component registrations moved, 896 deduped, 14 survivor serials canonicalised. PRN 20028 (test lab) ADLC matrix now correctly attributes to the XN10/20028H survivor (serial 11626) instead of the typo-serial sibling.
v0.11.4
Serial-number conflicts no longer drop instrument enrolments + their programme registrations
Fixed
legacy:port-fbc-memberships + legacy:port-adlc-memberships — when the legacy SerialNumber for a PMID conflicts with another enrolment's serial under the same (participant, instrument), the port now falls back to a guaranteed-unique synthetic serial ("legacy-pmid-{N}") rather than dropping the entire iteration in the catch block. Previously the (participant, instrument, identifier) row was lost AND its programme component registrations were never written — which is exactly what hid 20028's only Active ADLC PMID (XN10/20028H, serial 11626 already owned by the FBC enrolment of the same instrument).
Same fallback handles the legacy-data-quality case where multiple FBC PMIDs share an empty SerialNumber under different identifiers — without this the unique index on (participant, instrument, serial='') blocked all but one of them.
v0.11.3
legacy:sync-all now invalidates the membership-manager matrix cache after each run
Fixed
legacy:sync-all — busts the per-participant matrix caches (participant_instruments / automated_counting_instruments / matrix_data / available_programmes / grouped_programmes / *_programmes) at the end of the run. The membership manager caches each of these for 5 minutes; without an explicit bust the admin UI shows stale "no programmes" cells for up to 5 minutes after a sync, even though the underlying lab_component_registrations rows are correct. Skipped on dry-run.
v0.11.2
Per-PMID status mapping — labs no longer flipped to "suspended" by their newest analyser
Fixed
legacy:port-memberships — derive lab_programme_memberships.status by aggregating across ALL PMIDs for the (participant, programme), not just the latest. Active(2) > Suspended(3) > Prospect(1) > Withdrawn/Cancelled. Without this 20028's FBC + ADLC memberships were stuck at "suspended" because their newest registered analyser happened to be Suspended, even though both labs had 6+ Active PMIDs.
subscription_date now reflects the EARLIEST Joined across PMIDs (when the lab first started on the programme) rather than the newest analyser's join date — 20028 FBC went from 2026-03-24 to its true 2005-12-02 enrolment.
legacy:port-fbc-memberships + legacy:port-adlc-memberships — instrument_enrollments + lab_component_registrations status now derived from the per-PMID legacy status, not the membership's aggregated status. A lab can be active on the programme overall while individual analysers carry suspended/withdrawn flags. Existing registrations are status-synced on each run so the membership-manager UI (which filters status=active) reflects the legacy state on every sync.
PortMembershipsCommand idempotency anchor switched from legacy_id to (participant_id, programme_id) — the aggregated MAX(PMID) anchor can shift between runs as new PMIDs land in legacy, which previously triggered duplicate-key errors.
v0.11.1
FBC + ADLC instrument enrolments port — fix PMID-collapse bug that hid 80% of legacy instruments
Fixed
legacy:port-fbc-memberships + legacy:port-adlc-memberships — resolve detail rows via participant_id instead of PMID legacy_id. Legacy uses one PMID per (participant, programme, instrument) — a typical FBC lab has 13 PMIDs; ADLC labs have 5+. Our lab_programme_memberships keeps a single row per (participant, programme), so the PMID-keyed lookup was matching only the latest PMID and silently dropping every other instrument. After the fix participant 20028 (our test lab) gets all 11 FBC + 5 ADLC instruments instead of 1+1.
New legacy:port-fbc-memberships command — mirror of the ADLC variant. Trial-only port previously created enrolments only for participants who actively SUBMITTED results in the trial window; this command reads tblFBCMembershipDetail directly so every registered identifier gets an enrolment regardless of recent activity.
MN / Infectious Mononucleosis port end-to-end — 3 new artisan commands (legacy:port-mn-trial, legacy:port-mn-trials, legacy:diff-mn-stats), wired into legacy:sync-all with --skip-mn. Validated across 8 distributions (2403MN → 2602MN, 6,050 results) with 100 % value match, 99 % score match.
Techniques infrastructure (build-once, reused across in-house programmes that record kit/method per submission). Two new tables: techniques (per-programme catalogue with legacy_id back-reference) and distribution_snapshot_techniques (M:N pivot at the snapshot level with kit + other_name). Used by MN; ANT and CM ports will reuse.
MnProgrammeSeeder — canonical Haematology qualitative programme: 1 scored test (Negative / Weak positive / Other / Positive), 7 techniques in catalogue, scoring_method=majority_consensus with a 50-point wrong-answer penalty (lighter than POCT-D's 100).
Per-programme wrong-answer penalty in ConsensusStatisticsStrategy — reads programme_components.scoring_multiplier on the scored component, defaulting to 100 when unset. POCT-D continues to use the default; MN sets 50.
On-demand legacy sync admin UI — /admin/legacy-sync. "Run sync now" button dispatches a queued RunLegacySync job; recent-runs table shows who triggered, timestamps, status, duration, audit-rows count, and the captured artisan output. Polls every 5 s while a sync is in progress. Server-side double-click guard.
Bitbucket pipeline + deploy script now forward LEGACY_DB_HOST / LEGACY_DB_USERNAME / LEGACY_DB_PASSWORD as Azure container env vars (sourced from Bitbucket repository variables).
Fixed
ConsensusStatisticsStrategy — wrong-answer score now configurable per programme rather than hardcoded at 100, so MN scoring can derive 0/50 instead of 0/100.
v0.10.0
POCT-D programme port — first qualitative in-house programme on the new platform
New
POCT-D / BPAS port end-to-end — 4 new artisan commands: legacy:port-poctd-trial (single trial), legacy:port-poctd-trials (bulk), legacy:diff-poctd-stats (per-result audit), plus sync-all wiring with --skip-poctd. Validated across 8 distributions (24R5B → 26R2B, 1,599 results) with 100 % EXACT match against legacy on both value and score.
PoctdProgrammeSeeder rewritten as canonical idempotent seeder. scoring_method per component (Anti-D and Control: 'none' — recorded for context only; D-interpretation: 'majority_consensus'). Sample naming now 'Patient {n}' with display space.
Inhouse-programme port playbook published at docs/design/inhouse-programme-port-playbook.md, with the BTLP-stack defaults locked in: distribution codes read verbatim from legacy schedule, no late penalties for any BTLP programme, low-consensus samples handled via existing distribution_exclusion_rules.
POCT-D audit pack written to docs/testing/audit/ — markdown summary + CSV, regeneratable via legacy:diff-poctd-stats --csv.
Fixed
ConsensusStatisticsStrategy — Unable-to-test scoring bug. Was returning null (silently dropping the result from cumulative scoring); now returns 0 (lab literally couldn't perform the test, no penalty, per confirmed POCT-D rule). Unable-to-interpret continues to return 50 (honest non-call partial credit).
v0.9.0
ADLC programme — full pipeline (seeders, stats, port commands, report, validation)
New
ADLC programme established — Automated Differential Leucocyte Count, short code DL on the legacy side / ADLC on ours, 8 components (WBC, NEU*, LYM*, MON, EOS, BAS, LUC, NRB; * = mandatory) and 27 matrix-level instrument groups (Matrix A through Matrix N)
Linear-trimmed-mean stats strategy — same ES doc protocol as FBC (5%/5% trim, Downton's robust SD, n≥10) but no log transform, since zero is a clinically valid result for ADLC differential parameters
Four new ADLC port commands: legacy:port-adlc-machines (reconciles tblADLCMachine against existing instruments by manufacturer + description; 226 of 227 active machines reuse the FBC instrument row), legacy:port-adlc-memberships (identifier-level enrolments + lab component registrations with NoOfDLCPops + deselection filtering), legacy:port-adlc-trial (single trial), legacy:port-adlc-trials (bulk wrapper)
ADLC pipeline wired into the nightly legacy:sync-all orchestrator (--from-adlc / --skip-adlc switches)
legacy:diff-adlc-stats — row-by-row diff of tblADLCSampleStatistics against our recomputed StatisticSet; emits a human-readable CSV (sample code, test code, matrix name) — same audit-pack format as the FBC diff. First validation: mean median Δ = 0.00%
ADLC report template (adlc-v1.blade.php) — clone of FBC v5 with traffic-light flags, director comments, inline CAPA references, group-scoped histograms; renders the 8 ADLC components dynamically without hardcoded codes
Improved
Stats engine now writes decimals via sprintf('%.6f') to dodge SQL Server's scientific-notation bind error (PHP serializes 6.21e-5 as "6.21E-5" which the ODBC driver can't cast to decimal — bites ADLC on basophils, FBC dodged it via log transform). decimal(15,6) precision is unchanged
QuantitativeStatisticsStrategy now accepts both log_transformed_trimmed_mean (FBC) and linear_trimmed_mean (ADLC) — without this ADLC fell through to a legacy fallback that produced no StatisticSet
ReportViewController scoring branch keys off short_code instead of programme.name, so it picks up FBC + ADLC + future quantitative programmes uniformly
Instrument reconciliation pattern locked in for future programme ports: per-programme machine tables describe the same physical analysers; reuse the existing instruments row matched by (manufacturer.legacy_id, description) and add programme-specific compatibility / group-membership rows on top — never duplicate. Documented as a feedback memory
Fixed
legacy:port-memberships subscription_date falls back to a 2000-01-01 sentinel when legacy Joined is NULL — affects 2 of 6,666 ADLC memberships; FBC unaffected
Legacy data port — one-way destructive sync from UKNEQAS_Watford Azure SQL via legacy:port-{participants,instruments,contacts,fbc-trials} commands, all linked by legacy_id columns and a port_audits log
Nightly sync orchestrator — legacy:sync-all runs all four port commands at 02:00, with onOneServer + withoutOverlapping guards and a roll-up audit row
Stats validation tooling — legacy:diff-fbc-stats produces a per-component CSV (sample/test/group/status, no DB ids) comparing imported legacy values against our recomputed ES-doc stats
Traffic-light performance flag system — green/amber/red flags per (snapshot, component) with manual overrides preserved across recompute, surfaced on the participant dashboard as per-instrument action cards
FBC report v5 — director comments, traffic-light flag rendering, group-scoped histograms, CAPA references inline; available from the template seeder + admin preview
Per-participant ContactManager — granular role grants (participant-admin / edit-membership / edit-users / edit-results / view-results / view-reports) replacing the old main / trial / report / consultant / data-entry slugs
Unified CAPA review modal — single Review action replaces View / Review / Sign-Off triplet, with inline Save (status → under_review) and Sign Off (status → signed_off) buttons; shared partial used by both DistributionPerformanceMonitor and CapaManagement
CAPA cell colour coding on Performance Monitor — blue (raised / awaiting), amber (response received / under review), emerald (signed off / closed); clicking opens the review modal directly
New Artisan: performance:recompute-flags reapplies flag service over closed (or --all) distributions; manual overrides retained
Improved
FBC stats engine made ES-doc compliant — log-transformed trimmed mean, Downton's robust SD, consensus value/CV/HCV persisted on statistic_set_statistics for cross-distribution comparison
DI capped at min(|DI|, 3.5) in DeviationIndexService and backfilled across result_scores + cumulative_scores — extreme outliers no longer dominate cumulative totals
Histogram bin width now uses trimmed natural-scale SD over the central 90% of values (5% trim each end) — outliers no longer squash the central distribution into a single bin
Report data participant lookup now filters by snapshot_id — fixes case where the wrong instrument's DI appeared when multiple snapshots shared a model (e.g. seven Sysmex XN units)
Histograms scoped to the report's instrument group — DI now computed against the same population that the participant's mean is rendered against
Performance Monitor cumulative-scores query switched from correlated subquery to derived joinSub (~13× faster) — wire:click actions on the page are responsive again
Performance Monitor drops the redundant Participant ID column (PRN encoded in identifier) and merges lab + instrument name with the identifier
AcceptableLate / LatePenalty handling — late submissions tracked in a dedicated late_submissions table, AcceptableLate=Y rows flagged via late_accepted column
Excluded results (input_complete=N or ExResult set) skipped during stats and marked as non-returns
display_name accessor rolled out across UI/services/seeders — falls through name → main contact → PRN; safe for legacy participants without a name column
Port commands now insert into both user_participant (membership) and participant_user_role (granular roles) — ported users now appear in admin impersonation search
Fixed
Participant dashboard 500 error — display_name interpolation in raw SQL replaced with the name column
Participant dashboard now shows per-instrument flag + open-CAPA roll-up (was showing flat CAPA list only)
Stale view + opcache after stats fixes — clarified container restart needed to pick up changes
v0.7.0
Distribution exclusion rules — exclude results from scoring by sample, component, and instrument group
New
Exclusion rules system — exclude specific results from scoring and statistics per distribution
Progressive narrowing: exclude by sample (required), optionally narrow by components and instrument groups
Additive rules — stack multiple exclusion rules to build the full exclusion set
Exclusions tab on distribution dashboard — manage rules with add/delete, duplicate detection, and reason tracking
Stale statistics warning — amber badge on Exclusions and Statistics tabs when rules change after last stat generation
Excluded results marked with is_excluded flag in report data for N/A display and hollow circle rendering
Improved
Scoring pipeline respects exclusion rules — statistics, DI calculations, and optimised scoring all filter excluded results
Cumulative score walkback skips excluded DIs and walks further back to find up to 6 valid scores
Increased lookback window in cumulative calculations to compensate for excluded distributions
Report data service marks excluded results for template-level rendering decisions
Cumulative graph data includes per-point styling for hollow circles on excluded scores
ExclusionRuleService with O(1) in-memory lookup and NOT EXISTS SQL subquery — zero performance impact when no rules exist
v0.6.0
Save vs Submit workflow — two-action data entry with completeness validation
New
Two-action data entry — Save Draft (work-in-progress) and Submit Results (final) with completeness validation
Submission completeness service — validates all mandatory components have values for every sample before allowing submission
Submit confirmation modal — shows programme, distribution, and sample count before finalising
Amend Results — participants can un-submit and re-submit up to and including the close date
Submission status tracking on distribution snapshots — not_started, draft, submitted with timestamps and user audit
Participant dashboard shows submission status — Enter Data, Continue (draft), Submitted badge, Amend link
Admin participants tab shows colour-coded submission status — grey (Not Started), amber (Draft), green (Submitted) with timestamp
Improved
NR detection now uses submission_status instead of checking for result_sets existence — saved but not submitted counts as NR
All downstream services filter by submission_status=submitted — scoring, statistics, cumulative scores, reports, and performance assessment only include formally submitted data
Draft results excluded from group statistics, reports, and cumulative scores to prevent partial data affecting analysis
Shared submission button partial across all 8 programme-specific results entry views for consistent UI
Read-only mode after distribution close date — participants see locked state, admins can still edit
v0.5.0
Non-Return tracking, NR penalties, and cumulative score rework
New
Non-Return detection — automatically identifies instruments with zero submissions
NR penalty system — 50 points per non-return, rolling 3-distribution window, max 150
NR waiver workflow — admin can waive NR penalties with documented reason, or reinstate
Non-Returns panel in Performance Monitor with detection, waiver, and reinstate controls
NR status frozen on distribution snapshots for report integrity
Back-to-back prevention warns when CAPA/letter was raised 2 distributions ago with distribution code
Improved
Cumulative score calculation now skips NR distributions — scores based only on distributions where results were submitted
Historical score lookback fetches extra distributions to compensate for NR gaps
Assessment engine uses real NR penalties — participation rules P3-P12 now fire correctly
Back-to-back same-type interventions fully suppressed (previous distribution), not just downgraded
v0.4.1
Automated performance assessment engine and director comments
New
Rules-based performance assessment — evaluates participation and analytical rules (P1-P12, A1-A6) to auto-flag participants for letters, CAPAs, or director review
Director comment system — per-instrument comments stored against the statistics version, shown with trigger rule explanation, for inclusion in participant reports
Run Assessment button with preview of flagged participants before applying
Apply Flags creates performance letters and CAPAs automatically based on rule outcomes
Improved
Assessment rules structured as three independent groups: DI flags (A1), borderline monitoring (A2/A3), and poor performance actions (A4/A5/A6)
High DI list now shows all results above threshold instead of capping at 200
v0.4.0
Performance letters, CAPA workflow, and performance monitoring actions
New
Performance letters — flag participants for performance letters from the cumulative scores panel and send in batch
CAPA (Corrective Action) workflow — raise issues against instruments, participants respond with investigation details, admin reviews, director signs off
Participant dashboard shows open CAPAs with severity indicators and respond button
CAPA response page — participants can report investigation findings, corrective actions, or reason for not investigating
Admin CAPA management page — searchable, filterable list with full detail view, review, sign-off, and close actions
Previous distribution letter indicator — warns when a participant already had a performance letter last time
Improved
CAPAs accessible via Quick Actions on admin dashboard with open count badge
Send letters modal shows recipient list with flagged components before confirming
v0.3.1
Performance Monitor with cumulative scores, DI analysis, and CV tracking
New
Performance Monitor tab — analyse participant performance across three views
Cumulative Scores panel — heatmap grid with components as columns, expandable individual score history per participant
High DI panel — flags results with deviation index above adjustable threshold, colour-coded by severity
CV Analysis panel — coefficient of variation per instrument group with adjustable threshold highlighting
All panels filterable by component, sample, and statistics version
v0.3.0
Tabbed distribution dashboard, email audit trail, and streamlined workflow
New
Distribution dashboard split into tabs — Overview, Participants, Statistics & Reports, Results & Scores, Email Audit, and Performance Monitor
Email Audit tab shows all emails sent for a specific distribution with delivery stats and status tracking
Tab badges indicate pending actions — red for missing statistics or reports, amber for unscored results
Context-sensitive quick actions on Overview — buttons change based on distribution lifecycle state
Samples per distribution now configurable from the programme edit page
Improved
Each tab only renders its own content, reducing page size by ~75% and eliminating morphdom issues
Removed redundant bottom Quick Actions bar — actions live in the Overview card
Removed unused Reports Center and Scoring Configuration sections
Tab state persists in URL — refreshing keeps you on the same tab
Email notifications now tagged with distribution ID for precise audit trail filtering
v0.2.3
Per-version report viewing, admin report display, and participant dashboard improvements
New
Report version dropdown in admin participant list — view each published version with description and timestamp
Per-version read tracking — admins can see exactly which report versions a participant has opened
Submitted results indicator — distributions where data has been entered show "Edit Data" instead of "Enter Data"
Action Required count only includes distributions without submitted results
Replaced "Completed" dashboard card with "Submitted" — shows how many distributions have data entered
Fixed
Admin report column now correctly shows all published versions (was showing 0 due to query joining on wrong table)
Report version deduplication fixed — multiple versions of the same report now display correctly
Distribution lifecycle tracker now shows "Reports Issued" after publishing
v0.2.2
Report read tracking for participants
New
Unread report indicators — new reports are highlighted with a "New" badge until viewed
Dashboard summary shows count of unread reports separately from total
v0.2.1
Participant dashboard improvements and admin usability fixes
New
View Report button now links directly to the participant's report instead of a generic page
Multiple report versions shown in a dropdown with descriptions and timestamps
Organisation-coloured dots next to PRNs in the impersonation modal — red for Haematology, violet for BTLP, gradient for both
Samples per distribution now configurable per programme from the programme edit page
Changelog page with real-time search and category filters
Fixed
Dual-organisation participants now linked to the same user in test data for easier impersonation testing
Report version dropdown no longer clipped by table container
v0.2.0
Distribution workflow improvements and data integrity fixes
New
Dual-organisation participants — laboratories can now be registered with both Haematology and BTLP simultaneously
Unified instrument status display in distribution participant tables with consistent styling
Distribution lifecycle tracker now updates in real-time when issuing distributions and publishing reports
Improved
Programme enrolments now respect organisation boundaries — participants can only enrol in programmes belonging to their organisation
New participants default to active status instead of random assignment
Fixed
Statistics generation now works correctly after a fresh database seed
Distribution progress bar no longer gets permanently stuck when a creation job is lost
Buttons and modals remain interactive after statistics generation completes
Instrument removal no longer resets pagination to the first page
Publishing reports now correctly marks the distribution as "Reports Issued" in the tracker
Email notification audit log no longer creates duplicate entries for queued messages
v0.1.3
Report generation pipeline and cumulative scoring fixes
Fixed
Cumulative score calculation no longer filters by invalid statistic_set_id
SQL Server 1000-row INSERT limit handled correctly in bulk score calculations
Queue job failures resolved for POCT-D organisation assignment
v0.1.2
Distribution lifecycle management and packing lists
New
Distribution lifecycle management with automated creation, issuing, and closing
Packing list generation integrated into the distribution paperwork pipeline
Distribution bundle pipeline dashboard for tracking paperwork progress
Participant dashboard with distribution status overview and action items
Improved
Excel import support for distribution schedules (7-column format)
Manual distribution creation form for ad-hoc distributions
v0.1.1
Report generation and email notification improvements
Improved
Mail safety guard for non-production environments (Mailpit integration)
Fixed
Report generation uses version-specific result sets for accurate scoring
Participant count correctly reflects filtered results per score version
Report templates join through distribution component snapshots for proper data
Email notifications sent after report publishing
Distribution issue notification timing fixed to prevent "reminder" mislabelling