model

tcmts.examples.reminders.model · #z4gub3pq · ts file

// tcm: namespace reminders
// The model: reminders and lists as plain data, and plain functions over them. Nothing here touches the DOM or
// React, so tcm places all of it "anywhere": the app runs it in the browser, and the tests run it in Node.
import assert from "node:assert/strict";

/** A day, as `YYYY-MM-DD`: a reminder is due on a day, wherever you are when it is. */
export type Day = string;

/**
 * A reminder, as this device keeps it (in its database: `store.ts`): in a {@link List}, maybe due on a day, maybe
 * flagged, and done or not.
 */
export interface Reminder {
  id: string;
  /** the {@link List} it's in, by id */
  list: string;
  title: string;
  notes: string;
  due?: Day;
  flagged: boolean;
  /** when it was done (milliseconds since 1970), if it is */
  done?: number;
  /** when it was made: a list shows them in that order */
  created: number;
}

/** The colors a list can have, as Apple's Reminders has them. */
export type Color = "blue" | "red" | "orange" | "yellow" | "green" | "purple" | "brown" | "gray";

export const COLORS: readonly Color[] = ["blue", "red", "orange", "yellow", "green", "purple", "brown", "gray"];

/** A list of reminders, one of "My Lists". */
export interface List {
  id: string;
  name: string;
  color: Color;
  created: number;
}

/** The lists that gather reminders from every list, by what they have in common. */
export type Smart = "today" | "scheduled" | "all" | "flagged" | "completed";

/** The smart lists, in the order the home screen shows them, each with its name and color. */
export const SMART: readonly { id: Smart; name: string; color: Color }[] = [
  { id: "today", name: "Today", color: "blue" },
  { id: "scheduled", name: "Scheduled", color: "red" },
  { id: "all", name: "All", color: "gray" },
  { id: "flagged", name: "Flagged", color: "orange" },
  { id: "completed", name: "Completed", color: "gray" },
];

/** Whether a view's id names a smart list rather than one of the lists. */
export const isSmart = (view: string): view is Smart => SMART.some((s) => s.id === view);

/**
 * The day a moment is, where the device is.
 *
 * ```ts
 * dayOf(new Date(2026, 9, 31, 23, 59))
 * ```
 */
export function dayOf(date: Date): Day {
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}

/**
 * The day `n` days after `day` (before it, for a negative `n`).
 *
 * ```ts
 * addDays("2026-10-31", 1)
 * addDays("2026-03-01", -1)
 * ```
 */
export function addDays(day: Day, n: number): Day {
  const [y, m, d] = day.split("-").map(Number) as [number, number, number];
  return dayOf(new Date(y, m - 1, d + n));
}

const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

/** The day of the week a day is: 0 is Sunday. */
const weekday = (day: Day): number => {
  const [y, m, d] = day.split("-").map(Number) as [number, number, number];
  return new Date(y, m - 1, d).getDay();
};

/**
 * A due day as a list shows it, from `today`: "Today", "Tomorrow", "Yesterday", the weekday within the next week, or
 * the date (with its year if it isn't this year's).
 *
 * ```ts
 * dueLabel("2026-11-01", "2026-10-31")
 * dueLabel("2026-11-04", "2026-10-31")
 * dueLabel("2026-12-25", "2026-10-31")
 * dueLabel("2027-01-02", "2026-10-31")
 * ```
 */
export function dueLabel(due: Day, today: Day): string {
  if (due === today) return "Today";
  if (due === addDays(today, 1)) return "Tomorrow";
  if (due === addDays(today, -1)) return "Yesterday";
  if (due > today && due < addDays(today, 7)) return WEEKDAYS[weekday(due)]!;
  const [y, m, d] = due.split("-").map(Number) as [number, number, number];
  return `${WEEKDAYS[weekday(due)]!.slice(0, 3)} ${d} ${MONTHS[m - 1]}${due.slice(0, 4) === today.slice(0, 4) ? "" : ` ${y}`}`;
}

/** Whether a reminder that isn't done was due before today. */
export const isOverdue = (r: Reminder, today: Day): boolean => !r.done && !!r.due && r.due < today;

/**
 * Whether a reminder belongs in a view: a smart list ({@link SMART}), or one of the lists by its id. Today has what's
 * due today or before; done reminders are only in Completed.
 */
export function inView(view: string, r: Reminder, today: Day): boolean {
  switch (view) {
    case "today":
      return !r.done && !!r.due && r.due <= today;
    case "scheduled":
      return !r.done && !!r.due;
    case "all":
      return !r.done;
    case "flagged":
      return !r.done && r.flagged;
    case "completed":
      return !!r.done;
    default:
      return !r.done && r.list === view;
  }
}

/**
 * A view's reminders, in its order: by due day where days matter (Today, Scheduled), the latest done first in
 * Completed, and otherwise as they were made.
 */
export function remindersIn(view: string, all: readonly Reminder[], today: Day): Reminder[] {
  const shown = all.filter((r) => inView(view, r, today));
  const made = (a: Reminder, b: Reminder) => a.created - b.created;
  if (view === "completed") return shown.sort((a, b) => (b.done ?? 0) - (a.done ?? 0));
  if (view === "today" || view === "scheduled")
    return shown.sort((a, b) => (a.due ?? "").localeCompare(b.due ?? "") || made(a, b));
  return shown.sort(made);
}

/** How many reminders each view has: what the home screen shows beside each list. */
export function counts(all: readonly Reminder[], lists: readonly List[], today: Day): Record<string, number> {
  const views = [...SMART.map((s) => s.id), ...lists.map((l) => l.id)];
  return Object.fromEntries(views.map((v) => [v, all.filter((r) => inView(v, r, today)).length]));
}

