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:
parent
ea0625da45
commit
8daef1dc89
19 changed files with 1524 additions and 105 deletions
1018
docs/superpowers/plans/2026-05-02-activity-calendar-time-basis.md
Normal file
1018
docs/superpowers/plans/2026-05-02-activity-calendar-time-basis.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
26
web/tests/activity-calendar-tooltip.test.ts
Normal file
26
web/tests/activity-calendar-tooltip.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
104
web/tests/filtered-memo-stats.test.ts
Normal file
104
web/tests/filtered-memo-stats.test.ts
Normal file
|
|
@ -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…
Reference in a new issue