diff --git a/.github/workflows/lint-format-write.yml b/.github/workflows/lint-format-write.yml index 7912838..850a78d 100644 --- a/.github/workflows/lint-format-write.yml +++ b/.github/workflows/lint-format-write.yml @@ -11,17 +11,17 @@ on: jobs: lint-format-write: - # Only run on manual trigger, not on main/master branches if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: - name: Checkout code uses: actions/checkout@v4 with: - # Need to fetch all history for git operations fetch-depth: 0 - # Need write permissions to push commits token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python @@ -66,4 +66,10 @@ jobs: run: | git add -A git commit -m "${{ github.event.inputs.commit_message }}" - git push origin ${{ github.ref }} \ No newline at end of file + git push origin ${{ github.ref }} || { + echo "Failed to push changes. This might be due to:" + echo "1. Insufficient permissions on the token" + echo "2. Branch protection rules" + echo "3. Repository settings" + exit 1 + } diff --git a/src/bill/calculator.py b/src/bill/calculator.py index 33f6f0c..d954fbb 100644 --- a/src/bill/calculator.py +++ b/src/bill/calculator.py @@ -1,5 +1,6 @@ import csv from io import StringIO +from typing import Iterable from bill.person import Person from bill.receipts import Items, Item @@ -124,6 +125,24 @@ def get_person_total(self, person: Person) -> float: ) return person_subtotal + sum(person_extras) + def get_person_shares(self, person: Person) -> "Iterable[tuple[Item, float]]": + """ + Lazily compute a person's shares across all items, paired with items. + + Parameters + ---------- + person: Person + The person to calculate shares for + + Returns + ------- + Iterable[tuple[Item, float]] + A lazy iterable yielding (Item, share) tuples for each item in self.items.items. + """ + return map( + lambda item: (item, self.get_person_share(item, person)), self.items.items + ) + def get_shares_csv(self): """ Get a CSV string of shares for each person. diff --git a/src/bill/person.py b/src/bill/person.py index 912a9fe..79a3557 100644 --- a/src/bill/person.py +++ b/src/bill/person.py @@ -5,3 +5,59 @@ class Person(BaseModel): name: str items: List[int] + + def insert_item(self, item_index: int) -> None: + """ + Add an item to the person's items list if not already present. + + Parameters + ---------- + item_index : int + The index of the item to add to the person's items list. + + Returns + ------- + None + The items list is modified in place. + """ + items_set = set(self.items) + items_set.add(item_index) + self.items = sorted(list(items_set)) + + def remove_item(self, item_index: int) -> None: + """ + Remove an item from the person's items list if present. + + Parameters + ---------- + item_index : int + The index of the item to remove from the person's items list. + + Returns + ------- + None + The items list is modified in place. + """ + try: + self.items.remove(item_index) + except ValueError: + pass + + def update_item(self, item_index: int) -> None: + """ + Toggle an item in the person's items list - add if not present, remove if present. + + Parameters + ---------- + item_index : int + The index of the item to toggle in the person's items list. + + Returns + ------- + None + The items list is modified in place. + """ + if item_index not in self.items: + self.insert_item(item_index) + else: + self.remove_item(item_index) diff --git a/src/ui/payments.py b/src/ui/payments.py index 211784e..96d5625 100644 --- a/src/ui/payments.py +++ b/src/ui/payments.py @@ -4,12 +4,13 @@ Blueprint, request, Response, + jsonify, ) from bill.calculator import Calculator from logging import getLogger from items import get_current_items from extras import get_current_extras -from persons import get_current_persons +from persons import get_current_persons, save_persons_file from datetime import datetime log = getLogger(__file__) @@ -36,10 +37,9 @@ def payments_page_view(): { "name": item.name, "price": item.price, - "share": calculator.get_person_share(item, person), + "share": share, } - for item_index, item in enumerate(items.items) - if item_index in person.items + for item, share in calculator.get_person_shares(person) ] person_extras = [ @@ -88,3 +88,38 @@ def download_csv(): mimetype="text/csv", headers={"Content-Disposition": f"attachment; filename={filename}"}, ) + + +@payments_page.route("/share_item", methods=["POST"]) +def share_item(): + data = request.get_json() + item_index = data.get("item_index") + person_index = data.get("person_index") + + persons = get_current_persons(session) + person = persons[person_index] + person.update_item(item_index) + save_persons_file(persons, session) + + items = get_current_items(session) + extras = get_current_extras(session) + calculator = Calculator(persons=persons, items=items, extras=extras) + item = items.items[item_index] + share = calculator.get_person_share(item, person) + + person_subtotal = calculator.get_person_subtotal(person) + person_total = calculator.get_person_total(person) + + return ( + jsonify( + { + "success": True, + "share": share, + "item_name": item.name, + "item_price": item.price, + "person_subtotal": person_subtotal, + "person_total": person_total, + } + ), + 200, + ) diff --git a/src/ui/payments.ts b/src/ui/payments.ts index 2309545..663a4d7 100644 --- a/src/ui/payments.ts +++ b/src/ui/payments.ts @@ -23,6 +23,7 @@ document.addEventListener("DOMContentLoaded", () => { downloadButton.addEventListener("click", handleDownload); initializePersonData(); + setupItemClickedHandlers(); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { @@ -70,3 +71,64 @@ function handleExtras(): void { function handleDownload(): void { window.location.href = "/payments/download"; } + +function setupItemClickedHandlers(): void { + const itemElements = document.querySelectorAll(".item-box"); + itemElements.forEach((itemElement) => { + itemElement.addEventListener("click", handleItemClick); + }); +} + +async function handleItemClick(e: Event): Promise { + const itemElement = e.currentTarget as HTMLElement; + const itemIndex = parseInt(itemElement.getAttribute("data-item-index") || "0"); + + try { + const response = await fetch("/share_item", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + item_index: itemIndex, + person_index: currentPersonIndex, + }), + }); + + if (response.ok) { + const result = await response.json(); + updateItemInfoDisplay(itemElement, result); + } + } catch (error) { + console.error("Error sharing item:", error); + } +} + +function updateItemInfoDisplay(itemElement: HTMLElement, data: any): void { + const shareElement = itemElement.querySelector(".text-blue-400.font-semibold") as HTMLElement; + const priceElement = itemElement.querySelector(".text-xs.text-slate-400") as HTMLElement; + + if (data.share !== undefined) { + shareElement.textContent = `$${data.share.toFixed(2)}`; + } + + if (data.share > 0) { + itemElement.classList.add("ring-4", "ring-green-400/40", "border-green-400"); + } else { + itemElement.classList.remove("ring-4", "ring-green-400/40", "border-green-400"); + } + + if (data.person_subtotal !== undefined) { + const subtotalElement = document.querySelector(".text-green-400") as HTMLElement; + if (subtotalElement) { + subtotalElement.textContent = `$${data.person_subtotal.toFixed(2)}`; + } + } + + if (data.person_total !== undefined) { + const totalElement = document.getElementById("person-total") as HTMLElement; + if (totalElement) { + totalElement.textContent = `$${data.person_total.toFixed(2)}`; + } + } +} diff --git a/src/ui/templates/payments.html b/src/ui/templates/payments.html index e85f6a6..d8a8660 100644 --- a/src/ui/templates/payments.html +++ b/src/ui/templates/payments.html @@ -67,21 +67,15 @@

