feat(web): add a calendar view of your memos (#6277)

This commit is contained in:
Johnny 2026-09-05 12:51:39 +08:00 committed by GitHub
parent 50e71e2192
commit 500bb716bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1385 additions and 101 deletions

View file

@ -1,13 +1,14 @@
import dayjs from "dayjs";
import { memo, useMemo } from "react";
import { useInstance } from "@/contexts/InstanceContext";
import { getToday } from "@/lib/calendar-utils";
import { useTranslate } from "@/utils/i18n";
import { CalendarCell } from "./CalendarCell";
import { rotateWeekdays, useMonthDays } from "./monthDays";
import type { MonthCalendarProps } from "./types";
import { calculateMaxCount, getTooltipText } from "./utils";
const useWeekdayLabels = (weekStartDayOffset: number) => {
/** Localized weekday labels starting on the instance's first day of the week. */
export const useWeekdayLabels = (weekStartDayOffset: number) => {
const t = useTranslate();
return useMemo(
() =>
@ -36,7 +37,7 @@ export const MonthCalendar = memo(({ month, data, selectedDate, onClick, timeBas
month,
data,
weekStartDayOffset: generalSetting.weekStartDayOffset,
today: dayjs().format("YYYY-MM-DD"),
today: getToday(),
selectedDate,
});

View file

@ -1,2 +1,4 @@
export { MonthCalendar } from "./MonthCalendar";
export { MonthCalendar, useWeekdayLabels } from "./MonthCalendar";
export { useMonthDays } from "./monthDays";
export type { CalendarData, CalendarDayCell, MonthCalendarProps } from "./types";
export { getTooltipText } from "./utils";

View file

@ -3,6 +3,7 @@ import {
ArchiveIcon,
ArrowRightIcon,
BellIcon,
CalendarDaysIcon,
ChevronDownIcon,
EarthIcon,
FileAudioIcon,
@ -118,7 +119,8 @@ const ProfileMode = () => {
);
};
const CollectionSidebarContent = ({ context }: { context: MemoStatsContext }) => {
/** The calendar is its own month view, so its sidebar narrows by view and tag but skips the heatmap. */
const CollectionSidebarContent = ({ context, showStatistics = true }: { context: MemoStatsContext; showStatistics?: boolean }) => {
const t = useTranslate();
const location = useLocation();
const currentUser = useCurrentUser();
@ -149,9 +151,11 @@ const CollectionSidebarContent = ({ context }: { context: MemoStatsContext }) =>
return (
<div className={SIDEBAR_SECTION_STACK_CLASSES}>
{context === "profile" && <ProfileMode />}
<SidebarSection ariaLabel={t("common.statistics")}>
<StatisticsView statisticsData={statistics} onDateSelect={() => setMobileOpen(false)} />
</SidebarSection>
{showStatistics && (
<SidebarSection ariaLabel={t("common.statistics")}>
<StatisticsView statisticsData={statistics} onDateSelect={() => setMobileOpen(false)} />
</SidebarSection>
)}
{/* Every collection route narrows the same way: views (yours, so signed-in only), days, tags. */}
{currentUser && <ViewsSection />}
<TagsSection tagCount={tags} scope={tagStateScope} onSelect={() => setMobileOpen(false)} />
@ -300,6 +304,7 @@ const RouteSidebarContent = () => {
return <CollectionSidebarContent context={kind} />;
}
if (kind === "views") return <ViewsSection manageActive />;
if (kind === "calendar") return <CollectionSidebarContent context="home" showStatistics={false} />;
if (kind === "attachments") return <AttachmentsSidebarContent />;
if (kind === "inbox") return <InboxSidebarContent />;
if (kind === "settings") return <SettingsSidebarContent />;
@ -383,6 +388,13 @@ const GlobalNavigation = () => {
const items: GlobalNavItem[] = currentUser
? [
{
id: "calendar",
label: t("common.calendar"),
path: ROUTES.CALENDAR,
icon: CalendarDaysIcon,
active: routeKind === "calendar",
},
{
id: "attachments",
label: t("common.attachments"),

View file

@ -22,11 +22,32 @@ interface Props {
onWidthChange: (width: number) => void;
/** Element carrying the width custom property, written to directly while dragging. */
targetRef: RefObject<HTMLElement | null>;
/** The custom property the drag previews into; defaults to the sidebar's. */
cssVariable?: string;
/** What a double-click restores. */
defaultWidth?: number;
/** Which edge of the rail the handle sits on: dragging past a start edge widens the rail leftward. */
edge?: "start" | "end";
/** Accessible name; defaults to the sidebar's. */
label?: string;
}
const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetRef }: Props) => {
const SidebarResizeHandle = ({
width,
minWidth,
maxWidth,
onWidthChange,
targetRef,
cssVariable = SIDEBAR_WIDTH_VAR,
defaultWidth = SIDEBAR_DEFAULT_WIDTH,
edge = "end",
label,
}: Props) => {
const t = useTranslate();
const direction = useDirection();
// Pointer and arrow deltas grow the rail when they move toward its far side: rightward for an
// end-edge handle in LTR, leftward for a start-edge one; RTL mirrors both.
const growSign = (edge === "end" ? 1 : -1) * (direction === "rtl" ? -1 : 1);
// `dragging` drives the band's styling; `draggingRef` is what the handlers and the unmount
// cleanup read, so neither depends on a state update having been flushed first.
const [dragging, setDragging] = useState(false);
@ -45,10 +66,10 @@ const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetR
if (frameRef.current != null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
targetRef.current?.style.setProperty(SIDEBAR_WIDTH_VAR, `${pendingRef.current}px`);
targetRef.current?.style.setProperty(cssVariable, `${pendingRef.current}px`);
});
},
[targetRef],
[targetRef, cssVariable],
);
const stopPreview = useCallback(() => {
@ -66,10 +87,10 @@ const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetR
() => () => {
stopPreview();
if (draggingRef.current) {
targetRef.current?.style.setProperty(SIDEBAR_WIDTH_VAR, `${originRef.current.width}px`);
targetRef.current?.style.setProperty(cssVariable, `${originRef.current.width}px`);
}
},
[stopPreview, targetRef],
[stopPreview, targetRef, cssVariable],
);
// Hold the resize cursor for the whole gesture, so it does not flicker into a text caret
@ -102,7 +123,7 @@ const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetR
// Track the delta rather than the raw cursor x, so grabbing anywhere in the strip
// does not snap the rail's edge to the pointer.
const pointerDelta = event.clientX - originRef.current.x;
previewWidth(clamp(originRef.current.width + (direction === "rtl" ? -pointerDelta : pointerDelta)));
previewWidth(clamp(originRef.current.width + growSign * pointerDelta));
};
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
@ -117,7 +138,7 @@ const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetR
};
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
const horizontalStep = direction === "rtl" ? -KEYBOARD_STEP : KEYBOARD_STEP;
const horizontalStep = growSign * KEYBOARD_STEP;
const next =
event.key === "ArrowLeft"
? width - horizontalStep
@ -137,7 +158,7 @@ const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetR
<div
role="separator"
aria-orientation="vertical"
aria-label={t("common.resize-sidebar")}
aria-label={label ?? t("common.resize-sidebar")}
aria-valuenow={width}
aria-valuemin={minWidth}
aria-valuemax={maxWidth}
@ -146,9 +167,12 @@ const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetR
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onDoubleClick={() => onWidthChange(SIDEBAR_DEFAULT_WIDTH)}
onDoubleClick={() => onWidthChange(defaultWidth)}
onKeyDown={handleKeyDown}
className="group absolute inset-y-0 -end-1 z-10 flex w-2 cursor-col-resize touch-none justify-center focus-visible:outline-none"
className={cn(
"group absolute inset-y-0 z-10 flex w-2 cursor-col-resize touch-none justify-center focus-visible:outline-none",
edge === "end" ? "-end-1" : "-start-1",
)}
>
{/* A 2px band centering to whole pixels inside the 8px strip, so it covers the rail's
border and stays crisp at 1x instead of antialiasing across a half-pixel seam. */}

View file

@ -1,4 +1,4 @@
export { default, MobileAppHeader, MobileAppSidebar } from "./AppSidebar";
export { default as QuickFindDialog } from "./QuickFindDialog";
export { default as SidebarResizeHandle } from "./SidebarResizeHandle";
export { default as useSidebarWidth, SIDEBAR_WIDTH_VAR } from "./useSidebarWidth";
export { default as useSidebarWidth, type PersistedWidthConfig, SIDEBAR_WIDTH_VAR, usePersistedWidth } from "./useSidebarWidth";

View file

@ -1,8 +1,8 @@
import { matchPath } from "react-router-dom";
import { getProfileUsername, isMemoScopeRoute, type MemoScope, resolveMemoScope } from "@/lib/memo-views";
import { getProfileUsername, isCalendarRoute, isMemoScopeRoute, type MemoScope, resolveMemoScope } from "@/lib/memo-views";
import { ROUTES } from "@/router/routes";
export type SidebarRouteKind = MemoScope | "profile" | "views" | "attachments" | "inbox" | "settings" | "memo" | "common";
export type SidebarRouteKind = MemoScope | "profile" | "views" | "calendar" | "attachments" | "inbox" | "settings" | "memo" | "common";
export type RouteSearchScope = "remembered-collection" | "user-collection" | "profile" | "all";
export type RouteComposePlacement = "remembered-space" | "unassigned";
@ -19,6 +19,7 @@ export const getSidebarRouteKind = (path: string): SidebarRouteKind => {
if (isMemoScopeRoute(normalizedPath)) return resolveMemoScope(normalizedPath);
if (getProfileUsername(normalizedPath) !== undefined) return "profile";
if (matchPath(ROUTES.VIEWS, normalizedPath)) return "views";
if (isCalendarRoute(normalizedPath)) return "calendar";
if (matchPath(ROUTES.ATTACHMENTS, normalizedPath)) return "attachments";
if (matchPath(ROUTES.INBOX, normalizedPath)) return "inbox";
if (matchPath(ROUTES.SETTING, normalizedPath)) return "settings";
@ -29,7 +30,7 @@ export const getSidebarRouteKind = (path: string): SidebarRouteKind => {
/** Routes whose collections are filtered by the remembered All / Space scope. */
export const routeSupportsCollectionScope = (path: string): boolean => {
const kind = getSidebarRouteKind(path);
return kind === "home" || kind === "explore" || kind === "attachments";
return kind === "home" || kind === "explore" || kind === "calendar" || kind === "attachments";
};
/**
@ -54,7 +55,9 @@ export const getRouteActionPolicy = (path: string): RouteActionPolicy => {
};
}
if (kind === "attachments") {
// Calendar and attachments browse the remembered collection but are not memo lists
// themselves, so a search leaves for Home and Compose keeps the remembered Space.
if (kind === "calendar" || kind === "attachments") {
return {
searchScope: "remembered-collection",
searchDestination: ROUTES.HOME,

View file

@ -16,55 +16,72 @@ export const SIDEBAR_DEFAULT_WIDTH = 256;
/** The rail may never take more than this share of the window. */
const MAX_VIEWPORT_SHARE = 0.4;
const STORAGE_KEY = "memos-sidebar-width";
export interface PersistedWidthConfig {
storageKey: string;
defaultWidth: number;
minWidth: number;
/** The widest the panel may be in a window this wide; the result is floored at `minWidth`. */
maxWidthFor: (viewportWidth: number) => number;
}
const viewportMaxWidth = (): number => {
if (typeof window === "undefined") return SIDEBAR_MAX_WIDTH;
return Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, Math.round(window.innerWidth * MAX_VIEWPORT_SHARE)));
const viewportMaxWidth = ({ minWidth, maxWidthFor }: PersistedWidthConfig): number => {
if (typeof window === "undefined") return minWidth;
return Math.max(minWidth, Math.round(maxWidthFor(window.innerWidth)));
};
const readStoredWidth = (): number => {
const readStoredWidth = ({ storageKey, defaultWidth }: PersistedWidthConfig): number => {
try {
const stored = Number(localStorage.getItem(STORAGE_KEY));
const stored = Number(localStorage.getItem(storageKey));
if (Number.isFinite(stored) && stored > 0) return stored;
} catch (error) {
console.warn("Failed to load sidebar width from localStorage:", error);
console.warn(`Failed to load ${storageKey} from localStorage:`, error);
}
return SIDEBAR_DEFAULT_WIDTH;
return defaultWidth;
};
/**
* Desktop rail width: a persisted preference, capped by whatever the current window can spare.
* Read synchronously on mount so a stored width never flashes past the default.
* A resizable rail's width: a persisted preference, capped by whatever the current window can
* spare. Read synchronously on mount so a stored width never flashes past the default.
*/
const useSidebarWidth = () => {
const [preferredWidth, setPreferredWidth] = useState(readStoredWidth);
const [maxWidth, setMaxWidth] = useState(viewportMaxWidth);
export const usePersistedWidth = (config: PersistedWidthConfig) => {
const [preferredWidth, setPreferredWidth] = useState(() => readStoredWidth(config));
const [maxWidth, setMaxWidth] = useState(() => viewportMaxWidth(config));
const { storageKey, minWidth } = config;
useEffect(() => {
const handleResize = () => setMaxWidth(viewportMaxWidth());
const handleResize = () => setMaxWidth(viewportMaxWidth(config));
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
}, [config]);
// A narrow window caps what is rendered but leaves the stored preference alone, so widening
// the window restores the width the user actually chose.
const width = Math.min(Math.max(preferredWidth, SIDEBAR_MIN_WIDTH), maxWidth);
const width = Math.min(Math.max(preferredWidth, minWidth), maxWidth);
const setWidth = useCallback(
(next: number) => {
const clamped = Math.min(Math.max(Math.round(next), SIDEBAR_MIN_WIDTH), maxWidth);
const clamped = Math.min(Math.max(Math.round(next), minWidth), maxWidth);
setPreferredWidth(clamped);
try {
localStorage.setItem(STORAGE_KEY, String(clamped));
localStorage.setItem(storageKey, String(clamped));
} catch (error) {
console.warn("Failed to persist sidebar width:", error);
console.warn(`Failed to persist ${storageKey}:`, error);
}
},
[maxWidth],
[storageKey, minWidth, maxWidth],
);
return { width, minWidth: SIDEBAR_MIN_WIDTH, maxWidth, setWidth };
return { width, minWidth, maxWidth, setWidth };
};
const SIDEBAR_WIDTH_CONFIG: PersistedWidthConfig = {
storageKey: "memos-sidebar-width",
defaultWidth: SIDEBAR_DEFAULT_WIDTH,
minWidth: SIDEBAR_MIN_WIDTH,
maxWidthFor: (viewportWidth) => Math.min(SIDEBAR_MAX_WIDTH, viewportWidth * MAX_VIEWPORT_SHARE),
};
/** Desktop rail width. */
const useSidebarWidth = () => usePersistedWidth(SIDEBAR_WIDTH_CONFIG);
export default useSidebarWidth;

View file

@ -0,0 +1,123 @@
import { memo } from "react";
import { type CalendarDayCell as CalendarDayCellData, getTooltipText } from "@/components/ActivityCalendar";
import type { MemoTimeBasis } from "@/contexts/ViewContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { CalendarLink } from "./CalendarLink";
import type { CalendarDaySummary } from "./dayModel";
import { buildCalendarPath, getMonthOfDate } from "./paths";
/** Vertical rhythm of a cell, in px; the grid derives how many rows fit from these. */
export const CELL_PADDING_Y = 16;
export const CELL_NUMBER_ROW = 24;
export const CELL_ROWS_GAP = 4;
export const CELL_ROW_HEIGHT = 18;
export interface CalendarDayCellProps {
day: CalendarDayCellData;
summary?: CalendarDaySummary;
/** Memo rows the cell has room for; 0 below md, where the cell only shows a dot. */
visibleRows: number;
/** The month's memos are still loading; `day.count` from statistics is all we know. */
pending: boolean;
timeBasis: MemoTimeBasis;
tabIndex: number;
isLastColumn: boolean;
isLastRow: boolean;
/** Which rounded corner of the grid this cell occupies, so fill and focus follow it. */
corner?: "ss" | "se" | "es" | "ee";
}
const CORNER_CLASSES = { ss: "rounded-ss-lg", se: "rounded-se-lg", es: "rounded-es-lg", ee: "rounded-ee-lg" } as const;
/**
* One day of the month grid: the number, then one row per memo, as many as fit, and a
* "+N more" line for the rest. A row is the memo's first line with a small thumbnail when
* it carries an image. The whole cell is one link so its aria-label speaks for the day.
*/
export const CalendarDayCell = memo(
({ day, summary, visibleRows, pending, timeBasis, tabIndex, isLastColumn, isLastRow, corner }: CalendarDayCellProps) => {
const t = useTranslate();
const count = summary?.count ?? day.count;
const entries = summary?.entries ?? [];
// The "+N more" line takes a row of its own, so an overflowing day shows one fewer memo.
const overflows = count > visibleRows;
const shownEntries = entries.slice(0, overflows ? Math.max(visibleRows - 1, 0) : visibleRows);
const showMore = overflows && visibleRows >= 1;
const showSkeleton = pending && day.isCurrentMonth && day.count > 0;
return (
<CalendarLink
to={buildCalendarPath(getMonthOfDate(day.date), day.date)}
data-calendar-date={day.date}
tabIndex={tabIndex}
aria-label={getTooltipText(count, day.date, t, timeBasis)}
aria-current={day.isSelected ? "page" : undefined}
className={cn(
"group/day relative flex min-h-14 min-w-0 flex-col overflow-hidden border-border/70 px-3 py-2 text-start no-underline transition-colors sm:min-h-20 md:min-h-[5.5rem]",
!isLastColumn && "border-e",
!isLastRow && "border-b",
corner && CORNER_CLASSES[corner],
"focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring/60",
// The open day is the place you are, so it takes the fill the sidebar gives a current row.
day.isSelected ? "bg-accent" : day.isCurrentMonth ? "bg-card hover:bg-muted/40" : "bg-muted/25 hover:bg-muted/45",
)}
>
{/* Everything in the cell starts on one axis: the number is plain text, and today's circle
is drawn behind it, centered on the digits, so it never pushes the text. */}
<span className="flex h-6 items-center">
<span
className={cn(
"relative z-0 inline-flex h-6 items-center text-ui leading-none tabular-nums",
day.isToday
? "font-medium text-primary-foreground before:absolute before:left-1/2 before:top-1/2 before:-z-10 before:size-[22px] before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-primary before:content-['']"
: !day.isCurrentMonth
? "text-muted-foreground/40"
: count > 0
? "font-medium text-foreground"
: "text-muted-foreground/60",
)}
>
{day.label}
</span>
</span>
{showSkeleton && <span aria-hidden="true" className="mt-1.5 hidden h-2 w-2/3 animate-pulse rounded bg-muted md:block" />}
{day.isCurrentMonth && (shownEntries.length > 0 || showMore) && (
<ul className="mt-1 flex min-w-0 flex-col">
{shownEntries.map((entry) => (
<li key={entry.memoName} className="flex h-4.5 min-w-0 items-center gap-1.5 text-xs leading-4 text-foreground/75">
{entry.thumbnailUrl && (
<img
src={entry.thumbnailUrl}
alt=""
loading="lazy"
decoding="async"
className="size-3.5 shrink-0 rounded-[3px] object-cover"
// A thumbnail the server cannot produce must not leave a blank box in front of the text.
onError={(event) => {
event.currentTarget.hidden = true;
}}
/>
)}
<span className="min-w-0 flex-1 truncate">{entry.text}</span>
</li>
))}
{showMore && (
<li className="flex h-4.5 items-center text-xs leading-4 text-muted-foreground/70">
{t("calendar.more-memos", { count: count - shownEntries.length })}
</li>
)}
</ul>
)}
{count > 0 && (
<span aria-hidden="true" className={cn("mt-1 size-1.5 rounded-full bg-primary/70", day.isCurrentMonth && "md:hidden")} />
)}
</CalendarLink>
);
},
);
CalendarDayCell.displayName = "CalendarDayCell";

View file

@ -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<string, number> = { 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<HTMLDivElement>(null);
const cellsRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLElement>(`[data-calendar-date="${next}"]`);
if (!target) return;
event.preventDefault();
target.focus();
};
return (
<div ref={containerRef} role="group" aria-label={monthLabel} className="flex min-h-0 w-full flex-1 flex-col" onKeyDown={handleKeyDown}>
{/* Labels start on the cell's text axis (its padding); px-px offsets the grid's border. */}
<div className="grid grid-cols-7 px-px" aria-hidden="true">
{weekdayLabels.map((label) => (
<div key={label} className="flex h-8 items-end px-3 pb-1.5">
<span className="text-2xs leading-none text-muted-foreground/60">{label}</span>
</div>
))}
</div>
{/* 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. */}
<div
ref={cellsRef}
className="grid grid-cols-7 overflow-hidden rounded-lg border border-border/70 bg-card xl:min-h-0 xl:flex-1 xl:[grid-auto-rows:minmax(5.5rem,1fr)]"
>
{days.map((day, index) => (
<CalendarDayCell
key={day.date}
day={day}
summary={model[day.date]}
visibleRows={showRows ? visibleRows : 0}
pending={pending}
timeBasis={timeBasis}
tabIndex={day.date === focusDate ? 0 : -1}
isLastColumn={index % DAYS_IN_WEEK === DAYS_IN_WEEK - 1}
isLastRow={index >= days.length - DAYS_IN_WEEK}
corner={cornerOf(index, days.length)}
/>
))}
</div>
</div>
);
};

View file

@ -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.
<header className="flex h-9 shrink-0 items-center ps-px">
<MonthPicker month={month} monthLabel={monthLabel} today={today} />
<div className="ms-auto flex items-center gap-0.5">
<CalendarLink to={buildCalendarPath(addMonths(month, -1))} aria-label={t("common.previous-month")} className={NAV_LINK_CLASSES}>
<ChevronLeftIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
</CalendarLink>
<CalendarLink to={buildCalendarPath(addMonths(month, 1))} aria-label={t("common.next-month")} className={NAV_LINK_CLASSES}>
<ChevronRightIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
</CalendarLink>
<CalendarLink
to={getTodayPath(month, date, today)}
aria-pressed={todayOpen}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"ms-1.5 no-underline",
todayOpen && "bg-accent text-accent-foreground",
)}
>
{t("common.today")}
</CalendarLink>
</div>
</header>
);
};

View file

@ -0,0 +1,19 @@
import { forwardRef } from "react";
import { Link, type LinkProps, useLocation } from "react-router-dom";
export interface CalendarLinkProps extends Omit<LinkProps, "to"> {
/** 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<HTMLAnchorElement, CalendarLinkProps>(({ to, ...props }, ref) => {
const { search } = useLocation();
return <Link ref={ref} to={{ pathname: to, search }} {...props} />;
});
CalendarLink.displayName = "CalendarLink";

View file

@ -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<HTMLElement>(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 (
<div className="mx-auto flex w-full max-w-7xl items-start gap-6">
{/* From xl the section is sticky and viewport-tall so the grid can fill it beside the panel. */}
<section className="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-1 xl:sticky xl:top-6 xl:h-[calc(100dvh-3.5rem)]">
<CalendarHeader month={month} monthLabel={monthLabel} today={today} date={date} />
<CalendarGrid
month={month}
monthLabel={monthLabel}
today={today}
counts={statistics.activityStats}
model={model}
pending={isLoading}
selectedDate={activeDate}
showRows={md}
/>
{isEmptyMonth && md && (
<p className="mt-2 shrink-0 text-center text-ui text-muted-foreground">
{t("calendar.no-memos-in-month", { month: monthLabel })}
</p>
)}
{activeDate && !md && (
<div className="mt-4">
<DayPanel date={activeDate} filter={memoFilter} />
</div>
)}
</section>
{sheetDate && md && !xl && (
<Sheet open={Boolean(date)} onOpenChange={(open) => !open && closeDay()}>
{/* The panel keeps its own close control in every tier, so the sheet's is hidden. */}
<SheetContent
side="right"
className="w-[28rem] max-w-[90vw] gap-0 overflow-y-auto px-6 pb-8 pt-5 sm:max-w-md [&_[data-slot=sheet-close]]:hidden"
>
<SheetTitle className="sr-only">{sheetDate}</SheetTitle>
<DayPanel date={sheetDate} filter={memoFilter} onClose={closeDay} />
</SheetContent>
</Sheet>
)}
{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.
<aside
ref={panelRef}
className="relative w-[var(--calendar-day-panel-width)] shrink-0 self-stretch border-s border-border/70 ps-6"
style={{ [DAY_PANEL_WIDTH_VAR]: `${panelWidth.width}px` } as CSSProperties}
>
<SidebarResizeHandle
width={panelWidth.width}
minWidth={panelWidth.minWidth}
maxWidth={panelWidth.maxWidth}
onWidthChange={panelWidth.setWidth}
targetRef={panelRef}
cssVariable={DAY_PANEL_WIDTH_VAR}
defaultWidth={DAY_PANEL_DEFAULT_WIDTH}
edge="start"
label={t("calendar.resize-panel")}
/>
<DayPanel date={date} filter={memoFilter} onClose={closeDay} />
</aside>
)}
</div>
);
};

View file

@ -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 (
<section aria-label={dateLabel} className="flex w-full flex-col">
<header className="mb-3 flex items-center gap-0.5 border-b border-border/70 pb-3">
<h2 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight text-foreground">{dateLabel}</h2>
{isUserSettingsInitialized && (
<Button
variant="ghost"
size="icon-sm"
aria-label={t("calendar.new-memo-on-day")}
aria-pressed={composing}
className="size-7 text-muted-foreground/70 hover:text-foreground aria-pressed:bg-accent aria-pressed:text-accent-foreground"
onClick={() => setComposing((value) => !value)}
>
<SquarePenIcon className="size-4" strokeWidth={1.8} />
</Button>
)}
{onClose && (
<Button
variant="ghost"
size="icon-sm"
aria-label={t("calendar.close-day")}
className="size-7 text-muted-foreground/70 hover:text-foreground"
onClick={onClose}
>
<XIcon className="size-4" strokeWidth={1.8} />
</Button>
)}
</header>
<NewMemoProvider>
<PagedMemoList
renderer={(memo: Memo, { compact }) => (
<MemoView key={getMemoKey(memo)} memo={memo} showVisibility showPinned showSpace={!selectedSpaceName} compact={compact} />
)}
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 ? (
<MemoEditor
key={editorCacheKey}
cacheKey={editorCacheKey}
autoFocus
className="mb-2"
placeholder={t("editor.any-thoughts")}
defaultCreateTime={defaultCreateTime}
defaultSpace={selectedSpaceName}
onConfirm={() => setComposing(false)}
onCancel={() => setComposing(false)}
/>
) : null
}
/>
</NewMemoProvider>
</section>
);
};

