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/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; } diff --git a/app/data/session-data-defaults.js b/app/data/session-data-defaults.js index a5528111..69a7aaac 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', @@ -115,10 +116,16 @@ 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' 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 + hideReadsUntilArbitrated: 'true', // overview hides the original opinions until the case is arbitrated + lazySessions: 'true' + } } } 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/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/lib/utils/reading-cases.js b/app/lib/utils/reading-cases.js index db71a8e0..b549fb83 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,24 @@ const getOtherReads = (readingCase, userId) => { */ const getArbitrationRead = (readingCase) => { return ( - getReadsAsArray(readingCase).find((read) => read.readType === 'arbitration') || - null + getReadsAsArray(readingCase).find( + (read) => read.readType === 'arbitration' + ) || null + ) +} + +/** + * 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' ) } @@ -186,6 +203,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 * @@ -196,6 +244,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. * @@ -284,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 @@ -301,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' } @@ -342,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 * @@ -374,8 +469,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)) { @@ -438,8 +538,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 = @@ -464,7 +564,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 ) @@ -515,6 +615,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' } @@ -532,13 +639,20 @@ 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 // 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. Panel arbitrators may + // have been an original reader, so skip the user check for panels. + if (isCaseInArbitration(readingCase) && !getArbitrationRead(readingCase)) { + return panelArbitration || !userHasReadCase(readingCase, userId) + } + // Enough readers have had it already if (getReadsAsArray(readingCase).length >= maxReadsPerCase) return false @@ -646,28 +760,49 @@ 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.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] - return { + const read = { ...reading, - readerId: userId, readerType, readType, readNumber, timestamp: options.timestamp || new Date().toISOString() } + + // 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 } /** @@ -682,13 +817,24 @@ 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 - ? reads.map((candidate, index) => (index === existingIndex ? read : candidate)) + ? reads.map((candidate, index) => + index === existingIndex ? read : candidate + ) : [...reads, read] return { ...readingCase, reads: updatedReads } @@ -709,7 +855,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(), @@ -752,8 +898,12 @@ module.exports = { getReadForUser, getOtherReads, getArbitrationRead, + getOriginalReads, + getReadAuthorIds, + caseHasBeenArbitrated, userHasReadCase, caseHasReads, + withArbitrationRelease, isCaseDeferred, isCaseInArbitration, areReadsDiscordant, @@ -761,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 998f7387..4a496112 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -23,14 +23,19 @@ const { const { getReadsAsArray, getReadForUser, + getReadAuthorIds, getReadingMetadata, getReadingCaseState, getReadingCaseOutcome, isReadFinalised, isCaseDeferred, caseHasReads, + caseHasBeenArbitrated, caseNeedsFirstRead, caseNeedsSecondRead, + caseNeedsArbitration, + isCaseInArbitration, + getArbitrationRead, canUserReadCase, userHasReadCase, buildRead, @@ -54,7 +59,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 || {} + ) } /** @@ -81,7 +89,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, { + arbitratorIds: session?.arbitration?.arbitratorIds + }) const updatedCase = withRead(readingCase, read) updateReadingCase(data, appointment.episodeId, updatedCase) @@ -90,21 +101,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 (sessionId && data.readingSessions?.[sessionId]) { - const session = data.readingSessions[sessionId] - - // 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 @@ -113,6 +136,10 @@ const writeReading = (data, appointment, userId, reading, sessionId = null) => { * "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 @@ -131,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 @@ -142,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 @@ -153,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}} @@ -165,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 } @@ -247,7 +306,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) ) } @@ -275,7 +335,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) ) } @@ -386,7 +447,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 @@ -502,7 +565,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) @@ -572,23 +639,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 @@ -637,7 +725,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) @@ -645,7 +737,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 @@ -659,7 +753,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 [] } @@ -695,7 +793,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( @@ -729,7 +829,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 @@ -768,7 +869,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 @@ -801,6 +906,30 @@ 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) * @@ -809,7 +938,11 @@ const filterAppointmentsByNeedsSecondRead = (data, appointments) => { * @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 @@ -858,6 +991,10 @@ 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, 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 }) @@ -882,7 +1019,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) => @@ -911,8 +1052,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 @@ -932,8 +1079,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 @@ -942,7 +1095,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 } /************************************************************************ @@ -957,7 +1112,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 @@ -987,11 +1146,100 @@ 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 + ) +} + +/** + * 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 } + ) + } + + // 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 + ) + + // Forward only: running out is what ends the session, same as reading + return sessionAppointments.slice(currentIndex + 1).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)) + ) } /** @@ -1010,41 +1258,63 @@ const getNextUserReadableAppointment = function ( * @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) } /************************************************************************ @@ -1063,6 +1333,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 @@ -1110,7 +1394,6 @@ const canUserReadAppointment = function ( return canUserReadCase(resolveCase(data, appointment), currentUserId, options) } - /************************************************************************ // Sessions //*********************************************************************** @@ -1154,23 +1437,47 @@ 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) 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. + // Panel arbitration skips user filtering — a panel member who was + // an original reader can still participate in the group decision. + appointments = filterAppointmentsByNeedsArbitration( + data, + appointments, + filters.skipUserFilter ? null : currentUserId + ) + 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 @@ -1209,7 +1516,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. * @@ -1243,9 +1551,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 @@ -1263,10 +1575,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 = { @@ -1313,6 +1628,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' @@ -1384,7 +1701,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 @@ -1392,15 +1713,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 ) } @@ -1447,16 +1769,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. - const actionableCount = 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 + // 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 = 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 @@ -1497,7 +1827,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 @@ -1526,14 +1858,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. - const deadCount = sessionAppointments.filter((appointment) => { - return ( - !userHasReadAppointment(data, appointment, resolvedUserId) && - !canUserReadAppointment(data, appointment, resolvedUserId) && - !isCaseDeferred(getReadingCase(data, appointment)) && - !awaitingPriors(appointment) - ) - }).length + // 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 = 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 @@ -1544,8 +1884,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, @@ -1556,7 +1905,7 @@ const getSessionReadingProgress = ( targetRemaining: Math.max( 0, effectiveTargetSize - - progress.userReadCount - + doneCount - progress.userAwaitingPriorsCount - deferredCount ) @@ -1568,6 +1917,7 @@ module.exports = { getAppointmentReadingMetadata, writeReading, getUnfinalisedUserReadsForSession, + finaliseReadOnCase, finaliseUserReadsForSession, getEpisodeReadingStatus, getDeferredCases, @@ -1589,6 +1939,7 @@ module.exports = { filterAppointmentsByNeedsAnyRead, filterAppointmentsByNeedsFirstRead, filterAppointmentsByNeedsSecondRead, + filterAppointmentsByNeedsArbitration, filterAppointmentsByFullyRead, filterAppointmentsByUserCanRead, filterAppointmentsByUserCanReadOrHasRead, @@ -1601,9 +1952,12 @@ module.exports = { // User functions getFirstUserReadableAppointment, getNextUserReadableAppointment, + getNextCaseInSession, + getFirstOutstandingCaseInSession, getResumeAppointmentForUser, // Booleans userHasReadAppointment, + appointmentHasBeenArbitrated, canUserReadAppointment, // Sessions @@ -1615,6 +1969,7 @@ module.exports = { getOrCreateClinicSession, getFirstReadableAppointmentInSession, skipAppointmentInSession, + unskipAppointmentInSession, topUpSession, getSessionReadingProgress } 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 + ) } /** 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..be438b6e --- /dev/null +++ b/app/routes/arbitration.js @@ -0,0 +1,138 @@ +// app/routes/arbitration.js +// +// 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 { + getEligibleCandidatesForSession, + createReadingSession, + getFirstReadableAppointmentInSession +} = require('../lib/utils/reading') + +/** + * Create the arbitration session and send the user into its first case, + * falling back to the session overview when nothing is readable. + */ +const startArbitrationSession = (data, res, arbitration) => { + const isPanel = arbitration.mode === 'panel' + const sessionOptions = { + type: 'arbitration', + filters: isPanel ? { skipUserFilter: true } : {} + } + + const candidates = getEligibleCandidatesForSession(data, sessionOptions) + if (candidates.length === 0) { + return res.redirect('/reading') + } + + const session = createReadingSession(data, sessionOptions) + session.arbitration = arbitration + + // 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, + session.id, + data.currentUser.id + ) + + // 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/${firstAppointmentId}` + ) + } + + return res.redirect(`/reading/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 + + const backlogCount = getEligibleCandidatesForSession(data, { + type: 'arbitration', + filters: { skipUserFilter: true } + }).length + + const soloCount = getEligibleCandidatesForSession(data, { + type: 'arbitration' + }).length + + res.render('reading/arbitration/start', { backlogCount, soloCount }) + }) + + 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 (mode === 'panel') { + return res.redirect('/reading/arbitration/panel') + } + + delete data.arbitrationTemp + + startArbitrationSession(data, res, { + mode: 'alone', + arbitratorIds: [data.currentUser.id] + }) + }) + + // 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 + + // The picker chooses who else; the current user is an arbitrator too + const chosenUserIds = [] + .concat(data.arbitrationTemp?.panelUserIds || []) + .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 + + startArbitrationSession(data, res, { + mode: 'panel', + arbitratorIds: [data.currentUser.id, ...chosenUserIds] + }) + }) +} 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..acad33fc 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -17,6 +17,7 @@ const { userHasReadAppointment, writeReading, getUnfinalisedUserReadsForSession, + finaliseReadOnCase, finaliseUserReadsForSession, getEligibleCandidatesForSession, createReadingSession, @@ -25,8 +26,12 @@ const { getOrCreateClinicSession, getSessionReadingProgress, skipAppointmentInSession, + unskipAppointmentInSession, topUpSession, getAppointmentReadingMetadata, + appointmentHasBeenArbitrated, + getNextCaseInSession, + getFirstOutstandingCaseInSession, filterAppointmentsByEligibleForReading, filterAppointmentsByNeedsAnyRead, filterAppointmentsByUserCanRead @@ -38,8 +43,13 @@ const { getReadingMetadata, getReadsAsArray, getReadForUser, + getArbitrationRead, + getReadAuthorIds, + caseHasBeenArbitrated, + isReadFinalised, isCaseDeferred, - withoutRead + withoutRead, + withArbitrationRelease } = require('../lib/utils/reading-cases') const { getParticipant, getShortName } = require('../lib/utils/participants') const { @@ -365,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, @@ -396,8 +414,9 @@ module.exports = (router) => { ) .filter(Boolean) - const hasReadableCase = getFirstUserReadableAppointment( + const hasReadableCase = getFirstOutstandingCaseInSession( data, + session, loadedAppointments, data.currentUser.id ) @@ -417,7 +436,8 @@ module.exports = (router) => { data, sessionAppointments, data.currentUser.id, - session.skippedAppointments || [] + session.skippedAppointments || [], + session ) if (resumeAppointment) { @@ -427,8 +447,9 @@ 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 ) @@ -465,6 +486,7 @@ module.exports = (router) => { } res.render('reading/no-more-cases', { sessionId, + session, unfinalisedReadCount: getUnfinalisedUserReadsForSession( data, sessionId, @@ -492,18 +514,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 @@ -512,13 +536,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) => @@ -547,6 +584,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, @@ -560,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 @@ -593,28 +637,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, - 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( @@ -667,10 +719,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( @@ -708,6 +765,22 @@ 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' + + // 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 = { @@ -745,8 +818,17 @@ module.exports = (router) => { return res.redirect(`/reading/session/${sessionId}`) } - // Check if user has already read this appointment - if (userHasReadAppointment(data, appointment, currentUserId)) { + // 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' + const alreadyDone = isArbitrationSession + ? appointmentHasBeenArbitrated(data, appointment) + : userHasReadAppointment(data, appointment, currentUserId) + + if (alreadyDone) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/existing-read` ) @@ -770,12 +852,65 @@ 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 + if ( + isArbitrationSession && + data.settings?.reading?.arbitration?.flow !== 'opinion_first' + ) { + return res.redirect( + `/reading/session/${sessionId}/appointments/${appointmentId}/arbitration-compare` + ) + } + res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/opinion` ) } ) + // 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', @@ -795,12 +930,13 @@ 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 ) if (nextUnreadAppointment) { @@ -811,8 +947,9 @@ 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 ) @@ -889,6 +1026,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) @@ -898,12 +1039,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 @@ -928,8 +1069,9 @@ 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 ) @@ -1029,6 +1171,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) @@ -1037,12 +1183,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 @@ -1067,8 +1213,9 @@ 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 ) @@ -1161,6 +1308,7 @@ module.exports = (router) => { 'review', 'existing-read', 'compare', + 'arbitration-compare', 'request-priors', 'defer-case', 'medical-information' @@ -1892,15 +2040,34 @@ 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 + if ( + isArbitrationSession && + data.settings?.reading?.arbitration?.flow === '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 +2086,24 @@ 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 confirm on the review page, unless the + // confirmDecision setting turns that off. + if (isArbitrationSession && !isEditingExistingRead) { + 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 ( !isEditingExistingRead && data.settings?.reading?.confirmNormalWithDetails === 'true' @@ -1939,10 +2123,14 @@ module.exports = (router) => { : '' if ( !isEditingExistingRead && - data.settings?.reading?.confirmTechnicalRecall !== 'false' + (isArbitrationSession + ? data.settings?.reading?.arbitration?.confirmDecision !== 'false' + : 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( @@ -1957,10 +2145,14 @@ module.exports = (router) => { : '' if ( !isEditingExistingRead && - data.settings?.reading?.confirmRecallForAssessment !== 'false' + (isArbitrationSession + ? data.settings?.reading?.arbitration?.confirmDecision !== 'false' + : 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( @@ -2006,18 +2198,22 @@ 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 - // 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() @@ -2034,12 +2230,14 @@ module.exports = (router) => { const sessionAppointments = session.appointmentIds .map((id) => data.appointments.find((e) => e.id === id)) .filter(Boolean) - const nextUnreadAppointment = getNextUserReadableAppointment( + const isArbitrationSave = session?.type === 'arbitration' + + const nextUnreadAppointment = getNextCaseInSession( data, + session, sessionAppointments, appointmentId, - currentUserId, - { wrap: false } + currentUserId ) // Store banner message for the next case, but only if there is one. @@ -2057,7 +2255,9 @@ module.exports = (router) => { recall_for_assessment: 'Recall for assessment' } const resultLabel = resultLabels[formData.opinion] || 'Opinion' - const message = `${resultLabel} opinion recorded for ${shortName}` + const message = isArbitrationSave + ? `${resultLabel} outcome recorded for ${shortName}` + : `${resultLabel} opinion recorded for ${shortName}` data.readingOpinionBanner = { text: message, @@ -2101,8 +2301,9 @@ 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 ) @@ -2183,9 +2384,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 +2411,34 @@ module.exports = (router) => { // Handle different opinion types switch (opinion) { case 'normal': + // Arbitration: opinion-first sends the outcome through the compare + // step; the decision then confirms on the review page unless the + // confirmDecision setting turns that off + if (isArbitrationSession) { + if ( + data.settings?.reading?.arbitration?.flow === 'opinion_first' && + !data.imageReadingTemp.comparisonComplete + ) { + return res.redirect( + `/reading/session/${sessionId}/appointments/${appointmentId}/arbitration-compare` + ) + } + if ( + !isEditingExistingRead && + data.settings?.reading?.arbitration?.confirmDecision !== 'false' + ) { + 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 +2490,82 @@ 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 appointment = data.appointments.find((e) => e.id === appointmentId) + if (!appointment) return res.redirect(`/reading/session/${sessionId}`) + + // In arbitration, "editing" means the arbitration read already exists + const isEditingExistingRead = Boolean( + getArbitrationRead(getReadingCase(data, appointment)) + ) + + 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 || + data.settings?.reading?.arbitration?.confirmDecision === 'false' + ) { + 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', @@ -2446,6 +2753,7 @@ module.exports = (router) => { clinicId: appointment.clinicId, sessionId, readerId: reading.readerId, + arbitratorIds: reading.arbitratorIds, readType, opinion: reading.opinion, timestamp: reading.timestamp, @@ -2464,8 +2772,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/reading/reading-status-bar.njk b/app/views/_includes/reading/reading-status-bar.njk index 0f09f7ad..2ea517d1 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 %} @@ -26,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" diff --git a/app/views/_includes/summary-lists/read-summary.njk b/app/views/_includes/summary-lists/read-summary.njk index 52df492a..732b2970 100644 --- a/app/views/_includes/summary-lists/read-summary.njk +++ b/app/views/_includes/summary-lists/read-summary.njk @@ -74,21 +74,29 @@ {# 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 opinionValueHtml %} + {{ read.opinion | toTag }} + {% if read.timestamp and not (read | isReadFinalised(data.settings)) %} + (awaiting finalisation) + {% endif %} + {% endset %} {% set rows = rows | push({ key: { - text: "Opinion" + text: opinionLabel }, value: { - html: read.opinion | toTag + html: opinionValueHtml }, actions: { items: [ { href: changeOpinionUrl, text: "Change", - visuallyHiddenText: "opinion" + visuallyHiddenText: opinionLabel | lower } ] } if allowEdits and changeOpinionUrl @@ -252,6 +260,40 @@ {% 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.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({ + key: { + text: "Arbitrated by" + }, + value: { + html: arbitratorsHtml + } + }) %} +{% 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/panel.html b/app/views/reading/arbitration/panel.html new file mode 100644 index 00000000..7652f671 --- /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 + } | populateErrors) }} + + {{ 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..f2f5d530 --- /dev/null +++ b/app/views/reading/arbitration/session.html @@ -0,0 +1,260 @@ +{# /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" %} +{% set bodyClasses = (bodyClasses or "") + " app-page-width--wide" %} + +{% 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

