9.0 KiB
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
createTimeandupdateTime. - 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
- Empty calendar dates are clickable. Clicking a date with zero memos sets the
displayTimefilter the same way a populated date does. Tooltip and selection ring still work. - When the home editor renders with an active
displayTimefilter:- The
TimestampPopover(already used in edit mode) appears in create mode, pre-populated with the selected date. - The draft's
createTimeis 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
updateTimeis set to the same value, to avoid the saved memo immediately reading "updated today" relative to a back-datedcreateTime. - The user can adjust either field via the popover before saving.
- The
- When no
displayTimefilter is active, the editor is identical to today: no popover in create mode, no override, server stamps with "now". - 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.)
- Future dates are allowed (e.g., May 15 when today is May 2). Backend already accepts future timestamps.
- 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
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_MEMOfor create mode, ifdefaultCreateTimeis set, payloadtimestampsis{ createTime: defaultCreateTime, updateTime: defaultCreateTime }. - A
useEffectkeyed on[defaultCreateTime?.getTime(), memo]dispatchesSET_TIMESTAMPSwhenever the prop changes in create mode. - Popover render condition becomes
memoName || (!memo && state.timestamps.createTime).
deriveDefaultCreateTimeFromFilters — pure helper
// 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
undefinedfor malformed values rather than throwing. nowis injectable for deterministic tests.- Multiple
displayTimefilters are not produced by current UI;findignores extras safely.
PagedMemoList.tsx — call-site change
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 theday.count > 0check; just callonClick(day.date)ifonClickis provided.isInteractive:Boolean(onClick).tabIndex/aria-disabled/ hover-cursor classes follow the newisInteractive.shouldShowTooltip: drop theday.count > 0gate; tooltip text already conveys the count.- Out-of-month cells (existing early return) stay unclickable.
- The
selectedring 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:
defaultCreateTimebecomesundefined; editor falls back to current behavior. - User edits draft, then re-picks date: live-derived; editor's
createTimeupdates, 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"): cachescontent, nottimestamps. Reload restores text butcreateTimeis 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)), matchinguseDateFilterNavigation's local-date convention. Server receives an absoluteTimestamp. - Comment editor (
MemoCommentSection): doesn't passdefaultCreateTime→ no behavior change. - Edit mode (
memoprop 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
displayTimefilter →undefined - valid
displayTime:2025-05-01+ injectednow=14:32:10→2025-05-01 14:32:10local - malformed value (
"not-a-date","2025-13-40") →undefined - extra non-
displayTimefilters present → still works
- no
- Component (React Testing Library) for
CalendarCell: count=0 in-month cell is clickable, has correcttabIndex/aria-disabled, firesonClickwith date. - Component for
MemoEditor: withdefaultCreateTimeprop, popover renders in create mode andstate.timestamps.createTimematches; 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
useEffectre-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.