View file

@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
variant="ghost"
size="sm"
aria-label={t("calendar.select-month")}
className="px-3 text-base font-semibold tracking-tight"
/>
}
>
{monthLabel}
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-60 p-2">
<div className="mb-1 flex items-center">
<Button
variant="ghost"
size="icon-sm"
aria-label={t("calendar.previous-year")}
className="size-7 text-muted-foreground/70 hover:text-foreground"
onClick={() => setYear((value) => value - 1)}
>
<ChevronLeftIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
</Button>
<span className="flex-1 text-center text-sm font-medium tabular-nums text-foreground">{year}</span>
<Button
variant="ghost"
size="icon-sm"
aria-label={t("calendar.next-year")}
className="size-7 text-muted-foreground/70 hover:text-foreground"
onClick={() => setYear((value) => value + 1)}
>
<ChevronRightIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
</Button>
</div>
<div className="grid grid-cols-3 gap-0.5">
{MONTHS.map((value) => {
const key = buildMonthKey(year, value);
const isShown = key === month;
const isCurrent = key === currentMonth;
return (
<CalendarLink
key={key}
to={buildCalendarPath(key)}
aria-current={isShown ? "page" : undefined}
className={cn(
"relative flex h-8 items-center justify-center rounded-md text-ui no-underline transition-colors",
isShown ? "bg-accent font-medium text-accent-foreground" : "text-foreground hover:bg-accent hover:text-accent-foreground",
)}
onClick={() => setOpen(false)}
>
{monthShortLabels[value - 1]}
{isCurrent && (
<span aria-hidden="true" className="absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full bg-primary" />
)}
</CalendarLink>
);
})}
</div>
</PopoverContent>
</Popover>
);
};

