From 8daef1dc89544512fff78fd64d8216b2babc2e42 Mon Sep 17 00:00:00 2001 From: Steven Date: Sat, 2 May 2026 00:26:53 +0800 Subject: [PATCH] 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/. --- ...2026-05-02-activity-calendar-time-basis.md | 1018 +++++++++++++++++ ...-02-activity-calendar-time-basis-design.md | 124 ++ proto/api/v1/user_service.proto | 5 + proto/gen/api/v1/user_service.pb.go | 183 +-- proto/gen/openapi.yaml | 9 + .../api/v1/test/user_service_stats_test.go | 48 + server/router/api/v1/user_service_stats.go | 5 + .../ActivityCalendar/MonthCalendar.tsx | 14 +- .../ActivityCalendar/YearCalendar.tsx | 10 +- web/src/components/ActivityCalendar/types.ts | 4 + web/src/components/ActivityCalendar/utils.ts | 6 +- .../StatisticsView/MonthNavigator.tsx | 10 +- .../StatisticsView/StatisticsView.tsx | 10 +- web/src/hooks/useFilteredMemoStats.ts | 37 +- web/src/locales/en.json | 1 + web/src/types/proto/api/v1/user_service_pb.ts | 11 +- web/src/types/statistics.ts | 4 + web/tests/activity-calendar-tooltip.test.ts | 26 + web/tests/filtered-memo-stats.test.ts | 104 ++ 19 files changed, 1524 insertions(+), 105 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-02-activity-calendar-time-basis.md create mode 100644 docs/superpowers/specs/2026-05-02-activity-calendar-time-basis-design.md create mode 100644 web/tests/activity-calendar-tooltip.test.ts create mode 100644 web/tests/filtered-memo-stats.test.ts diff --git a/docs/superpowers/plans/2026-05-02-activity-calendar-time-basis.md b/docs/superpowers/plans/2026-05-02-activity-calendar-time-basis.md new file mode 100644 index 000000000..2bd54a69c --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-activity-calendar-time-basis.md @@ -0,0 +1,1018 @@ +# ActivityCalendar Time Basis Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the heatmap aggregation follow `ViewContext.timeBasis` so the calendar and the memo list agree about "today" when sorting by `update_time`. + +**Architecture:** Add a parallel `memo_updated_timestamps` field to `UserStats`, populate it in the backend stats path the same way `memo_created_timestamps` is populated. On the frontend, `useFilteredMemoStats` reads `timeBasis` from `ViewContext` and picks the matching array; the resulting `timeBasis` is propagated down through `StatisticsView` → `MonthCalendar`/`YearCalendar` so the tooltip text reflects the active basis. No DB migration; the only schema change is one additive proto field. + +**Tech Stack:** Go 1.26 / Protobuf / Connect RPC (backend), React 18 + TypeScript 6 + React Query v5 + i18next (frontend), Vitest (frontend tests), Go testify with TestContainers (backend tests). + +**Spec:** `docs/superpowers/specs/2026-05-02-activity-calendar-time-basis-design.md` + +--- + +## File Map + +**Backend** +- Modify: `proto/api/v1/user_service.proto` (add field) +- Generated: `proto/gen/api/v1/user_service.pb.go` (regenerated) +- Generated: `web/src/types/proto/api/v1/user_service_pb.ts` (regenerated) +- Modify: `server/router/api/v1/user_service_stats.go` (populate field in both functions) +- Modify: `server/router/api/v1/test/user_service_stats_test.go` (assert new field) + +**Frontend types & util** +- Modify: `web/src/components/ActivityCalendar/types.ts` (add `MemoTimeBasis` import, prop) +- Modify: `web/src/components/ActivityCalendar/utils.ts` (basis-aware tooltip) +- Modify: `web/src/components/ActivityCalendar/MonthCalendar.tsx` (accept & forward `timeBasis`) +- Modify: `web/src/components/ActivityCalendar/YearCalendar.tsx` (accept & forward `timeBasis`) + +**Frontend data** +- Modify: `web/src/hooks/useFilteredMemoStats.ts` (read `timeBasis`, switch source) +- Modify: `web/src/types/statistics.ts` (add `timeBasis` to `StatisticsData` and `MonthNavigatorProps`) +- Modify: `web/src/components/StatisticsView/StatisticsView.tsx` (forward `timeBasis`) +- Modify: `web/src/components/StatisticsView/MonthNavigator.tsx` (forward `timeBasis`) + +**Frontend i18n** +- Modify: `web/src/locales/en.json` (add `count-memos-updated-in-date` key) + +**Frontend tests** +- Create: `web/tests/activity-calendar-tooltip.test.ts` +- Create: `web/tests/filtered-memo-stats.test.ts` + +--- + +## Task 1: Add `memo_updated_timestamps` field to `UserStats` proto + +**Files:** +- Modify: `proto/api/v1/user_service.proto` +- Generated (do not hand-edit): `proto/gen/api/v1/user_service.pb.go`, `web/src/types/proto/api/v1/user_service_pb.ts` + +- [ ] **Step 1: Add the field** + +Open `proto/api/v1/user_service.proto`. Find the `UserStats` message (around line 345). After the existing `memo_created_timestamps` line (currently `repeated google.protobuf.Timestamp memo_created_timestamps = 7;`), add: + +```proto + // The latest update timestamps of the user's memos (one per memo, + // mirrors memo_created_timestamps). Used by the activity heatmap when + // the client's view is set to update_time basis. + repeated google.protobuf.Timestamp memo_updated_timestamps = 8; +``` + +The `total_memo_count = 6` field stays at tag 6; this new field claims tag 8 (tag 2 is reserved per the existing `reserved 2;` line). + +- [ ] **Step 2: Regenerate proto code** + +Run from the repo root: + +```bash +(cd proto && buf generate) +``` + +Expected: regenerates `proto/gen/api/v1/user_service.pb.go` and `web/src/types/proto/api/v1/user_service_pb.ts` (plus OpenAPI). Verify `memoUpdatedTimestamps` appears in the TS file: + +```bash +grep -n "memoUpdatedTimestamps\|memo_updated_timestamps" web/src/types/proto/api/v1/user_service_pb.ts +``` + +Expected output: matches in both the doc comment and the field declaration around the existing `memoCreatedTimestamps` block. + +- [ ] **Step 3: Verify Go compiles** + +```bash +go build ./... +``` + +Expected: success with no errors. + +- [ ] **Step 4: Commit** + +```bash +git add proto/api/v1/user_service.proto proto/gen/api/v1/user_service.pb.go web/src/types/proto/api/v1/user_service_pb.ts proto/gen/apidocs.swagger.yaml +git commit -m "proto(user_service): add memo_updated_timestamps to UserStats" +``` + +(If `buf generate` touches additional files, include them in the same commit.) + +--- + +## Task 2: Populate `MemoUpdatedTimestamps` in `GetUserStats` (backend) + +**Files:** +- Modify: `server/router/api/v1/user_service_stats.go:177-277` + +The existing `GetUserStats` function (line 177) collects `createdTimestamps` from each memo. We add a parallel `updatedTimestamps`. + +- [ ] **Step 1: Add the parallel slice declaration** + +In `server/router/api/v1/user_service_stats.go`, find line 207: + +```go + createdTimestamps := []*timestamppb.Timestamp{} +``` + +Replace with: + +```go + createdTimestamps := []*timestamppb.Timestamp{} + updatedTimestamps := []*timestamppb.Timestamp{} +``` + +- [ ] **Step 2: Append `UpdatedTs` inside the memo loop** + +Find line 233 (inside `for _, memo := range memos`): + +```go + createdTimestamps = append(createdTimestamps, timestamppb.New(time.Unix(memo.CreatedTs, 0))) +``` + +Replace with: + +```go + createdTimestamps = append(createdTimestamps, timestamppb.New(time.Unix(memo.CreatedTs, 0))) + updatedTimestamps = append(updatedTimestamps, timestamppb.New(time.Unix(memo.UpdatedTs, 0))) +``` + +- [ ] **Step 3: Set the new field on the returned struct** + +Find line 262-274 (the `userStats := &v1pb.UserStats{...}` literal). Add the new field next to `MemoCreatedTimestamps`: + +Before: + +```go + userStats := &v1pb.UserStats{ + Name: fmt.Sprintf("%s/stats", BuildUserName(user.Username)), + MemoCreatedTimestamps: createdTimestamps, + TagCount: tagCount, + ... +``` + +After: + +```go + userStats := &v1pb.UserStats{ + Name: fmt.Sprintf("%s/stats", BuildUserName(user.Username)), + MemoCreatedTimestamps: createdTimestamps, + MemoUpdatedTimestamps: updatedTimestamps, + TagCount: tagCount, + ... +``` + +- [ ] **Step 4: Build and verify** + +```bash +go build ./server/router/api/v1/... +``` + +Expected: success. + +--- + +## Task 3: Populate `MemoUpdatedTimestamps` in `ListAllUserStats` (backend) + +**Files:** +- Modify: `server/router/api/v1/user_service_stats.go:56-175` + +`ListAllUserStats` (line 56) builds a per-user stats map across all users. We mirror Task 2 inside this loop. + +- [ ] **Step 1: Initialize the slice on first sight of a user** + +Find lines 99-110 (the `userMemoStatMap[memo.CreatorID] = &v1pb.UserStats{...}` literal). Add `MemoUpdatedTimestamps: []*timestamppb.Timestamp{},` next to `MemoCreatedTimestamps`: + +```go + userMemoStatMap[memo.CreatorID] = &v1pb.UserStats{ + Name: "", + TagCount: make(map[string]int32), + MemoCreatedTimestamps: []*timestamppb.Timestamp{}, + MemoUpdatedTimestamps: []*timestamppb.Timestamp{}, + PinnedMemos: []string{}, + MemoTypeStats: &v1pb.UserStats_MemoTypeStats{ + LinkCount: 0, + CodeCount: 0, + TodoCount: 0, + UndoCount: 0, + }, + } +``` + +- [ ] **Step 2: Append in the per-memo loop** + +Find line 115: + +```go + stats.MemoCreatedTimestamps = append(stats.MemoCreatedTimestamps, timestamppb.New(time.Unix(memo.CreatedTs, 0))) +``` + +Replace with: + +```go + stats.MemoCreatedTimestamps = append(stats.MemoCreatedTimestamps, timestamppb.New(time.Unix(memo.CreatedTs, 0))) + stats.MemoUpdatedTimestamps = append(stats.MemoUpdatedTimestamps, timestamppb.New(time.Unix(memo.UpdatedTs, 0))) +``` + +- [ ] **Step 3: Build** + +```bash +go build ./server/router/api/v1/... +``` + +Expected: success. + +- [ ] **Step 4: Commit Tasks 2 + 3 together** + +```bash +git add server/router/api/v1/user_service_stats.go +git commit -m "feat(user_service): populate memo_updated_timestamps in user stats" +``` + +--- + +## Task 4: Backend test — assert `MemoUpdatedTimestamps` is populated + +**Files:** +- Modify: `server/router/api/v1/test/user_service_stats_test.go` + +Add a TDD-style test that creates a memo, updates it, and verifies both timestamp arrays are populated correctly. + +- [ ] **Step 1: Write the failing test** + +Append this function to `server/router/api/v1/test/user_service_stats_test.go`: + +```go +func TestGetUserStats_MemoUpdatedTimestamps(t *testing.T) { + ctx := context.Background() + + ts := NewTestService(t) + defer ts.Cleanup() + + user, err := ts.CreateHostUser(ctx, "ts-user") + require.NoError(t, err) + userCtx := ts.CreateUserContext(ctx, user.ID) + + // Create one memo. Its created_ts and updated_ts are equal at creation. + memo, err := ts.Store.CreateMemo(ctx, &store.Memo{ + UID: "ts-memo-1", + CreatorID: user.ID, + Content: "first content", + Visibility: store.Public, + }) + require.NoError(t, err) + require.NotNil(t, memo) + + // Bump updated_ts by updating content. + newContent := "second content" + updatedMemo, err := ts.Store.UpdateMemo(ctx, &store.UpdateMemo{ + ID: memo.ID, + Content: &newContent, + }) + require.NoError(t, err) + require.NotNil(t, updatedMemo) + + userName := fmt.Sprintf("users/%s", user.Username) + resp, err := ts.Service.GetUserStats(userCtx, &v1pb.GetUserStatsRequest{Name: userName}) + require.NoError(t, err) + require.NotNil(t, resp) + + require.Len(t, resp.MemoCreatedTimestamps, 1, "should have one created timestamp") + require.Len(t, resp.MemoUpdatedTimestamps, 1, "should have one updated timestamp") + + require.Equal(t, updatedMemo.CreatedTs, resp.MemoCreatedTimestamps[0].AsTime().Unix()) + require.Equal(t, updatedMemo.UpdatedTs, resp.MemoUpdatedTimestamps[0].AsTime().Unix()) + require.GreaterOrEqual( + t, + resp.MemoUpdatedTimestamps[0].AsTime().Unix(), + resp.MemoCreatedTimestamps[0].AsTime().Unix(), + "updated_ts should be at or after created_ts", + ) +} +``` + +- [ ] **Step 2: Verify it passes (Tasks 2/3 already implement the behavior)** + +```bash +go test -v -race -run TestGetUserStats_MemoUpdatedTimestamps ./server/router/api/v1/test/... +``` + +Expected: PASS. + +If it fails because `store.UpdateMemo` has a different signature, inspect the existing usage in this same test file or in `store/memo.go` and adjust. The intent is "create a memo, update it, then read stats." + +- [ ] **Step 3: Commit** + +```bash +git add server/router/api/v1/test/user_service_stats_test.go +git commit -m "test(user_service): cover memo_updated_timestamps in stats" +``` + +--- + +## Task 5: Add `MemoTimeBasis` to ActivityCalendar types + +**Files:** +- Modify: `web/src/components/ActivityCalendar/types.ts` + +The calendar components currently take `data: CalendarData`. We add an optional `timeBasis` prop so the tooltip can label correctly. + +- [ ] **Step 1: Re-export the basis type and add to props** + +Open `web/src/components/ActivityCalendar/types.ts`. At the top, add an import: + +```ts +import type { MemoTimeBasis } from "@/contexts/ViewContext"; +``` + +In `MonthCalendarProps` (around line 22), add an optional field: + +```ts +export interface MonthCalendarProps { + month: string; + data: CalendarData; + maxCount: number; + size?: CalendarSize; + onClick?: (date: string) => void; + selectedDate?: string; + className?: string; + disableTooltips?: boolean; + timeBasis?: MemoTimeBasis; +} +``` + +In `YearCalendarProps` (around line 33), add the same field: + +```ts +export interface YearCalendarProps { + selectedYear: number; + data: CalendarData; + onYearChange: (year: number) => void; + onDateClick: (date: string) => void; + className?: string; + timeBasis?: MemoTimeBasis; +} +``` + +- [ ] **Step 2: Type-check** + +```bash +cd web && pnpm lint +``` + +Expected: PASS (no implementations consume the new prop yet — that's fine, it's optional). + +--- + +## Task 6: Add basis-aware tooltip text + i18n key + +**Files:** +- Modify: `web/src/locales/en.json:233` +- Modify: `web/src/components/ActivityCalendar/utils.ts:63-73` + +- [ ] **Step 1: Add the new English locale key** + +Open `web/src/locales/en.json`. Find line 233: + +```json + "count-memos-in-date": "{{count}} {{memos}} in {{date}}", +``` + +Add a sibling key right after it: + +```json + "count-memos-in-date": "{{count}} {{memos}} in {{date}}", + "count-memos-updated-in-date": "{{count}} {{memos}} updated on {{date}}", +``` + +(Other locale files keep using only `count-memos-in-date`; i18next will fall back to English for the new key, matching the project's existing fallback strategy.) + +- [ ] **Step 2: Update `getTooltipText` to take a `timeBasis`** + +In `web/src/components/ActivityCalendar/utils.ts`, replace lines 63-73: + +```ts +export const getTooltipText = (count: number, date: string, t: TranslateFunction): string => { + if (count === 0) { + return date; + } + + return t("memo.count-memos-in-date", { + count, + memos: count === 1 ? t("common.memo") : t("common.memos"), + date, + }).toLowerCase(); +}; +``` + +With: + +```ts +import type { MemoTimeBasis } from "@/contexts/ViewContext"; + +export const getTooltipText = (count: number, date: string, t: TranslateFunction, timeBasis: MemoTimeBasis = "create_time"): string => { + if (count === 0) { + return date; + } + + const key = timeBasis === "update_time" ? "memo.count-memos-updated-in-date" : "memo.count-memos-in-date"; + return t(key, { + count, + memos: count === 1 ? t("common.memo") : t("common.memos"), + date, + }).toLowerCase(); +}; +``` + +(Move the new `import` to the top of the file with the other imports — don't leave it mid-file.) + +- [ ] **Step 3: Write the unit test** + +Create `web/tests/activity-calendar-tooltip.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { getTooltipText } from "@/components/ActivityCalendar/utils"; + +// Minimal stub for the i18n translate fn — returns a deterministic string we can assert on. +const t = ((key: string, vars?: Record) => { + if (!vars) return key; + const parts = Object.entries(vars).map(([k, v]) => `${k}=${String(v)}`); + return `${key}|${parts.join(",")}`; +}) as Parameters[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"); + }); +}); +``` + +- [ ] **Step 4: Run the test** + +```bash +cd web && pnpm test activity-calendar-tooltip +``` + +Expected: 3/3 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/locales/en.json web/src/components/ActivityCalendar/utils.ts web/src/components/ActivityCalendar/types.ts web/tests/activity-calendar-tooltip.test.ts +git commit -m "feat(activity-calendar): basis-aware tooltip text" +``` + +--- + +## Task 7: Forward `timeBasis` through `MonthCalendar` and `YearCalendar` + +**Files:** +- Modify: `web/src/components/ActivityCalendar/MonthCalendar.tsx:37-77` +- Modify: `web/src/components/ActivityCalendar/YearCalendar.tsx:71-114` + +- [ ] **Step 1: Pass `timeBasis` into `getTooltipText` from `MonthCalendar`** + +In `web/src/components/ActivityCalendar/MonthCalendar.tsx`, the destructured props line (around 38) currently reads: + +```tsx +const { month, data, maxCount, size = "default", onClick, selectedDate, className, disableTooltips = false } = props; +``` + +Replace with: + +```tsx +const { month, data, maxCount, size = "default", onClick, selectedDate, className, disableTooltips = false, timeBasis = "create_time" } = props; +``` + +In the `flatDays.map` block (line 61-71), the `getTooltipText` call currently reads: + +```tsx +tooltipText={getTooltipText(day.count, day.date, t)} +``` + +Replace with: + +```tsx +tooltipText={getTooltipText(day.count, day.date, t, timeBasis)} +``` + +- [ ] **Step 2: Pass `timeBasis` from `YearCalendar` into the inner `MonthCalendar`** + +In `web/src/components/ActivityCalendar/YearCalendar.tsx`, `YearCalendar` is currently: + +```tsx +export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClick, className }: YearCalendarProps) => { +``` + +Replace with: + +```tsx +export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClick, className, timeBasis }: YearCalendarProps) => { +``` + +`MonthCard` is the inner component that renders `MonthCalendar`. Update its props type (line 71-76) and component (line 78-83): + +Before: + +```tsx +interface MonthCardProps { + month: string; + data: CalendarData; + maxCount: number; + onDateClick: (date: string) => void; +} + +const MonthCard = memo(({ month, data, maxCount, onDateClick }: MonthCardProps) => ( +
+
{getMonthLabel(month)}
+ +
+)); +``` + +After: + +```tsx +interface MonthCardProps { + month: string; + data: CalendarData; + maxCount: number; + onDateClick: (date: string) => void; + timeBasis?: import("@/contexts/ViewContext").MemoTimeBasis; +} + +const MonthCard = memo(({ month, data, maxCount, onDateClick, timeBasis }: MonthCardProps) => ( +
+
{getMonthLabel(month)}
+ +
+)); +``` + +(Prefer importing `MemoTimeBasis` at the top of the file with other imports rather than the inline `import(...)` form. The inline form above is shown only to avoid forgetting it; relocate it.) + +Then in the `months.map(...)` block (around line 108), pass `timeBasis` down: + +Before: + +```tsx +{months.map((month) => ( + +))} +``` + +After: + +```tsx +{months.map((month) => ( + +))} +``` + +- [ ] **Step 3: Type-check** + +```bash +cd web && pnpm lint +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add web/src/components/ActivityCalendar/MonthCalendar.tsx web/src/components/ActivityCalendar/YearCalendar.tsx +git commit -m "feat(activity-calendar): forward timeBasis to tooltip" +``` + +--- + +## Task 8: Switch `useFilteredMemoStats` source by `timeBasis` + +**Files:** +- Modify: `web/src/hooks/useFilteredMemoStats.ts` + +- [ ] **Step 1: Read `timeBasis` from ViewContext and switch sources** + +Replace the entire body of `web/src/hooks/useFilteredMemoStats.ts` with: + +```ts +import { timestampDate } from "@bufbuild/protobuf/wkt"; +import dayjs from "dayjs"; +import { countBy } from "lodash-es"; +import { useMemo } from "react"; +import type { MemoExplorerContext } from "@/components/MemoExplorer"; +import { type MemoTimeBasis, useView } from "@/contexts/ViewContext"; +import useCurrentUser from "@/hooks/useCurrentUser"; +import { useMemos } from "@/hooks/useMemoQueries"; +import { useUserStats } from "@/hooks/useUserQueries"; +import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; +import type { StatisticsData } from "@/types/statistics"; + +export interface FilteredMemoStats { + statistics: StatisticsData; + tags: Record; + loading: boolean; +} + +export interface UseFilteredMemoStatsOptions { + userName?: string; + context?: MemoExplorerContext; +} + +const toDateString = (date: Date) => dayjs(date).format("YYYY-MM-DD"); + +const memoTimestampForBasis = (memo: Memo, basis: MemoTimeBasis): Date | undefined => { + const ts = basis === "update_time" ? memo.updateTime : memo.createTime; + return ts ? timestampDate(ts) : undefined; +}; + +export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => { + const { userName, context } = options; + const currentUser = useCurrentUser(); + const { timeBasis } = useView(); + + // home/profile: use backend per-user stats (full tag set, not page-limited) + const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(userName); + + // explore: fetch memos with visibility filter to exclude private content. + // ListMemos AND's the request filter with the server's auth filter, so private + // memos are always excluded regardless of backend version. + // other contexts: fetch with default params for the fallback memo-based path. + const exploreVisibilityFilter = currentUser != null ? 'visibility in ["PUBLIC", "PROTECTED"]' : 'visibility in ["PUBLIC"]'; + const memoQueryParams = context === "explore" ? { filter: exploreVisibilityFilter, pageSize: 1000 } : {}; + const { data: memosResponse, isLoading: isLoadingMemos } = useMemos(memoQueryParams); + + const data = useMemo(() => { + const loading = isLoadingUserStats || isLoadingMemos; + let activityStats: Record = {}; + let tagCount: Record = {}; + + if (context === "explore") { + // Tags and activity stats from visibility-filtered memos (no private content). + for (const memo of memosResponse?.memos ?? []) { + for (const tag of memo.tags ?? []) { + tagCount[tag] = (tagCount[tag] ?? 0) + 1; + } + } + const displayDates = (memosResponse?.memos ?? []) + .map((memo) => memoTimestampForBasis(memo, timeBasis)) + .filter((date): date is Date => date !== undefined) + .map(toDateString); + activityStats = countBy(displayDates); + } else if (userName && userStats) { + // home/profile: use backend per-user stats. + // + // Generated protobuf-es types make repeated fields non-optional T[], so an + // old server that doesn't know the new field will deserialize as []. Since + // memo.updated_ts is set to created_ts at row creation, the two arrays are + // expected to be the same length whenever there are memos. Length-based + // detection is therefore reliable: created non-empty AND updated empty + // means "old server". + const createdArray = userStats.memoCreatedTimestamps ?? []; + const updatedArray = userStats.memoUpdatedTimestamps ?? []; + const wantUpdated = timeBasis === "update_time"; + const oldServerFallback = wantUpdated && updatedArray.length === 0 && createdArray.length > 0; + if (oldServerFallback) { + console.warn("UserStats.memo_updated_timestamps not present; falling back to memo_created_timestamps"); + } + const sourceArray = wantUpdated && !oldServerFallback ? updatedArray : createdArray; + if (sourceArray.length > 0) { + activityStats = countBy( + sourceArray + .map((ts) => (ts ? timestampDate(ts) : undefined)) + .filter((date): date is Date => date !== undefined) + .map(toDateString), + ); + } + if (userStats.tagCount) { + tagCount = userStats.tagCount; + } + } else if (memosResponse?.memos) { + // archived/fallback: compute from cached memos + const displayDates = memosResponse.memos + .map((memo) => memoTimestampForBasis(memo, timeBasis)) + .filter((date): date is Date => date !== undefined) + .map(toDateString); + activityStats = countBy(displayDates); + for (const memo of memosResponse.memos) { + for (const tag of memo.tags ?? []) { + tagCount[tag] = (tagCount[tag] || 0) + 1; + } + } + } + + return { statistics: { activityStats, timeBasis }, tags: tagCount, loading }; + }, [context, userName, userStats, memosResponse, isLoadingUserStats, isLoadingMemos, timeBasis]); + + return data; +}; +``` + +- [ ] **Step 2: Type-check** + +```bash +cd web && pnpm lint +``` + +Expected: ONE error pointing at `web/src/types/statistics.ts` because `StatisticsData` doesn't yet have `timeBasis`. That's the next task; do not fix it here, leave the failure visible to drive Task 9. + +If you see other unrelated errors, stop and reconcile. + +--- + +## Task 9: Add `timeBasis` to `StatisticsData` and forward through `StatisticsView`/`MonthNavigator` + +**Files:** +- Modify: `web/src/types/statistics.ts` +- Modify: `web/src/components/StatisticsView/StatisticsView.tsx` +- Modify: `web/src/components/StatisticsView/MonthNavigator.tsx` + +- [ ] **Step 1: Extend the data shape** + +Replace the contents of `web/src/types/statistics.ts` with: + +```ts +import type { MemoTimeBasis } from "@/contexts/ViewContext"; + +export interface StatisticsViewProps { + className?: string; +} + +export interface MonthNavigatorProps { + visibleMonth: string; + onMonthChange: (month: string) => void; + activityStats: Record; + timeBasis: MemoTimeBasis; +} + +export interface StatisticsData { + activityStats: Record; + timeBasis: MemoTimeBasis; +} +``` + +- [ ] **Step 2: Forward `timeBasis` from `StatisticsView`** + +Open `web/src/components/StatisticsView/StatisticsView.tsx`. The current body (line 12-32) destructures `activityStats` from `statisticsData`. Replace with: + +```tsx +const StatisticsView = (props: Props) => { + const { statisticsData } = props; + const { activityStats, timeBasis } = statisticsData; + const navigateToDateFilter = useDateFilterNavigation(); + const [visibleMonthString, setVisibleMonthString] = useState(dayjs().format("YYYY-MM")); + + return ( +
+ + +
+ +
+
+ ); +}; +``` + +- [ ] **Step 3: Forward `timeBasis` from `MonthNavigator` into `YearCalendar`** + +Open `web/src/components/StatisticsView/MonthNavigator.tsx`. + +Replace the destructured prop list (line 11): + +```tsx +export const MonthNavigator = memo(({ visibleMonth, onMonthChange, activityStats }: MonthNavigatorProps) => { +``` + +With: + +```tsx +export const MonthNavigator = memo(({ visibleMonth, onMonthChange, activityStats, timeBasis }: MonthNavigatorProps) => { +``` + +Then update the `` call (line 62). Before: + +```tsx + +``` + +After: + +```tsx + +``` + +- [ ] **Step 4: Type-check** + +```bash +cd web && pnpm lint +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Tasks 8 + 9 together** + +```bash +git add web/src/hooks/useFilteredMemoStats.ts web/src/types/statistics.ts web/src/components/StatisticsView/StatisticsView.tsx web/src/components/StatisticsView/MonthNavigator.tsx +git commit -m "feat(activity-calendar): aggregate by ViewContext.timeBasis" +``` + +--- + +## Task 10: Frontend test — hook switches source by `timeBasis` + +**Files:** +- Create: `web/tests/filtered-memo-stats.test.ts` + +We test the hook indirectly by exercising the data-selection logic on a hand-built `userStats` object. Mocking the React Query hooks and ViewContext keeps the test fast and unit-scoped. + +- [ ] **Step 1: Write the test** + +Create `web/tests/filtered-memo-stats.test.ts`: + +```ts +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock dependencies BEFORE importing the hook under test. +vi.mock("@/hooks/useUserQueries", () => ({ + useUserStats: vi.fn(), +})); +vi.mock("@/hooks/useMemoQueries", () => ({ + useMemos: () => ({ data: undefined, isLoading: false }), +})); +vi.mock("@/hooks/useCurrentUser", () => ({ + default: () => ({ name: "users/test", id: 1 }), +})); + +const mockUseView = vi.fn(); +vi.mock("@/contexts/ViewContext", async () => { + const actual = await vi.importActual("@/contexts/ViewContext"); + return { + ...actual, + useView: () => mockUseView(), + }; +}); + +import { useUserStats } from "@/hooks/useUserQueries"; +import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats"; + +const wrapper = ({ children }: { children: ReactNode }) => children as JSX.Element; + +const ts = (year: number, month: number, day: number) => ({ + seconds: BigInt(Math.floor(Date.UTC(year, month - 1, day) / 1000)), + nanos: 0, +}); + +describe("useFilteredMemoStats", () => { + beforeEach(() => { + vi.mocked(useUserStats).mockReturnValue({ + data: { + memoCreatedTimestamps: [ts(2026, 5, 1), ts(2026, 5, 1), ts(2026, 5, 2)], + memoUpdatedTimestamps: [ts(2026, 5, 3), ts(2026, 5, 3), ts(2026, 5, 3)], + tagCount: {}, + }, + isLoading: false, + } as ReturnType); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("aggregates by created timestamps when timeBasis is create_time", () => { + mockUseView.mockReturnValue({ timeBasis: "create_time", orderByTimeAsc: false, toggleSortOrder: vi.fn(), setTimeBasis: vi.fn() }); + + const { result } = renderHook(() => useFilteredMemoStats({ userName: "users/test" }), { wrapper }); + + expect(result.current.statistics.activityStats).toEqual({ "2026-05-01": 2, "2026-05-02": 1 }); + expect(result.current.statistics.timeBasis).toBe("create_time"); + }); + + it("aggregates by updated timestamps when timeBasis is update_time", () => { + mockUseView.mockReturnValue({ timeBasis: "update_time", orderByTimeAsc: false, toggleSortOrder: vi.fn(), setTimeBasis: vi.fn() }); + + const { result } = renderHook(() => useFilteredMemoStats({ userName: "users/test" }), { wrapper }); + + expect(result.current.statistics.activityStats).toEqual({ "2026-05-03": 3 }); + expect(result.current.statistics.timeBasis).toBe("update_time"); + }); + + it("falls back to created timestamps when updated array is empty (old server)", () => { + // Old servers that don't know about the new field deserialize it as []. + // Length-divergence between created and updated is the reliable signal. + vi.mocked(useUserStats).mockReturnValue({ + data: { + memoCreatedTimestamps: [ts(2026, 5, 1)], + memoUpdatedTimestamps: [], + tagCount: {}, + }, + isLoading: false, + } as ReturnType); + 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(); + }); +}); +``` + +- [ ] **Step 2: Run the test** + +```bash +cd web && pnpm test filtered-memo-stats +``` + +Expected: 3/3 PASS. + +If `JSX.Element` is unavailable in the test env (depends on `@testing-library/react` version), change `wrapper`'s return type to `any` or wrap children in a `<>` fragment via `React.createElement`. The intent is "render-only wrapper, no provider needed because we mocked useView". + +- [ ] **Step 3: Commit** + +```bash +git add web/tests/filtered-memo-stats.test.ts +git commit -m "test(activity-calendar): cover timeBasis source switching" +``` + +--- + +## Task 11: Manual verification & final lint + +- [ ] **Step 1: Run all frontend checks** + +```bash +cd web && pnpm lint && pnpm test +``` + +Expected: lint clean, all tests PASS. + +- [ ] **Step 2: Run all backend checks** + +```bash +go test -v -race ./server/router/api/v1/test/... -run "UserStats" +golangci-lint run +``` + +Expected: tests PASS, lint clean. + +- [ ] **Step 3: Manual smoke test in dev** + +In two terminals: + +```bash +# terminal 1 +go run ./cmd/memos --port 8081 + +# terminal 2 +cd web && pnpm dev +``` + +Open `http://localhost:3001`, sign in, then: + +1. Create a memo (today). Verify the heatmap shows +1 today. +2. Edit yesterday's memo (or any past memo, then immediately edit it). +3. Find the time-basis toggle (in the existing view settings; persists in `localStorage` under `memos-view-setting`). Switch to `update_time`. +4. **Expected**: the heatmap recomputes; the edited memo now shows on its `updated_ts` day, not its `created_ts` day. Tooltip text says "X memos updated on YYYY-MM-DD" (lowercase per `getTooltipText`). +5. Switch back to `create_time`. Heatmap reverts; tooltip says "X memos in YYYY-MM-DD". + +If no UI control exists for the toggle, set it manually: + +```js +// In browser console: +localStorage.setItem("memos-view-setting", JSON.stringify({ orderByTimeAsc: false, timeBasis: "update_time" })); +location.reload(); +``` + +- [ ] **Step 4: No-op commit if nothing changed** + +If steps 1-3 surfaced fixes, commit each fix. Otherwise nothing more to commit. diff --git a/docs/superpowers/specs/2026-05-02-activity-calendar-time-basis-design.md b/docs/superpowers/specs/2026-05-02-activity-calendar-time-basis-design.md new file mode 100644 index 000000000..3212f348a --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-activity-calendar-time-basis-design.md @@ -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` 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` | — | + +## Data flow + +``` +ViewContext.timeBasis ──┐ + ▼ + useFilteredMemoStats ── pick array ── countBy(day) ── Record ── 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. diff --git a/proto/api/v1/user_service.proto b/proto/api/v1/user_service.proto index c6eeb86fc..1511deef5 100644 --- a/proto/api/v1/user_service.proto +++ b/proto/api/v1/user_service.proto @@ -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; diff --git a/proto/gen/api/v1/user_service.pb.go b/proto/gen/api/v1/user_service.pb.go index 39d27ce24..fd48b03bc 100644 --- a/proto/gen/api/v1/user_service.pb.go +++ b/proto/gen/api/v1/user_service.pb.go @@ -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() } diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index ec16f65a5..50668ccaf 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -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: diff --git a/server/router/api/v1/test/user_service_stats_test.go b/server/router/api/v1/test/user_service_stats_test.go index 901191fb6..bac17e137 100644 --- a/server/router/api/v1/test/user_service_stats_test.go +++ b/server/router/api/v1/test/user_service_stats_test.go @@ -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", + ) +} diff --git a/server/router/api/v1/user_service_stats.go b/server/router/api/v1/user_service_stats.go index 73670c294..bc47797f4 100644 --- a/server/router/api/v1/user_service_stats.go +++ b/server/router/api/v1/user_service_stats.go @@ -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, diff --git a/web/src/components/ActivityCalendar/MonthCalendar.tsx b/web/src/components/ActivityCalendar/MonthCalendar.tsx index 3c3e9176c..7c114c316 100644 --- a/web/src/components/ActivityCalendar/MonthCalendar.tsx +++ b/web/src/components/ActivityCalendar/MonthCalendar.tsx @@ -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} diff --git a/web/src/components/ActivityCalendar/YearCalendar.tsx b/web/src/components/ActivityCalendar/YearCalendar.tsx index 6c82faf9f..4e357f16e 100644 --- a/web/src/components/ActivityCalendar/YearCalendar.tsx +++ b/web/src/components/ActivityCalendar/YearCalendar.tsx @@ -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) => (
{getMonthLabel(month)}
- +
)); 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
{months.map((month) => ( - + ))}
diff --git a/web/src/components/ActivityCalendar/types.ts b/web/src/components/ActivityCalendar/types.ts index 119b0d071..b5e4c0438 100644 --- a/web/src/components/ActivityCalendar/types.ts +++ b/web/src/components/ActivityCalendar/types.ts @@ -1,3 +1,5 @@ +import type { MemoTimeBasis } from "@/contexts/ViewContext"; + export type CalendarSize = "default" | "small"; export type CalendarData = Record; @@ -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; } diff --git a/web/src/components/ActivityCalendar/utils.ts b/web/src/components/ActivityCalendar/utils.ts index 368c26abe..60f91b584 100644 --- a/web/src/components/ActivityCalendar/utils.ts +++ b/web/src/components/ActivityCalendar/utils.ts @@ -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, 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, diff --git a/web/src/components/StatisticsView/MonthNavigator.tsx b/web/src/components/StatisticsView/MonthNavigator.tsx index 14af7c8e1..60d8184a3 100644 --- a/web/src/components/StatisticsView/MonthNavigator.tsx +++ b/web/src/components/StatisticsView/MonthNavigator.tsx @@ -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} > Select Month - + diff --git a/web/src/components/StatisticsView/StatisticsView.tsx b/web/src/components/StatisticsView/StatisticsView.tsx index 02b0e6b93..7bea77c85 100644 --- a/web/src/components/StatisticsView/StatisticsView.tsx +++ b/web/src/components/StatisticsView/StatisticsView.tsx @@ -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 (
- +
{ data={activityStats} maxCount={calculateMaxCount(activityStats)} onClick={navigateToDateFilter} + timeBasis={timeBasis} />
diff --git a/web/src/hooks/useFilteredMemoStats.ts b/web/src/hooks/useFilteredMemoStats.ts index 8b8c90bf5..f9f0b4112 100644 --- a/web/src/hooks/useFilteredMemoStats.ts +++ b/web/src/hooks/useFilteredMemoStats.ts @@ -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; }; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 10156590d..7a3ec8a67 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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", diff --git a/web/src/types/proto/api/v1/user_service_pb.ts b/web/src/types/proto/api/v1/user_service_pb.ts index 6f9a52aa4..747ee0fb9 100644 --- a/web/src/types/proto/api/v1/user_service_pb.ts +++ b/web/src/types/proto/api/v1/user_service_pb.ts @@ -18,7 +18,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file api/v1/user_service.proto. */ export const file_api_v1_user_service: GenFile = /*@__PURE__*/ - fileDesc("ChlhcGkvdjEvdXNlcl9zZXJ2aWNlLnByb3RvEgxtZW1vcy5hcGkudjEi1gMKBFVzZXISEQoEbmFtZRgBIAEoCUID4EEIEioKBHJvbGUYAiABKA4yFy5tZW1vcy5hcGkudjEuVXNlci5Sb2xlQgPgQQISFQoIdXNlcm5hbWUYAyABKAlCA+BBAhISCgVlbWFpbBgEIAEoCUID4EEBEhkKDGRpc3BsYXlfbmFtZRgFIAEoCUID4EEBEhcKCmF2YXRhcl91cmwYBiABKAlCA+BBARIYCgtkZXNjcmlwdGlvbhgHIAEoCUID4EEBEhUKCHBhc3N3b3JkGAggASgJQgPgQQQSJwoFc3RhdGUYCSABKA4yEy5tZW1vcy5hcGkudjEuU3RhdGVCA+BBAhI0CgtjcmVhdGVfdGltZRgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAxI0Cgt1cGRhdGVfdGltZRgLIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAyIxCgRSb2xlEhQKEFJPTEVfVU5TUEVDSUZJRUQQABIJCgVBRE1JThACEggKBFVTRVIQAzo36kE0ChFtZW1vcy5hcGkudjEvVXNlchIMdXNlcnMve3VzZXJ9GgRuYW1lKgV1c2VyczIEdXNlciJzChBMaXN0VXNlcnNSZXF1ZXN0EhYKCXBhZ2Vfc2l6ZRgBIAEoBUID4EEBEhcKCnBhZ2VfdG9rZW4YAiABKAlCA+BBARITCgZmaWx0ZXIYAyABKAlCA+BBARIZCgxzaG93X2RlbGV0ZWQYBCABKAhCA+BBASJjChFMaXN0VXNlcnNSZXNwb25zZRIhCgV1c2VycxgBIAMoCzISLm1lbW9zLmFwaS52MS5Vc2VyEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCRISCgp0b3RhbF9zaXplGAMgASgFIikKFEJhdGNoR2V0VXNlcnNSZXF1ZXN0EhEKCXVzZXJuYW1lcxgBIAMoCSI6ChVCYXRjaEdldFVzZXJzUmVzcG9uc2USIQoFdXNlcnMYASADKAsyEi5tZW1vcy5hcGkudjEuVXNlciJtCg5HZXRVc2VyUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEjIKCXJlYWRfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBASKIAQoRQ3JlYXRlVXNlclJlcXVlc3QSKAoEdXNlchgBIAEoCzISLm1lbW9zLmFwaS52MS5Vc2VyQgbgQQLgQQQSFAoHdXNlcl9pZBgCIAEoCUID4EEBEhoKDXZhbGlkYXRlX29ubHkYAyABKAhCA+BBARIXCgpyZXF1ZXN0X2lkGAQgASgJQgPgQQEijAEKEVVwZGF0ZVVzZXJSZXF1ZXN0EiUKBHVzZXIYASABKAsyEi5tZW1vcy5hcGkudjEuVXNlckID4EECEjQKC3VwZGF0ZV9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFza0ID4EECEhoKDWFsbG93X21pc3NpbmcYAyABKAhCA+BBASJQChFEZWxldGVVc2VyUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEhIKBWZvcmNlGAIgASgIQgPgQQEi9wMKCVVzZXJTdGF0cxIRCgRuYW1lGAEgASgJQgPgQQgSPgoPbWVtb190eXBlX3N0YXRzGAMgASgLMiUubWVtb3MuYXBpLnYxLlVzZXJTdGF0cy5NZW1vVHlwZVN0YXRzEjgKCXRhZ19jb3VudBgEIAMoCzIlLm1lbW9zLmFwaS52MS5Vc2VyU3RhdHMuVGFnQ291bnRFbnRyeRI7ChdtZW1vX2NyZWF0ZWRfdGltZXN0YW1wcxgHIAMoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFAoMcGlubmVkX21lbW9zGAUgAygJEhgKEHRvdGFsX21lbW9fY291bnQYBiABKAUaLwoNVGFnQ291bnRFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAU6AjgBGl8KDU1lbW9UeXBlU3RhdHMSEgoKbGlua19jb3VudBgBIAEoBRISCgpjb2RlX2NvdW50GAIgASgFEhIKCnRvZG9fY291bnQYAyABKAUSEgoKdW5kb19jb3VudBgEIAEoBTo/6kE8ChZtZW1vcy5hcGkudjEvVXNlclN0YXRzEgx1c2Vycy97dXNlcn0qCXVzZXJTdGF0czIJdXNlclN0YXRzSgQIAhADUhdtZW1vX2Rpc3BsYXlfdGltZXN0YW1wcyI+ChNHZXRVc2VyU3RhdHNSZXF1ZXN0EicKBG5hbWUYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXIiGQoXTGlzdEFsbFVzZXJTdGF0c1JlcXVlc3QiQgoYTGlzdEFsbFVzZXJTdGF0c1Jlc3BvbnNlEiYKBXN0YXRzGAEgAygLMhcubWVtb3MuYXBpLnYxLlVzZXJTdGF0cyLkAwoLVXNlclNldHRpbmcSEQoEbmFtZRgBIAEoCUID4EEIEkMKD2dlbmVyYWxfc2V0dGluZxgCIAEoCzIoLm1lbW9zLmFwaS52MS5Vc2VyU2V0dGluZy5HZW5lcmFsU2V0dGluZ0gAEkUKEHdlYmhvb2tzX3NldHRpbmcYBSABKAsyKS5tZW1vcy5hcGkudjEuVXNlclNldHRpbmcuV2ViaG9va3NTZXR0aW5nSAAaVwoOR2VuZXJhbFNldHRpbmcSEwoGbG9jYWxlGAEgASgJQgPgQQESHAoPbWVtb192aXNpYmlsaXR5GAMgASgJQgPgQQESEgoFdGhlbWUYBCABKAlCA+BBARo+Cg9XZWJob29rc1NldHRpbmcSKwoId2ViaG9va3MYASADKAsyGS5tZW1vcy5hcGkudjEuVXNlcldlYmhvb2siNQoDS2V5EhMKD0tFWV9VTlNQRUNJRklFRBAAEgsKB0dFTkVSQUwQARIMCghXRUJIT09LUxAEOl3qQVoKGG1lbW9zLmFwaS52MS9Vc2VyU2V0dGluZxIjdXNlcnMve3VzZXJuYW1lfS9zZXR0aW5ncy97c2V0dGluZ30qDHVzZXJTZXR0aW5nczILdXNlclNldHRpbmdCBwoFdmFsdWUiRwoVR2V0VXNlclNldHRpbmdSZXF1ZXN0Ei4KBG5hbWUYASABKAlCIOBBAvpBGgoYbWVtb3MuYXBpLnYxL1VzZXJTZXR0aW5nIoEBChhVcGRhdGVVc2VyU2V0dGluZ1JlcXVlc3QSLwoHc2V0dGluZxgBIAEoCzIZLm1lbW9zLmFwaS52MS5Vc2VyU2V0dGluZ0ID4EECEjQKC3VwZGF0ZV9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFza0ID4EECInUKF0xpc3RVc2VyU2V0dGluZ3NSZXF1ZXN0EikKBnBhcmVudBgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvVXNlchIWCglwYWdlX3NpemUYAiABKAVCA+BBARIXCgpwYWdlX3Rva2VuGAMgASgJQgPgQQEidAoYTGlzdFVzZXJTZXR0aW5nc1Jlc3BvbnNlEisKCHNldHRpbmdzGAEgAygLMhkubWVtb3MuYXBpLnYxLlVzZXJTZXR0aW5nEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCRISCgp0b3RhbF9zaXplGAMgASgFIuoBCg5MaW5rZWRJZGVudGl0eRIRCgRuYW1lGAEgASgJQgPgQQgSNwoIaWRwX25hbWUYAiABKAlCJeBBA/pBHwodbWVtb3MuYXBpLnYxL0lkZW50aXR5UHJvdmlkZXISFwoKZXh0ZXJuX3VpZBgDIAEoCUID4EEDOnPqQXAKG21lbW9zLmFwaS52MS9MaW5rZWRJZGVudGl0eRIvdXNlcnMve3VzZXJ9L2xpbmtlZElkZW50aXRpZXMve2xpbmtlZF9pZGVudGl0eX0qEGxpbmtlZElkZW50aXRpZXMyDmxpbmtlZElkZW50aXR5IkgKG0xpc3RMaW5rZWRJZGVudGl0aWVzUmVxdWVzdBIpCgZwYXJlbnQYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXIiVwocTGlzdExpbmtlZElkZW50aXRpZXNSZXNwb25zZRI3ChFsaW5rZWRfaWRlbnRpdGllcxgBIAMoCzIcLm1lbW9zLmFwaS52MS5MaW5rZWRJZGVudGl0eSLLAQobQ3JlYXRlTGlua2VkSWRlbnRpdHlSZXF1ZXN0EikKBnBhcmVudBgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvVXNlchI3CghpZHBfbmFtZRgCIAEoCUIl4EEC+kEfCh1tZW1vcy5hcGkudjEvSWRlbnRpdHlQcm92aWRlchIRCgRjb2RlGAMgASgJQgPgQQISGQoMcmVkaXJlY3RfdXJpGAQgASgJQgPgQQISGgoNY29kZV92ZXJpZmllchgFIAEoCUID4EEBIk0KGEdldExpbmtlZElkZW50aXR5UmVxdWVzdBIxCgRuYW1lGAEgASgJQiPgQQL6QR0KG21lbW9zLmFwaS52MS9MaW5rZWRJZGVudGl0eSJQChtEZWxldGVMaW5rZWRJZGVudGl0eVJlcXVlc3QSMQoEbmFtZRgBIAEoCUIj4EEC+kEdChttZW1vcy5hcGkudjEvTGlua2VkSWRlbnRpdHki8gIKE1BlcnNvbmFsQWNjZXNzVG9rZW4SEQoEbmFtZRgBIAEoCUID4EEIEhgKC2Rlc2NyaXB0aW9uGAIgASgJQgPgQQESMwoKY3JlYXRlZF9hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAxIzCgpleHBpcmVzX2F0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEBEjUKDGxhc3RfdXNlZF9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAzqMAepBiAEKIG1lbW9zLmFwaS52MS9QZXJzb25hbEFjY2Vzc1Rva2VuEjl1c2Vycy97dXNlcn0vcGVyc29uYWxBY2Nlc3NUb2tlbnMve3BlcnNvbmFsX2FjY2Vzc190b2tlbn0qFHBlcnNvbmFsQWNjZXNzVG9rZW5zMhNwZXJzb25hbEFjY2Vzc1Rva2VuIn0KH0xpc3RQZXJzb25hbEFjY2Vzc1Rva2Vuc1JlcXVlc3QSKQoGcGFyZW50GAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEhYKCXBhZ2Vfc2l6ZRgCIAEoBUID4EEBEhcKCnBhZ2VfdG9rZW4YAyABKAlCA+BBASKSAQogTGlzdFBlcnNvbmFsQWNjZXNzVG9rZW5zUmVzcG9uc2USQQoWcGVyc29uYWxfYWNjZXNzX3Rva2VucxgBIAMoCzIhLm1lbW9zLmFwaS52MS5QZXJzb25hbEFjY2Vzc1Rva2VuEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCRISCgp0b3RhbF9zaXplGAMgASgFIoUBCiBDcmVhdGVQZXJzb25hbEFjY2Vzc1Rva2VuUmVxdWVzdBIpCgZwYXJlbnQYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXISGAoLZGVzY3JpcHRpb24YAiABKAlCA+BBARIcCg9leHBpcmVzX2luX2RheXMYAyABKAVCA+BBASJ0CiFDcmVhdGVQZXJzb25hbEFjY2Vzc1Rva2VuUmVzcG9uc2USQAoVcGVyc29uYWxfYWNjZXNzX3Rva2VuGAEgASgLMiEubWVtb3MuYXBpLnYxLlBlcnNvbmFsQWNjZXNzVG9rZW4SDQoFdG9rZW4YAiABKAkiWgogRGVsZXRlUGVyc29uYWxBY2Nlc3NUb2tlblJlcXVlc3QSNgoEbmFtZRgBIAEoCUIo4EEC+kEiCiBtZW1vcy5hcGkudjEvUGVyc29uYWxBY2Nlc3NUb2tlbiKqAQoLVXNlcldlYmhvb2sSDAoEbmFtZRgBIAEoCRILCgN1cmwYAiABKAkSFAoMZGlzcGxheV9uYW1lGAMgASgJEjQKC2NyZWF0ZV90aW1lGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEDEjQKC3VwZGF0ZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEDIi4KF0xpc3RVc2VyV2ViaG9va3NSZXF1ZXN0EhMKBnBhcmVudBgBIAEoCUID4EECIkcKGExpc3RVc2VyV2ViaG9va3NSZXNwb25zZRIrCgh3ZWJob29rcxgBIAMoCzIZLm1lbW9zLmFwaS52MS5Vc2VyV2ViaG9vayJgChhDcmVhdGVVc2VyV2ViaG9va1JlcXVlc3QSEwoGcGFyZW50GAEgASgJQgPgQQISLwoHd2ViaG9vaxgCIAEoCzIZLm1lbW9zLmFwaS52MS5Vc2VyV2ViaG9va0ID4EECInwKGFVwZGF0ZVVzZXJXZWJob29rUmVxdWVzdBIvCgd3ZWJob29rGAEgASgLMhkubWVtb3MuYXBpLnYxLlVzZXJXZWJob29rQgPgQQISLwoLdXBkYXRlX21hc2sYAiABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrIi0KGERlbGV0ZVVzZXJXZWJob29rUmVxdWVzdBIRCgRuYW1lGAEgASgJQgPgQQIiogcKEFVzZXJOb3RpZmljYXRpb24SFAoEbmFtZRgBIAEoCUIG4EED4EEIEikKBnNlbmRlchgCIAEoCUIZ4EED+kETChFtZW1vcy5hcGkudjEvVXNlchIsCgtzZW5kZXJfdXNlchgIIAEoCzISLm1lbW9zLmFwaS52MS5Vc2VyQgPgQQMSOgoGc3RhdHVzGAMgASgOMiUubWVtb3MuYXBpLnYxLlVzZXJOb3RpZmljYXRpb24uU3RhdHVzQgPgQQESNAoLY3JlYXRlX3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQMSNgoEdHlwZRgFIAEoDjIjLm1lbW9zLmFwaS52MS5Vc2VyTm90aWZpY2F0aW9uLlR5cGVCA+BBAxJOCgxtZW1vX2NvbW1lbnQYBiABKAsyMS5tZW1vcy5hcGkudjEuVXNlck5vdGlmaWNhdGlvbi5NZW1vQ29tbWVudFBheWxvYWRCA+BBA0gAEk4KDG1lbW9fbWVudGlvbhgHIAEoCzIxLm1lbW9zLmFwaS52MS5Vc2VyTm90aWZpY2F0aW9uLk1lbW9NZW50aW9uUGF5bG9hZEID4EEDSAAabAoSTWVtb0NvbW1lbnRQYXlsb2FkEgwKBG1lbW8YASABKAkSFAoMcmVsYXRlZF9tZW1vGAIgASgJEhQKDG1lbW9fc25pcHBldBgDIAEoCRIcChRyZWxhdGVkX21lbW9fc25pcHBldBgEIAEoCRpsChJNZW1vTWVudGlvblBheWxvYWQSDAoEbWVtbxgBIAEoCRIUCgxyZWxhdGVkX21lbW8YAiABKAkSFAoMbWVtb19zbmlwcGV0GAMgASgJEhwKFHJlbGF0ZWRfbWVtb19zbmlwcGV0GAQgASgJIjoKBlN0YXR1cxIWChJTVEFUVVNfVU5TUEVDSUZJRUQQABIKCgZVTlJFQUQQARIMCghBUkNISVZFRBACIkAKBFR5cGUSFAoQVFlQRV9VTlNQRUNJRklFRBAAEhAKDE1FTU9fQ09NTUVOVBABEhAKDE1FTU9fTUVOVElPThACOnDqQW0KHW1lbW9zLmFwaS52MS9Vc2VyTm90aWZpY2F0aW9uEil1c2Vycy97dXNlcn0vbm90aWZpY2F0aW9ucy97bm90aWZpY2F0aW9ufRoEbmFtZSoNbm90aWZpY2F0aW9uczIMbm90aWZpY2F0aW9uQgkKB3BheWxvYWQijwEKHExpc3RVc2VyTm90aWZpY2F0aW9uc1JlcXVlc3QSKQoGcGFyZW50GAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEhYKCXBhZ2Vfc2l6ZRgCIAEoBUID4EEBEhcKCnBhZ2VfdG9rZW4YAyABKAlCA+BBARITCgZmaWx0ZXIYBCABKAlCA+BBASJvCh1MaXN0VXNlck5vdGlmaWNhdGlvbnNSZXNwb25zZRI1Cg1ub3RpZmljYXRpb25zGAEgAygLMh4ubWVtb3MuYXBpLnYxLlVzZXJOb3RpZmljYXRpb24SFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIpABCh1VcGRhdGVVc2VyTm90aWZpY2F0aW9uUmVxdWVzdBI5Cgxub3RpZmljYXRpb24YASABKAsyHi5tZW1vcy5hcGkudjEuVXNlck5vdGlmaWNhdGlvbkID4EECEjQKC3VwZGF0ZV9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFza0ID4EECIlQKHURlbGV0ZVVzZXJOb3RpZmljYXRpb25SZXF1ZXN0EjMKBG5hbWUYASABKAlCJeBBAvpBHwodbWVtb3MuYXBpLnYxL1VzZXJOb3RpZmljYXRpb24ygh0KC1VzZXJTZXJ2aWNlEmMKCUxpc3RVc2VycxIeLm1lbW9zLmFwaS52MS5MaXN0VXNlcnNSZXF1ZXN0Gh8ubWVtb3MuYXBpLnYxLkxpc3RVc2Vyc1Jlc3BvbnNlIhWC0+STAg8SDS9hcGkvdjEvdXNlcnMSewoNQmF0Y2hHZXRVc2VycxIiLm1lbW9zLmFwaS52MS5CYXRjaEdldFVzZXJzUmVxdWVzdBojLm1lbW9zLmFwaS52MS5CYXRjaEdldFVzZXJzUmVzcG9uc2UiIYLT5JMCGzoBKiIWL2FwaS92MS91c2VyczpiYXRjaEdldBJiCgdHZXRVc2VyEhwubWVtb3MuYXBpLnYxLkdldFVzZXJSZXF1ZXN0GhIubWVtb3MuYXBpLnYxLlVzZXIiJdpBBG5hbWWC0+STAhgSFi9hcGkvdjEve25hbWU9dXNlcnMvKn0SZQoKQ3JlYXRlVXNlchIfLm1lbW9zLmFwaS52MS5DcmVhdGVVc2VyUmVxdWVzdBoSLm1lbW9zLmFwaS52MS5Vc2VyIiLaQQR1c2VygtPkkwIVOgR1c2VyIg0vYXBpL3YxL3VzZXJzEn8KClVwZGF0ZVVzZXISHy5tZW1vcy5hcGkudjEuVXBkYXRlVXNlclJlcXVlc3QaEi5tZW1vcy5hcGkudjEuVXNlciI82kEQdXNlcix1cGRhdGVfbWFza4LT5JMCIzoEdXNlcjIbL2FwaS92MS97dXNlci5uYW1lPXVzZXJzLyp9EmwKCkRlbGV0ZVVzZXISHy5tZW1vcy5hcGkudjEuRGVsZXRlVXNlclJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiJdpBBG5hbWWC0+STAhgqFi9hcGkvdjEve25hbWU9dXNlcnMvKn0SfgoQTGlzdEFsbFVzZXJTdGF0cxIlLm1lbW9zLmFwaS52MS5MaXN0QWxsVXNlclN0YXRzUmVxdWVzdBomLm1lbW9zLmFwaS52MS5MaXN0QWxsVXNlclN0YXRzUmVzcG9uc2UiG4LT5JMCFRITL2FwaS92MS91c2VyczpzdGF0cxJ6CgxHZXRVc2VyU3RhdHMSIS5tZW1vcy5hcGkudjEuR2V0VXNlclN0YXRzUmVxdWVzdBoXLm1lbW9zLmFwaS52MS5Vc2VyU3RhdHMiLtpBBG5hbWWC0+STAiESHy9hcGkvdjEve25hbWU9dXNlcnMvKn06Z2V0U3RhdHMSggEKDkdldFVzZXJTZXR0aW5nEiMubWVtb3MuYXBpLnYxLkdldFVzZXJTZXR0aW5nUmVxdWVzdBoZLm1lbW9zLmFwaS52MS5Vc2VyU2V0dGluZyIw2kEEbmFtZYLT5JMCIxIhL2FwaS92MS97bmFtZT11c2Vycy8qL3NldHRpbmdzLyp9EqgBChFVcGRhdGVVc2VyU2V0dGluZxImLm1lbW9zLmFwaS52MS5VcGRhdGVVc2VyU2V0dGluZ1JlcXVlc3QaGS5tZW1vcy5hcGkudjEuVXNlclNldHRpbmciUNpBE3NldHRpbmcsdXBkYXRlX21hc2uC0+STAjQ6B3NldHRpbmcyKS9hcGkvdjEve3NldHRpbmcubmFtZT11c2Vycy8qL3NldHRpbmdzLyp9EpUBChBMaXN0VXNlclNldHRpbmdzEiUubWVtb3MuYXBpLnYxLkxpc3RVc2VyU2V0dGluZ3NSZXF1ZXN0GiYubWVtb3MuYXBpLnYxLkxpc3RVc2VyU2V0dGluZ3NSZXNwb25zZSIy2kEGcGFyZW50gtPkkwIjEiEvYXBpL3YxL3twYXJlbnQ9dXNlcnMvKn0vc2V0dGluZ3MSqQEKFExpc3RMaW5rZWRJZGVudGl0aWVzEikubWVtb3MuYXBpLnYxLkxpc3RMaW5rZWRJZGVudGl0aWVzUmVxdWVzdBoqLm1lbW9zLmFwaS52MS5MaXN0TGlua2VkSWRlbnRpdGllc1Jlc3BvbnNlIjraQQZwYXJlbnSC0+STAisSKS9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9saW5rZWRJZGVudGl0aWVzEqcBChRDcmVhdGVMaW5rZWRJZGVudGl0eRIpLm1lbW9zLmFwaS52MS5DcmVhdGVMaW5rZWRJZGVudGl0eVJlcXVlc3QaHC5tZW1vcy5hcGkudjEuTGlua2VkSWRlbnRpdHkiRtpBD3BhcmVudCxpZHBfbmFtZYLT5JMCLjoBKiIpL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L2xpbmtlZElkZW50aXRpZXMSkwEKEUdldExpbmtlZElkZW50aXR5EiYubWVtb3MuYXBpLnYxLkdldExpbmtlZElkZW50aXR5UmVxdWVzdBocLm1lbW9zLmFwaS52MS5MaW5rZWRJZGVudGl0eSI42kEEbmFtZYLT5JMCKxIpL2FwaS92MS97bmFtZT11c2Vycy8qL2xpbmtlZElkZW50aXRpZXMvKn0SkwEKFERlbGV0ZUxpbmtlZElkZW50aXR5EikubWVtb3MuYXBpLnYxLkRlbGV0ZUxpbmtlZElkZW50aXR5UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSI42kEEbmFtZYLT5JMCKyopL2FwaS92MS97bmFtZT11c2Vycy8qL2xpbmtlZElkZW50aXRpZXMvKn0SuQEKGExpc3RQZXJzb25hbEFjY2Vzc1Rva2VucxItLm1lbW9zLmFwaS52MS5MaXN0UGVyc29uYWxBY2Nlc3NUb2tlbnNSZXF1ZXN0Gi4ubWVtb3MuYXBpLnYxLkxpc3RQZXJzb25hbEFjY2Vzc1Rva2Vuc1Jlc3BvbnNlIj7aQQZwYXJlbnSC0+STAi8SLS9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9wZXJzb25hbEFjY2Vzc1Rva2VucxK2AQoZQ3JlYXRlUGVyc29uYWxBY2Nlc3NUb2tlbhIuLm1lbW9zLmFwaS52MS5DcmVhdGVQZXJzb25hbEFjY2Vzc1Rva2VuUmVxdWVzdBovLm1lbW9zLmFwaS52MS5DcmVhdGVQZXJzb25hbEFjY2Vzc1Rva2VuUmVzcG9uc2UiOILT5JMCMjoBKiItL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3BlcnNvbmFsQWNjZXNzVG9rZW5zEqEBChlEZWxldGVQZXJzb25hbEFjY2Vzc1Rva2VuEi4ubWVtb3MuYXBpLnYxLkRlbGV0ZVBlcnNvbmFsQWNjZXNzVG9rZW5SZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IjzaQQRuYW1lgtPkkwIvKi0vYXBpL3YxL3tuYW1lPXVzZXJzLyovcGVyc29uYWxBY2Nlc3NUb2tlbnMvKn0SlQEKEExpc3RVc2VyV2ViaG9va3MSJS5tZW1vcy5hcGkudjEuTGlzdFVzZXJXZWJob29rc1JlcXVlc3QaJi5tZW1vcy5hcGkudjEuTGlzdFVzZXJXZWJob29rc1Jlc3BvbnNlIjLaQQZwYXJlbnSC0+STAiMSIS9hcGkvdjEve3BhcmVudD11c2Vycy8qfS93ZWJob29rcxKbAQoRQ3JlYXRlVXNlcldlYmhvb2sSJi5tZW1vcy5hcGkudjEuQ3JlYXRlVXNlcldlYmhvb2tSZXF1ZXN0GhkubWVtb3MuYXBpLnYxLlVzZXJXZWJob29rIkPaQQ5wYXJlbnQsd2ViaG9va4LT5JMCLDoHd2ViaG9vayIhL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3dlYmhvb2tzEqgBChFVcGRhdGVVc2VyV2ViaG9vaxImLm1lbW9zLmFwaS52MS5VcGRhdGVVc2VyV2ViaG9va1JlcXVlc3QaGS5tZW1vcy5hcGkudjEuVXNlcldlYmhvb2siUNpBE3dlYmhvb2ssdXBkYXRlX21hc2uC0+STAjQ6B3dlYmhvb2syKS9hcGkvdjEve3dlYmhvb2submFtZT11c2Vycy8qL3dlYmhvb2tzLyp9EoUBChFEZWxldGVVc2VyV2ViaG9vaxImLm1lbW9zLmFwaS52MS5EZWxldGVVc2VyV2ViaG9va1JlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiMNpBBG5hbWWC0+STAiMqIS9hcGkvdjEve25hbWU9dXNlcnMvKi93ZWJob29rcy8qfRKpAQoVTGlzdFVzZXJOb3RpZmljYXRpb25zEioubWVtb3MuYXBpLnYxLkxpc3RVc2VyTm90aWZpY2F0aW9uc1JlcXVlc3QaKy5tZW1vcy5hcGkudjEuTGlzdFVzZXJOb3RpZmljYXRpb25zUmVzcG9uc2UiN9pBBnBhcmVudILT5JMCKBImL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L25vdGlmaWNhdGlvbnMSywEKFlVwZGF0ZVVzZXJOb3RpZmljYXRpb24SKy5tZW1vcy5hcGkudjEuVXBkYXRlVXNlck5vdGlmaWNhdGlvblJlcXVlc3QaHi5tZW1vcy5hcGkudjEuVXNlck5vdGlmaWNhdGlvbiJk2kEYbm90aWZpY2F0aW9uLHVwZGF0ZV9tYXNrgtPkkwJDOgxub3RpZmljYXRpb24yMy9hcGkvdjEve25vdGlmaWNhdGlvbi5uYW1lPXVzZXJzLyovbm90aWZpY2F0aW9ucy8qfRKUAQoWRGVsZXRlVXNlck5vdGlmaWNhdGlvbhIrLm1lbW9zLmFwaS52MS5EZWxldGVVc2VyTm90aWZpY2F0aW9uUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSI12kEEbmFtZYLT5JMCKComL2FwaS92MS97bmFtZT11c2Vycy8qL25vdGlmaWNhdGlvbnMvKn1CqAEKEGNvbS5tZW1vcy5hcGkudjFCEFVzZXJTZXJ2aWNlUHJvdG9QAVowZ2l0aHViLmNvbS91c2VtZW1vcy9tZW1vcy9wcm90by9nZW4vYXBpL3YxO2FwaXYxogIDTUFYqgIMTWVtb3MuQXBpLlYxygIMTWVtb3NcQXBpXFYx4gIYTWVtb3NcQXBpXFYxXEdQQk1ldGFkYXRh6gIOTWVtb3M6OkFwaTo6VjFiBnByb3RvMw", [file_api_v1_common, file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask, file_google_protobuf_timestamp]); + fileDesc("ChlhcGkvdjEvdXNlcl9zZXJ2aWNlLnByb3RvEgxtZW1vcy5hcGkudjEi1gMKBFVzZXISEQoEbmFtZRgBIAEoCUID4EEIEioKBHJvbGUYAiABKA4yFy5tZW1vcy5hcGkudjEuVXNlci5Sb2xlQgPgQQISFQoIdXNlcm5hbWUYAyABKAlCA+BBAhISCgVlbWFpbBgEIAEoCUID4EEBEhkKDGRpc3BsYXlfbmFtZRgFIAEoCUID4EEBEhcKCmF2YXRhcl91cmwYBiABKAlCA+BBARIYCgtkZXNjcmlwdGlvbhgHIAEoCUID4EEBEhUKCHBhc3N3b3JkGAggASgJQgPgQQQSJwoFc3RhdGUYCSABKA4yEy5tZW1vcy5hcGkudjEuU3RhdGVCA+BBAhI0CgtjcmVhdGVfdGltZRgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAxI0Cgt1cGRhdGVfdGltZRgLIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAyIxCgRSb2xlEhQKEFJPTEVfVU5TUEVDSUZJRUQQABIJCgVBRE1JThACEggKBFVTRVIQAzo36kE0ChFtZW1vcy5hcGkudjEvVXNlchIMdXNlcnMve3VzZXJ9GgRuYW1lKgV1c2VyczIEdXNlciJzChBMaXN0VXNlcnNSZXF1ZXN0EhYKCXBhZ2Vfc2l6ZRgBIAEoBUID4EEBEhcKCnBhZ2VfdG9rZW4YAiABKAlCA+BBARITCgZmaWx0ZXIYAyABKAlCA+BBARIZCgxzaG93X2RlbGV0ZWQYBCABKAhCA+BBASJjChFMaXN0VXNlcnNSZXNwb25zZRIhCgV1c2VycxgBIAMoCzISLm1lbW9zLmFwaS52MS5Vc2VyEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCRISCgp0b3RhbF9zaXplGAMgASgFIikKFEJhdGNoR2V0VXNlcnNSZXF1ZXN0EhEKCXVzZXJuYW1lcxgBIAMoCSI6ChVCYXRjaEdldFVzZXJzUmVzcG9uc2USIQoFdXNlcnMYASADKAsyEi5tZW1vcy5hcGkudjEuVXNlciJtCg5HZXRVc2VyUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEjIKCXJlYWRfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBASKIAQoRQ3JlYXRlVXNlclJlcXVlc3QSKAoEdXNlchgBIAEoCzISLm1lbW9zLmFwaS52MS5Vc2VyQgbgQQLgQQQSFAoHdXNlcl9pZBgCIAEoCUID4EEBEhoKDXZhbGlkYXRlX29ubHkYAyABKAhCA+BBARIXCgpyZXF1ZXN0X2lkGAQgASgJQgPgQQEijAEKEVVwZGF0ZVVzZXJSZXF1ZXN0EiUKBHVzZXIYASABKAsyEi5tZW1vcy5hcGkudjEuVXNlckID4EECEjQKC3VwZGF0ZV9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFza0ID4EECEhoKDWFsbG93X21pc3NpbmcYAyABKAhCA+BBASJQChFEZWxldGVVc2VyUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEhIKBWZvcmNlGAIgASgIQgPgQQEitAQKCVVzZXJTdGF0cxIRCgRuYW1lGAEgASgJQgPgQQgSPgoPbWVtb190eXBlX3N0YXRzGAMgASgLMiUubWVtb3MuYXBpLnYxLlVzZXJTdGF0cy5NZW1vVHlwZVN0YXRzEjgKCXRhZ19jb3VudBgEIAMoCzIlLm1lbW9zLmFwaS52MS5Vc2VyU3RhdHMuVGFnQ291bnRFbnRyeRI7ChdtZW1vX2NyZWF0ZWRfdGltZXN0YW1wcxgHIAMoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASOwoXbWVtb191cGRhdGVkX3RpbWVzdGFtcHMYCCADKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhQKDHBpbm5lZF9tZW1vcxgFIAMoCRIYChB0b3RhbF9tZW1vX2NvdW50GAYgASgFGi8KDVRhZ0NvdW50RW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgFOgI4ARpfCg1NZW1vVHlwZVN0YXRzEhIKCmxpbmtfY291bnQYASABKAUSEgoKY29kZV9jb3VudBgCIAEoBRISCgp0b2RvX2NvdW50GAMgASgFEhIKCnVuZG9fY291bnQYBCABKAU6P+pBPAoWbWVtb3MuYXBpLnYxL1VzZXJTdGF0cxIMdXNlcnMve3VzZXJ9Kgl1c2VyU3RhdHMyCXVzZXJTdGF0c0oECAIQA1IXbWVtb19kaXNwbGF5X3RpbWVzdGFtcHMiPgoTR2V0VXNlclN0YXRzUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyIhkKF0xpc3RBbGxVc2VyU3RhdHNSZXF1ZXN0IkIKGExpc3RBbGxVc2VyU3RhdHNSZXNwb25zZRImCgVzdGF0cxgBIAMoCzIXLm1lbW9zLmFwaS52MS5Vc2VyU3RhdHMi5AMKC1VzZXJTZXR0aW5nEhEKBG5hbWUYASABKAlCA+BBCBJDCg9nZW5lcmFsX3NldHRpbmcYAiABKAsyKC5tZW1vcy5hcGkudjEuVXNlclNldHRpbmcuR2VuZXJhbFNldHRpbmdIABJFChB3ZWJob29rc19zZXR0aW5nGAUgASgLMikubWVtb3MuYXBpLnYxLlVzZXJTZXR0aW5nLldlYmhvb2tzU2V0dGluZ0gAGlcKDkdlbmVyYWxTZXR0aW5nEhMKBmxvY2FsZRgBIAEoCUID4EEBEhwKD21lbW9fdmlzaWJpbGl0eRgDIAEoCUID4EEBEhIKBXRoZW1lGAQgASgJQgPgQQEaPgoPV2ViaG9va3NTZXR0aW5nEisKCHdlYmhvb2tzGAEgAygLMhkubWVtb3MuYXBpLnYxLlVzZXJXZWJob29rIjUKA0tleRITCg9LRVlfVU5TUEVDSUZJRUQQABILCgdHRU5FUkFMEAESDAoIV0VCSE9PS1MQBDpd6kFaChhtZW1vcy5hcGkudjEvVXNlclNldHRpbmcSI3VzZXJzL3t1c2VybmFtZX0vc2V0dGluZ3Mve3NldHRpbmd9Kgx1c2VyU2V0dGluZ3MyC3VzZXJTZXR0aW5nQgcKBXZhbHVlIkcKFUdldFVzZXJTZXR0aW5nUmVxdWVzdBIuCgRuYW1lGAEgASgJQiDgQQL6QRoKGG1lbW9zLmFwaS52MS9Vc2VyU2V0dGluZyKBAQoYVXBkYXRlVXNlclNldHRpbmdSZXF1ZXN0Ei8KB3NldHRpbmcYASABKAsyGS5tZW1vcy5hcGkudjEuVXNlclNldHRpbmdCA+BBAhI0Cgt1cGRhdGVfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBAiJ1ChdMaXN0VXNlclNldHRpbmdzUmVxdWVzdBIpCgZwYXJlbnQYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXISFgoJcGFnZV9zaXplGAIgASgFQgPgQQESFwoKcGFnZV90b2tlbhgDIAEoCUID4EEBInQKGExpc3RVc2VyU2V0dGluZ3NSZXNwb25zZRIrCghzZXR0aW5ncxgBIAMoCzIZLm1lbW9zLmFwaS52MS5Vc2VyU2V0dGluZxIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkSEgoKdG90YWxfc2l6ZRgDIAEoBSLqAQoOTGlua2VkSWRlbnRpdHkSEQoEbmFtZRgBIAEoCUID4EEIEjcKCGlkcF9uYW1lGAIgASgJQiXgQQP6QR8KHW1lbW9zLmFwaS52MS9JZGVudGl0eVByb3ZpZGVyEhcKCmV4dGVybl91aWQYAyABKAlCA+BBAzpz6kFwChttZW1vcy5hcGkudjEvTGlua2VkSWRlbnRpdHkSL3VzZXJzL3t1c2VyfS9saW5rZWRJZGVudGl0aWVzL3tsaW5rZWRfaWRlbnRpdHl9KhBsaW5rZWRJZGVudGl0aWVzMg5saW5rZWRJZGVudGl0eSJIChtMaXN0TGlua2VkSWRlbnRpdGllc1JlcXVlc3QSKQoGcGFyZW50GAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyIlcKHExpc3RMaW5rZWRJZGVudGl0aWVzUmVzcG9uc2USNwoRbGlua2VkX2lkZW50aXRpZXMYASADKAsyHC5tZW1vcy5hcGkudjEuTGlua2VkSWRlbnRpdHkiywEKG0NyZWF0ZUxpbmtlZElkZW50aXR5UmVxdWVzdBIpCgZwYXJlbnQYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXISNwoIaWRwX25hbWUYAiABKAlCJeBBAvpBHwodbWVtb3MuYXBpLnYxL0lkZW50aXR5UHJvdmlkZXISEQoEY29kZRgDIAEoCUID4EECEhkKDHJlZGlyZWN0X3VyaRgEIAEoCUID4EECEhoKDWNvZGVfdmVyaWZpZXIYBSABKAlCA+BBASJNChhHZXRMaW5rZWRJZGVudGl0eVJlcXVlc3QSMQoEbmFtZRgBIAEoCUIj4EEC+kEdChttZW1vcy5hcGkudjEvTGlua2VkSWRlbnRpdHkiUAobRGVsZXRlTGlua2VkSWRlbnRpdHlSZXF1ZXN0EjEKBG5hbWUYASABKAlCI+BBAvpBHQobbWVtb3MuYXBpLnYxL0xpbmtlZElkZW50aXR5IvICChNQZXJzb25hbEFjY2Vzc1Rva2VuEhEKBG5hbWUYASABKAlCA+BBCBIYCgtkZXNjcmlwdGlvbhgCIAEoCUID4EEBEjMKCmNyZWF0ZWRfYXQYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQMSMwoKZXhwaXJlc19hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBARI1CgxsYXN0X3VzZWRfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQM6jAHqQYgBCiBtZW1vcy5hcGkudjEvUGVyc29uYWxBY2Nlc3NUb2tlbhI5dXNlcnMve3VzZXJ9L3BlcnNvbmFsQWNjZXNzVG9rZW5zL3twZXJzb25hbF9hY2Nlc3NfdG9rZW59KhRwZXJzb25hbEFjY2Vzc1Rva2VuczITcGVyc29uYWxBY2Nlc3NUb2tlbiJ9Ch9MaXN0UGVyc29uYWxBY2Nlc3NUb2tlbnNSZXF1ZXN0EikKBnBhcmVudBgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvVXNlchIWCglwYWdlX3NpemUYAiABKAVCA+BBARIXCgpwYWdlX3Rva2VuGAMgASgJQgPgQQEikgEKIExpc3RQZXJzb25hbEFjY2Vzc1Rva2Vuc1Jlc3BvbnNlEkEKFnBlcnNvbmFsX2FjY2Vzc190b2tlbnMYASADKAsyIS5tZW1vcy5hcGkudjEuUGVyc29uYWxBY2Nlc3NUb2tlbhIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkSEgoKdG90YWxfc2l6ZRgDIAEoBSKFAQogQ3JlYXRlUGVyc29uYWxBY2Nlc3NUb2tlblJlcXVlc3QSKQoGcGFyZW50GAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEhgKC2Rlc2NyaXB0aW9uGAIgASgJQgPgQQESHAoPZXhwaXJlc19pbl9kYXlzGAMgASgFQgPgQQEidAohQ3JlYXRlUGVyc29uYWxBY2Nlc3NUb2tlblJlc3BvbnNlEkAKFXBlcnNvbmFsX2FjY2Vzc190b2tlbhgBIAEoCzIhLm1lbW9zLmFwaS52MS5QZXJzb25hbEFjY2Vzc1Rva2VuEg0KBXRva2VuGAIgASgJIloKIERlbGV0ZVBlcnNvbmFsQWNjZXNzVG9rZW5SZXF1ZXN0EjYKBG5hbWUYASABKAlCKOBBAvpBIgogbWVtb3MuYXBpLnYxL1BlcnNvbmFsQWNjZXNzVG9rZW4iqgEKC1VzZXJXZWJob29rEgwKBG5hbWUYASABKAkSCwoDdXJsGAIgASgJEhQKDGRpc3BsYXlfbmFtZRgDIAEoCRI0CgtjcmVhdGVfdGltZRgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAxI0Cgt1cGRhdGVfdGltZRgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAyIuChdMaXN0VXNlcldlYmhvb2tzUmVxdWVzdBITCgZwYXJlbnQYASABKAlCA+BBAiJHChhMaXN0VXNlcldlYmhvb2tzUmVzcG9uc2USKwoId2ViaG9va3MYASADKAsyGS5tZW1vcy5hcGkudjEuVXNlcldlYmhvb2siYAoYQ3JlYXRlVXNlcldlYmhvb2tSZXF1ZXN0EhMKBnBhcmVudBgBIAEoCUID4EECEi8KB3dlYmhvb2sYAiABKAsyGS5tZW1vcy5hcGkudjEuVXNlcldlYmhvb2tCA+BBAiJ8ChhVcGRhdGVVc2VyV2ViaG9va1JlcXVlc3QSLwoHd2ViaG9vaxgBIAEoCzIZLm1lbW9zLmFwaS52MS5Vc2VyV2ViaG9va0ID4EECEi8KC3VwZGF0ZV9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFzayItChhEZWxldGVVc2VyV2ViaG9va1JlcXVlc3QSEQoEbmFtZRgBIAEoCUID4EECIqIHChBVc2VyTm90aWZpY2F0aW9uEhQKBG5hbWUYASABKAlCBuBBA+BBCBIpCgZzZW5kZXIYAiABKAlCGeBBA/pBEwoRbWVtb3MuYXBpLnYxL1VzZXISLAoLc2VuZGVyX3VzZXIYCCABKAsyEi5tZW1vcy5hcGkudjEuVXNlckID4EEDEjoKBnN0YXR1cxgDIAEoDjIlLm1lbW9zLmFwaS52MS5Vc2VyTm90aWZpY2F0aW9uLlN0YXR1c0ID4EEBEjQKC2NyZWF0ZV90aW1lGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEDEjYKBHR5cGUYBSABKA4yIy5tZW1vcy5hcGkudjEuVXNlck5vdGlmaWNhdGlvbi5UeXBlQgPgQQMSTgoMbWVtb19jb21tZW50GAYgASgLMjEubWVtb3MuYXBpLnYxLlVzZXJOb3RpZmljYXRpb24uTWVtb0NvbW1lbnRQYXlsb2FkQgPgQQNIABJOCgxtZW1vX21lbnRpb24YByABKAsyMS5tZW1vcy5hcGkudjEuVXNlck5vdGlmaWNhdGlvbi5NZW1vTWVudGlvblBheWxvYWRCA+BBA0gAGmwKEk1lbW9Db21tZW50UGF5bG9hZBIMCgRtZW1vGAEgASgJEhQKDHJlbGF0ZWRfbWVtbxgCIAEoCRIUCgxtZW1vX3NuaXBwZXQYAyABKAkSHAoUcmVsYXRlZF9tZW1vX3NuaXBwZXQYBCABKAkabAoSTWVtb01lbnRpb25QYXlsb2FkEgwKBG1lbW8YASABKAkSFAoMcmVsYXRlZF9tZW1vGAIgASgJEhQKDG1lbW9fc25pcHBldBgDIAEoCRIcChRyZWxhdGVkX21lbW9fc25pcHBldBgEIAEoCSI6CgZTdGF0dXMSFgoSU1RBVFVTX1VOU1BFQ0lGSUVEEAASCgoGVU5SRUFEEAESDAoIQVJDSElWRUQQAiJACgRUeXBlEhQKEFRZUEVfVU5TUEVDSUZJRUQQABIQCgxNRU1PX0NPTU1FTlQQARIQCgxNRU1PX01FTlRJT04QAjpw6kFtCh1tZW1vcy5hcGkudjEvVXNlck5vdGlmaWNhdGlvbhIpdXNlcnMve3VzZXJ9L25vdGlmaWNhdGlvbnMve25vdGlmaWNhdGlvbn0aBG5hbWUqDW5vdGlmaWNhdGlvbnMyDG5vdGlmaWNhdGlvbkIJCgdwYXlsb2FkIo8BChxMaXN0VXNlck5vdGlmaWNhdGlvbnNSZXF1ZXN0EikKBnBhcmVudBgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvVXNlchIWCglwYWdlX3NpemUYAiABKAVCA+BBARIXCgpwYWdlX3Rva2VuGAMgASgJQgPgQQESEwoGZmlsdGVyGAQgASgJQgPgQQEibwodTGlzdFVzZXJOb3RpZmljYXRpb25zUmVzcG9uc2USNQoNbm90aWZpY2F0aW9ucxgBIAMoCzIeLm1lbW9zLmFwaS52MS5Vc2VyTm90aWZpY2F0aW9uEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSKQAQodVXBkYXRlVXNlck5vdGlmaWNhdGlvblJlcXVlc3QSOQoMbm90aWZpY2F0aW9uGAEgASgLMh4ubWVtb3MuYXBpLnYxLlVzZXJOb3RpZmljYXRpb25CA+BBAhI0Cgt1cGRhdGVfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBAiJUCh1EZWxldGVVc2VyTm90aWZpY2F0aW9uUmVxdWVzdBIzCgRuYW1lGAEgASgJQiXgQQL6QR8KHW1lbW9zLmFwaS52MS9Vc2VyTm90aWZpY2F0aW9uMoIdCgtVc2VyU2VydmljZRJjCglMaXN0VXNlcnMSHi5tZW1vcy5hcGkudjEuTGlzdFVzZXJzUmVxdWVzdBofLm1lbW9zLmFwaS52MS5MaXN0VXNlcnNSZXNwb25zZSIVgtPkkwIPEg0vYXBpL3YxL3VzZXJzEnsKDUJhdGNoR2V0VXNlcnMSIi5tZW1vcy5hcGkudjEuQmF0Y2hHZXRVc2Vyc1JlcXVlc3QaIy5tZW1vcy5hcGkudjEuQmF0Y2hHZXRVc2Vyc1Jlc3BvbnNlIiGC0+STAhs6ASoiFi9hcGkvdjEvdXNlcnM6YmF0Y2hHZXQSYgoHR2V0VXNlchIcLm1lbW9zLmFwaS52MS5HZXRVc2VyUmVxdWVzdBoSLm1lbW9zLmFwaS52MS5Vc2VyIiXaQQRuYW1lgtPkkwIYEhYvYXBpL3YxL3tuYW1lPXVzZXJzLyp9EmUKCkNyZWF0ZVVzZXISHy5tZW1vcy5hcGkudjEuQ3JlYXRlVXNlclJlcXVlc3QaEi5tZW1vcy5hcGkudjEuVXNlciIi2kEEdXNlcoLT5JMCFToEdXNlciINL2FwaS92MS91c2VycxJ/CgpVcGRhdGVVc2VyEh8ubWVtb3MuYXBpLnYxLlVwZGF0ZVVzZXJSZXF1ZXN0GhIubWVtb3MuYXBpLnYxLlVzZXIiPNpBEHVzZXIsdXBkYXRlX21hc2uC0+STAiM6BHVzZXIyGy9hcGkvdjEve3VzZXIubmFtZT11c2Vycy8qfRJsCgpEZWxldGVVc2VyEh8ubWVtb3MuYXBpLnYxLkRlbGV0ZVVzZXJSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IiXaQQRuYW1lgtPkkwIYKhYvYXBpL3YxL3tuYW1lPXVzZXJzLyp9En4KEExpc3RBbGxVc2VyU3RhdHMSJS5tZW1vcy5hcGkudjEuTGlzdEFsbFVzZXJTdGF0c1JlcXVlc3QaJi5tZW1vcy5hcGkudjEuTGlzdEFsbFVzZXJTdGF0c1Jlc3BvbnNlIhuC0+STAhUSEy9hcGkvdjEvdXNlcnM6c3RhdHMSegoMR2V0VXNlclN0YXRzEiEubWVtb3MuYXBpLnYxLkdldFVzZXJTdGF0c1JlcXVlc3QaFy5tZW1vcy5hcGkudjEuVXNlclN0YXRzIi7aQQRuYW1lgtPkkwIhEh8vYXBpL3YxL3tuYW1lPXVzZXJzLyp9OmdldFN0YXRzEoIBCg5HZXRVc2VyU2V0dGluZxIjLm1lbW9zLmFwaS52MS5HZXRVc2VyU2V0dGluZ1JlcXVlc3QaGS5tZW1vcy5hcGkudjEuVXNlclNldHRpbmciMNpBBG5hbWWC0+STAiMSIS9hcGkvdjEve25hbWU9dXNlcnMvKi9zZXR0aW5ncy8qfRKoAQoRVXBkYXRlVXNlclNldHRpbmcSJi5tZW1vcy5hcGkudjEuVXBkYXRlVXNlclNldHRpbmdSZXF1ZXN0GhkubWVtb3MuYXBpLnYxLlVzZXJTZXR0aW5nIlDaQRNzZXR0aW5nLHVwZGF0ZV9tYXNrgtPkkwI0OgdzZXR0aW5nMikvYXBpL3YxL3tzZXR0aW5nLm5hbWU9dXNlcnMvKi9zZXR0aW5ncy8qfRKVAQoQTGlzdFVzZXJTZXR0aW5ncxIlLm1lbW9zLmFwaS52MS5MaXN0VXNlclNldHRpbmdzUmVxdWVzdBomLm1lbW9zLmFwaS52MS5MaXN0VXNlclNldHRpbmdzUmVzcG9uc2UiMtpBBnBhcmVudILT5JMCIxIhL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3NldHRpbmdzEqkBChRMaXN0TGlua2VkSWRlbnRpdGllcxIpLm1lbW9zLmFwaS52MS5MaXN0TGlua2VkSWRlbnRpdGllc1JlcXVlc3QaKi5tZW1vcy5hcGkudjEuTGlzdExpbmtlZElkZW50aXRpZXNSZXNwb25zZSI62kEGcGFyZW50gtPkkwIrEikvYXBpL3YxL3twYXJlbnQ9dXNlcnMvKn0vbGlua2VkSWRlbnRpdGllcxKnAQoUQ3JlYXRlTGlua2VkSWRlbnRpdHkSKS5tZW1vcy5hcGkudjEuQ3JlYXRlTGlua2VkSWRlbnRpdHlSZXF1ZXN0GhwubWVtb3MuYXBpLnYxLkxpbmtlZElkZW50aXR5IkbaQQ9wYXJlbnQsaWRwX25hbWWC0+STAi46ASoiKS9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9saW5rZWRJZGVudGl0aWVzEpMBChFHZXRMaW5rZWRJZGVudGl0eRImLm1lbW9zLmFwaS52MS5HZXRMaW5rZWRJZGVudGl0eVJlcXVlc3QaHC5tZW1vcy5hcGkudjEuTGlua2VkSWRlbnRpdHkiONpBBG5hbWWC0+STAisSKS9hcGkvdjEve25hbWU9dXNlcnMvKi9saW5rZWRJZGVudGl0aWVzLyp9EpMBChREZWxldGVMaW5rZWRJZGVudGl0eRIpLm1lbW9zLmFwaS52MS5EZWxldGVMaW5rZWRJZGVudGl0eVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiONpBBG5hbWWC0+STAisqKS9hcGkvdjEve25hbWU9dXNlcnMvKi9saW5rZWRJZGVudGl0aWVzLyp9ErkBChhMaXN0UGVyc29uYWxBY2Nlc3NUb2tlbnMSLS5tZW1vcy5hcGkudjEuTGlzdFBlcnNvbmFsQWNjZXNzVG9rZW5zUmVxdWVzdBouLm1lbW9zLmFwaS52MS5MaXN0UGVyc29uYWxBY2Nlc3NUb2tlbnNSZXNwb25zZSI+2kEGcGFyZW50gtPkkwIvEi0vYXBpL3YxL3twYXJlbnQ9dXNlcnMvKn0vcGVyc29uYWxBY2Nlc3NUb2tlbnMStgEKGUNyZWF0ZVBlcnNvbmFsQWNjZXNzVG9rZW4SLi5tZW1vcy5hcGkudjEuQ3JlYXRlUGVyc29uYWxBY2Nlc3NUb2tlblJlcXVlc3QaLy5tZW1vcy5hcGkudjEuQ3JlYXRlUGVyc29uYWxBY2Nlc3NUb2tlblJlc3BvbnNlIjiC0+STAjI6ASoiLS9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9wZXJzb25hbEFjY2Vzc1Rva2VucxKhAQoZRGVsZXRlUGVyc29uYWxBY2Nlc3NUb2tlbhIuLm1lbW9zLmFwaS52MS5EZWxldGVQZXJzb25hbEFjY2Vzc1Rva2VuUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSI82kEEbmFtZYLT5JMCLyotL2FwaS92MS97bmFtZT11c2Vycy8qL3BlcnNvbmFsQWNjZXNzVG9rZW5zLyp9EpUBChBMaXN0VXNlcldlYmhvb2tzEiUubWVtb3MuYXBpLnYxLkxpc3RVc2VyV2ViaG9va3NSZXF1ZXN0GiYubWVtb3MuYXBpLnYxLkxpc3RVc2VyV2ViaG9va3NSZXNwb25zZSIy2kEGcGFyZW50gtPkkwIjEiEvYXBpL3YxL3twYXJlbnQ9dXNlcnMvKn0vd2ViaG9va3MSmwEKEUNyZWF0ZVVzZXJXZWJob29rEiYubWVtb3MuYXBpLnYxLkNyZWF0ZVVzZXJXZWJob29rUmVxdWVzdBoZLm1lbW9zLmFwaS52MS5Vc2VyV2ViaG9vayJD2kEOcGFyZW50LHdlYmhvb2uC0+STAiw6B3dlYmhvb2siIS9hcGkvdjEve3BhcmVudD11c2Vycy8qfS93ZWJob29rcxKoAQoRVXBkYXRlVXNlcldlYmhvb2sSJi5tZW1vcy5hcGkudjEuVXBkYXRlVXNlcldlYmhvb2tSZXF1ZXN0GhkubWVtb3MuYXBpLnYxLlVzZXJXZWJob29rIlDaQRN3ZWJob29rLHVwZGF0ZV9tYXNrgtPkkwI0Ogd3ZWJob29rMikvYXBpL3YxL3t3ZWJob29rLm5hbWU9dXNlcnMvKi93ZWJob29rcy8qfRKFAQoRRGVsZXRlVXNlcldlYmhvb2sSJi5tZW1vcy5hcGkudjEuRGVsZXRlVXNlcldlYmhvb2tSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IjDaQQRuYW1lgtPkkwIjKiEvYXBpL3YxL3tuYW1lPXVzZXJzLyovd2ViaG9va3MvKn0SqQEKFUxpc3RVc2VyTm90aWZpY2F0aW9ucxIqLm1lbW9zLmFwaS52MS5MaXN0VXNlck5vdGlmaWNhdGlvbnNSZXF1ZXN0GisubWVtb3MuYXBpLnYxLkxpc3RVc2VyTm90aWZpY2F0aW9uc1Jlc3BvbnNlIjfaQQZwYXJlbnSC0+STAigSJi9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9ub3RpZmljYXRpb25zEssBChZVcGRhdGVVc2VyTm90aWZpY2F0aW9uEisubWVtb3MuYXBpLnYxLlVwZGF0ZVVzZXJOb3RpZmljYXRpb25SZXF1ZXN0Gh4ubWVtb3MuYXBpLnYxLlVzZXJOb3RpZmljYXRpb24iZNpBGG5vdGlmaWNhdGlvbix1cGRhdGVfbWFza4LT5JMCQzoMbm90aWZpY2F0aW9uMjMvYXBpL3YxL3tub3RpZmljYXRpb24ubmFtZT11c2Vycy8qL25vdGlmaWNhdGlvbnMvKn0SlAEKFkRlbGV0ZVVzZXJOb3RpZmljYXRpb24SKy5tZW1vcy5hcGkudjEuRGVsZXRlVXNlck5vdGlmaWNhdGlvblJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiNdpBBG5hbWWC0+STAigqJi9hcGkvdjEve25hbWU9dXNlcnMvKi9ub3RpZmljYXRpb25zLyp9QqgBChBjb20ubWVtb3MuYXBpLnYxQhBVc2VyU2VydmljZVByb3RvUAFaMGdpdGh1Yi5jb20vdXNlbWVtb3MvbWVtb3MvcHJvdG8vZ2VuL2FwaS92MTthcGl2MaICA01BWKoCDE1lbW9zLkFwaS5WMcoCDE1lbW9zXEFwaVxWMeICGE1lbW9zXEFwaVxWMVxHUEJNZXRhZGF0YeoCDk1lbW9zOjpBcGk6OlYxYgZwcm90bzM", [file_api_v1_common, file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask, file_google_protobuf_timestamp]); /** * @generated from message memos.api.v1.User @@ -423,6 +423,15 @@ export type UserStats = Message<"memos.api.v1.UserStats"> & { */ memoCreatedTimestamps: Timestamp[]; + /** + * 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. + * + * @generated from field: repeated google.protobuf.Timestamp memo_updated_timestamps = 8; + */ + memoUpdatedTimestamps: Timestamp[]; + /** * The pinned memos of the user. * diff --git a/web/src/types/statistics.ts b/web/src/types/statistics.ts index acb2330e4..b8e92dc34 100644 --- a/web/src/types/statistics.ts +++ b/web/src/types/statistics.ts @@ -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; + timeBasis: MemoTimeBasis; } export interface StatisticsData { activityStats: Record; + timeBasis: MemoTimeBasis; } diff --git a/web/tests/activity-calendar-tooltip.test.ts b/web/tests/activity-calendar-tooltip.test.ts new file mode 100644 index 000000000..edeb768d8 --- /dev/null +++ b/web/tests/activity-calendar-tooltip.test.ts @@ -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) => { + if (!vars) return key; + const parts = Object.entries(vars).map(([k, v]) => `${k}=${String(v)}`); + return `${key}|${parts.join(",")}`; +}) as Parameters[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"); + }); +}); diff --git a/web/tests/filtered-memo-stats.test.ts b/web/tests/filtered-memo-stats.test.ts new file mode 100644 index 000000000..0e3f3a0b5 --- /dev/null +++ b/web/tests/filtered-memo-stats.test.ts @@ -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("@/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); + }); + + 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); + 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(); + }); +});