All files / platform/core/src/utils latestInstanceDateTime.ts

71.23% Statements 52/73
48.83% Branches 21/43
80% Functions 8/10
72.22% Lines 52/72

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297                                      216x                                     216x                                                           216x   216x                                                                         236x 236x 236x                                                                                                             216x   3068x 3068x                     216x 1460x 1460x                 216x 577x 577x 577x 47x   530x 530x                                                                     236x 236x 236x 236x 236x   236x 1416x 1416x 879x   537x   232x 232x 232x 232x   537x 537x 320x 320x       236x 236x     236x 1416x   236x 236x 236x           236x                   44x 44x 4x   40x               44x 44x    
/**
 * DICOM records "when an object was created" in several different attribute
 * pairs, and which of them are present depends on the modality and on whoever
 * wrote the object.  A display set, though, is shown in a single place in the
 * series list and so needs a single date/time to be ordered by.
 *
 * The pair chosen here is the latest date found in any of the allowed
 * attributes, together with the latest time found in an allowed attribute
 * carrying that exact same date.  A time is therefore never combined with a
 * date it did not come with, so the result is a date/time that really occurred
 * rather than a synthetic mix of two different timestamps.  When nothing
 * carries a time for the winning date the time is empty: ordering is then only
 * accurate to the day, which is as good as the data allows.
 *
 * Study level attributes (`StudyDate`/`StudyTime`) are deliberately not
 * included.  They are shared by every series in the study, so they cannot tell
 * one series from another, and for a report or segmentation saved days after
 * the images they are simply the wrong date.
 */
export const dateTimeAttributes: Array<[string, string]> = [
  // Instance level.  These are the only ones that change when a new instance is
  // added to an existing series - a second report saved into the same SR series
  // keeps the original SeriesDate, and only the instance date/time says that
  // the series has just been added to.
  ['InstanceCreationDate', 'InstanceCreationTime'],
  ['ContentDate', 'ContentTime'],
  ['AcquisitionDate', 'AcquisitionTime'],
  // Creation stamps written by specific derived modalities.
  ['StructureSetDate', 'StructureSetTime'],
  ['PresentationCreationDate', 'PresentationCreationTime'],
  // Series level.
  ['SeriesDate', 'SeriesTime'],
];
 
/**
 * Attributes holding a date and a time in one DT value (`YYYYMMDDHHMMSS...`).
 * Enhanced multi-frame objects often carry these instead of the split pair.
 */
export const dateTimeCombinedAttributes: string[] = ['AcquisitionDateTime'];
 
export type LatestInstanceDateTime = {
  /** The chosen date, as found in the source, or `''`. */
  SeriesDate: string;
  /** The time belonging to that same date, as found in the source, or `''`. */
  SeriesTime: string;
};
 
/**
 * `&ZZXX` - the UTC offset DICOM defines - as a number of minutes ahead of UTC,
 * or `undefined` when the value is absent or not in that format.  It is the
 * whole of `TimezoneOffsetFromUTC` (0008,0201) and the suffix a DT value may
 * end with.
 */
export function parseUTCOffset(value): number | undefined {
  const match = /^([+-])(\d{2})(\d{2})$/.exec(`${value ?? ''}`.trim());
  Iif (!match) {
    return undefined;
  }
  const [, sign, hours, minutes] = match;
  return (sign === '-' ? -1 : 1) * (Number(hours) * 60 + Number(minutes));
}
 
/**
 * A DICOM DT value: 8 digits of date, up to 6 more of time, an optional
 * fraction, and an optional `&ZZXX` offset.  A DT with fewer than 8 digits of
 * date names a year or a month rather than a day, which is not a date this can
 * order by, so it is not matched at all.
 */
const dicomDateTime = /^(\d{8})(\d{0,6})(\.\d{1,6})?([+-]\d{4})?$/;
 
const pad = (value: number) => `${value}`.padStart(2, '0');
 
