├── packages
├── stats-lambda
│ ├── Procfile
│ ├── .browserslistrc
│ ├── Dockerfile
│ ├── .env.sample
│ ├── serverless.yml
│ ├── config
│ │ ├── twitter.js
│ │ ├── aws.js
│ │ └── youtube.js
│ ├── .eslintrc.json
│ ├── graphql.js
│ ├── resolvers
│ │ ├── clients
│ │ │ ├── twitter.js
│ │ │ ├── cache.js
│ │ │ ├── youtube.js
│ │ │ ├── simplecast-client.js
│ │ │ ├── overall-stats.js
│ │ │ └── soundcloud.js
│ │ └── index.js
│ ├── server.js
│ ├── package.json
│ └── schema
│ │ └── index.js
└── stats-pages
│ ├── .browserslistrc
│ ├── src
│ ├── components
│ │ ├── DashboardView.module.scss
│ │ ├── OverallStatsTimeSeries.module.scss
│ │ ├── ui
│ │ │ ├── Divider.module.scss
│ │ │ ├── Title.module.scss
│ │ │ ├── Divider.js
│ │ │ ├── Title.js
│ │ │ ├── Badge.module.scss
│ │ │ ├── List.module.scss
│ │ │ ├── Toggle.scss
│ │ │ ├── Card.js
│ │ │ ├── List.js
│ │ │ ├── Badge.js
│ │ │ └── Card.module.scss
│ │ ├── Loading.js
│ │ ├── Icons.module.scss
│ │ ├── Navigation.module.scss
│ │ ├── tabs
│ │ │ ├── OverallValuesTabView.module.scss
│ │ │ ├── EpisodesTabView.js
│ │ │ ├── TotalListensTabView.js
│ │ │ └── OverallValuesTabView.js
│ │ ├── TopEpisodesChart.scss
│ │ ├── OverallValue.scss
│ │ ├── Loading.scss
│ │ ├── WhatsUpToday.scss
│ │ ├── Navigation.js
│ │ ├── TopBottomNEpisodes.scss
│ │ ├── EpisodesChart.js
│ │ ├── OverallValue.js
│ │ ├── OverallStatsTimeSeries.js
│ │ ├── DashboardView.js
│ │ ├── TopBottomNEpisodes.js
│ │ ├── WhatsUpToday.js
│ │ ├── TopEpisodesChart.js
│ │ └── Icons.js
│ ├── queries
│ │ ├── invalidate-cache.mutation.js
│ │ └── dashboard.query.js
│ ├── index.js
│ ├── api
│ │ ├── overall-compare-service.js
│ │ └── episode-stats-service.js
│ ├── App.js
│ ├── index.scss
│ └── serviceWorker.js
│ ├── Dockerfile
│ ├── public
│ ├── favicon.ico
│ ├── manifest.json
│ └── index.html
│ ├── .eslintrc.json
│ └── package.json
├── .prettierrc
├── lerna.json
├── docker-compose.yml
├── prbuildspec.yml
├── .gitignore
├── package.json
└── README.md
/packages/stats-lambda/Procfile:
--------------------------------------------------------------------------------
1 | web: node ./packages/stats-lambda/server.js
--------------------------------------------------------------------------------
/packages/stats-lambda/.browserslistrc:
--------------------------------------------------------------------------------
1 | > 0.2%
2 | not dead
3 | not ie <= 11
4 | not op_mini all
--------------------------------------------------------------------------------
/packages/stats-pages/.browserslistrc:
--------------------------------------------------------------------------------
1 | > 0.2%
2 | not dead
3 | not ie <= 11
4 | not op_mini all
--------------------------------------------------------------------------------
/packages/stats-pages/src/components/DashboardView.module.scss:
--------------------------------------------------------------------------------
1 | .summary {
2 | width: 100%;
3 | }
4 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "tabWidth": 2,
4 | "semi": true,
5 | "singleQuote": true
6 | }
7 |
--------------------------------------------------------------------------------
/packages/stats-pages/src/components/OverallStatsTimeSeries.module.scss:
--------------------------------------------------------------------------------
1 | .chartContainer {
2 | margin-top: 1rem;
3 | }
4 |
--------------------------------------------------------------------------------
/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/Title.module.scss:
--------------------------------------------------------------------------------
1 | .title {
2 | margin: 0;
3 | font-size: 2rem;
4 | font-weight: 500;
5 | }
6 |
--------------------------------------------------------------------------------
/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-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/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/msusur/codefiction-stats-graphql/HEAD/packages/stats-pages/public/favicon.ico
--------------------------------------------------------------------------------
/lerna.json:
--------------------------------------------------------------------------------
1 | {
2 | "packages": [
3 | "packages/*"
4 | ],
5 | "npmClient": "yarn",
6 | "useWorkspaces": true,
7 | "version": "1.0.0"
8 | }
9 |
--------------------------------------------------------------------------------
/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/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/ui/Divider.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styles from './Divider.module.scss';
3 |
4 | const Divider = () => {
5 | return
;
6 | };
7 |
8 | export default Divider;
9 |
--------------------------------------------------------------------------------
/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/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 {value}
;
7 | };
8 |
9 | export default Title;
10 |
--------------------------------------------------------------------------------
/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-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-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 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/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-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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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-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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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-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-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-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/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-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/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-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 |
9 | {title && (
10 |
11 |
{title}
12 | {icon && }
13 |
14 | )}
15 | {children}
16 |
17 | );
18 | };
19 |
20 | export default Card;
21 |
--------------------------------------------------------------------------------
/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/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 |
13 | );
14 | };
15 |
16 | export const ListItem = ({ children, className, ...otherProps }) => {
17 | return (
18 |
19 | {children}
20 |
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/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 |
18 | {danger ? : }
19 | {children}
20 |
21 | );
22 | };
23 |
24 | export default Badge;
25 |
--------------------------------------------------------------------------------
/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/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/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/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/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/.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/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 |
20 |
21 | ,
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-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-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-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-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/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/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/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/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 ;
28 | }
29 | }
30 |
31 | export default EpisodesTabView;
32 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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-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/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/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-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-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 |
36 |
56 | );
57 | }
58 | }
59 |
60 | export default TotalListensTabView;
61 |
--------------------------------------------------------------------------------
/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 |
16 |
17 |
22 |
27 |
28 |
29 |
34 |
39 |
40 |
41 |
46 |
51 |
52 |
53 | );
54 | };
55 |
56 | export default OverallValuesTabView;
57 |
--------------------------------------------------------------------------------
/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/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-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-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 |
40 |
41 |
42 | {`Twitter'da yeni ${twitter.followersCount -
43 | lastResult.twitter} kişi takip etmeye başladı.`}
44 | {`Toplamda ${podcasts[0].overallStats.total_listens -
45 | lastResult.podcast} kişi Codefiction dinledi.`}
46 | {`Youtube'da yeni ${parseInt(
47 | youtube.statistics.subscriberCount,
48 | 10
49 | ) - lastResult.youtube} kişi takip etmeye başladı.`}
50 |
51 |
52 | );
53 | };
54 |
55 | export default WhatsUpToday;
56 |
--------------------------------------------------------------------------------
/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-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-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/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 |
--------------------------------------------------------------------------------
/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)
--------------------------------------------------------------------------------
/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-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 |
--------------------------------------------------------------------------------
/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 |
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-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 |
--------------------------------------------------------------------------------