├── LICENSE ├── README.md ├── app.js ├── package.json ├── public ├── .DS_Store ├── css │ └── styles.css └── js │ ├── squares.js │ └── tooltips.js └── views ├── .DS_Store ├── home.ejs └── partials ├── .DS_Store ├── footer.ejs └── header.ejs /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Anne-Laure Le Cunff 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pixel Progress 2 | A web app to display a tracker similar to the GitHub contribution graph, using Node.js and the Google Sheets API. [Read the tutorial](https://medium.com/@anthilemoon/how-to-create-a-tracker-like-the-github-contribution-graph-with-node-js-and-google-sheets-5e915c668c1). 3 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const request = require('request'); 3 | const fs = require('fs'); 4 | const readline = require('readline'); 5 | const {google} = require('googleapis'); 6 | 7 | app = express(); 8 | 9 | app.set('view engine', 'ejs'); 10 | 11 | app.use(express.static('public')); 12 | 13 | app.get("/", function(req, res){ 14 | 15 | // Load client secrets from a local file. 16 | fs.readFile('credentials.json', (err, content) => { 17 | if (err) return console.log('Error loading client secret file:', err); 18 | // Authorize a client with credentials, then call the Google Sheets API. 19 | authorize(JSON.parse(content), pullData); 20 | }); 21 | 22 | function pullData(auth) { 23 | const sheets = google.sheets({version: 'v4', auth}); 24 | sheets.spreadsheets.values.batchGet({ 25 | spreadsheetId: '1E1UruTlU6RhqtPkZq3McgbdnWJMATCBTfdHRk4J_f8E', 26 | ranges: ['2019!A2:A366', '2019!B2:B366', '2019!C2:C366', '2019!D2:D366'] 27 | }, (err, response) => { 28 | if (err) return console.log('The API returned an error: ' + err); 29 | const date = response.data.valueRanges[0].values; 30 | const topic = response.data.valueRanges[1].values; 31 | const time = response.data.valueRanges[2].values; 32 | const level = response.data.valueRanges[3].values; 33 | 34 | const data = [date, topic, time, level]; 35 | 36 | // if (data.length) { 37 | // console.log('Data:'); 38 | // // Print columns A to C, which correspond to indices 0 to 2. 39 | // data.map((row) => { 40 | // console.log(`${row[0]}, ${row[1]}, ${row[2]}`); 41 | // }); 42 | // } else { 43 | // console.log('No data found.'); 44 | // } 45 | 46 | res.render('home', {data: data}); 47 | 48 | }); 49 | } 50 | 51 | }); 52 | 53 | // If modifying these scopes, delete token.json. 54 | const SCOPES = ['https://www.googleapis.com/auth/spreadsheets.readonly']; 55 | // The file token.json stores the user's access and refresh tokens, and is 56 | // created automatically when the authorization flow completes for the first 57 | // time. 58 | const TOKEN_PATH = 'token.json'; 59 | 60 | /** 61 | * Create an OAuth2 client with the given credentials, and then execute the 62 | * given callback function. 63 | * @param {Object} credentials The authorization client credentials. 64 | * @param {function} callback The callback to call with the authorized client. 65 | */ 66 | function authorize(credentials, callback) { 67 | const {client_secret, client_id, redirect_uris} = credentials.installed; 68 | const oAuth2Client = new google.auth.OAuth2( 69 | client_id, client_secret, redirect_uris[0]); 70 | 71 | // Check if we have previously stored a token. 72 | fs.readFile(TOKEN_PATH, (err, token) => { 73 | if (err) return getNewToken(oAuth2Client, callback); 74 | oAuth2Client.setCredentials(JSON.parse(token)); 75 | callback(oAuth2Client); 76 | }); 77 | } 78 | 79 | /** 80 | * Get and store new token after prompting for user authorization, and then 81 | * execute the given callback with the authorized OAuth2 client. 82 | * @param {google.auth.OAuth2} oAuth2Client The OAuth2 client to get token for. 83 | * @param {getEventsCallback} callback The callback for the authorized client. 84 | */ 85 | function getNewToken(oAuth2Client, callback) { 86 | const authUrl = oAuth2Client.generateAuthUrl({ 87 | access_type: 'offline', 88 | scope: SCOPES, 89 | }); 90 | console.log('Authorize this app by visiting this url:', authUrl); 91 | const rl = readline.createInterface({ 92 | input: process.stdin, 93 | output: process.stdout, 94 | }); 95 | rl.question('Enter the code from that page here: ', (code) => { 96 | rl.close(); 97 | oAuth2Client.getToken(code, (err, token) => { 98 | if (err) return console.error('Error while trying to retrieve access token', err); 99 | oAuth2Client.setCredentials(token); 100 | // Store the token to disk for later program executions 101 | fs.writeFile(TOKEN_PATH, JSON.stringify(token), (err) => { 102 | if (err) return console.error(err); 103 | console.log('Token stored to', TOKEN_PATH); 104 | }); 105 | callback(oAuth2Client); 106 | }); 107 | }); 108 | } 109 | 110 | app.listen(process.env.PORT || 3000, function() { 111 | console.log('Server running on port 3000.'); 112 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pixel-progress", 3 | "version": "1.0.0", 4 | "description": "A web page to track your daily progress with pixels, inspired by the GitHub contribution graph.", 5 | "main": "app.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Anne-Laure Le Cunff", 10 | "license": "MIT", 11 | "dependencies": { 12 | "dotenv": "^8.0.0", 13 | "ejs": "^2.6.2", 14 | "express": "^4.17.1", 15 | "googleapis": "^39.2.0", 16 | "readline": "^1.3.0", 17 | "request": "^2.88.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /public/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anthilemoon/pixel-progress/91c4795b54f81baacd02cd0c5d86c778b34e1bed/public/.DS_Store -------------------------------------------------------------------------------- /public/css/styles.css: -------------------------------------------------------------------------------- 1 | /* Author - https://bitsofco.de/github-contribution-graph-css-grid/ */ 2 | 3 | /* Grid-related CSS */ 4 | 5 | :root { 6 | --square-size: 15px; 7 | --square-gap: 5px; 8 | --week-width: calc(var(--square-size) + var(--square-gap)); 9 | } 10 | 11 | .months { grid-area: months; } 12 | .days { grid-area: days; } 13 | .squares { grid-area: squares; } 14 | 15 | .graph { 16 | display: inline-grid; 17 | grid-template-areas: "empty months" 18 | "days squares"; 19 | grid-template-columns: auto 1fr; 20 | grid-gap: 10px; 21 | } 22 | 23 | .months { 24 | display: grid; 25 | grid-template-columns: calc(var(--week-width) * 4) /* Jan */ 26 | calc(var(--week-width) * 4) /* Feb */ 27 | calc(var(--week-width) * 4) /* Mar */ 28 | calc(var(--week-width) * 5) /* Apr */ 29 | calc(var(--week-width) * 4) /* May */ 30 | calc(var(--week-width) * 4) /* Jun */ 31 | calc(var(--week-width) * 5) /* Jul */ 32 | calc(var(--week-width) * 4) /* Aug */ 33 | calc(var(--week-width) * 4) /* Sep */ 34 | calc(var(--week-width) * 5) /* Oct */ 35 | calc(var(--week-width) * 4) /* Nov */ 36 | calc(var(--week-width) * 5) /* Dec */; 37 | } 38 | 39 | .days, 40 | .squares { 41 | display: grid; 42 | grid-gap: var(--square-gap); 43 | grid-template-rows: repeat(7, var(--square-size)); 44 | } 45 | 46 | .squares { 47 | grid-auto-flow: column; 48 | grid-auto-columns: var(--square-size); 49 | } 50 | 51 | 52 | /* Other styling */ 53 | 54 | body { 55 | font-family: Arial; 56 | font-size: 12px; 57 | } 58 | 59 | .title { 60 | font-family: 'Share Tech Mono', monospace, Arial !important; 61 | } 62 | 63 | ul { 64 | list-style-type: none; 65 | } 66 | 67 | .graph { 68 | padding-top: 25px; 69 | padding-right: 20px; 70 | padding-left: 0px; 71 | padding-bottom: 15px; 72 | border: 1px #C9D5FF solid; 73 | margin-top: 20px; 74 | margin-right:5%; 75 | margin-left: 10%; 76 | } 77 | 78 | .days li:nth-child(odd) { 79 | visibility: hidden; 80 | } 81 | 82 | .squares li { 83 | background-color: #D7DDF2; 84 | } 85 | 86 | .squares li[data-level="1"] { 87 | background-color: #577AF9; 88 | } 89 | 90 | .squares li[data-level="2"] { 91 | background-color: #3960EF; 92 | } 93 | 94 | .squares li[data-level="3"] { 95 | background-color: #1B3699; 96 | } 97 | 98 | .bg-blue { 99 | background-color: #3960EF; 100 | } 101 | 102 | /* Tooltip */ 103 | 104 | .tooltip-main { 105 | background: #FFFFFF; 106 | border: 1px solid #0000; 107 | border-radius: 0% !important; 108 | color: #000; 109 | font-family: monospace !important;; 110 | font-size: 9px; 111 | } 112 | 113 | .tooltip-inner { 114 | background: #FFFFFF; 115 | border-radius: 0% !important; 116 | color: rgb(0, 0, 0, .7); 117 | border: 1px solid #D7DDF2; 118 | } 119 | 120 | .tooltip.show { 121 | opacity: 1; 122 | } 123 | 124 | .bs-tooltip-auto[x-placement^=bottom] .arrow::before, 125 | .bs-tooltip-bottom .arrow::before { 126 | border-bottom-color: transparent; 127 | } -------------------------------------------------------------------------------- /public/js/squares.js: -------------------------------------------------------------------------------- 1 | // const squares = document.querySelector('.squares'); 2 | // for (var i = 1; i < 365; i++) { 3 | // const level = Math.floor(Math.random() * 3); 4 | // squares.insertAdjacentHTML('beforeend', `
`); 5 | // } 6 | 7 | // window.addEventListener('DOMContentLoaded', generateSquares, false); 8 | 9 | 10 | const squares = window.document.querySelector('.squares'); 11 | // for (var i = 0; i < 364; i++) { 12 | // const level = data[2][i]; 13 | // squares.insertAdjacentHTML('beforeend', ``);} 14 | 15 | -------------------------------------------------------------------------------- /public/js/tooltips.js: -------------------------------------------------------------------------------- 1 | // initialise tooltips 2 | 3 | $(function () { 4 | $('[data-toggle="tooltip"]').tooltip() 5 | }) -------------------------------------------------------------------------------- /views/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anthilemoon/pixel-progress/91c4795b54f81baacd02cd0c5d86c778b34e1bed/views/.DS_Store -------------------------------------------------------------------------------- /views/home.ejs: -------------------------------------------------------------------------------- 1 | <% include partials/header %> 2 | 3 |