docs/superpowers/plans/2026-05-02-activity-calendar-time-basis.md
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
Backend
proto/api/v1/user_service.proto (add field)proto/gen/api/v1/user_service.pb.go (regenerated)web/src/types/proto/api/v1/user_service_pb.ts (regenerated)server/router/api/v1/user_service_stats.go (populate field in both functions)server/router/api/v1/test/user_service_stats_test.go (assert new field)Frontend types & util
web/src/components/ActivityCalendar/types.ts (add MemoTimeBasis import, prop)web/src/components/ActivityCalendar/utils.ts (basis-aware tooltip)web/src/components/ActivityCalendar/MonthCalendar.tsx (accept & forward timeBasis)web/src/components/ActivityCalendar/YearCalendar.tsx (accept & forward timeBasis)Frontend data
web/src/hooks/useFilteredMemoStats.ts (read timeBasis, switch source)web/src/types/statistics.ts (add timeBasis to StatisticsData and MonthNavigatorProps)web/src/components/StatisticsView/StatisticsView.tsx (forward timeBasis)web/src/components/StatisticsView/MonthNavigator.tsx (forward timeBasis)Frontend i18n
web/src/locales/en.json (add count-memos-updated-in-date key)Frontend tests
web/tests/activity-calendar-tooltip.test.tsweb/tests/filtered-memo-stats.test.tsmemo_updated_timestamps field to UserStats protoFiles:
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:
// 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).
Run from the repo root:
(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:
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.
go build ./...
Expected: success with no errors.
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.)
MemoUpdatedTimestamps in GetUserStats (backend)Files:
server/router/api/v1/user_service_stats.go:177-277The existing GetUserStats function (line 177) collects createdTimestamps from each memo. We add a parallel updatedTimestamps.
In server/router/api/v1/user_service_stats.go, find line 207:
createdTimestamps := []*timestamppb.Timestamp{}
Replace with:
createdTimestamps := []*timestamppb.Timestamp{}
updatedTimestamps := []*timestamppb.Timestamp{}
UpdatedTs inside the memo loopFind line 233 (inside for _, memo := range memos):
createdTimestamps = append(createdTimestamps, timestamppb.New(time.Unix(memo.CreatedTs, 0)))
Replace with:
createdTimestamps = append(createdTimestamps, timestamppb.New(time.Unix(memo.CreatedTs, 0)))
updatedTimestamps = append(updatedTimestamps, timestamppb.New(time.Unix(memo.UpdatedTs, 0)))
Find line 262-274 (the userStats := &v1pb.UserStats{...} literal). Add the new field next to MemoCreatedTimestamps:
Before:
userStats := &v1pb.UserStats{
Name: fmt.Sprintf("%s/stats", BuildUserName(user.Username)),
MemoCreatedTimestamps: createdTimestamps,
TagCount: tagCount,
...
After:
userStats := &v1pb.UserStats{
Name: fmt.Sprintf("%s/stats", BuildUserName(user.Username)),
MemoCreatedTimestamps: createdTimestamps,
MemoUpdatedTimestamps: updatedTimestamps,
TagCount: tagCount,
...
go build ./server/router/api/v1/...
Expected: success.
MemoUpdatedTimestamps in ListAllUserStats (backend)Files:
server/router/api/v1/user_service_stats.go:56-175ListAllUserStats (line 56) builds a per-user stats map across all users. We mirror Task 2 inside this loop.
Find lines 99-110 (the userMemoStatMap[memo.CreatorID] = &v1pb.UserStats{...} literal). Add MemoUpdatedTimestamps: []*timestamppb.Timestamp{}, next to MemoCreatedTimestamps:
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,
},
}
Find line 115:
stats.MemoCreatedTimestamps = append(stats.MemoCreatedTimestamps, timestamppb.New(time.Unix(memo.CreatedTs, 0)))
Replace with:
stats.MemoCreatedTimestamps = append(stats.MemoCreatedTimestamps, timestamppb.New(time.Unix(memo.CreatedTs, 0)))
stats.MemoUpdatedTimestamps = append(stats.MemoUpdatedTimestamps, timestamppb.New(time.Unix(memo.UpdatedTs, 0)))
go build ./server/router/api/v1/...
Expected: success.
git add server/router/api/v1/user_service_stats.go
git commit -m "feat(user_service): populate memo_updated_timestamps in user stats"
MemoUpdatedTimestamps is populatedFiles:
server/router/api/v1/test/user_service_stats_test.goAdd a TDD-style test that creates a memo, updates it, and verifies both timestamp arrays are populated correctly.
Append this function to server/router/api/v1/test/user_service_stats_test.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",
)
}
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."
git add server/router/api/v1/test/user_service_stats_test.go
git commit -m "test(user_service): cover memo_updated_timestamps in stats"
MemoTimeBasis to ActivityCalendar typesFiles:
web/src/components/ActivityCalendar/types.tsThe calendar components currently take data: CalendarData. We add an optional timeBasis prop so the tooltip can label correctly.
Open web/src/components/ActivityCalendar/types.ts. At the top, add an import:
import type { MemoTimeBasis } from "@/contexts/ViewContext";
In MonthCalendarProps (around line 22), add an optional field:
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:
export interface YearCalendarProps {
selectedYear: number;
data: CalendarData;
onYearChange: (year: number) => void;
onDateClick: (date: string) => void;
className?: string;
timeBasis?: MemoTimeBasis;
}
cd web && pnpm lint
Expected: PASS (no implementations consume the new prop yet — that's fine, it's optional).
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:
"count-memos-in-date": "{{count}} {{memos}} in {{date}}",
Add a sibling key right after it:
"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.)
getTooltipText to take a timeBasisIn web/src/components/ActivityCalendar/utils.ts, replace lines 63-73:
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:
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.)
Create web/tests/activity-calendar-tooltip.test.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<string, unknown>) => {
if (!vars) return key;
const parts = Object.entries(vars).map(([k, v]) => `${k}=${String(v)}`);
return `${key}|${parts.join(",")}`;
}) as Parameters<typeof getTooltipText>[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");
});
});
cd web && pnpm test activity-calendar-tooltip
Expected: 3/3 PASS.
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"
timeBasis through MonthCalendar and YearCalendarFiles:
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:
const { month, data, maxCount, size = "default", onClick, selectedDate, className, disableTooltips = false } = props;
Replace with:
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:
tooltipText={getTooltipText(day.count, day.date, t)}
Replace with:
tooltipText={getTooltipText(day.count, day.date, t, timeBasis)}
timeBasis from YearCalendar into the inner MonthCalendarIn web/src/components/ActivityCalendar/YearCalendar.tsx, YearCalendar is currently:
export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClick, className }: YearCalendarProps) => {
Replace with:
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:
interface MonthCardProps {
month: string;
data: CalendarData;
maxCount: number;
onDateClick: (date: string) => void;
}
const MonthCard = memo(({ month, data, maxCount, onDateClick }: MonthCardProps) => (
<article className="flex flex-col gap-2 rounded-xl border border-border/20 bg-muted/5 p-3 transition-colors hover:bg-muted/10">
<header className="text-[10px] font-medium text-muted-foreground/80 uppercase tracking-widest">{getMonthLabel(month)}</header>
<MonthCalendar month={month} data={data} maxCount={maxCount} size="small" onClick={onDateClick} disableTooltips />
</article>
));
After:
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) => (
<article className="flex flex-col gap-2 rounded-xl border border-border/20 bg-muted/5 p-3 transition-colors hover:bg-muted/10">
<header className="text-[10px] font-medium text-muted-foreground/80 uppercase tracking-widest">{getMonthLabel(month)}</header>
<MonthCalendar month={month} data={data} maxCount={maxCount} size="small" onClick={onDateClick} disableTooltips timeBasis={timeBasis} />
</article>
));
(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:
{months.map((month) => (
<MonthCard key={month} month={month} data={yearData} maxCount={yearMaxCount} onDateClick={onDateClick} />
))}
After:
{months.map((month) => (
<MonthCard key={month} month={month} data={yearData} maxCount={yearMaxCount} onDateClick={onDateClick} timeBasis={timeBasis} />
))}
cd web && pnpm lint
Expected: PASS.
git add web/src/components/ActivityCalendar/MonthCalendar.tsx web/src/components/ActivityCalendar/YearCalendar.tsx
git commit -m "feat(activity-calendar): forward timeBasis to tooltip"
useFilteredMemoStats source by timeBasisFiles:
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:
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<string, number>;
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<string, number> = {};
let tagCount: Record<string, number> = {};
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;
};
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.
timeBasis to StatisticsData and forward through StatisticsView/MonthNavigatorFiles:
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:
import type { MemoTimeBasis } from "@/contexts/ViewContext";
export interface StatisticsViewProps {
className?: string;
}
export interface MonthNavigatorProps {
visibleMonth: string;
onMonthChange: (month: string) => void;
activityStats: Record<string, number>;
timeBasis: MemoTimeBasis;
}
export interface StatisticsData {
activityStats: Record<string, number>;
timeBasis: MemoTimeBasis;
}
timeBasis from StatisticsViewOpen web/src/components/StatisticsView/StatisticsView.tsx. The current body (line 12-32) destructures activityStats from statisticsData. Replace with:
const StatisticsView = (props: Props) => {
const { statisticsData } = props;
const { activityStats, timeBasis } = statisticsData;
const navigateToDateFilter = useDateFilterNavigation();
const [visibleMonthString, setVisibleMonthString] = useState(dayjs().format("YYYY-MM"));
return (
<div className="group w-full mt-2 flex flex-col text-muted-foreground animate-fade-in">
<MonthNavigator
visibleMonth={visibleMonthString}
onMonthChange={setVisibleMonthString}
activityStats={activityStats}
timeBasis={timeBasis}
/>
<div className="w-full animate-scale-in">
<MonthCalendar
month={visibleMonthString}
data={activityStats}
maxCount={calculateMaxCount(activityStats)}
onClick={navigateToDateFilter}
timeBasis={timeBasis}
/>
</div>
</div>
);
};
timeBasis from MonthNavigator into YearCalendarOpen web/src/components/StatisticsView/MonthNavigator.tsx.
Replace the destructured prop list (line 11):
export const MonthNavigator = memo(({ visibleMonth, onMonthChange, activityStats }: MonthNavigatorProps) => {
With:
export const MonthNavigator = memo(({ visibleMonth, onMonthChange, activityStats, timeBasis }: MonthNavigatorProps) => {
Then update the <YearCalendar ... /> call (line 62). Before:
<YearCalendar selectedYear={currentYear} data={activityStats} onYearChange={handleYearChange} onDateClick={handleDateClick} />
After:
<YearCalendar
selectedYear={currentYear}
data={activityStats}
onYearChange={handleYearChange}
onDateClick={handleDateClick}
timeBasis={timeBasis}
/>
cd web && pnpm lint
Expected: PASS.
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"
timeBasisFiles:
web/tests/filtered-memo-stats.test.tsWe 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.
Create web/tests/filtered-memo-stats.test.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<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 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<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 between created and updated is the reliable 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();
});
});
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".
git add web/tests/filtered-memo-stats.test.ts
git commit -m "test(activity-calendar): cover timeBasis source switching"
cd web && pnpm lint && pnpm test
Expected: lint clean, all tests PASS.
go test -v -race ./server/router/api/v1/test/... -run "UserStats"
golangci-lint run
Expected: tests PASS, lint clean.
In two terminals:
# terminal 1
go run ./cmd/memos --port 8081
# terminal 2
cd web && pnpm dev
Open http://localhost:3001, sign in, then:
localStorage under memos-view-setting). Switch to update_time.updated_ts day, not its created_ts day. Tooltip text says "X memos updated on YYYY-MM-DD" (lowercase per getTooltipText).create_time. Heatmap reverts; tooltip says "X memos in YYYY-MM-DD".If no UI control exists for the toggle, set it manually:
// In browser console:
localStorage.setItem("memos-view-setting", JSON.stringify({ orderByTimeAsc: false, timeBasis: "update_time" }));
location.reload();
If steps 1-3 surfaced fixes, commit each fix. Otherwise nothing more to commit.