model

tcmts.examples.notes.model · #5aurgdoy · ts file

// tcm: namespace notes
// The model: notes as plain data, and plain functions over them. Nothing here touches Node, the DOM or React, so
// tcm places all of it "anywhere": server components, browser components and tests share the same hashes.
import assert from "node:assert/strict";

/**
 * A note: a title and a markdown body. Stored in the `notes.notes` table; the sidebar shows it as a {@link NoteSummary}.
 */
export type Note = { title: string; body: string; updatedAt: Date };

/** A note as the sidebar lists it: made on the server by {@link summarize}, sent to the browser as props. */
export type NoteSummary = { id: string; title: string; excerpt: string; updated: string };

/**
 * The start of a note's text, without markdown syntax, on one line and cut to `max` characters.
 *
 * @param body markdown, as a {@link Note}'s body
 * @param max the longest excerpt, `…` included
 *
 * ```ts
 * excerpt("# Groceries\n\n- **eggs**\n- `milk`")
 * excerpt("A first line that goes on and on", 12)
 * ```
 */
export function excerpt(body: string, max = 80): string {
  const text = body
    .replace(/```[\s\S]*?(```|$)/g, " ")
    .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
    .replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.)\s+/gm, "")
    .replace(/[*_`]/g, "")
    .replace(/\s+/g, " ")
    .trim();
  return text.length <= max ? text : text.slice(0, max - 1).trimEnd() + "…";
}

/**
 * Whether a note matches a search: every word of `search` appears in its title or body, ignoring case. An empty
 * search matches everything.
 *
 * ```ts
 * matches(sampleNotes[0]!, "milk EGGS")
 * matches(sampleNotes[0]!, "bread")
 * ```
 */
export function matches(note: Note, search: string): boolean {
  const text = `${note.title}\n${note.body}`.toLowerCase();
  return search
    .toLowerCase()
    .split(/\s+/)
    .filter(Boolean)
    .every((word) => text.includes(word));
}

/**
 * How long ago `date` was, for people: "just now", "5 minutes ago", "3 hours ago", or the day.
 *
 * ```ts
 * ago(new Date("2026-01-01T12:00:00Z"), new Date("2026-01-01T12:05:00Z"))
 * ago(new Date("2026-01-01T09:00:00Z"), new Date("2026-01-01T12:00:00Z"))
 * ```
 */
export function ago(date: Date, now: Date = new Date()): string {
  const minutes = Math.floor((now.getTime() - date.getTime()) / 60000);
  if (minutes < 1) return "just now";
  if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
  return date.toISOString().slice(0, 10);
}

/** What the sidebar shows for a stored note: its {@link excerpt}, and when it changed ({@link ago}). */
export function summarize(id: string, note: Note, now: Date = new Date()): NoteSummary {
  return { id, title: note.title, excerpt: excerpt(note.body), updated: ago(note.updatedAt, now) };
}

/** Notes to start with: the empty sidebar offers to add them (`notes.addSampleNotes`). Frozen: it's data, not state. */
export const sampleNotes: readonly Note[] = Object.freeze([
  {
    title: "Groceries",
    body: "- **eggs** (a dozen)\n- milk\n- `coffee`, the good one",
    updatedAt: new Date("2026-10-20T09:00:00Z"),
  },
  {
    title: "How this app works",
    body:
      "# No directives\n\n" +
      "The sidebar and this page are **server components**: they read the notes table, so they render on the " +
      "server. The search box and the editor keep *state*, so they run in the browser.\n\n" +
      "Nothing says which is which: tcm infers it from what each component uses. See `tcm view notes.NoteList`.",
    updatedAt: new Date("2026-10-21T14:30:00Z"),
  },
  {
    title: "Markdown",
    body:
      "Notes are markdown: *emphasis*, **strong**, `code`, [links](https://react.dev), lists and code blocks.\n\n" +
      "```\nconst same = \"hash on both sides\";\n```\n\n" +
      "The same `notes.Markdown` component renders this page on the server and the editor's preview in the browser.",
    updatedAt: new Date("2026-10-22T08:15:00Z"),
  },
]);

/** Excerpts drop markdown syntax and fit on one line. */
export const testExcerpt = () => {
  assert.equal(excerpt("# Groceries\n\n- **eggs**\n- `milk`"), "Groceries eggs milk");
  assert.equal(excerpt("see [the docs](https://example.com)"), "see the docs");
  assert.equal(excerpt("abcdefghij", 5), "abcd…");
};

/** Every word of a search must appear; case doesn't matter. */
export const testMatches = () => {
  const note = sampleNotes[0]!;
  assert.ok(matches(note, ""));
  assert.ok(matches(note, "Milk eggs"));
  assert.ok(!matches(note, "milk bread"));
};

/** Recent changes read as minutes or hours; older ones as the day. */
export const testAgo = () => {
  const now = new Date("2026-01-02T12:00:00Z");
  assert.equal(ago(new Date("2026-01-02T11:59:40Z"), now), "just now");
  assert.equal(ago(new Date("2026-01-02T11:59:00Z"), now), "1 minute ago");
  assert.equal(ago(new Date("2026-01-02T07:00:00Z"), now), "5 hours ago");
  assert.equal(ago(new Date("2025-12-30T12:00:00Z"), now), "2025-12-30");
};

used by