marmar-context / web / context / src / logic / format.ts
format.ts
Raw
import type { CheckInStatus, NoteKind, Pronoun, Tier } from "../api/types";
import { checkInPills, dotTone } from "./pills";

const months = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" ");
const days = "Sun Mon Tue Wed Thu Fri Sat".split(" ");
const pad = (n: number) => String(n).padStart(2, "0");

/** "10:42" in the viewer's clock. */
export function clock(iso: string): string {
  const d = new Date(iso);
  return Number.isNaN(d.getTime()) ? "" : `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

const sameDay = (a: Date, b: Date) =>
  a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();

/** "Today", "Yesterday", "Tue 28 Oct" or "28 Oct 2025". */
export function dayLabel(iso: string, now = new Date()): string {
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return "";
  if (sameDay(d, now)) return "Today";
  const yesterday = new Date(now);
  yesterday.setDate(now.getDate() - 1);
  if (sameDay(d, yesterday)) return "Yesterday";
  const tomorrow = new Date(now);
  tomorrow.setDate(now.getDate() + 1);
  if (sameDay(d, tomorrow)) return "Tomorrow";
  const base = `${d.getDate()} ${months[d.getMonth()]}`;
  return d.getFullYear() === now.getFullYear() ? `${days[d.getDay()]} ${base}` : `${base} ${d.getFullYear()}`;
}

/** "just now", "4 min ago", "Today 10:42", "Tue 28 Oct 10:42". */
export function whenLabel(iso: string, now = new Date()): string {
  const d = new Date(iso);
  const ms = now.getTime() - d.getTime();
  if (Number.isNaN(ms)) return "";
  if (ms >= 0 && ms < 60_000) return "just now";
  if (ms >= 0 && ms < 3_600_000) return `${Math.floor(ms / 60_000)} min ago`;
  return `${dayLabel(iso, now)} ${clock(iso)}`;
}

/** "as of 10:42", or with the day when it is not today (SHR-6). */
export function asOfLabel(iso: string, now = new Date()): string {
  const day = dayLabel(iso, now);
  return day === "Today" ? `as of ${clock(iso)}` : `as of ${day} ${clock(iso)}`;
}

/** SHR-6: changed after the clinician last looked. Nothing is new on a first look. */
export function isNewSince(changedAt: string, lastLookedAt: string | null): boolean {
  if (!lastLookedAt) return false;
  return new Date(changedAt).getTime() > new Date(lastLookedAt).getTime();
}

export const firstName = (name: string) => name.trim().split(/\s+/)[0] || name;

/** Pronouns for a person whose pronouns may not be stated: they/them unless known. */
export function pronouns(p: Pronoun | null | undefined) {
  if (p === "he") return { subject: "he", object: "him", possessive: "his" };
  if (p === "she") return { subject: "she", object: "her", possessive: "her" };
  return { subject: "they", object: "them", possessive: "their" };
}

/** CHK-6: each tier with its time. None is worded as an instruction. */
export const tiers: Record<Tier, { label: string; when: string; tone: string; rank: number }> = {
  get_help_now: { label: "Urgent review", when: "Before the next dose", tone: "red", rank: 0 },
  today: { label: "Review today", when: "By the end of clinic today", tone: "amber", rank: 1 },
  this_week: { label: "Review this week", when: "Within 7 days", tone: "amber", rank: 2 },
  good_to_know: { label: "Good to know", when: "No deadline", tone: "", rank: 3 },
};
export const tierOf = (tier: string) => tiers[tier as Tier] ?? tiers.good_to_know;

/** YCI-28: each state's words, with the dot tone older chips use; the pills in pills.ts carry the colours. */
export const statusLabels = Object.fromEntries(
  Object.entries(checkInPills).map(([status, pill]) => [status, { label: pill.label, tone: dotTone(pill.tone) }]),
) as Record<CheckInStatus, { label: string; tone: string }>;

/** YCI-24. */
export const noteKinds: Record<NoteKind, string> = {
  symptom: "Symptom",
  how_taken: "How a medicine is taken",
  not_started: "Medicine not started",
  side_effect: "Side effect",
  allergy_history: "Allergy history",
  other_medicines: "Other medicines",
  measurement: "Measurement",
  patient_question: "Patient's question",
  red_flag: "Red flag",
  not_understood: "Not understood",
};

/** "01:12" from seconds into the call. */
export function callOffset(seconds: number): string {
  const s = Math.max(0, Math.round(seconds));
  return `${pad(Math.floor(s / 60))}:${pad(s % 60)}`;
}

/** "Mariam and Funke", "Mariam, Funke and Bola". */
export function listNames(names: string[]): string {
  if (names.length <= 1) return names[0] ?? "";
  return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
}

// Schedules in plain words (SHR-2): never a code such as twice_daily or 2/day. The backend's
// src/clinician/sharing/schedule-words.ts keeps the same table.
const SCHEDULE_WORDS: Record<string, string> = {
  once_daily: "Once a day", daily: "Once a day", qd: "Once a day",
  twice_daily: "Twice a day", bid: "Twice a day",
  three_times_daily: "Three times a day", tid: "Three times a day",
  four_times_daily: "Four times a day", qid: "Four times a day",
  every_morning: "Every morning", every_evening: "Every evening", every_night: "Every night",
  every_x_hours: "Every few hours", weekly: "Once a week", biweekly: "Every two weeks", monthly: "Once a month",
  as_needed: "When needed", prn: "When needed", custom: "Own schedule",
};
const TIMES = ["", "Once", "Twice", "Three times", "Four times", "Five times", "Six times"];

/** "Twice a day" for twice_daily or 2/day; words already plain stay as they are. */
export function scheduleLabel(value: string | null | undefined): string | null {
  const raw = value?.trim();
  if (!raw) return null;
  const key = raw.toLowerCase();
  if (SCHEDULE_WORDS[key]) return SCHEDULE_WORDS[key];
  const hours = /^every_(\d{1,2})_hours?$/.exec(key);
  if (hours) return `Every ${Number(hours[1])} hours`;
  const perDay = /^(\d+(?:\.\d+)?)\s*\/\s*day$/.exec(key);
  if (perDay) {
    const times = Number(perDay[1]);
    return Number.isInteger(times) && times >= 1 && times < TIMES.length ? `${TIMES[times]} a day` : `${perDay[1]} times a day`;
  }
  if (/^[a-z0-9]+(?:_[a-z0-9]+)+$/.test(key)) {
    const words = key.replace(/_/g, " ");
    return words.charAt(0).toUpperCase() + words.slice(1);
  }
  return raw;
}

/** A fact's detail ("500 mg · 1 tablet · 2/day") with its schedule in words. */
export const factDetail = (detail: string) =>
  detail
    .split(" · ")
    .map((part) => (/^\d+(?:\.\d+)?\s*\/\s*day$/.test(part.trim()) ? (scheduleLabel(part) ?? part) : part))
    .join(" · ");

export const scopeLabels = {
  medicines: "medicines",
  doses: "doses taken",
  check_ins: "check-ins",
  records: "records",
} as const;