From 92a95a262f5878b4918d0471a26fe67653a54dd0 Mon Sep 17 00:00:00 2001 From: johnnyjoygh Date: Mon, 7 Sep 2026 21:44:59 +0800 Subject: [PATCH] chore(calendar): display daily snapshots with text and image previews --- .../CalendarView/CalendarDayCell.tsx | 125 ++++++----- .../components/CalendarView/CalendarGrid.tsx | 23 +- .../components/CalendarView/CalendarView.tsx | 4 +- web/src/components/CalendarView/dayModel.ts | 138 +++++++++--- web/tests/calendar-day-model.test.ts | 205 ++++++++++++------ web/tests/calendar-day-snapshot.test.tsx | 79 +++++++ web/tests/calendar-grid-rows.test.ts | 22 +- 7 files changed, 420 insertions(+), 176 deletions(-) create mode 100644 web/tests/calendar-day-snapshot.test.tsx diff --git a/web/src/components/CalendarView/CalendarDayCell.tsx b/web/src/components/CalendarView/CalendarDayCell.tsx index 66017b64..199d918f 100644 --- a/web/src/components/CalendarView/CalendarDayCell.tsx +++ b/web/src/components/CalendarView/CalendarDayCell.tsx @@ -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([]); 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 ( - {/* 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. */} - + {/* A fixed date slot keeps today's circle inside the cell, including on narrow screens. */} + 0 ? "font-medium text-foreground" - : "text-muted-foreground/60", + : "text-muted-foreground", )} > {day.label} + {count > 0 && ( + + {count} + + )} - {showSkeleton && ); diff --git a/web/src/components/CalendarView/CalendarGrid.tsx b/web/src/components/CalendarView/CalendarGrid.tsx index 775a3743..a10bf657 100644 --- a/web/src/components/CalendarView/CalendarGrid.tsx +++ b/web/src/components/CalendarView/CalendarGrid.tsx @@ -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 = { 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, ))} {/* 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. */}
{days.map((day, index) => ( { const isEmptyMonth = !isLoading && !error && Object.keys(model).length === 0; return ( -
+
{/* From xl the section is sticky and viewport-tall so the grid can fill it beside the panel. */} -
+