Fixes the inconsistency where switching the memo list to update_time
left the activity heatmap aggregating by created_time. The heatmap
now follows the same time basis as the list it sits next to.
Backend
- UserStats gains memo_updated_timestamps (additive proto field, tag 8).
- GetUserStats and ListAllUserStats populate it alongside the existing
memo_created_timestamps. No DB migration; memo.updated_ts already
exists on every row.
Frontend
- useFilteredMemoStats reads timeBasis from ViewContext and selects
the matching timestamp source.
- StatisticsView and MonthNavigator forward timeBasis through to
MonthCalendar / YearCalendar so tooltip text matches the basis
("X memos in DATE" vs "X memos updated on DATE").
- Falls back to memoCreatedTimestamps when an old server returns an
empty memoUpdatedTimestamps array (detected by length divergence,
since protobuf-es deserializes missing repeated fields as []).
Tests
- Backend: TestGetUserStats_MemoUpdatedTimestamps verifies the field
is populated and reflects post-creation updates.
- Frontend: filtered-memo-stats covers create/update source switching
and the old-server fallback path; activity-calendar-tooltip covers
basis-aware label selection.
Spec and implementation plan committed under docs/superpowers/.
104 lines
3.5 KiB
TypeScript
104 lines
3.5 KiB
TypeScript
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<typeof import("@/contexts/ViewContext")>("@/contexts/ViewContext");
|
|
return {
|
|
...actual,
|
|
useView: () => mockUseView(),
|
|
};
|
|
});
|
|
|
|
import { useUserStats } from "@/hooks/useUserQueries";
|
|
import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
|
|
|
|
const wrapper = ({ children }: { children: ReactNode }) => children as never;
|
|
|
|
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<typeof useUserStats>);
|
|
});
|
|
|
|
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 (created non-empty, updated empty) is the signal.
|
|
vi.mocked(useUserStats).mockReturnValue({
|
|
data: {
|
|
memoCreatedTimestamps: [ts(2026, 5, 1)],
|
|
memoUpdatedTimestamps: [],
|
|
tagCount: {},
|
|
},
|
|
isLoading: false,
|
|
} as ReturnType<typeof useUserStats>);
|
|
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();
|
|
});
|
|
});
|