From ad6433c51a4deee53b25ddc223d77ed9612f6e56 Mon Sep 17 00:00:00 2001 From: Johnny Date: Sun, 6 Sep 2026 13:15:17 +0800 Subject: [PATCH] feat(web): calendar day panel refinements (#6280) --- .../CalendarView/CalendarDayCell.tsx | 5 +- .../CalendarView/CalendarHeader.tsx | 59 ++++---- .../components/CalendarView/CalendarView.tsx | 26 ++-- web/src/components/CalendarView/DayPanel.tsx | 135 ++++++++---------- .../components/CalendarView/MonthPicker.tsx | 41 +++--- web/src/components/CalendarView/controls.ts | 25 ++++ web/src/components/CalendarView/dayModel.ts | 12 +- .../components/CalendarView/useMonthMemos.ts | 2 +- web/src/components/MemoView/MemoView.tsx | 9 +- .../MemoView/components/MemoHeader.tsx | 4 +- web/src/components/MemoView/types.ts | 5 + .../PagedMemoList/PagedMemoList.tsx | 27 ++-- web/src/locales/en.json | 1 - web/src/locales/zh-Hans.json | 1 - web/src/locales/zh-Hant.json | 1 - web/tests/calendar-day-model.test.ts | 12 +- web/tests/calendar-today-path.test.ts | 17 ++- 17 files changed, 206 insertions(+), 176 deletions(-) create mode 100644 web/src/components/CalendarView/controls.ts diff --git a/web/src/components/CalendarView/CalendarDayCell.tsx b/web/src/components/CalendarView/CalendarDayCell.tsx index f62a5231..4881f0f5 100644 --- a/web/src/components/CalendarView/CalendarDayCell.tsx +++ b/web/src/components/CalendarView/CalendarDayCell.tsx @@ -4,6 +4,7 @@ import type { MemoTimeBasis } from "@/contexts/ViewContext"; import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import { CalendarLink } from "./CalendarLink"; +import { CALENDAR_FOCUS_CLASSES } from "./controls"; import type { CalendarDaySummary } from "./dayModel"; import { buildCalendarPath, getMonthOfDate } from "./paths"; @@ -38,7 +39,7 @@ const CORNER_CLASSES = { ss: "rounded-ss-lg", se: "rounded-se-lg", es: "rounded- export const CalendarDayCell = memo( ({ day, summary, visibleRows, pending, timeBasis, tabIndex, isLastColumn, isLastRow, corner }: CalendarDayCellProps) => { const t = useTranslate(); - const count = summary?.count ?? day.count; + const count = summary ? summary.memos.length : 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; @@ -58,7 +59,7 @@ export const CalendarDayCell = memo( !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", + CALENDAR_FOCUS_CLASSES, // 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", )} diff --git a/web/src/components/CalendarView/CalendarHeader.tsx b/web/src/components/CalendarView/CalendarHeader.tsx index 67d3c3c6..90fa4193 100644 --- a/web/src/components/CalendarView/CalendarHeader.tsx +++ b/web/src/components/CalendarView/CalendarHeader.tsx @@ -1,9 +1,10 @@ import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -import { buttonVariants } from "@/components/ui/button"; +import { useLocation, useNavigate } from "react-router-dom"; import { addMonths } from "@/lib/calendar-utils"; import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import { CalendarLink } from "./CalendarLink"; +import { CALENDAR_CONTROL_ACTIVE_CLASSES, CALENDAR_ICON_CONTROL_CLASSES, CALENDAR_TEXT_CONTROL_CLASSES } from "./controls"; import { MonthPicker } from "./MonthPicker"; import { buildCalendarPath, getMonthOfDate } from "./paths"; @@ -12,28 +13,28 @@ export interface CalendarHeaderProps { monthLabel: string; /** `YYYY-MM-DD` */ today: string; - /** `YYYY-MM-DD` of the open day, if any. */ - date?: string; + /** `YYYY-MM-DD` of the day being shown, whether the URL names it or the layout defaulted to it. */ + activeDate?: string; + /** Whether the shown day can be dismissed: a panel or sheet can, the phone's inline list cannot. */ + closable: boolean; } /** - * 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. + * Where "Today" goes: from another month it returns to this month; within this month it opens + * today, and where the day can be dismissed a second press closes it again. On phones a day is + * always shown, so Today simply keeps today selected and reads as pressed while it is. */ -export const getTodayPath = (month: string, date: string | undefined, today: string): string => { +export const getTodayPath = (month: string, activeDate: string | undefined, today: string, closable: boolean): string => { const currentMonth = getMonthOfDate(today); if (month !== currentMonth) return buildCalendarPath(currentMonth); - return date === today ? buildCalendarPath(currentMonth) : buildCalendarPath(currentMonth, today); + return closable && activeDate === 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) => { +export const CalendarHeader = ({ month, monthLabel, today, activeDate, closable }: CalendarHeaderProps) => { const t = useTranslate(); - const todayOpen = date === today; + const navigate = useNavigate(); + const { search } = useLocation(); + const todayOpen = activeDate === today; return ( // The title's text starts on the grid's text axis (border + cell padding); the month @@ -41,23 +42,29 @@ export const CalendarHeader = ({ month, monthLabel, today, date }: CalendarHeade
- - - - - + + + + + {/* Today is a toggle, so it is a button that navigates; the search keeps the filter query. */} +
); diff --git a/web/src/components/CalendarView/CalendarView.tsx b/web/src/components/CalendarView/CalendarView.tsx index 2bfc94c6..4814f68d 100644 --- a/web/src/components/CalendarView/CalendarView.tsx +++ b/web/src/components/CalendarView/CalendarView.tsx @@ -2,6 +2,7 @@ import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import { SidebarResizeHandle } from "@/components/AppSidebar"; +import MemoListError from "@/components/PagedMemoList/MemoListError"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { useAuth } from "@/contexts/AuthContext"; import { useInstance } from "@/contexts/InstanceContext"; @@ -23,6 +24,8 @@ import { buildCalendarPath, getDefaultDate } from "./paths"; import { DAY_PANEL_DEFAULT_WIDTH, DAY_PANEL_WIDTH_VAR, useDayPanelWidth } from "./useDayPanelWidth"; import { useMonthMemos } from "./useMonthMemos"; +const NO_MEMOS: Memo[] = []; + export interface CalendarViewProps { /** `YYYY-MM` */ month: string; @@ -79,7 +82,7 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => { 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({ + const { model, isLoading, error, refetch } = useMonthMemos({ month, filter: monthFilter, isRedacted, @@ -94,13 +97,12 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => { // The sheet stays mounted after its day closes so it can slide out; it keeps showing the // last open day while it does. + // The sheet stays mounted across a close so its exit animation can play; it keeps showing the + // last open day. Set during render, so no frame ever commits the previous day's content. const [sheetDate, setSheetDate] = useState(date); - useEffect(() => { - if (date) setSheetDate(date); - }, [date]); + if (date && date !== sheetDate) setSheetDate(date); + const dayMemos = (day: string | undefined) => (day && model[day]?.memos) || NO_MEMOS; - // 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) => { @@ -111,13 +113,14 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => { return () => document.removeEventListener("keydown", handleKeyDown); }, [date, xl, closeDay]); - const isEmptyMonth = !isLoading && Object.keys(model).length === 0; + // A failed month must not pass for an empty one. + const isEmptyMonth = !isLoading && !error && Object.keys(model).length === 0; return (
{/* From xl the section is sticky and viewport-tall so the grid can fill it beside the panel. */}
- + { selectedDate={activeDate} showRows={md} /> + {error && } {isEmptyMonth && md && (

{t("calendar.no-memos-in-month", { month: monthLabel })} @@ -135,7 +139,7 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => { )} {activeDate && !md && (

- +
)}
@@ -148,7 +152,7 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => { 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" > {sheetDate} - + )} @@ -173,7 +177,7 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => { edge="start" label={t("calendar.resize-panel")} /> - + )}
diff --git a/web/src/components/CalendarView/DayPanel.tsx b/web/src/components/CalendarView/DayPanel.tsx index 4a473431..f5972efc 100644 --- a/web/src/components/CalendarView/DayPanel.tsx +++ b/web/src/components/CalendarView/DayPanel.tsx @@ -1,113 +1,104 @@ -import { SquarePenIcon, XIcon } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { PlusIcon, XIcon } from "lucide-react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext"; 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 { parseLocalDate } from "@/lib/calendar-utils"; +import { cn } from "@/lib/utils"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; +import { CALENDAR_ICON_CONTROL_CLASSES, CALENDAR_TEXT_CONTROL_CLASSES } from "./controls"; 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; + /** The day's memos in time order, straight from the month model. */ + memos: Memo[]; /** 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. + * One day's memos, oldest first, rendered from the month already in hand: no second fetch, and + * a day reads as it unfolded, like the rows in its grid cell. The cards are the ordinary memo + * cards, so editing, reactions and comments behave exactly as on Home. The day ends with a + * quiet "new memo" row that expands into the editor in place, the way journals append at the + * end of a day; the memo it saves seeds its creation time to that date and lands right above it. */ -export const DayPanel = ({ date, filter, onClose }: DayPanelProps) => { +export const DayPanel = ({ date, memos, onClose }: DayPanelProps) => { const t = useTranslate(); const { i18n } = useTranslation(); const { isUserSettingsInitialized } = useAuth(); - const { memoFilter: contextFilter, selectedSpaceName } = useSpaceContext(); - const { timeBasis } = useView(); - const [composing, setComposing] = useState(false); + const { selectedSpaceName } = useSpaceContext(); + const { compactMode } = useView(); + // Remembering which day is being composed for closes the composer the moment the day + // changes, so a half-written memo can never silently move dates. + const [composingFor, setComposingFor] = useState(); + const composing = composingFor === date; - // 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}`; + const contents = useMemo(() => memos.map((memo) => memo.content), [memos]); + const userNames = useMemo( + () => Array.from(new Set(memos.flatMap((memo) => memo.reactions.map((reaction) => reaction.creator)))), + [memos], + ); 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 - } - /> + + {memos.map((memo) => ( + + ))} + + {isUserSettingsInitialized && + (composing ? ( + setComposingFor(undefined)} + onCancel={() => setComposingFor(undefined)} + /> + ) : ( + + ))}
); diff --git a/web/src/components/CalendarView/MonthPicker.tsx b/web/src/components/CalendarView/MonthPicker.tsx index bedbd872..a006fb25 100644 --- a/web/src/components/CalendarView/MonthPicker.tsx +++ b/web/src/components/CalendarView/MonthPicker.tsx @@ -1,11 +1,11 @@ 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 { CALENDAR_CONTROL_ACTIVE_CLASSES, CALENDAR_ICON_CONTROL_CLASSES, CALENDAR_TEXT_CONTROL_CLASSES } from "./controls"; import { buildCalendarPath, buildMonthKey, getMonthOfDate } from "./paths"; export interface MonthPickerProps { @@ -44,38 +44,30 @@ export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => { return ( - } + aria-label={t("calendar.select-month")} + className={cn(CALENDAR_TEXT_CONTROL_CLASSES, "text-base font-semibold tracking-tight text-foreground")} > {monthLabel}
- - {year} - + {year} + + +
{MONTHS.map((value) => { @@ -88,8 +80,9 @@ export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => { 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", + CALENDAR_TEXT_CONTROL_CLASSES, + "relative justify-center text-foreground", + isShown && ["font-medium", CALENDAR_CONTROL_ACTIVE_CLASSES], )} onClick={() => setOpen(false)} > diff --git a/web/src/components/CalendarView/controls.ts b/web/src/components/CalendarView/controls.ts new file mode 100644 index 00000000..a5a58f0a --- /dev/null +++ b/web/src/components/CalendarView/controls.ts @@ -0,0 +1,25 @@ +import { cn } from "@/lib/utils"; + +/** + * One quiet control grammar for the calendar, in the spirit of Notion and Linear: 28px tall, + * 13px text, muted ink that darkens on hover under a light wash, even padding, and the accent + * fill reserved for a pressed or current state. Raw elements carry these classes so the kit's + * variants never fight them. + */ +/** The ring-free focus treatment every calendar control and grid cell shares. */ +export const CALENDAR_FOCUS_CLASSES = + "focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring/60"; + +const CALENDAR_CONTROL_CLASSES = cn( + "inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md text-ui text-muted-foreground/70 no-underline transition-colors hover:bg-muted/60 hover:text-foreground [&_svg]:size-4 [&_svg]:shrink-0", + CALENDAR_FOCUS_CLASSES, +); + +/** A control that is only a glyph: a 28px square. */ +export const CALENDAR_ICON_CONTROL_CLASSES = cn(CALENDAR_CONTROL_CLASSES, "w-7 justify-center"); + +/** A control with a label: 8px of side padding, so the hover wash is a chip around the words. */ +export const CALENDAR_TEXT_CONTROL_CLASSES = cn(CALENDAR_CONTROL_CLASSES, "px-2"); + +/** The one state that takes a fill: the place you are, or the thing that is on. */ +export const CALENDAR_CONTROL_ACTIVE_CLASSES = "bg-accent text-accent-foreground hover:bg-accent"; diff --git a/web/src/components/CalendarView/dayModel.ts b/web/src/components/CalendarView/dayModel.ts index adee2544..2f7098e5 100644 --- a/web/src/components/CalendarView/dayModel.ts +++ b/web/src/components/CalendarView/dayModel.ts @@ -17,9 +17,9 @@ export interface CalendarDayEntry { } 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. */ + /** Every memo of the day in time order, including ones that yield no entry; the day panel renders these. */ + memos: Memo[]; + /** Grid rows in time order, capped at MAX_DAY_ENTRIES. */ entries: CalendarDayEntry[]; } @@ -56,12 +56,12 @@ export const buildCalendarMonthModel = ( 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; + const summary = (model[date] ??= { memos: [], entries: [] }); + summary.memos.push(memo); 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); + const text = firstLine(memo.snippet) || firstLine(memo.content); if (!text && !image) continue; summary.entries.push({ memoName: memo.name, diff --git a/web/src/components/CalendarView/useMonthMemos.ts b/web/src/components/CalendarView/useMonthMemos.ts index 441fa7c0..da3de07c 100644 --- a/web/src/components/CalendarView/useMonthMemos.ts +++ b/web/src/components/CalendarView/useMonthMemos.ts @@ -53,5 +53,5 @@ export const useMonthMemos = ({ month, filter, enabled = true, isRedacted }: Use const memos = query.data ?? NO_MEMOS; const model = useMemo(() => buildCalendarMonthModel(memos, timeBasis, { isRedacted }), [memos, timeBasis, isRedacted]); - return { model, isLoading: query.isLoading }; + return { model, isLoading: query.isLoading, error: query.isError ? query.error : undefined, refetch: query.refetch }; }; diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx index 2d02e4eb..af1ae51e 100644 --- a/web/src/components/MemoView/MemoView.tsx +++ b/web/src/components/MemoView/MemoView.tsx @@ -38,6 +38,7 @@ const MemoView = forwardRef((props, ref) => { parentPage: parentPageProp, parentScope: parentScopeProp, compact, + timeDisplay, showCreator, showVisibility, showPinned, @@ -167,7 +168,13 @@ const MemoView = forwardRef((props, ref) => { ref={cardRef} tabIndex={readonly ? -1 : 0} > - + diff --git a/web/src/components/MemoView/components/MemoHeader.tsx b/web/src/components/MemoView/components/MemoHeader.tsx index 7af50004..e4a1bd83 100644 --- a/web/src/components/MemoView/components/MemoHeader.tsx +++ b/web/src/components/MemoView/components/MemoHeader.tsx @@ -25,7 +25,7 @@ import MemoSpaceBadge from "./MemoSpaceBadge"; const MEMO_HEADER_ACTION_CLASSES = "size-6 shrink-0 rounded-md border-none bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50 data-popup-open:bg-accent data-popup-open:text-foreground"; -const MemoHeader: React.FC = ({ showCreator, showVisibility, showPinned, showSpace }) => { +const MemoHeader: React.FC = ({ timeDisplay = "relative", showCreator, showVisibility, showPinned, showSpace }) => { const t = useTranslate(); const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false); @@ -43,6 +43,8 @@ const MemoHeader: React.FC = ({ showCreator, showVisibility, sh const timeValue = isArchived ? ( memoDisplayTime?.toLocaleString(i18n.language) + ) : timeDisplay === "time" ? ( + memoDisplayTime?.toLocaleTimeString(i18n.language, { hour: "numeric", minute: "2-digit" }) ) : ( ); diff --git a/web/src/components/MemoView/types.ts b/web/src/components/MemoView/types.ts index b2f42a56..4857ebbe 100644 --- a/web/src/components/MemoView/types.ts +++ b/web/src/components/MemoView/types.ts @@ -1,9 +1,13 @@ import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { MemoOriginScope } from "./navigation"; +/** How the header names the memo's time: relative to now, or just the clock time for lists that already name the day. */ +export type MemoTimeDisplay = "relative" | "time"; + export interface MemoViewProps { memo: Memo; compact?: boolean; + timeDisplay?: MemoTimeDisplay; showCreator?: boolean; showVisibility?: boolean; showPinned?: boolean; @@ -20,6 +24,7 @@ export interface MemoViewHandle { } export interface MemoHeaderProps { + timeDisplay?: MemoTimeDisplay; showCreator?: boolean; showVisibility?: boolean; showPinned?: boolean; diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index 6ccbeaa5..310c8587 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -53,8 +53,6 @@ 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({ @@ -208,7 +206,6 @@ 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 }); @@ -257,12 +254,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 = showFilters && (filters.length > 0 || memoView !== undefined); + const hasFilters = filters.length > 0 || memoView !== undefined; const gridLeading = leadingContent || hasFilters || initialLoader || emptyPlaceholder || initialError ? (
{leadingContent} - {showFilters && } + {initialLoader} {initialError} {emptyPlaceholder} @@ -274,11 +271,7 @@ const PagedMemoList = (props: Props) => { <> {pageError} {isFetchingNextPage && } - {!isFetchingNextPage && (hasNextPage || displayMemoList.length > 0) && ( -
- -
- )} + {!isFetchingNextPage && (hasNextPage || displayMemoList.length > 0) && } ); @@ -305,7 +298,7 @@ const PagedMemoList = (props: Props) => { <> {headerContent} {leadingContent} - {showFilters && } + {initialLoader} {initialError} {displayMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))} @@ -342,16 +335,18 @@ const BackToTop = () => { }); }; - // Don't render if not visible + // Render nothing at all while hidden, so the list's end carries no phantom spacing. if (!isVisible) { return null; } return ( - +
+ +
); }; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 033b90d2..99bd859a 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -54,7 +54,6 @@ "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" diff --git a/web/src/locales/zh-Hans.json b/web/src/locales/zh-Hans.json index 6813f731..7f4232d0 100644 --- a/web/src/locales/zh-Hans.json +++ b/web/src/locales/zh-Hans.json @@ -54,7 +54,6 @@ "new-memo-on-day": "在这一天新建备忘录", "next-year": "下一年", "no-memos-in-month": "{{month}} 没有备忘录", - "no-memos-on-day": "这一天没有备忘录", "previous-year": "上一年", "resize-panel": "调整当天面板宽度", "select-month": "选择月份" diff --git a/web/src/locales/zh-Hant.json b/web/src/locales/zh-Hant.json index a025a848..1c976726 100644 --- a/web/src/locales/zh-Hant.json +++ b/web/src/locales/zh-Hant.json @@ -55,7 +55,6 @@ "new-memo-on-day": "在這一天新增備忘錄", "next-year": "下一年", "no-memos-in-month": "{{month}} 沒有備忘錄", - "no-memos-on-day": "這一天沒有備忘錄", "previous-year": "上一年", "resize-panel": "調整當天面板寬度", "select-month": "選擇月份" diff --git a/web/tests/calendar-day-model.test.ts b/web/tests/calendar-day-model.test.ts index 4fd8b609..575ff85b 100644 --- a/web/tests/calendar-day-model.test.ts +++ b/web/tests/calendar-day-model.test.ts @@ -24,8 +24,8 @@ describe("calendar month model", () => { "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); + expect(model["2026-08-02"].memos).toHaveLength(2); + expect(model["2026-08-03"].memos).toHaveLength(1); }); it("lists memos as rows in time order with a thumbnail when they carry an image", () => { @@ -47,13 +47,13 @@ describe("calendar month model", () => { 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, 9), { snippet: " ", 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"].memos).toHaveLength(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(); }); @@ -61,7 +61,7 @@ describe("calendar month model", () => { 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"].memos).toHaveLength(10); expect(model["2026-08-02"].entries).toHaveLength(8); }); @@ -79,7 +79,7 @@ describe("calendar month model", () => { "create_time", { isRedacted: (memo) => memo.tags.includes("private") }, ); - expect(model["2026-08-02"].count).toBe(2); + expect(model["2026-08-02"].memos).toHaveLength(2); expect(model["2026-08-02"].entries.map((entry) => entry.text)).toEqual(["public"]); }); }); diff --git a/web/tests/calendar-today-path.test.ts b/web/tests/calendar-today-path.test.ts index c7f67f49..33461273 100644 --- a/web/tests/calendar-today-path.test.ts +++ b/web/tests/calendar-today-path.test.ts @@ -5,16 +5,19 @@ 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"); + expect(getTodayPath("2026-07", undefined, today, true)).toBe("/calendar/2026/09"); + expect(getTodayPath("2026-07", "2026-07-16", today, true)).toBe("/calendar/2026/09"); + expect(getTodayPath("2026-07", "2026-07-01", today, false)).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("opens today when the current month is already shown", () => { + expect(getTodayPath("2026-09", undefined, today, true)).toBe("/calendar/2026/09/05"); + expect(getTodayPath("2026-09", "2026-09-04", today, true)).toBe("/calendar/2026/09/05"); + expect(getTodayPath("2026-09", "2026-09-04", today, false)).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"); + it("closes today's panel on a second press only where the day can be dismissed", () => { + expect(getTodayPath("2026-09", today, today, true)).toBe("/calendar/2026/09"); + expect(getTodayPath("2026-09", today, today, false)).toBe("/calendar/2026/09/05"); }); });