+ )}
+
+ {count > 0 && (
+
+ )}
+
+ );
+ },
+);
+
+CalendarDayCell.displayName = "CalendarDayCell";
diff --git a/web/src/components/CalendarView/CalendarGrid.tsx b/web/src/components/CalendarView/CalendarGrid.tsx
new file mode 100644
index 00000000..775a3743
--- /dev/null
+++ b/web/src/components/CalendarView/CalendarGrid.tsx
@@ -0,0 +1,126 @@
+import { useDirection } from "@base-ui/react/direction-provider";
+import dayjs from "dayjs";
+import { type KeyboardEvent, useLayoutEffect, useRef, useState } from "react";
+import { type CalendarData, useMonthDays, useWeekdayLabels } from "@/components/ActivityCalendar";
+import { useInstance } from "@/contexts/InstanceContext";
+import { useView } from "@/contexts/ViewContext";
+import { ISO_DATE_FORMAT } from "@/lib/calendar-utils";
+import { CalendarDayCell, CELL_NUMBER_ROW, CELL_PADDING_Y, CELL_ROW_HEIGHT, CELL_ROWS_GAP } from "./CalendarDayCell";
+import type { CalendarMonthModel } from "./dayModel";
+import { getDefaultDate } from "./paths";
+
+const DAYS_IN_WEEK = 7;
+
+export interface CalendarGridProps {
+ /** `YYYY-MM` */
+ month: string;
+ /** Accessible name for the whole grid, e.g. "August 2026". */
+ monthLabel: string;
+ /** `YYYY-MM-DD` */
+ today: string;
+ /** Per-day counts from statistics: instant, and the skeleton layer while memos load. */
+ counts: CalendarData;
+ model: CalendarMonthModel;
+ pending: boolean;
+ selectedDate?: string;
+ /** Whether cells have room for memo rows; below md they only show a dot. */
+ showRows: boolean;
+}
+
+const KEY_DELTAS: Record = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -DAYS_IN_WEEK, ArrowDown: DAYS_IN_WEEK };
+
+/** Memo rows that fit under the day number in a cell of `cellHeight` px. */
+export const rowsForCellHeight = (cellHeight: number): number =>
+ Math.max(0, Math.floor((cellHeight - CELL_PADDING_Y - CELL_NUMBER_ROW - CELL_ROWS_GAP) / CELL_ROW_HEIGHT));
+
+const cornerOf = (index: number, total: number): "ss" | "se" | "es" | "ee" | undefined => {
+ if (index === 0) return "ss";
+ if (index === DAYS_IN_WEEK - 1) return "se";
+ if (index === total - DAYS_IN_WEEK) return "es";
+ if (index === total - 1) return "ee";
+ return undefined;
+};
+
+export const CalendarGrid = ({ month, monthLabel, today, counts, model, pending, selectedDate, showRows }: CalendarGridProps) => {
+ const direction = useDirection();
+ const { generalSetting } = useInstance();
+ const { timeBasis } = useView();
+ const containerRef = useRef(null);
+ const cellsRef = useRef(null);
+ const weekdayLabels = useWeekdayLabels(generalSetting.weekStartDayOffset);
+
+ const days = useMonthDays({ month, data: counts, weekStartDayOffset: generalSetting.weekStartDayOffset, today, selectedDate });
+ const firstDate = `${month}-01`;
+ const lastDate = dayjs(firstDate).endOf("month").format(ISO_DATE_FORMAT);
+ // One tab stop for the grid: the open day, else today, else the first of the month.
+ const focusDate = selectedDate ?? getDefaultDate(month, today);
+ const rowCount = Math.ceil(days.length / DAYS_IN_WEEK);
+
+ // CSS sizes the rows (see the cells' grid-auto-rows); the cell height is only read back to
+ // learn how many memo rows fit, and re-read whenever the grid itself resizes.
+ const [visibleRows, setVisibleRows] = useState(2);
+ useLayoutEffect(() => {
+ const el = cellsRef.current;
+ if (!el || !showRows) return;
+ const apply = () => setVisibleRows(rowsForCellHeight(el.clientHeight / rowCount));
+ apply();
+ if (typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(apply);
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [rowCount, showRows]);
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ const current = (event.target as HTMLElement).dataset.calendarDate;
+ if (!current) return;
+ let next: string | undefined;
+ if (event.key in KEY_DELTAS) {
+ const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight";
+ const delta = KEY_DELTAS[event.key] * (horizontal && direction === "rtl" ? -1 : 1);
+ next = dayjs(current).add(delta, "day").format(ISO_DATE_FORMAT);
+ } else if (event.key === "Home") {
+ next = firstDate;
+ } else if (event.key === "End") {
+ next = lastDate;
+ }
+ if (!next) return;
+ const target = containerRef.current?.querySelector(`[data-calendar-date="${next}"]`);
+ if (!target) return;
+ event.preventDefault();
+ target.focus();
+ };
+
+ return (
+
+ {/* Labels start on the cell's text axis (its padding); px-px offsets the grid's border. */}
+
+ {weekdayLabels.map((label) => (
+
+ {label}
+
+ ))}
+
+ {/* From xl the grid fills the sticky section, so the rows share its height evenly with a
+ floor that keeps a number and two memo rows per day on short windows. */}
+
+ );
+};
diff --git a/web/src/components/CalendarView/CalendarHeader.tsx b/web/src/components/CalendarView/CalendarHeader.tsx
new file mode 100644
index 00000000..67d3c3c6
--- /dev/null
+++ b/web/src/components/CalendarView/CalendarHeader.tsx
@@ -0,0 +1,64 @@
+import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
+import { buttonVariants } from "@/components/ui/button";
+import { addMonths } from "@/lib/calendar-utils";
+import { cn } from "@/lib/utils";
+import { useTranslate } from "@/utils/i18n";
+import { CalendarLink } from "./CalendarLink";
+import { MonthPicker } from "./MonthPicker";
+import { buildCalendarPath, getMonthOfDate } from "./paths";
+
+export interface CalendarHeaderProps {
+ month: string;
+ monthLabel: string;
+ /** `YYYY-MM-DD` */
+ today: string;
+ /** `YYYY-MM-DD` of the open day, if any. */
+ date?: string;
+}
+
+/**
+ * Where "Today" goes: from another month it returns to this month; within this month it
+ * toggles today's panel, so the button is also the quickest way to today's memos.
+ */
+export const getTodayPath = (month: string, date: string | undefined, today: string): string => {
+ const currentMonth = getMonthOfDate(today);
+ if (month !== currentMonth) return buildCalendarPath(currentMonth);
+ return date === today ? buildCalendarPath(currentMonth) : buildCalendarPath(currentMonth, today);
+};
+
+const NAV_LINK_CLASSES = cn(
+ buttonVariants({ variant: "ghost", size: "icon-sm" }),
+ "size-7 rounded-md text-muted-foreground/70 no-underline hover:bg-muted/60 hover:text-foreground",
+);
+
+export const CalendarHeader = ({ month, monthLabel, today, date }: CalendarHeaderProps) => {
+ const t = useTranslate();
+ const todayOpen = date === today;
+
+ return (
+ // The title's text starts on the grid's text axis (border + cell padding); the month
+ // controls sit at the trailing edge so they never move as the title's width changes.
+
+
+
+
+
+
+
+
+
+
+ {t("common.today")}
+
+
+
+ );
+};
diff --git a/web/src/components/CalendarView/CalendarLink.tsx b/web/src/components/CalendarView/CalendarLink.tsx
new file mode 100644
index 00000000..b7bb526b
--- /dev/null
+++ b/web/src/components/CalendarView/CalendarLink.tsx
@@ -0,0 +1,19 @@
+import { forwardRef } from "react";
+import { Link, type LinkProps, useLocation } from "react-router-dom";
+
+export interface CalendarLinkProps extends Omit {
+ /** Pathname only; the current search string rides along so view and tag filters survive. */
+ to: string;
+}
+
+/**
+ * Every link inside the calendar keeps `?filter=`: the layout drops filters on a pathname
+ * change unless the query still carries them, and moving between months and days is a
+ * pathname change.
+ */
+export const CalendarLink = forwardRef(({ to, ...props }, ref) => {
+ const { search } = useLocation();
+ return ;
+});
+
+CalendarLink.displayName = "CalendarLink";
diff --git a/web/src/components/CalendarView/CalendarView.tsx b/web/src/components/CalendarView/CalendarView.tsx
new file mode 100644
index 00000000..2bfc94c6
--- /dev/null
+++ b/web/src/components/CalendarView/CalendarView.tsx
@@ -0,0 +1,181 @@
+import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useLocation, useNavigate } from "react-router-dom";
+import { SidebarResizeHandle } from "@/components/AppSidebar";
+import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
+import { useAuth } from "@/contexts/AuthContext";
+import { useInstance } from "@/contexts/InstanceContext";
+import { useSpaceContext } from "@/contexts/SpaceContext";
+import useCurrentUser from "@/hooks/useCurrentUser";
+import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
+import useMediaQuery from "@/hooks/useMediaQuery";
+import { useMemoFilters } from "@/hooks/useMemoFilters";
+import { formatMonthLabel, getToday } from "@/lib/calendar-utils";
+import { combineCELFilters } from "@/lib/cel-filter";
+import { buildMemoCreatorFilter } from "@/lib/resource-names";
+import { isMemoBlurred } from "@/lib/tag";
+import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
+import { useTranslate } from "@/utils/i18n";
+import { CalendarGrid } from "./CalendarGrid";
+import { CalendarHeader } from "./CalendarHeader";
+import { DayPanel } from "./DayPanel";
+import { buildCalendarPath, getDefaultDate } from "./paths";
+import { DAY_PANEL_DEFAULT_WIDTH, DAY_PANEL_WIDTH_VAR, useDayPanelWidth } from "./useDayPanelWidth";
+import { useMonthMemos } from "./useMonthMemos";
+
+export interface CalendarViewProps {
+ /** `YYYY-MM` */
+ month: string;
+ /** `YYYY-MM-DD` of the open day, if any. */
+ date?: string;
+}
+
+const isEditableTarget = (target: EventTarget | null): boolean =>
+ target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select, [contenteditable]") !== null);
+
+/**
+ * The signed-in user's memos as a month, scoped like Home to the remembered collection.
+ * Month and day both live in the URL; this component only reads them and renders.
+ *
+ * The open day has three homes. From xl it is a resizable panel beside the grid. Between md
+ * and xl the grid keeps its full width and the day slides in as a sheet from the end edge.
+ * Below md the grid is compact and the day's list sits under it, the way phone calendars
+ * work, so a day is always shown there: today in the current month, otherwise the first of
+ * the month, until the URL names one.
+ */
+export const CalendarView = ({ month, date }: CalendarViewProps) => {
+ const t = useTranslate();
+ const { i18n } = useTranslation();
+ const navigate = useNavigate();
+ const { search } = useLocation();
+ const user = useCurrentUser();
+ const md = useMediaQuery("md");
+ const xl = useMediaQuery("xl");
+ const panelRef = useRef(null);
+ const panelWidth = useDayPanelWidth();
+ const { userTagsSetting, isInitialized: authInitialized, isUserSettingsInitialized } = useAuth();
+ const { isInitialized: instanceInitialized } = useInstance();
+ const { memoFilter: contextFilter } = useSpaceContext();
+
+ // The sidebar's view and tag filters narrow the month exactly as they narrow Home. They are
+ // not echoed as chips here: the sidebar already shows them checked and clears them on a
+ // second click, and search hands off to Home, so nothing can be active without a sidebar row.
+ const viewFilter = useMemoFilters({ includeMemoViews: true, includePinned: false });
+ // Statistics are already creator-scoped server-side, so leaving the creator out of their
+ // filter shares the sidebar's cached query whenever no view or tag is active.
+ const statsFilter = useMemo(() => combineCELFilters(contextFilter, viewFilter), [contextFilter, viewFilter]);
+ const memoFilter = useMemo(() => combineCELFilters(viewFilter, user && buildMemoCreatorFilter(user.name)), [viewFilter, user]);
+ const monthFilter = useMemo(() => combineCELFilters(contextFilter, memoFilter), [contextFilter, memoFilter]);
+
+ // Statistics draw counts before the month's memos load, and are the only signal the grid
+ // has for days outside the month.
+ const { statistics } = useFilteredMemoStats({
+ context: "home",
+ userName: user?.name,
+ filter: statsFilter,
+ enabled: authInitialized && instanceInitialized,
+ });
+
+ const isRedacted = useCallback((memo: Memo) => isMemoBlurred(memo, userTagsSetting), [userTagsSetting]);
+ // Snippets and thumbnails must not appear before the tag settings that decide what to blur
+ // have loaded; until then the predicate would let everything through.
+ const { model, isLoading } = useMonthMemos({
+ month,
+ filter: monthFilter,
+ isRedacted,
+ enabled: Boolean(user) && isUserSettingsInitialized,
+ });
+
+ const monthLabel = useMemo(() => formatMonthLabel(month, i18n.language), [month, i18n.language]);
+ const closeDay = useCallback(() => navigate({ pathname: buildCalendarPath(month), search }), [navigate, month, search]);
+
+ const today = getToday();
+ const activeDate = date ?? (md ? undefined : getDefaultDate(month, today));
+
+ // The sheet stays mounted after its day closes so it can slide out; it keeps showing the
+ // last open day while it does.
+ const [sheetDate, setSheetDate] = useState(date);
+ useEffect(() => {
+ if (date) setSheetDate(date);
+ }, [date]);
+
+ // Escape closes the side panel unless something else consumed it first: popups and the
+ // editor both preventDefault on the Escape they handle, and text fields keep theirs.
+ useEffect(() => {
+ if (!date || !xl) return;
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== "Escape" || event.defaultPrevented || isEditableTarget(event.target)) return;
+ closeDay();
+ };
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [date, xl, closeDay]);
+
+ const isEmptyMonth = !isLoading && Object.keys(model).length === 0;
+
+ return (
+
+ {/* From xl the section is sticky and viewport-tall so the grid can fill it beside the panel. */}
+
+
+
+ {isEmptyMonth && md && (
+
+ )}
+
+
+ {sheetDate && md && !xl && (
+ !open && closeDay()}>
+ {/* The panel keeps its own close control in every tier, so the sheet's is hidden. */}
+
+ {sheetDate}
+
+
+
+ )}
+
+ {date && xl && (
+ // The panel's start edge is both its separator from the grid and the resize rail, the
+ // same grammar as the app sidebar's end edge; it stretches to the row so the edge runs
+ // the full height of the grid, and further when the day's list is longer.
+
+ )}
+
+ );
+};
diff --git a/web/src/components/CalendarView/DayPanel.tsx b/web/src/components/CalendarView/DayPanel.tsx
new file mode 100644
index 00000000..4a473431
--- /dev/null
+++ b/web/src/components/CalendarView/DayPanel.tsx
@@ -0,0 +1,114 @@
+import { SquarePenIcon, XIcon } from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import MemoEditor from "@/components/MemoEditor";
+import { deriveDefaultCreateTimeFromDate } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
+import MemoView from "@/components/MemoView";
+import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
+import { Button } from "@/components/ui/button";
+import { useAuth } from "@/contexts/AuthContext";
+import { NewMemoProvider } from "@/contexts/NewMemoContext";
+import { useSpaceContext } from "@/contexts/SpaceContext";
+import { useView } from "@/contexts/ViewContext";
+import { useMemoSorting } from "@/hooks";
+import { getLocalDayTimestampRange, parseLocalDate, withTimestampRange } from "@/lib/calendar-utils";
+import { State } from "@/types/proto/api/v1/common_pb";
+import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
+import { useTranslate } from "@/utils/i18n";
+
+export interface DayPanelProps {
+ /** `YYYY-MM-DD` */
+ date: string;
+ /** CEL fixing whose memos and which view or tags; the day's range is added here. */
+ filter?: string;
+ /** Present when the panel can be dismissed (the side panel); the inline phone list cannot. */
+ onClose?: () => void;
+}
+
+/**
+ * One day's memos as the ordinary paged list, so editing, reactions and comments behave
+ * exactly as on Home. The composer is opt-in per day and seeds the memo's creation time to
+ * that date, which is the one thing a calendar can do that the feed cannot.
+ */
+export const DayPanel = ({ date, filter, onClose }: DayPanelProps) => {
+ const t = useTranslate();
+ const { i18n } = useTranslation();
+ const { isUserSettingsInitialized } = useAuth();
+ const { memoFilter: contextFilter, selectedSpaceName } = useSpaceContext();
+ const { timeBasis } = useView();
+ const [composing, setComposing] = useState(false);
+
+ // Switching days closes the composer: a half-written memo must not silently move dates.
+ useEffect(() => setComposing(false), [date]);
+
+ const dayFilter = withTimestampRange(filter, getLocalDayTimestampRange(date), timeBasis);
+ const { listSort, orderBy } = useMemoSorting({ pinnedFirst: false, state: State.NORMAL });
+ const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromDate(date), [date]);
+
+ const dateLabel = useMemo(
+ () => parseLocalDate(date)?.toLocaleDateString(i18n.language, { weekday: "long", month: "long", day: "numeric" }) ?? date,
+ [date, i18n.language],
+ );
+
+ const editorCacheKey = `calendar-day-editor:${date}`;
+
+ return (
+
+
+
{dateLabel}
+ {isUserSettingsInitialized && (
+
+ )}
+ {onClose && (
+
+ )}
+
+
+ (
+
+ )}
+ listSort={listSort}
+ orderBy={orderBy}
+ filter={dayFilter}
+ contextFilter={contextFilter}
+ emptyMessage={t("calendar.no-memos-on-day")}
+ // Filters live in the sidebar on this route; see CalendarView.
+ showFilters={false}
+ renderLeading={() =>
+ composing && isUserSettingsInitialized ? (
+ setComposing(false)}
+ onCancel={() => setComposing(false)}
+ />
+ ) : null
+ }
+ />
+
+
+ );
+};
diff --git a/web/src/components/CalendarView/MonthPicker.tsx b/web/src/components/CalendarView/MonthPicker.tsx
new file mode 100644
index 00000000..bedbd872
--- /dev/null
+++ b/web/src/components/CalendarView/MonthPicker.tsx
@@ -0,0 +1,107 @@
+import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Button } from "@/components/ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { cn } from "@/lib/utils";
+import { useTranslate } from "@/utils/i18n";
+import { CalendarLink } from "./CalendarLink";
+import { buildCalendarPath, buildMonthKey, getMonthOfDate } from "./paths";
+
+export interface MonthPickerProps {
+ /** `YYYY-MM` currently shown. */
+ month: string;
+ monthLabel: string;
+ /** `YYYY-MM-DD` */
+ today: string;
+}
+
+const MONTHS = Array.from({ length: 12 }, (_, index) => index + 1);
+
+/**
+ * The month title is the trigger; the popup is a year stepper over the twelve months, the
+ * shown month filled and the current month marked. Months are links so the browser owns
+ * history and middle-click.
+ */
+export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => {
+ const t = useTranslate();
+ const { i18n } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const shownYear = Number(month.slice(0, 4));
+ const [year, setYear] = useState(shownYear);
+ const currentMonth = getMonthOfDate(today);
+
+ // Reopening lands on the shown month's year, not wherever the stepper was left.
+ useEffect(() => {
+ if (open) setYear(shownYear);
+ }, [open, shownYear]);
+
+ const monthShortLabels = useMemo(
+ () => MONTHS.map((value) => new Date(2000, value - 1, 1).toLocaleString(i18n.language, { month: "short" })),
+ [i18n.language],
+ );
+
+ return (
+
+
+ }
+ >
+ {monthLabel}
+
+
+