feat(web): calendar day panel refinements (#6280)

This commit is contained in:
Johnny 2026-09-06 13:15:17 +08:00 committed by GitHub
parent 500bb716bc
commit ad6433c51a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 206 additions and 176 deletions

View file

@ -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",
)}

View file

@ -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
<header className="flex h-9 shrink-0 items-center ps-px">
<MonthPicker month={month} monthLabel={monthLabel} today={today} />
<div className="ms-auto flex items-center gap-0.5">
<CalendarLink to={buildCalendarPath(addMonths(month, -1))} aria-label={t("common.previous-month")} className={NAV_LINK_CLASSES}>
<ChevronLeftIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
</CalendarLink>
<CalendarLink to={buildCalendarPath(addMonths(month, 1))} aria-label={t("common.next-month")} className={NAV_LINK_CLASSES}>
<ChevronRightIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
<CalendarLink
to={buildCalendarPath(addMonths(month, -1))}
aria-label={t("common.previous-month")}
className={CALENDAR_ICON_CONTROL_CLASSES}
>
<ChevronLeftIcon className="rtl:rotate-180" strokeWidth={1.75} />
</CalendarLink>
<CalendarLink
to={getTodayPath(month, date, today)}
to={buildCalendarPath(addMonths(month, 1))}
aria-label={t("common.next-month")}
className={CALENDAR_ICON_CONTROL_CLASSES}
>
<ChevronRightIcon className="rtl:rotate-180" strokeWidth={1.75} />
</CalendarLink>
{/* Today is a toggle, so it is a button that navigates; the search keeps the filter query. */}
<button
type="button"
aria-pressed={todayOpen}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"ms-1.5 no-underline",
todayOpen && "bg-accent text-accent-foreground",
)}
className={cn(CALENDAR_TEXT_CONTROL_CLASSES, "ms-1.5", todayOpen && CALENDAR_CONTROL_ACTIVE_CLASSES)}
onClick={() => navigate({ pathname: getTodayPath(month, activeDate, today, closable), search })}
>
{t("common.today")}
</CalendarLink>
</button>
</div>
</header>
);

View file

@ -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 (
<div className="mx-auto flex w-full max-w-7xl items-start gap-6">
{/* From xl the section is sticky and viewport-tall so the grid can fill it beside the panel. */}
<section className="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-1 xl:sticky xl:top-6 xl:h-[calc(100dvh-3.5rem)]">
<CalendarHeader month={month} monthLabel={monthLabel} today={today} date={date} />
<CalendarHeader month={month} monthLabel={monthLabel} today={today} activeDate={activeDate} closable={md} />
<CalendarGrid
month={month}
monthLabel={monthLabel}
@ -128,6 +131,7 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => {
selectedDate={activeDate}
showRows={md}
/>
{error && <MemoListError error={error} onRetry={refetch} />}
{isEmptyMonth && md && (
<p className="mt-2 shrink-0 text-center text-ui text-muted-foreground">
{t("calendar.no-memos-in-month", { month: monthLabel })}
@ -135,7 +139,7 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => {
)}
{activeDate && !md && (
<div className="mt-4">
<DayPanel date={activeDate} filter={memoFilter} />
<DayPanel date={activeDate} memos={dayMemos(activeDate)} />
</div>
)}
</section>
@ -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"
>
<SheetTitle className="sr-only">{sheetDate}</SheetTitle>
<DayPanel date={sheetDate} filter={memoFilter} onClose={closeDay} />
<DayPanel date={sheetDate} memos={dayMemos(sheetDate)} onClose={closeDay} />
</SheetContent>
</Sheet>
)}
@ -173,7 +177,7 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => {
edge="start"
label={t("calendar.resize-panel")}
/>
<DayPanel date={date} filter={memoFilter} onClose={closeDay} />
<DayPanel date={date} memos={dayMemos(date)} onClose={closeDay} />
</aside>
)}
</div>

View file

@ -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<string>();
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 (
<section aria-label={dateLabel} className="flex w-full flex-col">
<header className="mb-3 flex items-center gap-0.5 border-b border-border/70 pb-3">
<h2 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight text-foreground">{dateLabel}</h2>
{isUserSettingsInitialized && (
<Button
variant="ghost"
size="icon-sm"
aria-label={t("calendar.new-memo-on-day")}
aria-pressed={composing}
className="size-7 text-muted-foreground/70 hover:text-foreground aria-pressed:bg-accent aria-pressed:text-accent-foreground"
onClick={() => setComposing((value) => !value)}
>
<SquarePenIcon className="size-4" strokeWidth={1.8} />
</Button>
)}
{onClose && (
<Button
variant="ghost"
size="icon-sm"
aria-label={t("calendar.close-day")}
className="size-7 text-muted-foreground/70 hover:text-foreground"
onClick={onClose}
>
<XIcon className="size-4" strokeWidth={1.8} />
</Button>
<button type="button" aria-label={t("calendar.close-day")} className={CALENDAR_ICON_CONTROL_CLASSES} onClick={onClose}>
<XIcon strokeWidth={1.8} />
</button>
)}
</header>
<NewMemoProvider>
<PagedMemoList
renderer={(memo: Memo, { compact }) => (
<MemoView key={getMemoKey(memo)} memo={memo} showVisibility showPinned showSpace={!selectedSpaceName} compact={compact} />
)}
listSort={listSort}
orderBy={orderBy}
filter={dayFilter}
contextFilter={contextFilter}
emptyMessage={t("calendar.no-memos-on-day")}
// Filters live in the sidebar on this route; see CalendarView.
showFilters={false}
renderLeading={() =>
composing && isUserSettingsInitialized ? (
<MemoEditor
key={editorCacheKey}
cacheKey={editorCacheKey}
autoFocus
className="mb-2"
placeholder={t("editor.any-thoughts")}
defaultCreateTime={defaultCreateTime}
defaultSpace={selectedSpaceName}
onConfirm={() => setComposing(false)}
onCancel={() => setComposing(false)}
/>
) : null
}
/>
<MentionResolutionProvider contents={contents} userNames={userNames}>
{memos.map((memo) => (
<MemoView
key={memo.name}
memo={memo}
timeDisplay="time"
showVisibility
showPinned
showSpace={!selectedSpaceName}
compact={compactMode}
/>
))}
</MentionResolutionProvider>
{isUserSettingsInitialized &&
(composing ? (
<MemoEditor
cacheKey={`calendar-day-editor:${date}`}
autoFocus
placeholder={t("editor.any-thoughts")}
defaultCreateTime={defaultCreateTime}
defaultSpace={selectedSpaceName}
onConfirm={() => setComposingFor(undefined)}
onCancel={() => setComposingFor(undefined)}
/>
) : (
<button
type="button"
// The cards' own bottom margin sets the gap; the chip's edge lines up with theirs.
className={cn(CALENDAR_TEXT_CONTROL_CLASSES, "self-start")}
onClick={() => setComposingFor(date)}
>
<PlusIcon strokeWidth={1.8} />
<span>{t("calendar.new-memo-on-day")}</span>
</button>
))}
</NewMemoProvider>
</section>
);

View file

@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
variant="ghost"
size="sm"
aria-label={t("calendar.select-month")}
className="px-3 text-base font-semibold tracking-tight"
/>
}
aria-label={t("calendar.select-month")}
className={cn(CALENDAR_TEXT_CONTROL_CLASSES, "text-base font-semibold tracking-tight text-foreground")}
>
{monthLabel}
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-60 p-2">
<div className="mb-1 flex items-center">
<Button
variant="ghost"
size="icon-sm"
<button
type="button"
aria-label={t("calendar.previous-year")}
className="size-7 text-muted-foreground/70 hover:text-foreground"
className={CALENDAR_ICON_CONTROL_CLASSES}
onClick={() => setYear((value) => value - 1)}
>
<ChevronLeftIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
</Button>
<span className="flex-1 text-center text-sm font-medium tabular-nums text-foreground">{year}</span>
<Button
variant="ghost"
size="icon-sm"
<ChevronLeftIcon className="rtl:rotate-180" strokeWidth={1.75} />
</button>
<span className="flex-1 text-center text-ui font-medium tabular-nums text-foreground">{year}</span>
<button
type="button"
aria-label={t("calendar.next-year")}
className="size-7 text-muted-foreground/70 hover:text-foreground"
className={CALENDAR_ICON_CONTROL_CLASSES}
onClick={() => setYear((value) => value + 1)}
>
<ChevronRightIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
</Button>
<ChevronRightIcon className="rtl:rotate-180" strokeWidth={1.75} />
</button>
</div>
<div className="grid grid-cols-3 gap-0.5">
{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)}
>

View file

@ -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";

View file

@ -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,

View file

@ -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 };
};

View file

@ -38,6 +38,7 @@ const MemoView = forwardRef<MemoViewHandle, MemoViewProps>((props, ref) => {
parentPage: parentPageProp,
parentScope: parentScopeProp,
compact,
timeDisplay,
showCreator,
showVisibility,
showPinned,
@ -167,7 +168,13 @@ const MemoView = forwardRef<MemoViewHandle, MemoViewProps>((props, ref) => {
ref={cardRef}
tabIndex={readonly ? -1 : 0}
>
<MemoHeader showCreator={showCreator} showVisibility={showVisibility} showPinned={showPinned} showSpace={showSpace} />
<MemoHeader
timeDisplay={timeDisplay}
showCreator={showCreator}
showVisibility={showVisibility}
showPinned={showPinned}
showSpace={showSpace}
/>
<MemoBody compact={compact} />

View file

@ -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<MemoHeaderProps> = ({ showCreator, showVisibility, showPinned, showSpace }) => {
const MemoHeader: React.FC<MemoHeaderProps> = ({ timeDisplay = "relative", showCreator, showVisibility, showPinned, showSpace }) => {
const t = useTranslate();
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
@ -43,6 +43,8 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
const timeValue = isArchived ? (
memoDisplayTime?.toLocaleString(i18n.language)
) : timeDisplay === "time" ? (
memoDisplayTime?.toLocaleTimeString(i18n.language, { hour: "numeric", minute: "2-digit" })
) : (
<RelativeTime date={memoDisplayTime} format={relativeTimeFormat} />
);

View file

@ -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;

View file

@ -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 ? (
<div className="flex w-full flex-col" style={{ gap: GRID_GAP }}>
{leadingContent}
{showFilters && <MemoFilters />}
<MemoFilters />
{initialLoader}
{initialError}
{emptyPlaceholder}
@ -274,11 +271,7 @@ const PagedMemoList = (props: Props) => {
<>
{pageError}
{isFetchingNextPage && <Loader />}
{!isFetchingNextPage && (hasNextPage || displayMemoList.length > 0) && (
<div className="w-full opacity-70 flex flex-row justify-center items-center my-4">
<BackToTop />
</div>
)}
{!isFetchingNextPage && (hasNextPage || displayMemoList.length > 0) && <BackToTop />}
</>
);
@ -305,7 +298,7 @@ const PagedMemoList = (props: Props) => {
<>
{headerContent}
{leadingContent}
{showFilters && <MemoFilters className="mb-2" />}
<MemoFilters className="mb-2" />
{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 (
<Button variant="ghost" onClick={scrollToTop}>
{t("router.back-to-top")}
<ArrowUpIcon className="ml-1 w-4 h-auto" />
</Button>
<div className="my-4 flex w-full flex-row items-center justify-center opacity-70">
<Button variant="ghost" onClick={scrollToTop}>
{t("router.back-to-top")}
<ArrowUpIcon className="ml-1 w-4 h-auto" />
</Button>
</div>
);
};

View file

@ -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"

View file

@ -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": "选择月份"

View file

@ -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": "選擇月份"

View file

@ -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"]);
});
});

View file

@ -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");
});
});