feat(memo): create memos on the selected calendar date (#5925)
This commit is contained in:
parent
d349fe4409
commit
ef55013418
10 changed files with 1125 additions and 8 deletions
738
docs/superpowers/plans/2026-05-02-calendar-date-prefill.md
Normal file
738
docs/superpowers/plans/2026-05-02-calendar-date-prefill.md
Normal file
|
|
@ -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 `<MemoEditor>` 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> = {}): 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(<CalendarCell day={makeDay()} maxCount={5} tooltipText="May 1, 2025" onClick={onClick} />);
|
||||
|
||||
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(<CalendarCell day={makeDay()} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />);
|
||||
|
||||
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(<CalendarCell day={makeDay({ count: 3 })} maxCount={5} tooltipText="May 1, 2025" onClick={onClick} />);
|
||||
|
||||
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(
|
||||
<CalendarCell
|
||||
day={makeDay({ isCurrentMonth: false })}
|
||||
maxCount={5}
|
||||
tooltipText="May 1, 2025"
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
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<EditorRefActions | null>;
|
||||
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<MemoEditorProps> = ({
|
||||
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 && (
|
||||
<div className="w-full -mb-1">
|
||||
<TimestampPopover />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
{(memoName || (!memo && state.timestamps.createTime)) && (
|
||||
<div className="w-full -mb-1">
|
||||
<TimestampPopover />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
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 `<MemoEditor>`. 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 `<MemoEditor>`**
|
||||
|
||||
Replace the existing line ~155:
|
||||
|
||||
```tsx
|
||||
{showMemoEditor ? <MemoEditor className="mb-2" cacheKey="home-memo-editor" placeholder={t("editor.any-thoughts")} /> : null}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```tsx
|
||||
{showMemoEditor ? (
|
||||
<MemoEditor
|
||||
className="mb-2"
|
||||
cacheKey="home-memo-editor"
|
||||
placeholder={t("editor.any-thoughts")}
|
||||
defaultCreateTime={defaultCreateTime}
|
||||
/>
|
||||
) : 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<EditorState["timestamps"]>` (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.
|
||||
|
|
@ -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)
|
||||
→ <MemoEditor defaultCreateTime={...}> 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 ? (
|
||||
<MemoEditor
|
||||
className="mb-2"
|
||||
cacheKey="home-memo-editor"
|
||||
placeholder={t("editor.any-thoughts")}
|
||||
defaultCreateTime={defaultCreateTime}
|
||||
/>
|
||||
) : 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.
|
||||
|
|
@ -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) => {
|
|||
</button>
|
||||
);
|
||||
|
||||
const shouldShowTooltip = tooltipText && day.count > 0 && !disableTooltip;
|
||||
const shouldShowTooltip = tooltipText && !disableTooltip;
|
||||
|
||||
if (!shouldShowTooltip) {
|
||||
return button;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
parentMemoName,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
defaultCreateTime,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
|
|
@ -68,7 +69,15 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
// 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<MemoEditorProps> = ({
|
|||
// 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<MemoEditorProps> = ({
|
|||
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<MemoEditorProps> = ({
|
|||
{/* Exit button is absolutely positioned in top-right corner when active */}
|
||||
<FocusModeExitButton isActive={state.ui.isFocusMode} onToggle={handleToggleFocusMode} title={t("editor.exit-focus-mode")} />
|
||||
|
||||
{memoName && (
|
||||
{(memoName || (!memo && state.timestamps.createTime)) && (
|
||||
<div className="w-full -mb-1">
|
||||
<TimestampPopover />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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) => {
|
|||
<Skeleton showCreator={props.showCreator} count={4} />
|
||||
) : (
|
||||
<>
|
||||
{showMemoEditor ? <MemoEditor className="mb-2" cacheKey="home-memo-editor" placeholder={t("editor.any-thoughts")} /> : null}
|
||||
{showMemoEditor ? (
|
||||
<MemoEditor
|
||||
className="mb-2"
|
||||
cacheKey="home-memo-editor"
|
||||
placeholder={t("editor.any-thoughts")}
|
||||
defaultCreateTime={defaultCreateTime}
|
||||
/>
|
||||
) : null}
|
||||
<MemoFilters />
|
||||
{sortedMemoList.map((memo) => props.renderer(memo))}
|
||||
|
||||
|
|
|
|||
50
web/tests/calendar-cell-empty-clickable.test.tsx
Normal file
50
web/tests/calendar-cell-empty-clickable.test.tsx
Normal file
|
|
@ -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> = {}): 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(<CalendarCell day={makeDay()} maxCount={5} tooltipText="May 1, 2025" onClick={onClick} />);
|
||||
|
||||
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(<CalendarCell day={makeDay()} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />);
|
||||
|
||||
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(<CalendarCell day={makeDay({ count: 3 })} maxCount={5} tooltipText="May 1, 2025" onClick={onClick} />);
|
||||
|
||||
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(
|
||||
<CalendarCell day={makeDay({ isCurrentMonth: false })} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
});
|
||||
75
web/tests/derive-default-create-time.test.ts
Normal file
75
web/tests/derive-default-create-time.test.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue