├── .nvmrc ├── .prettierrc ├── test ├── .eslintrc ├── libs │ ├── global-setup.js │ └── global-teardown.js └── utils │ └── date.test.js ├── img └── screenshot.png ├── bin └── git-contrib-calendar.js ├── .npmignore ├── jest.config.js ├── .editorconfig ├── .eslintrc ├── src ├── utils │ ├── errors.js │ ├── cli.js │ └── date.js ├── index.js ├── components │ ├── RepoInfo.js │ ├── CalendarDay.js │ ├── App.js │ └── Calendar.js └── services │ └── git.js ├── .github └── workflows │ └── ci.yml ├── README.md ├── LICENSE ├── .gitignore └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 24.12.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giannisp/git-contrib-calendar/HEAD/img/screenshot.png -------------------------------------------------------------------------------- /test/libs/global-setup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Global setup for all tests. 3 | */ 4 | 5 | module.exports = () => {}; 6 | -------------------------------------------------------------------------------- /test/libs/global-teardown.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Global teardown for all tests. 3 | */ 4 | 5 | module.exports = () => {}; 6 | -------------------------------------------------------------------------------- /bin/git-contrib-calendar.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const importJsx = require('import-jsx'); 4 | 5 | importJsx('../src'); 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | .vscode 3 | img 4 | .editorconfig 5 | .eslintrc 6 | .nvmrc 7 | .prettierrc 8 | jest.config.js 9 | test 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Jest configuration file. 3 | */ 4 | 5 | module.exports = { 6 | testEnvironment: 'node', 7 | globalSetup: './test/libs/global-setup.js', 8 | globalTeardown: './test/libs/global-teardown.js', 9 | roots: ['./test'], 10 | }; 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb", "plugin:prettier/recommended", "prettier"], 3 | "env": { 4 | "node": true 5 | }, 6 | "rules": { 7 | "react/jsx-filename-extension": 0, 8 | "react/require-default-props": 0, 9 | "react/function-component-definition": [ 10 | 2, 11 | { 12 | "namedComponents": "arrow-function", 13 | "unnamedComponents": "arrow-function" 14 | } 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/errors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file App defined errors. 3 | */ 4 | 5 | /* eslint-disable max-classes-per-file */ 6 | 7 | /** 8 | * Generic app error handler extending base Error. 9 | */ 10 | class AppError extends Error { 11 | constructor(message) { 12 | super(message); 13 | 14 | Error.captureStackTrace(this, this.constructor); 15 | this.name = this.constructor.name; 16 | } 17 | } 18 | 19 | /** 20 | * Error for invalid path. 21 | */ 22 | class InvalidPathError extends AppError { 23 | constructor(path) { 24 | super('Invalid path'); 25 | this.data = { path }; 26 | } 27 | } 28 | 29 | module.exports = { 30 | AppError, 31 | InvalidPathError, 32 | }; 33 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file App entrypoint. 3 | */ 4 | 5 | /* eslint-disable no-console */ 6 | 7 | const fs = require('fs'); 8 | const React = require('react'); 9 | const { render } = require('ink'); 10 | const importJsx = require('import-jsx'); 11 | 12 | const { initCli, getCliOptions } = require('./utils/cli'); 13 | 14 | initCli(); 15 | 16 | const { repoPath, year, dateRange, author } = getCliOptions(); 17 | 18 | if (!fs.existsSync(repoPath)) { 19 | console.log(`Invalid path: ${repoPath}`); 20 | 21 | process.exit(); 22 | } 23 | 24 | const App = importJsx('./components/App'); 25 | 26 | render( 27 | , 34 | ); 35 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | name: Test 8 | 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [24] 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup NodeJS ${{ matrix.node-version }} 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | 24 | - name: Setup cache 25 | uses: actions/cache@v4 26 | with: 27 | path: ~/.npm 28 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 29 | restore-keys: | 30 | ${{ runner.os }}-node- 31 | 32 | - name: Install 33 | run: npm ci 34 | 35 | - name: ESLint 36 | run: npm run eslint 37 | 38 | - name: Test 39 | run: npm run test 40 | -------------------------------------------------------------------------------- /src/components/RepoInfo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file RepoInfo component. 3 | */ 4 | 5 | const React = require('react'); 6 | const PropTypes = require('prop-types'); 7 | const { Text, Newline } = require('ink'); 8 | 9 | const { getBranch } = require('../services/git'); 10 | 11 | const RepoInfo = ({ repoPath }) => { 12 | const [branch, setBranch] = React.useState(null); 13 | 14 | React.useEffect(() => { 15 | const fetchBranch = async () => { 16 | const branchData = await getBranch(repoPath); 17 | 18 | setBranch(branchData); 19 | }; 20 | 21 | fetchBranch(); 22 | }, []); 23 | 24 | if (!branch) { 25 | return null; 26 | } 27 | 28 | return ( 29 | 30 | Repository: {repoPath} 31 | 32 | Branch: {branch.current} 33 | 34 | ); 35 | }; 36 | 37 | RepoInfo.propTypes = { 38 | repoPath: PropTypes.string.isRequired, 39 | }; 40 | 41 | module.exports = RepoInfo; 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-contrib-calendar 2 | 3 | ![screenshot](img/screenshot.png) 4 | 5 | This is an experimental git contributions calendar running on the terminal for any local repository. 6 | 7 | The interface is inspired by the famous GitHub profile contributions graph, where each block represents a single day, and the color shade depends on the number of commits for that particular day. 8 | 9 | ## Install 10 | 11 | ``` 12 | npm i -g git-contrib-calendar 13 | ``` 14 | 15 | ## Run for git repository on the current path 16 | 17 | ``` 18 | git-contrib-calendar 19 | ``` 20 | 21 | ## CLI options 22 | 23 | ``` 24 | -p, --path Path to any local git repository (example: -p /path/to/repo) 25 | -a, --author Filter git commits by author (example: -a John) 26 | -y, --year Display git commits for a specific year (example: -y 2018) 27 | -v, --version Display the app version 28 | -h, --help Display help 29 | ``` 30 | 31 | ## Under the hood 32 | 33 | - Node 34 | - Ink 35 | - React 36 | - ESLint 37 | - Prettier 38 | - Commander CLI 39 | -------------------------------------------------------------------------------- /src/components/CalendarDay.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file CalendarDay component. 3 | */ 4 | 5 | const React = require('react'); 6 | const PropTypes = require('prop-types'); 7 | const { Box, Text } = require('ink'); 8 | 9 | /** 10 | * Return the background color for the calendar day box. 11 | * 12 | * @param {Number} commitsCount The commits count. 13 | * 14 | * @return {String} The background color. 15 | */ 16 | const getBackgroundColor = (commitsCount) => { 17 | if (commitsCount === 0) { 18 | return '#d0d0d0'; 19 | } 20 | 21 | if (commitsCount > 0 && commitsCount <= 10) { 22 | return '#00d700'; 23 | } 24 | 25 | if (commitsCount > 10 && commitsCount <= 30) { 26 | return '#00af00'; 27 | } 28 | 29 | return '#005f00'; 30 | }; 31 | 32 | const CalendarDay = ({ commitsCount }) => ( 33 | 34 | {' '} 35 | 36 | ); 37 | 38 | CalendarDay.propTypes = { 39 | commitsCount: PropTypes.number.isRequired, 40 | }; 41 | 42 | module.exports = CalendarDay; 43 | -------------------------------------------------------------------------------- /test/utils/date.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Date util tests. 3 | */ 4 | 5 | const { getCalendarDays, getDateRange } = require('../../src/utils/date'); 6 | 7 | describe('Date util', () => { 8 | it('Should return calendar days for a date range', () => { 9 | const calendarDays = getCalendarDays('2022-01-01', '2022-01-03'); 10 | expect(calendarDays).toHaveLength(3); 11 | expect(calendarDays[0]).toEqual({ 12 | dayIndex: 6, 13 | monthIndex: 0, 14 | year: 2022, 15 | date: '2022-01-01', 16 | }); 17 | expect(calendarDays[1]).toEqual({ 18 | dayIndex: 0, 19 | monthIndex: 0, 20 | year: 2022, 21 | date: '2022-01-02', 22 | }); 23 | expect(calendarDays[2]).toEqual({ 24 | dayIndex: 1, 25 | monthIndex: 0, 26 | year: 2022, 27 | date: '2022-01-03', 28 | }); 29 | }); 30 | 31 | it('Should return a date range for a specific year', () => { 32 | const dateRange = getDateRange(2022); 33 | expect(dateRange.from).toEqual('2022-01-02'); 34 | expect(dateRange.to).toEqual('2022-12-31'); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ioannis Poulakas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/cli.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file CLI utils. 3 | */ 4 | 5 | const path = require('path'); 6 | const { program } = require('commander'); 7 | 8 | const pkg = require('../../package.json'); 9 | const { getDateRange } = require('./date'); 10 | 11 | /** 12 | * Init command-line interface. 13 | */ 14 | exports.initCli = () => { 15 | program 16 | .option( 17 | '-p, --path ', 18 | 'Path to any local git repository (example: -p /path/to/repo)', 19 | process.cwd(), 20 | ) 21 | .option( 22 | '-a, --author ', 23 | 'Filter git commits by author (example: -a John)', 24 | undefined, 25 | ) 26 | .option( 27 | '-y, --year ', 28 | 'Display git commits for a specific year (example: -y 2018)', 29 | undefined, 30 | ); 31 | 32 | program.version(pkg.version, '-v, --version'); 33 | 34 | program.parse(process.argv); 35 | }; 36 | 37 | /** 38 | * Return command-line options. 39 | * 40 | * @return {Object} The options. 41 | */ 42 | exports.getCliOptions = () => { 43 | const options = program.opts(); 44 | 45 | const repoPath = path.resolve(options.path); 46 | const year = options.year ? parseInt(options.year, 10) : undefined; 47 | const dateRange = getDateRange(year); 48 | 49 | return { repoPath, year, dateRange, author: options.author }; 50 | }; 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # VS Code settings 76 | .vscode 77 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file App component. 3 | */ 4 | 5 | const React = require('react'); 6 | const PropTypes = require('prop-types'); 7 | const { Box, Text } = require('ink'); 8 | const importJsx = require('import-jsx'); 9 | 10 | const { isGitRepo } = require('../services/git'); 11 | 12 | const RepoInfo = importJsx('./RepoInfo'); 13 | const Calendar = importJsx('./Calendar'); 14 | 15 | const App = ({ repoPath, dateFrom, dateTo, year, author }) => { 16 | const [isRepo, setIsRepo] = React.useState(); 17 | 18 | React.useEffect(() => { 19 | const fetchIsGitRepo = async () => { 20 | const isRepoResult = await isGitRepo(repoPath); 21 | 22 | setIsRepo(isRepoResult); 23 | }; 24 | 25 | fetchIsGitRepo(); 26 | }, []); 27 | 28 | if (typeof isRepo === 'undefined') { 29 | return null; 30 | } 31 | 32 | if (!isRepo) { 33 | return Not a git repository: {repoPath}; 34 | } 35 | 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 49 | 50 | ); 51 | }; 52 | 53 | App.propTypes = { 54 | repoPath: PropTypes.string.isRequired, 55 | dateFrom: PropTypes.string.isRequired, 56 | dateTo: PropTypes.string.isRequired, 57 | year: PropTypes.number, 58 | author: PropTypes.string, 59 | }; 60 | 61 | module.exports = App; 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-contrib-calendar", 3 | "version": "1.3.0", 4 | "description": "Experimental git contributions calendar for the terminal (built with NodeJS, React and Ink)", 5 | "main": "bin/git-contrib-calendar.js", 6 | "bin": { 7 | "git-contrib-calendar": "bin/git-contrib-calendar.js" 8 | }, 9 | "scripts": { 10 | "start": "node bin/git-contrib-calendar.js", 11 | "eslint": "eslint src", 12 | "test": "jest" 13 | }, 14 | "keywords": [ 15 | "git", 16 | "contributions-calendar", 17 | "cli", 18 | "cli-app", 19 | "ink" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/giannisp/git-contrib-calendar.git" 24 | }, 25 | "author": "Ioannis Poulakas", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/giannisp/git-contrib-calendar/issues" 29 | }, 30 | "homepage": "https://github.com/giannisp/git-contrib-calendar#readme", 31 | "dependencies": { 32 | "commander": "^10.0.1", 33 | "import-jsx": "^4.0.1", 34 | "ink": "^3.2.0", 35 | "lodash.groupby": "^4.6.0", 36 | "lodash.maxby": "^4.6.0", 37 | "lodash.uniqby": "^4.7.0", 38 | "luxon": "^3.7.2", 39 | "prop-types": "^15.8.1", 40 | "react": "^18.3.1", 41 | "simple-git": "^3.30.0" 42 | }, 43 | "devDependencies": { 44 | "@types/jest": "^30.0.0", 45 | "eslint": "^8.56.0", 46 | "eslint-config-airbnb": "^19.0.4", 47 | "eslint-config-airbnb-base": "^15.0.0", 48 | "eslint-config-prettier": "^10.1.8", 49 | "eslint-plugin-import": "^2.32.0", 50 | "eslint-plugin-jsx-a11y": "^6.10.2", 51 | "eslint-plugin-prettier": "^5.5.4", 52 | "eslint-plugin-react": "^7.37.5", 53 | "eslint-plugin-react-hooks": "^4.6.0", 54 | "jest": "^30.2.0", 55 | "prettier": "^3.6.2" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/services/git.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Git service. 3 | */ 4 | 5 | const simpleGit = require('simple-git'); 6 | 7 | const { InvalidPathError } = require('../utils/errors'); 8 | 9 | /** 10 | * Return a git instance. 11 | * 12 | * @param {String} repoPath The local repository path. 13 | * 14 | * @return {Object} The git instance. 15 | */ 16 | const getGit = (repoPath) => { 17 | try { 18 | return simpleGit(repoPath); 19 | } catch (error) { 20 | if (error instanceof simpleGit.GitConstructError) { 21 | throw new InvalidPathError(repoPath); 22 | } 23 | 24 | throw error; 25 | } 26 | }; 27 | 28 | exports.getGit = getGit; 29 | 30 | /** 31 | * Return if path is an actual git repository. 32 | * 33 | * @param {String} repoPath The local repository path. 34 | * 35 | * @return {Promise} If path is a git repository. 36 | */ 37 | const isGitRepo = async (repoPath) => { 38 | const git = getGit(repoPath); 39 | 40 | return git.checkIsRepo(); 41 | }; 42 | 43 | exports.isGitRepo = isGitRepo; 44 | 45 | /** 46 | * Return branch data. 47 | * 48 | * @param {String} repoPath The local repository path. 49 | * @param {Object} options The branch command options. 50 | * 51 | * @return {Promise} The branch data. 52 | */ 53 | const getBranch = async (repoPath, options) => { 54 | const git = getGit(repoPath); 55 | 56 | return git.branch(options); 57 | }; 58 | 59 | exports.getBranch = getBranch; 60 | 61 | /** 62 | * Return log data. 63 | * 64 | * @param {String} repoPath The local repository path. 65 | * @param {Object} options The log command options. 66 | * 67 | * @return {Promise} The log data. 68 | */ 69 | const getLog = async (repoPath, options) => { 70 | const git = getGit(repoPath); 71 | 72 | return git.log(options); 73 | }; 74 | 75 | exports.getLog = getLog; 76 | -------------------------------------------------------------------------------- /src/utils/date.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Date helpers. 3 | */ 4 | 5 | const { DateTime, Interval } = require('luxon'); 6 | 7 | exports.DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 8 | 9 | exports.MONTH_NAMES = [ 10 | 'Jan', 11 | 'Feb', 12 | 'Mar', 13 | 'Apr', 14 | 'May', 15 | 'Jun', 16 | 'Jul', 17 | 'Aug', 18 | 'Sep', 19 | 'Oct', 20 | 'Nov', 21 | 'Dec', 22 | ]; 23 | 24 | const DATE_FORMAT = 'yyyy-MM-dd'; 25 | 26 | /** 27 | * Return the date range for the calendar. 28 | * 29 | * @param {Number|undefined} year The optional year input. 30 | * 31 | * @return {Object} The date range data. 32 | */ 33 | const getDateRange = (year) => { 34 | let dateFrom = null; 35 | let dateTo = null; 36 | 37 | if (year) { 38 | dateFrom = DateTime.fromObject({ year }); 39 | dateTo = dateFrom.endOf('year'); 40 | } else { 41 | const now = DateTime.now(); 42 | dateFrom = now.minus({ years: 1 }); 43 | dateTo = now; 44 | } 45 | 46 | return { 47 | from: dateFrom.endOf('week').toFormat(DATE_FORMAT), 48 | to: dateTo.toFormat(DATE_FORMAT), 49 | }; 50 | }; 51 | 52 | exports.getDateRange = getDateRange; 53 | 54 | /** 55 | * Return calendar days for a specific date range. 56 | * 57 | * @param {String} dateFrom The date from. 58 | * @param {String} dateTo The date to. 59 | * 60 | * @return {Object[]} The day data. 61 | */ 62 | const getCalendarDays = (dateFrom, dateTo) => { 63 | const interval = Interval.fromDateTimes( 64 | DateTime.fromFormat(dateFrom, DATE_FORMAT), 65 | DateTime.fromFormat(dateTo, DATE_FORMAT).plus({ days: 1 }), 66 | ); 67 | 68 | const intervalDays = interval.splitBy({ days: 1 }).map((day) => day.start); 69 | 70 | return intervalDays.map((intervalDay) => ({ 71 | dayIndex: intervalDay.weekday % 7, // convert from 1-7 to 0-6 (0 is Sun) 72 | monthIndex: intervalDay.month - 1, 73 | year: intervalDay.year, 74 | date: intervalDay.toFormat(DATE_FORMAT), 75 | })); 76 | }; 77 | 78 | exports.getCalendarDays = getCalendarDays; 79 | -------------------------------------------------------------------------------- /src/components/Calendar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Calendar component. 3 | */ 4 | 5 | const React = require('react'); 6 | const PropTypes = require('prop-types'); 7 | const { Box, Text, Newline } = require('ink'); 8 | const groupBy = require('lodash.groupby'); 9 | const uniqBy = require('lodash.uniqby'); 10 | const maxBy = require('lodash.maxby'); 11 | const importJsx = require('import-jsx'); 12 | 13 | const { getLog } = require('../services/git'); 14 | const { getCalendarDays, DAY_NAMES, MONTH_NAMES } = require('../utils/date'); 15 | 16 | const CalendarDay = importJsx('./CalendarDay'); 17 | 18 | /** 19 | * Return the months data for display purposes. 20 | * 21 | * @param {Object[]} days The first day for every week displayed. 22 | * 23 | * @return {Object[]} The months data. 24 | */ 25 | const getMonths = (days) => { 26 | return uniqBy( 27 | days, 28 | (calendarDay) => `${calendarDay.year}-${calendarDay.monthIndex}`, 29 | ).map(({ monthIndex, year }) => ({ 30 | monthIndex, 31 | daysCount: days.filter( 32 | (calendarDay) => 33 | calendarDay.monthIndex === monthIndex && calendarDay.year === year, 34 | ).length, 35 | })); 36 | }; 37 | 38 | /** 39 | * Return calendar days data with commits count per day. 40 | * 41 | * @param {Object[]} calendarDays The calendar days data. 42 | * @param {Object[]} commits The commits data. 43 | * 44 | * @return {Object[]} The calendar days data with commit counts. 45 | */ 46 | const getCalendarDaysWithCommitCounts = (calendarDays, commits) => { 47 | const groupedCommits = groupBy(commits, (commit) => 48 | commit.date.substring(0, 10), 49 | ); 50 | 51 | return calendarDays.map((day) => ({ 52 | ...day, 53 | commitsCount: groupedCommits[day.date] 54 | ? groupedCommits[day.date].length 55 | : 0, 56 | })); 57 | }; 58 | 59 | const Calendar = ({ repoPath, dateFrom, dateTo, year, author }) => { 60 | const [commits, setCommits] = React.useState(null); 61 | 62 | React.useEffect(() => { 63 | const fetchCommits = async () => { 64 | const options = { 65 | '--since': dateFrom, 66 | '--until': dateTo, 67 | '--max-parents': '1', // exclude merge commits 68 | }; 69 | 70 | if (author) { 71 | options['--author'] = author; 72 | } 73 | 74 | const logData = await getLog(repoPath, options); 75 | 76 | setCommits(logData.all); 77 | }; 78 | 79 | fetchCommits(); 80 | }, []); 81 | 82 | if (!commits) { 83 | return null; 84 | } 85 | 86 | const calendarDays = getCalendarDays(dateFrom, dateTo); 87 | const days = getCalendarDaysWithCommitCounts(calendarDays, commits); 88 | const groupedDays = groupBy(days, 'dayIndex'); 89 | const months = getMonths(groupedDays[0]); 90 | const maxCommitsDay = maxBy(days, 'commitsCount'); 91 | 92 | return ( 93 | 94 | 95 | {months.map(({ monthIndex }, index) => { 96 | const month = MONTH_NAMES[monthIndex]; 97 | const marginLeft = 98 | index === 0 ? 0 : Math.max(1, months[index - 1].daysCount * 2 - 3); 99 | 100 | return ( 101 | // eslint-disable-next-line react/no-array-index-key 102 | 103 | 104 | {index === 0 && months[0].daysCount === 1 105 | ? month.substring(0, 1) 106 | : month} 107 | 108 | 109 | ); 110 | })} 111 | 112 | 113 | {DAY_NAMES.map((dayName, index) => ( 114 | 115 | 116 | {dayName} 117 | 118 | 119 | {groupedDays[index].map(({ date, commitsCount }) => ( 120 | 121 | ))} 122 | 123 | ))} 124 | 125 | 126 | 127 | Total commits in {year || 'the last year'}: {commits.length} 128 | 129 | Avg commits per day: {(commits.length / days.length).toFixed(2)} 130 | 131 | Max commits on a single day: {maxCommitsDay.commitsCount} 132 | 133 | 134 | 135 | ); 136 | }; 137 | 138 | Calendar.propTypes = { 139 | repoPath: PropTypes.string.isRequired, 140 | dateFrom: PropTypes.string.isRequired, 141 | dateTo: PropTypes.string.isRequired, 142 | year: PropTypes.number, 143 | author: PropTypes.string, 144 | }; 145 | 146 | module.exports = Calendar; 147 | --------------------------------------------------------------------------------