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/.
pull/5925/head
Steven 4 weeks ago
parent ea0625da45
commit 8daef1dc89

@ -0,0 +1,124 @@
# ActivityCalendar: Honor `timeBasis` (Create vs Update)
## Problem
The ActivityCalendar in `web/src/components/ActivityCalendar/` always aggregates by memo creation time, regardless of how the surrounding memo list is sorted.
The application already supports a global "time basis" toggle (`web/src/contexts/ViewContext.tsx:3`):
```ts
export type MemoTimeBasis = "create_time" | "update_time";
```
The toggle is persisted in `localStorage` and drives memo list ordering across the app. When a user switches the list to `update_time`, the heatmap below it continues to show creation counts — the two views literally disagree about what "today" means.
This is the user-visible bug we are fixing.
## Non-goals
The following were considered and explicitly excluded:
- **Tracking every individual edit event.** This would require resurrecting the `activity` table that was deliberately dropped in migration `0.27/03__drop_activity.sql`. The cost (write-path instrumentation, storage growth, privacy review) is not justified by this UI bug.
- **Tracking archive / restore / delete events.** Housekeeping actions, not contributions; would also leak private behavior on public Profile/Explore pages.
- **Adding comments or reactions to the heatmap count.** A reasonable separate feature, but a different decision (event-type expansion). Out of scope for this spec — one ticket, one problem.
- **Renaming `ActivityCalendar` to `ContributionCalendar`.** Out of scope.
## Design
### Semantics
The heatmap aggregates one timestamp per memo:
- When `timeBasis === "create_time"`: use `memo.created_ts` (current behavior).
- When `timeBasis === "update_time"`: use `memo.updated_ts`.
Each memo contributes exactly one cell of color, on the day of its chosen timestamp. This matches the list view's semantics exactly: in `update_time` mode, a memo edited on 5/1 and again on 5/2 appears once at 5/2 in the list, and the heatmap will show +1 on 5/2 and nothing on 5/1. The "lossiness" is identical to the lossiness already accepted by the list view — so by definition, the two are consistent.
### Backend
`UserStats` (proto/api/v1/user_service.proto) gains one field:
```proto
// The latest update timestamps of the user's memos.
repeated google.protobuf.Timestamp memo_updated_timestamps = 8;
```
The implementation mirrors `memo_created_timestamps` in `server/router/api/v1/user_service_stats.go`: in the same loop that appends `memo.CreatedTs` (line 115), also append `memo.UpdatedTs` to a parallel slice. The same `FindMemo` filters apply automatically: `RowStatus: NORMAL` (archived excluded), `ExcludeComments: true`, and the viewer-based visibility filter. Both `GetUserStats` and `ListAllUserStats` paths must be updated symmetrically.
No DB migration. No new tables. No new write paths.
### Frontend
`web/src/hooks/useFilteredMemoStats.ts` reads `useView().timeBasis` and switches its data source:
- `create_time``userStats.memoCreatedTimestamps` (today's behavior, untouched)
- `update_time``userStats.memoUpdatedTimestamps`
The `explore` context branch (which derives stats from the in-memory memo list rather than `userStats`) applies the same switch using `memo.createTime` vs `memo.updateTime` from the cached memos.
The `MonthCalendar` / `YearCalendar` components themselves require no changes — they receive an opaque `Record<date, count>` and render it. The change is confined to the data-source layer.
### Tooltip / labeling
A small but necessary clarification for the user: the cell tooltip should reflect which basis is active, e.g.
- `create_time` mode: "3 memos on May 2"
- `update_time` mode: "3 memos updated on May 2"
This belongs in `ActivityCalendar/utils.ts:getTooltipText`, which already takes a `t` translator. Add a `timeBasis` argument and pick the right i18n key.
## Components
| Unit | Responsibility | Depends on |
|---|---|---|
| `UserStats` proto | Carry both timestamp arrays | — |
| `GetUserStats` server impl | Populate both arrays from `memo` table | store |
| `useFilteredMemoStats` | Pick the correct array based on `timeBasis`; aggregate by day | `useView`, `useUserStats`, `useMemos` |
| `getTooltipText` | Render basis-aware tooltip | i18n |
| `MonthCalendar` / `YearCalendar` | Unchanged — render `Record<date, count>` | — |
## Data flow
```
ViewContext.timeBasis ──┐
useFilteredMemoStats ── pick array ── countBy(day) ── Record<date,count> ── MonthCalendar
userStats ───────────┤ (memo_created_timestamps OR memo_updated_timestamps)
memos cache ─────────┘ (createTime OR updateTime — explore context only)
```
## Error handling
No new failure modes.
`protobuf-es` generates `repeated` fields as non-optional `T[]`, so an older server that doesn't populate the new field deserializes it as `[]` (never `undefined`). Naïvely treating empty as "no data" would be wrong, because a user with zero memos also gets `[]`. Detection uses **length divergence**: since `memo.updated_ts` is initialized to `created_ts` at row creation, the two arrays are the same length whenever there are any memos. So:
- `created.length === 0 && updated.length === 0` — user has no memos, render empty.
- `created.length > 0 && updated.length === created.length` — new server, normal path.
- `created.length > 0 && updated.length === 0` — old server, fall back to `memoCreatedTimestamps` regardless of `timeBasis`, with a one-line `console.warn`.
## Testing
- Unit test `useFilteredMemoStats`: given a fixed `userStats`, switching `timeBasis` returns aggregations matching the expected source array.
- Unit test the new `getTooltipText` branch.
- Manual verification: in dev, toggle the global time basis and confirm:
- Heatmap recomputes
- A memo edited yesterday but created last week shows up "yesterday" in update mode and "last week" in create mode
- Tooltip text reflects the basis
## Migration / compatibility
- Proto field is additive (tag 8 is unused; tag 2 is `reserved`).
- Old clients ignore the new field.
- New clients tolerate old servers via the fallback above.
- No DB migration.
- No data backfill — `updated_ts` already exists on every memo row.
## Out-of-scope follow-ups (not part of this work)
These came up during brainstorming and are tracked here only so they aren't lost:
1. Adding comment / reaction event types to the heatmap count.
2. A "Memo History / Versions" feature (per-edit snapshots, diffs, optional commit messages). If pursued, the heatmap would become a downstream consumer of that history, and the field added here may be revisited.

@ -366,6 +366,11 @@ message UserStats {
// The creation timestamps of the user's memos.
repeated google.protobuf.Timestamp memo_created_timestamps = 7;
// 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 pinned memos of the user.
repeated string pinned_memos = 5;

@ -855,6 +855,10 @@ type UserStats struct {
TagCount map[string]int32 `protobuf:"bytes,4,rep,name=tag_count,json=tagCount,proto3" json:"tag_count,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"`
// The creation timestamps of the user's memos.
MemoCreatedTimestamps []*timestamppb.Timestamp `protobuf:"bytes,7,rep,name=memo_created_timestamps,json=memoCreatedTimestamps,proto3" json:"memo_created_timestamps,omitempty"`
// 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.
MemoUpdatedTimestamps []*timestamppb.Timestamp `protobuf:"bytes,8,rep,name=memo_updated_timestamps,json=memoUpdatedTimestamps,proto3" json:"memo_updated_timestamps,omitempty"`
// The pinned memos of the user.
PinnedMemos []string `protobuf:"bytes,5,rep,name=pinned_memos,json=pinnedMemos,proto3" json:"pinned_memos,omitempty"`
// Total memo count.
@ -921,6 +925,13 @@ func (x *UserStats) GetMemoCreatedTimestamps() []*timestamppb.Timestamp {
return nil
}
func (x *UserStats) GetMemoUpdatedTimestamps() []*timestamppb.Timestamp {
if x != nil {
return x.MemoUpdatedTimestamps
}
return nil
}
func (x *UserStats) GetPinnedMemos() []string {
if x != nil {
return x.PinnedMemos
@ -3172,12 +3183,13 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\x11DeleteUserRequest\x12-\n" +
"\x04name\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" +
"\x11memos.api.v1/UserR\x04name\x12\x19\n" +
"\x05force\x18\x02 \x01(\bB\x03\xe0A\x01R\x05force\"\x83\x05\n" +
"\x05force\x18\x02 \x01(\bB\x03\xe0A\x01R\x05force\"\xd7\x05\n" +
"\tUserStats\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12M\n" +
"\x0fmemo_type_stats\x18\x03 \x01(\v2%.memos.api.v1.UserStats.MemoTypeStatsR\rmemoTypeStats\x12B\n" +
"\ttag_count\x18\x04 \x03(\v2%.memos.api.v1.UserStats.TagCountEntryR\btagCount\x12R\n" +
"\x17memo_created_timestamps\x18\a \x03(\v2\x1a.google.protobuf.TimestampR\x15memoCreatedTimestamps\x12!\n" +
"\x17memo_created_timestamps\x18\a \x03(\v2\x1a.google.protobuf.TimestampR\x15memoCreatedTimestamps\x12R\n" +
"\x17memo_updated_timestamps\x18\b \x03(\v2\x1a.google.protobuf.TimestampR\x15memoUpdatedTimestamps\x12!\n" +
"\fpinned_memos\x18\x05 \x03(\tR\vpinnedMemos\x12(\n" +
"\x10total_memo_count\x18\x06 \x01(\x05R\x0etotalMemoCount\x1a;\n" +
"\rTagCountEntry\x12\x10\n" +
@ -3478,89 +3490,90 @@ var file_api_v1_user_service_proto_depIdxs = []int32{
46, // 10: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats
45, // 11: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry
52, // 12: memos.api.v1.UserStats.memo_created_timestamps:type_name -> google.protobuf.Timestamp
13, // 13: memos.api.v1.ListAllUserStatsResponse.stats:type_name -> memos.api.v1.UserStats
47, // 14: memos.api.v1.UserSetting.general_setting:type_name -> memos.api.v1.UserSetting.GeneralSetting
48, // 15: memos.api.v1.UserSetting.webhooks_setting:type_name -> memos.api.v1.UserSetting.WebhooksSetting
17, // 16: memos.api.v1.UpdateUserSettingRequest.setting:type_name -> memos.api.v1.UserSetting
53, // 17: memos.api.v1.UpdateUserSettingRequest.update_mask:type_name -> google.protobuf.FieldMask
17, // 18: memos.api.v1.ListUserSettingsResponse.settings:type_name -> memos.api.v1.UserSetting
22, // 19: memos.api.v1.ListLinkedIdentitiesResponse.linked_identities:type_name -> memos.api.v1.LinkedIdentity
52, // 20: memos.api.v1.PersonalAccessToken.created_at:type_name -> google.protobuf.Timestamp
52, // 21: memos.api.v1.PersonalAccessToken.expires_at:type_name -> google.protobuf.Timestamp
52, // 22: memos.api.v1.PersonalAccessToken.last_used_at:type_name -> google.protobuf.Timestamp
28, // 23: memos.api.v1.ListPersonalAccessTokensResponse.personal_access_tokens:type_name -> memos.api.v1.PersonalAccessToken
28, // 24: memos.api.v1.CreatePersonalAccessTokenResponse.personal_access_token:type_name -> memos.api.v1.PersonalAccessToken
52, // 25: memos.api.v1.UserWebhook.create_time:type_name -> google.protobuf.Timestamp
52, // 26: memos.api.v1.UserWebhook.update_time:type_name -> google.protobuf.Timestamp
34, // 27: memos.api.v1.ListUserWebhooksResponse.webhooks:type_name -> memos.api.v1.UserWebhook
34, // 28: memos.api.v1.CreateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
34, // 29: memos.api.v1.UpdateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
53, // 30: memos.api.v1.UpdateUserWebhookRequest.update_mask:type_name -> google.protobuf.FieldMask
4, // 31: memos.api.v1.UserNotification.sender_user:type_name -> memos.api.v1.User
2, // 32: memos.api.v1.UserNotification.status:type_name -> memos.api.v1.UserNotification.Status
52, // 33: memos.api.v1.UserNotification.create_time:type_name -> google.protobuf.Timestamp
3, // 34: memos.api.v1.UserNotification.type:type_name -> memos.api.v1.UserNotification.Type
49, // 35: memos.api.v1.UserNotification.memo_comment:type_name -> memos.api.v1.UserNotification.MemoCommentPayload
50, // 36: memos.api.v1.UserNotification.memo_mention:type_name -> memos.api.v1.UserNotification.MemoMentionPayload
40, // 37: memos.api.v1.ListUserNotificationsResponse.notifications:type_name -> memos.api.v1.UserNotification
40, // 38: memos.api.v1.UpdateUserNotificationRequest.notification:type_name -> memos.api.v1.UserNotification
53, // 39: memos.api.v1.UpdateUserNotificationRequest.update_mask:type_name -> google.protobuf.FieldMask
34, // 40: memos.api.v1.UserSetting.WebhooksSetting.webhooks:type_name -> memos.api.v1.UserWebhook
5, // 41: memos.api.v1.UserService.ListUsers:input_type -> memos.api.v1.ListUsersRequest
7, // 42: memos.api.v1.UserService.BatchGetUsers:input_type -> memos.api.v1.BatchGetUsersRequest
9, // 43: memos.api.v1.UserService.GetUser:input_type -> memos.api.v1.GetUserRequest
10, // 44: memos.api.v1.UserService.CreateUser:input_type -> memos.api.v1.CreateUserRequest
11, // 45: memos.api.v1.UserService.UpdateUser:input_type -> memos.api.v1.UpdateUserRequest
12, // 46: memos.api.v1.UserService.DeleteUser:input_type -> memos.api.v1.DeleteUserRequest
15, // 47: memos.api.v1.UserService.ListAllUserStats:input_type -> memos.api.v1.ListAllUserStatsRequest
14, // 48: memos.api.v1.UserService.GetUserStats:input_type -> memos.api.v1.GetUserStatsRequest
18, // 49: memos.api.v1.UserService.GetUserSetting:input_type -> memos.api.v1.GetUserSettingRequest
19, // 50: memos.api.v1.UserService.UpdateUserSetting:input_type -> memos.api.v1.UpdateUserSettingRequest
20, // 51: memos.api.v1.UserService.ListUserSettings:input_type -> memos.api.v1.ListUserSettingsRequest
23, // 52: memos.api.v1.UserService.ListLinkedIdentities:input_type -> memos.api.v1.ListLinkedIdentitiesRequest
25, // 53: memos.api.v1.UserService.CreateLinkedIdentity:input_type -> memos.api.v1.CreateLinkedIdentityRequest
26, // 54: memos.api.v1.UserService.GetLinkedIdentity:input_type -> memos.api.v1.GetLinkedIdentityRequest
27, // 55: memos.api.v1.UserService.DeleteLinkedIdentity:input_type -> memos.api.v1.DeleteLinkedIdentityRequest
29, // 56: memos.api.v1.UserService.ListPersonalAccessTokens:input_type -> memos.api.v1.ListPersonalAccessTokensRequest
31, // 57: memos.api.v1.UserService.CreatePersonalAccessToken:input_type -> memos.api.v1.CreatePersonalAccessTokenRequest
33, // 58: memos.api.v1.UserService.DeletePersonalAccessToken:input_type -> memos.api.v1.DeletePersonalAccessTokenRequest
35, // 59: memos.api.v1.UserService.ListUserWebhooks:input_type -> memos.api.v1.ListUserWebhooksRequest
37, // 60: memos.api.v1.UserService.CreateUserWebhook:input_type -> memos.api.v1.CreateUserWebhookRequest
38, // 61: memos.api.v1.UserService.UpdateUserWebhook:input_type -> memos.api.v1.UpdateUserWebhookRequest
39, // 62: memos.api.v1.UserService.DeleteUserWebhook:input_type -> memos.api.v1.DeleteUserWebhookRequest
41, // 63: memos.api.v1.UserService.ListUserNotifications:input_type -> memos.api.v1.ListUserNotificationsRequest
43, // 64: memos.api.v1.UserService.UpdateUserNotification:input_type -> memos.api.v1.UpdateUserNotificationRequest
44, // 65: memos.api.v1.UserService.DeleteUserNotification:input_type -> memos.api.v1.DeleteUserNotificationRequest
6, // 66: memos.api.v1.UserService.ListUsers:output_type -> memos.api.v1.ListUsersResponse
8, // 67: memos.api.v1.UserService.BatchGetUsers:output_type -> memos.api.v1.BatchGetUsersResponse
4, // 68: memos.api.v1.UserService.GetUser:output_type -> memos.api.v1.User
4, // 69: memos.api.v1.UserService.CreateUser:output_type -> memos.api.v1.User
4, // 70: memos.api.v1.UserService.UpdateUser:output_type -> memos.api.v1.User
54, // 71: memos.api.v1.UserService.DeleteUser:output_type -> google.protobuf.Empty
16, // 72: memos.api.v1.UserService.ListAllUserStats:output_type -> memos.api.v1.ListAllUserStatsResponse
13, // 73: memos.api.v1.UserService.GetUserStats:output_type -> memos.api.v1.UserStats
17, // 74: memos.api.v1.UserService.GetUserSetting:output_type -> memos.api.v1.UserSetting
17, // 75: memos.api.v1.UserService.UpdateUserSetting:output_type -> memos.api.v1.UserSetting
21, // 76: memos.api.v1.UserService.ListUserSettings:output_type -> memos.api.v1.ListUserSettingsResponse
24, // 77: memos.api.v1.UserService.ListLinkedIdentities:output_type -> memos.api.v1.ListLinkedIdentitiesResponse
22, // 78: memos.api.v1.UserService.CreateLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
22, // 79: memos.api.v1.UserService.GetLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
54, // 80: memos.api.v1.UserService.DeleteLinkedIdentity:output_type -> google.protobuf.Empty
30, // 81: memos.api.v1.UserService.ListPersonalAccessTokens:output_type -> memos.api.v1.ListPersonalAccessTokensResponse
32, // 82: memos.api.v1.UserService.CreatePersonalAccessToken:output_type -> memos.api.v1.CreatePersonalAccessTokenResponse
54, // 83: memos.api.v1.UserService.DeletePersonalAccessToken:output_type -> google.protobuf.Empty
36, // 84: memos.api.v1.UserService.ListUserWebhooks:output_type -> memos.api.v1.ListUserWebhooksResponse
34, // 85: memos.api.v1.UserService.CreateUserWebhook:output_type -> memos.api.v1.UserWebhook
34, // 86: memos.api.v1.UserService.UpdateUserWebhook:output_type -> memos.api.v1.UserWebhook
54, // 87: memos.api.v1.UserService.DeleteUserWebhook:output_type -> google.protobuf.Empty
42, // 88: memos.api.v1.UserService.ListUserNotifications:output_type -> memos.api.v1.ListUserNotificationsResponse
40, // 89: memos.api.v1.UserService.UpdateUserNotification:output_type -> memos.api.v1.UserNotification
54, // 90: memos.api.v1.UserService.DeleteUserNotification:output_type -> google.protobuf.Empty
66, // [66:91] is the sub-list for method output_type
41, // [41:66] is the sub-list for method input_type
41, // [41:41] is the sub-list for extension type_name
41, // [41:41] is the sub-list for extension extendee
0, // [0:41] is the sub-list for field type_name
52, // 13: memos.api.v1.UserStats.memo_updated_timestamps:type_name -> google.protobuf.Timestamp
13, // 14: memos.api.v1.ListAllUserStatsResponse.stats:type_name -> memos.api.v1.UserStats
47, // 15: memos.api.v1.UserSetting.general_setting:type_name -> memos.api.v1.UserSetting.GeneralSetting
48, // 16: memos.api.v1.UserSetting.webhooks_setting:type_name -> memos.api.v1.UserSetting.WebhooksSetting
17, // 17: memos.api.v1.UpdateUserSettingRequest.setting:type_name -> memos.api.v1.UserSetting
53, // 18: memos.api.v1.UpdateUserSettingRequest.update_mask:type_name -> google.protobuf.FieldMask
17, // 19: memos.api.v1.ListUserSettingsResponse.settings:type_name -> memos.api.v1.UserSetting
22, // 20: memos.api.v1.ListLinkedIdentitiesResponse.linked_identities:type_name -> memos.api.v1.LinkedIdentity
52, // 21: memos.api.v1.PersonalAccessToken.created_at:type_name -> google.protobuf.Timestamp
52, // 22: memos.api.v1.PersonalAccessToken.expires_at:type_name -> google.protobuf.Timestamp
52, // 23: memos.api.v1.PersonalAccessToken.last_used_at:type_name -> google.protobuf.Timestamp
28, // 24: memos.api.v1.ListPersonalAccessTokensResponse.personal_access_tokens:type_name -> memos.api.v1.PersonalAccessToken
28, // 25: memos.api.v1.CreatePersonalAccessTokenResponse.personal_access_token:type_name -> memos.api.v1.PersonalAccessToken
52, // 26: memos.api.v1.UserWebhook.create_time:type_name -> google.protobuf.Timestamp
52, // 27: memos.api.v1.UserWebhook.update_time:type_name -> google.protobuf.Timestamp
34, // 28: memos.api.v1.ListUserWebhooksResponse.webhooks:type_name -> memos.api.v1.UserWebhook
34, // 29: memos.api.v1.CreateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
34, // 30: memos.api.v1.UpdateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
53, // 31: memos.api.v1.UpdateUserWebhookRequest.update_mask:type_name -> google.protobuf.FieldMask
4, // 32: memos.api.v1.UserNotification.sender_user:type_name -> memos.api.v1.User
2, // 33: memos.api.v1.UserNotification.status:type_name -> memos.api.v1.UserNotification.Status
52, // 34: memos.api.v1.UserNotification.create_time:type_name -> google.protobuf.Timestamp
3, // 35: memos.api.v1.UserNotification.type:type_name -> memos.api.v1.UserNotification.Type
49, // 36: memos.api.v1.UserNotification.memo_comment:type_name -> memos.api.v1.UserNotification.MemoCommentPayload
50, // 37: memos.api.v1.UserNotification.memo_mention:type_name -> memos.api.v1.UserNotification.MemoMentionPayload
40, // 38: memos.api.v1.ListUserNotificationsResponse.notifications:type_name -> memos.api.v1.UserNotification
40, // 39: memos.api.v1.UpdateUserNotificationRequest.notification:type_name -> memos.api.v1.UserNotification
53, // 40: memos.api.v1.UpdateUserNotificationRequest.update_mask:type_name -> google.protobuf.FieldMask
34, // 41: memos.api.v1.UserSetting.WebhooksSetting.webhooks:type_name -> memos.api.v1.UserWebhook
5, // 42: memos.api.v1.UserService.ListUsers:input_type -> memos.api.v1.ListUsersRequest
7, // 43: memos.api.v1.UserService.BatchGetUsers:input_type -> memos.api.v1.BatchGetUsersRequest
9, // 44: memos.api.v1.UserService.GetUser:input_type -> memos.api.v1.GetUserRequest
10, // 45: memos.api.v1.UserService.CreateUser:input_type -> memos.api.v1.CreateUserRequest
11, // 46: memos.api.v1.UserService.UpdateUser:input_type -> memos.api.v1.UpdateUserRequest
12, // 47: memos.api.v1.UserService.DeleteUser:input_type -> memos.api.v1.DeleteUserRequest
15, // 48: memos.api.v1.UserService.ListAllUserStats:input_type -> memos.api.v1.ListAllUserStatsRequest
14, // 49: memos.api.v1.UserService.GetUserStats:input_type -> memos.api.v1.GetUserStatsRequest
18, // 50: memos.api.v1.UserService.GetUserSetting:input_type -> memos.api.v1.GetUserSettingRequest
19, // 51: memos.api.v1.UserService.UpdateUserSetting:input_type -> memos.api.v1.UpdateUserSettingRequest
20, // 52: memos.api.v1.UserService.ListUserSettings:input_type -> memos.api.v1.ListUserSettingsRequest
23, // 53: memos.api.v1.UserService.ListLinkedIdentities:input_type -> memos.api.v1.ListLinkedIdentitiesRequest
25, // 54: memos.api.v1.UserService.CreateLinkedIdentity:input_type -> memos.api.v1.CreateLinkedIdentityRequest
26, // 55: memos.api.v1.UserService.GetLinkedIdentity:input_type -> memos.api.v1.GetLinkedIdentityRequest
27, // 56: memos.api.v1.UserService.DeleteLinkedIdentity:input_type -> memos.api.v1.DeleteLinkedIdentityRequest
29, // 57: memos.api.v1.UserService.ListPersonalAccessTokens:input_type -> memos.api.v1.ListPersonalAccessTokensRequest
31, // 58: memos.api.v1.UserService.CreatePersonalAccessToken:input_type -> memos.api.v1.CreatePersonalAccessTokenRequest
33, // 59: memos.api.v1.UserService.DeletePersonalAccessToken:input_type -> memos.api.v1.DeletePersonalAccessTokenRequest
35, // 60: memos.api.v1.UserService.ListUserWebhooks:input_type -> memos.api.v1.ListUserWebhooksRequest
37, // 61: memos.api.v1.UserService.CreateUserWebhook:input_type -> memos.api.v1.CreateUserWebhookRequest
38, // 62: memos.api.v1.UserService.UpdateUserWebhook:input_type -> memos.api.v1.UpdateUserWebhookRequest
39, // 63: memos.api.v1.UserService.DeleteUserWebhook:input_type -> memos.api.v1.DeleteUserWebhookRequest
41, // 64: memos.api.v1.UserService.ListUserNotifications:input_type -> memos.api.v1.ListUserNotificationsRequest
43, // 65: memos.api.v1.UserService.UpdateUserNotification:input_type -> memos.api.v1.UpdateUserNotificationRequest
44, // 66: memos.api.v1.UserService.DeleteUserNotification:input_type -> memos.api.v1.DeleteUserNotificationRequest
6, // 67: memos.api.v1.UserService.ListUsers:output_type -> memos.api.v1.ListUsersResponse
8, // 68: memos.api.v1.UserService.BatchGetUsers:output_type -> memos.api.v1.BatchGetUsersResponse
4, // 69: memos.api.v1.UserService.GetUser:output_type -> memos.api.v1.User
4, // 70: memos.api.v1.UserService.CreateUser:output_type -> memos.api.v1.User
4, // 71: memos.api.v1.UserService.UpdateUser:output_type -> memos.api.v1.User
54, // 72: memos.api.v1.UserService.DeleteUser:output_type -> google.protobuf.Empty
16, // 73: memos.api.v1.UserService.ListAllUserStats:output_type -> memos.api.v1.ListAllUserStatsResponse
13, // 74: memos.api.v1.UserService.GetUserStats:output_type -> memos.api.v1.UserStats
17, // 75: memos.api.v1.UserService.GetUserSetting:output_type -> memos.api.v1.UserSetting
17, // 76: memos.api.v1.UserService.UpdateUserSetting:output_type -> memos.api.v1.UserSetting
21, // 77: memos.api.v1.UserService.ListUserSettings:output_type -> memos.api.v1.ListUserSettingsResponse
24, // 78: memos.api.v1.UserService.ListLinkedIdentities:output_type -> memos.api.v1.ListLinkedIdentitiesResponse
22, // 79: memos.api.v1.UserService.CreateLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
22, // 80: memos.api.v1.UserService.GetLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
54, // 81: memos.api.v1.UserService.DeleteLinkedIdentity:output_type -> google.protobuf.Empty
30, // 82: memos.api.v1.UserService.ListPersonalAccessTokens:output_type -> memos.api.v1.ListPersonalAccessTokensResponse
32, // 83: memos.api.v1.UserService.CreatePersonalAccessToken:output_type -> memos.api.v1.CreatePersonalAccessTokenResponse
54, // 84: memos.api.v1.UserService.DeletePersonalAccessToken:output_type -> google.protobuf.Empty
36, // 85: memos.api.v1.UserService.ListUserWebhooks:output_type -> memos.api.v1.ListUserWebhooksResponse
34, // 86: memos.api.v1.UserService.CreateUserWebhook:output_type -> memos.api.v1.UserWebhook
34, // 87: memos.api.v1.UserService.UpdateUserWebhook:output_type -> memos.api.v1.UserWebhook
54, // 88: memos.api.v1.UserService.DeleteUserWebhook:output_type -> google.protobuf.Empty
42, // 89: memos.api.v1.UserService.ListUserNotifications:output_type -> memos.api.v1.ListUserNotificationsResponse
40, // 90: memos.api.v1.UserService.UpdateUserNotification:output_type -> memos.api.v1.UserNotification
54, // 91: memos.api.v1.UserService.DeleteUserNotification:output_type -> google.protobuf.Empty
67, // [67:92] is the sub-list for method output_type
42, // [42:67] is the sub-list for method input_type
42, // [42:42] is the sub-list for extension type_name
42, // [42:42] is the sub-list for extension extendee
0, // [0:42] is the sub-list for field type_name
}
func init() { file_api_v1_user_service_proto_init() }

@ -3809,6 +3809,15 @@ components:
type: string
format: date-time
description: The creation timestamps of the user's memos.
memoUpdatedTimestamps:
type: array
items:
type: string
format: date-time
description: |-
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.
pinnedMemos:
type: array
items:

@ -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,

@ -35,7 +35,17 @@ const WeekdayHeader = memo(({ weekDays, size }: WeekdayHeaderProps) => (
WeekdayHeader.displayName = "WeekdayHeader";
export const MonthCalendar = memo((props: MonthCalendarProps) => {
const { month, data, maxCount, size = "default", onClick, selectedDate, className, disableTooltips = false } = props;
const {
month,
data,
maxCount,
size = "default",
onClick,
selectedDate,
className,
disableTooltips = false,
timeBasis = "create_time",
} = props;
const t = useTranslate();
const { generalSetting } = useInstance();
const today = useTodayDate();
@ -63,7 +73,7 @@ export const MonthCalendar = memo((props: MonthCalendarProps) => {
key={day.date}
day={day}
maxCount={maxCount}
tooltipText={getTooltipText(day.count, day.date, t)}
tooltipText={getTooltipText(day.count, day.date, t, timeBasis)}
onClick={onClick}
size={size}
disableTooltip={disableTooltips}

@ -1,6 +1,7 @@
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { memo, useMemo } from "react";
import { Button } from "@/components/ui/button";
import type { MemoTimeBasis } from "@/contexts/ViewContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { getMaxYear, MIN_YEAR } from "./constants";
@ -73,17 +74,18 @@ interface MonthCardProps {
data: CalendarData;
maxCount: number;
onDateClick: (date: string) => void;
timeBasis?: MemoTimeBasis;
}
const MonthCard = memo(({ month, data, maxCount, onDateClick }: MonthCardProps) => (
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 />
<MonthCalendar month={month} data={data} maxCount={maxCount} size="small" onClick={onDateClick} disableTooltips timeBasis={timeBasis} />
</article>
));
MonthCard.displayName = "MonthCard";
export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClick, className }: YearCalendarProps) => {
export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClick, className, timeBasis }: YearCalendarProps) => {
const currentYear = useMemo(() => new Date().getFullYear(), []);
const yearData = useMemo(() => filterDataByYear(data, selectedYear), [data, selectedYear]);
const months = useMemo(() => generateMonthsForYear(selectedYear), [selectedYear]);
@ -106,7 +108,7 @@ export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClic
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 animate-fade-in">
{months.map((month) => (
<MonthCard key={month} month={month} data={yearData} maxCount={yearMaxCount} onDateClick={onDateClick} />
<MonthCard key={month} month={month} data={yearData} maxCount={yearMaxCount} onDateClick={onDateClick} timeBasis={timeBasis} />
))}
</div>
</section>

@ -1,3 +1,5 @@
import type { MemoTimeBasis } from "@/contexts/ViewContext";
export type CalendarSize = "default" | "small";
export type CalendarData = Record<string, number>;
@ -28,6 +30,7 @@ export interface MonthCalendarProps {
selectedDate?: string;
className?: string;
disableTooltips?: boolean;
timeBasis?: MemoTimeBasis;
}
export interface YearCalendarProps {
@ -36,4 +39,5 @@ export interface YearCalendarProps {
onYearChange: (year: number) => void;
onDateClick: (date: string) => void;
className?: string;
timeBasis?: MemoTimeBasis;
}

@ -1,6 +1,7 @@
import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import type { MemoTimeBasis } from "@/contexts/ViewContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { CELL_STYLES, INTENSITY_THRESHOLDS, MIN_COUNT, MONTHS_IN_YEAR } from "./constants";
@ -60,12 +61,13 @@ export const filterDataByYear = (data: Record<string, number>, year: number): Re
return filtered;
};
export const getTooltipText = (count: number, date: string, t: TranslateFunction): string => {
export const getTooltipText = (count: number, date: string, t: TranslateFunction, timeBasis: MemoTimeBasis = "create_time"): string => {
if (count === 0) {
return date;
}
return t("memo.count-memos-in-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,

@ -8,7 +8,7 @@ import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/
import { addMonths, formatMonth, getMonthFromDate, getYearFromDate, setYearAndMonth } from "@/lib/calendar-utils";
import type { MonthNavigatorProps } from "@/types/statistics";
export const MonthNavigator = memo(({ visibleMonth, onMonthChange, activityStats }: MonthNavigatorProps) => {
export const MonthNavigator = memo(({ visibleMonth, onMonthChange, activityStats, timeBasis }: MonthNavigatorProps) => {
const { i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
@ -59,7 +59,13 @@ export const MonthNavigator = memo(({ visibleMonth, onMonthChange, activityStats
showCloseButton={false}
>
<DialogTitle className="sr-only">Select Month</DialogTitle>
<YearCalendar selectedYear={currentYear} data={activityStats} onYearChange={handleYearChange} onDateClick={handleDateClick} />
<YearCalendar
selectedYear={currentYear}
data={activityStats}
onYearChange={handleYearChange}
onDateClick={handleDateClick}
timeBasis={timeBasis}
/>
</DialogContent>
</Dialog>

@ -11,13 +11,18 @@ interface Props {
const StatisticsView = (props: Props) => {
const { statisticsData } = props;
const { activityStats } = statisticsData;
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} />
<MonthNavigator
visibleMonth={visibleMonthString}
onMonthChange={setVisibleMonthString}
activityStats={activityStats}
timeBasis={timeBasis}
/>
<div className="w-full animate-scale-in">
<MonthCalendar
@ -25,6 +30,7 @@ const StatisticsView = (props: Props) => {
data={activityStats}
maxCount={calculateMaxCount(activityStats)}
onClick={navigateToDateFilter}
timeBasis={timeBasis}
/>
</div>
</div>

@ -3,9 +3,11 @@ 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 {
@ -21,9 +23,15 @@ export interface UseFilteredMemoStatsOptions {
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);
@ -49,15 +57,30 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}):
}
}
const displayDates = (memosResponse?.memos ?? [])
.map((memo) => (memo.createTime ? timestampDate(memo.createTime) : undefined))
.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
if (userStats.memoCreatedTimestamps && userStats.memoCreatedTimestamps.length > 0) {
// home/profile: use backend per-user stats.
//
// protobuf-es generates repeated fields as non-optional T[], so an old
// server that doesn't know the new field deserializes it as []. Since
// memo.updated_ts is initialized to created_ts at row creation, the two
// arrays are always the same length when there are memos. Length
// divergence (created non-empty AND updated empty) therefore reliably
// signals "old server" and is the only case where we fall back.
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(
userStats.memoCreatedTimestamps
sourceArray
.map((ts) => (ts ? timestampDate(ts) : undefined))
.filter((date): date is Date => date !== undefined)
.map(toDateString),
@ -69,7 +92,7 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}):
} else if (memosResponse?.memos) {
// archived/fallback: compute from cached memos
const displayDates = memosResponse.memos
.map((memo) => (memo.createTime ? timestampDate(memo.createTime) : undefined))
.map((memo) => memoTimestampForBasis(memo, timeBasis))
.filter((date): date is Date => date !== undefined)
.map(toDateString);
activityStats = countBy(displayDates);
@ -80,8 +103,8 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}):
}
}
return { statistics: { activityStats }, tags: tagCount, loading };
}, [context, userName, userStats, memosResponse, isLoadingUserStats, isLoadingMemos]);
return { statistics: { activityStats, timeBasis }, tags: tagCount, loading };
}, [context, userName, userStats, memosResponse, isLoadingUserStats, isLoadingMemos, timeBasis]);
return data;
};

@ -231,6 +231,7 @@
"copy-content": "Copy Content",
"copy-link": "Copy Link",
"count-memos-in-date": "{{count}} {{memos}} in {{date}}",
"count-memos-updated-in-date": "{{count}} {{memos}} updated on {{date}}",
"delete-confirm": "Are you sure you want to delete this memo?",
"delete-confirm-description": "This action is irreversible. Attachments, links, and references will also be removed.",
"direction": "Direction",

File diff suppressed because one or more lines are too long

@ -1,3 +1,5 @@
import type { MemoTimeBasis } from "@/contexts/ViewContext";
export interface StatisticsViewProps {
className?: string;
}
@ -6,8 +8,10 @@ export interface MonthNavigatorProps {
visibleMonth: string;
onMonthChange: (month: string) => void;
activityStats: Record<string, number>;
timeBasis: MemoTimeBasis;
}
export interface StatisticsData {
activityStats: Record<string, number>;
timeBasis: MemoTimeBasis;
}

@ -0,0 +1,26 @@
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");
});
});

@ -0,0 +1,104 @@
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();
});
});
Loading…
Cancel
Save