diff --git a/docs/superpowers/plans/2026-05-02-calendar-date-prefill.md b/docs/superpowers/plans/2026-05-02-calendar-date-prefill.md new file mode 100644 index 00000000..88e8fb27 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-calendar-date-prefill.md @@ -0,0 +1,738 @@ +# Calendar-Date Memo Prefill — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When the user picks a date in the activity calendar, the home memo editor pre-fills its `createTime`/`updateTime` to that date and exposes the existing `TimestampPopover` so the user can adjust before saving. Empty calendar dates also become clickable. + +**Architecture:** Frontend-only. A new pure helper derives a `Date` from the `displayTime` filter; `PagedMemoList` reads `MemoFilterContext` and passes the derived `Date` into `MemoEditor` via a new `defaultCreateTime` prop; `MemoEditor` seeds its reducer state from the prop, renders the popover when the prop is set in create mode, and re-syncs on prop change. `CalendarCell` drops the `count > 0` gate on click handling. + +**Tech Stack:** React 18 + TypeScript, Vite 7, Vitest + jsdom + @testing-library/react, Tailwind v4, react-router. State via React Context + `useReducer`. + +**Spec:** `docs/superpowers/specs/2026-05-02-calendar-date-prefill-design.md` + +--- + +## File map + +**Created** + +- `web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts` — pure helper `(filters, now?) => Date | undefined`. +- `web/tests/derive-default-create-time.test.ts` — Vitest unit tests for the helper. +- `web/tests/calendar-cell-empty-clickable.test.tsx` — RTL test that count=0 in-month cells are clickable. + +**Modified** + +- `web/src/components/ActivityCalendar/CalendarCell.tsx` — drop `day.count > 0` gate from click/interactivity/tooltip. +- `web/src/components/MemoEditor/types/components.ts` — add `defaultCreateTime?: Date` to `MemoEditorProps`. +- `web/src/components/MemoEditor/hooks/useMemoInit.ts` — accept optional `defaultCreateTime`; in create mode, dispatch `SET_TIMESTAMPS` to seed `{ createTime, updateTime }`. +- `web/src/components/MemoEditor/index.tsx` — accept the new prop, pass it to `useMemoInit`, add a `useEffect` that re-syncs timestamps when the prop changes in create mode, render `TimestampPopover` when `(!memo && state.timestamps.createTime)`. +- `web/src/components/PagedMemoList/PagedMemoList.tsx` — read `useMemoFilterContext`, derive `defaultCreateTime`, pass to `` at line ~155. + +--- + +## Task 1: Pure helper `deriveDefaultCreateTimeFromFilters` + +**Files:** +- Create: `web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts` +- Test: `web/tests/derive-default-create-time.test.ts` + +The helper takes the `filters` array from `MemoFilterContext` plus an injectable `now`, finds any `displayTime` filter (value format `YYYY-MM-DD`, local-date — produced by `useDateFilterNavigation` which forwards the `dayjs().format("YYYY-MM-DD")` string from `CalendarDayCell.date`), and returns a `Date` of `selected_date + now's hh:mm:ss`. Returns `undefined` if no `displayTime` filter or the value is malformed. + +- [ ] **Step 1: Write the failing tests** + +Create `web/tests/derive-default-create-time.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime"; +import type { MemoFilter } from "@/contexts/MemoFilterContext"; + +describe("deriveDefaultCreateTimeFromFilters", () => { + const now = new Date(2026, 4, 2, 14, 32, 10); // 2026-05-02 14:32:10 local + + it("returns undefined when no filters are set", () => { + expect(deriveDefaultCreateTimeFromFilters([], now)).toBeUndefined(); + }); + + it("returns undefined when no displayTime filter is present", () => { + const filters: MemoFilter[] = [ + { factor: "tagSearch", value: "work" }, + { factor: "pinned", value: "true" }, + ]; + expect(deriveDefaultCreateTimeFromFilters(filters, now)).toBeUndefined(); + }); + + it("merges the displayTime date with the current local hh:mm:ss", () => { + const filters: MemoFilter[] = [{ factor: "displayTime", value: "2025-05-01" }]; + const result = deriveDefaultCreateTimeFromFilters(filters, now); + expect(result).toBeDefined(); + expect(result!.getFullYear()).toBe(2025); + expect(result!.getMonth()).toBe(4); // May (0-indexed) + expect(result!.getDate()).toBe(1); + expect(result!.getHours()).toBe(14); + expect(result!.getMinutes()).toBe(32); + expect(result!.getSeconds()).toBe(10); + }); + + it("ignores extra non-displayTime filters", () => { + const filters: MemoFilter[] = [ + { factor: "tagSearch", value: "work" }, + { factor: "displayTime", value: "2025-05-01" }, + { factor: "pinned", value: "true" }, + ]; + const result = deriveDefaultCreateTimeFromFilters(filters, now); + expect(result?.getDate()).toBe(1); + }); + + it("returns undefined for a malformed YYYY-MM-DD value", () => { + const cases: MemoFilter[][] = [ + [{ factor: "displayTime", value: "not-a-date" }], + [{ factor: "displayTime", value: "2025-13-40" }], + [{ factor: "displayTime", value: "" }], + [{ factor: "displayTime", value: "2025-5-1" }], // single-digit month/day + ]; + for (const filters of cases) { + expect(deriveDefaultCreateTimeFromFilters(filters, now)).toBeUndefined(); + } + }); + + it("uses real `new Date()` when `now` is omitted", () => { + const filters: MemoFilter[] = [{ factor: "displayTime", value: "2025-05-01" }]; + const before = new Date(); + const result = deriveDefaultCreateTimeFromFilters(filters); + const after = new Date(); + expect(result).toBeDefined(); + // Time-of-day should fall between before and after (within 1s tolerance). + const resultTimeOnly = result!.getHours() * 3600 + result!.getMinutes() * 60 + result!.getSeconds(); + const beforeTimeOnly = before.getHours() * 3600 + before.getMinutes() * 60 + before.getSeconds(); + const afterTimeOnly = after.getHours() * 3600 + after.getMinutes() * 60 + after.getSeconds(); + // Handle midnight rollover by allowing any value if before > after. + if (beforeTimeOnly <= afterTimeOnly) { + expect(resultTimeOnly).toBeGreaterThanOrEqual(beforeTimeOnly); + expect(resultTimeOnly).toBeLessThanOrEqual(afterTimeOnly); + } + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd web && pnpm test derive-default-create-time` +Expected: FAIL — module `@/components/MemoEditor/utils/deriveDefaultCreateTime` does not exist. + +- [ ] **Step 3: Implement the helper** + +Create `web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts`: + +```ts +import type { MemoFilter } from "@/contexts/MemoFilterContext"; + +const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/; + +/** + * Derive a default `createTime` for a new memo from the active memo filters. + * If a `displayTime:YYYY-MM-DD` filter is present, returns that local date + * combined with `now`'s wall-clock hh:mm:ss. Returns undefined otherwise or + * when the filter value is malformed. + */ +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; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd web && pnpm test derive-default-create-time` +Expected: PASS — all 6 cases. + +- [ ] **Step 5: Run lint** + +Run: `cd web && pnpm lint` +Expected: PASS (TypeScript + Biome happy). + +- [ ] **Step 6: Commit** + +```bash +git add web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts web/tests/derive-default-create-time.test.ts +git commit -m "feat(memo-editor): add deriveDefaultCreateTimeFromFilters helper" +``` + +--- + +## Task 2: Make empty calendar cells clickable + +**Files:** +- Modify: `web/src/components/ActivityCalendar/CalendarCell.tsx` +- Test: `web/tests/calendar-cell-empty-clickable.test.tsx` + +`CalendarCell` currently gates `handleClick`, `isInteractive`, and `shouldShowTooltip` on `day.count > 0`. Drop those gates so any in-month cell is clickable when `onClick` is provided. Out-of-month cells (early-returned at line ~38) stay unclickable. + +- [ ] **Step 1: Write the failing test** + +Create `web/tests/calendar-cell-empty-clickable.test.tsx`: + +```tsx +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { CalendarCell } from "@/components/ActivityCalendar/CalendarCell"; +import type { CalendarDayCell } from "@/components/ActivityCalendar/types"; + +const makeDay = (overrides: Partial = {}): CalendarDayCell => ({ + date: "2025-05-01", + label: "1", + count: 0, + isCurrentMonth: true, + isToday: false, + isSelected: false, + ...overrides, +}); + +describe("CalendarCell empty-day clickability", () => { + it("fires onClick for an in-month day with count=0", () => { + const onClick = vi.fn(); + render(); + + const button = screen.getByRole("button", { name: /May 1, 2025/ }); + fireEvent.click(button); + + expect(onClick).toHaveBeenCalledWith("2025-05-01"); + }); + + it("renders an empty in-month day as interactive (tabIndex 0, not aria-disabled)", () => { + render( {}} />); + + const button = screen.getByRole("button", { name: /May 1, 2025/ }); + expect(button).toHaveAttribute("tabindex", "0"); + expect(button).toHaveAttribute("aria-disabled", "false"); + }); + + it("still renders a populated in-month day as interactive", () => { + const onClick = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /May 1, 2025/ })); + expect(onClick).toHaveBeenCalledWith("2025-05-01"); + }); + + it("does not render out-of-month days as interactive (no role=button)", () => { + render( + {}} + />, + ); + + expect(screen.queryByRole("button")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && pnpm test calendar-cell-empty-clickable` +Expected: FAIL — first two tests fail because the empty cell currently has `tabindex="-1"`, `aria-disabled="true"`, and `onClick` is not invoked. + +- [ ] **Step 3: Edit `CalendarCell.tsx` to drop the count gate** + +Modify `web/src/components/ActivityCalendar/CalendarCell.tsx`. Three edits: + +(a) Replace `handleClick`: + +```tsx + const handleClick = () => { + if (onClick) { + onClick(day.date); + } + }; +``` + +(b) Replace `isInteractive`: + +```tsx + const isInteractive = Boolean(onClick); +``` + +(c) Replace `shouldShowTooltip`: + +```tsx + const shouldShowTooltip = tooltipText && !disableTooltip; +``` + +Leave the out-of-month early return (`if (!day.isCurrentMonth) { ... }`) untouched — out-of-month cells remain non-buttons. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd web && pnpm test calendar-cell-empty-clickable` +Expected: PASS — all 4 cases. + +- [ ] **Step 5: Run the full test suite to catch regressions** + +Run: `cd web && pnpm test` +Expected: PASS. The existing `activity-calendar-tooltip.test.ts` covers `getTooltipText` (a separate utility) and shouldn't be affected. + +- [ ] **Step 6: Run lint** + +Run: `cd web && pnpm lint` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/components/ActivityCalendar/CalendarCell.tsx web/tests/calendar-cell-empty-clickable.test.tsx +git commit -m "feat(activity-calendar): allow clicking empty in-month dates" +``` + +--- + +## Task 3: Add `defaultCreateTime` prop to `MemoEditorProps` + +**Files:** +- Modify: `web/src/components/MemoEditor/types/components.ts` + +Type-only change. No tests at this step — TypeScript compiler is the gate. Subsequent tasks consume the prop. + +- [ ] **Step 1: Add the prop** + +Modify `web/src/components/MemoEditor/types/components.ts`. Replace the `MemoEditorProps` interface (lines 6–16) with: + +```ts +export interface MemoEditorProps { + className?: string; + cacheKey?: string; + placeholder?: string; + /** Existing memo to edit. When provided, the editor initializes from it without fetching. */ + memo?: Memo; + parentMemoName?: string; + autoFocus?: boolean; + /** + * Default `createTime` for a *new* memo (create mode only). When set, the + * editor seeds both `createTime` and `updateTime` to this value and renders + * the timestamp popover so the user can adjust before saving. Tracked live: + * if the prop changes after mount, the editor's timestamps re-sync. Ignored + * in edit mode (when `memo` is set). + */ + defaultCreateTime?: Date; + onConfirm?: (memoName: string) => void; + onCancel?: () => void; +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `cd web && pnpm lint` +Expected: PASS (no consumer changes yet, prop is optional). + +- [ ] **Step 3: Commit** + +```bash +git add web/src/components/MemoEditor/types/components.ts +git commit -m "feat(memo-editor): add defaultCreateTime prop type" +``` + +--- + +## Task 4: Seed editor timestamps in `useMemoInit` (create mode) + +**Files:** +- Modify: `web/src/components/MemoEditor/hooks/useMemoInit.ts` + +`useMemoInit` currently handles edit mode (`if (memo)`) by calling `memoService.fromMemo(memo)` and `actions.initMemo(...)`. In create mode it only restores cached content and sets default visibility — it never touches timestamps. Extend the create branch so that when `defaultCreateTime` is set, it dispatches `SET_TIMESTAMPS` with `{ createTime: defaultCreateTime, updateTime: defaultCreateTime }`. This handles the *initial mount* case. + +- [ ] **Step 1: Update `UseMemoInitOptions` and `useMemoInit`** + +Modify `web/src/components/MemoEditor/hooks/useMemoInit.ts`. Replace the entire file with: + +```ts +import { useEffect, useRef, useState } from "react"; +import type { Memo, Visibility } from "@/types/proto/api/v1/memo_service_pb"; +import type { EditorRefActions } from "../Editor"; +import { cacheService, memoService } from "../services"; +import { useEditorContext } from "../state"; + +interface UseMemoInitOptions { + editorRef: React.RefObject; + memo?: Memo; + cacheKey?: string; + username: string; + autoFocus?: boolean; + defaultVisibility?: Visibility; + defaultCreateTime?: Date; +} + +export const useMemoInit = ({ + editorRef, + memo, + cacheKey, + username, + autoFocus, + defaultVisibility, + defaultCreateTime, +}: UseMemoInitOptions) => { + const { actions, dispatch } = useEditorContext(); + const initializedRef = useRef(false); + const [isInitialized, setIsInitialized] = useState(false); + + useEffect(() => { + if (initializedRef.current) return; + initializedRef.current = true; + const key = cacheService.key(username, cacheKey); + + if (memo) { + const initialState = memoService.fromMemo(memo); + cacheService.clear(key); + dispatch(actions.initMemo(initialState)); + } else { + const cachedContent = cacheService.load(key); + if (cachedContent) { + dispatch(actions.updateContent(cachedContent)); + } + if (defaultVisibility !== undefined) { + dispatch(actions.setMetadata({ visibility: defaultVisibility })); + } + if (defaultCreateTime) { + dispatch(actions.setTimestamps({ createTime: defaultCreateTime, updateTime: defaultCreateTime })); + } + } + + if (autoFocus) { + setTimeout(() => editorRef.current?.focus(), 100); + } + + setIsInitialized(true); + }, [memo, cacheKey, username, autoFocus, defaultVisibility, defaultCreateTime, actions, dispatch, editorRef]); + + return { isInitialized }; +}; +``` + +Notes: +- The `defaultCreateTime` dependency is added to the effect's deps to satisfy the linter, but `initializedRef` ensures the body runs only once. Live re-sync after mount is handled by a separate effect in Task 5. +- Edit mode is unchanged — `defaultCreateTime` is intentionally ignored when `memo` is set. + +- [ ] **Step 2: Verify compilation** + +Run: `cd web && pnpm lint` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/components/MemoEditor/hooks/useMemoInit.ts +git commit -m "feat(memo-editor): seed timestamps from defaultCreateTime on init" +``` + +--- + +## Task 5: Wire `defaultCreateTime` through `MemoEditor` and render popover + +**Files:** +- Modify: `web/src/components/MemoEditor/index.tsx` + +Three changes: +1. Destructure `defaultCreateTime` from props. +2. Pass it into `useMemoInit`. +3. Add a `useEffect` that dispatches `setTimestamps` whenever `defaultCreateTime` changes after mount (live re-sync per design Q3-A). Skip when `memo` is set. +4. Update the popover render condition so it shows in create mode too when timestamps are seeded. + +- [ ] **Step 1: Destructure the prop and pass it to `useMemoInit`** + +In `web/src/components/MemoEditor/index.tsx`, update the `MemoEditorImpl` destructuring (around line 42): + +```tsx +const MemoEditorImpl: React.FC = ({ + className, + cacheKey, + memo, + parentMemoName, + autoFocus, + placeholder, + defaultCreateTime, + onConfirm, + onCancel, +}) => { +``` + +And update the `useMemoInit` call (around line 71): + +```tsx +const { isInitialized } = useMemoInit({ + editorRef, + memo, + cacheKey, + username: currentUser?.name ?? "", + autoFocus, + defaultVisibility, + defaultCreateTime, +}); +``` + +- [ ] **Step 2: Add the live re-sync effect** + +In the same file, add a new `useEffect` after `useMemoInit` (and after `useAutoSave`) that re-syncs timestamps when `defaultCreateTime` changes in create mode. Place it just before the existing `useEffect` that fetches AI settings (around line 80): + +```tsx +// Live-sync the draft's createTime/updateTime to the calendar-derived prop. +// Only applies in create mode; edit mode owns its own timestamps. Runs after +// initial mount (the seed value is set in useMemoInit), and again whenever +// the prop changes — e.g., when the user picks a different calendar date +// while the editor is open. +useEffect(() => { + if (memo) return; + if (!isInitialized) return; + dispatch( + actions.setTimestamps({ + createTime: defaultCreateTime, + updateTime: defaultCreateTime, + }), + ); +}, [defaultCreateTime, memo, isInitialized, actions, dispatch]); +``` + +Notes: +- We pass `undefined` through when the prop becomes undefined (filter cleared) — this resets timestamps to undefined so the editor falls back to "server-stamped now" on save, exactly the pre-feature behavior. +- The `isInitialized` guard avoids racing with `useMemoInit`'s one-shot seed. + +- [ ] **Step 3: Update the popover render condition** + +In the same file, find the existing block (around line 294): + +```tsx +{memoName && ( +
+ +
+)} +``` + +Replace with: + +```tsx +{(memoName || (!memo && state.timestamps.createTime)) && ( +
+ +
+)} +``` + +Now the popover renders in edit mode (unchanged) AND in create mode whenever a default timestamp has been seeded. + +- [ ] **Step 4: Verify compilation** + +Run: `cd web && pnpm lint` +Expected: PASS. + +- [ ] **Step 5: Run all tests** + +Run: `cd web && pnpm test` +Expected: PASS (no editor-specific tests added; existing tests continue to pass). + +- [ ] **Step 6: Commit** + +```bash +git add web/src/components/MemoEditor/index.tsx +git commit -m "feat(memo-editor): live-sync timestamps and reveal popover from defaultCreateTime" +``` + +--- + +## Task 6: Wire calendar selection through `PagedMemoList` + +**Files:** +- Modify: `web/src/components/PagedMemoList/PagedMemoList.tsx` + +The home memo editor is rendered at line ~155 of `PagedMemoList.tsx`. Read the current `MemoFilterContext` filters, derive the `defaultCreateTime`, and pass it to ``. Wrap in `useMemo` so the reference stays stable when filters don't change (avoids re-firing the live-sync effect). + +- [ ] **Step 1: Add the imports and derivation** + +Modify `web/src/components/PagedMemoList/PagedMemoList.tsx`. Add to the existing imports near the top: + +```tsx +import { useMemo } from "react"; +import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; +import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime"; +``` + +If `useMemo` is already imported from `react` in this file, merge into the existing import rather than duplicating. + +Inside the component body (above the `children` JSX, near the top of the function), add: + +```tsx +const { filters } = useMemoFilterContext(); +const defaultCreateTime = useMemo( + () => deriveDefaultCreateTimeFromFilters(filters), + [filters], +); +``` + +- [ ] **Step 2: Pass the prop to ``** + +Replace the existing line ~155: + +```tsx +{showMemoEditor ? : null} +``` + +with: + +```tsx +{showMemoEditor ? ( + +) : null} +``` + +- [ ] **Step 3: Verify compilation** + +Run: `cd web && pnpm lint` +Expected: PASS. + +- [ ] **Step 4: Run all tests** + +Run: `cd web && pnpm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/components/PagedMemoList/PagedMemoList.tsx +git commit -m "feat(home): pass calendar-selected date as default createTime to memo editor" +``` + +--- + +## Task 7: Manual smoke test + +No code changes. Per `CLAUDE.md`: "For UI or frontend changes, start the dev server and use the feature in a browser before reporting the task as complete." Walk through the user-visible flows. + +- [ ] **Step 1: Start the backend and frontend** + +In one terminal: +```bash +go run ./cmd/memos --port 8081 +``` + +In another: +```bash +cd web && pnpm dev +``` + +Open `http://localhost:3001`. + +- [ ] **Step 2: Smoke — populated past date** + +1. Sign in. Confirm the activity calendar is visible (statistics view). +2. Click a *past* date that already has memos. +3. Confirm the URL gains `?filter=displayTime:YYYY-MM-DD`. +4. Confirm the home memo editor shows the timestamp popover above the textarea, populated with the selected date. +5. Type a memo and click Save. +6. Clear the date filter (chip X). Reapply by clicking the same date. +7. Confirm the new memo appears under that date. + +- [ ] **Step 3: Smoke — empty past date** + +1. Pick a past date with **zero memos** in the calendar (lightest cell). +2. Confirm it is now clickable, the URL filter applies, and the empty-state shows. +3. Type and save a memo. +4. Confirm the memo appears for that date and the calendar cell tints up. + +- [ ] **Step 4: Smoke — future date** + +1. Click a future date in the current month. +2. Confirm the popover shows that date. +3. Save a memo. Confirm it appears under that date. + +- [ ] **Step 5: Smoke — clear filter mid-draft** + +1. Pick May 1 (or any non-today date). Type some content, do **not** save. +2. Click the filter chip X to clear the date filter. +3. Confirm the popover disappears and the draft content is preserved (autoSave behavior). +4. Save and confirm the memo gets a server-stamped "now" timestamp (i.e., appears under today). + +- [ ] **Step 6: Smoke — change filter mid-draft** + +1. Pick May 1. Type content. +2. Without saving, click May 3. +3. Confirm the popover updates to May 3. +4. Save. Confirm the memo appears under May 3. + +- [ ] **Step 7: Smoke — comment editor unaffected** + +1. Open any memo's detail view (or open the comments thread). +2. Confirm the reply editor does **not** show a timestamp popover. +3. Confirm the date filter has no visible effect on the reply editor. + +- [ ] **Step 8: Smoke — edit mode unaffected** + +1. Edit an existing memo (pencil icon). +2. Confirm the existing timestamp popover still works exactly as before, regardless of any active calendar filter. + +- [ ] **Step 9: Smoke — empty-date click on Explore page** + +1. Navigate to the Explore page (which also renders the calendar). +2. Click an empty date. +3. Confirm the URL filter applies and the empty-state shows. (No editor on Explore — that's correct.) + +- [ ] **Step 10: Record results** + +Note any unexpected behavior (especially: selection-ring contrast on the lowest-intensity background, mentioned as a flag in the spec). If the ring is too subtle, file a follow-up — *not* part of this plan. + +--- + +## Self-review + +**Spec coverage check:** + +| Spec requirement | Task | +|---|---| +| Empty calendar dates clickable | Task 2 | +| Editor shows TimestampPopover in create mode when filter active | Task 5 (popover condition) | +| `createTime` = selected date + current local hh:mm:ss | Task 1 (helper) + Task 4 (seed) | +| `updateTime` mirrored to same value | Task 4 (seed) + Task 5 (live sync) | +| Live-derived: filter change re-syncs timestamps | Task 5 (live-sync `useEffect`) | +| Filter cleared → undefined → server-stamped "now" | Task 5 (passes `undefined` through) + Task 6 (helper returns undefined) | +| Future dates allowed (no clamp) | Task 1 (no clamp in helper); confirmed in Task 7 step 4 | +| Comment editor unaffected | Task 6 wires only `PagedMemoList`; confirmed in Task 7 step 7 | +| Edit mode unaffected | Task 4 + 5 explicitly guard on `memo`; confirmed in Task 7 step 8 | +| Empty-date click on Explore/Archived/Profile | Task 2 (calendar-side change); confirmed in Task 7 step 9 | +| DST/timezone uses local time | Task 1 (`new Date(y, m-1, d, h, mi, s)`) | +| Helper unit tests | Task 1 | +| `CalendarCell` empty-cell test | Task 2 | +| Manual smoke | Task 7 | + +All spec requirements have a task. No gaps. + +**Placeholder scan:** No "TBD"/"TODO" steps. Every code step shows the actual code. + +**Type/name consistency check:** +- `MemoFilter` and `useMemoFilterContext` exist at `@/contexts/MemoFilterContext` (verified during exploration). +- `editorActions.setTimestamps` exists in `state/actions.ts:75` and accepts `Partial` (verified). Calls in Tasks 4 and 5 match. +- `state.timestamps.createTime` is `Date | undefined` (verified `state/types.ts:27-29`). The popover render condition uses it as a truthy guard — `Date` instances are truthy, `undefined` is falsy. +- `useMemoFilterContext` (alias used in PagedMemoList) is exported from `MemoFilterContext.tsx:151` (verified). +- `deriveDefaultCreateTimeFromFilters` signature is identical between Task 1 (definition) and Task 6 (consumer). +- `defaultCreateTime: Date | undefined` flows consistently through `MemoEditorProps` (Task 3) → `MemoEditorImpl` destructuring (Task 5) → `useMemoInit` options (Task 4) → reducer payloads. diff --git a/docs/superpowers/specs/2026-05-02-calendar-date-prefill-design.md b/docs/superpowers/specs/2026-05-02-calendar-date-prefill-design.md new file mode 100644 index 00000000..676d9199 --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-calendar-date-prefill-design.md @@ -0,0 +1,165 @@ +# Create memo on selected calendar date — design + +**Date:** 2026-05-02 +**Scope:** Frontend-only. + +## Problem + +Clicking a date in the activity calendar filters the memo list to that date but does nothing for the inline editor. To create a memo dated for the selected day, the user must (1) create with today's timestamp, then (2) open the timestamp popover on the saved memo and edit `createTime`. This is a two-step retro-fill that defeats the calendar selection. Empty dates are also not clickable today, so the user cannot start the first memo for an empty day from the calendar at all. + +## Goal + +When a user picks a calendar date and immediately writes in the home editor, the resulting memo is created on that date — single-step. + +## Non-goals + +- Backend changes. The API already accepts custom `createTime` and `updateTime`. +- Changes to the comment/reply editor or the edit-mode editor. +- Changes outside the home page's editor render site (Explore/Archived/Profile pages have no editor). +- Reworking the timestamp popover UI itself. +- Empty-state copy changes on non-Home pages when an empty date is selected. + +## User-visible behavior + +1. **Empty calendar dates are clickable.** Clicking a date with zero memos sets the `displayTime` filter the same way a populated date does. Tooltip and selection ring still work. +2. **When the home editor renders with an active `displayTime` filter:** + - The `TimestampPopover` (already used in edit mode) appears in create mode, pre-populated with the selected date. + - The draft's `createTime` is set to **selected local date + current local hh:mm:ss** (e.g., picking May 1 at 14:32 → `2025-05-01 14:32`). + - The draft's `updateTime` is set to the same value, to avoid the saved memo immediately reading "updated today" relative to a back-dated `createTime`. + - The user can adjust either field via the popover before saving. +3. **When no `displayTime` filter is active**, the editor is identical to today: no popover in create mode, no override, server stamps with "now". +4. **Live derivation.** If the filter changes while a draft is in progress, the editor's prefilled timestamps re-sync to the new date. The popover stays visible so the change is observable. (Manual popover edits before the next filter change are overwritten — chosen tradeoff.) +5. **Future dates are allowed** (e.g., May 15 when today is May 2). Backend already accepts future timestamps. +6. **Other contexts** (Explore/Archived/Profile) gain empty-date clickability for navigation consistency, but have no editor and so no prefill behavior. + +## Architecture + +Four touch points, all in `web/src/`: + +| File | Change | +|------|--------| +| `components/ActivityCalendar/CalendarCell.tsx` | Drop `day.count > 0` gate so empty in-month cells are clickable. | +| `components/MemoEditor/index.tsx` | Accept `defaultCreateTime?: Date` prop; render `TimestampPopover` in create mode when set; sync state on prop change. | +| `components/MemoEditor/utils/deriveDefaultCreateTime.ts` (new) | Pure helper: `(filters, now?) => Date \| undefined` derived from any `displayTime` filter. | +| `components/PagedMemoList/PagedMemoList.tsx` | At the home-editor render site (line 155), read `MemoFilterContext`, compute `defaultCreateTime`, pass as prop. | + +### Data flow + +``` +CalendarCell click + → useDateFilterNavigation + → URL ?filter=displayTime:YYYY-MM-DD + → MemoFilterContext re-renders + → PagedMemoList recomputes defaultCreateTime via deriveDefaultCreateTimeFromFilters(filters) + → re-renders + → editor reducer syncs state.timestamps (create + update) and renders TimestampPopover + → save → memoService.ts:111 sends createTime/updateTime to API +``` + +## Component contracts + +### `MemoEditor` — new prop + +```ts +interface MemoEditorProps { + // ...existing props + /** + * When set in create mode (no `memo` prop), seeds the draft's + * createTime/updateTime and reveals the TimestampPopover so the + * user can adjust. Tracked live: changes after mount re-sync state. + * Ignored in edit mode (when `memo` is set). + */ + defaultCreateTime?: Date; +} +``` + +Internal behavior: +- On `INIT_MEMO` for create mode, if `defaultCreateTime` is set, payload `timestamps` is `{ createTime: defaultCreateTime, updateTime: defaultCreateTime }`. +- A `useEffect` keyed on `[defaultCreateTime?.getTime(), memo]` dispatches `SET_TIMESTAMPS` whenever the prop changes in create mode. +- Popover render condition becomes `memoName || (!memo && state.timestamps.createTime)`. + +### `deriveDefaultCreateTimeFromFilters` — pure helper + +```ts +// web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts +export function deriveDefaultCreateTimeFromFilters( + filters: MemoFilter[], + now: Date = new Date(), +): Date | undefined { + const dateFilter = filters.find((f) => f.factor === "displayTime"); + if (!dateFilter) return undefined; + const [y, m, d] = dateFilter.value.split("-").map(Number); + if (!y || !m || !d) return undefined; + return new Date(y, m - 1, d, now.getHours(), now.getMinutes(), now.getSeconds()); +} +``` + +Notes: +- Defensive parse — returns `undefined` for malformed values rather than throwing. +- `now` is injectable for deterministic tests. +- Multiple `displayTime` filters are not produced by current UI; `find` ignores extras safely. + +### `PagedMemoList.tsx` — call-site change + +```tsx +const { filters } = useMemoFilterContext(); +const defaultCreateTime = useMemo( + () => deriveDefaultCreateTimeFromFilters(filters), + [filters], +); +// ... +{showMemoEditor ? ( + +) : null} +``` + +`useMemo` keyed on `filters` keeps the reference stable when the filter doesn't change, avoiding unnecessary editor re-syncs. `now` is captured once per filter change — matches "the local time when you picked the date". + +### `CalendarCell.tsx` — empty-cell clickability + +- `handleClick`: drop the `day.count > 0` check; just call `onClick(day.date)` if `onClick` is provided. +- `isInteractive`: `Boolean(onClick)`. +- `tabIndex` / `aria-disabled` / hover-cursor classes follow the new `isInteractive`. +- `shouldShowTooltip`: drop the `day.count > 0` gate; tooltip text already conveys the count. +- Out-of-month cells (existing early return) stay unclickable. +- The `selected` ring already works on count=0 cells. Visual contrast on the lowest-intensity background may need a small ring-weight bump in light theme; eyeball during implementation. + +## Edge cases + +- **No filter / filter cleared:** `defaultCreateTime` becomes `undefined`; editor falls back to current behavior. +- **User edits draft, then re-picks date:** live-derived; editor's `createTime` updates, popover reflects new value. +- **User manually edits via popover, then changes filter:** prop sync overwrites manual edit. Acceptable per design choice; popover keeps the change observable. +- **Draft cache (`cacheKey="home-memo-editor"`):** caches `content`, not `timestamps`. Reload restores text but `createTime` is freshly derived from current filter — consistent. +- **Future dates:** allowed. No clamp. +- **DST / timezone:** date arithmetic uses local time (`new Date(y, m-1, d, h, mi, s)`), matching `useDateFilterNavigation`'s local-date convention. Server receives an absolute `Timestamp`. +- **Comment editor (`MemoCommentSection`):** doesn't pass `defaultCreateTime` → no behavior change. +- **Edit mode (`memo` prop set):** prop is ignored; existing edit-mode popover is unchanged. +- **Empty-date click on Explore/Archived/Profile:** filters to empty date → "no memos" empty state. Acceptable. + +## Testing + +- **Unit (Vitest)** for `deriveDefaultCreateTimeFromFilters`: + - no `displayTime` filter → `undefined` + - valid `displayTime:2025-05-01` + injected `now=14:32:10` → `2025-05-01 14:32:10` local + - malformed value (`"not-a-date"`, `"2025-13-40"`) → `undefined` + - extra non-`displayTime` filters present → still works +- **Component (React Testing Library) for `CalendarCell`:** count=0 in-month cell is clickable, has correct `tabIndex`/`aria-disabled`, fires `onClick` with date. +- **Component for `MemoEditor`:** with `defaultCreateTime` prop, popover renders in create mode and `state.timestamps.createTime` matches; without prop, no popover; changing the prop re-syncs state. +- **Manual smoke (per CLAUDE.md UI-changes rule):** `pnpm dev`, click a non-today date (with and without existing memos), type a memo, save, confirm it appears under that date. Clear the filter chip; confirm a new memo posts to today. + +## Risks + +- The `useEffect` re-sync overwriting an in-progress popover edit is a *chosen* behavior. If users later complain, a "manual override sticky" flag is the natural follow-up. Not pre-built. +- Selection-ring contrast on the lowest-intensity background may need a small visual tweak; flagged for implementation. + +## Out of scope (explicit) + +- Sticky manual-override semantics for the popover. +- New empty-state copy on Explore/Archived/Profile when filtering to a date with zero memos. +- Any backend/API change. +- Any change to the comment editor, edit mode, or non-Home editor sites. diff --git a/web/src/components/ActivityCalendar/CalendarCell.tsx b/web/src/components/ActivityCalendar/CalendarCell.tsx index 175b5ddc..9fcc9b05 100644 --- a/web/src/components/ActivityCalendar/CalendarCell.tsx +++ b/web/src/components/ActivityCalendar/CalendarCell.tsx @@ -18,7 +18,7 @@ export const CalendarCell = memo((props: CalendarCellProps) => { const { day, maxCount, tooltipText, onClick, size = "default", disableTooltip = false } = props; const handleClick = () => { - if (day.count > 0 && onClick) { + if (onClick) { onClick(day.date); } }; @@ -32,7 +32,7 @@ export const CalendarCell = memo((props: CalendarCellProps) => { sizeConfig.borderRadius, smallExtraClasses, ); - const isInteractive = Boolean(onClick && day.count > 0); + const isInteractive = Boolean(onClick); const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText; if (!day.isCurrentMonth) { @@ -62,7 +62,7 @@ export const CalendarCell = memo((props: CalendarCellProps) => { ); - const shouldShowTooltip = tooltipText && day.count > 0 && !disableTooltip; + const shouldShowTooltip = tooltipText && !disableTooltip; if (!shouldShowTooltip) { return button; diff --git a/web/src/components/MemoEditor/hooks/useMemoInit.ts b/web/src/components/MemoEditor/hooks/useMemoInit.ts index 853ce6fc..8933c547 100644 --- a/web/src/components/MemoEditor/hooks/useMemoInit.ts +++ b/web/src/components/MemoEditor/hooks/useMemoInit.ts @@ -11,9 +11,18 @@ interface UseMemoInitOptions { username: string; autoFocus?: boolean; defaultVisibility?: Visibility; + defaultCreateTime?: Date; } -export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, defaultVisibility }: UseMemoInitOptions) => { +export const useMemoInit = ({ + editorRef, + memo, + cacheKey, + username, + autoFocus, + defaultVisibility, + defaultCreateTime, +}: UseMemoInitOptions) => { const { actions, dispatch } = useEditorContext(); const initializedRef = useRef(false); const [isInitialized, setIsInitialized] = useState(false); @@ -35,6 +44,9 @@ export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, de if (defaultVisibility !== undefined) { dispatch(actions.setMetadata({ visibility: defaultVisibility })); } + if (defaultCreateTime) { + dispatch(actions.setTimestamps({ createTime: defaultCreateTime, updateTime: defaultCreateTime })); + } } if (autoFocus) { @@ -42,7 +54,7 @@ export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, de } setIsInitialized(true); - }, [memo, cacheKey, username, autoFocus, defaultVisibility, actions, dispatch, editorRef]); + }, [memo, cacheKey, username, autoFocus, defaultVisibility, defaultCreateTime, actions, dispatch, editorRef]); return { isInitialized }; }; diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 248e85a4..31968965 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -46,6 +46,7 @@ const MemoEditorImpl: React.FC = ({ parentMemoName, autoFocus, placeholder, + defaultCreateTime, onConfirm, onCancel, }) => { @@ -68,7 +69,15 @@ const MemoEditorImpl: React.FC = ({ // Get default visibility from user settings const defaultVisibility = userGeneralSetting?.memoVisibility ? convertVisibilityFromString(userGeneralSetting.memoVisibility) : undefined; - const { isInitialized } = useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility }); + const { isInitialized } = useMemoInit({ + editorRef, + memo, + cacheKey, + username: currentUser?.name ?? "", + autoFocus, + defaultVisibility, + defaultCreateTime, + }); const isDraftCacheEnabled = !memo; // Auto-save content to localStorage @@ -77,6 +86,22 @@ const MemoEditorImpl: React.FC = ({ // Focus mode management with body scroll lock useFocusMode(state.ui.isFocusMode); + // Live-sync the draft's createTime/updateTime to the calendar-derived prop. + // Only applies in create mode; edit mode owns its own timestamps. Runs after + // initial mount (the seed value is set in useMemoInit), and again whenever + // the prop changes — e.g., when the user picks a different calendar date + // while the editor is open. + useEffect(() => { + if (memo) return; + if (!isInitialized) return; + dispatch( + actions.setTimestamps({ + createTime: defaultCreateTime, + updateTime: defaultCreateTime, + }), + ); + }, [defaultCreateTime, memo, isInitialized, actions, dispatch]); + useEffect(() => { if (!currentUser) { return; @@ -257,6 +282,13 @@ const MemoEditorImpl: React.FC = ({ if (!memoName && defaultVisibility) { dispatch(actions.setMetadata({ visibility: defaultVisibility })); } + // Re-seed the calendar-derived timestamps so the popover stays visible + // and subsequent memos in the same filter session keep the prefilled date. + // Without this, the live-sync effect won't re-fire (its deps don't change + // across reset), and memo #2 onward would silently fall back to "now". + if (!memoName && defaultCreateTime) { + dispatch(actions.setTimestamps({ createTime: defaultCreateTime, updateTime: defaultCreateTime })); + } // Notify parent component of successful save onConfirm?.(result.memoName); @@ -291,7 +323,7 @@ const MemoEditorImpl: React.FC = ({ {/* Exit button is absolutely positioned in top-right corner when active */} - {memoName && ( + {(memoName || (!memo && state.timestamps.createTime)) && (
diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts index 367a082d..a5f45a21 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -11,6 +11,14 @@ export interface MemoEditorProps { memo?: Memo; parentMemoName?: string; autoFocus?: boolean; + /** + * Default `createTime` for a *new* memo (create mode only). When set, the + * editor seeds both `createTime` and `updateTime` to this value and renders + * the timestamp popover so the user can adjust before saving. Tracked live: + * if the prop changes after mount, the editor's timestamps re-sync. Ignored + * in edit mode (when `memo` is set). + */ + defaultCreateTime?: Date; onConfirm?: (memoName: string) => void; onCancel?: () => void; } diff --git a/web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts b/web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts new file mode 100644 index 00000000..1ebd37a8 --- /dev/null +++ b/web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts @@ -0,0 +1,26 @@ +import type { MemoFilter } from "@/contexts/MemoFilterContext"; + +const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/; + +/** + * Derive a default `createTime` for a new memo from the active memo filters. + * If a `displayTime:YYYY-MM-DD` filter is present, returns that local date + * combined with `now`'s wall-clock hh:mm:ss. Returns undefined otherwise or + * when the filter value is malformed. + */ +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; +} diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index 3432da4c..7750a721 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -2,8 +2,10 @@ import { useQueryClient } from "@tanstack/react-query"; import { ArrowUpIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext"; +import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime"; import { Button } from "@/components/ui/button"; import { userServiceClient } from "@/connect"; +import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; import { useInfiniteMemos } from "@/hooks/useMemoQueries"; import { userKeys } from "@/hooks/useUserQueries"; @@ -82,8 +84,10 @@ function useAutoFetchWhenNotScrollable({ const PagedMemoList = (props: Props) => { const t = useTranslate(); const queryClient = useQueryClient(); + const { filters } = useMemoFilterContext(); const showMemoEditor = props.showMemoEditor ?? false; + const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromFilters(filters), [filters]); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteMemos( { @@ -152,7 +156,14 @@ const PagedMemoList = (props: Props) => { ) : ( <> - {showMemoEditor ? : null} + {showMemoEditor ? ( + + ) : null} {sortedMemoList.map((memo) => props.renderer(memo))} diff --git a/web/tests/calendar-cell-empty-clickable.test.tsx b/web/tests/calendar-cell-empty-clickable.test.tsx new file mode 100644 index 00000000..6308e1d5 --- /dev/null +++ b/web/tests/calendar-cell-empty-clickable.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { CalendarCell } from "@/components/ActivityCalendar/CalendarCell"; +import type { CalendarDayCell } from "@/components/ActivityCalendar/types"; + +const makeDay = (overrides: Partial = {}): CalendarDayCell => ({ + date: "2025-05-01", + label: "1", + count: 0, + isCurrentMonth: true, + isToday: false, + isSelected: false, + ...overrides, +}); + +describe("CalendarCell empty-day clickability", () => { + it("fires onClick for an in-month day with count=0", () => { + const onClick = vi.fn(); + render(); + + const button = screen.getByRole("button", { name: /May 1, 2025/ }); + fireEvent.click(button); + + expect(onClick).toHaveBeenCalledWith("2025-05-01"); + }); + + it("renders an empty in-month day as interactive (tabIndex 0, not aria-disabled)", () => { + render( {}} />); + + const button = screen.getByRole("button", { name: /May 1, 2025/ }); + expect(button).toHaveAttribute("tabindex", "0"); + expect(button).toHaveAttribute("aria-disabled", "false"); + }); + + it("still renders a populated in-month day as interactive", () => { + const onClick = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /May 1, 2025/ })); + expect(onClick).toHaveBeenCalledWith("2025-05-01"); + }); + + it("does not render out-of-month days as interactive (no role=button)", () => { + render( + {}} />, + ); + + expect(screen.queryByRole("button")).toBeNull(); + }); +}); diff --git a/web/tests/derive-default-create-time.test.ts b/web/tests/derive-default-create-time.test.ts new file mode 100644 index 00000000..0700d56d --- /dev/null +++ b/web/tests/derive-default-create-time.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime"; +import type { MemoFilter } from "@/contexts/MemoFilterContext"; + +describe("deriveDefaultCreateTimeFromFilters", () => { + const now = new Date(2026, 4, 2, 14, 32, 10); // 2026-05-02 14:32:10 local + + it("returns undefined when no filters are set", () => { + expect(deriveDefaultCreateTimeFromFilters([], now)).toBeUndefined(); + }); + + it("returns undefined when no displayTime filter is present", () => { + const filters: MemoFilter[] = [ + { factor: "tagSearch", value: "work" }, + { factor: "pinned", value: "true" }, + ]; + expect(deriveDefaultCreateTimeFromFilters(filters, now)).toBeUndefined(); + }); + + it("merges the displayTime date with the current local hh:mm:ss", () => { + const filters: MemoFilter[] = [{ factor: "displayTime", value: "2025-05-01" }]; + const result = deriveDefaultCreateTimeFromFilters(filters, now); + expect(result).toBeDefined(); + expect(result!.getFullYear()).toBe(2025); + expect(result!.getMonth()).toBe(4); // May (0-indexed) + expect(result!.getDate()).toBe(1); + expect(result!.getHours()).toBe(14); + expect(result!.getMinutes()).toBe(32); + expect(result!.getSeconds()).toBe(10); + }); + + it("ignores extra non-displayTime filters", () => { + const filters: MemoFilter[] = [ + { factor: "tagSearch", value: "work" }, + { factor: "displayTime", value: "2025-05-01" }, + { factor: "pinned", value: "true" }, + ]; + const result = deriveDefaultCreateTimeFromFilters(filters, now); + expect(result?.getDate()).toBe(1); + }); + + it("returns undefined for a malformed YYYY-MM-DD value", () => { + const cases: MemoFilter[][] = [ + [{ factor: "displayTime", value: "not-a-date" }], + [{ factor: "displayTime", value: "2025-13-40" }], + [{ factor: "displayTime", value: "" }], + [{ factor: "displayTime", value: "2025-5-1" }], // single-digit month/day + ]; + for (const filters of cases) { + expect(deriveDefaultCreateTimeFromFilters(filters, now)).toBeUndefined(); + } + }); + + it("uses real `new Date()` when `now` is omitted", () => { + const filters: MemoFilter[] = [{ factor: "displayTime", value: "2025-05-01" }]; + const before = new Date(); + const result = deriveDefaultCreateTimeFromFilters(filters); + const after = new Date(); + expect(result).toBeDefined(); + // Date components must come from the filter, not from `now` — guards + // against an impl that silently returns `new Date()` and ignores filters. + expect(result!.getFullYear()).toBe(2025); + expect(result!.getMonth()).toBe(4); // May (0-indexed) + expect(result!.getDate()).toBe(1); + // Time-of-day should fall between before and after (within 1s tolerance). + const resultTimeOnly = result!.getHours() * 3600 + result!.getMinutes() * 60 + result!.getSeconds(); + const beforeTimeOnly = before.getHours() * 3600 + before.getMinutes() * 60 + before.getSeconds(); + const afterTimeOnly = after.getHours() * 3600 + after.getMinutes() * 60 + after.getSeconds(); + // Handle midnight rollover by allowing any value if before > after. + if (beforeTimeOnly <= afterTimeOnly) { + expect(resultTimeOnly).toBeGreaterThanOrEqual(beforeTimeOnly); + expect(resultTimeOnly).toBeLessThanOrEqual(afterTimeOnly); + } + }); +});