Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
18 changes: 18 additions & 0 deletions app/api/cron/impact-history/route.js
Original file line number Diff line number Diff line change
@@ -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 });
}
}
78 changes: 78 additions & 0 deletions app/api/impact-history/route.js
Original file line number Diff line number Diff line change
@@ -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 });
}
}
3 changes: 3 additions & 0 deletions components/DeveloperActivityPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -74,6 +75,8 @@ export default function DeveloperActivityPage({ login }) {
</dl>
</section>

<ImpactHistoryPanel login={developer.login} />

<section className="activity-timeline" aria-labelledby="activity-timeline-title">
<div className="activity-timeline__heading">
<div>
Expand Down
147 changes: 147 additions & 0 deletions components/ImpactHistoryPanel.jsx
Original file line number Diff line number Diff line change
@@ -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 <span className="impact-delta impact-delta--empty">No comparison</span>;
const positive = value > 0;
const display = `${positive ? '+' : ''}${formatNum(value)}`;
return <span className={`impact-delta${positive ? ' impact-delta--up' : value < 0 ? ' impact-delta--down' : ''}`}>{display}{rank && value !== 0 ? ' places' : ''}</span>;
}

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 <p className="impact-history__empty">Rank trends appear after at least two daily snapshots.</p>;
}

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 (
<div className="impact-chart">
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Global, country, and language rank history; higher on the chart means a better numerical rank">
{[0, 1, 2, 3, 4].map(index => <line key={index} x1={padding} x2={width - padding} y1={padding + index * 43} y2={padding + index * 43} />)}
{available.map(series => <polyline key={series.key} points={pointsFor(series.key)} stroke={series.color} />)}
</svg>
<div className="impact-chart__legend">
{available.map(series => <span key={series.key}><i style={{ background: series.color }} />{series.label}</span>)}
</div>
</div>
);
}

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 <section className="impact-history"><p className="impact-history__empty">Loading impact history...</p></section>;
if (status === 'error') return <section className="impact-history"><p className="impact-history__empty">{error}</p></section>;
if (!result.current) return (
<section className="impact-history" aria-labelledby="impact-history-title">
<div className="impact-history__heading"><div><span>DAILY SNAPSHOTS</span><h2 id="impact-history-title">Impact history</h2></div></div>
<p className="impact-history__empty">History starts with the next daily snapshot. No earlier activity is inferred.</p>
</section>
);

const comparison = result.periods[period];
const current = result.current;
return (
<section className="impact-history" aria-labelledby="impact-history-title">
<div className="impact-history__heading">
<div><span>DAILY SNAPSHOTS</span><h2 id="impact-history-title">Impact history</h2></div>
{result.owner && (
<label className="impact-history__visibility">
<span>Public history</span>
<input type="checkbox" checked={result.visibility === 'public'} onChange={updateVisibility} />
</label>
)}
</div>

<div className="impact-history__periods" aria-label="Comparison period">
{PERIODS.map(days => <button type="button" key={days} className={period === days ? 'active' : ''} onClick={() => setPeriod(days)}>{days} days</button>)}
</div>

<div className="impact-metrics">
<div><span>Score</span><strong>{current.score}</strong><Delta value={comparison.metrics?.score} /></div>
<div><span>Stars</span><strong>{formatNum(current.totalStars)}</strong><Delta value={comparison.metrics?.totalStars} /></div>
<div><span>Followers</span><strong>{formatNum(current.followers)}</strong><Delta value={comparison.metrics?.followers} /></div>
<div><span>Commits</span><strong>{formatNum(current.totalCommits)}</strong><Delta value={comparison.metrics?.totalCommits} /></div>
</div>

{!comparison.available && <p className="impact-history__notice">A {period}-day comparison is not available yet. DevGlobe will show it after enough snapshots are collected.</p>}

<div className="impact-ranks">
<div><span>Global rank</span><strong>{current.globalRank ? `#${formatNum(current.globalRank)}` : '—'}</strong><Delta value={comparison.ranks?.globalRank} rank /></div>
<div><span>{current.country ? `${current.country} rank` : 'Country rank'}</span><strong>{current.countryRank ? `#${formatNum(current.countryRank)}` : '—'}</strong><Delta value={comparison.ranks?.countryRank} rank /></div>
<div><span>{current.language ? `${current.language} rank` : 'Language rank'}</span><strong>{current.languageRank ? `#${formatNum(current.languageRank)}` : '—'}</strong><Delta value={comparison.ranks?.languageRank} rank /></div>
</div>

<RankChart history={result.history} />

{result.explanations.length > 0 && (
<div className="impact-history__explanation"><strong>What changed the score</strong><p>{result.explanations.join(', ')}.</p></div>
)}
{error && <p className="impact-history__error" role="status">{error}</p>}
</section>
);
}
59 changes: 59 additions & 0 deletions docs/prd/developer-impact-history.md
Original file line number Diff line number Diff line change
@@ -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 `<login>:<YYYY-MM-DD>`, 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.
41 changes: 41 additions & 0 deletions lib/impact-history-capture.js
Original file line number Diff line number Diff line change
@@ -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) };
}
Loading