Merge pull request #2 from avimallu/add_proportion
Add 'Split by Proportion' and 'Remove Item' features
This commit is contained in:
@@ -5,14 +5,14 @@
|
||||
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="preload" as="script" href="/BorrowChecker/assets/borrow_checker-0061ef486a3a77ac.js" crossorigin><link rel="preload" as="fetch" type="application/wasm" href="/BorrowChecker/assets/borrow_checker_bg-700a1215554002db.wasm" crossorigin></head>
|
||||
<link rel="preload" as="script" href="/BorrowChecker/assets/borrow_checker-431cb889995abfdd.js" crossorigin><link rel="preload" as="fetch" type="application/wasm" href="/BorrowChecker/assets/borrow_checker_bg-27678ce938d4a71f.wasm" crossorigin></head>
|
||||
<body>
|
||||
<div id="main"></div>
|
||||
<script>
|
||||
// We can't use a module script here because we need to start the script immediately when streaming
|
||||
import("/BorrowChecker/assets/borrow_checker-0061ef486a3a77ac.js").then(
|
||||
import("/BorrowChecker/assets/borrow_checker-431cb889995abfdd.js").then(
|
||||
({ default: init }) => {
|
||||
init("/BorrowChecker/assets/borrow_checker_bg-700a1215554002db.wasm").then((wasm) => {
|
||||
init("/BorrowChecker/assets/borrow_checker_bg-27678ce938d4a71f.wasm").then((wasm) => {
|
||||
if (wasm.__wbindgen_start == undefined) {
|
||||
wasm.main();
|
||||
}
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
docs/assets/borrow_checker-431cb889995abfdd.js.br
Normal file
BIN
docs/assets/borrow_checker-431cb889995abfdd.js.br
Normal file
Binary file not shown.
BIN
docs/assets/borrow_checker_bg-27678ce938d4a71f.wasm
Normal file
BIN
docs/assets/borrow_checker_bg-27678ce938d4a71f.wasm
Normal file
Binary file not shown.
BIN
docs/assets/borrow_checker_bg-27678ce938d4a71f.wasm.br
Normal file
BIN
docs/assets/borrow_checker_bg-27678ce938d4a71f.wasm.br
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,14 +5,14 @@
|
||||
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="preload" as="script" href="/BorrowChecker/assets/borrow_checker-0061ef486a3a77ac.js" crossorigin><link rel="preload" as="fetch" type="application/wasm" href="/BorrowChecker/assets/borrow_checker_bg-700a1215554002db.wasm" crossorigin></head>
|
||||
<link rel="preload" as="script" href="/BorrowChecker/assets/borrow_checker-431cb889995abfdd.js" crossorigin><link rel="preload" as="fetch" type="application/wasm" href="/BorrowChecker/assets/borrow_checker_bg-27678ce938d4a71f.wasm" crossorigin></head>
|
||||
<body>
|
||||
<div id="main"></div>
|
||||
<script>
|
||||
// We can't use a module script here because we need to start the script immediately when streaming
|
||||
import("/BorrowChecker/assets/borrow_checker-0061ef486a3a77ac.js").then(
|
||||
import("/BorrowChecker/assets/borrow_checker-431cb889995abfdd.js").then(
|
||||
({ default: init }) => {
|
||||
init("/BorrowChecker/assets/borrow_checker_bg-700a1215554002db.wasm").then((wasm) => {
|
||||
init("/BorrowChecker/assets/borrow_checker_bg-27678ce938d4a71f.wasm").then((wasm) => {
|
||||
if (wasm.__wbindgen_start == undefined) {
|
||||
wasm.main();
|
||||
}
|
||||
|
||||
127
src/app/split.rs
127
src/app/split.rs
@@ -18,7 +18,7 @@ pub fn SplitUI() -> Element {
|
||||
}
|
||||
}
|
||||
div { class: "section",
|
||||
div { class: "container is-fluid",
|
||||
div { class: "container",
|
||||
for item_idx in 0..item_count {
|
||||
SplitItemUI { item_idx }
|
||||
}
|
||||
@@ -48,7 +48,6 @@ pub fn SplitUI() -> Element {
|
||||
icon: ld_icons::LdBookPlus,
|
||||
}
|
||||
span { class: "ml-2", "Add Item" }
|
||||
|
||||
}
|
||||
}
|
||||
div {
|
||||
@@ -76,7 +75,7 @@ pub fn SplitUI() -> Element {
|
||||
}
|
||||
footer { class: "hero is-small is-primary",
|
||||
div { class: "hero-body has-text-centered is-flex is-justify-content-center",
|
||||
p { class: "subtitle is-size-7 mr-1", "Built with Rust & Dioxus | " }
|
||||
p { class: "subtitle is-size-7 mr-1", "% = split proportionally | " }
|
||||
p { class: "subtitle is-size-7 mr-1", "👾🤖👻 |" }
|
||||
a {
|
||||
class: "subtitle is-size-7 mr-1",
|
||||
@@ -100,10 +99,10 @@ fn ColorBalanceTitle(balance: Decimal) -> Element {
|
||||
p { class: "subtitle is-size-5", "left to balance" }
|
||||
} else if balance < Decimal::ZERO {
|
||||
p { class: "title has-text-danger is-size-4", "Remaining: {balance}" }
|
||||
p { class: "subtitle is-size-6", "Item total exceeds receipt total." }
|
||||
p { class: "subtitle is-size-6", "item totals > receipt total" }
|
||||
} else {
|
||||
p { class: "title has-text-link is-size-4", "0" }
|
||||
p { class: "subtitle is-size-6", "Perfectly balanced, as all things should be." }
|
||||
p { class: "subtitle is-size-6", "balanced" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,21 +110,37 @@ fn ColorBalanceTitle(balance: Decimal) -> Element {
|
||||
#[component]
|
||||
fn SplitItemUI(item_idx: usize) -> Element {
|
||||
let people_list = (*RECEIPT_STATE.read()).as_ref().unwrap().shared_by.clone();
|
||||
let disable_proportional_button: String = match (*RECEIPT_STATE.read())
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.is_proportionally_splittable(item_idx)
|
||||
{
|
||||
false => "true".into(),
|
||||
true => "false".into(),
|
||||
};
|
||||
let disable_removal_button: String = match (*RECEIPT_STATE.read())
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.is_removable(item_idx)
|
||||
{
|
||||
false => "true".into(),
|
||||
true => "false".into(),
|
||||
};
|
||||
|
||||
let (item_name, item_value, item_shared_by) = &RECEIPT_STATE
|
||||
.read()
|
||||
let (item_name, item_value, item_shared_by, item_is_prop_dist) = (&RECEIPT_STATE.read())
|
||||
.as_ref()
|
||||
.and_then(|r| r.items.get(item_idx))
|
||||
.map(|item| {
|
||||
(
|
||||
item.name.clone(),
|
||||
item.value.clone(),
|
||||
item.value, //.clone(),
|
||||
item.shared_by.clone(),
|
||||
item.is_prop_dist, //.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let item_value = if item_value > &Decimal::ZERO {
|
||||
let item_value = if item_value > Decimal::ZERO {
|
||||
item_value.to_string()
|
||||
} else {
|
||||
"-".to_string()
|
||||
@@ -133,8 +148,8 @@ fn SplitItemUI(item_idx: usize) -> Element {
|
||||
|
||||
if true {
|
||||
rsx! {
|
||||
div { class: "columns is-mobile",
|
||||
div { class: "column is-two-thirds",
|
||||
div { class: "columns is-mobile is-1 is-vcentered",
|
||||
div { class: "column is-5",
|
||||
input {
|
||||
class: "input is-primary",
|
||||
key: "item_input_name_{item_idx}",
|
||||
@@ -150,7 +165,7 @@ fn SplitItemUI(item_idx: usize) -> Element {
|
||||
placeholder: "item name",
|
||||
}
|
||||
}
|
||||
div { class: "column is-one-third",
|
||||
div { class: "column is-3",
|
||||
input {
|
||||
class: "input is-primary",
|
||||
key: "item_input_value_{item_idx}",
|
||||
@@ -171,7 +186,51 @@ fn SplitItemUI(item_idx: usize) -> Element {
|
||||
}
|
||||
}
|
||||
},
|
||||
placeholder: "amount",
|
||||
placeholder: "$",
|
||||
}
|
||||
}
|
||||
div { class: "column is-2",
|
||||
button {
|
||||
class: if item_is_prop_dist { "button is-dark is-info is-fullwidth" } else { "button is-dark is-outlined is-info is-fullwidth" },
|
||||
key: "item_proportional_button_{item_idx}",
|
||||
disabled: disable_proportional_button,
|
||||
onclick: move |_| {
|
||||
if let Some(receipt) = RECEIPT_STATE.write().as_mut() {
|
||||
receipt
|
||||
.update_item_at_index(
|
||||
item_idx,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(!item_is_prop_dist.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
},
|
||||
Icon {
|
||||
width: 24,
|
||||
height: 24,
|
||||
fill: "white",
|
||||
icon: ld_icons::LdPercent,
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "column is-2",
|
||||
button {
|
||||
class: "button is-dark is-danger is-fullwidth",
|
||||
key: "item_delete_button_{item_idx}",
|
||||
disabled: disable_removal_button,
|
||||
onclick: move |_| {
|
||||
if let Some(receipt) = RECEIPT_STATE.write().as_mut() {
|
||||
receipt.remove_item_at_index(item_idx).unwrap();
|
||||
}
|
||||
},
|
||||
Icon {
|
||||
width: 24,
|
||||
height: 24,
|
||||
fill: "white",
|
||||
icon: ld_icons::LdCircleX,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,20 +241,34 @@ fn SplitItemUI(item_idx: usize) -> Element {
|
||||
class: if item_shared_by.contains(&person) { "button is-primary is-dark is-fullwidth" } else { "button is-primary is-outlined is-dark is-fullwidth" },
|
||||
key: "item_{item_idx}_person_{person_idx}",
|
||||
onclick: move |_| {
|
||||
if let Some(r) = RECEIPT_STATE.write().as_mut() {
|
||||
if let Some(item) = r.items.get_mut(item_idx) {
|
||||
if item.shared_by.contains(&person) && item.shared_by.len() > 1 {
|
||||
let shared_by_idx = item
|
||||
.shared_by
|
||||
.iter()
|
||||
.position(|name| *name == *person)
|
||||
.unwrap();
|
||||
item.shared_by.remove(shared_by_idx);
|
||||
item.share_ratio.remove(shared_by_idx);
|
||||
} else {
|
||||
item.shared_by.push(person.clone());
|
||||
item.share_ratio.push(Decimal::ONE);
|
||||
}
|
||||
if let Some(receipt) = RECEIPT_STATE.write().as_mut() {
|
||||
let mut new_item_shared_by = receipt.items[item_idx].shared_by.clone();
|
||||
if new_item_shared_by.contains(&person) && new_item_shared_by.len() > 1 {
|
||||
let new_item_shared_by = new_item_shared_by
|
||||
.iter()
|
||||
.filter(|&x| *x != person)
|
||||
.cloned()
|
||||
.collect();
|
||||
receipt
|
||||
.update_item_at_index(
|
||||
item_idx,
|
||||
None,
|
||||
None,
|
||||
Some(new_item_shared_by),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
} else if !new_item_shared_by.contains(&person) {
|
||||
new_item_shared_by.push(person.clone());
|
||||
receipt
|
||||
.update_item_at_index(
|
||||
item_idx,
|
||||
None,
|
||||
None,
|
||||
Some(new_item_shared_by),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
110
src/app/ui.rs
110
src/app/ui.rs
@@ -1,110 +0,0 @@
|
||||
use crate::core::receipt::Receipt;
|
||||
use dioxus::prelude::*;
|
||||
use rust_decimal::prelude::*;
|
||||
|
||||
static CSS: Asset = asset!("/assets/bulma.css");
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
enum AppState {
|
||||
CreatingReceipt,
|
||||
SplittingItems,
|
||||
DisplayingSplits,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> Element {
|
||||
let app_state = use_signal(|| AppState::CreatingReceipt);
|
||||
|
||||
// Variables to create the essential values of the receipt
|
||||
let receipt_value: Signal<Option<Decimal>> = use_signal(|| None);
|
||||
let people_input: Signal<Vec<String>> = use_signal(|| vec!["".to_string()]);
|
||||
let people_list: Memo<Vec<String>> = use_memo(move || {
|
||||
people_input
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|name| !name.is_empty())
|
||||
.cloned()
|
||||
.collect()
|
||||
});
|
||||
|
||||
// Variables to input and handle receipt items
|
||||
let receipt: Signal<Option<Receipt>> = use_signal(|| None);
|
||||
|
||||
rsx! {
|
||||
document::Stylesheet { href: CSS }
|
||||
section { class: "hero is-primary",
|
||||
div { class: "hero-body",
|
||||
p { class: "title", "Iron Abacus" }
|
||||
p { class: "subtitle", "A splitting helper" }
|
||||
}
|
||||
}
|
||||
if app_state() == AppState::CreatingReceipt {
|
||||
CreateReceipt {
|
||||
app_state,
|
||||
receipt_value,
|
||||
people_input,
|
||||
people_list,
|
||||
receipt,
|
||||
}
|
||||
} else if app_state() == AppState::SplittingItems {
|
||||
SplitReceipt {
|
||||
receipt_value,
|
||||
people_list,
|
||||
app_state,
|
||||
receipt,
|
||||
}
|
||||
} else {
|
||||
DisplayTable { app_state, receipt }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn DisplayTable(mut app_state: Signal<AppState>, mut receipt: Signal<Option<Receipt>>) -> Element {
|
||||
if let Some(valid_receipt) = receipt().as_ref() {
|
||||
let mut header = valid_receipt.shared_by.clone();
|
||||
header.insert(0, "Item Name".into());
|
||||
header.push("Total".into());
|
||||
|
||||
let (item_names, item_splits) = valid_receipt.calculate_splits()?;
|
||||
|
||||
let rows: Vec<Vec<String>> = item_names
|
||||
.into_iter()
|
||||
.zip(item_splits.into_iter())
|
||||
.map(|(item_name, splits)| {
|
||||
let mut splits_as_str: Vec<String> = splits.iter().map(|x| x.to_string()).collect();
|
||||
splits_as_str.insert(0, item_name.into());
|
||||
splits_as_str
|
||||
})
|
||||
.collect();
|
||||
|
||||
rsx! {
|
||||
div { class: "table-container",
|
||||
table { class: "table",
|
||||
thead {
|
||||
tr {
|
||||
for val in header.iter() {
|
||||
th { scope: "col", "{val}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for row in rows.iter() {
|
||||
tr {
|
||||
for (idx , val) in row.iter().enumerate() {
|
||||
if idx == 0 {
|
||||
th { scope: "row", "{val}" }
|
||||
} else {
|
||||
td { "{val}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rsx! { "No table to show yet, bitches!" }
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ pub enum SplittingError {
|
||||
ItemTotalExceedsReceiptTotal(String),
|
||||
DecimalParsingError(String),
|
||||
InvalidArgument(String),
|
||||
InvalidIndexError(String),
|
||||
NotProportionallySplittableError(String),
|
||||
}
|
||||
|
||||
impl From<rust_decimal::Error> for SplittingError {
|
||||
@@ -61,6 +63,8 @@ impl fmt::Display for SplittingError {
|
||||
Self::ItemTotalExceedsReceiptTotal(msg) => write!(f, "{}", msg),
|
||||
Self::DecimalParsingError(msg) => write!(f, "{}", msg),
|
||||
Self::InvalidArgument(msg) => write!(f, "{}", msg),
|
||||
Self::InvalidIndexError(msg) => write!(f, "{}", msg),
|
||||
Self::NotProportionallySplittableError(msg) => write!(f, "{}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,13 +134,9 @@ impl Receipt {
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
// Obtain a single vector with the exact splits, with or without items with
|
||||
// the is_prop_dist attribute as true
|
||||
fn calculate_overall_proportion(&self, remove_proportional_items: bool) -> Vec<Decimal> {
|
||||
let items = self
|
||||
.items
|
||||
.iter()
|
||||
.filter(|&x| !remove_proportional_items || !x.is_prop_dist);
|
||||
// Obtain a single vector with the exact splits
|
||||
fn calculate_receipt_proportions(&self) -> Vec<Decimal> {
|
||||
let items = self.items.iter().filter(|&x| !x.is_prop_dist);
|
||||
|
||||
let mut receipt_split: Vec<Decimal> = vec![Decimal::ZERO; self.shared_by.len()];
|
||||
for item in items {
|
||||
@@ -166,6 +166,31 @@ impl Receipt {
|
||||
receipt_split
|
||||
}
|
||||
|
||||
pub fn calculate_item_share_ratio_by_proportion(
|
||||
&self,
|
||||
shared_by: &Vec<String>,
|
||||
value: Decimal,
|
||||
) -> Vec<Decimal> {
|
||||
// Align the proportional splits to the current shared_by ratios
|
||||
let pre_prop_splits: Vec<Decimal> = self
|
||||
.calculate_receipt_proportions()
|
||||
.iter()
|
||||
.zip(self.shared_by.iter())
|
||||
.filter(|(_, person)| shared_by.contains(*person))
|
||||
.map(|(split, _)| split.clone())
|
||||
.collect();
|
||||
|
||||
let pre_prop_split_total: Decimal = pre_prop_splits.iter().sum();
|
||||
let proportional_splits: Vec<Decimal> = pre_prop_splits
|
||||
.iter()
|
||||
.map(|x| x / pre_prop_split_total)
|
||||
.collect();
|
||||
return proportional_splits
|
||||
.iter()
|
||||
.map(|ratio| (ratio * value).round_dp(2))
|
||||
.collect();
|
||||
}
|
||||
|
||||
pub fn add_item_split_by_proportion(
|
||||
&mut self,
|
||||
value: Decimal,
|
||||
@@ -173,42 +198,17 @@ impl Receipt {
|
||||
shared_by: Vec<String>,
|
||||
) -> Result<&mut Self, SplittingError> {
|
||||
if shared_by.len() == 0 {
|
||||
return Err(SplittingError::NotEnoughPeopleError(format!(
|
||||
"The number of people sharing the item {} is currently {}. It must be shared by at least 1 person.",
|
||||
name,
|
||||
shared_by.len()
|
||||
return Err(SplittingError::InvalidShareConfiguration(format!(
|
||||
"Number of people sharing this receipt cannot be zero."
|
||||
)));
|
||||
} else if name.is_empty() {
|
||||
}
|
||||
if name.is_empty() {
|
||||
return Err(SplittingError::InvalidFieldError(
|
||||
"Item name cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// These vectors are aligned with the order of self.shared_by
|
||||
let pre_prop_splits = self.calculate_overall_proportion(true);
|
||||
let pre_prop_split_total: Decimal = pre_prop_splits.iter().sum();
|
||||
let pre_prop_ratios: Vec<Decimal> = pre_prop_splits
|
||||
.iter()
|
||||
.map(|x| x / pre_prop_split_total)
|
||||
.collect();
|
||||
|
||||
// This vector's length will be identical to shared_by
|
||||
let share_ratio: Vec<Decimal> = shared_by
|
||||
.iter()
|
||||
.map(|sharer| {
|
||||
pre_prop_ratios
|
||||
.iter()
|
||||
.zip(self.shared_by.iter())
|
||||
.find(|&(_, person)| *sharer == *person)
|
||||
.map(|(&ratio, _)| ratio * value)
|
||||
.ok_or_else(|| {
|
||||
SplittingError::InternalError(format!(
|
||||
"The sharer {} was not found among the original sharers.",
|
||||
sharer,
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
let share_ratio = self.calculate_item_share_ratio_by_proportion(&shared_by, value);
|
||||
|
||||
self.items.push(ReceiptItem {
|
||||
value,
|
||||
@@ -254,6 +254,9 @@ impl Receipt {
|
||||
let mut splits: Vec<Decimal> = self
|
||||
.shared_by
|
||||
.iter()
|
||||
// If the person is sharing the item, specify the share as the person's share
|
||||
// divided by the item's total shares. This means that an item can be shared
|
||||
// proportional to other costs by fewer people than those present in the receipt.
|
||||
.map(|x| match item.shared_by.iter().position(|name| name == x) {
|
||||
Some(pos) => (item.value * item.share_ratio[pos]
|
||||
/ item.share_ratio.iter().sum::<Decimal>())
|
||||
@@ -269,7 +272,7 @@ impl Receipt {
|
||||
|
||||
// Add unaccounted item, if present
|
||||
if leftover_amount > Decimal::ZERO {
|
||||
let overall_prop = self.calculate_overall_proportion(true);
|
||||
let overall_prop = self.calculate_receipt_proportions();
|
||||
let overall_prop_sum: Decimal = overall_prop.iter().sum();
|
||||
let mut splits: Vec<Decimal> = overall_prop
|
||||
.iter()
|
||||
@@ -289,11 +292,150 @@ impl Receipt {
|
||||
|
||||
Ok((item_names, all_splits))
|
||||
}
|
||||
|
||||
pub fn is_proportionally_splittable(&self, index: usize) -> bool {
|
||||
// If there is only one item, it is not proportionally splittable
|
||||
if self.items.len() <= 1 || index > self.items.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A ReceiptItem can be split proportionally iff at least ONE
|
||||
// other receipt item is not split by proportion.
|
||||
return self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| *idx != index)
|
||||
.any(|(_, x)| !x.is_prop_dist);
|
||||
}
|
||||
|
||||
pub fn recalculate_proportions(&mut self) {
|
||||
let mut item_share_ratios: Vec<Vec<Decimal>> = Vec::new();
|
||||
for item in self.items.iter().filter(|x| x.is_prop_dist) {
|
||||
item_share_ratios
|
||||
.push(self.calculate_item_share_ratio_by_proportion(&item.shared_by, item.value));
|
||||
}
|
||||
for (item, share_ratio) in self
|
||||
.items
|
||||
.iter_mut()
|
||||
.filter(|x| x.is_prop_dist)
|
||||
.zip(item_share_ratios.into_iter())
|
||||
{
|
||||
item.share_ratio = share_ratio
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_item_at_index(
|
||||
&mut self,
|
||||
idx: usize,
|
||||
value: Option<Decimal>,
|
||||
name: Option<String>,
|
||||
shared_by: Option<Vec<String>>,
|
||||
is_prop_dist: Option<bool>,
|
||||
) -> Result<(), SplittingError> {
|
||||
let is_proportionally_splittable = self.is_proportionally_splittable(idx);
|
||||
|
||||
if let Some(receipt_item) = self.items.get_mut(idx) {
|
||||
// We could use `map` here to be succinct, but that's supposed to be an
|
||||
// anti-pattern? "Don't use map for its side effect".
|
||||
if let Some(value_) = value {
|
||||
receipt_item.value = value_
|
||||
}
|
||||
if let Some(name_) = name {
|
||||
receipt_item.name = name_
|
||||
}
|
||||
|
||||
// Learning: Decimal, bool and String implement copy, but Vec<String>
|
||||
// does not, that is why a manual `.clone()` is required here.
|
||||
if let Some(shared_by_) = shared_by.clone() {
|
||||
receipt_item.shared_by = shared_by_;
|
||||
receipt_item.share_ratio = vec![Decimal::ONE; receipt_item.shared_by.len()];
|
||||
}
|
||||
if let Some(is_prop_dist_) = is_prop_dist {
|
||||
if is_prop_dist_ && is_proportionally_splittable {
|
||||
// Setting is_prop_dist to true when possible
|
||||
receipt_item.is_prop_dist = true;
|
||||
receipt_item.shared_by = self.shared_by.clone();
|
||||
} else if !is_prop_dist_ {
|
||||
// Setting is_prop_dist to false and sharing it across all people
|
||||
receipt_item.is_prop_dist = false;
|
||||
receipt_item.shared_by = self.shared_by.clone();
|
||||
receipt_item.share_ratio = vec![Decimal::ONE; self.shared_by.len()];
|
||||
} else {
|
||||
return Err(SplittingError::NotProportionallySplittableError(
|
||||
"There aren't enough items left to split proportionally on".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(SplittingError::InvalidIndexError(
|
||||
"Provide index is outside the range of items present in the receipt".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Setting the item as (not) proportional means that it is (no longer) determining
|
||||
// proportional splits for other items. Therefore, this proportion needs to be recalculated.
|
||||
|
||||
// This is true for any change except for a change in name of an underlying item, as long
|
||||
// as proportional items exist.
|
||||
|
||||
// When the last proportional item is changed to being non-proportional, the adjustment
|
||||
// to its own value is already made in the `if !is_prop_dist` branch above, so no further
|
||||
// changes need to be made.
|
||||
|
||||
if self.items.iter().filter(|x| x.is_prop_dist).count() > 0
|
||||
&& (value.is_some() || shared_by.is_some() || is_prop_dist.is_some())
|
||||
{
|
||||
self.recalculate_proportions()
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_removable(&self, idx: usize) -> bool {
|
||||
let proportional_count = self.items.iter().filter(|x| x.is_prop_dist).count();
|
||||
|
||||
if idx >= self.items.len() {
|
||||
return false;
|
||||
// Err(SplittingError::InvalidIndexError(
|
||||
// "Provided index is out of bounds".to_string(),
|
||||
// ));
|
||||
}
|
||||
// Disallow removal of the last proportional item since the rest depend on it
|
||||
else if self.items.iter().filter(|x| !x.is_prop_dist).count() == 1
|
||||
&& proportional_count > 0
|
||||
&& !self.items.get(idx).unwrap().is_prop_dist
|
||||
{
|
||||
return false;
|
||||
// Err(SplittingError::InvalidIndexError(
|
||||
// "The last non-proportional item cannot be removed when there are proportional items in the receipt.".into()
|
||||
// ));
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
// Removing, as opposed to updating, is a far simpler operation - just remove the
|
||||
// index specified, and update all other values that depend on proportion. Voila!
|
||||
pub fn remove_item_at_index(&mut self, idx: usize) -> Result<(), SplittingError> {
|
||||
let proportional_count = self.items.iter().filter(|x| x.is_prop_dist).count();
|
||||
|
||||
if self.is_removable(idx) {
|
||||
self.items.remove(idx);
|
||||
} else {
|
||||
}
|
||||
|
||||
if proportional_count > 0 {
|
||||
self.recalculate_proportions();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::core::receipt::Receipt;
|
||||
use crate::core::receipt::{Receipt, SplittingError};
|
||||
use crate::utils;
|
||||
use rust_decimal::prelude::*;
|
||||
|
||||
@@ -332,4 +474,228 @@ mod tests {
|
||||
];
|
||||
assert_eq!(expected_splits, actual_splits);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_proportional() {
|
||||
let mut receipt = Receipt::new(dec![300], vec!["Alice", "Bob", "Marshall"]).unwrap();
|
||||
assert_eq!(receipt.is_proportionally_splittable(100), false);
|
||||
let _ = receipt.add_item_split_by_ratio(
|
||||
dec![25],
|
||||
"Hearty Burger".into(),
|
||||
utils::strs_to_strings(vec!["Alice"]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(receipt.is_proportionally_splittable(0), false);
|
||||
let _ = receipt.add_item_split_by_ratio(
|
||||
dec![10],
|
||||
"Vegan Salad".into(),
|
||||
utils::strs_to_strings(vec!["Marshall"]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(receipt.is_proportionally_splittable(0), true);
|
||||
}
|
||||
|
||||
fn proportional_receipt_helper() -> Result<Receipt, SplittingError> {
|
||||
let mut receipt = Receipt::new(dec![300], vec!["Alice", "Bob", "Marshall"])?;
|
||||
receipt
|
||||
.add_item_split_by_ratio(
|
||||
dec![30],
|
||||
"Hearty Burger".into(),
|
||||
utils::strs_to_strings(vec!["Alice"]),
|
||||
None,
|
||||
)?
|
||||
.add_item_split_by_ratio(
|
||||
dec![60],
|
||||
"Unhealthy Burger".into(),
|
||||
utils::strs_to_strings(vec!["Bob"]),
|
||||
None,
|
||||
)?
|
||||
.add_item_split_by_ratio(
|
||||
dec![15],
|
||||
"Vegan Salad".into(),
|
||||
utils::strs_to_strings(vec!["Marshall"]),
|
||||
None,
|
||||
)?
|
||||
.add_item_split_by_proportion(
|
||||
dec![50],
|
||||
"Tax".into(),
|
||||
utils::strs_to_strings(vec!["Alice", "Bob"]),
|
||||
)?
|
||||
.add_item_split_by_proportion(
|
||||
dec![50],
|
||||
"Tip".into(),
|
||||
utils::strs_to_strings(vec!["Bob", "Marshall"]),
|
||||
)?;
|
||||
Ok(receipt)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adding_by_proportions() {
|
||||
let receipt = proportional_receipt_helper().unwrap();
|
||||
assert_eq!(
|
||||
receipt.items[3].share_ratio,
|
||||
f64s_to_decimals(&[16.67, 33.33])
|
||||
);
|
||||
assert_eq!(
|
||||
receipt.items[4].share_ratio,
|
||||
f64s_to_decimals(&[40.0, 10.0])
|
||||
);
|
||||
println!("{:#?}", receipt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_updated_items() {
|
||||
let mut receipt = proportional_receipt_helper().unwrap();
|
||||
// At this point, the receipt is:
|
||||
// # Alice Bob Marshall Is Prop?
|
||||
// 0 30
|
||||
// 1 60
|
||||
// 2 15
|
||||
// 3 16.67 33.33 x
|
||||
// 4 40 37.5 x
|
||||
let _ = receipt.update_item_at_index(2, Some(dec![30]), None, None, None);
|
||||
|
||||
// At this point, the receipt should be:
|
||||
// # Alice Bob Marshall Is Prop?
|
||||
// 0 30
|
||||
// 1 60
|
||||
// 2 30
|
||||
// 3 16.67 33.33 x
|
||||
// 4 33.33 16.67 x
|
||||
assert_eq!(
|
||||
receipt.items[3].share_ratio,
|
||||
f64s_to_decimals(&[16.67, 33.33])
|
||||
);
|
||||
assert_eq!(
|
||||
receipt.items[4].share_ratio,
|
||||
f64s_to_decimals(&[33.33, 16.67])
|
||||
);
|
||||
|
||||
let _ = receipt.update_item_at_index(2, None, Some("Vegan Air".into()), None, None);
|
||||
|
||||
assert_eq!(
|
||||
receipt.items[3].share_ratio,
|
||||
f64s_to_decimals(&[16.67, 33.33])
|
||||
);
|
||||
assert_eq!(
|
||||
receipt.items[4].share_ratio,
|
||||
f64s_to_decimals(&[33.33, 16.67])
|
||||
);
|
||||
assert_eq!(receipt.items[2].name, "Vegan Air");
|
||||
|
||||
let _ = receipt.update_item_at_index(
|
||||
1,
|
||||
None,
|
||||
None,
|
||||
Some(utils::strs_to_strings(vec!["Bob", "Marshall"])),
|
||||
None,
|
||||
);
|
||||
// At this point, the receipt should be:
|
||||
// # Alice Bob Marshall Is Prop?
|
||||
// 0 30
|
||||
// 1 30 30
|
||||
// 2 30
|
||||
// 3 25 25 x
|
||||
// 4 16.67 33.33 x
|
||||
|
||||
assert_eq!(receipt.items[1].shared_by, vec!["Bob", "Marshall"]);
|
||||
assert_eq!(
|
||||
receipt.items[3].share_ratio,
|
||||
f64s_to_decimals(&[25.0, 25.0])
|
||||
);
|
||||
assert_eq!(
|
||||
receipt.items[4].share_ratio,
|
||||
f64s_to_decimals(&[16.67, 33.33])
|
||||
);
|
||||
|
||||
let _ = receipt.update_item_at_index(4, None, None, None, Some(false));
|
||||
// At this point, the receipt should be:
|
||||
// # Alice Bob Marshall Is Prop?
|
||||
// 1 30
|
||||
// 2 30 30
|
||||
// 3 30
|
||||
// 4 25 25 x
|
||||
// 5 13.33 13.33 13.33 x
|
||||
|
||||
assert_eq!(
|
||||
receipt.items[3].share_ratio,
|
||||
f64s_to_decimals(&[25.0, 25.0])
|
||||
);
|
||||
assert_eq!(
|
||||
receipt.items[4].share_ratio,
|
||||
f64s_to_decimals(&[1.0, 1.0, 1.0])
|
||||
);
|
||||
|
||||
for i in 0..3 {
|
||||
let _ = receipt.update_item_at_index(i, None, None, None, Some(true));
|
||||
}
|
||||
// This should fail since this is the last non-proportional item (3 is already proportional)
|
||||
let result = receipt.update_item_at_index(4, None, None, None, Some(true));
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(SplittingError::NotProportionallySplittableError(_))
|
||||
));
|
||||
|
||||
// Should work fine now!
|
||||
let _ = receipt.update_item_at_index(3, None, None, None, Some(false));
|
||||
let result = receipt.update_item_at_index(4, None, None, None, Some(true));
|
||||
assert_eq!(result, Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_removing_items() {
|
||||
let mut receipt_1 = proportional_receipt_helper().unwrap();
|
||||
let mut receipt_2 = proportional_receipt_helper().unwrap();
|
||||
// Starting point of the receipt is
|
||||
// # Alice Bob Marshall Is Prop?
|
||||
// 0 30
|
||||
// 1 60
|
||||
// 2 15
|
||||
// 3 25 25 x
|
||||
// 4 40 10 x
|
||||
let _ = receipt_1.remove_item_at_index(2);
|
||||
assert_eq!(
|
||||
receipt_1.items[3].share_ratio,
|
||||
f64s_to_decimals(&[50.0, 0.0])
|
||||
);
|
||||
|
||||
let _ = receipt_2.update_item_at_index(
|
||||
1,
|
||||
None,
|
||||
None,
|
||||
Some(utils::strs_to_strings(vec!["Alice", "Bob", "Marshall"])),
|
||||
None,
|
||||
);
|
||||
// At this point, the receipt is:
|
||||
// # Alice Bob Marshall Is Prop?
|
||||
// 0 30
|
||||
// 1 20 20 20
|
||||
// 2 15
|
||||
// 3 37.5 12.5 x
|
||||
// 4 12.5 37.5 x
|
||||
assert_eq!(
|
||||
receipt_2.items[3].share_ratio,
|
||||
f64s_to_decimals(&[35.71, 14.29])
|
||||
);
|
||||
assert_eq!(
|
||||
receipt_2.items[4].share_ratio,
|
||||
f64s_to_decimals(&[18.18, 31.82])
|
||||
);
|
||||
|
||||
let _ = receipt_2.remove_item_at_index(1);
|
||||
// At this point, the receipt should be:
|
||||
// # Alice Bob Marshall Is Prop?
|
||||
// 0 30
|
||||
// 1 15
|
||||
// 2 50 x
|
||||
// 3 50 x
|
||||
assert_eq!(
|
||||
receipt_2.items[2].share_ratio,
|
||||
f64s_to_decimals(&[50.0, 0.0])
|
||||
);
|
||||
assert_eq!(
|
||||
receipt_2.items[3].share_ratio,
|
||||
f64s_to_decimals(&[0.0, 50.0])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user