View file

@ -0,0 +1,73 @@
import dayjs from "dayjs";
import type { MemoTimeBasis } from "@/contexts/ViewContext";
import { getMemoSortTime } from "@/hooks/useMemoSorting";
import { ISO_DATE_FORMAT } from "@/lib/calendar-utils";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { getAttachmentThumbnailUrl, isImage } from "@/utils/attachment";
/** Rows a day keeps for the grid; a cell shows as many as fit and folds the rest into "+N more". */
export const MAX_DAY_ENTRIES = 8;
export interface CalendarDayEntry {
memoName: string;
/** First non-empty line of the memo, plain text. */
text: string;
/** Thumbnail of the memo's first image, when it has one. */
thumbnailUrl?: string;
}
export interface CalendarDaySummary {
/** Every memo of the day, including ones that yield no entry. */
count: number;
/** Memos in time order, capped at MAX_DAY_ENTRIES. */
entries: CalendarDayEntry[];
}
/** Day summaries keyed by ISO date (`YYYY-MM-DD`). Days without memos are absent. */
export type CalendarMonthModel = Record<string, CalendarDaySummary>;
export interface BuildCalendarMonthModelOptions {
/** Memos that must stay hidden in the grid (blurred tags): they count, but yield no entry. */
isRedacted?: (memo: Memo) => boolean;
}
const firstLine = (text: string): string => {
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed) return trimmed;
}
return "";
};
/**
* Fold a month's memos into one summary per day. Memos are walked in ascending time so the
* rows read as the day unfolded, whatever order the API returned them in.
*/
export const buildCalendarMonthModel = (
memos: Memo[],
timeBasis: MemoTimeBasis,
{ isRedacted }: BuildCalendarMonthModelOptions = {},
): CalendarMonthModel => {
const dated = memos
.map((memo) => ({ memo, time: getMemoSortTime(memo, timeBasis) }))
.filter((entry): entry is { memo: Memo; time: Date } => entry.time !== undefined)
.sort((a, b) => a.time.getTime() - b.time.getTime());
const model: CalendarMonthModel = {};
for (const { memo, time } of dated) {
const date = dayjs(time).format(ISO_DATE_FORMAT);
const summary = (model[date] ??= { count: 0, entries: [] });
summary.count += 1;
if (isRedacted?.(memo) || summary.entries.length >= MAX_DAY_ENTRIES) continue;
const image = memo.attachments.find((attachment) => isImage(attachment.type));
const text = firstLine(memo.snippet || memo.content);
if (!text && !image) continue;
summary.entries.push({
memoName: memo.name,
text,
thumbnailUrl: image ? getAttachmentThumbnailUrl(image) : undefined,
});
}
return model;
};

