diff --git a/AGENTS.md b/AGENTS.md index 2ea0b86..d805c9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,7 @@ Server imports must use `.js` extensions (NodeNext). - **Block types are defined twice.** Server `widgets/registry.ts` (zod schemas, presets) and client `entities/widget/model/{types,registry}.ts` (labels, ru/en descriptions). Adding a block type touches both plus `statsService` and both canvases — use the `add-block-type` skill. - Limits: `MAX_WIDGET_BLOCKS = 5` in the registry; `MAX_GRID_COLUMNS = 2` and block height ≤ 2 in shared `geometry.ts` (re-exported by the registry). Layout validation (bounds + no overlap) runs in `widgetService.validateLayouts`. - Widget width is currently fixed at 600 and height is recomputed from rows by `widgetDimensions` on every update — client-supplied `width`/`height` are ignored. +- GitHub data: profile, repositories and languages use REST (a token only raises limits; languages switch to exact GraphQL byte counts with a token). Activity, pull request and status blocks use GraphQL only and show an error without `GITHUB_TOKEN`. - `statsService` and the avatar cache are per-process in-memory caches; on Vercel each cold instance starts empty. Successful lookups are cached 15 min, failed ones 60 s; the public SVG `Cache-Control` follows the same TTL. Rate limits (`express-rate-limit`, memory store) are also per instance; `trust proxy` is enabled on Vercel so limits are per client IP. - `api/*.ts` import from `server/dist/src/app.js`. `server/tsconfig.build.json` pins `rootDir: "."` to keep that path stable, and CI runs `typecheck:api` to catch breakage. - Auth: short-lived access token in memory (Authorization: Bearer), rotating refresh token in an httpOnly cookie scoped to `/api/auth`, sessions stored hashed in `AuthSession`. Yandex OAuth returns the access token in the URL hash of `/auth/callback`. Accounts are keyed by provider id (`yandexId`), never linked by email; linking happens from `/account` (`/api/auth/yandex?intent=link`, the intent is stored with the OAuth state and the callback identifies the user by the refresh cookie). diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d050d..f46d589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project are documented here. ### Added +- Added three GitHub blocks: Activity (commits this year, current and best streak, and a contribution heatmap that shows as many weeks as fit the block), Pull Requests (total, merged, open with a status breakdown) and Status (emoji, message, busy flag), plus a "GitHub Activity" preset. They use the GitHub GraphQL API and need `GITHUB_TOKEN` on the server. - Added password reset by email ("Forgot password?" on sign-in) and email confirmation after sign-up, with a resend option on the account page. Emails are sent through Resend once `RESEND_API_KEY` and `EMAIL_FROM` are configured; until then the features stay hidden in production and emails are printed to the server log in development. - Added an account page with sign-in methods: connect Yandex ID to an existing account, or disconnect it when a password remains. - Added 404 and 500 pages for unknown addresses, missing or unpublished widgets, failed widget loads, and unexpected rendering errors. @@ -16,6 +17,7 @@ All notable changes to this project are documented here. ### Changed +- SVG text truncation and stat number formatting now use a per-character width estimate calibrated to the widget font (Cyrillic, bold and `%` are wider), so the SVG export cuts text in the same places as the HTML widget. - Split the 1,200-line widget editor into layout rules, state/autosave, drag and preview hooks, and focused UI components, and shared the embed snippet builder between the gallery and the editor. - Added client tests for widget rendering, scaling, editor layout rules and autosave, the API client token refresh, and the auth store. - Removed the automatic palette mode; widgets are either light or dark, and existing auto widgets open as light. diff --git a/README.md b/README.md index deb00b3..a6ee82f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Personal live widgets for GitHub, LeetCode, and other sources. Build a widget, c ## Features - Personal widget gallery. -- GitHub, language, and LeetCode statistics blocks. +- GitHub stats, languages, activity heatmap, pull requests and status, and LeetCode blocks. - Grid editor with resizing and live preview. - Public pages and iframe embeds. - Email and Yandex ID authentication. diff --git a/README.ru.md b/README.ru.md index 0daace2..f82380a 100644 --- a/README.ru.md +++ b/README.ru.md @@ -7,7 +7,7 @@ ## Возможности - Галерея персональных виджетов. -- Блоки статистики GitHub, языков и LeetCode. +- Блоки GitHub: статистика, языки, карта активности, pull requests и статус; блок LeetCode. - Редактор с сеткой, изменением размеров и live-preview. - Публичные страницы и iframe-встраивание. - Авторизация по email и через Yandex ID. diff --git a/client/src/entities/widget/lib/canvasStyle.ts b/client/src/entities/widget/lib/canvasStyle.ts index 194b6c8..1920828 100644 --- a/client/src/entities/widget/lib/canvasStyle.ts +++ b/client/src/entities/widget/lib/canvasStyle.ts @@ -9,6 +9,8 @@ import { BLOCK_RADIUS, CANVAS_RADIUS, GRID_GAP, + HEATMAP_CELL, + HEATMAP_GAP, MAX_GRID_COLUMNS, TEXT_BLOCK_LINE_HEIGHT, WIDGET_WIDTH, @@ -56,6 +58,8 @@ export const canvasStyleVars = ( '--block-padding': `${BLOCK_PADDING}px`, '--block-border': `${BLOCK_BORDER}px`, '--block-radius': `${BLOCK_RADIUS}px`, + '--heatmap-cell': `${HEATMAP_CELL}px`, + '--heatmap-gap': `${HEATMAP_GAP}px`, '--block-line-height': BLOCK_LINE_HEIGHT, '--text-line-height': TEXT_BLOCK_LINE_HEIGHT, '--color-easy': difficultyColors.easy, diff --git a/client/src/entities/widget/model/registry.ts b/client/src/entities/widget/model/registry.ts index 27402e7..b672f22 100644 --- a/client/src/entities/widget/model/registry.ts +++ b/client/src/entities/widget/model/registry.ts @@ -38,6 +38,15 @@ export const presets: PresetDefinition[] = [ source: 'github', blockTypes: ['github-langs'], }, + { + id: 'github-activity', + label: 'GitHub Activity', + labelRu: 'Активность GitHub', + description: 'Commit streaks, a contribution heatmap and pull requests.', + descriptionRu: 'Серии коммитов, карта активности и pull requests.', + source: 'github', + blockTypes: ['github-commits', 'github-prs'], + }, { id: 'leetcode-profile', label: 'LeetCode Profile', @@ -67,6 +76,24 @@ export const blockDefinitions: { description: 'Language distribution by repository size.', source: 'github', }, + { + type: 'github-commits', + label: 'GitHub Activity', + description: 'Commits this year, streaks and a contribution heatmap.', + source: 'github', + }, + { + type: 'github-prs', + label: 'Pull Requests', + description: 'Opened, merged and open pull requests.', + source: 'github', + }, + { + type: 'github-status', + label: 'GitHub Status', + description: 'Your current GitHub status emoji and message.', + source: 'github', + }, { type: 'leetcode-stats', label: 'LeetCode Profile', @@ -95,6 +122,9 @@ export const defaultBlockConfig = (type: BlockType): Record => if (type === 'github-stats') return { showRepositories: true, showFollowers: true, showFollowing: true }; if (type === 'github-langs') return { limit: 5 }; + if (type === 'github-commits') return { showStreak: true, showHeatmap: true }; + if (type === 'github-prs') return { showBreakdown: true }; + if (type === 'github-status') return {}; return { showRanking: true, showContestRating: true }; }; diff --git a/client/src/entities/widget/model/types.ts b/client/src/entities/widget/model/types.ts index 00da7a9..fdbb874 100644 --- a/client/src/entities/widget/model/types.ts +++ b/client/src/entities/widget/model/types.ts @@ -1,7 +1,14 @@ export type SourceType = 'github' | 'leetcode'; export type PaletteId = 'lavender' | 'midnight' | 'mint' | 'sunset' | 'cobalt' | 'paper'; export type PaletteMode = 'light' | 'dark'; -export type BlockType = 'text' | 'github-stats' | 'github-langs' | 'leetcode-stats'; +export type BlockType = + | 'text' + | 'github-stats' + | 'github-langs' + | 'github-commits' + | 'github-prs' + | 'github-status' + | 'leetcode-stats'; export type BlockLayout = { x: number; diff --git a/client/src/entities/widget/ui/WidgetCanvas.module.css b/client/src/entities/widget/ui/WidgetCanvas.module.css index 617fa40..40f75d2 100644 --- a/client/src/entities/widget/ui/WidgetCanvas.module.css +++ b/client/src/entities/widget/ui/WidgetCanvas.module.css @@ -303,6 +303,79 @@ background: var(--color-hard); } +.heatmap { + display: flex; + gap: var(--heatmap-gap); +} + +.heatmap > span { + display: grid; + grid-template-rows: repeat(7, var(--heatmap-cell)); + gap: var(--heatmap-gap); +} + +.heatmap i { + display: block; + width: var(--heatmap-cell); + height: var(--heatmap-cell); + border-radius: 2.5px; + background: var(--widget-accent); +} + +.heatmap i[data-level='0'] { + background: var(--widget-ink); +} + +.pr-merged { + background: var(--widget-accent) !important; +} + +.pr-open { + background: color-mix(in srgb, var(--widget-accent) 45%, transparent) !important; +} + +.pr-closed { + background: color-mix(in srgb, var(--widget-ink) 20%, transparent) !important; +} + +/* Mirrors the SVG: emoji line, message (up to 3 lines), busy chip. */ +.status { + display: grid; + justify-items: start; + gap: var(--block-heading-gap); +} + +.statusEmoji { + font-size: var(--block-text); + line-height: var(--block-line-height); +} + +.statusMessage { + display: -webkit-box; + margin: 0; + overflow: hidden; + font-size: var(--block-heading); + font-weight: 700; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow-wrap: anywhere; +} + +.statusBusy { + padding: 4px 10px; + color: var(--widget-accent); + border-radius: 999px; + background: color-mix(in srgb, var(--widget-accent) 14%, transparent); + font-size: var(--block-meta); + font-weight: 700; +} + +.statusEmpty { + color: color-mix(in srgb, var(--widget-ink) 60%, transparent); + font-size: var(--block-meta); +} + .error { margin: 0; color: var(--color-error); diff --git a/client/src/entities/widget/ui/WidgetCanvas.test.tsx b/client/src/entities/widget/ui/WidgetCanvas.test.tsx index 341c946..5e1710b 100644 --- a/client/src/entities/widget/ui/WidgetCanvas.test.tsx +++ b/client/src/entities/widget/ui/WidgetCanvas.test.tsx @@ -84,3 +84,116 @@ it('shows the empty state without blocks', () => { render(); expect(screen.getByText('Добавьте блок, чтобы начать.')).toBeInTheDocument(); }); + +describe('GitHub activity, pull request and status blocks', () => { + const block = (id: string, type: WidgetBlock['type'], config = {}, width = 1): WidgetBlock => ({ + id, + type, + position: 0, + config: { username: 'octo', layout: { x: 0, y: 0, width, height: 1 }, ...config }, + }); + + it('draws as many heatmap weeks as fit the block width', () => { + const levels = Array(371).fill(1); + const rendered = (id: string) => ({ + id, + type: 'github-commits' as const, + position: 0, + data: { username: 'octo', commitsYear: 5, currentStreak: 1, longestStreak: 2, levels }, + }); + const { container, rerender } = render( + , + ); + expect(container.querySelectorAll('[data-level]')).toHaveLength(17 * 7); + + rerender( + , + ); + // Full-width block: (510 + 3) / 13 → 39 weeks. + expect(container.querySelectorAll('[data-level]')).toHaveLength(39 * 7); + }); + + it('hides streaks and the heatmap when turned off', () => { + const { container } = render( + , + ); + expect(screen.getByText('Коммиты')).toBeInTheDocument(); + expect(screen.queryByText('Дней подряд')).not.toBeInTheDocument(); + expect(container.querySelector('[data-level]')).toBeNull(); + }); + + it('shows the pull request breakdown in percent', () => { + render( + , + ); + expect(screen.getByText('70%')).toBeInTheDocument(); + expect(screen.getByText('Closed')).toBeInTheDocument(); + }); + + it('shows the status or an empty state', () => { + const { rerender } = render( + , + ); + expect(screen.getByText('🌴')).toBeInTheDocument(); + expect(screen.getByText('On vacation')).toBeInTheDocument(); + expect(screen.getByText('Busy')).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText('No status set')).toBeInTheDocument(); + }); +}); diff --git a/client/src/entities/widget/ui/WidgetCanvas.tsx b/client/src/entities/widget/ui/WidgetCanvas.tsx index ee52ad1..388f945 100644 --- a/client/src/entities/widget/ui/WidgetCanvas.tsx +++ b/client/src/entities/widget/ui/WidgetCanvas.tsx @@ -18,9 +18,11 @@ import { MAX_GRID_COLUMNS, WIDGET_WIDTH, gridRows, + heatmapColumns, + heatmapWeeks, widgetDimensions, } from '@shared/widget/geometry'; -import { formatStatValue, languageColor, widgetLabels } from '@shared/widget/theme'; +import { formatStatValue, heatmapOpacity, languageColor, widgetLabels } from '@shared/widget/theme'; import styles from '@/entities/widget/ui/WidgetCanvas.module.css'; type WidgetLocale = 'ru' | 'en'; @@ -56,6 +58,21 @@ const sampleData: Record> = { { name: 'Other', percentage: 8 }, ], }, + 'github-commits': { + username: 'octocat', + commitsYear: 1_284, + currentStreak: 12, + longestStreak: 47, + levels: Array.from({ length: 364 }, (_, day) => [0, 1, 2, 1, 3, 0, 4, 2, 1][(day * 7) % 9]), + firstDayOfWeek: 0, + }, + 'github-prs': { username: 'octocat', total: 214, merged: 176, open: 6, closed: 32 }, + 'github-status': { + username: 'octocat', + emoji: '🚀', + message: 'Shipping new widgets', + busy: false, + }, 'leetcode-stats': { username: 'your-profile', ranking: 18_240, @@ -107,6 +124,18 @@ const formatNumber = (value: unknown, fallback = '—') => typeof value === 'number' && Number.isFinite(value) ? numberFormatter.format(value) : fallback; type BlockData = { + commitsYear?: number; + currentStreak?: number; + longestStreak?: number; + levels?: number[]; + firstDayOfWeek?: number; + total?: number; + merged?: number; + open?: number; + closed?: number; + emoji?: string | null; + message?: string | null; + busy?: boolean; username?: string; name?: string; avatarUrl?: string; @@ -236,6 +265,119 @@ export const WidgetBlockContent = ({ ); } + if (block.type === 'github-commits') { + const stats = [ + { label: labels.commits, value: data.commitsYear }, + ...(block.config.showStreak !== false + ? [ + { label: labels.currentStreak, value: data.currentStreak }, + { label: labels.longestStreak, value: data.longestStreak }, + ] + : []), + ]; + const columns = heatmapColumns( + data.levels ?? [], + heatmapWeeks(blockMetrics(getBlockLayout(block)).contentWidth), + data.firstDayOfWeek ?? 0, + ); + return ( +
+
+ {labels.activity} + @{data.username || username || 'username'} +
+ + {block.config.showHeatmap !== false && ( + + )} +
+ ); + } + + if (block.type === 'github-prs') { + const total = data.total ?? 0; + const parts = [ + { key: 'merged', label: labels.merged, value: data.merged ?? 0 }, + { key: 'open', label: labels.open, value: data.open ?? 0 }, + { key: 'closed', label: labels.closed, value: data.closed ?? 0 }, + ]; + return ( +
+
+ {labels.pullRequests} + @{data.username || username || 'username'} +
+ + {block.config.showBreakdown !== false && ( + <> +
+ {total > 0 && + parts.map((part) => ( + + ))} +
+
+ {parts.map((part) => ( + + + {part.label} + {total > 0 ? Math.round((part.value / total) * 100) : 0}% + + ))} +
+ + )} +
+ ); + } + + if (block.type === 'github-status') { + return ( +
+
+ {labels.status} + @{data.username || username || 'username'} +
+ {!data.emoji && !data.message ? ( + {labels.noStatus} + ) : ( +
+ {data.emoji && {data.emoji}} + {data.message &&

{data.message}

} + {data.busy && {labels.busy}} +
+ )} +
+ ); + } + const solved = data.solved ?? {}; const stats = [ { label: labels.solved, value: solved.all }, diff --git a/client/src/pages/widget-editor/ui/BlockConfigPanel.tsx b/client/src/pages/widget-editor/ui/BlockConfigPanel.tsx index c7fcaff..1d10398 100644 --- a/client/src/pages/widget-editor/ui/BlockConfigPanel.tsx +++ b/client/src/pages/widget-editor/ui/BlockConfigPanel.tsx @@ -26,12 +26,19 @@ export const BlockConfigPanel = ({ ['showFollowers', locale === 'ru' ? 'Подписчики' : 'Followers'], ['showFollowing', locale === 'ru' ? 'Подписки' : 'Following'], ] - : block.type === 'leetcode-stats' + : block.type === 'github-commits' ? [ - ['showRanking', locale === 'ru' ? 'Рейтинг' : 'Ranking'], - ['showContestRating', locale === 'ru' ? 'Рейтинг соревнований' : 'Contest rating'], + ['showStreak', locale === 'ru' ? 'Серии дней' : 'Streaks'], + ['showHeatmap', locale === 'ru' ? 'Карта активности' : 'Contribution heatmap'], ] - : []; + : block.type === 'github-prs' + ? [['showBreakdown', locale === 'ru' ? 'Разбивка по статусам' : 'Status breakdown']] + : block.type === 'leetcode-stats' + ? [ + ['showRanking', locale === 'ru' ? 'Рейтинг' : 'Ranking'], + ['showContestRating', locale === 'ru' ? 'Рейтинг соревнований' : 'Contest rating'], + ] + : []; return (
diff --git a/client/src/pages/widget-editor/ui/BlockLibrary.tsx b/client/src/pages/widget-editor/ui/BlockLibrary.tsx index 4e0123b..7e7407c 100644 --- a/client/src/pages/widget-editor/ui/BlockLibrary.tsx +++ b/client/src/pages/widget-editor/ui/BlockLibrary.tsx @@ -6,8 +6,15 @@ import { MAX_BLOCKS } from '@/pages/widget-editor/model/layout'; import { messages, type Locale } from '@/shared/locale/content'; import styles from '@/pages/widget-editor/ui/WidgetEditorPage.module.css'; -const glyphFor = (type: BlockType) => - type === 'text' ? 'T' : type.startsWith('github') ? 'GH' : 'LC'; +const glyphs: Partial> = { + text: 'T', + 'github-commits': 'AC', + 'github-prs': 'PR', + 'github-status': 'ST', + 'leetcode-stats': 'LC', +}; + +const glyphFor = (type: BlockType) => glyphs[type] ?? 'GH'; export const BlockLibrary = ({ locale, diff --git a/server/src/services/statsService.ts b/server/src/services/statsService.ts index ed6748c..34aa396 100644 --- a/server/src/services/statsService.ts +++ b/server/src/services/statsService.ts @@ -225,6 +225,180 @@ const getGithubLanguages = async (username: string, limit: number) => { }; }; +// --- GitHub GraphQL blocks (activity, pull requests, status); GraphQL always needs a token. + +const githubGraphql = async (query: string, variables: Record) => { + if (!process.env.GITHUB_TOKEN?.trim()) { + throw new AppError(503, 'Add GITHUB_TOKEN on the server to show this block'); + } + const response = await fetchJson<{ data?: T; errors?: { type?: string; message: string }[] }>( + 'https://api.github.com/graphql', + { + method: 'POST', + headers: { ...githubHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }, + githubRateLimitMessage, + ); + if (response.errors?.some((error) => error.type === 'NOT_FOUND')) { + throw new AppError(404, 'GitHub profile not found'); + } + if (response.errors?.length || !response.data) { + throw new AppError(502, 'GitHub API returned an error'); + } + return response.data; +}; + +const CONTRIBUTION_LEVELS: Record = { + NONE: 0, + FIRST_QUARTILE: 1, + SECOND_QUARTILE: 2, + THIRD_QUARTILE: 3, + FOURTH_QUARTILE: 4, +}; + +type GithubActivityResponse = { + user: { + login: string; + contributionsCollection: { + totalCommitContributions: number; + contributionCalendar: { + weeks: { + contributionDays: { + date: string; + contributionCount: number; + contributionLevel: string; + }[]; + }[]; + }; + }; + } | null; +}; + +/** Current streak ends today or yesterday (today may simply not have activity yet). */ +export const contributionStreaks = (counts: number[]) => { + let longest = 0; + let run = 0; + for (const count of counts) { + run = count > 0 ? run + 1 : 0; + longest = Math.max(longest, run); + } + let current = 0; + let index = counts.length - 1; + if (index >= 0 && counts[index] === 0) index -= 1; + while (index >= 0 && counts[index] > 0) { + current += 1; + index -= 1; + } + return { current, longest }; +}; + +const getGithubActivity = async (username: string) => { + const data = await getCached(`github:${username}:activity`, () => + githubGraphql( + `query userActivity($login: String!) { + user(login: $login) { + login + contributionsCollection { + totalCommitContributions + contributionCalendar { weeks { contributionDays { date contributionCount contributionLevel } } } + } + } + }`, + { login: username }, + ), + ); + if (!data.user) throw new AppError(404, 'GitHub profile not found'); + const days = data.user.contributionsCollection.contributionCalendar.weeks.flatMap( + (week) => week.contributionDays, + ); + const streaks = contributionStreaks(days.map((day) => day.contributionCount)); + return { + username: data.user.login, + commitsYear: data.user.contributionsCollection.totalCommitContributions, + currentStreak: streaks.current, + longestStreak: streaks.longest, + // Oldest → newest; the calendar starts on a Sunday, so days group into weeks of 7. + levels: days.map((day) => CONTRIBUTION_LEVELS[day.contributionLevel] ?? 0), + firstDayOfWeek: days.length ? new Date(`${days[0].date}T00:00:00Z`).getUTCDay() : 0, + }; +}; + +type GithubPullRequestsResponse = { + user: { + login: string; + all: { totalCount: number }; + merged: { totalCount: number }; + open: { totalCount: number }; + } | null; +}; + +const getGithubPullRequests = async (username: string) => { + const data = await getCached(`github:${username}:pull-requests`, () => + githubGraphql( + `query userPullRequests($login: String!) { + user(login: $login) { + login + all: pullRequests { totalCount } + merged: pullRequests(states: MERGED) { totalCount } + open: pullRequests(states: OPEN) { totalCount } + } + }`, + { login: username }, + ), + ); + if (!data.user) throw new AppError(404, 'GitHub profile not found'); + const { all, merged, open } = data.user; + return { + username: data.user.login, + total: all.totalCount, + merged: merged.totalCount, + open: open.totalCount, + closed: Math.max(all.totalCount - merged.totalCount - open.totalCount, 0), + }; +}; + +type GithubStatusResponse = { + user: { + login: string; + status: { + emojiHTML: string | null; + message: string | null; + indicatesLimitedAvailability: boolean; + } | null; + } | null; +}; + +// emojiHTML is markup like `
🚀
`; custom emoji are images and +// can't be shown as text, so they are dropped. +export const emojiFromHtml = (html: string | null | undefined) => { + if (!html || /]*>/g, '') + .replace(/&/g, '&') + .trim(); + return text || null; +}; + +const getGithubStatus = async (username: string) => { + const data = await getCached(`github:${username}:status`, () => + githubGraphql( + `query userStatus($login: String!) { + user(login: $login) { login status { emojiHTML message indicatesLimitedAvailability } } + }`, + { login: username }, + ), + ); + if (!data.user) throw new AppError(404, 'GitHub profile not found'); + const status = data.user.status; + return { + username: data.user.login, + emoji: emojiFromHtml(status?.emojiHTML), + message: status?.message?.trim() || null, + busy: Boolean(status?.indicatesLimitedAvailability), + }; +}; + type LeetcodeResponse = { data?: { matchedUser?: { @@ -324,6 +498,9 @@ const getBlockData = async ( const limit = typeof blockConfig.limit === 'number' ? blockConfig.limit : 5; return { data: await getGithubLanguages(username, limit) }; } + if (type === 'github-commits') return { data: await getGithubActivity(username) }; + if (type === 'github-prs') return { data: await getGithubPullRequests(username) }; + if (type === 'github-status') return { data: await getGithubStatus(username) }; return { data: await getLeetcodeStats(username) }; }; diff --git a/server/src/services/widgetCanvas.test.ts b/server/src/services/widgetCanvas.test.ts index 7735bdb..7d04dc8 100644 --- a/server/src/services/widgetCanvas.test.ts +++ b/server/src/services/widgetCanvas.test.ts @@ -93,3 +93,58 @@ it('inlines avatars as data URIs instead of external URLs', () => { expect(svg).toContain('href="data:image/png;base64,iVBORw0KGgo="'); expect(svg).not.toContain('avatars.githubusercontent.com'); }); + +it('renders the new GitHub blocks with the same geometry as the HTML canvas', async () => { + const { heatmapWeeks } = await import('@shared/widget/geometry.js'); + const layout = (x: number, y: number, width = 1) => ({ layout: { x, y, width, height: 1 } }); + const svg = renderToStaticMarkup( + createElement(WidgetCanvas, { + blocks: [ + { id: 'commits', type: 'github-commits', position: 0, config: layout(0, 0) }, + { id: 'prs', type: 'github-prs', position: 1, config: layout(1, 0) }, + { id: 'status', type: 'github-status', position: 2, config: layout(0, 1, 2) }, + ], + width: 600, + height: 600, + columns: 2, + renderedBlocks: [ + { + id: 'commits', + type: 'github-commits', + position: 0, + data: { + username: 'octo', + commitsYear: 1200, + currentStreak: 3, + longestStreak: 9, + levels: Array(371).fill(2), + firstDayOfWeek: 0, + }, + }, + { + id: 'prs', + type: 'github-prs', + position: 1, + data: { username: 'octo', total: 10, merged: 7, open: 1, closed: 2 }, + }, + { + id: 'status', + type: 'github-status', + position: 2, + data: { username: 'octo', emoji: '🚀', message: 'Shipping', busy: true }, + }, + ], + }), + ); + + // A 1-column block (225px content) fits 17 weeks × 7 days of heatmap cells. + expect(heatmapWeeks(225)).toBe(17); + expect(svg.match(/width="10" height="10" rx="2.5"/g)).toHaveLength(17 * 7); + // "1,200" at 22.5px/800 with -0.06em spacing (~57px) fits the 63px stat column. + expect(svg).toContain('>1,200<'); + expect(svg).toMatch(/>Merged<\/text>.*?>70%<\/text>/); + expect(svg).toMatch(/>Closed<\/text>.*?>20%<\/text>/); + expect(svg).toContain('🚀'); + expect(svg).toContain('Shipping'); + expect(svg).toContain('Busy'); +}); diff --git a/server/src/shared/widget/WidgetCanvas.tsx b/server/src/shared/widget/WidgetCanvas.tsx index eb8521e..f9a4871 100644 --- a/server/src/shared/widget/WidgetCanvas.tsx +++ b/server/src/shared/widget/WidgetCanvas.tsx @@ -4,13 +4,18 @@ import { BLOCK_PADDING, BLOCK_RADIUS, CANVAS_RADIUS, + HEATMAP_CELL, + HEATMAP_GAP, MAX_BLOCK_HEIGHT, MAX_GRID_COLUMNS, TEXT_BLOCK_LINE_HEIGHT, blockBox, blockContentWidth, blockTypography, + estimateTextWidth, gridRows, + heatmapColumns, + heatmapWeeks, type BlockTypography, type GridLayout, } from './geometry.js'; @@ -18,6 +23,7 @@ import { difficultyColors, errorColor, formatStatValue, + heatmapOpacity, languageColor, paletteTokens, widgetLabels, @@ -110,10 +116,14 @@ const layoutOf = (block: WidgetCanvasBlock): GridLayout => { const baseline = (top: number, size: number, lineHeight = BLOCK_LINE_HEIGHT) => top + size * ((lineHeight - 1.2109) / 2 + 0.96875); -// SVG has no text layout, so widths are estimated from an average glyph width. -const fitText = (value: string, width: number, size: number, glyph = 0.56) => { - const maxLength = Math.max(Math.floor(width / (size * glyph)), 1); - return value.length > maxLength ? `${value.slice(0, Math.max(maxLength - 1, 1))}…` : value; +// SVG has no text layout: cut text with an ellipsis where the HTML canvas would. +const fitText = (value: string, width: number, size: number, weight = 500) => { + if (estimateTextWidth(value, size, { weight }) <= width) return value; + let end = value.length; + while (end > 1 && estimateTextWidth(`${value.slice(0, end)}…`, size, { weight }) > width) { + end -= 1; + } + return `${value.slice(0, end)}…`; }; const linesOf = (value: string, maxLength: number, maxLines: number) => { @@ -260,13 +270,17 @@ const TitleRow = ({ const { typography: t } = frame; const rowHeight = t.heading * BLOCK_LINE_HEIGHT; const metaTop = (rowHeight - t.meta * BLOCK_LINE_HEIGHT) / 2; - const metaWidth = Math.min(meta.length * t.meta * 0.56, frame.width * 0.45); + // Like the HTML flex row: the title keeps its width, the meta shrinks (max 45%) with a gap of 12. + const titleWidth = Math.min(estimateTextWidth(title, t.heading, { weight: 800 }), frame.width); + const metaMaxWidth = Math.max(Math.min(frame.width * 0.45, frame.width - titleWidth - 12), 0); + const metaText = fitText(meta, metaMaxWidth, t.meta); + const metaWidth = metaText ? estimateTextWidth(metaText, t.meta) : 0; return ( <> typeof level === 'number') + : []; + const columns = heatmapColumns( + levels, + heatmapWeeks(frame.width), + asNumber(data.firstDayOfWeek), + ); + return ( + <> + + + {config.showHeatmap !== false && + columns.map((column, week) => + column.map((level, weekday) => + level === null ? null : ( + + ), + ), + )} + + ); + } + + if (block.type === 'github-prs') { + const total = asNumber(data.total); + const parts = [ + { label: labels.merged, value: asNumber(data.merged), fill: tokens.accent, opacity: 1 }, + { label: labels.open, value: asNumber(data.open), fill: tokens.accent, opacity: 0.45 }, + { label: labels.closed, value: asNumber(data.closed), fill: tokens.ink, opacity: 0.2 }, + ]; + const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap; + const barY = statsTop + statsRowHeight(t) + t.sectionGap; + const legendTop = barY + 10 + t.sectionGap; + const legendWidth = (frame.width - t.columnGap) / 2; + const legendRowHeight = t.meta * BLOCK_LINE_HEIGHT; + const barClipId = `prs-${safeId(block.id)}`; + let barOffset = 0; + return ( + <> + + + {config.showBreakdown !== false && ( + <> + + + + + + {total > 0 && + parts.map((part) => { + const width = (part.value / total) * frame.width; + const x = barOffset; + barOffset += width; + return ( + + ); + })} + + {parts.map((part, index) => { + // Same two-column list as the languages block: label left, percent right. + const x = (index % 2) * (legendWidth + t.columnGap); + const top = legendTop + Math.floor(index / 2) * (legendRowHeight + t.rowGap); + const percent = `${total > 0 ? Math.round((part.value / total) * 100) : 0}%`; + return ( + + + + + + ); + })} + + )} + + ); + } + + if (block.type === 'github-status') { + const emoji = asString(data.emoji); + const message = asString(data.message); + const top = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap; + const messageTop = emoji ? top + t.text * BLOCK_LINE_HEIGHT + t.headingGap : top; + const messageLines = message + ? linesOf(message, Math.floor(frame.width / (t.heading * 0.55)), 3) + : []; + const chipTop = messageTop + messageLines.length * t.heading * BLOCK_LINE_HEIGHT + t.headingGap; + const chipHeight = t.meta * BLOCK_LINE_HEIGHT + 8; + return ( + <> + + {!emoji && !message ? ( + + ) : ( + <> + {emoji && ( + + )} + + {data.busy === true && ( + + + + + )} + + )} + + ); + } + const solved = asRecord(data.solved); const stats = [ { label: labels.solved, value: solved.all }, diff --git a/server/src/shared/widget/geometry.test.ts b/server/src/shared/widget/geometry.test.ts index 9ae547b..6fd0626 100644 --- a/server/src/shared/widget/geometry.test.ts +++ b/server/src/shared/widget/geometry.test.ts @@ -52,3 +52,16 @@ it('switches stats to compact notation only when they do not fit the column', as expect(formatStatValue(2204.316)).toBe('2,204'); expect(formatStatValue(null)).toBe('—'); }); + +it('estimates wider text for Cyrillic and bold weights', async () => { + const { estimateTextWidth } = await import('@shared/widget/geometry.js'); + // Calibrated against the browser: "Смёрджено" at 10.125px/500 renders ~61px wide. + expect(estimateTextWidth('Смёрджено', 10.125)).toBeCloseTo(61, 0); + expect(estimateTextWidth('Merged', 10)).toBeLessThan(estimateTextWidth('Смёрджено', 10)); + expect(estimateTextWidth('123', 10, { weight: 800 })).toBeGreaterThan( + estimateTextWidth('123', 10), + ); + expect(estimateTextWidth('123', 10, { letterSpacing: -0.06 })).toBeCloseTo( + estimateTextWidth('123', 10) - 1.8, + ); +}); diff --git a/server/src/shared/widget/geometry.ts b/server/src/shared/widget/geometry.ts index ec15043..0afdea2 100644 --- a/server/src/shared/widget/geometry.ts +++ b/server/src/shared/widget/geometry.ts @@ -82,3 +82,63 @@ export const blockTypography = (contentWidth: number) => { }; export type BlockTypography = ReturnType; + +export const HEATMAP_CELL = 10; +export const HEATMAP_GAP = 3; +export const HEATMAP_HEIGHT = 7 * HEATMAP_CELL + 6 * HEATMAP_GAP; + +/** How many most recent weeks of the contribution calendar fit the block's content width. */ +export const heatmapWeeks = (contentWidth: number) => + clamp(Math.floor((contentWidth + HEATMAP_GAP) / (HEATMAP_CELL + HEATMAP_GAP)), 4, 53); + +/** + * Last `weeks` columns of a Sunday-first contribution calendar as [week][weekday] levels; + * missing days (the future part of the current week) are null. + */ +export const heatmapColumns = (levels: number[], weeks: number, firstDayOfWeek = 0) => { + const padded: (number | null)[] = [...Array(firstDayOfWeek).fill(null), ...levels]; + while (padded.length % 7 !== 0) padded.push(null); + const columns: (number | null)[][] = []; + for (let index = 0; index < padded.length; index += 7) + columns.push(padded.slice(index, index + 7)); + return columns.slice(-weeks); +}; + +// Average advance per character class (em), measured for the widget font stack at weight 500. +// SVG has no text layout, so these estimates decide truncation and number formatting in both +// renderers; they are deliberately on the wide side so SVG text never overruns HTML. +const GLYPH_WIDTHS = { + cyrillicUpper: 0.74, + cyrillicLower: 0.66, + latinUpper: 0.66, + latinLower: 0.52, + digit: 0.62, + space: 0.25, + percent: 1, + narrow: 0.3, + other: 0.62, +}; + +const glyphWidth = (character: string) => { + if (/[А-ЯЁ]/.test(character)) return GLYPH_WIDTHS.cyrillicUpper; + if (/[а-яё]/.test(character)) return GLYPH_WIDTHS.cyrillicLower; + if (/[A-Z]/.test(character)) return GLYPH_WIDTHS.latinUpper; + if (/[a-z]/.test(character)) return GLYPH_WIDTHS.latinLower; + if (/[0-9]/.test(character)) return GLYPH_WIDTHS.digit; + if (character === ' ') return GLYPH_WIDTHS.space; + if (character === '%') return GLYPH_WIDTHS.percent; + if (/[.,:;'!|il]/.test(character)) return GLYPH_WIDTHS.narrow; + return GLYPH_WIDTHS.other; +}; + +/** Estimated rendered width of `text` in px. `letterSpacing` is in em, as in the CSS. */ +export const estimateTextWidth = ( + text: string, + size: number, + { weight = 500, letterSpacing = 0 }: { weight?: number; letterSpacing?: number } = {}, +) => { + const boldness = weight >= 700 ? 1.05 : 1; + let width = 0; + for (const character of text) width += glyphWidth(character) * boldness + letterSpacing; + return width * size; +}; diff --git a/server/src/shared/widget/theme.ts b/server/src/shared/widget/theme.ts index edfaa34..7b2e1b3 100644 --- a/server/src/shared/widget/theme.ts +++ b/server/src/shared/widget/theme.ts @@ -1,3 +1,5 @@ +import { estimateTextWidth } from './geometry.js'; + // Colors and labels shared by the HTML and SVG widget renderers. export type PaletteTokens = { accent: string; soft: string; ink: string; surface: string }; @@ -31,6 +33,9 @@ export const paletteTokens = { export type PaletteName = keyof typeof paletteTokens; +// Contribution levels 0–4 as accent opacity; level 0 uses the ink color instead. +export const heatmapOpacity = [0.1, 0.35, 0.55, 0.78, 1] as const; + export const difficultyColors = { easy: '#22a477', medium: '#c88724', hard: '#d45c71' } as const; export const errorColor = '#a54352'; @@ -85,6 +90,18 @@ const labels = { defaultText: 'Build something worth sharing.', empty: 'Add a block to start shaping your widget.', loading: 'Loading...', + activity: 'Activity', + commits: 'Commits', + currentStreak: 'Day streak', + longestStreak: 'Best streak', + pullRequests: 'Pull requests', + total: 'Total', + merged: 'Merged', + open: 'Open', + closed: 'Closed', + status: 'Status', + busy: 'Busy', + noStatus: 'No status set', }, ru: { repositories: 'Репозитории', @@ -105,6 +122,18 @@ const labels = { defaultText: 'Создайте что-то достойное публикации.', empty: 'Добавьте блок, чтобы начать.', loading: 'Загрузка...', + activity: 'Активность', + commits: 'Коммиты', + currentStreak: 'Дней подряд', + longestStreak: 'Рекорд', + pullRequests: 'Pull requests', + total: 'Всего', + merged: 'Слито', + open: 'Открыто', + closed: 'Закрыто', + status: 'Статус', + busy: 'Занят', + noStatus: 'Статус не задан', }, }; @@ -118,9 +147,6 @@ const compactNumber = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, }); -// Approximate advance of a bold digit at letter-spacing -0.06em, relative to font size. -const STAT_GLYPH_WIDTH = 0.56; - /** * Formats a stat value so it fits its column: full digits when they fit, compact notation * (24.3K) otherwise. Both renderers call this with the same inputs, so they agree. @@ -129,7 +155,8 @@ export const formatStatValue = (value: unknown, columnWidth?: number, fontSize?: if (typeof value !== 'number' || !Number.isFinite(value)) return '—'; const full = fullNumber.format(value); if (!columnWidth || !fontSize) return full; - return full.length * fontSize * STAT_GLYPH_WIDTH <= columnWidth + // Stat values render at weight 800 with letter-spacing -0.06em. + return estimateTextWidth(full, fontSize, { weight: 800, letterSpacing: -0.06 }) <= columnWidth ? full : compactNumber.format(value); }; diff --git a/server/src/statsService.test.ts b/server/src/statsService.test.ts index b70a478..ffa9324 100644 --- a/server/src/statsService.test.ts +++ b/server/src/statsService.test.ts @@ -173,3 +173,150 @@ describe('GitHub languages', () => { }); }); }); + +describe('GitHub GraphQL blocks', () => { + const blockOf = (type: string) => ({ + config: {}, + blocks: [{ id: 'block-1', type, position: 0, config: { username: 'octo' } }], + }); + const graphqlResponse = (data: unknown, errors?: unknown[]) => ({ + ok: true, + json: async () => ({ data, ...(errors ? { errors } : {}) }), + }); + + beforeEach(() => { + process.env.GITHUB_TOKEN = 'token'; + }); + + afterEach(() => { + delete process.env.GITHUB_TOKEN; + }); + + it('maps the contribution calendar into commits, streaks and heatmap levels', async () => { + const counts = [0, 3, 4, 0, 1, 2, 5, 1, 0, 2]; + const levels = ['NONE', 'SECOND_QUARTILE', 'THIRD_QUARTILE', 'NONE', 'FIRST_QUARTILE']; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + graphqlResponse({ + user: { + login: 'octo', + contributionsCollection: { + totalCommitContributions: 321, + contributionCalendar: { + weeks: [ + { + contributionDays: counts.map((contributionCount, index) => ({ + date: `2026-01-${String(index + 4).padStart(2, '0')}`, + contributionCount, + contributionLevel: levels[index % levels.length], + })), + }, + ], + }, + }, + }, + }), + ), + ); + + const rendered = await renderWidgetStats(blockOf('github-commits')); + + expect(rendered.blocks[0].data).toEqual({ + username: 'octo', + commitsYear: 321, + currentStreak: 1, + longestStreak: 4, + levels: [0, 2, 3, 0, 1, 0, 2, 3, 0, 1], + firstDayOfWeek: 0, + }); + }); + + it('derives closed pull requests from the totals', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + graphqlResponse({ + user: { + login: 'octo', + all: { totalCount: 50 }, + merged: { totalCount: 40 }, + open: { totalCount: 3 }, + }, + }), + ), + ); + + const rendered = await renderWidgetStats(blockOf('github-prs')); + + expect(rendered.blocks[0].data).toEqual({ + username: 'octo', + total: 50, + merged: 40, + open: 3, + closed: 7, + }); + }); + + it('reads the status emoji out of GitHub markup', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + graphqlResponse({ + user: { + login: 'octo', + status: { + emojiHTML: '
🚀
', + message: ' Shipping ', + indicatesLimitedAvailability: true, + }, + }, + }), + ), + ); + + const rendered = await renderWidgetStats(blockOf('github-status')); + + expect(rendered.blocks[0].data).toEqual({ + username: 'octo', + emoji: '🚀', + message: 'Shipping', + busy: true, + }); + }); + + it('reports a missing user and a missing token clearly', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + graphqlResponse({ user: null }, [{ type: 'NOT_FOUND', message: 'Could not resolve' }]), + ), + ); + expect((await renderWidgetStats(blockOf('github-status'))).blocks[0].error).toBe( + 'GitHub profile not found', + ); + + delete process.env.GITHUB_TOKEN; + expect((await renderWidgetStats(blockOf('github-prs'))).blocks[0].error).toBe( + 'Add GITHUB_TOKEN on the server to show this block', + ); + }); +}); + +describe('contribution helpers', () => { + it('counts the current streak through yesterday when today is empty', async () => { + const { contributionStreaks } = await import('@server/services/statsService.js'); + expect(contributionStreaks([1, 1, 0, 2, 3, 0])).toEqual({ current: 2, longest: 2 }); + expect(contributionStreaks([1, 1, 1, 0, 0])).toEqual({ current: 0, longest: 3 }); + expect(contributionStreaks([])).toEqual({ current: 0, longest: 0 }); + }); + + it('drops custom image emoji', async () => { + const { emojiFromHtml } = await import('@server/services/statsService.js'); + expect(emojiFromHtml('
♾️
')).toBe('♾️'); + expect(emojiFromHtml('
:custom:
')).toBeNull(); + expect(emojiFromHtml(null)).toBeNull(); + }); +}); diff --git a/server/src/widgets/registry.ts b/server/src/widgets/registry.ts index 390ad22..3c7638d 100644 --- a/server/src/widgets/registry.ts +++ b/server/src/widgets/registry.ts @@ -2,7 +2,15 @@ import { z } from 'zod'; import { MAX_BLOCK_HEIGHT, MAX_GRID_COLUMNS } from '@shared/widget/geometry.js'; -export const BLOCK_TYPES = ['text', 'github-stats', 'github-langs', 'leetcode-stats'] as const; +export const BLOCK_TYPES = [ + 'text', + 'github-stats', + 'github-langs', + 'github-commits', + 'github-prs', + 'github-status', + 'leetcode-stats', +] as const; export type BlockType = (typeof BLOCK_TYPES)[number]; export const SOURCE_TYPES = ['github', 'leetcode'] as const; @@ -63,6 +71,21 @@ const blockSchemas: Record = { limit: z.number().int().min(3).max(8).default(5), ...sharedBlockFields, }), + 'github-commits': z.object({ + username: usernameSchema, + showStreak: z.boolean().default(true), + showHeatmap: z.boolean().default(true), + ...sharedBlockFields, + }), + 'github-prs': z.object({ + username: usernameSchema, + showBreakdown: z.boolean().default(true), + ...sharedBlockFields, + }), + 'github-status': z.object({ + username: usernameSchema, + ...sharedBlockFields, + }), 'leetcode-stats': z.object({ username: usernameSchema, showRanking: z.boolean().default(true), @@ -118,6 +141,14 @@ export const presetDefinitions = { source: 'github', blocks: [{ type: 'github-langs', config: { limit: 5 } }], }, + 'github-activity': { + label: 'GitHub Activity', + source: 'github', + blocks: [ + { type: 'github-commits', config: {} }, + { type: 'github-prs', config: {} }, + ], + }, 'leetcode-profile': { label: 'LeetCode Profile', source: 'leetcode',