bbdata-cli 0.1.1 → 0.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.
Files changed (41) hide show
  1. package/dist/bin/bbdata.js +1044 -36
  2. package/dist/bin/bbdata.js.map +1 -1
  3. package/dist/src/index.d.ts +55 -2
  4. package/dist/src/index.js +978 -33
  5. package/dist/src/index.js.map +1 -1
  6. package/dist/templates/queries/hitter-batted-ball.ts +66 -0
  7. package/dist/templates/queries/hitter-hot-cold-zones.ts +81 -0
  8. package/dist/templates/queries/hitter-raw-bip.ts +65 -0
  9. package/dist/templates/queries/hitter-vs-pitch-type.ts +78 -0
  10. package/dist/templates/queries/hitter-zone-grid.ts +90 -0
  11. package/dist/templates/queries/index.ts +27 -0
  12. package/dist/templates/queries/leaderboard-comparison.ts +72 -0
  13. package/dist/templates/queries/leaderboard-custom.ts +90 -0
  14. package/dist/templates/queries/matchup-pitcher-vs-hitter.ts +81 -0
  15. package/dist/templates/queries/matchup-situational.ts +68 -0
  16. package/dist/templates/queries/pitcher-arsenal.ts +89 -0
  17. package/dist/templates/queries/pitcher-handedness-splits.ts +81 -0
  18. package/dist/templates/queries/pitcher-raw-pitches.ts +62 -0
  19. package/dist/templates/queries/pitcher-velocity-trend.ts +73 -0
  20. package/dist/templates/queries/registry.ts +73 -0
  21. package/dist/templates/queries/trend-rolling-average.ts +98 -0
  22. package/dist/templates/queries/trend-year-over-year.ts +73 -0
  23. package/dist/templates/reports/advance-lineup.hbs +29 -0
  24. package/dist/templates/reports/advance-sp.hbs +66 -0
  25. package/dist/templates/reports/college-hitter-draft.hbs +49 -0
  26. package/dist/templates/reports/college-pitcher-draft.hbs +48 -0
  27. package/dist/templates/reports/dev-progress.hbs +29 -0
  28. package/dist/templates/reports/draft-board-card.hbs +35 -0
  29. package/dist/templates/reports/hs-prospect.hbs +48 -0
  30. package/dist/templates/reports/partials/footer.hbs +7 -0
  31. package/dist/templates/reports/partials/header.hbs +12 -0
  32. package/dist/templates/reports/post-promotion.hbs +25 -0
  33. package/dist/templates/reports/pro-hitter-eval.hbs +77 -0
  34. package/dist/templates/reports/pro-pitcher-eval.hbs +81 -0
  35. package/dist/templates/reports/registry.ts +215 -0
  36. package/dist/templates/reports/relief-pitcher-quick.hbs +29 -0
  37. package/dist/templates/reports/trade-target-onepager.hbs +45 -0
  38. package/package.json +68 -63
  39. package/src/templates/reports/advance-sp.hbs +66 -60
  40. package/src/templates/reports/pro-hitter-eval.hbs +77 -65
  41. package/src/templates/reports/pro-pitcher-eval.hbs +81 -69
