feat(web): add a calendar view of your memos (#6277)
This commit is contained in:
parent
50e71e2192
commit
500bb716bc
44 changed files with 1385 additions and 101 deletions
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
ArchiveIcon,
|
||||
ArrowRightIcon,
|
||||
BellIcon,
|
||||
CalendarDaysIcon,
|
||||
ChevronDownIcon,
|
||||
EarthIcon,
|
||||
FileAudioIcon,
|
||||
|
|
@ -118,7 +119,8 @@ const ProfileMode = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const CollectionSidebarContent = ({ context }: { context: MemoStatsContext }) => {
|
||||
/** The calendar is its own month view, so its sidebar narrows by view and tag but skips the heatmap. */
|
||||
const CollectionSidebarContent = ({ context, showStatistics = true }: { context: MemoStatsContext; showStatistics?: boolean }) => {
|
||||
const t = useTranslate();
|
||||
const location = useLocation();
|
||||
const currentUser = useCurrentUser();
|
||||
|
|
@ -149,9 +151,11 @@ const CollectionSidebarContent = ({ context }: { context: MemoStatsContext }) =>
|
|||
return (
|
||||
<div className={SIDEBAR_SECTION_STACK_CLASSES}>
|
||||
{context === "profile" && <ProfileMode />}
|
||||
<SidebarSection ariaLabel={t("common.statistics")}>
|
||||
<StatisticsView statisticsData={statistics} onDateSelect={() => setMobileOpen(false)} />
|
||||
</SidebarSection>
|
||||
{showStatistics && (
|
||||
<SidebarSection ariaLabel={t("common.statistics")}>
|
||||
<StatisticsView statisticsData={statistics} onDateSelect={() => setMobileOpen(false)} />
|
||||
</SidebarSection>
|
||||
)}
|
||||
{/* Every collection route narrows the same way: views (yours, so signed-in only), days, tags. */}
|
||||
{currentUser && <ViewsSection />}
|
||||
<TagsSection tagCount={tags} scope={tagStateScope} onSelect={() => setMobileOpen(false)} />
|
||||
|
|
@ -300,6 +304,7 @@ const RouteSidebarContent = () => {
|
|||
return <CollectionSidebarContent context={kind} />;
|
||||
}
|
||||
if (kind === "views") return <ViewsSection manageActive />;
|
||||
if (kind === "calendar") return <CollectionSidebarContent context="home" showStatistics={false} />;
|
||||
if (kind === "attachments") return <AttachmentsSidebarContent />;
|
||||
if (kind === "inbox") return <InboxSidebarContent />;
|
||||
if (kind === "settings") return <SettingsSidebarContent />;
|
||||
|
|
@ -383,6 +388,13 @@ const GlobalNavigation = () => {
|
|||
|
||||
const items: GlobalNavItem[] = currentUser
|
||||
? [
|
||||
{
|
||||
id: "calendar",
|
||||
label: t("common.calendar"),
|
||||
path: ROUTES.CALENDAR,
|
||||
icon: CalendarDaysIcon,
|
||||
active: routeKind === "calendar",
|
||||
},
|
||||
{
|
||||
id: "attachments",
|
||||
label: t("common.attachments"),
|
||||
|
|
|
|||
|
|
@ -22,11 +22,32 @@ interface Props {
|
|||
onWidthChange: (width: number) => void;
|
||||
/** Element carrying the width custom property, written to directly while dragging. */
|
||||
targetRef: RefObject<HTMLElement | null>;
|
||||
/** The custom property the drag previews into; defaults to the sidebar's. */
|
||||
cssVariable?: string;
|
||||
/** What a double-click restores. */
|
||||
defaultWidth?: number;
|
||||
/** Which edge of the rail the handle sits on: dragging past a start edge widens the rail leftward. */
|
||||
edge?: "start" | "end";
|
||||
/** Accessible name; defaults to the sidebar's. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetRef }: Props) => {
|
||||
const SidebarResizeHandle = ({
|
||||
width,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
onWidthChange,
|
||||
targetRef,
|
||||
cssVariable = SIDEBAR_WIDTH_VAR,
|
||||
defaultWidth = SIDEBAR_DEFAULT_WIDTH,
|
||||
edge = "end",
|
||||
label,
|
||||
}: Props) => {
|
||||
const t = useTranslate();
|
||||
const direction = useDirection();
|
||||
// Pointer and arrow deltas grow the rail when they move toward its far side: rightward for an
|
||||
// end-edge handle in LTR, leftward for a start-edge one; RTL mirrors both.
|
||||
const growSign = (edge === "end" ? 1 : -1) * (direction === "rtl" ? -1 : 1);
|
||||
// `dragging` drives the band's styling; `draggingRef` is what the handlers and the unmount
|
||||
// cleanup read, so neither depends on a state update having been flushed first.
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
|
@ -45,10 +66,10 @@ const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetR
|
|||
if (frameRef.current != null) return;
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
targetRef.current?.style.setProperty(SIDEBAR_WIDTH_VAR, `${pendingRef.current}px`);
|
||||
targetRef.current?.style.setProperty(cssVariable, `${pendingRef.current}px`);
|
||||
});
|
||||
},
|
||||
[targetRef],
|
||||
[targetRef, cssVariable],
|
||||
);
|
||||
|
||||
const stopPreview = useCallback(() => {
|
||||
|
|
@ -66,10 +87,10 @@ const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetR
|
|||
() => () => {
|
||||
stopPreview();
|
||||
if (draggingRef.current) {
|
||||
targetRef.current?.style.setProperty(SIDEBAR_WIDTH_VAR, `${originRef.current.width}px`);
|
||||
targetRef.current?.style.setProperty(cssVariable, `${originRef.current.width}px`);
|
||||
}
|
||||
},
|
||||
[stopPreview, targetRef],
|
||||
[stopPreview, targetRef, cssVariable],
|
||||
);
|
||||
|
||||
// Hold the resize cursor for the whole gesture, so it does not flicker into a text caret
|
||||
|
|
@ -102,7 +123,7 @@ const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetR
|
|||
// Track the delta rather than the raw cursor x, so grabbing anywhere in the strip
|
||||
// does not snap the rail's edge to the pointer.
|
||||
const pointerDelta = event.clientX - originRef.current.x;
|
||||
previewWidth(clamp(originRef.current.width + (direction === "rtl" ? -pointerDelta : pointerDelta)));
|
||||
previewWidth(clamp(originRef.current.width + growSign * pointerDelta));
|
||||
};
|
||||
|
||||
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
|
|
@ -117,7 +138,7 @@ const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetR
|
|||
};
|
||||
|
||||
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const horizontalStep = direction === "rtl" ? -KEYBOARD_STEP : KEYBOARD_STEP;
|
||||
const horizontalStep = growSign * KEYBOARD_STEP;
|
||||
const next =
|
||||
event.key === "ArrowLeft"
|
||||
? width - horizontalStep
|
||||
|
|
@ -137,7 +158,7 @@ const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetR
|
|||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("common.resize-sidebar")}
|
||||
aria-label={label ?? t("common.resize-sidebar")}
|
||||
aria-valuenow={width}
|
||||
aria-valuemin={minWidth}
|
||||
aria-valuemax={maxWidth}
|
||||
|
|
@ -146,9 +167,12 @@ const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetR
|
|||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onDoubleClick={() => onWidthChange(SIDEBAR_DEFAULT_WIDTH)}
|
||||
onDoubleClick={() => onWidthChange(defaultWidth)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="group absolute inset-y-0 -end-1 z-10 flex w-2 cursor-col-resize touch-none justify-center focus-visible:outline-none"
|
||||
className={cn(
|
||||
"group absolute inset-y-0 z-10 flex w-2 cursor-col-resize touch-none justify-center focus-visible:outline-none",
|
||||
edge === "end" ? "-end-1" : "-start-1",
|
||||
)}
|
||||
>
|
||||
{/* A 2px band centering to whole pixels inside the 8px strip, so it covers the rail's
|
||||
border and stays crisp at 1x instead of antialiasing across a half-pixel seam. */}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
123
web/src/components/CalendarView/CalendarDayCell.tsx
Normal file
123
web/src/components/CalendarView/CalendarDayCell.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { memo } from "react";
|
||||
import { type CalendarDayCell as CalendarDayCellData, getTooltipText } from "@/components/ActivityCalendar";
|
||||
import type { MemoTimeBasis } from "@/contexts/ViewContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { CalendarLink } from "./CalendarLink";
|
||||
import type { CalendarDaySummary } from "./dayModel";
|
||||
import { buildCalendarPath, getMonthOfDate } from "./paths";
|
||||
|
||||
/** Vertical rhythm of a cell, in px; the grid derives how many rows fit from these. */
|
||||
export const CELL_PADDING_Y = 16;
|
||||
export const CELL_NUMBER_ROW = 24;
|
||||
export const CELL_ROWS_GAP = 4;
|
||||
export const CELL_ROW_HEIGHT = 18;
|
||||
|
||||
export interface CalendarDayCellProps {
|
||||
day: CalendarDayCellData;
|
||||
summary?: CalendarDaySummary;
|
||||
/** Memo rows the cell has room for; 0 below md, where the cell only shows a dot. */
|
||||
visibleRows: number;
|
||||
/** The month's memos are still loading; `day.count` from statistics is all we know. */
|
||||
pending: boolean;
|
||||
timeBasis: MemoTimeBasis;
|
||||
tabIndex: number;
|
||||
isLastColumn: boolean;
|
||||
isLastRow: boolean;
|
||||
/** Which rounded corner of the grid this cell occupies, so fill and focus follow it. */
|
||||
corner?: "ss" | "se" | "es" | "ee";
|
||||
}
|
||||
|
||||
const CORNER_CLASSES = { ss: "rounded-ss-lg", se: "rounded-se-lg", es: "rounded-es-lg", ee: "rounded-ee-lg" } as const;
|
||||
|
||||
/**
|
||||
* One day of the month grid: the number, then one row per memo, as many as fit, and a
|
||||
* "+N more" line for the rest. A row is the memo's first line with a small thumbnail when
|
||||
* it carries an image. The whole cell is one link so its aria-label speaks for the day.
|
||||
*/
|
||||
export const CalendarDayCell = memo(
|
||||
({ day, summary, visibleRows, pending, timeBasis, tabIndex, isLastColumn, isLastRow, corner }: CalendarDayCellProps) => {
|
||||
const t = useTranslate();
|
||||
const count = summary?.count ?? day.count;
|
||||
const entries = summary?.entries ?? [];
|
||||
// The "+N more" line takes a row of its own, so an overflowing day shows one fewer memo.
|
||||
const overflows = count > visibleRows;
|
||||
const shownEntries = entries.slice(0, overflows ? Math.max(visibleRows - 1, 0) : visibleRows);
|
||||
const showMore = overflows && visibleRows >= 1;
|
||||
const showSkeleton = pending && day.isCurrentMonth && day.count > 0;
|
||||
|
||||
return (
|
||||
<CalendarLink
|
||||
to={buildCalendarPath(getMonthOfDate(day.date), day.date)}
|
||||
data-calendar-date={day.date}
|
||||
tabIndex={tabIndex}
|
||||
aria-label={getTooltipText(count, day.date, t, timeBasis)}
|
||||
aria-current={day.isSelected ? "page" : undefined}
|
||||
className={cn(
|
||||
"group/day relative flex min-h-14 min-w-0 flex-col overflow-hidden border-border/70 px-3 py-2 text-start no-underline transition-colors sm:min-h-20 md:min-h-[5.5rem]",
|
||||
!isLastColumn && "border-e",
|
||||
!isLastRow && "border-b",
|
||||
corner && CORNER_CLASSES[corner],
|
||||
"focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring/60",
|
||||
// The open day is the place you are, so it takes the fill the sidebar gives a current row.
|
||||
day.isSelected ? "bg-accent" : day.isCurrentMonth ? "bg-card hover:bg-muted/40" : "bg-muted/25 hover:bg-muted/45",
|
||||
)}
|
||||
>
|
||||
{/* Everything in the cell starts on one axis: the number is plain text, and today's circle
|
||||
is drawn behind it, centered on the digits, so it never pushes the text. */}
|
||||
<span className="flex h-6 items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"relative z-0 inline-flex h-6 items-center text-ui leading-none tabular-nums",
|
||||
day.isToday
|
||||
? "font-medium text-primary-foreground before:absolute before:left-1/2 before:top-1/2 before:-z-10 before:size-[22px] before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-primary before:content-['']"
|
||||
: !day.isCurrentMonth
|
||||
? "text-muted-foreground/40"
|
||||
: count > 0
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
{day.label}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{showSkeleton && <span aria-hidden="true" className="mt-1.5 hidden h-2 w-2/3 animate-pulse rounded bg-muted md:block" />}
|
||||
|
||||
{day.isCurrentMonth && (shownEntries.length > 0 || showMore) && (
|
||||
<ul className="mt-1 flex min-w-0 flex-col">
|
||||
{shownEntries.map((entry) => (
|
||||
<li key={entry.memoName} className="flex h-4.5 min-w-0 items-center gap-1.5 text-xs leading-4 text-foreground/75">
|
||||
{entry.thumbnailUrl && (
|
||||
<img
|
||||
src={entry.thumbnailUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="size-3.5 shrink-0 rounded-[3px] object-cover"
|
||||
// A thumbnail the server cannot produce must not leave a blank box in front of the text.
|
||||
onError={(event) => {
|
||||
event.currentTarget.hidden = true;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{entry.text}</span>
|
||||
</li>
|
||||
))}
|
||||
{showMore && (
|
||||
<li className="flex h-4.5 items-center text-xs leading-4 text-muted-foreground/70">
|
||||
{t("calendar.more-memos", { count: count - shownEntries.length })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{count > 0 && (
|
||||
<span aria-hidden="true" className={cn("mt-1 size-1.5 rounded-full bg-primary/70", day.isCurrentMonth && "md:hidden")} />
|
||||
)}
|
||||
</CalendarLink>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CalendarDayCell.displayName = "CalendarDayCell";
|
||||
126
web/src/components/CalendarView/CalendarGrid.tsx
Normal file
126
web/src/components/CalendarView/CalendarGrid.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { useDirection } from "@base-ui/react/direction-provider";
|
||||
import dayjs from "dayjs";
|
||||
import { type KeyboardEvent, useLayoutEffect, useRef, useState } from "react";
|
||||
import { type CalendarData, useMonthDays, useWeekdayLabels } from "@/components/ActivityCalendar";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { useView } from "@/contexts/ViewContext";
|
||||
import { ISO_DATE_FORMAT } from "@/lib/calendar-utils";
|
||||
import { CalendarDayCell, CELL_NUMBER_ROW, CELL_PADDING_Y, CELL_ROW_HEIGHT, CELL_ROWS_GAP } from "./CalendarDayCell";
|
||||
import type { CalendarMonthModel } from "./dayModel";
|
||||
import { getDefaultDate } from "./paths";
|
||||
|
||||
const DAYS_IN_WEEK = 7;
|
||||
|
||||
export interface CalendarGridProps {
|
||||
/** `YYYY-MM` */
|
||||
month: string;
|
||||
/** Accessible name for the whole grid, e.g. "August 2026". */
|
||||
monthLabel: string;
|
||||
/** `YYYY-MM-DD` */
|
||||
today: string;
|
||||
/** Per-day counts from statistics: instant, and the skeleton layer while memos load. */
|
||||
counts: CalendarData;
|
||||
model: CalendarMonthModel;
|
||||
pending: boolean;
|
||||
selectedDate?: string;
|
||||
/** Whether cells have room for memo rows; below md they only show a dot. */
|
||||
showRows: boolean;
|
||||
}
|
||||
|
||||
const KEY_DELTAS: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -DAYS_IN_WEEK, ArrowDown: DAYS_IN_WEEK };
|
||||
|
||||
/** Memo rows that fit under the day number in a cell of `cellHeight` px. */
|
||||
export const rowsForCellHeight = (cellHeight: number): number =>
|
||||
Math.max(0, Math.floor((cellHeight - CELL_PADDING_Y - CELL_NUMBER_ROW - CELL_ROWS_GAP) / CELL_ROW_HEIGHT));
|
||||
|
||||
const cornerOf = (index: number, total: number): "ss" | "se" | "es" | "ee" | undefined => {
|
||||
if (index === 0) return "ss";
|
||||
if (index === DAYS_IN_WEEK - 1) return "se";
|
||||
if (index === total - DAYS_IN_WEEK) return "es";
|
||||
if (index === total - 1) return "ee";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const CalendarGrid = ({ month, monthLabel, today, counts, model, pending, selectedDate, showRows }: CalendarGridProps) => {
|
||||
const direction = useDirection();
|
||||
const { generalSetting } = useInstance();
|
||||
const { timeBasis } = useView();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const cellsRef = useRef<HTMLDivElement>(null);
|
||||
const weekdayLabels = useWeekdayLabels(generalSetting.weekStartDayOffset);
|
||||
|
||||
const days = useMonthDays({ month, data: counts, weekStartDayOffset: generalSetting.weekStartDayOffset, today, selectedDate });
|
||||
const firstDate = `${month}-01`;
|
||||
const lastDate = dayjs(firstDate).endOf("month").format(ISO_DATE_FORMAT);
|
||||
// One tab stop for the grid: the open day, else today, else the first of the month.
|
||||
const focusDate = selectedDate ?? getDefaultDate(month, today);
|
||||
const rowCount = Math.ceil(days.length / DAYS_IN_WEEK);
|
||||
|
||||
// CSS sizes the rows (see the cells' grid-auto-rows); the cell height is only read back to
|
||||
// learn how many memo rows fit, and re-read whenever the grid itself resizes.
|
||||
const [visibleRows, setVisibleRows] = useState(2);
|
||||
useLayoutEffect(() => {
|
||||
const el = cellsRef.current;
|
||||
if (!el || !showRows) return;
|
||||
const apply = () => setVisibleRows(rowsForCellHeight(el.clientHeight / rowCount));
|
||||
apply();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(apply);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [rowCount, showRows]);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
const current = (event.target as HTMLElement).dataset.calendarDate;
|
||||
if (!current) return;
|
||||
let next: string | undefined;
|
||||
if (event.key in KEY_DELTAS) {
|
||||
const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight";
|
||||
const delta = KEY_DELTAS[event.key] * (horizontal && direction === "rtl" ? -1 : 1);
|
||||
next = dayjs(current).add(delta, "day").format(ISO_DATE_FORMAT);
|
||||
} else if (event.key === "Home") {
|
||||
next = firstDate;
|
||||
} else if (event.key === "End") {
|
||||
next = lastDate;
|
||||
}
|
||||
if (!next) return;
|
||||
const target = containerRef.current?.querySelector<HTMLElement>(`[data-calendar-date="${next}"]`);
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
target.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} role="group" aria-label={monthLabel} className="flex min-h-0 w-full flex-1 flex-col" onKeyDown={handleKeyDown}>
|
||||
{/* Labels start on the cell's text axis (its padding); px-px offsets the grid's border. */}
|
||||
<div className="grid grid-cols-7 px-px" aria-hidden="true">
|
||||
{weekdayLabels.map((label) => (
|
||||
<div key={label} className="flex h-8 items-end px-3 pb-1.5">
|
||||
<span className="text-2xs leading-none text-muted-foreground/60">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* From xl the grid fills the sticky section, so the rows share its height evenly with a
|
||||
floor that keeps a number and two memo rows per day on short windows. */}
|
||||
<div
|
||||
ref={cellsRef}
|
||||
className="grid grid-cols-7 overflow-hidden rounded-lg border border-border/70 bg-card xl:min-h-0 xl:flex-1 xl:[grid-auto-rows:minmax(5.5rem,1fr)]"
|
||||
>
|
||||
{days.map((day, index) => (
|
||||
<CalendarDayCell
|
||||
key={day.date}
|
||||
day={day}
|
||||
summary={model[day.date]}
|
||||
visibleRows={showRows ? visibleRows : 0}
|
||||
pending={pending}
|
||||
timeBasis={timeBasis}
|
||||
tabIndex={day.date === focusDate ? 0 : -1}
|
||||
isLastColumn={index % DAYS_IN_WEEK === DAYS_IN_WEEK - 1}
|
||||
isLastRow={index >= days.length - DAYS_IN_WEEK}
|
||||
corner={cornerOf(index, days.length)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
64
web/src/components/CalendarView/CalendarHeader.tsx
Normal file
64
web/src/components/CalendarView/CalendarHeader.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { addMonths } from "@/lib/calendar-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { CalendarLink } from "./CalendarLink";
|
||||
import { MonthPicker } from "./MonthPicker";
|
||||
import { buildCalendarPath, getMonthOfDate } from "./paths";
|
||||
|
||||
export interface CalendarHeaderProps {
|
||||
month: string;
|
||||
monthLabel: string;
|
||||
/** `YYYY-MM-DD` */
|
||||
today: string;
|
||||
/** `YYYY-MM-DD` of the open day, if any. */
|
||||
date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where "Today" goes: from another month it returns to this month; within this month it
|
||||
* toggles today's panel, so the button is also the quickest way to today's memos.
|
||||
*/
|
||||
export const getTodayPath = (month: string, date: string | undefined, today: string): string => {
|
||||
const currentMonth = getMonthOfDate(today);
|
||||
if (month !== currentMonth) return buildCalendarPath(currentMonth);
|
||||
return date === today ? buildCalendarPath(currentMonth) : buildCalendarPath(currentMonth, today);
|
||||
};
|
||||
|
||||
const NAV_LINK_CLASSES = cn(
|
||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
||||
"size-7 rounded-md text-muted-foreground/70 no-underline hover:bg-muted/60 hover:text-foreground",
|
||||
);
|
||||
|
||||
export const CalendarHeader = ({ month, monthLabel, today, date }: CalendarHeaderProps) => {
|
||||
const t = useTranslate();
|
||||
const todayOpen = date === today;
|
||||
|
||||
return (
|
||||
// The title's text starts on the grid's text axis (border + cell padding); the month
|
||||
// controls sit at the trailing edge so they never move as the title's width changes.
|
||||
<header className="flex h-9 shrink-0 items-center ps-px">
|
||||
<MonthPicker month={month} monthLabel={monthLabel} today={today} />
|
||||
<div className="ms-auto flex items-center gap-0.5">
|
||||
<CalendarLink to={buildCalendarPath(addMonths(month, -1))} aria-label={t("common.previous-month")} className={NAV_LINK_CLASSES}>
|
||||
<ChevronLeftIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
|
||||
</CalendarLink>
|
||||
<CalendarLink to={buildCalendarPath(addMonths(month, 1))} aria-label={t("common.next-month")} className={NAV_LINK_CLASSES}>
|
||||
<ChevronRightIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
|
||||
</CalendarLink>
|
||||
<CalendarLink
|
||||
to={getTodayPath(month, date, today)}
|
||||
aria-pressed={todayOpen}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"ms-1.5 no-underline",
|
||||
todayOpen && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
{t("common.today")}
|
||||
</CalendarLink>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
19
web/src/components/CalendarView/CalendarLink.tsx
Normal file
19
web/src/components/CalendarView/CalendarLink.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { forwardRef } from "react";
|
||||
import { Link, type LinkProps, useLocation } from "react-router-dom";
|
||||
|
||||
export interface CalendarLinkProps extends Omit<LinkProps, "to"> {
|
||||
/** Pathname only; the current search string rides along so view and tag filters survive. */
|
||||
to: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every link inside the calendar keeps `?filter=`: the layout drops filters on a pathname
|
||||
* change unless the query still carries them, and moving between months and days is a
|
||||
* pathname change.
|
||||
*/
|
||||
export const CalendarLink = forwardRef<HTMLAnchorElement, CalendarLinkProps>(({ to, ...props }, ref) => {
|
||||
const { search } = useLocation();
|
||||
return <Link ref={ref} to={{ pathname: to, search }} {...props} />;
|
||||
});
|
||||
|
||||
CalendarLink.displayName = "CalendarLink";
|
||||
181
web/src/components/CalendarView/CalendarView.tsx
Normal file
181
web/src/components/CalendarView/CalendarView.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { SidebarResizeHandle } from "@/components/AppSidebar";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { useSpaceContext } from "@/contexts/SpaceContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { useMemoFilters } from "@/hooks/useMemoFilters";
|
||||
import { formatMonthLabel, getToday } from "@/lib/calendar-utils";
|
||||
import { combineCELFilters } from "@/lib/cel-filter";
|
||||
import { buildMemoCreatorFilter } from "@/lib/resource-names";
|
||||
import { isMemoBlurred } from "@/lib/tag";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { CalendarGrid } from "./CalendarGrid";
|
||||
import { CalendarHeader } from "./CalendarHeader";
|
||||
import { DayPanel } from "./DayPanel";
|
||||
import { buildCalendarPath, getDefaultDate } from "./paths";
|
||||
import { DAY_PANEL_DEFAULT_WIDTH, DAY_PANEL_WIDTH_VAR, useDayPanelWidth } from "./useDayPanelWidth";
|
||||
import { useMonthMemos } from "./useMonthMemos";
|
||||
|
||||
export interface CalendarViewProps {
|
||||
/** `YYYY-MM` */
|
||||
month: string;
|
||||
/** `YYYY-MM-DD` of the open day, if any. */
|
||||
date?: string;
|
||||
}
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null): boolean =>
|
||||
target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select, [contenteditable]") !== null);
|
||||
|
||||
/**
|
||||
* The signed-in user's memos as a month, scoped like Home to the remembered collection.
|
||||
* Month and day both live in the URL; this component only reads them and renders.
|
||||
*
|
||||
* The open day has three homes. From xl it is a resizable panel beside the grid. Between md
|
||||
* and xl the grid keeps its full width and the day slides in as a sheet from the end edge.
|
||||
* Below md the grid is compact and the day's list sits under it, the way phone calendars
|
||||
* work, so a day is always shown there: today in the current month, otherwise the first of
|
||||
* the month, until the URL names one.
|
||||
*/
|
||||
export const CalendarView = ({ month, date }: CalendarViewProps) => {
|
||||
const t = useTranslate();
|
||||
const { i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { search } = useLocation();
|
||||
const user = useCurrentUser();
|
||||
const md = useMediaQuery("md");
|
||||
const xl = useMediaQuery("xl");
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const panelWidth = useDayPanelWidth();
|
||||
const { userTagsSetting, isInitialized: authInitialized, isUserSettingsInitialized } = useAuth();
|
||||
const { isInitialized: instanceInitialized } = useInstance();
|
||||
const { memoFilter: contextFilter } = useSpaceContext();
|
||||
|
||||
// The sidebar's view and tag filters narrow the month exactly as they narrow Home. They are
|
||||
// not echoed as chips here: the sidebar already shows them checked and clears them on a
|
||||
// second click, and search hands off to Home, so nothing can be active without a sidebar row.
|
||||
const viewFilter = useMemoFilters({ includeMemoViews: true, includePinned: false });
|
||||
// Statistics are already creator-scoped server-side, so leaving the creator out of their
|
||||
// filter shares the sidebar's cached query whenever no view or tag is active.
|
||||
const statsFilter = useMemo(() => combineCELFilters(contextFilter, viewFilter), [contextFilter, viewFilter]);
|
||||
const memoFilter = useMemo(() => combineCELFilters(viewFilter, user && buildMemoCreatorFilter(user.name)), [viewFilter, user]);
|
||||
const monthFilter = useMemo(() => combineCELFilters(contextFilter, memoFilter), [contextFilter, memoFilter]);
|
||||
|
||||
// Statistics draw counts before the month's memos load, and are the only signal the grid
|
||||
// has for days outside the month.
|
||||
const { statistics } = useFilteredMemoStats({
|
||||
context: "home",
|
||||
userName: user?.name,
|
||||
filter: statsFilter,
|
||||
enabled: authInitialized && instanceInitialized,
|
||||
});
|
||||
|
||||
const isRedacted = useCallback((memo: Memo) => isMemoBlurred(memo, userTagsSetting), [userTagsSetting]);
|
||||
// Snippets and thumbnails must not appear before the tag settings that decide what to blur
|
||||
// have loaded; until then the predicate would let everything through.
|
||||
const { model, isLoading } = useMonthMemos({
|
||||
month,
|
||||
filter: monthFilter,
|
||||
isRedacted,
|
||||
enabled: Boolean(user) && isUserSettingsInitialized,
|
||||
});
|
||||
|
||||
const monthLabel = useMemo(() => formatMonthLabel(month, i18n.language), [month, i18n.language]);
|
||||
const closeDay = useCallback(() => navigate({ pathname: buildCalendarPath(month), search }), [navigate, month, search]);
|
||||
|
||||
const today = getToday();
|
||||
const activeDate = date ?? (md ? undefined : getDefaultDate(month, today));
|
||||
|
||||
// The sheet stays mounted after its day closes so it can slide out; it keeps showing the
|
||||
// last open day while it does.
|
||||
const [sheetDate, setSheetDate] = useState(date);
|
||||
useEffect(() => {
|
||||
if (date) setSheetDate(date);
|
||||
}, [date]);
|
||||
|
||||
// Escape closes the side panel unless something else consumed it first: popups and the
|
||||
// editor both preventDefault on the Escape they handle, and text fields keep theirs.
|
||||
useEffect(() => {
|
||||
if (!date || !xl) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented || isEditableTarget(event.target)) return;
|
||||
closeDay();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [date, xl, closeDay]);
|
||||
|
||||
const isEmptyMonth = !isLoading && Object.keys(model).length === 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl items-start gap-6">
|
||||
{/* From xl the section is sticky and viewport-tall so the grid can fill it beside the panel. */}
|
||||
<section className="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-1 xl:sticky xl:top-6 xl:h-[calc(100dvh-3.5rem)]">
|
||||
<CalendarHeader month={month} monthLabel={monthLabel} today={today} date={date} />
|
||||
<CalendarGrid
|
||||
month={month}
|
||||
monthLabel={monthLabel}
|
||||
today={today}
|
||||
counts={statistics.activityStats}
|
||||
model={model}
|
||||
pending={isLoading}
|
||||
selectedDate={activeDate}
|
||||
showRows={md}
|
||||
/>
|
||||
{isEmptyMonth && md && (
|
||||
<p className="mt-2 shrink-0 text-center text-ui text-muted-foreground">
|
||||
{t("calendar.no-memos-in-month", { month: monthLabel })}
|
||||
</p>
|
||||
)}
|
||||
{activeDate && !md && (
|
||||
<div className="mt-4">
|
||||
<DayPanel date={activeDate} filter={memoFilter} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{sheetDate && md && !xl && (
|
||||
<Sheet open={Boolean(date)} onOpenChange={(open) => !open && closeDay()}>
|
||||
{/* The panel keeps its own close control in every tier, so the sheet's is hidden. */}
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[28rem] max-w-[90vw] gap-0 overflow-y-auto px-6 pb-8 pt-5 sm:max-w-md [&_[data-slot=sheet-close]]:hidden"
|
||||
>
|
||||
<SheetTitle className="sr-only">{sheetDate}</SheetTitle>
|
||||
<DayPanel date={sheetDate} filter={memoFilter} onClose={closeDay} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{date && xl && (
|
||||
// The panel's start edge is both its separator from the grid and the resize rail, the
|
||||
// same grammar as the app sidebar's end edge; it stretches to the row so the edge runs
|
||||
// the full height of the grid, and further when the day's list is longer.
|
||||
<aside
|
||||
ref={panelRef}
|
||||
className="relative w-[var(--calendar-day-panel-width)] shrink-0 self-stretch border-s border-border/70 ps-6"
|
||||
style={{ [DAY_PANEL_WIDTH_VAR]: `${panelWidth.width}px` } as CSSProperties}
|
||||
>
|
||||
<SidebarResizeHandle
|
||||
width={panelWidth.width}
|
||||
minWidth={panelWidth.minWidth}
|
||||
maxWidth={panelWidth.maxWidth}
|
||||
onWidthChange={panelWidth.setWidth}
|
||||
targetRef={panelRef}
|
||||
cssVariable={DAY_PANEL_WIDTH_VAR}
|
||||
defaultWidth={DAY_PANEL_DEFAULT_WIDTH}
|
||||
edge="start"
|
||||
label={t("calendar.resize-panel")}
|
||||
/>
|
||||
<DayPanel date={date} filter={memoFilter} onClose={closeDay} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
114
web/src/components/CalendarView/DayPanel.tsx
Normal file
114
web/src/components/CalendarView/DayPanel.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { SquarePenIcon, XIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import MemoEditor from "@/components/MemoEditor";
|
||||
import { deriveDefaultCreateTimeFromDate } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
|
||||
import MemoView from "@/components/MemoView";
|
||||
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { NewMemoProvider } from "@/contexts/NewMemoContext";
|
||||
import { useSpaceContext } from "@/contexts/SpaceContext";
|
||||
import { useView } from "@/contexts/ViewContext";
|
||||
import { useMemoSorting } from "@/hooks";
|
||||
import { getLocalDayTimestampRange, parseLocalDate, withTimestampRange } from "@/lib/calendar-utils";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
export interface DayPanelProps {
|
||||
/** `YYYY-MM-DD` */
|
||||
date: string;
|
||||
/** CEL fixing whose memos and which view or tags; the day's range is added here. */
|
||||
filter?: string;
|
||||
/** Present when the panel can be dismissed (the side panel); the inline phone list cannot. */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One day's memos as the ordinary paged list, so editing, reactions and comments behave
|
||||
* exactly as on Home. The composer is opt-in per day and seeds the memo's creation time to
|
||||
* that date, which is the one thing a calendar can do that the feed cannot.
|
||||
*/
|
||||
export const DayPanel = ({ date, filter, onClose }: DayPanelProps) => {
|
||||
const t = useTranslate();
|
||||
const { i18n } = useTranslation();
|
||||
const { isUserSettingsInitialized } = useAuth();
|
||||
const { memoFilter: contextFilter, selectedSpaceName } = useSpaceContext();
|
||||
const { timeBasis } = useView();
|
||||
const [composing, setComposing] = useState(false);
|
||||
|
||||
// Switching days closes the composer: a half-written memo must not silently move dates.
|
||||
useEffect(() => setComposing(false), [date]);
|
||||
|
||||
const dayFilter = withTimestampRange(filter, getLocalDayTimestampRange(date), timeBasis);
|
||||
const { listSort, orderBy } = useMemoSorting({ pinnedFirst: false, state: State.NORMAL });
|
||||
const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromDate(date), [date]);
|
||||
|
||||
const dateLabel = useMemo(
|
||||
() => parseLocalDate(date)?.toLocaleDateString(i18n.language, { weekday: "long", month: "long", day: "numeric" }) ?? date,
|
||||
[date, i18n.language],
|
||||
);
|
||||
|
||||
const editorCacheKey = `calendar-day-editor:${date}`;
|
||||
|
||||
return (
|
||||
<section aria-label={dateLabel} className="flex w-full flex-col">
|
||||
<header className="mb-3 flex items-center gap-0.5 border-b border-border/70 pb-3">
|
||||
<h2 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight text-foreground">{dateLabel}</h2>
|
||||
{isUserSettingsInitialized && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("calendar.new-memo-on-day")}
|
||||
aria-pressed={composing}
|
||||
className="size-7 text-muted-foreground/70 hover:text-foreground aria-pressed:bg-accent aria-pressed:text-accent-foreground"
|
||||
onClick={() => setComposing((value) => !value)}
|
||||
>
|
||||
<SquarePenIcon className="size-4" strokeWidth={1.8} />
|
||||
</Button>
|
||||
)}
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("calendar.close-day")}
|
||||
className="size-7 text-muted-foreground/70 hover:text-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
<XIcon className="size-4" strokeWidth={1.8} />
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
<NewMemoProvider>
|
||||
<PagedMemoList
|
||||
renderer={(memo: Memo, { compact }) => (
|
||||
<MemoView key={getMemoKey(memo)} memo={memo} showVisibility showPinned showSpace={!selectedSpaceName} compact={compact} />
|
||||
)}
|
||||
listSort={listSort}
|
||||
orderBy={orderBy}
|
||||
filter={dayFilter}
|
||||
contextFilter={contextFilter}
|
||||
emptyMessage={t("calendar.no-memos-on-day")}
|
||||
// Filters live in the sidebar on this route; see CalendarView.
|
||||
showFilters={false}
|
||||
renderLeading={() =>
|
||||
composing && isUserSettingsInitialized ? (
|
||||
<MemoEditor
|
||||
key={editorCacheKey}
|
||||
cacheKey={editorCacheKey}
|
||||
autoFocus
|
||||
className="mb-2"
|
||||
placeholder={t("editor.any-thoughts")}
|
||||
defaultCreateTime={defaultCreateTime}
|
||||
defaultSpace={selectedSpaceName}
|
||||
onConfirm={() => setComposing(false)}
|
||||
onCancel={() => setComposing(false)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</NewMemoProvider>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
107
web/src/components/CalendarView/MonthPicker.tsx
Normal file
107
web/src/components/CalendarView/MonthPicker.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { CalendarLink } from "./CalendarLink";
|
||||
import { buildCalendarPath, buildMonthKey, getMonthOfDate } from "./paths";
|
||||
|
||||
export interface MonthPickerProps {
|
||||
/** `YYYY-MM` currently shown. */
|
||||
month: string;
|
||||
monthLabel: string;
|
||||
/** `YYYY-MM-DD` */
|
||||
today: string;
|
||||
}
|
||||
|
||||
const MONTHS = Array.from({ length: 12 }, (_, index) => index + 1);
|
||||
|
||||
/**
|
||||
* The month title is the trigger; the popup is a year stepper over the twelve months, the
|
||||
* shown month filled and the current month marked. Months are links so the browser owns
|
||||
* history and middle-click.
|
||||
*/
|
||||
export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => {
|
||||
const t = useTranslate();
|
||||
const { i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const shownYear = Number(month.slice(0, 4));
|
||||
const [year, setYear] = useState(shownYear);
|
||||
const currentMonth = getMonthOfDate(today);
|
||||
|
||||
// Reopening lands on the shown month's year, not wherever the stepper was left.
|
||||
useEffect(() => {
|
||||
if (open) setYear(shownYear);
|
||||
}, [open, shownYear]);
|
||||
|
||||
const monthShortLabels = useMemo(
|
||||
() => MONTHS.map((value) => new Date(2000, value - 1, 1).toLocaleString(i18n.language, { month: "short" })),
|
||||
[i18n.language],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={t("calendar.select-month")}
|
||||
className="px-3 text-base font-semibold tracking-tight"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{monthLabel}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" sideOffset={6} className="w-60 p-2">
|
||||
<div className="mb-1 flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("calendar.previous-year")}
|
||||
className="size-7 text-muted-foreground/70 hover:text-foreground"
|
||||
onClick={() => setYear((value) => value - 1)}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
|
||||
</Button>
|
||||
<span className="flex-1 text-center text-sm font-medium tabular-nums text-foreground">{year}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("calendar.next-year")}
|
||||
className="size-7 text-muted-foreground/70 hover:text-foreground"
|
||||
onClick={() => setYear((value) => value + 1)}
|
||||
>
|
||||
<ChevronRightIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-0.5">
|
||||
{MONTHS.map((value) => {
|
||||
const key = buildMonthKey(year, value);
|
||||
const isShown = key === month;
|
||||
const isCurrent = key === currentMonth;
|
||||
return (
|
||||
<CalendarLink
|
||||
key={key}
|
||||
to={buildCalendarPath(key)}
|
||||
aria-current={isShown ? "page" : undefined}
|
||||
className={cn(
|
||||
"relative flex h-8 items-center justify-center rounded-md text-ui no-underline transition-colors",
|
||||
isShown ? "bg-accent font-medium text-accent-foreground" : "text-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{monthShortLabels[value - 1]}
|
||||
{isCurrent && (
|
||||
<span aria-hidden="true" className="absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
</CalendarLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
73
web/src/components/CalendarView/dayModel.ts
Normal file
73
web/src/components/CalendarView/dayModel.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import dayjs from "dayjs";
|
||||
import type { MemoTimeBasis } from "@/contexts/ViewContext";
|
||||
import { getMemoSortTime } from "@/hooks/useMemoSorting";
|
||||
import { ISO_DATE_FORMAT } from "@/lib/calendar-utils";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { getAttachmentThumbnailUrl, isImage } from "@/utils/attachment";
|
||||
|
||||
/** Rows a day keeps for the grid; a cell shows as many as fit and folds the rest into "+N more". */
|
||||
export const MAX_DAY_ENTRIES = 8;
|
||||
|
||||
export interface CalendarDayEntry {
|
||||
memoName: string;
|
||||
/** First non-empty line of the memo, plain text. */
|
||||
text: string;
|
||||
/** Thumbnail of the memo's first image, when it has one. */
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
export interface CalendarDaySummary {
|
||||
/** Every memo of the day, including ones that yield no entry. */
|
||||
count: number;
|
||||
/** Memos in time order, capped at MAX_DAY_ENTRIES. */
|
||||
entries: CalendarDayEntry[];
|
||||
}
|
||||
|
||||
/** Day summaries keyed by ISO date (`YYYY-MM-DD`). Days without memos are absent. */
|
||||
export type CalendarMonthModel = Record<string, CalendarDaySummary>;
|
||||
|
||||
export interface BuildCalendarMonthModelOptions {
|
||||
/** Memos that must stay hidden in the grid (blurred tags): they count, but yield no entry. */
|
||||
isRedacted?: (memo: Memo) => boolean;
|
||||
}
|
||||
|
||||
const firstLine = (text: string): string => {
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Fold a month's memos into one summary per day. Memos are walked in ascending time so the
|
||||
* rows read as the day unfolded, whatever order the API returned them in.
|
||||
*/
|
||||
export const buildCalendarMonthModel = (
|
||||
memos: Memo[],
|
||||
timeBasis: MemoTimeBasis,
|
||||
{ isRedacted }: BuildCalendarMonthModelOptions = {},
|
||||
): CalendarMonthModel => {
|
||||
const dated = memos
|
||||
.map((memo) => ({ memo, time: getMemoSortTime(memo, timeBasis) }))
|
||||
.filter((entry): entry is { memo: Memo; time: Date } => entry.time !== undefined)
|
||||
.sort((a, b) => a.time.getTime() - b.time.getTime());
|
||||
|
||||
const model: CalendarMonthModel = {};
|
||||
for (const { memo, time } of dated) {
|
||||
const date = dayjs(time).format(ISO_DATE_FORMAT);
|
||||
const summary = (model[date] ??= { count: 0, entries: [] });
|
||||
summary.count += 1;
|
||||
if (isRedacted?.(memo) || summary.entries.length >= MAX_DAY_ENTRIES) continue;
|
||||
|
||||
const image = memo.attachments.find((attachment) => isImage(attachment.type));
|
||||
const text = firstLine(memo.snippet || memo.content);
|
||||
if (!text && !image) continue;
|
||||
summary.entries.push({
|
||||
memoName: memo.name,
|
||||
text,
|
||||
thumbnailUrl: image ? getAttachmentThumbnailUrl(image) : undefined,
|
||||
});
|
||||
}
|
||||
return model;
|
||||
};
|
||||
2
web/src/components/CalendarView/index.ts
Normal file
2
web/src/components/CalendarView/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { CalendarView } from "./CalendarView";
|
||||
export { buildCalendarPath, parseCalendarParams } from "./paths";
|
||||
37
web/src/components/CalendarView/paths.ts
Normal file
37
web/src/components/CalendarView/paths.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { parseLocalDate } from "@/lib/calendar-utils";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
/** The route's params as react-router hands them over; a type alias so `useParams` can take it. */
|
||||
export type CalendarRouteParams = { year?: string; month?: string; day?: string };
|
||||
|
||||
const pad2 = (value: number) => String(value).padStart(2, "0");
|
||||
|
||||
/** `YYYY-MM` for a year and a 1-based month. */
|
||||
export const buildMonthKey = (year: number, month: number): string => `${year}-${pad2(month)}`;
|
||||
|
||||
/**
|
||||
* Resolve `/calendar/:year/:month/:day?` params. Month and day accept one or two digits so a
|
||||
* hand-typed `/calendar/2026/8` still lands; the canonical form is zero-padded. Returns
|
||||
* undefined for anything that is not a real calendar date so the page can redirect.
|
||||
*/
|
||||
export const parseCalendarParams = ({ year, month, day }: CalendarRouteParams): { month: string; date?: string } | undefined => {
|
||||
if (!year || !month || !/^\d{4}$/.test(year) || !/^\d{1,2}$/.test(month)) return undefined;
|
||||
const monthNumber = Number(month);
|
||||
if (monthNumber < 1 || monthNumber > 12) return undefined;
|
||||
const monthKey = buildMonthKey(Number(year), monthNumber);
|
||||
if (day === undefined) return { month: monthKey };
|
||||
if (!/^\d{1,2}$/.test(day)) return undefined;
|
||||
const date = `${monthKey}-${pad2(Number(day))}`;
|
||||
return parseLocalDate(date) ? { month: monthKey, date } : undefined;
|
||||
};
|
||||
|
||||
/** Canonical path for a month, or for a day inside it when `date` is given. */
|
||||
export const buildCalendarPath = (month: string, date?: string): string => {
|
||||
const base = `${ROUTES.CALENDAR}/${month.replace("-", "/")}`;
|
||||
return date ? `${base}/${date.slice(-2)}` : base;
|
||||
};
|
||||
|
||||
export const getMonthOfDate = (date: string): string => date.slice(0, 7);
|
||||
|
||||
/** The day a month shows by default: today when the month is the current one, else the first. */
|
||||
export const getDefaultDate = (month: string, today: string): string => (getMonthOfDate(today) === month ? today : `${month}-01`);
|
||||
19
web/src/components/CalendarView/useDayPanelWidth.ts
Normal file
19
web/src/components/CalendarView/useDayPanelWidth.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { type PersistedWidthConfig, usePersistedWidth } from "@/components/AppSidebar";
|
||||
|
||||
/** Custom property the panel publishes; `DayPanelAside` repeats the literal in its width class. */
|
||||
export const DAY_PANEL_WIDTH_VAR = "--calendar-day-panel-width";
|
||||
export const DAY_PANEL_DEFAULT_WIDTH = 384;
|
||||
const DAY_PANEL_MIN_WIDTH = 320;
|
||||
const DAY_PANEL_MAX_WIDTH = 640;
|
||||
/** App sidebar, page padding and the gap beside the grid, plus seven legible 72px columns. */
|
||||
const RESERVED_BESIDE_PANEL = 256 + 48 + 24 + 7 * 72;
|
||||
|
||||
const DAY_PANEL_WIDTH_CONFIG: PersistedWidthConfig = {
|
||||
storageKey: "memos-calendar-day-panel-width",
|
||||
defaultWidth: DAY_PANEL_DEFAULT_WIDTH,
|
||||
minWidth: DAY_PANEL_MIN_WIDTH,
|
||||
maxWidthFor: (viewportWidth) => Math.min(DAY_PANEL_MAX_WIDTH, viewportWidth - RESERVED_BESIDE_PANEL),
|
||||
};
|
||||
|
||||
/** Width of the inline day panel beside the month grid, persisted like the sidebar's. */
|
||||
export const useDayPanelWidth = () => usePersistedWidth(DAY_PANEL_WIDTH_CONFIG);
|
||||
57
web/src/components/CalendarView/useMonthMemos.ts
Normal file
57
web/src/components/CalendarView/useMonthMemos.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { memoServiceClient } from "@/connect";
|
||||
import { useView } from "@/contexts/ViewContext";
|
||||
import { memoKeys } from "@/hooks/useMemoQueries";
|
||||
import { getLocalMonthTimestampRange, withTimestampRange } from "@/lib/calendar-utils";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { ListMemosRequestSchema, type Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { type BuildCalendarMonthModelOptions, buildCalendarMonthModel } from "./dayModel";
|
||||
|
||||
/** A realistic personal month fits one page; the loop below still drains any that do not. */
|
||||
const MONTH_PAGE_SIZE = 500;
|
||||
const NO_MEMOS: Memo[] = [];
|
||||
|
||||
export interface UseMonthMemosOptions extends BuildCalendarMonthModelOptions {
|
||||
/** `YYYY-MM` */
|
||||
month: string;
|
||||
/** CEL clauses fixing whose memos and which collection; the month range is added here. */
|
||||
filter?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** Every page of the month: a partial month would understate counts and drop rows. */
|
||||
const listWholeMonth = async (filter: string | undefined, orderBy: string): Promise<Memo[]> => {
|
||||
const memos: Memo[] = [];
|
||||
let pageToken = "";
|
||||
do {
|
||||
const response = await memoServiceClient.listMemos(
|
||||
create(ListMemosRequestSchema, { state: State.NORMAL, filter, orderBy, pageSize: MONTH_PAGE_SIZE, pageToken }),
|
||||
);
|
||||
memos.push(...response.memos);
|
||||
pageToken = response.nextPageToken;
|
||||
} while (pageToken);
|
||||
return memos;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every memo of one month, folded into per-day summaries for the grid. Lives under the memo
|
||||
* list cache key so the create/update/delete mutations invalidate it like any other list.
|
||||
*/
|
||||
export const useMonthMemos = ({ month, filter, enabled = true, isRedacted }: UseMonthMemosOptions) => {
|
||||
const { timeBasis } = useView();
|
||||
const monthFilter = withTimestampRange(filter, getLocalMonthTimestampRange(month), timeBasis);
|
||||
const orderBy = `${timeBasis} asc`;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...memoKeys.lists(), "calendar-month", { filter: monthFilter, orderBy }],
|
||||
queryFn: () => listWholeMonth(monthFilter, orderBy),
|
||||
enabled,
|
||||
staleTime: 1000 * 60,
|
||||
});
|
||||
|
||||
const memos = query.data ?? NO_MEMOS;
|
||||
const model = useMemo(() => buildCalendarMonthModel(memos, timeBasis, { isRedacted }), [memos, timeBasis, isRedacted]);
|
||||
return { model, isLoading: query.isLoading };
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { loadMemoEditor } from "@/components/MemoEditor/loader";
|
|||
import type { MemoEditorProps } from "@/components/MemoEditor/types";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { findTagMetadata } from "@/lib/tag";
|
||||
import { isMemoBlurred } from "@/lib/tag";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { lazyWithReload } from "@/utils/lazy";
|
||||
|
|
@ -64,7 +64,7 @@ const MemoView = forwardRef<MemoViewHandle, MemoViewProps>((props, ref) => {
|
|||
|
||||
// Blur content when any tag has blur_content enabled in the current user's tag settings.
|
||||
const [showBlurredContent, setShowBlurredContent] = useState(false);
|
||||
const blurred = memoData.tags?.some((tag) => userTagsSetting && findTagMetadata(tag, userTagsSetting)?.blurContent) ?? false;
|
||||
const blurred = isMemoBlurred(memoData, userTagsSetting);
|
||||
const toggleBlurVisibility = useCallback(() => setShowBlurredContent((prev) => !prev), []);
|
||||
|
||||
const { previewState, openPreview, setPreviewOpen } = useImagePreview();
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ interface Props {
|
|||
renderHeader?: (options: { useGrid: boolean }) => ReactNode;
|
||||
/** Replaces the generic empty-state message when the route knows why the list is empty. */
|
||||
emptyMessage?: string;
|
||||
/** Off when the host already shows the active filter chips elsewhere. */
|
||||
showFilters?: boolean;
|
||||
}
|
||||
|
||||
function useAutoFetchWhenNotScrollable({
|
||||
|
|
@ -206,6 +208,7 @@ const PagedMemoList = (props: Props) => {
|
|||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [canPaginate, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const showFilters = props.showFilters ?? true;
|
||||
const leadingContent = props.renderLeading?.({ useGrid });
|
||||
const headerContent = props.renderHeader?.({ useGrid });
|
||||
|
||||
|
|
@ -254,12 +257,12 @@ const PagedMemoList = (props: Props) => {
|
|||
// empty state follows them. The newest memo also lands directly beneath them (priorityKey
|
||||
// above). Every vertical seam inside the stack uses GRID_GAP so y-spacing matches the
|
||||
// grid's x-spacing exactly.
|
||||
const hasFilters = filters.length > 0 || memoView !== undefined;
|
||||
const hasFilters = showFilters && (filters.length > 0 || memoView !== undefined);
|
||||
const gridLeading =
|
||||
leadingContent || hasFilters || initialLoader || emptyPlaceholder || initialError ? (
|
||||
<div className="flex w-full flex-col" style={{ gap: GRID_GAP }}>
|
||||
{leadingContent}
|
||||
<MemoFilters />
|
||||
{showFilters && <MemoFilters />}
|
||||
{initialLoader}
|
||||
{initialError}
|
||||
{emptyPlaceholder}
|
||||
|
|
@ -302,7 +305,7 @@ const PagedMemoList = (props: Props) => {
|
|||
<>
|
||||
{headerContent}
|
||||
{leadingContent}
|
||||
<MemoFilters className="mb-2" />
|
||||
{showFilters && <MemoFilters className="mb-2" />}
|
||||
{initialLoader}
|
||||
{initialError}
|
||||
{displayMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
type Breakpoint = "sm" | "md" | "lg";
|
||||
type Breakpoint = "sm" | "md" | "lg" | "xl";
|
||||
|
||||
const BREAKPOINTS: Record<Breakpoint, number> = {
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
};
|
||||
|
||||
const useMediaQuery = (breakpoint: Breakpoint): boolean => {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 演示站",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
15
web/src/pages/Calendar.tsx
Normal file
15
web/src/pages/Calendar.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Navigate, useParams } from "react-router-dom";
|
||||
import { buildCalendarPath, CalendarView, parseCalendarParams } from "@/components/CalendarView";
|
||||
import type { CalendarRouteParams } from "@/components/CalendarView/paths";
|
||||
import { getCurrentMonth } from "@/lib/calendar-utils";
|
||||
|
||||
/** `/calendar/:year?/:month?/:day?` — anything that is not a real date lands on this month. */
|
||||
const Calendar = () => {
|
||||
const state = parseCalendarParams(useParams<CalendarRouteParams>());
|
||||
if (!state) {
|
||||
return <Navigate to={buildCalendarPath(getCurrentMonth())} replace />;
|
||||
}
|
||||
return <CalendarView month={state.month} date={state.date} />;
|
||||
};
|
||||
|
||||
export default Calendar;
|
||||
|
|
@ -11,12 +11,13 @@ import {
|
|||
RequireGuestRoute,
|
||||
RequireInstanceInitializationRoute,
|
||||
} from "./guards";
|
||||
import { ROUTES } from "./routes";
|
||||
import { CALENDAR_ROUTE_PATTERN, ROUTES } from "./routes";
|
||||
|
||||
const AdminSignIn = lazyWithReload(() => import("@/pages/AdminSignIn"));
|
||||
const About = lazyWithReload(() => import("@/pages/About"));
|
||||
const Archived = lazyWithReload(() => import("@/pages/Archived"));
|
||||
const AuthCallback = lazyWithReload(() => import("@/pages/AuthCallback"));
|
||||
const Calendar = lazyWithReload(() => import("@/pages/Calendar"));
|
||||
const Explore = lazyWithReload(() => import("@/pages/Explore"));
|
||||
const Home = lazyWithReload(() => import("@/pages/Home"));
|
||||
const Inboxes = lazyWithReload(() => import("@/pages/Inboxes"));
|
||||
|
|
@ -89,6 +90,7 @@ export const routeConfig: RouteObject[] = [
|
|||
element: <RequireAuthRoute />,
|
||||
children: [
|
||||
{ path: Routes.ARCHIVED, element: <Archived /> },
|
||||
{ path: CALENDAR_ROUTE_PATTERN, element: <Calendar /> },
|
||||
{
|
||||
element: <RequireFullInitializationRoute />,
|
||||
children: [{ path: Routes.VIEWS, element: <MemoViews /> }],
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
85
web/tests/calendar-day-model.test.ts
Normal file
85
web/tests/calendar-day-model.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { create, type MessageInitShape } from "@bufbuild/protobuf";
|
||||
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCalendarMonthModel } from "@/components/CalendarView/dayModel";
|
||||
import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { type Memo, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
const memoAt = (date: Date, overrides: MessageInitShape<typeof MemoSchema> = {}): Memo =>
|
||||
create(MemoSchema, {
|
||||
name: `memos/${date.getTime()}`,
|
||||
createTime: timestampFromDate(date),
|
||||
updateTime: timestampFromDate(new Date(date.getTime() + 60_000)),
|
||||
content: "",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const image = (name: string) => create(AttachmentSchema, { name: `attachments/${name}`, filename: `${name}.jpg`, type: "image/jpeg" });
|
||||
const pdf = create(AttachmentSchema, { name: "attachments/doc", filename: "doc.pdf", type: "application/pdf" });
|
||||
|
||||
describe("calendar month model", () => {
|
||||
it("groups memos by local day and counts them", () => {
|
||||
const model = buildCalendarMonthModel(
|
||||
[memoAt(new Date(2026, 7, 2, 9)), memoAt(new Date(2026, 7, 2, 21)), memoAt(new Date(2026, 7, 3, 1))],
|
||||
"create_time",
|
||||
);
|
||||
expect(Object.keys(model).sort()).toEqual(["2026-08-02", "2026-08-03"]);
|
||||
expect(model["2026-08-02"].count).toBe(2);
|
||||
expect(model["2026-08-03"].count).toBe(1);
|
||||
});
|
||||
|
||||
it("lists memos as rows in time order with a thumbnail when they carry an image", () => {
|
||||
const model = buildCalendarMonthModel(
|
||||
[
|
||||
memoAt(new Date(2026, 7, 2, 21), { snippet: "late", attachments: [image("late")] }),
|
||||
memoAt(new Date(2026, 7, 2, 9), { snippet: "early", attachments: [pdf] }),
|
||||
],
|
||||
"create_time",
|
||||
);
|
||||
const entries = model["2026-08-02"].entries;
|
||||
expect(entries.map((entry) => entry.text)).toEqual(["early", "late"]);
|
||||
expect(entries[0].thumbnailUrl).toBeUndefined();
|
||||
expect(entries[1].thumbnailUrl).toContain("attachments/late/late.jpg");
|
||||
expect(entries[1].thumbnailUrl).toContain("thumbnail=true");
|
||||
});
|
||||
|
||||
it("uses first non-empty lines, keeps image-only memos, and drops blank ones", () => {
|
||||
const model = buildCalendarMonthModel(
|
||||
[
|
||||
memoAt(new Date(2026, 7, 2, 8), { snippet: " \nSecond line first\nmore" }),
|
||||
memoAt(new Date(2026, 7, 2, 9), { content: "Raw content only" }),
|
||||
memoAt(new Date(2026, 7, 2, 10), { content: " " }),
|
||||
memoAt(new Date(2026, 7, 2, 11), { content: "", attachments: [image("photo")] }),
|
||||
],
|
||||
"create_time",
|
||||
);
|
||||
expect(model["2026-08-02"].count).toBe(4);
|
||||
expect(model["2026-08-02"].entries.map((entry) => entry.text)).toEqual(["Second line first", "Raw content only", ""]);
|
||||
expect(model["2026-08-02"].entries[2].thumbnailUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it("caps entries while still counting every memo", () => {
|
||||
const memos = Array.from({ length: 10 }, (_, index) => memoAt(new Date(2026, 7, 2, index + 1), { snippet: `m${index}` }));
|
||||
const model = buildCalendarMonthModel(memos, "create_time");
|
||||
expect(model["2026-08-02"].count).toBe(10);
|
||||
expect(model["2026-08-02"].entries).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("buckets by update time when that is the basis", () => {
|
||||
const model = buildCalendarMonthModel([memoAt(new Date(2026, 7, 31, 23, 59, 30))], "update_time");
|
||||
expect(Object.keys(model)).toEqual(["2026-09-01"]);
|
||||
});
|
||||
|
||||
it("counts redacted memos without giving them a row", () => {
|
||||
const model = buildCalendarMonthModel(
|
||||
[
|
||||
memoAt(new Date(2026, 7, 2, 9), { tags: ["private"], snippet: "secret", attachments: [image("secret")] }),
|
||||
memoAt(new Date(2026, 7, 2, 10), { snippet: "public" }),
|
||||
],
|
||||
"create_time",
|
||||
{ isRedacted: (memo) => memo.tags.includes("private") },
|
||||
);
|
||||
expect(model["2026-08-02"].count).toBe(2);
|
||||
expect(model["2026-08-02"].entries.map((entry) => entry.text)).toEqual(["public"]);
|
||||
});
|
||||
});
|
||||
14
web/tests/calendar-grid-rows.test.ts
Normal file
14
web/tests/calendar-grid-rows.test.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { rowsForCellHeight } from "@/components/CalendarView/CalendarGrid";
|
||||
|
||||
describe("calendar cell rows", () => {
|
||||
it.each([
|
||||
[88, 2],
|
||||
[96, 2],
|
||||
[105, 3],
|
||||
[140, 5],
|
||||
[40, 0],
|
||||
])("fits %i px into %i memo rows", (height, rows) => {
|
||||
expect(rowsForCellHeight(height)).toBe(rows);
|
||||
});
|
||||
});
|
||||
35
web/tests/calendar-paths.test.ts
Normal file
35
web/tests/calendar-paths.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildCalendarPath, parseCalendarParams } from "@/components/CalendarView";
|
||||
|
||||
describe("calendar route params", () => {
|
||||
it("resolves a month and pads single-digit segments", () => {
|
||||
expect(parseCalendarParams({ year: "2026", month: "8" })).toEqual({ month: "2026-08" });
|
||||
expect(parseCalendarParams({ year: "2026", month: "08" })).toEqual({ month: "2026-08" });
|
||||
});
|
||||
|
||||
it("resolves a day inside the month", () => {
|
||||
expect(parseCalendarParams({ year: "2026", month: "08", day: "2" })).toEqual({ month: "2026-08", date: "2026-08-02" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}],
|
||||
[{ year: "2026" }],
|
||||
[{ year: "26", month: "08" }],
|
||||
[{ year: "2026", month: "13" }],
|
||||
[{ year: "2026", month: "0" }],
|
||||
[{ year: "2026", month: "02", day: "30" }],
|
||||
[{ year: "2026", month: "08", day: "x" }],
|
||||
])("rejects %j", (params) => {
|
||||
expect(parseCalendarParams(params)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds canonical month and day paths", () => {
|
||||
expect(buildCalendarPath("2026-08")).toBe("/calendar/2026/08");
|
||||
expect(buildCalendarPath("2026-08", "2026-08-02")).toBe("/calendar/2026/08/02");
|
||||
});
|
||||
|
||||
it("round-trips through the router params", () => {
|
||||
const [, , year, month, day] = buildCalendarPath("2026-08", "2026-08-17").split("/");
|
||||
expect(parseCalendarParams({ year, month, day })).toEqual({ month: "2026-08", date: "2026-08-17" });
|
||||
});
|
||||
});
|
||||
20
web/tests/calendar-today-path.test.ts
Normal file
20
web/tests/calendar-today-path.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { getTodayPath } from "@/components/CalendarView/CalendarHeader";
|
||||
|
||||
describe("calendar Today action", () => {
|
||||
const today = "2026-09-05";
|
||||
|
||||
it("returns to the current month from another month without opening a day", () => {
|
||||
expect(getTodayPath("2026-07", undefined, today)).toBe("/calendar/2026/09");
|
||||
expect(getTodayPath("2026-07", "2026-07-16", today)).toBe("/calendar/2026/09");
|
||||
});
|
||||
|
||||
it("opens today's panel when the current month is already shown", () => {
|
||||
expect(getTodayPath("2026-09", undefined, today)).toBe("/calendar/2026/09/05");
|
||||
expect(getTodayPath("2026-09", "2026-09-04", today)).toBe("/calendar/2026/09/05");
|
||||
});
|
||||
|
||||
it("closes today's panel when it is already open", () => {
|
||||
expect(getTodayPath("2026-09", today, today)).toBe("/calendar/2026/09");
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue