/* Recruitment Desk — every figure in the product. If a screen shows a number,
   it came from a function here, called with the array the screen is displaying.

   Four declarations this product makes once, and everywhere:
   · Time in stage. Counted in whole days from `stageEnteredAt` against that
     stage's own service level in RD_POLICY.stageDays — never one number for
     every stage, and never stored.
   · Money. Sterling only, held in minor units, formatted by one formatter. A
     band is a pair; an offer is a point inside it, and the product says where.
   · Progress. A step in the stepper is `done`, `current`, `todo` or `stopped`,
     decided by the record's stage against APPLICATION_PATH — never by counting
     how far down a list a step sits.
   · Evidence. A completed step shows the sub-record that completed it: the
     screening call, the returned exercise, the last scorecard, the references,
     the offer letter. Nothing is asserted that is not in the fixture. */

const DAY = 864e5;
const byId = (list, id) => (list || []).find((r) => r.id === id) || null;
const staffById = (staff, id) => byId(staff, id);
const applicationById = (applications, id) => byId(applications, id);
const requisitionById = (requisitions, id) => byId(requisitions, id);
const firstNameOf = (person) => (person ? person.name.split(" ")[0] : "somebody");

const sumBy = (list, fn) => list.reduce((total, item) => total + fn(item), 0);
const plural = (n, one, many) => n + " " + (n === 1 ? one : many);
const share = (part, whole) => (whole ? part / whole : 0);
const avg = (list, fn) => (list.length ? sumBy(list, fn) / list.length : null);
/* A status field's own words, mid-sentence. Status enums are `{id, label, tone}`
   and are read with statusValue — labelOf matches on `.value` and would silently
   hand back the raw id, which is how "DBS not_started" once reached a reader. */
const statusWords = (values, id) => statusValue(values, id).label.toLowerCase();
/* ONE percent spelling, product-wide. Everything here takes a ratio, never a
   pre-multiplied number, so no screen can round it its own way. Whole numbers,
   because every share in this product is over dozens of records — except a
   conversion rate against channel views, which is over thousands and would
   round to 0%, and asks for `precise`. */
const percent = (ratio, precise) => (ratio === null || ratio === undefined ? "—"
  : (precise ? (ratio * 100).toFixed(1) : String(Math.round(ratio * 100))) + "%");

/* ── Money ───────────────────────────────────────────────────────────────── */
const GBP = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP", maximumFractionDigits: 0 });
const money = (minor) => GBP.format(Math.round(minor / 100));
const moneyShort = (minor) => {
  const major = Math.round(minor / 100);
  return Math.abs(major) >= 1000 ? "£" + (major / 1000).toFixed(major >= 10000 ? 0 : 1) + "k" : money(minor);
};
const bandLabel = (requisition) => money(requisition.bandMinor[0]) + " – " + money(requisition.bandMinor[1]);
/* Where an offer sits inside the band it came from, as a share of the band. */
const bandPosition = (requisition, minor) => {
  const [low, high] = requisition.bandMinor;
  return high === low ? 1 : Math.max(0, Math.min(1, (minor - low) / (high - low)));
};

/* ── Dates. One absolute spelling for calendar dates; the distance rules in
   lib/ui-runtime.js govern event times — a note, a scorecard, an offer. ───── */