+ + {# 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 %} + {% set pendingCount = 0 if pendingCount < 0 else pendingCount %} + + {# Remaining counts the whole session, including slots not yet claimed #} + {% set remainingCount = sessionProgress.targetRemaining if sessionProgress else (appointments | length) - arbitratedCount %} + {% if remainingCount > 0 %} +

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

+ {% 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. 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] %} + + {% endfor %} + + + + + {% endfor %} + + {# Placeholder rows for pending lazy session slots #} + {% set placeholderRowsShown = 3 if pendingCount > 3 else pendingCount %} + {% for i in range(0, placeholderRowsShown) %} + + + + + + + + + + {% endfor %} + +
No.CaseScreening date1st read2nd readOutcomeAction
+ {{ loop.index }}. + +
{{ appointment.participant | getFullNameReversed }}
+ {% if appointment.medicalInformation.symptoms | length %} + {{ "Has symptoms" | toTag }} + {% endif %} +
+ {% 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 %} + {% 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 %} + {{ 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 }} + {% 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 %} +
+ {{ (appointments | length) + loop.index }}. + + + {% if resumeAppointment and pendingCount > 3 and loop.last %} + and {{ pendingCount - 3 }} more in this session + {% endif %} +
+
+
+{% endblock %} diff --git a/app/views/reading/arbitration/start.html b/app/views/reading/arbitration/start.html new file mode 100644 index 00000000..a5ee49f8 --- /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", + hint: { + text: soloCount ~ " of " ~ backlogCount ~ " available to you" + } if soloCount != backlogCount else undefined + }, + { + value: "panel", + text: "Me with other people" + } + ] + } | populateErrors) }} + + {{ button({ + text: "Continue" + }) }} + +{% endblock %} diff --git a/app/views/reading/case.html b/app/views/reading/case.html index 5eaa1cc0..2ec07c9c 100644 --- a/app/views/reading/case.html +++ b/app/views/reading/case.html @@ -215,7 +215,9 @@

- {{ thisRead.readerId | getUsername({ format: "short", identifyCurrentUser: true }) }}, + {%- for authorId in thisRead | getReadAuthorIds %} + {{ authorId | getUsername({ format: "short", identifyCurrentUser: true }) }}{{ "," if not loop.last }} + {%- endfor %}, {{ thisRead.timestamp | formatDate }}
@@ -286,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/index-complex.html b/app/views/reading/index-complex.html index ebebfebd..ef59013e 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 session", + href: "/reading/arbitration/start" +}) }} +
{{ actionLink({ classes: "nhsuk-link--no-visited-state nhsuk-u-margin-top-2", text: "See cases", 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.

diff --git a/app/views/reading/session.html b/app/views/reading/session.html index f68f778c..c3eaabfd 100644 --- a/app/views/reading/session.html +++ b/app/views/reading/session.html @@ -47,8 +47,7 @@

- {% set currentUserHasRead = readingStatus.userReadCount > 0 %} - {% set readingActionText = "Resume reading" if currentUserHasRead else "Start reading" %} + {% set readingActionText = "Resume reading" if readingStatus.userReadCount else "Start reading" %} {% if resumeAppointment %} @@ -56,10 +55,11 @@

{% else %} {% set sessionCompletePanelHtml %} + {% set panelDoneCount = readingStatus.userReadCount %} {% 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

+

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 %} -

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

+

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

{% endif %}
{% if backlogTotal > 0 %} @@ -111,7 +111,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 | 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 %} @@ -128,7 +129,8 @@

{% set deferredAppointments = [] %} {% for appointment in userReadableAppointments %} - {% if data | userHasReadAppointment(appointment) %} + {% set isDone = data | userHasReadAppointment(appointment) %} +{% if isDone %} {% set read = appointment.readingCase | getReadForUser(data.currentUser.id) %} {% if read.opinion == 'normal' %} {% set normalAppointments = normalAppointments | push(appointment) %} @@ -224,10 +226,11 @@

Opinion summary

{% endif %}

Reading session cases

- {% set userSessionRemainingCount = sessionTotalCount - readingStatus.userReadCount - readingStatus.userAwaitingPriorsCount - deferredCount %} + {% 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: {{ readingStatus.userReadCount }} 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 %} @@ -251,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) %} @@ -289,12 +290,13 @@

Reading session cases


{% endif %} {{ appointment.timing.startTime | formatDate }}
- + {{ appointment.timing.startTime | formatRelativeDate }}
@@ -435,6 +448,7 @@

Reading session cases

+ @@ -448,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) %} @@ -554,6 +566,18 @@

