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