Codex commited on
Commit
8fa8287
·
1 Parent(s): 6a0d361

Add sportsbook breakdown and bet book choice

Browse files
Files changed (8) hide show
  1. README.md +1 -0
  2. src/commands.js +17 -1
  3. src/db.js +42 -0
  4. src/embeds.js +28 -0
  5. src/index.js +22 -13
  6. src/parser.js +2 -2
  7. test/db.test.js +47 -0
  8. test/parser.test.js +13 -0
README.md CHANGED
@@ -19,6 +19,7 @@ A simple Discord bot for manually tracking bets and ROI per user. Each Discord u
19
  - `/roi` shows net profit, ROI percent, and a cumulative profit chart
20
  - `/bets` shows total bet count and the latest 5 bets
21
  - `/summary` shows win/loss totals plus a chart
 
22
  - `/commands` posts a public command reference embed
23
  - `/welcome` posts a public welcome embed and tags everyone
24
  - CockroachDB / Postgres-compatible persistence via `DATABASE_URL`
 
19
  - `/roi` shows net profit, ROI percent, and a cumulative profit chart
20
  - `/bets` shows total bet count and the latest 5 bets
21
  - `/summary` shows win/loss totals plus a chart
22
+ - `/books` shows ROI and win rate by sportsbook
23
  - `/commands` posts a public command reference embed
24
  - `/welcome` posts a public welcome embed and tags everyone
25
  - CockroachDB / Postgres-compatible persistence via `DATABASE_URL`
src/commands.js CHANGED
@@ -3,7 +3,20 @@ import { SlashCommandBuilder } from 'discord.js';
3
  export const commands = [
4
  new SlashCommandBuilder()
5
  .setName('bet')
6
- .setDescription('Log a new bet using a private modal.'),
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  new SlashCommandBuilder()
8
  .setName('resolve')
9
  .setDescription('Resolve one of your tracked bets.')
@@ -30,6 +43,9 @@ export const commands = [
30
  new SlashCommandBuilder()
31
  .setName('summary')
32
  .setDescription('Show your full betting summary and chart.'),
 
 
 
33
  new SlashCommandBuilder()
34
  .setName('commands')
35
  .setDescription('Post a public command reference embed.'),
 
3
  export const commands = [
4
  new SlashCommandBuilder()
5
  .setName('bet')
6
+ .setDescription('Log a new bet using a private modal.')
7
+ .addStringOption((option) =>
8
+ option
9
+ .setName('book')
10
+ .setDescription('Choose the sportsbook for this bet.')
11
+ .setRequired(true)
12
+ .addChoices(
13
+ { name: 'FanDuel', value: 'FanDuel' },
14
+ { name: 'DraftKings', value: 'DraftKings' },
15
+ { name: 'BetMGM', value: 'BetMGM' },
16
+ { name: 'Ceasars', value: 'Ceasars' },
17
+ { name: 'Bet365', value: 'Bet365' }
18
+ )
19
+ ),
20
  new SlashCommandBuilder()
21
  .setName('resolve')
22
  .setDescription('Resolve one of your tracked bets.')
 
43
  new SlashCommandBuilder()
44
  .setName('summary')
45
  .setDescription('Show your full betting summary and chart.'),
46
+ new SlashCommandBuilder()
47
+ .setName('books')
48
+ .setDescription('Show your ROI and win rate broken down by sportsbook.'),
49
  new SlashCommandBuilder()
50
  .setName('commands')
51
  .setDescription('Post a public command reference embed.'),
src/db.js CHANGED
@@ -310,6 +310,48 @@ export class BetStore {
310
  });
311
  }
312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  async close() {
314
  await this.pool.end();
315
  }
 
310
  });
311
  }
312
 
