-
-
-
-
- {tab === "stops" ? (
-
- ) : (
-
- )}
-
-
-
+ {/* Content */}
+
+
);
-}
+}
\ No newline at end of file
diff --git a/src/app/globals.css b/src/app/globals.css
index 96e51b7..137af18 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,15 +1,11 @@
@import "tailwindcss";
@theme {
- /* ─── Font families (Tailwind v4 @theme) ──────────────────────────
- * Used by the editorial product pages:
- * font-display → Fraunces (variable serif, set per-page via next/font)
- * font-mono → JetBrains Mono (variable, set per-page via next/font)
- * Pages that don't load these fonts fall back to the system serif/mono
- * stacks, so the rest of the app is unaffected.
- */
- --font-display: var(--font-display, ui-serif, Georgia, "Times New Roman", serif);
- --font-mono: var(--font-mono, ui-monospace, "SF Mono", "JetBrains Mono", monospace);
+ /* Custom typefaces — Field Almanac */
+ --font-display: var(--font-fraunces, "Georgia"), Georgia, serif;
+ --font-sans: var(--font-manrope, system-ui), system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
+ --font-mono: var(--font-fragment-mono, "JetBrains Mono"), ui-monospace, SFMono-Regular, Menlo, monospace;
+
/* Apple-inspired dark palette — sophisticated depth */
--color-surface-50: #f5f5f7;
--color-surface-100: #e8e8ed;
@@ -94,7 +90,7 @@ html {
}
body {
- font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-family: var(--font-sans);
background: #ffffff;
background-attachment: fixed;
color: #1a1a1a;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 4b65600..8f40af2 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,34 +1,32 @@
import type { Metadata, Viewport } from "next";
-import { Fraunces, Geist, JetBrains_Mono } from "next/font/google";
+import { Fraunces, Manrope, Fragment_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/Providers";
import ToastNotificationContainer from "@/components/notifications/ToastNotification";
import CookieConsentBanner from "@/components/legal/CookieConsentBanner";
+const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
+
const fraunces = Fraunces({
subsets: ["latin"],
- weight: ["400", "500", "600", "700", "800"],
- style: ["normal", "italic"],
+ display: "swap",
variable: "--font-fraunces",
- display: "swap",
+ axes: ["SOFT", "WONK", "opsz"],
});
-const geist = Geist({
+const manrope = Manrope({
subsets: ["latin"],
- weight: ["300", "400", "500", "600", "700"],
- variable: "--font-geist",
display: "swap",
+ variable: "--font-manrope",
});
-const jetbrainsMono = JetBrains_Mono({
+const fragmentMono = Fragment_Mono({
subsets: ["latin"],
- weight: ["400", "500", "600", "700"],
- variable: "--font-jetbrains-mono",
display: "swap",
+ variable: "--font-fragment-mono",
+ weight: "400",
});
-const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
-
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
@@ -73,12 +71,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
-
+
+
{children}
diff --git a/src/components/admin/stops/StopsCalendar.tsx b/src/components/admin/stops/StopsCalendar.tsx
new file mode 100644
index 0000000..536eac6
--- /dev/null
+++ b/src/components/admin/stops/StopsCalendar.tsx
@@ -0,0 +1,432 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import Link from "next/link";
+import {
+ type Stop,
+ type StopStatus,
+ formatMonthYear,
+ formatTime12,
+ getStopDate,
+ getStopStatus,
+ isSameDay,
+} from "./types";
+
+type Props = {
+ stops: Stop[];
+};
+
+const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+const STATUS_STYLES: Record
= {
+ active: {
+ bg: "bg-[var(--admin-accent-light)]",
+ text: "text-[var(--admin-accent-text)]",
+ dot: "bg-[var(--admin-accent-dot)]",
+ border: "border-[var(--admin-accent)]/40",
+ },
+ draft: {
+ bg: "bg-amber-50",
+ text: "text-amber-800",
+ dot: "bg-amber-500",
+ border: "border-amber-300",
+ },
+ inactive: {
+ bg: "bg-stone-100",
+ text: "text-stone-500",
+ dot: "bg-stone-400",
+ border: "border-stone-300",
+ },
+};
+
+function buildMonthGrid(viewYear: number, viewMonth: number) {
+ // Sunday-first 6-row grid
+ const first = new Date(viewYear, viewMonth, 1);
+ const startWeekday = first.getDay();
+ const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
+
+ const cells: { date: Date; inMonth: boolean }[] = [];
+ // Leading days from previous month
+ for (let i = startWeekday - 1; i >= 0; i--) {
+ const d = new Date(viewYear, viewMonth, -i);
+ cells.push({ date: d, inMonth: false });
+ }
+ for (let d = 1; d <= daysInMonth; d++) {
+ cells.push({ date: new Date(viewYear, viewMonth, d), inMonth: true });
+ }
+ // Trailing days to fill 6 rows = 42 cells
+ while (cells.length < 42) {
+ const last = cells[cells.length - 1].date;
+ const next = new Date(last);
+ next.setDate(last.getDate() + 1);
+ cells.push({ date: next, inMonth: false });
+ }
+ return cells;
+}
+
+function ymd(d: Date): string {
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${y}-${m}-${day}`;
+}
+
+export default function StopsCalendar({ stops }: Props) {
+ const today = useMemo(() => {
+ const d = new Date();
+ d.setHours(0, 0, 0, 0);
+ return d;
+ }, []);
+
+ const [view, setView] = useState({ year: today.getFullYear(), month: today.getMonth() });
+ const [selectedDate, setSelectedDate] = useState(ymd(today));
+
+ const cells = useMemo(() => buildMonthGrid(view.year, view.month), [view]);
+
+ const stopsByDate = useMemo(() => {
+ const map = new Map();
+ for (const s of stops) {
+ const d = getStopDate(s);
+ if (!d) continue;
+ const k = ymd(d);
+ const arr = map.get(k) ?? [];
+ arr.push(s);
+ map.set(k, arr);
+ }
+ // sort each bucket by time
+ for (const arr of map.values()) {
+ arr.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
+ }
+ return map;
+ }, [stops]);
+
+ const visibleStopsByDate = useMemo(() => {
+ const map = new Map();
+ for (const [k, arr] of stopsByDate.entries()) {
+ map.set(
+ k,
+ arr.filter((s) => getStopStatus(s) !== "inactive" || arr.length <= 6)
+ );
+ }
+ return map;
+ }, [stopsByDate]);
+
+ const monthStopsCount = useMemo(() => {
+ let count = 0;
+ for (const cell of cells) {
+ if (!cell.inMonth) continue;
+ count += stopsByDate.get(ymd(cell.date))?.length ?? 0;
+ }
+ return count;
+ }, [cells, stopsByDate]);
+
+ const upcomingWeek = useMemo(() => {
+ const end = new Date(today);
+ end.setDate(end.getDate() + 7);
+ let count = 0;
+ for (const s of stops) {
+ const d = getStopDate(s);
+ if (!d) continue;
+ if (d >= today && d < end && getStopStatus(s) !== "inactive") count++;
+ }
+ return count;
+ }, [stops, today]);
+
+ const goPrev = () => {
+ setView((v) => {
+ const m = v.month - 1;
+ if (m < 0) return { year: v.year - 1, month: 11 };
+ return { ...v, month: m };
+ });
+ setSelectedDate(null);
+ };
+ const goNext = () => {
+ setView((v) => {
+ const m = v.month + 1;
+ if (m > 11) return { year: v.year + 1, month: 0 };
+ return { ...v, month: m };
+ });
+ setSelectedDate(null);
+ };
+ const goToday = () => {
+ setView({ year: today.getFullYear(), month: today.getMonth() });
+ setSelectedDate(ymd(today));
+ };
+
+ const monthLabel = formatMonthYear(new Date(view.year, view.month, 1));
+ const isCurrentMonth = view.year === today.getFullYear() && view.month === today.getMonth();
+
+ const selectedDayStops = selectedDate ? stopsByDate.get(selectedDate) ?? [] : [];
+
+ return (
+
+ {/* Calendar card */}
+
+ {/* Decorative paper texture */}
+
+ \")",
+ }}
+ />
+
+ {/* Header */}
+
+
+ {/* Weekday header */}
+
+ {WEEKDAY_LABELS.map((d, i) => (
+
+ {d}
+
+ ))}
+
+
+ {/* Grid */}
+
+ {cells.map((cell, idx) => {
+ const k = ymd(cell.date);
+ const dayStops = stopsByDate.get(k) ?? [];
+ const visibleStops = visibleStopsByDate.get(k) ?? [];
+ const isToday = isSameDay(cell.date, today);
+ const isSelected = k === selectedDate;
+ const isWeekend = cell.date.getDay() === 0 || cell.date.getDay() === 6;
+ const overflow = dayStops.length - visibleStops.length;
+
+ return (
+
+ );
+ })}
+
+
+ {/* Legend */}
+
+
+
+ {/* Day detail panel */}
+
+
+ );
+}
diff --git a/src/components/admin/stops/StopsDashboardClient.tsx b/src/components/admin/stops/StopsDashboardClient.tsx
new file mode 100644
index 0000000..ce29829
--- /dev/null
+++ b/src/components/admin/stops/StopsDashboardClient.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { type Stop, type StopView, getStopStatus } from "./types";
+import StopsCalendar from "./StopsCalendar";
+import StopsLocations from "./StopsLocations";
+import StopsList from "./StopsList";
+
+type Props = {
+ stops: Stop[];
+};
+
+const TABS: { value: StopView; label: string; hint: string }[] = [
+ { value: "calendar", label: "Calendar", hint: "Month at a glance" },
+ { value: "locations", label: "Locations", hint: "Stops grouped by city" },
+ { value: "list", label: "List", hint: "All stops in order" },
+];
+
+export default function StopsDashboardClient({ stops }: Props) {
+ const [view, setView] = useState("calendar");
+
+ const stats = useMemo(() => {
+ const total = stops.length;
+ const active = stops.filter((s) => getStopStatus(s) === "active").length;
+ const draft = stops.filter((s) => getStopStatus(s) === "draft").length;
+ const cities = new Set(stops.map((s) => s.city.trim().toLowerCase())).size;
+ const venues = new Set(
+ stops.map((s) => `${s.city}|${s.state}|${s.location}`.toLowerCase())
+ ).size;
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const in7 = new Date(today);
+ in7.setDate(today.getDate() + 7);
+ const upcoming = stops.filter((s) => {
+ if (!s.date) return false;
+ if (getStopStatus(s) === "inactive") return false;
+ const d = new Date(s.date + "T00:00:00");
+ return d >= today && d < in7;
+ }).length;
+ return { total, active, draft, cities, venues, upcoming };
+ }, [stops]);
+
+ return (
+
+ {/* Top stats strip — almanac style */}
+
+ \")",
+ }}
+ />
+
+
+
+ Harvest Dispatch · Almanac
+
+
+ {stats.total === 0
+ ? "No stops scheduled"
+ : `${stats.total} stop${stats.total === 1 ? "" : "s"} on the route`}
+
+ {stats.cities} cit{stats.cities === 1 ? "y" : "ies"}
+
+
+
+ {stats.active} active · {stats.draft} drafts · {stats.venues} distinct venues
+
+
+
+ {/* Tab nav — binder-style with notched corners */}
+
+ {TABS.map((t) => {
+ const isActive = t.value === view;
+ return (
+
+ );
+ })}
+
+
+
+ {/* Stat cells */}
+
+
+
+ {/* Active view */}
+
+ {view === "calendar" && }
+ {view === "locations" && }
+ {view === "list" && }
+
+
+ );
+}
+
+function AlmanacStat({
+ numeral,
+ label,
+ value,
+ accent,
+}: {
+ numeral: string;
+ label: string;
+ value: number;
+ accent?: boolean;
+}) {
+ return (
+
+
+ {numeral}
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+ );
+}
diff --git a/src/components/admin/stops/StopsList.tsx b/src/components/admin/stops/StopsList.tsx
new file mode 100644
index 0000000..aa72da7
--- /dev/null
+++ b/src/components/admin/stops/StopsList.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useMemo } from "react";
+import { type Stop, getStopStatus } from "./types";
+
+type Props = {
+ stops: Stop[];
+};
+
+// Minimal list view used inside the StopsDashboard tab nav.
+// It is a calmer, read-only list; for bulk publish/edit use the
+// dedicated /admin/stops page.
+export default function StopsList({ stops }: Props) {
+ const sorted = useMemo(() => {
+ return [...stops].sort((a, b) => (a.date || "").localeCompare(b.date || ""));
+ }, [stops]);
+
+ if (sorted.length === 0) {
+ return (
+
+
No stops yet
+
+ Switch to a different tab to add stops.
+
+
+ );
+ }
+
+ return (
+
+ {sorted.map((s) => {
+ const status = getStopStatus(s);
+ const dot =
+ status === "active"
+ ? "bg-[var(--admin-accent-dot)]"
+ : status === "draft"
+ ? "bg-amber-500"
+ : "bg-stone-400";
+ const brand = Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name;
+ return (
+ -
+
+
+ {s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "2-digit" }) : "—"}
+
+
+ {s.time ? s.time.slice(0, 5) : "—"}
+
+
+
+ {s.city}, {s.state}
+
+ {s.location}
+
+ {brand && (
+
+ {brand}
+
+ )}
+
+ {status}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/admin/stops/StopsLocations.tsx b/src/components/admin/stops/StopsLocations.tsx
new file mode 100644
index 0000000..9ca88f2
--- /dev/null
+++ b/src/components/admin/stops/StopsLocations.tsx
@@ -0,0 +1,403 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import Link from "next/link";
+import {
+ type LocationGroup,
+ type Stop,
+ formatTime12,
+ getStopStatus,
+} from "./types";
+
+type Props = {
+ stops: Stop[];
+};
+
+function groupStopsByLocation(stops: Stop[]): LocationGroup[] {
+ const map = new Map();
+ for (const s of stops) {
+ const key = `${(s.city || "Unknown").trim().toUpperCase()}|${(s.state || "").trim().toUpperCase()}`;
+ let group = map.get(key);
+ if (!group) {
+ group = {
+ key,
+ city: s.city || "Unknown",
+ state: s.state || "",
+ venueCount: 0,
+ total: 0,
+ active: 0,
+ draft: 0,
+ inactive: 0,
+ upcoming: 0,
+ nextDate: null,
+ firstDate: null,
+ lastDate: null,
+ sampleVenue: s.location,
+ stops: [],
+ };
+ map.set(key, group);
+ }
+ group.stops.push(s);
+ group.total += 1;
+ const status = getStopStatus(s);
+ if (status === "active") group.active += 1;
+ else if (status === "draft") group.draft += 1;
+ else group.inactive += 1;
+
+ if (s.date) {
+ if (!group.firstDate || s.date < group.firstDate) group.firstDate = s.date;
+ if (!group.lastDate || s.date > group.lastDate) group.lastDate = s.date;
+ }
+ }
+ // post-process: count distinct venues, upcoming stops
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
+
+ for (const g of map.values()) {
+ const venues = new Set(g.stops.map((s) => (s.location || "").trim().toLowerCase()).filter(Boolean));
+ g.venueCount = Math.max(1, venues.size);
+ g.upcoming = g.stops.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive").length;
+ const future = g.stops
+ .filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive")
+ .map((s) => s.date)
+ .sort();
+ g.nextDate = future[0] ?? null;
+ g.stops.sort((a, b) => (a.date || "").localeCompare(b.date || ""));
+ }
+
+ return Array.from(map.values()).sort((a, b) => {
+ // active locations with upcoming stops first, sorted by next date; then by city
+ if (a.nextDate && !b.nextDate) return -1;
+ if (!a.nextDate && b.nextDate) return 1;
+ if (a.nextDate && b.nextDate) return a.nextDate.localeCompare(b.nextDate);
+ return a.city.localeCompare(b.city);
+ });
+}
+
+function formatDateLabel(ymd: string | null): string {
+ if (!ymd) return "—";
+ const d = new Date(ymd + "T00:00:00");
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
+}
+
+function formatRange(a: string | null, b: string | null): string {
+ if (!a) return "—";
+ if (!b || a === b) return formatDateLabel(a);
+ return `${formatDateLabel(a)} → ${formatDateLabel(b)}`;
+}
+
+export default function StopsLocations({ stops }: Props) {
+ const [expandedKey, setExpandedKey] = useState(null);
+ const [query, setQuery] = useState("");
+ const [showOnlyActive, setShowOnlyActive] = useState(false);
+
+ const groups = useMemo(() => groupStopsByLocation(stops), [stops]);
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ return groups.filter((g) => {
+ if (q) {
+ const hay = `${g.city} ${g.state} ${g.sampleVenue}`.toLowerCase();
+ if (!hay.includes(q)) return false;
+ }
+ if (showOnlyActive && g.active === 0) return false;
+ return true;
+ });
+ }, [groups, query, showOnlyActive]);
+
+ // Aggregated stats
+ const stats = useMemo(() => {
+ const totalLocations = groups.length;
+ const totalStops = stops.length;
+ const totalActive = groups.reduce((sum, g) => sum + g.active, 0);
+ const totalUpcoming = groups.reduce((sum, g) => sum + g.upcoming, 0);
+ const totalDrafts = groups.reduce((sum, g) => sum + g.draft, 0);
+ const totalCities = new Set(groups.map((g) => g.city.toLowerCase())).size;
+ const totalVenues = groups.reduce((sum, g) => sum + g.venueCount, 0);
+ return { totalLocations, totalStops, totalActive, totalUpcoming, totalDrafts, totalCities, totalVenues };
+ }, [groups, stops]);
+
+ if (stops.length === 0) {
+ return (
+
+
+
+ No locations yet
+
+
+ Create your first stop to see pickup locations here.
+
+
+ );
+ }
+
+ return (
+
+ {/* Stats strip — almanac style */}
+
+ {[
+ { numeral: "I", label: "Locations", value: stats.totalLocations, hint: `${stats.totalVenues} venues` },
+ { numeral: "II", label: "Cities", value: stats.totalCities, hint: "covered" },
+ { numeral: "III", label: "Stops", value: stats.totalStops, hint: "all-time" },
+ { numeral: "IV", label: "Active", value: stats.totalActive, hint: "live" },
+ { numeral: "V", label: "Upcoming", value: stats.totalUpcoming, hint: "in queue" },
+ { numeral: "VI", label: "Drafts", value: stats.totalDrafts, hint: "unpublished" },
+ ].map((s) => (
+
+
+
+
+ {s.numeral} · {s.label}
+
+
+ {s.value}
+
+
+ {s.hint}
+
+
+
+ {s.numeral}
+
+
+
+
+ ))}
+
+
+ {/* Filter bar */}
+
+
+
+
setQuery(e.target.value)}
+ placeholder="Search city, state, venue..."
+ className="w-full rounded-lg border border-[var(--admin-border)] bg-white pl-9 pr-3 py-2 text-sm text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:border-[var(--admin-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/15"
+ />
+
+
+
+ {filtered.length} of {groups.length} locations
+
+
+
+ {/* Location grid */}
+ {filtered.length === 0 ? (
+
+
No locations match.
+
Try a different search.
+
+ ) : (
+
+ {filtered.map((g, idx) => {
+ const isOpen = expandedKey === g.key;
+ return (
+
+ {/* Decorative route number */}
+
+ {String(idx + 1).padStart(2, "0")}
+
+
+
+
+ {/* Expanded stop list */}
+ {isOpen && (
+
+
+
+ All stops · {g.stops.length}
+
+
+ + Add at this city
+
+
+
+ {g.stops.map((s) => {
+ const status = getStopStatus(s);
+ const dot =
+ status === "active"
+ ? "bg-[var(--admin-accent-dot)]"
+ : status === "draft"
+ ? "bg-amber-500"
+ : "bg-stone-400";
+ return (
+ -
+
+
+
+ {s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }) : "—"}
+
+
+ {formatTime12(s.time)}
+
+
+ {s.location}
+
+
+ {status}
+
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
+}
diff --git a/src/components/admin/stops/types.ts b/src/components/admin/stops/types.ts
new file mode 100644
index 0000000..3816a97
--- /dev/null
+++ b/src/components/admin/stops/types.ts
@@ -0,0 +1,70 @@
+export type StopStatus = "draft" | "active" | "inactive";
+
+export type Stop = {
+ id: string;
+ city: string;
+ state: string;
+ date: string; // YYYY-MM-DD
+ time: string; // HH:MM
+ location: string;
+ active: boolean;
+ deleted_at?: string | null;
+ brand_id: string;
+ status?: string;
+ address?: string | null;
+ zip?: string | null;
+ cutoff_time?: string | null;
+ brands: { name: string } | { name: string }[];
+};
+
+export type StopView = "calendar" | "locations" | "list";
+
+export type LocationGroup = {
+ key: string;
+ city: string;
+ state: string;
+ venueCount: number; // distinct location strings at this city
+ total: number;
+ active: number;
+ draft: number;
+ inactive: number;
+ upcoming: number;
+ nextDate: string | null; // YYYY-MM-DD
+ firstDate: string | null;
+ lastDate: string | null;
+ sampleVenue: string;
+ stops: Stop[];
+};
+
+export function getStopStatus(s: Stop): StopStatus {
+ if (s.status === "draft") return "draft";
+ return s.active ? "active" : "inactive";
+}
+
+export function getStopDate(s: Stop): Date | null {
+ if (!s.date) return null;
+ const d = new Date(s.date + "T00:00:00");
+ return isNaN(d.getTime()) ? null : d;
+}
+
+export function formatTime12(time: string): string {
+ if (!time) return "—";
+ const [h, m] = time.split(":");
+ const hour = parseInt(h, 10);
+ if (isNaN(hour)) return time;
+ const ampm = hour >= 12 ? "PM" : "AM";
+ const hour12 = hour % 12 || 12;
+ return `${hour12}:${m} ${ampm}`;
+}
+
+export function formatMonthYear(date: Date): string {
+ return date.toLocaleDateString("en-US", { month: "long", year: "numeric" });
+}
+
+export function isSameDay(a: Date, b: Date): boolean {
+ return (
+ a.getFullYear() === b.getFullYear() &&
+ a.getMonth() === b.getMonth() &&
+ a.getDate() === b.getDate()
+ );
+}