Reading session cases

{{ (appointment.readingCase | getReadingCaseOutcome(data.settings)) | toTag }} {% endif %} + {% endfor %} @@ -574,6 +598,7 @@

Reading session cases

+ {% endfor %} 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 diff --git a/app/views/reading/workflow/arbitration-compare.html b/app/views/reading/workflow/arbitration-compare.html new file mode 100644 index 00000000..41893d98 --- /dev/null +++ b/app/views/reading/workflow/arbitration-compare.html @@ -0,0 +1,131 @@ +{# 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" %} + +{% block pageContent %} + + {% 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 %} + + {% 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 + +

{{ participant | getFullName }}

+
+
+ {{ "Arbitration" | toTag }} +
+
+ + {# 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] %} + {% if read %} + {% 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 – use 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 + }) }} +
+ {% endif %} + {% endfor %} +
+ +
+
+ + {% if ownOutcome %} + + {# 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" + }) }} + + + {% 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 %} + + + {% endif %} + +{% endblock %} diff --git a/app/views/reading/workflow/existing-read.html b/app/views/reading/workflow/existing-read.html index c85d29ba..3b7697f7 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 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 opinion

+

{{ "Arbitration 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: "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

+

{{ "Arbitration 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: "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

+

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

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

Your opinion

{% 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 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 | getReadForUser(data.currentUser.id) %} - {% set allowEdits = true %} + {% set read = thisRead %} + {% set allowEdits = not isFinalised %} {% set changeOpinionUrl = "./opinion" %} {% set showAnnotationImages = withImages %} {% set annotationImagePaths = allPaths %} @@ -149,34 +177,14 @@

Your opinion

{% endset %} {{ card({ - heading: "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 - }) }} #} - {% endfor %} - {% endif %} + {# The arbitration outcome above is what settles the case, so the reads it + arbitrated aren't shown here. #} {% endif %} {# Medical summary #} 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..e3468c97 100755 --- a/app/views/settings.html +++ b/app/views/settings.html @@ -190,19 +190,36 @@

Image reading

{value: "never", label: "Manual only"} ], data.settings.reading.finalisationDelay, "60") }} - {# Arbitration policy #} - {{ settingToggle("Arbitration policy", "settings[reading][arbitrationPolicy]", [ - {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") }} - {# 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.arbitration.policy, "discordant_only") }} + + {# Arbitration flow order - what an arbitration case opens on #} + {{ settingToggle("Arbitration flow", "settings[reading][arbitration][flow]", [ + {value: "compare_first", label: "Compare reads first"}, + {value: "opinion_first", label: "Own outcome first"} + ], data.settings.reading.arbitration.flow, "compare_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") }} + + {# 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") }} +

Generated data

{% set seedProfiles = data.settings.seedProfiles or data.defaultSettings.seedProfiles %} diff --git a/docs/IMAGE-READING-TECHNICAL-SUMMARY.md b/docs/IMAGE-READING-TECHNICAL-SUMMARY.md index 47f7f597..cde238a1 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,17 @@ 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'` +- `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`): - `priorityThreshold` - Days until "due soon" 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 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
- {% if data | userHasReadAppointment(appointment) %} + {% set isDone = data | userHasReadAppointment(appointment) %} +{% if isDone %} {% set read = appointment.readingCase | getReadForUser(data.currentUser.id) %} {% if read.opinion %} {{ read.opinion | toTag }} @@ -312,6 +314,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 %}
1st read 2nd read OutcomeAction
+ {# 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 }} +