/**
 * Splits a DICOM DT into a date and a time, in the timezone of the viewer.
 *
 * A DT may end with the UTC offset the rest of it is written in.  That offset
 * is not a part of the date/time, and reading it as one is how `20260819+0500`
 * becomes five in the morning and how the `05` of `202608191030-0500` becomes
 * the seconds.  It also cannot simply be dropped: the wall clock reading it
 * carries belongs to another place, so a viewer that displays it displays a
 * time that is not the time of day here, and around midnight the wrong day too.
 *
 * So a DT that declares an offset is moved to the offset of the viewer, and the
 * result is the local wall clock reading of the same instant.  A DT that
 * declares no offset is returned exactly as it was found - there is nothing to
 * say what zone it was written in, and every other attribute this module reads
 * is a bare DA or TM with the same silence.
 *
 * A DT holding a date alone names the start of that day, which is the reading
 * needed to move it, and it comes back with the time of that instant here.  It
 * gets that time even when the offset it declares is the viewer's own: two DT
 * values naming one instant have to give one answer, and returning a date alone
 * for the one that needs no move would order it before the one that does.
 *
 * @param value - the DT value
 * @param localOffsetMinutes - the offset to move the value to, in minutes ahead
 *   of UTC.  The viewer's own offset *at that instant* is used when this is not
 *   supplied, which is what keeps a summer acquisition correct when it is read
 *   in the winter.  Tests supply it to pin a result that does not depend on the
 *   zone the test runs in.
 * @returns the date and the time, or `undefined` when the value is not a DT
 *   naming a day
 */
export function expandDicomDateTime(
  value,
  localOffsetMinutes?: number
): LatestInstanceDateTime | undefined {
  const match = dicomDateTime.exec(`${value ?? ''}`.trim());
  if (!match) {
    return undefined;
  }
  const [, date, time = '', fraction = '', offset = ''] = match;
 
  const offsetMinutes = parseUTCOffset(offset);
  Iif (offsetMinutes === undefined) {
    return { SeriesDate: date, SeriesTime: `${time}${fraction}` };
  }
 
  // Only the hours and the minutes can move: a UTC offset is a whole number of
  // minutes, so the seconds and the fraction of the source survive untouched.
  const hours = Number(time.slice(0, 2) || 0);
  const minutes = Number(time.slice(2, 4) || 0);
  const at = new Date(0);
  at.setUTCFullYear(
    Number(date.slice(0, 4)),
    Number(date.slice(4, 6)) - 1,
    Number(date.slice(6, 8))
  );
  at.setUTCHours(hours, minutes - offsetMinutes, 0, 0);
 
  // Reading the instant with the local getters applies the viewer's offset at
  // that instant, daylight saving included.  A supplied offset is applied by
  // shifting the instant and reading it back in UTC instead.
  const supplied = localOffsetMinutes !== undefined;
  const local = supplied ? new Date(at.getTime() + localOffsetMinutes * 60_000) : at;
  const [year, month, day, localHours, localMinutes] = supplied
    ? [
        local.getUTCFullYear(),
        local.getUTCMonth() + 1,
        local.getUTCDate(),
        local.getUTCHours(),
        local.getUTCMinutes(),
      ]
    : [
        local.getFullYear(),
        local.getMonth() + 1,
        local.getDate(),
        local.getHours(),
        local.getMinutes(),
      ];
  // The computed reading is returned even when the offset needed no move, so
  // that two DT values naming one instant always give one answer.  The hours
  // and the minutes are always written, because a date alone cannot compare as
  // equal to the same instant written out in another offset.
  return {
    SeriesDate: `${year}${pad(month)}${pad(day)}`,
    SeriesTime: `${pad(localHours)}${pad(localMinutes)}${time.slice(4, 6)}${fraction}`,
  };
}
 
/**
 * Reads an attribute allowing for the normalized, lower camel case spelling
 * used by series level metadata (`seriesDate` as well as `SeriesDate`).
 */
const getAttribute = (source, attribute: string) => {
  const value =
    source[attribute] ?? source[`${attribute.charAt(0).toLowerCase()}${attribute.slice(1)}`];
  return Array.isArray(value) ? value[0] : value;
};
 
/**
 * A DICOM DA value as 8 comparable digits, or `''` when there is no date.
 *
 * A value that is not a DICOM DA is rejected rather than compared.  Some series
 * level metadata carries a date already formatted for display, and `19-Jan-2026`
 * would otherwise read as `192026`, which orders by day of month and makes two
 * different months compare as equal.
 */
const dateSortKey = (value): string => {
  const digits = `${value ?? ''}`.replace(/[^0-9]/g, '');
  return digits.length < 8 ? '' : digits.slice(0, 8);
};
 
/**
 * A DICOM TM value as a comparable fixed width string, or `''` when there is no
 * time.  Times are padded because `HHMM` and `HHMMSS` name the same instant but
 * do not compare as equal, and the fraction is separated so that a whole second
 * never compares above a fraction of the next one.
 */
