diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 9b7f2bd6..dd8688ab 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -7,7 +7,7 @@ on: push: branches: [main] pull_request: - branches: [main] + branches: [main, deploy] jobs: build: diff --git a/README.md b/README.md index a1df88f3..7c7a44d9 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,9 @@ 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`) | +| `ghosts` | number of prior windows to overlay as | number (0 - 6 inclusive, | +| | faded grey "ghost" lines behind the current | default: `0`) | +| | line | | ⚠ **For `custom_title` please make sure that you are using %20 for spaces** @@ -122,6 +125,20 @@ Example: [![Ashutosh's github activity graph](https://github-readme-activity-graph.vercel.app/graph?username=ashutosh00710&bg_color=fffff0&color=708090&line=24292e&point=24292e&area=true&border_color=ff0000)](https://github.com/ashutosh00710/github-readme-activity-graph) ``` +### Ghost lines (`ghosts=N`) + +Add `&ghosts=N` to overlay the previous `N` contribution windows as faded grey "ghost" lines behind the current line, so you can see at a glance how this window's pace compares to previous ones: + +```md +[![Ashutosh's github activity graph](https://github-readme-activity-graph.vercel.app/graph?username=ashutosh00710&ghosts=2)](https://github.com/ashutosh00710/github-readme-activity-graph) +``` + +- Each ghost is the current window shifted back by its own length — a 31-day graph with `ghosts=2` shows the current 31 days plus the two 31-day windows before it — index-aligned on the X-axis. Works with any `days` value and custom `from`/`to`. +- Ghosts are drawn in a fixed grey (`#9e9e9e`, ~35% opacity, thinner stroke) regardless of theme, with no points or area fill. +- Ghost data shares the Y-axis scale, so a busy prior window can flatten a quiet current one. +- X-axis labels reflect the current window only; ghosts are unlabeled. +- GitHub only serves contributions for about the last year, so ghost windows reaching further back are skipped — fewer lines than requested may appear for long windows. + ## Deploy on your own Replit instance
diff --git a/__test__/__snapshots__/svgs.test.ts.snap b/__test__/__snapshots__/svgs.test.ts.snap index 8f22c6e1..960dfaf4 100644 --- a/__test__/__snapshots__/svgs.test.ts.snap +++ b/__test__/__snapshots__/svgs.test.ts.snap @@ -131,13 +131,24 @@ exports[`SVG Testing Test SVGs 1`] = ` animation: dash 5s ease-in-out forwards; } + /* Faded prior windows ("ghost" lines) behind the current line + (chicks-net/chicks-projects#130). Fixed grey regardless of theme; + the doubled-class selector out-specifies the .ct-line rule above + for stroke, opacity and width, while the dash-draw animation and + fill:none are inherited. */ + .ct-series-ghost .ct-line { + stroke: #9e9e9e; + stroke-opacity: 0.35; + stroke-width: 2px; + } + .ct-area { stroke: none; fill-opacity: 0.1; } - .ct-series-a .ct-area, - .ct-series-a .ct-slice-pie { + .ct-series-current .ct-area, + .ct-series-current .ct-slice-pie { fill: #87ceeb; } @@ -329,13 +340,212 @@ exports[`SVG Testing chart SVGs 1`] = ` animation: dash 5s ease-in-out forwards; } + /* Faded prior windows ("ghost" lines) behind the current line + (chicks-net/chicks-projects#130). Fixed grey regardless of theme; + the doubled-class selector out-specifies the .ct-line rule above + for stroke, opacity and width, while the dash-draw animation and + fill:none are inherited. */ + .ct-series-ghost .ct-line { + stroke: #9e9e9e; + stroke-opacity: 0.35; + stroke-width: 2px; + } + + .ct-area { + stroke: none; + fill-opacity: 0.1; + } + + .ct-series-current .ct-area, + .ct-series-current .ct-slice-pie { + fill: #9e4c98; + } + + .ct-label .ct-horizontal { + transform: rotate(-90deg) + } + + + @keyframes blink { + from { + opacity: 0; + transform:translateX(-20px); + } + to { + opacity:1; + transform: translateX(0); + } + } + + + @keyframes dash { + to { + stroke-dashoffset: 0; + } + } + + + + + +

+ xyz's Contribution Graph +

+
+ 1211102468101214DaysContributions + +" +`; + +exports[`SVG Testing chart SVGs with ghosts 1`] = ` +" + + + + " `; diff --git a/__test__/__snapshots__/utils.test.ts.snap b/__test__/__snapshots__/utils.test.ts.snap index c318fa4f..63c22b2a 100644 --- a/__test__/__snapshots__/utils.test.ts.snap +++ b/__test__/__snapshots__/utils.test.ts.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Utilities Test Graph Generation 1`] = `"123450"`; +exports[`Utilities Test Graph Generation 1`] = `"123450"`; diff --git a/__test__/fakeInputs.ts b/__test__/fakeInputs.ts index 2fdbc938..1f764aad 100644 --- a/__test__/fakeInputs.ts +++ b/__test__/fakeInputs.ts @@ -97,6 +97,26 @@ export let fakeQueryString = [ hide_title: false, custom_title: undefined, }, + { + username: 'githubusername', + ghosts: '2', + }, + { + username: 'githubusername', + ghosts: '9', + }, + { + username: 'githubusername', + ghosts: '2.7', + }, + { + username: 'githubusername', + ghosts: '-3', + }, + { + username: 'githubusername', + ghosts: 'not-a-number', + }, ]; export let fakeQueryStringRes = [ @@ -116,6 +136,7 @@ export let fakeQueryStringRes = [ hide_title: false, area: false, days: 31, + ghosts: 0, from: '', to: '', grid: true, @@ -136,6 +157,7 @@ export let fakeQueryStringRes = [ hide_title: false, area: false, days: 31, + ghosts: 0, from: '', to: '', grid: true, @@ -156,6 +178,7 @@ export let fakeQueryStringRes = [ hide_title: false, area: false, days: 31, + ghosts: 0, from: '', to: '', grid: true, @@ -176,6 +199,7 @@ export let fakeQueryStringRes = [ hide_title: false, area: false, days: 31, + ghosts: 0, from: '', to: '', grid: true, @@ -196,6 +220,7 @@ export let fakeQueryStringRes = [ hide_title: false, area: false, days: 31, + ghosts: 0, from: '', to: '', grid: true, @@ -216,6 +241,7 @@ export let fakeQueryStringRes = [ hide_title: false, area: true, days: 31, + ghosts: 0, from: '', to: '', grid: true, @@ -236,6 +262,7 @@ export let fakeQueryStringRes = [ area: true, hide_title: false, days: 31, + ghosts: 0, from: '', to: '', grid: true, @@ -256,6 +283,7 @@ export let fakeQueryStringRes = [ area: true, hide_title: true, days: 31, + ghosts: 0, from: '', to: '', grid: true, @@ -277,6 +305,7 @@ export let fakeQueryStringRes = [ hide_title: false, custom_title: 'some title', days: 31, + ghosts: 0, from: '', to: '', grid: true, @@ -297,6 +326,112 @@ export let fakeQueryStringRes = [ area: true, hide_title: false, days: 31, + ghosts: 0, + from: '', + to: '', + grid: true, + }, + { + username: 'githubusername', + radius: 0, + height: 420, + colors: { + areaColor: '9e4c98', + bgColor: 'ffcfe9', + borderColor: 'ffffff', + color: '9e4c98', + titleColor: '9e4c98', + lineColor: '9e4c98', + pointColor: '403d3d', + }, + hide_title: false, + area: false, + days: 31, + ghosts: 2, + from: '', + to: '', + grid: true, + }, + { + username: 'githubusername', + radius: 0, + height: 420, + colors: { + areaColor: '9e4c98', + bgColor: 'ffcfe9', + borderColor: 'ffffff', + color: '9e4c98', + titleColor: '9e4c98', + lineColor: '9e4c98', + pointColor: '403d3d', + }, + hide_title: false, + area: false, + days: 31, + ghosts: 6, + from: '', + to: '', + grid: true, + }, + { + username: 'githubusername', + radius: 0, + height: 420, + colors: { + areaColor: '9e4c98', + bgColor: 'ffcfe9', + borderColor: 'ffffff', + color: '9e4c98', + titleColor: '9e4c98', + lineColor: '9e4c98', + pointColor: '403d3d', + }, + hide_title: false, + area: false, + days: 31, + ghosts: 2, + from: '', + to: '', + grid: true, + }, + { + username: 'githubusername', + radius: 0, + height: 420, + colors: { + areaColor: '9e4c98', + bgColor: 'ffcfe9', + borderColor: 'ffffff', + color: '9e4c98', + titleColor: '9e4c98', + lineColor: '9e4c98', + pointColor: '403d3d', + }, + hide_title: false, + area: false, + days: 31, + ghosts: 0, + from: '', + to: '', + grid: true, + }, + { + username: 'githubusername', + radius: 0, + height: 420, + colors: { + areaColor: '9e4c98', + bgColor: 'ffcfe9', + borderColor: 'ffffff', + color: '9e4c98', + titleColor: '9e4c98', + lineColor: '9e4c98', + pointColor: '403d3d', + }, + hide_title: false, + area: false, + days: 31, + ghosts: 0, from: '', to: '', grid: true, @@ -336,32 +471,45 @@ export let options = { title: '', area: false, plugins: [majorGridPlugin], + series: {} as { [name: string]: { showPoint?: boolean; showArea?: boolean } }, }; -export const expectedQuery = (username: string, from: string, to: string) => { +export const expectedQuery = (username: string, windows: Array<{ from: string; to: string }>) => { + const variableDefinitions = windows + .map((_, i) => `$FROM${i}: DateTime!, $TO${i}: DateTime!`) + .join(', '); + const contributions = windows + .map( + (_, i) => ` + c${i}: contributionsCollection(from: $FROM${i}, to: $TO${i}) { + contributionCalendar { + weeks { + contributionDays { + contributionCount + date + } + } + } + }`, + ) + .join(''); + const variables: { LOGIN: string; [name: string]: string } = { + LOGIN: username, + }; + windows.forEach((window, i) => { + variables[`FROM${i}`] = window.from; + variables[`TO${i}`] = window.to; + }); return { query: ` - query userInfo($LOGIN: String!, $FROM: DateTime!, $TO: DateTime!) { + query userInfo($LOGIN: String!, ${variableDefinitions}) { user(login: $LOGIN) { name - contributionsCollection(from: $FROM, to: $TO) { - contributionCalendar { - weeks { - contributionDays { - contributionCount - date - } - } - } - } + ${contributions} } } `, - variables: { - LOGIN: username, - FROM: from, - TO: to, - }, + variables, }; }; @@ -525,6 +673,33 @@ export const dummyWeeksData = [ }, ]; +// A 14-day window: fewer days than the 31-day current window, so the +// fetcher must front-pad it with zero days to keep the series aligned. +export const dummyGhostWeeksData = [ + { + contributionDays: [ + { contributionCount: 7 }, + { contributionCount: 3 }, + { contributionCount: 1 }, + { contributionCount: 0 }, + { contributionCount: 5 }, + { contributionCount: 2 }, + { contributionCount: 4 }, + ], + }, + { + contributionDays: [ + { contributionCount: 6 }, + { contributionCount: 6 }, + { contributionCount: 0 }, + { contributionCount: 1 }, + { contributionCount: 8 }, + { contributionCount: 3 }, + { contributionCount: 2 }, + ], + }, +]; + export const themes = { dracula: { areaColor: 'ff79c6', diff --git a/__test__/fetching.test.ts b/__test__/fetching.test.ts index 44b74814..dc147a82 100644 --- a/__test__/fetching.test.ts +++ b/__test__/fetching.test.ts @@ -4,6 +4,7 @@ import { mockQueryIncorrect, mockFetchCorrect, mockFetchIncorrect, + mockFetchGhosts, } from './mockFunctions'; import { expectedQuery } from './fakeInputs'; import { UserDetails } from '../src/interfaces/interface'; @@ -11,34 +12,62 @@ import { UserDetails } from '../src/interfaces/interface'; describe('Fetching Tests', () => { const fetcher = new Fetcher('ashutosh00710'); it('Query Test', () => { - const expected = expectedQuery( - 'ashutosh00710', - '2022-05-01T00:00:00.000+00:00', - '2022-06-01T00:00:00.000+00:00', - ); + const expected = expectedQuery('ashutosh00710', [ + { + from: '2022-05-01T00:00:00.000+00:00', + to: '2022-06-01T00:00:00.000+00:00', + }, + ]); expect( // @ts-ignore: testing private method - fetcher.getGraphQLQuery( - '2022-05-01T00:00:00.000+00:00', - '2022-06-01T00:00:00.000+00:00', - ), + fetcher.getGraphQLQuery([ + { from: '2022-05-01T00:00:00.000+00:00', to: '2022-06-01T00:00:00.000+00:00' }, + ]), ).toEqual(expected); }); - it('Fetching Contributions Test', () => { + it('Query Test with ghosts', () => { + const current = { + from: '2022-05-01T00:00:00.000+00:00', + to: '2022-06-01T00:00:00.000+00:00', + }; + const ghost = { + from: '2022-04-01T00:00:00.000+00:00', + to: '2022-05-01T00:00:00.000+00:00', + }; + expect( + // @ts-ignore: testing private method + fetcher.getGraphQLQuery([current, ghost]), + ).toEqual(expectedQuery('ashutosh00710', [current, ghost])); + expect( + // @ts-ignore: testing private method + fetcher.getGraphQLQuery([current, ghost]).query, + ).toContain('$FROM0: DateTime!, $TO0: DateTime!, $FROM1: DateTime!, $TO1: DateTime!'); + expect( + // @ts-ignore: testing private method + fetcher.getGraphQLQuery([current, ghost]).query, + ).toContain('c0: contributionsCollection(from: $FROM0, to: $TO0)'); + expect( + // @ts-ignore: testing private method + fetcher.getGraphQLQuery([current, ghost]).query, + ).toContain('c1: contributionsCollection(from: $FROM1, to: $TO1)'); + }); + + it('Fetching Contributions Test', async () => { // @ts-ignore: mocking private method fetcher.getGraphQLQuery = mockQueryCorrect; // @ts-ignore: mocking private method fetcher.fetch = mockFetchCorrect; - fetcher.fetchContributions(31).then( - //@ts-ignore: will always return data of type userDetails - (data: UserDetails) => { - expect(data.contributions).toEqual(expect.any(Array)); - expect(data.contributions.length).toEqual(31); - expect(data.name).toEqual('Ashutosh Dwivedi'); - }, - ); + const data = await fetcher.fetchContributions(31); + expect(data).toEqual(expect.any(Object)); + if (typeof data === 'object') { + expect(data.contributions).toEqual(expect.any(Array)); + expect(data.contributions.length).toEqual(31); + expect(data.name).toEqual('Ashutosh Dwivedi'); + // No ghosts requested: the field must stay absent. + expect(data.ghosts).toBeUndefined(); + } // @ts-ignore: mocking private method fetcher.getGraphQLQuery = mockQueryIncorrect; @@ -50,4 +79,36 @@ describe('Fetching Tests', () => { expect(data).toEqual(`Can't fetch any contribution. Please check your username 😬`); }); }); + + it('Fetching Contributions with ghosts Test', async () => { + // @ts-ignore: mocking private method + fetcher.getGraphQLQuery = mockQueryCorrect; + // @ts-ignore: mocking private method + fetcher.fetch = mockFetchGhosts; + + // Three ghosts requested: c1 trims, c2 pads, c3 is missing and gets + // skipped, so two ghost lines come back. + const data = await fetcher.fetchContributions(31, undefined, undefined, 3); + expect(data).toEqual(expect.any(Object)); + if (typeof data === 'string') { + throw new Error('expected UserDetails'); + } + const ghosts = (data as UserDetails).ghosts ?? []; + expect((data as UserDetails).contributions.length).toEqual(31); + expect(ghosts).toHaveLength(2); + + // c1: longer calendar trimmed to the current day count, keeping the + // trailing days so indices stay aligned. + expect(ghosts[0]).toHaveLength(31); + + // c2: 14 days returned for a 31-day window, padded at the front + // with zero days. + expect(ghosts[1]).toHaveLength(31); + expect(ghosts[1].slice(0, 17).map((day) => day.contributionCount)).toEqual( + Array(17).fill(0), + ); + expect(ghosts[1].slice(17).map((day) => day.contributionCount)).toEqual([ + 7, 3, 1, 0, 5, 2, 4, 6, 6, 0, 1, 8, 3, 2, + ]); + }); }); diff --git a/__test__/mockFunctions.ts b/__test__/mockFunctions.ts index 9316f764..344bc00a 100644 --- a/__test__/mockFunctions.ts +++ b/__test__/mockFunctions.ts @@ -1,12 +1,15 @@ -import { dummyWeeksData } from './fakeInputs'; +import { dummyWeeksData, dummyGhostWeeksData } from './fakeInputs'; +// The fixture is cloned per use so mocked windows never share (and never +// mutate) the same underlying data. const weeks = () => { + const data = JSON.parse(JSON.stringify(dummyWeeksData)); for (let i = 1; i <= new Date().getDay(); i++) { - dummyWeeksData[6].contributionDays.push({ + data[6].contributionDays.push({ contributionCount: 0, }); } - return dummyWeeksData; + return data; }; //For valid username @@ -16,7 +19,7 @@ export const mockFetchCorrect = jest.fn().mockReturnValue( data: { user: { name: 'Ashutosh Dwivedi', - contributionsCollection: { + c0: { contributionCalendar: { totalContributions: 389, weeks: weeks(), @@ -28,6 +31,39 @@ export const mockFetchCorrect = jest.fn().mockReturnValue( }), ); +//For ghost windows: c1 trims its longer calendar to the current window's +//day count, c2 pads its short calendar at the front, and c3 is missing +//entirely so it gets skipped. +export const mockFetchGhosts = jest.fn().mockReturnValue( + Promise.resolve({ + data: { + data: { + user: { + name: 'Ashutosh Dwivedi', + c0: { + contributionCalendar: { + totalContributions: 389, + weeks: weeks(), + }, + }, + c1: { + contributionCalendar: { + totalContributions: 210, + weeks: weeks(), + }, + }, + c2: { + contributionCalendar: { + totalContributions: 97, + weeks: JSON.parse(JSON.stringify(dummyGhostWeeksData)), + }, + }, + }, + }, + }, + }), +); + //For invalid username export const mockFetchIncorrect = jest.fn().mockReturnValue({ data: { @@ -58,17 +94,17 @@ export const mockQueryCorrect = jest.fn().mockReturnValue({ name contributionsCollection { contributionCalendar { - totalContributions - weeks { - contributionDays { - contributionCount - } - } - } - } - } - }, - `, + totalContributions + weeks { + contributionDays { + contributionCount + } + } + } + } + } + }, + `, variables: { LOGIN: 'ashutosh00710', }, @@ -82,17 +118,17 @@ export const mockQueryIncorrect = jest.fn().mockReturnValue({ name contributionsCollection { contributionCalendar { - totalContributions - weeks { - contributionDays { - contributionCount - } - } - } - } - } - }, - `, + totalContributions + weeks { + contributionDays { + contributionCount + } + } + } + } + } + }, + `, variables: { LOGIN: '', }, diff --git a/__test__/svgs.test.ts b/__test__/svgs.test.ts index 52052f62..7e643c7e 100644 --- a/__test__/svgs.test.ts +++ b/__test__/svgs.test.ts @@ -13,6 +13,29 @@ describe('SVG Testing', () => { expect(invalidUserSvg(fakeInvalidSvgArg)).toMatchSnapshot(); }); + const days = [ + { + contributionCount: 2, + date: '1', + }, + { + contributionCount: 3, + date: '2', + }, + { + contributionCount: 10, + date: '1', + }, + { + contributionCount: 12, + date: '1', + }, + { + contributionCount: 14, + date: '1', + }, + ]; + //- Svg testing Promise (GrapgCards.ts)✔ it('chart SVGs', async () => { expect.assertions(1); @@ -24,28 +47,32 @@ describe('SVG Testing', () => { fakeQueryStringRes[0].colors, "xyz's Contribution Graph", fakeQueryStringRes[0].area, - ).buildGraph([ - { - contributionCount: 2, - date: '1', - }, - { - contributionCount: 3, - date: '2', - }, - { - contributionCount: 10, - date: '1', - }, - { - contributionCount: 12, - date: '1', - }, - { - contributionCount: 14, - date: '1', - }, - ]), + ).buildGraph(days), ).toMatchSnapshot(); }); + + //- Svg testing with ghost lines (GraphCards.ts) ✔ + it('chart SVGs with ghosts', async () => { + expect.assertions(3); + const svg = await new Card( + 420, + 1200, + fakeQueryStringRes[0].radius, + fakeQueryStringRes[0].colors, + "xyz's Contribution Graph", + fakeQueryStringRes[0].area, + ).buildGraph(days, [ + // Ghost window further in the past. + days.map((day) => ({ contributionCount: day.contributionCount - 1, date: day.date })), + // Ghost window immediately before the current one. + days.map((day) => ({ contributionCount: day.contributionCount + 1, date: day.date })), + ]); + + // SVG paints in document order: the ghost series must come first + // so they sit behind the current line, and the ghost styling must + // be present. + expect(svg.indexOf('ct-series-ghost')).toBeLessThan(svg.indexOf('ct-series-current')); + expect(svg).toContain('.ct-series-ghost .ct-line'); + expect(svg).toMatchSnapshot(); + }); }); diff --git a/__test__/utils.test.ts b/__test__/utils.test.ts index d4fa69bb..3f0e1cf6 100644 --- a/__test__/utils.test.ts +++ b/__test__/utils.test.ts @@ -24,7 +24,11 @@ describe('Utilities Test', () => { }; describe('GET /graph with correct credential', () => { - test('responding', (done) => { + // This test calls the live GitHub API through the /graph handler; + // without a TOKEN (e.g. CI without repo secrets) it would fail, so + // it is skipped instead of reporting a false negative. + const maybeTest = process.env.TOKEN ? test : test.skip; + maybeTest('responding', (done) => { const app = fakeServer(); app.get('/graph', handlers.getGraph); request(app) @@ -139,7 +143,13 @@ describe('Utilities Test', () => { ]; const graph: Promise = await createGraph('line', options, { labels: days.map((day) => day.date), - series: [{ value: days.map((day) => day.contributionCount) }], + series: [ + { + name: 'current', + className: 'ct-series-current', + data: days.map((day) => day.contributionCount), + }, + ], }); expect(graph).toMatchSnapshot(); }); diff --git a/src/GraphCards.ts b/src/GraphCards.ts index 88eabdbb..815154ef 100644 --- a/src/GraphCards.ts +++ b/src/GraphCards.ts @@ -65,6 +65,10 @@ export class Card { showArea: this.area, fullWidth: true, plugins: [majorGridPlugin], + // Per-series overrides, filled in by buildGraph. Chartist reads + // these by series name (getSeriesOption), falling back to the + // global options for anything unset. + series: {} as { [name: string]: { showPoint?: boolean; showArea?: boolean } }, }; } @@ -83,14 +87,39 @@ export class Card { // return days.reverse(); // } - async buildGraph(days: ContributionDay[]): Promise { + async buildGraph( + days: ContributionDay[], + ghosts?: Array>, + ): Promise { //Options to pass in createGraph function const options = this.getOptions(); + // Ghost series go FIRST: SVG paints in document order, so the faded + // prior windows sit behind the current window's line. + const series: Array<{ name: string; className: string; data: number[] }> = []; + (ghosts ?? []).forEach((ghost, i) => + series.push({ + name: `ghost-${i + 1}`, + className: 'ct-series-ghost', + data: ghost.map((day) => day.contributionCount), + }), + ); + series.push({ + name: 'current', + className: 'ct-series-current', + data: days.map((day) => day.contributionCount), + }); + + // Ghosts are plain comparison lines: no points, no area fill. The + // current window keeps the global options. + (ghosts ?? []).forEach((_, i) => { + options.series[`ghost-${i + 1}`] = { showPoint: false, showArea: false }; + }); + //Construction of graph from node-chartist const line: Promise = await createGraph('line', options, { labels: days.map((day) => day.date), - series: [{ value: days.map((day) => day.contributionCount) }], + series, }); //Arguments to construct graphs with rect and other options diff --git a/src/fetcher.ts b/src/fetcher.ts index 25611777..bdcef36e 100644 --- a/src/fetcher.ts +++ b/src/fetcher.ts @@ -1,40 +1,70 @@ import axios, { AxiosResponse } from 'axios'; import * as dotenv from 'dotenv'; import moment from 'moment'; -import { Query, UserDetails, Week, ContributionDay, ResponseOfApi } from 'src/interfaces/interface'; +import { + Query, + UserDetails, + Week, + ContributionDay, + ResponseOfApi, + ContributionsCollection, +} from 'src/interfaces/interface'; dotenv.config(); +// One [from, to] window to fetch; index 0 is the current window, the rest +// are the ghost windows trailing it. +interface ContributionWindow { + from: string; + to: string; +} + export class Fetcher { private readonly username: string; constructor(username: string) { this.username = username; } - private getGraphQLQuery(from: string, to: string) { + private getGraphQLQuery(windows: ContributionWindow[]) { + // Every window is aliased into a single request (c0 = current, then + // one per ghost), so ghost lines cost no extra HTTP requests. + const variableDefinitions = windows + .map((_, i) => `$FROM${i}: DateTime!, $TO${i}: DateTime!`) + .join(', '); + const contributions = windows + .map( + (_, i) => ` + c${i}: contributionsCollection(from: $FROM${i}, to: $TO${i}) { + contributionCalendar { + weeks { + contributionDays { + contributionCount + date + } + } + } + }`, + ) + .join(''); + + const variables: Query['variables'] = { + LOGIN: this.username, + }; + windows.forEach((window, i) => { + variables[`FROM${i}`] = window.from; + variables[`TO${i}`] = window.to; + }); + return { query: ` - query userInfo($LOGIN: String!, $FROM: DateTime!, $TO: DateTime!) { + query userInfo($LOGIN: String!, ${variableDefinitions}) { user(login: $LOGIN) { name - contributionsCollection(from: $FROM, to: $TO) { - contributionCalendar { - weeks { - contributionDays { - contributionCount - date - } - } - } - } + ${contributions} } } `, - variables: { - LOGIN: this.username, - FROM: from, - TO: to, - }, + variables, }; } @@ -49,10 +79,26 @@ export class Fetcher { }); } + // Flatten one window's calendar weeks into a day list, converting each + // date to the day-of-month string used for the graph's X labels. + private flattenWeeks(collection: ContributionsCollection): ContributionDay[] { + const days: ContributionDay[] = []; + collection.contributionCalendar.weeks.forEach((week: Week) => + week.contributionDays.forEach((contributionDay: ContributionDay) => { + days.push({ + contributionCount: contributionDay.contributionCount, + date: moment(contributionDay.date, moment.ISO_8601).date().toString(), + }); + }), + ); + return days; + } + public async fetchContributions( days: number, customFromDate?: string, customToDate?: string, + ghosts = 0, ): Promise { let from = '', to = ''; @@ -66,8 +112,29 @@ export class Fetcher { to = moment(now).add(1, 'days').utc().toISOString(); } + // Ghost windows trail the current one: each is the current range + // shifted back by its own length, so a 31-day graph with ghosts=2 + // shows this window plus the two 31-day windows before it, + // index-aligned on the X-axis. + const windowLength = moment(to).diff(moment(from), 'days'); + const windows: ContributionWindow[] = [{ from, to }]; + for (let i = 1; i <= ghosts; i++) { + const ghostFrom = moment(from).subtract(i * windowLength, 'days'); + // GitHub only serves contributions for about the last year; + // windows reaching further back are skipped, so fewer ghost + // lines than requested may appear for long windows. + if (ghostFrom.isBefore(moment().subtract(1, 'year'))) break; + windows.push({ + from: ghostFrom.utc().toISOString(), + to: moment(to) + .subtract(i * windowLength, 'days') + .utc() + .toISOString(), + }); + } + try { - const apiResponse = await this.fetch(this.getGraphQLQuery(from, to)); + const apiResponse = await this.fetch(this.getGraphQLQuery(windows)); if (apiResponse.data.errors) { console.error('API Error: ', apiResponse.data.errors); @@ -81,35 +148,57 @@ export class Fetcher { if (apiResponse.data.data.user === null) return `Can't fetch any contribution. Please check your username 😬`; else { + const user = apiResponse.data.data.user; const userData: UserDetails = { contributions: [], - name: apiResponse.data.data.user.name, + name: user.name, }; - //filtering the week data from API response - const weeks = - apiResponse.data.data.user.contributionsCollection.contributionCalendar - .weeks; - // get day-contribution data - weeks.map((week: Week) => - week.contributionDays.map((contributionDay: ContributionDay) => { - contributionDay.date = moment(contributionDay.date, moment.ISO_8601) - .date() - .toString(); - userData.contributions.push(contributionDay); - }), - ); - // if 32nd entry is 0 means: - // either the day hasn't really started - // or the user hasn't contributed today - const length = userData.contributions.length; + // Current window: if the last entry is 0, the day hasn't + // really started (or the user hasn't contributed today), + // so drop it and trim to the requested day count. + // Custom ranges are used exactly as returned. + const currentDays = this.flattenWeeks(user['c0']); if (!(customFromDate && customToDate)) { - if (userData.contributions[length - 1].contributionCount === 0) { - userData.contributions.pop(); + if (currentDays[currentDays.length - 1].contributionCount === 0) { + currentDays.pop(); } - const extra = userData.contributions.length - days; - userData.contributions.splice(0, extra); + const extra = currentDays.length - days; + currentDays.splice(0, extra); } + userData.contributions = currentDays; + + // Ghost windows are fully in the past, so there is no + // partial trailing day to drop: each is trimmed to the + // same day count as the current window by keeping the + // trailing days, which aligns index-for-index with the + // current series. Windows the API didn't return data + // for are skipped; a partially returned window (an + // account younger than the window) is padded at the + // front with zero days. + const targetLength = userData.contributions.length; + const ghostWindows = windows + .slice(1) + .map((_, i) => { + const collection = user[`c${i + 1}`]; + if (!collection) return []; + const ghostDays = this.flattenWeeks(collection); + if (ghostDays.length === 0) return []; + if (ghostDays.length > targetLength) { + ghostDays.splice(0, ghostDays.length - targetLength); + } else if (ghostDays.length < targetLength) { + ghostDays.unshift( + ...Array.from( + { length: targetLength - ghostDays.length }, + () => ({ contributionCount: 0, date: '0' }), + ), + ); + } + return ghostDays; + }) + .filter((ghost) => ghost.length > 0); + if (ghostWindows.length > 0) userData.ghosts = ghostWindows; + return userData; } } else { diff --git a/src/handlers.ts b/src/handlers.ts index a7daaeaf..427bb489 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -16,9 +16,10 @@ export class Handlers { const fetcher = new Fetcher(utils.username); const queryOptions = utils.queryOptions(); const fetchCalendarData = await fetcher.fetchContributions( - utils.queryOptions().days, + queryOptions.days, queryOptions.from, queryOptions.to, + queryOptions.ghosts, ); const { finalGraph, header } = await utils.buildGraph(fetchCalendarData); diff --git a/src/interfaces/interface.ts b/src/interfaces/interface.ts index d0dbece5..ed002393 100644 --- a/src/interfaces/interface.ts +++ b/src/interfaces/interface.ts @@ -2,6 +2,8 @@ export class Query { query: string; variables: { LOGIN: string; + // One FROMn/TOn pair per contribution window (current + ghosts). + [variable: string]: string; }; } @@ -27,6 +29,7 @@ export class QueryOption { radius: number; height: number; days: number; + ghosts: number; } export class ParsedQs { @@ -49,6 +52,7 @@ export class ParsedQs { from?: string; to?: string; grid?: string; + ghosts?: string; } export class GraphArgs { @@ -62,6 +66,10 @@ export class GraphArgs { export class UserDetails { contributions: Array; + // Prior contribution windows returned by fetchContributions when ghost + // lines were requested; each entry aligns index-for-index with + // `contributions`. Absent when no ghosts were requested. + ghosts?: Array>; name: string; } @@ -74,17 +82,18 @@ export class Week { contributionDays: Array; } +export class ContributionsCollection { + contributionCalendar: { + totalContributions: number; + weeks: Array; + }; +} + export class ResponseOfApi { + // Each requested window comes back under its own alias (c0 = current + // window, c1.. = ghosts) on the user object. data?: { - user: { - name: string; - contributionsCollection: { - contributionCalendar: { - totalContributions: number; - weeks: Array; - }; - }; - }; + user: { name: string } & { [alias: string]: ContributionsCollection }; }; errors?: Array<{ message: string; type: string }>; } diff --git a/src/styles/graphStyle.ts b/src/styles/graphStyle.ts index df2ef658..0ff0ea32 100644 --- a/src/styles/graphStyle.ts +++ b/src/styles/graphStyle.ts @@ -104,13 +104,24 @@ export const graphStyle = (color: string, line: string, point: string, area: str animation: dash 5s ease-in-out forwards; } + /* Faded prior windows ("ghost" lines) behind the current line + (chicks-net/chicks-projects#130). Fixed grey regardless of theme; + the doubled-class selector out-specifies the .ct-line rule above + for stroke, opacity and width, while the dash-draw animation and + fill:none are inherited. */ + .ct-series-ghost .ct-line { + stroke: #9e9e9e; + stroke-opacity: 0.35; + stroke-width: 2px; + } + .ct-area { stroke: none; fill-opacity: 0.1; } - .ct-series-a .ct-area, - .ct-series-a .ct-slice-pie { + .ct-series-current .ct-area, + .ct-series-current .ct-slice-pie { fill: #${area}; } diff --git a/src/utils.ts b/src/utils.ts index 63725a81..4961e005 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -71,6 +71,17 @@ export class Utilities { } } + // Ghost overlay count, clamped into [0, 6]; anything that isn't a finite + // number (or is below the range) falls back to 0, which is exactly the + // current single-line behavior. + private validateGhosts(ghosts?: string): number { + const g = Math.floor(Number(ghosts)); + if (!Number.isFinite(g)) { + return 0; + } + return Math.min(Math.max(g, 0), 6); + } + private validateDate(date?: string): boolean { const format = 'YYYY-MM-DD'; return moment(date, format, true).isValid(); @@ -140,6 +151,7 @@ export class Utilities { ? Math.min(Math.max(this.queryString.height, 200), 600) : 420, // Custom height implementation from range [200, 600], if not specified use default value - 420 days: isFromValid && isToValid ? days : this.validateDays(this.queryString.days), + ghosts: this.validateGhosts(this.queryString.ghosts), grid: this.queryString.grid === 'false' ? false : true, from, to, @@ -176,7 +188,10 @@ export class Utilities { options.area, options.grid, ); - const getChart = await graph.buildGraph(fetchCalendarData.contributions); + const getChart = await graph.buildGraph( + fetchCalendarData.contributions, + fetchCalendarData.ghosts, + ); return { finalGraph: getChart, header: {