View file

@ -0,0 +1,2 @@
export { CalendarView } from "./CalendarView";
export { buildCalendarPath, parseCalendarParams } from "./paths";

View file

@ -0,0 +1,37 @@
import { parseLocalDate } from "@/lib/calendar-utils";
import { ROUTES } from "@/router/routes";
/** The route's params as react-router hands them over; a type alias so `useParams` can take it. */
export type CalendarRouteParams = { year?: string; month?: string; day?: string };
const pad2 = (value: number) => String(value).padStart(2, "0");
/** `YYYY-MM` for a year and a 1-based month. */
export const buildMonthKey = (year: number, month: number): string => `${year}-${pad2(month)}`;
/**
* Resolve `/calendar/:year/:month/:day?` params. Month and day accept one or two digits so a
* hand-typed `/calendar/2026/8` still lands; the canonical form is zero-padded. Returns
* undefined for anything that is not a real calendar date so the page can redirect.
*/
export const parseCalendarParams = ({ year, month, day }: CalendarRouteParams): { month: string; date?: string } | undefined => {
if (!year || !month || !/^\d{4}$/.test(year) || !/^\d{1,2}$/.test(month)) return undefined;
const monthNumber = Number(month);
if (monthNumber < 1 || monthNumber > 12) return undefined;
const monthKey = buildMonthKey(Number(year), monthNumber);
if (day === undefined) return { month: monthKey };
if (!/^\d{1,2}$/.test(day)) return undefined;
const date = `${monthKey}-${pad2(Number(day))}`;
return parseLocalDate(date) ? { month: monthKey, date } : undefined;
};
/** Canonical path for a month, or for a day inside it when `date` is given. */
export const buildCalendarPath = (month: string, date?: string): string => {
const base = `${ROUTES.CALENDAR}/${month.replace("-", "/")}`;
return date ? `${base}/${date.slice(-2)}` : base;
};
export const getMonthOfDate = (date: string): string => date.slice(0, 7);
/** The day a month shows by default: today when the month is the current one, else the first. */
export const getDefaultDate = (month: string, today: string): string => (getMonthOfDate(today) === month ? today : `${month}-01`);

View file

@ -0,0 +1,19 @@
import { type PersistedWidthConfig, usePersistedWidth } from "@/components/AppSidebar";
/** Custom property the panel publishes; `DayPanelAside` repeats the literal in its width class. */
export const DAY_PANEL_WIDTH_VAR = "--calendar-day-panel-width";
export const DAY_PANEL_DEFAULT_WIDTH = 384;
const DAY_PANEL_MIN_WIDTH = 320;
const DAY_PANEL_MAX_WIDTH = 640;
/** App sidebar, page padding and the gap beside the grid, plus seven legible 72px columns. */
const RESERVED_BESIDE_PANEL = 256 + 48 + 24 + 7 * 72;
const DAY_PANEL_WIDTH_CONFIG: PersistedWidthConfig = {
storageKey: "memos-calendar-day-panel-width",
defaultWidth: DAY_PANEL_DEFAULT_WIDTH,
minWidth: DAY_PANEL_MIN_WIDTH,
maxWidthFor: (viewportWidth) => Math.min(DAY_PANEL_MAX_WIDTH, viewportWidth - RESERVED_BESIDE_PANEL),
};
/** Width of the inline day panel beside the month grid, persisted like the sidebar's. */
export const useDayPanelWidth = () => usePersistedWidth(DAY_PANEL_WIDTH_CONFIG);

