# ActivityCalendar Time Basis 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:** Make the heatmap aggregation follow `ViewContext.timeBasis` so the calendar and the memo list agree about "today" when sorting by `update_time`. **Architecture:** Add a parallel `memo_updated_timestamps` field to `UserStats`, populate it in the backend stats path the same way `memo_created_timestamps` is populated. On the frontend, `useFilteredMemoStats` reads `timeBasis` from `ViewContext` and picks the matching array; the resulting `timeBasis` is propagated down through `StatisticsView` → `MonthCalendar`/`YearCalendar` so the tooltip text reflects the active basis. No DB migration; the only schema change is one additive proto field. **Tech Stack:** Go 1.26 / Protobuf / Connect RPC (backend), React 18 + TypeScript 6 + React Query v5 + i18next (frontend), Vitest (frontend tests), Go testify with TestContainers (backend tests). **Spec:** `docs/superpowers/specs/2026-05-02-activity-calendar-time-basis-design.md` --- ## File Map **Backend** - Modify: `proto/api/v1/user_service.proto` (add field) - Generated: `proto/gen/api/v1/user_service.pb.go` (regenerated) - Generated: `web/src/types/proto/api/v1/user_service_pb.ts` (regenerated) - Modify: `server/router/api/v1/user_service_stats.go` (populate field in both functions) - Modify: `server/router/api/v1/test/user_service_stats_test.go` (assert new field) **Frontend types & util** - Modify: `web/src/components/ActivityCalendar/types.ts` (add `MemoTimeBasis` import, prop) - Modify: `web/src/components/ActivityCalendar/utils.ts` (basis-aware tooltip) - Modify: `web/src/components/ActivityCalendar/MonthCalendar.tsx` (accept & forward `timeBasis`) - Modify: `web/src/components/ActivityCalendar/YearCalendar.tsx` (accept & forward `timeBasis`) **Frontend data** - Modify: `web/src/hooks/useFilteredMemoStats.ts` (read `timeBasis`, switch source) - Modify: `web/src/types/statistics.ts` (add `timeBasis` to `StatisticsData` and `MonthNavigatorProps`) - Modify: `web/src/components/StatisticsView/StatisticsView.tsx` (forward `timeBasis`) - Modify: `web/src/components/StatisticsView/MonthNavigator.tsx` (forward `timeBasis`) **Frontend i18n** - Modify: `web/src/locales/en.json` (add `count-memos-updated-in-date` key) **Frontend tests** - Create: `web/tests/activity-calendar-tooltip.test.ts` - Create: `web/tests/filtered-memo-stats.test.ts` --- ## Task 1: Add `memo_updated_timestamps` field to `UserStats` proto **Files:** - Modify: `proto/api/v1/user_service.proto` - Generated (do not hand-edit): `proto/gen/api/v1/user_service.pb.go`, `web/src/types/proto/api/v1/user_service_pb.ts` - [ ] **Step 1: Add the field** Open `proto/api/v1/user_service.proto`. Find the `UserStats` message (around line 345). After the existing `memo_created_timestamps` line (currently `repeated google.protobuf.Timestamp memo_created_timestamps = 7;`), add: ```proto // The latest update timestamps of the user's memos (one per memo, // mirrors memo_created_timestamps). Used by the activity heatmap when // the client's view is set to update_time basis. repeated google.protobuf.Timestamp memo_updated_timestamps = 8; ``` The `total_memo_count = 6` field stays at tag 6; this new field claims tag 8 (tag 2 is reserved per the existing `reserved 2;` line). - [ ] **Step 2: Regenerate proto code** Run from the repo root: ```bash (cd proto && buf generate) ``` Expected: regenerates `proto/gen/api/v1/user_service.pb.go` and `web/src/types/proto/api/v1/user_service_pb.ts` (plus OpenAPI). Verify `memoUpdatedTimestamps` appears in the TS file: ```bash grep -n "memoUpdatedTimestamps\|memo_updated_timestamps" web/src/types/proto/api/v1/user_service_pb.ts ``` Expected output: matches in both the doc comment and the field declaration around the existing `memoCreatedTimestamps` block. - [ ] **Step 3: Verify Go compiles** ```bash go build ./... ``` Expected: success with no errors. - [ ] **Step 4: Commit** ```bash git add proto/api/v1/user_service.proto proto/gen/api/v1/user_service.pb.go web/src/types/proto/api/v1/user_service_pb.ts proto/gen/apidocs.swagger.yaml git commit -m "proto(user_service): add memo_updated_timestamps to UserStats" ``` (If `buf generate` touches additional files, include them in the same commit.) --- ## Task 2: Populate `MemoUpdatedTimestamps` in `GetUserStats` (backend) **Files:** - Modify: `server/router/api/v1/user_service_stats.go:177-277` The existing `GetUserStats` function (line 177) collects `createdTimestamps` from each memo. We add a parallel `updatedTimestamps`. - [ ] **Step 1: Add the parallel slice declaration** In `server/router/api/v1/user_service_stats.go`, find line 207: ```go createdTimestamps := []*timestamppb.Timestamp{} ``` Replace with: ```go createdTimestamps := []*timestamppb.Timestamp{} updatedTimestamps := []*timestamppb.Timestamp{} ``` - [ ] **Step 2: Append `UpdatedTs` inside the memo loop** Find line 233 (inside `for _, memo := range memos`): ```go createdTimestamps = append(createdTimestamps, timestamppb.New(time.Unix(memo.CreatedTs, 0))) ``` Replace with: ```go createdTimestamps = append(createdTimestamps, timestamppb.New(time.Unix(memo.CreatedTs, 0))) updatedTimestamps = append(updatedTimestamps, timestamppb.New(time.Unix(memo.UpdatedTs, 0))) ``` - [ ] **Step 3: Set the new field on the returned struct** Find line 262-274 (the `userStats := &v1pb.UserStats{...}` literal). Add the new field next to `MemoCreatedTimestamps`: Before: ```go userStats := &v1pb.UserStats{ Name: fmt.Sprintf("%s/stats", BuildUserName(user.Username)), MemoCreatedTimestamps: createdTimestamps, TagCount: tagCount, ... ``` After: ```go userStats := &v1pb.UserStats{ Name: fmt.Sprintf("%s/stats", BuildUserName(user.Username)), MemoCreatedTimestamps: createdTimestamps, MemoUpdatedTimestamps: updatedTimestamps, TagCount: tagCount, ... ``` - [ ] **Step 4: Build and verify** ```bash go build ./server/router/api/v1/... ``` Expected: success. --- ## Task 3: Populate `MemoUpdatedTimestamps` in `ListAllUserStats` (backend) **Files:** - Modify: `server/router/api/v1/user_service_stats.go:56-175` `ListAllUserStats` (line 56) builds a per-user stats map across all users. We mirror Task 2 inside this loop. - [ ] **Step 1: Initialize the slice on first sight of a user** Find lines 99-110 (the `userMemoStatMap[memo.CreatorID] = &v1pb.UserStats{...}` literal). Add `MemoUpdatedTimestamps: []*timestamppb.Timestamp{},` next to `MemoCreatedTimestamps`: ```go userMemoStatMap[memo.CreatorID] = &v1pb.UserStats{ Name: "", TagCount: make(map[string]int32), MemoCreatedTimestamps: []*timestamppb.Timestamp{}, MemoUpdatedTimestamps: []*timestamppb.Timestamp{}, PinnedMemos: []string{}, MemoTypeStats: &v1pb.UserStats_MemoTypeStats{ LinkCount: 0, CodeCount: 0, TodoCount: 0, UndoCount: 0, }, } ``` - [ ] **Step 2: Append in the per-memo loop** Find line 115: ```go stats.MemoCreatedTimestamps = append(stats.MemoCreatedTimestamps, timestamppb.New(time.Unix(memo.CreatedTs, 0))) ``` Replace with: ```go stats.MemoCreatedTimestamps = append(stats.MemoCreatedTimestamps, timestamppb.New(time.Unix(memo.CreatedTs, 0))) stats.MemoUpdatedTimestamps = append(stats.MemoUpdatedTimestamps, timestamppb.New(time.Unix(memo.UpdatedTs, 0))) ``` - [ ] **Step 3: Build** ```bash go build ./server/router/api/v1/... ``` Expected: success. - [ ] **Step 4: Commit Tasks 2 + 3 together** ```bash git add server/router/api/v1/user_service_stats.go git commit -m "feat(user_service): populate memo_updated_timestamps in user stats" ``` --- ## Task 4: Backend test — assert `MemoUpdatedTimestamps` is populated **Files:** - Modify: `server/router/api/v1/test/user_service_stats_test.go` Add a TDD-style test that creates a memo, updates it, and verifies both timestamp arrays are populated correctly. - [ ] **Step 1: Write the failing test** Append this function to `server/router/api/v1/test/user_service_stats_test.go`: ```go func TestGetUserStats_MemoUpdatedTimestamps(t *testing.T) { ctx := context.Background() ts := NewTestService(t) defer ts.Cleanup() user, err := ts.CreateHostUser(ctx, "ts-user") require.NoError(t, err) userCtx := ts.CreateUserContext(ctx, user.ID) // Create one memo. Its created_ts and updated_ts are equal at creation. memo, err := ts.Store.CreateMemo(ctx, &store.Memo{ UID: "ts-memo-1", CreatorID: user.ID, Content: "first content", Visibility: store.Public, }) require.NoError(t, err) require.NotNil(t, memo) // Bump updated_ts by updating content. newContent := "second content" updatedMemo, err := ts.Store.UpdateMemo(ctx, &store.UpdateMemo{ ID: memo.ID, Content: &newContent, }) require.NoError(t, err) require.NotNil(t, updatedMemo) userName := fmt.Sprintf("users/%s", user.Username) resp, err := ts.Service.GetUserStats(userCtx, &v1pb.GetUserStatsRequest{Name: userName}) require.NoError(t, err) require.NotNil(t, resp) require.Len(t, resp.MemoCreatedTimestamps, 1, "should have one created timestamp") require.Len(t, resp.MemoUpdatedTimestamps, 1, "should have one updated timestamp") require.Equal(t, updatedMemo.CreatedTs, resp.MemoCreatedTimestamps[0].AsTime().Unix()) require.Equal(t, updatedMemo.UpdatedTs, resp.MemoUpdatedTimestamps[0].AsTime().Unix()) require.GreaterOrEqual( t, resp.MemoUpdatedTimestamps[0].AsTime().Unix(), resp.MemoCreatedTimestamps[0].AsTime().Unix(), "updated_ts should be at or after created_ts", ) } ``` - [ ] **Step 2: Verify it passes (Tasks 2/3 already implement the behavior)** ```bash go test -v -race -run TestGetUserStats_MemoUpdatedTimestamps ./server/router/api/v1/test/... ``` Expected: PASS. If it fails because `store.UpdateMemo` has a different signature, inspect the existing usage in this same test file or in `store/memo.go` and adjust. The intent is "create a memo, update it, then read stats." - [ ] **Step 3: Commit** ```bash git add server/router/api/v1/test/user_service_stats_test.go git commit -m "test(user_service): cover memo_updated_timestamps in stats" ``` --- ## Task 5: Add `MemoTimeBasis` to ActivityCalendar types **Files:** - Modify: `web/src/components/ActivityCalendar/types.ts` The calendar components currently take `data: CalendarData`. We add an optional `timeBasis` prop so the tooltip can label correctly. - [ ] **Step 1: Re-export the basis type and add to props** Open `web/src/components/ActivityCalendar/types.ts`. At the top, add an import: ```ts import type { MemoTimeBasis } from "@/contexts/ViewContext"; ``` In `MonthCalendarProps` (around line 22), add an optional field: ```ts export interface MonthCalendarProps { month: string; data: CalendarData; maxCount: number; size?: CalendarSize; onClick?: (date: string) => void; selectedDate?: string; className?: string; disableTooltips?: boolean; timeBasis?: MemoTimeBasis; } ``` In `YearCalendarProps` (around line 33), add the same field: ```ts export interface YearCalendarProps { selectedYear: number; data: CalendarData; onYearChange: (year: number) => void; onDateClick: (date: string) => void; className?: string; timeBasis?: MemoTimeBasis; } ``` - [ ] **Step 2: Type-check** ```bash cd web && pnpm lint ``` Expected: PASS (no implementations consume the new prop yet — that's fine, it's optional). --- ## Task 6: Add basis-aware tooltip text + i18n key **Files:** - Modify: `web/src/locales/en.json:233` - Modify: `web/src/components/ActivityCalendar/utils.ts:63-73` - [ ] **Step 1: Add the new English locale key** Open `web/src/locales/en.json`. Find line 233: ```json "count-memos-in-date": "{{count}} {{memos}} in {{date}}", ``` Add a sibling key right after it: ```json "count-memos-in-date": "{{count}} {{memos}} in {{date}}", "count-memos-updated-in-date": "{{count}} {{memos}} updated on {{date}}", ``` (Other locale files keep using only `count-memos-in-date`; i18next will fall back to English for the new key, matching the project's existing fallback strategy.) - [ ] **Step 2: Update `getTooltipText` to take a `timeBasis`** In `web/src/components/ActivityCalendar/utils.ts`, replace lines 63-73: ```ts export const getTooltipText = (count: number, date: string, t: TranslateFunction): string => { if (count === 0) { return date; } return t("memo.count-memos-in-date", { count, memos: count === 1 ? t("common.memo") : t("common.memos"), date, }).toLowerCase(); }; ``` With: ```ts import type { MemoTimeBasis } from "@/contexts/ViewContext"; export const getTooltipText = (count: number, date: string, t: TranslateFunction, timeBasis: MemoTimeBasis = "create_time"): string => { if (count === 0) { return date; } const key = timeBasis === "update_time" ? "memo.count-memos-updated-in-date" : "memo.count-memos-in-date"; return t(key, { count, memos: count === 1 ? t("common.memo") : t("common.memos"), date, }).toLowerCase(); }; ``` (Move the new `import` to the top of the file with the other imports — don't leave it mid-file.) - [ ] **Step 3: Write the unit test** Create `web/tests/activity-calendar-tooltip.test.ts`: ```ts import { describe, expect, it } from "vitest"; import { getTooltipText } from "@/components/ActivityCalendar/utils"; // Minimal stub for the i18n translate fn — returns a deterministic string we can assert on. const t = ((key: string, vars?: Record) => { if (!vars) return key; const parts = Object.entries(vars).map(([k, v]) => `${k}=${String(v)}`); return `${key}|${parts.join(",")}`; }) as Parameters[2]; describe("getTooltipText", () => { it("returns just the date when count is 0", () => { expect(getTooltipText(0, "2026-05-02", t)).toBe("2026-05-02"); }); it("uses the created-tooltip key for create_time basis (default)", () => { const out = getTooltipText(3, "2026-05-02", t); expect(out.toLowerCase()).toContain("memo.count-memos-in-date"); expect(out.toLowerCase()).not.toContain("updated"); }); it("uses the updated-tooltip key for update_time basis", () => { const out = getTooltipText(3, "2026-05-02", t, "update_time"); expect(out.toLowerCase()).toContain("memo.count-memos-updated-in-date"); }); }); ``` - [ ] **Step 4: Run the test** ```bash cd web && pnpm test activity-calendar-tooltip ``` Expected: 3/3 PASS. - [ ] **Step 5: Commit** ```bash git add web/src/locales/en.json web/src/components/ActivityCalendar/utils.ts web/src/components/ActivityCalendar/types.ts web/tests/activity-calendar-tooltip.test.ts git commit -m "feat(activity-calendar): basis-aware tooltip text" ``` --- ## Task 7: Forward `timeBasis` through `MonthCalendar` and `YearCalendar` **Files:** - Modify: `web/src/components/ActivityCalendar/MonthCalendar.tsx:37-77` - Modify: `web/src/components/ActivityCalendar/YearCalendar.tsx:71-114` - [ ] **Step 1: Pass `timeBasis` into `getTooltipText` from `MonthCalendar`** In `web/src/components/ActivityCalendar/MonthCalendar.tsx`, the destructured props line (around 38) currently reads: ```tsx const { month, data, maxCount, size = "default", onClick, selectedDate, className, disableTooltips = false } = props; ``` Replace with: ```tsx const { month, data, maxCount, size = "default", onClick, selectedDate, className, disableTooltips = false, timeBasis = "create_time" } = props; ``` In the `flatDays.map` block (line 61-71), the `getTooltipText` call currently reads: ```tsx tooltipText={getTooltipText(day.count, day.date, t)} ``` Replace with: ```tsx tooltipText={getTooltipText(day.count, day.date, t, timeBasis)} ``` - [ ] **Step 2: Pass `timeBasis` from `YearCalendar` into the inner `MonthCalendar`** In `web/src/components/ActivityCalendar/YearCalendar.tsx`, `YearCalendar` is currently: ```tsx export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClick, className }: YearCalendarProps) => { ``` Replace with: ```tsx export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClick, className, timeBasis }: YearCalendarProps) => { ``` `MonthCard` is the inner component that renders `MonthCalendar`. Update its props type (line 71-76) and component (line 78-83): Before: ```tsx interface MonthCardProps { month: string; data: CalendarData; maxCount: number; onDateClick: (date: string) => void; } const MonthCard = memo(({ month, data, maxCount, onDateClick }: MonthCardProps) => (
{getMonthLabel(month)}
)); ``` After: ```tsx interface MonthCardProps { month: string; data: CalendarData; maxCount: number; onDateClick: (date: string) => void; timeBasis?: import("@/contexts/ViewContext").MemoTimeBasis; } const MonthCard = memo(({ month, data, maxCount, onDateClick, timeBasis }: MonthCardProps) => (
{getMonthLabel(month)}
)); ``` (Prefer importing `MemoTimeBasis` at the top of the file with other imports rather than the inline `import(...)` form. The inline form above is shown only to avoid forgetting it; relocate it.) Then in the `months.map(...)` block (around line 108), pass `timeBasis` down: Before: ```tsx {months.map((month) => ( ))} ``` After: ```tsx {months.map((month) => ( ))} ``` - [ ] **Step 3: Type-check** ```bash cd web && pnpm lint ``` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add web/src/components/ActivityCalendar/MonthCalendar.tsx web/src/components/ActivityCalendar/YearCalendar.tsx git commit -m "feat(activity-calendar): forward timeBasis to tooltip" ``` --- ## Task 8: Switch `useFilteredMemoStats` source by `timeBasis` **Files:** - Modify: `web/src/hooks/useFilteredMemoStats.ts` - [ ] **Step 1: Read `timeBasis` from ViewContext and switch sources** Replace the entire body of `web/src/hooks/useFilteredMemoStats.ts` with: ```ts import { timestampDate } from "@bufbuild/protobuf/wkt"; import dayjs from "dayjs"; import { countBy } from "lodash-es"; import { useMemo } from "react"; import type { MemoExplorerContext } from "@/components/MemoExplorer"; import { type MemoTimeBasis, useView } from "@/contexts/ViewContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import { useMemos } from "@/hooks/useMemoQueries"; import { useUserStats } from "@/hooks/useUserQueries"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { StatisticsData } from "@/types/statistics"; export interface FilteredMemoStats { statistics: StatisticsData; tags: Record; loading: boolean; } export interface UseFilteredMemoStatsOptions { userName?: string; context?: MemoExplorerContext; } const toDateString = (date: Date) => dayjs(date).format("YYYY-MM-DD"); const memoTimestampForBasis = (memo: Memo, basis: MemoTimeBasis): Date | undefined => { const ts = basis === "update_time" ? memo.updateTime : memo.createTime; return ts ? timestampDate(ts) : undefined; }; export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => { const { userName, context } = options; const currentUser = useCurrentUser(); const { timeBasis } = useView(); // home/profile: use backend per-user stats (full tag set, not page-limited) const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(userName); // explore: fetch memos with visibility filter to exclude private content. // ListMemos AND's the request filter with the server's auth filter, so private // memos are always excluded regardless of backend version. // other contexts: fetch with default params for the fallback memo-based path. const exploreVisibilityFilter = currentUser != null ? 'visibility in ["PUBLIC", "PROTECTED"]' : 'visibility in ["PUBLIC"]'; const memoQueryParams = context === "explore" ? { filter: exploreVisibilityFilter, pageSize: 1000 } : {}; const { data: memosResponse, isLoading: isLoadingMemos } = useMemos(memoQueryParams); const data = useMemo(() => { const loading = isLoadingUserStats || isLoadingMemos; let activityStats: Record = {}; let tagCount: Record = {}; if (context === "explore") { // Tags and activity stats from visibility-filtered memos (no private content). for (const memo of memosResponse?.memos ?? []) { for (const tag of memo.tags ?? []) { tagCount[tag] = (tagCount[tag] ?? 0) + 1; } } const displayDates = (memosResponse?.memos ?? []) .map((memo) => memoTimestampForBasis(memo, timeBasis)) .filter((date): date is Date => date !== undefined) .map(toDateString); activityStats = countBy(displayDates); } else if (userName && userStats) { // home/profile: use backend per-user stats. // // Generated protobuf-es types make repeated fields non-optional T[], so an // old server that doesn't know the new field will deserialize as []. Since // memo.updated_ts is set to created_ts at row creation, the two arrays are // expected to be the same length whenever there are memos. Length-based // detection is therefore reliable: created non-empty AND updated empty // means "old server". const createdArray = userStats.memoCreatedTimestamps ?? []; const updatedArray = userStats.memoUpdatedTimestamps ?? []; const wantUpdated = timeBasis === "update_time"; const oldServerFallback = wantUpdated && updatedArray.length === 0 && createdArray.length > 0; if (oldServerFallback) { console.warn("UserStats.memo_updated_timestamps not present; falling back to memo_created_timestamps"); } const sourceArray = wantUpdated && !oldServerFallback ? updatedArray : createdArray; if (sourceArray.length > 0) { activityStats = countBy( sourceArray .map((ts) => (ts ? timestampDate(ts) : undefined)) .filter((date): date is Date => date !== undefined) .map(toDateString), ); } if (userStats.tagCount) { tagCount = userStats.tagCount; } } else if (memosResponse?.memos) { // archived/fallback: compute from cached memos const displayDates = memosResponse.memos .map((memo) => memoTimestampForBasis(memo, timeBasis)) .filter((date): date is Date => date !== undefined) .map(toDateString); activityStats = countBy(displayDates); for (const memo of memosResponse.memos) { for (const tag of memo.tags ?? []) { tagCount[tag] = (tagCount[tag] || 0) + 1; } } } return { statistics: { activityStats, timeBasis }, tags: tagCount, loading }; }, [context, userName, userStats, memosResponse, isLoadingUserStats, isLoadingMemos, timeBasis]); return data; }; ``` - [ ] **Step 2: Type-check** ```bash cd web && pnpm lint ``` Expected: ONE error pointing at `web/src/types/statistics.ts` because `StatisticsData` doesn't yet have `timeBasis`. That's the next task; do not fix it here, leave the failure visible to drive Task 9. If you see other unrelated errors, stop and reconcile. --- ## Task 9: Add `timeBasis` to `StatisticsData` and forward through `StatisticsView`/`MonthNavigator` **Files:** - Modify: `web/src/types/statistics.ts` - Modify: `web/src/components/StatisticsView/StatisticsView.tsx` - Modify: `web/src/components/StatisticsView/MonthNavigator.tsx` - [ ] **Step 1: Extend the data shape** Replace the contents of `web/src/types/statistics.ts` with: ```ts import type { MemoTimeBasis } from "@/contexts/ViewContext"; export interface StatisticsViewProps { className?: string; } export interface MonthNavigatorProps { visibleMonth: string; onMonthChange: (month: string) => void; activityStats: Record; timeBasis: MemoTimeBasis; } export interface StatisticsData { activityStats: Record; timeBasis: MemoTimeBasis; } ``` - [ ] **Step 2: Forward `timeBasis` from `StatisticsView`** Open `web/src/components/StatisticsView/StatisticsView.tsx`. The current body (line 12-32) destructures `activityStats` from `statisticsData`. Replace with: ```tsx const StatisticsView = (props: Props) => { const { statisticsData } = props; const { activityStats, timeBasis } = statisticsData; const navigateToDateFilter = useDateFilterNavigation(); const [visibleMonthString, setVisibleMonthString] = useState(dayjs().format("YYYY-MM")); return (
); }; ``` - [ ] **Step 3: Forward `timeBasis` from `MonthNavigator` into `YearCalendar`** Open `web/src/components/StatisticsView/MonthNavigator.tsx`. Replace the destructured prop list (line 11): ```tsx export const MonthNavigator = memo(({ visibleMonth, onMonthChange, activityStats }: MonthNavigatorProps) => { ``` With: ```tsx export const MonthNavigator = memo(({ visibleMonth, onMonthChange, activityStats, timeBasis }: MonthNavigatorProps) => { ``` Then update the `` call (line 62). Before: ```tsx ``` After: ```tsx ``` - [ ] **Step 4: Type-check** ```bash cd web && pnpm lint ``` Expected: PASS. - [ ] **Step 5: Commit Tasks 8 + 9 together** ```bash git add web/src/hooks/useFilteredMemoStats.ts web/src/types/statistics.ts web/src/components/StatisticsView/StatisticsView.tsx web/src/components/StatisticsView/MonthNavigator.tsx git commit -m "feat(activity-calendar): aggregate by ViewContext.timeBasis" ``` --- ## Task 10: Frontend test — hook switches source by `timeBasis` **Files:** - Create: `web/tests/filtered-memo-stats.test.ts` We test the hook indirectly by exercising the data-selection logic on a hand-built `userStats` object. Mocking the React Query hooks and ViewContext keeps the test fast and unit-scoped. - [ ] **Step 1: Write the test** Create `web/tests/filtered-memo-stats.test.ts`: ```ts import { renderHook } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock dependencies BEFORE importing the hook under test. vi.mock("@/hooks/useUserQueries", () => ({ useUserStats: vi.fn(), })); vi.mock("@/hooks/useMemoQueries", () => ({ useMemos: () => ({ data: undefined, isLoading: false }), })); vi.mock("@/hooks/useCurrentUser", () => ({ default: () => ({ name: "users/test", id: 1 }), })); const mockUseView = vi.fn(); vi.mock("@/contexts/ViewContext", async () => { const actual = await vi.importActual("@/contexts/ViewContext"); return { ...actual, useView: () => mockUseView(), }; }); import { useUserStats } from "@/hooks/useUserQueries"; import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats"; const wrapper = ({ children }: { children: ReactNode }) => children as JSX.Element; const ts = (year: number, month: number, day: number) => ({ seconds: BigInt(Math.floor(Date.UTC(year, month - 1, day) / 1000)), nanos: 0, }); describe("useFilteredMemoStats", () => { beforeEach(() => { vi.mocked(useUserStats).mockReturnValue({ data: { memoCreatedTimestamps: [ts(2026, 5, 1), ts(2026, 5, 1), ts(2026, 5, 2)], memoUpdatedTimestamps: [ts(2026, 5, 3), ts(2026, 5, 3), ts(2026, 5, 3)], tagCount: {}, }, isLoading: false, } as ReturnType); }); afterEach(() => { vi.clearAllMocks(); }); it("aggregates by created timestamps when timeBasis is create_time", () => { mockUseView.mockReturnValue({ timeBasis: "create_time", orderByTimeAsc: false, toggleSortOrder: vi.fn(), setTimeBasis: vi.fn() }); const { result } = renderHook(() => useFilteredMemoStats({ userName: "users/test" }), { wrapper }); expect(result.current.statistics.activityStats).toEqual({ "2026-05-01": 2, "2026-05-02": 1 }); expect(result.current.statistics.timeBasis).toBe("create_time"); }); it("aggregates by updated timestamps when timeBasis is update_time", () => { mockUseView.mockReturnValue({ timeBasis: "update_time", orderByTimeAsc: false, toggleSortOrder: vi.fn(), setTimeBasis: vi.fn() }); const { result } = renderHook(() => useFilteredMemoStats({ userName: "users/test" }), { wrapper }); expect(result.current.statistics.activityStats).toEqual({ "2026-05-03": 3 }); expect(result.current.statistics.timeBasis).toBe("update_time"); }); it("falls back to created timestamps when updated array is empty (old server)", () => { // Old servers that don't know about the new field deserialize it as []. // Length-divergence between created and updated is the reliable signal. vi.mocked(useUserStats).mockReturnValue({ data: { memoCreatedTimestamps: [ts(2026, 5, 1)], memoUpdatedTimestamps: [], tagCount: {}, }, isLoading: false, } as ReturnType); mockUseView.mockReturnValue({ timeBasis: "update_time", orderByTimeAsc: false, toggleSortOrder: vi.fn(), setTimeBasis: vi.fn() }); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const { result } = renderHook(() => useFilteredMemoStats({ userName: "users/test" }), { wrapper }); expect(result.current.statistics.activityStats).toEqual({ "2026-05-01": 1 }); expect(warn).toHaveBeenCalled(); warn.mockRestore(); }); }); ``` - [ ] **Step 2: Run the test** ```bash cd web && pnpm test filtered-memo-stats ``` Expected: 3/3 PASS. If `JSX.Element` is unavailable in the test env (depends on `@testing-library/react` version), change `wrapper`'s return type to `any` or wrap children in a `<>` fragment via `React.createElement`. The intent is "render-only wrapper, no provider needed because we mocked useView". - [ ] **Step 3: Commit** ```bash git add web/tests/filtered-memo-stats.test.ts git commit -m "test(activity-calendar): cover timeBasis source switching" ``` --- ## Task 11: Manual verification & final lint - [ ] **Step 1: Run all frontend checks** ```bash cd web && pnpm lint && pnpm test ``` Expected: lint clean, all tests PASS. - [ ] **Step 2: Run all backend checks** ```bash go test -v -race ./server/router/api/v1/test/... -run "UserStats" golangci-lint run ``` Expected: tests PASS, lint clean. - [ ] **Step 3: Manual smoke test in dev** In two terminals: ```bash # terminal 1 go run ./cmd/memos --port 8081 # terminal 2 cd web && pnpm dev ``` Open `http://localhost:3001`, sign in, then: 1. Create a memo (today). Verify the heatmap shows +1 today. 2. Edit yesterday's memo (or any past memo, then immediately edit it). 3. Find the time-basis toggle (in the existing view settings; persists in `localStorage` under `memos-view-setting`). Switch to `update_time`. 4. **Expected**: the heatmap recomputes; the edited memo now shows on its `updated_ts` day, not its `created_ts` day. Tooltip text says "X memos updated on YYYY-MM-DD" (lowercase per `getTooltipText`). 5. Switch back to `create_time`. Heatmap reverts; tooltip says "X memos in YYYY-MM-DD". If no UI control exists for the toggle, set it manually: ```js // In browser console: localStorage.setItem("memos-view-setting", JSON.stringify({ orderByTimeAsc: false, timeBasis: "update_time" })); location.reload(); ``` - [ ] **Step 4: No-op commit if nothing changed** If steps 1-3 surfaced fixes, commit each fix. Otherwise nothing more to commit.