313
+ async getBookBreakdown(userId) {
314
+ const { rows } = await this.pool.query(
315
+ `
316
+ SELECT
317
+ book,
318
+ COUNT(*) AS total_bets,
319
+ COALESCE(SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END), 0) AS open_bets,
320
+ COALESCE(SUM(CASE WHEN status = 'win' THEN 1 ELSE 0 END), 0) AS wins,
321
+ COALESCE(SUM(CASE WHEN status = 'loss' THEN 1 ELSE 0 END), 0) AS losses,
322
+ COALESCE(SUM(CASE WHEN status = 'void' THEN 1 ELSE 0 END), 0) AS voids,
323
+ COALESCE(SUM(CASE WHEN status IN ('win', 'loss') THEN stake ELSE 0 END), 0) AS settled_stake,
324
+ COALESCE(SUM(CASE WHEN status IN ('win', 'loss', 'void') THEN profit_loss ELSE 0 END), 0) AS net_profit
325
+ FROM bets
326
+ WHERE discord_user_id = $1
327
+ GROUP BY book
328
+ ORDER BY book ASC
329
+ `,
330
+ [userId]
331
+ );
332
+
333
+ return rows.map((row) => {
334
+ const wins = Number(row.wins ?? 0);
335
+ const losses = Number(row.losses ?? 0);
336
+ const settledStake = Number(row.settled_stake ?? 0);
337
+ const netProfit = Number(row.net_profit ?? 0);
338
+ const gradedBets = wins + losses;
339
+
340
+ return {
341
+ book: row.book,
342
+ totalBets: Number(row.total_bets ?? 0),
343
+ openBets: Number(row.open_bets ?? 0),
344
+ wins,
345
+ losses,
346
+ voids: Number(row.voids ?? 0),
347
+ settledStake,
348
+ netProfit,
349
+ roiPercent: settledStake > 0 ? (netProfit / settledStake) * 100 : 0,
350
+ winRatePercent: gradedBets > 0 ? (wins / gradedBets) * 100 : 0,
351
+ };
352
+ });
353
+ }
354
+
355
  async close() {
356
  await this.pool.end();
357
  }
src/embeds.js CHANGED
@@ -120,6 +120,7 @@ export function buildCommandsEmbed() {
120
  { name: '/roi', value: 'Show your net profit, ROI %, and cumulative chart.' },
121
  { name: '/bets', value: 'Show your total tracked bets and latest 5 bets.' },
122
  { name: '/summary', value: 'Show full totals, record, ROI %, and chart.' },
 
123
  { name: '/commands', value: 'Post this public command reference embed.' },
124
  { name: '/welcome', value: 'Post the public welcome embed and tag everyone.' }
125
  );
@@ -144,3 +145,30 @@ function truncate(value, maxLength) {
144
  }
145
  return `${value.slice(0, maxLength - 3)}...`;
146
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  { name: '/roi', value: 'Show your net profit, ROI %, and cumulative chart.' },
121
  { name: '/bets', value: 'Show your total tracked bets and latest 5 bets.' },
122
  { name: '/summary', value: 'Show full totals, record, ROI %, and chart.' },
123
+ { name: '/books', value: 'Show ROI and win rate broken down by sportsbook.' },
124
  { name: '/commands', value: 'Post this public command reference embed.' },
125
  { name: '/welcome', value: 'Post the public welcome embed and tag everyone.' }
126
  );
 
145
  }
146
  return `${value.slice(0, maxLength - 3)}...`;
147
  }
