From f3c1981d04fe65ca20fccabf207a0424e57994cb Mon Sep 17 00:00:00 2001 From: dawn Date: Wed, 2 Sep 2026 00:17:49 +0900 Subject: [PATCH] web: show activity on profile overview Signed-off-by: dawn --- web/src/lib/api/lexicons/index.ts | 1 + .../sh/tangled/actor/getProfileActivity.ts | 146 +++++++++++ web/src/lib/api/records.ts | 17 +- ...e-overviewtab--skeleton-fixture.bones.json | 112 ++++++--- .../components/profile/ActivityStub.svelte | 5 - .../profile/ProfileActivity.stories.svelte | 141 +++++++++++ .../components/profile/ProfileActivity.svelte | 237 ++++++++++++++++++ web/src/lib/components/profile/pages.test.ts | 126 +++++++++- web/src/lib/components/profile/pages.ts | 86 ++++++- .../profile/tabs/OverviewTab.stories.svelte | 92 ++++++- .../profile/tabs/OverviewTab.svelte | 12 +- web/src/lib/components/profile/types.ts | 39 +++ web/src/routes/[handle]/+page.svelte | 6 +- web/src/routes/[handle]/+page.ts | 13 +- 14 files changed, 979 insertions(+), 54 deletions(-) create mode 100644 web/src/lib/api/lexicons/types/sh/tangled/actor/getProfileActivity.ts delete mode 100644 web/src/lib/components/profile/ActivityStub.svelte create mode 100644 web/src/lib/components/profile/ProfileActivity.stories.svelte create mode 100644 web/src/lib/components/profile/ProfileActivity.svelte diff --git a/web/src/lib/api/lexicons/index.ts b/web/src/lib/api/lexicons/index.ts index 500edc964..a02c85079 100644 --- a/web/src/lib/api/lexicons/index.ts +++ b/web/src/lib/api/lexicons/index.ts @@ -48,6 +48,7 @@ export * as OrgTangledTempSpindleQuotaUnset from "./types/org/tangled/temp/spind export * as OrgTangledTempSpindleQuotaUsage from "./types/org/tangled/temp/spindle/quota/usage.js"; export * as ShTangledActorDefs from "./types/sh/tangled/actor/defs.js"; export * as ShTangledActorGetProfile from "./types/sh/tangled/actor/getProfile.js"; +export * as ShTangledActorGetProfileActivity from "./types/sh/tangled/actor/getProfileActivity.js"; export * as ShTangledActorGetProfiles from "./types/sh/tangled/actor/getProfiles.js"; export * as ShTangledActorGetTrending from "./types/sh/tangled/actor/getTrending.js"; export * as ShTangledActorProfile from "./types/sh/tangled/actor/profile.js"; diff --git a/web/src/lib/api/lexicons/types/sh/tangled/actor/getProfileActivity.ts b/web/src/lib/api/lexicons/types/sh/tangled/actor/getProfileActivity.ts new file mode 100644 index 000000000..00efe18fe --- /dev/null +++ b/web/src/lib/api/lexicons/types/sh/tangled/actor/getProfileActivity.ts @@ -0,0 +1,146 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _activityMonthSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "sh.tangled.actor.getProfileActivity#activityMonth", + ), + ), + /** + * @minimum 0 + */ + commits: /*#__PURE__*/ v.integer(), + get issues() { + return /*#__PURE__*/ v.array(issueActivitySchema); + }, + get pulls() { + return /*#__PURE__*/ v.array(pullActivitySchema); + }, + get repos() { + return /*#__PURE__*/ v.array(repoActivitySchema); + }, + /** + * Start of this UTC calendar month. + */ + start: /*#__PURE__*/ v.datetimeString(), +}); +const _issueActivitySchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "sh.tangled.actor.getProfileActivity#issueActivity", + ), + ), + get repo() { + return repoRefSchema; + }, + state: /*#__PURE__*/ v.string<"closed" | "open" | (string & {})>(), + title: /*#__PURE__*/ v.string(), + uri: /*#__PURE__*/ v.resourceUriString(), +}); +const _mainSchema = /*#__PURE__*/ v.query( + "sh.tangled.actor.getProfileActivity", + { + params: /*#__PURE__*/ v.object({ + /** + * Actor whose activity to return. + */ + actor: /*#__PURE__*/ v.didString(), + /** + * Number of UTC calendar months to include, including the current month. + * @minimum 1 + * @maximum 12 + * @default 7 + */ + months: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ + /*#__PURE__*/ v.integerRange(1, 12), + ]), + 7, + ), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + get months() { + return /*#__PURE__*/ v.array(activityMonthSchema); + }, + /** + * True when the result reached the server activity-item safety limit. + */ + truncated: /*#__PURE__*/ v.boolean(), + }), + }, + }, +); +const _pullActivitySchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("sh.tangled.actor.getProfileActivity#pullActivity"), + ), + get repo() { + return repoRefSchema; + }, + state: /*#__PURE__*/ v.string<"closed" | "merged" | "open" | (string & {})>(), + title: /*#__PURE__*/ v.string(), + uri: /*#__PURE__*/ v.resourceUriString(), +}); +const _repoActivitySchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("sh.tangled.actor.getProfileActivity#repoActivity"), + ), + kind: /*#__PURE__*/ v.string<"created" | "forked" | (string & {})>(), + get repo() { + return repoRefSchema; + }, +}); +const _repoRefSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("sh.tangled.actor.getProfileActivity#repoRef"), + ), + did: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()), + name: /*#__PURE__*/ v.string(), + ownerDid: /*#__PURE__*/ v.didString(), + ownerHandle: /*#__PURE__*/ v.handleString(), + uri: /*#__PURE__*/ v.resourceUriString(), +}); + +type activityMonth$schematype = typeof _activityMonthSchema; +type issueActivity$schematype = typeof _issueActivitySchema; +type main$schematype = typeof _mainSchema; +type pullActivity$schematype = typeof _pullActivitySchema; +type repoActivity$schematype = typeof _repoActivitySchema; +type repoRef$schematype = typeof _repoRefSchema; + +export interface activityMonthSchema extends activityMonth$schematype {} +export interface issueActivitySchema extends issueActivity$schematype {} +export interface mainSchema extends main$schematype {} +export interface pullActivitySchema extends pullActivity$schematype {} +export interface repoActivitySchema extends repoActivity$schematype {} +export interface repoRefSchema extends repoRef$schematype {} + +export const activityMonthSchema = _activityMonthSchema as activityMonthSchema; +export const issueActivitySchema = _issueActivitySchema as issueActivitySchema; +export const mainSchema = _mainSchema as mainSchema; +export const pullActivitySchema = _pullActivitySchema as pullActivitySchema; +export const repoActivitySchema = _repoActivitySchema as repoActivitySchema; +export const repoRefSchema = _repoRefSchema as repoRefSchema; + +export interface ActivityMonth extends v.InferInput< + typeof activityMonthSchema +> {} +export interface IssueActivity extends v.InferInput< + typeof issueActivitySchema +> {} +export interface PullActivity extends v.InferInput {} +export interface RepoActivity extends v.InferInput {} +export interface RepoRef extends v.InferInput {} + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "sh.tangled.actor.getProfileActivity": mainSchema; + } +} diff --git a/web/src/lib/api/records.ts b/web/src/lib/api/records.ts index 70b9c4593..c958ab7b4 100644 --- a/web/src/lib/api/records.ts +++ b/web/src/lib/api/records.ts @@ -1,5 +1,6 @@ import type { BobbinContext, XrpcRequestInit } from "$lib/api/client"; import { jsonGet } from "$lib/api/_request"; +import type * as ShTangledActorGetProfileActivity from "$lib/api/lexicons/types/sh/tangled/actor/getProfileActivity"; import type * as ShTangledActorProfile from "$lib/api/lexicons/types/sh/tangled/actor/profile"; import type * as ShTangledPullGetPullView from "$lib/api/lexicons/types/sh/tangled/pull/getPullView"; import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star"; @@ -8,7 +9,6 @@ import type * as ShTangledRepo from "$lib/api/lexicons/types/sh/tangled/repo"; import type * as ShTangledRepoPull from "$lib/api/lexicons/types/sh/tangled/repo/pull"; import type * as ShTangledRepoPullStatus from "$lib/api/lexicons/types/sh/tangled/repo/pull/status"; import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string"; - export interface RecordView { uri: string; cid?: string; @@ -26,6 +26,8 @@ export type PullStatusRecord = ShTangledRepoPullStatus.Main; export type StringRecord = ShTangledString.Main; export type StarRecord = ShTangledFeedStar.Main; export type VouchRecord = ShTangledGraphVouch.Main; +export type ProfileActivityOutput = ShTangledActorGetProfileActivity.$output; +export type ProfileActivityRepoRef = ShTangledActorGetProfileActivity.RepoRef; export const BULK_LIMIT = 50; @@ -95,6 +97,19 @@ export const getReposByRepoDids = ( init?: XrpcRequestInit ) => jsonGet>(ctx, "sh.tangled.repo.getReposByRepoDids", { dids }, init); +export const getProfileActivity = ( + ctx: BobbinContext, + actor: string, + months = 7, + init?: XrpcRequestInit +) => + jsonGet( + ctx, + "sh.tangled.actor.getProfileActivity", + { actor, months }, + init + ); + export const getProfiles = ( ctx: BobbinContext, actors: readonly string[], diff --git a/web/src/lib/bones/profile-overviewtab--skeleton-fixture.bones.json b/web/src/lib/bones/profile-overviewtab--skeleton-fixture.bones.json index 2ccc6a222..746a1b79e 100644 --- a/web/src/lib/bones/profile-overviewtab--skeleton-fixture.bones.json +++ b/web/src/lib/bones/profile-overviewtab--skeleton-fixture.bones.json @@ -4,35 +4,51 @@ "name": "profile-overviewtab--skeleton-fixture", "viewportWidth": 343, "width": 343, - "height": 1098, + "height": 1351, "bones": [ [2.3324, 3, 42.7706, 20, 4], [0, 43, 100, 130, 4, true, 15, true], - [7.2886, 64, 51.6627, 21, 4], + [7.2886, 64, 25.0866, 21, 4], [7.2886, 91, 77.469, 15, 4], [7.2886, 136, 27.7606, 20, 4], [0, 189, 100, 130, 4, true, 15, true], - [7.2886, 210, 51.6627, 21, 4], + [7.2886, 210, 25.0866, 21, 4], [7.2886, 237, 77.469, 15, 4], [7.2886, 282, 40.3517, 20, 4], [0, 335, 100, 130, 4, true, 15, true], - [7.2886, 356, 51.6627, 21, 4], + [7.2886, 356, 25.0866, 21, 4], [7.2886, 383, 77.469, 15, 4], [7.2886, 428, 27.7606, 20, 4], [0, 481, 100, 130, 4, true, 15, true], - [7.2886, 502, 51.6627, 21, 4], + [7.2886, 502, 25.0866, 21, 4], [7.2886, 529, 77.469, 15, 4], [7.2886, 574, 40.3517, 20, 4], [0, 627, 100, 130, 4, true, 15, true], - [7.2886, 648, 51.6627, 21, 4], + [7.2886, 648, 25.0866, 21, 4], [7.2886, 675, 77.469, 15, 4], [7.2886, 720, 27.7606, 20, 4], [0, 773, 100, 130, 4, true, 15, true], - [7.2886, 794, 51.6627, 21, 4], + [7.2886, 794, 25.0866, 21, 4], [7.2886, 821, 77.469, 15, 4], [7.2886, 866, 40.3517, 20, 4], [2.3324, 930, 16.814, 20, 4], - [20.0665, 1024, 59.8624, 20, 4] + [0, 970, 100, 158, 4, true, 15, true], + [7.2886, 989, 20.9913, 15, 4], + [7.2886, 1015, 51.9315, 21, 4], + [7.2886, 1040, 46.7474, 21, 4], + [7.2886, 1069, 46.1234, 20, 4], + [7.2886, 1065, 57.7442, 21, 4], + [7.2886, 1094, 85.4227, 20, 4], + [7.2886, 1090, 75.6104, 21, 4], + [7.2886, 1119, 85.4227, 20, 4], + [0, 1144, 100, 83, 4, true, 15, true], + [7.2886, 1163, 23.0913, 15, 4], + [7.2886, 1189, 47.1711, 21, 4], + [0, 1243, 100, 108, 4, true, 15, true], + [7.2886, 1262, 25.1913, 15, 4], + [7.2886, 1288, 49.2848, 21, 4], + [7.2886, 1313, 46.7474, 21, 4], + [7.2886, 1342, 47.7269, 9, 4] ] }, "768": { @@ -43,31 +59,43 @@ "bones": [ [1.087, 3, 19.9325, 20, 4], [0, 43, 48.3696, 130, 4, true, 15, true], - [3.3967, 64, 24.0765, 21, 4], + [3.3967, 64, 11.6912, 21, 4], [3.3967, 91, 36.1031, 15, 4], [3.3967, 136, 12.9373, 20, 4], [0, 189, 48.3696, 130, 4, true, 15, true], - [3.3967, 210, 24.0765, 21, 4], + [3.3967, 210, 11.6912, 21, 4], [3.3967, 237, 36.1031, 15, 4], [3.3967, 282, 18.8052, 20, 4], [0, 335, 48.3696, 130, 4, true, 15, true], - [3.3967, 356, 24.0765, 21, 4], + [3.3967, 356, 11.6912, 21, 4], [3.3967, 383, 36.1031, 15, 4], [3.3967, 428, 12.9373, 20, 4], [0, 481, 48.3696, 130, 4, true, 15, true], - [3.3967, 502, 24.0765, 21, 4], + [3.3967, 502, 11.6912, 21, 4], [3.3967, 529, 36.1031, 15, 4], [3.3967, 574, 18.8052, 20, 4], [0, 627, 48.3696, 130, 4, true, 15, true], - [3.3967, 648, 24.0765, 21, 4], + [3.3967, 648, 11.6912, 21, 4], [3.3967, 675, 36.1031, 15, 4], [3.3967, 720, 12.9373, 20, 4], [0, 773, 48.3696, 130, 4, true, 15, true], - [3.3967, 794, 24.0765, 21, 4], + [3.3967, 794, 11.6912, 21, 4], [3.3967, 821, 36.1031, 15, 4], [3.3967, 866, 18.8052, 20, 4], [52.7174, 3, 7.8359, 20, 4], - [61.8652, 97, 27.8978, 20, 4] + [51.6304, 43, 48.3696, 158, 4, true, 15, true], + [55.0272, 62, 9.7826, 15, 4], + [55.0272, 88, 24.2018, 21, 4], + [55.0272, 113, 21.7858, 21, 4], + [55.0272, 138, 26.9107, 21, 4], + [55.0272, 163, 35.2369, 21, 4], + [51.6304, 217, 48.3696, 83, 4, true, 15, true], + [55.0272, 236, 10.7613, 15, 4], + [55.0272, 262, 21.9833, 21, 4], + [51.6304, 316, 48.3696, 108, 4, true, 15, true], + [55.0272, 335, 11.74, 15, 4], + [55.0272, 361, 22.9683, 21, 4], + [55.0272, 386, 21.7858, 21, 4] ] }, "1024": { @@ -78,31 +106,43 @@ "bones": [ [0.8065, 3, 14.7886, 20, 4], [0, 43, 48.7903, 130, 4, true, 15, true], - [2.5202, 64, 17.8632, 21, 4], + [2.5202, 64, 8.6741, 21, 4], [2.5202, 91, 26.7862, 15, 4], [2.5202, 136, 9.5987, 20, 4], [0, 189, 48.7903, 130, 4, true, 15, true], - [2.5202, 210, 17.8632, 21, 4], + [2.5202, 210, 8.6741, 21, 4], [2.5202, 237, 26.7862, 15, 4], [2.5202, 282, 13.9522, 20, 4], [0, 335, 48.7903, 130, 4, true, 15, true], - [2.5202, 356, 17.8632, 21, 4], + [2.5202, 356, 8.6741, 21, 4], [2.5202, 383, 26.7862, 15, 4], [2.5202, 428, 9.5987, 20, 4], [0, 481, 48.7903, 130, 4, true, 15, true], - [2.5202, 502, 17.8632, 21, 4], + [2.5202, 502, 8.6741, 21, 4], [2.5202, 529, 26.7862, 15, 4], [2.5202, 574, 13.9522, 20, 4], [0, 627, 48.7903, 130, 4, true, 15, true], - [2.5202, 648, 17.8632, 21, 4], + [2.5202, 648, 8.6741, 21, 4], [2.5202, 675, 26.7862, 15, 4], [2.5202, 720, 9.5987, 20, 4], [0, 773, 48.7903, 130, 4, true, 15, true], - [2.5202, 794, 17.8632, 21, 4], + [2.5202, 794, 8.6741, 21, 4], [2.5202, 821, 26.7862, 15, 4], [2.5202, 866, 13.9522, 20, 4], [52.0161, 3, 5.8137, 20, 4], - [65.2549, 97, 20.6984, 20, 4] + [51.2097, 43, 48.7903, 158, 4, true, 15, true], + [53.7298, 62, 7.2581, 15, 4], + [53.7298, 88, 17.9561, 21, 4], + [53.7298, 113, 16.1637, 21, 4], + [53.7298, 138, 19.966, 21, 4], + [53.7298, 163, 26.1435, 21, 4], + [51.2097, 217, 48.7903, 83, 4, true, 15, true], + [53.7298, 236, 7.9842, 15, 4], + [53.7298, 262, 16.3102, 21, 4], + [51.2097, 316, 48.7903, 108, 4, true, 15, true], + [53.7298, 335, 8.7103, 15, 4], + [53.7298, 361, 17.041, 21, 4], + [53.7298, 386, 16.1637, 21, 4] ] }, "1440": { @@ -113,33 +153,45 @@ "bones": [ [0.5682, 3, 10.4193, 20, 4], [0, 43, 49.1477, 130, 4, true, 15, true], - [1.7756, 64, 12.5854, 21, 4], + [1.7756, 64, 6.1113, 21, 4], [1.7756, 91, 18.8721, 15, 4], [1.7756, 136, 6.7627, 20, 4], [0, 189, 49.1477, 130, 4, true, 15, true], - [1.7756, 210, 12.5854, 21, 4], + [1.7756, 210, 6.1113, 21, 4], [1.7756, 237, 18.8721, 15, 4], [1.7756, 282, 9.83, 20, 4], [0, 335, 49.1477, 130, 4, true, 15, true], - [1.7756, 356, 12.5854, 21, 4], + [1.7756, 356, 6.1113, 21, 4], [1.7756, 383, 18.8721, 15, 4], [1.7756, 428, 6.7627, 20, 4], [0, 481, 49.1477, 130, 4, true, 15, true], - [1.7756, 502, 12.5854, 21, 4], + [1.7756, 502, 6.1113, 21, 4], [1.7756, 529, 18.8721, 15, 4], [1.7756, 574, 9.83, 20, 4], [0, 627, 49.1477, 130, 4, true, 15, true], - [1.7756, 648, 12.5854, 21, 4], + [1.7756, 648, 6.1113, 21, 4], [1.7756, 675, 18.8721, 15, 4], [1.7756, 720, 6.7627, 20, 4], [0, 773, 49.1477, 130, 4, true, 15, true], - [1.7756, 794, 12.5854, 21, 4], + [1.7756, 794, 6.1113, 21, 4], [1.7756, 821, 18.8721, 15, 4], [1.7756, 866, 9.83, 20, 4], [51.4205, 3, 4.096, 20, 4], - [68.1341, 97, 14.583, 20, 4] + [50.8523, 43, 49.1477, 158, 4, true, 15, true], + [52.6278, 62, 5.1136, 15, 4], + [52.6278, 88, 12.6509, 21, 4], + [52.6278, 113, 11.3881, 21, 4], + [52.6278, 138, 14.0669, 21, 4], + [52.6278, 163, 18.4193, 21, 4], + [50.8523, 217, 49.1477, 83, 4, true, 15, true], + [52.6278, 236, 5.6252, 15, 4], + [52.6278, 262, 11.4913, 21, 4], + [50.8523, 316, 49.1477, 108, 4, true, 15, true], + [52.6278, 335, 6.1368, 15, 4], + [52.6278, 361, 12.0062, 21, 4], + [52.6278, 386, 11.3881, 21, 4] ] } }, - "_hash": "73c7b84a5dc99d69cc15a1ea86b9e4a5" + "_hash": "8122c48f0f26753e7318601053e1c168" } diff --git a/web/src/lib/components/profile/ActivityStub.svelte b/web/src/lib/components/profile/ActivityStub.svelte deleted file mode 100644 index 07e8823b7..000000000 --- a/web/src/lib/components/profile/ActivityStub.svelte +++ /dev/null @@ -1,5 +0,0 @@ -
- Activity is not available yet. -
diff --git a/web/src/lib/components/profile/ProfileActivity.stories.svelte b/web/src/lib/components/profile/ProfileActivity.stories.svelte new file mode 100644 index 000000000..8b7c73651 --- /dev/null +++ b/web/src/lib/components/profile/ProfileActivity.stories.svelte @@ -0,0 +1,141 @@ + + + { + const canvas = within(canvasElement); + expect(canvas.getByText("Committed 12 commits")).toBeVisible(); + expect(canvas.getByText("Committed 1 commit")).toBeVisible(); + expect(canvas.getAllByText("1 open")).toHaveLength(2); + expect(canvas.getAllByText("1 closed")).toHaveLength(2); + expect(canvas.getByText("1 merged")).toBeVisible(); + await userEvent.click(canvas.getByText("Created 2 repositories")); + expect(canvas.getByRole("link", { name: "dawn.tngl.sh/core-fork" })).toHaveAttribute( + "href", + "/dawn.tngl.sh/core-fork" + ); + await userEvent.click(canvas.getByText("Created 2 issues")); + expect(canvas.getByRole("link", { name: "Render profile activity" })).toHaveAttribute( + "href", + "/tangled.org/core/issues/at://did:plc:dawn/sh.tangled.repo.issue/3mopenissue" + ); + await userEvent.click(canvas.getByText("Created 3 pull requests")); + expect(canvas.getByRole("link", { name: "Add the activity view" })).toHaveAttribute( + "href", + "/tangled.org/core/pulls/at://did:plc:dawn/sh.tangled.repo.pull/3mmergedpull/latest" + ); + }} +/> + + { + expect( + within(canvasElement).getByText("This user does not have any activity yet.") + ).toBeVisible(); + }} +/> + + { + expect(within(canvasElement).getByText("Some older activity is not shown.")).toBeVisible(); + }} +/> diff --git a/web/src/lib/components/profile/ProfileActivity.svelte b/web/src/lib/components/profile/ProfileActivity.svelte new file mode 100644 index 000000000..ae27b6d7c --- /dev/null +++ b/web/src/lib/components/profile/ProfileActivity.svelte @@ -0,0 +1,237 @@ + + +{#if activity.months.length === 0} + +{:else} +
+ {#each activity.months as month (month.monthsAgo)} + +

+ {monthLabel(month.monthsAgo)} +

+
+ {#if month.commits > 0} +
+
+ {/if} + + {#if month.repos.length > 0} +
+ + +
+ {#each month.repos as repo (repo.repoDid || `${repo.ownerHandle}/${repo.name}`)} +
+ {#if repo.forked} +
+ {/each} +
+
+ {/if} + + {#if month.issues.length > 0} +
+ + +
+ {#each month.issues as issue (issue.uri)} +
+ {#if issue.state === "open"} +
+ {/each} +
+
+ {/if} + + {#if month.pulls.length > 0} +
+ + +
+ {#each month.pulls as pull (pull.uri)} +
+ {#if pull.state === "open"} +
+ {/each} +
+
+ {/if} +
+
+ {/each} + {#if activity.truncated} +

+ Some older activity is not shown. +

+ {/if} +
+{/if} diff --git a/web/src/lib/components/profile/pages.test.ts b/web/src/lib/components/profile/pages.test.ts index ad4a3eb8e..cfb68b707 100644 --- a/web/src/lib/components/profile/pages.test.ts +++ b/web/src/lib/components/profile/pages.test.ts @@ -4,10 +4,10 @@ import { TYPE_MINIDOC } from "$lib/api/enrich"; import { fetchPeoplePage, fetchPinned, + fetchProfileActivity, fetchReposPage, fetchStarredPage } from "$lib/components/profile/pages"; - const enriched = () => Response.json({ output: { items: [], hits: [] }, data: {} }); const requestBody = (fetchMock: ReturnType, index = 0) => { @@ -216,3 +216,127 @@ describe("fetchStarredPage", () => { expect(result.items[1].repoDid).toBe("did:plc:repo-two"); }); }); + +describe("fetchProfileActivity", () => { + it("maps one hydrated Bobbin response into the profile view model", async () => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + months: [ + { + start: "2026-09-01T00:00:00Z", + commits: 3, + repos: [ + { + kind: "created", + repo: { + uri: "at://did:plc:alice/sh.tangled.repo/current", + did: "did:plc:current", + ownerDid: "did:plc:alice", + ownerHandle: "handle.invalid", + name: "current" + } + } + ], + issues: [ + { + uri: "at://did:plc:alice/sh.tangled.repo.issue/open", + title: "Open issue", + state: "open", + repo: { + uri: "at://did:plc:bob/sh.tangled.repo/target", + did: "did:plc:target", + ownerDid: "did:plc:bob", + ownerHandle: "handle.invalid", + name: "target" + } + } + ], + pulls: [ + { + uri: "at://did:plc:alice/sh.tangled.repo.pull/merged", + title: "Merged pull", + state: "merged", + repo: { + uri: "at://did:plc:bob/sh.tangled.repo/target", + did: "did:plc:target", + ownerDid: "did:plc:bob", + ownerHandle: "bob.test", + name: "target" + } + } + ] + }, + { + start: "2026-08-01T00:00:00Z", + commits: 0, + repos: [ + { + kind: "forked", + repo: { + uri: "at://did:plc:alice/sh.tangled.repo/fork", + did: "did:plc:fork", + ownerDid: "did:plc:alice", + ownerHandle: "alice.test", + name: "fork" + } + } + ], + issues: [], + pulls: [] + }, + { + start: "2026-03-01T00:00:00Z", + commits: 0, + repos: [], + issues: [ + { + uri: "at://did:plc:alice/sh.tangled.repo.issue/closed", + title: "Closed issue", + state: "closed", + repo: { + uri: "at://did:plc:bob/sh.tangled.repo/target", + did: "did:plc:target", + ownerDid: "did:plc:bob", + ownerHandle: "bob.test", + name: "target" + } + } + ], + pulls: [] + } + ], + truncated: true + }) + ); + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); + + const activity = await fetchProfileActivity(ctx, { + did: "did:plc:alice", + handle: "alice.test", + now: new Date("2026-09-01T12:00:00Z") + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const request = fetchMock.mock.calls[0][0]; + const requestUrl = new URL(request instanceof Request ? request.url : String(request)); + expect(requestUrl.pathname).toBe("/xrpc/sh.tangled.actor.getProfileActivity"); + expect(requestUrl.searchParams.get("actor")).toBe("did:plc:alice"); + expect(requestUrl.searchParams.get("months")).toBe("7"); + expect(activity.months.map((month) => month.monthsAgo)).toEqual([0, 1, 6]); + expect(activity.truncated).toBe(true); + expect(activity.months[0].commits).toBe(3); + expect(activity.months[0].repos).toMatchObject([ + { name: "current", ownerHandle: "alice.test", forked: false } + ]); + expect(activity.months[1].repos).toMatchObject([{ name: "fork", forked: true }]); + expect(activity.months[0].issues).toMatchObject([ + { + rkey: "open", + state: "open", + repo: { repoDid: "did:plc:target", ownerHandle: "did:plc:bob", name: "target" } + } + ]); + expect(activity.months[0].pulls).toMatchObject([{ rkey: "merged", state: "merged" }]); + expect(activity.months[2].issues).toMatchObject([{ rkey: "closed", state: "closed" }]); + }); +}); diff --git a/web/src/lib/components/profile/pages.ts b/web/src/lib/components/profile/pages.ts index a565f99f2..bac0fb54c 100644 --- a/web/src/lib/components/profile/pages.ts +++ b/web/src/lib/components/profile/pages.ts @@ -27,7 +27,9 @@ import { fetchPage } from "$lib/api/pagination"; import { IdentityCache, INVALID_HANDLE, type MiniDoc } from "$lib/api/identity"; import { getProfile, + getProfileActivity, getProfiles, + type ProfileActivityRepoRef, type RecordView, type RepoRecord, type ProfileRecord @@ -46,10 +48,13 @@ import type { VouchData, StarData, ProfileCardData, - DirectVouch + DirectVouch, + ActivityPull, + ActivityRepoRef, + ProfileActivityData, + ProfileActivityMonth } from "$lib/components/profile/types"; import { repoKey } from "$lib/components/profile/types"; - export const PROFILE_PAGE_LIMIT = 30; export interface ListPage { @@ -479,3 +484,80 @@ export const fetchPinned = async ( .map((key) => cards.get(key)) .filter((card): card is RepoCardData => card !== undefined); }; + +const PROFILE_ACTIVITY_MONTHS = 7; + +const monthOffset = (value: string, now: Date): number | null => { + const date = new Date(value); + if (Number.isNaN(date.getTime()) || date > now) return null; + const offset = + (now.getUTCFullYear() - date.getUTCFullYear()) * 12 + + (now.getUTCMonth() - date.getUTCMonth()); + return offset >= 0 && offset < PROFILE_ACTIVITY_MONTHS ? offset : null; +}; + +const activityPullState = (state: string): ActivityPull["state"] => { + if (state === "closed") return "closed"; + if (state === "merged") return "merged"; + return "open"; +}; + +const activityRepoRef = ( + repo: ProfileActivityRepoRef, + profileDid: string, + profileHandle: string +): ActivityRepoRef => ({ + repoDid: repo.did ?? "", + ownerHandle: + repo.ownerDid === profileDid + ? profileHandle + : repo.ownerHandle.endsWith(".invalid") + ? repo.ownerDid + : repo.ownerHandle, + name: repo.name +}); + +export interface ProfileActivityOptions { + did: string; + handle: string; + now?: Date; +} + +export const fetchProfileActivity = async ( + ctx: BobbinContext, + { did, handle, now = new Date() }: ProfileActivityOptions +): Promise => { + const profileHandle = handle.endsWith(".invalid") ? did : handle; + const output = await getProfileActivity(ctx, did, PROFILE_ACTIVITY_MONTHS); + return { + months: output.months.flatMap((month): ProfileActivityMonth[] => { + const monthsAgo = monthOffset(month.start, now); + if (monthsAgo === null) return []; + return [ + { + monthsAgo, + commits: Math.max(0, month.commits), + repos: month.repos.map((item) => ({ + ...activityRepoRef(item.repo, did, profileHandle), + forked: item.kind === "forked" + })), + issues: month.issues.map((item) => ({ + uri: item.uri, + rkey: rkeyFromUri(item.uri), + title: item.title, + state: item.state === "closed" ? "closed" : "open", + repo: activityRepoRef(item.repo, did, profileHandle) + })), + pulls: month.pulls.map((item) => ({ + uri: item.uri, + rkey: rkeyFromUri(item.uri), + title: item.title, + state: activityPullState(item.state), + repo: activityRepoRef(item.repo, did, profileHandle) + })) + } + ]; + }), + truncated: output.truncated + }; +}; diff --git a/web/src/lib/components/profile/tabs/OverviewTab.stories.svelte b/web/src/lib/components/profile/tabs/OverviewTab.stories.svelte index 4822402b8..1f1cf8a82 100644 --- a/web/src/lib/components/profile/tabs/OverviewTab.stories.svelte +++ b/web/src/lib/components/profile/tabs/OverviewTab.stories.svelte @@ -1,9 +1,8 @@ + + { + const canvas = within(canvasElement); + expect(canvas.getByText("Committed 12 commits")).toBeVisible(); + await userEvent.click(canvas.getByText("Created 1 issue")); + await userEvent.click(canvas.getByText("Created 1 pull request")); + expect(canvas.getByRole("link", { name: "Render profile activity" })).toHaveAttribute( + "href", + "/tangled.org/core/issues/at://did:plc:dawn/sh.tangled.repo.issue/3mabcissue" + ); + expect(canvas.getByRole("link", { name: "Add the activity view" })).toHaveAttribute( + "href", + "/tangled.org/core/pulls/at://did:plc:dawn/sh.tangled.repo.pull/3mabcpull/latest" + ); + }} +/> \ No newline at end of file diff --git a/web/src/lib/components/profile/tabs/OverviewTab.svelte b/web/src/lib/components/profile/tabs/OverviewTab.svelte index d4977ac66..32bc86e1e 100644 --- a/web/src/lib/components/profile/tabs/OverviewTab.svelte +++ b/web/src/lib/components/profile/tabs/OverviewTab.svelte @@ -1,18 +1,18 @@ -
+

Activity

- +
diff --git a/web/src/lib/components/profile/types.ts b/web/src/lib/components/profile/types.ts index 2a6323fbb..bfad6e5ce 100644 --- a/web/src/lib/components/profile/types.ts +++ b/web/src/lib/components/profile/types.ts @@ -104,3 +104,42 @@ export interface ProfileCardData { export type StarData = | { kind: "repo"; uri: string; createdAt: string; repo: RepoCardData } | { kind: "string"; uri: string; createdAt: string; ownerHandle: string; rkey: string }; + +export interface ActivityRepoRef { + repoDid: string; + ownerHandle: string; + name: string; +} + +export interface ActivityRepo extends ActivityRepoRef { + forked: boolean; +} + +export interface ActivityIssue { + uri: string; + rkey: string; + title: string; + state: "open" | "closed"; + repo: ActivityRepoRef; +} + +export interface ActivityPull { + uri: string; + rkey: string; + title: string; + state: "open" | "closed" | "merged"; + repo: ActivityRepoRef; +} + +export interface ProfileActivityMonth { + monthsAgo: number; + commits: number; + repos: ActivityRepo[]; + issues: ActivityIssue[]; + pulls: ActivityPull[]; +} + +export interface ProfileActivityData { + months: ProfileActivityMonth[]; + truncated: boolean; +} diff --git a/web/src/routes/[handle]/+page.svelte b/web/src/routes/[handle]/+page.svelte index 73bcbdfe4..3ccc1ecd9 100644 --- a/web/src/routes/[handle]/+page.svelte +++ b/web/src/routes/[handle]/+page.svelte @@ -30,7 +30,11 @@ {/snippet} {@const identity = await data.identity} - + {:else if data.tab === "repos"} diff --git a/web/src/routes/[handle]/+page.ts b/web/src/routes/[handle]/+page.ts index 63672b611..afa63a562 100644 --- a/web/src/routes/[handle]/+page.ts +++ b/web/src/routes/[handle]/+page.ts @@ -7,7 +7,8 @@ import { fetchStringsPage, fetchStarredPage, fetchPeoplePage, - fetchPinned + fetchPinned, + fetchProfileActivity } from "$lib/components/profile/pages"; import type { PageLoad } from "./$types"; @@ -97,7 +98,15 @@ export const load: PageLoad = async (event) => { viewerDid }) ); - return { tab: "overview" as const, overview: { pinned: stream(pinned) } }; + const activity = identity.then(({ did, handle }) => + cached(`profile:${did}:activity`, 30_000, () => + fetchProfileActivity(ctx, { did, handle }) + ) + ); + return { + tab: "overview" as const, + overview: { pinned: stream(pinned), activity: stream(activity) } + }; } } }; -- 2.51.2