diff --git a/README.md b/README.md index 43ff5c0..b747bff 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ Required environment variables: | `RESEND_API_KEY` | Optional Resend API key for claim and approval emails | | `EMAIL_FROM` | Sender on a domain verified by Resend | | `COSMOS_WATCHLIST_CONTAINER` | Optional private watchlist container name (default: `watchlists`) | +| `COSMOS_IMPACT_HISTORY_CONTAINER` | Optional impact snapshot container name (default: `impact-history`) | | `CRON_SECRET` | Bearer token used by Vercel Cron for the weekly digest endpoint | | `EMAIL_PREFERENCE_SECRET` | HMAC secret for weekly-email unsubscribe links; defaults to `SESSION_SECRET` | diff --git a/app/api/cron/impact-history/route.js b/app/api/cron/impact-history/route.js new file mode 100644 index 0000000..df2468e --- /dev/null +++ b/app/api/cron/impact-history/route.js @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; +import { captureImpactHistory } from '../../../../lib/impact-history-capture.js'; + +export const maxDuration = 300; + +export async function GET(request) { + const cronSecret = process.env.CRON_SECRET?.trim(); + if (!cronSecret || request.headers.get('authorization') !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + return NextResponse.json({ ok: true, ...await captureImpactHistory() }); + } catch (error) { + console.error('Impact history capture failed:', error.message); + return NextResponse.json({ error: 'Impact history capture failed' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/impact-history/route.js b/app/api/impact-history/route.js new file mode 100644 index 0000000..6e9de3f --- /dev/null +++ b/app/api/impact-history/route.js @@ -0,0 +1,78 @@ +import { NextResponse } from 'next/server'; +import { getSession } from '../../../lib/auth.js'; +import { getCosmosContainer } from '../../../lib/cosmos.js'; +import { buildImpactHistory, canViewImpactHistory } from '../../../lib/impact-history.js'; +import { listImpactSnapshots } from '../../../lib/impact-history-store.js'; + +async function findPublicDeveloper(login) { + const container = getCosmosContainer(); + if (!container) return { login, claimed: false, impactHistoryVisibility: 'public' }; + const { resources } = await container.items.query({ + query: `SELECT TOP 1 c.id, c.login, c.location, c.claimed, c.impactHistoryVisibility + FROM c WHERE LOWER(c.login) = @login + AND (NOT IS_DEFINED(c.nomination) OR c.nomination.status = 'approved')`, + parameters: [{ name: '@login', value: login.toLowerCase() }], + }).fetchAll(); + return resources[0] || null; +} + +export async function GET(request) { + const { searchParams } = new URL(request.url); + const login = searchParams.get('login')?.trim(); + if (!login) return NextResponse.json({ error: 'login is required' }, { status: 400 }); + + try { + const [developer, session] = await Promise.all([findPublicDeveloper(login), getSession()]); + if (!developer) return NextResponse.json({ error: 'Developer not found' }, { status: 404 }); + if (!canViewImpactHistory(developer, session?.login)) { + return NextResponse.json({ error: 'Impact history is private' }, { status: 403 }); + } + + const snapshots = await listImpactSnapshots(developer.login, 121); + const result = buildImpactHistory(snapshots); + const ninetyDayCutoff = Date.now() - 90 * 24 * 60 * 60 * 1000; + result.history = result.history.filter(snapshot => Date.parse(snapshot.capturedAt) >= ninetyDayCutoff); + return NextResponse.json({ + ...result, + owner: Boolean(session?.login && session.login.toLowerCase() === developer.login.toLowerCase()), + visibility: developer.impactHistoryVisibility === 'private' ? 'private' : 'public', + }, { headers: { 'Cache-Control': 'no-store' } }); + } catch (error) { + console.error('Impact history query failed:', error.message); + return NextResponse.json({ error: 'Unable to load impact history' }, { status: 503 }); + } +} + +export async function PUT(request) { + const session = await getSession(); + if (!session?.login) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + let visibility; + try { + visibility = (await request.json()).visibility; + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + if (!['public', 'private'].includes(visibility)) { + return NextResponse.json({ error: 'visibility must be public or private' }, { status: 400 }); + } + + try { + const container = getCosmosContainer(); + if (!container) return NextResponse.json({ error: 'Cosmos DB is not configured' }, { status: 503 }); + const { resources } = await container.items.query({ + query: 'SELECT TOP 1 * FROM c WHERE LOWER(c.login) = @login AND c.claimed = true', + parameters: [{ name: '@login', value: session.login.toLowerCase() }], + }).fetchAll(); + const developer = resources[0]; + if (!developer) return NextResponse.json({ error: 'Claim your profile first' }, { status: 403 }); + + await container.item(developer.id, developer.location).patch([ + { op: 'set', path: '/impactHistoryVisibility', value: visibility }, + ], { accessCondition: { type: 'IfMatch', condition: developer._etag } }); + return NextResponse.json({ visibility }); + } catch (error) { + console.error('Impact history visibility update failed:', error.message); + return NextResponse.json({ error: 'Unable to update impact history visibility' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/components/DeveloperActivityPage.jsx b/components/DeveloperActivityPage.jsx index 6c7ac71..fc84000 100644 --- a/components/DeveloperActivityPage.jsx +++ b/components/DeveloperActivityPage.jsx @@ -5,6 +5,7 @@ import { useEffect, useState } from 'react'; import { formatNum, formatRelativeTime } from '../lib/format.js'; import { useActivityFeed } from './useActivityFeed.js'; import SpecialTags from './SpecialTags.jsx'; +import ImpactHistoryPanel from './ImpactHistoryPanel.jsx'; export default function DeveloperActivityPage({ login }) { const [developer, setDeveloper] = useState(null); @@ -74,6 +75,8 @@ export default function DeveloperActivityPage({ login }) { + +
diff --git a/components/ImpactHistoryPanel.jsx b/components/ImpactHistoryPanel.jsx new file mode 100644 index 0000000..02d27ce --- /dev/null +++ b/components/ImpactHistoryPanel.jsx @@ -0,0 +1,147 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { formatNum } from '../lib/format.js'; + +const PERIODS = [7, 30, 90]; +const RANK_SERIES = [ + { key: 'globalRank', label: 'Global', color: '#0891b2' }, + { key: 'countryRank', label: 'Country', color: '#2ea44f' }, + { key: 'languageRank', label: 'Language', color: '#d97706' }, +]; + +function Delta({ value, rank = false }) { + if (value == null) return No comparison; + const positive = value > 0; + const display = `${positive ? '+' : ''}${formatNum(value)}`; + return {display}{rank && value !== 0 ? ' places' : ''}; +} + +function RankChart({ history }) { + const available = RANK_SERIES.filter(series => history.some(item => Number.isInteger(item[series.key]))); + if (history.length < 2 || available.length === 0) { + return

Rank trends appear after at least two daily snapshots.

; + } + + const width = 720; + const height = 220; + const padding = 24; + const pointsFor = key => { + const values = history.map(item => item[key]).filter(Number.isInteger); + const max = Math.max(...values); + const min = Math.min(...values); + return history.map((item, index) => { + if (!Number.isInteger(item[key])) return null; + const x = padding + index * ((width - padding * 2) / Math.max(history.length - 1, 1)); + const y = padding + ((item[key] - min) / Math.max(max - min, 1)) * (height - padding * 2); + return `${x},${y}`; + }).filter(Boolean).join(' '); + }; + + return ( +
+ + {[0, 1, 2, 3, 4].map(index => )} + {available.map(series => )} + +
+ {available.map(series => {series.label})} +
+
+ ); +} + +export default function ImpactHistoryPanel({ login }) { + const [result, setResult] = useState(null); + const [period, setPeriod] = useState(7); + const [status, setStatus] = useState('loading'); + const [error, setError] = useState(''); + + useEffect(() => { + let cancelled = false; + fetch(`/api/impact-history?login=${encodeURIComponent(login)}`, { cache: 'no-store' }) + .then(async response => { + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Unable to load impact history'); + if (!cancelled) { + setResult(data); + setStatus('ready'); + } + }) + .catch(loadError => { + if (!cancelled) { + setError(loadError.message); + setStatus('error'); + } + }); + return () => { cancelled = true; }; + }, [login]); + + const updateVisibility = async event => { + const visibility = event.target.checked ? 'public' : 'private'; + setError(''); + try { + const response = await fetch('/api/impact-history', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ visibility }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Unable to update visibility'); + setResult(current => ({ ...current, visibility: data.visibility })); + } catch (updateError) { + setError(updateError.message); + } + }; + + if (status === 'loading') return

Loading impact history...

; + if (status === 'error') return

{error}

; + if (!result.current) return ( +
+
DAILY SNAPSHOTS

Impact history

+

History starts with the next daily snapshot. No earlier activity is inferred.

+
+ ); + + const comparison = result.periods[period]; + const current = result.current; + return ( +
+
+
DAILY SNAPSHOTS

Impact history

+ {result.owner && ( + + )} +
+ +
+ {PERIODS.map(days => )} +
+ +
+
Score{current.score}
+
Stars{formatNum(current.totalStars)}
+
Followers{formatNum(current.followers)}
+
Commits{formatNum(current.totalCommits)}
+
+ + {!comparison.available &&

A {period}-day comparison is not available yet. DevGlobe will show it after enough snapshots are collected.

} + +
+
Global rank{current.globalRank ? `#${formatNum(current.globalRank)}` : '—'}
+
{current.country ? `${current.country} rank` : 'Country rank'}{current.countryRank ? `#${formatNum(current.countryRank)}` : '—'}
+
{current.language ? `${current.language} rank` : 'Language rank'}{current.languageRank ? `#${formatNum(current.languageRank)}` : '—'}
+
+ + + + {result.explanations.length > 0 && ( +
What changed the score

{result.explanations.join(', ')}.

+ )} + {error &&

{error}

} +
+ ); +} \ No newline at end of file diff --git a/docs/prd/developer-impact-history.md b/docs/prd/developer-impact-history.md new file mode 100644 index 0000000..8937e9a --- /dev/null +++ b/docs/prd/developer-impact-history.md @@ -0,0 +1,59 @@ +# PRD: Developer Impact History and Rank Movement + +**Status:** MVP implementation +**Issue:** [#111](https://github.com/sajeetharan/devglobe/issues/111) +**Priority:** P0 +**Related:** [#22](https://github.com/sajeetharan/devglobe/issues/22), [#127](https://github.com/sajeetharan/devglobe/issues/127), [#168](https://github.com/sajeetharan/devglobe/issues/168) +**Last updated:** 2026-08-16 + +## Summary + +DevGlobe will capture one compact impact snapshot per public developer per UTC day and show how score, stars, followers, commits, and ranks change over time. Rank movements also become events in the personalized feed for followed developers. + +## Goals + +- Persist dated metrics without copying full developer documents. +- Show current values and nearest available changes over 7, 30, and 90 days. +- Chart global, country, and primary-language rank history. +- Explain material score changes using normalized score dimensions. +- Represent new profiles and sparse periods as unavailable rather than zero movement. +- Let claimed profile owners make impact history private. + +## Non-goals + +- Treating the DevGlobe score as an absolute measure of skill. +- Reconstructing history before the first captured snapshot. +- Real-time snapshots after every GitHub event. +- Public follower counts or comparisons between private histories. + +## Snapshot model + +Snapshots contain only `login`, UTC day/time, score dimensions, aggregate stars/followers/commits, and global/country/language rank metadata. They exclude email, OAuth, contact, biography, repositories, AI preferences, and other full-profile fields. The ID is `:`, making daily reruns idempotent. + +## Capture + +Vercel invokes `/api/cron/impact-history` daily with `CRON_SECRET`. The job scores and ranks the complete public dataset once, adds rank within each primary language, writes snapshots, and publishes a deduplicated `rank_movement` feed event when global rank changes. + +## History and deltas + +The API returns at most 90 days in ascending order. A period comparison uses the nearest snapshot at or before its boundary. If none exists, the period is explicitly unavailable. Country and language movement are omitted when the cohort changed. + +## Privacy + +History defaults to public because all underlying metrics are already public. A claimed owner can set `impactHistoryVisibility` to `private`; non-owner history requests then return `403`, and future movement events are private. Owners retain access to their own history and can restore public visibility. + +## Success metrics + +- Claimed-profile weekly return rate. +- History-view opens and 7/30/90 period engagement. +- Personalized-feed opens from rank-movement events. +- Weekly-digest clicks into history. + +## Acceptance criteria + +- Daily writes are idempotent and compact. +- 7/30/90 changes and sparse periods are tested. +- Global, country, and language rank history is available where cohorts are stable. +- Material score changes identify up to three underlying dimensions. +- Claimed owners can control visibility. +- Full tests and production build pass. \ No newline at end of file diff --git a/lib/impact-history-capture.js b/lib/impact-history-capture.js new file mode 100644 index 0000000..87907d4 --- /dev/null +++ b/lib/impact-history-capture.js @@ -0,0 +1,41 @@ +import { getCosmosContainer } from './cosmos.js'; +import { saveFeedEvents } from './feed-store.js'; +import { addDeveloperRanks } from './ranking.js'; +import { scoreAll } from './scoring.js'; +import { addLanguageRanks, createImpactSnapshot, createRankMovementEvent } from './impact-history.js'; +import { getLatestImpactSnapshot, saveImpactSnapshot } from './impact-history-store.js'; + +async function listPublicDevelopers() { + const container = getCosmosContainer(); + if (!container) throw new Error('Cosmos DB is not configured'); + const { resources } = await container.items.query(`SELECT * FROM c + WHERE NOT IS_DEFINED(c.nomination) OR c.nomination.status = 'approved'`).fetchAll(); + return resources; +} + +export async function captureImpactHistory(options = {}) { + const now = options.now || new Date(); + const developers = options.developers || await listPublicDevelopers(); + const getPrevious = options.getPrevious || getLatestImpactSnapshot; + const saveSnapshot = options.saveSnapshot || saveImpactSnapshot; + const saveEvents = options.saveEvents || saveFeedEvents; + const ranked = addLanguageRanks(addDeveloperRanks(scoreAll(developers))); + let snapshots = 0; + let movements = 0; + + for (const developer of ranked) { + const snapshot = createImpactSnapshot(developer, now.toISOString()); + const previous = await getPrevious(snapshot.login, snapshot.day); + await saveSnapshot(snapshot); + snapshots += 1; + + const event = createRankMovementEvent(snapshot, previous); + if (event) { + const result = await saveEvents([event], { + isPublicDeveloper: developer.impactHistoryVisibility !== 'private', + }); + movements += result.inserted; + } + } + return { developers: ranked.length, snapshots, movements, day: now.toISOString().slice(0, 10) }; +} \ No newline at end of file diff --git a/lib/impact-history-store.js b/lib/impact-history-store.js new file mode 100644 index 0000000..4189590 --- /dev/null +++ b/lib/impact-history-store.js @@ -0,0 +1,65 @@ +import { getCosmosContainer } from './cosmos.js'; + +const memorySnapshots = new Map(); + +function getHistoryContainer() { + return getCosmosContainer(process.env.COSMOS_IMPACT_HISTORY_CONTAINER || 'impact-history'); +} + +export async function getLatestImpactSnapshot(login, beforeDay = null) { + const normalizedLogin = String(login || '').toLowerCase(); + const container = getHistoryContainer(); + if (!container) { + return [...memorySnapshots.values()] + .filter(snapshot => snapshot.login === normalizedLogin && (!beforeDay || snapshot.day < beforeDay)) + .sort((a, b) => b.capturedAt.localeCompare(a.capturedAt))[0] || null; + } + + const conditions = ['c.login = @login']; + const parameters = [{ name: '@login', value: normalizedLogin }]; + if (beforeDay) { + conditions.push('c.day < @beforeDay'); + parameters.push({ name: '@beforeDay', value: beforeDay }); + } + const { resources } = await container.items.query({ + query: `SELECT TOP 1 * FROM c WHERE ${conditions.join(' AND ')} ORDER BY c.capturedAt DESC`, + parameters, + }, { partitionKey: normalizedLogin }).fetchAll(); + return resources[0] || null; +} + +export async function listImpactSnapshots(login, days = 90, now = new Date()) { + const normalizedLogin = String(login || '').toLowerCase(); + const cutoff = new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString(); + const container = getHistoryContainer(); + if (!container) { + return [...memorySnapshots.values()] + .filter(snapshot => snapshot.login === normalizedLogin && snapshot.capturedAt >= cutoff) + .sort((a, b) => a.capturedAt.localeCompare(b.capturedAt)); + } + + const { resources } = await container.items.query({ + query: `SELECT * FROM c + WHERE c.login = @login AND c.capturedAt >= @cutoff + ORDER BY c.capturedAt ASC`, + parameters: [ + { name: '@login', value: normalizedLogin }, + { name: '@cutoff', value: cutoff }, + ], + }, { partitionKey: normalizedLogin }).fetchAll(); + return resources; +} + +export async function saveImpactSnapshot(snapshot) { + const container = getHistoryContainer(); + if (!container) { + memorySnapshots.set(snapshot.id, snapshot); + return snapshot; + } + const { resource } = await container.items.upsert(snapshot); + return resource || snapshot; +} + +export function __resetMemoryImpactHistoryForTests() { + memorySnapshots.clear(); +} \ No newline at end of file diff --git a/lib/impact-history.js b/lib/impact-history.js new file mode 100644 index 0000000..7c950cf --- /dev/null +++ b/lib/impact-history.js @@ -0,0 +1,139 @@ +const PERIODS = [7, 30, 90]; +const METRIC_FIELDS = ['score', 'totalStars', 'followers', 'totalCommits']; + +function roundDay(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) throw new Error('Snapshot date is invalid'); + return date.toISOString().slice(0, 10); +} + +export function addLanguageRanks(developers) { + const groups = new Map(); + for (const developer of developers) { + if (!developer.topLanguage) continue; + if (!groups.has(developer.topLanguage)) groups.set(developer.topLanguage, []); + groups.get(developer.topLanguage).push(developer.login); + } + + const ranks = new Map(); + for (const [language, logins] of groups) { + logins.forEach((login, index) => ranks.set(login, { + language, + languageRank: index + 1, + languageTotal: logins.length, + })); + } + return developers.map(developer => ({ ...developer, ...(ranks.get(developer.login) || {}) })); +} + +export function createImpactSnapshot(developer, capturedAt = new Date().toISOString()) { + const day = roundDay(capturedAt); + return { + id: `${developer.login.toLowerCase()}:${day}`, + documentType: 'impact-snapshot', + schemaVersion: 1, + login: developer.login.toLowerCase(), + day, + capturedAt: new Date(capturedAt).toISOString(), + score: developer.score || 0, + scoreDimensions: developer.scoreDimensions || {}, + totalStars: developer.totalStars || 0, + followers: developer.followers || 0, + totalCommits: developer.totalCommits || 0, + globalRank: developer.globalRank || null, + globalTotal: developer.globalTotal || null, + country: developer.country || null, + countryRank: developer.countryRank || null, + countryTotal: developer.countryTotal || null, + language: developer.language || developer.topLanguage || null, + languageRank: developer.languageRank || null, + languageTotal: developer.languageTotal || null, + }; +} + +function latestOnOrBefore(history, targetTime) { + return [...history] + .filter(snapshot => Date.parse(snapshot.capturedAt) <= targetTime) + .sort((a, b) => b.capturedAt.localeCompare(a.capturedAt))[0] || null; +} + +function metricChanges(current, previous) { + if (!previous) return null; + return Object.fromEntries(METRIC_FIELDS.map(field => [field, current[field] - previous[field]])); +} + +function rankChanges(current, previous) { + if (!previous) return null; + const change = field => Number.isInteger(current[field]) && Number.isInteger(previous[field]) + ? previous[field] - current[field] + : null; + return { + globalRank: change('globalRank'), + countryRank: current.country === previous.country ? change('countryRank') : null, + languageRank: current.language === previous.language ? change('languageRank') : null, + }; +} + +export function explainImpactChange(current, previous) { + if (!previous || current.score === previous.score) return []; + const labels = { + stars: 'GitHub stars', + commits: 'commit activity', + repoReach: 'repository reach', + soReputation: 'Stack Overflow reputation', + soEngagement: 'Stack Overflow engagement', + community: 'community reach', + }; + return Object.keys(labels) + .map(key => ({ key, change: (current.scoreDimensions?.[key] || 0) - (previous.scoreDimensions?.[key] || 0) })) + .filter(item => Math.abs(item.change) >= 0.01) + .sort((a, b) => Math.abs(b.change) - Math.abs(a.change)) + .slice(0, 3) + .map(item => `${labels[item.key]} ${item.change > 0 ? 'increased' : 'decreased'}`); +} + +export function buildImpactHistory(history, now = new Date()) { + const ordered = [...history].sort((a, b) => a.capturedAt.localeCompare(b.capturedAt)); + const current = ordered.at(-1) || null; + if (!current) return { current: null, periods: {}, history: [], explanations: [] }; + + const periods = {}; + for (const days of PERIODS) { + const targetTime = now.getTime() - days * 24 * 60 * 60 * 1000; + const previous = latestOnOrBefore(ordered, targetTime); + periods[days] = previous ? { + available: true, + since: previous.capturedAt, + metrics: metricChanges(current, previous), + ranks: rankChanges(current, previous), + } : { available: false }; + } + + const previous = ordered.length > 1 ? ordered.at(-2) : null; + return { current, periods, history: ordered, explanations: explainImpactChange(current, previous) }; +} + +export function createRankMovementEvent(current, previous) { + if (!previous || !Number.isInteger(current.globalRank) || !Number.isInteger(previous.globalRank)) return null; + const movement = previous.globalRank - current.globalRank; + if (movement === 0) return null; + return { + id: `rank_movement:${current.login}:${current.day}`, + eventType: 'rank_movement', + subjectLogin: current.login, + language: current.language, + country: current.country, + summary: `Moved ${movement > 0 ? 'up' : 'down'} ${Math.abs(movement)} place${Math.abs(movement) === 1 ? '' : 's'} in the global ranking`, + detail: { previousRank: previous.globalRank, currentRank: current.globalRank }, + createdAt: current.capturedAt, + refreshCycle: current.day, + }; +} + +export function canViewImpactHistory(developer, sessionLogin) { + if (!developer) return false; + if (developer.impactHistoryVisibility !== 'private') return true; + return Boolean(sessionLogin && developer.login.toLowerCase() === sessionLogin.toLowerCase()); +} + +export { PERIODS }; \ No newline at end of file diff --git a/package.json b/package.json index 0d1d3e3..f60592e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "populate-special-tags": "node scripts/populate-special-tags.js", "setup-activity-container": "node scripts/setup-activity-container.js", "setup-watchlist-container": "node scripts/setup-watchlist-container.js", + "setup-impact-history-container": "node scripts/setup-impact-history-container.js", "setup-introductions-container": "node scripts/setup-introductions-container.js", "setup-contacts-container": "node scripts/setup-contacts-container.js", "create-agent-key": "node scripts/create-agent-key.js", diff --git a/scripts/setup-impact-history-container.js b/scripts/setup-impact-history-container.js new file mode 100644 index 0000000..cb28989 --- /dev/null +++ b/scripts/setup-impact-history-container.js @@ -0,0 +1,30 @@ +import 'dotenv/config'; +import { CosmosClient } from '@azure/cosmos'; + +const endpoint = process.env.COSMOS_ENDPOINT?.trim(); +const key = process.env.COSMOS_KEY?.trim(); +const databaseId = process.env.COSMOS_DATABASE || 'devglobe'; +const containerId = process.env.COSMOS_IMPACT_HISTORY_CONTAINER || 'impact-history'; + +if (!endpoint || !key) { + console.error('COSMOS_ENDPOINT and COSMOS_KEY are required.'); + process.exit(1); +} + +const client = new CosmosClient({ endpoint, key }); +const database = client.database(databaseId); +const { resource, statusCode } = await database.containers.createIfNotExists({ + id: containerId, + partitionKey: { paths: ['/login'], kind: 'Hash' }, + indexingPolicy: { + indexingMode: 'consistent', + automatic: true, + includedPaths: [ + { path: '/capturedAt/?' }, + { path: '/day/?' }, + ], + excludedPaths: [{ path: '/*' }], + }, +}); + +console.log(`${statusCode === 201 ? 'Created' : 'Verified'} history container ${databaseId}/${resource.id}.`); \ No newline at end of file diff --git a/styles/main.css b/styles/main.css index c6a2ce7..c1ef008 100644 --- a/styles/main.css +++ b/styles/main.css @@ -1213,6 +1213,71 @@ body { .activity-timeline__heading > span { color: var(--text-muted); font-size: 11px; } .activity-timeline__empty { padding: 36px 0; color: var(--text-muted); } +.impact-history { + margin-bottom: 48px; + padding: 24px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-card); +} + +.impact-history__heading, +.impact-history__visibility, +.impact-history__periods, +.impact-chart__legend { + display: flex; + align-items: center; +} + +.impact-history__heading { justify-content: space-between; gap: 20px; } +.impact-history__heading span { color: var(--text-muted); font-size: 10px; font-weight: 700; } +.impact-history__heading h2 { margin-top: 3px; font-size: 24px; } +.impact-history__visibility { gap: 10px; } +.impact-history__visibility input { width: 18px; height: 18px; accent-color: #2ea44f; } + +.impact-history__periods { gap: 4px; margin: 22px 0 16px; } +.impact-history__periods button { + padding: 7px 12px; + border: 1px solid var(--border); + background: transparent; + color: var(--text-secondary); + font: 600 12px var(--font); + cursor: pointer; +} +.impact-history__periods button.active { border-color: #0891b2; background: rgba(8, 145, 178, 0.12); color: #0891b2; } + +.impact-metrics, +.impact-ranks { display: grid; gap: 10px; } +.impact-metrics { grid-template-columns: repeat(4, 1fr); } +.impact-ranks { grid-template-columns: repeat(3, 1fr); margin: 14px 0; } +.impact-metrics > div, +.impact-ranks > div { display: flex; flex-direction: column; gap: 4px; padding: 13px; border: 1px solid var(--border); } +.impact-metrics span, +.impact-ranks span { color: var(--text-muted); font-size: 10px; } +.impact-metrics strong, +.impact-ranks strong { font-size: 20px; } +.impact-delta { font-size: 11px !important; } +.impact-delta--up { color: #2ea44f !important; } +.impact-delta--down { color: #ef4444 !important; } +.impact-delta--empty { color: var(--text-muted) !important; } + +.impact-chart { margin-top: 18px; } +.impact-chart svg { width: 100%; max-height: 220px; overflow: visible; } +.impact-chart line { stroke: var(--border); stroke-width: 1; } +.impact-chart polyline { fill: none; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; } +.impact-chart__legend { justify-content: center; gap: 18px; } +.impact-chart__legend span { display: inline-flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 11px; } +.impact-chart__legend i { width: 9px; height: 9px; border-radius: 50%; } + +.impact-history__notice, +.impact-history__empty, +.impact-history__explanation { color: var(--text-secondary); font-size: 13px; line-height: 1.5; } +.impact-history__notice { margin: 12px 0; padding: 10px 12px; border-left: 2px solid #d97706; background: rgba(217, 119, 6, 0.08); } +.impact-history__empty { padding: 24px 0; text-align: center; } +.impact-history__explanation { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); } +.impact-history__explanation p { margin-top: 4px; } +.impact-history__error { margin-top: 10px; color: #ef4444; font-size: 12px; } + .timeline-event { display: grid; grid-template-columns: 12px 1fr auto; @@ -3159,6 +3224,10 @@ body { .activity-profile__avatar { width: 72px; height: 72px; } .activity-profile h1 { font-size: 30px; } .activity-profile__stats { grid-column: 1 / -1; justify-content: space-between; padding-top: 8px; } + .impact-history { padding: 18px 14px; } + .impact-history__heading { align-items: flex-start; } + .impact-metrics { grid-template-columns: repeat(2, 1fr); } + .impact-ranks { grid-template-columns: 1fr; } .activity-timeline { margin-top: 36px; } .timeline-event { grid-template-columns: 10px 1fr; gap: 12px; } .timeline-event time { grid-column: 2; } diff --git a/tests/impact-history.test.js b/tests/impact-history.test.js new file mode 100644 index 0000000..ef90bca --- /dev/null +++ b/tests/impact-history.test.js @@ -0,0 +1,80 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + addLanguageRanks, + buildImpactHistory, + canViewImpactHistory, + createImpactSnapshot, + createRankMovementEvent, +} from '../lib/impact-history.js'; +import { captureImpactHistory } from '../lib/impact-history-capture.js'; + +test('creates compact dated snapshots with global, country, and language ranks', () => { + const ranked = addLanguageRanks([ + { login: 'a', topLanguage: 'JavaScript' }, + { login: 'b', topLanguage: 'JavaScript' }, + { login: 'c', topLanguage: 'Go' }, + ]); + const snapshot = createImpactSnapshot({ + ...ranked[1], score: 80, globalRank: 2, globalTotal: 3, country: 'US', countryRank: 1, + totalStars: 10, followers: 4, totalCommits: 20, privateField: 'omitted', + }, '2026-08-16T12:00:00.000Z'); + + assert.equal(snapshot.id, 'b:2026-08-16'); + assert.equal(snapshot.languageRank, 2); + assert.equal(snapshot.languageTotal, 2); + assert.equal('privateField' in snapshot, false); +}); + +test('calculates 7/30/90-day changes from sparse history honestly', () => { + const history = [ + { capturedAt: '2026-07-15T00:00:00.000Z', score: 70, totalStars: 5, followers: 2, totalCommits: 10, globalRank: 8, countryRank: 3, languageRank: 4, country: 'US', language: 'Go' }, + { capturedAt: '2026-08-01T00:00:00.000Z', score: 75, totalStars: 8, followers: 3, totalCommits: 15, globalRank: 6, countryRank: 2, languageRank: 3, country: 'US', language: 'Go' }, + { capturedAt: '2026-08-16T00:00:00.000Z', score: 80, totalStars: 12, followers: 5, totalCommits: 24, globalRank: 4, countryRank: 1, languageRank: 2, country: 'US', language: 'Go' }, + ]; + const result = buildImpactHistory(history, new Date('2026-08-16T12:00:00.000Z')); + + assert.equal(result.periods[7].available, true); + assert.equal(result.periods[7].metrics.score, 5); + assert.equal(result.periods[7].ranks.globalRank, 2); + assert.equal(result.periods[30].metrics.totalStars, 7); + assert.equal(result.periods[90].available, false); +}); + +test('creates one rank movement feed event and skips unchanged ranks', () => { + const current = { login: 'octocat', day: '2026-08-16', capturedAt: '2026-08-16T00:00:00.000Z', globalRank: 3, country: 'US', language: 'Go' }; + assert.match(createRankMovementEvent(current, { globalRank: 5 }).summary, /Moved up 2 places/); + assert.equal(createRankMovementEvent(current, { globalRank: 3 }), null); +}); + +test('private impact history is visible only to the claimed profile owner', () => { + const developer = { login: 'OctoCat', claimed: true, impactHistoryVisibility: 'private' }; + assert.equal(canViewImpactHistory(developer, null), false); + assert.equal(canViewImpactHistory(developer, 'another-user'), false); + assert.equal(canViewImpactHistory(developer, 'octocat'), true); + assert.equal(canViewImpactHistory({ ...developer, impactHistoryVisibility: 'public' }, null), true); +}); + +test('capture stores ranked snapshots and publishes privacy-aware movement events', async () => { + const snapshots = []; + const events = []; + const summary = await captureImpactHistory({ + now: new Date('2026-08-16T14:00:00.000Z'), + developers: [ + { login: 'a', topLanguage: 'Go', totalStars: 20, totalCommits: 20, followers: 5 }, + { login: 'b', topLanguage: 'Go', totalStars: 10, totalCommits: 10, followers: 2, impactHistoryVisibility: 'private' }, + ], + getPrevious: async login => ({ login, globalRank: login === 'a' ? 2 : 1 }), + saveSnapshot: async snapshot => snapshots.push(snapshot), + saveEvents: async (items, options) => { + events.push({ event: items[0], options }); + return { inserted: 1 }; + }, + }); + + assert.deepEqual(summary, { developers: 2, snapshots: 2, movements: 2, day: '2026-08-16' }); + assert.equal(snapshots[0].globalRank, 1); + assert.equal(snapshots[0].languageRank, 1); + assert.equal(events[0].options.isPublicDeveloper, true); + assert.equal(events[1].options.isPublicDeveloper, false); +}); \ No newline at end of file diff --git a/vercel.json b/vercel.json index 7daf838..a51baf6 100644 --- a/vercel.json +++ b/vercel.json @@ -5,6 +5,10 @@ { "path": "/api/cron/weekly-digest", "schedule": "0 13 * * 1" + }, + { + "path": "/api/cron/impact-history", + "schedule": "0 14 * * *" } ] }