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 %}
+ 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 %} + + {% 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.label }}
+ + {% endset %} +Progress: {{ arbitratedCount }} arbitrated, {{ remainingCount }} remaining
+ {% endif %} + +| No. | +Case | +Screening date | +1st read | +2nd read | +Outcome | +Action | +
|---|---|---|---|---|---|---|
| + {{ 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 }} + + |
+
+ {# The two original reads. Until the case is arbitrated the opinions
+ can be hidden (per hideReadsUntilArbitrated) so the arbitrator
+ forms their own view first; who read is never sensitive. #}
+ {% set hideReads = (data.settings.reading.arbitration.hideReadsUntilArbitrated != 'false') and not arbitrationRead %}
+ {% for readIndex in [0, 1] %}
+
+ {% set originalRead = originalReads[readIndex] %}
+ {% if originalRead %}
+ {% 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 %} + |
+ {% endfor %}
+
+
+ {% 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 %} + | ++ | + | + | + | + |
+ {{ 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 @@{{ 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" +}) }} +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 @@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 %}