Compare commits

...
Author SHA1 Message Date
jheaps d206758729 Merge branch 'master' into feature/casino-holdem-quick-split 2026-08-31 18:32:56 -06:00
jheaps 4afc3649cc Merge pull request 'Make the dealer table usable on a phone' (#16) from feature/casino-holdem-mobile-table into feature/casino-holdem-quick-split
Reviewed-on: #16
2026-08-31 18:31:52 -06:00
jheaps 3866bdb335 Merge pull request 'Replace the browser confirm boxes with undo and in-page dialogs' (#17) from feature/casino-holdem-undo-toasts into feature/casino-holdem-mobile-table
Reviewed-on: #17
2026-08-31 18:31:40 -06:00
jheaps 71b33f8623 Merge pull request 'Give the Hold'em dealer a session record: hand history, CSV export and standings' (#14) from feature/casino-holdem-hand-history into master
Build and Deploy / build-deploy (push) Successful in 1m13s
Reviewed-on: #14
2026-08-31 18:31:19 -06:00
jheapsandClaude Sonnet 5 d01c67fc45 Replace the browser confirm boxes with undo and in-page dialogs
Three system confirm() dialogs sat in the middle of dealing. The worst was on
Next Hand — the most-pressed button in a session, asking "are you sure?" every
single time, stealing focus and, on a phone, taking over the screen.

Next Hand now just happens, and a toast says where the button landed with an
Undo beside it for the few seconds anyone would notice a misclick. The same
undo covers the pot wizard, which is the more valuable case: paying the wrong
player is a real mistake, and rolling it back restores the hand history entry
and the session standings along with the seats. The snapshot is in-memory only
— an undo is for the mistake you just made, not one from last night.

The two genuinely destructive actions keep a confirmation, but as an in-page
dialog that matches the table instead of the browser's own: Escape or the
backdrop cancels, Enter confirms, and on a phone the buttons go full width with
Cancel nearest the thumb.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 18:22:50 -06:00
jheapsandClaude Sonnet 5 a39675f975 Make the dealer table usable on a phone
Two things were broken on a narrow screen. The seat ring is drawn on a radius
measured from the table wrap, so at phone width the left- and right-most seats
hung off the edge of the viewport — at a ten-handed table two players were
simply not on screen. And tapping a seat opened an editor far enough down the
page that it looked like nothing had happened.

The ring is now inset by half a puck on phones, which keeps every seat on
screen, and the pucks are narrower there so neighbours stop overlapping where
the ring flattens out at the top and bottom. The seat editor becomes a bottom
sheet that slides up over the table: grip, Done button, tap the same seat again
to dismiss, table still visible above it. Its controls grow to thumb size, and
every field goes to 16px — below that iOS zooms the page on focus and the
dealer has to pinch back out mid-hand.

Header actions become full-width tap targets, and the page padding tightens to
give the table the width it needs. Nothing above 700px changes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 18:11:09 -06:00
jheapsandClaude Sonnet 5 870dee4a29 Add a phone-first Quick Split screen for chopping a pot mid-hand
The dealer table is a two-handed, big-screen tool. A dealer standing over a
chopped pot with a phone in one hand and players waiting needs an answer in
seconds, not a table to set up first — so /casino/texasholdem/quicksplit is
the whole job on one screen: type the pot, tap how many ways, tap the
smallest chip in play, read the payout. Nothing to configure, nothing saved.

It splits in whole chips rather than raw arithmetic, because a dealer can't
push half a chip across the felt: everyone gets the same stack and the
remainder is called out as odd chips going one at a time to the first
players left of the button. A pot that doesn't divide into the chosen
denomination says so instead of quietly hiding the difference.

Money formatting moves out of render.js into its own file so this screen can
use it without pulling in the whole seat/chip UI.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 17:53:18 -06:00
12 changed files with 1186 additions and 31 deletions
+9
View File
@@ -19,6 +19,15 @@
</div>
</a>
<a href="/casino/texasholdem/quicksplit" class="game-card">
<div class="game-icon">➗</div>
<div class="game-title">Quick Split</div>
<div class="game-description">
Chop a pot in seconds on your phone — no table setup, just the amount,
how many players are splitting it, and the smallest chip in play.
</div>
</a>
<div class="game-card disabled">
<div class="game-icon">🎲</div>
<div class="game-title">Craps</div>
+21 -1
View File
@@ -13,6 +13,7 @@
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
</div>
<div id="holdemHeaderActions">
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
@@ -37,7 +38,11 @@
<aside id="holdemSidebar">
<section id="seatEditorPanel" class="sidebarPanel seatEditorPanel">
<h2 id="seatEditorTitle">Select a seat</h2>
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="seatEditorHeader">
<h2 id="seatEditorTitle">Select a seat</h2>
<button id="closeSeatEditorBtn" class="seatSheetClose" aria-label="Done editing this seat">Done</button>
</div>
<div id="seatEditorBody">
<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>
</div>
@@ -205,6 +210,19 @@
</div>
</template>
<div id="toastHost" aria-live="polite"></div>
<div id="confirmOverlay" hidden>
<div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1">
<h2 id="confirmTitle"></h2>
<p id="confirmMessage"></p>
<div class="confirmActions">
<button id="confirmCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
</div>
</div>
</div>
<div id="potWizardOverlay" hidden>
<div id="potWizardModal">
<div class="potWizardHeader">
@@ -220,6 +238,8 @@
}
@section Scripts {
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
@@ -0,0 +1,60 @@
@page
@model JoshHeaps.Net.Pages.Casino.TexasHoldem.QuickSplitModel
@{
ViewData["Title"] = "Quick Split";
Layout = "_Layout";
}
<div id="splitApp">
<header id="splitHeader">
<h1>Quick Split</h1>
<p class="splitSubtitle">Chop a pot without setting up a table.</p>
</header>
<div id="splitResult" aria-live="polite">
<div id="splitResultLabel">Each player gets</div>
<div id="splitResultAmount">$0.00</div>
<div id="splitOddNote" hidden></div>
<div id="splitDustNote" hidden></div>
</div>
<section class="splitField">
<label for="potInput" class="splitLabel">Pot</label>
<div class="potInputWrap">
<span class="potCurrency">$</span>
<input type="text" id="potInput" inputmode="decimal" autocomplete="off"
placeholder="0" aria-label="Pot amount" />
<button type="button" id="clearPotBtn" class="clearPotBtn" aria-label="Clear pot amount" hidden>✕</button>
</div>
</section>
<section class="splitField">
<span class="splitLabel" id="waysLabel">Split between</span>
<div id="waysChoices" class="chipRow" role="group" aria-labelledby="waysLabel"></div>
</section>
<section class="splitField">
<span class="splitLabel" id="chipLabel">Smallest chip in play</span>
<div id="chipChoices" class="chipRow" role="group" aria-labelledby="chipLabel"></div>
</section>
<div id="splitActions">
<button type="button" id="resetSplitBtn" class="splitBtn splitBtnGhost">Clear</button>
<a href="/casino/texasholdem" class="splitBtn splitBtnGhost">Full dealer table →</a>
</div>
<p class="splitFootnote">
Uneven pots — someone all-in for less — need the side-pot walkthrough on the
<a href="/casino/texasholdem">full table</a>.
</p>
</div>
@section Styles {
<link href="/css/casino/quicksplit.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@section Scripts {
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/splitCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/quickSplit.js?v=@ViewData["cssVersion"]"></script>
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.Casino.TexasHoldem
{
public class QuickSplitModel : PageModel
{
public void OnGet()
{
}
}
}
+353
View File
@@ -376,6 +376,27 @@ body {
padding: 0.9rem;
}
.seatEditorHeader {
display: flex;
align-items: center;
gap: 0.6rem;
}
.seatEditorHeader h2 {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Grip and Done button are the sheet's furniture — no reason for either on a
desktop sidebar, where the panel is just a panel. */
.seatSheetGrip,
.seatSheetClose {
display: none;
}
.seatEditorPanel h2 {
margin: 0 0 0.6rem;
padding-bottom: 0.5rem;
@@ -732,6 +753,135 @@ body {
/* ---------- Pot wizard ---------- */
/* ---------- Toasts ---------- */
#toastHost {
position: fixed;
left: 50%;
bottom: 1.25rem;
transform: translateX(-50%);
z-index: 80;
width: min(560px, calc(100vw - 1.5rem));
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 0.75rem;
background: linear-gradient(180deg, #2b2b2b, #202020);
border: 1px solid #414141;
border-left: 3px solid #d8b46a;
border-radius: 10px;
padding: 0.7rem 0.8rem 0.7rem 0.9rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
pointer-events: auto;
opacity: 0;
transform: translateY(12px);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.toast.toastVisible {
opacity: 1;
transform: translateY(0);
}
.toastText {
flex: 1;
min-width: 0;
color: #e9e9e9;
font-size: 0.85rem;
line-height: 1.35;
}
.toastAction {
flex: none;
min-height: 38px;
padding: 0.35rem 0.9rem;
border-radius: 8px;
border: 1px solid #d8b46a;
background: rgba(216, 180, 106, 0.12);
color: #f0d9a8;
font-size: 0.85rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
.toastAction:hover {
background: rgba(216, 180, 106, 0.22);
}
.toastClose {
flex: none;
width: 30px;
height: 30px;
border-radius: 50%;
border: none;
background: transparent;
color: #888;
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
}
.toastClose:hover {
background: #333;
color: #ddd;
}
/* ---------- Confirmation dialog ---------- */
#confirmOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
padding: 1.5rem;
}
#confirmOverlay[hidden] {
display: none;
}
#confirmModal {
background: #1e1e1e;
border: 1px solid #3a3a3a;
border-radius: 12px;
width: 100%;
max-width: 420px;
padding: 1.3rem 1.4rem 1.2rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
outline: none;
}
#confirmTitle {
margin: 0 0 0.5rem;
font-size: 1.05rem;
font-weight: 500;
color: #f5f5f5;
}
#confirmMessage {
margin: 0 0 1.2rem;
color: #a5a5a5;
font-size: 0.85rem;
line-height: 1.5;
}
.confirmActions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
}
.confirmActions .holdemBtn {
min-height: 44px;
}
#potWizardOverlay {
position: fixed;
inset: 0;
@@ -843,3 +993,206 @@ body {
min-height: 44px;
}
}
/* ---------- Phones ----------
Two things break on a narrow screen: seat pucks ring the table on a radius
measured from the wrap, so the left- and right-most ones hang off the edge of
the viewport; and the seat editor lives far enough down the page that tapping a
seat looks like it did nothing. Inset the ring by half a puck, and float the
editor up as a bottom sheet over the table. */
@media (max-width: 700px) {
#holdemApp {
padding: 1rem 0.75rem 2rem;
}
#holdemHeader {
margin-bottom: 1rem;
gap: 0.6rem;
}
#holdemHeader h1 {
font-size: 1.4rem;
}
.holdemSubtitle {
font-size: 0.8rem;
}
#holdemHeaderActions {
width: 100%;
gap: 0.45rem;
}
#holdemHeaderActions .holdemBtn {
flex: 1 1 auto;
justify-content: center;
min-height: 46px;
padding: 0.5rem 0.7rem;
font-size: 0.82rem;
}
/* Keeps every puck inside the viewport: the ring is drawn within this box,
so half a puck of padding is exactly enough to stop the overhang. */
#seatsLayer {
inset: 26px 44px;
}
/* Narrower than the desktop puck: at a full ten-handed table the ring is
flattest at the top and bottom, and wide pucks are what made neighbours
overlap there. The puck is only a summary — the real form is the sheet. */
.seat {
width: 64px;
min-height: 42px;
padding: 0.25rem 0.3rem;
}
#tableWatermark {
font-size: 1.4rem;
}
#potBadge {
padding: 0.5rem 1.1rem;
}
#potBadgeAmount {
font-size: 1.2rem;
}
.tableHint {
margin-top: 0.75rem;
font-size: 0.75rem;
}
/* ---- Seat editor as a bottom sheet ---- */
#seatEditorPanel {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 60;
margin: 0;
max-height: 74vh;
overflow-y: auto;
border-radius: 16px 16px 0 0;
border-bottom: none;
padding: 0.35rem 1rem 1.25rem;
background: linear-gradient(180deg, #242424, #1a1a1a);
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
transform: translateY(105%);
transition: transform 0.25s ease;
-webkit-overflow-scrolling: touch;
}
body.seatSheetOpen #seatEditorPanel {
transform: translateY(0);
}
.seatSheetGrip {
display: block;
width: 38px;
height: 4px;
margin: 0.35rem auto 0.5rem;
border-radius: 999px;
background: #4a4a4a;
}
.seatSheetClose {
display: inline-flex;
align-items: center;
flex: none;
min-height: 44px;
padding: 0.35rem 1.1rem;
border-radius: 8px;
border: 1px solid #3a3a3a;
background: linear-gradient(180deg, #2e2e2e, #232323);
color: #ddd;
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
}
/* The sheet covers the bottom of the page while it's open — give the sidebar
room so nothing is stranded underneath it. */
body.seatSheetOpen #holdemSidebar {
padding-bottom: 40vh;
}
/* The seat form was sized for a 320px desktop sidebar. In the sheet it has a
whole phone width to work with, so everything grows to thumb size — and
every field goes to 16px, below which iOS zooms the page on focus and the
dealer has to pinch back out mid-hand. */
.seatEditorForm .seatNameInput,
.seatEditorForm .amountOverrideInput,
.seatEditorForm .chipCountInput,
.seatEditorForm .customFieldLabel,
.seatEditorForm .customFieldValue {
font-size: 16px;
min-height: 44px;
padding: 0.4rem 0.6rem;
}
.seatEditorForm .chipCountInput {
width: 56px;
text-align: center;
}
.seatEditorForm .chipCounts {
gap: 0.6rem;
}
.seatEditorForm .chipSwatch {
width: 16px;
height: 16px;
}
.seatEditorForm .amountOverrideInput {
width: 110px;
}
.seatEditorForm .cardInput {
font-size: 1.35rem;
min-height: 58px;
}
.seatEditorForm .seatCardToggles {
gap: 1rem;
}
.seatEditorForm .inHandToggle,
.seatEditorForm .foldToggle {
min-height: 44px;
font-size: 0.95rem;
}
.seatEditorForm .inHandToggle input,
.seatEditorForm .foldToggle input {
width: 22px;
height: 22px;
}
.seatEditorForm .amountOverrideRow,
.seatEditorForm .seatTotal {
font-size: 0.95rem;
}
.seatEditorForm .addFieldBtn {
width: 100%;
justify-content: center;
min-height: 44px;
margin-top: 0.4rem;
}
.toastAction {
min-height: 44px;
}
.confirmActions {
flex-direction: column-reverse;
}
.confirmActions .holdemBtn {
width: 100%;
justify-content: center;
}
}
@@ -0,0 +1,301 @@
/* Quick Split is phone-first: sized for one thumb at the table, then allowed to
breathe on a laptop. Palette matches the dealer table so the two read as one tool. */
body {
background: radial-gradient(ellipse at 50% -10%, #1c1c1c 0%, #101010 55%, #0a0a0a 100%);
min-height: 100vh;
margin: 0;
color: #e6e6e6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
#splitApp {
max-width: 480px;
margin: 0 auto;
padding: 1.25rem 1rem 3rem;
}
/* ---------- Header ---------- */
#splitHeader h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 300;
letter-spacing: 0.04em;
color: #f5f5f5;
position: relative;
display: inline-block;
padding-bottom: 0.35rem;
}
#splitHeader h1::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 36px;
height: 3px;
border-radius: 2px;
background: linear-gradient(90deg, #d8b46a, #a9782f);
}
.splitSubtitle {
margin: 0.35rem 0 1.25rem;
color: #8f8f8f;
font-size: 0.85rem;
}
/* ---------- The answer ---------- */
#splitResult {
background: linear-gradient(160deg, #1f2a21, #161c17);
border: 1px solid #2c3a2f;
border-radius: 14px;
padding: 1.4rem 1.1rem;
text-align: center;
margin-bottom: 1.5rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
#splitResult.hasAmount {
border-color: #3c6b46;
box-shadow: 0 0 0 1px rgba(78, 201, 122, 0.08), 0 6px 20px rgba(0, 0, 0, 0.35);
}
#splitResultLabel {
color: #8f9d92;
font-size: 0.78rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
#splitResultAmount {
margin-top: 0.35rem;
font-size: clamp(2.6rem, 14vw, 3.4rem);
font-weight: 600;
line-height: 1.05;
color: #f2f2f2;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
#splitResult.hasAmount #splitResultAmount {
color: #7ee2a0;
}
#splitOddNote {
margin-top: 0.7rem;
color: #d8b46a;
font-size: 0.85rem;
line-height: 1.45;
}
#splitDustNote {
margin-top: 0.5rem;
color: #e0a1a1;
font-size: 0.8rem;
line-height: 1.4;
}
/* ---------- Inputs ---------- */
.splitField {
display: block;
margin-bottom: 1.35rem;
}
.splitLabel {
display: block;
margin-bottom: 0.5rem;
color: #9a9a9a;
font-size: 0.75rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.potInputWrap {
display: flex;
align-items: center;
gap: 0.4rem;
background: #141414;
border: 1px solid #3a3a3a;
border-radius: 12px;
padding: 0.2rem 0.75rem;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.potInputWrap:focus-within {
border-color: #d8b46a;
box-shadow: 0 0 0 3px rgba(216, 180, 106, 0.12);
}
.potCurrency {
color: #777;
font-size: 1.6rem;
font-weight: 300;
}
#potInput {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: #f2f2f2;
font-size: 2rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
padding: 0.55rem 0;
font-family: inherit;
}
#potInput::placeholder {
color: #4a4a4a;
}
.clearPotBtn {
flex: none;
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
background: #262626;
color: #999;
font-size: 0.85rem;
cursor: pointer;
}
.clearPotBtn:hover {
background: #333;
color: #ddd;
}
/* ---------- Tap targets ---------- */
.chipRow {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.choiceChip {
flex: 1 1 auto;
min-width: 58px;
min-height: 52px;
padding: 0.5rem 0.7rem;
background: linear-gradient(180deg, #2e2e2e, #232323);
border: 1px solid #3a3a3a;
border-radius: 11px;
color: #ddd;
font-size: 1rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
}
.choiceChip:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
.choiceChip.selected {
background: linear-gradient(180deg, #3a2f18, #241d0f);
border-color: #d8b46a;
color: #f0d9a8;
font-weight: 600;
}
.waysStepper {
display: flex;
align-items: center;
gap: 0.35rem;
flex: 1 1 auto;
min-width: 150px;
background: #191919;
border: 1px solid #333;
border-radius: 11px;
padding: 0.2rem 0.4rem;
}
.stepBtn {
width: 40px;
height: 44px;
border: none;
border-radius: 8px;
background: #262626;
color: #ddd;
font-size: 1.25rem;
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.stepBtn:hover {
background: #333;
}
.waysReadout {
flex: 1;
text-align: center;
color: #999;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
/* ---------- Footer actions ---------- */
#splitActions {
display: flex;
gap: 0.6rem;
margin-top: 1.75rem;
}
.splitBtn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 0.6rem 0.9rem;
border-radius: 10px;
border: 1px solid #3a3a3a;
background: linear-gradient(180deg, #2e2e2e, #232323);
color: #ddd;
font-size: 0.9rem;
font-family: inherit;
text-decoration: none;
cursor: pointer;
transition: all 0.15s ease;
}
.splitBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
.splitBtnGhost {
background: transparent;
}
.splitFootnote {
margin-top: 1.5rem;
color: #6f6f6f;
font-size: 0.78rem;
line-height: 1.5;
text-align: center;
}
.splitFootnote a {
color: #a9782f;
}
/* Roomier once there's a real screen to work with. */
@media (min-width: 700px) {
#splitApp {
padding-top: 2.5rem;
}
#splitHeader h1 {
font-size: 1.8rem;
}
}
@@ -0,0 +1,115 @@
// Toasts and confirmation dialogs, so the table never has to fall back on the
// browser's own alert boxes. A system confirm() steals focus, looks nothing like
// the rest of the page, and on a phone it's a full-screen interruption — all of
// which is a bad trade for a dealer trying to keep a game moving.
// A short-lived message at the bottom of the screen, optionally with one action
// (in practice: Undo). Only one is ever on screen; a second replaces the first.
const Toast = {
DEFAULT_DURATION_MS: 8000,
timer: null,
show(message, options = {}) {
const host = document.getElementById("toastHost");
if (!host) return;
this.dismiss();
const toast = document.createElement("div");
toast.className = "toast";
toast.setAttribute("role", "status");
const text = document.createElement("span");
text.className = "toastText";
text.textContent = message;
toast.appendChild(text);
if (options.actionLabel && options.onAction) {
const action = document.createElement("button");
action.type = "button";
action.className = "toastAction";
action.textContent = options.actionLabel;
action.addEventListener("click", () => {
this.dismiss();
options.onAction();
});
toast.appendChild(action);
}
const close = document.createElement("button");
close.type = "button";
close.className = "toastClose";
close.setAttribute("aria-label", "Dismiss");
close.textContent = "✕";
close.addEventListener("click", () => this.dismiss());
toast.appendChild(close);
host.appendChild(toast);
// Next frame, so the entrance transition actually runs.
requestAnimationFrame(() => toast.classList.add("toastVisible"));
this.timer = setTimeout(
() => this.dismiss(),
options.duration || this.DEFAULT_DURATION_MS
);
},
dismiss() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const host = document.getElementById("toastHost");
if (host) host.innerHTML = "";
},
};
// Styled stand-in for confirm(). Resolves true if the dealer goes through with it.
const Dialog = {
confirm({ title, message, confirmLabel = "Confirm", cancelLabel = "Cancel", danger = false }) {
return new Promise(resolve => {
const overlay = document.getElementById("confirmOverlay");
const modal = overlay.querySelector("#confirmModal");
overlay.querySelector("#confirmTitle").textContent = title;
overlay.querySelector("#confirmMessage").textContent = message;
const confirmBtn = overlay.querySelector("#confirmAcceptBtn");
const cancelBtn = overlay.querySelector("#confirmCancelBtn");
confirmBtn.textContent = confirmLabel;
cancelBtn.textContent = cancelLabel;
confirmBtn.classList.toggle("holdemBtnDanger", danger);
confirmBtn.classList.toggle("holdemBtnAccent", !danger);
const finish = result => {
overlay.hidden = true;
confirmBtn.removeEventListener("click", onAccept);
cancelBtn.removeEventListener("click", onCancel);
overlay.removeEventListener("click", onBackdrop);
document.removeEventListener("keydown", onKey);
resolve(result);
};
const onAccept = () => finish(true);
const onCancel = () => finish(false);
const onBackdrop = e => {
if (e.target === overlay) finish(false);
};
const onKey = e => {
if (e.key === "Escape") finish(false);
if (e.key === "Enter") finish(true);
};
confirmBtn.addEventListener("click", onAccept);
cancelBtn.addEventListener("click", onCancel);
overlay.addEventListener("click", onBackdrop);
document.addEventListener("keydown", onKey);
overlay.hidden = false;
modal.focus();
confirmBtn.focus();
});
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = { Toast, Dialog };
}
+60 -12
View File
@@ -8,6 +8,29 @@ document.addEventListener("DOMContentLoaded", () => {
Render.mount(state, hooks);
// Moving to the next hand used to ask "are you sure?" every single time —
// the most-pressed button in a session, gated behind a system dialog. It now
// just happens, and the toast offers the way back for the few seconds anyone
// would notice a misclick. The snapshot is deliberately in-memory only: an
// undo is for the mistake you just made, not one from last night's session.
const runUndoable = (message, mutate) => {
const snapshot = JSON.parse(JSON.stringify(state));
mutate();
hooks.save(state);
Render.renderAll();
Toast.show(message, {
actionLabel: "Undo",
onAction: () => {
Object.keys(state).forEach(key => delete state[key]);
Object.assign(state, snapshot);
hooks.save(state);
Render.renderAll();
Toast.show("Undone.", { duration: 2500 });
},
});
};
document.getElementById("seatCountInput").addEventListener("change", e => {
HoldemState.resizeSeats(state, Number(e.target.value));
hooks.save(state);
@@ -49,38 +72,63 @@ document.addEventListener("DOMContentLoaded", () => {
});
document.getElementById("nextHandBtn").addEventListener("click", () => {
if (!confirm("Start the next hand? This moves the button, clears cards and chip counts for every seat.")) return;
HoldemState.startNextHand(state);
hooks.save(state);
Render.renderAll();
// startNextHand moves the button on by one; say where it landed so the
// dealer can confirm the rotation without hunting for the badge.
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
const where = state.dedicatedDealer
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
: `button on seat ${nextDealer + 1}`;
runUndoable(`Next hand — ${where}. Cards and chip counts cleared.`, () =>
HoldemState.startNextHand(state)
);
});
document.getElementById("resetTableBtn").addEventListener("click", () => {
if (!confirm("Reset the whole table? This clears every seat, chip legend and note.")) return;
document.getElementById("resetTableBtn").addEventListener("click", async () => {
const ok = await Dialog.confirm({
title: "Reset the whole table?",
message: "Every seat, chip colour and note goes back to defaults. The hand history and session standings are cleared too.",
confirmLabel: "Reset table",
danger: true,
});
if (!ok) return;
HoldemState.clear();
Object.assign(state, HoldemState.createDefault());
Render.mount(state, hooks);
Toast.show("Table reset.", { duration: 2500 });
});
document.getElementById("resolvePotBtn").addEventListener("click", () => {
PotWizard.open(state, payouts => {
if (payouts.length > 0) HoldemState.recordHand(state, payouts);
HoldemState.startNextHand(state);
hooks.save(state);
Render.renderAll();
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
if (payouts.length > 0) HoldemState.recordHand(state, payouts);
HoldemState.startNextHand(state);
});
});
});
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
document.getElementById("closePotWizardBtn").addEventListener("click", () => PotWizard.close());
document.getElementById("potWizardOverlay").addEventListener("click", e => {
if (e.target.id === "potWizardOverlay") PotWizard.close();
});
document.getElementById("clearHistoryBtn").addEventListener("click", () => {
if (!confirm("Clear the hand history and reset the hand counter to 1? This can't be undone.")) return;
document.getElementById("clearHistoryBtn").addEventListener("click", async () => {
const ok = await Dialog.confirm({
title: "Clear the hand history?",
message: "This wipes every logged hand and the session standings, and puts the hand counter back to #1. Export first if anyone might want the numbers.",
confirmLabel: "Clear history",
danger: true,
});
if (!ok) return;
HoldemState.clearHistory(state);
hooks.save(state);
Render.renderAll();
Toast.show("Hand history cleared.", { duration: 2500 });
});
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
@@ -0,0 +1,22 @@
// Shared money formatting for every casino surface. Kept apart from the table's
// rendering so the quick-split calculator can pull in the formatter without dragging
// the whole seat/chip UI along with it.
const Money = {
format(amount) {
const value = Number(amount) || 0;
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
},
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
// glance, and the sign belongs out front where the eye lands first.
formatSigned(amount) {
const value = Number(amount) || 0;
if (value > 0) return `+${this.format(value)}`;
if (value < 0) return `-${this.format(Math.abs(value))}`;
return this.format(0);
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = Money;
}
@@ -0,0 +1,166 @@
// The quick-split screen: pot in, payout out, nothing to set up and nothing saved.
// A dealer opens this one-handed with players waiting, so every control is a tap and
// the answer updates as they type rather than behind a "calculate" button.
const QuickSplit = {
WAY_CHOICES: [2, 3, 4, 5],
CHIP_CHOICES: [
{ value: 0.01, label: "1¢" },
{ value: 0.25, label: "25¢" },
{ value: 1, label: "$1" },
{ value: 5, label: "$5" },
{ value: 25, label: "$25" },
],
MAX_WAYS: 12,
ways: 2,
chip: 1,
mount() {
this.potInput = document.getElementById("potInput");
this.clearPotBtn = document.getElementById("clearPotBtn");
this.renderWayChoices();
this.renderChipChoices();
this.potInput.addEventListener("input", () => {
this.sanitizePotInput();
this.update();
});
this.clearPotBtn.addEventListener("click", () => {
this.potInput.value = "";
this.potInput.focus();
this.update();
});
document.getElementById("resetSplitBtn").addEventListener("click", () => {
this.potInput.value = "";
this.ways = 2;
this.chip = 1;
this.renderWayChoices();
this.renderChipChoices();
this.potInput.focus();
this.update();
});
this.update();
},
// Keeps the field to something parseable without fighting the typist: digits and a
// single decimal point, at most two places.
sanitizePotInput() {
const cleaned = this.potInput.value
.replace(/[^0-9.]/g, "")
.replace(/(\..*)\./g, "$1")
.replace(/(\.\d\d)\d+/, "$1");
if (cleaned !== this.potInput.value) this.potInput.value = cleaned;
},
makeChoiceButton(label, isSelected, onPick) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "choiceChip";
btn.textContent = label;
btn.setAttribute("aria-pressed", String(isSelected));
btn.classList.toggle("selected", isSelected);
btn.addEventListener("click", onPick);
return btn;
},
renderWayChoices() {
const row = document.getElementById("waysChoices");
row.innerHTML = "";
this.WAY_CHOICES.forEach(count => {
row.appendChild(
this.makeChoiceButton(String(count), this.ways === count, () => {
this.ways = count;
this.renderWayChoices();
this.update();
})
);
});
// Beyond a five-way chop it's rare enough to hide behind a stepper rather than
// spend a row of buttons on it.
const more = document.createElement("div");
more.className = "waysStepper";
const minus = this.makeStepButton("−", () => this.stepWays(-1));
const plus = this.makeStepButton("+", () => this.stepWays(1));
const readout = document.createElement("span");
readout.className = "waysReadout";
readout.textContent = `${this.ways} ways`;
more.appendChild(minus);
more.appendChild(readout);
more.appendChild(plus);
row.appendChild(more);
},
makeStepButton(label, onPick) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "stepBtn";
btn.textContent = label;
btn.addEventListener("click", onPick);
return btn;
},
stepWays(delta) {
this.ways = Math.max(2, Math.min(this.MAX_WAYS, this.ways + delta));
this.renderWayChoices();
this.update();
},
renderChipChoices() {
const row = document.getElementById("chipChoices");
row.innerHTML = "";
this.CHIP_CHOICES.forEach(choice => {
row.appendChild(
this.makeChoiceButton(choice.label, this.chip === choice.value, () => {
this.chip = choice.value;
this.renderChipChoices();
this.update();
})
);
});
},
update() {
const potValue = Number(this.potInput.value);
const pot = Number.isNaN(potValue) ? 0 : potValue;
this.clearPotBtn.hidden = this.potInput.value === "";
const result = SplitCalculator.split(pot, this.ways, this.chip);
document.getElementById("splitResultLabel").textContent =
`Each of ${result.players} players gets`;
document.getElementById("splitResultAmount").textContent = Money.format(result.each);
const oddNote = document.getElementById("splitOddNote");
if (result.valid && result.oddChips > 0) {
oddNote.textContent = result.oddChips === 1
? `plus one odd ${Money.format(result.chip)} chip to the first player left of the button`
: `plus ${result.oddChips} odd ${Money.format(result.chip)} chips — one each to the ` +
`first ${result.oddChips} players left of the button`;
oddNote.hidden = false;
} else {
oddNote.hidden = true;
}
const dustNote = document.getElementById("splitDustNote");
if (result.valid && result.dust > 0) {
dustNote.textContent =
`${Money.format(result.dust)} doesn't divide into ${Money.format(result.chip)} chips — ` +
`pick a smaller denomination.`;
dustNote.hidden = false;
} else {
dustNote.hidden = true;
}
document.getElementById("splitResult").classList.toggle("hasAmount", result.valid && result.each > 0);
},
};
document.addEventListener("DOMContentLoaded", () => QuickSplit.mount());
@@ -106,7 +106,10 @@ const Render = {
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
node.addEventListener("click", () => {
this.selectedSeatIndex = index;
// Tapping the open seat again closes it — on a phone the editor is a
// sheet over the table, and the seat you just tapped is the closest
// thing to hand for dismissing it.
this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
this.renderSeats();
this.renderSeatEditor();
});
@@ -175,10 +178,20 @@ const Render = {
// ---- Seat editor panel (the full form for whichever seat is selected) ----
closeSeatEditor() {
this.selectedSeatIndex = null;
this.renderSeats();
this.renderSeatEditor();
},
renderSeatEditor() {
const title = document.getElementById("seatEditorTitle");
const body = document.getElementById("seatEditorBody");
// Drives the mobile bottom sheet: the panel only slides up over the table
// while a seat is actually being edited.
document.body.classList.toggle("seatSheetOpen", this.selectedSeatIndex !== null);
if (this.selectedSeatIndex === null) {
title.textContent = "Select a seat";
body.innerHTML = '<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>';
@@ -415,22 +428,6 @@ const Render = {
},
};
const Money = {
format(amount) {
const value = Number(amount) || 0;
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
},
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
// glance, and the sign belongs out front where the eye lands first.
formatSigned(amount) {
const value = Number(amount) || 0;
if (value > 0) return `+${this.format(value)}`;
if (value < 0) return `-${this.format(Math.abs(value))}`;
return this.format(0);
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = { Render, Money };
module.exports = { Render };
}
@@ -0,0 +1,53 @@
// Pure math for chopping one pot between tied players. No DOM.
//
// A dealer can't push half a chip across the felt, so this works in whole chips of
// whatever the smallest denomination in play is: everyone gets the same stack, and
// whatever won't divide is called out separately as odd chips. House rule for those
// is that they go to the first player left of the button, one at a time.
const SplitCalculator = {
// Everything is done in cents to keep 0.1 + 0.2 out of a payout.
toCents(amount) {
return Math.round((Number(amount) || 0) * 100);
},
split(potAmount, ways, chipAmount) {
const potCents = this.toCents(potAmount);
const chipCents = Math.max(1, this.toCents(chipAmount));
const players = Math.max(1, Math.floor(Number(ways) || 0));
if (potCents <= 0) {
return { valid: false, each: 0, oddChips: 0, chip: chipCents / 100, dust: 0, players };
}
const wholeChips = Math.floor(potCents / chipCents);
// Left over when the pot itself isn't a whole number of the smallest chip —
// usually means the dealer picked the wrong denomination, so it's surfaced
// rather than silently rolled into someone's stack.
const dustCents = potCents - wholeChips * chipCents;
const chipsEach = Math.floor(wholeChips / players);
const oddChips = wholeChips - chipsEach * players;
return {
valid: true,
players,
each: (chipsEach * chipCents) / 100,
oddChips,
chip: chipCents / 100,
dust: dustCents / 100,
};
},
// Sanity check for the UI: what the split actually hands out has to equal the pot.
accountedFor(result) {
return (
Math.round(result.each * 100) * result.players +
Math.round(result.chip * 100) * result.oddChips +
Math.round(result.dust * 100)
) / 100;
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = SplitCalculator;
}