148
+
149
+ export function buildBooksEmbed(bookRows) {
150
+ const embed = new EmbedBuilder()
151
+ .setColor(0x0f766e)
152
+ .setTitle('Sportsbook Breakdown');
153
+
154
+ if (bookRows.length === 0) {
155
+ return embed.setDescription('No bets tracked yet.');
156
+ }
157
+
158
+ embed.setDescription('ROI and win rate for each sportsbook.');
159
+ embed.addFields(
160
+ bookRows.map((row) => ({
161
+ name: row.book,
162
+ value: [
163
+ `ROI: ${formatPercent(row.roiPercent)}`,
164
+ `Net: ${formatCurrency(row.netProfit)}`,
165
+ `Win Rate: ${formatPercent(row.winRatePercent)}`,
166
+ `Record: ${row.wins}-${row.losses}-${row.voids}`,
167
+ `Open: ${row.openBets} | Total: ${row.totalBets}`,
168
+ ].join('\n'),
169
+ inline: true,
170
+ }))
171
+ );
172
+
173
+ return embed;
174
+ }
src/index.js CHANGED
@@ -16,6 +16,7 @@ import { parseBetInput } from './parser.js';
16
  import {
17
  buildBetSavedEmbed,
18
  buildBetsEmbed,
 
19
  buildChartAttachment,
20
  buildChartCaption,
21
  buildCommandsEmbed,
@@ -25,8 +26,7 @@ import {
25
  buildWelcomeEmbed,
26
  } from './embeds.js';
27
 
28
- const BET_MODAL_ID = 'bet-entry-modal';
29
- const BET_BOOK_INPUT_ID = 'bet-book';
30
  const BET_PROP_INPUT_ID = 'bet-prop';
31
  const BET_ODDS_INPUT_ID = 'bet-odds';
32
  const BET_STAKE_INPUT_ID = 'bet-stake';
@@ -69,7 +69,7 @@ async function main() {
69
  return;
70
  }
71
 
72
- if (interaction.isModalSubmit() && interaction.customId === BET_MODAL_ID) {
73
  await handleBetModal(interaction, store);
74
  }
75
  } catch (error) {
@@ -134,6 +134,11 @@ async function handleChatInput(interaction, store, config) {
134
  return;
135
  }
136
 
 
 
 
 
 
137
  if (commandName === 'commands') {
138
  await interaction.reply({
139
  embeds: [buildCommandsEmbed()],
@@ -147,14 +152,10 @@ async function handleChatInput(interaction, store, config) {
147
  }
148
 
149
  async function showBetModal(interaction) {
150
- const modal = new ModalBuilder().setCustomId(BET_MODAL_ID).setTitle('Log a Bet');
151
- const bookInput = new TextInputBuilder()
152
- .setCustomId(BET_BOOK_INPUT_ID)
153
- .setLabel('Sportsbook')
154
- .setPlaceholder('FanDuel')
155
- .setRequired(true)
156
- .setStyle(TextInputStyle.Short)
157
- .setMaxLength(100);
158
  const propInput = new TextInputBuilder()
159
  .setCustomId(BET_PROP_INPUT_ID)
160
  .setLabel('Prop / bet')
@@ -178,7 +179,6 @@ async function showBetModal(interaction) {
178
  .setMaxLength(20);
179
 
180
  modal.addComponents(
181
- new ActionRowBuilder().addComponents(bookInput),
182
  new ActionRowBuilder().addComponents(propInput),
183
  new ActionRowBuilder().addComponents(oddsInput),
184
  new ActionRowBuilder().addComponents(stakeInput)
@@ -187,8 +187,9 @@ async function showBetModal(interaction) {
187
  }
188
 
189
  async function handleBetModal(interaction, store) {
 
190
  const parsed = parseBetInput({
191
- book: interaction.fields.getTextInputValue(BET_BOOK_INPUT_ID),
192
  prop: interaction.fields.getTextInputValue(BET_PROP_INPUT_ID),
193
  odds: interaction.fields.getTextInputValue(BET_ODDS_INPUT_ID),
194
  stake: interaction.fields.getTextInputValue(BET_STAKE_INPUT_ID),
@@ -302,6 +303,14 @@ async function handleSummary(interaction, store) {
302
  });
303
  }
304
 
 
 
 
 
 
 
 
 
305
  async function handleWelcome(interaction, config) {
306
  if (interaction.user.username !== config.restrictedWelcomeUsername) {
307
  await interaction.reply({
 
16
  import {
17
  buildBetSavedEmbed,
18
  buildBetsEmbed,
19
+ buildBooksEmbed,
20
  buildChartAttachment,
21
  buildChartCaption,
22
  buildCommandsEmbed,
 
26
  buildWelcomeEmbed,
27
  } from './embeds.js';
28
 
29
+ const BET_MODAL_PREFIX = 'bet-entry-modal';
 
30
  const BET_PROP_INPUT_ID = 'bet-prop';
31
  const BET_ODDS_INPUT_ID = 'bet-odds';
32
  const BET_STAKE_INPUT_ID = 'bet-stake';
 
69
  return;
70
  }
71
 
72
+ if (interaction.isModalSubmit() && interaction.customId.startsWith(`${BET_MODAL_PREFIX}|`)) {
73
  await handleBetModal(interaction, store);
74
  }
75
  } catch (error) {
 
134
  return;
135
  }
136
 
137
+ if (commandName === 'books') {
138
+ await handleBooks(interaction, store);
139
+ return;
140
+ }
141
+
142
  if (commandName === 'commands') {
143
  await interaction.reply({
144
  embeds: [buildCommandsEmbed()],
 
152
  }
153
 
154
  async function showBetModal(interaction) {
155
+ const selectedBook = interaction.options.getString('book', true);
156
+ const modal = new ModalBuilder()
157
+ .setCustomId(`${BET_MODAL_PREFIX}|${selectedBook}`)
158
+ .setTitle(`Log a Bet - ${selectedBook}`);
 
 
 
 
159
  const propInput = new TextInputBuilder()
160
  .setCustomId(BET_PROP_INPUT_ID)
161
  .setLabel('Prop / bet')
 
179
  .setMaxLength(20);
180
 
181
  modal.addComponents(
 
182
  new ActionRowBuilder().addComponents(propInput),
183
  new ActionRowBuilder().addComponents(oddsInput),
184
  new ActionRowBuilder().addComponents(stakeInput)
 
187
  }
188
 
189
  async function handleBetModal(interaction, store) {
190
+ const [, selectedBook] = interaction.customId.split('|');
191
  const parsed = parseBetInput({
192
+ book: selectedBook,
193
  prop: interaction.fields.getTextInputValue(BET_PROP_INPUT_ID),
194
  odds: interaction.fields.getTextInputValue(BET_ODDS_INPUT_ID),
195
  stake: interaction.fields.getTextInputValue(BET_STAKE_INPUT_ID),
 
303
  });
304
  }
305
 
306
+ async function handleBooks(interaction, store) {
307
+ const breakdown = await store.getBookBreakdown(interaction.user.id);
308
+
309
+ await interaction.reply({
310
+ embeds: [buildBooksEmbed(breakdown)],
311
+ });
312
+ }
313
+
314
  async function handleWelcome(interaction, config) {
315
  if (interaction.user.username !== config.restrictedWelcomeUsername) {
316
  await interaction.reply({
src/parser.js CHANGED
@@ -17,10 +17,10 @@ function parseOdds(sourceInput) {
17
  return americanMatch[1];
18
  }
19
 
20
- const decimalMatch = source.match(/^([1-9]\d?(?:\.\d{1,3})?)$/);
21
  if (decimalMatch) {
22
  const decimal = Number(decimalMatch[1]);
23
- if (decimal >= 1.01 && decimal <= 100) {
24
  return decimalMatch[1];
25
  }
26
  }
 
17
  return americanMatch[1];
18
  }
19
 
20
+ const decimalMatch = source.match(/^(\d+(?:\.\d{1,3})?)$/);
21
  if (decimalMatch) {
22
  const decimal = Number(decimalMatch[1]);
23
+ if (decimal >= 1.01) {
24
  return decimalMatch[1];
25
  }
26
  }
test/db.test.js CHANGED
@@ -80,3 +80,50 @@ test('calculates roi excluding void bets from settled stake', async () => {
80
 
81
  await store.close();
82
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
  await store.close();
82
  });
83
+
84
+ test('returns sportsbook breakdown with roi and win rate', async () => {
85
+ const store = await createStore();
86
+ const user = { id: 'user-books', username: 'books', displayName: 'Books' };
87
+
88
+ await store.createBet(user, {
89
+ book: 'FanDuel',
90
+ oddsInput: '+100',
91
+ normalizedDecimalOdds: 2,
92
+ prop: 'Bet A',
93
+ stake: 10,
94
+ rawInput: 'raw',
95
+ });
96
+ await store.createBet(user, {
97
+ book: 'FanDuel',
98
+ oddsInput: '-110',
99
+ normalizedDecimalOdds: 1.9091,
100
+ prop: 'Bet B',
101
+ stake: 20,
102
+ rawInput: 'raw',
103
+ });
104
+ await store.createBet(user, {
105
+ book: 'DraftKings',
106
+ oddsInput: '+150',
107
+ normalizedDecimalOdds: 2.5,
108
+ prop: 'Bet C',
109
+ stake: 10,
110
+ rawInput: 'raw',
111
+ });
112
+
113
+ await store.resolveBet(user.id, 1, 'win');
114
+ await store.resolveBet(user.id, 2, 'loss');
115
+ await store.resolveBet(user.id, 3, 'win');
116
+
117
+ const rows = await store.getBookBreakdown(user.id);
118
+ const draftKings = rows.find((row) => row.book === 'DraftKings');
119
+ const fanDuel = rows.find((row) => row.book === 'FanDuel');
120
+
121
+ assert.equal(rows.length, 2);
122
+ assert.equal(draftKings.winRatePercent, 100);
123
+ assert.equal(draftKings.roiPercent, 150);
124
+ assert.equal(fanDuel.wins, 1);
125
+ assert.equal(fanDuel.losses, 1);
126
+ assert.equal(fanDuel.totalBets, 2);
127
+
128
+ await store.close();
129
+ });
test/parser.test.js CHANGED
@@ -31,6 +31,19 @@ test('parses decimal or american odds from structured fields', () => {
31
  assert.match(parsed.bet.prop, /Celtics ML/i);
32
  });
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  test('reports missing fields for incomplete input', () => {
35
  const parsed = parseBetInput({
36
  stake: '',
 
31
  assert.match(parsed.bet.prop, /Celtics ML/i);
32
  });
33
 
34
+ test('accepts larger decimal odds values', () => {
35
+ const parsed = parseBetInput({
36
+ stake: '10',
37
+ odds: '11.0',
38
+ prop: 'JJ WetherGoat 1+ HR',
39
+ book: 'BetMGM',
40
+ });
41
+
42
+ assert.equal(parsed.ok, true);
43
+ assert.equal(parsed.bet.oddsInput, '11.0');
44
+ assert.equal(parsed.bet.normalizedDecimalOdds, 11);
45
+ });
46
+
47
  test('reports missing fields for incomplete input', () => {
48
  const parsed = parseBetInput({
49
  stake: '',