Account for nights and double meals in estimator

This commit is contained in:
Neeldhara Misra 2026-07-06 23:22:25 +05:30
parent 545a341efb
commit 3f53d3c289

View file

@ -30,6 +30,7 @@ const currency = new Intl.NumberFormat("en-IN", {
<p class="print-only print-report-note"> <p class="print-only print-report-note">
Rates include 18% GST. Accommodation rates are for one night. Rates include 18% GST. Accommodation rates are for one night.
</p> </p>
<p class="print-only report-meta">Estimate date: <span data-report-date></span></p>
</div> </div>
<button class="button button--secondary print-button" type="button" data-print-estimate> <button class="button button--secondary print-button" type="button" data-print-estimate>
Print Cost Estimate Print Cost Estimate
@ -62,11 +63,11 @@ const currency = new Intl.NumberFormat("en-IN", {
<dd>{currency.format(withGst(rates.double))}</dd> <dd>{currency.format(withGst(rates.double))}</dd>
</div> </div>
<div> <div>
<dt>Lunch</dt> <dt>Lunch Per Person</dt>
<dd>{currency.format(withGst(rates.lunch))}</dd> <dd>{currency.format(withGst(rates.lunch))}</dd>
</div> </div>
<div> <div>
<dt>Dinner</dt> <dt>Dinner Per Person</dt>
<dd>{currency.format(withGst(rates.dinner))}</dd> <dd>{currency.format(withGst(rates.dinner))}</dd>
</div> </div>
</dl> </dl>
@ -76,7 +77,7 @@ const currency = new Intl.NumberFormat("en-IN", {
<div class="table-head"> <div class="table-head">
<div> <div>
<h2 id="estimate-table-title">Estimate</h2> <h2 id="estimate-table-title">Estimate</h2>
<p>Rates include 18% GST.</p> <p>Rates include 18% GST. Lunch and dinner are counted twice for double occupancy.</p>
</div> </div>
</div> </div>
@ -85,6 +86,23 @@ const currency = new Intl.NumberFormat("en-IN", {
<thead> <thead>
<tr> <tr>
<th scope="col">Name</th> <th scope="col">Name</th>
<th scope="col">
<span class="column-heading">
Nights
<label class="night-global-control">
<span class="sr-only">Set nights for all visible rows</span>
<input
type="number"
min="1"
step="1"
value="1"
inputmode="numeric"
data-nights-all
aria-label="Set number of nights for all visible rows"
/>
</label>
</span>
</th>
<th scope="col"> <th scope="col">
<span class="column-heading"> <span class="column-heading">
Single Occupancy Single Occupancy
@ -136,6 +154,20 @@ const currency = new Intl.NumberFormat("en-IN", {
data-1p-ignore data-1p-ignore
/> />
</td> </td>
<td>
<label class="sr-only" for={`nights-${row}`}>Number of nights for person {row}</label>
<input
id={`nights-${row}`}
class="night-input"
type="number"
min="1"
step="1"
value="1"
inputmode="numeric"
data-nights
aria-label={`Number of nights for person ${row}`}
/>
</td>
<td> <td>
<label class="check-cell"> <label class="check-cell">
<input <input
@ -162,14 +194,14 @@ const currency = new Intl.NumberFormat("en-IN", {
</td> </td>
<td> <td>
<label class="check-cell"> <label class="check-cell">
<input type="checkbox" value="lunch" data-cost="lunch" aria-label={`Lunch for person ${row}`} /> <input type="checkbox" value="lunch" data-cost="lunch" data-meal aria-label={`Lunch for person ${row}`} />
<span>Lunch</span> <span data-meal-label data-base-label="Lunch">Lunch</span>
</label> </label>
</td> </td>
<td> <td>
<label class="check-cell"> <label class="check-cell">
<input type="checkbox" value="dinner" data-cost="dinner" aria-label={`Dinner for person ${row}`} /> <input type="checkbox" value="dinner" data-cost="dinner" data-meal aria-label={`Dinner for person ${row}`} />
<span>Dinner</span> <span data-meal-label data-base-label="Dinner">Dinner</span>
</label> </label>
</td> </td>
</tr> </tr>
@ -186,6 +218,9 @@ const currency = new Intl.NumberFormat("en-IN", {
</div> </div>
<p data-total-note>For 5 people.</p> <p data-total-note>For 5 people.</p>
</div> </div>
<p class="calculator-disclaimer">
These numbers are indicative and not binding.
</p>
</section> </section>
</div> </div>
</section> </section>
@ -211,6 +246,8 @@ const currency = new Intl.NumberFormat("en-IN", {
const totalNote = document.querySelector<HTMLElement>("[data-total-note]"); const totalNote = document.querySelector<HTMLElement>("[data-total-note]");
const printButton = document.querySelector<HTMLButtonElement>("[data-print-estimate]"); const printButton = document.querySelector<HTMLButtonElement>("[data-print-estimate]");
const columnToggleButtons = Array.from(document.querySelectorAll<HTMLButtonElement>("[data-toggle-column]")); const columnToggleButtons = Array.from(document.querySelectorAll<HTMLButtonElement>("[data-toggle-column]"));
const globalNightsInput = document.querySelector<HTMLInputElement>("[data-nights-all]");
const reportDate = document.querySelector<HTMLElement>("[data-report-date]");
function getPeopleCount() { function getPeopleCount() {
return Number(peopleInput?.value ?? 1); return Number(peopleInput?.value ?? 1);
@ -238,6 +275,24 @@ const currency = new Intl.NumberFormat("en-IN", {
return rows.filter((row) => !row.hidden); return rows.filter((row) => !row.hidden);
} }
function normalizeNights(value: string | number | undefined) {
const nights = Math.floor(Number(value));
return Number.isFinite(nights) && nights > 0 ? nights : 1;
}
function getNights(row: HTMLTableRowElement) {
return normalizeNights(row.querySelector<HTMLInputElement>("[data-nights]")?.value);
}
function syncGlobalNights() {
if (!globalNightsInput) return;
const visibleNightValues = getVisibleRows().map(getNights);
const firstValue = visibleNightValues[0] ?? 1;
const sameValue = visibleNightValues.every((value) => value === firstValue);
globalNightsInput.value = sameValue ? String(firstValue) : "";
}
function getColumnInputs(cost: keyof typeof rateMap) { function getColumnInputs(cost: keyof typeof rateMap) {
return getVisibleRows() return getVisibleRows()
.map((row) => row.querySelector<HTMLInputElement>(`input[data-cost='${cost}']`)) .map((row) => row.querySelector<HTMLInputElement>(`input[data-cost='${cost}']`))
@ -255,34 +310,75 @@ const currency = new Intl.NumberFormat("en-IN", {
}); });
} }
function getMealMultiplier(row: HTMLTableRowElement) {
return row.querySelector<HTMLInputElement>("input[data-cost='double']")?.checked ? 2 : 1;
}
function syncPrintValues() { function syncPrintValues() {
rows.forEach((row) => { rows.forEach((row) => {
row.querySelectorAll<HTMLLabelElement>(".check-cell").forEach((label) => { row.querySelectorAll<HTMLLabelElement>(".check-cell").forEach((label) => {
const input = label.querySelector<HTMLInputElement>("input[type='checkbox']"); const input = label.querySelector<HTMLInputElement>("input[type='checkbox']");
label.dataset.printValue = input?.checked ? "Yes" : "No"; const isDoubledMeal = input?.checked && ["lunch", "dinner"].includes(input.dataset.cost ?? "") && getMealMultiplier(row) === 2;
label.dataset.printValue = input?.checked ? (isDoubledMeal ? "Yes (2)" : "Yes") : "No";
}); });
}); });
} }
function syncMealLabels() {
rows.forEach((row) => {
const mealMultiplier = getMealMultiplier(row);
const rowIndex = row.dataset.rowIndex ?? "";
row.querySelectorAll<HTMLElement>("[data-meal-label]").forEach((label) => {
const baseLabel = label.dataset.baseLabel ?? label.textContent ?? "";
label.textContent = mealMultiplier === 2 ? `${baseLabel} x2` : baseLabel;
});
row.querySelectorAll<HTMLInputElement>("input[data-meal]").forEach((input) => {
const baseLabel = input.dataset.cost === "dinner" ? "Dinner" : "Lunch";
input.setAttribute(
"aria-label",
`${baseLabel}${mealMultiplier === 2 ? " for two people" : ""} for person ${rowIndex}`,
);
});
});
}
function syncReportDate() {
if (!reportDate) return;
reportDate.textContent = new Intl.DateTimeFormat("en-IN", {
dateStyle: "medium",
}).format(new Date());
}
function updateTotal() { function updateTotal() {
let total = 0; let total = 0;
rows.forEach((row) => { rows.forEach((row) => {
if (row.hidden) return; if (row.hidden) return;
const mealMultiplier = getMealMultiplier(row);
const nights = getNights(row);
row.querySelectorAll<HTMLInputElement>("input[type='checkbox']:checked").forEach((input) => { row.querySelectorAll<HTMLInputElement>("input[type='checkbox']:checked").forEach((input) => {
const cost = input.dataset.cost as keyof typeof rateMap | undefined; const cost = input.dataset.cost as keyof typeof rateMap | undefined;
if (cost) total += rateMap[cost]; if (!cost) return;
const multiplier = cost === "lunch" || cost === "dinner" ? mealMultiplier : nights;
total += rateMap[cost] * multiplier;
}); });
}); });
if (totalCost) totalCost.textContent = currencyFormatter.format(total); if (totalCost) totalCost.textContent = currencyFormatter.format(total);
syncColumnToggles(); syncColumnToggles();
syncMealLabels();
syncPrintValues(); syncPrintValues();
} }
peopleInput?.addEventListener("input", () => { peopleInput?.addEventListener("input", () => {
syncRows(); syncRows();
syncGlobalNights();
updateTotal(); updateTotal();
}); });
@ -299,6 +395,26 @@ const currency = new Intl.NumberFormat("en-IN", {
updateTotal(); updateTotal();
}); });
row.addEventListener("input", (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement) || !target.matches("[data-nights]")) return;
syncGlobalNights();
updateTotal();
});
});
globalNightsInput?.addEventListener("input", () => {
const nights = normalizeNights(globalNightsInput.value);
getVisibleRows().forEach((row) => {
const input = row.querySelector<HTMLInputElement>("[data-nights]");
if (input) input.value = String(nights);
});
syncGlobalNights();
updateTotal();
}); });
columnToggleButtons.forEach((button) => { columnToggleButtons.forEach((button) => {
@ -327,10 +443,13 @@ const currency = new Intl.NumberFormat("en-IN", {
printButton?.addEventListener("click", () => { printButton?.addEventListener("click", () => {
syncPrintValues(); syncPrintValues();
syncReportDate();
window.print(); window.print();
}); });
syncRows(); syncRows();
syncGlobalNights();
syncReportDate();
updateTotal(); updateTotal();
</script> </script>
@ -457,7 +576,7 @@ const currency = new Intl.NumberFormat("en-IN", {
} }
.calculator-table { .calculator-table {
min-width: 760px; min-width: 860px;
background: var(--card); background: var(--card);
} }
@ -495,6 +614,37 @@ const currency = new Intl.NumberFormat("en-IN", {
box-shadow: 0 0 0 1px color-mix(in oklch, var(--primary) 24%, var(--border)); box-shadow: 0 0 0 1px color-mix(in oklch, var(--primary) 24%, var(--border));
} }
.night-global-control {
display: block;
width: 5.2rem;
}
.night-global-control input,
.night-input {
width: 5.2rem;
min-height: 38px;
border: 0;
border-radius: 6px;
background: var(--card);
color: var(--foreground);
font: inherit;
font-size: 16px;
font-variant-numeric: tabular-nums;
padding: 0.35rem 0.45rem;
box-shadow: 0 0 0 1px color-mix(in oklch, var(--border) 86%, transparent);
}
.night-input {
min-height: 44px;
background: color-mix(in oklch, var(--muted-2) 34%, white);
}
.night-global-control input:focus,
.night-input:focus {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.calculator-table td { .calculator-table td {
padding: 0.6rem 0.7rem; padding: 0.6rem 0.7rem;
vertical-align: middle; vertical-align: middle;
@ -571,6 +721,13 @@ const currency = new Intl.NumberFormat("en-IN", {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.calculator-disclaimer {
margin-top: 0.8rem;
color: var(--muted);
font-size: 0.92rem;
line-height: 1.4;
}
.sr-only { .sr-only {
position: absolute; position: absolute;
width: 1px; width: 1px;
@ -656,6 +813,12 @@ const currency = new Intl.NumberFormat("en-IN", {
font-size: 9.5pt; font-size: 9.5pt;
} }
.report-meta {
margin-top: 0.16rem;
color: #50554c;
font-size: 9pt;
}
.calculator-panel { .calculator-panel {
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
@ -753,6 +916,10 @@ const currency = new Intl.NumberFormat("en-IN", {
display: none; display: none;
} }
.night-global-control {
display: none;
}
.check-cell span { .check-cell span {
display: none; display: none;
} }
@ -783,6 +950,16 @@ const currency = new Intl.NumberFormat("en-IN", {
padding: 0; padding: 0;
} }
.night-input {
width: 100%;
min-height: 0;
box-shadow: none;
background: transparent;
color: #151812;
font-size: 9pt;
padding: 0;
}
.total-bar { .total-bar {
display: block; display: block;
margin-top: 0.5rem; margin-top: 0.5rem;
@ -794,6 +971,13 @@ const currency = new Intl.NumberFormat("en-IN", {
text-align: right; text-align: right;
} }
.calculator-disclaimer {
margin-top: 0.35rem;
color: #50554c;
font-size: 8.5pt;
text-align: right;
}
.total-bar span { .total-bar span {
color: #50554c; color: #50554c;
font-size: 8pt; font-size: 8pt;