From bc5dcb00bbc3bddbffb853715239070f51acd539 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Tue, 4 Aug 2026 15:37:50 +0100 Subject: [PATCH 01/27] Stub the arbitration flow pages Setup (alone or panel with user picker), per-case compare with agree actions, a different-outcome page, and a session overview showing provisional outcomes. Sessions and outcomes are stubbed under data.arbitrationSessions rather than written as real reads. --- app/routes.js | 1 + app/routes/arbitration.js | 302 +++++++++++++++++++++ app/views/reading/arbitration/compare.html | 114 ++++++++ app/views/reading/arbitration/outcome.html | 51 ++++ app/views/reading/arbitration/panel.html | 45 +++ app/views/reading/arbitration/session.html | 99 +++++++ app/views/reading/arbitration/start.html | 52 ++++ app/views/reading/index-complex.html | 6 + 8 files changed, 670 insertions(+) create mode 100644 app/routes/arbitration.js create mode 100644 app/views/reading/arbitration/compare.html create mode 100644 app/views/reading/arbitration/outcome.html create mode 100644 app/views/reading/arbitration/panel.html create mode 100644 app/views/reading/arbitration/session.html create mode 100644 app/views/reading/arbitration/start.html diff --git a/app/routes.js b/app/routes.js index f5dd4832..e130ff3a 100644 --- a/app/routes.js +++ b/app/routes.js @@ -315,6 +315,7 @@ require('./routes/episodes')(router) require('./routes/appointments')(router) require('./routes/reading')(router) require('./routes/reading-cases')(router) +require('./routes/arbitration')(router) require('./routes/reports')(router) router.get('/modal-examples', (req, res) => { diff --git a/app/routes/arbitration.js b/app/routes/arbitration.js new file mode 100644 index 00000000..a6d7c01c --- /dev/null +++ b/app/routes/arbitration.js @@ -0,0 +1,302 @@ +// app/routes/arbitration.js +// +// Arbitration flow - stub pages only for now. An arbitration session works +// through the cases awaiting arbitration; the arbitration outcome is the final +// outcome of image reading for the case. +// +// Stub status: sessions and outcomes are stored under data.arbitrationSessions +// so the flow can be clicked through end to end, but nothing writes real +// arbitration reads yet - that is the rest of phase 3 (buildRead with +// readType 'arbitration', panelUserIds, finalisation). +// +// Registered after routes/reading.js so its `/reading` middleware (nav state) +// has already run. + +const generateId = require('../lib/utils/id-generator') +const { getReadingCaseList } = require('../lib/utils/reading-case-list') +const { getReadingCaseById } = require('../lib/utils/episodes') +const { getAppointment } = require('../lib/utils/appointment-data') +const { getParticipant } = require('../lib/utils/participants') +const { getReadsAsArray } = require('../lib/utils/reading-cases') + +/** + * The cases currently awaiting arbitration, oldest images first. + * + * @param {object} data - Session data + * @returns {Array} Rows from the reading case list + */ +const getArbitrationBacklog = (data) => { + return getReadingCaseList(data, { scope: 'open', state: 'awaiting_arbitration' }).rows +} + +/** + * Look up an arbitration session + * + * @param {object} data - Session data + * @param {string} sessionId - Session id + * @returns {object|null} Session + */ +const getArbitrationSession = (data, sessionId) => { + return data.arbitrationSessions?.[sessionId] || null +} + +/** + * Create an arbitration session from the current backlog. + * + * @param {object} data - Session data + * @param {object} arbitration - { mode, panelUserIds } + * @returns {object} The new session + */ +const createArbitrationSession = (data, arbitration) => { + const session = { + id: generateId(), + type: 'arbitration', + createdAt: new Date().toISOString(), + caseIds: getArbitrationBacklog(data).map((row) => row.readingCase.id), + arbitration, + // Stub: outcomes recorded per case id rather than as real reads + outcomes: {} + } + + data.arbitrationSessions = data.arbitrationSessions || {} + data.arbitrationSessions[session.id] = session + + return session +} + +/** + * The next case in the session without a recorded outcome, or null. + * + * @param {object} session - Arbitration session + * @param {string} [afterCaseId] - Start looking after this case + * @returns {string|null} Case id + */ +const getNextArbitrationCaseId = (session, afterCaseId = null) => { + const startIndex = afterCaseId ? session.caseIds.indexOf(afterCaseId) + 1 : 0 + + // Look forward from the current case first, then wrap to the start + const ordered = [ + ...session.caseIds.slice(startIndex), + ...session.caseIds.slice(0, startIndex) + ] + + return ordered.find((caseId) => !session.outcomes[caseId]) || null +} + +/** + * Redirect to the next unarbitrated case, or the session overview when done. + */ +const redirectToNextCase = (res, session, afterCaseId = null) => { + const nextCaseId = getNextArbitrationCaseId(session, afterCaseId) + + if (nextCaseId) { + return res.redirect( + `/reading/arbitration/session/${session.id}/cases/${nextCaseId}/compare` + ) + } + + return res.redirect(`/reading/arbitration/session/${session.id}`) +} + +module.exports = (router) => { + // Setup: who is arbitrating - just the current user, or a panel + router.get('/reading/arbitration/start', (req, res) => { + const data = req.session.data + + res.render('reading/arbitration/start', { + backlogCount: getArbitrationBacklog(data).length + }) + }) + + router.post('/reading/arbitration/start-answer', (req, res) => { + const data = req.session.data + const mode = data.arbitrationTemp?.mode + + if (mode === 'panel') { + return res.redirect('/reading/arbitration/panel') + } + + const session = createArbitrationSession(data, { + mode: 'alone', + panelUserIds: [data.currentUser.id] + }) + + delete data.arbitrationTemp + + redirectToNextCase(res, session) + }) + + // Setup: pick who else is arbitrating + router.get('/reading/arbitration/panel', (req, res) => { + const data = req.session.data + + // Clinicians other than the current user + const availableUsers = data.users.filter( + (user) => + user.role.includes('clinician') && user.id !== data.currentUser.id + ) + + res.render('reading/arbitration/panel', { + availableUsers + }) + }) + + router.post('/reading/arbitration/panel-answer', (req, res) => { + const data = req.session.data + + const panelUserIds = [].concat(data.arbitrationTemp?.panelUserIds || []) + .filter(Boolean) + + const session = createArbitrationSession(data, { + mode: 'panel', + panelUserIds: [data.currentUser.id, ...panelUserIds] + }) + + delete data.arbitrationTemp + + redirectToNextCase(res, session) + }) + + // Load session and case context for the per-case pages + router.use( + '/reading/arbitration/session/:sessionId/cases/:caseId', + (req, res, next) => { + const data = req.session.data + const session = getArbitrationSession(data, req.params.sessionId) + + if (!session) { + return res.redirect('/reading/arbitration/start') + } + + const found = getReadingCaseById(data, req.params.caseId) + if (!found) { + return res.redirect(`/reading/arbitration/session/${session.id}`) + } + + const { readingCase, episode } = found + const appointment = getAppointment(data, readingCase.appointmentId) + + res.locals.session = session + res.locals.sessionId = session.id + res.locals.readingCase = readingCase + res.locals.episode = episode + res.locals.appointment = appointment + res.locals.appointmentId = appointment?.id + res.locals.participant = getParticipant(data, episode.participantId) + res.locals.reads = getReadsAsArray(readingCase) + res.locals.caseIndex = session.caseIds.indexOf(readingCase.id) + 1 + res.locals.caseTotal = session.caseIds.length + + next() + } + ) + + router.get( + '/reading/arbitration/session/:sessionId/cases/:caseId', + (req, res) => { + res.redirect( + `/reading/arbitration/session/${req.params.sessionId}/cases/${req.params.caseId}/compare` + ) + } + ) + + // Both reads side by side - agree with either, or go on to record a + // different outcome + router.get( + '/reading/arbitration/session/:sessionId/cases/:caseId/compare', + (req, res) => { + res.render('reading/arbitration/compare') + } + ) + + router.post( + '/reading/arbitration/session/:sessionId/cases/:caseId/compare-answer', + (req, res) => { + const data = req.session.data + const session = res.locals.session + const readingCase = res.locals.readingCase + + // Stub: agreeing adopts that read's opinion as the arbitration outcome. + // Reads have no id of their own - the reader identifies the read. + const agreedReaderId = req.body.agreedReaderId + const agreedRead = res.locals.reads.find( + (read) => read.readerId === agreedReaderId + ) + + session.outcomes[readingCase.id] = { + outcome: agreedRead?.opinion, + agreedWithReaderId: agreedReaderId, + recordedBy: data.currentUser.id, + recordedAt: new Date().toISOString() + } + + redirectToNextCase(res, session, readingCase.id) + } + ) + + // Record a different outcome to either read + router.get( + '/reading/arbitration/session/:sessionId/cases/:caseId/outcome', + (req, res) => { + res.render('reading/arbitration/outcome') + } + ) + + router.post( + '/reading/arbitration/session/:sessionId/cases/:caseId/outcome-answer', + (req, res) => { + const data = req.session.data + const session = res.locals.session + const readingCase = res.locals.readingCase + + session.outcomes[readingCase.id] = { + outcome: req.body.arbitrationOutcome, + recordedBy: data.currentUser.id, + recordedAt: new Date().toISOString() + } + + redirectToNextCase(res, session, readingCase.id) + } + ) + + // Session overview - everyone arbitrated this session, outcomes pending + // finalisation + router.get('/reading/arbitration/session/:sessionId', (req, res) => { + const data = req.session.data + const session = getArbitrationSession(data, req.params.sessionId) + + if (!session) { + return res.redirect('/reading/arbitration/start') + } + + // One row per case in the session, with its reads and any recorded outcome + const rows = session.caseIds + .map((caseId) => { + const found = getReadingCaseById(data, caseId) + if (!found) return null + + const { readingCase, episode } = found + const appointment = getAppointment(data, readingCase.appointmentId) + + return { + readingCase, + episode, + appointment, + participant: getParticipant(data, episode.participantId), + reads: getReadsAsArray(readingCase), + outcome: session.outcomes[caseId] || null + } + }) + .filter(Boolean) + + const arbitratedCount = rows.filter((row) => row.outcome).length + + res.render('reading/arbitration/session', { + session, + sessionId: session.id, + rows, + arbitratedCount, + nextCaseId: getNextArbitrationCaseId(session) + }) + }) +} diff --git a/app/views/reading/arbitration/compare.html b/app/views/reading/arbitration/compare.html new file mode 100644 index 00000000..da58ce19 --- /dev/null +++ b/app/views/reading/arbitration/compare.html @@ -0,0 +1,114 @@ +{# app/views/reading/arbitration/compare.html #} +{# Arbitration per-case page - both reads' outcomes side by side. Agree with + either, or go on to record a different outcome. #} + +{% extends 'layout-reading.html' %} + +{% set pageHeading = participant | getFullName %} +{% set bodyClasses = (bodyClasses or "") + " app-page-width--wide" %} + +{% set back = { + href: "/reading/arbitration/session/" + sessionId, + text: "Back to arbitration session" +} %} + +{% block pageContent %} + + {% set firstRead = reads[0] %} + {% set secondRead = reads[1] %} + + {# Format outcome labels #} + {% set outcomeLabels = { + "normal": "Normal", + "technical_recall": "Technical recall", + "recall_for_assessment": "Recall for assessment" + } %} + + {% set outcomeCardClasses = { + "normal": "app-reading-compare-card--normal", + "technical_recall": "app-reading-compare-card--technical-recall", + "recall_for_assessment": "app-reading-compare-card--recall-for-assessment" + } %} + +
+
+ + + Arbitration – case {{ caseIndex }} of {{ caseTotal }} + +

{{ participant | getFullName }}

+ +

+ Agree with one of the reads to record it as the outcome, or + record a different outcome. + View full case details +

+ +
+
+ + {% if reads | length >= 2 %} + + {# Get image paths for annotation thumbnails #} + {% set withImages = data.settings.reading.annotationsMode != 'without-images' %} + {% if withImages %} + {% set mammogramImages = getImagesForAppointment(appointmentId, "diagrams", { appointment: appointment }) %} + {% set allPaths = mammogramImages.allPaths if mammogramImages else {} %} + {% endif %} + +
+ {% for read in [firstRead, secondRead] %} + {% set readSummaryHtml %} + {% set allowEdits = false %} + {% set hideOpinionRow = true %} + {% set showAnnotationImages = withImages %} + {% set annotationImagePaths = allPaths %} + {% include "_includes/summary-lists/read-summary.njk" %} + {% endset %} + + {% set cardContentHtml %} + {{ readSummaryHtml | safe }} +
+
+ + {{ button({ + text: "Agree – record as the outcome", + classes: "nhsuk-u-margin-bottom-0" + }) }} +
+
+ {% endset %} + +
+

+ {{ "First read" if loop.first else "Second read" }} + + – {{ read.readerId | getUsername({ format: "short" }) }}, + {{ read.timestamp | formatDate("D MMM YYYY") }} + +

+ {{ card({ + heading: outcomeLabels[read.opinion] or read.opinion, + headingLevel: "2", + feature: true, + classes: "app-reading-compare-card " + (outcomeCardClasses[read.opinion] or ""), + descriptionHtml: cardContentHtml + }) }} +
+ {% endfor %} +
+ + {% else %} + + {{ insetText({ + text: "This case does not have two reads to compare." + }) }} + + {% endif %} + + {% if withImages %} + + + {% endif %} + +{% endblock %} diff --git a/app/views/reading/arbitration/outcome.html b/app/views/reading/arbitration/outcome.html new file mode 100644 index 00000000..39d4f6d8 --- /dev/null +++ b/app/views/reading/arbitration/outcome.html @@ -0,0 +1,51 @@ +{# app/views/reading/arbitration/outcome.html #} +{# Arbitration per-case page - record an outcome different to either read. + Stub: records the outcome directly with no details pages yet. #} + +{% extends 'layout-reading.html' %} + +{% set pageHeading = participant | getFullName %} +{% set gridColumn = "nhsuk-grid-column-two-thirds" %} + +{% set back = { + href: "./compare", + text: "Back to compare reads" +} %} + +{% block pageContent %} + + + Arbitration – case {{ caseIndex }} of {{ caseTotal }} + +

{{ participant | getFullName }}

+ +
+ +

What is the outcome for this case?

+ + {{ button({ + text: "Normal", + value: "normal", + name: "arbitrationOutcome", + classes: "app-button-full-width nhsuk-u-margin-bottom-3" + }) }} + + {{ button({ + text: "Technical recall", + value: "technical_recall", + name: "arbitrationOutcome", + variant: "secondary", + classes: "app-button-full-width nhsuk-u-margin-bottom-3" + }) }} + + {{ button({ + text: "Recall for assessment", + value: "recall_for_assessment", + name: "arbitrationOutcome", + variant: "warning", + classes: "app-button-full-width nhsuk-u-margin-bottom-1" + }) }} + +
+ +{% endblock %} diff --git a/app/views/reading/arbitration/panel.html b/app/views/reading/arbitration/panel.html new file mode 100644 index 00000000..222b2be5 --- /dev/null +++ b/app/views/reading/arbitration/panel.html @@ -0,0 +1,45 @@ +{# app/views/reading/arbitration/panel.html #} +{# Arbitration setup - pick who else is arbitrating #} + +{% extends 'layout-app.html' %} + +{% set pageHeading = "Who is arbitrating with you?" %} +{% set gridColumn = "nhsuk-grid-column-two-thirds" %} +{% set back = { + href: "/reading/arbitration/start", + text: "Back" +} %} + +{% set formAction = "/reading/arbitration/panel-answer" %} + +{% block pageContent %} + + {% set userItems = [] %} + {% for user in availableUsers %} + {% set userItems = userItems | push({ + value: user.id, + text: user.firstName + " " + user.lastName + }) %} + {% endfor %} + + {{ checkboxes({ + idPrefix: "panel-users", + name: "arbitrationTemp[panelUserIds]", + fieldset: { + legend: { + text: pageHeading, + size: "l", + isPageHeading: true + } + }, + hint: { + text: "Select everyone taking part. Outcomes will be recorded as agreed by the group." + }, + items: userItems + }) }} + + {{ button({ + text: "Start arbitrating" + }) }} + +{% endblock %} diff --git a/app/views/reading/arbitration/session.html b/app/views/reading/arbitration/session.html new file mode 100644 index 00000000..b3090d5a --- /dev/null +++ b/app/views/reading/arbitration/session.html @@ -0,0 +1,99 @@ +{# app/views/reading/arbitration/session.html #} +{# Arbitration session overview - every case in the session with its reads and + the arbitration outcome so far. Outcomes stay provisional until finalised. #} + +{% extends 'layout-reading.html' %} + +{% set pageHeading = "Arbitration session" %} + +{% set back = { + href: "/reading", + text: "Back to reading" +} %} + +{% block pageContent %} + + {% set outcomeLabels = { + "normal": "Normal", + "technical_recall": "Technical recall", + "recall_for_assessment": "Recall for assessment" + } %} + +

{{ pageHeading }}

+ + {# Who is arbitrating #} +

+ Arbitrated by + {%- for userId in session.arbitration.panelUserIds %} + {{ userId | getUsername({ format: "short", identifyCurrentUser: true }) }}{{ "," if not loop.last }} + {%- endfor %} +

+ +

+ {{ arbitratedCount }} of {{ rows | length }} {{ "case" | pluralise(rows | length) }} arbitrated. +

+ + {% if nextCaseId %} + {{ button({ + text: "Continue arbitrating", + href: "/reading/arbitration/session/" + sessionId + "/cases/" + nextCaseId + "/compare" + }) }} + {% else %} + {{ insetText({ + html: "

All cases in this session have been arbitrated. Outcomes stay provisional until finalised, and can be changed until then.

" + }) }} + {% endif %} + + + + + + + + + + + + + {% for row in rows %} + + + + {% for read in [row.reads[0], row.reads[1]] %} + + {% endfor %} + + + {% endfor %} + +
ParticipantImages takenFirst readSecond readArbitration outcome
+ + {{ row.participant | getFullName }} + + + {{ row.readingCase.openedDate | formatDate }} + + {% if read %} + {{ (outcomeLabels[read.opinion] or read.opinion) | toTag }} +
+ + {{ read.readerId | getUsername({ format: "short" }) }} + + {% else %} + None + {% endif %} +
+ {% if row.outcome %} + {{ (outcomeLabels[row.outcome.outcome] or row.outcome.outcome) | toTag }} +
+ + Provisional + + {% else %} + + Arbitrate + + {% endif %} +
+ +{% endblock %} diff --git a/app/views/reading/arbitration/start.html b/app/views/reading/arbitration/start.html new file mode 100644 index 00000000..531399c7 --- /dev/null +++ b/app/views/reading/arbitration/start.html @@ -0,0 +1,52 @@ +{# app/views/reading/arbitration/start.html #} +{# Arbitration setup - who is arbitrating #} + +{% extends 'layout-app.html' %} + +{% set pageHeading = "Start arbitration" %} +{% set gridColumn = "nhsuk-grid-column-two-thirds" %} +{% set back = { + href: "/reading", + text: "Back to reading" +} %} + +{% set formAction = "/reading/arbitration/start-answer" %} + +{% block pageContent %} + +

{{ pageHeading }}

+ +

+ {{ backlogCount }} {{ "case" | pluralise(backlogCount) }} {{ "is" if backlogCount == 1 else "are" }} waiting for arbitration. + View the cases +

+ + {{ radios({ + idPrefix: "arbitration-mode", + name: "arbitrationTemp[mode]", + fieldset: { + legend: { + text: "Who is arbitrating?", + size: "m" + } + }, + items: [ + { + value: "alone", + text: "Just me" + }, + { + value: "panel", + text: "Me with other people", + hint: { + text: "You’ll choose who on the next page" + } + } + ] + }) }} + + {{ button({ + text: "Continue" + }) }} + +{% endblock %} diff --git a/app/views/reading/index-complex.html b/app/views/reading/index-complex.html index ebebfebd..020a6e01 100644 --- a/app/views/reading/index-complex.html +++ b/app/views/reading/index-complex.html @@ -106,6 +106,12 @@

Start new reading session

{# Arbitration card content #} {% set arbitrationContent %}

{{ arbitrationCount }} {{ "case" | pluralise(arbitrationCount) }} need{{ "s" if arbitrationCount == 1 }} arbitration

+{{ actionLink({ + classes: "nhsuk-link--no-visited-state nhsuk-u-margin-top-2", + text: "Start arbitration", + href: "/reading/arbitration/start" +}) }} +
{{ actionLink({ classes: "nhsuk-link--no-visited-state nhsuk-u-margin-top-2", text: "See cases", From 7b4fad84848f667718f7941ad385268a6f5250e8 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Tue, 4 Aug 2026 16:17:15 +0100 Subject: [PATCH 02/27] Arbitration rides the reading workflow Arbitration sessions are reading sessions of type 'arbitration': setup claims the backlog and records each case's release, the per-case flow runs through the standard workflow with a new arbitration-compare step (agree adopts a read's outcome and details), a different outcome goes via the reused opinion and details pages, and every decision confirms on the review page. Compare-first or opinion-first is a setting. The session overview is the reading one with arbitration wording and participant links. An unfinalised arbitration read is awaiting_finalisation, not concluded. --- app/data/session-data-defaults.js | 1 + app/lib/utils/reading-cases.js | 15 +- app/lib/utils/reading.js | 34 ++ app/routes/arbitration.js | 295 ++++-------------- app/routes/reading-cases.js | 8 +- app/routes/reading.js | 161 +++++++++- app/views/reading/arbitration/outcome.html | 51 --- app/views/reading/arbitration/session.html | 99 ------ app/views/reading/session.html | 32 +- .../arbitration-compare.html} | 99 +++--- app/views/reading/workflow/existing-read.html | 14 +- app/views/reading/workflow/opinion.html | 28 +- app/views/reading/workflow/review.html | 4 +- app/views/settings.html | 6 + 14 files changed, 385 insertions(+), 462 deletions(-) delete mode 100644 app/views/reading/arbitration/outcome.html delete mode 100644 app/views/reading/arbitration/session.html rename app/views/reading/{arbitration/compare.html => workflow/arbitration-compare.html} (50%) diff --git a/app/data/session-data-defaults.js b/app/data/session-data-defaults.js index a5528111..6170206e 100644 --- a/app/data/session-data-defaults.js +++ b/app/data/session-data-defaults.js @@ -116,6 +116,7 @@ const defaultSettings = { secondReaderComparison: 'off', // 'early' | 'late' | 'off' compareWhen: 'non_normal', // 'non_normal' | 'discordant_only' arbitrationPolicy: 'discordant_only', // 'discordant_only' | 'all_recalls' | 'all_non_normal' + arbitrationFlow: 'compare_first', // 'compare_first' | 'opinion_first' - what an arbitration case opens on finalisationDelay: '60', // minutes before reads auto-finalise; '0' immediate | 'never' manual only lazySessions: 'true', defaultSessionSize: '25' diff --git a/app/lib/utils/reading-cases.js b/app/lib/utils/reading-cases.js index db71a8e0..46655c3f 100644 --- a/app/lib/utils/reading-cases.js +++ b/app/lib/utils/reading-cases.js @@ -374,8 +374,13 @@ const getReadingCaseState = (readingCase, settings = {}, now = null) => { if (reads.length === 0) return 'awaiting_first_read' if (reads.length === 1) return 'awaiting_second_read' - // An arbitration read settles the case whatever the first two said - if (getArbitrationRead(readingCase)) return 'concluded' + // An arbitration read settles the case whatever the first two said - but + // like any read it is not a result until finalised + if (getArbitrationRead(readingCase)) { + return areAllReadsFinalised(readingCase, settings, now) + ? 'concluded' + : 'awaiting_finalisation' + } // Two opinions are not a result until they are finalised if (!areAllReadsFinalised(readingCase, settings, now)) { @@ -539,6 +544,12 @@ const canUserReadCase = (readingCase, userId, options = {}) => { // A deferred case is out of the queue until someone reviews it if (isCaseDeferred(readingCase)) return false + // A case released to arbitration takes one more read - the arbitration + // read - from someone who hasn't read it already + if (isCaseInArbitration(readingCase) && !getArbitrationRead(readingCase)) { + return !userHasReadCase(readingCase, userId) + } + // Enough readers have had it already if (getReadsAsArray(readingCase).length >= maxReadsPerCase) return false diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index 998f7387..74439a42 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -31,6 +31,7 @@ const { caseHasReads, caseNeedsFirstRead, caseNeedsSecondRead, + caseNeedsArbitration, canUserReadCase, userHasReadCase, buildRead, @@ -801,6 +802,26 @@ const filterAppointmentsByNeedsSecondRead = (data, appointments) => { ) } +/** + * Filter appointments whose case sits in the arbitration backlog and which + * this user could arbitrate - nobody reads the same case twice. + * + * @param {object} data - Session data + * @param {Array} appointments - Appointments to filter + * @param {string} [userId] - User who would arbitrate; omit to skip the check + * @returns {Array} Appointments needing arbitration + */ +const filterAppointmentsByNeedsArbitration = (data, appointments, userId = null) => { + return appointments.filter((appointment) => { + const readingCase = resolveCase(data, appointment) + + if (!caseNeedsArbitration(readingCase, data.settings)) return false + if (isCaseDeferred(readingCase)) return false + + return !userId || !userHasReadCase(readingCase, userId) + }) +} + /** * Filter appointments that are fully read (have all required reads) * @@ -1160,6 +1181,16 @@ const getEligibleCandidatesForSession = (data, sessionOptions) => { if (!clinicId) throw new Error('Clinic ID is required for clinic-type sessions') appointments = filterAppointmentsByClinic(appointments, clinicId) + } else if (type === 'arbitration') { + // The arbitration backlog, not the reading queues. The generic + // user-can-read filter below would reject these cases (two reads + // already), so arbitration selects its own way. + appointments = filterAppointmentsByNeedsArbitration( + data, + appointments, + currentUserId + ) + appointments = appointments.filter((appointment) => !awaitingPriors(appointment)) } else { // 1. Filter to appointments the user can read (unless overridden) if (filters.userCanRead !== false) { @@ -1313,6 +1344,8 @@ const getDefaultSessionName = (type, clinicId, data) => { return '2nd reads session' case 'awaiting_priors': return 'Awaiting priors session' + case 'arbitration': + return 'Arbitration session' case 'clinic': { const clinic = getClinic(data, clinicId) if (!clinic) return 'Clinic session' @@ -1589,6 +1622,7 @@ module.exports = { filterAppointmentsByNeedsAnyRead, filterAppointmentsByNeedsFirstRead, filterAppointmentsByNeedsSecondRead, + filterAppointmentsByNeedsArbitration, filterAppointmentsByFullyRead, filterAppointmentsByUserCanRead, filterAppointmentsByUserCanReadOrHasRead, diff --git a/app/routes/arbitration.js b/app/routes/arbitration.js index a6d7c01c..7a0dab04 100644 --- a/app/routes/arbitration.js +++ b/app/routes/arbitration.js @@ -1,101 +1,80 @@ // app/routes/arbitration.js // -// Arbitration flow - stub pages only for now. An arbitration session works -// through the cases awaiting arbitration; the arbitration outcome is the final -// outcome of image reading for the case. -// -// Stub status: sessions and outcomes are stored under data.arbitrationSessions -// so the flow can be clicked through end to end, but nothing writes real -// arbitration reads yet - that is the rest of phase 3 (buildRead with -// readType 'arbitration', panelUserIds, finalisation). +// Arbitration setup - who is arbitrating, then a real reading session of type +// 'arbitration' over the arbitration backlog. The per-case flow itself runs +// through the standard reading workflow (routes/reading.js), which knows to +// start arbitration cases on the compare step. // // Registered after routes/reading.js so its `/reading` middleware (nav state) // has already run. -const generateId = require('../lib/utils/id-generator') -const { getReadingCaseList } = require('../lib/utils/reading-case-list') -const { getReadingCaseById } = require('../lib/utils/episodes') -const { getAppointment } = require('../lib/utils/appointment-data') -const { getParticipant } = require('../lib/utils/participants') -const { getReadsAsArray } = require('../lib/utils/reading-cases') +const { + getEligibleCandidatesForSession, + createReadingSession, + getFirstReadableAppointmentInSession +} = require('../lib/utils/reading') +const { getReadingCase, updateReadingCase } = require('../lib/utils/episodes') /** - * The cases currently awaiting arbitration, oldest images first. + * Record the release of each case in an arbitration session. * - * @param {object} data - Session data - * @returns {Array} Rows from the reading case list - */ -const getArbitrationBacklog = (data) => { - return getReadingCaseList(data, { scope: 'open', state: 'awaiting_arbitration' }).rows -} - -/** - * Look up an arbitration session + * Auto-finalisation by time never writes the release (there is no act to + * record) - pulling a case into an arbitration session is one, so the release + * gets recorded here if finalisation didn't already. This is what makes + * buildRead stamp the eventual read as an arbitration read. * * @param {object} data - Session data - * @param {string} sessionId - Session id - * @returns {object|null} Session + * @param {object} session - The arbitration reading session */ -const getArbitrationSession = (data, sessionId) => { - return data.arbitrationSessions?.[sessionId] || null -} +const recordArbitrationReleases = (data, session) => { + const releasedAt = new Date().toISOString() -/** - * Create an arbitration session from the current backlog. - * - * @param {object} data - Session data - * @param {object} arbitration - { mode, panelUserIds } - * @returns {object} The new session - */ -const createArbitrationSession = (data, arbitration) => { - const session = { - id: generateId(), - type: 'arbitration', - createdAt: new Date().toISOString(), - caseIds: getArbitrationBacklog(data).map((row) => row.readingCase.id), - arbitration, - // Stub: outcomes recorded per case id rather than as real reads - outcomes: {} - } + for (const appointmentId of session.appointmentIds) { + const appointment = data.appointments.find( + (candidate) => candidate.id === appointmentId + ) + if (!appointment) continue - data.arbitrationSessions = data.arbitrationSessions || {} - data.arbitrationSessions[session.id] = session + const readingCase = getReadingCase(data, appointment) + if (!readingCase || readingCase.arbitration?.releasedAt) continue - return session + updateReadingCase(data, appointment.episodeId, { + ...readingCase, + arbitration: { releasedAt, releasedBy: data.currentUser.id } + }) + } } /** - * The next case in the session without a recorded outcome, or null. - * - * @param {object} session - Arbitration session - * @param {string} [afterCaseId] - Start looking after this case - * @returns {string|null} Case id + * Create the arbitration session and send the user into its first case, + * falling back to the session overview when nothing is readable. */ -const getNextArbitrationCaseId = (session, afterCaseId = null) => { - const startIndex = afterCaseId ? session.caseIds.indexOf(afterCaseId) + 1 : 0 +const startArbitrationSession = (data, res, arbitration) => { + const sessionOptions = { type: 'arbitration', lazy: false } - // Look forward from the current case first, then wrap to the start - const ordered = [ - ...session.caseIds.slice(startIndex), - ...session.caseIds.slice(0, startIndex) - ] + const candidates = getEligibleCandidatesForSession(data, sessionOptions) + if (candidates.length === 0) { + return res.redirect('/reading') + } - return ordered.find((caseId) => !session.outcomes[caseId]) || null -} + const session = createReadingSession(data, sessionOptions) + session.arbitration = arbitration -/** - * Redirect to the next unarbitrated case, or the session overview when done. - */ -const redirectToNextCase = (res, session, afterCaseId = null) => { - const nextCaseId = getNextArbitrationCaseId(session, afterCaseId) + recordArbitrationReleases(data, session) + + const firstReadableAppointment = getFirstReadableAppointmentInSession( + data, + session.id, + data.currentUser.id + ) - if (nextCaseId) { + if (firstReadableAppointment) { return res.redirect( - `/reading/arbitration/session/${session.id}/cases/${nextCaseId}/compare` + `/reading/session/${session.id}/appointments/${firstReadableAppointment.id}` ) } - return res.redirect(`/reading/arbitration/session/${session.id}`) + return res.redirect(`/reading/session/${session.id}`) } module.exports = (router) => { @@ -103,27 +82,26 @@ module.exports = (router) => { router.get('/reading/arbitration/start', (req, res) => { const data = req.session.data - res.render('reading/arbitration/start', { - backlogCount: getArbitrationBacklog(data).length - }) + const backlogCount = getEligibleCandidatesForSession(data, { + type: 'arbitration' + }).length + + res.render('reading/arbitration/start', { backlogCount }) }) router.post('/reading/arbitration/start-answer', (req, res) => { const data = req.session.data - const mode = data.arbitrationTemp?.mode - if (mode === 'panel') { + if (data.arbitrationTemp?.mode === 'panel') { return res.redirect('/reading/arbitration/panel') } - const session = createArbitrationSession(data, { + delete data.arbitrationTemp + + startArbitrationSession(data, res, { mode: 'alone', panelUserIds: [data.currentUser.id] }) - - delete data.arbitrationTemp - - redirectToNextCase(res, session) }) // Setup: pick who else is arbitrating @@ -136,9 +114,7 @@ module.exports = (router) => { user.role.includes('clinician') && user.id !== data.currentUser.id ) - res.render('reading/arbitration/panel', { - availableUsers - }) + res.render('reading/arbitration/panel', { availableUsers }) }) router.post('/reading/arbitration/panel-answer', (req, res) => { @@ -147,156 +123,11 @@ module.exports = (router) => { const panelUserIds = [].concat(data.arbitrationTemp?.panelUserIds || []) .filter(Boolean) - const session = createArbitrationSession(data, { - mode: 'panel', - panelUserIds: [data.currentUser.id, ...panelUserIds] - }) - delete data.arbitrationTemp - redirectToNextCase(res, session) - }) - - // Load session and case context for the per-case pages - router.use( - '/reading/arbitration/session/:sessionId/cases/:caseId', - (req, res, next) => { - const data = req.session.data - const session = getArbitrationSession(data, req.params.sessionId) - - if (!session) { - return res.redirect('/reading/arbitration/start') - } - - const found = getReadingCaseById(data, req.params.caseId) - if (!found) { - return res.redirect(`/reading/arbitration/session/${session.id}`) - } - - const { readingCase, episode } = found - const appointment = getAppointment(data, readingCase.appointmentId) - - res.locals.session = session - res.locals.sessionId = session.id - res.locals.readingCase = readingCase - res.locals.episode = episode - res.locals.appointment = appointment - res.locals.appointmentId = appointment?.id - res.locals.participant = getParticipant(data, episode.participantId) - res.locals.reads = getReadsAsArray(readingCase) - res.locals.caseIndex = session.caseIds.indexOf(readingCase.id) + 1 - res.locals.caseTotal = session.caseIds.length - - next() - } - ) - - router.get( - '/reading/arbitration/session/:sessionId/cases/:caseId', - (req, res) => { - res.redirect( - `/reading/arbitration/session/${req.params.sessionId}/cases/${req.params.caseId}/compare` - ) - } - ) - - // Both reads side by side - agree with either, or go on to record a - // different outcome - router.get( - '/reading/arbitration/session/:sessionId/cases/:caseId/compare', - (req, res) => { - res.render('reading/arbitration/compare') - } - ) - - router.post( - '/reading/arbitration/session/:sessionId/cases/:caseId/compare-answer', - (req, res) => { - const data = req.session.data - const session = res.locals.session - const readingCase = res.locals.readingCase - - // Stub: agreeing adopts that read's opinion as the arbitration outcome. - // Reads have no id of their own - the reader identifies the read. - const agreedReaderId = req.body.agreedReaderId - const agreedRead = res.locals.reads.find( - (read) => read.readerId === agreedReaderId - ) - - session.outcomes[readingCase.id] = { - outcome: agreedRead?.opinion, - agreedWithReaderId: agreedReaderId, - recordedBy: data.currentUser.id, - recordedAt: new Date().toISOString() - } - - redirectToNextCase(res, session, readingCase.id) - } - ) - - // Record a different outcome to either read - router.get( - '/reading/arbitration/session/:sessionId/cases/:caseId/outcome', - (req, res) => { - res.render('reading/arbitration/outcome') - } - ) - - router.post( - '/reading/arbitration/session/:sessionId/cases/:caseId/outcome-answer', - (req, res) => { - const data = req.session.data - const session = res.locals.session - const readingCase = res.locals.readingCase - - session.outcomes[readingCase.id] = { - outcome: req.body.arbitrationOutcome, - recordedBy: data.currentUser.id, - recordedAt: new Date().toISOString() - } - - redirectToNextCase(res, session, readingCase.id) - } - ) - - // Session overview - everyone arbitrated this session, outcomes pending - // finalisation - router.get('/reading/arbitration/session/:sessionId', (req, res) => { - const data = req.session.data - const session = getArbitrationSession(data, req.params.sessionId) - - if (!session) { - return res.redirect('/reading/arbitration/start') - } - - // One row per case in the session, with its reads and any recorded outcome - const rows = session.caseIds - .map((caseId) => { - const found = getReadingCaseById(data, caseId) - if (!found) return null - - const { readingCase, episode } = found - const appointment = getAppointment(data, readingCase.appointmentId) - - return { - readingCase, - episode, - appointment, - participant: getParticipant(data, episode.participantId), - reads: getReadsAsArray(readingCase), - outcome: session.outcomes[caseId] || null - } - }) - .filter(Boolean) - - const arbitratedCount = rows.filter((row) => row.outcome).length - - res.render('reading/arbitration/session', { - session, - sessionId: session.id, - rows, - arbitratedCount, - nextCaseId: getNextArbitrationCaseId(session) + startArbitrationSession(data, res, { + mode: 'panel', + panelUserIds: [data.currentUser.id, ...panelUserIds] }) }) } diff --git a/app/routes/reading-cases.js b/app/routes/reading-cases.js index 2fafaff9..70331213 100644 --- a/app/routes/reading-cases.js +++ b/app/routes/reading-cases.js @@ -25,6 +25,7 @@ const { getReadingMetadata, getArbitrationRead, isCaseDeferred, + isCaseInArbitration, canUserReadCase } = require('../lib/utils/reading-cases') const { describeReadingCaseStatus } = require('../lib/utils/status') @@ -104,10 +105,13 @@ module.exports = (router) => { // Blind reading: someone who could still read this case must not see what // the other reader said. Once they have read it - or it is no longer theirs - // to read - the reads are theirs to see. + // to read - the reads are theirs to see. Arbitration is the exception: + // the arbitrator's job is to weigh the two reads, so they see them. const blindReading = data.settings?.reading?.blindReading === 'true' const readsHidden = - blindReading && canUserReadCase(readingCase, data.currentUser?.id) + blindReading && + !isCaseInArbitration(readingCase) && + canUserReadCase(readingCase, data.currentUser?.id) const allReads = getReadsAsArray(readingCase) const caseOutcome = getReadingCaseOutcome(readingCase, data.settings) diff --git a/app/routes/reading.js b/app/routes/reading.js index 6004b71f..19cd2bd3 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -708,6 +708,7 @@ module.exports = (router) => { // workflow templates can work in reading-case terms without each of them // walking back to the episode res.locals.isReadingWorkflow = true + res.locals.isArbitration = session.type === 'arbitration' res.locals.readingCase = getReadingCase(data, appointment) res.locals.session = session res.locals.appointmentData = { @@ -770,6 +771,18 @@ module.exports = (router) => { // Delete temporary data from previous steps delete data.imageReadingTemp + // Arbitration cases open on the two reads by default; opinion-first is a + // settings choice, with the compare step following the opinion instead + const session = getReadingSession(data, sessionId) + if ( + session?.type === 'arbitration' && + data.settings?.reading?.arbitrationFlow !== 'opinion_first' + ) { + return res.redirect( + `/reading/session/${sessionId}/appointments/${appointmentId}/arbitration-compare` + ) + } + res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/opinion` ) @@ -1161,6 +1174,7 @@ module.exports = (router) => { 'review', 'existing-read', 'compare', + 'arbitration-compare', 'request-priors', 'defer-case', 'medical-information' @@ -1898,9 +1912,29 @@ module.exports = (router) => { currentUserId ) + const isArbitrationSession = + getReadingSession(data, sessionId)?.type === 'arbitration' + + // Arbitration, opinion-first: the compare step follows the outcome and + // its details rather than opening the case + if ( + isArbitrationSession && + data.settings?.reading?.arbitrationFlow === 'opinion_first' && + !formData?.comparisonComplete && + !isEditingExistingRead + ) { + return res.redirect( + `/reading/session/${sessionId}/appointments/${appointmentId}/arbitration-compare` + ) + } + // Check for late comparison if not already done const comparisonSetting = data.settings?.reading?.secondReaderComparison - if (comparisonSetting === 'late' && !formData?.comparisonComplete) { + if ( + comparisonSetting === 'late' && + !isArbitrationSession && + !formData?.comparisonComplete + ) { if ( shouldShowComparePage( getReadingCase(data, appointment), @@ -1919,7 +1953,13 @@ module.exports = (router) => { switch (opinion) { case 'normal': // opinion-details-complete is only reached for normal when the user - // went through the normal-details page, so use confirmNormalWithDetails + // went through the normal-details page, so use confirmNormalWithDetails. + // Arbitration decisions are always confirmed, on the review page. + if (isArbitrationSession && !isEditingExistingRead) { + return res.redirect( + `/reading/session/${sessionId}/appointments/${appointmentId}/review` + ) + } if ( !isEditingExistingRead && data.settings?.reading?.confirmNormalWithDetails === 'true' @@ -1939,7 +1979,8 @@ module.exports = (router) => { : '' if ( !isEditingExistingRead && - data.settings?.reading?.confirmTechnicalRecall !== 'false' + (isArbitrationSession || + data.settings?.reading?.confirmTechnicalRecall !== 'false') ) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/review${trChainParam}` @@ -1957,7 +1998,8 @@ module.exports = (router) => { : '' if ( !isEditingExistingRead && - data.settings?.reading?.confirmRecallForAssessment !== 'false' + (isArbitrationSession || + data.settings?.reading?.confirmRecallForAssessment !== 'false') ) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/review${rfaChainParam}` @@ -2057,7 +2099,11 @@ module.exports = (router) => { recall_for_assessment: 'Recall for assessment' } const resultLabel = resultLabels[formData.opinion] || 'Opinion' - const message = `${resultLabel} opinion recorded for ${shortName}` + const isArbitrationSave = + getReadingSession(data, sessionId)?.type === 'arbitration' + const message = isArbitrationSave + ? `${resultLabel} outcome recorded for ${shortName}` + : `${resultLabel} opinion recorded for ${shortName}` data.readingOpinionBanner = { text: message, @@ -2183,9 +2229,14 @@ module.exports = (router) => { // Clean up previousOpinion - only needed for change detection delete data.imageReadingTemp.previousOpinion + // Arbitration has its own compare step, so the second-reader comparison + // gates below don't apply + const isArbitrationSession = + getReadingSession(data, sessionId)?.type === 'arbitration' + // Check for early comparison (second reader only, not normal+normal) const comparisonSetting = data.settings?.reading?.secondReaderComparison - if (comparisonSetting === 'early') { + if (comparisonSetting === 'early' && !isArbitrationSession) { const currentUserId = data.currentUser?.id if ( shouldShowComparePage( @@ -2205,9 +2256,30 @@ module.exports = (router) => { // Handle different opinion types switch (opinion) { case 'normal': + // Arbitration: opinion-first sends the outcome through the compare + // step; either way the decision is confirmed on the review page + if (isArbitrationSession) { + if ( + data.settings?.reading?.arbitrationFlow === 'opinion_first' && + !data.imageReadingTemp.comparisonComplete + ) { + return res.redirect( + `/reading/session/${sessionId}/appointments/${appointmentId}/arbitration-compare` + ) + } + if (!isEditingExistingRead) { + return res.redirect( + `/reading/session/${sessionId}/appointments/${appointmentId}/review` + ) + } + return res.redirect( + 307, + `/reading/session/${sessionId}/appointments/${appointmentId}/save-opinion` + ) + } // For late comparison, normal still needs to go through compare if discordant // (since there's no review page to intercept) - if (comparisonSetting === 'late') { + if (comparisonSetting === 'late' && !isArbitrationSession) { if ( shouldShowComparePage( getReadingCase(data, appointment), @@ -2259,6 +2331,81 @@ module.exports = (router) => { } ) + // Handle the arbitration compare decision - agree with one of the reads, + // or keep the outcome already given on the opinion page (opinion-first flow) + router.post( + '/reading/session/:sessionId/appointments/:appointmentId/arbitration-compare-answer', + (req, res) => { + const { sessionId, appointmentId } = req.params + const data = req.session.data + const currentUserId = data.currentUser?.id + + const appointment = data.appointments.find((e) => e.id === appointmentId) + if (!appointment) return res.redirect(`/reading/session/${sessionId}`) + + const isEditingExistingRead = userHasReadAppointment( + data, + appointment, + currentUserId + ) + + data.imageReadingTemp = data.imageReadingTemp || { appointmentId } + data.imageReadingTemp.comparisonComplete = true + + const agreedReaderId = req.body.agreedReaderId + + if (agreedReaderId) { + const reads = getReadsAsArray(getReadingCase(data, appointment)) + const agreedRead = reads.find( + (read) => read.readerId === agreedReaderId + ) + + if (agreedRead) { + // Adopt the read wholesale - outcome and details. The review page + // lets the arbitrator change any of it before saving. + data.imageReadingTemp.opinion = agreedRead.opinion + data.imageReadingTemp.agreedWithReaderId = agreedReaderId + + if (agreedRead.technicalRecall) { + data.imageReadingTemp.technicalRecall = { + ...agreedRead.technicalRecall + } + } + if (agreedRead.left) { + data.imageReadingTemp.left = JSON.parse( + JSON.stringify(agreedRead.left) + ) + } + if (agreedRead.right) { + data.imageReadingTemp.right = JSON.parse( + JSON.stringify(agreedRead.right) + ) + } + if (agreedRead.normalDetails) { + data.imageReadingTemp.normalDetails = agreedRead.normalDetails + } + } + + if (isEditingExistingRead) { + return res.redirect( + `/reading/session/${sessionId}/appointments/${appointmentId}/save-opinion` + ) + } + + return res.redirect( + `/reading/session/${sessionId}/appointments/${appointmentId}/review` + ) + } + + // Keeping the outcome already given - back through the routing hub so + // details and confirmation happen as normal + return res.redirect( + 307, + `/reading/session/${sessionId}/appointments/${appointmentId}/opinion-details-complete` + ) + } + ) + // Handle compare decision - keep opinion or adopt first reader's router.post( '/reading/session/:sessionId/appointments/:appointmentId/compare-answer', diff --git a/app/views/reading/arbitration/outcome.html b/app/views/reading/arbitration/outcome.html deleted file mode 100644 index 39d4f6d8..00000000 --- a/app/views/reading/arbitration/outcome.html +++ /dev/null @@ -1,51 +0,0 @@ -{# app/views/reading/arbitration/outcome.html #} -{# Arbitration per-case page - record an outcome different to either read. - Stub: records the outcome directly with no details pages yet. #} - -{% extends 'layout-reading.html' %} - -{% set pageHeading = participant | getFullName %} -{% set gridColumn = "nhsuk-grid-column-two-thirds" %} - -{% set back = { - href: "./compare", - text: "Back to compare reads" -} %} - -{% block pageContent %} - - - Arbitration – case {{ caseIndex }} of {{ caseTotal }} - -

{{ participant | getFullName }}

- -
- -

What is the outcome for this case?

- - {{ button({ - text: "Normal", - value: "normal", - name: "arbitrationOutcome", - classes: "app-button-full-width nhsuk-u-margin-bottom-3" - }) }} - - {{ button({ - text: "Technical recall", - value: "technical_recall", - name: "arbitrationOutcome", - variant: "secondary", - classes: "app-button-full-width nhsuk-u-margin-bottom-3" - }) }} - - {{ button({ - text: "Recall for assessment", - value: "recall_for_assessment", - name: "arbitrationOutcome", - variant: "warning", - classes: "app-button-full-width nhsuk-u-margin-bottom-1" - }) }} - -
- -{% endblock %} diff --git a/app/views/reading/arbitration/session.html b/app/views/reading/arbitration/session.html deleted file mode 100644 index b3090d5a..00000000 --- a/app/views/reading/arbitration/session.html +++ /dev/null @@ -1,99 +0,0 @@ -{# app/views/reading/arbitration/session.html #} -{# Arbitration session overview - every case in the session with its reads and - the arbitration outcome so far. Outcomes stay provisional until finalised. #} - -{% extends 'layout-reading.html' %} - -{% set pageHeading = "Arbitration session" %} - -{% set back = { - href: "/reading", - text: "Back to reading" -} %} - -{% block pageContent %} - - {% set outcomeLabels = { - "normal": "Normal", - "technical_recall": "Technical recall", - "recall_for_assessment": "Recall for assessment" - } %} - -

{{ pageHeading }}

- - {# Who is arbitrating #} -

- Arbitrated by - {%- for userId in session.arbitration.panelUserIds %} - {{ userId | getUsername({ format: "short", identifyCurrentUser: true }) }}{{ "," if not loop.last }} - {%- endfor %} -

- -

- {{ arbitratedCount }} of {{ rows | length }} {{ "case" | pluralise(rows | length) }} arbitrated. -

- - {% if nextCaseId %} - {{ button({ - text: "Continue arbitrating", - href: "/reading/arbitration/session/" + sessionId + "/cases/" + nextCaseId + "/compare" - }) }} - {% else %} - {{ insetText({ - html: "

All cases in this session have been arbitrated. Outcomes stay provisional until finalised, and can be changed until then.

" - }) }} - {% endif %} - - - - - - - - - - - - - {% for row in rows %} - - - - {% for read in [row.reads[0], row.reads[1]] %} - - {% endfor %} - - - {% endfor %} - -
ParticipantImages takenFirst readSecond readArbitration outcome
- - {{ row.participant | getFullName }} - - - {{ row.readingCase.openedDate | formatDate }} - - {% if read %} - {{ (outcomeLabels[read.opinion] or read.opinion) | toTag }} -
- - {{ read.readerId | getUsername({ format: "short" }) }} - - {% else %} - None - {% endif %} -
- {% if row.outcome %} - {{ (outcomeLabels[row.outcome.outcome] or row.outcome.outcome) | toTag }} -
- - Provisional - - {% else %} - - Arbitrate - - {% endif %} -
- -{% endblock %} diff --git a/app/views/reading/session.html b/app/views/reading/session.html index f68f778c..cb220225 100644 --- a/app/views/reading/session.html +++ b/app/views/reading/session.html @@ -31,7 +31,7 @@
- Image reading + {{ "Arbitration" if session.type == 'arbitration' else "Image reading" }}

{{ pageHeading }}

@@ -47,8 +47,21 @@

+ {% if session.type == 'arbitration' and session.arbitration.panelUserIds | length %} +

+ Arbitrated by + {%- for userId in session.arbitration.panelUserIds %} + {{ userId | getUsername({ format: "short", identifyCurrentUser: true }) }}{{ "," if not loop.last }} + {%- endfor %} +

+ {% endif %} + {% set currentUserHasRead = readingStatus.userReadCount > 0 %} - {% set readingActionText = "Resume reading" if currentUserHasRead else "Start reading" %} + {% if session.type == 'arbitration' %} + {% set readingActionText = "Resume arbitrating" if currentUserHasRead else "Start arbitrating" %} + {% else %} + {% set readingActionText = "Resume reading" if currentUserHasRead else "Start reading" %} + {% endif %} {% if resumeAppointment %} @@ -236,7 +249,7 @@

Reading session cases

No. Case Screening date - Your opinion + {{ "Your outcome" if session.type == 'arbitration' else "Your opinion" }} Action @@ -258,11 +271,16 @@

Reading session cases


- {% set readCount = metadata.readCount %} - {% if data | userHasReadAppointment(appointment) %} - {{ "1st read" if readCount == 1 else "2nd read" }} + {% if session.type == 'arbitration' %} + Arbitration + ·
Participant record {% else %} - {{ "1st read" if readCount == 0 else "2nd read" }} + {% set readCount = metadata.readCount %} + {% if data | userHasReadAppointment(appointment) %} + {{ "1st read" if readCount == 1 else "2nd read" }} + {% else %} + {{ "1st read" if readCount == 0 else "2nd read" }} + {% endif %} {% endif %} {% if (data.settings.debugMode | falsify) %} diff --git a/app/views/reading/arbitration/compare.html b/app/views/reading/workflow/arbitration-compare.html similarity index 50% rename from app/views/reading/arbitration/compare.html rename to app/views/reading/workflow/arbitration-compare.html index da58ce19..41893d98 100644 --- a/app/views/reading/arbitration/compare.html +++ b/app/views/reading/workflow/arbitration-compare.html @@ -1,23 +1,23 @@ -{# app/views/reading/arbitration/compare.html #} -{# Arbitration per-case page - both reads' outcomes side by side. Agree with - either, or go on to record a different outcome. #} +{# app/views/reading/workflow/arbitration-compare.html #} +{# Arbitration compare step - the two reads side by side. Agree with either to + adopt it as the outcome, or record a different outcome. In the opinion-first + flow this follows the outcome instead, adding a "keep my outcome" action. #} {% extends 'layout-reading.html' %} {% set pageHeading = participant | getFullName %} +{% set showWorkflowNav = true %} {% set bodyClasses = (bodyClasses or "") + " app-page-width--wide" %} -{% set back = { - href: "/reading/arbitration/session/" + sessionId, - text: "Back to arbitration session" -} %} - {% block pageContent %} - {% set firstRead = reads[0] %} - {% set secondRead = reads[1] %} + {% set allReads = readingCase | getReadsAsArray %} + {% set firstRead = allReads[0] %} + {% set secondRead = allReads[1] %} + + {# The outcome already given, when the compare step follows the opinion page #} + {% set ownOutcome = data.imageReadingTemp.opinion if data.imageReadingTemp else null %} - {# Format outcome labels #} {% set outcomeLabels = { "normal": "Normal", "technical_recall": "Technical recall", @@ -30,34 +30,28 @@ "recall_for_assessment": "app-reading-compare-card--recall-for-assessment" } %} -
-
- +
+
- Arbitration – case {{ caseIndex }} of {{ caseTotal }} + Arbitration

{{ participant | getFullName }}

- -

- Agree with one of the reads to record it as the outcome, or - record a different outcome. - View full case details -

- +
+
+ {{ "Arbitration" | toTag }}
- {% if reads | length >= 2 %} - - {# Get image paths for annotation thumbnails #} - {% set withImages = data.settings.reading.annotationsMode != 'without-images' %} - {% if withImages %} - {% set mammogramImages = getImagesForAppointment(appointmentId, "diagrams", { appointment: appointment }) %} - {% set allPaths = mammogramImages.allPaths if mammogramImages else {} %} - {% endif %} + {# Get image paths for annotation thumbnails #} + {% set withImages = data.settings.reading.annotationsMode != 'without-images' %} + {% if withImages %} + {% set mammogramImages = getImagesForAppointment(appointmentId, "diagrams", { appointment: appointment }) %} + {% set allPaths = mammogramImages.allPaths if mammogramImages else {} %} + {% endif %} -
- {% for read in [firstRead, secondRead] %} +
+ {% for read in [firstRead, secondRead] %} + {% if read %} {% set readSummaryHtml %} {% set allowEdits = false %} {% set hideOpinionRow = true %} @@ -69,10 +63,10 @@

{{ participant | getFullName }}

{% set cardContentHtml %} {{ readSummaryHtml | safe }}
-
+ {{ button({ - text: "Agree – record as the outcome", + text: "Agree – use as the outcome", classes: "nhsuk-u-margin-bottom-0" }) }}
@@ -95,16 +89,39 @@

descriptionHtml: cardContentHtml }) }}

- {% endfor %} -
+ {% endif %} + {% endfor %} +
+ +
+
- {% else %} + {% if ownOutcome %} - {{ insetText({ - text: "This case does not have two reads to compare." - }) }} + {# Opinion-first flow: their outcome exists - keep it or agree above #} +

Or keep your outcome

+

You gave the outcome {{ (outcomeLabels[ownOutcome] or ownOutcome) | toTag }}

+
+ {{ button({ + text: "Keep my outcome", + variant: "secondary" + }) }} +
- {% endif %} + {% else %} + +

Or give a different outcome

+

If you don’t agree with either read, review the images and record your own outcome.

+ {{ button({ + text: "Record a different outcome", + href: "./opinion", + variant: "secondary" + }) }} + + {% endif %} + +
+
{% if withImages %} diff --git a/app/views/reading/workflow/existing-read.html b/app/views/reading/workflow/existing-read.html index c85d29ba..7981badf 100644 --- a/app/views/reading/workflow/existing-read.html +++ b/app/views/reading/workflow/existing-read.html @@ -3,7 +3,7 @@ {% extends 'layout-reading.html' %} -{% set pageHeading = "Your opinion" %} +{% set pageHeading = "Your outcome" if isArbitration else "Your opinion" %} {% set showWorkflowNav = true %} {% block pageContent %} @@ -18,7 +18,7 @@ {% if isDeferredCase and not hasUserRead %} {# Appointment has been deferred - show deferral summary #} -

Your opinion

+

{{ "Your outcome" if isArbitration else "Your opinion" }}

{% set deferral = readingCase.deferral %} {% set canUndo = deferral.deferredBy == data.currentUser.id %} @@ -63,7 +63,7 @@

Your opinion

{% endset %} {{ card({ - heading: "Your read", + heading: "Arbitration outcome" if isArbitration else "Your read", headingLevel: "2", feature: true, descriptionHtml: deferralSummaryHtml @@ -71,7 +71,7 @@

Your opinion

{% elseif isAwaitingPriors and not hasUserRead %} {# Appointment is awaiting priors - show as the reader's opinion #} -

Your opinion

+

{{ "Your outcome" if isArbitration else "Your opinion" }}

{% set canUndo = appointment | userRequestedPriors(data.currentUser.id) %} @@ -122,7 +122,7 @@

Your opinion

{% endset %} {{ card({ - heading: "Your read", + heading: "Arbitration outcome" if isArbitration else "Your read", headingLevel: "2", feature: true, descriptionHtml: priorsReadSummaryHtml @@ -130,7 +130,7 @@

Your opinion

{% else %} {# Normal existing read view #} -

Your opinion

+

{{ "Your outcome" if isArbitration else "Your opinion" }}

{# Read summary card #} {% set withImages = data.settings.reading.annotationsMode != 'without-images' %} @@ -149,7 +149,7 @@

Your opinion

{% endset %} {{ card({ - heading: "Your read", + heading: "Arbitration outcome" if isArbitration else "Your read", headingLevel: "2", feature: true, descriptionHtml: readSummaryHtml diff --git a/app/views/reading/workflow/opinion.html b/app/views/reading/workflow/opinion.html index 6b9d2f42..d8c89e5f 100644 --- a/app/views/reading/workflow/opinion.html +++ b/app/views/reading/workflow/opinion.html @@ -36,7 +36,7 @@ {% set opinionHeading = "Update review" %} {% endif %} - {% set questionText = "What is your opinion of these images?" %} + {% set questionText = "What is the outcome for this case?" if isArbitration else "What is your opinion of these images?" %} {# ========================================================= Setup: mammogram images and view order @@ -366,19 +366,23 @@

{% endif %} - {# First/second read tag #} - {% set readCount = (readingCase | getReadingMetadata).readCount %} - {% if existingOpinion %} - {% if readCount == 1 %} - {{ "First read" | toTag }} - {% else %} - {{ "Second read" | toTag }} - {% endif %} + {# First/second read tag - arbitration is its own kind of read #} + {% if isArbitration %} + {{ "Arbitration" | toTag }} {% else %} - {% if readCount == 0 %} - {{ "First read" | toTag }} + {% set readCount = (readingCase | getReadingMetadata).readCount %} + {% if existingOpinion %} + {% if readCount == 1 %} + {{ "First read" | toTag }} + {% else %} + {{ "Second read" | toTag }} + {% endif %} {% else %} - {{ "Second read" | toTag }} + {% if readCount == 0 %} + {{ "First read" | toTag }} + {% else %} + {{ "Second read" | toTag }} + {% endif %} {% endif %} {% endif %} diff --git a/app/views/reading/workflow/review.html b/app/views/reading/workflow/review.html index 63704270..42cfaa13 100644 --- a/app/views/reading/workflow/review.html +++ b/app/views/reading/workflow/review.html @@ -3,7 +3,7 @@ {% extends parentLayout or 'layout-reading.html' %} -{% set pageHeading = "Confirm your opinion" %} +{% set pageHeading = "Confirm the outcome" if isArbitration else "Confirm your opinion" %} {% set formAction = './save-opinion' | urlWithReferrer(referrerChain) %} @@ -34,7 +34,7 @@

{{ pageHeading }}

{% endset %} {{ card({ - heading: "Your read", + heading: "Arbitration outcome" if isArbitration else "Your read", headingLevel: "2", feature: true, descriptionHtml: readSummaryHtml diff --git a/app/views/settings.html b/app/views/settings.html index 3facb995..3fa56681 100755 --- a/app/views/settings.html +++ b/app/views/settings.html @@ -197,6 +197,12 @@

Image reading

{value: "all_non_normal", label: "All non-normal outcomes"} ], data.settings.reading.arbitrationPolicy, "discordant_only") }} + {# Arbitration flow order - what an arbitration case opens on #} + {{ settingToggle("Arbitration flow", "settings[reading][arbitrationFlow]", [ + {value: "compare_first", label: "Compare reads first"}, + {value: "opinion_first", label: "Own outcome first"} + ], data.settings.reading.arbitrationFlow, "compare_first") }} + {# Mammogram view order #} {{ settingToggle("Mammogram view order", "settings[mammogramViewOrder]", [ {value: "cc-first", label: "CC first"}, From d382d3a8bd76f842e3a07109a7ab9d7795a33f94 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Tue, 4 Aug 2026 17:15:26 +0100 Subject: [PATCH 03/27] Arbitration cases join workflow navigation; status bar says arbitration filterAppointmentsByUserCanReadOrHasRead counted reads under two, so released cases fell out of prev/next navigation. The status bar tag is now Arbitration (orange) in arbitration sessions. --- app/data/session-data-defaults.js | 1 + app/lib/utils/reading.js | 7 +++++++ app/views/_includes/reading/reading-status-bar.njk | 12 +++++++++--- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/data/session-data-defaults.js b/app/data/session-data-defaults.js index 6170206e..8d721404 100644 --- a/app/data/session-data-defaults.js +++ b/app/data/session-data-defaults.js @@ -104,6 +104,7 @@ const defaultSettings = { } }, reading: { + indexLayout: 'complex', // 'simple' | 'complex' blindReading: config.reading.blindReading, confirmNormal: 'false', confirmNormalWithDetails: 'false', diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index 74439a42..46b2cd8f 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -32,6 +32,8 @@ const { caseNeedsFirstRead, caseNeedsSecondRead, caseNeedsArbitration, + isCaseInArbitration, + getArbitrationRead, canUserReadCase, userHasReadCase, buildRead, @@ -879,6 +881,11 @@ const filterAppointmentsByUserCanReadOrHasRead = ( // Include if the case isn't fully read yet, so they could still read it if (getReadsAsArray(readingCase).length < maxReadsPerCase) return true + // A case released to arbitration still takes its arbitration read + if (isCaseInArbitration(readingCase) && !getArbitrationRead(readingCase)) { + return true + } + // Exclude cases that are fully read by other users return false }) diff --git a/app/views/_includes/reading/reading-status-bar.njk b/app/views/_includes/reading/reading-status-bar.njk index 0f09f7ad..07058701 100644 --- a/app/views/_includes/reading/reading-status-bar.njk +++ b/app/views/_includes/reading/reading-status-bar.njk @@ -7,9 +7,15 @@ {% set firstRowItems = [] %} {# Environment tag - always first item #} -{% set firstRowItems = firstRowItems | push({ - html: 'Reading' -}) %} +{% if isArbitration %} + {% set firstRowItems = firstRowItems | push({ + html: 'Arbitration' + }) %} +{% else %} + {% set firstRowItems = firstRowItems | push({ + html: 'Reading' + }) %} +{% endif %} {# Clinic context item (session context has no label, just the environment tag) #} {% if isClinicContext %} From 3ad9d36ec4c82b3c41b6b6bb58c3ec5fc2fcd0d7 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Wed, 5 Aug 2026 15:25:15 +0100 Subject: [PATCH 04/27] Increase arbitration seed data and fix modal breakout on review page - Generate ~32 discordant cases for arbitration (was ~4) by drawing second reads from clinics 5-8 with forced disagreement - Fix opinion-details-complete redirecting to review inside modal: wrap all three opinion-type redirects with modalBreakout() --- TODO.md | 3 ++ app/lib/generators/reading-generator.js | 67 ++++++++++++++++++++----- app/routes/reading.js | 12 +++-- app/views/reading/index-complex.html | 2 +- 4 files changed, 67 insertions(+), 17 deletions(-) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..dfdc1f6c --- /dev/null +++ b/TODO.md @@ -0,0 +1,3 @@ +# TODO + +- style-guide/modal.html references `layout-fragment.html`, which doesn't exist — real layout is `_templates/layout-modal-form.html` diff --git a/app/lib/generators/reading-generator.js b/app/lib/generators/reading-generator.js index 646ae35c..71c8c437 100644 --- a/app/lib/generators/reading-generator.js +++ b/app/lib/generators/reading-generator.js @@ -780,18 +780,16 @@ const generateReadingData = (appointments, users, episodes, seedProfile = {}) => } // A FEW VERY RECENT SECOND READS: timestamped inside the finalisation delay, - // so the backlog carries live awaiting_finalisation cases - alternating - // concordant (finalising towards a conclusion) and forced discordant - // (finalising, then arbitration). Drawn from clinics[7], the first of the - // partial-first-read pair above - its first reads are recent, so the - // oldest-first session ordering the e2e journeys lean on is untouched. + // so the backlog carries live awaiting_finalisation cases - mostly forced + // discordant (for arbitration), with every 4th concordant. Drawn from + // clinics[7] and clinics[8]'s read appointments. if (clinics.length >= 9) { - const clinic = clinics[7] - const candidates = clinic.appointments + const candidates = [clinics[7], clinics[8]] + .flatMap((clinic) => clinic.appointments) .filter((appointment) => readAppointmentIds.has(appointment.id)) - .slice(0, 8) + .slice(0, 20) - let minutesAgo = 50 + let minutesAgo = 100 let count = 0 candidates.forEach((appointment, index) => { @@ -799,10 +797,11 @@ const generateReadingData = (appointments, users, episodes, seedProfile = {}) => const firstRead = readingCase?.reads?.[0] if (!firstRead) return + // Most cases forced discordant (arbitration), every 5th concordant const opinion = - index % 2 === 0 - ? pickSecondOpinion(firstRead, 0) // force disagreement - : firstRead.opinion + index % 5 === 4 + ? firstRead.opinion + : pickSecondOpinion(firstRead, 0) // force disagreement addRead( readingCase, @@ -812,7 +811,7 @@ const generateReadingData = (appointments, users, episodes, seedProfile = {}) => { forceOpinion: opinion, alignmentProbability } ) - minutesAgo -= 5 + minutesAgo -= 3 count++ }) @@ -821,6 +820,48 @@ const generateReadingData = (appointments, users, episodes, seedProfile = {}) => ) } + // MORE DISCORDANT SECOND READS: drawn from clinics[5] and clinics[6] (first + // read by secondReader), with thirdReader as the disagreeing second reader. + // Neither reader is the current user, so all are arbitrable by Jane Hitchin. + if (clinics.length >= 7) { + let minutesAgo = 95 + let count = 0 + + const candidates = [clinics[5], clinics[6]] + .flatMap((clinic) => clinic.appointments) + .filter((appointment) => readAppointmentIds.has(appointment.id)) + .filter((appointment) => { + const rc = casesByAppointmentId.get(appointment.id) + return rc && rc.reads?.length === 1 + }) + .slice(0, 20) + + candidates.forEach((appointment, index) => { + const readingCase = casesByAppointmentId.get(appointment.id) + const firstRead = readingCase.reads[0] + + const opinion = + index % 5 === 4 + ? firstRead.opinion + : pickSecondOpinion(firstRead, 0) + + addRead( + readingCase, + appointment, + thirdReader, + dayjs().subtract(minutesAgo, 'minute').toISOString(), + { forceOpinion: opinion, alignmentProbability } + ) + + minutesAgo -= 3 + count++ + }) + + console.log( + `Added ${count} discordant second reads from clinics 5-6 for arbitration` + ) + } + // A COUPLE OF DEFERRED CASES: deferred by a reader with a reason, so the // deferred list and the case view's deferral detail have real data behind // them. Drawn from clinics[8]'s unread remainder, matching how deferral diff --git a/app/routes/reading.js b/app/routes/reading.js index 19cd2bd3..6d8a18ca 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -1957,7 +1957,9 @@ module.exports = (router) => { // Arbitration decisions are always confirmed, on the review page. if (isArbitrationSession && !isEditingExistingRead) { return res.redirect( - `/reading/session/${sessionId}/appointments/${appointmentId}/review` + modalBreakout( + `/reading/session/${sessionId}/appointments/${appointmentId}/review` + ) ) } if ( @@ -1983,7 +1985,9 @@ module.exports = (router) => { data.settings?.reading?.confirmTechnicalRecall !== 'false') ) { return res.redirect( - `/reading/session/${sessionId}/appointments/${appointmentId}/review${trChainParam}` + modalBreakout( + `/reading/session/${sessionId}/appointments/${appointmentId}/review${trChainParam}` + ) ) } return res.redirect( @@ -2002,7 +2006,9 @@ module.exports = (router) => { data.settings?.reading?.confirmRecallForAssessment !== 'false') ) { return res.redirect( - `/reading/session/${sessionId}/appointments/${appointmentId}/review${rfaChainParam}` + modalBreakout( + `/reading/session/${sessionId}/appointments/${appointmentId}/review${rfaChainParam}` + ) ) } return res.redirect( diff --git a/app/views/reading/index-complex.html b/app/views/reading/index-complex.html index 020a6e01..ef59013e 100644 --- a/app/views/reading/index-complex.html +++ b/app/views/reading/index-complex.html @@ -108,7 +108,7 @@

Start new reading session

{{ arbitrationCount }} {{ "case" | pluralise(arbitrationCount) }} need{{ "s" if arbitrationCount == 1 }} arbitration

{{ actionLink({ classes: "nhsuk-link--no-visited-state nhsuk-u-margin-top-2", - text: "Start arbitration", + text: "Start arbitration session", href: "/reading/arbitration/start" }) }}
From 35aa53f38fb9189fcca2a517d0cf9f606639911b Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Wed, 5 Aug 2026 16:32:47 +0100 Subject: [PATCH 05/27] Allow panel arbitrators to read cases they originally read - Panel mode passes skipUserFilter so all awaiting-arbitration cases are included in the session regardless of who read them - Start page shows solo count hint when it differs from the total - canUserReadCase accepts panelArbitration option to skip user check - Case URL skips the existing-read redirect for arbitration sessions - save-opinion finds next un-arbitrated case directly for arbitration - isEditingExistingRead in arbitration checks for arbitration read, not original reads (panel members may have been original readers) --- app/lib/utils/reading-cases.js | 7 +-- app/lib/utils/reading.js | 4 +- app/routes/arbitration.js | 16 ++++-- app/routes/reading.js | 68 ++++++++++++++---------- app/views/reading/arbitration/start.html | 5 +- 5 files changed, 64 insertions(+), 36 deletions(-) diff --git a/app/lib/utils/reading-cases.js b/app/lib/utils/reading-cases.js index 46655c3f..473c6c67 100644 --- a/app/lib/utils/reading-cases.js +++ b/app/lib/utils/reading-cases.js @@ -537,7 +537,7 @@ const caseNeedsArbitration = (readingCase, settings = {}) => { * @returns {boolean} */ const canUserReadCase = (readingCase, userId, options = {}) => { - const { maxReadsPerCase = 2 } = options + const { maxReadsPerCase = 2, panelArbitration = false } = options if (!userId) return false @@ -545,9 +545,10 @@ const canUserReadCase = (readingCase, userId, options = {}) => { if (isCaseDeferred(readingCase)) return false // A case released to arbitration takes one more read - the arbitration - // read - from someone who hasn't read it already + // read - from someone who hasn't read it already. Panel arbitrators may + // have been an original reader, so skip the user check for panels. if (isCaseInArbitration(readingCase) && !getArbitrationRead(readingCase)) { - return !userHasReadCase(readingCase, userId) + return panelArbitration || !userHasReadCase(readingCase, userId) } // Enough readers have had it already diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index 46b2cd8f..45a5535f 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -1192,10 +1192,12 @@ const getEligibleCandidatesForSession = (data, sessionOptions) => { // The arbitration backlog, not the reading queues. The generic // user-can-read filter below would reject these cases (two reads // already), so arbitration selects its own way. + // Panel arbitration skips user filtering — a panel member who was + // an original reader can still participate in the group decision. appointments = filterAppointmentsByNeedsArbitration( data, appointments, - currentUserId + filters.skipUserFilter ? null : currentUserId ) appointments = appointments.filter((appointment) => !awaitingPriors(appointment)) } else { diff --git a/app/routes/arbitration.js b/app/routes/arbitration.js index 7a0dab04..e318533b 100644 --- a/app/routes/arbitration.js +++ b/app/routes/arbitration.js @@ -50,7 +50,12 @@ const recordArbitrationReleases = (data, session) => { * falling back to the session overview when nothing is readable. */ const startArbitrationSession = (data, res, arbitration) => { - const sessionOptions = { type: 'arbitration', lazy: false } + const isPanel = arbitration.mode === 'panel' + const sessionOptions = { + type: 'arbitration', + lazy: false, + filters: isPanel ? { skipUserFilter: true } : {} + } const candidates = getEligibleCandidatesForSession(data, sessionOptions) if (candidates.length === 0) { @@ -68,9 +73,14 @@ const startArbitrationSession = (data, res, arbitration) => { data.currentUser.id ) - if (firstReadableAppointment) { + // Panel arbitrators may have read every case in the session — fall back to + // the first appointment so they still land on the arbitration compare page + const firstAppointmentId = firstReadableAppointment?.id + || session.appointmentIds[0] + + if (firstAppointmentId) { return res.redirect( - `/reading/session/${session.id}/appointments/${firstReadableAppointment.id}` + `/reading/session/${session.id}/appointments/${firstAppointmentId}` ) } diff --git a/app/routes/reading.js b/app/routes/reading.js index 6d8a18ca..a7bea5fb 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -38,6 +38,7 @@ const { getReadingMetadata, getReadsAsArray, getReadForUser, + getArbitrationRead, isCaseDeferred, withoutRead } = require('../lib/utils/reading-cases') @@ -747,7 +748,11 @@ module.exports = (router) => { } // Check if user has already read this appointment - if (userHasReadAppointment(data, appointment, currentUserId)) { + // In arbitration, the user may have been an original reader (panel mode) + // but still needs to reach the arbitration compare page + const session = getReadingSession(data, sessionId) + const isArbitrationSession = session?.type === 'arbitration' + if (!isArbitrationSession && userHasReadAppointment(data, appointment, currentUserId)) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/existing-read` ) @@ -773,9 +778,8 @@ module.exports = (router) => { // Arbitration cases open on the two reads by default; opinion-first is a // settings choice, with the compare step following the opinion instead - const session = getReadingSession(data, sessionId) if ( - session?.type === 'arbitration' && + isArbitrationSession && data.settings?.reading?.arbitrationFlow !== 'opinion_first' ) { return res.redirect( @@ -1906,14 +1910,13 @@ module.exports = (router) => { // Editing a read the user has already saved. The existing-read page they // came from is itself a summary of the read, so the confirmation step is // redundant — save straight away regardless of the confirmation settings. - const isEditingExistingRead = userHasReadAppointment( - data, - appointment, - currentUserId - ) - + // In arbitration, "editing" means the arbitration read exists - not that + // the user was an original reader (panel members may have been). const isArbitrationSession = getReadingSession(data, sessionId)?.type === 'arbitration' + const isEditingExistingRead = isArbitrationSession + ? Boolean(getArbitrationRead(getReadingCase(data, appointment))) + : userHasReadAppointment(data, appointment, currentUserId) // Arbitration, opinion-first: the compare step follows the outcome and // its details rather than opening the case @@ -2082,13 +2085,22 @@ module.exports = (router) => { const sessionAppointments = session.appointmentIds .map((id) => data.appointments.find((e) => e.id === id)) .filter(Boolean) - const nextUnreadAppointment = getNextUserReadableAppointment( - data, - sessionAppointments, - appointmentId, - currentUserId, - { wrap: false } - ) + const isArbitrationSave = session?.type === 'arbitration' + + // Arbitration sessions find the next case without an arbitration read, + // bypassing the user-can-read check (panel members may be original readers) + const currentIndex = sessionAppointments.findIndex((e) => e.id === appointmentId) + const nextUnreadAppointment = isArbitrationSave + ? sessionAppointments.slice(currentIndex + 1).find((appt) => + !getArbitrationRead(getReadingCase(data, appt)) + ) + : getNextUserReadableAppointment( + data, + sessionAppointments, + appointmentId, + currentUserId, + { wrap: false } + ) // Store banner message for the next case, but only if there is one. // Edits stay on the current case, so there's nowhere to show it. @@ -2105,8 +2117,6 @@ module.exports = (router) => { recall_for_assessment: 'Recall for assessment' } const resultLabel = resultLabels[formData.opinion] || 'Opinion' - const isArbitrationSave = - getReadingSession(data, sessionId)?.type === 'arbitration' const message = isArbitrationSave ? `${resultLabel} outcome recorded for ${shortName}` : `${resultLabel} opinion recorded for ${shortName}` @@ -2153,11 +2163,15 @@ module.exports = (router) => { ) } else { // Check if there are any readable cases left in the session - const firstReadable = getFirstUserReadableAppointment( - data, - sessionAppointments, - currentUserId - ) + const firstReadable = isArbitrationSave + ? sessionAppointments.find((appt) => + !getArbitrationRead(getReadingCase(data, appt)) + ) + : getFirstUserReadableAppointment( + data, + sessionAppointments, + currentUserId + ) if (firstReadable) { res.redirect(modalBreakout(`/reading/session/${sessionId}`)) } else { @@ -2344,15 +2358,13 @@ module.exports = (router) => { (req, res) => { const { sessionId, appointmentId } = req.params const data = req.session.data - const currentUserId = data.currentUser?.id const appointment = data.appointments.find((e) => e.id === appointmentId) if (!appointment) return res.redirect(`/reading/session/${sessionId}`) - const isEditingExistingRead = userHasReadAppointment( - data, - appointment, - currentUserId + // In arbitration, "editing" means the arbitration read already exists + const isEditingExistingRead = Boolean( + getArbitrationRead(getReadingCase(data, appointment)) ) data.imageReadingTemp = data.imageReadingTemp || { appointmentId } diff --git a/app/views/reading/arbitration/start.html b/app/views/reading/arbitration/start.html index 531399c7..2ab9114d 100644 --- a/app/views/reading/arbitration/start.html +++ b/app/views/reading/arbitration/start.html @@ -33,7 +33,10 @@

{{ pageHeading }}

items: [ { value: "alone", - text: "Just me" + text: "Just me", + hint: { + text: soloCount ~ " of " ~ backlogCount ~ " available to you (you read the others)" + } if soloCount != backlogCount else undefined }, { value: "panel", From a640503a97b19c476040ba9534693d1a19508983 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Wed, 5 Aug 2026 17:23:52 +0100 Subject: [PATCH 06/27] Fix start page: pass both backlogCount and soloCount correctly --- app/lib/utils/reading-cases.js | 16 ++- app/lib/utils/reading.js | 199 ++++++++++++++++++++++++++------- app/routes/arbitration.js | 14 ++- app/routes/reading.js | 19 ++-- 4 files changed, 188 insertions(+), 60 deletions(-) diff --git a/app/lib/utils/reading-cases.js b/app/lib/utils/reading-cases.js index 473c6c67..755feb6c 100644 --- a/app/lib/utils/reading-cases.js +++ b/app/lib/utils/reading-cases.js @@ -147,7 +147,8 @@ const getReadForUser = (readingCase, userId) => { if (!userId) return null return ( - getReadsAsArray(readingCase).find((read) => read.readerId === userId) || null + getReadsAsArray(readingCase).find((read) => read.readerId === userId) || + null ) } @@ -170,8 +171,9 @@ const getOtherReads = (readingCase, userId) => { */ const getArbitrationRead = (readingCase) => { return ( - getReadsAsArray(readingCase).find((read) => read.readType === 'arbitration') || - null + getReadsAsArray(readingCase).find( + (read) => read.readType === 'arbitration' + ) || null ) } @@ -443,8 +445,8 @@ const getReadingCaseStatus = (readingCase, settings = {}, now = null) => { const willArbitrate = Boolean( reads.length >= 2 && - !arbitrationRead && - willGoToArbitration(reads[0], reads[1], settings) + !arbitrationRead && + willGoToArbitration(reads[0], reads[1], settings) ) const provisionalOutcome = @@ -700,7 +702,9 @@ const withRead = (readingCase, read) => { const updatedReads = existingIndex >= 0 - ? reads.map((candidate, index) => (index === existingIndex ? read : candidate)) + ? reads.map((candidate, index) => + index === existingIndex ? read : candidate + ) : [...reads, read] return { ...readingCase, reads: updatedReads } diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index 45a5535f..3ac7b5c1 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -57,7 +57,10 @@ const { * @returns {object} Reading metadata, all zeros if there is no case yet */ const getAppointmentReadingMetadata = (data, appointment) => { - return getReadingMetadata(resolveCase(data, appointment), data?.settings || {}) + return getReadingMetadata( + resolveCase(data, appointment), + data?.settings || {} + ) } /** @@ -250,7 +253,8 @@ const getDeferredCases = (data) => { .filter((row) => isCaseDeferred(row.readingCase)) .map((row) => ({ ...row, deferral: row.readingCase.deferral })) .sort( - (a, b) => new Date(b.deferral.deferredAt) - new Date(a.deferral.deferredAt) + (a, b) => + new Date(b.deferral.deferredAt) - new Date(a.deferral.deferredAt) ) } @@ -278,7 +282,8 @@ const getResolvedDeferrals = (data) => { })) }) .sort( - (a, b) => new Date(b.deferral.resolvedAt) - new Date(a.deferral.resolvedAt) + (a, b) => + new Date(b.deferral.resolvedAt) - new Date(a.deferral.resolvedAt) ) } @@ -389,7 +394,9 @@ const calculateReadingMetrics = function ( } // Resolve each appointment's case once - every count below is about the case - const cases = appointments.map((appointment) => resolveCase(data, appointment)) + const cases = appointments.map((appointment) => + resolveCase(data, appointment) + ) // Count first reads (cases with at least one read) const firstReadCount = cases.filter(caseHasReads).length @@ -505,7 +512,11 @@ const calculateReadingMetrics = function ( * @param {string | null} [userId] - Optional user ID (defaults to current user if available) * @returns {object} Detailed reading status */ -const getReadingStatusForAppointments = function (data, appointments, userId = null) { +const getReadingStatusForAppointments = function ( + data, + appointments, + userId = null +) { // Get metrics from base calculation function const metrics = calculateReadingMetrics.call(this, data, appointments, userId) @@ -575,23 +586,44 @@ const getReadingProgress = function ( const currentUserId = userId || this?.ctx?.data?.currentUser?.id // Find current appointment index - const currentIndex = appointments.findIndex((e) => e.id === currentAppointmentId) + const currentIndex = appointments.findIndex( + (e) => e.id === currentAppointmentId + ) // Basic sequential navigation - const nextAppointment = getNextAppointmentInList(appointments, currentAppointmentId, false) - const previousAppointment = getPreviousAppointmentInList(appointments, currentAppointmentId, false) + const nextAppointment = getNextAppointmentInList( + appointments, + currentAppointmentId, + false + ) + const previousAppointment = getPreviousAppointmentInList( + appointments, + currentAppointmentId, + false + ) // Get appointments needing any reads (first or second) - const readableAppointments = filterAppointmentsByNeedsAnyRead(data, appointments) + const readableAppointments = filterAppointmentsByNeedsAnyRead( + data, + appointments + ) // Find next/previous of each type const nextReadableAppointment = currentIndex !== -1 - ? getNextAppointmentInList(readableAppointments, currentAppointmentId, true) + ? getNextAppointmentInList( + readableAppointments, + currentAppointmentId, + true + ) : null const previousReadableAppointment = currentIndex !== -1 - ? getPreviousAppointmentInList(readableAppointments, currentAppointmentId, true) + ? getPreviousAppointmentInList( + readableAppointments, + currentAppointmentId, + true + ) : null // For user-specific navigation, get appointments this user can read or has read @@ -640,7 +672,11 @@ const getReadingProgress = function ( previousUserReadableId: previousUserReadableAppointment?.id || null, // Whether user has already read the previous/next appointment (for review page links) previousUserHasRead: previousUserReadableAppointment - ? userHasReadAppointment(data, previousUserReadableAppointment, currentUserId) + ? userHasReadAppointment( + data, + previousUserReadableAppointment, + currentUserId + ) : false, nextUserHasRead: nextUserReadableAppointment ? userHasReadAppointment(data, nextUserReadableAppointment, currentUserId) @@ -648,7 +684,9 @@ const getReadingProgress = function ( // Skipped appointments skippedAppointments, isCurrentSkipped: skippedAppointments.includes(currentAppointmentId), - nextAppointmentSkipped: nextAppointment ? skippedAppointments.includes(nextAppointment.id) : false, + nextAppointmentSkipped: nextAppointment + ? skippedAppointments.includes(nextAppointment.id) + : false, previousAppointmentSkipped: previousAppointment ? skippedAppointments.includes(previousAppointment.id) : false @@ -662,7 +700,11 @@ const getReadingProgress = function ( * @returns {Array} Sorted appointments array */ const sortAppointmentsByScreeningDate = (appointments) => { - if (!appointments || !Array.isArray(appointments) || appointments.length === 0) { + if ( + !appointments || + !Array.isArray(appointments) || + appointments.length === 0 + ) { return [] } @@ -698,7 +740,9 @@ const getReadingClinics = (data, options = {}) => { return data.clinics .filter((clinic) => - data.appointments.some((e) => e.clinicId === clinic.id && eligibleForReading(e)) + data.appointments.some( + (e) => e.clinicId === clinic.id && eligibleForReading(e) + ) ) .map((clinic) => { const unit = data.breastScreeningUnits.find( @@ -732,7 +776,8 @@ const getReadingClinics = (data, options = {}) => { const getReadableAppointmentsForClinic = (data, clinicId) => { // Filter eligible appointments for this clinic const eligibleAppointments = data.appointments.filter( - (appointment) => appointment.clinicId === clinicId && eligibleForReading(appointment) + (appointment) => + appointment.clinicId === clinicId && eligibleForReading(appointment) ) // Enhance the appointments with reading metadata @@ -771,7 +816,11 @@ const filterAppointmentsByEligibleForReading = (appointments) => { * @param {number} maxReadsPerCase - Number of reads required to be complete (default: 2) * @returns {Array} Appointments needing any read */ -const filterAppointmentsByNeedsAnyRead = (data, appointments, maxReadsPerCase = 2) => { +const filterAppointmentsByNeedsAnyRead = ( + data, + appointments, + maxReadsPerCase = 2 +) => { return appointments.filter( (appointment) => getReadsAsArray(resolveCase(data, appointment)).length < maxReadsPerCase @@ -813,7 +862,11 @@ const filterAppointmentsByNeedsSecondRead = (data, appointments) => { * @param {string} [userId] - User who would arbitrate; omit to skip the check * @returns {Array} Appointments needing arbitration */ -const filterAppointmentsByNeedsArbitration = (data, appointments, userId = null) => { +const filterAppointmentsByNeedsArbitration = ( + data, + appointments, + userId = null +) => { return appointments.filter((appointment) => { const readingCase = resolveCase(data, appointment) @@ -832,7 +885,11 @@ const filterAppointmentsByNeedsArbitration = (data, appointments, userId = null) * @param {number} requiredReads - Number of required reads (default: 2) * @returns {Array} Fully read appointments */ -const filterAppointmentsByFullyRead = (data, appointments, requiredReads = 2) => { +const filterAppointmentsByFullyRead = ( + data, + appointments, + requiredReads = 2 +) => { return appointments.filter( (appointment) => getReadsAsArray(resolveCase(data, appointment)).length >= requiredReads @@ -910,7 +967,11 @@ const filterAppointmentsByClinic = (appointments, clinicId) => { * @param {number | null} [maxDays] - Maximum days old (inclusive), if null, no upper bound * @returns {Array} Appointments within the specified day range */ -const filterAppointmentsByDayRange = (appointments, minDays, maxDays = null) => { +const filterAppointmentsByDayRange = ( + appointments, + minDays, + maxDays = null +) => { if (!appointments || !Array.isArray(appointments)) return [] return appointments.filter((appointment) => @@ -939,8 +1000,14 @@ const getFirstAppointmentInList = (appointments) => { * @param {boolean} wrap - Whether to wrap around to start if at end * @returns {object | null} Next appointment or null */ -const getNextAppointmentInList = (appointments, currentAppointmentId, wrap = true) => { - const currentIndex = appointments.findIndex((e) => e.id === currentAppointmentId) +const getNextAppointmentInList = ( + appointments, + currentAppointmentId, + wrap = true +) => { + const currentIndex = appointments.findIndex( + (e) => e.id === currentAppointmentId + ) if (currentIndex === -1) return null // Next appointment exists @@ -960,8 +1027,14 @@ const getNextAppointmentInList = (appointments, currentAppointmentId, wrap = tru * @param {boolean} wrap - Whether to wrap around to end if at start * @returns {object | null} Previous appointment or null */ -const getPreviousAppointmentInList = (appointments, currentAppointmentId, wrap = true) => { - const currentIndex = appointments.findIndex((e) => e.id === currentAppointmentId) +const getPreviousAppointmentInList = ( + appointments, + currentAppointmentId, + wrap = true +) => { + const currentIndex = appointments.findIndex( + (e) => e.id === currentAppointmentId + ) if (currentIndex === -1) return null // Previous appointment exists @@ -970,7 +1043,9 @@ const getPreviousAppointmentInList = (appointments, currentAppointmentId, wrap = } // Wrap around to last appointment - return wrap && appointments.length > 0 ? appointments[appointments.length - 1] : null + return wrap && appointments.length > 0 + ? appointments[appointments.length - 1] + : null } /************************************************************************ @@ -985,7 +1060,11 @@ const getPreviousAppointmentInList = (appointments, currentAppointmentId, wrap = * @param {string | null} userId - User ID to check for * @returns {object | null} First appointment user can read or null if none */ -const getFirstUserReadableAppointment = function (data, appointments, userId = null) { +const getFirstUserReadableAppointment = function ( + data, + appointments, + userId = null +) { // Get user ID from context if not provided and we're in a template context const currentUserId = userId || this?.ctx?.data?.currentUser?.id @@ -1015,11 +1094,20 @@ const getNextUserReadableAppointment = function ( ) { const { wrap = true } = options const currentUserId = userId || this?.ctx?.data?.currentUser?.id - const currentIndex = appointments.findIndex((e) => e.id === currentAppointmentId) + const currentIndex = appointments.findIndex( + (e) => e.id === currentAppointmentId + ) const appointmentsFromNext = wrap - ? [...appointments.slice(currentIndex + 1), ...appointments.slice(0, currentIndex)] + ? [ + ...appointments.slice(currentIndex + 1), + ...appointments.slice(0, currentIndex) + ] : appointments.slice(currentIndex + 1) - return getFirstUserReadableAppointment(data, appointmentsFromNext, currentUserId) + return getFirstUserReadableAppointment( + data, + appointmentsFromNext, + currentUserId + ) } /** @@ -1072,7 +1160,11 @@ const getResumeAppointmentForUser = function ( ...appointments.slice(lastActedIndex + 1), ...appointments.slice(0, lastActedIndex + 1) ] - return getFirstUserReadableAppointment(data, appointmentsFromNext, currentUserId) + return getFirstUserReadableAppointment( + data, + appointmentsFromNext, + currentUserId + ) } /************************************************************************ @@ -1138,7 +1230,6 @@ const canUserReadAppointment = function ( return canUserReadCase(resolveCase(data, appointment), currentUserId, options) } - /************************************************************************ // Sessions //*********************************************************************** @@ -1182,7 +1273,9 @@ const getEligibleCandidatesForSession = (data, sessionOptions) => { const { type = 'custom', clinicId, filters = {} } = sessionOptions const currentUserId = data.currentUser.id - let appointments = data.appointments.filter((appointment) => eligibleForReading(appointment)) + let appointments = data.appointments.filter((appointment) => + eligibleForReading(appointment) + ) if (type === 'clinic') { if (!clinicId) @@ -1199,18 +1292,28 @@ const getEligibleCandidatesForSession = (data, sessionOptions) => { appointments, filters.skipUserFilter ? null : currentUserId ) - appointments = appointments.filter((appointment) => !awaitingPriors(appointment)) + appointments = appointments.filter( + (appointment) => !awaitingPriors(appointment) + ) } else { // 1. Filter to appointments the user can read (unless overridden) if (filters.userCanRead !== false) { - appointments = filterAppointmentsByUserCanRead(data, appointments, currentUserId) + appointments = filterAppointmentsByUserCanRead( + data, + appointments, + currentUserId + ) } // 2. Apply awaiting priors filter if (type === 'awaiting_priors') { - appointments = appointments.filter((appointment) => awaitingPriors(appointment)) + appointments = appointments.filter((appointment) => + awaitingPriors(appointment) + ) } else if (!filters.includeAwaitingPriors) { - appointments = appointments.filter((appointment) => !awaitingPriors(appointment)) + appointments = appointments.filter( + (appointment) => !awaitingPriors(appointment) + ) } // 3. Symptoms filter @@ -1303,10 +1406,13 @@ const createReadingSession = (data, options) => { // Lazy sessions start with only the first appointment const initialAppointments = - isLazy && cappedAppointments.length > 0 ? [cappedAppointments[0]] : cappedAppointments + isLazy && cappedAppointments.length > 0 + ? [cappedAppointments[0]] + : cappedAppointments // Clinic sessions have no fixed target — their size is however many eligible appointments exist - const sessionTargetSize = type === 'clinic' ? cappedAppointments.length : targetSize + const sessionTargetSize = + type === 'clinic' ? cappedAppointments.length : targetSize // Create and store the session const session = { @@ -1426,7 +1532,11 @@ const getOrCreateClinicSession = (data, clinicId) => { * @param {string | null} [userId] - User ID (defaults to current user) * @returns {object | null} First readable appointment or null if none found */ -const getFirstReadableAppointmentInSession = (data, sessionId, userId = null) => { +const getFirstReadableAppointmentInSession = ( + data, + sessionId, + userId = null +) => { const session = getReadingSession(data, sessionId) if (!session) return null @@ -1434,15 +1544,16 @@ const getFirstReadableAppointmentInSession = (data, sessionId, userId = null) => // Get all appointments for the session const sessionAppointments = session.appointmentIds - .map((appointmentId) => data.appointments.find((e) => e.id === appointmentId)) + .map((appointmentId) => + data.appointments.find((e) => e.id === appointmentId) + ) .filter(Boolean) // Find the first one the user can read return ( sessionAppointments.find((appointment) => canUserReadAppointment(data, appointment, currentUserId) - ) || - null + ) || null ) } @@ -1539,7 +1650,9 @@ const getSessionReadingProgress = ( // Get all appointments for the session const sessionAppointments = session.appointmentIds - .map((appointmentId) => data.appointments.find((e) => e.id === appointmentId)) + .map((appointmentId) => + data.appointments.find((e) => e.id === appointmentId) + ) .filter(Boolean) // Use existing function for progress tracking, then add session-level size info diff --git a/app/routes/arbitration.js b/app/routes/arbitration.js index e318533b..8cc2166a 100644 --- a/app/routes/arbitration.js +++ b/app/routes/arbitration.js @@ -75,8 +75,8 @@ const startArbitrationSession = (data, res, arbitration) => { // Panel arbitrators may have read every case in the session — fall back to // the first appointment so they still land on the arbitration compare page - const firstAppointmentId = firstReadableAppointment?.id - || session.appointmentIds[0] + const firstAppointmentId = + firstReadableAppointment?.id || session.appointmentIds[0] if (firstAppointmentId) { return res.redirect( @@ -93,10 +93,15 @@ module.exports = (router) => { const data = req.session.data const backlogCount = getEligibleCandidatesForSession(data, { + type: 'arbitration', + filters: { skipUserFilter: true } + }).length + + const soloCount = getEligibleCandidatesForSession(data, { type: 'arbitration' }).length - res.render('reading/arbitration/start', { backlogCount }) + res.render('reading/arbitration/start', { backlogCount, soloCount }) }) router.post('/reading/arbitration/start-answer', (req, res) => { @@ -130,7 +135,8 @@ module.exports = (router) => { router.post('/reading/arbitration/panel-answer', (req, res) => { const data = req.session.data - const panelUserIds = [].concat(data.arbitrationTemp?.panelUserIds || []) + const panelUserIds = [] + .concat(data.arbitrationTemp?.panelUserIds || []) .filter(Boolean) delete data.arbitrationTemp diff --git a/app/routes/reading.js b/app/routes/reading.js index a7bea5fb..a7f246d1 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -752,7 +752,10 @@ module.exports = (router) => { // but still needs to reach the arbitration compare page const session = getReadingSession(data, sessionId) const isArbitrationSession = session?.type === 'arbitration' - if (!isArbitrationSession && userHasReadAppointment(data, appointment, currentUserId)) { + if ( + !isArbitrationSession && + userHasReadAppointment(data, appointment, currentUserId) + ) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/existing-read` ) @@ -2089,11 +2092,13 @@ module.exports = (router) => { // Arbitration sessions find the next case without an arbitration read, // bypassing the user-can-read check (panel members may be original readers) - const currentIndex = sessionAppointments.findIndex((e) => e.id === appointmentId) + const currentIndex = sessionAppointments.findIndex( + (e) => e.id === appointmentId + ) const nextUnreadAppointment = isArbitrationSave - ? sessionAppointments.slice(currentIndex + 1).find((appt) => - !getArbitrationRead(getReadingCase(data, appt)) - ) + ? sessionAppointments + .slice(currentIndex + 1) + .find((appt) => !getArbitrationRead(getReadingCase(data, appt))) : getNextUserReadableAppointment( data, sessionAppointments, @@ -2164,8 +2169,8 @@ module.exports = (router) => { } else { // Check if there are any readable cases left in the session const firstReadable = isArbitrationSave - ? sessionAppointments.find((appt) => - !getArbitrationRead(getReadingCase(data, appt)) + ? sessionAppointments.find( + (appt) => !getArbitrationRead(getReadingCase(data, appt)) ) : getFirstUserReadableAppointment( data, From 239457d374ca9139c23d10ce9ed5836dbb24f750 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Thu, 6 Aug 2026 11:52:50 +0100 Subject: [PATCH 07/27] Trim arbitration start page hints --- app/views/reading/arbitration/start.html | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/views/reading/arbitration/start.html b/app/views/reading/arbitration/start.html index 2ab9114d..6234694d 100644 --- a/app/views/reading/arbitration/start.html +++ b/app/views/reading/arbitration/start.html @@ -35,15 +35,12 @@

{{ pageHeading }}

value: "alone", text: "Just me", hint: { - text: soloCount ~ " of " ~ backlogCount ~ " available to you (you read the others)" + text: soloCount ~ " of " ~ backlogCount ~ " available to you" } if soloCount != backlogCount else undefined }, { value: "panel", - text: "Me with other people", - hint: { - text: "You’ll choose who on the next page" - } + text: "Me with other people" } ] }) }} From b7ab77c4d49a14d18be95f0e95fd1de30aceeccd Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Thu, 6 Aug 2026 12:00:09 +0100 Subject: [PATCH 08/27] Record panel members on the arbitration read and show them A panel arbitration is decided by several people together, but the read only carried a single readerId. buildRead now stamps panelUserIds from the session, and the case view names the panel in the read byline and as an 'Arbitrated by' row in the read summary. --- app/lib/utils/reading-cases.js | 11 ++++++++++- app/lib/utils/reading.js | 9 +++++---- .../_includes/summary-lists/read-summary.njk | 18 ++++++++++++++++++ app/views/reading/case.html | 8 +++++++- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/app/lib/utils/reading-cases.js b/app/lib/utils/reading-cases.js index 755feb6c..81813a1f 100644 --- a/app/lib/utils/reading-cases.js +++ b/app/lib/utils/reading-cases.js @@ -660,6 +660,7 @@ const shouldShowComparePage = ( * @param {object} reading - The opinion and its details * @param {object} [options] - Options * @param {string} [options.timestamp] - When the read was made + * @param {string[]} [options.panelUserIds] - Who arbitrated together, if a panel * @returns {object} The read record */ const buildRead = (readingCase, userId, readerType, reading, options = {}) => { @@ -674,7 +675,7 @@ const buildRead = (readingCase, userId, readerType, reading, options = {}) => { ? 'arbitration' : READ_TYPES[Math.min(readNumber, READ_TYPES.length) - 1] - return { + const read = { ...reading, readerId: userId, readerType, @@ -682,6 +683,14 @@ const buildRead = (readingCase, userId, readerType, reading, options = {}) => { readNumber, timestamp: options.timestamp || new Date().toISOString() } + + // Who was in the room is a fact about the arbitration read itself - the + // session is working data and won't survive to explain the read later + if (readType === 'arbitration' && options.panelUserIds?.length > 1) { + read.panelUserIds = options.panelUserIds + } + + return read } /** diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index 3ac7b5c1..bbf2953b 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -87,7 +87,10 @@ const writeReading = (data, appointment, userId, reading, sessionId = null) => { } const readerType = data.users?.find((user) => user.id === userId)?.role - const read = buildRead(readingCase, userId, readerType, reading) + const session = sessionId ? data.readingSessions?.[sessionId] : null + const read = buildRead(readingCase, userId, readerType, reading, { + panelUserIds: session?.arbitration?.panelUserIds + }) const updatedCase = withRead(readingCase, read) updateReadingCase(data, appointment.episodeId, updatedCase) @@ -98,9 +101,7 @@ const writeReading = (data, appointment, userId, reading, sessionId = null) => { // If we have session context, remove this appointment from skipped appointments // (readingSessions is per-session working data, so in-place edits are fine) - if (sessionId && data.readingSessions?.[sessionId]) { - const session = data.readingSessions[sessionId] - + if (session) { // Remove appointment from skipped list if present const skippedIndex = session.skippedAppointments.indexOf(appointment.id) if (skippedIndex !== -1) { diff --git a/app/views/_includes/summary-lists/read-summary.njk b/app/views/_includes/summary-lists/read-summary.njk index 52df492a..dbfc46be 100644 --- a/app/views/_includes/summary-lists/read-summary.njk +++ b/app/views/_includes/summary-lists/read-summary.njk @@ -252,6 +252,24 @@ {% endif %} {% endif %} +{# A panel arbitration was decided by several people together, so the read's + own reader byline doesn't tell the whole story #} +{% if read.panelUserIds | length %} + {% set panelHtml %} + {%- for panelUserId in read.panelUserIds %} + {{ panelUserId | getUsername({ format: "short", identifyCurrentUser: true }) }}{{ "," if not loop.last }} + {%- endfor %} + {% endset %} + {% set rows = rows | push({ + key: { + text: "Arbitrated by" + }, + value: { + html: panelHtml + } + }) %} +{% endif %} + {{ summaryList({ rows: rows | removeLastRowBorder } | handleSummaryListMissingInformation) }} diff --git a/app/views/reading/case.html b/app/views/reading/case.html index 5eaa1cc0..71d95716 100644 --- a/app/views/reading/case.html +++ b/app/views/reading/case.html @@ -215,7 +215,13 @@

- {{ thisRead.readerId | getUsername({ format: "short", identifyCurrentUser: true }) }}, + {%- if thisRead.panelUserIds | length %} + {%- for panelUserId in thisRead.panelUserIds %} + {{ panelUserId | getUsername({ format: "short", identifyCurrentUser: true }) }}{{ "," if not loop.last }} + {%- endfor %} + {%- else %} + {{ thisRead.readerId | getUsername({ format: "short", identifyCurrentUser: true }) }} + {%- endif %}, {{ thisRead.timestamp | formatDate }}
From 5878f30eb74ce720d05655f88d49b996a0b05306 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Thu, 6 Aug 2026 13:43:44 +0100 Subject: [PATCH 09/27] Model arbitration as a group decision, and make arbitration sessions lazy An arbitration read is made by everyone arbitrating together, so it now carries arbitratorIds rather than a single readerId plus a panel list. getReadAuthorIds gives display code one way to ask who made a read. Arbitration settles a case once for everyone, so progress is how many of the session's cases have been arbitrated - not what the current user has read. Panel members who gave one of the original opinions were being counted as already done, so a fresh session reported most of its cases complete before any arbitration had happened. Sessions no longer force lazy loading off, so they hold one case at a time and release each case as it is reached rather than claiming the whole backlog up front. --- app/lib/generators/episode-generator.js | 8 +- app/lib/utils/reading-cases.js | 108 +++++++++++++++--- app/lib/utils/reading.js | 42 ++++++- app/routes/arbitration.js | 42 +------ app/routes/reading.js | 36 +++++- .../_includes/summary-lists/read-summary.njk | 10 +- app/views/reading/case.html | 14 +-- app/views/reading/cases.html | 2 +- app/views/reading/history.html | 10 +- app/views/reading/session.html | 46 +++++--- 10 files changed, 221 insertions(+), 97 deletions(-) diff --git a/app/lib/generators/episode-generator.js b/app/lib/generators/episode-generator.js index effd4afd..751181e6 100644 --- a/app/lib/generators/episode-generator.js +++ b/app/lib/generators/episode-generator.js @@ -781,7 +781,13 @@ const checkEpisodes = (episodes, appointmentsById) => { ) } }) - if (new Set(reads.map((read) => read.readerId)).size !== reads.length) { + // Nobody reads a case twice. An arbitration read is the group's rather + // than one reader's, so it is exempt - its authors may include the + // original readers. + const readerIds = reads + .filter((read) => read.readType !== 'arbitration') + .map((read) => read.readerId) + if (new Set(readerIds).size !== readerIds.length) { problems.push( `reading case ${readingCase.id} has the same reader twice` ) diff --git a/app/lib/utils/reading-cases.js b/app/lib/utils/reading-cases.js index 81813a1f..2c9b0b04 100644 --- a/app/lib/utils/reading-cases.js +++ b/app/lib/utils/reading-cases.js @@ -188,6 +188,37 @@ const userHasReadCase = (readingCase, userId) => { return Boolean(getReadForUser(readingCase, userId)) } +/** + * Who made a read. + * + * An ordinary read has one reader; an arbitration read has however many people + * arbitrated together, all equal authors. Display code that just needs "who + * made this" can use this rather than knowing which shape it has. + * + * @param {object} read - A read + * @returns {string[]} User IDs, in no particular order + */ +const getReadAuthorIds = (read) => { + if (!read) return [] + if (read.arbitratorIds?.length) return read.arbitratorIds + return read.readerId ? [read.readerId] : [] +} + +/** + * Whether a case has been arbitrated. + * + * Arbitration settles a case once, for everyone - so unlike a read there is no + * per-user version of this question. Who may arbitrate a case is decided when + * it is picked into a session (see getEligibleCandidatesForSession); from then + * on the only question is whether it has been done. + * + * @param {object} readingCase - Reading case + * @returns {boolean} + */ +const caseHasBeenArbitrated = (readingCase) => { + return Boolean(getArbitrationRead(readingCase)) +} + /** * Whether a case has any reads * @@ -198,6 +229,29 @@ const caseHasReads = (readingCase) => { return getReadsAsArray(readingCase).length > 0 } +/** + * Record that a case has been released for arbitration, if it wasn't already. + * + * Auto-finalisation by time never writes the release (there is no act to + * record) - reaching the case in an arbitration session is one. This is what + * makes buildRead stamp the eventual read as an arbitration read. + * + * @param {object} readingCase - Reading case + * @param {string} userId - Who released it + * @returns {object} The case, released + */ +const withArbitrationRelease = (readingCase, userId) => { + if (readingCase.arbitration?.releasedAt) return readingCase + + return { + ...readingCase, + arbitration: { + releasedAt: new Date().toISOString(), + releasedBy: userId + } + } +} + /** * Whether a case has been deferred from reading. * @@ -471,7 +525,7 @@ const getReadingCaseStatus = (readingCase, settings = {}, now = null) => { */ const getReadingMetadata = (readingCase, settings = {}) => { const reads = getReadsAsArray(readingCase) - const uniqueReaderCount = new Set(reads.map((read) => read.readerId)).size + const uniqueReaderCount = new Set(reads.flatMap(getReadAuthorIds)).size const opinions = [...new Set(reads.map((read) => read.opinion))].filter( Boolean ) @@ -660,34 +714,46 @@ const shouldShowComparePage = ( * @param {object} reading - The opinion and its details * @param {object} [options] - Options * @param {string} [options.timestamp] - When the read was made - * @param {string[]} [options.panelUserIds] - Who arbitrated together, if a panel + * @param {string[]} [options.arbitratorIds] - Everyone arbitrating, if arbitration * @returns {object} The read record */ const buildRead = (readingCase, userId, readerType, reading, options = {}) => { - const existingRead = getReadForUser(readingCase, userId) - const otherReads = getOtherReads(readingCase, userId) + // A case already in arbitration is being settled, whoever is reading it + const isArbitration = isCaseInArbitration(readingCase) + + // An arbitration amends the case's one arbitration read, not the user's own - + // a panel member may also have read this case as first or second reader + const existingRead = isArbitration + ? getArbitrationRead(readingCase) + : getReadForUser(readingCase, userId) + + const otherReads = getReadsAsArray(readingCase).filter( + (read) => read !== existingRead + ) // Amending a read keeps its place in the order; a new one takes the next const readNumber = existingRead?.readNumber || otherReads.length + 1 - // A case already in arbitration is being settled, whoever is reading it - const readType = isCaseInArbitration(readingCase) + const readType = isArbitration ? 'arbitration' : READ_TYPES[Math.min(readNumber, READ_TYPES.length) - 1] const read = { ...reading, - readerId: userId, readerType, readType, readNumber, timestamp: options.timestamp || new Date().toISOString() } - // Who was in the room is a fact about the arbitration read itself - the - // session is working data and won't survive to explain the read later - if (readType === 'arbitration' && options.panelUserIds?.length > 1) { - read.panelUserIds = options.panelUserIds + // Arbitrators are equal authors of the one decision, so authorship is a set. + // An ordinary read has the single reader it belongs to. + if (isArbitration) { + read.arbitratorIds = options.arbitratorIds?.length + ? options.arbitratorIds + : [userId] + } else { + read.readerId = userId } return read @@ -705,9 +771,18 @@ const buildRead = (readingCase, userId, readerType, reading, options = {}) => { */ const withRead = (readingCase, read) => { const reads = getReadsAsArray(readingCase) - const existingIndex = reads.findIndex( - (candidate) => candidate.readerId === read.readerId - ) + + // A case has one arbitration read whoever made it; an ordinary read replaces + // the same reader's own. Matching on readerId alone would treat two + // arbitration reads as the same read, both having no readerId. + const existingIndex = + read.readType === 'arbitration' + ? reads.findIndex((candidate) => candidate.readType === 'arbitration') + : reads.findIndex( + (candidate) => + candidate.readType !== 'arbitration' && + candidate.readerId === read.readerId + ) const updatedReads = existingIndex >= 0 @@ -734,7 +809,7 @@ const withRead = (readingCase, read) => { */ const withReadFinalised = (readingCase, userId, options = {}) => { const reads = getReadsAsArray(readingCase).map((read) => - read.readerId === userId && !read.finalisedAt + getReadAuthorIds(read).includes(userId) && !read.finalisedAt ? { ...read, finalisedAt: options.finalisedAt || new Date().toISOString(), @@ -777,8 +852,11 @@ module.exports = { getReadForUser, getOtherReads, getArbitrationRead, + getReadAuthorIds, + caseHasBeenArbitrated, userHasReadCase, caseHasReads, + withArbitrationRelease, isCaseDeferred, isCaseInArbitration, areReadsDiscordant, diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index bbf2953b..e8a7abf4 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -29,6 +29,7 @@ const { isReadFinalised, isCaseDeferred, caseHasReads, + caseHasBeenArbitrated, caseNeedsFirstRead, caseNeedsSecondRead, caseNeedsArbitration, @@ -89,7 +90,7 @@ const writeReading = (data, appointment, userId, reading, sessionId = null) => { const readerType = data.users?.find((user) => user.id === userId)?.role const session = sessionId ? data.readingSessions?.[sessionId] : null const read = buildRead(readingCase, userId, readerType, reading, { - panelUserIds: session?.arbitration?.panelUserIds + arbitratorIds: session?.arbitration?.arbitratorIds }) const updatedCase = withRead(readingCase, read) @@ -1184,6 +1185,20 @@ const getResumeAppointmentForUser = function ( * @param {string} [userId] - User ID (falls back to current user from context) * @returns {boolean} Whether the user has read this appointment */ +/** + * Whether an appointment's case has been arbitrated. + * + * The appointment-shaped version of caseHasBeenArbitrated, for templates and + * callers that only have an appointment to hand. + * + * @param {object} data - Session data + * @param {object} appointment - The appointment + * @returns {boolean} + */ +const appointmentHasBeenArbitrated = (data, appointment) => { + return caseHasBeenArbitrated(resolveCase(data, appointment)) +} + const userHasReadAppointment = function (data, appointment, userId = null) { const currentUserId = userId || this?.ctx?.data?.currentUser?.id @@ -1601,11 +1616,15 @@ const topUpSession = (data, sessionId) => { // Count appointments that are still actionable for this user — appointments they have read, // can still read, deferred, or awaiting priors. Appointments fully read by other readers // ('dead' slots) are excluded so the session can be topped up to replace them. + const isArbitration = session.type === 'arbitration' const actionableCount = session.appointmentIds.filter((appointmentId) => { const appointment = data.appointments.find((e) => e.id === appointmentId) if (!appointment) return false + const isDone = isArbitration + ? appointmentHasBeenArbitrated(data, appointment) + : userHasReadAppointment(data, appointment, currentUserId) return ( - userHasReadAppointment(data, appointment, currentUserId) || + isDone || canUserReadAppointment(data, appointment, currentUserId) || isCaseDeferred(getReadingCase(data, appointment)) || awaitingPriors(appointment) @@ -1682,9 +1701,14 @@ const getSessionReadingProgress = ( // Dead appointments — fully read by other users and not actionable by this user. // They occupy session slots but can never be completed, so they don't count // toward reachable size. topUpSession will replace them when appointments are read. + const isArbitration = session.type === 'arbitration' const deadCount = sessionAppointments.filter((appointment) => { + const isDone = isArbitration + ? appointmentHasBeenArbitrated(data, appointment) + : userHasReadAppointment(data, appointment, resolvedUserId) + return ( - !userHasReadAppointment(data, appointment, resolvedUserId) && + !isDone && !canUserReadAppointment(data, appointment, resolvedUserId) && !isCaseDeferred(getReadingCase(data, appointment)) && !awaitingPriors(appointment) @@ -1700,8 +1724,17 @@ const getSessionReadingProgress = ( isCaseDeferred(getReadingCase(data, appointment)) ).length + // Arbitration progress is how many cases have been settled, not what this + // user has read - an arbitrator may have read some of these cases before + const doneCount = isArbitration + ? sessionAppointments.filter((appointment) => + appointmentHasBeenArbitrated(data, appointment) + ).length + : progress.userReadCount + return { ...progress, + doneCount, // How many appointments are currently loaded vs the overall target populatedCount: sessionAppointments.length, targetSize: resolvedTargetSize, @@ -1712,7 +1745,7 @@ const getSessionReadingProgress = ( targetRemaining: Math.max( 0, effectiveTargetSize - - progress.userReadCount - + doneCount - progress.userAwaitingPriorsCount - deferredCount ) @@ -1761,6 +1794,7 @@ module.exports = { getResumeAppointmentForUser, // Booleans userHasReadAppointment, + appointmentHasBeenArbitrated, canUserReadAppointment, // Sessions diff --git a/app/routes/arbitration.js b/app/routes/arbitration.js index 8cc2166a..aeed2712 100644 --- a/app/routes/arbitration.js +++ b/app/routes/arbitration.js @@ -13,37 +13,6 @@ const { createReadingSession, getFirstReadableAppointmentInSession } = require('../lib/utils/reading') -const { getReadingCase, updateReadingCase } = require('../lib/utils/episodes') - -/** - * Record the release of each case in an arbitration session. - * - * Auto-finalisation by time never writes the release (there is no act to - * record) - pulling a case into an arbitration session is one, so the release - * gets recorded here if finalisation didn't already. This is what makes - * buildRead stamp the eventual read as an arbitration read. - * - * @param {object} data - Session data - * @param {object} session - The arbitration reading session - */ -const recordArbitrationReleases = (data, session) => { - const releasedAt = new Date().toISOString() - - for (const appointmentId of session.appointmentIds) { - const appointment = data.appointments.find( - (candidate) => candidate.id === appointmentId - ) - if (!appointment) continue - - const readingCase = getReadingCase(data, appointment) - if (!readingCase || readingCase.arbitration?.releasedAt) continue - - updateReadingCase(data, appointment.episodeId, { - ...readingCase, - arbitration: { releasedAt, releasedBy: data.currentUser.id } - }) - } -} /** * Create the arbitration session and send the user into its first case, @@ -53,7 +22,6 @@ const startArbitrationSession = (data, res, arbitration) => { const isPanel = arbitration.mode === 'panel' const sessionOptions = { type: 'arbitration', - lazy: false, filters: isPanel ? { skipUserFilter: true } : {} } @@ -65,7 +33,8 @@ const startArbitrationSession = (data, res, arbitration) => { const session = createReadingSession(data, sessionOptions) session.arbitration = arbitration - recordArbitrationReleases(data, session) + // Cases are released as the user reaches them (see the per-case middleware in + // routes/reading.js), so a lazy session doesn't claim the whole backlog const firstReadableAppointment = getFirstReadableAppointmentInSession( data, @@ -115,7 +84,7 @@ module.exports = (router) => { startArbitrationSession(data, res, { mode: 'alone', - panelUserIds: [data.currentUser.id] + arbitratorIds: [data.currentUser.id] }) }) @@ -135,7 +104,8 @@ module.exports = (router) => { router.post('/reading/arbitration/panel-answer', (req, res) => { const data = req.session.data - const panelUserIds = [] + // The picker chooses who else; the current user is an arbitrator too + const chosenUserIds = [] .concat(data.arbitrationTemp?.panelUserIds || []) .filter(Boolean) @@ -143,7 +113,7 @@ module.exports = (router) => { startArbitrationSession(data, res, { mode: 'panel', - panelUserIds: [data.currentUser.id, ...panelUserIds] + arbitratorIds: [data.currentUser.id, ...chosenUserIds] }) }) } diff --git a/app/routes/reading.js b/app/routes/reading.js index a7f246d1..62316305 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -39,8 +39,11 @@ const { getReadsAsArray, getReadForUser, getArbitrationRead, + getReadAuthorIds, + caseHasBeenArbitrated, isCaseDeferred, - withoutRead + withoutRead, + withArbitrationRelease } = require('../lib/utils/reading-cases') const { getParticipant, getShortName } = require('../lib/utils/participants') const { @@ -548,6 +551,12 @@ module.exports = (router) => { data.currentUser.id ) + // Arbitration settles a case for everyone, so its progress is how many of + // the session's cases have been arbitrated - not what any one user has done + const arbitratedCount = enhancedAppointments.filter((appointment) => + caseHasBeenArbitrated(appointment.readingCase) + ).length + const sessionProgress = getSessionReadingProgress( data, sessionId, @@ -610,6 +619,7 @@ module.exports = (router) => { sessionProgress, resumeAppointment, autoFinaliseAt, + arbitratedCount, unfinalisedReadCount: unconfirmedReads.length, clinic, backlogTotal, @@ -710,6 +720,21 @@ module.exports = (router) => { // walking back to the episode res.locals.isReadingWorkflow = true res.locals.isArbitration = session.type === 'arbitration' + + // Reaching a case in an arbitration session is the act that releases it. + // Lazy sessions bring cases in one at a time, so this is where release + // happens rather than over the whole backlog at session creation. + if (session.type === 'arbitration') { + const caseToRelease = getReadingCase(data, appointment) + if (caseToRelease && !caseToRelease.arbitration?.releasedAt) { + updateReadingCase( + data, + appointment.episodeId, + withArbitrationRelease(caseToRelease, currentUserId) + ) + } + } + res.locals.readingCase = getReadingCase(data, appointment) res.locals.session = session res.locals.appointmentData = { @@ -2069,9 +2094,9 @@ module.exports = (router) => { delete data.imageReadingTemp delete res.locals.data?.imageReadingTemp - // Create and save the reading + // Create and save the reading. Authorship is settled by buildRead, which + // knows whether this is an arbitration (many authors) or a read (one) const readResult = { - readerId: currentUserId, readerType: data.currentUser.role, ...formData, timestamp: new Date().toISOString() @@ -2616,6 +2641,7 @@ module.exports = (router) => { clinicId: appointment.clinicId, sessionId, readerId: reading.readerId, + arbitratorIds: reading.arbitratorIds, readType, opinion: reading.opinion, timestamp: reading.timestamp, @@ -2634,8 +2660,8 @@ module.exports = (router) => { // Determine which readings to display based on view let readings = [] if (view === 'mine') { - readings = recentReadings.filter( - (reading) => reading.readerId === currentUserId + readings = recentReadings.filter((reading) => + getReadAuthorIds(reading).includes(currentUserId) ) } else { readings = recentReadings diff --git a/app/views/_includes/summary-lists/read-summary.njk b/app/views/_includes/summary-lists/read-summary.njk index dbfc46be..e184889f 100644 --- a/app/views/_includes/summary-lists/read-summary.njk +++ b/app/views/_includes/summary-lists/read-summary.njk @@ -254,10 +254,10 @@ {# A panel arbitration was decided by several people together, so the read's own reader byline doesn't tell the whole story #} -{% if read.panelUserIds | length %} - {% set panelHtml %} - {%- for panelUserId in read.panelUserIds %} - {{ panelUserId | getUsername({ format: "short", identifyCurrentUser: true }) }}{{ "," if not loop.last }} +{% if read.arbitratorIds | length %} + {% set arbitratorsHtml %} + {%- for arbitratorId in read.arbitratorIds %} + {{ arbitratorId | getUsername({ format: "short", identifyCurrentUser: true }) }}{{ "," if not loop.last }} {%- endfor %} {% endset %} {% set rows = rows | push({ @@ -265,7 +265,7 @@ text: "Arbitrated by" }, value: { - html: panelHtml + html: arbitratorsHtml } }) %} {% endif %} diff --git a/app/views/reading/case.html b/app/views/reading/case.html index 71d95716..2ec07c9c 100644 --- a/app/views/reading/case.html +++ b/app/views/reading/case.html @@ -215,13 +215,9 @@

- {%- if thisRead.panelUserIds | length %} - {%- for panelUserId in thisRead.panelUserIds %} - {{ panelUserId | getUsername({ format: "short", identifyCurrentUser: true }) }}{{ "," if not loop.last }} - {%- endfor %} - {%- else %} - {{ thisRead.readerId | getUsername({ format: "short", identifyCurrentUser: true }) }} - {%- endif %}, + {%- for authorId in thisRead | getReadAuthorIds %} + {{ authorId | getUsername({ format: "short", identifyCurrentUser: true }) }}{{ "," if not loop.last }} + {%- endfor %}, {{ thisRead.timestamp | formatDate }}
@@ -292,7 +288,9 @@

{{ side | sentenceCase }} breast

{% set sideAnnotations = thisRead[side].annotations or [] %} {% if sideAnnotations | length %}

- {{ thisRead.readerId | getUsername({ format: "short", identifyCurrentUser: true }) }} + {%- for authorId in thisRead | getReadAuthorIds %} + {{ authorId | getUsername({ format: "short", identifyCurrentUser: true }) }}{{ "," if not loop.last }} + {%- endfor %} ({{ thisRead.readType | formatWords }} read) diff --git a/app/views/reading/cases.html b/app/views/reading/cases.html index 654192ff..3f6a7f8f 100644 --- a/app/views/reading/cases.html +++ b/app/views/reading/cases.html @@ -23,7 +23,7 @@ {%- endmacro -%} {% set scopeLabels = { - "open": "Open rounds", + "open": "Open cases", "recently-closed": "Recently closed", "all": "All, including history" } %} diff --git a/app/views/reading/history.html b/app/views/reading/history.html index 3a820340..1ca53555 100644 --- a/app/views/reading/history.html +++ b/app/views/reading/history.html @@ -147,10 +147,12 @@

{{ pageHeading }}

{% if view == 'all' %} - {{ reading.readerId | getUsername({ - format: 'short', - identifyCurrentUser: true - }) }} + {%- for authorId in reading | getReadAuthorIds %} + {{ authorId | getUsername({ + format: 'short', + identifyCurrentUser: true + }) }}{{ "," if not loop.last }} + {%- endfor %} {% endif %} diff --git a/app/views/reading/session.html b/app/views/reading/session.html index cb220225..06388268 100644 --- a/app/views/reading/session.html +++ b/app/views/reading/session.html @@ -47,20 +47,19 @@

- {% if session.type == 'arbitration' and session.arbitration.panelUserIds | length %} + {% if session.type == 'arbitration' and session.arbitration.arbitratorIds | length %}

Arbitrated by - {%- for userId in session.arbitration.panelUserIds %} + {%- for userId in session.arbitration.arbitratorIds %} {{ userId | getUsername({ format: "short", identifyCurrentUser: true }) }}{{ "," if not loop.last }} {%- endfor %}

{% endif %} - {% set currentUserHasRead = readingStatus.userReadCount > 0 %} {% if session.type == 'arbitration' %} - {% set readingActionText = "Resume arbitrating" if currentUserHasRead else "Start arbitrating" %} + {% set readingActionText = "Resume arbitrating" if arbitratedCount else "Start arbitrating" %} {% else %} - {% set readingActionText = "Resume reading" if currentUserHasRead else "Start reading" %} + {% set readingActionText = "Resume reading" if readingStatus.userReadCount else "Start reading" %} {% endif %} {% if resumeAppointment %} @@ -69,10 +68,12 @@

{% else %} {% set sessionCompletePanelHtml %} + {% set panelDoneCount = arbitratedCount if session.type == 'arbitration' else readingStatus.userReadCount %} + {% set panelNoun = "outcome" if session.type == 'arbitration' else "opinion" %} {% if unfinalisedReadCount > 0 %} -

An opinion has been provided for {{ readingStatus.userReadCount }} {{ "case" | pluralise(readingStatus.userReadCount) }}.{% if autoFinaliseAt %} The first opinion will be finalised automatically at {{ autoFinaliseAt | formatTime("h:mma") }}.{% endif %} Finalise opinions now

+

{{ panelNoun | sentenceCase }} recorded for {{ panelDoneCount }} {{ "case" | pluralise(panelDoneCount) }}.{% if autoFinaliseAt %} The first {{ panelNoun }} will be finalised automatically at {{ autoFinaliseAt | formatTime("h:mma") }}.{% endif %} Finalise {{ panelNoun }}s now

{% else %} -

An opinion has been provided for {{ readingStatus.userReadCount }} {{ "case" | pluralise(readingStatus.userReadCount) }}. All opinions are finalised.

+

{{ panelNoun | sentenceCase }} recorded for {{ panelDoneCount }} {{ "case" | pluralise(panelDoneCount) }}. All {{ panelNoun }}s are finalised.

{% endif %}
{% if backlogTotal > 0 %} @@ -115,6 +116,10 @@

items: secondaryNavItems }) }} + {# Arbitration settles a case once for everyone, so "done" is a fact about + the case rather than about what this user has read #} + {% set isArbitration = session.type == 'arbitration' %} + {# Number of slots not yet populated in a lazy session #} {% set sessionTotalCount = sessionProgress.effectiveTargetSize if sessionProgress else session.targetSize %} {% set pendingCount = (sessionTotalCount - (session.appointmentIds | length)) if sessionTotalCount else 0 %} @@ -124,7 +129,8 @@

{# YOUR READS VIEW - Shows cases from user's perspective #} {% set userReadableAppointments = [] %} {% for appointment in appointments %} - {% if (data | canUserReadAppointment(appointment)) or (data | userHasReadAppointment(appointment)) or (appointment | userRequestedPriors(data.currentUser.id)) or (appointment.readingCase | isCaseDeferred) %} + {% set isDone = (data | appointmentHasBeenArbitrated(appointment)) if isArbitration else (data | userHasReadAppointment(appointment)) %} + {% if (data | canUserReadAppointment(appointment)) or isDone or (appointment | userRequestedPriors(data.currentUser.id)) or (appointment.readingCase | isCaseDeferred) %} {% set userReadableAppointments = userReadableAppointments | push(appointment) %} {% endif %} {% endfor %} @@ -141,8 +147,9 @@

{% set deferredAppointments = [] %} {% for appointment in userReadableAppointments %} - {% if data | userHasReadAppointment(appointment) %} - {% set read = appointment.readingCase | getReadForUser(data.currentUser.id) %} + {% set isDone = (data | appointmentHasBeenArbitrated(appointment)) if isArbitration else (data | userHasReadAppointment(appointment)) %} +{% if isDone %} + {% set read = (appointment.readingCase | getArbitrationRead) if isArbitration else (appointment.readingCase | getReadForUser(data.currentUser.id)) %} {% if read.opinion == 'normal' %} {% set normalAppointments = normalAppointments | push(appointment) %} {% elseif read.opinion == 'technical_recall' %} @@ -236,11 +243,12 @@

Opinion summary

{% endif %} -

Reading session cases

- {% set userSessionRemainingCount = sessionTotalCount - readingStatus.userReadCount - readingStatus.userAwaitingPriorsCount - deferredCount %} +

{{ "Arbitration cases" if isArbitration else "Reading session cases" }}

+ {% set doneCount = arbitratedCount if isArbitration else readingStatus.userReadCount %} + {% set userSessionRemainingCount = sessionTotalCount - doneCount - readingStatus.userAwaitingPriorsCount - deferredCount %} {% set userSessionRemainingCount = 0 if userSessionRemainingCount < 0 else userSessionRemainingCount %} {% if userSessionRemainingCount > 0 or readingStatus.userAwaitingPriorsCount > 0 or deferredCount > 0 %} -

Progress: {{ readingStatus.userReadCount }} read{%- if readingStatus.userAwaitingPriorsCount > 0 -%}, {{ readingStatus.userAwaitingPriorsCount }} awaiting priors{%- endif -%}{%- if deferredCount > 0 -%}, {{ deferredCount }} deferred{%- endif -%}, {{ userSessionRemainingCount }} remaining

+

Progress: {{ doneCount }} {{ "arbitrated" if isArbitration else "read" }}{%- if readingStatus.userAwaitingPriorsCount > 0 -%}, {{ readingStatus.userAwaitingPriorsCount }} awaiting priors{%- endif -%}{%- if deferredCount > 0 -%}, {{ deferredCount }} deferred{%- endif -%}, {{ userSessionRemainingCount }} remaining

{% endif %} @@ -256,7 +264,7 @@

Reading session cases

{% for appointment in userReadableAppointments %} {% set metadata = appointment.readingCase | getReadingMetadata(data.settings) %} - + @@ -276,7 +284,8 @@

Reading session cases

· Participant record {% else %} {% set readCount = metadata.readCount %} - {% if data | userHasReadAppointment(appointment) %} + {% set isDone = (data | appointmentHasBeenArbitrated(appointment)) if isArbitration else (data | userHasReadAppointment(appointment)) %} +{% if isDone %} {{ "1st read" if readCount == 1 else "2nd read" }} {% else %} {{ "1st read" if readCount == 0 else "2nd read" }} @@ -312,8 +321,9 @@

Reading session cases

From c35026940e2cf48ff2a0a52a1db3ff1a1c5ba261 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Thu, 6 Aug 2026 13:54:50 +0100 Subject: [PATCH 10/27] Returning to an arbitrated case shows what was recorded Arbitration sessions were excluded from the existing-read redirect, so going back to a settled case restarted the compare flow. The redirect now asks whether the case has been arbitrated rather than whether the user has any read on it. The page shows the arbitration read as the one being viewed and changed, with the two original reads below as read-only context - reading keeps its blind-reading gate and still doesn't show them. --- app/routes/reading.js | 17 +++--- app/views/reading/workflow/existing-read.html | 61 ++++++++++--------- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/app/routes/reading.js b/app/routes/reading.js index 62316305..b3dc2fac 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -27,6 +27,7 @@ const { skipAppointmentInSession, topUpSession, getAppointmentReadingMetadata, + appointmentHasBeenArbitrated, filterAppointmentsByEligibleForReading, filterAppointmentsByNeedsAnyRead, filterAppointmentsByUserCanRead @@ -772,15 +773,17 @@ module.exports = (router) => { return res.redirect(`/reading/session/${sessionId}`) } - // Check if user has already read this appointment - // In arbitration, the user may have been an original reader (panel mode) - // but still needs to reach the arbitration compare page + // Returning to a case that has already been done shows what was recorded, + // rather than starting the flow again. In arbitration that means the + // arbitration read - a panel member's own earlier read as first or second + // reader isn't the thing this session is here to do. const session = getReadingSession(data, sessionId) const isArbitrationSession = session?.type === 'arbitration' - if ( - !isArbitrationSession && - userHasReadAppointment(data, appointment, currentUserId) - ) { + const alreadyDone = isArbitrationSession + ? appointmentHasBeenArbitrated(data, appointment) + : userHasReadAppointment(data, appointment, currentUserId) + + if (alreadyDone) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/existing-read` ) diff --git a/app/views/reading/workflow/existing-read.html b/app/views/reading/workflow/existing-read.html index 7981badf..a6748ff4 100644 --- a/app/views/reading/workflow/existing-read.html +++ b/app/views/reading/workflow/existing-read.html @@ -3,13 +3,13 @@ {% extends 'layout-reading.html' %} -{% set pageHeading = "Your outcome" if isArbitration else "Your opinion" %} +{% set pageHeading = "Arbitration outcome" if isArbitration else "Your opinion" %} {% set showWorkflowNav = true %} {% block pageContent %} {% set isAwaitingPriors = appointment | awaitingPriors %} - {% set hasUserRead = readingCase | userHasReadCase(data.currentUser.id) %} + {% set hasUserRead = (readingCase | caseHasBeenArbitrated) if isArbitration else (readingCase | userHasReadCase(data.currentUser.id)) %} {% set isDeferredCase = readingCase | isCaseDeferred %} @@ -18,7 +18,7 @@ {% if isDeferredCase and not hasUserRead %} {# Appointment has been deferred - show deferral summary #} -

{{ "Your outcome" if isArbitration else "Your opinion" }}

+

{{ "Arbitration outcome" if isArbitration else "Your opinion" }}

{% set deferral = readingCase.deferral %} {% set canUndo = deferral.deferredBy == data.currentUser.id %} @@ -63,7 +63,7 @@

{{ "Your outcome" if isArbitration else "Your opinio {% endset %} {{ card({ - heading: "Arbitration outcome" if isArbitration else "Your read", + heading: "Outcome" if isArbitration else "Your read", headingLevel: "2", feature: true, descriptionHtml: deferralSummaryHtml @@ -71,7 +71,7 @@

{{ "Your outcome" if isArbitration else "Your opinio {% elseif isAwaitingPriors and not hasUserRead %} {# Appointment is awaiting priors - show as the reader's opinion #} -

{{ "Your outcome" if isArbitration else "Your opinion" }}

+

{{ "Arbitration outcome" if isArbitration else "Your opinion" }}

{% set canUndo = appointment | userRequestedPriors(data.currentUser.id) %} @@ -122,7 +122,7 @@

{{ "Your outcome" if isArbitration else "Your opinio {% endset %} {{ card({ - heading: "Arbitration outcome" if isArbitration else "Your read", + heading: "Outcome" if isArbitration else "Your read", headingLevel: "2", feature: true, descriptionHtml: priorsReadSummaryHtml @@ -130,7 +130,7 @@

{{ "Your outcome" if isArbitration else "Your opinio {% else %} {# Normal existing read view #} -

{{ "Your outcome" if isArbitration else "Your opinion" }}

+

{{ "Arbitration outcome" if isArbitration else "Your opinion" }}

{# Read summary card #} {% set withImages = data.settings.reading.annotationsMode != 'without-images' %} @@ -139,8 +139,10 @@

{{ "Your outcome" if isArbitration else "Your opinio {% set allPaths = mammogramImages.allPaths if mammogramImages else {} %} {% endif %} + {# The read this page is about: in arbitration the case's one arbitration + read, otherwise the current user's own #} {% set readSummaryHtml %} - {% set read = readingCase | getReadForUser(data.currentUser.id) %} + {% set read = (readingCase | getArbitrationRead) if isArbitration else (readingCase | getReadForUser(data.currentUser.id)) %} {% set allowEdits = true %} {% set changeOpinionUrl = "./opinion" %} {% set showAnnotationImages = withImages %} @@ -149,32 +151,33 @@

{{ "Your outcome" if isArbitration else "Your opinio {% endset %} {{ card({ - heading: "Arbitration outcome" if isArbitration else "Your read", + heading: "Outcome" if isArbitration else "Your read", headingLevel: "2", feature: true, descriptionHtml: readSummaryHtml }) }} - {# Other readers' assessments (for testing/debugging) #} - {% set otherReads = readingCase | getOtherReads(data.currentUser.id) %} - {% if otherReads | length > 0 %} - {% for otherRead in otherReads %} - {% set readerName = otherRead.readerId | getUsername %} - {% set otherReadSummaryHtml %} - {% set read = otherRead %} - {% set allowEdits = false %} - {% set showAnnotationImages = withImages %} - {% set annotationImagePaths = allPaths %} - {% include "_includes/summary-lists/read-summary.njk" %} - {% endset %} - - {# Commented out until we decide whether to show other reads #} - {# {{ card({ - heading: (otherRead.readNumber | getOrdinalName | sentenceCase) + " read", - headingLevel: "2", - feature: true, - descriptionHtml: otherReadSummaryHtml - }) }} #} + {# The reads that were arbitrated, for reference. They can't be changed now - + the arbitration outcome above is what settles the case. Reading keeps its + blind-reading gate, so it doesn't show the other read here. #} + {% if isArbitration %} + {% for originalRead in readingCase | getReadsAsArray %} + {% if originalRead.readType != 'arbitration' %} + {% set originalReadSummaryHtml %} + {% set read = originalRead %} + {% set allowEdits = false %} + {% set hideEmptyRows = true %} + {% set showAnnotationImages = withImages %} + {% set annotationImagePaths = allPaths %} + {% include "_includes/summary-lists/read-summary.njk" %} + {% endset %} + + {{ card({ + heading: (originalRead.readType | formatWords | sentenceCase) + " read by " + (originalRead.readerId | getUsername({ format: "short", identifyCurrentUser: true })), + headingLevel: "2", + descriptionHtml: originalReadSummaryHtml + }) }} + {% endif %} {% endfor %} {% endif %} {% endif %} From 112eeefc090d9efb778bf504a62e5b368df58e02 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Thu, 6 Aug 2026 14:44:36 +0100 Subject: [PATCH 11/27] Fix returning to an arbitrated case: navigation, prefill and labels Prev/next navigation dropped a case from the list once it was arbitrated, so the links pointed at the current case and there was no way back to it. The opinion page prefilled from the current user's own read, so amending an arbitration outcome started from blank, or from a panel member's original read. An arbitration read's first summary row now reads Outcome rather than Opinion - it settles the case rather than being one of several opinions - and the existing-read page no longer lists the reads it arbitrated. --- app/lib/utils/reading.js | 7 +++--- app/routes/reading.js | 13 +++++++--- .../_includes/summary-lists/read-summary.njk | 8 +++--- app/views/reading/workflow/existing-read.html | 25 ++----------------- 4 files changed, 19 insertions(+), 34 deletions(-) diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index e8a7abf4..e1a2e1f8 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -940,10 +940,9 @@ const filterAppointmentsByUserCanReadOrHasRead = ( // Include if the case isn't fully read yet, so they could still read it if (getReadsAsArray(readingCase).length < maxReadsPerCase) return true - // A case released to arbitration still takes its arbitration read - if (isCaseInArbitration(readingCase) && !getArbitrationRead(readingCase)) { - return true - } + // A case released to arbitration still takes its arbitration read, and + // stays navigable once arbitrated so it can be looked at again + if (isCaseInArbitration(readingCase)) return true // Exclude cases that are fully read by other users return false diff --git a/app/routes/reading.js b/app/routes/reading.js index b3dc2fac..61b43fa5 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -679,10 +679,15 @@ module.exports = (router) => { !data.imageReadingTemp || data.imageReadingTemp.appointmentId !== appointmentId ) { - const existingRead = getReadForUser( - getReadingCase(data, appointment), - currentUserId - ) + // In arbitration the read being amended is the case's arbitration + // read, not the current user's own - a panel member may also have + // read this case as first or second reader + const readingCaseForTemp = getReadingCase(data, appointment) + const existingRead = + session.type === 'arbitration' + ? getArbitrationRead(readingCaseForTemp) + : getReadForUser(readingCaseForTemp, currentUserId) + if (existingRead) { // User has already read this appointment - populate temp from saved read console.log( diff --git a/app/views/_includes/summary-lists/read-summary.njk b/app/views/_includes/summary-lists/read-summary.njk index e184889f..ade06d17 100644 --- a/app/views/_includes/summary-lists/read-summary.njk +++ b/app/views/_includes/summary-lists/read-summary.njk @@ -74,11 +74,13 @@ {# Build rows based on result type #} {% set rows = [] %} -{# Opinion row #} +{# Opinion row. An arbitration read settles the case, so it is the outcome + rather than one of several opinions #} +{% set opinionLabel = "Outcome" if read.readType == 'arbitration' else "Opinion" %} {% if not hideOpinionRow %} {% set rows = rows | push({ key: { - text: "Opinion" + text: opinionLabel }, value: { html: read.opinion | toTag @@ -88,7 +90,7 @@ { href: changeOpinionUrl, text: "Change", - visuallyHiddenText: "opinion" + visuallyHiddenText: opinionLabel | lower } ] } if allowEdits and changeOpinionUrl diff --git a/app/views/reading/workflow/existing-read.html b/app/views/reading/workflow/existing-read.html index a6748ff4..cf58e408 100644 --- a/app/views/reading/workflow/existing-read.html +++ b/app/views/reading/workflow/existing-read.html @@ -157,29 +157,8 @@

{{ "Arbitration outcome" if isArbitration else "Your descriptionHtml: readSummaryHtml }) }} - {# The reads that were arbitrated, for reference. They can't be changed now - - the arbitration outcome above is what settles the case. Reading keeps its - blind-reading gate, so it doesn't show the other read here. #} - {% if isArbitration %} - {% for originalRead in readingCase | getReadsAsArray %} - {% if originalRead.readType != 'arbitration' %} - {% set originalReadSummaryHtml %} - {% set read = originalRead %} - {% set allowEdits = false %} - {% set hideEmptyRows = true %} - {% set showAnnotationImages = withImages %} - {% set annotationImagePaths = allPaths %} - {% include "_includes/summary-lists/read-summary.njk" %} - {% endset %} - - {{ card({ - heading: (originalRead.readType | formatWords | sentenceCase) + " read by " + (originalRead.readerId | getUsername({ format: "short", identifyCurrentUser: true })), - headingLevel: "2", - descriptionHtml: originalReadSummaryHtml - }) }} - {% endif %} - {% endfor %} - {% endif %} + {# The arbitration outcome above is what settles the case, so the reads it + arbitrated aren't shown here. #} {% endif %} {# Medical summary #} From 25447cd905e8dc0fc9db1e65cf6afb002e1003c0 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Thu, 6 Aug 2026 14:59:07 +0100 Subject: [PATCH 12/27] Fix arbitration sessions running out of cases after a few decisions A case released into arbitration keeps its original reads unfinalised, so it derived awaiting_finalisation and caseNeedsArbitration returned false. The session could then find no candidates to top up with, and the overview showed 'session complete' while still reporting cases remaining. A released case now counts as needing arbitration until it has an arbitration read. Saving an arbitration also treated every panel member who had read the case originally as editing an existing read, so it returned to the existing-read page instead of moving on. That check now looks for the arbitration read. The next-case search wraps, so a case passed over earlier isn't stranded, and the end-of-session page is worded for arbitration. --- app/lib/utils/reading-cases.js | 7 +++++++ app/routes/reading.js | 25 +++++++++++++++++-------- app/views/reading/no-more-cases.html | 17 +++++++++++++---- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/app/lib/utils/reading-cases.js b/app/lib/utils/reading-cases.js index 2c9b0b04..5b79d8a2 100644 --- a/app/lib/utils/reading-cases.js +++ b/app/lib/utils/reading-cases.js @@ -576,6 +576,13 @@ const caseNeedsSecondRead = (readingCase) => { * @returns {boolean} */ const caseNeedsArbitration = (readingCase, settings = {}) => { + // A case already released into arbitration belongs to the backlog until it + // has been arbitrated, whatever its derived state says. Releasing it doesn't + // finalise the original reads, so the state stays awaiting_finalisation. + if (isCaseInArbitration(readingCase)) { + return !getArbitrationRead(readingCase) + } + return getReadingCaseState(readingCase, settings) === 'awaiting_arbitration' } diff --git a/app/routes/reading.js b/app/routes/reading.js index 61b43fa5..32b69167 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -470,6 +470,7 @@ module.exports = (router) => { } res.render('reading/no-more-cases', { sessionId, + session, unfinalisedReadCount: getUnfinalisedUserReadsForSession( data, sessionId, @@ -2093,11 +2094,15 @@ module.exports = (router) => { // journey reaching here with a read in place started from that page — // and should return to it rather than moving on to the next case. // Must be checked before the read is written. - const isEditingExistingRead = userHasReadAppointment( - data, - appointment, - currentUserId - ) + // + // In arbitration this means the arbitration read already exists. A panel + // member's own earlier read as first or second reader isn't an edit - + // they still have the arbitration to do. + const sessionForSave = getReadingSession(data, sessionId) + const isEditingExistingRead = + sessionForSave?.type === 'arbitration' + ? Boolean(getArbitrationRead(getReadingCase(data, appointment))) + : userHasReadAppointment(data, appointment, currentUserId) delete data.imageReadingTemp delete res.locals.data?.imageReadingTemp @@ -2128,10 +2133,14 @@ module.exports = (router) => { const currentIndex = sessionAppointments.findIndex( (e) => e.id === appointmentId ) + const notYetArbitrated = (appointment) => + !getArbitrationRead(getReadingCase(data, appointment)) + const nextUnreadAppointment = isArbitrationSave - ? sessionAppointments - .slice(currentIndex + 1) - .find((appt) => !getArbitrationRead(getReadingCase(data, appt))) + ? // Look forward first, then wrap - a case skipped earlier in the + // session is still waiting to be arbitrated + sessionAppointments.slice(currentIndex + 1).find(notYetArbitrated) || + sessionAppointments.slice(0, currentIndex).find(notYetArbitrated) : getNextUserReadableAppointment( data, sessionAppointments, diff --git a/app/views/reading/no-more-cases.html b/app/views/reading/no-more-cases.html index 0b66bb79..5c367b65 100644 --- a/app/views/reading/no-more-cases.html +++ b/app/views/reading/no-more-cases.html @@ -13,19 +13,28 @@
- Image reading + {% set isArbitration = session.type == 'arbitration' %} + + {{ "Arbitration" if isArbitration else "Image reading" }}

{{ pageHeading }}

-

You have given an opinion on all available cases.

+

+ {%- if isArbitration -%} + Every available case has been arbitrated. + {%- else -%} + You have given an opinion on all available cases. + {%- endif -%} +

{% if unfinalisedReadCount > 0 %} {# Finalisation itself lives on the session overview, behind a chance to review what was read - this page only points there #} + {% set unfinalisedNoun = "outcome" if isArbitration else "read" %}

{% if unfinalisedReadCount == 1 %} - Your read from this session is not yet finalised. + {{ "The" if isArbitration else "Your" }} {{ unfinalisedNoun }} from this session is not yet finalised. {% else %} - Your {{ unfinalisedReadCount }} reads from this session are not yet finalised. + {{ "The" if isArbitration else "Your" }} {{ unfinalisedReadCount }} {{ unfinalisedNoun }}s from this session are not yet finalised. {% endif %} Review and finalise them from the session overview.

From c8b2514b502610156b830d1f4f11aa619cceb2a9 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Thu, 6 Aug 2026 15:05:34 +0100 Subject: [PATCH 13/27] Fix arbitration status bar count and skipping to the next case The status bar counted the current user's own reads, so it sat at '0 read' however many cases the session had arbitrated. It now uses the session progress doneCount, which counts arbitrated cases in an arbitration session. Skipping used the reading predicate to find the next case, which rejects cases a panel member originally read. With nothing found it fell through to the end-of-session skipped-review page instead of moving on. The skip link was also hidden on cases a panel member had read, offering plain next navigation on a case they still had to arbitrate. --- app/routes/reading.js | 27 ++++++++++++++----- .../_includes/reading/reading-status-bar.njk | 5 ++-- .../_includes/reading/workflow-navigation.njk | 6 ++++- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/app/routes/reading.js b/app/routes/reading.js index 32b69167..fe537b82 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -849,14 +849,29 @@ module.exports = (router) => { const sessionAppointments = session.appointmentIds .map((id) => data.appointments.find((e) => e.id === id)) .filter(Boolean) - const nextUnreadAppointment = getNextUserReadableAppointment( - data, - sessionAppointments, - appointmentId, - currentUserId, - { wrap: false } + + // Arbitration looks for the next case without an arbitration read, since + // the user-can-read check rejects cases a panel member originally read + const isArbitrationSkip = session.type === 'arbitration' + const notYetArbitrated = (candidate) => + candidate.id !== appointmentId && + !getArbitrationRead(getReadingCase(data, candidate)) + + const currentIndex = sessionAppointments.findIndex( + (candidate) => candidate.id === appointmentId ) + const nextUnreadAppointment = isArbitrationSkip + ? sessionAppointments.slice(currentIndex + 1).find(notYetArbitrated) || + sessionAppointments.slice(0, currentIndex).find(notYetArbitrated) + : getNextUserReadableAppointment( + data, + sessionAppointments, + appointmentId, + currentUserId, + { wrap: false } + ) + if (nextUnreadAppointment) { res.redirect( `/reading/session/${sessionId}/appointments/${nextUnreadAppointment.id}` diff --git a/app/views/_includes/reading/reading-status-bar.njk b/app/views/_includes/reading/reading-status-bar.njk index 07058701..2ea517d1 100644 --- a/app/views/_includes/reading/reading-status-bar.njk +++ b/app/views/_includes/reading/reading-status-bar.njk @@ -32,9 +32,10 @@ }) %} {% endif %} -{# Progress text #} +{# Progress text. doneCount counts arbitrated cases in an arbitration session + and the user's own reads otherwise #} {% set progressText %} - {{ progress.userReadCount }} read + {{ progress.doneCount }} {{ "arbitrated" if isArbitration else "read" }} {%- if progress.userAwaitingPriorsCount > 0 -%} , {{ progress.userAwaitingPriorsCount }} awaiting priors {%- endif -%} diff --git a/app/views/_includes/reading/workflow-navigation.njk b/app/views/_includes/reading/workflow-navigation.njk index 997305d1..6e769055 100644 --- a/app/views/_includes/reading/workflow-navigation.njk +++ b/app/views/_includes/reading/workflow-navigation.njk @@ -18,7 +18,11 @@ {% if progress.hasNextUserReadable %}
- {% if data | canUserReadAppointment(appointment) %} + {# Still to do on this case, so skipping past it is meaningful. In + arbitration that's whether it has been arbitrated - a panel member + may have read it originally and still owe the arbitration #} + {% set stillToDo = (not (data | appointmentHasBeenArbitrated(appointment))) if isArbitration else (data | canUserReadAppointment(appointment)) %} + {% if stillToDo %} {{ appForwardLink({ href: "/reading/session/" ~ sessionId ~ "/appointments/" ~ appointmentId ~ "/skip", text: "Skip to next" From 6ec7fd92b6717aed07b7d587c39e372cbad3d3c4 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Thu, 6 Aug 2026 16:20:59 +0100 Subject: [PATCH 14/27] Route session navigation through session-aware helpers Deferring or requesting priors in an arbitration session used the reading predicate to find the next case. That rejects cases a panel member read originally, so with nothing found both fell through to the end-of-session skipped-review page instead of moving on. The same question was being answered in six places, two of which had already been fixed for arbitration by hand. getNextCaseInSession and getFirstOutstandingCaseInSession now answer it once, branching on the session type, so a new caller can't get the arbitration case wrong. --- app/lib/utils/reading.js | 78 +++++++++++++++++++++++++++++ app/routes/reading.js | 103 +++++++++++++++------------------------ 2 files changed, 118 insertions(+), 63 deletions(-) diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index e1a2e1f8..24ad983c 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -1111,6 +1111,82 @@ const getNextUserReadableAppointment = function ( ) } +/** + * The next case to work on in a session, after the current one. + * + * What "still to do" means depends on the session: reading asks whether this + * user can read the case, arbitration whether the case has been arbitrated - + * the reading question rejects cases a panel member originally read, which + * would strand an arbitration session with nothing left to do. + * + * Arbitration wraps to the start, so a case passed over earlier isn't + * abandoned. Reading keeps its own no-wrap behaviour. + * + * @param {object} data - Session data + * @param {object} session - The reading session + * @param {Array} sessionAppointments - The session's appointments, in order + * @param {string} currentAppointmentId - The case just finished with + * @param {string} userId - User ID + * @returns {object | undefined} The next appointment, or undefined if none + */ +const getNextCaseInSession = ( + data, + session, + sessionAppointments, + currentAppointmentId, + userId +) => { + if (session?.type !== 'arbitration') { + return getNextUserReadableAppointment( + data, + sessionAppointments, + currentAppointmentId, + userId, + { wrap: false } + ) + } + + const stillToArbitrate = (appointment) => + appointment.id !== currentAppointmentId && + !getArbitrationRead(getReadingCase(data, appointment)) + + const currentIndex = sessionAppointments.findIndex( + (appointment) => appointment.id === currentAppointmentId + ) + + return ( + sessionAppointments.slice(currentIndex + 1).find(stillToArbitrate) || + sessionAppointments.slice(0, currentIndex).find(stillToArbitrate) + ) +} + +/** + * The first case still to work on in a session, wherever it sits. + * + * The session-aware counterpart to getFirstUserReadableAppointment, used to + * decide whether a session has anything left to do at all. + * + * @param {object} data - Session data + * @param {object} session - The reading session + * @param {Array} sessionAppointments - The session's appointments, in order + * @param {string} userId - User ID + * @returns {object | undefined} The first outstanding appointment, if any + */ +const getFirstOutstandingCaseInSession = ( + data, + session, + sessionAppointments, + userId +) => { + if (session?.type !== 'arbitration') { + return getFirstUserReadableAppointment(data, sessionAppointments, userId) + } + + return sessionAppointments.find( + (appointment) => !getArbitrationRead(getReadingCase(data, appointment)) + ) +} + /** * Get the appointment the user should resume reading from. * @@ -1790,6 +1866,8 @@ module.exports = { // User functions getFirstUserReadableAppointment, getNextUserReadableAppointment, + getNextCaseInSession, + getFirstOutstandingCaseInSession, getResumeAppointmentForUser, // Booleans userHasReadAppointment, diff --git a/app/routes/reading.js b/app/routes/reading.js index fe537b82..1af01518 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -28,6 +28,8 @@ const { topUpSession, getAppointmentReadingMetadata, appointmentHasBeenArbitrated, + getNextCaseInSession, + getFirstOutstandingCaseInSession, filterAppointmentsByEligibleForReading, filterAppointmentsByNeedsAnyRead, filterAppointmentsByUserCanRead @@ -401,8 +403,9 @@ module.exports = (router) => { ) .filter(Boolean) - const hasReadableCase = getFirstUserReadableAppointment( + const hasReadableCase = getFirstOutstandingCaseInSession( data, + session, loadedAppointments, data.currentUser.id ) @@ -432,11 +435,12 @@ module.exports = (router) => { } // Check if there are any readable cases left in the session - const firstReadable = getFirstUserReadableAppointment( + const firstReadable = getFirstOutstandingCaseInSession( data, + session, sessionAppointments, data.currentUser.id - ) + ) if (firstReadable) { res.redirect(`/reading/session/${sessionId}`) } else { @@ -850,28 +854,14 @@ module.exports = (router) => { .map((id) => data.appointments.find((e) => e.id === id)) .filter(Boolean) - // Arbitration looks for the next case without an arbitration read, since - // the user-can-read check rejects cases a panel member originally read - const isArbitrationSkip = session.type === 'arbitration' - const notYetArbitrated = (candidate) => - candidate.id !== appointmentId && - !getArbitrationRead(getReadingCase(data, candidate)) - - const currentIndex = sessionAppointments.findIndex( - (candidate) => candidate.id === appointmentId + const nextUnreadAppointment = getNextCaseInSession( + data, + session, + sessionAppointments, + appointmentId, + currentUserId ) - const nextUnreadAppointment = isArbitrationSkip - ? sessionAppointments.slice(currentIndex + 1).find(notYetArbitrated) || - sessionAppointments.slice(0, currentIndex).find(notYetArbitrated) - : getNextUserReadableAppointment( - data, - sessionAppointments, - appointmentId, - currentUserId, - { wrap: false } - ) - if (nextUnreadAppointment) { res.redirect( `/reading/session/${sessionId}/appointments/${nextUnreadAppointment.id}` @@ -880,11 +870,12 @@ module.exports = (router) => { res.redirect(`/reading/session/${sessionId}/skipped-review`) } else { // Check if there are any readable cases left in the session - const firstReadable = getFirstUserReadableAppointment( + const firstReadable = getFirstOutstandingCaseInSession( data, + session, sessionAppointments, currentUserId - ) + ) if (firstReadable) { res.redirect(`/reading/session/${sessionId}`) } else { @@ -967,12 +958,12 @@ module.exports = (router) => { const sessionAppointments = session.appointmentIds .map((id) => data.appointments.find((e) => e.id === id)) .filter(Boolean) - const nextUnreadAppointment = getNextUserReadableAppointment( + const nextUnreadAppointment = getNextCaseInSession( data, + session, sessionAppointments, appointmentId, - currentUserId, - { wrap: false } + currentUserId ) // Only store the banner if there is a next case to show it on @@ -997,11 +988,12 @@ module.exports = (router) => { ) } else { // Check if there are any readable cases left in the session - const firstReadable = getFirstUserReadableAppointment( + const firstReadable = getFirstOutstandingCaseInSession( data, + session, sessionAppointments, currentUserId - ) + ) if (firstReadable) { res.redirect(modalBreakout(`/reading/session/${sessionId}`)) } else { @@ -1106,12 +1098,12 @@ module.exports = (router) => { const sessionAppointments = session.appointmentIds .map((id) => data.appointments.find((e) => e.id === id)) .filter(Boolean) - const nextUnreadAppointment = getNextUserReadableAppointment( + const nextUnreadAppointment = getNextCaseInSession( data, + session, sessionAppointments, appointmentId, - currentUserId, - { wrap: false } + currentUserId ) // Show a banner on the next case if there is one @@ -1136,11 +1128,12 @@ module.exports = (router) => { ) } else { // Check if there are any readable cases left in the session - const firstReadable = getFirstUserReadableAppointment( + const firstReadable = getFirstOutstandingCaseInSession( data, + session, sessionAppointments, currentUserId - ) + ) if (firstReadable) { res.redirect(modalBreakout(`/reading/session/${sessionId}`)) } else { @@ -2143,26 +2136,13 @@ module.exports = (router) => { .filter(Boolean) const isArbitrationSave = session?.type === 'arbitration' - // Arbitration sessions find the next case without an arbitration read, - // bypassing the user-can-read check (panel members may be original readers) - const currentIndex = sessionAppointments.findIndex( - (e) => e.id === appointmentId + const nextUnreadAppointment = getNextCaseInSession( + data, + session, + sessionAppointments, + appointmentId, + currentUserId ) - const notYetArbitrated = (appointment) => - !getArbitrationRead(getReadingCase(data, appointment)) - - const nextUnreadAppointment = isArbitrationSave - ? // Look forward first, then wrap - a case skipped earlier in the - // session is still waiting to be arbitrated - sessionAppointments.slice(currentIndex + 1).find(notYetArbitrated) || - sessionAppointments.slice(0, currentIndex).find(notYetArbitrated) - : getNextUserReadableAppointment( - data, - sessionAppointments, - appointmentId, - currentUserId, - { wrap: false } - ) // Store banner message for the next case, but only if there is one. // Edits stay on the current case, so there's nowhere to show it. @@ -2225,15 +2205,12 @@ module.exports = (router) => { ) } else { // Check if there are any readable cases left in the session - const firstReadable = isArbitrationSave - ? sessionAppointments.find( - (appt) => !getArbitrationRead(getReadingCase(data, appt)) - ) - : getFirstUserReadableAppointment( - data, - sessionAppointments, - currentUserId - ) + const firstReadable = getFirstOutstandingCaseInSession( + data, + session, + sessionAppointments, + currentUserId + ) if (firstReadable) { res.redirect(modalBreakout(`/reading/session/${sessionId}`)) } else { From 1648ca3e6f6561857f794d5b4b6ba1aac5bb2a34 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Thu, 6 Aug 2026 16:33:57 +0100 Subject: [PATCH 15/27] Keep the skipped list honest, and stop skipping past the end of a session Deferring or requesting priors settles a case, but only writeReading took one off the session's skipped list. A case skipped and then deferred stayed on the list, so the session claimed a skipped case remained when there was nothing left to do on it. Affected regular reading as much as arbitration. The arbitration next-case search also wrapped to the start, which meant it kept finding skipped cases and never reached the end of the session - so the skipped-review interstitial never appeared. It now looks forward only and passes over skipped cases, matching reading. --- app/lib/utils/reading.js | 45 +++++++++++++++++++-------- app/routes/reading.js | 9 ++++++ app/views/reading/skipped-review.html | 15 +++++++-- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index 24ad983c..94012e5f 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -100,19 +100,33 @@ const writeReading = (data, appointment, userId, reading, sessionId = null) => { // computed outcome is not a finalised result - the episode moves on when // the reads are finalised (see finaliseUserReadsForSession below). - // If we have session context, remove this appointment from skipped appointments - // (readingSessions is per-session working data, so in-place edits are fine) - if (session) { - // Remove appointment from skipped list if present - const skippedIndex = session.skippedAppointments.indexOf(appointment.id) - if (skippedIndex !== -1) { - session.skippedAppointments.splice(skippedIndex, 1) - } - } + unskipAppointmentInSession(data, sessionId, appointment.id) return updatedCase } +/** + * Take an appointment off a session's skipped list. + * + * Skipping says "not now" - so anything that settles the case, whether a read, + * a deferral or a request for priors, takes it off the list. Otherwise the + * session keeps sending the user back to a case there is nothing left to do on. + * + * @param {object} data - Session data + * @param {string | null} sessionId - Reading session ID + * @param {string} appointmentId - The appointment to unskip + */ +const unskipAppointmentInSession = (data, sessionId, appointmentId) => { + // readingSessions is per-session working data, so in-place edits are fine + const session = sessionId ? data.readingSessions?.[sessionId] : null + if (!session?.skippedAppointments) return + + const skippedIndex = session.skippedAppointments.indexOf(appointmentId) + if (skippedIndex !== -1) { + session.skippedAppointments.splice(skippedIndex, 1) + } +} + /** * The user's not-yet-finalised reads in a session, each with the appointment * and case it belongs to. The session-complete panel's count and the finalise @@ -1146,18 +1160,22 @@ const getNextCaseInSession = ( ) } + // Skipped cases are deliberately passed over, so they aren't offered as the + // next case - reaching the end of the session with some still skipped is what + // sends the user to the skipped-review page + const skipped = new Set(session.skippedAppointments || []) + const stillToArbitrate = (appointment) => appointment.id !== currentAppointmentId && + !skipped.has(appointment.id) && !getArbitrationRead(getReadingCase(data, appointment)) const currentIndex = sessionAppointments.findIndex( (appointment) => appointment.id === currentAppointmentId ) - return ( - sessionAppointments.slice(currentIndex + 1).find(stillToArbitrate) || - sessionAppointments.slice(0, currentIndex).find(stillToArbitrate) - ) + // Forward only: running out is what ends the session, same as reading + return sessionAppointments.slice(currentIndex + 1).find(stillToArbitrate) } /** @@ -1883,6 +1901,7 @@ module.exports = { getOrCreateClinicSession, getFirstReadableAppointmentInSession, skipAppointmentInSession, + unskipAppointmentInSession, topUpSession, getSessionReadingProgress } diff --git a/app/routes/reading.js b/app/routes/reading.js index 1af01518..c935f7d4 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -25,6 +25,7 @@ const { getOrCreateClinicSession, getSessionReadingProgress, skipAppointmentInSession, + unskipAppointmentInSession, topUpSession, getAppointmentReadingMetadata, appointmentHasBeenArbitrated, @@ -949,6 +950,10 @@ module.exports = (router) => { return } + // Requesting priors settles what happens to this case for now, so it is + // no longer waiting to be come back to + unskipAppointmentInSession(data, sessionId, appointmentId) + // Top up the batch with the next eligible appointment if under target size topUpSession(data, sessionId) @@ -1090,6 +1095,10 @@ module.exports = (router) => { return } + // A deferred case is settled for now, so it is no longer waiting to be + // come back to + unskipAppointmentInSession(data, sessionId, appointmentId) + // Top up the session with the next eligible appointment if under target size topUpSession(data, sessionId) diff --git a/app/views/reading/skipped-review.html b/app/views/reading/skipped-review.html index 9a085281..66473193 100644 --- a/app/views/reading/skipped-review.html +++ b/app/views/reading/skipped-review.html @@ -13,15 +13,24 @@
- Image reading + {% set isArbitration = session.type == 'arbitration' %} + + {{ "Arbitration" if isArbitration else "Image reading" }}

{{ pageHeading }}

-

An opinion must be given on all cases before this session can be completed.

+

+ {%- if isArbitration -%} + Every case must be arbitrated before this session can be completed. + {%- else -%} + An opinion must be given on all cases before this session can be completed. + {%- endif -%} +

{% if firstSkippedAppointmentId %} {{ button({ - text: "Read skipped " + ("case" | pluralise(session.skippedAppointments.length)), href: "/reading/session/" + sessionId + "/appointments/" + firstSkippedAppointmentId + text: ("Arbitrate skipped " if isArbitration else "Read skipped ") + ("case" | pluralise(session.skippedAppointments.length)), + href: "/reading/session/" + sessionId + "/appointments/" + firstSkippedAppointmentId }) }} {% endif %} Go to session overview From 73afe12960b8d73b04b0a39c8ce73e8b60ca642a Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Thu, 6 Aug 2026 17:24:56 +0100 Subject: [PATCH 16/27] Give arbitration its own session overview, and make finalisation real The arbitration overview now has its own template rather than bending reading's: no tabs, a single case list at /reading/session/:id showing both original reads against the outcome, and outcome summary cards. Reading's session.html loses the arbitration branches it no longer needs. Finalisation was only half-built for arbitration. The unfinalised-reads lookup found reads by readerId, but an arbitration read carries arbitratorIds instead, so it matched nothing - the count was always zero and the session panel always claimed everything was finalised. It now matches on getReadAuthorIds, as withReadFinalised already did. Finalisation is also visible and actionable per case: the existing-read page says whether the read has finalised and when it will, and offers to finalise it now. Once finalised it can no longer be changed, so the summary drops its change links and records when it was finalised. --- app/lib/utils/reading-cases.js | 41 ++++ app/lib/utils/reading.js | 110 ++++++--- app/routes/reading.js | 142 +++++++++--- .../_includes/summary-lists/read-summary.njk | 24 +- app/views/reading/arbitration/session.html | 219 ++++++++++++++++++ app/views/reading/session.html | 64 ++--- app/views/reading/workflow/existing-read.html | 30 ++- 7 files changed, 513 insertions(+), 117 deletions(-) create mode 100644 app/views/reading/arbitration/session.html diff --git a/app/lib/utils/reading-cases.js b/app/lib/utils/reading-cases.js index 5b79d8a2..9ba32fd1 100644 --- a/app/lib/utils/reading-cases.js +++ b/app/lib/utils/reading-cases.js @@ -177,6 +177,21 @@ const getArbitrationRead = (readingCase) => { ) } +/** + * A case's ordinary reads - everything except the arbitration read. + * + * These are the reads an arbitration is deciding between, so display code that + * wants "the first and second read" wants this rather than every read. + * + * @param {object} readingCase - Reading case + * @returns {Array} The ordinary reads, oldest first + */ +const getOriginalReads = (readingCase) => { + return getReadsAsArray(readingCase).filter( + (read) => read.readType !== 'arbitration' + ) +} + /** * Whether a user has read a case * @@ -398,6 +413,30 @@ const isReadFinalised = (read, settings = {}, now = null) => { return judgedAt >= finalisesAt } +/** + * When a read will finalise itself, or null if it won't. + * + * Null covers both ends: an already-finalised read has no pending moment, and + * a 'never' delay means it only ever finalises by hand. + * + * @param {object} read - The read + * @param {object} [settings] - Site settings object (data.settings) + * @returns {string | null} ISO timestamp, or null + */ +const getAutoFinaliseTime = (read, settings = {}) => { + if (!read?.timestamp || read.finalisedAt) return null + + const delay = settings?.reading?.finalisationDelay ?? '60' + if (delay === 'never') return null + + const delayMinutes = parseInt(delay, 10) + if (Number.isNaN(delayMinutes)) return null + + return new Date( + new Date(read.timestamp).getTime() + delayMinutes * 60000 + ).toISOString() +} + /** * Whether every read on a case is finalised * @@ -859,6 +898,7 @@ module.exports = { getReadForUser, getOtherReads, getArbitrationRead, + getOriginalReads, getReadAuthorIds, caseHasBeenArbitrated, userHasReadCase, @@ -871,6 +911,7 @@ module.exports = { getComparisonInfo, shouldShowComparePage, isReadFinalised, + getAutoFinaliseTime, areAllReadsFinalised, getReadingCaseState, getReadingCaseOutcome, diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index 94012e5f..cf7b0a9f 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -23,6 +23,7 @@ const { const { getReadsAsArray, getReadForUser, + getReadAuthorIds, getReadingMetadata, getReadingCaseState, getReadingCaseOutcome, @@ -135,6 +136,10 @@ const unskipAppointmentInSession = (data, sessionId, appointmentId) => { * "Not yet finalised" means not finalised either way: no explicit finalisedAt, * and the auto-finalisation delay hasn't passed. * + * A read counts as the user's if they authored it, which for an arbitration + * read means being one of its arbitrators - so arbitration sessions get the + * same finalise-early prompt as reading ones. + * * @param {object} data - Session data * @param {string} sessionId - Reading session ID * @param {string} userId - User ID @@ -153,7 +158,9 @@ const getUnfinalisedUserReadsForSession = (data, sessionId, userId) => { if (!appointment) continue const readingCase = getReadingCase(data, appointment) - const read = getReadForUser(readingCase, userId) + const read = getReadsAsArray(readingCase).find((candidate) => + getReadAuthorIds(candidate).includes(userId) + ) if (!read) continue if (isReadFinalised(read, data.settings)) continue @@ -164,10 +171,9 @@ const getUnfinalisedUserReadsForSession = (data, sessionId, userId) => { } /** - * Finalise the user's outstanding reads from a session, and settle what each - * finalisation makes true: a case whose finalised reads the rules send to - * arbitration gets its release recorded, and a case that concludes moves its - * episode on. + * Finalise the user's read on one case, and settle what that makes true: a case + * whose finalised reads the rules send to arbitration gets its release + * recorded, and a case that concludes moves its episode on. * * Auto-finalisation (the delay passing) has no moment like this - a case can * conclude by time alone without anything recording the release or advancing @@ -175,6 +181,59 @@ const getUnfinalisedUserReadsForSession = (data, sessionId, userId) => { * recorded where there is an act to record. * * @param {object} data - Session data + * @param {object} appointment - The appointment the case belongs to + * @param {object} readingCase - The case + * @param {string} userId - Whose read to finalise + * @param {string} [finalisedAt] - When; defaults to now + * @returns {{released: boolean, concluded: boolean}} + */ +const finaliseReadOnCase = ( + data, + appointment, + readingCase, + userId, + finalisedAt = new Date().toISOString() +) => { + let updatedCase = withReadFinalised(readingCase, userId, { + finalisedAt, + finalisedBy: userId + }) + + const state = getReadingCaseState(updatedCase, data.settings) + + // Both reads finalised and the rules send it to arbitration: record the + // release into the backlog (see isCaseInArbitration) + let released = false + if ( + state === 'awaiting_arbitration' && + !updatedCase.arbitration?.releasedAt + ) { + updatedCase = { + ...updatedCase, + arbitration: { releasedAt: finalisedAt, releasedBy: userId } + } + released = true + } + + updateReadingCase(data, appointment.episodeId, updatedCase) + + // A finalised conclusion is a real result, so the episode moves on + const concluded = state === 'concluded' + if (concluded) { + advanceEpisodeForReadingOutcome( + data, + appointment, + getReadingCaseOutcome(updatedCase, data.settings) + ) + } + + return { released, concluded } +} + +/** + * Finalise all the user's outstanding reads from a session. + * + * @param {object} data - Session data * @param {string} sessionId - Reading session ID * @param {string} userId - User ID * @returns {{finalisedCount: number, releasedCount: number, concludedCount: number}} @@ -187,37 +246,15 @@ const finaliseUserReadsForSession = (data, sessionId, userId) => { let concludedCount = 0 for (const { appointment, readingCase } of unfinalised) { - let updatedCase = withReadFinalised(readingCase, userId, { - finalisedAt, - finalisedBy: userId - }) - - const state = getReadingCaseState(updatedCase, data.settings) - - // Both reads finalised and the rules send it to arbitration: record the - // release into the backlog (see isCaseInArbitration) - if ( - state === 'awaiting_arbitration' && - !updatedCase.arbitration?.releasedAt - ) { - updatedCase = { - ...updatedCase, - arbitration: { releasedAt: finalisedAt, releasedBy: userId } - } - releasedCount++ - } - - updateReadingCase(data, appointment.episodeId, updatedCase) - - // A finalised conclusion is a real result, so the episode moves on - if (state === 'concluded') { - advanceEpisodeForReadingOutcome( - data, - appointment, - getReadingCaseOutcome(updatedCase, data.settings) - ) - concludedCount++ - } + const { released, concluded } = finaliseReadOnCase( + data, + appointment, + readingCase, + userId, + finalisedAt + ) + if (released) releasedCount++ + if (concluded) concludedCount++ } return { finalisedCount: unfinalised.length, releasedCount, concludedCount } @@ -1850,6 +1887,7 @@ module.exports = { getAppointmentReadingMetadata, writeReading, getUnfinalisedUserReadsForSession, + finaliseReadOnCase, finaliseUserReadsForSession, getEpisodeReadingStatus, getDeferredCases, diff --git a/app/routes/reading.js b/app/routes/reading.js index c935f7d4..e2f8310c 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -17,6 +17,7 @@ const { userHasReadAppointment, writeReading, getUnfinalisedUserReadsForSession, + finaliseReadOnCase, finaliseUserReadsForSession, getEligibleCandidatesForSession, createReadingSession, @@ -45,6 +46,7 @@ const { getArbitrationRead, getReadAuthorIds, caseHasBeenArbitrated, + isReadFinalised, isCaseDeferred, withoutRead, withArbitrationRelease @@ -373,10 +375,18 @@ module.exports = (router) => { } }) - // Route for viewing a batch + // Route for viewing a batch. Reading sessions have per-user views and live at + // /your-reads; arbitration has no yours-vs-everyone split, so it renders here. router.get('/reading/session/:sessionId', (req, res) => { - // Default to "your-reads" view - res.redirect(`/reading/session/${req.params.sessionId}/your-reads`) + const data = req.session.data + const { sessionId } = req.params + const session = getReadingSession(data, sessionId) + + if (session?.type !== 'arbitration') { + return res.redirect(`/reading/session/${sessionId}/your-reads`) + } + + renderSessionOverview(req, res, session, null) }) // Route for resuming a session — jumps straight into the next readable case, @@ -441,7 +451,7 @@ module.exports = (router) => { session, sessionAppointments, data.currentUser.id - ) + ) if (firstReadable) { res.redirect(`/reading/session/${sessionId}`) } else { @@ -503,18 +513,20 @@ module.exports = (router) => { ) if (finalisedCount > 0) { + const noun = session.type === 'arbitration' ? 'outcome' : 'read' req.flash( 'success', finalisedCount === 1 - ? '1 read finalised' - : `${finalisedCount} reads finalised` + ? `1 ${noun} finalised` + : `${finalisedCount} ${noun}s finalised` ) } res.redirect(`/reading/session/${sessionId}`) }) - // Route for viewing a session with specific view + // Route for viewing a reading session with a specific view. Arbitration + // sessions never reach here - they render their own overview above. router.get('/reading/session/:sessionId/:view', (req, res) => { const data = req.session.data const { sessionId, view } = req.params @@ -523,13 +535,26 @@ module.exports = (router) => { // Validate view parameter const selectedView = validViews.includes(view) ? view : 'your-reads' - // Get the batch const session = getReadingSession(data, sessionId) if (!session) { - // req.flash('error', 'Session not found') return res.redirect('/reading') } + if (session.type === 'arbitration') { + return res.redirect(`/reading/session/${sessionId}`) + } + + renderSessionOverview(req, res, session, selectedView) + }) + + // Build the session overview's view model and render the template that suits + // the session type. Reading gets tabbed per-user views; arbitration gets one + // list, because a case is arbitrated once for everyone. + const renderSessionOverview = (req, res, session, selectedView) => { + const data = req.session.data + const sessionId = session.id + const isArbitration = session.type === 'arbitration' + // Get enhanced appointments with reading metadata const enhancedAppointments = session.appointmentIds .map((appointmentId) => @@ -610,29 +635,36 @@ module.exports = (router) => { clinic = getClinic(data, session.clinicId) } - // Overall backlog count — used to gate the 'Start a new session' button. - // Checks only cases the current user can actually read (not already read by them, - // not fully read by others, not deferred or awaiting priors). - const backlogTotal = filterAppointmentsByUserCanRead( - data, - filterAppointmentsByEligibleForReading(data.appointments), - data.currentUser.id - ).length + // Overall backlog count — used to gate the 'Start a new session' button, so + // it counts the work a new session of *this* type would draw on. For + // reading that's cases the user can read (not already read by them, not + // fully read by others, not deferred or awaiting priors); for arbitration + // it's the cases they'd be eligible to arbitrate. + const backlogTotal = isArbitration + ? getEligibleCandidatesForSession(data, { type: 'arbitration' }).length + : filterAppointmentsByUserCanRead( + data, + filterAppointmentsByEligibleForReading(data.appointments), + data.currentUser.id + ).length - res.render('reading/session', { - session, - appointments: enhancedAppointments, - readingStatus, - sessionProgress, - resumeAppointment, - autoFinaliseAt, - arbitratedCount, - unfinalisedReadCount: unconfirmedReads.length, - clinic, - backlogTotal, - view: selectedView - }) - }) + res.render( + isArbitration ? 'reading/arbitration/session' : 'reading/session', + { + session, + appointments: enhancedAppointments, + readingStatus, + sessionProgress, + resumeAppointment, + autoFinaliseAt, + arbitratedCount, + unfinalisedReadCount: unconfirmedReads.length, + clinic, + backlogTotal, + view: selectedView + } + ) + } // Middleware to make sure pages have the right data router.use( @@ -835,6 +867,48 @@ module.exports = (router) => { } ) + // Finalise this one case early, from the existing-read page. Redirects back + // to that page so the change is visible in place. + router.all( + '/reading/session/:sessionId/appointments/:appointmentId/finalise-read', + (req, res) => { + const data = req.session.data + const { sessionId, appointmentId } = req.params + const currentUserId = data.currentUser?.id + const backHref = `/reading/session/${sessionId}/appointments/${appointmentId}/existing-read` + + const appointment = data.appointments.find( + (candidate) => candidate.id === appointmentId + ) + if (!appointment) { + return res.redirect(`/reading/session/${sessionId}`) + } + + const readingCase = getReadingCase(data, appointment) + const session = getReadingSession(data, sessionId) + const isArbitrationSession = session?.type === 'arbitration' + + // The read this page is about - the case's arbitration read in an + // arbitration session, otherwise the user's own + const read = isArbitrationSession + ? getArbitrationRead(readingCase) + : getReadForUser(readingCase, currentUserId) + + if (!read || isReadFinalised(read, data.settings)) { + return res.redirect(backHref) + } + + finaliseReadOnCase(data, appointment, readingCase, currentUserId) + + req.flash( + 'success', + isArbitrationSession ? 'Outcome finalised' : 'Read finalised' + ) + + res.redirect(backHref) + } + ) + // Handle skipping an appointment in a batch router.get( '/reading/session/:sessionId/appointments/:appointmentId/skip', @@ -876,7 +950,7 @@ module.exports = (router) => { session, sessionAppointments, currentUserId - ) + ) if (firstReadable) { res.redirect(`/reading/session/${sessionId}`) } else { @@ -998,7 +1072,7 @@ module.exports = (router) => { session, sessionAppointments, currentUserId - ) + ) if (firstReadable) { res.redirect(modalBreakout(`/reading/session/${sessionId}`)) } else { @@ -1142,7 +1216,7 @@ module.exports = (router) => { session, sessionAppointments, currentUserId - ) + ) if (firstReadable) { res.redirect(modalBreakout(`/reading/session/${sessionId}`)) } else { diff --git a/app/views/_includes/summary-lists/read-summary.njk b/app/views/_includes/summary-lists/read-summary.njk index ade06d17..732b2970 100644 --- a/app/views/_includes/summary-lists/read-summary.njk +++ b/app/views/_includes/summary-lists/read-summary.njk @@ -78,12 +78,18 @@ rather than one of several opinions #} {% set opinionLabel = "Outcome" if read.readType == 'arbitration' else "Opinion" %} {% if not hideOpinionRow %} + {% set opinionValueHtml %} + {{ read.opinion | toTag }} + {% if read.timestamp and not (read | isReadFinalised(data.settings)) %} + (awaiting finalisation) + {% endif %} + {% endset %} {% set rows = rows | push({ key: { text: opinionLabel }, value: { - html: read.opinion | toTag + html: opinionValueHtml }, actions: { items: [ @@ -272,6 +278,22 @@ }) %} {% endif %} +{# When it was finalised. Reads finalised by the delay passing have no + finalisedAt stamp, so fall back to the moment the delay ran out. #} +{% if read.timestamp and (read | isReadFinalised(data.settings)) %} + {% set finalisedAt = read.finalisedAt or (read | getAutoFinaliseTime(data.settings)) %} + {% if finalisedAt %} + {% set rows = rows | push({ + key: { + text: "Finalised" + }, + value: { + text: finalisedAt | formatDateTime + } + }) %} + {% endif %} +{% endif %} + {{ summaryList({ rows: rows | removeLastRowBorder } | handleSummaryListMissingInformation) }} diff --git a/app/views/reading/arbitration/session.html b/app/views/reading/arbitration/session.html new file mode 100644 index 00000000..47897304 --- /dev/null +++ b/app/views/reading/arbitration/session.html @@ -0,0 +1,219 @@ +{# /app/views/reading/arbitration/session.html #} +{# Arbitration session overview. Unlike reading there are no tabs: an + arbitration session shows both original reads by design, and a case is + arbitrated once for everyone, so there is no yours-vs-everyone split. #} + +{% extends 'layout-app.html' %} + +{% set back = { + href: "/reading", + text: "Back to image reading dashboard" +} %} + +{% set pageHeading = session.name %} +{% set gridColumn = "none" %} + +{% block pageContent %} + +{{ session | log("Session data") }} +{{ appointments | log("Appointments in session") }} + +
+
+
+ Arbitration +

{{ pageHeading }}

+
+
+
+ +
+
+ + {% if session.arbitration.arbitratorIds | length %} +

+ Arbitrated by + {%- for userId in session.arbitration.arbitratorIds %} + {{ userId | getUsername({ format: "short", identifyCurrentUser: true }) }}{{ "," if not loop.last }} + {%- endfor %} +

+ {% endif %} + + {% set arbitrationActionText = "Resume arbitrating" if arbitratedCount else "Start arbitrating" %} + + {% if resumeAppointment %} + + {{ arbitrationActionText }} + + {% else %} + {% set sessionCompletePanelHtml %} + {% if unfinalisedReadCount > 0 %} +

Outcome recorded for {{ arbitratedCount }} {{ "case" | pluralise(arbitratedCount) }}.{% if autoFinaliseAt %} The first outcome will be finalised automatically at {{ autoFinaliseAt | formatTime("h:mma") }}.{% endif %} Finalise outcomes now

+ {% else %} +

Outcome recorded for {{ arbitratedCount }} {{ "case" | pluralise(arbitratedCount) }}. All outcomes are finalised.

+ {% endif %} +
+ {% if backlogTotal > 0 %} + {{ button({ + text: "Start a new arbitration session", + href: "/reading/arbitration/start", + variant: "reverse", + classes: "nhsuk-u-margin-bottom-0" + }) }} + {% endif %} + Return to reading dashboard +
+ {% endset %} + + {{ panel({ + titleText: "Session complete", + html: sessionCompletePanelHtml + }) }} + {% endif %} + + {# Outcome summary — percentages across the cases arbitrated so far #} + {% set normalCount = 0 %} + {% set technicalRecallCount = 0 %} + {% set recallForAssessmentCount = 0 %} + + {% for appointment in appointments %} + {% set arbitrationRead = appointment.readingCase | getArbitrationRead %} + {% if arbitrationRead.opinion == 'normal' %} + {% set normalCount = normalCount + 1 %} + {% elseif arbitrationRead.opinion == 'technical_recall' %} + {% set technicalRecallCount = technicalRecallCount + 1 %} + {% elseif arbitrationRead.opinion == 'recall_for_assessment' %} + {% set recallForAssessmentCount = recallForAssessmentCount + 1 %} + {% endif %} + {% endfor %} + + {% set outcomeDenominator = arbitratedCount if arbitratedCount > 0 else 1 %} + + {% if not resumeAppointment and arbitratedCount > 0 %} +

Outcome summary

+ +
    + {% for outcome in [ + { label: "Normal", count: normalCount }, + { label: "Technical recall", count: technicalRecallCount }, + { label: "Recall for assessment", count: recallForAssessmentCount } + ] %} + {% set outcomeCardContent %} +

    + + {{ outcome.label }}: {{ (outcome.count / outcomeDenominator * 100) | round }}% + +

    +

    {{ outcome.label }}

    +

    {{ outcome.count }} {{ "case" | pluralise(outcome.count) }}

    + {% endset %} +
  • + {{ card({ descriptionHtml: outcomeCardContent }) }} +
  • + {% endfor %} +
+ {% endif %} + +

Arbitration cases

+ + {% set remainingCount = (appointments | length) - arbitratedCount %} + {% if remainingCount > 0 %} +

Progress: {{ arbitratedCount }} arbitrated, {{ remainingCount }} remaining

+ {% endif %} + +

{{ loop.index }}. - {% if data | userHasReadAppointment(appointment) %} - {% set read = appointment.readingCase | getReadForUser(data.currentUser.id) %} + {% set isDone = (data | appointmentHasBeenArbitrated(appointment)) if isArbitration else (data | userHasReadAppointment(appointment)) %} +{% if isDone %} + {% set read = (appointment.readingCase | getArbitrationRead) if isArbitration else (appointment.readingCase | getReadForUser(data.currentUser.id)) %} {% if read.opinion %} {{ read.opinion | toTag }} {% endif %} @@ -328,7 +338,7 @@

Reading session cases

{% endif %}
- {% if resumeAppointment and appointment.id == resumeAppointment.id and not (data | userHasReadAppointment(appointment)) %} + {% if resumeAppointment and appointment.id == resumeAppointment.id and not ((data | appointmentHasBeenArbitrated(appointment)) if isArbitration else (data | userHasReadAppointment(appointment))) %} {{ readingActionText }} {% endif %}
+ + + + + + + + + + + + + {% for appointment in appointments %} + {% set arbitrationRead = appointment.readingCase | getArbitrationRead %} + {% set isNextCase = resumeAppointment and appointment.id == resumeAppointment.id and not arbitrationRead %} + {% set originalReads = appointment.readingCase | getOriginalReads %} + + + + + + {# The two original reads. Arbitration shows both by design - there + is no blind-reading gate here, the disagreement is the point. #} + {% for readIndex in [0, 1] %} + + {% endfor %} + + + + + {% endfor %} + +
No.CaseScreening date1st read2nd readOutcomeAction
+ {{ loop.index }}. + + {% if appointment.medicalInformation.symptoms | length %} + {{ "Has symptoms" | toTag }} + {% endif %} + {{ appointment.participant | getFullNameReversed }} +
+ + Participant record + +
+ {% set daysSinceScreening = appointment.timing.startTime | daysSince %} + {% if daysSinceScreening >= data.config.reading.urgentThreshold %} + {{ "Urgent" | toTag }}
+ {% elseif daysSinceScreening >= data.config.reading.priorityThreshold %} + {{ "Due soon" | toTag }}
+ {% endif %} + {{ appointment.timing.startTime | formatDate }}
+ + {{ appointment.timing.startTime | formatRelativeDate }} + +
+ {% set originalRead = originalReads[readIndex] %} + {% if originalRead %} + {# Forced grey so the two reads read as context, not as the outcome #} + {{ originalRead.opinion | toTag({ colour: "grey" }) }} +
+ by + {%- for authorId in originalRead | getReadAuthorIds %} + {{ authorId | getUsername({ format: "short", identifyCurrentUser: true }) }}{{ "," if not loop.last }} + {%- endfor %} + + {% else %} + {{ "Not read" | toTag }} + {% endif %} +
+ {% if arbitrationRead %} + {{ arbitrationRead.opinion | toTag }} +
+ {% if arbitrationRead | isReadFinalised(data.settings) %} + {% set finalisedAt = arbitrationRead.finalisedAt or (arbitrationRead | getAutoFinaliseTime(data.settings)) %} + {% if finalisedAt %} + finalised {{ finalisedAt | formatTime("h:mma") }} + {% endif %} + {% else %} + awaiting finalisation + {% endif %} + {% elseif appointment.readingCase | isCaseDeferred %} + {{ "Deferred" | toTag }} + {% elseif session.skippedAppointments | includes(appointment.id) %} + {{ "Skipped" | toTag }} + {% else %} + {{ "Not arbitrated" | toTag }} + {% endif %} +
+ {% if isNextCase %} + {{ arbitrationActionText }} + {% endif %} +
+
+

+{% endblock %} diff --git a/app/views/reading/session.html b/app/views/reading/session.html index 06388268..dc561d10 100644 --- a/app/views/reading/session.html +++ b/app/views/reading/session.html @@ -31,7 +31,7 @@
- {{ "Arbitration" if session.type == 'arbitration' else "Image reading" }} + Image reading

{{ pageHeading }}

@@ -47,20 +47,7 @@

- {% if session.type == 'arbitration' and session.arbitration.arbitratorIds | length %} -

- Arbitrated by - {%- for userId in session.arbitration.arbitratorIds %} - {{ userId | getUsername({ format: "short", identifyCurrentUser: true }) }}{{ "," if not loop.last }} - {%- endfor %} -

- {% endif %} - - {% if session.type == 'arbitration' %} - {% set readingActionText = "Resume arbitrating" if arbitratedCount else "Start arbitrating" %} - {% else %} - {% set readingActionText = "Resume reading" if readingStatus.userReadCount else "Start reading" %} - {% endif %} + {% set readingActionText = "Resume reading" if readingStatus.userReadCount else "Start reading" %} {% if resumeAppointment %} @@ -68,12 +55,11 @@

{% else %} {% set sessionCompletePanelHtml %} - {% set panelDoneCount = arbitratedCount if session.type == 'arbitration' else readingStatus.userReadCount %} - {% set panelNoun = "outcome" if session.type == 'arbitration' else "opinion" %} + {% set panelDoneCount = readingStatus.userReadCount %} {% if unfinalisedReadCount > 0 %} -

{{ panelNoun | sentenceCase }} recorded for {{ panelDoneCount }} {{ "case" | pluralise(panelDoneCount) }}.{% if autoFinaliseAt %} The first {{ panelNoun }} will be finalised automatically at {{ autoFinaliseAt | formatTime("h:mma") }}.{% endif %} Finalise {{ panelNoun }}s now

+

Opinion recorded for {{ panelDoneCount }} {{ "case" | pluralise(panelDoneCount) }}.{% if autoFinaliseAt %} The first opinion will be finalised automatically at {{ autoFinaliseAt | formatTime("h:mma") }}.{% endif %} Finalise opinions now

{% else %} -

{{ panelNoun | sentenceCase }} recorded for {{ panelDoneCount }} {{ "case" | pluralise(panelDoneCount) }}. All {{ panelNoun }}s are finalised.

+

Opinion recorded for {{ panelDoneCount }} {{ "case" | pluralise(panelDoneCount) }}. All opinions are finalised.

{% endif %}
{% if backlogTotal > 0 %} @@ -116,10 +102,6 @@

items: secondaryNavItems }) }} - {# Arbitration settles a case once for everyone, so "done" is a fact about - the case rather than about what this user has read #} - {% set isArbitration = session.type == 'arbitration' %} - {# Number of slots not yet populated in a lazy session #} {% set sessionTotalCount = sessionProgress.effectiveTargetSize if sessionProgress else session.targetSize %} {% set pendingCount = (sessionTotalCount - (session.appointmentIds | length)) if sessionTotalCount else 0 %} @@ -129,7 +111,7 @@

{# YOUR READS VIEW - Shows cases from user's perspective #} {% set userReadableAppointments = [] %} {% for appointment in appointments %} - {% set isDone = (data | appointmentHasBeenArbitrated(appointment)) if isArbitration else (data | userHasReadAppointment(appointment)) %} + {% set isDone = data | userHasReadAppointment(appointment) %} {% if (data | canUserReadAppointment(appointment)) or isDone or (appointment | userRequestedPriors(data.currentUser.id)) or (appointment.readingCase | isCaseDeferred) %} {% set userReadableAppointments = userReadableAppointments | push(appointment) %} {% endif %} @@ -147,9 +129,9 @@

{% set deferredAppointments = [] %} {% for appointment in userReadableAppointments %} - {% set isDone = (data | appointmentHasBeenArbitrated(appointment)) if isArbitration else (data | userHasReadAppointment(appointment)) %} + {% set isDone = data | userHasReadAppointment(appointment) %} {% if isDone %} - {% set read = (appointment.readingCase | getArbitrationRead) if isArbitration else (appointment.readingCase | getReadForUser(data.currentUser.id)) %} + {% set read = appointment.readingCase | getReadForUser(data.currentUser.id) %} {% if read.opinion == 'normal' %} {% set normalAppointments = normalAppointments | push(appointment) %} {% elseif read.opinion == 'technical_recall' %} @@ -243,12 +225,12 @@

Opinion summary

{% endif %} -

{{ "Arbitration cases" if isArbitration else "Reading session cases" }}

- {% set doneCount = arbitratedCount if isArbitration else readingStatus.userReadCount %} +

Reading session cases

+ {% set doneCount = readingStatus.userReadCount %} {% set userSessionRemainingCount = sessionTotalCount - doneCount - readingStatus.userAwaitingPriorsCount - deferredCount %} {% set userSessionRemainingCount = 0 if userSessionRemainingCount < 0 else userSessionRemainingCount %} {% if userSessionRemainingCount > 0 or readingStatus.userAwaitingPriorsCount > 0 or deferredCount > 0 %} -

Progress: {{ doneCount }} {{ "arbitrated" if isArbitration else "read" }}{%- if readingStatus.userAwaitingPriorsCount > 0 -%}, {{ readingStatus.userAwaitingPriorsCount }} awaiting priors{%- endif -%}{%- if deferredCount > 0 -%}, {{ deferredCount }} deferred{%- endif -%}, {{ userSessionRemainingCount }} remaining

+

Progress: {{ doneCount }} read{%- if readingStatus.userAwaitingPriorsCount > 0 -%}, {{ readingStatus.userAwaitingPriorsCount }} awaiting priors{%- endif -%}{%- if deferredCount > 0 -%}, {{ deferredCount }} deferred{%- endif -%}, {{ userSessionRemainingCount }} remaining

{% endif %} @@ -257,14 +239,14 @@

{{ "Arbitration cases" if isArbitration else "Reading session cases" }}

- + {% for appointment in userReadableAppointments %} {% set metadata = appointment.readingCase | getReadingMetadata(data.settings) %} - + @@ -279,17 +261,11 @@

{{ "Arbitration cases" if isArbitration else "Reading session cases" }}


- {% if session.type == 'arbitration' %} - Arbitration - · Participant record + {% set readCount = metadata.readCount %} + {% if data | userHasReadAppointment(appointment) %} + {{ "1st read" if readCount == 1 else "2nd read" }} {% else %} - {% set readCount = metadata.readCount %} - {% set isDone = (data | appointmentHasBeenArbitrated(appointment)) if isArbitration else (data | userHasReadAppointment(appointment)) %} -{% if isDone %} - {{ "1st read" if readCount == 1 else "2nd read" }} - {% else %} - {{ "1st read" if readCount == 0 else "2nd read" }} - {% endif %} + {{ "1st read" if readCount == 0 else "2nd read" }} {% endif %} {% if (data.settings.debugMode | falsify) %} @@ -321,9 +297,9 @@

{{ "Arbitration cases" if isArbitration else "Reading session cases" }}

diff --git a/app/views/reading/workflow/existing-read.html b/app/views/reading/workflow/existing-read.html index cf58e408..3b7697f7 100644 --- a/app/views/reading/workflow/existing-read.html +++ b/app/views/reading/workflow/existing-read.html @@ -141,9 +141,35 @@

{{ "Arbitration outcome" if isArbitration else "Your {# The read this page is about: in arbitration the case's one arbitration read, otherwise the current user's own #} + {% set thisRead = (readingCase | getArbitrationRead) if isArbitration else (readingCase | getReadForUser(data.currentUser.id)) %} + {% set isFinalised = thisRead | isReadFinalised(data.settings) %} + {% set finalisationNoun = "outcome" if isArbitration else "read" %} + + {# Until it finalises, the result isn't yet real - nothing has been released + to arbitration or moved the episode on. Say when that happens, and offer + to bring it forward. Once finalised it can no longer be changed, so the + summary below drops its change links. #} + {% if thisRead and not isFinalised %} + {% set autoFinaliseAt = thisRead | getAutoFinaliseTime(data.settings) %} + {% set finalisationInsetHtml %} +

+ This {{ finalisationNoun }} has not been finalised + {%- if autoFinaliseAt %}. It will finalise automatically at {{ autoFinaliseAt | formatTime("h:mma") }}{% endif %}. + You can still change it until then. +

+

+ Finalise this {{ finalisationNoun }} now +

+ {% endset %} + + {{ insetText({ + html: finalisationInsetHtml + }) }} + {% endif %} + {% set readSummaryHtml %} - {% set read = (readingCase | getArbitrationRead) if isArbitration else (readingCase | getReadForUser(data.currentUser.id)) %} - {% set allowEdits = true %} + {% set read = thisRead %} + {% set allowEdits = not isFinalised %} {% set changeOpinionUrl = "./opinion" %} {% set showAnnotationImages = withImages %} {% set annotationImagePaths = allPaths %} From 7ed529d30cfb20b4a8e9be304b00496e88844dcc Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Thu, 6 Aug 2026 17:27:53 +0100 Subject: [PATCH 17/27] Update tag colour --- app/lib/utils/status.js | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/app/lib/utils/status.js b/app/lib/utils/status.js index 659eb633..cd99fb2f 100644 --- a/app/lib/utils/status.js +++ b/app/lib/utils/status.js @@ -26,12 +26,7 @@ const STATUS_GROUPS = { 'attended_not_screened', 'cancelled' ], - active: [ - 'scheduled', - 'checked_in', - 'in_progress', - 'paused' - ], + active: ['scheduled', 'checked_in', 'in_progress', 'paused'], eligible_for_reading: ['complete', 'partially_screened'] } @@ -235,7 +230,10 @@ const STATUS_TAGS = { // appointment- and group-level vocabulary. 'awaiting_first_read': { label: 'Awaiting 1st read', colour: 'grey' }, 'awaiting_second_read': { label: 'Awaiting 2nd read', colour: 'blue' }, - 'awaiting_finalisation': { label: 'Awaiting finalisation', colour: 'yellow' }, + 'awaiting_finalisation': { + label: 'Awaiting finalisation', + colour: 'yellow' + }, 'awaiting_arbitration': { label: 'Awaiting arbitration', colour: 'orange' }, 'in_arbitration': { label: 'In arbitration', colour: 'purple' }, 'concluded': { label: 'Concluded', colour: 'green' }, @@ -245,6 +243,7 @@ const STATUS_TAGS = { 'skipped': { colour: 'grey' }, 'previously_skipped': { colour: 'grey' }, 'not_read': { colour: 'white' }, + 'not_arbitrated': { colour: 'white' }, 'complete': { colour: 'green' }, 'partial_first_read': { colour: 'blue' }, 'first_read_complete': { colour: 'yellow' }, @@ -400,9 +399,13 @@ const describeReadingCaseStatus = (status) => { const filterAppointmentsByStatus = (appointments, filter) => { switch (filter) { case 'scheduled': - return appointments.filter((appointment) => appointment.status === 'scheduled') + return appointments.filter( + (appointment) => appointment.status === 'scheduled' + ) case 'checked-in': - return appointments.filter((appointment) => appointment.status === 'checked_in') + return appointments.filter( + (appointment) => appointment.status === 'checked_in' + ) case 'in-progress': return appointments.filter( (appointment) => @@ -435,7 +438,10 @@ const isSpecialAppointment = (appointment) => { * @returns {boolean} Whether the appointment has an appointment note */ const hasAppointmentNote = (appointment) => { - return appointment?.appointmentNote && appointment.appointmentNote.trim().length > 0 + return ( + appointment?.appointmentNote && + appointment.appointmentNote.trim().length > 0 + ) } /** From a107396f34b98a3436258711a8da0a0b2e4bf46d Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Thu, 6 Aug 2026 17:33:33 +0100 Subject: [PATCH 18/27] Regenerate the utils and filters reference Picks up getOriginalReads, getAutoFinaliseTime and finaliseReadOnCase, and refreshes the line numbers the arbitration work shifted. --- docs/utils-filter-reference.md | 227 +++++++++++++++++---------------- 1 file changed, 119 insertions(+), 108 deletions(-) diff --git a/docs/utils-filter-reference.md b/docs/utils-filter-reference.md index 5d7f8e5e..4c661f95 100644 --- a/docs/utils-filter-reference.md +++ b/docs/utils-filter-reference.md @@ -3,7 +3,7 @@ --- **Auto-generated** — do not edit manually. -- **Generated:** 2026-08-06 08:57 UTC +- **Generated:** 2026-08-06 16:33 UTC - **Source:** `app/lib/utils/` and `app/filters/` - **Regenerate:** `npm run docs` @@ -18,28 +18,28 @@ | `dates.js` | Date formatting and calculation using dayjs | 50 | | `strings.js` | String manipulation: case conversion, formatting, NHS-specific formats (NHS number, phone), pluralisation, and HTML-wrapping helpers for use in templates. | 86 | | `status.js` | Appointment status checks and display helpers | 121 | -| `participants.js` | Participant lookups and derived data: full/short names, age, clinic history, and risk level. | 145 | -| `appointment-data.js` | Appointment lookups and mutations in session data | 165 | -| `episodes.js` | Episode lookups and stage changes | 179 | -| `clinics.js` | Clinic filtering by time period, slot formatting, and opening hours calculation. | 216 | -| `reading-cases.js` | A reading case is one set of mammograms being read, held on the episode as episode.readingCases[] | 232 | -| `reading.js` | Image reading workflow: read state, progress tracking, batch management, per-user navigation, and filtering | 271 | -| `prior-mammograms.js` | Prior mammogram request state (awaiting, unrequested, resolved) and one-line summary helpers. | 320 | -| `medical-information.js` | Summarise medical history items, symptoms, breast features, and other clinical information into concise display strings. | 339 | -| `annotation-summary.js` | Summarise image reading annotations (abnormality type, level of concern, location) into concise display strings. | 360 | -| `arrays.js` | Array helpers: find by key/id, filter, push (immutable), remove empty | 373 | -| `objects.js` | Object utilities for extracting and flattening values. | 391 | -| `summary-list.js` | NHS summary list helpers: replace empty row values with "Enter X" links or "Not provided" text, and remove the bottom border from the last row. | 401 | -| `random.js` | Seeded random functions for stable prototype data | 412 | -| `referrers.js` | Referrer chain navigation for multi-level back links | 429 | -| `roles-and-permissions.js` | User role checks | 442 | -| `utility.js` | General-purpose type coercion (`falsify`) and limiting utilities. | 460 | +| `participants.js` | Participant lookups and derived data: full/short names, age, clinic history, and risk level. | 146 | +| `appointment-data.js` | Appointment lookups and mutations in session data | 166 | +| `episodes.js` | Episode lookups and stage changes | 180 | +| `clinics.js` | Clinic filtering by time period, slot formatting, and opening hours calculation. | 217 | +| `reading-cases.js` | A reading case is one set of mammograms being read, held on the episode as episode.readingCases[] | 233 | +| `reading.js` | Image reading workflow: read state, progress tracking, batch management, per-user navigation, and filtering | 277 | +| `prior-mammograms.js` | Prior mammogram request state (awaiting, unrequested, resolved) and one-line summary helpers. | 331 | +| `medical-information.js` | Summarise medical history items, symptoms, breast features, and other clinical information into concise display strings. | 350 | +| `annotation-summary.js` | Summarise image reading annotations (abnormality type, level of concern, location) into concise display strings. | 371 | +| `arrays.js` | Array helpers: find by key/id, filter, push (immutable), remove empty | 384 | +| `objects.js` | Object utilities for extracting and flattening values. | 402 | +| `summary-list.js` | NHS summary list helpers: replace empty row values with "Enter X" links or "Not provided" text, and remove the bottom border from the last row. | 412 | +| `random.js` | Seeded random functions for stable prototype data | 423 | +| `referrers.js` | Referrer chain navigation for multi-level back links | 440 | +| `roles-and-permissions.js` | User role checks | 453 | +| `utility.js` | General-purpose type coercion (`falsify`) and limiting utilities. | 471 | | | | | -| `formatting.js` | Display formatting for yes/no answers and ordinal names. (filter only) | 476 | -| `forms.js` | Injects matching flash error messages into NHS form component configs by field name. (filter only) | 488 | -| `nunjucks.js` | Nunjucks-specific helpers: joining arrays, resolving user names from IDs, template debugging, and template literal support. (filter only) | 500 | -| `tags.js` | Convert status strings to NHS `` HTML elements. (filter only) | 514 | -| `markdown.js` | Convert markdown strings to Nunjucks-safe HTML using markdown-it (filter only) | 524 | +| `formatting.js` | Display formatting for yes/no answers and ordinal names. (filter only) | 487 | +| `forms.js` | Injects matching flash error messages into NHS form component configs by field name. (filter only) | 499 | +| `nunjucks.js` | Nunjucks-specific helpers: joining arrays, resolving user names from IDs, template debugging, and template literal support. (filter only) | 511 | +| `tags.js` | Convert status strings to NHS `` HTML elements. (filter only) | 525 | +| `markdown.js` | Convert markdown strings to Nunjucks-safe HTML using markdown-it (filter only) | 535 | --- @@ -126,21 +126,22 @@ Appointment status checks and display helpers. Use these instead of comparing st | Function | Description | Line | |---|---|---| -| `hasNotStarted(input)` | Check if a status represents a not started appointment | 62 | -| `isCompleted(input)` | Check if a status represents a completed appointment | 74 | -| `isInProgress(input)` | Check if a status represents an in-progress appointment (includes paused) | 86 | -| `isPaused(input)` | Check if a status represents a paused appointment | 98 | -| `isInProgressNotPaused(input)` | Check if a status represents an in-progress appointment that is not paused | 110 | -| `isFinal(input)` | Check if a status represents a final state | 122 | -| `isActive(input)` | Check if a status represents an active appointment | 134 | -| `isAppointmentWorkflow(appointment, currentUser)` | Check if an appointment is in the appointment workflow for the current user | 146 | -| `eligibleForReading(appointment)` | Check if a status indicates reading is eligible | 178 | -| `getStatusTagColour(status, [vocabulary])` | Map a status key to its NHS tag colour string — e.g. `getStatusTagColour('complete', 'appointment') // 'green'` | 324 | -| `getStatusText(status, [vocabulary])` | Map a status key to its display text — e.g. `getStatusText('complete', 'appointment') // 'Screened'` | 338 | -| `filterAppointmentsByStatus(appointments, filter)` | Filter appointments by status category | 352 | -| `isSpecialAppointment(appointment)` | Check if an appointment is a special appointment | 380 | -| `hasAppointmentNote(appointment)` | Check if an appointment has an appointment note | 390 | -| `hasSymptoms(appointment)` | Check if an appointment has recorded symptoms | 400 | +| `hasNotStarted(input)` | Check if a status represents a not started appointment | 57 | +| `isCompleted(input)` | Check if a status represents a completed appointment | 69 | +| `isInProgress(input)` | Check if a status represents an in-progress appointment (includes paused) | 81 | +| `isPaused(input)` | Check if a status represents a paused appointment | 93 | +| `isInProgressNotPaused(input)` | Check if a status represents an in-progress appointment that is not paused | 105 | +| `isFinal(input)` | Check if a status represents a final state | 117 | +| `isActive(input)` | Check if a status represents an active appointment | 129 | +| `isAppointmentWorkflow(appointment, currentUser)` | Check if an appointment is in the appointment workflow for the current user | 141 | +| `eligibleForReading(appointment)` | Check if a status indicates reading is eligible | 173 | +| `getStatusTagColour(status, [vocabulary])` | Map a status key to its NHS tag colour string — e.g. `getStatusTagColour('complete', 'appointment') // 'green'` | 323 | +| `getStatusText(status, [vocabulary])` | Map a status key to its display text — e.g. `getStatusText('complete', 'appointment') // 'Screened'` | 337 | +| `describeReadingCaseStatus(status)` | The display facts for a reading case's status, composed from the facts | 362 | +| `filterAppointmentsByStatus(appointments, filter)` | Filter appointments by status category | 392 | +| `isSpecialAppointment(appointment)` | Check if an appointment is a special appointment | 424 | +| `hasAppointmentNote(appointment)` | Check if an appointment has an appointment note | 434 | +| `hasSymptoms(appointment)` | Check if an appointment has recorded symptoms | 447 | ### participants.js @@ -154,13 +155,13 @@ Participant lookups and derived data: full/short names, age, clinic history, and | `getFullName(participant)` | Get full name (first, middle, last) of a participant as a Nunjucks-safe string | 28 | | `getFirstNames(participant)` | Get first names (first + middle) of a participant as a Nunjucks-safe string | 42 | | `getFullNameReversed(participant)` | Get full name in reversed 'Last, First Middle' format — e.g. `getFullNameReversed(participant) // 'SMITH, Jane Louise'` | 54 | -| `getShortName(participant)` | Get short name (first + last only) of participant as a Nunjucks-safe string | 68 | -| `findBySXNumber(participants, sxNumber)` | Find a participant by their SX number | 80 | -| `getAge(participant, [referenceDate])` | Get participant's age | 91 | -| `sortBySurname(participants)` | Sort participants by surname | 112 | -| `getCurrentRiskLevel(participant)` | Determine a participant's current risk level based on age and risk factors | 126 | -| `updateParticipant(data, participantId, updatedParticipant)` | Find and update a participant in session data | 159 | -| `saveTempParticipantToParticipant(data)` | Save temporary participant data back to the main participant | 183 | +| `getShortName(participant)` | Get short name (first + last only) of participant as a Nunjucks-safe string | 70 | +| `findBySXNumber(participants, sxNumber)` | Find a participant by their SX number | 82 | +| `getAge(participant, [referenceDate])` | Get participant's age | 93 | +| `sortBySurname(participants)` | Sort participants by surname | 114 | +| `getCurrentRiskLevel(participant)` | Determine a participant's current risk level based on age and risk factors | 128 | +| `updateParticipant(data, participantId, updatedParticipant)` | Find and update a participant in session data | 161 | +| `saveTempParticipantToParticipant(data)` | Save temporary participant data back to the main participant | 185 | ### appointment-data.js @@ -243,30 +244,35 @@ A reading case is one set of mammograms being read, held on the episode as episo | `getReadingCaseForAppointment(episode, appointmentId)` | Find the reading case covering a given appointment's images | 111 | | `getReadsAsArray(readingCase)` | A case's reads in order, oldest first. | 126 | | `getReadForUser(readingCase, userId)` | Get one user's read on a case | 139 | -| `getOtherReads(readingCase, userId)` | Get the reads on a case made by anyone other than the given user | 154 | -| `getArbitrationRead(readingCase)` | Get the arbitration read on a case, if one has been made | 165 | -| `userHasReadCase(readingCase, userId)` | Whether a user has read a case | 178 | -| `caseHasReads(readingCase)` | Whether a case has any reads | 189 | -| `isCaseDeferred(readingCase)` | Whether a case has been deferred from reading. | 199 | -| `isCaseInArbitration(readingCase)` | Whether a case has been released into arbitration. | 212 | -| `areReadsDiscordant(readA, readB)` | Whether the reads on a case disagree in a clinically meaningful way. | 226 | -| `willGoToArbitration(readA, readB, [settings])` | Whether two reads mean the case needs arbitrating, taking the site's | 283 | -| `isReadFinalised(read, [settings], [now])` | Whether a read is finalised. | 315 | -| `areAllReadsFinalised(readingCase, [settings], [now])` | Whether every read on a case is finalised | 345 | -| `getReadingCaseState(readingCase, [settings], [now])` | Where a case has got to. | 359 | -| `getReadingCaseOutcome(readingCase, [settings], [now])` | What a case found, or null while reading is still under way. | 394 | -| `getReadingCaseStatus(readingCase, [settings], [now])` | The facts about where a case stands, for composing status displays. | 418 | -| `getReadingMetadata(readingCase, [settings])` | Summary counts and flags for a case, for lists and progress displays | 458 | -| `caseNeedsFirstRead(readingCase)` | Whether a case still needs a first read | 489 | -| `caseNeedsSecondRead(readingCase)` | Whether a case has a first read and still needs a second | 499 | -| `caseNeedsArbitration(readingCase, [settings])` | Whether a case sits in the arbitration backlog - finalised reads whose | 509 | -| `canUserReadCase(readingCase, userId, [options], [options.maxReadsPerCase])` | Whether a user can read a case. | 521 | -| `getComparisonInfo(readingCase, secondReadData, userId, [settings])` | Work out what the second reader should be shown about the first read. | 549 | -| `shouldShowComparePage(readingCase, secondReadData, userId, [settings])` | Whether the compare page should be shown to the second reader. | 598 | -| `buildRead(readingCase, userId, readerType, reading, [options], [options.timestamp])` | Build the read record for a user's opinion on a case. | 637 | -| `withRead(readingCase, read)` | Add or replace a user's read on a case, returning a new case record. | 673 | -| `withReadFinalised(readingCase, userId, [options], [options.finalisedAt], [options.finalisedBy])` | Mark a user's read on a case as finalised, returning a new case record. | 697 | -| `withoutRead(readingCase, userId)` | Remove a user's read from a case, returning a new case record. | 724 | +| `getOtherReads(readingCase, userId)` | Get the reads on a case made by anyone other than the given user | 155 | +| `getArbitrationRead(readingCase)` | Get the arbitration read on a case, if one has been made | 166 | +| `getOriginalReads(readingCase)` | A case's ordinary reads - everything except the arbitration read. | 180 | +| `userHasReadCase(readingCase, userId)` | Whether a user has read a case | 195 | +| `getReadAuthorIds(read)` | Who made a read. | 206 | +| `caseHasBeenArbitrated(readingCase)` | Whether a case has been arbitrated. | 222 | +| `caseHasReads(readingCase)` | Whether a case has any reads | 237 | +| `withArbitrationRelease(readingCase, userId)` | Record that a case has been released for arbitration, if it wasn't already. | 247 | +| `isCaseDeferred(readingCase)` | Whether a case has been deferred from reading. | 270 | +| `isCaseInArbitration(readingCase)` | Whether a case has been released into arbitration. | 283 | +| `areReadsDiscordant(readA, readB)` | Whether the reads on a case disagree in a clinically meaningful way. | 297 | +| `willGoToArbitration(readA, readB, [settings])` | Whether two reads mean the case needs arbitrating, taking the site's | 354 | +| `isReadFinalised(read, [settings], [now])` | Whether a read is finalised. | 386 | +| `getAutoFinaliseTime(read, [settings])` | When a read will finalise itself, or null if it won't. | 416 | +| `areAllReadsFinalised(readingCase, [settings], [now])` | Whether every read on a case is finalised | 440 | +| `getReadingCaseState(readingCase, [settings], [now])` | Where a case has got to. | 454 | +| `getReadingCaseOutcome(readingCase, [settings], [now])` | What a case found, or null while reading is still under way. | 494 | +| `getReadingCaseStatus(readingCase, [settings], [now])` | The facts about where a case stands, for composing status displays. | 518 | +| `getReadingMetadata(readingCase, [settings])` | Summary counts and flags for a case, for lists and progress displays | 558 | +| `caseNeedsFirstRead(readingCase)` | Whether a case still needs a first read | 589 | +| `caseNeedsSecondRead(readingCase)` | Whether a case has a first read and still needs a second | 599 | +| `caseNeedsArbitration(readingCase, [settings])` | Whether a case sits in the arbitration backlog - finalised reads whose | 609 | +| `canUserReadCase(readingCase, userId, [options], [options.maxReadsPerCase])` | Whether a user can read a case. | 628 | +| `getComparisonInfo(readingCase, secondReadData, userId, [settings])` | Work out what the second reader should be shown about the first read. | 663 | +| `shouldShowComparePage(readingCase, secondReadData, userId, [settings])` | Whether the compare page should be shown to the second reader. | 712 | +| `buildRead(readingCase, userId, readerType, reading, [options], [options.timestamp], [options.arbitratorIds])` | Build the read record for a user's opinion on a case. | 751 | +| `withRead(readingCase, read)` | Add or replace a user's read on a case, returning a new case record. | 808 | +| `withReadFinalised(readingCase, userId, [options], [options.finalisedAt], [options.finalisedBy])` | Mark a user's read on a case as finalised, returning a new case record. | 843 | +| `withoutRead(readingCase, userId)` | Remove a user's read from a case, returning a new case record. | 870 | ### reading.js @@ -276,46 +282,51 @@ Image reading workflow: read state, progress tracking, batch management, per-use | Function | Description | Line | |---|---|---| -| `getAppointmentReadingMetadata(data, appointment)` | Get the reading metadata for an appointment's case | 49 | -| `writeReading(data, appointment, userId, reading, [sessionId])` | Save a user's read of an appointment's images, and take the appointment off | 60 | -| `getUnfinalisedUserReadsForSession(data, sessionId, userId)` | The user's not-yet-finalised reads in a session, each with the appointment | 108 | -| `finaliseUserReadsForSession(data, sessionId, userId)` | Finalise the user's outstanding reads from a session, and settle what each | 144 | -| `getEpisodeReadingStatus(data, episode, [userId])` | Get the reading status of an episode. | 204 | -| `getDeferredCases(data)` | Every case currently deferred from reading, most recently deferred first. | 228 | -| `getResolvedDeferrals(data)` | Every deferral that has since been resolved, most recently resolved first. | 254 | -| `enhanceAppointmentsWithReadingData(data, appointments, participants, userId)` | Enhance appointments with their reading case and pre-calculated metadata. | 303 | -| `getReadingStatusForAppointments(data, appointments, [userId])` | Get detailed reading status for a group of appointments | 497 | -| `getReadingProgress(data, appointments, currentAppointmentId, skippedAppointments, [userId])` | Get progress through reading a set of appointments | 544 | -| `sortAppointmentsByScreeningDate(appointments)` | Sort appointments by screening date (oldest first) | 655 | -| `getFirstAvailableClinic(data)` | Get the first clinic that still has appointments needing reads | 675 | -| `getReadingClinics(data, [options])` | Get all clinics available for reading, enriched with unit, location, and reading status | 686 | -| `getReadableAppointmentsForClinic(data, clinicId)` | Get readable appointments for a clinic with pre-calculated metadata | 722 | -| `filterAppointmentsByEligibleForReading(appointments)` | Filter appointments that are eligible for reading | 754 | -| `filterAppointmentsByNeedsAnyRead(data, appointments, maxReadsPerCase)` | Filter appointments that need any read (first or second) | 763 | -| `filterAppointmentsByNeedsFirstRead(data, appointments)` | Filter appointments that need a first read | 778 | -| `filterAppointmentsByNeedsSecondRead(data, appointments)` | Filter appointments that need a second read | 791 | -| `filterAppointmentsByFullyRead(data, appointments, requiredReads)` | Filter appointments that are fully read (have all required reads) | 804 | -| `filterAppointmentsByUserCanRead(data, appointments, userId)` | Filter appointments that a specific user can read | 819 | -| `filterAppointmentsByUserCanReadOrHasRead(data, appointments, userId, [options])` | Filter appointments that user can read or has already read | 833 | -| `filterAppointmentsByClinic(appointments, clinicId)` | Filter appointments for a specific clinic | 866 | -| `filterAppointmentsByDayRange(appointments, minDays, [maxDays])` | Filter appointments that are within a specific day range | 877 | -| `getFirstAppointmentInList(appointments)` | Get the first appointment from an array | 897 | -| `getNextAppointmentInList(appointments, currentAppointmentId, wrap)` | Get the next appointment after a specific appointment | 906 | -| `getPreviousAppointmentInList(appointments, currentAppointmentId, wrap)` | Get the previous appointment before a specific appointment | 927 | -| `getFirstUserReadableAppointment(data, appointments, userId)` | Get first appointment from an array that a user can read | 952 | -| `getNextUserReadableAppointment(data, appointments, currentAppointmentId, [userId])` | Get the next appointment the user can read after the current appointment, wrapping to start if needed | 972 | -| `getResumeAppointmentForUser(data, appointments, [userId], [skippedAppointments])` | Get the appointment the user should resume reading from. | 997 | -| `userHasReadAppointment(data, appointment, [userId])` | Check if a user has already read an appointment's images | 1058 | -| `canUserReadAppointment(data, appointment, [userId], [options])` | Check if a user can read an appointment's images. | 1079 | -| `getEligibleCandidatesForSession(data, sessionOptions)` | Get eligible appointment candidates for a session based on its type and filters | 1145 | -| `createReadingSession(data, options, options.type, [options.name], [options.clinicId], [options.sessionId], [options.limit], [options.filters])` | Create a session of appointments for reading based on specified criteria | 1209 | -| `getDefaultSessionName(type, clinicId, data)` | Generate a default name for a session based on its type | 1298 | -| `generateSessionId()` | Generate a unique ID for a session | 1333 | -| `getReadingSession(data, sessionId)` | Get a reading session by ID | 1342 | -| `getFirstReadableAppointmentInSession(data, sessionId, [userId])` | Get the first appointment in a session that a user can read | 1379 | -| `skipAppointmentInSession(data, sessionId, appointmentId)` | Mark an appointment as skipped in a session | 1407 | -| `topUpSession(data, sessionId)` | Add the next eligible appointment to a session if it is under its target size | 1430 | -| `getSessionReadingProgress(data, sessionId, currentAppointmentId, [userId])` | Get reading progress for a session | 1480 | +| `getAppointmentReadingMetadata(data, appointment)` | Get the reading metadata for an appointment's case | 54 | +| `writeReading(data, appointment, userId, reading, [sessionId])` | Save a user's read of an appointment's images, and take the appointment off | 68 | +| `unskipAppointmentInSession(data, sessionId, appointmentId)` | Take an appointment off a session's skipped list. | 109 | +| `getUnfinalisedUserReadsForSession(data, sessionId, userId)` | The user's not-yet-finalised reads in a session, each with the appointment | 131 | +| `finaliseReadOnCase(data, appointment, readingCase, userId, [finalisedAt])` | Finalise the user's read on one case, and settle what that makes true: a case | 173 | +| `finaliseUserReadsForSession(data, sessionId, userId)` | Finalise all the user's outstanding reads from a session. | 233 | +| `getEpisodeReadingStatus(data, episode, [userId])` | Get the reading status of an episode. | 263 | +| `getDeferredCases(data)` | Every case currently deferred from reading, most recently deferred first. | 287 | +| `getResolvedDeferrals(data)` | Every deferral that has since been resolved, most recently resolved first. | 314 | +| `enhanceAppointmentsWithReadingData(data, appointments, participants, userId)` | Enhance appointments with their reading case and pre-calculated metadata. | 364 | +| `getReadingStatusForAppointments(data, appointments, [userId])` | Get detailed reading status for a group of appointments | 560 | +| `getReadingProgress(data, appointments, currentAppointmentId, skippedAppointments, [userId])` | Get progress through reading a set of appointments | 611 | +| `sortAppointmentsByScreeningDate(appointments)` | Sort appointments by screening date (oldest first) | 749 | +| `getFirstAvailableClinic(data)` | Get the first clinic that still has appointments needing reads | 773 | +| `getReadingClinics(data, [options])` | Get all clinics available for reading, enriched with unit, location, and reading status | 784 | +| `getReadableAppointmentsForClinic(data, clinicId)` | Get readable appointments for a clinic with pre-calculated metadata | 822 | +| `filterAppointmentsByEligibleForReading(appointments)` | Filter appointments that are eligible for reading | 855 | +| `filterAppointmentsByNeedsAnyRead(data, appointments, maxReadsPerCase)` | Filter appointments that need any read (first or second) | 864 | +| `filterAppointmentsByNeedsFirstRead(data, appointments)` | Filter appointments that need a first read | 883 | +| `filterAppointmentsByNeedsSecondRead(data, appointments)` | Filter appointments that need a second read | 896 | +| `filterAppointmentsByNeedsArbitration(data, appointments, [userId])` | Filter appointments whose case sits in the arbitration backlog and which | 909 | +| `filterAppointmentsByFullyRead(data, appointments, requiredReads)` | Filter appointments that are fully read (have all required reads) | 933 | +| `filterAppointmentsByUserCanRead(data, appointments, userId)` | Filter appointments that a specific user can read | 952 | +| `filterAppointmentsByUserCanReadOrHasRead(data, appointments, userId, [options])` | Filter appointments that user can read or has already read | 966 | +| `filterAppointmentsByClinic(appointments, clinicId)` | Filter appointments for a specific clinic | 1003 | +| `filterAppointmentsByDayRange(appointments, minDays, [maxDays])` | Filter appointments that are within a specific day range | 1014 | +| `getFirstAppointmentInList(appointments)` | Get the first appointment from an array | 1038 | +| `getNextAppointmentInList(appointments, currentAppointmentId, wrap)` | Get the next appointment after a specific appointment | 1047 | +| `getPreviousAppointmentInList(appointments, currentAppointmentId, wrap)` | Get the previous appointment before a specific appointment | 1074 | +| `getFirstUserReadableAppointment(data, appointments, userId)` | Get first appointment from an array that a user can read | 1107 | +| `getNextUserReadableAppointment(data, appointments, currentAppointmentId, [userId])` | Get the next appointment the user can read after the current appointment, wrapping to start if needed | 1131 | +| `getNextCaseInSession(data, session, sessionAppointments, currentAppointmentId, userId)` | The next case to work on in a session, after the current one. | 1165 | +| `getFirstOutstandingCaseInSession(data, session, sessionAppointments, userId)` | The first case still to work on in a session, wherever it sits. | 1218 | +| `getResumeAppointmentForUser(data, appointments, [userId], [skippedAppointments])` | Get the appointment the user should resume reading from. | 1245 | +| `appointmentHasBeenArbitrated(data, appointment)` | Whether an appointment's case has been arbitrated. | 1318 | +| `canUserReadAppointment(data, appointment, [userId], [options])` | Check if a user can read an appointment's images. | 1345 | +| `getEligibleCandidatesForSession(data, sessionOptions)` | Get eligible appointment candidates for a session based on its type and filters | 1410 | +| `createReadingSession(data, options, options.type, [options.name], [options.clinicId], [options.sessionId], [options.limit], [options.filters])` | Create a session of appointments for reading based on specified criteria | 1498 | +| `getDefaultSessionName(type, clinicId, data)` | Generate a default name for a session based on its type | 1590 | +| `generateSessionId()` | Generate a unique ID for a session | 1627 | +| `getReadingSession(data, sessionId)` | Get a reading session by ID | 1636 | +| `getFirstReadableAppointmentInSession(data, sessionId, [userId])` | Get the first appointment in a session that a user can read | 1673 | +| `skipAppointmentInSession(data, sessionId, appointmentId)` | Mark an appointment as skipped in a session | 1706 | +| `topUpSession(data, sessionId)` | Add the next eligible appointment to a session if it is under its target size | 1729 | +| `getSessionReadingProgress(data, sessionId, currentAppointmentId, [userId])` | Get reading progress for a session | 1783 | ### prior-mammograms.js From e8381f170df0e3491c0e46efd42461ae7aabaf09 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Fri, 7 Aug 2026 12:37:06 +0100 Subject: [PATCH 19/27] Give arbitration its own settings section, with configurable confirm and lazy loading Settings move to a nested settings.reading.arbitration object (policy, flow, new confirmDecision and lazySessions). The setup pages gain standard validation, and the session overview drops the participant record link. --- app/data/session-data-defaults.js | 10 +++-- app/lib/utils/reading-cases.js | 4 +- app/lib/utils/reading.js | 11 ++++-- app/routes/arbitration.js | 23 ++++++++++- app/routes/reading.js | 46 +++++++++++++++------- app/views/reading/arbitration/panel.html | 2 +- app/views/reading/arbitration/session.html | 4 -- app/views/reading/arbitration/start.html | 2 +- app/views/settings.html | 28 ++++++++----- docs/IMAGE-READING-TECHNICAL-SUMMARY.md | 10 ++++- tests/e2e/reading.spec.js | 2 +- 11 files changed, 99 insertions(+), 43 deletions(-) diff --git a/app/data/session-data-defaults.js b/app/data/session-data-defaults.js index 8d721404..4ef7755d 100644 --- a/app/data/session-data-defaults.js +++ b/app/data/session-data-defaults.js @@ -116,11 +116,15 @@ const defaultSettings = { annotationsMode: 'with-images-simple', // 'without-images' | 'with-images-simple' | 'with-images' | 'with-images-progressive' secondReaderComparison: 'off', // 'early' | 'late' | 'off' compareWhen: 'non_normal', // 'non_normal' | 'discordant_only' - arbitrationPolicy: 'discordant_only', // 'discordant_only' | 'all_recalls' | 'all_non_normal' - arbitrationFlow: 'compare_first', // 'compare_first' | 'opinion_first' - what an arbitration case opens on finalisationDelay: '60', // minutes before reads auto-finalise; '0' immediate | 'never' manual only lazySessions: 'true', - defaultSessionSize: '25' + defaultSessionSize: '25', + arbitration: { + policy: 'discordant_only', // 'discordant_only' | 'all_recalls' | 'all_non_normal' + flow: 'compare_first', // 'compare_first' | 'opinion_first' - what an arbitration case opens on + confirmDecision: 'true', // show the review page before saving an arbitration decision + lazySessions: 'true' + } } } diff --git a/app/lib/utils/reading-cases.js b/app/lib/utils/reading-cases.js index 9ba32fd1..b549fb83 100644 --- a/app/lib/utils/reading-cases.js +++ b/app/lib/utils/reading-cases.js @@ -355,7 +355,7 @@ const areReadsDiscordant = (readA, readB) => { * Whether two reads mean the case needs arbitrating, taking the site's * arbitration policy into account. * - * Policies (from settings.reading.arbitrationPolicy): + * Policies (from settings.reading.arbitration.policy): * - 'discordant_only' (default): only discordant reads need arbitration * - 'all_recalls': concordant recalls for assessment do too * - 'all_non_normal': any concordant non-normal outcome does too @@ -372,7 +372,7 @@ const willGoToArbitration = (readA, readB, settings = {}) => { if (areReadsDiscordant(readA, readB)) return true // Concordant but non-normal: depends on policy - const policy = settings?.reading?.arbitrationPolicy || 'discordant_only' + const policy = settings?.reading?.arbitration?.policy || 'discordant_only' if (policy === 'all_non_normal') { return readA.opinion !== 'normal' } diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index cf7b0a9f..1a6fe70c 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -1498,7 +1498,8 @@ const getEligibleCandidatesForSession = (data, sessionOptions) => { /** * Create a session of appointments for reading based on specified criteria * - * When lazy sessions are enabled (settings.reading.lazySessions), non-clinic sessions + * When lazy sessions are enabled (settings.reading.lazySessions, or + * settings.reading.arbitration.lazySessions for arbitration), non-clinic sessions * start with only the first eligible appointment. The session is topped up one appointment at a * time via topUpSession() as reads and skips happen, until targetSize is reached. * @@ -1532,9 +1533,13 @@ const createReadingSession = (data, options) => { // Lazy loading: start with only the first appointment and top up as reads happen // Clinic sessions are always fully populated upfront + // Arbitration sessions have their own lazy setting // Explicit lazy param overrides the setting - const lazyEnabled = - lazy !== null ? lazy : data.settings?.reading?.lazySessions === 'true' + const lazySetting = + type === 'arbitration' + ? data.settings?.reading?.arbitration?.lazySessions + : data.settings?.reading?.lazySessions + const lazyEnabled = lazy !== null ? lazy : lazySetting === 'true' const isLazy = lazyEnabled && type !== 'clinic' // Get all eligible candidates using the shared helper diff --git a/app/routes/arbitration.js b/app/routes/arbitration.js index aeed2712..be438b6e 100644 --- a/app/routes/arbitration.js +++ b/app/routes/arbitration.js @@ -75,8 +75,18 @@ module.exports = (router) => { router.post('/reading/arbitration/start-answer', (req, res) => { const data = req.session.data + const mode = data.arbitrationTemp?.mode + + if (!mode) { + req.flash('error', { + text: 'Select who is arbitrating', + name: 'arbitrationTemp[mode]', + href: '#arbitration-mode' + }) + return res.redirect('/reading/arbitration/start') + } - if (data.arbitrationTemp?.mode === 'panel') { + if (mode === 'panel') { return res.redirect('/reading/arbitration/panel') } @@ -107,7 +117,16 @@ module.exports = (router) => { // The picker chooses who else; the current user is an arbitrator too const chosenUserIds = [] .concat(data.arbitrationTemp?.panelUserIds || []) - .filter(Boolean) + .filter((userId) => userId && userId !== '_unchecked') + + if (chosenUserIds.length === 0) { + req.flash('error', { + text: 'Select who is arbitrating with you', + name: 'arbitrationTemp[panelUserIds]', + href: '#panel-users' + }) + return res.redirect('/reading/arbitration/panel') + } delete data.arbitrationTemp diff --git a/app/routes/reading.js b/app/routes/reading.js index e2f8310c..993a2c8b 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -854,7 +854,7 @@ module.exports = (router) => { // settings choice, with the compare step following the opinion instead if ( isArbitrationSession && - data.settings?.reading?.arbitrationFlow !== 'opinion_first' + data.settings?.reading?.arbitration?.flow !== 'opinion_first' ) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/arbitration-compare` @@ -2050,7 +2050,7 @@ module.exports = (router) => { // its details rather than opening the case if ( isArbitrationSession && - data.settings?.reading?.arbitrationFlow === 'opinion_first' && + data.settings?.reading?.arbitration?.flow === 'opinion_first' && !formData?.comparisonComplete && !isEditingExistingRead ) { @@ -2085,12 +2085,21 @@ module.exports = (router) => { case 'normal': // opinion-details-complete is only reached for normal when the user // went through the normal-details page, so use confirmNormalWithDetails. - // Arbitration decisions are always confirmed, on the review page. + // Arbitration decisions confirm on the review page, unless the + // confirmDecision setting turns that off. if (isArbitrationSession && !isEditingExistingRead) { - return res.redirect( - modalBreakout( - `/reading/session/${sessionId}/appointments/${appointmentId}/review` + if ( + data.settings?.reading?.arbitration?.confirmDecision !== 'false' + ) { + return res.redirect( + modalBreakout( + `/reading/session/${sessionId}/appointments/${appointmentId}/review` + ) ) + } + return res.redirect( + 307, + `/reading/session/${sessionId}/appointments/${appointmentId}/save-opinion` ) } if ( @@ -2112,8 +2121,9 @@ module.exports = (router) => { : '' if ( !isEditingExistingRead && - (isArbitrationSession || - data.settings?.reading?.confirmTechnicalRecall !== 'false') + (isArbitrationSession + ? data.settings?.reading?.arbitration?.confirmDecision !== 'false' + : data.settings?.reading?.confirmTechnicalRecall !== 'false') ) { return res.redirect( modalBreakout( @@ -2133,8 +2143,9 @@ module.exports = (router) => { : '' if ( !isEditingExistingRead && - (isArbitrationSession || - data.settings?.reading?.confirmRecallForAssessment !== 'false') + (isArbitrationSession + ? data.settings?.reading?.arbitration?.confirmDecision !== 'false' + : data.settings?.reading?.confirmRecallForAssessment !== 'false') ) { return res.redirect( modalBreakout( @@ -2399,17 +2410,21 @@ module.exports = (router) => { switch (opinion) { case 'normal': // Arbitration: opinion-first sends the outcome through the compare - // step; either way the decision is confirmed on the review page + // step; the decision then confirms on the review page unless the + // confirmDecision setting turns that off if (isArbitrationSession) { if ( - data.settings?.reading?.arbitrationFlow === 'opinion_first' && + data.settings?.reading?.arbitration?.flow === 'opinion_first' && !data.imageReadingTemp.comparisonComplete ) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/arbitration-compare` ) } - if (!isEditingExistingRead) { + if ( + !isEditingExistingRead && + data.settings?.reading?.arbitration?.confirmDecision !== 'false' + ) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/review` ) @@ -2526,7 +2541,10 @@ module.exports = (router) => { } } - if (isEditingExistingRead) { + if ( + isEditingExistingRead || + data.settings?.reading?.arbitration?.confirmDecision === 'false' + ) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/save-opinion` ) diff --git a/app/views/reading/arbitration/panel.html b/app/views/reading/arbitration/panel.html index 222b2be5..7652f671 100644 --- a/app/views/reading/arbitration/panel.html +++ b/app/views/reading/arbitration/panel.html @@ -36,7 +36,7 @@ text: "Select everyone taking part. Outcomes will be recorded as agreed by the group." }, items: userItems - }) }} + } | populateErrors) }} {{ button({ text: "Start arbitrating" diff --git a/app/views/reading/arbitration/session.html b/app/views/reading/arbitration/session.html index 47897304..dee5801c 100644 --- a/app/views/reading/arbitration/session.html +++ b/app/views/reading/arbitration/session.html @@ -147,10 +147,6 @@

Arbitration cases

{{ "Has symptoms" | toTag }} {% endif %} {{ appointment.participant | getFullNameReversed }} -
- - Participant record -

{% endfor %} + + {# Placeholder rows for pending lazy session slots #} + {% set placeholderRowsShown = 3 if pendingCount > 3 else pendingCount %} + {% for i in range(0, placeholderRowsShown) %} + + + + + + + + + + {% endfor %}
No. Case Screening date{{ "Your outcome" if session.type == 'arbitration' else "Your opinion" }}Your opinion Action
{{ loop.index }}. - {% set isDone = (data | appointmentHasBeenArbitrated(appointment)) if isArbitration else (data | userHasReadAppointment(appointment)) %} + {% set isDone = data | userHasReadAppointment(appointment) %} {% if isDone %} - {% set read = (appointment.readingCase | getArbitrationRead) if isArbitration else (appointment.readingCase | getReadForUser(data.currentUser.id)) %} + {% set read = appointment.readingCase | getReadForUser(data.currentUser.id) %} {% if read.opinion %} {{ read.opinion | toTag }} {% endif %} @@ -338,7 +314,7 @@

{{ "Arbitration cases" if isArbitration else "Reading session cases" }}

{% endif %}
- {% if resumeAppointment and appointment.id == resumeAppointment.id and not ((data | appointmentHasBeenArbitrated(appointment)) if isArbitration else (data | userHasReadAppointment(appointment))) %} + {% if resumeAppointment and appointment.id == resumeAppointment.id and not (data | userHasReadAppointment(appointment)) %} {{ readingActionText }} {% endif %} {% set daysSinceScreening = appointment.timing.startTime | daysSince %} diff --git a/app/views/reading/arbitration/start.html b/app/views/reading/arbitration/start.html index 6234694d..a5ee49f8 100644 --- a/app/views/reading/arbitration/start.html +++ b/app/views/reading/arbitration/start.html @@ -43,7 +43,7 @@

{{ pageHeading }}

text: "Me with other people" } ] - }) }} + } | populateErrors) }} {{ button({ text: "Continue" diff --git a/app/views/settings.html b/app/views/settings.html index 3fa56681..3e573dda 100755 --- a/app/views/settings.html +++ b/app/views/settings.html @@ -190,24 +190,32 @@

Image reading

{value: "never", label: "Manual only"} ], data.settings.reading.finalisationDelay, "60") }} - {# Arbitration policy #} - {{ settingToggle("Arbitration policy", "settings[reading][arbitrationPolicy]", [ + {# Mammogram view order #} + {{ settingToggle("Mammogram view order", "settings[mammogramViewOrder]", [ + {value: "cc-first", label: "CC first"}, + {value: "mlo-first", label: "MLO first"} + ], data.settings.mammogramViewOrder, "mlo-first") }} + +

Arbitration

+ + {# Arbitration policy - which reads go to arbitration #} + {{ settingToggle("Arbitration policy", "settings[reading][arbitration][policy]", [ {value: "discordant_only", label: "Discordant reads only"}, {value: "all_recalls", label: "All recalls for assessment"}, {value: "all_non_normal", label: "All non-normal outcomes"} - ], data.settings.reading.arbitrationPolicy, "discordant_only") }} + ], data.settings.reading.arbitration.policy, "discordant_only") }} {# Arbitration flow order - what an arbitration case opens on #} - {{ settingToggle("Arbitration flow", "settings[reading][arbitrationFlow]", [ + {{ settingToggle("Arbitration flow", "settings[reading][arbitration][flow]", [ {value: "compare_first", label: "Compare reads first"}, {value: "opinion_first", label: "Own outcome first"} - ], data.settings.reading.arbitrationFlow, "compare_first") }} + ], data.settings.reading.arbitration.flow, "compare_first") }} - {# Mammogram view order #} - {{ settingToggle("Mammogram view order", "settings[mammogramViewOrder]", [ - {value: "cc-first", label: "CC first"}, - {value: "mlo-first", label: "MLO first"} - ], data.settings.mammogramViewOrder, "mlo-first") }} + {# Confirm decision - show the review page before saving #} + {{ settingToggle("Confirm arbitration decision", "settings[reading][arbitration][confirmDecision]", [{value: "true", label: "Required"}, {value: "false", label: "Not required"}], data.settings.reading.arbitration.confirmDecision, "true") }} + + {# Lazy sessions - arbitration sessions claim cases one at a time #} + {{ settingToggle("Lazy sessions", "settings[reading][arbitration][lazySessions]", [{value: "true", label: "Enabled"}, {value: "false", label: "Disabled"}], data.settings.reading.arbitration.lazySessions, "false") }}

Generated data

diff --git a/docs/IMAGE-READING-TECHNICAL-SUMMARY.md b/docs/IMAGE-READING-TECHNICAL-SUMMARY.md index 47f7f597..a20843dd 100644 --- a/docs/IMAGE-READING-TECHNICAL-SUMMARY.md +++ b/docs/IMAGE-READING-TECHNICAL-SUMMARY.md @@ -422,7 +422,7 @@ Templates receive via `res.locals`: - `getOtherReads(appointment, userId)` - Get reads from other users (for comparison) - `writeReading(data, appointment, userId, reading, sessionId)` - Saves a read onto the appointment's case, settles readNumber and readType, removes from skipped list - `areReadsDiscordant(readA, readB)` - Compares opinions, TR views, and RFA breast assessments -- `willGoToArbitration(readA, readB, settings)` - Policy-aware: always true if discordant; may be true for concordant non-normal depending on `arbitrationPolicy` +- `willGoToArbitration(readA, readB, settings)` - Policy-aware: always true if discordant; may be true for concordant non-normal depending on `settings.reading.arbitration.policy` - `getReadingCaseState(readingCase, settings, now)` - Where the case has got to: `awaiting_first_read` | `awaiting_second_read` | `awaiting_finalisation` | `awaiting_arbitration` | `in_arbitration` | `concluded` - `getReadingCaseOutcome(readingCase, settings, now)` - What it found: `normal` | `technical_recall` | `recall_for_assessment`, or `null` while reading is still under way. The arbitration read, where there is one, is the deciding read. - `isReadFinalised(read, settings, now)` - Whether a read is finalised: explicitly (`finalisedAt`) or automatically once `settings.reading.finalisationDelay` minutes have passed since the read (`'0'` immediate, `'never'` manual only) @@ -551,10 +551,16 @@ Reading behavior is configured via: - `indexLayout` - Reading dashboard layout: `'simple'` (default) | `'complex'` - `secondReaderComparison` - When to show compare page: `'early'` | `'late'` | `'off'` (default) - `compareWhen` - Which cases trigger compare: `'non_normal'` (default) | `'discordant_only'` -- `arbitrationPolicy` - When reads go to arbitration: `'discordant_only'` (default) | `'all_non_normal'` - `lazySessions` - Build sessions lazily one case at a time: `'true'` (default) | `'false'` - `defaultSessionSize` - Default session size (default: 25) +**Arbitration settings** (in `settings.reading.arbitration`): + +- `policy` - When reads go to arbitration: `'discordant_only'` (default) | `'all_recalls'` | `'all_non_normal'` +- `flow` - What an arbitration case opens on: `'compare_first'` (default) | `'opinion_first'` +- `confirmDecision` - Confirm arbitration decisions on the review page: `'true'` (default) | `'false'` +- `lazySessions` - Build arbitration sessions lazily, separate from reading's setting + **Hard config** (in `config.reading`): - `priorityThreshold` - Days until "due soon" diff --git a/tests/e2e/reading.spec.js b/tests/e2e/reading.spec.js index 1ff8a72d..c15bd8f3 100644 --- a/tests/e2e/reading.spec.js +++ b/tests/e2e/reading.spec.js @@ -289,7 +289,7 @@ test.describe('Image reading', () => { await pinSettings(page, { ...readingSettings, // Concordant reads conclude without arbitration - 'settings[reading][arbitrationPolicy]': 'discordant_only' + 'settings[reading][arbitration][policy]': 'discordant_only' }) // Picked from the seed data so the first read's opinion is known - the From c58c72c2deb047861d57eb755310c81073884237 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Fri, 7 Aug 2026 12:45:35 +0100 Subject: [PATCH 20/27] Show placeholder rows on the lazy arbitration session overview The overview now mirrors reading's pending-slot placeholders and counts remaining against the whole session, not just the loaded rows. Arbitration sessions no longer count fully-read cases as dead slots - every arbitration case is fully read by definition, so the per-user readability test had collapsed the session's effective size. --- app/lib/utils/reading.js | 27 +++++++++++--------- app/views/reading/arbitration/session.html | 29 +++++++++++++++++++++- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index 1a6fe70c..2405f824 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -1836,19 +1836,22 @@ const getSessionReadingProgress = ( // Dead appointments — fully read by other users and not actionable by this user. // They occupy session slots but can never be completed, so they don't count // toward reachable size. topUpSession will replace them when appointments are read. + // Arbitration has no dead slots: every case is fully read by definition (so + // canUserReadAppointment is the wrong test) and the session's arbitrators can + // always settle a case that hasn't been arbitrated yet. const isArbitration = session.type === 'arbitration' - const deadCount = sessionAppointments.filter((appointment) => { - const isDone = isArbitration - ? appointmentHasBeenArbitrated(data, appointment) - : userHasReadAppointment(data, appointment, resolvedUserId) - - return ( - !isDone && - !canUserReadAppointment(data, appointment, resolvedUserId) && - !isCaseDeferred(getReadingCase(data, appointment)) && - !awaitingPriors(appointment) - ) - }).length + const deadCount = isArbitration + ? 0 + : sessionAppointments.filter((appointment) => { + const isDone = userHasReadAppointment(data, appointment, resolvedUserId) + + return ( + !isDone && + !canUserReadAppointment(data, appointment, resolvedUserId) && + !isCaseDeferred(getReadingCase(data, appointment)) && + !awaitingPriors(appointment) + ) + }).length const reachableSessionSize = sessionAppointments.length - deadCount + availableTopUpCount diff --git a/app/views/reading/arbitration/session.html b/app/views/reading/arbitration/session.html index dee5801c..857cd28a 100644 --- a/app/views/reading/arbitration/session.html +++ b/app/views/reading/arbitration/session.html @@ -116,7 +116,13 @@

Progress: {{ arbitratedCount }} arbitrated, {{ remainingCount }} remaining

{% endif %} @@ -208,6 +214,27 @@

Arbitration cases

+ {{ (appointments | length) + loop.index }}. + + + {% if resumeAppointment and pendingCount > 3 and loop.last %} + and {{ pendingCount - 3 }} more in this session + {% endif %} +
From 7260aa7c193cdaa86b079de4cf7d676c250ae4d8 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Fri, 7 Aug 2026 12:53:32 +0100 Subject: [PATCH 21/27] Unlink the participant name on the arbitration session overview A linked name reads as a link to the participant record, which is not where it went. The name is now plain bold text, matching the clinic list, and each row gets an action link that says what opening the case does - arbitrate, view outcome, or view case - with the name visually hidden for screen readers. Also stops arbitration sessions creeping past their target size: topUpSession counted every un-arbitrated case as a dead slot (fully-read cases fail the per-user readability test), so each decision topped up an extra case. --- app/lib/utils/reading.js | 30 ++++++++++++---------- app/views/reading/arbitration/session.html | 13 +++++++++- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index 2405f824..5b3754f4 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -1751,20 +1751,24 @@ const topUpSession = (data, sessionId) => { // Count appointments that are still actionable for this user — appointments they have read, // can still read, deferred, or awaiting priors. Appointments fully read by other readers // ('dead' slots) are excluded so the session can be topped up to replace them. + // Arbitration has no dead slots: every case is fully read by definition (so + // canUserReadAppointment is the wrong test) and a claimed case can always be + // settled by the session's arbitrators, so every slot counts. const isArbitration = session.type === 'arbitration' - const actionableCount = session.appointmentIds.filter((appointmentId) => { - const appointment = data.appointments.find((e) => e.id === appointmentId) - if (!appointment) return false - const isDone = isArbitration - ? appointmentHasBeenArbitrated(data, appointment) - : userHasReadAppointment(data, appointment, currentUserId) - return ( - isDone || - canUserReadAppointment(data, appointment, currentUserId) || - isCaseDeferred(getReadingCase(data, appointment)) || - awaitingPriors(appointment) - ) - }).length + const actionableCount = isArbitration + ? session.appointmentIds.length + : session.appointmentIds.filter((appointmentId) => { + const appointment = data.appointments.find( + (e) => e.id === appointmentId + ) + if (!appointment) return false + return ( + userHasReadAppointment(data, appointment, currentUserId) || + canUserReadAppointment(data, appointment, currentUserId) || + isCaseDeferred(getReadingCase(data, appointment)) || + awaitingPriors(appointment) + ) + }).length if (!session.targetSize || actionableCount >= session.targetSize) return false diff --git a/app/views/reading/arbitration/session.html b/app/views/reading/arbitration/session.html index 857cd28a..bcb196d4 100644 --- a/app/views/reading/arbitration/session.html +++ b/app/views/reading/arbitration/session.html @@ -152,7 +152,7 @@

Arbitration cases

{% if appointment.medicalInformation.symptoms | length %} {{ "Has symptoms" | toTag }} {% endif %} - {{ appointment.participant | getFullNameReversed }} + {{ appointment.participant | getFullNameReversed }} {% set daysSinceScreening = appointment.timing.startTime | daysSince %} @@ -210,6 +210,17 @@

Arbitration cases

{% if isNextCase %} {{ arbitrationActionText }} + {% else %} + {# The link says what opening the case will do, with the name + hidden for screen readers so each row's link is distinct #} + {% if arbitrationRead %} + {% set caseActionText = "View outcome" %} + {% elseif appointment.readingCase | isCaseDeferred %} + {% set caseActionText = "View case" %} + {% else %} + {% set caseActionText = "Arbitrate case" %} + {% endif %} + {{ caseActionText }} {{- ((" for " + (appointment.participant | getShortName)) | asVisuallyHiddenText) | safe }} {% endif %} From 6bf8b49f845efc535e46480e4d4da497f14d35b5 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Fri, 7 Aug 2026 13:03:23 +0100 Subject: [PATCH 22/27] Resume arbitration by case state, not per-user readability A panel session showed 'Session complete' with cases still to arbitrate whenever the next case was one the current user had originally read: getResumeAppointmentForUser tested readability per user, which excludes cases you were an original reader on. Arbitration resume now asks only whether a case has been arbitrated or deferred. --- app/lib/utils/reading.js | 50 +++++++++++++++++++++++++++------------- app/routes/reading.js | 6 +++-- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index 5b3754f4..4a496112 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -1258,45 +1258,63 @@ const getFirstOutstandingCaseInSession = ( * @param {Array} appointments - Array of all appointments in the session, in session order * @param {string | null} [userId] - User ID (falls back to current user from context) * @param {Array} [skippedAppointments] - Array of skipped appointment IDs from the session + * @param {object} [session] - The reading session; arbitration sessions resume by case state, not per-user readability * @returns {object | null} The appointment to resume from, or null if nothing to read */ const getResumeAppointmentForUser = function ( data, appointments, userId = null, - skippedAppointments = [] + skippedAppointments = [], + session = null ) { const currentUserId = userId || this?.ctx?.data?.currentUser?.id - // Find the highest-index appointment the user has read or that has been skipped + // Arbitration settles a case for everyone, so both the resume position and + // the next case are case-shaped questions. The per-user readability tests + // below would wrongly exclude cases a panel arbitrator originally read. + const isArbitration = session?.type === 'arbitration' + + const hasActed = (appointment) => + skippedAppointments.includes(appointment.id) || + (isArbitration + ? caseHasBeenArbitrated(resolveCase(data, appointment)) + : userHasReadCase(resolveCase(data, appointment), currentUserId)) + + const firstActionable = (candidates) => { + if (!isArbitration) { + return getFirstUserReadableAppointment(data, candidates, currentUserId) + } + return ( + candidates.find((appointment) => { + const readingCase = resolveCase(data, appointment) + return ( + !caseHasBeenArbitrated(readingCase) && !isCaseDeferred(readingCase) + ) + }) || null + ) + } + + // Find the highest-index appointment acted on - read/arbitrated or skipped let lastActedIndex = -1 appointments.forEach((appointment, index) => { - const wasReadByUser = userHasReadCase( - resolveCase(data, appointment), - currentUserId - ) - const wasSkipped = skippedAppointments.includes(appointment.id) - if (wasReadByUser || wasSkipped) { + if (hasActed(appointment)) { lastActedIndex = index } }) - // Nothing acted on yet — fall back to first readable + // Nothing acted on yet — fall back to first actionable if (lastActedIndex === -1) { - return getFirstUserReadableAppointment(data, appointments, currentUserId) + return firstActionable(appointments) } - // Search for the first readable appointment after lastActedIndex, wrapping around + // Search for the first actionable appointment after lastActedIndex, wrapping around const appointmentsFromNext = [ ...appointments.slice(lastActedIndex + 1), ...appointments.slice(0, lastActedIndex + 1) ] - return getFirstUserReadableAppointment( - data, - appointmentsFromNext, - currentUserId - ) + return firstActionable(appointmentsFromNext) } /************************************************************************ diff --git a/app/routes/reading.js b/app/routes/reading.js index 993a2c8b..acad33fc 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -436,7 +436,8 @@ module.exports = (router) => { data, sessionAppointments, data.currentUser.id, - session.skippedAppointments || [] + session.skippedAppointments || [], + session ) if (resumeAppointment) { @@ -602,7 +603,8 @@ module.exports = (router) => { data, enhancedAppointments, data.currentUser.id, - session.skippedAppointments || [] + session.skippedAppointments || [], + session ) // The user's reads still awaiting finalisation, and when the first will From f1fa218dcf56e6c9c57fd3ff54a93b96f8ae807a Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Fri, 7 Aug 2026 13:09:44 +0100 Subject: [PATCH 23/27] Widen the arbitration overview and unlink names on the reading overview The arbitration session overview gets the wide page width so its table fits the grid. The reading session overview follows the same pattern as arbitration: names are plain bold text and each row carries an action link saying what opening the case does (read case, view read, view case), with the all-reads table gaining an action column. --- app/views/reading/arbitration/session.html | 1 + app/views/reading/session.html | 29 ++++++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/views/reading/arbitration/session.html b/app/views/reading/arbitration/session.html index bcb196d4..32856074 100644 --- a/app/views/reading/arbitration/session.html +++ b/app/views/reading/arbitration/session.html @@ -12,6 +12,7 @@ {% set pageHeading = session.name %} {% set gridColumn = "none" %} +{% set bodyClasses = (bodyClasses or "") + " app-page-width--wide" %} {% block pageContent %} diff --git a/app/views/reading/session.html b/app/views/reading/session.html index dc561d10..17adedb9 100644 --- a/app/views/reading/session.html +++ b/app/views/reading/session.html @@ -257,7 +257,7 @@

Reading session cases

{% if appointment.medicalInformation.symptoms | length %} {{ "Has symptoms" | toTag }} {% endif %} - {{ appointment.participant | getFullNameReversed }} + {{ appointment.participant | getFullNameReversed }}
@@ -316,6 +316,17 @@

Reading session cases

{% if resumeAppointment and appointment.id == resumeAppointment.id and not (data | userHasReadAppointment(appointment)) %} {{ readingActionText }} + {% else %} + {# The link says what opening the case will do, with the name + hidden for screen readers so each row's link is distinct #} + {% if data | userHasReadAppointment(appointment) %} + {% set caseActionText = "View read" %} + {% elseif (appointment.readingCase | isCaseDeferred) or (appointment | userRequestedPriors(data.currentUser.id)) %} + {% set caseActionText = "View case" %} + {% else %} + {% set caseActionText = "Read case" %} + {% endif %} + {{ caseActionText }} {{- ((" for " + (appointment.participant | getShortName)) | asVisuallyHiddenText) | safe }} {% endif %} @@ -439,6 +450,7 @@

Reading session cases

1st read 2nd read Outcome + Action @@ -455,7 +467,7 @@

Reading session cases

{% if appointment.medicalInformation.symptoms | length %} {{ "Has symptoms" | toTag }} {% endif %} - {{ appointment.participant | getFullNameReversed }} + {{ appointment.participant | getFullNameReversed }} {% if appointment | awaitingPriors %}
@@ -558,6 +570,18 @@

Reading session cases

{{ (appointment.readingCase | getReadingCaseOutcome(data.settings)) | toTag }} {% endif %} + + {# The link says what opening the case will do, with the name + hidden for screen readers so each row's link is distinct #} + {% if data | userHasReadAppointment(appointment) %} + {% set caseActionText = "View read" %} + {% elseif (data | canUserReadAppointment(appointment)) and not (appointment | userRequestedPriors(data.currentUser.id)) %} + {% set caseActionText = "Read case" %} + {% else %} + {% set caseActionText = "View case" %} + {% endif %} + {{ caseActionText }} {{- ((" for " + (appointment.participant | getShortName)) | asVisuallyHiddenText) | safe }} + {% endfor %} @@ -578,6 +602,7 @@

Reading session cases

+ {% endfor %} From ca3928fbe7471dcc290048528d0941f89b1c8e32 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Fri, 7 Aug 2026 13:28:27 +0100 Subject: [PATCH 24/27] Hide original opinions on the arbitration overview until a case is arbitrated New arbitration setting hideReadsUntilArbitrated (default on): pending cases show a neutral Completed tag with the reader's name, revealing the opinions once the case is settled. Keeps the overview from pre-announcing the disagreement ahead of the arbitrator forming their own view. --- app/data/session-data-defaults.js | 1 + app/views/reading/arbitration/session.html | 14 ++++++++++---- app/views/settings.html | 3 +++ docs/IMAGE-READING-TECHNICAL-SUMMARY.md | 1 + 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/data/session-data-defaults.js b/app/data/session-data-defaults.js index 4ef7755d..69a7aaac 100644 --- a/app/data/session-data-defaults.js +++ b/app/data/session-data-defaults.js @@ -123,6 +123,7 @@ const defaultSettings = { policy: 'discordant_only', // 'discordant_only' | 'all_recalls' | 'all_non_normal' flow: 'compare_first', // 'compare_first' | 'opinion_first' - what an arbitration case opens on confirmDecision: 'true', // show the review page before saving an arbitration decision + hideReadsUntilArbitrated: 'true', // overview hides the original opinions until the case is arbitrated lazySessions: 'true' } } diff --git a/app/views/reading/arbitration/session.html b/app/views/reading/arbitration/session.html index 32856074..c3fa587b 100644 --- a/app/views/reading/arbitration/session.html +++ b/app/views/reading/arbitration/session.html @@ -168,14 +168,20 @@

Arbitration cases

- {# The two original reads. Arbitration shows both by design - there - is no blind-reading gate here, the disagreement is the point. #} + {# The two original reads. Until the case is arbitrated the opinions + can be hidden (per hideReadsUntilArbitrated) so the arbitrator + forms their own view first; who read is never sensitive. #} + {% set hideReads = (data.settings.reading.arbitration.hideReadsUntilArbitrated != 'false') and not arbitrationRead %} {% for readIndex in [0, 1] %} {% set originalRead = originalReads[readIndex] %} {% if originalRead %} - {# Forced grey so the two reads read as context, not as the outcome #} - {{ originalRead.opinion | toTag({ colour: "grey" }) }} + {% if hideReads %} + {{ "Completed" | toTag({ colour: "grey" }) }} + {% else %} + {# Forced grey so the two reads read as context, not as the outcome #} + {{ originalRead.opinion | toTag({ colour: "grey" }) }} + {% endif %}
by {%- for authorId in originalRead | getReadAuthorIds %} diff --git a/app/views/settings.html b/app/views/settings.html index 3e573dda..e3468c97 100755 --- a/app/views/settings.html +++ b/app/views/settings.html @@ -214,6 +214,9 @@

Arbitration

{# Confirm decision - show the review page before saving #} {{ settingToggle("Confirm arbitration decision", "settings[reading][arbitration][confirmDecision]", [{value: "true", label: "Required"}, {value: "false", label: "Not required"}], data.settings.reading.arbitration.confirmDecision, "true") }} + {# Original reads on the session overview #} + {{ settingToggle("Original reads on session overview", "settings[reading][arbitration][hideReadsUntilArbitrated]", [{value: "true", label: "Hidden until arbitrated"}, {value: "false", label: "Always shown"}], data.settings.reading.arbitration.hideReadsUntilArbitrated, "true") }} + {# Lazy sessions - arbitration sessions claim cases one at a time #} {{ settingToggle("Lazy sessions", "settings[reading][arbitration][lazySessions]", [{value: "true", label: "Enabled"}, {value: "false", label: "Disabled"}], data.settings.reading.arbitration.lazySessions, "false") }} diff --git a/docs/IMAGE-READING-TECHNICAL-SUMMARY.md b/docs/IMAGE-READING-TECHNICAL-SUMMARY.md index a20843dd..cde238a1 100644 --- a/docs/IMAGE-READING-TECHNICAL-SUMMARY.md +++ b/docs/IMAGE-READING-TECHNICAL-SUMMARY.md @@ -559,6 +559,7 @@ Reading behavior is configured via: - `policy` - When reads go to arbitration: `'discordant_only'` (default) | `'all_recalls'` | `'all_non_normal'` - `flow` - What an arbitration case opens on: `'compare_first'` (default) | `'opinion_first'` - `confirmDecision` - Confirm arbitration decisions on the review page: `'true'` (default) | `'false'` +- `hideReadsUntilArbitrated` - Session overview hides the original opinions until the case is arbitrated: `'true'` (default) | `'false'` - `lazySessions` - Build arbitration sessions lazily, separate from reading's setting **Hard config** (in `config.reading`): From 1dd97bc334c13928ad94a80449a9282d7c6fb8d7 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Fri, 7 Aug 2026 13:42:59 +0100 Subject: [PATCH 25/27] Move the symptoms tag below the participant name on session overviews The block-displayed tag stacked above the name once the name stopped being an inline link. The name is now the block element, with the tag (and the awaiting-priors tag on all-reads) following it. --- app/views/reading/arbitration/session.html | 2 +- app/views/reading/session.html | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/app/views/reading/arbitration/session.html b/app/views/reading/arbitration/session.html index c3fa587b..f5644cdb 100644 --- a/app/views/reading/arbitration/session.html +++ b/app/views/reading/arbitration/session.html @@ -150,10 +150,10 @@

Arbitration cases

{{ loop.index }}. +
{{ appointment.participant | getFullNameReversed }}
{% if appointment.medicalInformation.symptoms | length %} {{ "Has symptoms" | toTag }} {% endif %} - {{ appointment.participant | getFullNameReversed }} {% set daysSinceScreening = appointment.timing.startTime | daysSince %} diff --git a/app/views/reading/session.html b/app/views/reading/session.html index 17adedb9..5f3f5b9d 100644 --- a/app/views/reading/session.html +++ b/app/views/reading/session.html @@ -254,12 +254,10 @@

Reading session cases

{% set readingHref -%} /reading/session/{{ session.id }}/appointments/{{ appointment.id }} {%- endset %} +
{{ appointment.participant | getFullNameReversed }}
{% if appointment.medicalInformation.symptoms | length %} {{ "Has symptoms" | toTag }} {% endif %} - {{ appointment.participant | getFullNameReversed }} - -
{% set readCount = metadata.readCount %} {% if data | userHasReadAppointment(appointment) %} @@ -464,13 +462,11 @@

Reading session cases

{% set readingHref -%} /reading/session/{{ session.id }}/appointments/{{ appointment.id }} {%- endset %} +
{{ appointment.participant | getFullNameReversed }}
{% if appointment.medicalInformation.symptoms | length %} {{ "Has symptoms" | toTag }} {% endif %} - {{ appointment.participant | getFullNameReversed }} - {% if appointment | awaitingPriors %} -
{{ "Awaiting priors" | toTag }} {% endif %} {% if (data.settings.debugMode | falsify) %} From 366bb13910735cc885785094cdd1ba35ae4abe79 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Fri, 7 Aug 2026 13:52:22 +0100 Subject: [PATCH 26/27] Left-align the session overview action column The right alignment suited a column holding only the next-case button; with an action link on every row it read as misaligned. --- app/assets/sass/components/_reading.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/sass/components/_reading.scss b/app/assets/sass/components/_reading.scss index f69e83d7..a6a4f9c3 100644 --- a/app/assets/sass/components/_reading.scss +++ b/app/assets/sass/components/_reading.scss @@ -117,7 +117,6 @@ .app-placeholder-action-cell { padding-right: 15px; - text-align: right; white-space: nowrap; } From d3c08a2986ae11c5a3ec6f16a96a05b5466a1ab5 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Fri, 7 Aug 2026 13:52:52 +0100 Subject: [PATCH 27/27] Remove margins making rows too tall --- app/views/reading/arbitration/session.html | 2 +- app/views/reading/session.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/reading/arbitration/session.html b/app/views/reading/arbitration/session.html index f5644cdb..f2f5d530 100644 --- a/app/views/reading/arbitration/session.html +++ b/app/views/reading/arbitration/session.html @@ -163,7 +163,7 @@

Arbitration cases

{{ "Due soon" | toTag }}
{% endif %} {{ appointment.timing.startTime | formatDate }}
- + {{ appointment.timing.startTime | formatRelativeDate }} diff --git a/app/views/reading/session.html b/app/views/reading/session.html index 5f3f5b9d..c3eaabfd 100644 --- a/app/views/reading/session.html +++ b/app/views/reading/session.html @@ -290,7 +290,7 @@

Reading session cases


{% endif %} {{ appointment.timing.startTime | formatDate }}
- + {{ appointment.timing.startTime | formatRelativeDate }}