View file

@ -0,0 +1,57 @@
import { create } from "@bufbuild/protobuf";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { memoServiceClient } from "@/connect";
import { useView } from "@/contexts/ViewContext";
import { memoKeys } from "@/hooks/useMemoQueries";
import { getLocalMonthTimestampRange, withTimestampRange } from "@/lib/calendar-utils";
import { State } from "@/types/proto/api/v1/common_pb";
import { ListMemosRequestSchema, type Memo } from "@/types/proto/api/v1/memo_service_pb";
import { type BuildCalendarMonthModelOptions, buildCalendarMonthModel } from "./dayModel";
/** A realistic personal month fits one page; the loop below still drains any that do not. */
const MONTH_PAGE_SIZE = 500;
const NO_MEMOS: Memo[] = [];
export interface UseMonthMemosOptions extends BuildCalendarMonthModelOptions {
/** `YYYY-MM` */
month: string;
/** CEL clauses fixing whose memos and which collection; the month range is added here. */
filter?: string;
enabled?: boolean;
}
/** Every page of the month: a partial month would understate counts and drop rows. */
const listWholeMonth = async (filter: string | undefined, orderBy: string): Promise<Memo[]> => {
const memos: Memo[] = [];
let pageToken = "";
do {
const response = await memoServiceClient.listMemos(
create(ListMemosRequestSchema, { state: State.NORMAL, filter, orderBy, pageSize: MONTH_PAGE_SIZE, pageToken }),
);
memos.push(...response.memos);
pageToken = response.nextPageToken;
} while (pageToken);
return memos;
};
/**
* Every memo of one month, folded into per-day summaries for the grid. Lives under the memo
* list cache key so the create/update/delete mutations invalidate it like any other list.
*/
export const useMonthMemos = ({ month, filter, enabled = true, isRedacted }: UseMonthMemosOptions) => {
const { timeBasis } = useView();
const monthFilter = withTimestampRange(filter, getLocalMonthTimestampRange(month), timeBasis);
const orderBy = `${timeBasis} asc`;
const query = useQuery({
queryKey: [...memoKeys.lists(), "calendar-month", { filter: monthFilter, orderBy }],
queryFn: () => listWholeMonth(monthFilter, orderBy),
enabled,
staleTime: 1000 * 60,
});
const memos = query.data ?? NO_MEMOS;
const model = useMemo(() => buildCalendarMonthModel(memos, timeBasis, { isRedacted }), [memos, timeBasis, isRedacted]);
return { model, isLoading: query.isLoading };
};

View file

@ -1,6 +1,5 @@
import type { MemoFilter } from "@/contexts/MemoFilterContext";
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
import { parseLocalDate } from "@/lib/calendar-utils";
/**
* Derive a default `createTime` for a new memo from the active memo filters.
@ -11,16 +10,16 @@ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
export function deriveDefaultCreateTimeFromFilters(filters: MemoFilter[], now: Date = new Date()): Date | undefined {
const dateFilter = filters.find((f) => f.factor === "displayTime");
if (!dateFilter) return undefined;
const match = DATE_RE.exec(dateFilter.value);
if (!match) return undefined;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
// Construct a local-time Date and verify the components round-trip
// (catches things like 2025-13-40 that JS would silently roll forward).
const candidate = new Date(year, month - 1, day, now.getHours(), now.getMinutes(), now.getSeconds());
if (candidate.getFullYear() !== year || candidate.getMonth() !== month - 1 || candidate.getDate() !== day) {
return undefined;
}
return candidate;
return deriveDefaultCreateTimeFromDate(dateFilter.value, now);
}
/**
* The local date `YYYY-MM-DD` combined with `now`'s wall-clock hh:mm:ss, so a memo composed
* for a past day still orders naturally within it. Undefined for a malformed date.
*/
export function deriveDefaultCreateTimeFromDate(value: string, now: Date = new Date()): Date | undefined {
const date = parseLocalDate(value);
if (!date) return undefined;
date.setHours(now.getHours(), now.getMinutes(), now.getSeconds());
return date;
}

View file

