playball 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,18 +25,27 @@ $ playball
25
25
  ```
26
26
 
27
27
  ### Docker
28
- ```
29
- $ docker build -t playball .
30
- $ docker run -it --rm --name playball playball:latest
31
- ```
32
-
33
- #### Build options
34
-
35
- Update the language encoding of by adding `--build-args`
36
-
37
- ```
38
- $ docker build --build-arg LANG=en_US.UTF-8 -t playball .
39
- ```
28
+ Don't have Node.js installed? You can run via Docker instead.
29
+ ```
30
+ $ docker run -it --rm paaatrick0/playball
31
+ ```
32
+
33
+ > [!TIP]
34
+ > When running via Docker, times will be shown by default in Eastern Time. To change this, set the `TZ` environment variable to your desired timezone.
35
+ >
36
+ > For Central Time use:
37
+ > ```
38
+ > $ docker run -it --rm -e TZ=America/Chicago paaatrick0/playball
39
+ > ```
40
+ > For Mountain Time use:
41
+ > ```
42
+ > $ docker run -it --rm -e TZ=America/Denver paaatrick0/playball
43
+ > ```
44
+ > For Pacific Time use:
45
+ > ```
46
+ > $ docker run -it --rm -e TZ=America/Los_Angeles paaatrick0/playball
47
+ > ```
48
+ > For other timezones see the list of [TZ database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
40
49
 
41
50
  ### Keys
42
51
  #### Global
@@ -122,6 +131,7 @@ key | description | default | allowed values
122
131
  `color.walk` | Color of result where play ends on a ball (walk, hit by pitch) in list of plays in game view | green | _See above_
123
132
  `favorites` | Teams to highlight in schedule and standings views | | Any one of the following: `ATL`, `AZ`, `BAL`, `BOS`, `CHC`, `CIN`, `CLE`, `COL`, `CWS`, `DET`, `HOU`, `KC`, `LAA`, `LAD`, `MIA`, `MIL`, `MIN`, `NYM`, `NYY`, `OAK`, `PHI`, `PIT`, `SD`, `SEA`, `SF`, `STL`, `TB`, `TEX`, `TOR`, `WSH`. Or a comma-separated list of multiple (e.g. `SEA,MIL`).<br/><br />Note: in some terminals the list must be quoted: `playball config favorites "SEA,MIL"`
124
133
  `title` | If enabled, the terminal title will be set to the score of the current game | `false` | `false`, `true`
134
+ `live-delay` | Number of seconds to delay the live game stream. Useful when watching with delayed broadcast streams. | `0` (no delay) | Any positive number
125
135
 
126
136
  ### Development
127
137
  ```
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ const notifier = updateNotifier({
10
10
  updateCheckInterval: 1000 * 60 * 60 * 24 * 7 // 1 week
11
11
  });
12
12
 