const formatDay = (ts) => (ts ? new Date(ts).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }) : "—");
const formatDayShort = (ts) => (ts ? new Date(ts).toLocaleDateString("en-GB", { day: "numeric", month: "short" }) : "—");
const formatWeekday = (ts) => new Date(ts).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" });
const formatTime = (ts) => new Date(ts).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
const dayKey = (ts) => { const d = new Date(ts); return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0"); };
const startOfDay = (ts) => { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime(); };
/* "Today", "Tomorrow", then the weekday — one spelling, product-wide. */
const dayLabel = (ts, now) => {
  const days = Math.round((startOfDay(ts) - startOfDay(now)) / DAY);
  if (days === 0) return "Today";
  if (days === 1) return "Tomorrow";
  if (days === -1) return "Yesterday";
  return formatWeekday(ts);
};
/* One spelling for every duration in the product. Direction is in the words. */
const daysBetween = (a, b) => Math.round((startOfDay(b) - startOfDay(a)) / DAY);
const durationWords = (days) => Math.abs(days) + " " + (Math.abs(days) === 1 ? "day" : "days") + (days < 0 ? " over" : " left");

/* ── One application, read properly ─────────────────────────────────────── */
const daysInStage = (application, now) => Math.max(0, daysBetween(application.stageEnteredAt, now));
const stageAllowance = (application) => RD_POLICY.stageDays[application.stage] || null;
const stageOverBy = (application, now) => {
  const allowed = stageAllowance(application);
  return allowed === null ? 0 : daysInStage(application, now) - allowed;
};
const daysSinceApplied = (application, now) => Math.max(0, daysBetween(application.appliedAt, now));
const stageIndex = (stage) => APPLICATION_PATH.indexOf(stage);

/* The state of one step for one application. `stopped` is where a declined or
   withdrawn application came to rest, which is why the stepper still reads. */
function stepState(application, stage) {
  const here = stageIndex(application.stage);
  const there = stageIndex(stage);
  if (here > -1) return there < here ? "done" : there === here ? "current" : "todo";
  const stopped = stageIndex(application.lastLiveStage || "applied");
  return there < stopped ? "done" : there === stopped ? "stopped" : "todo";
}

/* What actually completed a step, from the sub-records — never a claim the
   fixture cannot support. A step with no evidence says what it is for instead. */
function stepEvidence(application, stage, data, now) {
  const interviews = interviewsFor(data.interviews, application.id);
  if (stage === "applied") return "Applied " + formatDayShort(application.appliedAt) + " · " + labelOf(SOURCES, application.source);
  if (stage === "screening") {
    const call = interviews.filter((iv) => iv.kind === "screening")[0];
    return call ? "Screening call " + formatDayShort(call.at) + " with " + firstNameOf(staffById(data.staff, call.panelIds[0])) : null;
  }
  if (stage === "assessment") {
    const returned = application.documents.filter((d) => d.kind === "case")[0];
    const booked = interviews.filter((iv) => iv.kind === "assessment")[0];
    if (returned) return "Exercise returned " + formatDayShort(returned.at);
    return booked ? "Booked " + formatWeekday(booked.at) : null;
  }
  if (stage === "panel") {
    const score = scoresOf(application);
    if (!score.count) return null;
    return plural(score.count, "scorecard", "scorecards") + " · " + score.average.toFixed(1) + " average";
  }
  if (stage === "checks") {
    const refs = referenceState(application);
    if (!refs.requested) return null;
    return refs.received + " of " + refs.required + " references back · right to work " + statusWords(RIGHT_TO_WORK, application.rightToWork);
  }
  if (stage === "offer" && application.offer) {
    return application.offer.acceptedAt
      ? "Accepted " + formatDayShort(application.offer.acceptedAt)
      : money(application.offer.salaryMinor) + " sent " + formatDayShort(application.offer.sentAt) + " · " + durationWords(daysBetween(now, application.offer.expiresAt));
  }
  if (stage === "hired" && application.offer && application.offer.acceptedAt) {
    return "Starting " + formatDay(application.offer.startAt);
  }
  return null;
}

/* ── Panel scorecards ───────────────────────────────────────────────────── */
function scoresOf(application) {
  const cards = application.scorecards || [];
  const areaAverage = {};
  SCORE_AREAS.forEach((area) => { areaAverage[area.value] = avg(cards, (c) => c.scores[area.value]); });
  const cardAverage = (c) => avg(SCORE_AREAS, (a) => c.scores[a.value]);
  return {
    count: cards.length,
    cards,
    required: RD_POLICY.panelScorecards,
    average: cards.length ? avg(cards, cardAverage) : null,
    areaAverage,
    cardAverage,
    yeses: cards.filter((c) => c.recommend === "yes").length,
    complete: cards.length >= RD_POLICY.panelScorecards,
  };
}

/* ── References and checks ──────────────────────────────────────────────── */
function referenceState(application) {
  const refs = application.references || [];
  const received = refs.filter((r) => r.status === "received");
  return {
    refs,
    requested: refs.length,
    required: RD_POLICY.referencesRequired,
    received: received.length,
    outstanding: refs.filter((r) => r.status !== "received"),
    complete: received.length >= RD_POLICY.referencesRequired,
  };
}

/* What an offer is waiting on, as a list of named things — the same list the
   disabled primary action states as its reason. */
function checksMissing(application) {
  const refs = referenceState(application);
  const missing = [];
  if (!refs.complete) missing.push(refs.received + " of " + refs.required + " references back");
  if (application.rightToWork !== "verified") missing.push("right to work " + statusWords(RIGHT_TO_WORK, application.rightToWork));
  if (application.dbs !== "clear") missing.push("DBS " + statusWords(DBS_STATUS, application.dbs));
  return missing;
}

/* ── Offers: the queue, and the clock on each one ────────────────────────── */
const offersOut = (applications) => applications
  .filter((a) => a.stage === "offer" && a.offer)
  .sort((a, b) => a.offer.expiresAt - b.offer.expiresAt);
const offersAwaitingAnswer = (applications) => offersOut(applications).filter((a) => !a.offer.acceptedAt);
const offersExpiringSoon = (applications, now) => offersAwaitingAnswer(applications)
  .filter((a) => a.offer.expiresAt - now <= RD_POLICY.offerExpiryWarnDays * 864e5);
/* One row shape for the offer queue, so the table, the tiles and the toast copy
   read the same numbers. */
function offerRow(application, requisitions, now) {
  const requisition = requisitionById(requisitions, application.requisitionId);
  const offer = application.offer;
  return {
    application, requisition, offer,
    daysLeft: daysBetween(now, offer.expiresAt),
    position: bandPosition(requisition, offer.salaryMinor),
    metExpectation: !application.salaryExpectationMinor || offer.salaryMinor >= application.salaryExpectationMinor,
    daysToStart: daysBetween(now, offer.startAt),
    answered: !!offer.acceptedAt,
  };
}

/* ── Compliance: three things, one predicate, one row per live application ──
   The row takes the requisitions array because whether a registration is owed
   at all belongs to the seat, not to the person. */
function complianceRow(application, requisitions, now) {
  const requisition = requisitionById(requisitions, application.requisitionId);
  const blockers = [];
  if (COMPLIANCE_FLAGS.rtwOutstanding(application)) blockers.push("right to work " + statusWords(RIGHT_TO_WORK, application.rightToWork));
  if (COMPLIANCE_FLAGS.dbsOutstanding(application)) blockers.push("DBS " + statusWords(DBS_STATUS, application.dbs));
  /* One guard, so the blockers a reader sees and the clear/blocked split the
     screen recesses on are the same three flags read once. */
  if (COMPLIANCE_FLAGS.registrationOutstanding(application, requisition, now)) {
    if (COMPLIANCE_FLAGS.registrationMissing(application, requisition)) blockers.push("no " + requisition.registration + " registration on file");
    else if (COMPLIANCE_FLAGS.registrationExpired(application, now)) blockers.push("registration expired " + formatDayShort(application.registrationExpiresAt));
    else blockers.push("registration renews " + formatDayShort(application.registrationExpiresAt));
  }
  return {
    application, requisition, blockers,
    /* What this seat asks for, so the cell can say "not required" and mean it. */
    registerNeeded: COMPLIANCE_FLAGS.registrationRequired(requisition) ? requisition.registration : null,
    clear: COMPLIANCE_FLAGS.clearToStart(application, requisition, now),
    registrationDays: application.registrationExpiresAt ? daysBetween(now, application.registrationExpiresAt) : null,
    /* Urgency is the stage, not the number of blockers: a blocker on somebody at
       offer stops a start date, the same blocker at applied stops nothing yet. */
    urgent: stageIndex(application.stage) >= stageIndex("checks") && blockers.length > 0,
  };
}
const complianceRows = (applications, requisitions, now) => liveApplications(applications)
  .map((a) => complianceRow(a, requisitions, now))
  .sort((a, b) => (b.urgent - a.urgent) || (b.blockers.length - a.blockers.length) || stageIndex(b.application.stage) - stageIndex(a.application.stage));

/* ── Adverts: the wording, and what each channel actually produced ────────
   Views are the only stored figure (they come from outside the product); the
   applications a channel produced are counted from the applications array. */
const advertFor = (adverts, requisitionId) => adverts.find((ad) => ad.requisitionId === requisitionId) || null;
function channelRows(advert, applications, now) {
  const all = applicationsFor(applications, advert.requisitionId);
  return advert.postedTo.map((posting) => {
    const rows = all.filter((a) => a.source === posting.channel && a.appliedAt >= posting.postedAt);
    const offered = rows.filter((a) => a.offer && a.offer.sentAt);
    return {
      posting,
      source: SOURCES.find((s) => s.value === posting.channel),
      rows,
      applications: rows.length,
      views: posting.views,
      rate: posting.views ? rows.length / posting.views : null,
      live: rows.filter(APPLICATION_FLAGS.isLive).length,
      offered: offered.length,
      days: Math.max(1, daysBetween(posting.postedAt, now)),
    };
  }).sort((a, b) => b.applications - a.applications);
}
/* The advert's own totals, summed over the rows it renders — and the
   applications that arrived from a channel the advert is not posted to, named
   rather than quietly dropped. */
function advertTotals(advert, applications, now) {
  const rows = channelRows(advert, applications, now);
  const all = applicationsFor(applications, advert.requisitionId);
  const counted = sumBy(rows, (r) => r.applications);
  return {
    rows,
    views: sumBy(rows, (r) => r.views),
    applications: counted,
    rate: sumBy(rows, (r) => r.views) ? counted / sumBy(rows, (r) => r.views) : null,
    elsewhere: all.length - counted,
    posted: advert.postedTo.length,
  };
}

/* The context advanceGate needs, derived rather than passed around by hand. */
const advanceContext = (application) => ({ scorecards: scoresOf(application).count });
const transitionsFor = (application) => applicationTransitions(application, advanceContext(application));

/* ── Predicates over the applications array ─────────────────────────────── */
const liveApplications = (applications) => applications.filter(APPLICATION_FLAGS.isLive);
/* THE act-ability predicate at desk scale — the queue rows, the tab counts, the
   desk figures and the sidebar badge all end up here. */
const awaitingUs = (applications) => liveApplications(applications).filter(APPLICATION_FLAGS.awaitsUs);
const awaitingThem = (applications) => liveApplications(applications).filter(APPLICATION_FLAGS.awaitsThem);
const unreadApplications = (applications) => applications.filter(APPLICATION_FLAGS.isUnread);
const stalledApplications = (applications, now) => liveApplications(applications).filter((a) => APPLICATION_FLAGS.isStalled(a, now));
const applicationsFor = (applications, requisitionId) => applications.filter((a) => a.requisitionId === requisitionId);
const ownedBy = (applications, personId) => applications.filter((a) => a.ownerId === personId);
const watchedBy = (applications, personId) => applications.filter((a) => (a.watcherIds || []).indexOf(personId) > -1);
const applicationsAtStage = (applications, stage) => applications.filter((a) => a.stage === stage);

/* The pool is a predicate, not a list: people we said we would talk to again. */
const talentPool = (applications) => applications.filter((a) => !APPLICATION_FLAGS.isLive(a) && a.stage !== "hired" && a.wouldReconsider);

/* ── Interviews ─────────────────────────────────────────────────────────── */
const interviewsFor = (interviews, applicationId) => interviews
  .filter((iv) => iv.applicationId === applicationId)
  .sort((a, b) => a.at - b.at);
const feedbackOutstanding = (interviews) => interviews.filter(INTERVIEW_FLAGS.feedbackOutstanding);
const interviewsAhead = (interviews, now) => interviews.filter((iv) => INTERVIEW_FLAGS.isAhead(iv, now)).sort((a, b) => a.at - b.at);
const interviewsToday = (interviews, now) => interviews.filter((iv) => INTERVIEW_FLAGS.isToday(iv, now) && iv.status !== "cancelled").sort((a, b) => a.at - b.at);
/* The ladder groups the same array by day; nothing is filtered twice. */
function interviewDays(interviews) {
  const days = [];
  const index = {};
  interviews.slice().sort((a, b) => a.at - b.at).forEach((iv) => {
    const key = dayKey(iv.at);
    if (!index[key]) { index[key] = { key, at: iv.at, items: [] }; days.push(index[key]); }
    index[key].items.push(iv);
  });
  return days;
}
const panelOf = (staff, interview) => interview.panelIds.map((id) => staffById(staff, id)).filter(Boolean);

/* ── Requisitions ───────────────────────────────────────────────────────── */
function requisitionHealth(applications, requisition, now) {
  const all = applicationsFor(applications, requisition.id);
  const live = liveApplications(all);
  const hires = all.filter((a) => a.stage === "hired").length;
  const offersOut = all.filter((a) => a.stage === "offer" && a.offer && !a.offer.acceptedAt).length;
  const accepted = all.filter((a) => a.offer && a.offer.acceptedAt && a.stage !== "declined" && a.stage !== "withdrawn").length;
  return {
    all, live, hires, offersOut, accepted,
    seats: requisition.headcount,
    seatsLeft: Math.max(0, requisition.headcount - accepted),
    awaitingUs: live.filter(APPLICATION_FLAGS.awaitsUs).length,
    stalled: live.filter((a) => APPLICATION_FLAGS.isStalled(a, now)).length,
    thin: live.length < RD_POLICY.thinPipeline && requisition.status === "open",
    openDays: daysBetween(requisition.openedAt, now),
    aging: REQUISITION_FLAGS.isAging(requisition, now),
    daysToTarget: daysBetween(now, requisition.targetStartAt),
  };
}

/* The stage strip: one segment per path stage, proportional to the live
   applications sitting in it. The strip's own numbers are figures too. */
function stageStrip(applications, requisitionId) {
  const live = liveApplications(applicationsFor(applications, requisitionId));
  const segments = APPLICATION_PATH.filter((s) => s !== "hired").map((stage) => ({
    stage, label: statusValue(APPLICATION_STAGE, stage).label,
    count: live.filter((a) => a.stage === stage).length,
  }));
  return { segments, total: live.length };
}

/* The board: one column per path stage, for one requisition. */
function pipelineBoard(applications, requisitionId) {
  const all = applicationsFor(applications, requisitionId);
  return APPLICATION_PATH.map((stage) => ({
    stage,
    label: statusValue(APPLICATION_STAGE, stage).label,
    means: STAGE_MEANS[stage],
    items: all.filter((a) => a.stage === stage).sort((a, b) => a.stageEnteredAt - b.stageEnteredAt),
  }));
}

/* ── Insights: the funnel over one array, and the rates between its steps ── */
function funnel(applications) {
  const reached = (stage) => applications.filter((a) => {
    const here = stageIndex(a.stage);
    const rest = stageIndex(a.lastLiveStage || a.stage);
    const depth = here > -1 ? here : rest;
    return depth >= stageIndex(stage);
  }).length;
  const rows = APPLICATION_PATH.map((stage, i) => {
    const count = reached(stage);
    const previous = i === 0 ? count : reached(APPLICATION_PATH[i - 1]);
    return {
      stage, label: statusValue(APPLICATION_STAGE, stage).label,
      count, share: share(count, applications.length),
      fromPrevious: i === 0 ? null : share(count, previous),
    };
  });
  return { rows, total: applications.length };
}

/* Time to offer, over the applications that actually reached an offer. */
function timeToOffer(applications) {
  const offered = applications.filter((a) => a.offer && a.offer.sentAt);
  const days = offered.map((a) => daysBetween(a.appliedAt, a.offer.sentAt)).sort((a, b) => a - b);
  return {
    applications: offered,
    count: offered.length,
    median: days.length ? days[Math.floor(days.length / 2)] : null,
    fastest: days.length ? days[0] : null,
    slowest: days.length ? days[days.length - 1] : null,
  };
}

/* Where applications come from, and how far each source gets. */
function sourceMix(applications) {
  return SOURCES.map((source) => {
    const rows = applications.filter((a) => a.source === source.value);
    const offered = rows.filter((a) => a.offer && a.offer.sentAt);
    return { source, rows, count: rows.length, share: share(rows.length, applications.length), offered: offered.length };
  }).filter((row) => row.count > 0).sort((a, b) => b.count - a.count);
}

/* Acceptance: offers sent against offers accepted, over the same array. */
function offerOutcome(applications) {
  const sent = applications.filter((a) => a.offer && a.offer.sentAt);
  const accepted = sent.filter((a) => a.offer.acceptedAt);
  return { sent, accepted, rate: sent.length ? share(accepted.length, sent.length) : null };
}

/* ── The desk itself ────────────────────────────────────────────────────── */
function deskLoad(staff, applications, interviews, now) {
  return staff.filter((person) => person.role === "recruiter" || person.role === "coordinator" || person.role === "lead")
    .map((person) => {
      const owned = liveApplications(ownedBy(applications, person.id));
      return {
        person,
        owned,
        awaitingUs: owned.filter(APPLICATION_FLAGS.awaitsUs).length,
        stalled: owned.filter((a) => APPLICATION_FLAGS.isStalled(a, now)).length,
        interviews: interviews.filter((iv) => iv.panelIds.indexOf(person.id) > -1 && INTERVIEW_FLAGS.isAhead(iv, now)).length,
      };
    });
}
/* Panel load is a different question over a different array, so it is a
   different function — never the same figure relabelled. */
function panelLoad(staff, applications, interviews, now) {
  return staff.filter((person) => person.role === "hiring_manager" || person.role === "panel")
    .map((person) => ({
      person,
      booked: interviews.filter((iv) => iv.panelIds.indexOf(person.id) > -1 && INTERVIEW_FLAGS.isAhead(iv, now)).length,
      outstanding: feedbackOutstanding(interviews).filter((iv) => iv.panelIds.indexOf(person.id) > -1).length,
      watching: watchedBy(applications, person.id).filter(APPLICATION_FLAGS.isLive).length,
    }));
}

/* ── The thread ─────────────────────────────────────────────────────────── */
const notesFor = (notes, applicationId) => notes.filter((n) => n.applicationId === applicationId).sort((a, b) => a.at - b.at);
/* Top-level notes with their replies attached, so the thread renders in one
   pass and a reply can never appear without its parent. */
function threadFor(notes, applicationId) {
  const rows = notesFor(notes, applicationId);
  return rows.filter((n) => !n.parentId).map((n) => ({ note: n, replies: rows.filter((r) => r.parentId === n.id) }));
}
const reactionsOf = (note) => REACTIONS
  .map((reaction) => ({ reaction, who: (note.reactions || {})[reaction.value] || [] }))
  .filter((row) => row.who.length > 0);
const mentionIds = (body) => (body.match(/@p-[a-z]+/g) || []).map((m) => m.slice(1));
/* Everybody who is following this application: the owner, the watchers and
   anybody who has written on it. One list, so the faces and the count agree. */
function followersOf(application, notes, staff) {
  const ids = [];
  const add = (id) => { if (id && ids.indexOf(id) === -1) ids.push(id); };
  add(application.ownerId);
  (application.watcherIds || []).forEach(add);
  notesFor(notes, application.id).forEach((n) => { add(n.authorId); mentionIds(n.body).forEach(add); });
  return ids.map((id) => staffById(staff, id)).filter(Boolean);
}

/* ── Notifications ────────────────────────────────────────────────────────
   One array, read three ways: the bell's count, the inbox's tabs and its day
   groups. Unread is the absence of `readAt`, never a second flag. */
const unreadNotifications = (notifications) => notifications.filter((n) => !n.readAt);
const notificationsOfKind = (notifications, kind) => notifications.filter((n) => n.kind === kind);
function notificationDays(notifications, now) {
  const days = [];
  const index = {};
  notifications.slice().sort((a, b) => b.at - a.at).forEach((n) => {
    const key = dayKey(n.at);
    if (!index[key]) { index[key] = { key, at: n.at, items: [] }; days.push(index[key]); }
    index[key].items.push(n);
  });
  return days;
}
const notificationKind = (n) => NOTIFICATION_KINDS.find((k) => k.value === n.kind) || NOTIFICATION_KINDS[0];

/* ── The sidebar's badges. Each one is a predicate over one array, and the
   label says what it counts, so a reader can recover the number. ─────────── */const NAV_BADGES = {
  awaitingUs: { label: "waiting on the desk", count: (data) => awaitingUs(data.applications).length },
  feedbackOutstanding: { label: "scorecards outstanding", count: (data) => feedbackOutstanding(data.interviews).length },
  offersExpiring: { label: "offers inside " + RD_POLICY.offerExpiryWarnDays + " days of running out", count: (data, now) => offersExpiringSoon(data.applications, now).length },
  complianceUrgent: { label: "blockers on people close to starting", count: (data, now) => complianceRows(data.applications, data.requisitions, now).filter((row) => row.urgent).length },
};

Object.assign(window, {
  DAY, byId, staffById, applicationById, requisitionById, firstNameOf,
  sumBy, plural, share, percent, avg, statusWords, money, moneyShort, bandLabel, bandPosition,
  formatDay, formatDayShort, formatWeekday, formatTime, dayKey, startOfDay, dayLabel, daysBetween, durationWords,
  daysInStage, stageAllowance, stageOverBy, daysSinceApplied, stageIndex, stepState, stepEvidence,
  scoresOf, referenceState, checksMissing, advanceContext, transitionsFor,
  liveApplications, awaitingUs, awaitingThem, unreadApplications, stalledApplications,
  applicationsFor, ownedBy, watchedBy, applicationsAtStage, talentPool,
  interviewsFor, feedbackOutstanding, interviewsAhead, interviewsToday, interviewDays, panelOf,
  requisitionHealth, stageStrip, pipelineBoard, funnel, timeToOffer, sourceMix, offerOutcome,
  offersOut, offersAwaitingAnswer, offersExpiringSoon, offerRow,
  complianceRow, complianceRows, advertFor, channelRows, advertTotals,
  unreadNotifications, notificationsOfKind, notificationDays, notificationKind,
  deskLoad, panelLoad, notesFor, threadFor, reactionsOf, mentionIds, followersOf, NAV_BADGES,
});
