diff --git a/README.md b/README.md index 7a43f00..4d87fef 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,10 @@ Novas gravações podem ser pausadas e retomadas com uma preferência persistida somente no dispositivo; pausar preserva tudo que já foi salvo, enquanto retomar registra imediatamente a leitura atual quando ela estiver completa. Leituras parciais nunca substituem uma linha de base completa. A linha do tempo -mostra todas as observações distintas, seus cinco sinais — seguidores, selos, -PRs, estrelas e repositórios — e a mudança desde o estado anterior. +mostra todas as observações distintas, seus seis sinais — seguidores, perfis +seguidos, selos, PRs, estrelas e repositórios — e a mudança desde o estado +anterior. Leituras antigas são migradas sem inventar a contagem de perfis +seguidos quando esse campo ainda não era preservado. Quando existem pelo menos dois perfis nessa memória, o painel mostra órbitas recentes ordenadas pela última observação, com um resumo dos cinco sinais e diff --git a/app/page.tsx b/app/page.tsx index 922cd1f..44ef224 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -134,6 +134,7 @@ function timelineChangeSummary(changes: AuditChanges | null) { const signals = [ { value: changes.followers, label: "seguidores" }, + { value: changes.following, label: "seguindo" }, { value: changes.visibleAchievements, label: "selos" }, { value: changes.mergedPullRequests, label: "PRs" }, { value: changes.topRepositoryStars, label: "estrelas" }, @@ -195,6 +196,7 @@ function RecentOrbits({ {timelineMetric(profile.followers)} seguidores + {timelineMetric(profile.following)} seguindo {timelineMetric(profile.visibleAchievementCount)} selos {timelineMetric(profile.mergedPullRequests)} PRs {timelineMetric(profile.topRepositoryStars)} estrelas @@ -465,6 +467,7 @@ function ProgressHistory({ const changedSignals = memory.changes ? [ { value: memory.changes.followers, label: "seguidores" }, + { value: memory.changes.following, label: "seguindo" }, { value: memory.changes.visibleAchievements, label: "conquistas visíveis" }, { value: memory.changes.mergedPullRequests, label: "PRs mesclados" }, { value: memory.changes.topRepositoryStars, label: "estrelas no melhor projeto" }, @@ -596,6 +599,7 @@ function ProgressHistory({ Observação Seguidores + Seguindo Selos PRs Estrelas @@ -613,6 +617,7 @@ function ProgressHistory({ {index === 0 ? mais recente : null} {timelineMetric(entry.snapshot.followers)} + {timelineMetric(entry.snapshot.following)} {timelineMetric(entry.snapshot.visibleAchievementCount)} {timelineMetric(entry.snapshot.mergedPullRequests)} {timelineMetric(entry.snapshot.topRepositoryStars)} diff --git a/lib/audit-history.ts b/lib/audit-history.ts index a2c4c09..57063df 100644 --- a/lib/audit-history.ts +++ b/lib/audit-history.ts @@ -7,11 +7,12 @@ export const MAX_SNAPSHOTS_PER_PROFILE = 12; export const MAX_TRACKED_PROFILES = 8; export type AuditSnapshot = { - version: 2; + version: 3; login: string; capturedAt: string; complete: boolean; followers: number | null; + following: number | null; visibleAchievementCount: number | null; mergedPullRequests: number | null; topRepositoryStars: number | null; @@ -23,7 +24,7 @@ export type AuditHistory = Record; export type AuditHistoryBackup = { format: typeof AUDIT_HISTORY_BACKUP_FORMAT; - version: 2; + version: 3; exportedAt: string; history: AuditHistory; }; @@ -33,6 +34,7 @@ export type RecentAuditProfile = { lastObservedAt: string; observationCount: number; followers: number | null; + following: number | null; visibleAchievementCount: number | null; mergedPullRequests: number | null; topRepositoryStars: number | null; @@ -41,6 +43,7 @@ export type RecentAuditProfile = { export type AuditChanges = { followers: number | null; + following: number | null; visibleAchievements: number | null; mergedPullRequests: number | null; topRepositoryStars: number | null; @@ -57,6 +60,7 @@ type SnapshotInput = { profile: { login: string; followers: number; + following: number; publicRepos: number; }; metrics: { @@ -87,15 +91,19 @@ function normalizeSnapshot(value: unknown): AuditSnapshot | null { const followers = snapshot.version === 1 && snapshot.followers === undefined ? null : snapshot.followers; + const following = (snapshot.version === 1 || snapshot.version === 2) && snapshot.following === undefined + ? null + : snapshot.following; if ( - (snapshot.version !== 1 && snapshot.version !== 2) || + (snapshot.version !== 1 && snapshot.version !== 2 && snapshot.version !== 3) || typeof snapshot.login !== "string" || !GITHUB_LOGIN_PATTERN.test(snapshot.login) || typeof snapshot.capturedAt !== "string" || !Number.isFinite(Date.parse(snapshot.capturedAt)) || typeof snapshot.complete !== "boolean" || !nullableCount(followers) || + !nullableCount(following) || !nullableCount(snapshot.visibleAchievementCount) || !nullableCount(snapshot.mergedPullRequests) || !nullableCount(snapshot.topRepositoryStars) || @@ -112,11 +120,12 @@ function normalizeSnapshot(value: unknown): AuditSnapshot | null { } return { - version: 2, + version: 3, login: snapshot.login, capturedAt: snapshot.capturedAt, complete: snapshot.complete, followers, + following, visibleAchievementCount: snapshot.visibleAchievementCount, mergedPullRequests: snapshot.mergedPullRequests, topRepositoryStars: snapshot.topRepositoryStars, @@ -176,6 +185,7 @@ function sameSignals(left: AuditSnapshot, right: AuditSnapshot) { return ( left.complete === right.complete && left.followers === right.followers && + left.following === right.following && left.visibleAchievementCount === right.visibleAchievementCount && left.mergedPullRequests === right.mergedPullRequests && left.topRepositoryStars === right.topRepositoryStars && @@ -188,11 +198,12 @@ export function createAuditSnapshot(audit: SnapshotInput): AuditSnapshot { const complete = Object.values(audit.sources).every((source) => source === "available"); return { - version: 2, + version: 3, login: audit.profile.login, capturedAt: audit.generatedAt, complete, followers: audit.profile.followers, + following: audit.profile.following, visibleAchievementCount: audit.visibleAchievementCount, mergedPullRequests: audit.metrics.mergedPullRequests, topRepositoryStars: @@ -262,6 +273,7 @@ export function listRecentAuditProfiles(history: AuditHistory): RecentAuditProfi lastObservedAt: latest.capturedAt, observationCount: completeSnapshots.length, followers: latest.followers, + following: latest.following, visibleAchievementCount: latest.visibleAchievementCount, mergedPullRequests: latest.mergedPullRequests, topRepositoryStars: latest.topRepositoryStars, @@ -296,12 +308,14 @@ export function serializeAuditTimelineCsv(timeline: AuditTimelineEntry[]) { "captured_at", "login", "followers", + "following", "visible_achievements", "merged_pull_requests", "top_repository_stars", "public_repositories", "unlocked_achievements", "followers_change", + "following_change", "visible_achievements_change", "merged_pull_requests_change", "top_repository_stars_change", @@ -315,12 +329,14 @@ export function serializeAuditTimelineCsv(timeline: AuditTimelineEntry[]) { snapshot.capturedAt, snapshot.login, snapshot.followers, + snapshot.following, snapshot.visibleAchievementCount, snapshot.mergedPullRequests, snapshot.topRepositoryStars, snapshot.publicRepositories, snapshot.unlockedAchievementSlugs?.join("|") ?? "", changes?.followers ?? null, + changes?.following ?? null, changes?.visibleAchievements ?? null, changes?.mergedPullRequests ?? null, changes?.topRepositoryStars ?? null, @@ -336,7 +352,7 @@ export function serializeAuditTimelineCsv(timeline: AuditTimelineEntry[]) { export function serializeAuditHistoryBackup(history: AuditHistory, exportedAt = new Date().toISOString()) { const backup: AuditHistoryBackup = { format: AUDIT_HISTORY_BACKUP_FORMAT, - version: 2, + version: 3, exportedAt, history: mergeAuditHistories({}, history), }; @@ -352,7 +368,7 @@ export function parseAuditHistoryBackup(serialized: string): AuditHistoryBackup !parsed || typeof parsed !== "object" || parsed.format !== AUDIT_HISTORY_BACKUP_FORMAT || - (parsed.version !== 1 && parsed.version !== 2) || + (parsed.version !== 1 && parsed.version !== 2 && parsed.version !== 3) || typeof parsed.exportedAt !== "string" || !Number.isFinite(Date.parse(parsed.exportedAt)) || !history @@ -362,7 +378,7 @@ export function parseAuditHistoryBackup(serialized: string): AuditHistoryBackup return { format: AUDIT_HISTORY_BACKUP_FORMAT, - version: 2, + version: 3, exportedAt: parsed.exportedAt, history, }; @@ -434,6 +450,7 @@ export function compareAuditSnapshots(current: AuditSnapshot, previous: AuditSna return { followers: delta(current.followers, previous.followers), + following: delta(current.following, previous.following), visibleAchievements: delta(current.visibleAchievementCount, previous.visibleAchievementCount), mergedPullRequests: delta(current.mergedPullRequests, previous.mergedPullRequests), topRepositoryStars: delta(current.topRepositoryStars, previous.topRepositoryStars), diff --git a/tests/audit-history-preference.test.mjs b/tests/audit-history-preference.test.mjs index eda09c3..2b4b3be 100644 --- a/tests/audit-history-preference.test.mjs +++ b/tests/audit-history-preference.test.mjs @@ -8,11 +8,12 @@ import { } from "../lib/audit-history-preference.ts"; const completeSnapshot = { - version: 2, + version: 3, login: "octocat", capturedAt: "2026-07-31T12:00:00.000Z", complete: true, followers: 10, + following: 6, visibleAchievementCount: 2, mergedPullRequests: 3, topRepositoryStars: 4, diff --git a/tests/audit-history-storage.test.mjs b/tests/audit-history-storage.test.mjs index e80b035..68051de 100644 --- a/tests/audit-history-storage.test.mjs +++ b/tests/audit-history-storage.test.mjs @@ -16,11 +16,12 @@ import { function snapshot(login, capturedAt) { return { - version: 2, + version: 3, login, capturedAt, complete: true, followers: 10, + following: 6, visibleAchievementCount: 2, mergedPullRequests: 3, topRepositoryStars: 4, diff --git a/tests/audit-history.test.mjs b/tests/audit-history.test.mjs index 013193d..f6cc6a1 100644 --- a/tests/audit-history.test.mjs +++ b/tests/audit-history.test.mjs @@ -23,7 +23,7 @@ import { function audit(overrides = {}) { return { - profile: { login: "octocat", followers: 18_400, publicRepos: 8 }, + profile: { login: "octocat", followers: 18_400, following: 12, publicRepos: 8 }, metrics: { mergedPullRequests: 12, topRepository: { stars: 7 }, @@ -46,11 +46,12 @@ function audit(overrides = {}) { test("creates a complete, minimal snapshot from an audit", () => { assert.deepEqual(createAuditSnapshot(audit()), { - version: 2, + version: 3, login: "octocat", capturedAt: "2026-07-31T00:00:00.000Z", complete: true, followers: 18_400, + following: 12, visibleAchievementCount: 2, mergedPullRequests: 12, topRepositoryStars: 7, @@ -95,6 +96,7 @@ test("compares changes against the last different state", () => { assert.equal(comparison?.capturedAt, baseline.capturedAt); assert.deepEqual(compareAuditSnapshots(current, comparison), { followers: 0, + following: 0, visibleAchievements: 1, mergedPullRequests: 3, topRepositoryStars: 1, @@ -116,7 +118,7 @@ test("builds a newest-first timeline from complete snapshots in chronological or generatedAt: "2026-08-01T00:00:00.000Z", })); const latest = createAuditSnapshot(audit({ - profile: { login: "octocat", followers: 18_410, publicRepos: 9 }, + profile: { login: "octocat", followers: 18_410, following: 14, publicRepos: 9 }, metrics: { mergedPullRequests: 17, topRepository: { stars: 8 }, @@ -150,6 +152,7 @@ test("builds a newest-first timeline from complete snapshots in chronological or ); assert.deepEqual(timeline[0].changes, { followers: 10, + following: 2, visibleAchievements: 1, mergedPullRequests: 2, topRepositoryStars: 0, @@ -158,6 +161,7 @@ test("builds a newest-first timeline from complete snapshots in chronological or }); assert.deepEqual(timeline[1].changes, { followers: 0, + following: 0, visibleAchievements: 0, mergedPullRequests: 3, topRepositoryStars: 1, @@ -170,7 +174,7 @@ test("builds a newest-first timeline from complete snapshots in chronological or test("exports a chronological spreadsheet timeline with values and deltas", () => { const baseline = createAuditSnapshot(audit()); const latest = createAuditSnapshot(audit({ - profile: { login: "octocat", followers: 18_410, publicRepos: 9 }, + profile: { login: "octocat", followers: 18_410, following: 14, publicRepos: 9 }, metrics: { mergedPullRequests: 15, topRepository: { stars: 8 } }, visibleAchievementCount: 3, achievements: [ @@ -184,19 +188,19 @@ test("exports a chronological spreadsheet timeline with values and deltas", () = const csv = serializeAuditTimelineCsv(timeline); const lines = csv.slice(1).trimEnd().split("\r\n"); - assert.equal(csv.startsWith("\uFEFFcaptured_at,login,followers"), true); + assert.equal(csv.startsWith("\uFEFFcaptured_at,login,followers,following"), true); assert.equal(lines.length, 3); assert.equal( lines[0], - "captured_at,login,followers,visible_achievements,merged_pull_requests,top_repository_stars,public_repositories,unlocked_achievements,followers_change,visible_achievements_change,merged_pull_requests_change,top_repository_stars_change,public_repositories_change,newly_unlocked_achievements", + "captured_at,login,followers,following,visible_achievements,merged_pull_requests,top_repository_stars,public_repositories,unlocked_achievements,followers_change,following_change,visible_achievements_change,merged_pull_requests_change,top_repository_stars_change,public_repositories_change,newly_unlocked_achievements", ); assert.equal( lines[1], - "2026-07-31T00:00:00.000Z,octocat,18400,2,12,7,8,quickdraw|pull-shark,,,,,,", + "2026-07-31T00:00:00.000Z,octocat,18400,12,2,12,7,8,quickdraw|pull-shark,,,,,,,", ); assert.equal( lines[2], - "2026-08-03T00:00:00.000Z,octocat,18410,3,15,8,9,quickdraw|pull-shark|starstruck,10,1,3,1,1,starstruck", + "2026-08-03T00:00:00.000Z,octocat,18410,14,3,15,8,9,quickdraw|pull-shark|starstruck,10,2,1,3,1,1,starstruck", ); assert.equal( auditTimelineCsvFilename("OctoCat", "2026-08-04T10:20:30.000Z"), @@ -244,7 +248,7 @@ test("keeps only the most recently observed profiles", () => { for (let index = 0; index < MAX_TRACKED_PROFILES + 2; index += 1) { history = appendAuditSnapshot(history, createAuditSnapshot(audit({ - profile: { login: `user-${index}`, followers: index * 10, publicRepos: index }, + profile: { login: `user-${index}`, followers: index * 10, following: index, publicRepos: index }, generatedAt: new Date(Date.UTC(2026, 7, index + 1)).toISOString(), }))); } @@ -273,7 +277,7 @@ test("round-trips a versioned backup with a stable filename", () => { const backup = parseAuditHistoryBackup(serialized); assert.equal(backup?.format, "constellation-audit-history"); - assert.equal(backup?.version, 2); + assert.equal(backup?.version, 3); assert.equal(backup?.exportedAt, exportedAt); assert.deepEqual(backup?.history, history); assert.equal(countAuditHistorySnapshots(backup.history), 1); @@ -287,7 +291,7 @@ test("rejects malformed, incompatible and internally inconsistent backups", () = const valid = JSON.parse(serializeAuditHistoryBackup({ octocat: [snapshot] })); assert.equal(parseAuditHistoryBackup("not-json"), null); - assert.equal(parseAuditHistoryBackup(JSON.stringify({ ...valid, version: 3 })), null); + assert.equal(parseAuditHistoryBackup(JSON.stringify({ ...valid, version: 4 })), null); assert.equal(parseAuditHistoryBackup(JSON.stringify({ ...valid, history: { hubot: [snapshot] }, @@ -339,14 +343,16 @@ test("merges backups chronologically without duplicating shared observations", ( assert.equal(countAuditHistorySnapshots(merged), 3); }); -test("migrates legacy history and version 1 backups without inventing followers", () => { +test("migrates legacy history and old backups without inventing unavailable profile counts", () => { const current = createAuditSnapshot(audit()); const legacy = { ...current, version: 1 }; delete legacy.followers; + delete legacy.following; const parsedHistory = parseAuditHistory(JSON.stringify({ octocat: [legacy] })); - assert.equal(parsedHistory.octocat[0].version, 2); + assert.equal(parsedHistory.octocat[0].version, 3); assert.equal(parsedHistory.octocat[0].followers, null); + assert.equal(parsedHistory.octocat[0].following, null); const legacyBackup = JSON.stringify({ format: "constellation-audit-history", @@ -355,9 +361,16 @@ test("migrates legacy history and version 1 backups without inventing followers" history: { octocat: [legacy] }, }); const migratedBackup = parseAuditHistoryBackup(legacyBackup); - assert.equal(migratedBackup?.version, 2); + assert.equal(migratedBackup?.version, 3); assert.equal(migratedBackup?.history.octocat[0].followers, null); assert.equal(compareAuditSnapshots(current, migratedBackup.history.octocat[0]).followers, null); + assert.equal(compareAuditSnapshots(current, migratedBackup.history.octocat[0]).following, null); + + const version2 = { ...current, version: 2 }; + delete version2.following; + const migratedVersion2 = parseAuditHistory(JSON.stringify({ octocat: [version2] })); + assert.equal(migratedVersion2.octocat[0].version, 3); + assert.equal(migratedVersion2.octocat[0].following, null); }); test("reapplies profile and snapshot retention limits while merging", () => { @@ -368,7 +381,7 @@ test("reapplies profile and snapshot retention limits while merging", () => { imported[login] = []; for (let snapshot = 0; snapshot < MAX_SNAPSHOTS_PER_PROFILE + 2; snapshot += 1) { imported[login].push(createAuditSnapshot(audit({ - profile: { login, followers: profile * 10, publicRepos: profile }, + profile: { login, followers: profile * 10, following: profile, publicRepos: profile }, metrics: { mergedPullRequests: snapshot, topRepository: { stars: profile } }, generatedAt: new Date(Date.UTC(2026, 7, profile * 20 + snapshot + 1)).toISOString(), }))); @@ -392,13 +405,13 @@ test("lists recent profiles by their latest complete observation", () => { generatedAt: "2026-08-04T00:00:00.000Z", })); const hubotLatest = createAuditSnapshot(audit({ - profile: { login: "hubot", followers: 200, publicRepos: 14 }, + profile: { login: "hubot", followers: 200, following: 5, publicRepos: 14 }, metrics: { mergedPullRequests: 7, topRepository: { stars: 21 } }, visibleAchievementCount: 1, generatedAt: "2026-08-03T00:00:00.000Z", })); const partialOnly = createAuditSnapshot(audit({ - profile: { login: "monalisa", followers: 10, publicRepos: 4 }, + profile: { login: "monalisa", followers: 10, following: 2, publicRepos: 4 }, sources: { achievements: "unavailable", mergedPullRequests: "available", @@ -420,6 +433,7 @@ test("lists recent profiles by their latest complete observation", () => { lastObservedAt: octocatLatest.capturedAt, observationCount: 2, followers: 18_400, + following: 12, visibleAchievementCount: 3, mergedPullRequests: 18, topRepositoryStars: 9,