├── .gitignore ├── .prettierrc ├── README.md ├── buildspec.yml ├── docker-compose.yml ├── lerna.json ├── package.json ├── packages ├── stats-lambda │ ├── .browserslistrc │ ├── .env.sample │ ├── .eslintrc.json │ ├── Dockerfile │ ├── Procfile │ ├── config │ │ ├── aws.js │ │ ├── twitter.js │ │ └── youtube.js │ ├── graphql.js │ ├── package-lock.json │ ├── package.json │ ├── resolvers │ │ ├── clients │ │ │ ├── cache.js │ │ │ ├── overall-stats.js │ │ │ ├── simplecast-client.js │ │ │ ├── soundcloud.js │ │ │ ├── twitter.js │ │ │ └── youtube.js │ │ └── index.js │ ├── schema │ │ └── index.js │ ├── server.js │ └── serverless.yml └── stats-pages │ ├── .browserslistrc │ ├── .eslintrc.json │ ├── Dockerfile │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json │ └── src │ ├── App.js │ ├── api │ ├── episode-stats-service.js │ └── overall-compare-service.js │ ├── components │ ├── DashboardView.js │ ├── DashboardView.module.scss │ ├── EpisodesChart.js │ ├── Icons.js │ ├── Icons.module.scss │ ├── Loading.js │ ├── Loading.scss │ ├── Navigation.js │ ├── Navigation.module.scss │ ├── OverallStatsTimeSeries.js │ ├── OverallStatsTimeSeries.module.scss │ ├── OverallValue.js │ ├── OverallValue.scss │ ├── TopBottomNEpisodes.js │ ├── TopBottomNEpisodes.scss │ ├── TopEpisodesChart.js │ ├── TopEpisodesChart.scss │ ├── WhatsUpToday.js │ ├── WhatsUpToday.scss │ ├── tabs │ │ ├── EpisodesTabView.js │ │ ├── OverallValuesTabView.js │ │ ├── OverallValuesTabView.module.scss │ │ └── TotalListensTabView.js │ └── ui │ │ ├── Badge.js │ │ ├── Badge.module.scss │ │ ├── Card.js │ │ ├── Card.module.scss │ │ ├── Divider.js │ │ ├── Divider.module.scss │ │ ├── List.js │ │ ├── List.module.scss │ │ ├── Title.js │ │ ├── Title.module.scss │ │ └── Toggle.scss │ ├── index.js │ ├── index.scss │ ├── queries │ ├── dashboard.query.js │ └── invalidate-cache.mutation.js │ └── serviceWorker.js ├── prbuildspec.yml └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | .vscode/ 25 | 26 | # serverless stuff 27 | .serverless 28 | admin.env 29 | _meta 30 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Codefiction Stats Project 2 | 3 | The project has two different application in this mono-repo project, and using `lerna` to maintain the dependencies and flow. 4 | 5 | ## Bootstrapping the dependencies using Lerna 6 | 7 | Lerna helps you run `yarn` scripts for both projects. Bootstrapping is the first step to install the project dependencies. It will basically run the `yarn install` for both projects. 8 | 9 | ```sh 10 | yarn 11 | yarn bootstrap 12 | ``` 13 | 14 | ## Running both projects 15 | 16 | If you want to run the both projects together using lerna, 17 | 18 | ```sh 19 | yarn start:all 20 | ``` 21 | 22 | This will kick `yarn start` on both projects. But you need to satisfy the environment variable for the lambda project before running the application. See the next sub-section for further details. 23 | 24 | ### Running the graphql application 25 | 26 | Graphql lambda function is designed to make several requests to different services and aggregate the responses into one single HTTP response. The project is using Apollo Server to serve the graphql POST requests. 27 | 28 | In order to succesfully run the full fledged graphql aggregator you need to create a file named `.env` under the `./packages/stats-lambda/` folder. Then put the required environment variables into it. You can checkout the [.env.sample](./packages/stats-lambda/.env.sample) as an example. 29 | 30 | ```env 31 | SECRET=SECRET 32 | YOUTUBE=SECRET 33 | YOUTUBE_KEY=SECRET 34 | TWITTER_CONSUMER_API_KEY=SECRET 35 | TWITTER_CONSUMER_API_SECRET_KEY=SECRET 36 | TWITTER_ACCESS_TOKEN=SECRET 37 | TWITTER_ACCESS_SECRET=SECRET 38 | AMAZON_AWS_ACCESS_KEY=SECRET 39 | AMAZON_AWS_ACCESS_SECRET_KEY=SECRET 40 | ENGINE_API_KEY=SECRET 41 | ``` 42 | 43 | After satifying the environment variables you can simply run the following command in the root folder to start the application. 44 | 45 | ```sh 46 | yarn start 47 | ``` 48 | 49 | ### Running the React pages 50 | 51 | React application is the single page application that displays the results aggregated by the `Graphql Server`. 52 | 53 | In order to run the application using lerna run the following command. 54 | 55 | ```sh 56 | yarn start:react 57 | ``` 58 | 59 | React application is using the [react-scripts](https://www.npmjs.com/package/react-scripts). You can do whatever react-scripts allows you to do. 60 | 61 | ## The deployment of components 62 | 63 | - [Build & Deployment Job for AWS](https://eu-west-1.console.aws.amazon.com/codesuite/codebuild/projects/codefictionStats/history?region=eu-west-1) 64 | - [S3 Bucket](http://stats.codefiction.tech.s3-website-eu-west-1.amazonaws.com) 65 | 66 | - [Build & deployment job for Heroku](https://dashboard.heroku.com/apps/codefiction-stats/) 67 | - [Deployment Url](https://codefiction-stats.herokuapp.com/graphql) 68 | 69 | ## Docker Container 70 | 71 | ### Why docker? 72 | Why not? 73 | 74 | ### Run the application 75 | 76 | Read the `Running the graphql application` section before continue and make sure you have created the `.env` file. 77 | 78 | To run the both containers run the `docker-compose up` command. This will brought up two docker containers for each application. 79 | 80 | #### Maintainers 81 | 82 | - [Mert Susur](https://github.com/msusur) 83 | - [Mustafa Turhan](https://github.com/mustaphaturhan) 84 | 85 | #### Contributers 86 | 87 | - [Deniz Irgin](https://github.com/Blind-Striker) 88 | - [Barış Özaydın](https://github.com/ozaydinb) 89 | - [Uğur Atar](https://github.com/uguratar) 90 | - [TheYkk](https://github.com/TheYkk) -------------------------------------------------------------------------------- /buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | commands: 6 | - npm install yarn -g 7 | - yarn 8 | - yarn bootstrap 9 | pre_build: 10 | commands: 11 | - yarn lint 12 | build: 13 | commands: 14 | - yarn build 15 | post_build: 16 | commands: 17 | # Deploy SPA to S3 and deploy Serverless 18 | - yarn deploy 19 | artifacts: 20 | files: 21 | - '**/*' 22 | base-directory: 'packages/stats-pages/build' 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | stats-api: 5 | ports: 6 | - "4000:4000" 7 | build: 8 | context: ./packages/stats-lambda/. 9 | command: yarn start 10 | stats-pages: 11 | ports: 12 | - "3000:3000" 13 | build: 14 | context: ./packages/stats-pages/. 15 | command: yarn start -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "npmClient": "yarn", 6 | "useWorkspaces": true, 7 | "version": "1.0.0" 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codefiction-stats-graphql", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Codefiction Podcast statistics application using Graphql and React components.", 6 | "main": "index.js", 7 | "scripts": { 8 | "build": "lerna run build --parallel", 9 | "dev": "lerna run dev --parallel", 10 | "deploy": "lerna run deploy", 11 | "start": "lerna run --scope stats-lambda start", 12 | "start:react": "lerna run --scope stats-pages start", 13 | "start:all": "lerna run start", 14 | "test": "lerna run --stream --concurrency 1 test", 15 | "bootstrap": "lerna bootstrap", 16 | "lint": "lerna run lint --parallel", 17 | "lint-autofix": "lerna run lint-autofix --parallel" 18 | }, 19 | "husky": { 20 | "hooks": { 21 | "pre-commit": "npm run lint" 22 | } 23 | }, 24 | "workspaces": [ 25 | "packages/*" 26 | ], 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/msusur/codefiction-stats-graphql.git" 30 | }, 31 | "keywords": [], 32 | "author": "", 33 | "license": "ISC", 34 | "bugs": { 35 | "url": "https://github.com/msusur/codefiction-stats-graphql/issues" 36 | }, 37 | "homepage": "https://codefiction.tech", 38 | "devDependencies": { 39 | "lerna": "^3.13.1", 40 | "husky": "^1.3.1" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/stats-lambda/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 0.2% 2 | not dead 3 | not ie <= 11 4 | not op_mini all -------------------------------------------------------------------------------- /packages/stats-lambda/.env.sample: -------------------------------------------------------------------------------- 1 | SECRET=SECRET 2 | YOUTUBE=SECRET 3 | YOUTUBE_KEY=SECRET 4 | TWITTER_CONSUMER_API_KEY=SECRET 5 | TWITTER_CONSUMER_API_SECRET_KEY=SECRET 6 | TWITTER_ACCESS_TOKEN=SECRET 7 | TWITTER_ACCESS_SECRET=SECRET 8 | AMAZON_AWS_ACCESS_KEY=SECRET 9 | AMAZON_AWS_ACCESS_SECRET_KEY=SECRET 10 | ENGINE_API_KEY=SECRET -------------------------------------------------------------------------------- /packages/stats-lambda/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": ["airbnb-base", "prettier"], 7 | "plugins": ["import", "prettier", "standard"], 8 | "parserOptions": { 9 | "ecmaVersion": 2017 10 | }, 11 | "rules": { 12 | "prettier/prettier": ["error"], 13 | "no-unused-vars": "warn", 14 | "no-console": "off", 15 | "class-methods-use-this": "off" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/stats-lambda/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.10.0-alpine 2 | 3 | RUN mkdir /stats 4 | WORKDIR /stats 5 | 6 | COPY . . 7 | EXPOSE 4000 8 | RUN yarn -------------------------------------------------------------------------------- /packages/stats-lambda/Procfile: -------------------------------------------------------------------------------- 1 | web: node ./packages/stats-lambda/server.js -------------------------------------------------------------------------------- /packages/stats-lambda/config/aws.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | 3 | const SERVICE_NAME = 'dynamodb'; 4 | const REGION = 'eu-west-1'; 5 | const configuration = { 6 | region: REGION, 7 | endpoint: `https://${SERVICE_NAME}.${REGION}.amazonaws.com`, 8 | accessKeyId: process.env.AMAZON_AWS_ACCESS_KEY, 9 | secretAccessKey: process.env.AMAZON_AWS_ACCESS_SECRET_KEY, 10 | }; 11 | 12 | console.log(`DynamoDb endpoint is: ${configuration.endpoint}`); 13 | AWS.config.update(configuration); 14 | module.exports = { 15 | dynamoClient: new AWS.DynamoDB.DocumentClient(), 16 | }; 17 | -------------------------------------------------------------------------------- /packages/stats-lambda/config/twitter.js: -------------------------------------------------------------------------------- 1 | const twitterConfig = { 2 | CONSUMER_API_KEYS: { 3 | API_KEY: process.env.TWITTER_CONSUMER_API_KEY, 4 | API_SECRET_KEY: process.env.TWITTER_CONSUMER_API_SECRET_KEY, 5 | }, 6 | ACCESS_KEYS: { 7 | ACCESS_TOKEN: process.env.TWITTER_ACCESS_TOKEN, 8 | ACCESS_SECRET: process.env.TWITTER_ACCESS_SECRET, 9 | }, 10 | }; 11 | 12 | module.exports = { twitterConfig }; 13 | -------------------------------------------------------------------------------- /packages/stats-lambda/config/youtube.js: -------------------------------------------------------------------------------- 1 | const youtubeConfig = { 2 | web: { 3 | client_id: 4 | '1068620534768-opg46t5vkb5364196u6tf2dkna6dir49.apps.googleusercontent.com', 5 | project_id: 'codefiction-221700', 6 | auth_uri: 'https://accounts.google.com/o/oauth2/auth', 7 | token_uri: 'https://www.googleapis.com/oauth2/v3/token', 8 | auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', 9 | client_secret: process.env.YOUTUBE, 10 | }, 11 | key: process.env.YOUTUBE_KEY, 12 | channel_id: 'UCq3oLmam_8au66Hyw2-BJtg', 13 | }; 14 | 15 | module.exports = { youtubeConfig }; 16 | -------------------------------------------------------------------------------- /packages/stats-lambda/graphql.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server-lambda'); 2 | const resolvers = require('./resolvers/index'); 3 | const typeDefs = require('./schema/index'); 4 | 5 | const server = new ApolloServer({ 6 | typeDefs, 7 | resolvers, 8 | engine: { 9 | apiKey: process.env.ENGINE_API_KEY, 10 | }, 11 | cacheControl: true, 12 | }); 13 | exports.graphqlHandler = server.createHandler({ 14 | cors: { 15 | origin: true, 16 | credentials: true, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /packages/stats-lambda/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stats-lambda", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "start:server": "node server.js", 9 | "deploy": "serverless deploy", 10 | "lint": "eslint --ext .js ./", 11 | "lint-autofix": "eslint --ext .js ./ --fix" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "apollo-boost": "^0.1.20", 18 | "apollo-server-express": "^2.1.0", 19 | "apollo-server-lambda": "^2.4.6", 20 | "aws-sdk": "^2.350.0", 21 | "body-parser": "^1.18.3", 22 | "dotenv": "^6.2.0", 23 | "express": "^4.16.4", 24 | "google-auth-library": "^3.1.1", 25 | "googleapis": "^38.0.0", 26 | "graphql": "^14.1.1", 27 | "graphql-tools": "^4.0.3", 28 | "http-debug": "^0.1.2", 29 | "moment": "^2.29.2", 30 | "node-cache": "^4.2.0", 31 | "node-cache-promise": "^1.0.0", 32 | "numeral": "^2.0.6", 33 | "redis": "^2.8.0", 34 | "redis-commands": "^1.4.0", 35 | "simplecast-api-client": "^2.0.0", 36 | "string-similarity": "^3.0.0", 37 | "then-redis": "^2.0.1", 38 | "twit": "^2.2.11", 39 | "unstated": "^2.1.1", 40 | "youtube-api": "^2.0.10" 41 | }, 42 | "devDependencies": { 43 | "eslint": "^5.15.3", 44 | "eslint-config-airbnb-base": "^13.1.0", 45 | "eslint-config-prettier": "^4.1.0", 46 | "eslint-plugin-import": "^2.14.0", 47 | "eslint-plugin-jsx-a11y": "^6.1.1", 48 | "eslint-plugin-node": "^8.0.1", 49 | "eslint-plugin-prettier": "^3.0.1", 50 | "eslint-plugin-promise": "^4.0.1", 51 | "eslint-plugin-standard": "^4.0.0", 52 | "prettier": "1.15.2", 53 | "serverless": "^1.42.3" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/stats-lambda/resolvers/clients/cache.js: -------------------------------------------------------------------------------- 1 | const NodeCache = require('node-cache-promise'); 2 | const { promisify } = require('util'); 3 | 4 | const redis = require('redis'); 5 | // Time to leave in seconds. 6 | const TTL = 43200; 7 | 8 | class InMemoryCache { 9 | constructor() { 10 | this.cache = new NodeCache({ stdTTL: 43200 }); 11 | console.log('InMemory cache selected.'); 12 | } 13 | 14 | getOrUpdate(key, updateFn) { 15 | return this.cache.get(key).then(value => { 16 | if (value) { 17 | return value; 18 | } 19 | return updateFn().then(response => { 20 | this.cache.set(key, response); 21 | return response; 22 | }); 23 | }); 24 | } 25 | 26 | clearCache() { 27 | return new Promise(resolve => { 28 | this.cache.flushAll(); 29 | resolve({ flushed: true }); 30 | }); 31 | } 32 | } 33 | 34 | class RedisCache { 35 | constructor() { 36 | this.client = redis.createClient(process.env.REDIS_URL); 37 | console.log(`Connecting to redis cluster: ${process.env.REDIS_URL}`); 38 | this.client.on('connect', () => console.log('Connected to redis cluster.')); 39 | this.client.on('error', error => console.log(error)); 40 | } 41 | 42 | getOrUpdate(key, updateFn) { 43 | const getAsync = promisify(this.client.get).bind(this.client); 44 | 45 | return getAsync(key).then(value => { 46 | if (value) { 47 | return JSON.parse(value); 48 | } 49 | return updateFn().then(response => { 50 | this.client.set(key, JSON.stringify(response), 'EX', TTL); 51 | return response; 52 | }); 53 | }); 54 | } 55 | 56 | clearCache() { 57 | return new Promise(resolve => { 58 | resolve({ flushed: this.client.flushall() }); 59 | }); 60 | } 61 | } 62 | 63 | const getCacheInstance = () => { 64 | const cacheTypes = { 65 | inMemory: InMemoryCache, 66 | redis: RedisCache, 67 | }; 68 | const type = process.env.CACHE_TYPE || 'inMemory'; 69 | return new cacheTypes[type](); 70 | }; 71 | 72 | module.exports = { getCacheInstance }; 73 | -------------------------------------------------------------------------------- /packages/stats-lambda/resolvers/clients/overall-stats.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const { dynamoClient } = require('../../config/aws'); 3 | 4 | const OVERALL_STATS_TABLE_NAME = 'codefiction-stats-overall'; 5 | 6 | const fixCharLengthToTwo = number => { 7 | return number < 10 ? `0${number}` : number; 8 | }; 9 | 10 | class OverallStatsClient { 11 | createOverallRecord({ twitter, youtube, podcast }) { 12 | return new Promise((resolve, reject) => { 13 | // The whole idea is to allow creating the data only once for each day. 14 | const now = new Date(); 15 | const day = fixCharLengthToTwo(now.getDate()); 16 | const month = fixCharLengthToTwo(now.getMonth() + 1); 17 | const createdOn = `${day}.${month}.${now.getFullYear()}`; 18 | this.getTodaysOverall(createdOn).then(result => { 19 | if (result.length > 0) { 20 | return resolve(result[0]); 21 | } 22 | const params = { 23 | TableName: OVERALL_STATS_TABLE_NAME, 24 | Item: { 25 | twitter, 26 | youtube, 27 | podcast, 28 | createdOn, 29 | }, 30 | }; 31 | return dynamoClient.put(params, error => { 32 | if (error) { 33 | return reject(error); 34 | } 35 | return resolve({ 36 | twitter, 37 | youtube, 38 | podcast, 39 | createdOn, 40 | }); 41 | }); 42 | }); 43 | }); 44 | } 45 | 46 | getTodaysOverall(today) { 47 | return new Promise((resolve, reject) => { 48 | const params = { 49 | TableName: OVERALL_STATS_TABLE_NAME, 50 | FilterExpression: '#createdOn = :createdOn', 51 | ExpressionAttributeNames: { 52 | '#createdOn': 'createdOn', 53 | }, 54 | ExpressionAttributeValues: { 55 | ':createdOn': today, 56 | }, 57 | }; 58 | return dynamoClient.scan(params, (err, data) => { 59 | if (err) { 60 | return reject(err); 61 | } 62 | return resolve(data.Items); 63 | }); 64 | }); 65 | } 66 | 67 | getOverallRecords() { 68 | return new Promise((resolve, reject) => { 69 | const params = { 70 | TableName: OVERALL_STATS_TABLE_NAME, 71 | }; 72 | return dynamoClient.scan(params, (error, result) => { 73 | if (error) { 74 | return reject(error); 75 | } 76 | const response = []; 77 | result.Items.forEach(item => { 78 | const calcItem = item; 79 | 80 | calcItem.createdOnMoment = moment(item.createdOn, 'DD.MM.YYYY'); 81 | response.push(item); 82 | }); 83 | response.sort((a, b) => { 84 | return b.createdOnMoment.format('X') - a.createdOnMoment.format('X'); 85 | }); 86 | return resolve(response.reverse()); 87 | }); 88 | }); 89 | } 90 | } 91 | 92 | module.exports = OverallStatsClient; 93 | -------------------------------------------------------------------------------- /packages/stats-lambda/resolvers/clients/simplecast-client.js: -------------------------------------------------------------------------------- 1 | const SimpleCastAPIClient = require('simplecast-api-client'); 2 | const { getCacheInstance } = require('./cache'); 3 | 4 | const CACHE_KEYS = { 5 | PODCASTS: 'CACHE:PODCASTS', 6 | EPISODES: 'CACHE:EPISODES::', 7 | OVERALL_PODCAST_STATS: 'CACHE:STATS:PODCAST::', 8 | OVERALL_EPISODE_STATS: 'CACHE:STATS:EPISODE::', 9 | }; 10 | 11 | class SimpleCastClient { 12 | constructor() { 13 | this.client = new SimpleCastAPIClient({ apikey: process.env.SECRET }); 14 | this.cache = getCacheInstance(); 15 | } 16 | 17 | getPodcasts() { 18 | return this.cache.getOrUpdate(CACHE_KEYS.PODCASTS, () => 19 | this.client.podcasts.getPodcasts().then(podcasts => podcasts.collection) 20 | ); 21 | } 22 | 23 | getOverallDownloads(podcastId, orderBy) { 24 | return this.client.podcasts 25 | .getAllDownloadsAnalytics(podcastId) 26 | .then(download => { 27 | const downloadDetails = download; 28 | downloadDetails.by_interval = download.by_interval.sort( 29 | (date1, date2) => { 30 | return orderBy === 'asc' 31 | ? new Date(date1.interval) - new Date(date2.interval) 32 | : new Date(date2.interval) - new Date(date1.interval); 33 | } 34 | ); 35 | return download; 36 | }); 37 | } 38 | 39 | getEpisodes(podcastId) { 40 | return this.cache.getOrUpdate(`${CACHE_KEYS.EPISODES}::${podcastId}`, () => 41 | this.client.episodes 42 | .getEpisodes(podcastId, { limit: 1000 }) 43 | .then(episodes => episodes.collection) 44 | ); 45 | } 46 | 47 | getOverallStats(podcastId) { 48 | return this.cache.getOrUpdate( 49 | `${CACHE_KEYS.OVERALL_PODCAST_STATS}::${podcastId}`, 50 | () => this.client.podcasts.getAllDownloadsAnalytics(podcastId) 51 | ); 52 | } 53 | 54 | getEpisodeStats(episodeId) { 55 | return this.cache.getOrUpdate( 56 | `${CACHE_KEYS.OVERALL_EPISODE_STATS}::${episodeId}`, 57 | () => this.client.episodes.getDownloads(episodeId) 58 | ); 59 | } 60 | 61 | getEpisode(episodeId) { 62 | return this.cache.getOrUpdate(`${CACHE_KEYS.EPISODES}::${episodeId}`, () => 63 | this.client.episodes.getEpisode(episodeId).then(episode => { 64 | return { 65 | waveform_json: episode.waveform_json, 66 | audio_file_url: episode.audio_file_url, 67 | authors: episode.authors.collection, 68 | waveform_pack: episode.waveform_pack, 69 | audio_file_size: episode.audio_file_size, 70 | duration: episode.duration, 71 | episode_url: episode.episode_url, 72 | }; 73 | }) 74 | ); 75 | } 76 | 77 | clearCache() { 78 | return this.cache.clearCache(); 79 | } 80 | } 81 | 82 | module.exports = { SimpleCastClient }; 83 | -------------------------------------------------------------------------------- /packages/stats-lambda/resolvers/clients/soundcloud.js: -------------------------------------------------------------------------------- 1 | const soundCloudScrapedData = [ 2 | { 3 | url: 4 | '/codefiction/sezon-3-kirksekizinci-bolum-legacy-nedir-ve-legacy-sistemler-nasil-degistirilir', 5 | title: 6 | 'Sezon 3 - Kırksekizinci Bölüm - Legacy Nedir ve Legacy Sistemler Nasıl Değiştirilir', 7 | listenCount: 40, 8 | }, 9 | { 10 | url: 11 | '/codefiction/p2p-umut-gokbayrak-kurumsal-vs-startup-ile-fonksiyonel-programlama', 12 | title: 13 | 'P2P - Umut Gökbayrak - Kurumsal vs Startup ile Fonksiyonel Programlama', 14 | listenCount: 169, 15 | }, 16 | { 17 | url: 18 | '/codefiction/p2p-ozan-gumus-oyun-programlama-ve-yeni-mount-and-blade-hakkinda', 19 | title: 'P2P Ozan Gümüş - Oyun programlama ve yeni Mount and Blade hakkında', 20 | listenCount: 209, 21 | }, 22 | { 23 | url: 24 | '/codefiction/sezon-3-kirkyedinci-bolum-yazilim-gelistirme-test-surecleri', 25 | title: 'Sezon 3 - Kirkyedinci Bölüm - Yazılım Geliştirme Test Süreçleri', 26 | listenCount: 272, 27 | }, 28 | { 29 | url: 30 | '/codefiction/p2p-cenk-civici-cto-olmak-trendyolda-muhendislik-kulturu-ve-yazilim-surecleri', 31 | title: 32 | "P2P Cenk Çivici - CTO olmak, Trendyol'da mühendislik kültürü ve yazılım süreçleri", 33 | listenCount: 1021, 34 | }, 35 | { 36 | url: 37 | '/codefiction/p2p-erol-degim-armutcom-girisimcilik-startup-ve-yurt-disina-acilma', 38 | title: 39 | 'P2P Erol Değim - Armut.com, Girişimcilik, Startup ve yurt dışına açılma', 40 | listenCount: 741, 41 | }, 42 | { 43 | url: 44 | '/codefiction/sezon-3-kirkaltinci-bolum-bilgisayar-muhendisligi-front-end-state-management-ve-kadin-yazilimci', 45 | title: 46 | 'Sezon 3 - Kırkaltıncı Bölüm - Bilgisayar mühendisliği, front-end state management ve kadın yazılımcı', 47 | listenCount: 919, 48 | }, 49 | { 50 | url: '/codefiction/sezon-3-kirkbesinci-bolum-defensive-programming', 51 | title: 'Sezon 3 - Kırkbeşinci Bölüm - Defensive programming', 52 | listenCount: 678, 53 | }, 54 | { 55 | url: '/codefiction/sezon-2-kirkdorduncu-bolum-stajyerlik-muessesesi', 56 | title: 'Sezon 2 - Kırkdördüncü Bölüm - Stajyerlik Müessesesi', 57 | listenCount: 1174, 58 | }, 59 | { 60 | url: 61 | '/codefiction/sezon-2-kirkucuncu-bolum-data-scientist-nedir-ne-is-yapar', 62 | title: 'Sezon 2 - Kırküçüncü Bölüm - Data Scientist Nedir? Ne iş Yapar?', 63 | listenCount: 963, 64 | }, 65 | { 66 | url: '/codefiction/sezon-2-kirkikinci-bolum-burnout', 67 | title: 'Sezon 2 - Kırkikinci Bölüm - Burnout', 68 | listenCount: 1035, 69 | }, 70 | { 71 | url: '/codefiction/p2p-aysegul-yonet-angular-dunyasinda-neler-oluyor', 72 | title: 'P2P - Ayşegül Yönet - Angular dünyasında neler oluyor?', 73 | listenCount: 1481, 74 | }, 75 | { 76 | url: '/codefiction/sezon-2-kirkucuncu-bolum-kanban-vs-scrum', 77 | title: 'Sezon 2 - Kırkbirinci Bölüm - Kanban vs Scrum', 78 | listenCount: 1462, 79 | }, 80 | { 81 | url: '/codefiction/sezon-2-kirkinci-bolum-sessiz-yazilimci', 82 | title: 'Sezon 2 - Kırkıncı Bölüm - Sessiz Yazılımcı', 83 | listenCount: 1280, 84 | }, 85 | { 86 | url: '/codefiction/uctan-uca-yazilim-gelistirme-yasam-dongusu', 87 | title: 88 | 'Sezon 2 - Otuzdokuzuncu Bölüm - Uçtan uca yazılım geliştirme yaşam döngüsü', 89 | listenCount: 1478, 90 | }, 91 | { 92 | url: 93 | '/codefiction/sezon-2-otuzsekizinci-bolum-yazilim-firmalarinin-egitim-politikasi-nasil-olmalidir', 94 | title: 95 | 'Sezon 2 - Otuzsekizinci Bölüm - Yazılım firmalarının eğitim politikası nasıl olmalıdır?', 96 | listenCount: 948, 97 | }, 98 | { 99 | url: 100 | '/codefiction/sezon-2-otuzyedinci-bolum-stack-overflow-survey-2018-sonuclari-ve-yazilimci-sikayetleri', 101 | title: 102 | 'Sezon 2 - Otuzyedinci Bölüm - Stack Overflow Survey 2018 Sonuçları ve Yazılımcı Şikayetleri', 103 | listenCount: 1053, 104 | }, 105 | { 106 | url: '/codefiction/ozel-yayin-facebook-skandali', 107 | title: 'Özel Yayın - Facebook Skandalı', 108 | listenCount: 820, 109 | }, 110 | { 111 | url: 112 | '/codefiction/sezon-2-otuzaltinci-bolum-muhendislik-organizasyonlarinin-olceklenmesi-turkce', 113 | title: 114 | 'Sezon 2 - Otuzaltıncı Bölüm - Mühendislik organizasyonlarının ölçeklenmesi', 115 | listenCount: 806, 116 | }, 117 | { 118 | url: 119 | '/codefiction/sezon-2-otuzbesinci-bolum-operasyonel-projeler-ve-yazilimin-urun-oldugu-projelerde-yazilim', 120 | title: 121 | 'Sezon 2 - Otuzbeşinci Bölüm - Operasyonel projeler ve Yazılımın ürün olduğu projelerde yazılım', 122 | listenCount: 1141, 123 | }, 124 | { 125 | url: 126 | '/codefiction/p2p-bilgem-cakir-yalin-kod-ve-engineering-manager-ne-is-yapar', 127 | title: 'P2P Bilgem Çakır - Yalın Kod ve Engineering Manager ne iş yapar?', 128 | listenCount: 2462, 129 | }, 130 | { 131 | url: 132 | '/codefiction/sezon-2-otuzdorduncu-bolum-ab-testing-nedir-nerelerde-kullanilmali', 133 | title: 134 | 'Sezon 2 - Otuzdördüncü Bölüm - A/B Testing Nedir? Nerelerde Kullanılmalı?', 135 | listenCount: 1384, 136 | }, 137 | { 138 | url: 139 | '/codefiction/sezon-2-otuzucuncu-bolum-firmanizi-buluta-nasil-tasirsiniz', 140 | title: 'Sezon 2 - Otuzüçüncü Bölüm - Firmanızı buluta nasıl taşırsınız?', 141 | listenCount: 1143, 142 | }, 143 | { 144 | url: '/codefiction/otuzikinci-bolum-sezon-finali-ve-net-core', 145 | title: 'Otuzikinci Bölüm - Sezon Finali ve .Net Core', 146 | listenCount: 2135, 147 | }, 148 | { 149 | url: '/codefiction/otuzbirinci-bolum-guvenli-yazilim-gelistirme-surecleri', 150 | title: 'Otuzbirinci Bölüm - Güvenli Yazılım Geliştirme Süreçleri', 151 | listenCount: 1428, 152 | }, 153 | { 154 | url: 155 | '/codefiction/otuzuncu-bolum-gamification-turkiyede-bilgisayar-muhendisligi-ve-akademisyenlik', 156 | title: 157 | "Otuzuncu Bölüm - Gamification, Türkiye'de Bilgisayar Mühendisliği ve Akademisyenlik", 158 | listenCount: 1386, 159 | }, 160 | { 161 | url: '/codefiction/yirmidokuzuncu-bolum-yazilim-egitimi-nasil-secilir', 162 | title: 'Yirmidokuzuncu Bölüm - Yazılım Eğitimi Nasıl Seçilir?', 163 | listenCount: 1378, 164 | }, 165 | { 166 | url: 167 | '/codefiction/yirmisekizinci-bolum-daha-cok-aws-lambda-ve-daha-cok-ethereum', 168 | title: 'Yirmisekizinci Bölüm - Daha çok AWS Lambda ve daha çok Ethereum', 169 | listenCount: 1799, 170 | }, 171 | { 172 | url: '/codefiction/yirmiyedinci-bolum-product-manager-ne-is-yapar', 173 | title: 'Yirmiyedinci Bölüm - Product Manager Ne İş Yapar?', 174 | listenCount: 1378, 175 | }, 176 | { 177 | url: '/codefiction/yirmialtinci-bolum-aws-ve-front-end-altyapilari', 178 | title: 'Yirmialtıncı Bölüm - AWS ve Front-end Altyapıları', 179 | listenCount: 1158, 180 | }, 181 | { 182 | url: 183 | '/codefiction/yirmibesinci-bolum-http2-yazilimcinin-yobazlasmasi-ve-biraz-da-ethereum', 184 | title: 185 | 'Yirmibeşinci Bölüm - Http2, Yazılımcının Yobazlaşması ve biraz da Ethereum', 186 | listenCount: 1590, 187 | }, 188 | { 189 | url: 190 | '/codefiction/codefiction-yirmidorduncu-bolum-nasil-devops-deneyimi-kazaniriz', 191 | title: 'Yirmidördüncü Bölüm - Nasıl Devops Deneyimi Kazanırız', 192 | listenCount: 1172, 193 | }, 194 | { 195 | url: '/codefiction/yirmiucuncu-bolum-paket-yonetimi-ve-teknoloji-secimi', 196 | title: 'Yirmiüçüncü Bölüm - Paket Yönetimi ve Teknoloji Seçimi', 197 | listenCount: 1952, 198 | }, 199 | { 200 | url: '/codefiction/yirmiikinci-bolum-google-io-serverless-onceliklendirme', 201 | title: 'Yirmiikinci Bölüm - Google IO, Serverless, Önceliklendirme', 202 | listenCount: 1863, 203 | }, 204 | { 205 | url: '/codefiction/yirminci-bolum-sansurler-ve-yasaklar', 206 | title: 'Yirminci Bölüm - Sansürler ve Yasaklar', 207 | listenCount: 829, 208 | }, 209 | { 210 | url: '/codefiction/yirmibirinci-bolum-yazilim-gelistirme-dongusu', 211 | title: 'Yirmibirinci Bölüm - Yazılım Geliştirme Döngüsü', 212 | listenCount: 1497, 213 | }, 214 | { 215 | url: 216 | '/codefiction/onsekizinci-bolum-yazilimcilar-icin-zihin-sagligi-ve-zaman-yonetimi', 217 | title: 218 | 'Onsekizinci Bolum - Yazilimcilar icin zihin sagligi ve zaman yonetimi', 219 | listenCount: 1507, 220 | }, 221 | { 222 | url: '/codefiction/onyedinci-bolum-guvenlik', 223 | title: 'Onyedinci Bolum - Guvenlik', 224 | listenCount: 926, 225 | }, 226 | { 227 | url: '/codefiction/onaltinci-bolum-canli-sistemlerin-bakimi', 228 | title: 'Onaltinci Bolum - Canli Sistemlerin Bakimi', 229 | listenCount: 919, 230 | }, 231 | { 232 | url: '/codefiction/onbesinci-bolum-freelance-calismak', 233 | title: 'Onbesinci Bolum - Freelance Calismak ve CV Hazirlamak', 234 | listenCount: 1370, 235 | }, 236 | { 237 | url: '/codefiction/ondorduncu-bolum-algoritmalar-ve-is-gorusmeleri', 238 | title: 'Ondorduncu Bolum - Algoritmalar ve is gorusmeleri', 239 | listenCount: 1330, 240 | }, 241 | { 242 | url: '/codefiction/onucuncu-bolum-codefiction-ve-komunite-degerleri', 243 | title: 'Onucuncu Bolum - Codefiction ve Komunite Degerleri', 244 | listenCount: 868, 245 | }, 246 | { 247 | url: '/codefiction/p2p-sinan-ata-uzaktan-calisma-modeli-ve-crossover', 248 | title: 'P2P - Sinan Ata Uzaktan Calisma Modeli ve Crossover', 249 | listenCount: 2443, 250 | }, 251 | { 252 | url: '/codefiction/onikinci-bolum-teknolojilere-nasil-karar-vermemeliyiz', 253 | title: 'Onikinci Bölüm - Teknolojilere Nasil Karar Vermemeliyiz', 254 | listenCount: 941, 255 | }, 256 | { 257 | url: '/codefiction/onbirinci-bolum-firmani-nasil-degistirebilirsin', 258 | title: 'Onbirinci Bolum - Firmani Nasil Degistirebilirsin?', 259 | listenCount: 708, 260 | }, 261 | { 262 | url: 263 | '/codefiction/onuncu-bolum-onuncu-bolum-olceklenebilirlik-ve-yazilim-mimarlari', 264 | title: 'Onuncu Bolum - Olceklenebilirlik ve Yazilim Mimarlari', 265 | listenCount: 781, 266 | }, 267 | { 268 | url: '/codefiction/codefiction-dokuzuncu-bolum-rest-api-ve-tasarimi', 269 | title: 'Dokuzuncu Bolum - REST API ve Tasarimi', 270 | listenCount: 1143, 271 | }, 272 | { 273 | url: 274 | '/codefiction/p2p-mert-topcu-google-yazilim-kulturu-yapay-zeka-ve-machine-learning', 275 | title: 276 | 'P2P Mert Topcu - Google Yazilim Kulturu, Yapay Zeka, Chatbotlar ve Machine Learning', 277 | listenCount: 1461, 278 | }, 279 | { 280 | url: '/codefiction/p2p-ebru-meric-akgul', 281 | title: 282 | "P2P Ebru Meriç Akgül - Türkiye ve Avrupa'da yazlım kültürü ve işe alım", 283 | listenCount: 1361, 284 | }, 285 | { 286 | url: '/codefiction/sekizinci-bolum-pair-programming-ve-kod-kalitesi', 287 | title: 'Sekizinci Bolum - Pair Programming ve Kod Kalitesi', 288 | listenCount: 896, 289 | }, 290 | { 291 | url: '/codefiction/codefiction-p2p-burak-selim-senyurt', 292 | title: 'P2P Burak Selim Şenyurt - Yazılım Zanaatı', 293 | listenCount: 1294, 294 | }, 295 | { 296 | url: '/codefiction/codefiction-yedinci-bolum-kod-standartlari', 297 | title: 'Yedinci Bolum - Kod Standartlari', 298 | listenCount: 1034, 299 | }, 300 | { 301 | url: '/codefiction/codefiction-p2p-birinci-bolum', 302 | title: 'P2P İbrahim Gündüz - Yazılım Mimarlığı ve Docker', 303 | listenCount: 1142, 304 | }, 305 | { 306 | url: '/codefiction/codefiction-altinci-bolum-uygulama-testleri', 307 | title: 'Altinci Bolum - Uygulama Testleri', 308 | listenCount: 1071, 309 | }, 310 | { 311 | url: 312 | '/codefiction/codefiction-besinci-bolum-cevik-yazilim-gelistirme-yontemleri', 313 | title: 'Besinci Bolum - Cevik Yazilim Gelistirme Yontemleri', 314 | listenCount: 1223, 315 | }, 316 | { 317 | url: '/codefiction/microsoft-connect-ozel-yayini', 318 | title: 'Microsoft Connect 2017 Ozel Yayini', 319 | listenCount: 805, 320 | }, 321 | { 322 | url: '/codefiction/codefiction-dorduncu-bolum-continuous-integration', 323 | title: 'Dorduncu Bolum - Continuous Integration', 324 | listenCount: 1257, 325 | }, 326 | { 327 | url: '/codefiction/codefiction-podcast-ucuncu-bolum', 328 | title: 'Ucuncu Bolum - Javascript Kutuphaneleri', 329 | listenCount: 1743, 330 | }, 331 | { 332 | url: '/codefiction/codefiction-podcast-ikinci-bolum', 333 | title: 'Ikinci Bolum - Microservices', 334 | listenCount: 1869, 335 | }, 336 | { 337 | url: '/codefiction/codefiction-podcast-birinci-bolum', 338 | title: 'Birinci Bolum - Dotnet Core', 339 | listenCount: 3859, 340 | }, 341 | ]; 342 | 343 | const allTimeListeningCount = () => { 344 | let total = 0; 345 | soundCloudScrapedData.forEach(item => { 346 | total += item.listenCount; 347 | }); 348 | return total; 349 | }; 350 | 351 | module.exports = { soundCloudScrapedData, allTimeListeningCount }; 352 | -------------------------------------------------------------------------------- /packages/stats-lambda/resolvers/clients/twitter.js: -------------------------------------------------------------------------------- 1 | const Twit = require('twit'); 2 | const { twitterConfig } = require('../../config/twitter'); 3 | 4 | class TwitterClient { 5 | constructor() { 6 | this.twit = new Twit({ 7 | consumer_key: twitterConfig.CONSUMER_API_KEYS.API_KEY, 8 | consumer_secret: twitterConfig.CONSUMER_API_KEYS.API_SECRET_KEY, 9 | access_token: twitterConfig.ACCESS_KEYS.ACCESS_TOKEN, 10 | access_token_secret: twitterConfig.ACCESS_KEYS.ACCESS_SECRET, 11 | }); 12 | } 13 | 14 | getFollowers() { 15 | const tweets = this.twit 16 | .get('followers/ids', { 17 | screen_name: 'codefictiontech', 18 | }) 19 | .then(response => { 20 | return { 21 | followersCount: response.data.ids.length, 22 | }; 23 | }) 24 | .catch(err => { 25 | throw err; 26 | }); 27 | 28 | return tweets; 29 | } 30 | } 31 | 32 | module.exports = TwitterClient; 33 | -------------------------------------------------------------------------------- /packages/stats-lambda/resolvers/clients/youtube.js: -------------------------------------------------------------------------------- 1 | const { google } = require('googleapis'); 2 | const { youtubeConfig } = require('../../config/youtube'); 3 | 4 | class YoutubeClient { 5 | constructor() { 6 | this.auth = google.auth.fromAPIKey(youtubeConfig.key); 7 | } 8 | 9 | getChannel() { 10 | return google 11 | .youtube('v3') 12 | .channels.list({ 13 | part: 'statistics', 14 | key: youtubeConfig.key, 15 | id: youtubeConfig.channel_id, 16 | }) 17 | .then(channels => { 18 | return channels.data.items[0]; 19 | }); 20 | } 21 | 22 | getVideos(channelId, maxCount) { 23 | return google 24 | .youtube('v3') 25 | .channels.list({ 26 | part: 'contentDetails', 27 | key: youtubeConfig.key, 28 | id: channelId, 29 | }) 30 | .then(channels => { 31 | const playlistId = 32 | channels.data.items[0].contentDetails.relatedPlaylists; 33 | return this.getPlaylistItems(playlistId, maxCount); 34 | }); 35 | } 36 | 37 | getPlaylistItems(playlistId, maxCount, nextPageToken) { 38 | return google 39 | .youtube('v3') 40 | .playlistItems.list({ 41 | part: 'snippet', 42 | playlistId: playlistId.uploads, 43 | key: youtubeConfig.key, 44 | maxResults: maxCount, 45 | pageToken: nextPageToken, 46 | }) 47 | .then(playlist => { 48 | if (playlist.data.nextPageToken) { 49 | return this.getPlaylistItems( 50 | playlistId, 51 | maxCount, 52 | playlist.data.nextPageToken 53 | ).then(items => playlist.data.items.concat(items)); 54 | } 55 | return playlist.data.items; 56 | }) 57 | .catch(err => { 58 | throw err; 59 | }); 60 | } 61 | 62 | getVideoStats(videoId) { 63 | return google 64 | .youtube('v3') 65 | .videos.list({ part: 'statistics', key: youtubeConfig.key, id: videoId }) 66 | .then(video => { 67 | return video.data.items[0].statistics; 68 | }) 69 | .catch(ex => { 70 | throw ex; 71 | }); 72 | } 73 | } 74 | 75 | module.exports = YoutubeClient; 76 | -------------------------------------------------------------------------------- /packages/stats-lambda/resolvers/index.js: -------------------------------------------------------------------------------- 1 | const stringSimilarity = require('string-similarity'); 2 | const { SimpleCastClient } = require('./clients/simplecast-client'); 3 | const YoutubeClient = require('./clients/youtube'); 4 | const TwitterClient = require('./clients/twitter'); 5 | const { 6 | soundCloudScrapedData, 7 | allTimeListeningCount, 8 | } = require('./clients/soundcloud'); 9 | const OverallStatsClient = require('./clients/overall-stats'); 10 | 11 | const simpleCastClient = new SimpleCastClient(); 12 | const youtube = new YoutubeClient(); 13 | const twitter = new TwitterClient(); 14 | const overallClient = new OverallStatsClient(); 15 | 16 | const query = { 17 | RootQuery: { 18 | podcasts() { 19 | return simpleCastClient.getPodcasts(); 20 | }, 21 | youtube() { 22 | return youtube.getChannel(); 23 | }, 24 | twitter() { 25 | return twitter.getFollowers(); 26 | }, 27 | overallTimeSeries() { 28 | return overallClient.getOverallRecords(); 29 | }, 30 | }, 31 | Podcast: { 32 | episodes(podcast, { title }) { 33 | return simpleCastClient 34 | .getEpisodes(podcast.id) 35 | .then(episodes => 36 | !title 37 | ? episodes 38 | : episodes.filter( 39 | episode => 40 | episode.title.toLowerCase().indexOf(title.toLowerCase()) > -1 41 | ) 42 | ); 43 | }, 44 | downloads(podcast, { orderBy }) { 45 | return simpleCastClient.getOverallDownloads( 46 | podcast.id, 47 | orderBy || 'desc' 48 | ); 49 | }, 50 | numberOfEpisodes(podcast) { 51 | return podcast.episodes.count; 52 | }, 53 | overallStats(podcast) { 54 | return simpleCastClient 55 | .getOverallStats(podcast.id) 56 | .then(podcastResult => { 57 | return { total_listens: podcastResult.total }; 58 | }) 59 | .then(result => { 60 | const calcResult = result; 61 | 62 | calcResult.total_listens += allTimeListeningCount(); 63 | return calcResult; 64 | }); 65 | }, 66 | }, 67 | Episode: { 68 | downloads(episode) { 69 | // Temporary solution until the bug on simplecast resolved. 70 | if (episode.id === '92227acd-be24-4560-98e5-6ad2b21710e5') { 71 | return {}; 72 | } 73 | return simpleCastClient.getEpisodeStats(episode.id).then(stats => { 74 | const calcStats = stats; 75 | soundCloudScrapedData.map(item => { 76 | const similarity = 77 | stringSimilarity.compareTwoStrings(item.title, episode.title) * 100; 78 | if (similarity > 80) { 79 | calcStats.total += item.listenCount; 80 | return false; 81 | } 82 | return true; 83 | }); 84 | return calcStats; 85 | }); 86 | }, 87 | details(episode) { 88 | return simpleCastClient.getEpisode(episode.id); 89 | }, 90 | }, 91 | YoutubeChannel: { 92 | videos(channel, { maxCount }) { 93 | return youtube.getVideos(channel.id, maxCount || 50); 94 | }, 95 | }, 96 | Video: { 97 | statistics(video) { 98 | return youtube.getVideoStats(video.snippet.resourceId.videoId); 99 | }, 100 | }, 101 | Mutation: { 102 | createDailyOverallRecord( 103 | parent, 104 | { podcastOverall, twitterOverall, youtubeOverall } 105 | ) { 106 | return overallClient.createOverallRecord({ 107 | twitter: twitterOverall, 108 | youtube: youtubeOverall, 109 | podcast: podcastOverall, 110 | }); 111 | }, 112 | invalidateCache() { 113 | simpleCastClient.clearCache(); 114 | return query.RootQuery; 115 | }, 116 | }, 117 | }; 118 | 119 | module.exports = query; 120 | -------------------------------------------------------------------------------- /packages/stats-lambda/schema/index.js: -------------------------------------------------------------------------------- 1 | const { gql } = require('apollo-server-lambda'); 2 | 3 | const schema = gql` 4 | type RootQuery { 5 | podcasts: [Podcast] 6 | youtube: YoutubeChannel 7 | twitter: TwitterProfile 8 | overallTimeSeries: [OverallStats] 9 | } 10 | 11 | enum OrderBy { 12 | desc 13 | asc 14 | } 15 | 16 | ####################### 17 | ## Video Schema 18 | ####################### 19 | type YoutubeChannel { 20 | etag: String 21 | id: String 22 | kind: String 23 | statistics: ChannelStats 24 | videos(maxCount: Int): [Video] 25 | } 26 | 27 | type ChannelStats { 28 | commentCount: String 29 | subscriberCount: String 30 | hiddenSubscriberCount: Boolean 31 | videoCount: String 32 | viewCount: String 33 | } 34 | 35 | type Video { 36 | id: String 37 | kind: String 38 | etag: String 39 | snippet: VideoSnippet 40 | statistics: VideoStats 41 | } 42 | 43 | type VideoSnippet { 44 | publishedAt: String 45 | channelId: String 46 | title: String 47 | description: String 48 | thumbnails: VideoThumb 49 | channelTitle: String 50 | playlistId: String 51 | resourceId: VideoResource 52 | } 53 | 54 | type VideoResource { 55 | kind: String 56 | videoId: String 57 | } 58 | 59 | type VideoThumb { 60 | default: VideoImage 61 | medium: VideoImage 62 | high: VideoImage 63 | standard: VideoImage 64 | maxres: VideoImage 65 | } 66 | 67 | type VideoImage { 68 | url: String 69 | width: Int 70 | height: Int 71 | } 72 | 73 | type VideoStats { 74 | commentCount: String 75 | dislikeCount: String 76 | favoriteCount: String 77 | likeCount: String 78 | viewCount: String 79 | } 80 | 81 | ####################### 82 | ## Podcast Schema 83 | ####################### 84 | type Podcast { 85 | id: String! 86 | title: String 87 | href: String 88 | status: String 89 | image_url: String 90 | numberOfEpisodes: Int 91 | episodes(title: String): [Episode] 92 | downloads(orderBy: OrderBy): Downloads 93 | overallStats: PodcastStats 94 | } 95 | 96 | type PodcastStats { 97 | total_listens: Int 98 | } 99 | 100 | type Episode { 101 | id: String! 102 | title: String 103 | updated_at: String 104 | token: String 105 | status: String 106 | season: Season 107 | scheduled_for: String 108 | published_at: String 109 | number: Int 110 | image_url: String 111 | image_path: String 112 | href: String 113 | guid: String 114 | enclosure_url: String 115 | description: String 116 | downloads(orderBy: OrderBy): Downloads 117 | countries: [CountryStats] 118 | details: PodcastDetail 119 | ## This is missing in the client 120 | ## operating systems, providers, network types, devices, device class, browsers, applications. 121 | ## technologies: [TechnologyStats] 122 | } 123 | 124 | type Season { 125 | href: String 126 | number: Int 127 | } 128 | 129 | type PodcastDetail { 130 | waveform_json: String 131 | audio_file_url: String 132 | authors: [Authors] 133 | waveform_pack: String 134 | audio_file_size: Int 135 | duration: Int 136 | episode_url: String 137 | } 138 | 139 | type Authors { 140 | name: String 141 | } 142 | 143 | type Downloads { 144 | id: String 145 | total: Int 146 | interval: String # TODO: Enum? (day, week, month, year) 147 | by_interval: [Interval] 148 | } 149 | 150 | type Interval { 151 | interval: String 152 | downloads_total: Int 153 | downloads_percent: Float 154 | } 155 | 156 | type CountryStats { 157 | rank: Int 158 | name: String 159 | id: Int 160 | downloads_total: Int 161 | downloads_percent: Float 162 | } 163 | 164 | ####################### 165 | ## Twitter Schema 166 | ####################### 167 | type TwitterProfile { 168 | followersCount: Int 169 | } 170 | 171 | type Mutation { 172 | createDailyOverallRecord( 173 | podcastOverall: Int! 174 | twitterOverall: Int! 175 | youtubeOverall: Int! 176 | ): OverallStats! 177 | invalidateCache: RootQuery 178 | } 179 | 180 | type OverallStats { 181 | twitter: Int 182 | youtube: Int 183 | podcast: Int 184 | createdOn: String 185 | } 186 | 187 | schema { 188 | query: RootQuery 189 | mutation: Mutation 190 | } 191 | `; 192 | 193 | module.exports = schema; 194 | -------------------------------------------------------------------------------- /packages/stats-lambda/server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * This server is only for local development 4 | * Actual server implementation is in graphql.js 5 | * 6 | */ 7 | 8 | // Read the local environment variables from .env file. 9 | require('dotenv').config(); 10 | const express = require('express'); 11 | const { ApolloServer } = require('apollo-server-express'); 12 | 13 | const resolvers = require('./resolvers/index'); 14 | const typeDefs = require('./schema/index'); 15 | 16 | const server = new ApolloServer({ 17 | typeDefs, 18 | resolvers, 19 | introspection: true, 20 | engine: { 21 | apiKey: process.env.ENGINE_API_KEY, 22 | }, 23 | }); 24 | 25 | const app = express(); 26 | app.set('port', process.env.PORT || 4000); 27 | server.applyMiddleware({ app }); 28 | app.use('/', express.static('build')); 29 | app.listen(app.get('port'), () => 30 | console.log( 31 | `Server ready at http://localhost:${app.get('port')}${server.graphqlPath}` 32 | ) 33 | ); 34 | -------------------------------------------------------------------------------- /packages/stats-lambda/serverless.yml: -------------------------------------------------------------------------------- 1 | service: apollo-lambda 2 | provider: 3 | name: aws 4 | runtime: nodejs8.10 5 | region: eu-west-1 6 | functions: 7 | graphql: 8 | # this is formatted as . 9 | handler: graphql.graphqlHandler 10 | events: 11 | - http: 12 | path: graphql 13 | method: post 14 | cors: true -------------------------------------------------------------------------------- /packages/stats-pages/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 0.2% 2 | not dead 3 | not ie <= 11 4 | not op_mini all -------------------------------------------------------------------------------- /packages/stats-pages/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": ["airbnb", "plugin:prettier/recommended", "prettier/react"], 7 | "globals": { 8 | "Atomics": "readonly", 9 | "SharedArrayBuffer": "readonly" 10 | }, 11 | "parser": "babel-eslint", 12 | "parserOptions": { 13 | "ecmaVersion": 2017, 14 | "sourceType": "module", 15 | "ecmaFeatures": { 16 | "jsx": true, 17 | "modules": true 18 | } 19 | }, 20 | "plugins": ["react", "prettier"], 21 | "rules": { 22 | "prettier/prettier": ["error"], 23 | "no-unused-vars": "warn", 24 | "class-methods-use-this": "off", 25 | "import/no-named-as-default": 0, 26 | "react/prop-types": 0, 27 | "react/jsx-filename-extension": 0, 28 | "no-console": 0 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/stats-pages/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.10.0-alpine 2 | 3 | RUN mkdir /stats 4 | WORKDIR /stats 5 | 6 | COPY . . 7 | EXPOSE 3000 8 | RUN yarn -------------------------------------------------------------------------------- /packages/stats-pages/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stats-pages", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "react-scripts start", 8 | "build": "react-scripts build", 9 | "deploy": "aws s3 cp ./build s3://stats.codefiction.tech --recursive", 10 | "lint": "eslint --ext .js ./src", 11 | "lint-autofix": "eslint --ext .js ./src --fix" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "@apollo/react-hooks": "^3.1.3", 18 | "apollo-boost": "^0.1.20", 19 | "apollo-server-express": "^2.1.0", 20 | "aws-sdk": "^2.350.0", 21 | "body-parser": "^1.18.3", 22 | "classnames": "^2.2.6", 23 | "express": "^4.16.4", 24 | "google-auth-library": "^3.0.1", 25 | "googleapis": "^37.0.0", 26 | "graphql": "^14.0.2", 27 | "graphql-tag": "^2.10.1", 28 | "graphql-tools": "^4.0.3", 29 | "moment": "^2.24.0", 30 | "node-sass": "^4.10.0", 31 | "numeral": "^2.0.6", 32 | "react": "^16.10.2", 33 | "react-dom": "^16.10.2", 34 | "react-helmet": "^5.2.0", 35 | "react-scripts": "^2.1.5", 36 | "react-select": "^2.4.3", 37 | "react-table": "^6.10.0", 38 | "react-toggle": "^4.0.2", 39 | "recharts": "^1.6.2", 40 | "simplecast-api-client": "^1.0.2", 41 | "string-similarity": "^3.0.0", 42 | "twit": "^2.2.11", 43 | "unstated": "^2.1.1", 44 | "youtube-api": "^2.0.10" 45 | }, 46 | "devDependencies": { 47 | "eslint": "5.12.0", 48 | "eslint-config-airbnb": "^17.1.0", 49 | "eslint-config-prettier": "^4.1.0", 50 | "eslint-plugin-import": "^2.14.0", 51 | "eslint-plugin-jsx-a11y": "^6.1.1", 52 | "eslint-plugin-node": "^8.0.1", 53 | "eslint-plugin-prettier": "^3.0.1", 54 | "eslint-plugin-promise": "^4.0.1", 55 | "eslint-plugin-react": "^7.12.4", 56 | "prettier": "1.15.2" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/stats-pages/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msusur/codefiction-stats-graphql/cc47a599261738bfb4bdeb0aec0a574a9158f6fc/packages/stats-pages/public/favicon.ico -------------------------------------------------------------------------------- /packages/stats-pages/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Codefiction Dashboard 12 | 13 | 14 | 15 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /packages/stats-pages/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [{ 5 | "src": "favicon.ico", 6 | "sizes": "64x64 32x32 24x24 16x16", 7 | "type": "image/x-icon" 8 | }], 9 | "start_url": ".", 10 | "display": "standalone", 11 | "theme_color": "#000000", 12 | "background_color": "#ffffff" 13 | } -------------------------------------------------------------------------------- /packages/stats-pages/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useQuery } from '@apollo/react-hooks'; 3 | import Helmet from 'react-helmet'; 4 | import DashboardView from './components/DashboardView'; 5 | import DashboardQuery from './queries/dashboard.query'; 6 | import Navigation from './components/Navigation'; 7 | import Loading from './components/Loading'; 8 | 9 | export const App = () => { 10 | const { loading, data } = useQuery(DashboardQuery, { 11 | notifyOnNetworkStatusChange: true, 12 | onError: ({ graphQLErrors, networkError }) => { 13 | if (graphQLErrors) { 14 | graphQLErrors.forEach(async err => { 15 | console.log(`[GraphQL error]: ${err.extensions.code}`); 16 | }); 17 | } 18 | if (networkError) { 19 | console.log(`[Network error]: ${networkError}`); 20 | } 21 | }, 22 | }); 23 | 24 | const [theme, setTheme] = useState('light'); 25 | 26 | useEffect(() => { 27 | setTheme(localStorage.getItem('theme') || 'light'); 28 | }, []); 29 | 30 | const toggleTheme = () => 31 | setTheme(oldTheme => { 32 | const newTheme = oldTheme === 'dark' ? 'light' : 'dark'; 33 | localStorage.setItem('theme', newTheme); 34 | return newTheme; 35 | }); 36 | 37 | return ( 38 | <> 39 | 40 | 41 | 42 | 43 | {loading ? : } 44 | 45 | ); 46 | }; 47 | 48 | export default App; 49 | -------------------------------------------------------------------------------- /packages/stats-pages/src/api/episode-stats-service.js: -------------------------------------------------------------------------------- 1 | const MONTHS_NAMES_TR = [ 2 | 'Ocak', 3 | 'Şubat', 4 | 'Mart', 5 | 'Nisan', 6 | 'Mayıs', 7 | 'Haziran', 8 | 'Temmuz', 9 | 'Ağustos', 10 | 'Eylül', 11 | 'Ekim', 12 | 'Kasım', 13 | 'Aralık', 14 | ]; 15 | 16 | export class EpisodeStatsService { 17 | getTimeSeries(episodes) { 18 | const months = {}; 19 | MONTHS_NAMES_TR.forEach(month => { 20 | months[month] = 0; 21 | }); 22 | episodes.map(episode => { 23 | if (!episode.downloads || !episode.downloads.by_interval) { 24 | return {}; 25 | } 26 | return episode.downloads.by_interval.forEach(item => { 27 | const month = MONTHS_NAMES_TR[new Date(item.interval).getMonth()]; 28 | months[month] = 29 | (months[month] ? months[month] : 0) + item.downloads_total; 30 | }); 31 | }); 32 | 33 | let data = []; 34 | 35 | Object.keys(months).forEach(key => { 36 | data = [ 37 | ...data, 38 | { 39 | month: key, 40 | listens: months[key], 41 | }, 42 | ]; 43 | }); 44 | 45 | return data; 46 | } 47 | } 48 | 49 | export default EpisodeStatsService; 50 | -------------------------------------------------------------------------------- /packages/stats-pages/src/api/overall-compare-service.js: -------------------------------------------------------------------------------- 1 | const fixCharLengthToTwo = number => (number < 10 ? `0${number}` : number); 2 | 3 | export class OverallCompareService { 4 | constructor(series) { 5 | this.series = series; 6 | } 7 | 8 | setAndCompareValue(key, currentValue) { 9 | const yesterday = new Date(); 10 | yesterday.setDate(yesterday.getDate() - 1); 11 | const createdOn = `${yesterday.getDate()}.${fixCharLengthToTwo( 12 | yesterday.getMonth() + 1 13 | )}.${yesterday.getFullYear()}`; 14 | const lastDayStat = this.series.filter(item => { 15 | if (item.createdOn === createdOn) { 16 | return item; 17 | } 18 | return null; 19 | }); 20 | 21 | if (lastDayStat.length <= 0) { 22 | return { 23 | currentValue, 24 | existingValue: -1, 25 | }; 26 | } 27 | const existingValue = lastDayStat[0][key]; 28 | const ratio = Math.ceil( 29 | ((currentValue - existingValue) / existingValue) * 100 30 | ); 31 | return { 32 | currentValue, 33 | existingValue, 34 | ratio, 35 | }; 36 | } 37 | } 38 | 39 | export default OverallCompareService; 40 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/DashboardView.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TotalListensTabView from './tabs/TotalListensTabView'; 3 | import OverallValuesTabView from './tabs/OverallValuesTabView'; 4 | import EpisodesTabView from './tabs/EpisodesTabView'; 5 | import EpisodesChart from './EpisodesChart'; 6 | 7 | import styles from './DashboardView.module.scss'; 8 | import WhatsUpToday from './WhatsUpToday'; 9 | 10 | export const DashboardView = ({ results }) => { 11 | const { twitter, overallTimeSeries, podcasts, youtube } = results; 12 | const whatsUpTodayContext = { 13 | twitter, 14 | overallTimeSeries, 15 | podcasts, 16 | youtube, 17 | }; 18 | 19 | return ( 20 |
21 |
22 | 23 | 29 |
30 |
31 | 35 |
36 |
37 | 41 |
42 |
43 | 44 |
45 |
46 | ); 47 | }; 48 | 49 | export default DashboardView; 50 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/DashboardView.module.scss: -------------------------------------------------------------------------------- 1 | .summary { 2 | width: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/EpisodesChart.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | BarChart, 4 | Bar, 5 | XAxis, 6 | CartesianGrid, 7 | Tooltip, 8 | ResponsiveContainer, 9 | } from 'recharts'; 10 | import { EpisodeStatsService } from '../api/episode-stats-service'; 11 | import Card from './ui/Card'; 12 | 13 | const EpisodesChart = ({ podcast }) => { 14 | const stats = new EpisodeStatsService(); 15 | const statValues = stats.getTimeSeries(podcast.episodes); 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default EpisodesChart; 32 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/Icons.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cls from 'classnames'; 3 | import styles from './Icons.module.scss'; 4 | 5 | const SvgIcon = React.forwardRef(function SvgIcon(props, ref) { 6 | const { children, className, viewBox, ...otherProps } = props; 7 | 8 | return ( 9 | 15 | {children} 16 | 17 | ); 18 | }); 19 | 20 | export const Logo = ({ ...otherProps }) => ( 21 | 22 | 26 | 27 | ); 28 | 29 | export const Refresh = ({ ...otherProps }) => ( 30 | 31 | 41 | 42 | ); 43 | 44 | export const Sun = ({ ...otherProps }) => ( 45 | 46 | 56 | 57 | ); 58 | 59 | export const Moon = ({ ...otherProps }) => ( 60 | 61 | 67 | 68 | ); 69 | 70 | export const ChevronUp = ({ ...otherProps }) => ( 71 | 72 | 73 | 74 | 80 | 81 | 82 | 83 | 84 | ); 85 | 86 | export const ChevronDown = ({ ...otherProps }) => ( 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | ); 96 | 97 | export const PlayIcon = ({ ...otherProps }) => ( 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | ); 108 | 109 | export const MusicIcon = ({ ...otherProps }) => ( 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | ); 119 | 120 | export default SvgIcon; 121 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/Icons.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | user-select: none; 3 | width: 1em; 4 | height: 1em; 5 | display: inline-block; 6 | fill: currentColor; 7 | flex-shrink: 0; 8 | font-size: 1.5em; 9 | } 10 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Loading.scss'; 3 | 4 | const Loading = () =>
; 5 | 6 | export default Loading; 7 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/Loading.scss: -------------------------------------------------------------------------------- 1 | .loading { 2 | display: inline-block; 3 | width: 64px; 4 | height: 64px; 5 | margin:0 auto; 6 | display: flex; 7 | padding: 10px; 8 | } 9 | 10 | .loading:after { 11 | content: ' '; 12 | align-content: center; 13 | display: block; 14 | width: 46px; 15 | height: 46px; 16 | margin: 1px; 17 | border-radius: 50%; 18 | border: 5px solid #000; 19 | border-color: #000 transparent; 20 | animation: loading 1.2s linear infinite; 21 | } 22 | 23 | @keyframes loading { 24 | 0% { 25 | transform: rotate(0deg); 26 | } 27 | 100% { 28 | transform: rotate(360deg); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/Navigation.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cls from 'classnames'; 3 | import Toggle from 'react-toggle'; 4 | import 'react-toggle/style.css'; 5 | import styles from './Navigation.module.scss'; 6 | import './ui/Toggle.scss'; 7 | import { Logo, Sun, Moon } from './Icons'; 8 | 9 | const Navigation = ({ toggleTheme, isThemeDark }) => ( 10 | 24 | ); 25 | 26 | export default Navigation; 27 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/Navigation.module.scss: -------------------------------------------------------------------------------- 1 | .nav { 2 | margin: 2em 0; 3 | 4 | .content { 5 | display: flex; 6 | justify-content: space-between; 7 | align-items: center; 8 | } 9 | 10 | .logo { 11 | fill: #e76953; 12 | height: 42px; 13 | width: 42px; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/OverallStatsTimeSeries.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | LineChart, 4 | Line, 5 | XAxis, 6 | CartesianGrid, 7 | Tooltip, 8 | ResponsiveContainer, 9 | } from 'recharts'; 10 | import styles from './OverallStatsTimeSeries.module.scss'; 11 | 12 | const OverallStatsTimeSeries = ({ title, data, dataKey }) => { 13 | const chartProps = { 14 | title, 15 | items: data, 16 | key: dataKey, 17 | }; 18 | 19 | if (!chartProps.items || chartProps.items.length === 0) { 20 | return
Henuz yeterli veri yok.
; 21 | } 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default OverallStatsTimeSeries; 42 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/OverallStatsTimeSeries.module.scss: -------------------------------------------------------------------------------- 1 | .chartContainer { 2 | margin-top: 1rem; 3 | } 4 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/OverallValue.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './OverallValue.scss'; 3 | import * as numeral from 'numeral'; 4 | import OverallCompareService from '../api/overall-compare-service'; 5 | import Badge from './ui/Badge'; 6 | 7 | const OverallValue = ({ series, valueKey, value, text }) => { 8 | const compare = new OverallCompareService(series); 9 | const comparedValue = valueKey 10 | ? compare.setAndCompareValue(valueKey, value) 11 | : undefined; 12 | return ( 13 |
14 | {text &&
{text}
} 15 |
16 | {valueKey ? numeral(comparedValue.currentValue).format(0, 0) : value} 17 | {comparedValue && ( 18 | 19 | {comparedValue.ratio}% 20 | 21 | )} 22 |
23 |
24 | ); 25 | }; 26 | 27 | export default OverallValue; 28 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/OverallValue.scss: -------------------------------------------------------------------------------- 1 | .dashboard { 2 | text-align: left; 3 | 4 | &--label { 5 | font-size: 1em; 6 | font-weight: 500; 7 | } 8 | &--value { 9 | color: #303030; 10 | font-weight: 700; 11 | font-size: 2.3em; 12 | display: flex; 13 | align-items: center; 14 | } 15 | &--badge { 16 | font-size: 14px; 17 | color: var(--badge); 18 | margin-left: 0.5em; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/TopBottomNEpisodes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactTable from 'react-table'; 3 | import 'react-table/react-table.css'; 4 | import './TopBottomNEpisodes.scss'; 5 | import Card from './ui/Card'; 6 | import { PlayIcon, MusicIcon } from './Icons'; 7 | 8 | const columns = [ 9 | { 10 | Header: 'Bölüm Adı', 11 | accessor: 'title', 12 | width: 620, 13 | filterable: true, 14 | }, 15 | { 16 | Header: 'Dinlenme', 17 | accessor: 'stats.total_listens', 18 | }, 19 | { 20 | Header: 'Youtube İzlenme', 21 | accessor: 'videoRef.statistics.viewCount', 22 | }, 23 | { 24 | Header: 'Toplam', 25 | accessor: 'grandTotal', 26 | }, 27 | { 28 | Header: 'Dinle', 29 | Cell: row => ( 30 | <> 31 | 37 | 38 | 39 | {row.original.videoRef && ( 40 | 48 | 49 | 50 | )} 51 | 52 | ), 53 | }, 54 | ]; 55 | 56 | const TopBottomNEpisodes = ({ episodes }) => ( 57 | 58 | 64 | 65 | ); 66 | 67 | export default TopBottomNEpisodes; 68 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/TopBottomNEpisodes.scss: -------------------------------------------------------------------------------- 1 | .dashboard { 2 | &--head-row { 3 | padding: 10px; 4 | input { 5 | width: 100%; 6 | } 7 | } 8 | &--up-down-button { 9 | float: right; 10 | cursor: pointer; 11 | } 12 | &--fix-head { 13 | overflow-y: auto; 14 | height: 100px; 15 | th { 16 | position: sticky; 17 | top: 0; 18 | } 19 | } 20 | } 21 | 22 | table { 23 | border-collapse: collapse; 24 | width: 100%; 25 | } 26 | 27 | th, 28 | td { 29 | padding: 8px 16px; 30 | } 31 | 32 | th { 33 | background: var(--table-header); 34 | } 35 | 36 | td.align-center { 37 | text-align: center; 38 | } 39 | 40 | th.w-65 { 41 | width: 65%; 42 | } 43 | 44 | .table-striped>tbody>tr:nth-of-type(odd) { 45 | background-color: var(--table-stripped); 46 | } 47 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/TopEpisodesChart.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | LineChart, 4 | Line, 5 | XAxis, 6 | CartesianGrid, 7 | Tooltip, 8 | ResponsiveContainer, 9 | } from 'recharts'; 10 | import gql from 'graphql-tag'; 11 | import { useQuery } from '@apollo/react-hooks'; 12 | import { compareTwoStrings } from 'string-similarity'; 13 | import Loading from './Loading'; 14 | import OverallValue from './OverallValue'; 15 | 16 | import './TopEpisodesChart.scss'; 17 | 18 | const QUERY_EPISODES_STATS = gql` 19 | query getEpisodesStats($title: String!) { 20 | podcasts { 21 | episodes(title: $title) { 22 | downloads(orderBy: desc) { 23 | total 24 | by_interval { 25 | downloads_total 26 | interval 27 | } 28 | } 29 | } 30 | } 31 | } 32 | `; 33 | 34 | const TopEpisodesChart = ({ episode, videos }) => { 35 | const { loading, data } = useQuery(QUERY_EPISODES_STATS, { 36 | variables: { title: episode.title }, 37 | fetchPolicy: 'no-cache', 38 | }); 39 | 40 | const youtubeVideos = videos.filter(video => { 41 | const episodeTitle = episode.title; 42 | return compareTwoStrings(video.snippet.title, episodeTitle) * 100 > 60; 43 | }); 44 | 45 | if (loading) { 46 | return ; 47 | } 48 | 49 | const chartData = data.podcasts[0].episodes[0].downloads.by_interval; 50 | const youtubeVideoCount = youtubeVideos.length 51 | ? youtubeVideos[0].statistics.viewCount 52 | : '-'; 53 | 54 | return ( 55 |
56 |
57 | 58 |
59 |
60 | 64 |
65 | 66 | 67 | 68 | 69 | 70 | 77 | 78 | 79 |
80 | ); 81 | }; 82 | 83 | export default TopEpisodesChart; 84 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/TopEpisodesChart.scss: -------------------------------------------------------------------------------- 1 | .dashboard { 2 | &--items { 3 | margin-right: 40px; 4 | float: left; 5 | } 6 | 7 | &--items-container { 8 | display: flex; 9 | margin-top: 1.5rem; 10 | flex-direction: column; 11 | 12 | @media (min-width: 768px) { 13 | flex-direction: row; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/WhatsUpToday.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import cls from 'classnames'; 3 | import { useApolloClient } from '@apollo/react-hooks'; 4 | import { invalidateCacheMutation } from '../queries/invalidate-cache.mutation'; 5 | import { DashboardQuery } from '../queries/dashboard.query'; 6 | 7 | import './WhatsUpToday.scss'; 8 | import Title from './ui/Title'; 9 | import { Refresh } from './Icons'; 10 | import { List, ListItem } from './ui/List'; 11 | 12 | const WhatsUpToday = ({ results }) => { 13 | const client = useApolloClient(); 14 | const [isLoading, setIsLoading] = useState(false); 15 | 16 | const invalidateCache = async () => { 17 | setIsLoading(true); 18 | await client.mutate({ 19 | mutation: invalidateCacheMutation, 20 | refetchQueries: [{ query: DashboardQuery }], 21 | notifyOnNetworkStatusChange: true, 22 | awaitRefetchQueries: true, 23 | }); 24 | setIsLoading(false); 25 | }; 26 | 27 | const { overallTimeSeries, twitter, youtube, podcasts } = results; 28 | const lastResult = overallTimeSeries[overallTimeSeries.length - 1]; 29 | 30 | return ( 31 |
32 |
33 | 34 | <Refresh 35 | className={cls('whatsup-today--refresh', { 36 | 'whatsup-today--animate': isLoading, 37 | })} 38 | onClick={invalidateCache} 39 | /> 40 | </div> 41 | <List unstyled> 42 | <ListItem>{`Twitter'da yeni ${twitter.followersCount - 43 | lastResult.twitter} kişi takip etmeye başladı.`}</ListItem> 44 | <ListItem>{`Toplamda ${podcasts[0].overallStats.total_listens - 45 | lastResult.podcast} kişi Codefiction dinledi.`}</ListItem> 46 | <ListItem>{`Youtube'da yeni ${parseInt( 47 | youtube.statistics.subscriberCount, 48 | 10 49 | ) - lastResult.youtube} kişi takip etmeye başladı.`}</ListItem> 50 | </List> 51 | </div> 52 | ); 53 | }; 54 | 55 | export default WhatsUpToday; 56 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/WhatsUpToday.scss: -------------------------------------------------------------------------------- 1 | .whatsup-today { 2 | &--main { 3 | width: 100%; 4 | 5 | @media (min-width: 768px) { 6 | width: 50%; 7 | } 8 | } 9 | 10 | &--header { 11 | display: flex; 12 | align-items: center; 13 | margin-bottom: 1em; 14 | @media (max-width: 991.98px) { 15 | justify-content: space-between; 16 | } 17 | } 18 | &--refresh { 19 | margin-left: 1em; 20 | cursor: pointer; 21 | } 22 | &--animate { 23 | animation: spin 1.5s infinite linear; 24 | } 25 | } 26 | 27 | @keyframes spin { 28 | from { 29 | transform: scale(1) rotate(0deg); 30 | } 31 | to { 32 | transform: scale(1) rotate(360deg); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/tabs/EpisodesTabView.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { compareTwoStrings } from 'string-similarity'; 3 | import TopBottomNEpisodes from '../TopBottomNEpisodes'; 4 | 5 | export class EpisodesTabView extends Component { 6 | render() { 7 | const { videos, episodes } = this.props; 8 | const mappedEpisodes = episodes.map(episode => { 9 | const episodeRefined = episode; 10 | episodeRefined.grandTotal = episode.downloads.total; 11 | const filteredVideos = videos.filter(video => { 12 | const result = 13 | compareTwoStrings(video.snippet.title, episode.title) * 100 > 60; 14 | return result; 15 | }); 16 | if (filteredVideos.length > 0) { 17 | const videoRef = filteredVideos[0]; 18 | episodeRefined.videoRef = videoRef; 19 | episodeRefined.grandTotal += parseInt( 20 | episodeRefined.videoRef.statistics.viewCount, 21 | 10 22 | ); 23 | } 24 | 25 | return episodeRefined; 26 | }); 27 | return <TopBottomNEpisodes maxItems={20} episodes={mappedEpisodes} />; 28 | } 29 | } 30 | 31 | export default EpisodesTabView; 32 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/tabs/OverallValuesTabView.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import OverallValue from '../OverallValue'; 3 | import Card from '../ui/Card'; 4 | import { Sun } from '../Icons'; 5 | import styles from './OverallValuesTabView.module.scss'; 6 | import OverallStatsTimeSeries from '../OverallStatsTimeSeries'; 7 | 8 | const OverallValuesTabView = ({ 9 | overallTimeSeries, 10 | twitter, 11 | youtube, 12 | podcasts, 13 | }) => { 14 | return ( 15 | <div className={styles.cards}> 16 | <Card title="Twitter"> 17 | <OverallValue 18 | valueKey="twitter" 19 | series={overallTimeSeries} 20 | value={twitter ? twitter.followersCount : null} 21 | /> 22 | <OverallStatsTimeSeries 23 | data={overallTimeSeries} 24 | dataKey="twitter" 25 | title="Twitter Trend" 26 | /> 27 | </Card> 28 | <Card title="Youtube" icon={Sun}> 29 | <OverallValue 30 | valueKey="youtube" 31 | series={overallTimeSeries} 32 | value={youtube.statistics ? youtube.statistics.subscriberCount : null} 33 | /> 34 | <OverallStatsTimeSeries 35 | data={overallTimeSeries} 36 | dataKey="youtube" 37 | title="Youtube Followers Trend" 38 | /> 39 | </Card> 40 | <Card title="Podcast"> 41 | <OverallValue 42 | valueKey="podcast" 43 | series={overallTimeSeries} 44 | value={podcasts ? podcasts[0].overallStats.total_listens : null} 45 | /> 46 | <OverallStatsTimeSeries 47 | data={overallTimeSeries} 48 | dataKey="podcast" 49 | title="Podcast Listeners Trend" 50 | /> 51 | </Card> 52 | </div> 53 | ); 54 | }; 55 | 56 | export default OverallValuesTabView; 57 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/tabs/OverallValuesTabView.module.scss: -------------------------------------------------------------------------------- 1 | .cards { 2 | display: flex; 3 | justify-content: space-between; 4 | width: 100%; 5 | flex-direction: column; 6 | 7 | @media (min-width: 768px) { 8 | flex-direction: row; 9 | } 10 | } 11 | 12 | .noPadding { 13 | padding: 0; 14 | } 15 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/tabs/TotalListensTabView.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Select from 'react-select'; 3 | import TopEpisodesChart from '../TopEpisodesChart'; 4 | import Card from '../ui/Card'; 5 | 6 | export class TotalListensTabView extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { options: [], selectedItem: '' }; 10 | } 11 | 12 | componentDidMount = () => { 13 | this.mapEpisodes(); 14 | }; 15 | 16 | mapEpisodes = () => { 17 | const { episodes } = this.props; 18 | const options = episodes.map(episode => ({ 19 | label: episode.title, 20 | value: episode.title, 21 | original: episode, 22 | })); 23 | this.setState({ 24 | options, 25 | }); 26 | }; 27 | 28 | render() { 29 | const { youtubeVideos } = this.props; 30 | const { options, selectedValue, selectedItem } = this.state; 31 | return ( 32 | <Card 33 | title="Bölüm başına dinlenme istatistikleri" 34 | style={{ marginLeft: 0 }} 35 | > 36 | <Select 37 | options={options} 38 | value={selectedValue} 39 | menuPlacement="auto" 40 | onChange={value => 41 | this.setState({ 42 | selectedItem: value.original, 43 | selectedValue: { 44 | label: value.label, 45 | value: value.value, 46 | }, 47 | }) 48 | } 49 | /> 50 | {selectedItem ? ( 51 | <TopEpisodesChart episode={selectedItem} videos={youtubeVideos} /> 52 | ) : ( 53 | <p>Devam etmek için bir seçim yapmanız gerekiyor.</p> 54 | )} 55 | </Card> 56 | ); 57 | } 58 | } 59 | 60 | export default TotalListensTabView; 61 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Badge.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cls from 'classnames'; 3 | import styles from './Badge.module.scss'; 4 | import { ChevronUp, ChevronDown } from '../Icons'; 5 | 6 | const Badge = ({ children, value, className }) => { 7 | const danger = value < 0; 8 | const equal = value === 0; 9 | 10 | return ( 11 | <div 12 | className={cls( 13 | styles.badge, 14 | { [styles.danger]: danger, [styles.equal]: equal }, 15 | className 16 | )} 17 | > 18 | {danger ? <ChevronDown /> : <ChevronUp />} 19 | {children} 20 | </div> 21 | ); 22 | }; 23 | 24 | export default Badge; 25 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Badge.module.scss: -------------------------------------------------------------------------------- 1 | .badge { 2 | display: inline-flex; 3 | font-weight: 700; 4 | font-size: 1.1rem; 5 | color: #5ccd97; 6 | justify-content: center; 7 | align-items: center; 8 | 9 | &.equal { 10 | color: #000; 11 | } 12 | 13 | &.danger { 14 | background: var(--danger); 15 | color: #fff; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Card.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cls from 'classnames'; 3 | import styles from './Card.module.scss'; 4 | 5 | const Card = ({ children, title, icon, className, ...otherProps }) => { 6 | const Icon = icon; 7 | return ( 8 | <div className={cls(styles.card, className)} {...otherProps}> 9 | {title && ( 10 | <div className={styles.cardHeader}> 11 | <h4 className={styles.cardTitle}>{title}</h4> 12 | {icon && <Icon />} 13 | </div> 14 | )} 15 | {children} 16 | </div> 17 | ); 18 | }; 19 | 20 | export default Card; 21 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Card.module.scss: -------------------------------------------------------------------------------- 1 | .card { 2 | background: var(--card); 3 | box-shadow: var(--shadow); 4 | border-radius: 6px; 5 | padding: 1.5rem; 6 | margin-top: 1em; 7 | border: var(--card-border); 8 | flex: 1 0 auto; 9 | 10 | @media (min-width: 768px) { 11 | margin-right: 1em; 12 | margin-left: 1em; 13 | } 14 | 15 | &:first-child { 16 | margin-left: 0; 17 | } 18 | 19 | &:last-child { 20 | margin-right: 0; 21 | } 22 | 23 | &:only-child { 24 | margin-left: 0; 25 | margin-right: 0; 26 | } 27 | 28 | .cardHeader { 29 | display: flex; 30 | justify-content: space-between; 31 | 32 | .cardTitle { 33 | color: var(--card-title); 34 | font-weight: 600; 35 | margin: 0; 36 | font-size: 1.1rem; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Divider.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './Divider.module.scss'; 3 | 4 | const Divider = () => { 5 | return <hr className={styles.divider} />; 6 | }; 7 | 8 | export default Divider; 9 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Divider.module.scss: -------------------------------------------------------------------------------- 1 | .divider { 2 | border-top: 1px solid var(--divider); 3 | } 4 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cls from 'classnames'; 3 | import styles from './List.module.scss'; 4 | 5 | export const List = ({ children, className, unstyled, ...otherProps }) => { 6 | return ( 7 | <ul 8 | className={cls(styles.list, className, { [styles.unstyled]: unstyled })} 9 | {...otherProps} 10 | > 11 | {children} 12 | </ul> 13 | ); 14 | }; 15 | 16 | export const ListItem = ({ children, className, ...otherProps }) => { 17 | return ( 18 | <li className={cls(styles.listItem, className)} {...otherProps}> 19 | {children} 20 | </li> 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/List.module.scss: -------------------------------------------------------------------------------- 1 | .list { 2 | &.unstyled { 3 | list-style: none; 4 | margin: 0; 5 | padding: 0; 6 | margin-bottom: 0.5em; 7 | } 8 | } 9 | 10 | .listItem { 11 | background: var(--card); 12 | box-shadow: var(--box-shadow); 13 | padding: 0.75em 1em; 14 | margin-bottom: 0.5em; 15 | border: var(--card-border); 16 | border-radius: 6px; 17 | font-size: 1rem; 18 | 19 | &:hover { 20 | background: var(--hover-color); 21 | color: #fff; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Title.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cls from 'classnames'; 3 | import styles from './Title.module.scss'; 4 | 5 | const Title = ({ value, className }) => { 6 | return <h2 className={cls(styles.title, className)}>{value}</h2>; 7 | }; 8 | 9 | export default Title; 10 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Title.module.scss: -------------------------------------------------------------------------------- 1 | .title { 2 | margin: 0; 3 | font-size: 2rem; 4 | font-weight: 500; 5 | } 6 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Toggle.scss: -------------------------------------------------------------------------------- 1 | .toggle { 2 | svg { 3 | font-size: 18px; 4 | color: #fff; 5 | } 6 | &.react-toggle--checked { 7 | .react-toggle-track { 8 | background-color: #e76953; 9 | } 10 | &:hover:not(.react-toggle--disabled) { 11 | .react-toggle-track { 12 | background: #be5846; 13 | } 14 | } 15 | } 16 | .react-toggle-track-x, 17 | .react-toggle-track-check { 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/stats-pages/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { ApolloClient, HttpLink, InMemoryCache } from 'apollo-boost'; 4 | import { ApolloProvider } from '@apollo/react-hooks'; 5 | import App from './App'; 6 | 7 | import * as serviceWorker from './serviceWorker'; 8 | 9 | import './index.scss'; 10 | 11 | const client = new ApolloClient({ 12 | link: new HttpLink({ 13 | uri: 'https://codefiction-stats.herokuapp.com/graphql', 14 | }), 15 | cache: new InMemoryCache(), 16 | }); 17 | 18 | ReactDOM.render( 19 | <ApolloProvider client={client}> 20 | <App /> 21 | </ApolloProvider>, 22 | document.getElementById('root') 23 | ); 24 | 25 | // If you want your app to work offline and load faster, you can change 26 | // unregister() to register() below. Note this comes with some pitfalls. 27 | // Learn more about service workers: http://bit.ly/CRA-PWA 28 | serviceWorker.unregister(); 29 | -------------------------------------------------------------------------------- /packages/stats-pages/src/index.scss: -------------------------------------------------------------------------------- 1 | .light-theme { 2 | --body-background: #f4f8f9; 3 | --navigation-color: #fafbfc; 4 | --shadow: 0px 4px 9px rgba(0, 0, 0, 0.02); 5 | --card: #fff; 6 | --card-border: 1px solid rgba(197, 197, 197, 0.4); 7 | --card-title: #BEBEBE; 8 | --badge: rgba(50, 60, 71, 0.4); 9 | --danger: #ff6d4a; 10 | --hover-color: #4da1ff; 11 | --divider: #d6d6d6; 12 | --animation: 0.5s ease all; 13 | --table-header: #eee; 14 | --table-stripped: #f9f9f9; 15 | transition: var(--animation); 16 | } 17 | 18 | .dark-theme { 19 | --body-background: #36393f; 20 | --navigation-color: #2a2d31; 21 | --shadow: 0px 4px 9px rgba(0, 0, 0, 0.02); 22 | --card: #494c55; 23 | --card-border: 1px solid rgba(197, 197, 197, 0); 24 | --card-title: #BEBEBE; 25 | --badge: rgba(178, 184, 190, 0.4); 26 | --danger: #ff6d4a; 27 | --hover-color: #4f535c; 28 | --divider: #4d4d4d; 29 | --animation: 0.5s ease all; 30 | --table-header: #555; 31 | --table-stripped: #757575; 32 | transition: var(--animation); 33 | color: #fff; 34 | } 35 | 36 | body { 37 | background-color: var(--body-background); 38 | font-family: 'Nunito', sans-serif; 39 | margin: 0; 40 | } 41 | 42 | .main { 43 | align-content: center; 44 | } 45 | 46 | .container { 47 | width: 100%; 48 | display: flex; 49 | flex-wrap: wrap; 50 | margin-right: auto; 51 | margin-left: auto; 52 | padding: 0 1em; 53 | } 54 | 55 | @media (min-width: 576px) { 56 | .container { 57 | max-width: 540px; 58 | } 59 | } 60 | 61 | @media (min-width: 768px) { 62 | .container { 63 | max-width: 720px; 64 | } 65 | } 66 | 67 | @media (min-width: 992px) { 68 | .container { 69 | max-width: 960px; 70 | } 71 | } 72 | 73 | @media (min-width: 1200px) { 74 | .container { 75 | max-width: 1140px; 76 | } 77 | } 78 | 79 | .no-padding { 80 | padding: 0; 81 | } 82 | 83 | #root { 84 | padding-bottom: 1.5rem; 85 | } 86 | -------------------------------------------------------------------------------- /packages/stats-pages/src/queries/dashboard.query.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const DashboardQuery = gql` 4 | { 5 | podcasts { 6 | overallStats { 7 | total_listens 8 | } 9 | title 10 | episodes { 11 | title 12 | id 13 | enclosure_url 14 | details { 15 | episode_url 16 | } 17 | guid 18 | downloads(orderBy: desc) { 19 | total 20 | by_interval { 21 | downloads_total 22 | interval 23 | } 24 | } 25 | } 26 | } 27 | youtube { 28 | statistics { 29 | subscriberCount 30 | } 31 | videos { 32 | snippet { 33 | title 34 | resourceId { 35 | videoId 36 | } 37 | } 38 | statistics { 39 | viewCount 40 | } 41 | } 42 | } 43 | twitter { 44 | followersCount 45 | } 46 | overallTimeSeries { 47 | twitter 48 | youtube 49 | podcast 50 | createdOn 51 | } 52 | } 53 | `; 54 | export default DashboardQuery; 55 | -------------------------------------------------------------------------------- /packages/stats-pages/src/queries/invalidate-cache.mutation.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const invalidateCacheMutation = gql` 4 | mutation InvalidateCache { 5 | invalidateCache { 6 | podcasts { 7 | title 8 | } 9 | } 10 | } 11 | `; 12 | 13 | export default invalidateCacheMutation; 14 | -------------------------------------------------------------------------------- /packages/stats-pages/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read http://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | function registerValidSW(swUrl, config) { 24 | navigator.serviceWorker 25 | .register(swUrl) 26 | .then(r => { 27 | const registration = r; 28 | registration.onupdatefound = () => { 29 | const installingWorker = registration.installing; 30 | if (installingWorker == null) { 31 | return; 32 | } 33 | installingWorker.onstatechange = () => { 34 | if (installingWorker.state === 'installed') { 35 | if (navigator.serviceWorker.controller) { 36 | // At this point, the updated precached content has been fetched, 37 | // but the previous service worker will still serve the older 38 | // content until all client tabs are closed. 39 | console.log( 40 | 'New content is available and will be used when all ' + 41 | 'tabs for this page are closed. See http://bit.ly/CRA-PWA.' 42 | ); 43 | 44 | // Execute callback 45 | if (config && config.onUpdate) { 46 | config.onUpdate(registration); 47 | } 48 | } else { 49 | // At this point, everything has been precached. 50 | // It's the perfect time to display a 51 | // "Content is cached for offline use." message. 52 | console.log('Content is cached for offline use.'); 53 | 54 | // Execute callback 55 | if (config && config.onSuccess) { 56 | config.onSuccess(registration); 57 | } 58 | } 59 | } 60 | }; 61 | }; 62 | }) 63 | .catch(error => { 64 | console.error('Error during service worker registration:', error); 65 | }); 66 | } 67 | 68 | function checkValidServiceWorker(swUrl, config) { 69 | // Check if the service worker can be found. If it can't reload the page. 70 | fetch(swUrl) 71 | .then(response => { 72 | // Ensure service worker exists, and that we really are getting a JS file. 73 | const contentType = response.headers.get('content-type'); 74 | if ( 75 | response.status === 404 || 76 | (contentType != null && contentType.indexOf('javascript') === -1) 77 | ) { 78 | // No service worker found. Probably a different app. Reload the page. 79 | navigator.serviceWorker.ready.then(registration => { 80 | registration.unregister().then(() => { 81 | window.location.reload(); 82 | }); 83 | }); 84 | } else { 85 | // Service worker found. Proceed as normal. 86 | registerValidSW(swUrl, config); 87 | } 88 | }) 89 | .catch(() => { 90 | console.log( 91 | 'No internet connection found. App is running in offline mode.' 92 | ); 93 | }); 94 | } 95 | 96 | export function register(config) { 97 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 98 | // The URL constructor is available in all browsers that support SW. 99 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 100 | if (publicUrl.origin !== window.location.origin) { 101 | // Our service worker won't work if PUBLIC_URL is on a different origin 102 | // from what our page is served on. This might happen if a CDN is used to 103 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 104 | return; 105 | } 106 | 107 | window.addEventListener('load', () => { 108 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 109 | 110 | if (isLocalhost) { 111 | // This is running on localhost. Let's check if a service worker still exists or not. 112 | checkValidServiceWorker(swUrl, config); 113 | 114 | // Add some additional logging to localhost, pointing developers to the 115 | // service worker/PWA documentation. 116 | navigator.serviceWorker.ready.then(() => { 117 | console.log( 118 | 'This web app is being served cache-first by a service ' + 119 | 'worker. To learn more, visit http://bit.ly/CRA-PWA' 120 | ); 121 | }); 122 | } else { 123 | // Is not localhost. Just register service worker 124 | registerValidSW(swUrl, config); 125 | } 126 | }); 127 | } 128 | } 129 | 130 | export function unregister() { 131 | if ('serviceWorker' in navigator) { 132 | navigator.serviceWorker.ready.then(registration => { 133 | registration.unregister(); 134 | }); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /prbuildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | commands: 6 | - npm install yarn -g 7 | - yarn 8 | - yarn bootstrap 9 | pre_build: 10 | commands: 11 | - yarn lint 12 | build: 13 | commands: 14 | - yarn build 15 | artifacts: 16 | files: 17 | - '**/*' 18 | base-directory: 'packages/stats-pages/build' 19 | --------------------------------------------------------------------------------