@@ -0,0 +1,68 @@
1
+ import { registerTemplate, type QueryTemplate } from './registry.js';
2
+ import type { PlayerStats } from '../../adapters/types.js';
3
+
4
+ const template: QueryTemplate = {
5
+ id: 'matchup-situational',
6
+ name: 'Situational Splits',
7
+ category: 'matchup',
8
+ description: 'Performance in key situations — RISP, high leverage, close & late, by inning',
9
+ preferredSources: ['fangraphs', 'mlb-stats-api'],
10
+ requiredParams: ['player'],
11
+ optionalParams: ['season'],
12
+ examples: [
13
+ 'bbdata query matchup-situational --player "Juan Soto" --season 2025',
14
+ ],
15
+
16
+ buildQuery(params) {
17
+ return {
18
+ player_name: params.player,
19
+ season: params.season ?? new Date().getFullYear(),
20
+ stat_type: 'batting',
21
+ };
22
+ },
23
+
24
+ columns() {
25
+ return ['Situation', 'PA', 'AVG', 'OBP', 'SLG', 'K %', 'BB %'];
26
+ },
27
+
28
+ transform(data) {
29
+ // Situational splits typically come pre-computed from FanGraphs
30
+ // With raw pitch data this would require game state context
31
+ // This template works best with FanGraphs/MLB API aggregated splits
32
+ const stats = data as PlayerStats[];
33
+
34
+ if (stats.length === 0) return [];
35
+
36
+ const player = stats[0];
37
+ const s = player.stats;
38
+
39
+ // Return whatever aggregated stats we have — format for display
40
+ return [
41
+ {
42
+ Situation: 'Overall',
43
+ PA: s.plateAppearances ?? s.PA ?? '—',
44
+ AVG: formatStat(s.avg ?? s.AVG),
45
+ OBP: formatStat(s.obp ?? s.OBP),
46
+ SLG: formatStat(s.slg ?? s.SLG),
47
+ 'K %': formatPct(s.strikeOuts ?? s.SO, s.plateAppearances ?? s.PA),
48
+ 'BB %': formatPct(s.baseOnBalls ?? s.BB, s.plateAppearances ?? s.PA),
49
+ },
50
+ ];
51
+ },
52
+ };
53
+
54
+ function formatStat(val: unknown): string {
55
+ if (val === null || val === undefined) return '—';
56
+ const n = Number(val);
57
+ if (isNaN(n)) return String(val);
58
+ return n < 1 ? n.toFixed(3) : n.toFixed(1);
59
+ }
60
+
61
+ function formatPct(num: unknown, denom: unknown): string {
62
+ const n = Number(num);
63
+ const d = Number(denom);
64
+ if (isNaN(n) || isNaN(d) || d === 0) return '—';
65
+ return ((n / d) * 100).toFixed(1) + '%';
66
+ }
67
+
68
+ registerTemplate(template);
@@ -0,0 +1,89 @@
1
+ import { registerTemplate, type QueryTemplate, type QueryTemplateParams } from './registry.js';
2
+ import type { PitchData } from '../../adapters/types.js';
3
+ import { pitchTypeName } from '../../adapters/types.js';
4
+
5
+ const template: QueryTemplate = {
6
+ id: 'pitcher-arsenal',
7
+ name: 'Pitcher Arsenal Profile',
8
+ category: 'pitcher',
9
+ description: 'Pitch usage rates, velocity, spin, movement, and whiff rates by pitch type',
10
+ preferredSources: ['savant', 'fangraphs', 'mlb-stats-api'],
11
+ requiredParams: ['player'],
12
+ optionalParams: ['season', 'pitchType'],
13
+ examples: [
14
+ 'bbdata query pitcher-arsenal --player "Corbin Burnes" --season 2025',
15
+ 'bbdata query pitcher-arsenal --player "Spencer Strider"',
16
+ ],
17
+
18
+ buildQuery(params) {
19
+ return {
20
+ player_name: params.player,
21
+ season: params.season ?? new Date().getFullYear(),
22
+ stat_type: 'pitching',
23
+ pitch_type: params.pitchType ? [params.pitchType] : undefined,
24
+ };
25
+ },
26
+
27
+ columns() {
28
+ return [
29
+ 'Pitch Type',
30
+ 'Usage %',
31
+ 'Avg Velo',
32
+ 'Avg Spin',
33
+ 'H Break',
34
+ 'V Break',
35
+ 'Whiff %',
36
+ 'Put Away %',
37
+ 'Pitches',
38
+ ];
39
+ },
40
+
41
+ transform(data, _params) {
42
+ const pitches = data as PitchData[];
43
+ if (pitches.length === 0) return [];
44
+
45
+ // Group by pitch type
46
+ const byType = new Map<string, PitchData[]>();
47
+ for (const pitch of pitches) {
48
+ if (!pitch.pitch_type) continue;
49
+ const group = byType.get(pitch.pitch_type) ?? [];
50
+ group.push(pitch);
51
+ byType.set(pitch.pitch_type, group);
52
+ }
53
+
54
+ const total = pitches.length;
55
+
56
+ return Array.from(byType.entries())
57
+ .map(([type, group]) => {
58
+ const count = group.length;
59
+ const swings = group.filter((p) =>
60
+ p.description.includes('swing') || p.description.includes('foul'),
61
+ );
62
+ const whiffs = group.filter((p) =>
63
+ p.description.includes('swinging_strike'),
64
+ );
65
+ const twoStrikes = group.filter((p) =>
66
+ p.description.includes('strikeout') || p.description.includes('swinging_strike'),
67
+ );
68
+
69
+ return {
70
+ 'Pitch Type': pitchTypeName(type),
71
+ 'Usage %': ((count / total) * 100).toFixed(1) + '%',
72
+ 'Avg Velo': (group.reduce((s, p) => s + p.release_speed, 0) / count).toFixed(1) + ' mph',
73
+ 'Avg Spin': Math.round(group.reduce((s, p) => s + p.release_spin_rate, 0) / count) + ' rpm',
74
+ 'H Break': (group.reduce((s, p) => s + p.pfx_x, 0) / count).toFixed(1) + ' in',
75
+ 'V Break': (group.reduce((s, p) => s + p.pfx_z, 0) / count).toFixed(1) + ' in',
76
+ 'Whiff %': swings.length > 0
77
+ ? ((whiffs.length / swings.length) * 100).toFixed(1) + '%'
78
+ : '—',
79
+ 'Put Away %': twoStrikes.length > 0
80
+ ? ((whiffs.length / count) * 100).toFixed(1) + '%'
81
+ : '—',
82
+ 'Pitches': count,
83
+ };
84
+ })
85
+ .sort((a, b) => (b.Pitches as number) - (a.Pitches as number));
86
+ },
87
+ };
88
+
89
+ registerTemplate(template);
@@ -0,0 +1,81 @@
1
+ import { registerTemplate, type QueryTemplate } from './registry.js';
2
+ import type { PitchData } from '../../adapters/types.js';
3
+
4
+ const template: QueryTemplate = {
5
+ id: 'pitcher-handedness-splits',
6
+ name: 'Pitcher Handedness Splits',
7
+ category: 'pitcher',
8
+ description: 'Performance splits vs LHH and RHH — BA, SLG, K%, BB%, pitch mix, exit velocity',
9
+ preferredSources: ['savant', 'fangraphs'],
10
+ requiredParams: ['player'],
11
+ optionalParams: ['season'],
12
+ examples: [
13
+ 'bbdata query pitcher-handedness-splits --player "Blake Snell" --season 2025',
14
+ ],
15
+
16
+ buildQuery(params) {
17
+ return {
18
+ player_name: params.player,
19
+ season: params.season ?? new Date().getFullYear(),
20
+ stat_type: 'pitching',
21
+ };
22
+ },
23
+
24
+ columns() {
25
+ return ['vs', 'PA', 'AVG', 'SLG', 'K %', 'BB %', 'Avg EV', 'Whiff %'];
26
+ },
27
+
28
+ transform(data) {
29
+ const pitches = data as PitchData[];
30
+ if (pitches.length === 0) return [];
31
+
32
+ return (['L', 'R'] as const).map((hand) => {
33
+ const group = pitches.filter((p) => p.stand === hand);
34
+ if (group.length === 0) {
35
+ return { vs: `vs ${hand}HH`, PA: 0, AVG: '—', SLG: '—', 'K %': '—', 'BB %': '—', 'Avg EV': '—', 'Whiff %': '—' };
36
+ }
37
+
38
+ // Plate appearances (approximate: count events)
39
+ const pas = group.filter((p) => p.events !== null);
40
+ const hits = pas.filter((p) =>
41
+ ['single', 'double', 'triple', 'home_run'].includes(p.events ?? ''),
42
+ );
43
+ const strikeouts = pas.filter((p) => p.events === 'strikeout');
44
+ const walks = pas.filter((p) => ['walk', 'hit_by_pitch'].includes(p.events ?? ''));
45
+
46
+ // Exit velocity on contact
47
+ const contacted = group.filter((p) => p.launch_speed !== null && p.launch_speed > 0);
48
+ const avgEv = contacted.length > 0
49
+ ? contacted.reduce((s, p) => s + (p.launch_speed ?? 0), 0) / contacted.length
50
+ : null;
51
+
52
+ // Total bases for SLG
53
+ const totalBases = pas.reduce((sum, p) => {
54
+ if (p.events === 'single') return sum + 1;
55
+ if (p.events === 'double') return sum + 2;
56
+ if (p.events === 'triple') return sum + 3;
57
+ if (p.events === 'home_run') return sum + 4;
58
+ return sum;
59
+ }, 0);
60
+
61
+ // Whiff rate
62
+ const swings = group.filter((p) =>
63
+ p.description.includes('swing') || p.description.includes('foul'),
64
+ );
65
+ const whiffs = group.filter((p) => p.description.includes('swinging_strike'));
66
+
67
+ return {
68
+ vs: `vs ${hand}HH`,
69
+ PA: pas.length,
70
+ AVG: pas.length > 0 ? (hits.length / pas.length).toFixed(3) : '—',
71
+ SLG: pas.length > 0 ? (totalBases / pas.length).toFixed(3) : '—',
72
+ 'K %': pas.length > 0 ? ((strikeouts.length / pas.length) * 100).toFixed(1) + '%' : '—',
73
+ 'BB %': pas.length > 0 ? ((walks.length / pas.length) * 100).toFixed(1) + '%' : '—',
74
+ 'Avg EV': avgEv !== null ? avgEv.toFixed(1) + ' mph' : '—',
75
+ 'Whiff %': swings.length > 0 ? ((whiffs.length / swings.length) * 100).toFixed(1) + '%' : '—',
76
+ };
77
+ });
78
+ },
79
+ };
80
+
81
+ registerTemplate(template);
@@ -0,0 +1,62 @@
1
+ import { registerTemplate, type QueryTemplate } from './registry.js';
2
+ import type { PitchData } from '../../adapters/types.js';
3
+
4
+ /**
5
+ * Raw pitch-level projection — one row per pitch, coordinate columns preserved.
6
+ * Powers the pitch-movement visualization (pfx_x, pfx_z scatter).
7
+ * Unlike pitcher-arsenal this does NOT aggregate; viz builders need per-pitch points.
8
+ */
9
+ const template: QueryTemplate = {
10
+ id: 'pitcher-raw-pitches',
11
+ name: 'Pitcher Raw Pitches',
12
+ category: 'pitcher',
13
+ description: 'One row per pitch with coordinate columns for visualization (movement, location)',
14
+ preferredSources: ['savant'],
15
+ requiredParams: ['player'],
16
+ optionalParams: ['season', 'pitchType'],
17
+ examples: [
18
+ 'bbdata query pitcher-raw-pitches --player "Corbin Burnes" --season 2025 --format json',
19
+ ],
20
+
21
+ buildQuery(params) {
22
+ return {
23
+ player_name: params.player,
24
+ season: params.season ?? new Date().getFullYear(),
25
+ stat_type: 'pitching',
26
+ pitch_type: params.pitchType ? [params.pitchType] : undefined,
27
+ };
28
+ },
29
+
30
+ columns() {
31
+ return [
32
+ 'pitch_type',
33
+ 'release_speed',
34
+ 'release_spin_rate',
35
+ 'pfx_x',
36
+ 'pfx_z',
37
+ 'plate_x',
38
+ 'plate_z',
39
+ 'game_date',
40
+ ];
41
+ },
42
+
43
+ transform(data) {
44
+ const pitches = data as PitchData[];
45
+ if (pitches.length === 0) return [];
46
+
47
+ return pitches
48
+ .filter((p) => p.pitch_type)
49
+ .map((p) => ({
50
+ pitch_type: p.pitch_type,
51
+ release_speed: p.release_speed,
52
+ release_spin_rate: p.release_spin_rate,
53
+ pfx_x: p.pfx_x,
54
+ pfx_z: p.pfx_z,
55
+ plate_x: p.plate_x,
56
+ plate_z: p.plate_z,
57
+ game_date: p.game_date,
58
+ }));
59
+ },
60
+ };
61
+
62
+ registerTemplate(template);
@@ -0,0 +1,73 @@
1
+ import { registerTemplate, type QueryTemplate } from './registry.js';
2
+ import type { PitchData } from '../../adapters/types.js';
3
+ import { pitchTypeName } from '../../adapters/types.js';
4
+
5
+ const template: QueryTemplate = {
6
+ id: 'pitcher-velocity-trend',
7
+ name: 'Pitcher Velocity Trend',
8
+ category: 'pitcher',
9
+ description: 'Month-by-month fastball velocity tracking — flags drops > 0.5 mph',
10
+ preferredSources: ['savant'],
11
+ requiredParams: ['player'],
12
+ optionalParams: ['season'],
13
+ examples: [
14
+ 'bbdata query pitcher-velocity-trend --player "Gerrit Cole" --season 2025',
15
+ ],
16
+
17
+ buildQuery(params) {
18
+ return {
19
+ player_name: params.player,
20
+ season: params.season ?? new Date().getFullYear(),
21
+ stat_type: 'pitching',
22
+ };
23
+ },
24
+
25
+ columns() {
26
+ return ['Month', 'Avg Velo', 'Max Velo', 'Min Velo', 'Δ vs Prior', 'Pitches', 'Flag'];
27
+ },
28
+
29
+ transform(data) {
30
+ const pitches = (data as PitchData[]).filter(
31
+ (p) => ['FF', 'SI', 'FC'].includes(p.pitch_type) && p.release_speed > 0,
32
+ );
33
+
34
+ if (pitches.length === 0) return [];
35
+
36
+ // Group by month
37
+ const byMonth = new Map<string, PitchData[]>();
38
+ for (const pitch of pitches) {
39
+ const month = pitch.game_date.slice(0, 7); // YYYY-MM
40
+ const group = byMonth.get(month) ?? [];
41
+ group.push(pitch);
42
+ byMonth.set(month, group);
43
+ }
44
+
45
+ const months = Array.from(byMonth.entries()).sort(([a], [b]) => a.localeCompare(b));
46
+ let prevAvg: number | null = null;
47
+
48
+ return months.map(([month, group]) => {
49
+ const velos = group.map((p) => p.release_speed);
50
+ const avg = velos.reduce((s, v) => s + v, 0) / velos.length;
51
+ const max = Math.max(...velos);
52
+ const min = Math.min(...velos);
53
+
54
+ const delta = prevAvg !== null ? avg - prevAvg : 0;
55
+ const flag = prevAvg !== null && delta < -0.5 ? '⚠ DROP' : '';
56
+ prevAvg = avg;
57
+
58
+ return {
59
+ Month: month,
60
+ 'Avg Velo': avg.toFixed(1) + ' mph',
61
+ 'Max Velo': max.toFixed(1) + ' mph',
62
+ 'Min Velo': min.toFixed(1) + ' mph',
63
+ 'Δ vs Prior': prevAvg !== null && delta !== 0
64
+ ? (delta > 0 ? '+' : '') + delta.toFixed(1) + ' mph'
65
+ : '—',
66
+ Pitches: group.length,
67
+ Flag: flag,
68
+ };
69
+ });
70
+ },
71
+ };
72
+
73
+ registerTemplate(template);
@@ -0,0 +1,73 @@
1
+ import type { DataSource, AdapterQuery, PitchData, PlayerStats } from '../../adapters/types.js';
2
+
3
+ export type QueryCategory = 'pitcher' | 'hitter' | 'matchup' | 'leaderboard' | 'trend';
4
+
5
+ export interface QueryTemplateParams {
6
+ player?: string;
7
+ players?: string[];
8
+ team?: string;
9
+ season?: number;
10
+ stat?: string;
11
+ pitchType?: string;
12
+ minPa?: number;
13
+ minIp?: number;
14
+ top?: number;
15
+ seasons?: string; // "2023-2025"
16
+ }
17
+
18
+ export interface QueryResult {
19
+ rows: Record<string, unknown>[];
20
+ columns: string[];
21
+ title: string;
22
+ description: string;
23
+ source: DataSource;
24
+ cached: boolean;
25
+ }
26
+
27
+ export interface QueryTemplate {
28
+ id: string;
29
+ name: string;
30
+ category: QueryCategory;
31
+ description: string;
32
+ preferredSources: DataSource[];
33
+ requiredParams: (keyof QueryTemplateParams)[];
34
+ optionalParams: (keyof QueryTemplateParams)[];
35
+ examples: string[];
36
+
37
+ /** Build the adapter query from user-provided params */
38
+ buildQuery(params: QueryTemplateParams): AdapterQuery;
39
+
40
+ /** Transform raw adapter data into display rows */
41
+ transform(data: PitchData[] | PlayerStats[], params: QueryTemplateParams): Record<string, unknown>[];
42
+
43
+ /** Column names for display */
44
+ columns(params: QueryTemplateParams): string[];
45
+ }
46
+
47
+ // Template registry
48
+ const templates = new Map<string, QueryTemplate>();
49
+
50
+ export function registerTemplate(template: QueryTemplate): void {
51
+ templates.set(template.id, template);
52
+ }
53
+
54
+ export function getTemplate(id: string): QueryTemplate | undefined {
55
+ return templates.get(id);
56
+ }
57
+
58
+ export function getAllTemplates(): QueryTemplate[] {
59
+ return Array.from(templates.values());
60
+ }
61
+
62
+ export function getTemplatesByCategory(category: QueryCategory): QueryTemplate[] {
63
+ return getAllTemplates().filter((t) => t.category === category);
64
+ }
65
+
66
+ export function listTemplates(): { id: string; name: string; category: string; description: string }[] {
67
+ return getAllTemplates().map((t) => ({
68
+ id: t.id,
69
+ name: t.name,
70
+ category: t.category,
71
+ description: t.description,
72
+ }));
73
+ }
@@ -0,0 +1,98 @@
1
+ import { registerTemplate, type QueryTemplate } from './registry.js';
2
+ import type { PitchData } from '../../adapters/types.js';
3
+
4
+ const template: QueryTemplate = {
5
+ id: 'trend-rolling-average',
6
+ name: 'Season Trend (Rolling Average)',
7
+ category: 'trend',
8
+ description: '15-game (hitters) or 5-start (pitchers) rolling averages — identifies sustained trends',
9
+ preferredSources: ['savant'],
10
+ requiredParams: ['player'],
11
+ optionalParams: ['season'],
12
+ examples: [
13
+ 'bbdata query trend-rolling-average --player "Freddie Freeman" --season 2025',
14
+ ],
15
+
16
+ buildQuery(params) {
17
+ return {
18
+ player_name: params.player,
19
+ season: params.season ?? new Date().getFullYear(),
20
+ stat_type: 'batting',
21
+ };
22
+ },
23
+
24
+ columns() {
25
+ return ['Window', 'Window End', 'Games', 'AVG', 'SLG', 'K %', 'Avg EV', 'Hard Hit %'];
26
+ },
27
+
28
+ transform(data) {
29
+ const pitches = data as PitchData[];
30
+ if (pitches.length === 0) return [];
31
+
32
+ // Group by game date
33
+ const byDate = new Map<string, PitchData[]>();
34
+ for (const p of pitches) {
35
+ const group = byDate.get(p.game_date) ?? [];
36
+ group.push(p);
37
+ byDate.set(p.game_date, group);
38
+ }
39
+
40
+ const dates = Array.from(byDate.keys()).sort();
41
+ const windowSize = 15;
42
+
43
+ if (dates.length < windowSize) {
44
+ return [{
45
+ Window: 'Insufficient data',
46
+ 'Window End': '',
47
+ Games: dates.length,
48
+ AVG: '—',
49
+ SLG: '—',
50
+ 'K %': '—',
51
+ 'Avg EV': '—',
52
+ 'Hard Hit %': '—',
53
+ }];
54
+ }
55
+
56
+ // Calculate rolling windows
57
+ const results: Record<string, unknown>[] = [];
58
+
59
+ for (let i = 0; i <= dates.length - windowSize; i += Math.max(1, Math.floor(windowSize / 3))) {
60
+ const windowDates = dates.slice(i, i + windowSize);
61
+ const windowPitches = windowDates.flatMap((d) => byDate.get(d) ?? []);
62
+
63
+ const pas = windowPitches.filter((p) => p.events !== null);
64
+ const hits = pas.filter((p) => ['single', 'double', 'triple', 'home_run'].includes(p.events ?? ''));
65
+ const ks = pas.filter((p) => p.events === 'strikeout');
66
+ const totalBases = pas.reduce((sum, p) => {
67
+ if (p.events === 'single') return sum + 1;
68
+ if (p.events === 'double') return sum + 2;
69
+ if (p.events === 'triple') return sum + 3;
70
+ if (p.events === 'home_run') return sum + 4;
71
+ return sum;
72
+ }, 0);
73
+
74
+ const batted = windowPitches.filter((p) => p.launch_speed !== null && p.launch_speed > 0);
75
+ const avgEv = batted.length > 0
76
+ ? batted.reduce((s, p) => s + p.launch_speed!, 0) / batted.length
77
+ : null;
78
+ const hardHit = batted.filter((p) => p.launch_speed! >= 95).length;
79
+
80
+ const windowEnd = windowDates[windowDates.length - 1]!;
81
+
82
+ results.push({
83
+ Window: `${windowDates[0]} → ${windowEnd}`,
84
+ 'Window End': windowEnd,
85
+ Games: windowDates.length,
86
+ AVG: pas.length > 0 ? (hits.length / pas.length).toFixed(3) : '—',
87
+ SLG: pas.length > 0 ? (totalBases / pas.length).toFixed(3) : '—',
88
+ 'K %': pas.length > 0 ? ((ks.length / pas.length) * 100).toFixed(1) + '%' : '—',
89
+ 'Avg EV': avgEv !== null ? avgEv.toFixed(1) + ' mph' : '—',
90
+ 'Hard Hit %': batted.length > 0 ? ((hardHit / batted.length) * 100).toFixed(1) + '%' : '—',
91
+ });
92
+ }
93
+
94
+ return results;
95
+ },
96
+ };
97
+
98
+ registerTemplate(template);
@@ -0,0 +1,73 @@
1
+ import { registerTemplate, type QueryTemplate } from './registry.js';
2
+ import type { PlayerStats } from '../../adapters/types.js';
3
+
4
+ const template: QueryTemplate = {
5
+ id: 'trend-year-over-year',
6
+ name: 'Year-over-Year Comparison',
7
+ category: 'trend',
8
+ description: 'Compare metric changes year to year — flags changes greater than 10%',
9
+ preferredSources: ['fangraphs', 'mlb-stats-api'],
10
+ requiredParams: ['player'],
11
+ optionalParams: ['seasons'], // e.g. "2023-2025"
12
+ examples: [
13
+ 'bbdata query trend-year-over-year --player "Julio Rodriguez" --seasons 2023-2025',
14
+ ],
15
+
16
+ buildQuery(params) {
17
+ // We'll need multiple seasons — use the most recent by default
18
+ const season = params.season ?? new Date().getFullYear();
19
+ return {
20
+ player_name: params.player,
21
+ season,
22
+ stat_type: 'batting',
23
+ };
24
+ },
25
+
26
+ columns() {
27
+ return ['Metric', 'Prior', 'Current', 'Change', 'Flag'];
28
+ },
29
+
30
+ transform(data, params) {
31
+ const stats = data as PlayerStats[];
32
+ if (stats.length === 0) return [];
33
+
34
+ // If we have multi-season data, compare last two
35
+ // With single-season data, we show what we have
36
+ const player = stats[0];
37
+ const s = player.stats;
38
+
39
+ // Key metrics to compare
40
+ const metrics = ['AVG', 'OBP', 'SLG', 'HR', 'wRC+', 'K%', 'BB%', 'WAR', 'ISO', 'BABIP'];
41
+
42
+ return metrics.map((metric) => {
43
+ const current = findStat(s, metric);
44
+ // Without multi-season support in a single query, we show current only
45
+ // Full YoY requires querying multiple seasons (future enhancement)
46
+ return {
47
+ Metric: metric,
48
+ Prior: '—',
49
+ Current: current !== null ? formatVal(current) : '—',
50
+ Change: '—',
51
+ Flag: '',
52
+ };
53
+ });
54
+ },
55
+ };
56
+
57
+ function findStat(stats: Record<string, unknown>, key: string): number | null {
58
+ const lower = key.toLowerCase().replace(/[+%]/g, '');
59
+ for (const [k, v] of Object.entries(stats)) {
60
+ if (k.toLowerCase().replace(/[+%]/g, '') === lower) {
61
+ const n = Number(v);
62
+ return isNaN(n) ? null : n;
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function formatVal(n: number): string {
69
+ if (n < 1 && n > 0) return n.toFixed(3);
70
+ return n % 1 === 0 ? String(n) : n.toFixed(1);
71
+ }
72
+
73
+ registerTemplate(template);
@@ -0,0 +1,29 @@
1
+ # Advance Report: {{team}} Lineup
2
+
3
+ **Season:** {{season}} | **For:** {{audience}} | **Generated:** {{date}}
4
+
5
+ ---
6
+
7
+ ## Header
8
+
9
+ | Field | Value |
10
+ |-------|-------|
11
+ | Opponent | {{team}} |
12
+ | Season | {{season}} |
13
+
14
+ ## Lineup Overview
15
+
16
+ *Projected lineup, team tendencies, overall approach — to be populated*
17
+
18
+ ## Hitter Breakdowns
19
+
20
+ *Individual hitter cards — to be generated per player:*
21
+ *Strengths, weaknesses, pitch approach, hot/cold zones*
22
+
23
+ ## Key Matchups
24
+
25
+ *Critical at-bats to plan for — to be filled by coaching staff*
26
+
27
+ ---
28
+
29
+ *Generated by bbdata CLI · 1-page advance format*