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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user