@ -16,7 +16,7 @@ import { loadMemoEditor } from "@/components/MemoEditor/loader";
import type { MemoEditorProps } from "@/components/MemoEditor/types";
import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { findTagMetadata } from "@/lib/tag";
import { isMemoBlurred } from "@/lib/tag";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
import { lazyWithReload } from "@/utils/lazy";
@ -64,7 +64,7 @@ const MemoView = forwardRef<MemoViewHandle, MemoViewProps>((props, ref) => {
// Blur content when any tag has blur_content enabled in the current user's tag settings.
const [showBlurredContent, setShowBlurredContent] = useState(false);
const blurred = memoData.tags?.some((tag) => userTagsSetting && findTagMetadata(tag, userTagsSetting)?.blurContent) ?? false;
const blurred = isMemoBlurred(memoData, userTagsSetting);
const toggleBlurVisibility = useCallback(() => setShowBlurredContent((prev) => !prev), []);
const { previewState, openPreview, setPreviewOpen } = useImagePreview();

View file

@ -53,6 +53,8 @@ interface Props {
renderHeader?: (options: { useGrid: boolean }) => ReactNode;
/** Replaces the generic empty-state message when the route knows why the list is empty. */
emptyMessage?: string;
/** Off when the host already shows the active filter chips elsewhere. */
showFilters?: boolean;
}
function useAutoFetchWhenNotScrollable({
@ -206,6 +208,7 @@ const PagedMemoList = (props: Props) => {
return () => window.removeEventListener("scroll", handleScroll);
}, [canPaginate, hasNextPage, isFetchingNextPage, fetchNextPage]);
const showFilters = props.showFilters ?? true;
const leadingContent = props.renderLeading?.({ useGrid });
const headerContent = props.renderHeader?.({ useGrid });
@ -254,12 +257,12 @@ const PagedMemoList = (props: Props) => {
// empty state follows them. The newest memo also lands directly beneath them (priorityKey
// above). Every vertical seam inside the stack uses GRID_GAP so y-spacing matches the
// grid's x-spacing exactly.
const hasFilters = filters.length > 0 || memoView !== undefined;
const hasFilters = showFilters && (filters.length > 0 || memoView !== undefined);
const gridLeading =
leadingContent || hasFilters || initialLoader || emptyPlaceholder || initialError ? (
<div className="flex w-full flex-col" style={{ gap: GRID_GAP }}>
{leadingContent}
<MemoFilters />
{showFilters && <MemoFilters />}
{initialLoader}
{initialError}
{emptyPlaceholder}
@ -302,7 +305,7 @@ const PagedMemoList = (props: Props) => {
<>
{headerContent}
{leadingContent}
<MemoFilters className="mb-2" />
{showFilters && <MemoFilters className="mb-2" />}
{initialLoader}
{initialError}
{displayMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))}

View file

@ -1,16 +1,15 @@
import dayjs from "dayjs";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { memo } from "react";
import { useTranslation } from "react-i18next";
import { SIDEBAR_ROW_BOX_CLASSES } from "@/components/AppSidebar/SidebarRow";
import { Button } from "@/components/ui/button";
import { addMonths } from "@/lib/calendar-utils";
import { addMonths, formatMonthLabel } from "@/lib/calendar-utils";
import { cn } from "@/lib/utils";
import type { MonthNavigatorProps } from "@/types/statistics";
export const MonthNavigator = memo(({ visibleMonth, onMonthChange }: MonthNavigatorProps) => {
const { i18n, t } = useTranslation();
const monthLabel = dayjs(visibleMonth).toDate().toLocaleString(i18n.language, { year: "numeric", month: "long" });
const monthLabel = formatMonthLabel(visibleMonth, i18n.language);
const handlePrevMonth = () => onMonthChange(addMonths(visibleMonth, -1));
const handleNextMonth = () => onMonthChange(addMonths(visibleMonth, 1));

View file

@ -1,8 +1,8 @@
import dayjs from "dayjs";
import { useState } from "react";
import { MonthCalendar } from "@/components/ActivityCalendar";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useDateFilterNavigation } from "@/hooks";
import { getCurrentMonth } from "@/lib/calendar-utils";
import type { StatisticsData } from "@/types/statistics";
import { MonthNavigator } from "./MonthNavigator";
@ -16,7 +16,7 @@ const StatisticsView = (props: Props) => {
const { activityStats, timeBasis } = statisticsData;
const { filters } = useMemoFilterContext();
const navigateToDateFilter = useDateFilterNavigation();
const [visibleMonthString, setVisibleMonthString] = useState(dayjs().format("YYYY-MM"));
const [visibleMonthString, setVisibleMonthString] = useState(getCurrentMonth);
const selectedDate = filters.find((filter) => filter.factor === "displayTime")?.value;
return (

View file

@ -1,11 +1,12 @@
import { useEffect, useState } from "react";
type Breakpoint = "sm" | "md" | "lg";
type Breakpoint = "sm" | "md" | "lg" | "xl";
const BREAKPOINTS: Record<Breakpoint, number> = {
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
};
const useMediaQuery = (breakpoint: Breakpoint): boolean => {

View file

@ -1,7 +1,9 @@
import { useMemo } from "react";
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { type MemoTimeBasis, useView } from "@/contexts/ViewContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useMemoViews } from "@/hooks/useUserQueries";
import { buildTimestampRangeFilter, getLocalDayTimestampRange, getTimeBasisField } from "@/lib/calendar-utils";
import { combineCELFilters } from "@/lib/cel-filter";
import { BUILTIN_TASKS_VIEW_FILTER, BUILTIN_TASKS_VIEW_ID, getMemoViewId } from "@/lib/memo-views";
import { buildMemoCreatorFilter, getVisibilityName } from "@/lib/resource-names";
@ -9,24 +11,6 @@ import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
const escapeFilterValue = (value: string): string => JSON.stringify(value);
const getLocalDayTimestampRange = (value: string): { startTimestamp: number; endTimestamp: number } | undefined => {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return undefined;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const startDate = new Date(year, month - 1, day);
if (startDate.getFullYear() !== year || startDate.getMonth() !== month - 1 || startDate.getDate() !== day) return undefined;
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + 1);
return {
startTimestamp: Math.floor(startDate.getTime() / 1000),
endTimestamp: Math.floor(endDate.getTime() / 1000),
};
};
export interface UseMemoFiltersOptions {
creatorName?: string;
includeMemoViews?: boolean;
@ -41,6 +25,8 @@ interface BuildMemoFilterOptions {
includePinned: boolean;
selectedMemoViewFilter?: string;
visibilities?: Visibility[];
/** Which timestamp a `displayTime` day filter selects on; defaults to creation time. */
timeBasis?: MemoTimeBasis;
}
export const buildMemoFilter = ({
@ -50,6 +36,7 @@ export const buildMemoFilter = ({
includePinned,
selectedMemoViewFilter,
visibilities,
timeBasis = "create_time",
}: BuildMemoFilterOptions): string | undefined => {
const conditions: string[] = [];
@ -88,7 +75,7 @@ export const buildMemoFilter = ({
} else if (filter.factor === "displayTime") {
const range = getLocalDayTimestampRange(filter.value);
if (range) {
conditions.push(`created_ts >= timestamp(${range.startTimestamp}) && created_ts < timestamp(${range.endTimestamp})`);
conditions.push(buildTimestampRangeFilter(getTimeBasisField(timeBasis), range));
}
}
}
@ -107,6 +94,8 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
const currentUser = useCurrentUser();
const { data: memoViews = [] } = useMemoViews(includeMemoViews ? currentUser?.name : undefined);
const { filters, memoView: currentMemoView } = useMemoFilterContext();
// The sidebar calendar buckets days by this basis, so a picked day must select on it too.
const { timeBasis } = useView();
// Get the selected memo view if needed.
const selectedMemoViewFilter = useMemo(() => {
@ -123,7 +112,8 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
includePinned,
selectedMemoViewFilter,
visibilities,
timeBasis,
}),
[creatorName, currentMemoView, filters, includePinned, includeMemoViews, selectedMemoViewFilter, visibilities],
[creatorName, currentMemoView, filters, includePinned, includeMemoViews, selectedMemoViewFilter, visibilities, timeBasis],
);
};

View file

@ -15,7 +15,8 @@ export interface UseMemoSortingResult {
orderBy: string;
}
const getMemoSortTime = (memo: Memo, timeBasis: MemoTimeBasis): Date | undefined => {
/** The timestamp a memo is ordered and bucketed by under `timeBasis`. */
export const getMemoSortTime = (memo: Memo, timeBasis: MemoTimeBasis): Date | undefined => {
const timestamp = timeBasis === "update_time" ? memo.updateTime : memo.createTime;
return timestamp ? timestampDate(timestamp) : undefined;
};

View file

@ -1,7 +1,72 @@
import dayjs from "dayjs";
import type { MemoTimeBasis } from "@/contexts/ViewContext";
import { combineCELFilters } from "@/lib/cel-filter";
export const MONTH_DATE_FORMAT = "YYYY-MM" as const;
export const ISO_DATE_FORMAT = "YYYY-MM-DD" as const;
export const getToday = (): string => dayjs().format(ISO_DATE_FORMAT);
export const getCurrentMonth = (): string => dayjs().format(MONTH_DATE_FORMAT);
export const addMonths = (date: Date | string, count: number): string => {
return dayjs(date).add(count, "month").format(MONTH_DATE_FORMAT);
};
/** "August 2026" for `YYYY-MM`, in the viewer's language. */
export const formatMonthLabel = (month: string, locale: string): string =>
dayjs(month).toDate().toLocaleString(locale, { year: "numeric", month: "long" });
/**
* The local midnight named by `YYYY-MM-DD`, or undefined for anything else — including
* dates JS would silently roll forward (2026-02-30 → March 2).
*/
export const parseLocalDate = (value: string): Date | undefined => {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return undefined;
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
const candidate = new Date(year, month - 1, day);
if (candidate.getFullYear() !== year || candidate.getMonth() !== month - 1 || candidate.getDate() !== day) return undefined;
return candidate;
};
/** A half-open `[start, end)` window in epoch seconds, in the viewer's local time zone. */
export interface LocalTimestampRange {
startTimestamp: number;
endTimestamp: number;
}
const toRange = (start: Date, end: Date): LocalTimestampRange => ({
startTimestamp: Math.floor(start.getTime() / 1000),
endTimestamp: Math.floor(end.getTime() / 1000),
});
/** The local calendar day named by `YYYY-MM-DD`, or undefined for an invalid date. */
export const getLocalDayTimestampRange = (value: string): LocalTimestampRange | undefined => {
const start = parseLocalDate(value);
if (!start) return undefined;
const end = new Date(start);
end.setDate(end.getDate() + 1);
return toRange(start, end);
};
/** The local calendar month named by `YYYY-MM`, or undefined for an invalid month. */
export const getLocalMonthTimestampRange = (value: string): LocalTimestampRange | undefined => {
const start = parseLocalDate(`${value}-01`);
if (!start) return undefined;
return toRange(start, new Date(start.getFullYear(), start.getMonth() + 1, 1));
};
/** The CEL timestamp field a time basis is stored under. */
export const getTimeBasisField = (timeBasis: MemoTimeBasis): "created_ts" | "updated_ts" =>
timeBasis === "update_time" ? "updated_ts" : "created_ts";
/** A CEL clause selecting memos whose `field` falls inside `range`. */
export const buildTimestampRangeFilter = (field: "created_ts" | "updated_ts", range: LocalTimestampRange): string =>
`${field} >= timestamp(${range.startTimestamp}) && ${field} < timestamp(${range.endTimestamp})`;
/** `filter` narrowed to memos whose `timeBasis` timestamp falls inside `range`; unchanged when there is no range. */
export const withTimestampRange = (
filter: string | undefined,
range: LocalTimestampRange | undefined,
timeBasis: MemoTimeBasis,
): string | undefined => (range ? combineCELFilters(filter, buildTimestampRangeFilter(getTimeBasisField(timeBasis), range)) : filter);

View file

@ -29,14 +29,20 @@ export const getProfileUsername = (pathname: string): string | undefined => {
return match ? decodeURIComponent(match[1]) : undefined;
};
/** `/calendar` and any month or day beneath it. */
export const isCalendarRoute = (pathname: string): boolean => {
const comparablePath = cleanPathname(pathname).toLowerCase();
return comparablePath === ROUTES.CALENDAR || comparablePath.startsWith(`${ROUTES.CALENDAR}/`);
};
/**
* Routes that render a memo collection the sidebar can narrow: the scope routes plus a
* user profile. Views, calendar days and tags apply in place on all of them. This is a
* different question from `isMemoCollectionOrigin` (MemoView/navigation.ts), which asks
* whether returning to a route should keep the remembered Space.
* Routes that render a memo collection the sidebar can narrow: the scope routes, a user
* profile and the calendar. Views, calendar days and tags apply in place on all of them.
* This is a different question from `isMemoCollectionOrigin` (MemoView/navigation.ts),
* which asks whether returning to a route should keep the remembered Space.
*/
export const isMemoCollectionRoute = (pathname: string): boolean =>
isMemoScopeRoute(pathname) || getProfileUsername(pathname) !== undefined;
isMemoScopeRoute(pathname) || getProfileUsername(pathname) !== undefined || isCalendarRoute(pathname);
export const getMemoScopePath = (scope: PrimaryMemoScope): string => {
if (scope === "explore") return ROUTES.EXPLORE;

View file

@ -71,3 +71,7 @@ export const isValidTagPattern = (pattern: string): boolean => {
}
return true;
};
/** Whether any of the memo's tags is set to blur its content for this user. */
export const isMemoBlurred = (memo: { tags: string[] }, tagsSetting: UserSetting_TagsSetting | undefined): boolean =>
tagsSetting !== undefined && memo.tags.some((tag) => findTagMetadata(tag, tagsSetting)?.blurContent === true);

View file

@ -48,6 +48,17 @@
"sso-signup-tip": "Sign in with a provider below to create your account.",
"welcome-back": "Welcome back."
},
"calendar": {
"close-day": "Close day",
"more-memos": "+{{count}} more",
"new-memo-on-day": "New memo on this day",
"next-year": "Next year",
"no-memos-in-month": "No memos in {{month}}",
"no-memos-on-day": "No memos on this day",
"previous-year": "Previous year",
"resize-panel": "Resize day panel",
"select-month": "Select month"
},
"demo": {
"banner-description": "Feel free to explore! This demo is reset periodically.",
"banner-title": "Memos demo",

View file

@ -48,6 +48,17 @@
"sso-signup-tip": "使用以下登录方式即可创建账号。",
"welcome-back": "欢迎回来。"
},
"calendar": {
"close-day": "关闭当天",
"more-memos": "还有 {{count}} 条",
"new-memo-on-day": "在这一天新建备忘录",
"next-year": "下一年",
"no-memos-in-month": "{{month}} 没有备忘录",
"no-memos-on-day": "这一天没有备忘录",
"previous-year": "上一年",
"resize-panel": "调整当天面板宽度",
"select-month": "选择月份"
},
"demo": {
"banner-description": "这里的改动是临时的,可能会被重置。",
"banner-title": "Memos 演示站",

View file

@ -49,6 +49,17 @@
"sso-signup-tip": "使用下方提供者登入以建立帳號。",
"welcome-back": "歡迎回來。"
},
"calendar": {
"close-day": "關閉當天",
"more-memos": "還有 {{count}} 條",
"new-memo-on-day": "在這一天新增備忘錄",
"next-year": "下一年",
"no-memos-in-month": "{{month}} 沒有備忘錄",
"no-memos-on-day": "這一天沒有備忘錄",
"previous-year": "上一年",
"resize-panel": "調整當天面板寬度",
"select-month": "選擇月份"
},
"demo": {
"banner-description": "這裡的改動是暫時的,可能會被重置。",
"banner-title": "Memos demo",

View file

@ -0,0 +1,15 @@
import { Navigate, useParams } from "react-router-dom";
import { buildCalendarPath, CalendarView, parseCalendarParams } from "@/components/CalendarView";
import type { CalendarRouteParams } from "@/components/CalendarView/paths";
import { getCurrentMonth } from "@/lib/calendar-utils";
/** `/calendar/:year?/:month?/:day?` — anything that is not a real date lands on this month. */
const Calendar = () => {
const state = parseCalendarParams(useParams<CalendarRouteParams>());
if (!state) {
return <Navigate to={buildCalendarPath(getCurrentMonth())} replace />;
}
return <CalendarView month={state.month} date={state.date} />;
};
export default Calendar;

View file

@ -11,12 +11,13 @@ import {
RequireGuestRoute,
RequireInstanceInitializationRoute,
} from "./guards";
import { ROUTES } from "./routes";
import { CALENDAR_ROUTE_PATTERN, ROUTES } from "./routes";
const AdminSignIn = lazyWithReload(() => import("@/pages/AdminSignIn"));
const About = lazyWithReload(() => import("@/pages/About"));
const Archived = lazyWithReload(() => import("@/pages/Archived"));
const AuthCallback = lazyWithReload(() => import("@/pages/AuthCallback"));
const Calendar = lazyWithReload(() => import("@/pages/Calendar"));
const Explore = lazyWithReload(() => import("@/pages/Explore"));
const Home = lazyWithReload(() => import("@/pages/Home"));
const Inboxes = lazyWithReload(() => import("@/pages/Inboxes"));
@ -89,6 +90,7 @@ export const routeConfig: RouteObject[] = [
element: <RequireAuthRoute />,
children: [
{ path: Routes.ARCHIVED, element: <Archived /> },
{ path: CALENDAR_ROUTE_PATTERN, element: <Calendar /> },
{
element: <RequireFullInitializationRoute />,
children: [{ path: Routes.VIEWS, element: <MemoViews /> }],

View file

@ -4,6 +4,7 @@ export const ROUTES = {
ATTACHMENTS: "/attachments",
INBOX: "/inbox",
ARCHIVED: "/archived",
CALENDAR: "/calendar",
VIEWS: "/views",
SETTING: "/setting",
EXPLORE: "/explore",
@ -15,5 +16,8 @@ export const ROUTES = {
SHARED_MEMO: "/memos/shares",
} as const;
/** Router pattern for the calendar: month and day are optional so `/calendar` can redirect. */
export const CALENDAR_ROUTE_PATTERN = `${ROUTES.CALENDAR}/:year?/:month?/:day?`;
export type RouteKey = keyof typeof ROUTES;
export type RoutePath = (typeof ROUTES)[RouteKey];

View file

@ -265,7 +265,9 @@ describe("App sidebar logo", () => {
fireEvent.click(compose);
expect(globalEditorState.openEditor).toHaveBeenCalledOnce();
expect(screen.queryByText("common.calendar")).not.toBeInTheDocument();
// The Calendar destination is a nav pill; the statistics calendar stays off this route.
expect(within(primaryNavigation).getByRole("link", { name: "common.calendar" })).toHaveAttribute("href", "/calendar");
expect(screen.queryByText("Calendar")).not.toBeInTheDocument();
});
it.each([

View file

@ -12,6 +12,9 @@ describe("sidebar route content", () => {
["/U/Steven/", "profile"],
["/views", "views"],
["/Views/", "views"],
["/calendar", "calendar"],
["/calendar/2026/08", "calendar"],
["/Calendar/2026/08/02/", "calendar"],
["/attachments", "attachments"],
["/Attachments/", "attachments"],
["/inbox", "inbox"],
@ -34,6 +37,8 @@ describe("sidebar route content", () => {
["/", true],
["/explore", true],
["/archived", false],
["/calendar", true],
["/calendar/2026/08/02", true],
["/attachments", true],
["/Explore/", true],
["/ARCHIVED/", false],
@ -64,8 +69,8 @@ describe("sidebar route content", () => {
});
});
it("keeps the remembered scope when Attachments sends search to Home", () => {
expect(getRouteActionPolicy("/attachments")).toEqual({
it.each(["/attachments", "/calendar/2026/08/02"])("keeps the remembered scope when %s sends search to Home", (path) => {
expect(getRouteActionPolicy(path)).toEqual({
searchScope: "remembered-collection",
searchDestination: "/",
composePlacement: "remembered-space",

View file

@ -0,0 +1,85 @@
import { create, type MessageInitShape } from "@bufbuild/protobuf";
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
import { describe, expect, it } from "vitest";
import { buildCalendarMonthModel } from "@/components/CalendarView/dayModel";
import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
import { type Memo, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
const memoAt = (date: Date, overrides: MessageInitShape<typeof MemoSchema> = {}): Memo =>
create(MemoSchema, {
name: `memos/${date.getTime()}`,
createTime: timestampFromDate(date),
updateTime: timestampFromDate(new Date(date.getTime() + 60_000)),
content: "",
...overrides,
});
const image = (name: string) => create(AttachmentSchema, { name: `attachments/${name}`, filename: `${name}.jpg`, type: "image/jpeg" });
const pdf = create(AttachmentSchema, { name: "attachments/doc", filename: "doc.pdf", type: "application/pdf" });
describe("calendar month model", () => {
it("groups memos by local day and counts them", () => {
const model = buildCalendarMonthModel(
[memoAt(new Date(2026, 7, 2, 9)), memoAt(new Date(2026, 7, 2, 21)), memoAt(new Date(2026, 7, 3, 1))],
"create_time",
);
expect(Object.keys(model).sort()).toEqual(["2026-08-02", "2026-08-03"]);
expect(model["2026-08-02"].count).toBe(2);
expect(model["2026-08-03"].count).toBe(1);
});
it("lists memos as rows in time order with a thumbnail when they carry an image", () => {
const model = buildCalendarMonthModel(
[
memoAt(new Date(2026, 7, 2, 21), { snippet: "late", attachments: [image("late")] }),
memoAt(new Date(2026, 7, 2, 9), { snippet: "early", attachments: [pdf] }),
],
"create_time",
);
const entries = model["2026-08-02"].entries;
expect(entries.map((entry) => entry.text)).toEqual(["early", "late"]);
expect(entries[0].thumbnailUrl).toBeUndefined();
expect(entries[1].thumbnailUrl).toContain("attachments/late/late.jpg");
expect(entries[1].thumbnailUrl).toContain("thumbnail=true");
});
it("uses first non-empty lines, keeps image-only memos, and drops blank ones", () => {
const model = buildCalendarMonthModel(
[
memoAt(new Date(2026, 7, 2, 8), { snippet: " \nSecond line first\nmore" }),
memoAt(new Date(2026, 7, 2, 9), { content: "Raw content only" }),
memoAt(new Date(2026, 7, 2, 10), { content: " " }),
memoAt(new Date(2026, 7, 2, 11), { content: "", attachments: [image("photo")] }),
],
"create_time",
);
expect(model["2026-08-02"].count).toBe(4);
expect(model["2026-08-02"].entries.map((entry) => entry.text)).toEqual(["Second line first", "Raw content only", ""]);
expect(model["2026-08-02"].entries[2].thumbnailUrl).toBeDefined();
});
it("caps entries while still counting every memo", () => {
const memos = Array.from({ length: 10 }, (_, index) => memoAt(new Date(2026, 7, 2, index + 1), { snippet: `m${index}` }));
const model = buildCalendarMonthModel(memos, "create_time");
expect(model["2026-08-02"].count).toBe(10);
expect(model["2026-08-02"].entries).toHaveLength(8);
});
it("buckets by update time when that is the basis", () => {
const model = buildCalendarMonthModel([memoAt(new Date(2026, 7, 31, 23, 59, 30))], "update_time");
expect(Object.keys(model)).toEqual(["2026-09-01"]);
});
it("counts redacted memos without giving them a row", () => {
const model = buildCalendarMonthModel(
[
memoAt(new Date(2026, 7, 2, 9), { tags: ["private"], snippet: "secret", attachments: [image("secret")] }),
memoAt(new Date(2026, 7, 2, 10), { snippet: "public" }),
],
"create_time",
{ isRedacted: (memo) => memo.tags.includes("private") },
);
expect(model["2026-08-02"].count).toBe(2);
expect(model["2026-08-02"].entries.map((entry) => entry.text)).toEqual(["public"]);
});
});

View file

@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { rowsForCellHeight } from "@/components/CalendarView/CalendarGrid";
describe("calendar cell rows", () => {
it.each([
[88, 2],
[96, 2],
[105, 3],
[140, 5],
[40, 0],
])("fits %i px into %i memo rows", (height, rows) => {
expect(rowsForCellHeight(height)).toBe(rows);
});
});

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { buildCalendarPath, parseCalendarParams } from "@/components/CalendarView";
describe("calendar route params", () => {
it("resolves a month and pads single-digit segments", () => {
expect(parseCalendarParams({ year: "2026", month: "8" })).toEqual({ month: "2026-08" });
expect(parseCalendarParams({ year: "2026", month: "08" })).toEqual({ month: "2026-08" });
});
it("resolves a day inside the month", () => {
expect(parseCalendarParams({ year: "2026", month: "08", day: "2" })).toEqual({ month: "2026-08", date: "2026-08-02" });
});
it.each([
[{}],
[{ year: "2026" }],
[{ year: "26", month: "08" }],
[{ year: "2026", month: "13" }],
[{ year: "2026", month: "0" }],
[{ year: "2026", month: "02", day: "30" }],
[{ year: "2026", month: "08", day: "x" }],
])("rejects %j", (params) => {
expect(parseCalendarParams(params)).toBeUndefined();
});
it("builds canonical month and day paths", () => {
expect(buildCalendarPath("2026-08")).toBe("/calendar/2026/08");
expect(buildCalendarPath("2026-08", "2026-08-02")).toBe("/calendar/2026/08/02");
});
it("round-trips through the router params", () => {
const [, , year, month, day] = buildCalendarPath("2026-08", "2026-08-17").split("/");
expect(parseCalendarParams({ year, month, day })).toEqual({ month: "2026-08", date: "2026-08-17" });
});
});

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { getTodayPath } from "@/components/CalendarView/CalendarHeader";
describe("calendar Today action", () => {
const today = "2026-09-05";
it("returns to the current month from another month without opening a day", () => {
expect(getTodayPath("2026-07", undefined, today)).toBe("/calendar/2026/09");
expect(getTodayPath("2026-07", "2026-07-16", today)).toBe("/calendar/2026/09");
});
it("opens today's panel when the current month is already shown", () => {
expect(getTodayPath("2026-09", undefined, today)).toBe("/calendar/2026/09/05");
expect(getTodayPath("2026-09", "2026-09-04", today)).toBe("/calendar/2026/09/05");
});
it("closes today's panel when it is already open", () => {
expect(getTodayPath("2026-09", today, today)).toBe("/calendar/2026/09");
});
});

View file

@ -38,6 +38,9 @@ describe("memo scopes", () => {
expect(isMemoCollectionRoute("/archived")).toBe(true);
expect(isMemoCollectionRoute("/u/steven")).toBe(true);
expect(isMemoCollectionRoute("/u/steven/?view=map")).toBe(true);
expect(isMemoCollectionRoute("/calendar")).toBe(true);
expect(isMemoCollectionRoute("/Calendar/2026/08/02/")).toBe(true);
expect(isMemoCollectionRoute("/calendars")).toBe(false);
expect(getProfileUsername("/u/j%C3%BAlia/")).toBe("júlia");
expect(getProfileUsername("/u/steven/memos")).toBeUndefined();
expect(isMemoScopeRoute("/u/steven")).toBe(false);
@ -135,6 +138,22 @@ describe("memo views", () => {
);
});
it("selects on update time when that is the basis", () => {
const start = new Date(2026, 7, 2);
const end = new Date(start);
end.setDate(end.getDate() + 1);
expect(
buildMemoFilter({
filters: [{ factor: "displayTime", value: "2026-08-02" }],
includePinned: false,
timeBasis: "update_time",
}),
).toBe(
`(updated_ts >= timestamp(${Math.floor(start.getTime() / 1000)}) && updated_ts < timestamp(${Math.floor(end.getTime() / 1000)}))`,
);
});
it("ignores invalid display-time filter values", () => {
expect(
buildMemoFilter({

View file

@ -3,6 +3,7 @@ import type { RouteObject } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { ROUTES, routeConfig } from "@/router";
import { RequireAuthRoute, RequireFullInitializationRoute, RequireGuestRoute, RequireInstanceInitializationRoute } from "@/router/guards";
import { CALENDAR_ROUTE_PATTERN } from "@/router/routes";
// Walk the nested route config and find the first route with the given path,
// starting from the provided roots. Returns undefined if nothing matches.
@ -53,7 +54,7 @@ describe("router configuration", () => {
});
it("wraps authenticated-only pages in RequireAuthRoute", () => {
for (const path of [ROUTES.ARCHIVED, ROUTES.VIEWS, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.SETTING]) {
for (const path of [ROUTES.ARCHIVED, CALENDAR_ROUTE_PATTERN, ROUTES.VIEWS, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.SETTING]) {
expect(hasAncestorOfType(routeConfig, path, RequireAuthRoute)).toBe(true);
}
});
@ -70,7 +71,7 @@ describe("router configuration", () => {
});
it("leaves memo feeds available for early queries", () => {
for (const path of [ROUTES.EXPLORE, ROUTES.ARCHIVED, "memos/:uid", "memos/shares/:token", "u/:username"]) {
for (const path of [ROUTES.EXPLORE, ROUTES.ARCHIVED, CALENDAR_ROUTE_PATTERN, "memos/:uid", "memos/shares/:token", "u/:username"]) {
expect(hasAncestorOfType(routeConfig, path, RequireFullInitializationRoute)).toBe(false);
}
});