const DAY_WORDS: Record<string, number> = { today: 0, tonight: 0, tomorrow: 1, tmrw: 1 };

/**
 * A reminder typed in one line, as the new reminder field reads it: a day at the end ("today", "tomorrow", a
 * weekday, which is the next one, or "next week"), and `!` for flagged, each taken out of the title.
 *
 * ```ts
 * quickAdd("Call the bank tomorrow", "2026-10-31")
 * quickAdd("Water the plants fri !", "2026-10-31")
 * quickAdd("Book flights next week", "2026-10-31")
 * quickAdd("Tomorrow", "2026-10-31")
 * ```
 */
export function quickAdd(text: string, today: Day): { title: string; due?: Day; flagged: boolean } {
  let words = text.trim().split(/\s+/).filter(Boolean);
  const flagged = words.includes("!");
  words = words.filter((w) => w !== "!");
  let due: Day | undefined;
  const last = words.at(-1)?.toLowerCase() ?? "";
  const pair = words.slice(-2).join(" ").toLowerCase();
  const day = WEEKDAYS.findIndex((w) => w.toLowerCase() === last || w.slice(0, 3).toLowerCase() === last);
  // (a title of one word is a title: "Today" isn't a reminder due today)
  if (words.length > 2 && pair === "next week") {
    due = addDays(today, 7);
    words = words.slice(0, -2);
  } else if (words.length > 1 && last in DAY_WORDS) {
    due = addDays(today, DAY_WORDS[last]!);
    words = words.slice(0, -1);
  } else if (words.length > 1 && day >= 0) {
    due = addDays(today, ((day - weekday(today) + 7) % 7) || 7);
    words = words.slice(0, -1);
  }
  return { title: words.join(" "), ...(due ? { due } : {}), flagged };
}

/** What a device starts with: a list, and reminders that say what to try. */
export function starter(now: number): { lists: List[]; reminders: Reminder[] } {
  const list: List = { id: "reminders", name: "Reminders", color: "blue", created: now };
  const today = dayOf(new Date(now));
  const r = (n: number, title: string, notes: string, more: Partial<Reminder> = {}): Reminder => ({
    id: `starter-${n}`,
    list: list.id,
    title,
    notes,
    flagged: false,
    created: now + n,
    ...more,
  });
  return {
    lists: [list],
    reminders: [
      r(1, "Add a reminder", "Type in the field below: “Call the bank tomorrow !” is due tomorrow, and flagged.", {
        due: today,
      }),
      r(2, "Go offline, and reload", "Everything is on this device, and so is the app: it opens without a network.", {
        flagged: true,
      }),
      r(3, "Open it in a second window", "A change in one shows in the other at once."),
      r(4, "Install it", "In Safari: Share → Add to Home Screen. In Chrome: the install button in the address bar."),
    ],
  };
}

/** Days are local days: the day after the last of the month is the first of the next. */
export const testDays = () => {
  assert.equal(addDays("2026-10-31", 1), "2026-11-01");
  assert.equal(addDays("2026-01-01", -1), "2025-12-31");
  assert.equal(dayOf(new Date(2026, 1, 3, 23, 59)), "2026-02-03");
  assert.equal(dueLabel("2026-10-31", "2026-10-31"), "Today");
  assert.equal(dueLabel("2026-10-30", "2026-10-31"), "Yesterday");
  assert.equal(dueLabel("2026-11-03", "2026-10-31"), "Tuesday");
  assert.equal(dueLabel("2026-11-07", "2026-10-31"), "Sat 7 Nov");
};

/** Each view has the reminders it says, in its order; done ones only in Completed. */
export const testViews = () => {
  const today = "2026-10-31";
  const base = { list: "l", notes: "", flagged: false };
  const all: Reminder[] = [
    { ...base, id: "a", title: "late", due: "2026-10-30", created: 3 },
    { ...base, id: "b", title: "soon", due: "2026-11-02", created: 1, flagged: true },
    { ...base, id: "c", title: "someday", created: 2 },
    { ...base, id: "d", title: "done", created: 4, done: 10, due: today },
  ];
  const ids = (view: string) => remindersIn(view, all, today).map((r) => r.id);
  assert.deepEqual(ids("today"), ["a"], "overdue is today's");
  assert.deepEqual(ids("scheduled"), ["a", "b"]);
  assert.deepEqual(ids("all"), ["b", "c", "a"]);
  assert.deepEqual(ids("flagged"), ["b"]);
  assert.deepEqual(ids("completed"), ["d"]);
  assert.deepEqual(ids("l"), ["b", "c", "a"]);
  assert.equal(counts(all, [{ id: "l", name: "L", color: "red", created: 0 }], today).l, 3);
  assert.ok(isOverdue(all[0]!, today));
};

/** One line makes a reminder: a day at its end, `!` for flagged. */
export const testQuickAdd = () => {
  const today = "2026-10-31"; // a Saturday
  assert.deepEqual(quickAdd("Call the bank tomorrow", today), { title: "Call the bank", due: "2026-11-01", flagged: false });
  assert.deepEqual(quickAdd("Plants sat !", today), { title: "Plants", due: "2026-11-07", flagged: true });
  assert.deepEqual(quickAdd("Plants Monday", today), { title: "Plants", due: "2026-11-02", flagged: false });
  assert.deepEqual(quickAdd("Book flights next week", today), { title: "Book flights", due: "2026-11-07", flagged: false });
  assert.deepEqual(quickAdd("today", today), { title: "today", flagged: false }, "a title of one word is a title");
};

used by