feat(activity-calendar): aggregate by ViewContext.timeBasis

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/.
This commit is contained in:
Steven
2026-05-02 00:26:53 +08:00
parent ea0625da45
commit 8daef1dc89
19 changed files with 1524 additions and 105 deletions
@@ -110,3 +110,51 @@ func TestGetUserStats_TagCount(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "user not found")
}
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)
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)
// SQLite UpdateMemo only sets fields explicitly passed (created_ts default
// fires on INSERT only). So bump updated_ts explicitly to simulate an edit
// happening after creation.
newContent := "second content"
newUpdatedTs := memo.UpdatedTs + 100
require.NoError(t, ts.Store.UpdateMemo(ctx, &store.UpdateMemo{
ID: memo.ID,
Content: &newContent,
UpdatedTs: &newUpdatedTs,
}))
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, memo.CreatedTs, resp.MemoCreatedTimestamps[0].AsTime().Unix())
require.Equal(t, newUpdatedTs, resp.MemoUpdatedTimestamps[0].AsTime().Unix())
require.Greater(
t,
resp.MemoUpdatedTimestamps[0].AsTime().Unix(),
resp.MemoCreatedTimestamps[0].AsTime().Unix(),
"updated_ts should be after created_ts after an edit",
)
}
@@ -100,6 +100,7 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser
Name: "",
TagCount: make(map[string]int32),
MemoCreatedTimestamps: []*timestamppb.Timestamp{},
MemoUpdatedTimestamps: []*timestamppb.Timestamp{},
PinnedMemos: []string{},
MemoTypeStats: &v1pb.UserStats_MemoTypeStats{
LinkCount: 0,
@@ -113,6 +114,7 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser
stats := userMemoStatMap[memo.CreatorID]
stats.MemoCreatedTimestamps = append(stats.MemoCreatedTimestamps, timestamppb.New(time.Unix(memo.CreatedTs, 0)))
stats.MemoUpdatedTimestamps = append(stats.MemoUpdatedTimestamps, timestamppb.New(time.Unix(memo.UpdatedTs, 0)))
// Count memo stats
stats.TotalMemoCount++
@@ -205,6 +207,7 @@ func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserSt
}
createdTimestamps := []*timestamppb.Timestamp{}
updatedTimestamps := []*timestamppb.Timestamp{}
tagCount := make(map[string]int32)
linkCount := int32(0)
codeCount := int32(0)
@@ -231,6 +234,7 @@ func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserSt
for _, memo := range memos {
createdTimestamps = append(createdTimestamps, timestamppb.New(time.Unix(memo.CreatedTs, 0)))
updatedTimestamps = append(updatedTimestamps, timestamppb.New(time.Unix(memo.UpdatedTs, 0)))
// Count different memo types based on content.
if memo.Payload != nil {
for _, tag := range memo.Payload.Tags {
@@ -262,6 +266,7 @@ func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserSt
userStats := &v1pb.UserStats{
Name: fmt.Sprintf("%s/stats", BuildUserName(user.Username)),
MemoCreatedTimestamps: createdTimestamps,
MemoUpdatedTimestamps: updatedTimestamps,
TagCount: tagCount,
PinnedMemos: pinnedMemos,
TotalMemoCount: totalMemoCount,