Skip to content

Commit

Permalink
Add forecast based on exisiting data + Bump the version to v2.4 (#26)
Browse files Browse the repository at this point in the history
* Add forecast

* - Few bugfixes
- Update README
- Update the demo gif in the README file
- Add some details in the forecast form on how the forecast is calculated

* Add forecast to the list of features
  • Loading branch information
seladb authored Jun 16, 2021
1 parent 2ef8958 commit 468d32d
Show file tree
Hide file tree
Showing 11 changed files with 250 additions and 12 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ Try it now: https://seladb.github.io/StarTrack-js/
- View a GitHub repo star history
- Show stargazer stats such as average number of stars per day, max stars in one day, etc.
- Supports showing multiple repos at the same time (very useful for comparison)
- Display a stargazer forecast based on the existing data
- Provide GitHub authentication (via access token) to overcome GitHub API rate limiter which limits the number of API calls without authentication. The authentication details are stored locally and not sent to any server
- By default they're stored in the browser's session storage
- The user can choose to store them in the browser's local storage for longer persistence
- Preloading repos by URL, for example: <https://seladb.github.io/StarTrack-js/#/preload?r=seladb,pickledb-rs> will preload `seladb/pickledb-rs` upon loading the page

## What's new in version 2.4?

- Added an option to display a forecast based on the existing stargazer data. The forecast is calculated using [Linear Least Squares](https://en.wikipedia.org/wiki/Linear_least_squares) regression
- Change between username and repo name text boxes using the `/` key (thanks @zaldih !)

## What's new in version 2.3?

- Parallel load of stargazer data which significantly improves the overall loading time (thanks @gsaraf !)
Expand Down Expand Up @@ -64,6 +70,7 @@ It uses the following npm packages:
- [React Google Analytics Module](https://github.com/react-ga/react-ga)
- [parse-github-url](https://github.com/jonschlinkert/parse-github-url) to find GitHub URLs that are being pasted to the repo details text boxes
- [react-responsive](https://github.com/contra/react-responsive) for adjusting the UI according to the screen size (desktop vs. mobile)
- [least-squares](https://github.com/jprichardson/least-squares) for calculating the forecast

GitHub pages deployment status: [![Build Status](https://travis-ci.com/seladb/StarTrack-js.svg?branch=master)](https://travis-ci.com/seladb/StarTrack-js)

Expand Down
7 changes: 6 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "startrack-js",
"version": "2.3.0",
"version": "2.4.0",
"private": true,
"homepage": "http://seladb.github.io/StarTrack-js",
"dependencies": {
Expand All @@ -14,6 +14,7 @@
"apexcharts": "^3.14.0",
"axios": "^0.21.1",
"bootstrap": "^4.4.1",
"least-squares": "0.0.2",
"parse-github-url": "^1.0.2",
"react": "^16.12.0",
"react-apexcharts": "^1.3.7",
Expand Down
Binary file modified public/StarTrackDemo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 17 additions & 4 deletions src/components/ChartContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@ export const LOGSCALE = 'logscale';

const ChartContainer = (props) => {

const chartSeries = props.repos.map( ({ username, repo, stargazerData }) => {
return {
const chartSeries = props.repos.flatMap( ({ username, repo, stargazerData, forecast }) => {
let series = [{
name: username + "/" + repo,
data: stargazerData,
}]
if (forecast !== null) {
series.push({
name: username + "/" + repo + " (forecast)",
data: forecast
})
}
return series;
})

const onZoom = (chartContext, { xaxis, yaxis }) => {
Expand Down Expand Up @@ -73,8 +80,14 @@ const ChartContainer = (props) => {
format: "dd MMM yyyy",
},
},
colors: props.repos.map( (repoData) => {
return repoData.color
stroke: {
curve: 'straight',
dashArray: props.repos.flatMap( ({ forecast }) => {
return forecast !== null ? [0, 5] : [0];
}),
},
colors: props.repos.flatMap( ({color, forecast}) => {
return forecast !== null ? [color, color] : [color];
}),
}

Expand Down
164 changes: 164 additions & 0 deletions src/components/ForecastChooser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import React, { useState, useRef } from 'react'
import { Form, Modal, Container, Button, Col, Alert } from 'react-bootstrap/'

const checkBoxDefaultLabel = "Display forecast"
const minForecastBackwardsDays = 10
const minForecastForwardDays = 10
const maxForecastForwardDays = 365 * 10


const ForecastChooser = (props) => {
const [showModal, setShowModal] = useState(false);
const [checkBoxLabel, setCheckBoxLabel] = useState(checkBoxDefaultLabel);
const [showAlert, setShowAlert] = useState(false);
const [alertText, setAlertText] = useState("");

const forecastBasedOnLastInput = useRef();
const forecastBasedOnLastSelect = useRef();
const forecastForwardInput = useRef();
const forecastForwardSelect = useRef();
const forecastValues = useRef();
const forecastCheckBox = useRef();

const onForecastCheckBoxChanged = (event) => {
if (event.target.checked) {
setShowModal(true);
setShowAlert(false);
}
else {
setCheckBoxLabel(checkBoxDefaultLabel);
props.onForecastProps(null);
}
}

const closeModal = () => {
setShowModal(false);
setCheckBoxLabel(checkBoxDefaultLabel);
forecastCheckBox.current.checked = false;
props.onForecastProps(null);
setShowAlert(false);
}

const calculateDays = (input, select) => {
switch (select) {
case "Days":
return parseInt(input);
case "Weeks":
return input * 7;
case "Months":
let monthsAgo = new Date();
monthsAgo.setMonth(monthsAgo.getMonth() - input);
let now = new Date();
const oneDay = 24 * 60 * 60 * 1000; // hours*minutes*seconds*milliseconds
return Math.round(Math.abs((now - monthsAgo) / oneDay));
case "Years":
return input * 365;
default:
return parseInt(input);
}
}

const validateForecastPropsData = (forecastProps) => {
if (forecastProps.daysBackwards < minForecastBackwardsDays) {
setShowAlert(true);
setAlertText("The forecast must be based on at least 10 days of data");
return false;
}
if (forecastProps.daysForward < minForecastForwardDays) {
setShowAlert(true);
setAlertText("The forecast must show at least 10 days forward");
return false;
}
if (forecastProps.daysForward > maxForecastForwardDays) {
setShowAlert(true);
setAlertText("The forecast cannot show more than 10 years forward");
return false;
}

return true;
}

const forecastPropsSelected = () => {
let forecastProps = {
daysBackwards: calculateDays(forecastBasedOnLastInput.current.value, forecastBasedOnLastSelect.current.value),
daysForward: calculateDays(forecastForwardInput.current.value, forecastForwardSelect.current.value),
numValues: parseInt(forecastValues.current.value)
}
if (!validateForecastPropsData(forecastProps)) {
return;
}

let checkBoxNewLabel = `Display ${forecastForwardInput.current.value} ${forecastForwardSelect.current.value} forecast based on the last ${forecastBasedOnLastInput.current.value} ${forecastBasedOnLastSelect.current.value}`;
setCheckBoxLabel(checkBoxNewLabel)
setShowModal(false);
props.onForecastProps(forecastProps)
}

return (
<Container>
<Form>
<h3>Forecast</h3>
<Form.Check custom type="checkbox" ref={forecastCheckBox} id="display-forecast-checkbox" label={checkBoxLabel} onChange={onForecastCheckBoxChanged}/>
</Form>
<Modal show={showModal} onHide={closeModal}>
<Form onSubmit={forecastPropsSelected}>
<Modal.Header closeButton>
<Modal.Title>Forecast Properties</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Group>
<Form.Label>Display forecast based on the last:</Form.Label>
<Form.Row>
<Col>
<Form.Control ref={forecastBasedOnLastInput} type="number" min="1" defaultValue="3" required/>
</Col>
<Col>
<Form.Control ref={forecastBasedOnLastSelect} as="select" defaultValue="Months">
<option>Days</option>
<option>Weeks</option>
<option>Months</option>
<option>Years</option>
</Form.Control>
</Col>
</Form.Row>
</Form.Group>
<Form.Group>
<Form.Label>Display forecast ahead:</Form.Label>
<Form.Row>
<Col>
<Form.Control ref={forecastForwardInput} type="number" min="1" defaultValue="3" required/>
</Col>
<Col>
<Form.Control ref={forecastForwardSelect} as="select" defaultValue="Months">
<option>Days</option>
<option>Weeks</option>
<option>Months</option>
<option>Years</option>
</Form.Control>
</Col>
</Form.Row>
</Form.Group>
<Form.Group>
<Form.Label>Number of forecast values to calculate:</Form.Label>
<Form.Control ref={forecastValues} type="number" min="10" max="100" defaultValue="10" required/>
</Form.Group>
<p>The forecast is based on <a target="_blank" rel="noopener noreferrer" href="https://en.wikipedia.org/wiki/Linear_least_squares">Linear Least Squares</a> which creates a regression line from the existing stargazer data and extends this line into the future</p>
<Form.Group>
<Alert variant="danger" show={showAlert}>{alertText}</Alert>
</Form.Group>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={closeModal}>
Close
</Button>
<Button variant="primary" type="submit">
Ok
</Button>
</Modal.Footer>
</Form>
</Modal>
</Container>
)
}

export default ForecastChooser
2 changes: 1 addition & 1 deletion src/components/GitHubAuthForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const GitHubAuthForm = (props) => {
</Button>
</Modal.Footer>
</Form>
</Modal>
</Modal>
)
}

Expand Down
24 changes: 22 additions & 2 deletions src/components/MainContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useState, useRef } from 'react'
import { Button, Modal, ProgressBar, Container } from 'react-bootstrap/'
import './MainContainer.css'
import RepoDetails from './RepoDetails'
import ForecastChooser from './ForecastChooser'
import ChartContainer, { LINEAR } from './ChartContainer'
import StatsTable from './StatsTable'
import UrlDisplay from './UrlDisplay'
Expand All @@ -28,6 +29,8 @@ const MainContainer = (props) => {

const [chartType, setChartType] = useState(LINEAR);

const [forecastProps, setForecastProps] = useState(null);

const onLoadInProgress = (progress) => {
setLoadingStatus({
isLoading: true,
Expand Down Expand Up @@ -75,8 +78,9 @@ const MainContainer = (props) => {

try {
let stargazerData = await stargazerLoader.loadStargazers(
username,
repo,
username,
repo,
forecastProps,
onLoadInProgress,
() => requestStopLoading.current);

Expand Down Expand Up @@ -105,6 +109,11 @@ const MainContainer = (props) => {
}

const handleRemoveRepo = (repoDetails) => {
// if this is the last repo, cleanup forecast
if (repos.length === 1) {
setForecastProps(null);
}

setRepos(repos.filter(repo => {
return repo.username !== repoDetails.username || repo.repo !== repoDetails.repo;
}));
Expand Down Expand Up @@ -132,6 +141,16 @@ const MainContainer = (props) => {
setRepos(reposWithUpdatedStats);
}

const handleForecastProps = (forecastProps) => {
let reposWithForecast = repos.slice();
for (let index = 0; index < reposWithForecast.length; index++) {
reposWithForecast[index].forecast = forecastProps === null? null : stargazerStats.calcForecast(reposWithForecast[index].stargazerData, forecastProps.daysBackwards, forecastProps.daysForward, forecastProps.numValues);
}

setRepos(reposWithForecast);
setForecastProps(forecastProps);
}

return (
<div>
{ loadingStatus.isLoading ? <ProgressBar now={loadingStatus.loadProgress} variant="success" animated /> : <div className="progress MainContainer-progressBarPlaceholder"/> }
Expand All @@ -156,6 +175,7 @@ const MainContainer = (props) => {
</div>
</Container>
{ repos.length > 0 ? <ChartContainer repos={repos} onTimeRangeChange={handleChartTimeRangeChange} chartType={chartType} onChartTypeChange={setChartType}/> : null }
{ repos.length > 0 ? <ForecastChooser onForecastProps={handleForecastProps}/> : null }
{ repos.length > 0 ? <Container><StatsTable repos={repos} requestToSyncChartTimeRange={handleRequestToSyncChartTimeRange}/></Container> : null }
{ repos.length > 0 ? <Container><UrlDisplay repos={repos}/></Container> : null }
<Footer pageEmpty={repos.length === 0}/>
Expand Down
2 changes: 1 addition & 1 deletion src/components/StatsTable.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const StatsTable = (props) => {

return (
<Container className="StatsTable-topContainer">
<h3>Repo stats:</h3>
<h3>Repo stats</h3>
<Form className="mb-3">
<Form.Check type="checkbox" label="Sync stats to chart zoom level" onChange={onSyncCheckBoxChanged}/>
</Form>
Expand Down
5 changes: 3 additions & 2 deletions src/utils/StargazerLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const maxReposAllowed = 8;
class StargazerLoader {
static colorIndex = -1

async loadStargazers(username, repo, handleProgress, shouldStop) {
async loadStargazers(username, repo, forecastProps, handleProgress, shouldStop) {
let stargazerData = await gitHubUtils.loadStargazers(username, repo, handleProgress, shouldStop);
if (stargazerData === null) {
return null;
Expand All @@ -19,7 +19,8 @@ class StargazerLoader {
repo: repo,
color: colors[StargazerLoader.colorIndex],
stargazerData: stargazerData,
stats: stargazerStats.calcStats(stargazerData)
stats: stargazerStats.calcStats(stargazerData),
forecast: forecastProps !== null ? stargazerStats.calcForecast(stargazerData, forecastProps.daysBackwards, forecastProps.daysForward, forecastProps.numValues) : null
}
}
}
Expand Down
Loading

0 comments on commit 468d32d

Please sign in to comment.