13
- program.name(pkg.name).description(pkg.description).version(pkg.version).action(main).hook('postAction', () => notifier.notify({
13
+ program.name(pkg.name).description(pkg.description).version(pkg.version).option('--replay <gameId>', 'Replay a game by game ID').action(main).hook('postAction', () => notifier.notify({
14
14
  isGlobal: true
15
15
  }));
16
16
  program.command('config').description('Set or get configration values').argument('[key]').argument('[value]').option('--unset', 'Unset configuration value').action((key, value, options) => {
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { useSelector } from "react-redux/lib/alternate-renderers.js";
3
+ import PropTypes from 'prop-types';
3
4
  import { selectAllPlays, selectTeams } from "../features/games.js";
4
5
  import { get } from "../config.js";
5
6
  import style from "../style/index.js";
@@ -19,28 +20,46 @@ function getPlayResultColor(play) {
19
20
  }
20
21
  }
21
22
  function formatOut(out) {
22
- return ` {bold}${out} out{/}`;
23
+ return ` {bold}${out} out${out > 1 ? 's' : ''}{/}`;
23
24
  }
24
- function AllPlays() {
25
+ function AllPlays({
26
+ reverse,
27
+ scoringOnly
28
+ }) {
25
29
  const plays = useSelector(selectAllPlays);
26
30
  const teams = useSelector(selectTeams);
27
31
  const formatScoreDetail = scoreObj => ` {bold}{${get('color.in-play-runs-bg')}-bg}{${get('color.in-play-runs-fg')}-fg} ` + `${teams.away.abbreviation} ${scoreObj.awayScore} - ` + `${teams.home.abbreviation} ${scoreObj.homeScore}` + ' {/}';
28
- let inning = '';
29
- const lines = [];
30
- plays && plays.slice().reverse().forEach((play, playIdx, plays) => {
31
- let lastPlay;
32
- if (playIdx < plays.length - 1) {
33
- lastPlay = plays[playIdx + 1];
34
- }
32
+ const playsByInning = {};
33
+ plays === null || plays === void 0 ? void 0 : plays.forEach(play => {
34
+ var _play$playEvents2;
35
35
  const playInning = play.about.halfInning + ' ' + play.about.inning;
36
- if (playInning !== inning) {
37
- inning = playInning;
38
- if (lines.length > 0) {
39
- lines.push('');
36
+ (_play$playEvents2 = play.playEvents) === null || _play$playEvents2 === void 0 ? void 0 : _play$playEvents2.forEach(event => {
37
+ if (event.type === 'action') {
38
+ if (scoringOnly && !event.details.isScoringPlay) {
39
+ return;
40
+ }
41
+ if (event.details.eventType === 'batter_timeout' || event.details.eventType === 'mound_visit') {
42
+ return;
43
+ }
44
+ let line = '';
45
+ if (event.details.event) {
46
+ line += `{${get('color.other-event')}-fg}[${event.details.event}]{/} `;
47
+ }
48
+ line += event.details.description;
49
+ if (event.details.isOut) {
50
+ var _event$count;
51
+ line += formatOut((_event$count = event.count) === null || _event$count === void 0 ? void 0 : _event$count.outs);
52
+ }
53
+ if (event.isScoringPlay || event.details.isScoringPlay) {
54
+ line += formatScoreDetail(event.details);
55
+ }
56
+ if (!(playInning in playsByInning)) {
57
+ playsByInning[playInning] = [];
58
+ }
59
+ playsByInning[playInning].push(line);
40
60
  }
41
- lines.push(`{bold}[${inning.toUpperCase()}]{/}`);
42
- }
43
- if (play.about.isComplete) {
61
+ });
62
+ if (play.about.isComplete && (!scoringOnly || play.about.isScoringPlay)) {
44
63
  const color = getPlayResultColor(play);
45
64
  let line = `{${color}-fg}[${play.result.event}]{/} ${play.result.description}`;
46
65
  if (play.about.hasOut) {
@@ -52,32 +71,35 @@ function AllPlays() {
52
71
  if (play.about.isScoringPlay) {
53
72
  line += formatScoreDetail(play.result);
54
73
  }
55
- lines.push(line);
56
- }
57
- play.playEvents && play.playEvents.slice().reverse().forEach((event, eventIdx, events) => {
58
- if (event.type === 'action') {
59
- var _event$count;
60
- let line = '';
61
- if (event.details.event) {
62
- line += `{${get('color.other-event')}-fg}[${event.details.event}]{/} `;
63
- }
64
- line += event.details.description;
65
- if (event.isScoringPlay || event.details.isScoringPlay) {
66
- line += formatScoreDetail(event.details);
67
- }
68
- const currentOut = (_event$count = event.count) === null || _event$count === void 0 ? void 0 : _event$count.outs;
69
- let prevOut = lastPlay ? lastPlay.count.outs : 0;
70
- if (eventIdx < events.length - 1) {
71
- var _events$count;
72
- prevOut = (_events$count = events[eventIdx + 1].count) === null || _events$count === void 0 ? void 0 : _events$count.outs;
73
- }
74
- if (currentOut > prevOut) {
75
- line += formatOut(currentOut);
76
- }
77
- lines.push(line);
74
+ if (!(playInning in playsByInning)) {
75
+ playsByInning[playInning] = [];
78
76
  }
79
- });
77
+ playsByInning[playInning].push(line);
78
+ }
80
79
  });
80
+ const lines = [];
81
+ const inningKeys = Object.keys(playsByInning);
82
+ if (reverse) {
83
+ inningKeys.reverse();
84
+ }
85
+ inningKeys.forEach(inning => {
86
+ if (lines.length > 0) {
87
+ lines.push('');
88
+ }
89
+ lines.push(`{bold}[${inning.toUpperCase()}]{/}`);
90
+ if (reverse) {
91
+ lines.push(...playsByInning[inning].slice().reverse());
92
+ } else {
93
+ lines.push(...playsByInning[inning]);
94
+ }
95
+ });
96
+ if (lines.length === 0) {
97
+ if (scoringOnly) {
98
+ lines.push('No scoring plays yet');
99
+ } else {
100
+ lines.push('No plays yet');
101
+ }
102
+ }
81
103
  return /*#__PURE__*/React.createElement("box", {
82
104
  content: lines.join('\n'),
83
105
  focused: true,
@@ -90,4 +112,8 @@ function AllPlays() {
90
112
  tags: true
91
113
  });
92
114
  }
115
+ AllPlays.propTypes = {
116
+ reverse: PropTypes.bool,
117
+ scoringOnly: PropTypes.bool
118
+ };
93
119
  export default AllPlays;
@@ -1,20 +1,23 @@
1
- import React, { useState } from 'react';
1
+ import React, { useEffect, useState } from 'react';
2
2
  import { useDispatch } from "react-redux/lib/alternate-renderers.js";
3
+ import PropTypes from 'prop-types';
3
4
  import GameList from "./GameList.js";
4
5
  import HelpBar from "./HelpBar.js";
5
- import { setSelectedId } from "../features/games.js";
6
+ import { setReplayGame, setLiveGame } from "../features/games.js";
6
7
  import Game from "./Game.js";
7
8
  import useKey from "../hooks/useKey.js";
8
9
  import Standings from "./Standings.js";
9
10
  const SCHEDULE = 'schedule';
10
11
  const STANDINGS = 'standings';
11
12
  const GAME = 'game';
12
- function App() {
13
+ function App({
14
+ replayId
15
+ }) {
13
16
  const [view, setView] = useState(SCHEDULE);
14
17
  const dispatch = useDispatch();
15
18
  useKey('c', () => {
16
19
  setView(SCHEDULE);
17
- dispatch(setSelectedId(null));
20
+ dispatch(setLiveGame(null));
18
21
  }, {
19
22
  key: 'C',
20
23
  label: 'Schedule'
@@ -24,9 +27,14 @@ function App() {
24
27
  label: 'Standings'
25
28
  });
26
29
  const handleGameSelect = game => {
27
- dispatch(setSelectedId(game.gamePk));
30
+ dispatch(setLiveGame(game.gamePk));
28
31
  setView(GAME);
29
32
  };
33
+ useEffect(() => {
34
+ if (replayId) {
35
+ dispatch(setReplayGame(replayId)).unwrap().then(() => setView(GAME)).catch(() => setView(SCHEDULE));
36
+ }
37
+ }, [replayId]);
30
38
  return /*#__PURE__*/React.createElement("element", null, /*#__PURE__*/React.createElement("element", {
31
39
  top: 0,
32
40
  left: 0,
@@ -39,4 +47,7 @@ function App() {
39
47
  height: 1
40
48
  }, /*#__PURE__*/React.createElement(HelpBar, null)));
41
49
  }
50
+ App.propTypes = {
51
+ replayId: PropTypes.string
52
+ };
42
53
  export default App;
@@ -0,0 +1,69 @@
1
+ import React from 'react';
2
+ import { useSelector } from "react-redux/lib/alternate-renderers.js";
3
+ import { selectBoxscore, selectPlayers } from "../features/games.js";
4
+ import Table from "./Table.js";
5
+ function getBatterRows(boxscoreTeam, players) {
6
+ const batters = Object.values(boxscoreTeam.players).filter(player => player.battingOrder !== undefined).sort((a, b) => parseInt(a.battingOrder) - parseInt(b.battingOrder));
7
+ const batterNames = batters.map(batter => {
8
+ var _batter$allPositions, _batter$gameStatus;
9
+ const name = players[`ID${batter.person.id}`].boxscoreName;
10
+ const positions = (_batter$allPositions = batter.allPositions) === null || _batter$allPositions === void 0 ? void 0 : _batter$allPositions.map(pos => pos.abbreviation).join('-');
11
+ const prefix = (_batter$gameStatus = batter.gameStatus) !== null && _batter$gameStatus !== void 0 && _batter$gameStatus.isSubstitute ? ' ' : '';
12
+ return `${prefix}${name} (${positions})`;
13
+ });
14
+ return [...batters.map((batter, idx) => [batterNames[idx], batter.stats.batting.atBats.toString(), batter.stats.batting.runs.toString(), batter.stats.batting.hits.toString(), batter.stats.batting.rbi.toString(), batter.stats.batting.baseOnBalls.toString(), batter.stats.batting.strikeOuts.toString(), batter.seasonStats.batting.avg, batter.seasonStats.batting.ops]), ['Totals', boxscoreTeam.teamStats.batting.atBats.toString(), boxscoreTeam.teamStats.batting.runs.toString(), boxscoreTeam.teamStats.batting.hits.toString(), boxscoreTeam.teamStats.batting.rbi.toString(), boxscoreTeam.teamStats.batting.baseOnBalls.toString(), boxscoreTeam.teamStats.batting.strikeOuts.toString(), '', '']];
15
+ }
16
+ function getPitcherRows(boxscoreTeam, players) {
17
+ const pitchers = boxscoreTeam.pitchers.map(pitcherId => boxscoreTeam.players['ID' + pitcherId]);
18
+ const pitcherNames = pitchers.map(pitcher => {
19
+ const name = players[`ID${pitcher.person.id}`].boxscoreName;
20
+ const note = pitcher.stats.pitching.note ? ` ${pitcher.stats.pitching.note}` : '';
21
+ return `${name}${note}`;
22
+ });
23
+ return [...pitchers.map((pitcher, idx) => [pitcherNames[idx], pitcher.stats.pitching.inningsPitched, pitcher.stats.pitching.hits.toString(), pitcher.stats.pitching.runs.toString(), pitcher.stats.pitching.earnedRuns.toString(), pitcher.stats.pitching.baseOnBalls.toString(), pitcher.stats.pitching.strikeOuts.toString(), pitcher.stats.pitching.homeRuns.toString(), pitcher.seasonStats.pitching.era]), ['Totals', boxscoreTeam.teamStats.pitching.inningsPitched, boxscoreTeam.teamStats.pitching.hits.toString(), boxscoreTeam.teamStats.pitching.runs.toString(), boxscoreTeam.teamStats.pitching.earnedRuns.toString(), boxscoreTeam.teamStats.pitching.baseOnBalls.toString(), boxscoreTeam.teamStats.pitching.strikeOuts.toString(), boxscoreTeam.teamStats.pitching.homeRuns.toString(), '']];
24
+ }
25
+ function BoxScore({
26
+ ...props
27
+ }) {
28
+ const boxscore = useSelector(selectBoxscore);
29
+ const players = useSelector(selectPlayers);
30
+ const batterHeader = ['Batters', 'AB', 'R', 'H', 'RBI', 'BB', 'K', 'AVG', 'OPS'];
31
+ const batterWidths = ['auto', 4, 4, 4, 4, 4, 4, 6, 6];
32
+ const awayBatterRows = getBatterRows(boxscore.away, players);
33
+ const homeBatterRows = getBatterRows(boxscore.home, players);
34
+ const pitcherHeader = ['Pitchers', 'IP', 'H', 'R', 'ER', 'BB', 'K', 'HR', 'ERA'];
35
+ const pitcherWidths = ['auto', 5, 4, 4, 4, 4, 4, 4, 6];
36
+ const pitcherStart = Math.max(awayBatterRows.length, homeBatterRows.length) + 2;
37
+ const awayPitcherRows = getPitcherRows(boxscore.away, players);
38
+ const homePitcherRows = getPitcherRows(boxscore.home, players);
39
+ return /*#__PURE__*/React.createElement("element", props, /*#__PURE__*/React.createElement(Table, {
40
+ top: 0,
41
+ left: 0,
42
+ width: "50%-1",
43
+ headers: batterHeader,
44
+ widths: batterWidths,
45
+ rows: awayBatterRows
46
+ }), /*#__PURE__*/React.createElement(Table, {
47
+ top: 0,
48
+ left: "50%+1",
49
+ width: "50%-1",
50
+ headers: batterHeader,
51
+ widths: batterWidths,
52
+ rows: homeBatterRows
53
+ }), /*#__PURE__*/React.createElement(Table, {
54
+ top: pitcherStart,
55
+ left: 0,
56
+ width: "50%-1",
57
+ headers: pitcherHeader,
58
+ widths: pitcherWidths,
59
+ rows: awayPitcherRows
60
+ }), /*#__PURE__*/React.createElement(Table, {
61
+ top: pitcherStart,
62
+ left: "50%+1",
63
+ width: "50%-1",
64
+ headers: pitcherHeader,
65
+ widths: pitcherWidths,
66
+ rows: homePitcherRows
67
+ }));
68
+ }
69
+ export default BoxScore;
@@ -1,9 +1,16 @@
1
- import React, { useEffect } from 'react';
2
- import { useSelector } from "react-redux/lib/alternate-renderers.js";
1
+ import React, { useEffect, useMemo } from 'react';
2
+ import { useDispatch, useSelector } from "react-redux/lib/alternate-renderers.js";
3
+ import figlet from 'figlet';
3
4
  import LineScore from "./LineScore.js";
4
5
  import { get } from "../config.js";
5
- import { selectLineScore, selectTeams, selectDecisions, selectBoxscore, selectGameStatus } from "../features/games.js";
6
+ import { selectBoxscore, selectDecisions, selectGameStatus, selectLineScore, selectSelectedId, selectTeams, setReplayGame } from "../features/games.js";
6
7
  import { resetTitle, setTitle } from "../screen.js";
8
+ import useKey from "../hooks/useKey.js";
9
+ import BoxScore from "./BoxScore.js";
10
+ import AllPlays from "./AllPlays.js";
11
+ const BOX_SCORE = 'BOX_SCORE';
12
+ const ALL_PLAYS = 'ALL_PLAYS';
13
+ const SCORING_PLAYS = 'SCORING_PLAYS';
7
14
  const getPlayer = (id, boxscore) => {
8
15
  var _boxscore$home, _boxscore$away;
9
16
  const homePlayer = (_boxscore$home = boxscore.home) === null || _boxscore$home === void 0 || (_boxscore$home = _boxscore$home.players) === null || _boxscore$home === void 0 ? void 0 : _boxscore$home['ID' + id];
@@ -37,24 +44,31 @@ const formatDecisions = (decisions, boxscore) => {
37
44
  }
38
45
  return content.join('\n');
39
46
  };
40
- const formatScore = (status, linescore) => {
41
- let display = '';
42
- if (status.detailedState === 'Postponed') {
43
- display = status.detailedState;
44
- if (status.reason) {
45
- display += '\n' + status.reason;
46
- }
47
- } else {
48
- display = `\n${linescore.teams.away.runs} - ${linescore.teams.home.runs}`;
49
- }
50
- return display;
51
- };
52
47
  function FinishedGame() {
48
+ const dispatch = useDispatch();
49
+ const id = useSelector(selectSelectedId);
53
50
  const boxscore = useSelector(selectBoxscore);
54
51
  const decisions = useSelector(selectDecisions);
55
52
  const linescore = useSelector(selectLineScore);
56
53
  const status = useSelector(selectGameStatus);
57
54
  const teams = useSelector(selectTeams);
55
+ const [view, setView] = React.useState(BOX_SCORE);
56
+ useKey('r', () => dispatch(setReplayGame(id)), {
57
+ key: 'R',
58
+ label: 'Replay'
59
+ });
60
+ useKey('b', () => setView(BOX_SCORE), {
61
+ key: 'B',
62
+ label: 'Box Score'
63
+ });
64
+ useKey('a', () => setView(ALL_PLAYS), {
65
+ key: 'A',
66
+ label: 'All Plays'
67
+ });
68
+ useKey('p', () => setView(SCORING_PLAYS), {
69
+ key: 'P',
70
+ label: 'Scoring Plays'
71
+ });
58
72
  useEffect(() => {
59
73
  if (get('title')) {
60
74
  const homeRuns = linescore.teams['home'].runs;
@@ -67,38 +81,61 @@ function FinishedGame() {
67
81
  };
68
82
  }
69
83
  }, [get, linescore, resetTitle, setTitle, teams]);
84
+ const bigTextOptions = {
85
+ font: 'Small Block'
86
+ };
70
87
  const awayTeam = `${teams.away.teamName}\n(${teams.away.record.wins}-${teams.away.record.losses})`;
88
+ const awayRuns = useMemo(() => figlet.textSync(linescore.teams.away.runs, bigTextOptions), [linescore.teams.away.runs]);
71
89
  const homeTeam = `${teams.home.teamName}\n(${teams.home.record.wins}-${teams.home.record.losses})`;
72
- return /*#__PURE__*/React.createElement("element", null, /*#__PURE__*/React.createElement("element", {
73
- height: "60%"
90
+ const homeRuns = useMemo(() => figlet.textSync(linescore.teams.home.runs, bigTextOptions), [linescore.teams.home.runs]);
91
+ return /*#__PURE__*/React.createElement("element", null, status.detailedState === 'Postponed' ? /*#__PURE__*/React.createElement("element", null, /*#__PURE__*/React.createElement("box", {
92
+ top: 1
93
+ }, "status.detailedState"), /*#__PURE__*/React.createElement("box", {
94
+ top: 2
95
+ }, status.reason)) : /*#__PURE__*/React.createElement("element", {
96
+ top: 1
74
97
  }, /*#__PURE__*/React.createElement("box", {
98
+ top: 1,
99
+ left: 0,
100
+ width: "25%",
75
101
  content: awayTeam,
76
- width: "33%-1",
77
- top: "50%",
102
+ align: "right"
103
+ }), /*#__PURE__*/React.createElement("box", {
104
+ top: 0,
105
+ left: "25%",
106
+ width: "25%",
107
+ content: awayRuns,
78
108
  align: "center"
79
109
  }), /*#__PURE__*/React.createElement("box", {
80
- content: formatScore(status, linescore),
81
- width: "33%-1",
82
- left: "33%",
83
- top: "50%",
110
+ top: 0,
111
+ left: "50%",
112
+ width: "25%",
113
+ content: homeRuns,
84
114
  align: "center"
85
115
  }), /*#__PURE__*/React.createElement("box", {
116
+ top: 1,
117
+ left: "75%",
118
+ width: "25%",
86
119
  content: homeTeam,
87
- width: "34%",
88
- top: "50%",
89
- left: "66%",
90
- align: "center"
91
- })), /*#__PURE__*/React.createElement("element", {
92
- top: "60%+1",
93
- height: 3
120
+ align: "left"
121
+ }), /*#__PURE__*/React.createElement("element", {
122
+ top: 5,
123
+ left: 0,
124
+ width: "50%-2"
94
125
  }, /*#__PURE__*/React.createElement(LineScore, {
95
- align: "center",
126
+ align: "right",
96
127
  final: true
97
128
  })), /*#__PURE__*/React.createElement("element", {
98
- top: "60%+5",
99
- left: "50%-20"
129
+ top: 5,
130
+ left: "50%+2",
131
+ width: "50%-2",
132
+ align: "right"
100
133
  }, /*#__PURE__*/React.createElement("box", {
101
134
  content: formatDecisions(decisions, boxscore)
102
- })));
135
+ })), /*#__PURE__*/React.createElement("element", {
136
+ top: 9
137
+ }, view === BOX_SCORE && /*#__PURE__*/React.createElement(BoxScore, null), view === ALL_PLAYS && /*#__PURE__*/React.createElement(AllPlays, null), view === SCORING_PLAYS && /*#__PURE__*/React.createElement(AllPlays, {
138
+ scoringOnly: true
139
+ }))));
103
140
  }
104
141
  export default FinishedGame;
@@ -1,6 +1,6 @@
1
1
  import React, { useEffect, useRef } from 'react';
2
2
  import { useDispatch, useSelector } from "react-redux/lib/alternate-renderers.js";
3
- import { fetchGame, selectGame, selectSelectedId, selectFullUpdateRequired } from "../features/games.js";
3
+ import { fetchGame, selectGame, selectSelectedId, selectFullUpdateRequired, selectDelay } from "../features/games.js";
4
4
  import PreviewGame from "./PreviewGame.js";
5
5
  import LiveGame from "./LiveGame.js";
6
6
  import FinishedGame from "./FinishedGame.js";
@@ -11,13 +11,15 @@ function Game() {
11
11
  const game = useSelector(selectGame);
12
12
  const fullUpdateRequired = useSelector(selectFullUpdateRequired);
13
13
  const id = useSelector(selectSelectedId);
14
+ const delay = useSelector(selectDelay);
14
15
  const timerRef = useRef(null);
15
16
  const timestampRef = useRef();
16
17
  timestampRef.current = fullUpdateRequired ? null : game === null || game === void 0 || (_game$metaData = game.metaData) === null || _game$metaData === void 0 ? void 0 : _game$metaData.timeStamp;
17
18
  const updateGameData = () => {
18
19
  dispatch(fetchGame({
19
20
  id,
20
- start: timestampRef.current
21
+ start: timestampRef.current,
22
+ delay
21
23
  })).unwrap().then(result => {
22
24
  var _result$metaData;
23
25
  const wait = (result && ((_result$metaData = result.metaData) === null || _result$metaData === void 0 ? void 0 : _result$metaData.wait) || 10) * 1000;
@@ -29,7 +31,7 @@ function Game() {
29
31
  return () => {
30
32
  clearTimeout(timerRef.current);
31
33
  };
32
- }, [id]);
34
+ }, [id, delay]);
33
35
  if (!game) {
34
36
  return /*#__PURE__*/React.createElement("element", null);
35
37
  }
@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
2
2
  import { useSelector } from "react-redux/lib/alternate-renderers.js";
3
3
  import Count from "./Count.js";
4
4
  import Bases from "./Bases.js";
5
+ import BoxScore from "./BoxScore.js";
5
6
  import LineScore from "./LineScore.js";
6
7
  import Matchup from "./Matchup.js";
7
8
  import AtBat from "./AtBat.js";
@@ -10,10 +11,27 @@ import InningDisplay from "./InningDisplay.js";
10
11
  import { get } from "../config.js";
11
12
  import { selectGameStatus, selectLineScore, selectTeams } from "../features/games.js";
12
13
  import { resetTitle, setTitle } from "../screen.js";
14
+ import useKey from "../hooks/useKey.js";
15
+ const GAME_STATUS = 'GAME_STATUS';
16
+ const BOX_SCORE = 'BOX_SCORE';
17
+ const SCORING_PLAYS = 'SCORING_PLAYS';
13
18
  function LiveGame() {
14
19
  const gameStatus = useSelector(selectGameStatus);
15
20
  const linescore = useSelector(selectLineScore);
16
21
  const teams = useSelector(selectTeams);
22
+ const [view, setView] = React.useState(GAME_STATUS);
23
+ useKey('g', () => setView(GAME_STATUS), {
24
+ key: 'G',
25
+ label: 'Game Status'
26
+ });
27
+ useKey('b', () => setView(BOX_SCORE), {
28
+ key: 'B',
29
+ label: 'Box Score'
30
+ });
31
+ useKey('p', () => setView(SCORING_PLAYS), {
32
+ key: 'P',
33
+ label: 'Scoring Plays'
34
+ });
17
35
  useEffect(() => {
18
36
  if (get('title')) {
19
37
  const homeRuns = linescore.teams['home'].runs;
@@ -62,7 +80,7 @@ function LiveGame() {
62
80
  type: "line",
63
81
  top: 3,
64
82
  width: "100%"
65
- }), /*#__PURE__*/React.createElement("element", {
83
+ }), view === GAME_STATUS && /*#__PURE__*/React.createElement("element", {
66
84
  top: 4,
67
85
  left: 1
68
86
  }, /*#__PURE__*/React.createElement("element", {
@@ -79,6 +97,16 @@ function LiveGame() {
79
97
  }), /*#__PURE__*/React.createElement("element", {
80
98
  left: "50%+2",
81
99
  width: "50%-2"
82
- }, /*#__PURE__*/React.createElement(AllPlays, null))));
100
+ }, /*#__PURE__*/React.createElement(AllPlays, {
101
+ reverse: true
102
+ }))), view === BOX_SCORE && /*#__PURE__*/React.createElement("element", {
103
+ top: 4,
104
+ left: 1
105
+ }, /*#__PURE__*/React.createElement(BoxScore, null)), view === SCORING_PLAYS && /*#__PURE__*/React.createElement("element", {
106
+ top: 4,
107
+ left: 1
108
+ }, /*#__PURE__*/React.createElement(AllPlays, {
109
+ scoringOnly: true
110
+ })));
83
111
  }
84
112
  export default LiveGame;
@@ -1,6 +1,6 @@
1
1
  import React, { useEffect } from 'react';
2
2
  import { useSelector } from "react-redux/lib/alternate-renderers.js";
3
- import { format } from 'date-fns';
3
+ import { format, isSameDay } from 'date-fns';
4
4
  import { get } from "../config.js";
5
5
  import { selectTeams, selectVenue, selectStartTime, selectBoxscore, selectProbablePitchers, selectGameStatus } from "../features/games.js";
6
6
  import { resetTitle, setTitle } from "../screen.js";
@@ -42,10 +42,9 @@ function PreviewGame() {
42
42
 
43
43
  // Only show the date if it's not today.
44
44
  const startDate = new Date(startTime);
45
- const today = format(new Date(), 'yyyy-DDD');
46
- const gameDay = format(startDate, 'yyyy-DDD');
45
+ const today = new Date();
47
46
  let start = format(startDate, 'p');
48
- if (today !== gameDay) {
47
+ if (!isSameDay(startDate, today)) {
49
48
  start = `${format(startDate, 'MMMM d, yyy')} ${start}`;
50
49
  }
51
50
  setTitle(`${away} - ${home} @ ${start}`);
@@ -0,0 +1,44 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ function formatRow(row, widths) {
4
+ return row.map((cell, idx) => idx === 0 ? cell.padEnd(widths[idx]) : cell.padStart(widths[idx])).join('');
5
+ }
6
+ function Table({
7
+ headers,
8
+ widths,
9
+ rows,
10
+ ...rest
11
+ }) {
12
+ const resolvedWidths = widths.map((width, idx) => {
13
+ if (width !== 'auto') {
14
+ return width;
15
+ }
16
+ return Math.max((headers[idx] || '').length, ...rows.map(row => (row[idx] || '').length));
17
+ });
18
+ const headerRow = formatRow(headers, resolvedWidths);
19
+ const contentRows = rows.map(row => formatRow(row, resolvedWidths));
20
+ return /*#__PURE__*/React.createElement("element", rest, /*#__PURE__*/React.createElement("box", {
21
+ top: 0,
22
+ left: 0,
23
+ width: "100%",
24
+ height: 1,
25
+ fg: "black",
26
+ bg: "white",
27
+ wrap: false,
28
+ content: headerRow
29
+ }), /*#__PURE__*/React.createElement("box", {
30
+ top: 1,
31
+ left: 0,
32
+ width: "100%",
33
+ height: rows.length,
34
+ wrap: false,
35
+ content: contentRows.join('\n')
36
+ }));
37
+ }
38
+ Table.propTypes = {
39
+ headers: PropTypes.arrayOf(PropTypes.string),
40
+ widths: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.oneOf(['auto'])])),
41
+ rows: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
42
+ top: PropTypes.number
43
+ };
44
+ export default Table;
package/dist/config.js CHANGED
@@ -79,6 +79,11 @@ const schema = {
79
79
  'title': {
80
80
  type: 'boolean',
81
81
  default: false
82
+ },
83
+ 'live-delay': {
84
+ type: 'number',
85
+ default: 0,
86
+ minimum: 0
82
87
  }
83
88
  };
84
89
  const config = new Conf({
@@ -92,11 +97,13 @@ function serialize(value) {
92
97
  return value;
93
98
  }
94
99
  function deserialize(key, value) {
95
- var _schema$key, _schema$key2;
100
+ var _schema$key, _schema$key2, _schema$key3;
96
101
  if (value && ((_schema$key = schema[key]) === null || _schema$key === void 0 ? void 0 : _schema$key.type) === 'array') {
97
102
  return value.split(/\s*,\s*/);
98
103
  } else if (((_schema$key2 = schema[key]) === null || _schema$key2 === void 0 ? void 0 : _schema$key2.type) === 'boolean') {
99
104
  return value === 'true';
105
+ } else if (((_schema$key3 = schema[key]) === null || _schema$key3 === void 0 ? void 0 : _schema$key3.type) === 'number') {
106
+ return parseInt(value);
100
107
  }
101
108
  return value;
102
109
  }
@@ -6,19 +6,42 @@ const {
6
6
  createSelector
7
7
  } = reduxjsToolkit;
8
8
  import jsonpatch from 'json-patch';
9
+ import { UTCDate } from '@date-fns/utc';
10
+ import { addSeconds, differenceInSeconds, format } from 'date-fns';
11
+ import logger from "../logger.js";
12
+ import { get } from "../config.js";
9
13
  const initialState = {
10
14
  loading: false,
11
15
  fullUpdateRequired: false,
12
16
  error: null,
13
17
  selectedId: null,
18
+ delay: 0,
14
19
  games: {}
15
20
  };
21
+ function makeDiffParams(start, end) {
22
+ const endParam = start ? '&endTimecode=' : '?timecode=';
23
+ const startParam = start ? `/diffPatch?startTimecode=${start}` : '';
24
+ if (end) {
25
+ return `${startParam}${endParam}${end}`;
26
+ } else {
27
+ return startParam;
28
+ }
29
+ }
16
30
  export const fetchGame = createAsyncThunk('games/fetch', async ({
17
31
  id,
18
- start
32
+ start,
33
+ delay
19
34
  }) => {
20
- const diffParams = start ? `/diffPatch?startTimecode=${start}` : '';
35
+ const end = delay > 0 ? format(addSeconds(new UTCDate(Date.now()), -delay), 'yyyyMMdd_HHmmss') : null;
36
+ const diffParams = makeDiffParams(start, end);
21
37
  const url = `https://statsapi.mlb.com/api/v1.1/game/${id}/feed/live${diffParams}`;
38
+ logger.info(`GET ${url}`);
39
+ const response = await axios.get(url);
40
+ return response.data;
41
+ });
42
+ export const setReplayGame = createAsyncThunk('games/setReplay', async id => {
43
+ const url = `https://statsapi.mlb.com/api/v1.1/game/${id}/feed/live?fields=gamePk,gameData,datetime,gameInfo,dateTime,firstPitch`;
44
+ logger.info(`GET ${url}`);
22
45
  const response = await axios.get(url);
23
46
  return response.data;
24
47
  });
@@ -26,11 +49,28 @@ export const gamesSlice = createSlice({
26
49
  name: 'games',
27
50
  initialState,
28
51
  reducers: {
29
- setSelectedId(state, action) {
52
+ setLiveGame(state, action) {
30
53
  state.selectedId = action.payload;
54
+ state.delay = get('live-delay') || 0;
31
55
  }
32
56
  },
33
57
  extraReducers: builder => {
58
+ builder.addCase(setReplayGame.pending, state => {
59
+ state.loading = true;
60
+ });
61
+ builder.addCase(setReplayGame.fulfilled, (state, action) => {
62
+ state.loading = false;
63
+ state.error = null;
64
+ state.selectedId = action.payload.gamePk;
65
+ state.fullUpdateRequired = true;
66
+ const start = action.payload.gameData.gameInfo.firstPitch || action.payload.gameData.datetime.dateTime;
67
+ state.delay = differenceInSeconds(new Date(), start);
68
+ });
69
+ builder.addCase(setReplayGame.rejected, (state, action) => {
70
+ state.loading = false;
71
+ state.fullUpdateRequired = true;
72
+ state.error = action.error;
73
+ });
34
74
  builder.addCase(fetchGame.pending, state => {
35
75
  state.loading = true;
36
76
  });
@@ -70,13 +110,14 @@ export const gamesSlice = createSlice({
70
110
  }
71
111
  });
72
112
  export const {
73
- setSelectedId
113
+ setLiveGame
74
114
  } = gamesSlice.actions;
75
115
  const gamesRoot = state => state.games;
76
116
  export const selectLoading = createSelector(gamesRoot, root => root.loading);
77
117
  export const selectError = createSelector(gamesRoot, root => root.error);
78
118
  export const selectFullUpdateRequired = createSelector(gamesRoot, root => root.fullUpdateRequired);
79
119
  export const selectSelectedId = createSelector(gamesRoot, root => root.selectedId);
120
+ export const selectDelay = createSelector(gamesRoot, root => root.delay);
80
121
  export const selectGame = createSelector([gamesRoot, selectSelectedId], (root, id) => root.games[id]);
81
122
  const selectLiveData = createSelector(selectGame, game => game.liveData);
82
123
  const selectPlays = createSelector(selectLiveData, data => data.plays);
@@ -90,6 +131,7 @@ export const selectLineScore = createSelector(selectLiveData, data => data.lines
90
131
  export const selectDecisions = createSelector(selectLiveData, data => data.decisions);
91
132
  const selectGameData = createSelector(selectGame, game => game.gameData);
92
133
  export const selectGameStatus = createSelector(selectGameData, game => game.status);
134
+ export const selectPlayers = createSelector(selectGameData, gameData => gameData.players);
93
135
  export const selectTeams = createSelector(selectGameData, gameData => gameData.teams);
94
136
  export const selectVenue = createSelector(selectGameData, gameData => gameData.venue);
95
137
  export const selectStartTime = createSelector(selectGameData, gameData => {
package/dist/main.js CHANGED
@@ -5,7 +5,7 @@ import screen from "./screen.js";
5
5
  import store from "./store/index.js";
6
6
  import log from "./logger.js";
7
7
  import App from "./components/App.js";
8
- export default async function startInterface() {
8
+ export default async function startInterface(options) {
9
9
  raf.polyfill();
10
10
  process.on('uncaughtException', function (error) {
11
11
  log.error('UNCAUGHT EXCEPTION\n' + JSON.stringify(error) + '\n' + error.stack);
@@ -16,5 +16,7 @@ export default async function startInterface() {
16
16
  const reactBlessed = await import('react-blessed');
17
17
  reactBlessed.render( /*#__PURE__*/React.createElement(Provider, {
18
18
  store: store
19
- }, /*#__PURE__*/React.createElement(App, null)), screen());
19
+ }, /*#__PURE__*/React.createElement(App, {
20
+ replayId: options.replay
21
+ })), screen());
20
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playball",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Watch MLB games from the comfort of your terminal",
5
5
  "keywords": [
6
6
  "MLB",
@@ -16,7 +16,8 @@
16
16
  "lint": "eslint --ext .jsx,.js src",
17
17
  "start": "babel src --out-dir dist --watch",
18
18
  "prepublishOnly": "npm run build",
19
- "react-devtools": "react-devtools"
19
+ "react-devtools": "react-devtools",
20
+ "test": "echo \"No tests yet\""
20
21
  },
21
22
  "bin": {
22
23
  "playball": "./bin/playball.js"
@@ -37,12 +38,14 @@
37
38
  "preferGlobal": true,
38
39
  "license": "MIT",
39
40
  "dependencies": {
41
+ "@date-fns/utc": "^2.1.1",
40
42
  "@reduxjs/toolkit": "^1.8.0",
41
43
  "axios": "^0.26.1",
42
44
  "blessed": "^0.1.81",
43
45
  "commander": "^11.0.0",
44
46
  "conf": "^11.0.1",
45
- "date-fns": "^2.28.0",
47
+ "date-fns": "^4.1.0",
48
+ "figlet": "^1.10.0",
46
49
  "json-patch": "^0.7.0",
47
50
  "prop-types": "^15.8.1",
48
51
  "raf": "^3.4.1",