marmar-context / web / context / src / logic / checkin-events.ts
checkin-events.ts
Raw
import type { CheckIn, CheckInEvent, CheckInNote, CheckInStatus } from "../api/types";

const byTime = (a: { at: string }, b: { at: string }) => new Date(a.at).getTime() - new Date(b.at).getTime();

function upsertNote(notes: CheckInNote[], note: CheckInNote): CheckInNote[] {
  const rest = notes.filter((n) => n.id !== note.id);
  return [...rest, note].sort(byTime);
}

/**
 * Fold one check-in event into the snapshot. Events at or below lastEventId are replays
 * (after a reconnect with Last-Event-ID, or a poll overlapping the stream) and change nothing.
 */
export function applyEvent(state: CheckIn, event: CheckInEvent): CheckIn {
  if (event.id <= state.lastEventId) return state;
  const next: CheckIn = { ...state, lastEventId: event.id };
  switch (event.type) {
    case "status": {
      const last = state.history[state.history.length - 1];
      const repeat = last && last.status === event.status && last.at === event.at;
      return {
        ...next,
        status: event.status,
        statusAt: event.at,
        later: event.status === "later" ? event.later : state.later,
        history: repeat ? state.history : [...state.history, { status: event.status, at: event.at, detail: event.detail }],
      };
    }
    case "note":
    case "note_updated":
      return { ...next, notes: upsertNote(state.notes, event.note), urgent: state.urgent || event.note.urgent };
    case "transcript": {
      const rest = state.transcript.filter((l) => l.id !== event.line.id);
      return { ...next, transcript: [...rest, event.line].sort((a, b) => a.offsetSeconds - b.offsetSeconds) };
    }
    case "summary":
      return { ...next, summary: event.summary };
    case "urgent":
      return {
        ...next,
        urgent: true,
        notes: state.notes.map((n) => (n.id === event.noteId ? { ...n, urgent: true } : n)),
      };
    case "alert":
      return event.message ? { ...next, alerts: [...state.alerts, { id: event.id, at: event.at, kind: event.kind ?? "alert", message: event.message }] } : next;
  }
}

export const applyEvents = (state: CheckIn, events: CheckInEvent[]) =>
  [...events].sort((a, b) => a.id - b.id).reduce(applyEvent, state);

const ended: CheckInStatus[] = [
  "completed",
  "declined",
  "not_reached",
  "identity_not_confirmed",
  "ended_early",
  "cancelled",
];
/** No more events are expected once a check-in has ended and its summary (if any) has arrived. */
export const isSettled = (c: Pick<CheckIn, "status" | "summary">) =>
  ended.includes(c.status) && (c.status !== "completed" || c.summary !== null);
export const isEnded = (status: CheckInStatus) => ended.includes(status);
export const isLive = (status: CheckInStatus) => status === "in_progress";

/** YCI-29: urgent notes still waiting for a clinician's acknowledgement. */
export const unacknowledged = (c: Pick<CheckIn, "notes">) => c.notes.filter((n) => n.urgent && !n.acknowledgedAt);

/** The notes a summary sentence rests on, in note order (YCI-25). */
export const sentenceNotes = (c: Pick<CheckIn, "notes">, noteIds: string[]) =>
  c.notes.filter((n) => noteIds.includes(n.id));