Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
83bea44
Refactor distribute page to support per-person item distribution
cursoragent Aug 8, 2025
f605516
Refactor item share calculation using map and list comprehension
cursoragent Aug 8, 2025
4e61eb1
feature/actions (#6)
dphoria Aug 10, 2025
6b018d9
bugfix/action-commit (#7)
dphoria Aug 10, 2025
8cbdade
Merge branch 'master' into feature/split-list
dphoria Aug 10, 2025
4f11899
Simplify person items rendering and filtering logic
cursoragent Aug 9, 2025
c008ad8
Add method to get individual person's shares across all items
cursoragent Aug 9, 2025
1c02d92
Modify get_person_shares to return item-share tuples instead of just …
cursoragent Aug 9, 2025
1f14845
Refactor get_person_shares to use lazy evaluation with map
cursoragent Aug 9, 2025
df2e8e7
Add distribute_item endpoint for splitting bill item among people
cursoragent Aug 9, 2025
efb86e1
Remove total_distributed calculation from item distribution response
cursoragent Aug 9, 2025
4d06333
Add Calculator initialization with empty extras in distribute_item
cursoragent Aug 9, 2025
09c1a62
Refactor item distribution to use Calculator for precise share calcul…
cursoragent Aug 9, 2025
3834da1
Remove distribute_item function from payments page
cursoragent Aug 9, 2025
98c7f87
Add route to share/unshare payment items between persons
cursoragent Aug 10, 2025
2fcccbb
Simplify share_item logic by removing redundant index check
cursoragent Aug 10, 2025
32d5c66
Add interactive item sharing with dynamic UI updates
cursoragent Aug 10, 2025
31b8c18
Remove unnecessary comments in payments UI code
cursoragent Aug 10, 2025
ff58c2d
Simplify item sharing logic by removing redundant sharing flag
cursoragent Aug 10, 2025
778378f
Add person subtotal and total to share item response
cursoragent Aug 10, 2025
ea7088f
Refactor payments view to use new calculator method for person shares
cursoragent Aug 10, 2025
3dcb737
Add item management methods to Person class and simplify share_item l…
cursoragent Aug 10, 2025
de37314
Enhance docstrings for Person methods with detailed parameter descrip…
cursoragent Aug 10, 2025
7250184
Refactor item management methods to prevent duplicates and handle errors
cursoragent Aug 10, 2025
54d1529
Add test for Person.update_item() method with toggle functionality
cursoragent Aug 10, 2025
04d7853
Simplify test_person_update_item by removing redundant comments
cursoragent Aug 10, 2025
af270c8
Revert distribute files to master branch state
cursoragent Aug 10, 2025
ab33932
Checkpoint before follow-up message
cursoragent Aug 10, 2025
9d51687
Fix TypeScript compilation errors by renaming duplicate functions in …
cursoragent Aug 10, 2025
8b3ef1f
Fix missing import: add save_persons_file to payments.py imports
cursoragent Aug 10, 2025
e0fc81c
Fix missing import: add Iterable to calculator.py imports
cursoragent Aug 10, 2025
8f1d5b3
style: apply automatic formatting and linting fixes
actions-user Aug 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions .github/workflows/lint-format-write.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -66,4 +66,10 @@ jobs:
run: |
git add -A
git commit -m "${{ github.event.inputs.commit_message }}"
git push origin ${{ github.ref }}
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
}
19 changes: 19 additions & 0 deletions src/bill/calculator.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
Expand Down
56 changes: 56 additions & 0 deletions src/bill/person.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
43 changes: 39 additions & 4 deletions src/ui/payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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 = [
Expand Down Expand Up @@ -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,
)
62 changes: 62 additions & 0 deletions src/ui/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ document.addEventListener("DOMContentLoaded", () => {
downloadButton.addEventListener("click", handleDownload);

initializePersonData();
setupItemClickedHandlers();

document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
Expand Down Expand Up @@ -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<void> {
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)}`;
}
}
}
22 changes: 8 additions & 14 deletions src/ui/templates/payments.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,21 +67,15 @@ <h1 class="text-2xl font-bold">Bill</h1>
<h3 class="text-lg font-semibold text-slate-200 mb-4">Items</h3>

<div class="space-y-3 mb-6">
{% if person_items %}
{% for item in person_items %}
<div class="flex justify-between items-center p-3 bg-slate-800/50 rounded-lg border border-slate-700">
<span class="font-medium text-slate-100">{{ item.name }}</span>
<div class="text-right">
<div class="text-blue-400 font-semibold">${{ "%.2f"|format(item.share) }}</div>
<div class="text-xs text-slate-400">/ ${{ "%.2f"|format(item.price) }}</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center text-slate-400 py-4">
<p>No items</p>
{% for item in person_items %}
<div class="flex justify-between items-center p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800/70 transition-colors item-box" data-item-index="{{ loop.index0 }}">
<span class="font-medium text-slate-100">{{ item.name }}</span>
<div class="text-right">
<div class="text-blue-400 font-semibold">${{ "%.2f"|format(item.share) }}</div>
<div class="text-xs text-slate-400">/ ${{ "%.2f"|format(item.price) }}</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>

<!-- Items Subtotal -->
Expand Down
31 changes: 31 additions & 0 deletions tests/test_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"