chore(calendar): display daily snapshots with text and image previews

This commit is contained in:
johnnyjoygh 2026-09-07 21:44:59 +08:00
parent 2d75439ecc
commit 92a95a262f
7 changed files with 420 additions and 176 deletions

View file

@ -1,4 +1,4 @@
import { memo } from "react";
import { memo, useState } from "react";
import { type CalendarDayCell as CalendarDayCellData, getTooltipText } from "@/components/ActivityCalendar";
import { FOCUS_VISIBLE_OUTLINE_CLASSES } from "@/components/ui/focus";
import type { MemoTimeBasis } from "@/contexts/ViewContext";
@ -8,17 +8,28 @@ 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 CalendarCellLayout {
compact: boolean;
textLines: number;
imageHeight: number;
imageCount: number;
}
/** Use the actual cell size, including width lost to the resizable day panel. */
export const layoutForCellSize = (width: number, height: number): CalendarCellLayout => {
const available = Math.max(0, height - 48);
return {
compact: width < 100,
textLines: width < 100 ? 0 : Math.min(3, Math.floor(available / 18)),
imageHeight: width < 100 || available < 32 ? 0 : available,
imageCount: width < 150 ? 1 : 2,
};
};
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;
layout: CalendarCellLayout;
/** The month's memos are still loading; `day.count` from statistics is all we know. */
pending: boolean;
timeBasis: MemoTimeBasis;
@ -31,21 +42,19 @@ export interface CalendarDayCellProps {
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.
*/
/** One day, one excerpt, and supporting photos. The entire cell opens the day's memo stream. */
export const CalendarDayCell = memo(
({ day, summary, visibleRows, pending, timeBasis, tabIndex, isLastColumn, isLastRow, corner }: CalendarDayCellProps) => {
({ day, summary, layout, pending, timeBasis, tabIndex, isLastColumn, isLastRow, corner }: CalendarDayCellProps) => {
const t = useTranslate();
const [failedImages, setFailedImages] = useState<string[]>([]);
const count = summary ? summary.memos.length : day.count;
const entries = summary?.entries ?? [];
// The "+N more" line takes a row of its own, so an overflowing day shows one fewer memo.
const overflows = count > visibleRows;
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;
const excerpt = summary?.excerpt;
const images = (summary?.images ?? []).filter((image) => !failedImages.includes(image.thumbnailUrl)).slice(0, layout.imageCount);
// Keep a usable image even when a short cell cannot also fit the excerpt.
// Text uses the space above the 32px image and 8px gap; flex gives photos any unused space.
const showImages = images.length > 0 && layout.imageHeight >= 32;
const textLines = showImages && excerpt ? Math.max(0, Math.min(2, Math.floor((layout.imageHeight - 40) / 18))) : layout.textLines;
const showSkeleton = pending && !summary && day.isCurrentMonth && day.count > 0;
return (
<CalendarLink
@ -55,7 +64,7 @@ export const CalendarDayCell = memo(
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]",
"group/day relative flex min-h-14 min-w-0 flex-col overflow-hidden border-border/70 px-1.5 py-2 sm:px-3 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],
@ -64,57 +73,71 @@ export const CalendarDayCell = memo(
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">
{/* A fixed date slot keeps today's circle inside the cell, including on narrow screens. */}
<span className={cn("flex min-h-6 gap-1", layout.compact ? "flex-col items-start" : "items-center justify-between")}>
<span
className={cn(
"relative z-0 inline-flex h-6 items-center text-ui leading-none tabular-nums",
"relative z-0 inline-flex size-6 shrink-0 items-center justify-center text-ui font-medium 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",
: "text-muted-foreground",
)}
>
{day.label}
</span>
{count > 0 && (
<span
className={cn(
"min-w-0 max-w-full whitespace-nowrap text-[10px] font-normal leading-4 tabular-nums sm:text-2xs",
layout.compact && "w-6 text-center",
day.isCurrentMonth ? "text-muted-foreground" : "text-muted-foreground/40",
)}
>
{count}
</span>
)}
</span>
{showSkeleton && <span aria-hidden="true" className="mt-1.5 hidden h-2 w-2/3 animate-pulse rounded bg-muted md:block" />}
{showSkeleton && layout.textLines > 0 && <span aria-hidden="true" className="mt-2 h-2 w-2/3 animate-pulse rounded bg-muted" />}
{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 && (
{day.isCurrentMonth && !showSkeleton && ((excerpt && textLines > 0) || showImages) && (
<span
aria-hidden="true"
className="mt-1 flex min-h-0 min-w-0 flex-col gap-2"
style={{ height: showImages ? layout.imageHeight : undefined }}
>
{excerpt && textLines > 0 && (
<span
className={cn(
"shrink-0 overflow-hidden whitespace-pre-line break-words text-xs leading-[18px] text-foreground/80 [display:-webkit-box] [-webkit-box-orient:vertical]",
excerpt.isCode && "font-mono",
)}
style={{ WebkitLineClamp: textLines }}
>
{excerpt.text}
</span>
)}
{showImages && (
<span className="flex max-h-16 min-h-8 min-w-0 flex-1 gap-1">
{images.map((image) => (
<img
src={entry.thumbnailUrl}
key={image.thumbnailUrl}
src={image.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;
}}
className="h-full min-w-0 flex-1 rounded object-cover"
style={{ maxWidth: images.length === 1 ? 112 : undefined }}
onError={() => setFailedImages((failed) => [...failed, image.thumbnailUrl])}
/>
)}
<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>
))}
</span>
)}
</ul>
)}
{count > 0 && (
<span aria-hidden="true" className={cn("mt-1 size-1.5 rounded-full bg-primary/70", day.isCurrentMonth && "md:hidden")} />
</span>
)}
</CalendarLink>
);

View file

@ -5,7 +5,7 @@ import { type CalendarData, useMonthDays, useWeekdayLabels } from "@/components/
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 { CalendarDayCell, layoutForCellSize } from "./CalendarDayCell";
import type { CalendarMonthModel } from "./dayModel";
import { getDefaultDate } from "./paths";
@ -23,16 +23,12 @@ export interface CalendarGridProps {
model: CalendarMonthModel;
pending: boolean;
selectedDate?: string;
/** Whether cells have room for memo rows; below md they only show a dot. */
/** Below md the compact calendar shows dates and counts, with the stream beneath. */
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";
@ -56,13 +52,12 @@ export const CalendarGrid = ({ month, monthLabel, today, counts, model, pending,
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);
// Observe both dimensions so previews adapt when the day panel or viewport resizes.
const [cellSize, setCellSize] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
const el = cellsRef.current;
if (!el || !showRows) return;
const apply = () => setVisibleRows(rowsForCellHeight(el.clientHeight / rowCount));
if (!el) return;
const apply = () => setCellSize({ width: el.clientWidth / DAYS_IN_WEEK, height: el.clientHeight / rowCount });
apply();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(apply);
@ -101,17 +96,17 @@ export const CalendarGrid = ({ month, monthLabel, today, counts, model, pending,
))}
</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. */}
floor that keeps the date, count, and a short excerpt visible 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)]"
className="grid auto-rows-fr grid-cols-7 overflow-hidden rounded-lg border border-border/70 bg-card md:[grid-auto-rows:9rem] 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}
layout={layoutForCellSize(cellSize.width, showRows ? cellSize.height : 0)}
pending={pending}
timeBasis={timeBasis}
tabIndex={day.date === focusDate ? 0 : -1}

View file

@ -121,9 +121,9 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => {
const isEmptyMonth = !isLoading && !error && Object.keys(model).length === 0;
return (
<div className="mx-auto flex w-full max-w-7xl items-start gap-6">
<div className="flex w-full 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)]">
<section className="flex w-full min-w-0 flex-1 flex-col gap-1 xl:sticky xl:top-6 xl:h-[calc(100dvh-3.5rem)]">
<CalendarHeader month={month} monthLabel={monthLabel} today={today} activeDate={activeDate} closable={md} />
<CalendarGrid
month={month}

View file

@ -1,48 +1,108 @@
import dayjs from "dayjs";
import type { Element, Root as HtmlRoot } from "hast";
import type { Root, RootContent } from "mdast";
import { fromMarkdown } from "mdast-util-from-markdown";
import { gfmFromMarkdown } from "mdast-util-gfm";
import { gfm } from "micromark-extension-gfm";
import { defaultUrlTransform } from "react-markdown";
import rehypeSanitize from "rehype-sanitize";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
import { visit } from "unist-util-visit";
import { SANITIZE_SCHEMA } from "@/components/MemoContent/constants";
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";
import { getAttachmentThumbnailUrl, getAttachmentUrl, isImage } from "@/utils/attachment";
import { classifyManagedAttachmentImageURL, extractAttachmentUIDFromName } from "@/utils/managed-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 {
export interface CalendarDayExcerpt {
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;
isCode: boolean;
}
export interface CalendarDayImage {
memoName: string;
thumbnailUrl: string;
}
export interface CalendarDaySummary {
/** Every memo of the day in time order, including ones that yield no entry; the day panel renders these. */
/** Every memo, including hidden ones, in chronological order for the day panel. */
memos: Memo[];
/** Grid rows in time order, capped at MAX_DAY_ENTRIES. */
entries: CalendarDayEntry[];
excerpt?: CalendarDayExcerpt;
/** At most one image per memo, from the first two eligible image-bearing memos. */
images: CalendarDayImage[];
}
/** 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. */
/** Hidden memos count, but never contribute text, filenames, or images. */
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 "";
const plainText = (node: RootContent): string => {
if (node.type === "text" || node.type === "inlineCode" || node.type === "code") return node.value;
if (node.type === "break") return "\n";
// Image alt text and link definitions are not a written excerpt. Never render raw HTML.
if (!("children" in node)) return "";
const separator = ["list", "listItem", "blockquote", "table", "tableRow"].includes(node.type) ? "\n" : "";
return node.children.map((child) => plainText(child as RootContent)).join(separator);
};
/**
* 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.
*/
const getExcerpt = (memo: Memo, tree: Root): CalendarDayExcerpt | undefined => {
const blocks = tree.children;
const readable = blocks.map((block) => ({ text: plainText(block).trim(), isCode: block.type === "code" })).filter(({ text }) => text);
const text =
readable
.map((block) => block.text)
.join("\n")
.trim() || (!memo.content.trim() ? memo.snippet.trim() : "");
if (!text) return undefined;
return { memoName: memo.name, text: text.length > 280 ? `${text.slice(0, 280).trimEnd()}…` : text, isCode: readable[0]?.isCode ?? false };
};
// Resolve reference-style images and apply the same src sanitization as the body renderer.
// Raw HTML is deliberately excluded: only Markdown images contribute to this preview.
const imageProcessor = unified()
.use(remarkRehype)
.use([[rehypeSanitize, SANITIZE_SCHEMA]]);
const imageKey = (source: string): string => {
try {
return new URL(source, window.location.href).href;
} catch {
return source;
}
};
const getImageCandidates = (memo: Memo, tree: Root): string[] => {
const candidates: string[] = [];
const attachments = memo.attachments.filter((attachment) => isImage(attachment.type));
const html = imageProcessor.runSync(tree) as HtmlRoot;
visit(html, "element", (node: Element) => {
if (node.tagName !== "img" || typeof node.properties.src !== "string") return;
const source = defaultUrlTransform(node.properties.src);
if (!source) return;
const classified = classifyManagedAttachmentImageURL(source);
if (classified.kind === "invalid") return;
const attachment = attachments.find((candidate) =>
classified.kind === "managed"
? extractAttachmentUIDFromName(candidate.name) === classified.uid
: imageKey(getAttachmentUrl(candidate)) === imageKey(source),
);
// A managed image uses the attachment's thumbnail URL, just like an attached image.
// Missing managed attachments must not be treated as external image URLs.
if (classified.kind === "managed" && !attachment) return;
candidates.push(attachment ? getAttachmentThumbnailUrl(attachment) : source);
});
candidates.push(...attachments.map(getAttachmentThumbnailUrl));
return candidates;
};
/** Fold the month into stable day snapshots, independent of API pagination/order. */
export const buildCalendarMonthModel = (
memos: Memo[],
timeBasis: MemoTimeBasis,
@ -51,23 +111,31 @@ export const buildCalendarMonthModel = (
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());
.sort((a, b) => a.time.getTime() - b.time.getTime() || a.memo.name.localeCompare(b.memo.name));
const model: CalendarMonthModel = {};
const fileFallbacks = new Map<string, CalendarDayExcerpt>();
for (const { memo, time } of dated) {
const date = dayjs(time).format(ISO_DATE_FORMAT);
const summary = (model[date] ??= { memos: [], entries: [] });
const summary = (model[date] ??= { memos: [], images: [] });
summary.memos.push(memo);
if (isRedacted?.(memo) || summary.entries.length >= MAX_DAY_ENTRIES) continue;
const image = memo.attachments.find((attachment) => isImage(attachment.type));
const text = firstLine(memo.snippet) || firstLine(memo.content);
if (!text && !image) continue;
summary.entries.push({
memoName: memo.name,
text,
thumbnailUrl: image ? getAttachmentThumbnailUrl(image) : undefined,
});
if (isRedacted?.(memo)) continue;
const tree =
!summary.excerpt || summary.images.length < 2
? fromMarkdown(memo.content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
: undefined;
if (!summary.excerpt && tree) summary.excerpt = getExcerpt(memo, tree);
if (!fileFallbacks.has(date)) {
const file = memo.attachments.find((attachment) => !isImage(attachment.type) && !attachment.motionMedia?.groupId);
if (file?.filename) fileFallbacks.set(date, { memoName: memo.name, text: file.filename, isCode: false });
}
if (summary.images.length >= 2 || !tree) continue;
const usedImages = new Set(summary.images.map((image) => imageKey(image.thumbnailUrl)));
const thumbnailUrl = getImageCandidates(memo, tree).find((source) => !usedImages.has(imageKey(source)));
if (thumbnailUrl) summary.images.push({ memoName: memo.name, thumbnailUrl });
}
for (const [date, summary] of Object.entries(model)) {
summary.excerpt ??= fileFallbacks.get(date);
}
return model;
};

View file

@ -5,81 +5,158 @@ 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 =>
const memoAt = (hour: number, overrides: MessageInitShape<typeof MemoSchema> = {}): Memo =>
create(MemoSchema, {
name: `memos/${date.getTime()}`,
createTime: timestampFromDate(date),
updateTime: timestampFromDate(new Date(date.getTime() + 60_000)),
content: "",
name: `memos/${hour}`,
createTime: timestampFromDate(new Date(2026, 7, 2, hour)),
updateTime: timestampFromDate(new Date(2026, 7, 3, hour)),
...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" });
const day = (memos: Memo[]) => buildCalendarMonthModel(memos, "create_time")["2026-08-02"];
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"].memos).toHaveLength(2);
expect(model["2026-08-03"].memos).toHaveLength(1);
describe("calendar day snapshots", () => {
it("groups by local day and keeps every memo in chronological order", () => {
const memos = Array.from({ length: 24 }, (_, i) => memoAt(i, { content: `memo ${i}` })).reverse();
const summary = day(memos);
expect(summary.memos).toHaveLength(24);
expect(summary.memos[0].name).toBe("memos/0");
expect(summary.excerpt?.text).toBe("memo 0");
});
it("lists memos as rows in time order with a thumbnail when they carry an image", () => {
const model = buildCalendarMonthModel(
it("uses memo identity to choose the same sample when timestamps tie", () => {
const a = memoAt(9, { name: "memos/a", content: "first" });
const b = memoAt(9, { name: "memos/b", content: "second" });
expect(day([b, a]).excerpt).toEqual(day([a, b]).excerpt);
expect(day([b, a]).excerpt?.text).toBe("first");
});
it("uses full Markdown content instead of a truncated snippet, without markup or image captions", () => {
const summary = day([
memoAt(9, {
content: "# Morning\n\nA **quiet** walk with [a friend](https://example.com).\n\n![caption](/photo.jpg)",
snippet: "Morning…",
}),
]);
expect(summary.excerpt?.text).toBe("Morning\nA quiet walk with a friend.");
expect(summary.excerpt?.isCode).toBe(false);
});
it("preserves readable code and checklist text without fences or active checkboxes", () => {
expect(day([memoAt(9, { content: "```js\nconst x = 1;\nconsole.log(x);\n```" })]).excerpt).toMatchObject({
text: "const x = 1;\nconsole.log(x);",
isCode: true,
});
expect(day([memoAt(9, { content: "- [x] Ship release\n- [ ] Write notes" })]).excerpt?.text).toBe("Ship release\nWrite notes");
});
it("skips empty and image-only memos when choosing text and keeps photos from distinct memos", () => {
const summary = day([
memoAt(8),
memoAt(9, { attachments: [image("a"), image("b")] }),
memoAt(10, { content: "The day's writing", attachments: [image("c")] }),
memoAt(11, { attachments: [image("d")] }),
]);
expect(summary.excerpt?.text).toBe("The day's writing");
expect(summary.images.map((entry) => entry.memoName)).toEqual(["memos/9", "memos/10"]);
expect(summary.images[0].thumbnailUrl).toContain("attachments/a/a.jpg?thumbnail=true");
expect(summary.memos).toHaveLength(4);
});
it("does not duplicate an inline attachment or a repeated image", () => {
const photo = image("a");
expect(
day([
memoAt(9, { content: "![photo](/file/attachments/a/a.jpg)", attachments: [photo] }),
memoAt(10, { attachments: [photo] }),
memoAt(11, { attachments: [image("b")] }),
]).images,
).toHaveLength(2);
});
it("prefers Markdown images over attachment order and supports local SVG references", () => {
const svg = create(AttachmentSchema, { name: "attachments/bird", filename: "bird.svg", type: "image/svg+xml" });
const result = day([memoAt(9, { content: "![bird](/file/attachments/bird)", attachments: [image("a"), svg] })]);
expect(result.images[0].thumbnailUrl).toContain("attachments/bird/bird.svg?thumbnail=true");
expect(result.images).toHaveLength(1);
});
it("extracts external and reference-style images in content order", () => {
const result = day([
memoAt(9, {
content:
"![first][PHOTO]\n\n![second](https://example.com/second.jpg)\n\n[photo]: https://example.com/first.jpg\n[photo]: https://example.com/ignored.jpg",
attachments: [image("a")],
}),
memoAt(10, { content: "![next](https://example.com/next.jpg)" }),
]);
expect(result.images.map((entry) => entry.thumbnailUrl)).toEqual(["https://example.com/first.jpg", "https://example.com/next.jpg"]);
});
it("deduplicates content and attachment images before filling the next day slot", () => {
const photo = image("a");
const result = day([
memoAt(9, { content: "![a](/file/attachments/a)", attachments: [photo] }),
memoAt(10, { content: "![a](/file/attachments/a/a.jpg)\n\n![b](https://example.com/b.jpg)", attachments: [photo] }),
]);
expect(result.images).toHaveLength(2);
expect(result.images[1].thumbnailUrl).toBe("https://example.com/b.jpg");
});
it("deduplicates equivalent external URLs", () => {
const result = day([
memoAt(9, { content: "![a](https://EXAMPLE.com/a.jpg)" }),
memoAt(10, { content: "![a](https://example.com/a.jpg)\n\n![b](https://example.com/b.jpg)" }),
]);
expect(result.images.map((entry) => entry.thumbnailUrl)).toEqual(["https://EXAMPLE.com/a.jpg", "https://example.com/b.jpg"]);
});
it("ignores code examples, raw HTML, unresolved references, and disallowed image URLs", () => {
const result = day([
memoAt(9, {
content: [
"`![code](https://example.com/code.jpg)`",
"```md\n![fenced](https://example.com/fenced.jpg)\n```",
'<img src="https://example.com/html.jpg">',
"![missing][undefined]",
"![data](data:image/png;base64,AAAA)",
"![insecure](http://example.com/insecure.jpg)",
"![script](javascript:alert)",
"![managed](/file/attachments/missing)",
"![valid](https://example.com/valid.jpg)",
].join("\n\n"),
}),
]);
expect(result.images.map((entry) => entry.thumbnailUrl)).toEqual(["https://example.com/valid.jpg"]);
});
it("does not turn an image-only memo's alt text into an excerpt", () => {
const result = day([memoAt(9, { content: "![caption](https://example.com/a.jpg)", snippet: "caption" })]);
expect(result.excerpt).toBeUndefined();
expect(result.images).toHaveLength(1);
});
it("uses a filename only when the day has no readable text", () => {
expect(day([memoAt(9, { attachments: [pdf] })]).excerpt?.text).toBe("doc.pdf");
expect(day([memoAt(9, { attachments: [pdf] }), memoAt(10, { content: "Notes" })]).excerpt?.text).toBe("Notes");
});
it("counts hidden memos without exposing text, filenames, or photos", () => {
const summary = 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), { snippet: " ", content: "Raw content only" }),
memoAt(new Date(2026, 7, 2, 10), { content: " " }),
memoAt(new Date(2026, 7, 2, 11), { content: "", attachments: [image("photo")] }),
],
"create_time",
);
expect(model["2026-08-02"].memos).toHaveLength(4);
expect(model["2026-08-02"].entries.map((entry) => entry.text)).toEqual(["Second line first", "Raw content only", ""]);
expect(model["2026-08-02"].entries[2].thumbnailUrl).toBeDefined();
});
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"].memos).toHaveLength(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" }),
memoAt(9, { tags: ["private"], content: "secret ![private](https://example.com/secret.jpg)", attachments: [pdf, image("secret")] }),
memoAt(10, { content: "visible" }),
],
"create_time",
{ isRedacted: (memo) => memo.tags.includes("private") },
);
expect(model["2026-08-02"].memos).toHaveLength(2);
expect(model["2026-08-02"].entries.map((entry) => entry.text)).toEqual(["public"]);
)["2026-08-02"];
expect(summary.memos).toHaveLength(2);
expect(summary.excerpt?.text).toBe("visible");
expect(summary.images).toEqual([]);
});
it("uses update time when selected and ignores undated memos", () => {
expect(Object.keys(buildCalendarMonthModel([memoAt(9), create(MemoSchema)], "update_time"))).toEqual(["2026-08-03"]);
});
});

