From 500bb716bcf76e5c99a6ccf00371ead7177c4eae Mon Sep 17 00:00:00 2001 From: Johnny Date: Sat, 5 Sep 2026 12:51:39 +0800 Subject: [PATCH] feat(web): add a calendar view of your memos (#6277) --- .../ActivityCalendar/MonthCalendar.tsx | 7 +- web/src/components/ActivityCalendar/index.ts | 4 +- web/src/components/AppSidebar/AppSidebar.tsx | 20 +- .../AppSidebar/SidebarResizeHandle.tsx | 44 ++++- web/src/components/AppSidebar/index.ts | 2 +- web/src/components/AppSidebar/routes.ts | 11 +- .../components/AppSidebar/useSidebarWidth.ts | 59 ++++-- .../CalendarView/CalendarDayCell.tsx | 123 ++++++++++++ .../components/CalendarView/CalendarGrid.tsx | 126 ++++++++++++ .../CalendarView/CalendarHeader.tsx | 64 +++++++ .../components/CalendarView/CalendarLink.tsx | 19 ++ .../components/CalendarView/CalendarView.tsx | 181 ++++++++++++++++++ web/src/components/CalendarView/DayPanel.tsx | 114 +++++++++++ .../components/CalendarView/MonthPicker.tsx | 107 +++++++++++ web/src/components/CalendarView/dayModel.ts | 73 +++++++ web/src/components/CalendarView/index.ts | 2 + web/src/components/CalendarView/paths.ts | 37 ++++ .../CalendarView/useDayPanelWidth.ts | 19 ++ .../components/CalendarView/useMonthMemos.ts | 57 ++++++ .../utils/deriveDefaultCreateTime.ts | 27 ++- web/src/components/MemoView/MemoView.tsx | 4 +- .../PagedMemoList/PagedMemoList.tsx | 9 +- .../StatisticsView/MonthNavigator.tsx | 5 +- .../StatisticsView/StatisticsView.tsx | 4 +- web/src/hooks/useMediaQuery.ts | 3 +- web/src/hooks/useMemoFilters.ts | 30 +-- web/src/hooks/useMemoSorting.ts | 3 +- web/src/lib/calendar-utils.ts | 65 +++++++ web/src/lib/memo-views.ts | 16 +- web/src/lib/tag.ts | 4 + web/src/locales/en.json | 11 ++ web/src/locales/zh-Hans.json | 11 ++ web/src/locales/zh-Hant.json | 11 ++ web/src/pages/Calendar.tsx | 15 ++ web/src/router/index.tsx | 4 +- web/src/router/routes.ts | 4 + web/tests/app-sidebar-logo.test.tsx | 4 +- web/tests/app-sidebar-routes.test.ts | 9 +- web/tests/calendar-day-model.test.ts | 85 ++++++++ web/tests/calendar-grid-rows.test.ts | 14 ++ web/tests/calendar-paths.test.ts | 35 ++++ web/tests/calendar-today-path.test.ts | 20 ++ web/tests/memo-views.test.ts | 19 ++ web/tests/router-config.test.tsx | 5 +- 44 files changed, 1385 insertions(+), 101 deletions(-) create mode 100644 web/src/components/CalendarView/CalendarDayCell.tsx create mode 100644 web/src/components/CalendarView/CalendarGrid.tsx create mode 100644 web/src/components/CalendarView/CalendarHeader.tsx create mode 100644 web/src/components/CalendarView/CalendarLink.tsx create mode 100644 web/src/components/CalendarView/CalendarView.tsx create mode 100644 web/src/components/CalendarView/DayPanel.tsx create mode 100644 web/src/components/CalendarView/MonthPicker.tsx create mode 100644 web/src/components/CalendarView/dayModel.ts create mode 100644 web/src/components/CalendarView/index.ts create mode 100644 web/src/components/CalendarView/paths.ts create mode 100644 web/src/components/CalendarView/useDayPanelWidth.ts create mode 100644 web/src/components/CalendarView/useMonthMemos.ts create mode 100644 web/src/pages/Calendar.tsx create mode 100644 web/tests/calendar-day-model.test.ts create mode 100644 web/tests/calendar-grid-rows.test.ts create mode 100644 web/tests/calendar-paths.test.ts create mode 100644 web/tests/calendar-today-path.test.ts diff --git a/web/src/components/ActivityCalendar/MonthCalendar.tsx b/web/src/components/ActivityCalendar/MonthCalendar.tsx index 1cc58157..b2df525a 100644 --- a/web/src/components/ActivityCalendar/MonthCalendar.tsx +++ b/web/src/components/ActivityCalendar/MonthCalendar.tsx @@ -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, }); diff --git a/web/src/components/ActivityCalendar/index.ts b/web/src/components/ActivityCalendar/index.ts index 68c7607e..a7f154d9 100644 --- a/web/src/components/ActivityCalendar/index.ts +++ b/web/src/components/ActivityCalendar/index.ts @@ -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"; diff --git a/web/src/components/AppSidebar/AppSidebar.tsx b/web/src/components/AppSidebar/AppSidebar.tsx index 2039d52c..571f96e8 100644 --- a/web/src/components/AppSidebar/AppSidebar.tsx +++ b/web/src/components/AppSidebar/AppSidebar.tsx @@ -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 (
{context === "profile" && } - - setMobileOpen(false)} /> - + {showStatistics && ( + + setMobileOpen(false)} /> + + )} {/* Every collection route narrows the same way: views (yours, so signed-in only), days, tags. */} {currentUser && } setMobileOpen(false)} /> @@ -300,6 +304,7 @@ const RouteSidebarContent = () => { return ; } if (kind === "views") return ; + if (kind === "calendar") return ; if (kind === "attachments") return ; if (kind === "inbox") return ; if (kind === "settings") return ; @@ -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"), diff --git a/web/src/components/AppSidebar/SidebarResizeHandle.tsx b/web/src/components/AppSidebar/SidebarResizeHandle.tsx index dfc5318c..eb2c90f5 100644 --- a/web/src/components/AppSidebar/SidebarResizeHandle.tsx +++ b/web/src/components/AppSidebar/SidebarResizeHandle.tsx @@ -22,11 +22,32 @@ interface Props { onWidthChange: (width: number) => void; /** Element carrying the width custom property, written to directly while dragging. */ targetRef: RefObject; + /** 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) => { @@ -117,7 +138,7 @@ const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetR }; const handleKeyDown = (event: ReactKeyboardEvent) => { - 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
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. */} diff --git a/web/src/components/AppSidebar/index.ts b/web/src/components/AppSidebar/index.ts index 3d92d57a..cb06d34f 100644 --- a/web/src/components/AppSidebar/index.ts +++ b/web/src/components/AppSidebar/index.ts @@ -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"; diff --git a/web/src/components/AppSidebar/routes.ts b/web/src/components/AppSidebar/routes.ts index 46a25f48..8ad3d3ab 100644 --- a/web/src/components/AppSidebar/routes.ts +++ b/web/src/components/AppSidebar/routes.ts @@ -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, diff --git a/web/src/components/AppSidebar/useSidebarWidth.ts b/web/src/components/AppSidebar/useSidebarWidth.ts index e889f11c..cecce7a4 100644 --- a/web/src/components/AppSidebar/useSidebarWidth.ts +++ b/web/src/components/AppSidebar/useSidebarWidth.ts @@ -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; diff --git a/web/src/components/CalendarView/CalendarDayCell.tsx b/web/src/components/CalendarView/CalendarDayCell.tsx new file mode 100644 index 00000000..f62a5231 --- /dev/null +++ b/web/src/components/CalendarView/CalendarDayCell.tsx @@ -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 ( + + {/* 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. */} + + 0 + ? "font-medium text-foreground" + : "text-muted-foreground/60", + )} + > + {day.label} + + + + {showSkeleton && + ); + }, +); + +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. */} + + {/* 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. */} +
+ {days.map((day, index) => ( + = days.length - DAYS_IN_WEEK} + corner={cornerOf(index, days.length)} + /> + ))} +
+
+ ); +}; 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 && ( +

+ {t("calendar.no-memos-in-month", { month: monthLabel })} +

+ )} + {activeDate && !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} + + +
+ + {year} + +
+
+ {MONTHS.map((value) => { + const key = buildMonthKey(year, value); + const isShown = key === month; + const isCurrent = key === currentMonth; + return ( + setOpen(false)} + > + {monthShortLabels[value - 1]} + {isCurrent && ( + + ); + })} +
+
+
+ ); +}; diff --git a/web/src/components/CalendarView/dayModel.ts b/web/src/components/CalendarView/dayModel.ts new file mode 100644 index 00000000..adee2544 --- /dev/null +++ b/web/src/components/CalendarView/dayModel.ts @@ -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; + +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; +}; diff --git a/web/src/components/CalendarView/index.ts b/web/src/components/CalendarView/index.ts new file mode 100644 index 00000000..7c73fc08 --- /dev/null +++ b/web/src/components/CalendarView/index.ts @@ -0,0 +1,2 @@ +export { CalendarView } from "./CalendarView"; +export { buildCalendarPath, parseCalendarParams } from "./paths"; diff --git a/web/src/components/CalendarView/paths.ts b/web/src/components/CalendarView/paths.ts new file mode 100644 index 00000000..65802f6b --- /dev/null +++ b/web/src/components/CalendarView/paths.ts @@ -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`); diff --git a/web/src/components/CalendarView/useDayPanelWidth.ts b/web/src/components/CalendarView/useDayPanelWidth.ts new file mode 100644 index 00000000..c1450fc3 --- /dev/null +++ b/web/src/components/CalendarView/useDayPanelWidth.ts @@ -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); diff --git a/web/src/components/CalendarView/useMonthMemos.ts b/web/src/components/CalendarView/useMonthMemos.ts new file mode 100644 index 00000000..441fa7c0 --- /dev/null +++ b/web/src/components/CalendarView/useMonthMemos.ts @@ -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 => { + 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 }; +}; diff --git a/web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts b/web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts index 1ebd37a8..47a7640e 100644 --- a/web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts +++ b/web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts @@ -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; } diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx index 04e7d461..2d02e4eb 100644 --- a/web/src/components/MemoView/MemoView.tsx +++ b/web/src/components/MemoView/MemoView.tsx @@ -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((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(); diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index 9b0775e5..6ccbeaa5 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -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 ? (
{leadingContent} - + {showFilters && } {initialLoader} {initialError} {emptyPlaceholder} @@ -302,7 +305,7 @@ const PagedMemoList = (props: Props) => { <> {headerContent} {leadingContent} - + {showFilters && } {initialLoader} {initialError} {displayMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))} diff --git a/web/src/components/StatisticsView/MonthNavigator.tsx b/web/src/components/StatisticsView/MonthNavigator.tsx index 83ad4c67..3914d6e7 100644 --- a/web/src/components/StatisticsView/MonthNavigator.tsx +++ b/web/src/components/StatisticsView/MonthNavigator.tsx @@ -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)); diff --git a/web/src/components/StatisticsView/StatisticsView.tsx b/web/src/components/StatisticsView/StatisticsView.tsx index 29adb84d..80d2dec0 100644 --- a/web/src/components/StatisticsView/StatisticsView.tsx +++ b/web/src/components/StatisticsView/StatisticsView.tsx @@ -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 ( diff --git a/web/src/hooks/useMediaQuery.ts b/web/src/hooks/useMediaQuery.ts index ed5e1986..98ddc5b9 100644 --- a/web/src/hooks/useMediaQuery.ts +++ b/web/src/hooks/useMediaQuery.ts @@ -1,11 +1,12 @@ import { useEffect, useState } from "react"; -type Breakpoint = "sm" | "md" | "lg"; +type Breakpoint = "sm" | "md" | "lg" | "xl"; const BREAKPOINTS: Record = { sm: 640, md: 768, lg: 1024, + xl: 1280, }; const useMediaQuery = (breakpoint: Breakpoint): boolean => { diff --git a/web/src/hooks/useMemoFilters.ts b/web/src/hooks/useMemoFilters.ts index 472b2e6f..3a3f531f 100644 --- a/web/src/hooks/useMemoFilters.ts +++ b/web/src/hooks/useMemoFilters.ts @@ -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], ); }; diff --git a/web/src/hooks/useMemoSorting.ts b/web/src/hooks/useMemoSorting.ts index 8a193513..52433bff 100644 --- a/web/src/hooks/useMemoSorting.ts +++ b/web/src/hooks/useMemoSorting.ts @@ -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; }; diff --git a/web/src/lib/calendar-utils.ts b/web/src/lib/calendar-utils.ts index e3322730..26457852 100644 --- a/web/src/lib/calendar-utils.ts +++ b/web/src/lib/calendar-utils.ts @@ -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); diff --git a/web/src/lib/memo-views.ts b/web/src/lib/memo-views.ts index 8b79eeb5..dfb967c9 100644 --- a/web/src/lib/memo-views.ts +++ b/web/src/lib/memo-views.ts @@ -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; diff --git a/web/src/lib/tag.ts b/web/src/lib/tag.ts index 0a3255ee..b90e8f20 100644 --- a/web/src/lib/tag.ts +++ b/web/src/lib/tag.ts @@ -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); diff --git a/web/src/locales/en.json b/web/src/locales/en.json index be2c43d0..033b90d2 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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", diff --git a/web/src/locales/zh-Hans.json b/web/src/locales/zh-Hans.json index bfdafcc5..6813f731 100644 --- a/web/src/locales/zh-Hans.json +++ b/web/src/locales/zh-Hans.json @@ -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 演示站", diff --git a/web/src/locales/zh-Hant.json b/web/src/locales/zh-Hant.json index b09ef40f..a025a848 100644 --- a/web/src/locales/zh-Hant.json +++ b/web/src/locales/zh-Hant.json @@ -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", diff --git a/web/src/pages/Calendar.tsx b/web/src/pages/Calendar.tsx new file mode 100644 index 00000000..b41a1f4c --- /dev/null +++ b/web/src/pages/Calendar.tsx @@ -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()); + if (!state) { + return ; + } + return ; +}; + +export default Calendar; diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 4fc277ce..6c1b6b57 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -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: , children: [ { path: Routes.ARCHIVED, element: }, + { path: CALENDAR_ROUTE_PATTERN, element: }, { element: , children: [{ path: Routes.VIEWS, element: }], diff --git a/web/src/router/routes.ts b/web/src/router/routes.ts index 90a54956..3c798a4a 100644 --- a/web/src/router/routes.ts +++ b/web/src/router/routes.ts @@ -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]; diff --git a/web/tests/app-sidebar-logo.test.tsx b/web/tests/app-sidebar-logo.test.tsx index 744594e3..3938686e 100644 --- a/web/tests/app-sidebar-logo.test.tsx +++ b/web/tests/app-sidebar-logo.test.tsx @@ -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([ diff --git a/web/tests/app-sidebar-routes.test.ts b/web/tests/app-sidebar-routes.test.ts index 94edac3d..183de7ef 100644 --- a/web/tests/app-sidebar-routes.test.ts +++ b/web/tests/app-sidebar-routes.test.ts @@ -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", diff --git a/web/tests/calendar-day-model.test.ts b/web/tests/calendar-day-model.test.ts new file mode 100644 index 00000000..4fd8b609 --- /dev/null +++ b/web/tests/calendar-day-model.test.ts @@ -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 = {}): 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"]); + }); +}); diff --git a/web/tests/calendar-grid-rows.test.ts b/web/tests/calendar-grid-rows.test.ts new file mode 100644 index 00000000..271a3d85 --- /dev/null +++ b/web/tests/calendar-grid-rows.test.ts @@ -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); + }); +}); diff --git a/web/tests/calendar-paths.test.ts b/web/tests/calendar-paths.test.ts new file mode 100644 index 00000000..acad7e07 --- /dev/null +++ b/web/tests/calendar-paths.test.ts @@ -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" }); + }); +}); diff --git a/web/tests/calendar-today-path.test.ts b/web/tests/calendar-today-path.test.ts new file mode 100644 index 00000000..c7f67f49 --- /dev/null +++ b/web/tests/calendar-today-path.test.ts @@ -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"); + }); +}); diff --git a/web/tests/memo-views.test.ts b/web/tests/memo-views.test.ts index 1c3e299c..da889016 100644 --- a/web/tests/memo-views.test.ts +++ b/web/tests/memo-views.test.ts @@ -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({ diff --git a/web/tests/router-config.test.tsx b/web/tests/router-config.test.tsx index 7f20f22b..8a6b4d58 100644 --- a/web/tests/router-config.test.tsx +++ b/web/tests/router-config.test.tsx @@ -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); } });