diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index bd2cb6a2..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,29 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - parserOptions: { - project: 'tsconfig.json', - sourceType: 'module', - }, - plugins: ['@typescript-eslint/eslint-plugin'], - extends: [ - 'plugin:@typescript-eslint/recommended', - 'plugin:prettier/recommended', - ], - root: true, - env: { - node: true, - jest: true, - }, - ignorePatterns: [ - '.eslintrc.js', - 'src/createChart.ts', - '__test__', - 'script.js', - ], - rules: { - '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - }, -}; diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..bbde7821 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [2026-10-27] + +### Added + +- `restrictedContributionsCount` in the GraphQL query, plus three opt-in activity graph options: + a "+N private" label (`show_private=true`), a year-over-year ghost line for the same window one + year earlier (`compare=year`, fetched via an aliased query in the same request), and a 7-day + moving average trend line (`trend=true`). +- Optional `line_width` and `point_size` query params for customizing the graph line and point styling. + +### Changed + +- Migrated ESLint config to the ESLint 9 flat config format. +- Set an explicit `rootDir` in `tsconfig.json` to match `tsc`'s existing compiled output and silence the TS6 rootDir-inference warning. diff --git a/README.md b/README.md index 4ee2a463..fecfd586 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,11 @@ Customize the appearance of your Activity Graph however you want with URL params | `from` | date from which the graph starts | format `YYYY-MM-DD` | | `to` | date where the graph will end | format `YYYY-MM-DD` | | `grid` | show grid | boolean (default: `true`) | +| `line_width` | thickness of the graph's line | number (1-10 inclusive, default: `4`) | +| `point_size` | diameter of points on the line graph | number (1-30 inclusive, default: `10`) | +| `show_private` | adds a "+N private" label for restricted contributions in the window | boolean (default: `false`) | +| `compare` | overlays a ghost line for the same window one year earlier | string (`year`) | +| `trend` | overlays a 7-day moving average line | boolean (default: `false`) | ⚠ **For `custom_title` please make sure that you are using %20 for spaces** diff --git a/__test__/activityGraphFeatures.test.ts b/__test__/activityGraphFeatures.test.ts new file mode 100644 index 00000000..6bd97839 --- /dev/null +++ b/__test__/activityGraphFeatures.test.ts @@ -0,0 +1,218 @@ +import { Utilities, movingAverage } from '../src/utils'; +import { Card } from '../src/GraphCards'; +import { Fetcher } from '../src/fetcher'; +import { UserDetails } from '../src/interfaces/interface'; +import { fakeQueryStringRes } from './fakeInputs'; + +const flags = (query: Record) => + new Utilities({ username: 'u', ...query }).queryOptions(); + +describe('show_private, compare and trend query options', () => { + it('all default to off', () => { + const options = flags({}); + expect(options.show_private).toBe(false); + expect(options.compare).toBe(false); + expect(options.trend).toBe(false); + }); + + it('show_private only turns on for "true"', () => { + expect(flags({ show_private: 'true' }).show_private).toBe(true); + expect(flags({ show_private: 'false' }).show_private).toBe(false); + expect(flags({ show_private: 'yes' }).show_private).toBe(false); + }); + + it('compare only turns on for "year"', () => { + expect(flags({ compare: 'year' }).compare).toBe(true); + expect(flags({ compare: 'month' }).compare).toBe(false); + expect(flags({ compare: 'true' }).compare).toBe(false); + }); + + it('trend only turns on for "true"', () => { + expect(flags({ trend: 'true' }).trend).toBe(true); + expect(flags({ trend: 'false' }).trend).toBe(false); + }); +}); + +describe('movingAverage', () => { + it('averages over a trailing window, partial at the start', () => { + expect(movingAverage([1, 2, 3, 4, 5, 6, 7, 8], 7)).toEqual([ + 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, + ]); + }); + + it('defaults to a 7-day window', () => { + expect(movingAverage([7, 7, 7, 7, 7, 7, 7, 0])).toEqual([ + 7, 7, 7, 7, 7, 7, 7, 6, + ]); + }); + + it('returns an array the same length as the input, even for a single value', () => { + expect(movingAverage([5])).toEqual([5]); + expect(movingAverage([])).toEqual([]); + }); +}); + +describe('getGraphQLQuery with a previous-year comparison', () => { + const fetcher = new Fetcher('someone'); + + it('adds restrictedContributionsCount to the base query', () => { + // @ts-ignore: testing private method + const { query } = fetcher.getGraphQLQuery('2024-01-01T00:00:00.000Z', '2024-02-01T00:00:00.000Z'); + expect(query).toContain('restrictedContributionsCount'); + expect(query).not.toContain('userPrevious'); + }); + + it('adds an aliased userPrevious query and its variables when asked', () => { + // @ts-ignore: testing private method + const built = fetcher.getGraphQLQuery( + '2024-01-01T00:00:00.000Z', + '2024-02-01T00:00:00.000Z', + { from: '2023-01-01T00:00:00.000Z', to: '2023-02-01T00:00:00.000Z' }, + ); + expect(built.query).toContain('userPrevious: user(login: $LOGIN)'); + expect(built.query).toContain('$FROM_PREV: DateTime!'); + expect(built.query).toContain('$TO_PREV: DateTime!'); + expect(built.variables).toEqual({ + LOGIN: 'someone', + FROM: '2024-01-01T00:00:00.000Z', + TO: '2024-02-01T00:00:00.000Z', + FROM_PREV: '2023-01-01T00:00:00.000Z', + TO_PREV: '2023-02-01T00:00:00.000Z', + }); + }); +}); + +describe('fetchContributions with compareYear', () => { + it('captures restrictedContributionsCount and a length-matched previousYearContributions', async () => { + const fetcher = new Fetcher('someone'); + // @ts-ignore: mocking private method + fetcher.fetch = jest.fn().mockResolvedValue({ + data: { + data: { + user: { + name: 'Someone', + contributionsCollection: { + restrictedContributionsCount: 42, + contributionCalendar: { + weeks: [ + { + contributionDays: [ + { contributionCount: 1, date: '2024-01-01' }, + { contributionCount: 2, date: '2024-01-02' }, + { contributionCount: 3, date: '2024-01-03' }, + { contributionCount: 4, date: '2024-01-04' }, + { contributionCount: 5, date: '2024-01-05' }, + ], + }, + ], + }, + }, + }, + userPrevious: { + contributionsCollection: { + contributionCalendar: { + weeks: [ + { + contributionDays: [ + { contributionCount: 9, date: '2023-01-01' }, + { contributionCount: 9, date: '2023-01-02' }, + { contributionCount: 9, date: '2023-01-03' }, + { contributionCount: 9, date: '2023-01-04' }, + { contributionCount: 9, date: '2023-01-05' }, + { contributionCount: 9, date: '2023-01-06' }, + { contributionCount: 9, date: '2023-01-07' }, + ], + }, + ], + }, + }, + }, + }, + }, + }); + + const data = (await fetcher.fetchContributions( + 5, + '2024-01-01', + '2024-01-05', + true, + )) as UserDetails; + + expect(data.restrictedContributionsCount).toBe(42); + expect(data.previousYearContributions).toHaveLength(data.contributions.length); + }); + + it('omits previousYearContributions when compareYear is not requested', async () => { + const fetcher = new Fetcher('someone'); + // @ts-ignore: mocking private method + fetcher.fetch = jest.fn().mockResolvedValue({ + data: { + data: { + user: { + name: 'Someone', + contributionsCollection: { + restrictedContributionsCount: 0, + contributionCalendar: { + weeks: [ + { + contributionDays: [ + { contributionCount: 1, date: '2024-01-01' }, + ], + }, + ], + }, + }, + }, + }, + }, + }); + + const data = (await fetcher.fetchContributions(1, '2024-01-01', '2024-01-01')) as UserDetails; + expect(data.previousYearContributions).toBeUndefined(); + }); +}); + +describe('Card.buildGraph with ghost line, trend line and private label', () => { + const days = [ + { contributionCount: 2, date: '1' }, + { contributionCount: 1, date: '2' }, + ]; + + it('renders the overlay series and private label when provided', async () => { + const svg = await new Card( + 420, + 1200, + 0, + fakeQueryStringRes[0].colors, + 'title', + false, + true, + 4, + 10, + [ + { contributionCount: 5, date: '1' }, + { contributionCount: 6, date: '2' }, + ], + [1.5, 1.5], + '+42 private', + ).buildGraph(days); + + expect(svg).toContain('ct-series-ghost'); + expect(svg).toContain('ct-series-trend'); + expect(svg).toContain('+42 private'); + }); + + it('omits overlay styling and the label when none are provided', async () => { + const svg = await new Card( + 420, + 1200, + 0, + fakeQueryStringRes[0].colors, + 'title', + ).buildGraph(days); + + expect(svg).not.toContain('ct-series-ghost'); + expect(svg).not.toContain('ct-series-trend'); + expect(svg).not.toContain('private'); + }); +}); diff --git a/__test__/fakeInputs.ts b/__test__/fakeInputs.ts index 3346aec1..3fbe0348 100644 --- a/__test__/fakeInputs.ts +++ b/__test__/fakeInputs.ts @@ -118,6 +118,11 @@ export let fakeQueryStringRes = [ from: '', to: '', grid: true, + line_width: 4, + point_size: 10, + show_private: false, + compare: false, + trend: false, }, { username: 'githubusername', @@ -138,6 +143,11 @@ export let fakeQueryStringRes = [ from: '', to: '', grid: true, + line_width: 4, + point_size: 10, + show_private: false, + compare: false, + trend: false, }, { username: 'githubusername', @@ -158,6 +168,11 @@ export let fakeQueryStringRes = [ from: '', to: '', grid: true, + line_width: 4, + point_size: 10, + show_private: false, + compare: false, + trend: false, }, { username: 'githubusername', @@ -178,6 +193,11 @@ export let fakeQueryStringRes = [ from: '', to: '', grid: true, + line_width: 4, + point_size: 10, + show_private: false, + compare: false, + trend: false, }, { username: 'githubusername', @@ -198,6 +218,11 @@ export let fakeQueryStringRes = [ from: '', to: '', grid: true, + line_width: 4, + point_size: 10, + show_private: false, + compare: false, + trend: false, }, { username: 'githubusername', @@ -218,6 +243,11 @@ export let fakeQueryStringRes = [ from: '', to: '', grid: true, + line_width: 4, + point_size: 10, + show_private: false, + compare: false, + trend: false, }, { username: 'githubusername', @@ -238,6 +268,11 @@ export let fakeQueryStringRes = [ from: '', to: '', grid: true, + line_width: 4, + point_size: 10, + show_private: false, + compare: false, + trend: false, }, { username: 'githubusername', @@ -258,6 +293,11 @@ export let fakeQueryStringRes = [ from: '', to: '', grid: true, + line_width: 4, + point_size: 10, + show_private: false, + compare: false, + trend: false, }, { username: 'githubusername', @@ -279,6 +319,11 @@ export let fakeQueryStringRes = [ from: '', to: '', grid: true, + line_width: 4, + point_size: 10, + show_private: false, + compare: false, + trend: false, }, { username: 'githubusername', @@ -299,6 +344,11 @@ export let fakeQueryStringRes = [ from: '', to: '', grid: true, + line_width: 4, + point_size: 10, + show_private: false, + compare: false, + trend: false, }, ]; @@ -343,6 +393,7 @@ export const expectedQuery = (username: string, from: string, to: string) => { user(login: $LOGIN) { name contributionsCollection(from: $FROM, to: $TO) { + restrictedContributionsCount contributionCalendar { weeks { contributionDays { diff --git a/__test__/lineWidthPointSize.test.ts b/__test__/lineWidthPointSize.test.ts new file mode 100644 index 00000000..2d12757c --- /dev/null +++ b/__test__/lineWidthPointSize.test.ts @@ -0,0 +1,53 @@ +import { Utilities } from '../src/utils'; +import { graphStyle } from '../src/styles/graphStyle'; + +const sizes = (query: Record) => { + const { line_width, point_size } = new Utilities({ username: 'u', ...query }).queryOptions(); + return { line_width, point_size }; +}; + +describe('line_width and point_size', () => { + it('keeps upstream defaults when omitted or empty', () => { + expect(sizes({})).toEqual({ line_width: 4, point_size: 10 }); + expect(sizes({ line_width: '', point_size: '' })).toEqual({ + line_width: 4, + point_size: 10, + }); + }); + + it('accepts in-range values', () => { + expect(sizes({ line_width: '2', point_size: '6' })).toEqual({ + line_width: 2, + point_size: 6, + }); + }); + + it('clamps out-of-range values', () => { + expect(sizes({ line_width: '999', point_size: '999' })).toEqual({ + line_width: 10, + point_size: 30, + }); + expect(sizes({ line_width: '-5', point_size: '0' })).toEqual({ + line_width: 1, + point_size: 1, + }); + }); + + it('falls back to defaults for non-numeric input (no CSS injection)', () => { + const res = sizes({ line_width: '4;}