Bill

Items

- {% if person_items %} - {% for item in person_items %} -
- {{ item.name }} -
-
${{ "%.2f"|format(item.share) }}
-
/ ${{ "%.2f"|format(item.price) }}
-
-
- {% endfor %} - {% else %} -
-

No items

+ {% for item in person_items %} +
+ {{ item.name }} +
+
${{ "%.2f"|format(item.share) }}
+
/ ${{ "%.2f"|format(item.price) }}
- {% endif %} +
+ {% endfor %}
diff --git a/tests/test_calculator.py b/tests/test_calculator.py index 5436ce9..2c2afd2 100644 --- a/tests/test_calculator.py +++ b/tests/test_calculator.py @@ -194,3 +194,34 @@ def test_csv(calculator, sample_persons): total = subtotal + service_charge + tax assert f"Total,{total:.2f},99.72,118.62,200.09,{total:.2f}" in csv_output + + +def test_person_update_item(): + """ + Test Person.update_item() method to ensure it correctly toggles items. + Tests both insert_item() and remove_item() scenarios. + """ + person = Person(name="Test", items=[1, 3, 5]) + + person.update_item(7) + assert person.items == [ + 1, + 3, + 5, + 7, + ], "Item 7 should be added and list should be sorted" + + person.update_item(3) + assert person.items == [1, 3, 5, 7], "Item 3 should remain unchanged" + + person.update_item(5) + assert person.items == [1, 3, 7], "Item 5 should be removed" + + person.update_item(9) + assert person.items == [1, 3, 7], "Item 9 should not affect the list" + + person.update_item(5) + assert person.items == [1, 3, 5, 7], "Item 5 should be added back" + + person.update_item(5) + assert person.items == [1, 3, 7], "Item 5 should be removed again"