const timeSortKey = (value): string => {
  const [whole = '', fraction = ''] = `${value ?? ''}`.split('.');
  const digits = whole.replace(/[^0-9]/g, '').slice(0, 6);
  if (!digits) {
    return '';
  }
  const fractionDigits = fraction.replace(/[^0-9]/g, '').slice(0, 6);
  return `${digits.padEnd(6, '0')}.${fractionDigits.padEnd(6, '0')}`;
};
 
/**
 * Chooses the single date/time that says when the given instance, series or
 * display set was created, from the {@link dateTimeAttributes} it carries.
 *
 * Accepts an array, in which case the latest date/time carried by any of its
 * entries is returned - the date/time of a multi instance derived series is the
 * one of its most recently created instance.
 *
 * The values are returned as found, so they are safe to store on a display set
 * and to display; use {@link getLatestInstanceDateTimeSortKey} to compare
 * them.  The one exception is a DT value that declares a UTC offset, which
 * {@link expandDicomDateTime} moves to the offset of the viewer first - the
 * date/time returned is then the local wall clock reading of the same instant,
 * and a valid DA and TM rather than the offset-bearing DT it came from.
 *
 * **A handler of a derived display set calls this function.  The handler of an
 * image display set must not call this function.**  A SEG, an RTSTRUCT, an SR,
 * a PMAP, a PDF, a video and a chart each need the date/time of creation of the
 * object, so a report that the user saves today into a series of last week is
 * listed as the work of today.  An image instance is different: an image
 * instance carries `AcquisitionDate` and `AcquisitionTime`, and those two
 * attributes differ between the instances of one series.  This function would
 * therefore give a different date/time to each display set of one split series.
 * The display sets of one series must tie on `dateTimeSortKey`, because only a
 * tie sends them to `compareSameSeriesDisplaySet` and to every comparison that
 * `addSameSeriesCompare` registers.  The handler of an image display set writes
 * `instance.SeriesDate` and `instance.SeriesTime` directly, and
 * `extensions/default/src/getSopClassHandlerModule.js` does exactly that.  A
 * change of that handler to this function raises no error, and puts the series
 * list in the wrong order.
 */
export function getLatestInstanceDateTime(source): LatestInstanceDateTime {
  const sources = Array.isArray(source) ? source : [source];
  let SeriesDate = '';
  let SeriesTime = '';
  let dateKey = '';
  let timeKey = '';
 
  const consider = (dateValue, timeValue) => {
    const candidateDateKey = dateSortKey(dateValue);
    if (!candidateDateKey || candidateDateKey < dateKey) {
      return;
    }
    if (candidateDateKey > dateKey) {
      // A later date discards the time that belonged to the earlier one.
      dateKey = candidateDateKey;
      SeriesDate = `${dateValue}`;
      timeKey = '';
      SeriesTime = '';
    }
    const candidateTimeKey = timeSortKey(timeValue);
    if (candidateTimeKey > timeKey) {
      timeKey = candidateTimeKey;
      SeriesTime = `${timeValue}`;
    }
  };
 
  for (const item of sources) {
    Iif (!item) {
      continue;
    }
    for (const [dateAttribute, timeAttribute] of dateTimeAttributes) {
      consider(getAttribute(item, dateAttribute), getAttribute(item, timeAttribute));
    }
    for (const attribute of dateTimeCombinedAttributes) {
      const dateTime = expandDicomDateTime(getAttribute(item, attribute));
      Iif (dateTime) {
        consider(dateTime.SeriesDate, dateTime.SeriesTime);
      }
    }
  }
 
  return { SeriesDate, SeriesTime };
}
 
/**
 * A date and a time as a single string that orders correctly under a natural
 * string compare, oldest first.  With no date the key is `''`, which sorts as
 * the oldest, and a date with no time sorts before every timed value of that
 * same date.
 */
export function getDateTimeSortKey(date, time): string {
  const dateKey = dateSortKey(date);
  if (!dateKey) {
    return '';
  }
  return `${dateKey} ${timeSortKey(time)}`;
}
 
/**
 * The {@link getLatestInstanceDateTime} of the given instance, series or
 * display set as a {@link getDateTimeSortKey} comparable string.
 */
export function getLatestInstanceDateTimeSortKey(source): string {
  const { SeriesDate, SeriesTime } = getLatestInstanceDateTime(source);
  return getDateTimeSortKey(SeriesDate, SeriesTime);
}