All files / src/lib items.ts

96.42% Statements 27/28
93.1% Branches 27/29
100% Functions 8/8
96% Lines 24/25

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73                1758x         1220x 1209x   11x         5x 5x 5x 5x         391x     22x         32x 32x 38x 35x 15x 74x                             32x 32x 28x 2x   26x 10x       16x     16x    
import type { Item } from "@/types";
 
type NameableItem = { name: string | null; number: number | null };
 
/** True when an item has a non-blank name. The one definition of "named" —
 *  display, filter, and the "add a name" cues all agree (a whitespace-only
 *  name counts as nameless). */
export function hasName(item: { name: string | null }): boolean {
  return !!item.name?.trim();
}
 
/** Human label for an item: its name, else its catalog number (#N), else "Untitled". */
export function itemDisplayName(item: NameableItem): string {
  if (hasName(item)) {
    return item.name!.trim();
  }
  return item.number != null ? `#${item.number}` : "Untitled";
}
 
/** "#14 · added in June" — the year appears only when it isn't the current one. */
export function itemSubtitle(item: { number: number | null; createdAt: Date }, now: Date): string {
  const d = new Date(item.createdAt);
  const month = d.toLocaleString("en-US", { month: "long", timeZone: "UTC" });
  const when = d.getUTCFullYear() === now.getUTCFullYear() ? month : `${month} ${d.getUTCFullYear()}`;
  return `${item.number != null ? `#${item.number} · ` : ""}added in ${when}`;
}
 
/** Single-glyph avatar for an item with no photo: name initial, else a neutral mark. */
export function itemInitial(item: NameableItem): string {
  return hasName(item) ? item.name!.trim().charAt(0).toUpperCase() : "•";
}
 
const SEARCHABLE = ["name", "brand", "style", "color", "material", "category"] as const;
 
/** Grid filter: plain-text search (substring, case-insensitive — never a pattern)
 *  and the "Waiting for a name" view. `#N` matches an item's catalog number. */
export function filterItems(items: Item[], opts: { unnamed: boolean; q: string }): Item[] {
  const needle = opts.q.trim().toLocaleLowerCase();
  return items.filter((item) => {
    if (opts.unnamed && hasName(item)) return false;
    if (!needle) return true;
    return (
      SEARCHABLE.some((k) => item[k]?.toLocaleLowerCase().includes(needle)) ||
      (item.number != null && `#${item.number}` === needle)
    );
  });
}
 
/** Heading + message for the items grid's empty states. Kept pure + tested so
 *  the gentle, no-shame copy can't silently regress (TONE: never count what's
 *  missing, no "incomplete"/"need" framing). */
export function itemsEmptyState(opts: {
  hasAnyItems: boolean;
  hasArchived?: boolean;
  view: "all" | "archived" | "unnamed";
  q: string;
}): { heading: string; message: string } {
  const q = opts.q.trim();
  if (q) return { heading: `Nothing matches “${q}”`, message: "Try another word, or clear the search." };
  if (opts.view === "archived") {
    return { heading: "Nothing archived", message: "Archived items rest here, ready to come back whenever you like." };
  }
  if (!opts.hasAnyItems) {
    return opts.hasArchived
      ? { heading: "Everything here is archived", message: "Find it under Archived." }
      : { heading: "No items yet", message: "Add photos to start your collection." };
  }
  Iif (opts.view === "unnamed") {
    return { heading: "Everything has a name", message: "Nothing is waiting — name things whenever you like." };
  }
  return { heading: "No items here", message: "Try a different filter." };
}