View file

@ -0,0 +1,79 @@
import { create } from "@bufbuild/protobuf";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { CalendarDayCell, layoutForCellSize } from "@/components/CalendarView/CalendarDayCell";
import type { CalendarDaySummary } from "@/components/CalendarView/dayModel";
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
const summary: CalendarDaySummary = {
memos: [create(MemoSchema), create(MemoSchema)],
excerpt: { memoName: "memos/a", text: "A memorable afternoon", isCode: false },
images: [{ memoName: "memos/b", thumbnailUrl: "/photo.jpg" }],
};
const cell = (value?: CalendarDaySummary, width = 170, height = 170) =>
render(
<MemoryRouter initialEntries={["/spaces/work/calendar/2026/08?filter=test"]}>
<CalendarDayCell
day={{ date: "2026-08-07", label: 7, count: 0, isCurrentMonth: true, isToday: true, isSelected: true }}
summary={value}
layout={layoutForCellSize(width, height)}
pending={false}
timeBasis="create_time"
tabIndex={0}
isLastColumn={false}
isLastRow={false}
/>
</MemoryRouter>,
);
describe("calendar daily snapshot cell", () => {
it("separates the count from the date and keeps one day link with Space and filters", () => {
cell(summary);
const link = screen.getByRole("link");
expect(screen.getByText("2")).not.toHaveClass("rounded-full", "bg-primary");
expect(screen.getByText("7")).toHaveClass("text-primary-foreground", "size-6");
expect(screen.getByText("2")).toHaveClass("font-normal", "text-muted-foreground");
expect(link).toHaveAccessibleName("2 memos in 2026-08-07");
expect(link).toHaveAttribute("href", "/spaces/work/calendar/2026/08/07?filter=test");
expect(link).toHaveAttribute("aria-current", "page");
expect(screen.getAllByRole("link")).toHaveLength(1);
expect(screen.queryByRole("button")).toBeNull();
});
it("keeps empty days clickable without a zero count or image space", () => {
cell();
expect(screen.getByRole("link")).toHaveAttribute("tabindex", "0");
expect(screen.queryByText("0")).toBeNull();
expect(screen.queryByText(/memos/)).toBeNull();
expect(document.querySelector("img")).toBeNull();
});
it("removes failed photos while retaining the excerpt and total count", () => {
cell(summary);
fireEvent.error(document.querySelector("img")!);
expect(document.querySelector("img")).toBeNull();
expect(screen.getByText("A memorable afternoon")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
});
it("keeps a thumbnail beside a clamped excerpt in the 117px cell that previously hid it", () => {
cell(summary, 106, 117);
expect(document.querySelector("img")).toHaveAttribute("src", "/photo.jpg");
expect(screen.getByText("A memorable afternoon")).toHaveStyle({ WebkitLineClamp: "1" });
});
it("keeps the image instead of text when both cannot fit in a short cell", () => {
cell(summary, 106, 97);
expect(document.querySelector("img")).toHaveAttribute("src", "/photo.jpg");
expect(screen.queryByText("A memorable afternoon")).toBeNull();
fireEvent.error(document.querySelector("img")!);
expect(screen.getByText("A memorable afternoon")).toBeInTheDocument();
});
it("keeps image-only days visible in short cells", () => {
cell({ ...summary, excerpt: undefined }, 106, 88);
expect(document.querySelector("img")).toHaveAttribute("src", "/photo.jpg");
});
it("shows only the date and count when narrow", () => {
cell(summary, 72);
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.queryByText("A memorable afternoon")).toBeNull();
expect(document.querySelector("img")).toBeNull();
});
});

View file

@ -1,14 +1,16 @@
import { describe, expect, it } from "vitest";
import { rowsForCellHeight } from "@/components/CalendarView/CalendarGrid";
import { layoutForCellSize } from "@/components/CalendarView/CalendarDayCell";
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);
describe("calendar snapshot sizing", () => {
it("keeps narrow cells readable by omitting previews", () => {
expect(layoutForCellSize(72, 140)).toMatchObject({ compact: true, textLines: 0, imageHeight: 0 });
});
it("limits short cells to the text that fits", () => {
expect(layoutForCellSize(160, 88)).toMatchObject({ textLines: 2 });
expect(layoutForCellSize(160, 40)).toMatchObject({ textLines: 0, imageHeight: 0 });
});
it("reduces photos when the day panel takes space without shrinking the type", () => {
expect(layoutForCellSize(110, 160)).toMatchObject({ textLines: 3, imageCount: 1 });
expect(layoutForCellSize(160, 160)).toMatchObject({ textLines: 3, imageCount: 2 });
});
});