diff --git a/app/lib/generate-seed-data.js b/app/lib/generate-seed-data.js index 55d8100b..7cc668be 100644 --- a/app/lib/generate-seed-data.js +++ b/app/lib/generate-seed-data.js @@ -15,6 +15,7 @@ const { generateAppointment } = require('./generators/appointment-generator') const { generateEpisode, generateHistoricEpisodes, + syncEpisodeReadingCases, finaliseEpisodeStage, checkEpisodes } = require('./generators/episode-generator') @@ -24,7 +25,11 @@ const { generateSingleRead } = require('./generators/reading-generator') const { getSeedDataProfile } = require('./generators/seed-profiles') -const { getOutcome } = require('./utils/reading') +const { + buildRead, + getLatestReadingCase, + getReadingCaseOutcome +} = require('./utils/reading-cases') const riskLevels = require('../data/risk-levels') @@ -285,12 +290,14 @@ const generateSnapshotPeriod = (startDate, numberOfDays) => { * @param {Array} episodes - The generated (real) episodes * @param {Array} participants - All participants * @param {object} seedDataProfile - Active seed profile + * @param {Array} readers - Users who could have read these past rounds * @returns {Array} Historic episodes */ const generateHistoricEpisodesForParticipants = ( episodes, participants, - seedDataProfile + seedDataProfile, + readers ) => { const max = config.generation.maxHistoricEpisodesPerParticipant const outcomeWeights = seedDataProfile?.episodes?.historicOutcomeWeights @@ -319,7 +326,9 @@ const generateHistoricEpisodesForParticipants = ( type: earliest.type, earliestOpenedDate: earliest.openedDate, max, - outcomeWeights + outcomeWeights, + readers, + seedProfile: seedDataProfile }) ) }) @@ -398,7 +407,12 @@ const seedTechnicalRecallRescreen = ({ if (episode.appointmentIds.length !== 1) return false const appointment = appointmentsById.get(episode.appointmentIds[0]) - return Boolean(appointment) && getOutcome(appointment, {}) === 'technical_recall' + if (!appointment) return false + + return ( + getReadingCaseOutcome(getLatestReadingCase(episode), {}) === + 'technical_recall' + ) } const episode = @@ -424,12 +438,27 @@ const seedTechnicalRecallRescreen = ({ ) const [firstReader, secondReader] = users - const firstRead = generateSingleRead( + // Reads live on the episode's case for this set of images + const readingCase = getLatestReadingCase(episode) + if (!readingCase) return null + + const generatedRead = generateSingleRead( firstAppointment, firstReader.id, firstReader.role, readAt.add(1, 'day').toISOString(), - { forceOpinion: 'technical_recall', readNumber: 1 } + { forceOpinion: 'technical_recall' } + ) + // Built against an empty case, because this replaces whatever reads it had + // rather than adding to them - buildRead types a read from where the case + // had got to, and against the existing reads this would come out as an + // arbitration read + const firstRead = buildRead( + { ...readingCase, reads: [] }, + firstReader.id, + firstReader.role, + generatedRead, + { timestamp: readAt.add(1, 'day').toISOString() } ) // The second read has to agree with the first, down to which views need @@ -439,17 +468,12 @@ const seedTechnicalRecallRescreen = ({ ...structuredClone(firstRead), readerId: secondReader.id, readerType: secondReader.role, + readType: 'second', readNumber: 2, timestamp: readAt.add(2, 'day').toISOString() } - firstAppointment.imageReading = { - ...firstAppointment.imageReading, - reads: { - [firstReader.id]: firstRead, - [secondReader.id]: secondRead - } - } + readingCase.reads = [firstRead, secondRead] finaliseEpisodeStage(episode, [firstAppointment], clinicsById) if (!owedRescreen(episode)) return null @@ -575,10 +599,27 @@ const generateData = async (options = {}) => { return new Date(a.timing.startTime) - new Date(b.timing.startTime) }) + const appointmentsById = new Map( + sortedAppointments.map((appointment) => [appointment.id, appointment]) + ) + const clinicsById = new Map(allClinics.map((clinic) => [clinic.id, clinic])) + + // Every set of images taken is a case to read, so the cases have to exist + // before there is anywhere to write reads to + const episodeAppointmentsFor = (episode) => + episode.appointmentIds + .map((appointmentId) => appointmentsById.get(appointmentId)) + .filter(Boolean) + + allEpisodes.forEach((episode) => { + syncEpisodeReadingCases(episode, episodeAppointmentsFor(episode)) + }) + console.log('Generating sample reading data...') - const appointmentsWithReadingData = generateReadingData( + generateReadingData( sortedAppointments, users, + allEpisodes, selectedSeedDataProfile ) @@ -586,34 +627,27 @@ const generateData = async (options = {}) => { // multi-appointment (technical recall) case, then the summary-level past rounds console.log('Finalising episodes...') - const appointmentsById = new Map( - appointmentsWithReadingData.map((appointment) => [appointment.id, appointment]) - ) - const clinicsById = new Map(allClinics.map((clinic) => [clinic.id, clinic])) - allEpisodes.forEach((episode) => { - const episodeAppointments = episode.appointmentIds - .map((appointmentId) => appointmentsById.get(appointmentId)) - .filter(Boolean) - finaliseEpisodeStage(episode, episodeAppointments, clinicsById) + finaliseEpisodeStage(episode, episodeAppointmentsFor(episode), clinicsById) }) const rescreenAppointment = seedTechnicalRecallRescreen({ episodes: allEpisodes, - appointments: appointmentsWithReadingData, + appointments: sortedAppointments, clinics: allClinics, participants: finalParticipants, users, seedDataProfile: selectedSeedDataProfile }) if (rescreenAppointment) { - appointmentsWithReadingData.push(rescreenAppointment) + sortedAppointments.push(rescreenAppointment) } const historicEpisodes = generateHistoricEpisodesForParticipants( allEpisodes, finalParticipants, - selectedSeedDataProfile + selectedSeedDataProfile, + users ) const episodesWithHistory = [...allEpisodes, ...historicEpisodes] @@ -628,7 +662,7 @@ const generateData = async (options = {}) => { ) // The re-screen appointment was added after the appointments map was built - appointmentsWithReadingData.forEach((appointment) => appointmentsById.set(appointment.id, appointment)) + sortedAppointments.forEach((appointment) => appointmentsById.set(appointment.id, appointment)) const episodeProblems = checkEpisodes(episodesWithHistory, appointmentsById) if (episodeProblems.length) { @@ -659,7 +693,7 @@ const generateData = async (options = {}) => { ) })) }) - writeData('appointments.json', { appointments: appointmentsWithReadingData }) + writeData('appointments.json', { appointments: sortedAppointments }) writeData('episodes.json', { episodes: episodesWithHistory }) writeData('generation-info.json', { generatedAt: new Date().toISOString(), @@ -667,7 +701,7 @@ const generateData = async (options = {}) => { stats: { participants: finalParticipants.length, clinics: allClinics.length, - appointments: appointmentsWithReadingData.length, + appointments: sortedAppointments.length, episodes: episodesWithHistory.length } }) @@ -676,7 +710,7 @@ const generateData = async (options = {}) => { console.log('Generated:') console.log(`- ${finalParticipants.length} participants`) console.log(`- ${allClinics.length} clinics`) - console.log(`- ${appointmentsWithReadingData.length} appointments`) + console.log(`- ${sortedAppointments.length} appointments`) console.log( `- ${episodesWithHistory.length} episodes ` + `(${allEpisodes.length} current, ${historicEpisodes.length} historic)` diff --git a/app/lib/generators/episode-generator.js b/app/lib/generators/episode-generator.js index ba993984..bc4f4fb3 100644 --- a/app/lib/generators/episode-generator.js +++ b/app/lib/generators/episode-generator.js @@ -15,7 +15,15 @@ const weighted = require('weighted') const { faker } = require('@faker-js/faker') const generateId = require('../utils/id-generator') const riskLevels = require('../../data/risk-levels') -const { getOutcome } = require('../utils/reading') +const { + generateMedicalInformation +} = require('./medical-information-generator') +const { + buildReadingCase, + getLatestReadingCase, + getReadingCaseOutcome, + getReadsAsArray +} = require('../utils/reading-cases') const { eligibleForReading, isCompleted } = require('../utils/status') const { EPISODE_OUTCOMES, @@ -50,6 +58,212 @@ const ASSESSMENT_OUTCOME_WEIGHTS = { refer_for_treatment: 0.2 } +// How often a past round that ended in routine recall got there via assessment +// rather than straight from a clear reading. Most clear rounds were simply read +// as normal; a minority were recalled and then found to be clear. Without this +// every historic recall would have ended in treatment, which is the opposite of +// how screening actually goes. +const HISTORIC_RECALLED_THEN_CLEAR_PROBABILITY = 0.06 + +// How often a past round's two readers disagreed and it went to arbitration. +// A few percent of cases is the right order for real screening - the point of +// seeding any is that arbitration work has past examples to look at, not only +// cases created by hand. +const HISTORIC_ARBITRATION_PROBABILITY = 0.04 + +/** + * Build the stand-in appointment for a past round. + * + * A past round has no appointment record - reviving one in full is what the + * episodes work deliberately dropped, because a whole historic clinic snapshot + * cost far more than it was worth. This is the fidelity tier in between: enough + * to show what an appointment that day looked like, held on the episode rather + * than in `data.appointments`, so it can never drift into a clinic list, a + * reading queue or a route that expects a real appointment. + * + * Where and when the round was screened already lives in `episode.mammograms`; + * what this adds is the appointment's own detail - its status, and the medical + * information recorded at it. + * + * Held as a list, like the episode's other per-appointment records, so a past + * round is shaped the same as a live one. Only one is generated today; a past + * technical recall would have had two. + * + * @param {object} options + * @param {object} options.screenedDate - When the images were taken (dayjs) + * @param {object} [options.seedProfile] - Active seed profile + * @returns {object} The summary appointment + */ +const buildHistoricSummaryAppointment = ({ screenedDate, seedProfile }) => { + const medicalInformation = generateMedicalInformation({ + ...(seedProfile?.medicalInformation || {}) + }) + + return { + id: generateId(), + // A past round was screened - that is why it has images and a reading. The + // status is carried explicitly so the episode page can show an appointments + // list the same way a live round does. + status: 'complete', + type: 'screening', + startTime: screenedDate.toISOString(), + medicalInformation: stampRecordedDates(medicalInformation, screenedDate) + } +} + +/** + * Re-date everything in a generated medical information record to when it was + * actually recorded. + * + * The medical information generators stamp `dateAdded` with the current time, + * which is right for a live appointment and wrong for a past round - it would + * show a symptom from 2023 as having been recorded today. Rewriting them here + * keeps that fix to historic generation, rather than threading a date through + * every generator that the live flow also uses. + * + * Walks the whole structure rather than naming fields, so it keeps working as + * the medical information shape grows. + * + * @param {*} value - Generated medical information, or part of it + * @param {object} recordedDate - When the round was screened (dayjs) + * @returns {*} The same shape, with recorded dates moved back + */ +const stampRecordedDates = (value, recordedDate) => { + if (Array.isArray(value)) { + return value.map((item) => stampRecordedDates(item, recordedDate)) + } + + if (!value || typeof value !== 'object') return value + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + key === 'dateAdded' + ? recordedDate.toISOString() + : stampRecordedDates(item, recordedDate) + ]) + ) +} + +/** + * Build the summary reading case for a past round. + * + * Past rounds are seeded outcome-first, so the reads are chosen to be + * consistent with the outcome already picked rather than the other way round: + * a round that ended in treatment must have been recalled for assessment, and a + * clear round was usually - though not always - read as normal. + * + * Deliberately lean. There is no appointment behind a past round, so no image + * set for annotations to point at; what a past round can honestly say is who + * read it, when, and what they concluded. + * + * @param {object} options + * @param {string} options.outcome - The round's episode outcome + * @param {object} options.screenedDate - When the images were taken (dayjs) + * @param {object} options.closedDate - When the round closed (dayjs) + * @param {Array} options.readers - Users available to have read it + * @returns {object | null} The reading case, or null if the round wasn't screened + */ +const buildHistoricReadingCase = ({ + outcome, + screenedDate, + closedDate, + readers +}) => { + if (outcome === 'no_result' || readers.length < 2) return null + + // A round ending in treatment was recalled for assessment; a clear round + // usually wasn't, but sometimes was and the assessment found nothing + const wasRecalled = + outcome === 'refer_for_treatment' || + Math.random() < HISTORIC_RECALLED_THEN_CLEAR_PROBABILITY + + // What the round concluded at reading - the arbitration read where there was + // one, otherwise what both readers agreed + const opinion = wasRecalled ? 'recall_for_assessment' : 'normal' + + // Sometimes the two readers disagreed and a third settled it. Needs three + // readers: nobody reads the same case twice, arbitration included + const wentToArbitration = + readers.length >= 3 && Math.random() < HISTORIC_ARBITRATION_PROBABILITY + + const [firstReader, secondReader, arbitrationReader] = + faker.helpers.arrayElements(readers, wentToArbitration ? 3 : 2) + + // Read in the window between the images being taken and the round closing + const firstReadAt = screenedDate.add( + faker.number.int({ min: 1, max: 4 }), + 'day' + ) + const secondReadAt = firstReadAt.add( + faker.number.int({ min: 0, max: 2 }), + 'day' + ) + const cappedSecondReadAt = secondReadAt.isAfter(closedDate) + ? closedDate + : secondReadAt + + const buildSummaryRead = (reader, readType, readNumber, timestamp, readOpinion) => ({ + opinion: readOpinion, + readerId: reader.id, + readerType: reader.role, + readType, + readNumber, + timestamp: timestamp.toISOString() + }) + + // When it went to arbitration the two readers disagreed, so one of them said + // the opposite of what the round concluded. Which one is arbitrary - either + // reader could have been the one who saw something + const disagreeingOpinion = + opinion === 'normal' ? 'recall_for_assessment' : 'normal' + const firstReaderDisagreed = wentToArbitration && Math.random() < 0.5 + + const reads = [ + buildSummaryRead( + firstReader, + 'first', + 1, + firstReadAt, + firstReaderDisagreed ? disagreeingOpinion : opinion + ), + buildSummaryRead( + secondReader, + 'second', + 2, + cappedSecondReadAt, + wentToArbitration && !firstReaderDisagreed ? disagreeingOpinion : opinion + ) + ] + + if (wentToArbitration) { + // The arbitration read is what settled it, so it carries the round's + // conclusion - and dates after the two it was called in to resolve + const arbitratedAt = cappedSecondReadAt.add( + faker.number.int({ min: 1, max: 3 }), + 'day' + ) + reads.push( + buildSummaryRead( + arbitrationReader, + 'arbitration', + 3, + arbitratedAt.isAfter(closedDate) ? closedDate : arbitratedAt, + opinion + ) + ) + } + + return { + id: generateId(), + // A past round has no appointment record for the case to hang off - the + // same reason its mammogram entry carries no appointmentId + appointmentId: null, + openedDate: screenedDate.toISOString(), + reads + } +} + /** * Record a stage change, keeping stageHistory in step * @@ -90,10 +304,42 @@ const generateEpisode = ({ participant, type, appointmentDate }) => { closedDate: null, appointmentIds: [], mammograms: [], + readingCases: [], isHistoric: false } } +/** + * Open a reading case for each set of images the episode's appointments + * produced, keeping any case that already exists. + * + * Runs before reading data is generated, because reads are written onto cases - + * there has to be a case to write to. Existing cases are kept rather than + * rebuilt so re-running never throws away reads. + * + * @param {object} episode - Episode to sync (mutated - generation only) + * @param {Array} appointments - The episode's appointments, oldest first + * @returns {object} The same episode + */ +const syncEpisodeReadingCases = (episode, appointments) => { + const existingByAppointmentId = new Map( + (episode.readingCases || []).map((readingCase) => [ + readingCase.appointmentId, + readingCase + ]) + ) + + episode.readingCases = appointments + .filter(appointmentProducedImages) + .map( + (appointment) => + existingByAppointmentId.get(appointment.id) || + buildReadingCase(appointment) + ) + + return episode +} + /** * Work out an episode's stage and outcome from the state of its appointments. * @@ -117,12 +363,14 @@ const finaliseEpisodeStage = (episode, appointments, clinicsById = new Map()) => episode.closedDate = null // The round's record of images taken is derived from the appointments alongside - // the stage, so a re-run refreshes both together + // the stage, so a re-run refreshes both together. Its reading cases follow the + // same image sets, and keep any reads already written to them episode.mammograms = appointments .filter(appointmentProducedImages) .map((appointment) => buildMammogramEntry(appointment, clinicsById.get(appointment.clinicId)) ) + syncEpisodeReadingCases(episode, appointments) const latestAppointment = appointments[appointments.length - 1] if (!latestAppointment) return episode @@ -153,10 +401,12 @@ const finaliseEpisodeStage = (episode, appointments, clinicsById = new Map()) => moveTo(destination, appointmentEnded) // Once the images are taken, it's the reading that decides what happens - // next - close the episode, or send it back for a technical recall + // next - close the episode, or send it back for a technical recall. The + // *latest* case decides: a technical recall's re-screen supersedes the + // reading that asked for it if (episode.stage === 'reading') { - const reads = Object.values(latestAppointment.imageReading?.reads || {}) - const lastReadAt = reads + const latestCase = getLatestReadingCase(episode) + const lastReadAt = getReadsAsArray(latestCase) .map((read) => read.timestamp) .sort() .pop() @@ -169,7 +419,7 @@ const finaliseEpisodeStage = (episode, appointments, clinicsById = new Map()) => : appointmentEnded const destinationAfterReading = - EPISODE_STAGE_BY_READING_OUTCOME[getOutcome(latestAppointment, {})] + EPISODE_STAGE_BY_READING_OUTCOME[getReadingCaseOutcome(latestCase, {})] if (destinationAfterReading) { moveTo(destinationAfterReading, concludedAt) @@ -235,10 +485,11 @@ const countHistoricEpisodes = ( /** * Generate summary-level episodes for a participant's past screening rounds. * - * Outcome-first: each round says what it found, and we don't model how it got - * there - no appointments, no reads, no assessment detail. That is enough for - * every "what happened before" view, and cheap to hold. If we later model the - * steps, the outcome can be computed from them instead. + * Outcome-first: each round says what it found, and the detail beneath it is + * chosen to be consistent with that - not the other way round. A past round + * holds no appointment and no assessment detail, but it does carry a summary + * reading case, so "who read this and what did they say" is answerable for + * every round rather than only the current one. * * Spacing follows the risk level's own screening interval (routine every 3 * years, family history / high risk yearly). @@ -249,6 +500,8 @@ const countHistoricEpisodes = ( * @param {string|Date} options.earliestOpenedDate - Opened date of their oldest real episode * @param {number} options.max - Cap on how many to generate * @param {object} [options.outcomeWeights] - Override the default outcome mix + * @param {Array} [options.readers] - Users who could have read these rounds + * @param {object} [options.seedProfile] - Active seed profile * @returns {Array} Historic episodes, oldest first */ const generateHistoricEpisodes = ({ @@ -256,7 +509,9 @@ const generateHistoricEpisodes = ({ type, earliestOpenedDate, max, - outcomeWeights + outcomeWeights, + readers = [], + seedProfile }) => { const riskLevel = riskLevels[type] || riskLevels.routine const weights = outcomeWeights || HISTORIC_OUTCOME_WEIGHTS @@ -288,6 +543,19 @@ const generateHistoricEpisodes = ({ // A round with no result never produced images either const wasScreened = outcome !== 'no_result' + const readingCase = buildHistoricReadingCase({ + outcome, + screenedDate, + closedDate, + readers + }) + + // A round that produced no images had no screening appointment worth + // standing in for either + const summaryAppointments = wasScreened + ? [buildHistoricSummaryAppointment({ screenedDate, seedProfile })] + : [] + episodes.push({ id: generateId(), participantId: participant.id, @@ -305,6 +573,8 @@ const generateHistoricEpisodes = ({ openedDate: openedDate.toISOString(), closedDate: closedDate.toISOString(), appointmentIds: [], + readingCases: readingCase ? [readingCase] : [], + summaryAppointments, isHistoric: true, // Enough to list this round as a prior without holding a full image @@ -378,6 +648,50 @@ const checkEpisodes = (episodes, appointmentsById) => { `historic episode ${episode.id} outcome and mammograms disagree` ) } + // A screened past round has a stand-in appointment; one that produced no + // images was never screened, so has none + const summaryAppointments = episode.summaryAppointments || [] + if (wasScreened !== (summaryAppointments.length > 0)) { + problems.push( + `historic episode ${episode.id} summary appointments and outcome disagree` + ) + } + + // A screened past round carries exactly one summary reading case, and a + // round that produced no images has nothing to read + const historicCases = episode.readingCases || [] + if (wasScreened !== (historicCases.length === 1)) { + problems.push( + `historic episode ${episode.id} should have exactly one reading case when screened` + ) + } + + historicCases.forEach((readingCase) => { + // No appointment record exists behind a past round + if (readingCase.appointmentId) { + problems.push( + `historic reading case ${readingCase.id} references an appointment` + ) + } + // The reads have to agree with the outcome the round was seeded with: + // a round ending in treatment must have been recalled for assessment + const readingOutcome = getReadingCaseOutcome(readingCase, {}) + if ( + episode.outcome === 'refer_for_treatment' && + readingOutcome !== 'recall_for_assessment' + ) { + problems.push( + `historic episode ${episode.id} ended in treatment but was read as "${readingOutcome}"` + ) + } + // Two readers, or three where they disagreed and one arbitrated + const historicReadCount = getReadsAsArray(readingCase).length + if (historicReadCount !== 2 && historicReadCount !== 3) { + problems.push( + `historic reading case ${readingCase.id} has ${historicReadCount} reads` + ) + } + }) return } @@ -403,6 +717,52 @@ const checkEpisodes = (episodes, appointmentsById) => { ) } + // One reading case per image set, and none without images to read: the two + // records answer the same question from different sides, so they must agree + const caseAppointmentIds = (episode.readingCases || []).map( + (readingCase) => readingCase.appointmentId + ) + if ( + screenedAppointmentIds.length !== caseAppointmentIds.length || + screenedAppointmentIds.some( + (appointmentId) => !caseAppointmentIds.includes(appointmentId) + ) + ) { + problems.push( + `episode ${episode.id} reading cases don't match its screened appointments` + ) + } + + // A case is read at most twice until arbitration, and an arbitration read + // only makes sense once two reads have disagreed + ;(episode.readingCases || []).forEach((readingCase) => { + const reads = getReadsAsArray(readingCase) + const arbitrationReads = reads.filter( + (read) => read.readType === 'arbitration' + ) + + if (reads.length > 3) { + problems.push( + `reading case ${readingCase.id} has ${reads.length} reads` + ) + } + if (arbitrationReads.length > 1) { + problems.push( + `reading case ${readingCase.id} has more than one arbitration read` + ) + } + if (arbitrationReads.length && reads.length < 3) { + problems.push( + `reading case ${readingCase.id} has an arbitration read without two prior reads` + ) + } + if (new Set(reads.map((read) => read.readerId)).size !== reads.length) { + problems.push( + `reading case ${readingCase.id} has the same reader twice` + ) + } + }) + // Reading needs images: an episode can only be in reading off the back of // a completed mammogram appointment that is still within the reading window if (episode.stage === 'reading') { @@ -434,6 +794,7 @@ const checkEpisodes = (episodes, appointmentsById) => { module.exports = { generateEpisode, generateHistoricEpisodes, + syncEpisodeReadingCases, finaliseEpisodeStage, checkEpisodes } diff --git a/app/lib/generators/reading-generator.js b/app/lib/generators/reading-generator.js index d29cf7ca..8ec7948b 100644 --- a/app/lib/generators/reading-generator.js +++ b/app/lib/generators/reading-generator.js @@ -4,6 +4,7 @@ const dayjs = require('dayjs') const weighted = require('weighted') const { faker } = require('@faker-js/faker') const { eligibleForReading } = require('../utils/status') +const { buildRead } = require('../utils/reading-cases') const { getSetById, getResolvedAnnotations @@ -71,7 +72,6 @@ const TAG_TO_RESULT = { * @param {object} [options] - Generation options * @param {boolean} [options.forceAlignment] - Force alignment with set (ignore probability) * @param {string} [options.forceOpinion] - Force a specific opinion type - * @param {number} [options.readNumber] - The read number (1 = first, 2 = second) * @returns {object} The generated read object */ const generateSingleRead = ( @@ -111,13 +111,13 @@ const generateSingleRead = ( opinion = weighted.select(DEFAULT_READ_WEIGHTS) } - // Build base read object + // Build base read object. readNumber and readType are settled by buildRead, + // from where the case had got to when the read was made const read = { opinion, readerId, readerType, - timestamp, - readNumber: options.readNumber || 1 + timestamp } // Add opinion-specific data @@ -336,24 +336,80 @@ const generatePlaceholderAnnotation = (side, breastData) => { } } +/** + * Add a generated read to a case. + * + * Mutates the case: episodes are ordinary objects during generation, and only + * become shared read-only data once they're loaded into the store. + * + * @param {object} readingCase - The case being read + * @param {object} appointment - The appointment whose images these are + * @param {object} reader - The reader (a user record) + * @param {string} timestamp - ISO timestamp for the read + * @param {object} [options] - Options passed through to generateSingleRead + * @returns {object} The read that was added + */ +const addRead = (readingCase, appointment, reader, timestamp, options = {}) => { + const generated = generateSingleRead( + appointment, + reader.id, + reader.role, + timestamp, + options + ) + + // buildRead settles readNumber and readType from where the case had got to + const read = buildRead(readingCase, reader.id, reader.role, generated, { + timestamp + }) + + const existingIndex = readingCase.reads.findIndex( + (candidate) => candidate.readerId === reader.id + ) + if (existingIndex >= 0) { + readingCase.reads[existingIndex] = read + } else { + readingCase.reads.push(read) + } + + return read +} + +/** + * Pick an opinion for a second read: usually agreeing with the first, sometimes + * not, so some cases land in arbitration. + * + * @param {object} firstRead - The first read + * @param {number} [agreementProbability] - Chance of agreeing + * @returns {string} The opinion to force + */ +const pickSecondOpinion = (firstRead, agreementProbability = 0.8) => { + if (Math.random() <= agreementProbability) return firstRead.opinion + + const otherOpinions = [...new Set(Object.values(TAG_TO_RESULT))].filter( + (opinion) => opinion !== firstRead.opinion + ) + + return faker.helpers.arrayElement(otherOpinions) +} + /** * Apply reads when a backlogLimit is set. * - * All eligible appointments except the last `backlogLimit` are fully read (2 reads - * by non-current users). Of the remaining `backlogLimit` appointments: - * - The first `floor(backlogLimit × partialReadRatio)` get 1 read by a - * non-current user (second read still available to current user) - * - The rest get no reads (first read available to current user) + * All eligible cases except the last `backlogLimit` are fully read (2 reads + * by non-current users). Of the remaining `backlogLimit` cases: + * - The first `floor(backlogLimit × partialReadRatio)` get 1 read by the + * current user (so the second read is still available to someone else) + * - The rest get no reads (first read available to the current user) * - * @param {Array} allAppointments - Full appointments array * @param {Array} eligibleAppointments - Appointments eligible for reading + * @param {Map} casesByAppointmentId - Reading cases, keyed by appointment id * @param {object} readers - { firstReader, secondReader, thirdReader } * @param {object} options - { backlogLimit, backlogPartialReadRatio, alignmentProbability } - * @returns {Array} Updated appointments array */ const generateReadingDataWithBacklogLimit = ( - allAppointments, eligibleAppointments, + casesByAppointmentId, { firstReader, secondReader, thirdReader }, { backlogLimit, backlogPartialReadRatio, alignmentProbability } ) => { @@ -392,36 +448,30 @@ const generateReadingDataWithBacklogLimit = ( `${blockedByPriorsAppointments.length} unread (awaiting priors)` ) - const updatedAppointments = [...allAppointments] - let baseTime = dayjs().subtract(72, 'hours') // Fully read: 2 reads by secondReader and thirdReader fullyReadAppointments.forEach((appointment) => { - const index = updatedAppointments.findIndex((e) => e.id === appointment.id) - if (index === -1) return - if (!updatedAppointments[index].imageReading) { - updatedAppointments[index].imageReading = { reads: {} } - } + const readingCase = casesByAppointmentId.get(appointment.id) + if (!readingCase) return baseTime = baseTime.add(1, 'minute') - const firstRead = generateSingleRead( - updatedAppointments[index], - secondReader.id, - secondReader.role, + + const firstRead = addRead( + readingCase, + appointment, + secondReader, baseTime.toISOString(), - { readNumber: 1, alignmentProbability } + { alignmentProbability } ) - updatedAppointments[index].imageReading.reads[secondReader.id] = firstRead - const secondRead = generateSingleRead( - updatedAppointments[index], - thirdReader.id, - thirdReader.role, + addRead( + readingCase, + appointment, + thirdReader, baseTime.add(15, 'minutes').toISOString(), - { forceOpinion: firstRead.opinion, readNumber: 2, alignmentProbability } + { forceOpinion: firstRead.opinion, alignmentProbability } ) - updatedAppointments[index].imageReading.reads[thirdReader.id] = secondRead }) baseTime = dayjs().subtract(24, 'hours') @@ -429,44 +479,46 @@ const generateReadingDataWithBacklogLimit = ( // Partially read: 1 read by firstReader (the current user) — so the current // user has already read these and cannot read them again partialAppointments.forEach((appointment) => { - const index = updatedAppointments.findIndex((e) => e.id === appointment.id) - if (index === -1) return - if (!updatedAppointments[index].imageReading) { - updatedAppointments[index].imageReading = { reads: {} } - } + const readingCase = casesByAppointmentId.get(appointment.id) + if (!readingCase) return baseTime = baseTime.add(1, 'minute') - const firstRead = generateSingleRead( - updatedAppointments[index], - firstReader.id, - firstReader.role, - baseTime.toISOString(), - { readNumber: 1, alignmentProbability } - ) - updatedAppointments[index].imageReading.reads[firstReader.id] = firstRead + addRead(readingCase, appointment, firstReader, baseTime.toISOString(), { + alignmentProbability + }) }) // Unread appointments: no reads added - - return updatedAppointments } /** - * Generate sample image reading data to simulate first and second reads + * Generate sample reading data to simulate first and second reads. + * + * Reads are written onto the episode's reading cases, so the cases must already + * exist — syncEpisodeReadingCases runs over the episodes before this does. * * @param {Array} appointments - Array of screening appointments * @param {Array} users - Array of system users - * @returns {Array} Updated appointments with reading data + * @param {Array} episodes - Array of episodes, holding the reading cases + * @param {object} [seedProfile] - Active seed profile */ -const generateReadingData = (appointments, users, seedProfile = {}) => { +const generateReadingData = (appointments, users, episodes, seedProfile = {}) => { const alignmentProbability = - seedProfile?.imageReading?.probabilityFirstReaderOpinionMatchesImages ?? + seedProfile?.reads?.probabilityFirstReaderOpinionMatchesImages ?? DEFAULT_ALIGNMENT_PROBABILITY if (!appointments || !appointments.length || !users || users.length < 2) { console.log('No appointments or not enough users to generate reading data') - return appointments + return } + // Every case, keyed by the appointment whose images it covers + const casesByAppointmentId = new Map() + episodes.forEach((episode) => { + ;(episode.readingCases || []).forEach((readingCase) => { + casesByAppointmentId.set(readingCase.appointmentId, readingCase) + }) + }) + // Use the first, second, and third users as our readers const firstReader = users[0] const secondReader = users[1] @@ -482,9 +534,9 @@ const generateReadingData = (appointments, users, seedProfile = {}) => { // default clinic-by-clinic pattern. backlogLimit=0 means empty backlog. const backlogLimit = seedProfile?.reading?.backlogLimit ?? null if (backlogLimit !== null) { - return generateReadingDataWithBacklogLimit( - appointments, + generateReadingDataWithBacklogLimit( recentAppointments, + casesByAppointmentId, { firstReader, secondReader, thirdReader }, { backlogLimit, @@ -493,6 +545,7 @@ const generateReadingData = (appointments, users, seedProfile = {}) => { alignmentProbability } ) + return } // Sort by date (oldest first) @@ -502,7 +555,7 @@ const generateReadingData = (appointments, users, seedProfile = {}) => { if (sortedAppointments.length === 0) { console.log('No recent completed appointments to add reading data to') - return appointments + return } // Group appointments by clinic @@ -528,22 +581,56 @@ const generateReadingData = (appointments, users, seedProfile = {}) => { `Found ${clinics.length} clinics with completed appointments in the last 30 days` ) - // Clone the appointments array to avoid modifying the original - const updatedAppointments = [...appointments] - - // Track which appointments are updated for efficient lookup later - const updatedAppointmentIds = new Set() + // Track which appointments have been dealt with, so later passes skip them + const readAppointmentIds = new Set() // Function to generate a recent timestamp (within past 7 days) - const generateRecentTimestamp = (baseDate, minHours = 2, maxHours = 36) => { + const generateRecentTimestamp = (minHours = 2, maxHours = 36) => { const hoursAgo = Math.floor(Math.random() * (maxHours - minHours)) + minHours return dayjs().subtract(hoursAgo, 'hours').toISOString() } - // TWO OLDEST CLINICS: Complete first and second reads + /** + * Walk a clinic's appointments, giving each one a read, and note them as done + * + * @param {object} clinic - Clinic with its appointments + * @param {object} reader - Who is reading + * @param {object} [options] - Options + * @param {dayjs.Dayjs} options.startTime - Time of the first read + * @param {Array} [options.only] - Restrict to these appointments + * @param {boolean} [options.skipAlreadyRead] - Leave dealt-with appointments alone + * @returns {number} How many reads were added + */ + const readClinic = (clinic, reader, options = {}) => { + const { startTime, only = null, skipAlreadyRead = true } = options + let baseReadTime = startTime + let count = 0 + + const candidates = only || clinic.appointments + + candidates.forEach((appointment) => { + if (skipAlreadyRead && readAppointmentIds.has(appointment.id)) return + + const readingCase = casesByAppointmentId.get(appointment.id) + if (!readingCase) return + + baseReadTime = baseReadTime.add(1, 'minute') + addRead(readingCase, appointment, reader, baseReadTime.toISOString(), { + alignmentProbability + }) + + readAppointmentIds.add(appointment.id) + count++ + }) + + return count + } + + // TWO OLDEST CLINICS: complete first and second reads if (clinics.length >= 2) { let count = 0 + for (let i = 0; i < 2 && i < clinics.length; i++) { const clinic = clinics[i] console.log( @@ -551,207 +638,93 @@ const generateReadingData = (appointments, users, seedProfile = {}) => { ) // Use the same base time for all reads in this clinic, then advance by 1 minute for each appointment - let baseReadTime = dayjs(generateRecentTimestamp(clinic.date, 48, 72)) + let baseReadTime = dayjs(generateRecentTimestamp(48, 72)) clinic.appointments.forEach((appointment) => { - // Find the appointment in our array - const appointmentIndex = updatedAppointments.findIndex((e) => e.id === appointment.id) - if (appointmentIndex === -1) return + const readingCase = casesByAppointmentId.get(appointment.id) + if (!readingCase) return - // Ensure the imageReading structure exists - if (!updatedAppointments[appointmentIndex].imageReading) { - updatedAppointments[appointmentIndex].imageReading = { reads: {} } - } - - // Advance time by 1 minute for each read baseReadTime = baseReadTime.add(1, 'minute') - const firstReadTime = baseReadTime.toISOString() - - // First read (by second user) - aligned with image set - const firstRead = generateSingleRead( - updatedAppointments[appointmentIndex], - secondReader.id, - secondReader.role, - firstReadTime, - { readNumber: 1, alignmentProbability } + + // First read by the second user, aligned with the image set + const firstRead = addRead( + readingCase, + appointment, + secondReader, + baseReadTime.toISOString(), + { alignmentProbability } ) - updatedAppointments[appointmentIndex].imageReading.reads[secondReader.id] = - firstRead - // Second read (by first user) - 80% chance of agreement with first read + // Second read by the current user, usually agreeing with the first const secondReadTime = baseReadTime .add(Math.floor(Math.random() * 16) + 15, 'minutes') .toISOString() - // Determine second read opinion - 80% agree with first, 20% different - const forceSecondOpinion = - Math.random() > 0.8 - ? Object.keys(TAG_TO_RESULT) - .map((t) => TAG_TO_RESULT[t]) - .filter((r) => r !== firstRead.opinion)[ - Math.floor(Math.random() * 2) - ] - : firstRead.opinion - - const secondRead = generateSingleRead( - updatedAppointments[appointmentIndex], - firstReader.id, - firstReader.role, - secondReadTime, - { - forceOpinion: forceSecondOpinion, - readNumber: 2, - alignmentProbability - } - ) - updatedAppointments[appointmentIndex].imageReading.reads[firstReader.id] = - secondRead + addRead(readingCase, appointment, firstReader, secondReadTime, { + forceOpinion: pickSecondOpinion(firstRead), + alignmentProbability + }) - updatedAppointmentIds.add(appointment.id) + readAppointmentIds.add(appointment.id) count++ }) } + console.log( `Added first and second reads to ${count} appointments in the 2 oldest clinics` ) } - // NEW: Add clinic where both reads are completed, but neither by the current user + // NEXT CLINIC: both reads completed, but neither by the current user if (clinics.length >= 3) { - let count = 0 - // Use the next clinic for this scenario const clinic = clinics[2] console.log( `Adding a clinic with both reads completed by users other than current user to clinic ${clinic.id}` ) - // Use the same base time for all reads in this clinic, then advance by 1 minute for each appointment - let baseReadTime = dayjs(generateRecentTimestamp(clinic.date, 30, 48)) - - // Add full first reads by third user - clinic.appointments.forEach((appointment) => { - // Skip if already updated - if (updatedAppointmentIds.has(appointment.id)) return - - // Find the appointment in our array - const appointmentIndex = updatedAppointments.findIndex((e) => e.id === appointment.id) - if (appointmentIndex === -1) return - - // Ensure the imageReading structure exists - if (!updatedAppointments[appointmentIndex].imageReading) { - updatedAppointments[appointmentIndex].imageReading = { reads: {} } - } - - // Advance time by 1 minute for each read - baseReadTime = baseReadTime.add(1, 'minute') - const firstReadTime = baseReadTime.toISOString() - - // First read (by third user) - aligned with image set - const firstRead = generateSingleRead( - updatedAppointments[appointmentIndex], - thirdReader.id, - thirdReader.role, - firstReadTime, - { readNumber: 1, alignmentProbability } - ) - updatedAppointments[appointmentIndex].imageReading.reads[thirdReader.id] = firstRead - - updatedAppointmentIds.add(appointment.id) - count++ + const count = readClinic(clinic, thirdReader, { + startTime: dayjs(generateRecentTimestamp(30, 48)) }) - // Add second reads by second user to 60% of appointments + // Second reads by the second user on 60% of them const appointmentsForSecondRead = clinic.appointments - .filter((appointment) => updatedAppointmentIds.has(appointment.id)) - .slice(0, Math.ceil(clinic.appointments.length * 0.6)) // Take 60% of appointments for second read + .filter((appointment) => readAppointmentIds.has(appointment.id)) + .slice(0, Math.ceil(clinic.appointments.length * 0.6)) - baseReadTime = dayjs(generateRecentTimestamp(clinic.date, 12, 24)) // More recent timestamp for second reads + let baseReadTime = dayjs(generateRecentTimestamp(12, 24)) // More recent than the first reads appointmentsForSecondRead.forEach((appointment) => { - const appointmentIndex = updatedAppointments.findIndex((e) => e.id === appointment.id) - if (appointmentIndex === -1) return - - // Get the first read - const firstRead = - updatedAppointments[appointmentIndex].imageReading.reads[thirdReader.id] + const readingCase = casesByAppointmentId.get(appointment.id) + const firstRead = readingCase?.reads?.[0] if (!firstRead) return - // Second read (by second user) - 80% chance of agreement with first read - // Determine second read opinion - 80% agree with first, 20% different - const forceSecondOpinion = - Math.random() > 0.8 - ? Object.keys(TAG_TO_RESULT) - .map((t) => TAG_TO_RESULT[t]) - .filter((r) => r !== firstRead.opinion)[ - Math.floor(Math.random() * 2) - ] - : firstRead.opinion - - // Advance time by 1-2 minutes for each read baseReadTime = baseReadTime.add( 1 + Math.floor(Math.random() * 2), 'minute' ) - const secondReadTime = baseReadTime.toISOString() - - const secondRead = generateSingleRead( - updatedAppointments[appointmentIndex], - secondReader.id, - secondReader.role, - secondReadTime, - { - forceOpinion: forceSecondOpinion, - readNumber: 2, - alignmentProbability - } + + addRead( + readingCase, + appointment, + secondReader, + baseReadTime.toISOString(), + { forceOpinion: pickSecondOpinion(firstRead), alignmentProbability } ) - updatedAppointments[appointmentIndex].imageReading.reads[secondReader.id] = secondRead }) console.log( - `Added a clinic with ${clinic.appointments.length} first reads and ${appointmentsForSecondRead.length} second reads, both done by users other than current user` + `Added a clinic with ${count} first reads and ${appointmentsForSecondRead.length} second reads, both done by users other than current user` ) } - // NEXT TWO CLINICS: First user (current user) reads all first, but no second reads + // NEXT TWO CLINICS: current user reads all first, but no second reads if (clinics.length >= 5) { let count = 0 for (let i = 3; i < 5 && i < clinics.length; i++) { const clinic = clinics[i] console.log(`Adding first reads by current user to clinic ${clinic.id}`) - - // Use the same base time for all reads in this clinic, then advance by 1 minute for each appointment - let baseReadTime = dayjs(generateRecentTimestamp(clinic.date, 12, 36)) - - clinic.appointments.forEach((appointment) => { - // Skip if already updated - if (updatedAppointmentIds.has(appointment.id)) return - - // Find the appointment in our array - const appointmentIndex = updatedAppointments.findIndex((e) => e.id === appointment.id) - if (appointmentIndex === -1) return - - // Ensure the imageReading structure exists - if (!updatedAppointments[appointmentIndex].imageReading) { - updatedAppointments[appointmentIndex].imageReading = { reads: {} } - } - - // Advance time by 1 minute for each read - baseReadTime = baseReadTime.add(1, 'minute') - const firstReadTime = baseReadTime.toISOString() - - // First read (by first user/current user) - aligned with image set - const firstRead = generateSingleRead( - updatedAppointments[appointmentIndex], - firstReader.id, - firstReader.role, - firstReadTime, - { readNumber: 1, alignmentProbability } - ) - updatedAppointments[appointmentIndex].imageReading.reads[firstReader.id] = firstRead - - updatedAppointmentIds.add(appointment.id) - count++ + count += readClinic(clinic, firstReader, { + startTime: dayjs(generateRecentTimestamp(12, 36)) }) } console.log( @@ -759,46 +732,14 @@ const generateReadingData = (appointments, users, seedProfile = {}) => { ) } - // NEXT TWO CLINICS: Second user reads all first, waiting for first user (current user) to do second reads + // NEXT TWO CLINICS: second user reads all first, waiting on the current user if (clinics.length >= 7) { let count = 0 for (let i = 5; i < 7 && i < clinics.length; i++) { const clinic = clinics[i] console.log(`Adding first reads by second user to clinic ${clinic.id}`) - - // Use the same base time for all reads in this clinic, then advance by 1 minute for each appointment - let baseReadTime = dayjs(generateRecentTimestamp(clinic.date, 4, 24)) - - clinic.appointments.forEach((appointment) => { - // Skip if already updated - if (updatedAppointmentIds.has(appointment.id)) return - - // Find the appointment in our array - const appointmentIndex = updatedAppointments.findIndex((e) => e.id === appointment.id) - if (appointmentIndex === -1) return - - // Ensure the imageReading structure exists - if (!updatedAppointments[appointmentIndex].imageReading) { - updatedAppointments[appointmentIndex].imageReading = { reads: {} } - } - - // Advance time by 1 minute for each read - baseReadTime = baseReadTime.add(1, 'minute') - const firstReadTime = baseReadTime.toISOString() - - // First read (by second user) - aligned with image set - const firstRead = generateSingleRead( - updatedAppointments[appointmentIndex], - secondReader.id, - secondReader.role, - firstReadTime, - { readNumber: 1, alignmentProbability } - ) - updatedAppointments[appointmentIndex].imageReading.reads[secondReader.id] = - firstRead - - updatedAppointmentIds.add(appointment.id) - count++ + count += readClinic(clinic, secondReader, { + startTime: dayjs(generateRecentTimestamp(4, 24)) }) } console.log( @@ -806,47 +747,21 @@ const generateReadingData = (appointments, users, seedProfile = {}) => { ) } - // NEXT TWO OLDEST CLINICS: 75% first read by third user + // NEXT TWO CLINICS: 75% first read by the third user if (clinics.length >= 9) { let count = 0 for (let i = 7; i < 9 && i < clinics.length; i++) { const clinic = clinics[i] console.log(`Adding partial first reads to clinic ${clinic.id}`) - // Use the same base time for all reads in this clinic, then advance by 1 minute for each appointment - let baseReadTime = dayjs(generateRecentTimestamp(clinic.date, 1, 12)) - - // Only read 75% of appointments in these clinics + // Only read 75% of the appointments in these clinics const appointmentsToRead = clinic.appointments - .filter((appointment) => !updatedAppointmentIds.has(appointment.id)) - .slice(0, Math.ceil(clinic.appointments.length * 0.75)) // Take first 75% - - appointmentsToRead.forEach((appointment) => { - // Find the appointment in our array - const appointmentIndex = updatedAppointments.findIndex((e) => e.id === appointment.id) - if (appointmentIndex === -1) return + .filter((appointment) => !readAppointmentIds.has(appointment.id)) + .slice(0, Math.ceil(clinic.appointments.length * 0.75)) - // Ensure the imageReading structure exists - if (!updatedAppointments[appointmentIndex].imageReading) { - updatedAppointments[appointmentIndex].imageReading = { reads: {} } - } - - // Advance time by 1 minute for each read - baseReadTime = baseReadTime.add(1, 'minute') - const firstReadTime = baseReadTime.toISOString() - - // First read (by third user) - aligned with image set - const firstRead = generateSingleRead( - updatedAppointments[appointmentIndex], - thirdReader.id, - thirdReader.role, - firstReadTime, - { readNumber: 1, alignmentProbability } - ) - updatedAppointments[appointmentIndex].imageReading.reads[thirdReader.id] = firstRead - - updatedAppointmentIds.add(appointment.id) - count++ + count += readClinic(clinic, thirdReader, { + startTime: dayjs(generateRecentTimestamp(1, 12)), + only: appointmentsToRead }) } console.log( @@ -854,8 +769,7 @@ const generateReadingData = (appointments, users, seedProfile = {}) => { ) } - console.log(`Total appointments with reading data: ${updatedAppointmentIds.size}`) - return updatedAppointments + console.log(`Total appointments with reading data: ${readAppointmentIds.size}`) } module.exports = { diff --git a/app/lib/generators/seed-profiles.js b/app/lib/generators/seed-profiles.js index f9f531db..c9f45527 100644 --- a/app/lib/generators/seed-profiles.js +++ b/app/lib/generators/seed-profiles.js @@ -30,9 +30,22 @@ const mergeDeep = (base, override) => { } const SEED_DATA_PROFILE_DEFAULTS = { - imageReading: { + // How generated reads behave. Named `reads` rather than `imageReading` - + // nothing in the data model is called imageReading any more, and the reads + // themselves are what this shapes. + reads: { probabilityFirstReaderOpinionMatchesImages: 0.95 }, + episodes: { + // How past rounds turned out. Overriding this is what stops a profile's + // claim about the current round being contradicted by a participant's + // history - 'all normals' shouldn't show past referrals for treatment. + historicOutcomeWeights: { + routine_recall: 0.9, + refer_for_treatment: 0.03, + no_result: 0.07 + } + }, reading: { // null = no limit (use default clinic-pattern behaviour) // 0 = empty backlog, n = limit backlog to n cases @@ -216,6 +229,14 @@ const SEED_DATA_PROFILE_DEFINITIONS = [ previousMammograms: { rate: 0 }, + // Nothing was ever found, in this round or any before it + episodes: { + historicOutcomeWeights: { + routine_recall: 0.93, + refer_for_treatment: 0, + no_result: 0.07 + } + }, imageSetSelection: { contextualTagWeights: { default: { @@ -233,6 +254,15 @@ const SEED_DATA_PROFILE_DEFINITIONS = [ label: 'High abnormalities', description: 'High chance of abnormalities in selected images', settings: { + // A population with abnormal current rounds plausibly has a history of + // them too, so past rounds find more as well + episodes: { + historicOutcomeWeights: { + routine_recall: 0.78, + refer_for_treatment: 0.15, + no_result: 0.07 + } + }, imageSetSelection: { contextualTagWeights: { default: { diff --git a/app/lib/utils/appointment-status.js b/app/lib/utils/appointment-status.js index 6352f776..3e2cf5ff 100644 --- a/app/lib/utils/appointment-status.js +++ b/app/lib/utils/appointment-status.js @@ -17,6 +17,7 @@ const { } = require('./appointment-data') const { syncEpisodeMammogramsForAppointment, + syncReadingCasesForAppointment, advanceEpisodeForAppointmentStatus } = require('./episodes') @@ -51,8 +52,10 @@ const updateAppointmentStatus = (data, appointmentId, newStatus) => { if (!updatedAppointment) return null // Keep the appointment's episode in step, and record on the episode that - // images were taken (or weren't after all, on an undo) + // images were taken (or weren't after all, on an undo). A new set of images + // is both a mammogram entry and a new case to read, so the two sync together syncEpisodeMammogramsForAppointment(data, updatedAppointment) + syncReadingCasesForAppointment(data, updatedAppointment) advanceEpisodeForAppointmentStatus(data, updatedAppointment) return updatedAppointment diff --git a/app/lib/utils/episodes.js b/app/lib/utils/episodes.js index 15efa81d..fb1b3fcc 100644 --- a/app/lib/utils/episodes.js +++ b/app/lib/utils/episodes.js @@ -1,18 +1,25 @@ // app/lib/utils/episodes.js // // An episode is one screening round for a participant - the container its -// appointment(s) sit in. See docs/data-conventions.md. +// appointment(s) and its reading sit in. See docs/data-conventions.md. // -// Reading data still lives on appointments, not on the episode; the reading -// accessors here derive an episode's state from its appointments rather than -// holding a copy. That move happens with the work that needs it. +// Reading lives on the episode as `episode.readingCases[]`, one case per set of +// images. The case logic itself is in reading-cases.js, which is deliberately +// pure - this file owns getting cases out of session data and writing them +// back, because that means going through the episode. const dataStore = require('../data-store') const { getAppointment } = require('./appointment-data.js') -const { getReadingStatusForAppointments } = require('./reading.js') const { getClinic } = require('./clinics.js') const { formatMonthYear } = require('./dates.js') const { isActive } = require('./status.js') +const { + buildReadingCase, + getReadingCases, + getLatestReadingCase, + getReadingCaseForAppointment, + getReadingCaseOutcome +} = require('./reading-cases.js') // An episode is open until it closes. While open, the stage says where in the // process it has got to; `closed` means it has an outcome and is done. @@ -74,9 +81,9 @@ const EPISODE_STAGE_BY_APPOINTMENT_STATUS = { attended_not_screened: { stage: 'closed', outcome: 'no_result' } } -// Where a concluded reading leaves its episode. Reading outcomes that mean -// reading is still under way (not_read, pending_second_read, -// arbitration_pending) are absent - the episode stays in `reading`. +// Where a concluded reading leaves its episode. A reading still under way has +// no outcome at all (getReadingCaseOutcome returns null), and an episode with +// no reading outcome stays in `reading`. // // Note a clear reading is the only one that ends the episode. Recall for // assessment is an interim routing decision, not a result: it moves the @@ -226,18 +233,123 @@ const getEpisodeAppointments = (data, episode) => { } /** - * Get the reading status of an episode, derived from its appointments. + * Get the reading case covering an appointment's images. * - * Reading data lives on appointments, so this rescopes the existing group-level - * reading helper over just this episode's appointments. + * The single bridge from an appointment to its case. Everything that works in + * appointment terms - the reading session, the workflow pages, the backlog - + * comes through here once, at the edge, and passes the case onwards. * * @param {object} data - Session data + * @param {object} appointment - Appointment object + * @returns {object | null} The case, or null if the appointment produced no images + */ +const getReadingCase = (data, appointment) => { + if (!appointment?.episodeId) return null + + const episode = getEpisode(data, appointment.episodeId) + if (!episode) return null + + return getReadingCaseForAppointment(episode, appointment.id) +} + +/** + * Get an episode's reading cases, oldest first + * + * @param {object} episode - Episode object + * @returns {Array} The cases + */ +const getEpisodeReadingCases = (episode) => { + return getReadingCases(episode) +} + +/** + * Get the case that says where an episode's reading has got to - its latest. + * + * @param {object} episode - Episode object + * @returns {object | null} The latest case, or null if the round has no images + */ +const getEpisodeReadingCase = (episode) => { + return getLatestReadingCase(episode) +} + +/** + * Get an episode's reading outcome, from its latest case. + * + * The *latest* case, so a technical recall's re-screen supersedes the reading + * that asked for it rather than the episode being stuck on the older answer. + * * @param {object} episode - Episode object - * @param {string} [userId] - Optional user, for per-user reading counts - * @returns {object} Reading status and metrics for the episode + * @param {object} [settings] - Site settings object (data.settings) + * @returns {string | null} The outcome, or null while reading is under way */ -const getEpisodeReadingStatus = (data, episode, userId = null) => { - return getReadingStatusForAppointments(getEpisodeAppointments(data, episode), userId) +const getEpisodeReadingOutcome = (episode, settings = {}) => { + const latestCase = getLatestReadingCase(episode) + return latestCase ? getReadingCaseOutcome(latestCase, settings) : null +} + +/** + * Save a changed reading case back to its episode. + * + * Build a whole replacement case and pass it in - the one you read came out of + * shared read-only data (see docs/data-conventions.md). reading-cases.js has + * `withRead` / `withoutRead` for building those replacements. + * + * @param {object} data - Session data + * @param {string} episodeId - Episode ID + * @param {object} updatedCase - Whole replacement case record + * @returns {object | null} The updated episode, or null if not found + */ +const updateReadingCase = (data, episodeId, updatedCase) => { + const episode = getEpisode(data, episodeId) + if (!episode) { + console.warn(`updateReadingCase: no episode with id ${episodeId}`) + return null + } + + const readingCases = getReadingCases(episode).map((existing) => + existing.id === updatedCase.id ? updatedCase : existing + ) + + return updateEpisode(data, episodeId, { readingCases }) +} + +/** + * Keep an episode's reading cases in step with one of its appointments. + * + * Called alongside syncEpisodeMammogramsForAppointment, because they answer the + * same question from two sides: a new set of images is both a mammogram entry + * and a new case to read. Undoing a screened status removes both again. + * + * @param {object} data - Session data + * @param {object} appointment - The appointment whose status just changed + * @returns {object | null} The updated episode, or null if nothing to do + */ +const syncReadingCasesForAppointment = (data, appointment) => { + if (!appointment?.episodeId) return null + + const episode = getEpisode(data, appointment.episodeId) + if (!episode) return null + + const existing = getReadingCases(episode) + const otherCases = existing.filter( + (readingCase) => readingCase.appointmentId !== appointment.id + ) + + if (!appointmentProducedImages(appointment)) { + // No images from this appointment any more - drop its case. Any reads on it + // go too, which is right: they were reads of images that didn't happen. + if (otherCases.length === existing.length) return null + return updateEpisode(data, episode.id, { readingCases: otherCases }) + } + + // Already has a case - leave it and its reads alone + if (otherCases.length !== existing.length) return null + + const readingCases = [...existing, buildReadingCase(appointment)].sort( + (a, b) => new Date(a.openedDate) - new Date(b.openedDate) + ) + + return updateEpisode(data, episode.id, { readingCases }) } /** @@ -573,7 +685,7 @@ const advanceEpisodeForAppointmentStatus = (data, appointment) => { * * @param {object} data - Session data * @param {object} appointment - The appointment that was read - * @param {string} readingOutcome - Outcome from getOutcome + * @param {string} readingOutcome - Outcome from getReadingCaseOutcome * @returns {object | null} The updated episode, or null if nothing to do */ const advanceEpisodeForReadingOutcome = (data, appointment, readingOutcome) => { @@ -598,7 +710,12 @@ module.exports = { getEpisodesForParticipant, getCurrentEpisode, getEpisodeAppointments, - getEpisodeReadingStatus, + getReadingCase, + getEpisodeReadingCases, + getEpisodeReadingCase, + getEpisodeReadingOutcome, + updateReadingCase, + syncReadingCasesForAppointment, getEpisodeMammogramDate, getLastMammogram, getNextAppointment, diff --git a/app/lib/utils/reading-cases.js b/app/lib/utils/reading-cases.js new file mode 100644 index 00000000..b1ad25cd --- /dev/null +++ b/app/lib/utils/reading-cases.js @@ -0,0 +1,644 @@ +// app/lib/utils/reading-cases.js +// +// A reading case is one set of mammograms being read. Taking images triggers a +// case; the reads - first, second, and later an arbitration read - belong to +// the case rather than to the episode or the appointment directly. Cases live +// on the episode as `episode.readingCases[]`, one per image set, oldest first. +// +// Most episodes have one case. A technical recall produces a second set of +// images and therefore a second case, which is why the episode's reading state +// comes from its *latest* case rather than from any appointment. +// +// The trigger rule is: new image set -> new case. `episode.mammograms[]` is +// already one entry per image set, so cases are opened alongside those entries +// (see syncReadingCasesForAppointment). +// +// Everything here takes a case, not an appointment. Resolving the case from an +// appointment happens once, at the edges - route handlers, the reading workflow +// middleware, and the list-building helpers in reading.js - via +// getReadingCase. + +const generateId = require('./id-generator') + +// What kind of read this was, recorded when the read is written rather than +// worked out later from its position. Once arbitration has a manual gate in +// front of it, "was this an arbitration read" depends on where the case had got +// to at the time, which nothing can recover afterwards. +const READ_TYPES = ['first', 'second', 'arbitration'] + +// Where a case has got to. Derived from its reads plus the acts recorded on it +// - never stored, so it can't drift from the reads it describes. +// +// `arbitration_required` and `in_arbitration` are deliberately different +// states: reads disagreeing is what makes arbitration *necessary*, but a case +// only becomes arbitratable once someone releases it. Nothing performs that +// release yet, so no case reaches `in_arbitration` today. +const READING_CASE_STATES = [ + 'awaiting_first_read', + 'awaiting_second_read', + 'arbitration_required', + 'in_arbitration', + 'concluded' +] + +// What a concluded case found. Note this is the *reading* outcome, not the +// episode outcome - a recall for assessment concludes the reading but leaves +// the episode open, because the result comes from the assessment. +// +// Deferral and outstanding priors are deliberately not outcomes: both are +// temporary states that hold a case up, and a case held up still owes an +// outcome once it is released. +const READING_CASE_OUTCOMES = [ + 'normal', + 'technical_recall', + 'recall_for_assessment' +] + +/** + * Build a new reading case for one set of images. + * + * Shared by the seed generator and the runtime sync so the two can't drift. + * + * @param {object} appointment - The appointment that produced the images + * @param {string} [openedDate] - When the case opened; defaults to when the + * images were taken + * @returns {object} Reading case record + */ +const buildReadingCase = (appointment, openedDate = null) => { + return { + id: generateId(), + appointmentId: appointment.id, + openedDate: + openedDate || + appointment.timing?.actualStartTime || + appointment.timing?.startTime || + null, + reads: [] + } +} + +/** + * All of an episode's reading cases, oldest first + * + * @param {object} episode - Episode object + * @returns {Array} Reading cases + */ +const getReadingCases = (episode) => { + return episode?.readingCases || [] +} + +/** + * An episode's most recent reading case - the one that decides where the + * episode has got to. + * + * A technical recall leaves an older, superseded case behind; that one is + * history, and must not drag the episode backwards. + * + * @param {object} episode - Episode object + * @returns {object | null} The latest case, or null if the round has no images yet + */ +const getLatestReadingCase = (episode) => { + const cases = getReadingCases(episode) + return cases[cases.length - 1] || null +} + +/** + * Find the reading case covering a given appointment's images + * + * @param {object} episode - Episode object + * @param {string} appointmentId - Appointment ID + * @returns {object | null} The case, or null if that appointment produced no images + */ +const getReadingCaseForAppointment = (episode, appointmentId) => { + return ( + getReadingCases(episode).find( + (readingCase) => readingCase.appointmentId === appointmentId + ) || null + ) +} + +/** + * A case's reads in order, oldest first. + * + * Reads are stored in order, so this mainly exists to give callers a safe empty + * array and a single place to change if ordering ever needs deriving again. + * + * @param {object} readingCase - Reading case + * @returns {Array} The reads + */ +const getReadsAsArray = (readingCase) => { + return readingCase?.reads || [] +} + +/** + * Get one user's read on a case + * + * @param {object} readingCase - Reading case + * @param {string} userId - User ID + * @returns {object | null} Their read, or null if they haven't read it + */ +const getReadForUser = (readingCase, userId) => { + if (!userId) return null + + return ( + getReadsAsArray(readingCase).find((read) => read.readerId === userId) || null + ) +} + +/** + * Get the reads on a case made by anyone other than the given user + * + * @param {object} readingCase - Reading case + * @param {string} userId - User ID to exclude + * @returns {Array} The other reads, in order + */ +const getOtherReads = (readingCase, userId) => { + return getReadsAsArray(readingCase).filter((read) => read.readerId !== userId) +} + +/** + * Get the arbitration read on a case, if one has been made + * + * @param {object} readingCase - Reading case + * @returns {object | null} The arbitration read, or null + */ +const getArbitrationRead = (readingCase) => { + return ( + getReadsAsArray(readingCase).find((read) => read.readType === 'arbitration') || + null + ) +} + +/** + * Whether a user has read a case + * + * @param {object} readingCase - Reading case + * @param {string} userId - User ID + * @returns {boolean} + */ +const userHasReadCase = (readingCase, userId) => { + return Boolean(getReadForUser(readingCase, userId)) +} + +/** + * Whether a case has any reads + * + * @param {object} readingCase - Reading case + * @returns {boolean} + */ +const caseHasReads = (readingCase) => { + return getReadsAsArray(readingCase).length > 0 +} + +/** + * Whether a case has been deferred from reading. + * + * Deferral is recorded as an act (who deferred it and when), and the state is + * read back from its presence - the same shape arbitration release takes. + * + * @param {object} readingCase - Reading case + * @returns {boolean} + */ +const isCaseDeferred = (readingCase) => { + return Boolean(readingCase?.deferral?.deferredAt) +} + +/** + * Whether a case has been released into arbitration. + * + * Nothing performs the release yet - the flow that does is future work - so + * this is false for every case today. It exists so the state vocabulary is + * whole, rather than growing a new value later across every call site. + * + * @param {object} readingCase - Reading case + * @returns {boolean} + */ +const isCaseInArbitration = (readingCase) => { + return Boolean(readingCase?.arbitration?.releasedAt) +} + +/** + * Whether the reads on a case disagree in a clinically meaningful way. + * + * Rules: + * - Different top-level opinions -> always discordant + * - Both technical recall: discordant if the set of selected views differs + * (reasons are ignored - same views = concordant even with different reasons) + * - Both recall for assessment: discordant if either per-breast assessment + * differs (annotations and comments are ignored) + * - Both normal -> concordant + * + * Handles partial data gracefully: if TR views or RFA breast assessments are + * not yet filled in, falls back to comparing only what's available. + * + * @param {object} readA - First read (saved read or imageReadingTemp) + * @param {object} readB - Second read (saved read or imageReadingTemp) + * @returns {boolean} Whether the reads are discordant + */ +const areReadsDiscordant = (readA, readB) => { + if (!readA?.opinion || !readB?.opinion) return false + + // Different top-level opinions -> always discordant + if (readA.opinion !== readB.opinion) return true + + const opinion = readA.opinion + + // Both TR: compare the set of selected view keys + if (opinion === 'technical_recall') { + const viewsA = readA.technicalRecall?.views + const viewsB = readB.technicalRecall?.views + // If either side has no view data yet, can't compare further + if (!viewsA || !viewsB) return false + const keysA = new Set(Object.keys(viewsA)) + const keysB = new Set(Object.keys(viewsB)) + if (keysA.size !== keysB.size) return true + for (const view of keysA) { + if (!keysB.has(view)) return true + } + return false + } + + // Both RFA: compare per-breast assessments + if (opinion === 'recall_for_assessment') { + const leftA = readA.left?.breastAssessment + const leftB = readB.left?.breastAssessment + const rightA = readA.right?.breastAssessment + const rightB = readB.right?.breastAssessment + // If no breast data on either side yet, can't compare further + if (!leftA && !leftB && !rightA && !rightB) return false + if ((leftA || leftB) && leftA !== leftB) return true + if ((rightA || rightB) && rightA !== rightB) return true + return false + } + + return false +} + +/** + * Whether two reads mean the case needs arbitrating, taking the site's + * arbitration policy into account. + * + * Policies (from settings.reading.arbitrationPolicy): + * - 'discordant_only' (default): only discordant reads need arbitration + * - 'all_non_normal': any concordant non-normal outcome does too + * + * @param {object} readA - First read + * @param {object} readB - Second read + * @param {object} [settings] - Site settings object (data.settings) + * @returns {boolean} + */ +const willGoToArbitration = (readA, readB, settings = {}) => { + if (!readA || !readB) return false + + // Discordant reads always go to arbitration + if (areReadsDiscordant(readA, readB)) return true + + // Concordant but non-normal: depends on policy + const policy = settings?.reading?.arbitrationPolicy || 'discordant_only' + if (policy === 'all_non_normal') { + return readA.opinion !== 'normal' + } + + return false +} + +/** + * Where a case has got to. + * + * Deferral and outstanding priors are not states here - both hold a case up + * without changing how far through reading it is, so they belong alongside the + * state as flags rather than replacing it. + * + * @param {object} readingCase - Reading case + * @param {object} [settings] - Site settings object (data.settings) + * @returns {string} One of READING_CASE_STATES + */ +const getReadingCaseState = (readingCase, settings = {}) => { + const reads = getReadsAsArray(readingCase) + + 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' + + const [firstRead, secondRead] = reads + + if (willGoToArbitration(firstRead, secondRead, settings)) { + return isCaseInArbitration(readingCase) + ? 'in_arbitration' + : 'arbitration_required' + } + + return 'concluded' +} + +/** + * What a case found, or null while reading is still under way. + * + * Null is meaningful: it says the reading hasn't produced an answer yet, which + * is what leaves an episode sitting in `reading`. + * + * @param {object} readingCase - Reading case + * @param {object} [settings] - Site settings object (data.settings) + * @returns {string | null} One of READING_CASE_OUTCOMES, or null + */ +const getReadingCaseOutcome = (readingCase, settings = {}) => { + if (getReadingCaseState(readingCase, settings) !== 'concluded') return null + + // Arbitration, where it happened, is the deciding read + const arbitrationRead = getArbitrationRead(readingCase) + if (arbitrationRead) return arbitrationRead.opinion || null + + // Otherwise the two reads agreed, so either one gives the answer + return getReadsAsArray(readingCase)[0]?.opinion || null +} + +/** + * Summary counts and flags for a case, for lists and progress displays + * + * @param {object} readingCase - Reading case + * @param {object} [settings] - Site settings object (data.settings) + * @returns {object} Reading metadata + */ +const getReadingMetadata = (readingCase, settings = {}) => { + const reads = getReadsAsArray(readingCase) + const uniqueReaderCount = new Set(reads.map((read) => read.readerId)).size + const opinions = [...new Set(reads.map((read) => read.opinion))].filter( + Boolean + ) + + // Discordance is richer than comparing opinion strings - it also checks TR + // views and RFA breast assessments + const isDiscordant = + reads.length >= 2 ? areReadsDiscordant(reads[0], reads[1]) : false + + return { + readCount: reads.length, + uniqueReaderCount, + firstReadComplete: reads.length >= 1, + secondReadComplete: reads.length >= 2, + isDiscordant, + opinions, + state: getReadingCaseState(readingCase, settings), + outcome: getReadingCaseOutcome(readingCase, settings) + } +} + +/** + * Whether a case still needs a first read + * + * @param {object} readingCase - Reading case + * @returns {boolean} + */ +const caseNeedsFirstRead = (readingCase) => { + return !caseHasReads(readingCase) +} + +/** + * Whether a case has a first read and still needs a second + * + * @param {object} readingCase - Reading case + * @returns {boolean} + */ +const caseNeedsSecondRead = (readingCase) => { + return getReadsAsArray(readingCase).length === 1 +} + +/** + * Whether a case needs arbitrating but hasn't been released into it + * + * @param {object} readingCase - Reading case + * @param {object} [settings] - Site settings object (data.settings) + * @returns {boolean} + */ +const caseNeedsArbitration = (readingCase, settings = {}) => { + return getReadingCaseState(readingCase, settings) === 'arbitration_required' +} + +/** + * Whether a user can read a case. + * + * Only covers what the case itself knows. Outstanding priors also block + * reading, but those live on the appointment, so callers working from an + * appointment combine the two (see canUserReadAppointment in reading.js). + * + * @param {object} readingCase - Reading case + * @param {string} userId - User ID + * @param {object} [options] - Options + * @param {number} [options.maxReadsPerCase] - Reads needed before it's complete + * @returns {boolean} + */ +const canUserReadCase = (readingCase, userId, options = {}) => { + const { maxReadsPerCase = 2 } = options + + if (!userId) return false + + // A deferred case is out of the queue until someone reviews it + if (isCaseDeferred(readingCase)) return false + + // Enough readers have had it already + if (getReadsAsArray(readingCase).length >= maxReadsPerCase) return false + + // Nobody reads the same case twice + return !userHasReadCase(readingCase, userId) +} + +/** + * Work out what the second reader should be shown about the first read. + * + * Returns false when there is nothing to compare - the user is the first + * reader, or both opinions are normal. + * + * @param {object} readingCase - The case being read + * @param {object | string} secondReadData - The second reader's working data + * (imageReadingTemp or a read object); a bare opinion string also works + * @param {string} userId - Current user ID + * @param {object} [settings] - Site settings object (data.settings) + * @returns {false | object} False if no comparison is needed, else the details + */ +const getComparisonInfo = ( + readingCase, + secondReadData, + userId, + settings = {} +) => { + // Support passing just an opinion string + const secondRead = + typeof secondReadData === 'string' + ? { opinion: secondReadData } + : secondReadData + + // Reads are held in order, so the earliest by anyone else is the first read + const firstRead = getOtherReads(readingCase, userId)[0] + + // No first read exists - the user is the first reader + if (!firstRead) return false + + const firstOpinion = firstRead.opinion + const secondOpinion = secondRead?.opinion + + // Both normal - nothing worth comparing + if (firstOpinion === 'normal' && secondOpinion === 'normal') return false + + const discordant = areReadsDiscordant(firstRead, secondRead) + + return { + type: discordant ? 'discordant' : 'agreeing', + discordant, + goesToArbitration: willGoToArbitration(firstRead, secondRead, settings), + firstRead, + firstOpinion, + secondOpinion + } +} + +/** + * Whether the compare page should be shown to the second reader. + * + * Combines whether there is anything to compare with the show-when setting + * (settings.reading.compareWhen): + * - 'non_normal' (default): show whenever either opinion is non-normal + * - 'discordant_only': only show when the two reads actually disagree + * + * The separate timing setting (secondReaderComparison) decides *when* in the + * flow to ask this, and is handled by the routes. + * + * @param {object} readingCase - The case being read + * @param {object} secondReadData - The second reader's working data + * @param {string} userId - Current user ID + * @param {object} [settings] - Site settings object (data.settings) + * @returns {boolean} + */ +const shouldShowComparePage = ( + readingCase, + secondReadData, + userId, + settings = {} +) => { + const comparisonInfo = getComparisonInfo( + readingCase, + secondReadData, + userId, + settings + ) + + if (!comparisonInfo) return false + + const compareWhen = settings?.reading?.compareWhen || 'non_normal' + + if (compareWhen === 'discordant_only') return comparisonInfo.discordant + + return true +} + +/** + * Build the read record for a user's opinion on a case. + * + * The read type is settled here, from where the case had got to when the read + * was made - so it stays true even if reads are later withdrawn. + * + * @param {object} readingCase - The case being read + * @param {string} userId - Reader's user ID + * @param {string} readerType - Reader's role + * @param {object} reading - The opinion and its details + * @param {object} [options] - Options + * @param {string} [options.timestamp] - When the read was made + * @returns {object} The read record + */ +const buildRead = (readingCase, userId, readerType, reading, options = {}) => { + const existingRead = getReadForUser(readingCase, userId) + const otherReads = getOtherReads(readingCase, userId) + + // 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) + ? 'arbitration' + : READ_TYPES[Math.min(readNumber, READ_TYPES.length) - 1] + + return { + ...reading, + readerId: userId, + readerType, + readType, + readNumber, + timestamp: options.timestamp || new Date().toISOString() + } +} + +/** + * Add or replace a user's read on a case, returning a new case record. + * + * Returns a replacement rather than mutating: cases live inside episodes, which + * are shared read-only data (see docs/data-conventions.md). + * + * @param {object} readingCase - The case being read + * @param {object} read - The read record, from buildRead + * @returns {object} A new case record with the read applied + */ +const withRead = (readingCase, read) => { + const reads = getReadsAsArray(readingCase) + const existingIndex = reads.findIndex( + (candidate) => candidate.readerId === read.readerId + ) + + const updatedReads = + existingIndex >= 0 + ? reads.map((candidate, index) => (index === existingIndex ? read : candidate)) + : [...reads, read] + + return { ...readingCase, reads: updatedReads } +} + +/** + * Remove a user's read from a case, returning a new case record. + * + * Deferring after giving an opinion withdraws that opinion - the reader is + * saying they can't judge this case after all. + * + * @param {object} readingCase - The case + * @param {string} userId - Whose read to remove + * @returns {object} A new case record without that read + */ +const withoutRead = (readingCase, userId) => { + return { + ...readingCase, + reads: getReadsAsArray(readingCase).filter( + (read) => read.readerId !== userId + ) + } +} + +module.exports = { + READ_TYPES, + READING_CASE_STATES, + READING_CASE_OUTCOMES, + buildReadingCase, + getReadingCases, + getLatestReadingCase, + getReadingCaseForAppointment, + getReadsAsArray, + getReadForUser, + getOtherReads, + getArbitrationRead, + userHasReadCase, + caseHasReads, + isCaseDeferred, + isCaseInArbitration, + areReadsDiscordant, + willGoToArbitration, + getComparisonInfo, + shouldShowComparePage, + getReadingCaseState, + getReadingCaseOutcome, + getReadingMetadata, + caseNeedsFirstRead, + caseNeedsSecondRead, + caseNeedsArbitration, + canUserReadCase, + buildRead, + withRead, + withoutRead +} diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index 16040df5..d555289c 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -1,125 +1,86 @@ // app/lib/utils/reading.js +// +// The appointment- and session-shaped layer over image reading: reading +// sessions, backlogs, clinic lists, progress and navigation, all of which work +// in terms of the appointments a reader is working through. +// +// The reading data itself lives on the episode, as `episode.readingCases[]`. +// Case-level logic is in reading-cases.js and takes a case; getting the case +// for an appointment goes through getReadingCase in episodes.js. That is why +// most helpers here take `data` - it's what resolving a case needs. const dayjs = require('dayjs') const { getClinic } = require('./clinics') const { eligibleForReading, getStatusTagColour } = require('./status') const { isWithinDayRange } = require('./dates') const { awaitingPriors, userRequestedPriors } = require('./prior-mammograms') -const { updateAppointmentData } = require('./appointment-data') - -// /** -// * Get first unread appointment in a clinic -// */ -// const getFirstUnreadAppointment = (data, clinicId) => { -// return data.appointments.find(appointment => -// appointment.clinicId === clinicId && -// eligibleForReading(appointment) && -// !appointment.reads?.length -// ) || null -// } - -// /** -// * Get first unread appointment from first available clinic -// */ -// const getFirstUnreadAppointmentOverall = (data) => { -// const firstClinic = getFirstAvailableClinic(data) -// if (!firstClinic) return null - -// return getFirstUnreadAppointment(data, firstClinic.id) -// } +const { + getReadingCase, + getEpisodeAppointments, + updateReadingCase +} = require('./episodes') +const { + getReadsAsArray, + getReadForUser, + getReadingMetadata, + getReadingCaseState, + isCaseDeferred, + caseHasReads, + caseNeedsFirstRead, + caseNeedsSecondRead, + canUserReadCase, + userHasReadCase, + buildRead, + withRead +} = require('./reading-cases') /************************************************************************ // Single appointment -//*********************************************************************** - -/** - * Get reading metadata for an appointment - * @param {Object} appointment - The appointment to check - * @returns {Object} Object with reading metadata - */ -const getReadingMetadata = (appointment) => { - // Get all reads from the imageReading structure - const reads = appointment.imageReading?.reads - ? Object.values(appointment.imageReading.reads) - : [] - const readerIds = reads.map((read) => read.readerId) - const uniqueReaderCount = new Set(readerIds).size - - // Get all unique opinions from reads - const opinions = reads.map((read) => read.opinion) - const uniqueOpinions = [...new Set(opinions)].filter(Boolean) // Filter out undefined - - // Discordance: richer than just comparing opinion strings — also checks TR views and RFA breast assessments - const isDiscordant = - reads.length >= 2 ? areReadsDiscordant(reads[0], reads[1]) : false - - return { - readCount: reads.length, - uniqueReaderCount, - firstReadComplete: reads.length >= 1, - secondReadComplete: reads.length >= 2, - isDiscordant, - opinions: uniqueOpinions - } -} +// +// Thin bridges from an appointment to its case, for the places that only have +// an appointment to hand. Anything doing real work with reads should take the +// case instead. +//***********************************************************************/ /** - * Get all reads for an appointment as an ordered array - * Sorted by readNumber if available, otherwise by timestamp - * @param {Object} appointment - The appointment to get reads for - * @returns {Array} Array of read objects sorted by read order + * Get the reading metadata for an appointment's case + * + * @param {object} data - Session data + * @param {object} appointment - The appointment + * @returns {object} Reading metadata, all zeros if there is no case yet */ -const getReadsAsArray = function (appointment) { - if (!appointment?.imageReading?.reads) { - return [] - } - - return Object.values(appointment.imageReading.reads).sort((a, b) => { - // Sort by readNumber if both have it - if (a.readNumber && b.readNumber) { - return a.readNumber - b.readNumber - } - // Fall back to timestamp - return new Date(a.timestamp) - new Date(b.timestamp) - }) +const getAppointmentReadingMetadata = (data, appointment) => { + return getReadingMetadata(resolveCase(data, appointment), data?.settings || {}) } /** - * Save a user's reading for an appointment, and remove the appointment from the reading - * session's skipped list if present + * Save a user's read of an appointment's images, and take the appointment off + * the reading session's skipped list if it was on it. * - * Builds a new imageReading object and saves it through updateAppointmentData - * rather than mutating the appointment - appointment records are shared read-only data. + * The read goes onto the episode's reading case, not the appointment - a read + * is a read of one set of images, and the case is what holds those. * - * @param {object} appointment - The appointment to update - * @param {string} userId - User ID - * @param {object} reading - Reading data to save * @param {object} data - Session data + * @param {object} appointment - The appointment whose images were read + * @param {string} userId - User ID + * @param {object} reading - The opinion and its details * @param {string | null} [sessionId] - Reading session ID (if in session context) + * @returns {object | null} The updated case, or null if there was none to write to */ -const writeReading = (appointment, userId, reading, data, sessionId = null) => { - // Work on a clone so the shared record is never touched in place - const imageReading = structuredClone(appointment.imageReading || {}) - if (!imageReading.reads) { - imageReading.reads = {} +const writeReading = (data, appointment, userId, reading, sessionId = null) => { + const readingCase = getReadingCase(data, appointment) + if (!readingCase) { + console.warn( + `writeReading: no reading case for appointment ${appointment?.id}` + ) + return null } - // Calculate readNumber based on existing reads - const existingReadCount = Object.keys(imageReading.reads).length - // If this user already has a read, keep their readNumber; otherwise assign next number - const existingRead = imageReading.reads[userId] - const readNumber = existingRead?.readNumber || existingReadCount + 1 - - // Add the reading with timestamp and readNumber - imageReading.reads[userId] = { - ...reading, - readerId: userId, // Ensure the reader ID is saved - readNumber, - timestamp: new Date().toISOString() - } + const readerType = data.users?.find((user) => user.id === userId)?.role + const read = buildRead(readingCase, userId, readerType, reading) + const updatedCase = withRead(readingCase, read) - // Saves to the appointment and mirrors into data.appointment if it matches - updateAppointmentData(data, appointment.id, { imageReading }) + updateReadingCase(data, appointment.episodeId, updatedCase) // Note the episode deliberately stays in `reading`. Two opinions and a // computed outcome is not a confirmed result, and there is no step in the @@ -137,6 +98,103 @@ const writeReading = (appointment, userId, reading, data, sessionId = null) => { session.skippedAppointments.splice(skippedIndex, 1) } } + + return updatedCase +} + +/** + * Get the reading status of an episode. + * + * Lives here rather than in episodes.js so the requires stay one-directional - + * episodes.js owns getting cases out of session data, and this is the layer + * above that summarises them. + * + * Scoped by the episode's appointments rather than its cases directly, because + * the summary mixes case facts (reads) with appointment ones (outstanding + * priors, how long ago the images were taken). + * + * @param {object} data - Session data + * @param {object} episode - Episode object + * @param {string} [userId] - Optional user, for per-user reading counts + * @returns {object} Reading status and metrics for the episode + */ +const getEpisodeReadingStatus = (data, episode, userId = null) => { + return getReadingStatusForAppointments( + data, + getEpisodeAppointments(data, episode), + userId + ) +} + +/** + * Every case currently deferred from reading, most recently deferred first. + * + * Deferral is a case-level fact, so this walks the reading backlog and pairs + * each deferred case with the appointment and participant it belongs to - what + * a list of deferred cases needs to show a row. + * + * @param {object} data - Session data + * @returns {Array} `{ appointment, participant, readingCase, deferral }`, newest first + */ +const getDeferredCases = (data) => { + return data.appointments + .map((appointment) => ({ + appointment, + participant: data.participants.find( + (participant) => participant.id === appointment.participantId + ), + readingCase: getReadingCase(data, appointment) + })) + .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) + ) +} + +/** + * Every deferral that has since been resolved, most recently resolved first. + * + * A case can have been deferred and returned to the queue more than once, so + * this is a list of deferrals rather than of cases. + * + * @param {object} data - Session data + * @returns {Array} `{ appointment, participant, readingCase, deferral }`, newest first + */ +const getResolvedDeferrals = (data) => { + return data.appointments + .flatMap((appointment) => { + const readingCase = getReadingCase(data, appointment) + + return (readingCase?.deferralHistory || []).map((deferral) => ({ + appointment, + participant: data.participants.find( + (participant) => participant.id === appointment.participantId + ), + readingCase, + deferral + })) + }) + .sort( + (a, b) => new Date(b.deferral.resolvedAt) - new Date(a.deferral.resolvedAt) + ) +} + +/** + * Get an appointment's reading case, preferring one already attached. + * + * List-building code enriches appointments with their case up front so a long + * list resolves each one once; anything working from a raw appointment record + * falls through to resolving it here. + * + * @param {object} data - Session data + * @param {object} appointment - The appointment + * @returns {object | null} The case, or null if the appointment produced no images + */ +const resolveCase = (data, appointment) => { + return appointment?.readingCase !== undefined + ? appointment.readingCase + : getReadingCase(data, appointment) } /************************************************************************ @@ -144,23 +202,35 @@ const writeReading = (appointment, userId, reading, data, sessionId = null) => { //*********************************************************************** /** - * Enhance appointments with pre-calculated reading metadata + * Enhance appointments with their reading case and pre-calculated metadata. + * + * Attaching the case here is what lets the rest of the list-handling code stay + * cheap - everything downstream reads `appointment.readingCase` instead of + * resolving the episode again per row. + * + * @param {object} data - Session data * @param {Array} appointments - Array of appointments to enhance * @param {Array} participants - Array of participants for lookups * @param {string} userId - Current user ID - * @returns {Array} Enhanced appointments with pre-calculated metadata + * @returns {Array} Enhanced appointments with their case and metadata */ -const enhanceAppointmentsWithReadingData = (appointments, participants, userId) => { +const enhanceAppointmentsWithReadingData = ( + data, + appointments, + participants, + userId +) => { // Create a lookup map for participants const participantMap = new Map(participants.map((p) => [p.id, p])) - // Enhanced appointments with pre-calculated metadata + // Enhanced appointments with their case and pre-calculated metadata return appointments.map((appointment) => { - // Calculate metadata once - const metadata = getReadingMetadata(appointment) + const readingCase = getReadingCase(data, appointment) + const enhanced = { ...appointment, readingCase } + const metadata = getReadingMetadata(readingCase, data?.settings || {}) return { - ...appointment, + ...enhanced, participant: participantMap.get(appointment.participantId), readStatus: metadata.readCount > 0 ? `Read (${metadata.readCount})` : 'Not read', @@ -168,7 +238,7 @@ const enhanceAppointmentsWithReadingData = (appointments, participants, userId) metadata.readCount > 0 ? 'read' : 'not_read' ), readingMetadata: metadata, - canUserRead: canUserReadAppointment(appointment, userId) + canUserRead: canUserReadAppointment(data, enhanced, userId) } }) } @@ -176,19 +246,21 @@ const enhanceAppointmentsWithReadingData = (appointments, participants, userId) /** * Calculate core reading metrics used for both status and progress tracking * + * @param {object} data - Session data * @param {Array} appointments - Array of appointments to analyze * @param {string | null} userId - User ID for user-specific metrics * @param {Array} [skippedAppointments] - Array of skipped appointment IDs * @returns {object} Core metrics object */ const calculateReadingMetrics = function ( + data, appointments, userId = null, skippedAppointments = [] ) { // Get user ID and settings from context if not provided and we're in a template context const currentUserId = userId || this?.ctx?.data?.currentUser?.id - const settings = this?.ctx?.data?.settings || {} + const settings = data?.settings || this?.ctx?.data?.settings || {} if (!appointments || appointments.length === 0) { return { @@ -214,25 +286,26 @@ const calculateReadingMetrics = function ( } } - // Count first reads (appointments with at least one read) - const firstReadCount = appointments.filter(hasReads).length + // Resolve each appointment's case once - every count below is about the case + const cases = appointments.map((appointment) => resolveCase(data, appointment)) + + // Count first reads (cases with at least one read) + const firstReadCount = cases.filter(caseHasReads).length const completedCount = firstReadCount // For compatibility with current usage - // Count second reads (appointments with at least two different readers) - const secondReadCount = appointments.filter((appointment) => { - const metadata = getReadingMetadata(appointment) - return metadata.uniqueReaderCount >= 2 - }).length + // Count second reads (cases with at least two different readers) + const secondReadCount = cases.filter( + (readingCase) => + getReadingMetadata(readingCase, settings).uniqueReaderCount >= 2 + ).length - // Count appointments that are ready for second read (have first read but not second) - const secondReadReady = appointments.filter((appointment) => { - const metadata = getReadingMetadata(appointment) - return metadata.readCount === 1 // Exactly one read means ready for second - }).length + // Count cases that are ready for second read (have first read but not second) + const secondReadReady = cases.filter(caseNeedsSecondRead).length - // Count appointments needing arbitration (policy-aware via getOutcome) - const arbitrationCount = appointments.filter( - (appointment) => getOutcome(appointment, settings) === 'arbitration_pending' + // Count cases needing arbitration (policy-aware via the case state) + const arbitrationCount = cases.filter( + (readingCase) => + getReadingCaseState(readingCase, settings) === 'arbitration_required' ).length // Global awaiting priors count (appointments with any outstanding prior request) @@ -250,54 +323,40 @@ const calculateReadingMetrics = function ( let userSecondReadableCount = 0 if (currentUserId) { - // Appointments this user has read - userReadCount = appointments.filter((appointment) => - userHasReadAppointment(appointment, currentUserId) + // Cases this user has read + userReadCount = cases.filter((readingCase) => + userHasReadCase(readingCase, currentUserId) ).length - // Count first/second reads by this user - appointments.forEach((appointment) => { - const metadata = getReadingMetadata(appointment) - const reads = appointment.imageReading?.reads - ? Object.values(appointment.imageReading.reads) - : [] - - // Find reads by this user - const userReads = reads.filter((read) => read.readerId === currentUserId) - - // Count based on read position (first or second) - if (userReads.length > 0) { - // Check if this user did the first read - if (reads[0]?.readerId === currentUserId) { - userFirstReadCount++ - } - - // Check if this user did the second read - if (reads.length > 1 && reads[1]?.readerId === currentUserId) { - userSecondReadCount++ - } - } - }) + // Count first/second reads by this user, by the read's own recorded type + // rather than its position - a withdrawn read must not shuffle the rest + cases.forEach((readingCase) => { + const userRead = getReadForUser(readingCase, currentUserId) + if (!userRead) return - // Appointments where this user has an outstanding priors request - userAwaitingPriorsCount = appointments.filter( - (appointment) => - awaitingPriors(appointment) && userRequestedPriors(appointment, currentUserId) - ).length + if (userRead.readType === 'first') userFirstReadCount++ + if (userRead.readType === 'second') userSecondReadCount++ + }) // Appointments this user can read userReadableCount = appointments.filter((appointment) => - canUserReadAppointment(appointment, currentUserId) + canUserReadAppointment(data, appointment, currentUserId) ).length // Appointments needing first read that this user can read - userFirstReadableCount = filterAppointmentsByNeedsFirstRead(appointments).filter( - (appointment) => canUserReadAppointment(appointment, currentUserId) + userFirstReadableCount = filterAppointmentsByNeedsFirstRead( + data, + appointments + ).filter((appointment) => + canUserReadAppointment(data, appointment, currentUserId) ).length // Appointments needing second read that this user can read - userSecondReadableCount = filterAppointmentsByNeedsSecondRead(appointments).filter( - (appointment) => canUserReadAppointment(appointment, currentUserId) + userSecondReadableCount = filterAppointmentsByNeedsSecondRead( + data, + appointments + ).filter((appointment) => + canUserReadAppointment(data, appointment, currentUserId) ).length // Appointments where this user has an outstanding prior request @@ -339,13 +398,14 @@ const calculateReadingMetrics = function ( /** * Get detailed reading status for a group of appointments * + * @param {object} data - Session data * @param {Array} appointments - Array of appointments to analyze * @param {string | null} [userId] - Optional user ID (defaults to current user if available) * @returns {object} Detailed reading status */ -const getReadingStatusForAppointments = function (appointments, userId = null) { +const getReadingStatusForAppointments = function (data, appointments, userId = null) { // Get metrics from base calculation function - const metrics = calculateReadingMetrics(appointments, userId) + const metrics = calculateReadingMetrics.call(this, data, appointments, userId) // If no appointments, return basic metrics with default status if (!appointments || appointments.length === 0) { @@ -386,6 +446,7 @@ const getReadingStatusForAppointments = function (appointments, userId = null) { * Get progress through reading a set of appointments * Enhanced to include user-specific navigation * + * @param {object} data - Session data * @param {Array} appointments - Array of appointments to track progress through * @param {string} currentAppointmentId - ID of current appointment * @param {Array} skippedAppointments - Array of appointment IDs that have been skipped @@ -393,13 +454,20 @@ const getReadingStatusForAppointments = function (appointments, userId = null) { * @returns {object} Progress information */ const getReadingProgress = function ( + data, appointments, currentAppointmentId, skippedAppointments = [], userId = null ) { // Get base metrics - const metrics = calculateReadingMetrics(appointments, userId, skippedAppointments) + const metrics = calculateReadingMetrics.call( + this, + data, + appointments, + userId, + skippedAppointments + ) // Get user ID from context if not provided and we're in a template context const currentUserId = userId || this?.ctx?.data?.currentUser?.id @@ -412,7 +480,7 @@ const getReadingProgress = function ( const previousAppointment = getPreviousAppointmentInList(appointments, currentAppointmentId, false) // Get appointments needing any reads (first or second) - const readableAppointments = filterAppointmentsByNeedsAnyRead(appointments) + const readableAppointments = filterAppointmentsByNeedsAnyRead(data, appointments) // Find next/previous of each type const nextReadableAppointment = @@ -428,6 +496,7 @@ const getReadingProgress = function ( let userNavigableAppointments = appointments if (currentUserId) { userNavigableAppointments = filterAppointmentsByUserCanReadOrHasRead( + data, appointments, currentUserId ) @@ -469,10 +538,10 @@ const getReadingProgress = function ( previousUserReadableId: previousUserReadableAppointment?.id || null, // Whether user has already read the previous/next appointment (for review page links) previousUserHasRead: previousUserReadableAppointment - ? userHasReadAppointment(previousUserReadableAppointment, currentUserId) + ? userHasReadAppointment(data, previousUserReadableAppointment, currentUserId) : false, nextUserHasRead: nextUserReadableAppointment - ? userHasReadAppointment(nextUserReadableAppointment, currentUserId) + ? userHasReadAppointment(data, nextUserReadableAppointment, currentUserId) : false, // Skipped appointments skippedAppointments, @@ -484,221 +553,6 @@ const getReadingProgress = function ( } } -// /** -// * Get progress through reading a set of appointments -// * Enhanced to include user-specific navigation -// * @param {Array} appointments - Array of appointments to track progress through -// * @param {string} currentAppointmentId - ID of current appointment -// * @param {Array} skippedAppointments - Array of appointment IDs that have been skipped -// * @param {string} [userId=null] - Optional user ID (defaults to current user if available) -// * @returns {Object} Progress information -// */ -// const getReadingProgress = function(appointments, currentAppointmentId, skippedAppointments = [], 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); - -// const currentIndex = appointments.findIndex(e => e.id === currentAppointmentId); - -// // Get complete appointments count -// const completedCount = appointments.filter(hasReads).length; - -// // Basic sequential navigation -// const nextAppointment = getNextAppointmentInList(appointments, currentAppointmentId, false); -// const previousAppointment = getPreviousAppointmentInList(appointments, currentAppointmentId, false); - -// // Get appointments needing any reads (first or second) -// const readableAppointments = filterAppointmentsByNeedsAnyRead(appointments); - -// // Find next/previous of each type -// const nextReadableAppointment = currentIndex !== -1 ? -// getNextAppointmentInList(readableAppointments, currentAppointmentId, true) : null; -// const previousReadableAppointment = currentIndex !== -1 ? -// getPreviousAppointmentInList(readableAppointments, currentAppointmentId, true) : null; - -// // For user-specific navigation, get appointments this user can read or has read -// let userNavigableAppointments = appointments; -// if (currentUserId) { -// userNavigableAppointments = filterAppointmentsByUserCanReadOrHasRead(appointments, currentUserId); -// } - -// // Find next/previous user-readable appointments if userId provided -// let nextUserReadableAppointment = null; -// let previousUserReadableAppointment = null; - -// if (currentUserId && currentIndex !== -1) { -// nextUserReadableAppointment = getNextAppointmentInList(userNavigableAppointments, currentAppointmentId, true); -// previousUserReadableAppointment = getPreviousAppointmentInList(userNavigableAppointments, currentAppointmentId, true); -// } - -// return { -// current: currentIndex + 1, -// total: appointments.length, -// completed: completedCount, -// // Appointment navigation -// hasNext: !!nextAppointment, -// hasPrevious: !!previousAppointment, -// nextAppointmentId: nextAppointment?.id || null, -// previousAppointmentId: previousAppointment?.id || null, -// hasNextReadableAppointment: !!nextReadableAppointment, -// hasPreviousReadableAppointment: !!previousReadableAppointment, -// nextReadableAppointmentId: nextReadableAppointment?.id || null, -// previousReadableAppointmentId: previousReadableAppointment?.id || null, -// // User-specific navigation -// hasNextUserReadable: !!nextUserReadableAppointment, -// hasPreviousUserReadable: !!previousUserReadableAppointment, -// nextUserReadableId: nextUserReadableAppointment?.id || null, -// previousUserReadableId: previousUserReadableAppointment?.id || null, -// // Skipped appointments -// skippedCount: skippedAppointments.length, -// skippedAppointments, -// isCurrentSkipped: skippedAppointments.includes(currentAppointmentId), -// nextAppointmentSkipped: nextAppointment ? skippedAppointments.includes(nextAppointment.id) : false, -// previousAppointmentSkipped: previousAppointment ? skippedAppointments.includes(previousAppointment.id) : false -// }; -// }; - -// /** -// * Get detailed reading status for a group of appointments -// * @param {Array} appointments - Array of appointments to analyze -// * @param {string} [userId=null] - Optional user ID (defaults to current user if available) -// * @returns {Object} Detailed reading status -// */ -// const getReadingStatusForAppointments = function(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); - -// if (!appointments || appointments.length === 0) { -// return { -// total: 0, -// firstReadCount: 0, -// firstReadRemaining: 0, -// secondReadCount: 0, -// secondReadRemaining: 0, -// secondReadReady: 0, -// arbitrationCount: 0, -// status: 'no_appointments', -// statusColor: 'grey', -// // User-specific counts -// userReadCount: 0, -// userFirstReadCount: 0, -// userSecondReadCount: 0, -// userReadableCount: 0, -// userFirstReadableCount: 0, -// userSecondReadableCount: 0 -// }; -// } - -// // Count first reads (appointments with at least one read) -// const firstReadCount = appointments.filter(hasReads).length; - -// // Count second reads (appointments with at least two different readers) -// const secondReadCount = appointments.filter(appointment => { -// const metadata = getReadingMetadata(appointment); -// return metadata.uniqueReaderCount >= 2; -// }).length; - -// // Count appointments that are ready for second read (have first read but not second) -// const secondReadReady = appointments.filter(appointment => { -// const metadata = getReadingMetadata(appointment); -// return metadata.readCount === 1; // Exactly one read means ready for second -// }).length; - -// // Count appointments needing arbitration (still track this for informational purposes) -// const arbitrationCount = appointments.filter(appointment => { -// const metadata = getReadingMetadata(appointment); -// return metadata.needsArbitration; -// }).length; - -// // User-specific counts if userId provided -// let userReadCount = 0; -// let userFirstReadCount = 0; -// let userSecondReadCount = 0; -// let userReadableCount = 0; -// let userFirstReadableCount = 0; -// let userSecondReadableCount = 0; - -// if (currentUserId) { -// // Appointments this user has read -// userReadCount = appointments.filter(appointment => userHasReadAppointment(appointment, currentUserId)).length; - -// // Count first/second reads by this user -// appointments.forEach(appointment => { -// const metadata = getReadingMetadata(appointment); -// const reads = appointment.imageReading?.reads ? Object.values(appointment.imageReading.reads) : []; - -// // Find reads by this user -// const userReads = reads.filter(read => read.readerId === currentUserId); - -// // Count based on read position (first or second) -// if (userReads.length > 0) { -// // Check if this user did the first read -// if (reads[0]?.readerId === currentUserId) { -// userFirstReadCount++; -// } - -// // Check if this user did the second read -// if (reads.length > 1 && reads[1]?.readerId === currentUserId) { -// userSecondReadCount++; -// } -// } -// }); - -// // Appointments this user can read -// userReadableCount = appointments.filter(appointment => canUserReadAppointment(appointment, currentUserId)).length; - -// // Appointments needing first read that this user can read -// userFirstReadableCount = filterAppointmentsByNeedsFirstRead(appointments) -// .filter(appointment => canUserReadAppointment(appointment, currentUserId)).length; - -// // Appointments needing second read that this user can read -// userSecondReadableCount = filterAppointmentsByNeedsSecondRead(appointments) -// .filter(appointment => canUserReadAppointment(appointment, currentUserId)).length; -// } - -// // Determine detailed status based on read counts -// let status; - -// if (firstReadCount === 0) { -// status = 'not_started'; -// } else if (firstReadCount < appointments.length) { -// if (secondReadCount > 0) { -// status = 'mixed_reads'; -// } else { -// status = 'partial_first_read'; -// } -// } else if (secondReadCount === 0) { -// status = 'first_read_complete'; -// } else if (secondReadCount < appointments.length) { -// status = 'partial_second_read'; -// } else { -// status = 'complete'; -// } - -// return { -// total: appointments.length, -// firstReadCount, -// firstReadRemaining: appointments.length - firstReadCount, -// secondReadCount, -// secondReadRemaining: appointments.length - secondReadCount, -// secondReadReady, // Appointments ready for immediate second read -// arbitrationCount, -// status, -// statusColor: getStatusTagColour(status), -// daysSinceScreening: appointments[0] ? -// dayjs().startOf('day').diff(dayjs(appointments[0].timing.startTime).startOf('day'), 'days') : 0, -// // User-specific counts -// userReadCount, -// userFirstReadCount, -// userSecondReadCount, -// userReadableCount, -// userFirstReadableCount, -// userSecondReadableCount, -// userCanRead: userReadableCount > 0 -// }; -// }; - -// Add this to app/lib/utils/reading.js - /** * Sort appointments by screening date (oldest first) * @@ -755,7 +609,11 @@ const getReadingClinics = (data, options = {}) => { ...clinic, unit, location, - readingStatus: getReadingStatusForAppointments(appointments, data.currentUser.id) + readingStatus: getReadingStatusForAppointments( + data, + appointments, + data.currentUser.id + ) } }) .sort((a, b) => new Date(a.id) - new Date(b.id)) // Some clinics share the same date so sort first by a unique ID to keep consistent sort @@ -777,6 +635,7 @@ const getReadableAppointmentsForClinic = (data, clinicId) => { // Enhance the appointments with reading metadata const enhancedAppointments = enhanceAppointmentsWithReadingData( + data, eligibleAppointments, data.participants, data.currentUser?.id @@ -805,89 +664,102 @@ const filterAppointmentsByEligibleForReading = (appointments) => { /** * Filter appointments that need any read (first or second) * + * @param {object} data - Session data * @param {Array} appointments - Appointments to filter - * @param {number} maxReadsPerAppointment - Number of reads required to be complete (default: 2) + * @param {number} maxReadsPerCase - Number of reads required to be complete (default: 2) * @returns {Array} Appointments needing any read */ -const filterAppointmentsByNeedsAnyRead = (appointments, maxReadsPerAppointment = 2) => { - return appointments.filter((appointment) => { - const metadata = getReadingMetadata(appointment) - return metadata.uniqueReaderCount < maxReadsPerAppointment - }) +const filterAppointmentsByNeedsAnyRead = (data, appointments, maxReadsPerCase = 2) => { + return appointments.filter( + (appointment) => + getReadsAsArray(resolveCase(data, appointment)).length < maxReadsPerCase + ) } /** * Filter appointments that need a first read * + * @param {object} data - Session data * @param {Array} appointments - Appointments to filter * @returns {Array} Appointments needing first read */ -const filterAppointmentsByNeedsFirstRead = (appointments) => { - return appointments.filter((appointment) => needsFirstRead(appointment)) +const filterAppointmentsByNeedsFirstRead = (data, appointments) => { + return appointments.filter((appointment) => + caseNeedsFirstRead(resolveCase(data, appointment)) + ) } /** * Filter appointments that need a second read * + * @param {object} data - Session data * @param {Array} appointments - Appointments to filter * @returns {Array} Appointments needing second read */ -const filterAppointmentsByNeedsSecondRead = (appointments) => { - return appointments.filter((appointment) => needsSecondRead(appointment)) +const filterAppointmentsByNeedsSecondRead = (data, appointments) => { + return appointments.filter((appointment) => + caseNeedsSecondRead(resolveCase(data, appointment)) + ) } /** * Filter appointments that are fully read (have all required reads) * + * @param {object} data - Session data * @param {Array} appointments - Appointments to filter * @param {number} requiredReads - Number of required reads (default: 2) * @returns {Array} Fully read appointments */ -const filterAppointmentsByFullyRead = (appointments, requiredReads = 2) => { - return appointments.filter((appointment) => { - const metadata = getReadingMetadata(appointment) - return metadata.uniqueReaderCount >= requiredReads - }) +const filterAppointmentsByFullyRead = (data, appointments, requiredReads = 2) => { + return appointments.filter( + (appointment) => + getReadsAsArray(resolveCase(data, appointment)).length >= requiredReads + ) } /** * Filter appointments that a specific user can read * + * @param {object} data - Session data * @param {Array} appointments - Appointments to filter * @param {string} userId - User ID * @returns {Array} Appointments user can read */ -const filterAppointmentsByUserCanRead = (appointments, userId) => { - return appointments.filter((appointment) => canUserReadAppointment(appointment, userId)) +const filterAppointmentsByUserCanRead = (data, appointments, userId) => { + return appointments.filter((appointment) => + canUserReadAppointment(data, appointment, userId) + ) } /** * Filter appointments that user can read or has already read * + * @param {object} data - Session data * @param {Array} appointments - Array of appointments to filter * @param {string} userId - User ID to check * @param {object} [options] - Options for determining eligibility * @returns {Array} Appointments user can read or has read * - * Priarily to support navigating backwards through appointments + * Primarily to support navigating backwards through appointments */ -const filterAppointmentsByUserCanReadOrHasRead = (appointments, userId, options = {}) => { - const { maxReadsPerAppointment = 2 } = options +const filterAppointmentsByUserCanReadOrHasRead = ( + data, + appointments, + userId, + options = {} +) => { + const { maxReadsPerCase = 2 } = options return appointments.filter((appointment) => { - const metadata = getReadingMetadata(appointment) + const readingCase = resolveCase(data, appointment) - // Include if user has already read this appointment - if (userHasReadAppointment(appointment, userId)) { - return true - } + // Include if the user has already read this case + if (userHasReadCase(readingCase, userId)) return true - // Include if appointment isn't fully read and user can read it - if (metadata.uniqueReaderCount < maxReadsPerAppointment) { - return true - } + // Include if the case isn't fully read yet, so they could still read it + if (getReadsAsArray(readingCase).length < maxReadsPerCase) return true - // Exclude appointments that are fully read by other users + // Exclude cases that are fully read by other users return false }) } @@ -978,47 +850,37 @@ const getPreviousAppointmentInList = (appointments, currentAppointmentId, wrap = / User functions /***********************************************************************/ -/** - * Get the read object for a specific user on an appointment - * - * @param {object} appointment - The appointment to check - * @param {string | null} [userId] - User ID (falls back to current user from context) - * @returns {object | null} The read object, or null if not found - */ -const getReadForUser = function (appointment, userId = null) { - const currentUserId = userId || this?.ctx?.data?.currentUser?.id - - if (!currentUserId) { - return null - } - - return appointment.imageReading?.reads?.[currentUserId] || null -} - /** * Get first appointment from an array that a user can read * + * @param {object} data - Session data * @param {Array} appointments - Array of appointments to search * @param {string | null} userId - User ID to check for * @returns {object | null} First appointment user can read or null if none */ -const getFirstUserReadableAppointment = function (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 - const readableAppointments = filterAppointmentsByUserCanRead(appointments, currentUserId) + const readableAppointments = filterAppointmentsByUserCanRead( + data, + appointments, + currentUserId + ) return readableAppointments.length > 0 ? readableAppointments[0] : null } /** * Get the next appointment the user can read after the current appointment, wrapping to start if needed * + * @param {object} data - Session data * @param {Array} appointments - Array of all appointments * @param {string} currentAppointmentId - ID of the current appointment * @param {string | null} [userId] - User ID (falls back to current user from context) * @returns {object | null} Next readable appointment, or null if none */ const getNextUserReadableAppointment = function ( + data, appointments, currentAppointmentId, userId = null, @@ -1030,7 +892,7 @@ const getNextUserReadableAppointment = function ( const appointmentsFromNext = wrap ? [...appointments.slice(currentIndex + 1), ...appointments.slice(0, currentIndex)] : appointments.slice(currentIndex + 1) - return getFirstUserReadableAppointment(appointmentsFromNext, currentUserId) + return getFirstUserReadableAppointment(data, appointmentsFromNext, currentUserId) } /** @@ -1045,12 +907,14 @@ const getNextUserReadableAppointment = function ( * * Falls back to getFirstUserReadableAppointment if the user has no reads or skips yet. * + * @param {object} data - Session data * @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 * @returns {object | null} The appointment to resume from, or null if nothing to read */ const getResumeAppointmentForUser = function ( + data, appointments, userId = null, skippedAppointments = [] @@ -1061,7 +925,10 @@ const getResumeAppointmentForUser = function ( let lastActedIndex = -1 appointments.forEach((appointment, index) => { - const wasReadByUser = !!appointment.imageReading?.reads?.[currentUserId] + const wasReadByUser = userHasReadCase( + resolveCase(data, appointment), + currentUserId + ) const wasSkipped = skippedAppointments.includes(appointment.id) if (wasReadByUser || wasSkipped) { lastActedIndex = index @@ -1070,7 +937,7 @@ const getResumeAppointmentForUser = function ( // Nothing acted on yet — fall back to first readable if (lastActedIndex === -1) { - return getFirstUserReadableAppointment(appointments, currentUserId) + return getFirstUserReadableAppointment(data, appointments, currentUserId) } // Search for the first readable appointment after lastActedIndex, wrapping around @@ -1078,20 +945,26 @@ const getResumeAppointmentForUser = function ( ...appointments.slice(lastActedIndex + 1), ...appointments.slice(0, lastActedIndex + 1) ] - return getFirstUserReadableAppointment(appointmentsFromNext, currentUserId) + return getFirstUserReadableAppointment(data, appointmentsFromNext, currentUserId) } /************************************************************************ // Booleans -//*********************************************************************** +// +// Only the predicates that need more than the case: reading is blocked by +// outstanding priors, which live on the appointment. Everything else about a +// read is in reading-cases.js and takes a case. +//***********************************************************************/ /** - * Check if a user has already read an appointment - * @param {Object} appointment - The appointment to check - * @param {string} userId - User ID to check + * Check if a user has already read an appointment's images + * + * @param {object} data - Session data + * @param {object} appointment - The appointment to check + * @param {string} [userId] - User ID (falls back to current user from context) * @returns {boolean} Whether the user has read this appointment */ -const userHasReadAppointment = function (appointment, userId) { +const userHasReadAppointment = function (data, appointment, userId = null) { const currentUserId = userId || this?.ctx?.data?.currentUser?.id if (!currentUserId) { @@ -1101,290 +974,28 @@ const userHasReadAppointment = function (appointment, userId) { return false } - return !!getReadForUser(appointment, currentUserId) -} - -/** - * Get reads from other users (not the current user) - * @param {Object} appointment - The appointment to check - * @param {string} userId - Current user ID to exclude - * @returns {Array} Array of read objects from other users - */ -const getOtherReads = function (appointment, userId = null) { - const currentUserId = userId || this?.ctx?.data?.currentUser?.id - - if (!appointment?.imageReading?.reads) { - return [] - } - - return Object.entries(appointment.imageReading.reads) - .filter(([readerId]) => readerId !== currentUserId) - .map(([readerId, read]) => ({ - ...read, - readerId - })) -} - -/** - * Determine if two reads are discordant (disagree in a clinically meaningful way). - * - * Rules: - * - Different top-level opinions → always discordant - * - Both technical recall: discordant if the set of selected views differs - * (reasons are ignored — same views = concordant even with different reasons) - * - Both recall for assessment: discordant if either per-breast assessment differs - * (annotations and comments are ignored) - * - Both normal → concordant - * - * Handles partial data gracefully: if TR views or RFA breast assessments are not - * yet filled in, falls back to comparing only what's available. - * - * @param {object} readA - First read (saved read or imageReadingTemp) - * @param {object} readB - Second read (saved read or imageReadingTemp) - * @returns {boolean} Whether the reads are discordant - */ -const areReadsDiscordant = (readA, readB) => { - if (!readA?.opinion || !readB?.opinion) return false - - // Different top-level opinions → always discordant - if (readA.opinion !== readB.opinion) return true - - const opinion = readA.opinion - - // Both TR: compare the set of selected view keys - if (opinion === 'technical_recall') { - const viewsA = readA.technicalRecall?.views - const viewsB = readB.technicalRecall?.views - // If either side has no view data yet, can't compare further - if (!viewsA || !viewsB) return false - const keysA = new Set(Object.keys(viewsA)) - const keysB = new Set(Object.keys(viewsB)) - if (keysA.size !== keysB.size) return true - for (const view of keysA) { - if (!keysB.has(view)) return true - } - return false - } - - // Both RFA: compare per-breast assessments - if (opinion === 'recall_for_assessment') { - const leftA = readA.left?.breastAssessment - const leftB = readB.left?.breastAssessment - const rightA = readA.right?.breastAssessment - const rightB = readB.right?.breastAssessment - // If no breast data on either side yet, can't compare further - if (!leftA && !leftB && !rightA && !rightB) return false - if ((leftA || leftB) && leftA !== leftB) return true - if ((rightA || rightB) && rightA !== rightB) return true - return false - } - - return false -} - -/** - * Determine whether two reads will result in arbitration, taking the site's - * arbitration policy into account. - * - * Policies (from settings.reading.arbitrationPolicy): - * - 'discordant_only' (default): only discordant reads go to arbitration - * - 'all_non_normal': any concordant non-normal outcome also goes to arbitration - * - * @param {object} readA - First read - * @param {object} readB - Second read - * @param {object} [settings] - Site settings object (data.settings) - * @returns {boolean} - */ -const willGoToArbitration = (readA, readB, settings = {}) => { - if (!readA || !readB) return false - - // Discordant reads always go to arbitration - if (areReadsDiscordant(readA, readB)) return true - - // Concordant but non-normal: depends on policy - const policy = settings?.reading?.arbitrationPolicy || 'discordant_only' - if (policy === 'all_non_normal') { - return readA.opinion !== 'normal' - } - - return false + return userHasReadCase(resolveCase(data, appointment), currentUserId) } /** - * Compute the overall outcome for an appointment based on its reads and site policy. - * - * Outcomes: - * - 'not_read' — no reads yet - * - 'pending_second_read' — one read, awaiting second - * - 'arbitration_pending' — two reads that are discordant (or policy requires arbitration) - * - 'normal' / 'technical_recall' / 'recall_for_assessment' - * — concordant outcome (or resolved by an arbitration read) - * - * Note: outcome is computed on demand, not persisted. If you need to filter or - * report by outcome at scale, consider writing it to appointment.imageReading.outcome at - * save-opinion time. - * - * @param {object} appointment - The appointment - * @param {object} [settings] - Site settings object (data.settings) - * @returns {string} Outcome key - */ -const getOutcome = function (appointment, settings = null) { - const resolvedSettings = settings || this?.ctx?.data?.settings || {} - const reads = getReadsAsArray(appointment) - - if (reads.length === 0) return 'not_read' - if (reads.length === 1) return 'pending_second_read' - - // Third read = arbitration read; its opinion resolves the case - if (reads.length >= 3) { - return reads[2].opinion - } - - const [firstRead, secondRead] = reads - - if (willGoToArbitration(firstRead, secondRead, resolvedSettings)) { - return 'arbitration_pending' - } - - // Concordant reads — outcome is the shared opinion - return firstRead.opinion -} - -/** - * Determine if a comparison page should be shown to the second reader. - * Returns false if user is first reader, or if both opinions are normal. - * Otherwise returns comparison info including discordance and arbitration flags. - * - * @param {object} appointment - The appointment being read - * @param {object} secondReadData - The second reader's data (imageReadingTemp or a read object) - * @param {string} [userId] - Current user ID (optional, falls back to context) - * @param {object} [settings] - Site settings (optional, falls back to context) - * @returns {false | object} False if no comparison needed, else comparison info - */ -const getComparisonInfo = function ( - appointment, - secondReadData, - userId = null, - settings = null -) { - const currentUserId = userId || this?.ctx?.data?.currentUser?.id - const resolvedSettings = settings || this?.ctx?.data?.settings || {} - - // Support passing just an opinion string for backwards compatibility - const secondRead = - typeof secondReadData === 'string' - ? { opinion: secondReadData } - : secondReadData - - // Get the first read (from other users) - const otherReads = getOtherReads.call(this, appointment, currentUserId) - - // No first read exists - user is first reader - if (otherReads.length === 0) { - return false - } - - // Get the first reader's opinion (sorted by readNumber) - const firstRead = otherReads.sort((a, b) => { - if (a.readNumber && b.readNumber) return a.readNumber - b.readNumber - return new Date(a.timestamp) - new Date(b.timestamp) - })[0] - - const firstOpinion = firstRead.opinion - const secondOpinion = secondRead.opinion - - // Both normal - no comparison needed - if (firstOpinion === 'normal' && secondOpinion === 'normal') { - return false - } - - const discordant = areReadsDiscordant(firstRead, secondRead) - const type = discordant ? 'discordant' : 'agreeing' - - return { - type, - discordant, - goesToArbitration: willGoToArbitration( - firstRead, - secondRead, - resolvedSettings - ), - firstRead, - firstOpinion, - secondOpinion - } -} - -/** - * Decide whether the compare page should be shown to the second reader. - * - * Combines the timing setting (secondReaderComparison) and the new show-when - * setting (compareWhen) to give a single boolean answer. + * Check if a user can read an appointment's images. * - * compareWhen values (settings.reading.compareWhen): - * - 'non_normal' (default): show whenever either opinion is non-normal - * (i.e. whenever getComparisonInfo returns a result — current behaviour) - * - 'discordant_only': only show when the two reads are discordant + * Combines what the case knows (deferral, who has read it, how many reads it + * has) with the one blocker that lives on the appointment: a prior mammogram + * that has been requested but not yet arrived. * - * @param {object} appointment - The appointment being read - * @param {object} secondReadData - The second reader's data (imageReadingTemp or read object) - * @param {string} [userId] - Current user ID (optional, falls back to context) - * @param {object} [settings] - Site settings (optional, falls back to context) - * @returns {boolean} + * @param {object} data - Session data + * @param {object} appointment - The appointment to check + * @param {string | null} [userId] - User ID (falls back to current user from context) + * @param {object} [options] - Options for determining eligibility + * @returns {boolean} Whether the user can read this appointment */ -const shouldShowComparePage = function ( +const canUserReadAppointment = function ( + data, appointment, - secondReadData, userId = null, - settings = null + options = {} ) { - const resolvedSettings = settings || this?.ctx?.data?.settings || {} - const currentUserId = userId || this?.ctx?.data?.currentUser?.id - - const comparisonInfo = getComparisonInfo.call( - this, - appointment, - secondReadData, - currentUserId, - resolvedSettings - ) - - // getComparisonInfo returns false when no comparison needed - // (user is first reader, or both opinions are normal) - if (!comparisonInfo) return false - - const compareWhen = resolvedSettings?.reading?.compareWhen || 'non_normal' - - // 'non_normal': show for any non-normal combination — current behaviour - if (compareWhen === 'non_normal') return true - - // 'discordant_only': only show when reads disagree in a clinically meaningful way - if (compareWhen === 'discordant_only') return comparisonInfo.discordant - - return true -} - -/** - * Check if current user can read an appointment - * - * @param {object} appointment - The appointment to check - * @param {string | null} userId - Current user ID - * @param {object} [options] - Options for determining eligibility - * @returns {boolean} Whether the current user can read this appointment - */ -/** - * Check if an appointment has been deferred from reading - * - * @param {object} appointment - The appointment to check - * @returns {boolean} Whether the appointment has been deferred - */ -const isDeferred = (appointment) => { - return !!appointment?.imageReading?.deferral?.deferredAt -} - -const canUserReadAppointment = function (appointment, userId = null, options = {}) { - const { maxReadsPerAppointment = 2 } = options - const currentUserId = userId || this?.ctx?.data?.currentUser?.id if (!currentUserId) { @@ -1394,70 +1005,12 @@ const canUserReadAppointment = function (appointment, userId = null, options = { return false } - // Can't read if appointment is awaiting priors - if (awaitingPriors(appointment)) { - return false - } - - // Can't read if appointment has been deferred - if (isDeferred(appointment)) { - return false - } - - const metadata = getReadingMetadata(appointment) + // Can't read while a requested prior is still outstanding + if (awaitingPriors(appointment)) return false - // If we already have enough unique readers, no more reads needed - if (metadata.uniqueReaderCount >= maxReadsPerAppointment) { - return false - } - - // User can't read if they've already read it - if (userHasReadAppointment(appointment, currentUserId)) { - return false - } - - return true + return canUserReadCase(resolveCase(data, appointment), currentUserId, options) } -/** - * Check if an appointment has any reads - * - * @param {object} appointment - The appointment to check - * @returns {boolean} Whether the appointment has any reads - */ -const hasReads = (appointment) => { - return ( - appointment.imageReading?.reads && - Object.keys(appointment.imageReading.reads).length > 0 - ) -} - -/** - * Check if an appointment needs a first read - * - * @param {object} appointment - The appointment to check - * @returns {boolean} Whether a first read is needed - */ -const needsFirstRead = (appointment) => { - return !hasReads(appointment) -} - -/** - * Check if an appointment needs a second read - */ -const needsSecondRead = (appointment) => { - const metadata = getReadingMetadata(appointment) - return metadata.firstReadComplete && !metadata.secondReadComplete -} - -/** - * Check if an appointment needs arbitration. - * Policy-aware: reads arbitrationPolicy from Nunjucks context if available. - */ -const needsArbitration = function (appointment) { - const settings = this?.ctx?.data?.settings || {} - return getOutcome(appointment, settings) === 'arbitration_pending' -} /************************************************************************ // Sessions @@ -1511,7 +1064,7 @@ const getEligibleCandidatesForSession = (data, sessionOptions) => { } else { // 1. Filter to appointments the user can read (unless overridden) if (filters.userCanRead !== false) { - appointments = filterAppointmentsByUserCanRead(appointments, currentUserId) + appointments = filterAppointmentsByUserCanRead(data, appointments, currentUserId) } // 2. Apply awaiting priors filter @@ -1537,14 +1090,14 @@ const getEligibleCandidatesForSession = (data, sessionOptions) => { // Apply read type filters switch (type) { case 'first_reads': - appointments = filterAppointmentsByNeedsFirstRead(appointments) + appointments = filterAppointmentsByNeedsFirstRead(data, appointments) break case 'second_reads': - appointments = filterAppointmentsByNeedsSecondRead(appointments) + appointments = filterAppointmentsByNeedsSecondRead(data, appointments) break case 'all_reads': case 'awaiting_priors': - appointments = filterAppointmentsByNeedsAnyRead(appointments) + appointments = filterAppointmentsByNeedsAnyRead(data, appointments) break } @@ -1745,7 +1298,9 @@ const getFirstReadableAppointmentInSession = (data, sessionId, userId = null) => // Find the first one the user can read return ( - sessionAppointments.find((appointment) => canUserReadAppointment(appointment, currentUserId)) || + sessionAppointments.find((appointment) => + canUserReadAppointment(data, appointment, currentUserId) + ) || null ) } @@ -1797,9 +1352,9 @@ const topUpSession = (data, sessionId) => { const appointment = data.appointments.find((e) => e.id === appointmentId) if (!appointment) return false return ( - userHasReadAppointment(appointment, currentUserId) || - canUserReadAppointment(appointment, currentUserId) || - isDeferred(appointment) || + userHasReadAppointment(data, appointment, currentUserId) || + canUserReadAppointment(data, appointment, currentUserId) || + isCaseDeferred(getReadingCase(data, appointment)) || awaitingPriors(appointment) ) }).length @@ -1848,6 +1403,7 @@ const getSessionReadingProgress = ( // Use existing function for progress tracking, then add session-level size info const progress = getReadingProgress( + data, sessionAppointments, currentAppointmentId, session.skippedAppointments, @@ -1873,9 +1429,9 @@ const getSessionReadingProgress = ( // toward reachable size. topUpSession will replace them when appointments are read. const deadCount = sessionAppointments.filter((appointment) => { return ( - !userHasReadAppointment(appointment, resolvedUserId) && - !canUserReadAppointment(appointment, resolvedUserId) && - !isDeferred(appointment) && + !userHasReadAppointment(data, appointment, resolvedUserId) && + !canUserReadAppointment(data, appointment, resolvedUserId) && + !isCaseDeferred(getReadingCase(data, appointment)) && !awaitingPriors(appointment) ) }).length @@ -1885,7 +1441,9 @@ const getSessionReadingProgress = ( const effectiveTargetSize = Math.min(resolvedTargetSize, reachableSessionSize) // Count deferred appointments so they count toward the session target - const deferredCount = sessionAppointments.filter(isDeferred).length + const deferredCount = sessionAppointments.filter((appointment) => + isCaseDeferred(getReadingCase(data, appointment)) + ).length return { ...progress, @@ -1907,15 +1465,12 @@ const getSessionReadingProgress = ( } module.exports = { - // getFirstUnreadAppointment, - // getFirstUnreadAppointmentOverall, - // Single appointment - getReadingMetadata, - areReadsDiscordant, - willGoToArbitration, - getOutcome, + getAppointmentReadingMetadata, writeReading, + getEpisodeReadingStatus, + getDeferredCases, + getResolvedDeferrals, // Multiple appointments enhanceAppointmentsWithReadingData, @@ -1943,22 +1498,12 @@ module.exports = { getNextAppointmentInList, getPreviousAppointmentInList, // User functions - getReadForUser, - getOtherReads, - getComparisonInfo, - shouldShowComparePage, - getReadsAsArray, getFirstUserReadableAppointment, getNextUserReadableAppointment, getResumeAppointmentForUser, // Booleans userHasReadAppointment, canUserReadAppointment, - isDeferred, - hasReads, - needsArbitration, - needsFirstRead, - needsSecondRead, // Sessions getEligibleCandidatesForSession, diff --git a/app/lib/utils/status.js b/app/lib/utils/status.js index 213c17c0..db120a17 100644 --- a/app/lib/utils/status.js +++ b/app/lib/utils/status.js @@ -249,7 +249,7 @@ const STATUS_TAGS = { // External prior mammogram request tracking (episode.priors) priorsRequest: { not_requested: { label: 'Not requested', colour: 'white' }, - pending: { label: 'Needs requesting', colour: 'orange' }, + pending: { label: 'Priors required', colour: 'orange' }, requested: { label: 'Requested', colour: 'yellow' }, received: { label: 'Received', colour: 'green' }, not_available: { label: 'Not available', colour: 'grey' }, @@ -267,7 +267,9 @@ const STATUS_TAGS = { incomplete: { colour: 'orange' }, urgent: { colour: 'red' }, due_soon: { colour: 'orange' }, - priors_requested: { colour: 'yellow' }, + // Case-level equivalent of priorsRequest.pending - same words and colour, + // so readers and admin staff see one status rather than two + priors_required: { colour: 'orange' }, deferred: { colour: 'orange' } } } diff --git a/app/routes/participants.js b/app/routes/participants.js index 78fda502..485bb0d7 100644 --- a/app/routes/participants.js +++ b/app/routes/participants.js @@ -10,9 +10,9 @@ const { getEpisodesForParticipant, getCurrentEpisode, getEpisodeAppointments, - getEpisodeMammogramDate, - getEpisodeReadingStatus + getEpisodeMammogramDate } = require('../lib/utils/episodes') +const { getEpisodeReadingStatus } = require('../lib/utils/reading') const { getClinic } = require('../lib/utils/clinics') const { findById } = require('../lib/utils/arrays') const { createDynamicTemplateRoute } = require('../lib/utils/dynamic-routing') @@ -148,11 +148,8 @@ module.exports = (router) => { return { episode, - date: - mammogramDate || - latestAppointment?.timing?.startTime || - episode.closedDate || - episode.openedDate, + screenedDate: mammogramDate || null, + closedDate: episode.closedDate || null, wasScreened: Boolean(mammogramDate) } }) diff --git a/app/routes/reading.js b/app/routes/reading.js index 3628e6c7..c70be246 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -13,7 +13,6 @@ const { getReadingStatusForAppointments, getReadingClinics, getReadingProgress, - hasReads, canUserReadAppointment, userHasReadAppointment, writeReading, @@ -25,13 +24,21 @@ const { getSessionReadingProgress, skipAppointmentInSession, topUpSession, - getReadingMetadata, - getComparisonInfo, - shouldShowComparePage, + getAppointmentReadingMetadata, filterAppointmentsByEligibleForReading, filterAppointmentsByNeedsAnyRead, filterAppointmentsByUserCanRead } = require('../lib/utils/reading') +const { getReadingCase, updateReadingCase } = require('../lib/utils/episodes') +const { + getComparisonInfo, + shouldShowComparePage, + getReadingMetadata, + getReadsAsArray, + getReadForUser, + isCaseDeferred, + withoutRead +} = require('../lib/utils/reading-cases') const { getParticipant, getShortName } = require('../lib/utils/participants') const { PRIOR_REQUEST_STATUSES, @@ -361,11 +368,28 @@ module.exports = (router) => { return res.redirect('/reading') } - // Fill any dead slots (appointments fully read by others) before looking for the - // next readable case. topUpSession adds one appointment at a time so loop until - // it can no longer add anything. + // A session can end up with nothing readable in it — every loaded case + // taken by other readers — while the backlog still has cases waiting. Top + // up until one readable case appears, and no further. + // + // Not "until topUpSession stops adding": it grows the session towards its + // target size, so running it to exhaustion here would load every remaining + // case at once and defeat lazy sessions. The bound is a backstop only. const maxTopUps = session.targetSize || 25 for (let count = 0; count < maxTopUps; count++) { + const loadedAppointments = session.appointmentIds + .map((appointmentId) => + data.appointments.find((e) => e.id === appointmentId) + ) + .filter(Boolean) + + const hasReadableCase = getFirstUserReadableAppointment( + data, + loadedAppointments, + data.currentUser.id + ) + if (hasReadableCase) break + if (!topUpSession(data, sessionId)) break } @@ -377,6 +401,7 @@ module.exports = (router) => { .filter(Boolean) const resumeAppointment = getResumeAppointmentForUser( + data, sessionAppointments, data.currentUser.id, session.skippedAppointments || [] @@ -390,6 +415,7 @@ module.exports = (router) => { // Check if there are any readable cases left in the session const firstReadable = getFirstUserReadableAppointment( + data, sessionAppointments, data.currentUser.id ) @@ -453,21 +479,23 @@ module.exports = (router) => { ) .filter(Boolean) .map((appointment) => { - // Add participant data and reading metadata + // Add participant data, the reading case, and its metadata const participant = data.participants.find( (p) => p.id === appointment.participantId ) - const metadata = getReadingMetadata(appointment) + const readingCase = getReadingCase(data, appointment) return { ...appointment, participant, - readingMetadata: metadata + readingCase, + readingMetadata: getReadingMetadata(readingCase, data.settings) } }) // Get reading status for the session const readingStatus = getReadingStatusForAppointments( + data, enhancedAppointments, data.currentUser.id ) @@ -482,6 +510,7 @@ module.exports = (router) => { // Find where the user should resume — first readable after the furthest // point they've reached (reads or skips), falling back to first readable const resumeAppointment = getResumeAppointmentForUser( + data, enhancedAppointments, data.currentUser.id, session.skippedAppointments || [] @@ -491,7 +520,8 @@ module.exports = (router) => { const firstUserReadTimestamp = enhancedAppointments .map( (appointment) => - appointment.imageReading?.reads?.[data.currentUser.id]?.timestamp + getReadForUser(appointment.readingCase, data.currentUser.id) + ?.timestamp ) .filter(Boolean) .sort((a, b) => new Date(a) - new Date(b))[0] @@ -515,6 +545,7 @@ module.exports = (router) => { // 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 @@ -583,7 +614,10 @@ module.exports = (router) => { !data.imageReadingTemp || data.imageReadingTemp.appointmentId !== appointmentId ) { - const existingRead = appointment.imageReading?.reads?.[currentUserId] + const existingRead = getReadForUser( + getReadingCase(data, appointment), + currentUserId + ) if (existingRead) { // User has already read this appointment - populate temp from saved read console.log( @@ -617,8 +651,11 @@ module.exports = (router) => { } } - // Set up locals for templates + // Set up locals for templates. The case is resolved once here so the + // workflow templates can work in reading-case terms without each of them + // walking back to the episode res.locals.isReadingWorkflow = true + res.locals.readingCase = getReadingCase(data, appointment) res.locals.session = session res.locals.appointmentData = { clinic, @@ -656,7 +693,7 @@ module.exports = (router) => { } // Check if user has already read this appointment - if (userHasReadAppointment(appointment, currentUserId)) { + if (userHasReadAppointment(data, appointment, currentUserId)) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/existing-read` ) @@ -671,8 +708,7 @@ module.exports = (router) => { } // Check if appointment has been deferred from reading - const { isDeferred } = require('../lib/utils/reading') - if (isDeferred(appointment)) { + if (isCaseDeferred(getReadingCase(data, appointment))) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/existing-read` ) @@ -707,6 +743,7 @@ module.exports = (router) => { .map((id) => data.appointments.find((e) => e.id === id)) .filter(Boolean) const nextUnreadAppointment = getNextUserReadableAppointment( + data, sessionAppointments, appointmentId, currentUserId, @@ -722,6 +759,7 @@ module.exports = (router) => { } else { // Check if there are any readable cases left in the session const firstReadable = getFirstUserReadableAppointment( + data, sessionAppointments, currentUserId ) @@ -808,6 +846,7 @@ module.exports = (router) => { .map((id) => data.appointments.find((e) => e.id === id)) .filter(Boolean) const nextUnreadAppointment = getNextUserReadableAppointment( + data, sessionAppointments, appointmentId, currentUserId, @@ -837,6 +876,7 @@ module.exports = (router) => { } else { // Check if there are any readable cases left in the session const firstReadable = getFirstUserReadableAppointment( + data, sessionAppointments, currentUserId ) @@ -909,22 +949,20 @@ module.exports = (router) => { // mutating in place - appointment records are shared read-only data; writes // go through the update helpers const appointment = data.appointments.find((e) => e.id === appointmentId) - if (appointment) { - const imageReading = structuredClone(appointment.imageReading || {}) - - // Remove any existing read by this user — deferral replaces a prior opinion - if (imageReading.reads?.[currentUserId]) { - delete imageReading.reads[currentUserId] - } - - imageReading.deferral = { - deferredAt: new Date().toISOString(), - deferredBy: currentUserId, - reason: reason || null + const readingCase = getReadingCase(data, appointment) + if (readingCase) { + // Deferring withdraws any opinion this user had already given - they're + // saying they can't judge this case after all + const updatedCase = { + ...withoutRead(readingCase, currentUserId), + deferral: { + deferredAt: new Date().toISOString(), + deferredBy: currentUserId, + reason: reason || null + } } - // Saves to the appointment and mirrors into data.appointment if it matches - updateAppointmentData(data, appointmentId, { imageReading }) + updateReadingCase(data, appointment.episodeId, updatedCase) } // If submitted from an existing-read page (e.g. editing reason), return there @@ -947,6 +985,7 @@ module.exports = (router) => { .map((id) => data.appointments.find((e) => e.id === id)) .filter(Boolean) const nextUnreadAppointment = getNextUserReadableAppointment( + data, sessionAppointments, appointmentId, currentUserId, @@ -976,6 +1015,7 @@ module.exports = (router) => { } else { // Check if there are any readable cases left in the session const firstReadable = getFirstUserReadableAppointment( + data, sessionAppointments, currentUserId ) @@ -998,13 +1038,10 @@ module.exports = (router) => { const { sessionId, appointmentId } = req.params const appointment = data.appointments.find((e) => e.id === appointmentId) - if (appointment?.imageReading?.deferral) { - // Clone rather than mutate - appointment records are shared read-only data - const imageReading = structuredClone(appointment.imageReading) - delete imageReading.deferral - - // Saves to the appointment and mirrors into data.appointment if it matches - updateAppointmentData(data, appointmentId, { imageReading }) + const readingCase = getReadingCase(data, appointment) + if (isCaseDeferred(readingCase)) { + const { deferral, ...withoutDeferral } = readingCase + updateReadingCase(data, appointment.episodeId, withoutDeferral) } res.redirect( @@ -1025,21 +1062,21 @@ module.exports = (router) => { const { appointmentId } = req.body const appointment = data.appointments.find((e) => e.id === appointmentId) - if (appointment?.imageReading?.deferral) { - // Clone rather than mutate - appointment records are shared read-only data - const imageReading = structuredClone(appointment.imageReading) - imageReading.deferralHistory = [ - ...(imageReading.deferralHistory || []), - { - ...imageReading.deferral, - resolvedAt: new Date().toISOString(), - resolvedBy: data.currentUser?.id - } - ] - delete imageReading.deferral - - // Saves to the appointment and mirrors into data.appointment if it matches - updateAppointmentData(data, appointmentId, { imageReading }) + const readingCase = getReadingCase(data, appointment) + if (isCaseDeferred(readingCase)) { + const { deferral, ...withoutDeferral } = readingCase + + updateReadingCase(data, appointment.episodeId, { + ...withoutDeferral, + deferralHistory: [ + ...(readingCase.deferralHistory || []), + { + ...deferral, + resolvedAt: new Date().toISOString(), + resolvedBy: data.currentUser?.id + } + ] + }) const participant = data.participants.find( (p) => p.id === appointment.participantId @@ -1799,12 +1836,21 @@ module.exports = (router) => { const appointment = data.appointments.find((e) => e.id === appointmentId) if (!appointment) return res.redirect(`/reading/session/${sessionId}`) + // 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 + ) + // Check for late comparison if not already done const comparisonSetting = data.settings?.reading?.secondReaderComparison if (comparisonSetting === 'late' && !formData?.comparisonComplete) { if ( shouldShowComparePage( - appointment, + getReadingCase(data, appointment), formData, currentUserId, data.settings @@ -1821,7 +1867,10 @@ module.exports = (router) => { case 'normal': // opinion-details-complete is only reached for normal when the user // went through the normal-details page, so use confirmNormalWithDetails - if (data.settings?.reading?.confirmNormalWithDetails === 'true') { + if ( + !isEditingExistingRead && + data.settings?.reading?.confirmNormalWithDetails === 'true' + ) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/confirm-normal` ) @@ -1835,7 +1884,10 @@ module.exports = (router) => { const trChainParam = trReferrer ? `?referrerChain=${encodeURIComponent(trReferrer)}` : '' - if (data.settings?.reading?.confirmTechnicalRecall !== 'false') { + if ( + !isEditingExistingRead && + data.settings?.reading?.confirmTechnicalRecall !== 'false' + ) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/review${trChainParam}` ) @@ -1850,7 +1902,10 @@ module.exports = (router) => { const rfaChainParam = rfaReferrer ? `?referrerChain=${encodeURIComponent(rfaReferrer)}` : '' - if (data.settings?.reading?.confirmRecallForAssessment !== 'false') { + if ( + !isEditingExistingRead && + data.settings?.reading?.confirmRecallForAssessment !== 'false' + ) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/review${rfaChainParam}` ) @@ -1893,6 +1948,17 @@ module.exports = (router) => { return res.redirect(`/reading/session/${sessionId}`) } + // Whether this save is an edit of a read the user had already made. + // The case URL only routes already-read users via existing-read, so any + // 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 + ) + delete data.imageReadingTemp delete res.locals.data?.imageReadingTemp @@ -1905,7 +1971,7 @@ module.exports = (router) => { } // Write the reading (passing session context to handle skipped appointments) - writeReading(appointment, currentUserId, readResult, data, sessionId) + writeReading(data, appointment, currentUserId, readResult, sessionId) // Top up the session with the next eligible appointment if under target size topUpSession(data, sessionId) @@ -1916,16 +1982,18 @@ module.exports = (router) => { .map((id) => data.appointments.find((e) => e.id === id)) .filter(Boolean) const nextUnreadAppointment = getNextUserReadableAppointment( + data, sessionAppointments, appointmentId, currentUserId, { wrap: false } ) - // Store banner message for the next case, but only if there is one + // Store banner message for the next case, but only if there is one. + // Edits stay on the current case, so there's nowhere to show it. // Bypassing req.flash as we couldn't get it to work - possibly due to redirect loops // Todo: can we get this working with req.flash? - if (nextUnreadAppointment) { + if (nextUnreadAppointment && !isEditingExistingRead) { const participant = data.participants.find( (person) => person.id === appointment.participantId ) @@ -1956,6 +2024,17 @@ module.exports = (router) => { return } + // An edit returns to the read it was made from, so the reader can see the + // change they've just made rather than being pushed on to the next case + if (isEditingExistingRead) { + res.redirect( + modalBreakout( + `/reading/session/${sessionId}/appointments/${appointmentId}/existing-read` + ) + ) + return + } + // Redirect to next unread appointment or end-of-session page if (nextUnreadAppointment) { res.redirect( @@ -1970,6 +2049,7 @@ module.exports = (router) => { } else { // Check if there are any readable cases left in the session const firstReadable = getFirstUserReadableAppointment( + data, sessionAppointments, currentUserId ) @@ -2008,6 +2088,14 @@ module.exports = (router) => { const appointment = data.appointments.find((e) => e.id === appointmentId) if (!appointment) return res.redirect(`/reading/session/${sessionId}`) + // Editing a read the user has already saved — skip the confirmation step, + // the existing-read page they return to already summarises the read + const isEditingExistingRead = userHasReadAppointment( + data, + appointment, + data.currentUser?.id + ) + // Ensure appointmentId is set for tracking if (!data.imageReadingTemp) { data.imageReadingTemp = { appointmentId: appointmentId } @@ -2048,7 +2136,7 @@ module.exports = (router) => { const currentUserId = data.currentUser?.id if ( shouldShowComparePage( - appointment, + getReadingCase(data, appointment), data.imageReadingTemp, currentUserId, data.settings @@ -2069,7 +2157,7 @@ module.exports = (router) => { if (comparisonSetting === 'late') { if ( shouldShowComparePage( - appointment, + getReadingCase(data, appointment), data.imageReadingTemp, data.currentUser?.id, data.settings @@ -2080,7 +2168,10 @@ module.exports = (router) => { ) } } - if (data.settings.reading.confirmNormal === 'true') { + if ( + !isEditingExistingRead && + data.settings.reading.confirmNormal === 'true' + ) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/confirm-normal` ) @@ -2127,11 +2218,20 @@ module.exports = (router) => { const appointment = data.appointments.find((e) => e.id === appointmentId) if (!appointment) return res.redirect(`/reading/session/${sessionId}`) + // Editing a read the user has already saved — skip the confirmation step, + // the existing-read page they return to already summarises the read + const isEditingExistingRead = userHasReadAppointment( + data, + appointment, + currentUserId + ) + const opinion = data.imageReadingTemp?.opinion const comparisonInfo = getComparisonInfo( - appointment, + getReadingCase(data, appointment), data.imageReadingTemp, - currentUserId + currentUserId, + data.settings ) const firstOpinion = comparisonInfo?.firstOpinion const forceNormalDetailsForDiscordantNormal = @@ -2176,6 +2276,12 @@ module.exports = (router) => { console.log('Adopted first reader opinion:', firstRead.opinion) } + if (isEditingExistingRead) { + return res.redirect( + `/reading/session/${sessionId}/appointments/${appointmentId}/save-opinion` + ) + } + // Go straight to review since we have complete data return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/review` @@ -2210,7 +2316,10 @@ module.exports = (router) => { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/normal-details` ) - } else if (data.settings.reading.confirmNormal === 'true') { + } else if ( + !isEditingExistingRead && + data.settings.reading.confirmNormal === 'true' + ) { return res.redirect( `/reading/session/${sessionId}/appointments/${appointmentId}/confirm-normal` ) @@ -2250,29 +2359,18 @@ module.exports = (router) => { // Get all recent readings across all appointments - last 30 days const thirtyDaysAgo = dayjs().subtract(30, 'days').toISOString() - // Collect all readings from appointments + // Collect all reads across the reading cases const allReadings = [] data.appointments.forEach((appointment) => { - if (!appointment.imageReading?.reads) return - - const appointmentReadings = Object.entries( - appointment.imageReading.reads - ).map(([readerId, reading]) => { - // Determine if this is a first or second read - const readingsForAppointment = Object.values( - appointment.imageReading.reads - ) - const sortedReadings = [...readingsForAppointment].sort( - (a, b) => new Date(a.timestamp) - new Date(b.timestamp) - ) - - const readOrder = sortedReadings.findIndex( - (r) => r.readerId === readerId - ) + const readingCase = getReadingCase(data, appointment) + const reads = getReadsAsArray(readingCase) + if (!reads.length) return - const readType = - readOrder === 0 ? 'first' : readOrder === 1 ? 'second' : 'arbitration' + const appointmentReadings = reads.map((reading) => { + // Each read records what kind it was when it was made, so history + // doesn't have to infer it from ordering + const readType = reading.readType // Get participant info const participant = data.participants.find( @@ -2337,7 +2435,7 @@ module.exports = (router) => { .filter(Boolean) const userCompletedCount = sessionAppointments.filter( (appointment) => - userHasReadAppointment(appointment, currentUserId) || + userHasReadAppointment(data, appointment, currentUserId) || userRequestedPriors(appointment, currentUserId) ).length diff --git a/app/routes/settings.js b/app/routes/settings.js index dd151a24..791c3c5c 100644 --- a/app/routes/settings.js +++ b/app/routes/settings.js @@ -45,7 +45,7 @@ const convertPercentageOverrides = (overrides, fallbackProfile) => { const getCustomOverridesFromBody = (body = {}, fallbackProfile = {}) => { const rawOverrides = { - imageReading: body.imageReading, + reads: body.reads, medicalInformation: body.medicalInformation, specialAppointment: body.specialAppointment, previousMammograms: body.previousMammograms, @@ -54,7 +54,7 @@ const getCustomOverridesFromBody = (body = {}, fallbackProfile = {}) => { } const converted = convertPercentageOverrides(rawOverrides, { - imageReading: fallbackProfile.imageReading, + reads: fallbackProfile.reads, medicalInformation: fallbackProfile.medicalInformation, specialAppointment: fallbackProfile.specialAppointment, previousMammograms: fallbackProfile.previousMammograms, diff --git a/app/views/_includes/episodes/reading-cases-table.njk b/app/views/_includes/episodes/reading-cases-table.njk new file mode 100644 index 00000000..97936451 --- /dev/null +++ b/app/views/_includes/episodes/reading-cases-table.njk @@ -0,0 +1,49 @@ +{# app/views/_includes/episodes/reading-cases-table.njk + + One row per reading case - one set of images read. Shared by past and live + rounds, because a case is the same thing either way; only the fidelity of + what sits behind it differs. + + The individual reads deliberately aren't here. They belong on the case view, + which is where a reader goes to see who said what. #} + +
| Images taken | +Reads | +State | +Outcome | +Actions | +
|---|---|---|---|---|
| + {{ readingCase.openedDate | formatDate }} + | ++ {{ (readingCase | getReadsAsArray) | length }} + | ++ {{ caseState | formatWords | sentenceCase }} + | ++ {% if caseOutcome %} + {{ caseOutcome | toTag }} + {% else %} + Not yet + {% endif %} + | ++ {# Alludes to the reading case view, which the reading backlog work + will build. Nothing serves it yet, so it goes nowhere. #} + View case + | +
| Date | +Screened | +Closed | Type | -Stage | Outcome | Actions | |||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
- {% if row.date %}
- {{ row.date | formatDate }}
- {% if not row.wasScreened %}
- {{ "Not screened" | asHint }} - {% endif %} + {% if row.screenedDate %} + {{ row.screenedDate | formatDate }} {% else %} - {{ "Not booked" | asHint }} + {{ "Not screened" | asHint }} {% endif %} |
- {{ episode.type | sentenceCase }} + {% if row.closedDate %} + {{ row.closedDate | formatDate }} + {% else %} + {{ "—" | asHint }} + {% endif %} | - {{ tag({ - text: episode.stage | getEpisodeStageText, - colour: episode.stage | getEpisodeStageTagColour - }) }} + {{ episode.type | sentenceCase }} |
{% if episode.outcome %}
diff --git a/app/views/episodes/show.html b/app/views/episodes/show.html
index 7a5f47ce..25ecd7c0 100644
--- a/app/views/episodes/show.html
+++ b/app/views/episodes/show.html
@@ -41,7 +41,8 @@
{% if episode.isHistoric %}
{# Past rounds are held as summaries - what the round concluded, not how
- it got there. There are no appointment or reading records to show. #}
+ it got there. There is no appointment record, but the round does carry a
+ summary of its reading. #}
{{ summaryList({ rows: rows }) }}
+ {# A past round holds stand-ins rather than appointment records, so the list
+ is shaped the same as a live round's but has nothing to open. Showing it
+ as a list rather than a single block is deliberate: an episode can hold
+ more than one appointment, and a past one is no different. #}
+ |
| Date | +Type | +Status | +Actions | +
|---|---|---|---|
| + {{ summaryAppointment.startTime | formatDate }} + | ++ {{ summaryAppointment.type | formatWords | sentenceCase }} + | ++ {{ tag({ + text: summaryAppointment.status | getStatusText('appointment'), + colour: summaryAppointment.status | getStatusTagColour('appointment') + }) }} + | ++ {# Alludes to the page a past appointment would have. Nothing + serves it yet, so it deliberately goes nowhere. #} + View appointment + | +
This round was not screened, so it has no appointments.
+ {% endif %} + + {# What was recorded on the day #} + {% set latestSummaryAppointment = episode.summaryAppointments | last %} + {% if latestSummaryAppointment %} + ++ As recorded at the {{ latestWithInfo.timing.startTime | formatDate }} appointment. +
+ {% endif %} + + {% set appointment = latestWithInfo %} + {% include "_includes/summary-lists/medical-info-summary.njk" %} + {% endif %} +- Reading state across this episode’s appointments: - {{ tag({ - text: readingStatus.status | formatWords | sentenceCase, - colour: readingStatus.statusColor - }) }} -
+ {% if episode.readingCases.length %} + {% include "_includes/episodes/reading-cases-table.njk" %} + {% else %} ++ Reading state across this episode’s appointments: + {{ tag({ + text: readingStatus.status | formatWords | sentenceCase, + colour: readingStatus.statusColor + }) }} +
+ {% endif %} {% else %}This episode has no appointments.
diff --git a/app/views/reading/batch.html b/app/views/reading/batch.html deleted file mode 100644 index 9bbb83a5..00000000 --- a/app/views/reading/batch.html +++ /dev/null @@ -1,564 +0,0 @@ -{# /app/views/reading/batch.html #} - -{% extends 'layout-app.html' %} - -{% set isClinicContext = batch.clinicId %} - -{% if isClinicContext %} - {% set back = { - href: "/reading/clinics", - text: "Back to clinics" - } %} -{% endif %} - -{% set pageHeading %} - {{ batch.name }} -{% endset %} - -{% set gridColumn = "none" %} - -{% block pageContent %} - -{{ batch | log("Batch data") }} -{{ readingStatus | log("Reading status") }} -{{ appointments | log("Appointments in batch") }} - -An opinion has been provided for {{ readingStatus.userReadCount }} {{ "case" | pluralise(readingStatus.userReadCount) }}. The first opinion will be automatically finalised in {{ autoFinaliseMinutesRemaining }} {{ "minute" | pluralise(autoFinaliseMinutesRemaining) }}. - Finalise opinions now
- - {% endset %} - - {{ panel({ - titleText: "Session complete", - html: sessionCompletePanelHtml - }) }} - {% endif %} - - {# Secondary navigation for view tabs #} - {% set secondaryNavItems = [] %} - - {% for item in [ - { id: 'your-reads', label: 'Your opinion' }, - { id: 'all-reads', label: 'All reads and outcomes' } - ] %} - {% set href -%} - /reading/batch/{{ batch.id }}/{{ item.id }} - {% endset %} - {% set secondaryNavItems = secondaryNavItems | push({ - text: item.label, - href: href | trim, - current: true if item.id == view - }) %} - {% endfor %} - - {{ appSecondaryNavigation({ - visuallyHiddenTitle: "Batch view modes", - items: secondaryNavItems - }) }} - - {# Number of slots not yet populated in a lazy batch #} - {% set pendingCount = (batch.targetSize - batch.appointmentIds | length) if batch.targetSize else 0 %} - {% set pendingCount = 0 if pendingCount < 0 else pendingCount %} - - {% if view == 'your-reads' %} - {# YOUR READS VIEW - Shows cases from user's perspective #} - {% set userReadableAppointments = [] %} - {% for appointment in appointments %} - {% if appointment | canUserReadAppointment or appointment | userHasReadAppointment or appointment | userRequestedPriors(data.currentUser.id) %} - {% set userReadableAppointments = userReadableAppointments | push(appointment) %} - {% endif %} - {% endfor %} - - {% if userReadableAppointments | length == 0 %} -You don't have any cases you can read in this batch.
- {% else %} - - {# Build opinion counts from appointments the user has read #} - {% set normalAppointments = [] %} - {% set technicalRecallAppointments = [] %} - {% set recallForAssessmentAppointments = [] %} - {% set priorsRequestedAppointments = [] %} - - {% for appointment in userReadableAppointments %} - {% if appointment | userHasReadAppointment %} - {% set read = appointment | getReadForUser %} - {% if read.opinion == 'normal' %} - {% set normalAppointments = normalAppointments | push(appointment) %} - {% elseif read.opinion == 'technical_recall' %} - {% set technicalRecallAppointments = technicalRecallAppointments | push(appointment) %} - {% elseif read.opinion == 'recall_for_assessment' %} - {% set recallForAssessmentAppointments = recallForAssessmentAppointments | push(appointment) %} - {% endif %} - {% elseif appointment | userRequestedPriors(data.currentUser.id) %} - {% set priorsRequestedAppointments = priorsRequestedAppointments | push(appointment) %} - {% endif %} - {% endfor %} - - {% set totalCount = userReadableAppointments | length %} - {% set normalCount = normalAppointments | length %} - {% set technicalRecallCount = technicalRecallAppointments | length %} - {% set recallForAssessmentCount = recallForAssessmentAppointments | length %} - {% set priorsRequestedCount = priorsRequestedAppointments | length %} - - {% set normalPercent = (normalCount / totalCount * 100) | round %} - {% set technicalRecallPercent = (technicalRecallCount / totalCount * 100) | round %} - {% set recallForAssessmentPercent = (recallForAssessmentCount / totalCount * 100) | round %} - {% set priorsRequestedPercent = (priorsRequestedCount / totalCount * 100) | round %} - - {% set normalCardContent %} -Normal
- - {% endset %} - - {% set technicalRecallCardContent %} -Technical recall
- - {% endset %} - - {% set recallForAssessmentCardContent %} -Recall for assessment
- - {% endset %} - - {% set priorsRequestedCardContent %} -Priors requested
- - {% endset %} - - {% if not resumeAppointment %} -Progress: {{ readingStatus.userReadCount }} read{%- if readingStatus.userAwaitingPriorsCount > 0 -%}, {{ readingStatus.userAwaitingPriorsCount }} awaiting priors{%- endif -%}, {{ userSessionRemainingCount }} remaining
- {% endif %} - -| No. | -Case | -Screening date | -Your opinion | -Action | -
|---|---|---|---|---|
| - {{ loop.index }}. - | -
- {% set readingHref -%}
- /reading/batch/{{ batch.id }}/appointments/{{ appointment.id }}
- {%- endset %}
- {% if appointment.medicalInformation.symptoms | length %}
- {{ "Has symptoms" | toTag }}
- {% endif %}
- {{ appointment.participant | getFullName }}
-
- - - {% set readCount = metadata.readCount %} - {% if appointment | userHasReadAppointment %} - {{ "1st read" if readCount == 1 else "2nd read" }} - {% else %} - {{ "1st read" if readCount == 0 else "2nd read" }} - {% endif %} - - {% if (data.settings.debugMode | falsify) %} - {% if appointment | hasRecordedMammograms %} - {{ "Has priors" | toTag }} - {% endif %} - {% if appointment.mammogramData.metadata.hasAdditionalImages %} - {{ "Has additional" | toTag }} - {% endif %} - {% if appointment.mammogramData.isImperfectButBestPossible | includes("yes") %} - {{ "Imperfect" | toTag }} - {% endif %} - {% if appointment.mammogramData.isIncompleteMammography | includes("yes") %} - {{ "Incomplete" | toTag }} - {% endif %} - {% 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 }} - - |
- - {% if appointment | userHasReadAppointment %} - {% set read = appointment | getReadForUser %} - {% if read.opinion %} - {{ read.opinion | toTag }} - {% endif %} - {% elseif appointment | userRequestedPriors(data.currentUser.id) %} - {{ "Priors requested" | toTag }} - {% elseif batch.skippedAppointments | includes(appointment.id) %} - {{ "Skipped" | toTag }} - {% else %} - {{ "Not read" | toTag }} - {% endif %} - | -- {% if resumeAppointment and appointment.id == resumeAppointment.id and not (appointment | userHasReadAppointment) %} - {{ readingActionText }} - {% endif %} - | -
| - {{ (userReadableAppointments | length) + loop.index }}. - | -- - {% if pendingCount > 3 and loop.last %} - and {{ pendingCount - 3 }} more in this batch - {% endif %} - | -- | - | - |
Awaiting 2nd read
- - {% endset %} - - {% set allViewNormalCardContent %} -Normal
- - {% endset %} - - {% set allViewTechnicalRecallCardContent %} -Technical recall
- - {% endset %} - - {% set allViewRecallForAssessmentCardContent %} -Recall for assessment
- - {% endset %} - - {% set allViewArbitrationCardContent %} -Require arbitration
- - {% endset %} - -| No. | -Case | -Screening date | -1st read | -2nd read | -Outcome | -
|---|---|---|---|---|---|
| - {{ loop.index }}. - | -
- {% set readingHref -%}
- /reading/batch/{{ batch.id }}/appointments/{{ appointment.id }}
- {%- endset %}
- {% if appointment.medicalInformation.symptoms | length %}
- {{ "Has symptoms" | toTag }}
- {% endif %}
- {{ appointment.participant | getFullName }}
-
- {% if appointment | awaitingPriors %}
- - {{ "Awaiting priors" | toTag }} - {% endif %} - {% if (data.settings.debugMode | falsify) %} - {% if appointment | hasRecordedMammograms %} - {{ "Has priors" | toTag }} - {% endif %} - {% if appointment.mammogramData.metadata.hasAdditionalImages %} - {{ "Has additional" | toTag }} - {% endif %} - {% if appointment.mammogramData.isImperfectButBestPossible | includes("yes") %} - {{ "Imperfect" | toTag }} - {% endif %} - {% if appointment.mammogramData.isIncompleteMammography | includes("yes") %} - {{ "Incomplete" | toTag }} - {% endif %} - {% 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 }} - - |
- {# 1st read column #}
-
- {% set isBlind = data.settings.reading.blindReading | falsify %}
- {% set isCurrentUserRead = false %}
- {% set allReads = appointment | getReadsAsArray %}
-
- {% if metadata.readCount >= 1 %}
- {% set firstRead = allReads[0] %}
- {% set isCurrentUserRead = firstRead.readerId === data.currentUser.id %}
-
- {% if isBlind and not isCurrentUserRead and metadata.readCount < 2 %}
- {{ "Completed (blind)" | toTag }}
- {% else %}
- {# Override default colour and make sure it's always grey #}
- {{ firstRead.opinion | toTag({colour: "grey"}) }}
- {% endif %}
-
- - by {{ firstRead.readerId | getUsername({ - format: 'short', - identifyCurrentUser: true - }) }} - {% elif batch.skippedAppointments | includes(appointment.id) %} - {{ "Skipped" | toTag }} - {% else %} - {{ "Not read" | toTag }} - {% endif %} - |
- {# 2nd read column #}
-
- {% set isBlind = data.settings.reading.blindReading | falsify %}
- {% set isCurrentUserRead = false %}
-
- {% if metadata.readCount >= 2 %}
- {% set secondRead = allReads[1] %}
- {% set isCurrentUserRead = secondRead.readerId === data.currentUser.id %}
- {# Force tags to grey #}
- {{ secondRead.opinion | toTag({colour: "grey"}) }}
- - by {{ secondRead.readerId | getUsername({ - format: 'short', - identifyCurrentUser: true - }) }} - {% elif metadata.readCount == 1 %} - {% if batch.skippedAppointments | includes(appointment.id) %} - {{ "Skipped" | toTag }} - {% else %} - {{ "Not read" | toTag }} - {% endif %} - {% else %} - {{ "Waiting for 1st read" | toTag }} - {% endif %} - |
- {# Outcome column — uses getOutcome which respects the site's arbitration policy #}
- - {% set outcome = appointment | getOutcome %} - {% if outcome == 'not_read' %} - {{ "Waiting for 1st read" | toTag }} - {% elseif outcome == 'pending_second_read' %} - {{ "Waiting for 2nd read" | toTag }} - {% elseif outcome == 'arbitration_pending' %} - {{ "Arbitration" | toTag }} - {% else %} - {{ outcome | toTag }} - {% endif %} - | -
| - {{ (appointments | length) + loop.index }}. - | -- - {% if pendingCount > 3 and loop.last %} - and {{ pendingCount - 3 }} more in this batch - {% endif %} - | -- | - | - | - |
Cases deferred from reading require manual review before they can be returned to the reading queue.
- {% if deferredAppointments | length == 0 %} + {% if deferredCases | length == 0 %}No deferred cases.
{% else %} - {% for thisAppointment in deferredAppointments %} - {% set thisParticipant = data | getParticipant(thisAppointment.participantId) %} - {% set deferral = thisAppointment.imageReading.deferral %} + {% for row in deferredCases %} + {% set thisAppointment = row.appointment %} + {% set thisParticipant = row.participant %} + {% set deferral = row.deferral %} {% set screenedHtml %} {% set daysSinceScreening = thisAppointment.timing.startTime | daysSince %} @@ -114,8 +100,8 @@{{ mammogram | summarisePriorMammogram }}
+ + {% if changedDate or mammogram.requestReason %} +
+ {% if changedDate %}
+
+ {{- changedLabel }}
+ {%- if changedBy %} by {{ changedBy | getUsername({ format: "short", identifyCurrentUser: true }) }}{% endif %}
+ on {{ changedDate | formatDate("D MMMM YYYY") -}}
+
+ {% endif %}
+ {% if mammogram.requestReason %}
+ {% if changedDate %}
{% endif %}
+ Reason: {{ mammogram.requestReason }}
+ {% endif %}
+