Edited layout of mmddyyyy and time dropdown. Changed algorithm for determining pricing so that it choosest the cheapest option for users
This commit is contained in:
376
backend/tests/unit/utils/rentalDurationCalculator.test.js
Normal file
376
backend/tests/unit/utils/rentalDurationCalculator.test.js
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
const RentalDurationCalculator = require("../../../utils/rentalDurationCalculator");
|
||||||
|
|
||||||
|
describe("RentalDurationCalculator - Hybrid Pricing", () => {
|
||||||
|
// Helper to create ISO date strings
|
||||||
|
const date = (dateStr, time = "10:00") => {
|
||||||
|
return `2024-01-${dateStr.padStart(2, "0")}T${time}:00.000Z`;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("calculateRentalCost", () => {
|
||||||
|
describe("Basic single-tier pricing", () => {
|
||||||
|
test("should calculate hourly rate for short rentals", () => {
|
||||||
|
const item = { pricePerHour: 10 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("01", "13:00"),
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(30); // 3 hours * $10
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should calculate daily rate for exact day rentals", () => {
|
||||||
|
const item = { pricePerDay: 50 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("03", "10:00"),
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(100); // 2 days * $50
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should calculate weekly rate for exact week rentals", () => {
|
||||||
|
const item = { pricePerWeek: 200 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("15", "10:00"),
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(400); // 2 weeks * $200
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should calculate monthly rate for exact month rentals", () => {
|
||||||
|
const item = { pricePerMonth: 600 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
"2024-01-01T10:00:00.000Z",
|
||||||
|
"2024-03-01T10:00:00.000Z", // 60 days = exactly 2 months (using 30-day months)
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(1200); // 2 months * $600
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Hybrid day + hours pricing", () => {
|
||||||
|
test("should combine day and hours for 26-hour rental", () => {
|
||||||
|
const item = { pricePerDay: 50, pricePerHour: 10 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "15:00"),
|
||||||
|
date("02", "17:00"), // 26 hours
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(70); // 1 day ($50) + 2 hours ($20)
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should combine day and hours for 27-hour rental", () => {
|
||||||
|
const item = { pricePerDay: 50, pricePerHour: 10 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("02", "13:00"), // 27 hours
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(80); // 1 day ($50) + 3 hours ($30)
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should choose cheaper option - round up when hourly is expensive", () => {
|
||||||
|
// 25 hours: daily = $40, hourly = $50
|
||||||
|
// Hybrid: 1 day ($40) + 1 hour ($50) = $90
|
||||||
|
// Round-up: 2 days ($80)
|
||||||
|
// Expected: $80 (round-up wins)
|
||||||
|
const item = { pricePerDay: 40, pricePerHour: 50 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("02", "11:00"), // 25 hours
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(80); // 2 days (cheaper than 1d + 1h)
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should choose cheaper option - hybrid when hourly is cheap", () => {
|
||||||
|
// 25 hours: daily = $50, hourly = $5
|
||||||
|
// Hybrid: 1 day ($50) + 1 hour ($5) = $55
|
||||||
|
// Round-up: 2 days ($100)
|
||||||
|
// Expected: $55 (hybrid wins)
|
||||||
|
const item = { pricePerDay: 50, pricePerHour: 5 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("02", "11:00"), // 25 hours
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(55); // 1 day + 1 hour (cheaper)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Hybrid week + days pricing", () => {
|
||||||
|
test("should combine week and days for 10-day rental", () => {
|
||||||
|
const item = { pricePerWeek: 200, pricePerDay: 50 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("11", "10:00"), // 10 days
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(350); // 1 week ($200) + 3 days ($150)
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should combine week and days for 9-day rental", () => {
|
||||||
|
const item = { pricePerWeek: 200, pricePerDay: 50 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("10", "10:00"), // 9 days
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(300); // 1 week ($200) + 2 days ($100)
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should round up to 2 weeks when daily cost exceeds week", () => {
|
||||||
|
// 10 days: weekly = $100, daily = $50
|
||||||
|
// Hybrid: 1 week ($100) + 3 days ($150) = $250
|
||||||
|
// Round-up: 2 weeks ($200)
|
||||||
|
// Expected: $200 (round-up wins)
|
||||||
|
const item = { pricePerWeek: 100, pricePerDay: 50 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("11", "10:00"), // 10 days
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(200); // 2 weeks (cheaper)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Hybrid month + weeks + days pricing", () => {
|
||||||
|
test("should combine month, week, and days for 38-day rental", () => {
|
||||||
|
const item = { pricePerMonth: 600, pricePerWeek: 200, pricePerDay: 50 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
"2024-01-01T10:00:00.000Z",
|
||||||
|
"2024-02-08T10:00:00.000Z", // 38 days
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(850); // 1 month ($600) + 1 week ($200) + 1 day ($50)
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should combine month and days for 35-day rental without weekly", () => {
|
||||||
|
const item = { pricePerMonth: 600, pricePerDay: 50 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
"2024-01-01T10:00:00.000Z",
|
||||||
|
"2024-02-05T10:00:00.000Z", // 35 days
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(850); // 1 month ($600) + 5 days ($250)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Full hybrid: month + week + day + hour", () => {
|
||||||
|
test("should combine all tiers for complex duration", () => {
|
||||||
|
const item = {
|
||||||
|
pricePerMonth: 600,
|
||||||
|
pricePerWeek: 200,
|
||||||
|
pricePerDay: 50,
|
||||||
|
pricePerHour: 10,
|
||||||
|
};
|
||||||
|
// 38 days + 5 hours
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
"2024-01-01T10:00:00.000Z",
|
||||||
|
"2024-02-08T15:00:00.000Z",
|
||||||
|
item
|
||||||
|
);
|
||||||
|
// 1 month + 1 week + 1 day + 5 hours = $600 + $200 + $50 + $50 = $900
|
||||||
|
expect(result).toBe(900);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Missing tier fallback", () => {
|
||||||
|
test("should round up to days when no hourly rate", () => {
|
||||||
|
const item = { pricePerDay: 50 }; // No hourly
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "15:00"),
|
||||||
|
date("02", "17:00"), // 26 hours
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(100); // 2 days (rounded up)
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should round up to weeks when no daily rate", () => {
|
||||||
|
const item = { pricePerWeek: 200 }; // No daily
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("10", "10:00"), // 9 days
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(400); // 2 weeks (rounded up)
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should round up to months when no weekly or daily rate", () => {
|
||||||
|
const item = { pricePerMonth: 600 }; // Only monthly
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
"2024-01-01T10:00:00.000Z",
|
||||||
|
"2024-02-15T10:00:00.000Z", // 45 days
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(1200); // 2 months (rounded up)
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use hourly only when no larger tiers", () => {
|
||||||
|
const item = { pricePerHour: 10 }; // Only hourly
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("03", "10:00"), // 48 hours
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(480); // 48 hours * $10
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edge cases", () => {
|
||||||
|
test("should return 0 for zero duration", () => {
|
||||||
|
const item = { pricePerDay: 50 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("01", "10:00"),
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 0 for negative duration", () => {
|
||||||
|
const item = { pricePerDay: 50 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("02", "10:00"),
|
||||||
|
date("01", "10:00"), // End before start
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 0 for free items (no pricing)", () => {
|
||||||
|
const item = {}; // No pricing
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("05", "10:00"),
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 0 when all prices are 0", () => {
|
||||||
|
const item = {
|
||||||
|
pricePerHour: 0,
|
||||||
|
pricePerDay: 0,
|
||||||
|
pricePerWeek: 0,
|
||||||
|
pricePerMonth: 0,
|
||||||
|
};
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("05", "10:00"),
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle decimal prices", () => {
|
||||||
|
const item = { pricePerHour: 5.5 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("01", "13:00"), // 3 hours
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(16.5); // 3 * $5.50
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should round result to 2 decimal places", () => {
|
||||||
|
const item = { pricePerHour: 3.33 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "10:00"),
|
||||||
|
date("01", "13:00"), // 3 hours
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(9.99); // 3 * $3.33
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle string prices in item", () => {
|
||||||
|
const item = { pricePerDay: "50", pricePerHour: "10" };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
date("01", "15:00"),
|
||||||
|
date("02", "17:00"), // 26 hours
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(70); // 1 day + 2 hours
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle very short rentals (less than 1 hour)", () => {
|
||||||
|
const item = { pricePerHour: 10 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
"2024-01-01T10:00:00.000Z",
|
||||||
|
"2024-01-01T10:30:00.000Z", // 30 minutes
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(10); // Rounds up to 1 hour
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Real-world scenarios", () => {
|
||||||
|
test("Scenario: Tool rental 3 days 4 hours", () => {
|
||||||
|
const item = { pricePerDay: 25, pricePerHour: 5 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
"2024-01-01T08:00:00.000Z",
|
||||||
|
"2024-01-04T12:00:00.000Z", // 3 days + 4 hours
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(95); // 3 days ($75) + 4 hours ($20)
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Scenario: Equipment rental 2 weeks 3 days", () => {
|
||||||
|
const item = { pricePerWeek: 100, pricePerDay: 20 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
"2024-01-01T10:00:00.000Z",
|
||||||
|
"2024-01-18T10:00:00.000Z", // 17 days = 2 weeks + 3 days
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(260); // 2 weeks ($200) + 3 days ($60)
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Scenario: Long-term rental 2 months 1 week", () => {
|
||||||
|
const item = { pricePerMonth: 500, pricePerWeek: 150 };
|
||||||
|
const result = RentalDurationCalculator.calculateRentalCost(
|
||||||
|
"2024-01-01T10:00:00.000Z",
|
||||||
|
"2024-03-08T10:00:00.000Z", // ~67 days = 2 months + 1 week
|
||||||
|
item
|
||||||
|
);
|
||||||
|
expect(result).toBe(1150); // 2 months ($1000) + 1 week ($150)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildPricingTiers", () => {
|
||||||
|
test("should return empty array for items with no pricing", () => {
|
||||||
|
const item = {};
|
||||||
|
const tiers = RentalDurationCalculator.buildPricingTiers(item);
|
||||||
|
expect(tiers).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should build tiers from largest to smallest", () => {
|
||||||
|
const item = {
|
||||||
|
pricePerMonth: 600,
|
||||||
|
pricePerWeek: 200,
|
||||||
|
pricePerDay: 50,
|
||||||
|
pricePerHour: 10,
|
||||||
|
};
|
||||||
|
const tiers = RentalDurationCalculator.buildPricingTiers(item);
|
||||||
|
expect(tiers).toHaveLength(4);
|
||||||
|
expect(tiers[0].name).toBe("month");
|
||||||
|
expect(tiers[1].name).toBe("week");
|
||||||
|
expect(tiers[2].name).toBe("day");
|
||||||
|
expect(tiers[3].name).toBe("hour");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should skip tiers with 0 or null pricing", () => {
|
||||||
|
const item = {
|
||||||
|
pricePerMonth: 0,
|
||||||
|
pricePerWeek: null,
|
||||||
|
pricePerDay: 50,
|
||||||
|
pricePerHour: 10,
|
||||||
|
};
|
||||||
|
const tiers = RentalDurationCalculator.buildPricingTiers(item);
|
||||||
|
expect(tiers).toHaveLength(2);
|
||||||
|
expect(tiers[0].name).toBe("day");
|
||||||
|
expect(tiers[1].name).toBe("hour");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,61 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Rental Duration Calculator with Hybrid Pricing
|
||||||
|
*
|
||||||
|
* Finds the optimal (cheapest) combination of pricing tiers for any rental duration.
|
||||||
|
* Supports combining hours + days + weeks + months for best pricing.
|
||||||
|
*
|
||||||
|
* Example: 26 hours with $10/hr and $50/day
|
||||||
|
* - Old logic: 2 days × $50 = $100
|
||||||
|
* - New logic: 1 day × $50 + 2 hours × $10 = $70
|
||||||
|
*/
|
||||||
class RentalDurationCalculator {
|
class RentalDurationCalculator {
|
||||||
|
// Time constants in milliseconds
|
||||||
|
static HOUR_MS = 60 * 60 * 1000;
|
||||||
|
static DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
static WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
static MONTH_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build available pricing tiers from item, sorted largest to smallest
|
||||||
|
* @param {Object} item - Item object with pricing information
|
||||||
|
* @returns {Array} Array of {name, price, durationMs} sorted largest to smallest
|
||||||
|
*/
|
||||||
|
static buildPricingTiers(item) {
|
||||||
|
const tiers = [];
|
||||||
|
|
||||||
|
if (item.pricePerMonth && Number(item.pricePerMonth) > 0) {
|
||||||
|
tiers.push({
|
||||||
|
name: "month",
|
||||||
|
price: Number(item.pricePerMonth),
|
||||||
|
durationMs: this.MONTH_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.pricePerWeek && Number(item.pricePerWeek) > 0) {
|
||||||
|
tiers.push({
|
||||||
|
name: "week",
|
||||||
|
price: Number(item.pricePerWeek),
|
||||||
|
durationMs: this.WEEK_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.pricePerDay && Number(item.pricePerDay) > 0) {
|
||||||
|
tiers.push({
|
||||||
|
name: "day",
|
||||||
|
price: Number(item.pricePerDay),
|
||||||
|
durationMs: this.DAY_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.pricePerHour && Number(item.pricePerHour) > 0) {
|
||||||
|
tiers.push({
|
||||||
|
name: "hour",
|
||||||
|
price: Number(item.pricePerHour),
|
||||||
|
durationMs: this.HOUR_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively calculate optimal price for remaining duration
|
||||||
|
* Uses greedy approach: try largest tier first, then handle remainder with smaller tiers
|
||||||
|
* Compares hybrid price vs round-up price and returns the cheaper option
|
||||||
|
*
|
||||||
|
* @param {number} remainingMs - Remaining duration in milliseconds
|
||||||
|
* @param {Array} tiers - Available pricing tiers (largest to smallest)
|
||||||
|
* @param {number} tierIndex - Current tier being considered
|
||||||
|
* @returns {number} Optimal price for this duration
|
||||||
|
*/
|
||||||
|
static calculateOptimalForDuration(remainingMs, tiers, tierIndex = 0) {
|
||||||
|
// Base case: no remaining time
|
||||||
|
if (remainingMs <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base case: no more tiers available - round up with smallest available tier
|
||||||
|
if (tierIndex >= tiers.length) {
|
||||||
|
const smallestTier = tiers[tiers.length - 1];
|
||||||
|
const unitsNeeded = Math.ceil(remainingMs / smallestTier.durationMs);
|
||||||
|
return unitsNeeded * smallestTier.price;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTier = tiers[tierIndex];
|
||||||
|
const completeUnits = Math.floor(remainingMs / currentTier.durationMs);
|
||||||
|
const remainder = remainingMs - completeUnits * currentTier.durationMs;
|
||||||
|
|
||||||
|
if (completeUnits > 0) {
|
||||||
|
// Option 1: Use complete units of current tier + handle remainder with smaller tiers
|
||||||
|
const currentCost = completeUnits * currentTier.price;
|
||||||
|
const remainderCost = this.calculateOptimalForDuration(
|
||||||
|
remainder,
|
||||||
|
tiers,
|
||||||
|
tierIndex + 1
|
||||||
|
);
|
||||||
|
const hybridCost = currentCost + remainderCost;
|
||||||
|
|
||||||
|
// Option 2: Round up to one more unit of current tier (may be cheaper if remainder cost is high)
|
||||||
|
const roundUpCost = (completeUnits + 1) * currentTier.price;
|
||||||
|
|
||||||
|
// Return the cheaper option when there's a remainder to handle
|
||||||
|
return remainder > 0 ? Math.min(hybridCost, roundUpCost) : currentCost;
|
||||||
|
} else {
|
||||||
|
// Duration too small for this tier, try next smaller tier
|
||||||
|
return this.calculateOptimalForDuration(remainingMs, tiers, tierIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate rental cost based on duration and item pricing tiers
|
* Calculate rental cost based on duration and item pricing tiers
|
||||||
|
* Uses hybrid pricing to find the optimal (cheapest) combination of tiers
|
||||||
|
*
|
||||||
* @param {Date|string} startDateTime - Rental start date/time
|
* @param {Date|string} startDateTime - Rental start date/time
|
||||||
* @param {Date|string} endDateTime - Rental end date/time
|
* @param {Date|string} endDateTime - Rental end date/time
|
||||||
* @param {Object} item - Item object with pricing information
|
* @param {Object} item - Item object with pricing information
|
||||||
* @returns {number} Total rental cost
|
* @returns {number} Total rental cost
|
||||||
*/
|
*/
|
||||||
static calculateRentalCost(startDateTime, endDateTime, item) {
|
static calculateRentalCost(startDateTime, endDateTime, item) {
|
||||||
const rentalStartDateTime = new Date(startDateTime);
|
const start = new Date(startDateTime);
|
||||||
const rentalEndDateTime = new Date(endDateTime);
|
const end = new Date(endDateTime);
|
||||||
|
|
||||||
// Calculate rental duration
|
// Calculate total duration in milliseconds
|
||||||
const diffMs = rentalEndDateTime.getTime() - rentalStartDateTime.getTime();
|
const durationMs = end.getTime() - start.getTime();
|
||||||
const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
|
|
||||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
const diffWeeks = Math.ceil(diffDays / 7);
|
|
||||||
|
|
||||||
// Calculate difference in calendar months
|
if (durationMs <= 0) {
|
||||||
let diffMonths = (rentalEndDateTime.getFullYear() - rentalStartDateTime.getFullYear()) * 12;
|
return 0;
|
||||||
diffMonths += rentalEndDateTime.getMonth() - rentalStartDateTime.getMonth();
|
|
||||||
|
|
||||||
// Add 1 if we're past the start day/time in the end month
|
|
||||||
if (rentalEndDateTime.getDate() > rentalStartDateTime.getDate()) {
|
|
||||||
diffMonths += 1;
|
|
||||||
} else if (rentalEndDateTime.getDate() === rentalStartDateTime.getDate() &&
|
|
||||||
rentalEndDateTime.getTime() > rentalStartDateTime.getTime()) {
|
|
||||||
diffMonths += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
diffMonths = Math.max(1, diffMonths);
|
// Build available pricing tiers
|
||||||
|
const tiers = this.buildPricingTiers(item);
|
||||||
|
|
||||||
// Calculate base amount based on duration (tiered pricing)
|
// No pricing tiers = free item
|
||||||
let totalAmount;
|
if (tiers.length === 0) {
|
||||||
|
return 0;
|
||||||
if (item.pricePerHour && diffHours < 24) {
|
|
||||||
// Use hourly rate for rentals under 24 hours
|
|
||||||
totalAmount = diffHours * Number(item.pricePerHour);
|
|
||||||
} else if (diffDays <= 7 && item.pricePerDay) {
|
|
||||||
// Use daily rate for rentals <= 7 days
|
|
||||||
totalAmount = diffDays * Number(item.pricePerDay);
|
|
||||||
} else if (diffMonths <= 1 && item.pricePerWeek) {
|
|
||||||
// Use weekly rate for rentals <= 1 calendar month
|
|
||||||
totalAmount = diffWeeks * Number(item.pricePerWeek);
|
|
||||||
} else if (diffMonths > 1 && item.pricePerMonth) {
|
|
||||||
// Use monthly rate for rentals > 1 calendar month
|
|
||||||
totalAmount = diffMonths * Number(item.pricePerMonth);
|
|
||||||
} else if (item.pricePerWeek) {
|
|
||||||
// Fallback to weekly rate if monthly not available
|
|
||||||
totalAmount = diffWeeks * Number(item.pricePerWeek);
|
|
||||||
} else if (item.pricePerDay) {
|
|
||||||
// Fallback to daily rate
|
|
||||||
totalAmount = diffDays * Number(item.pricePerDay);
|
|
||||||
} else {
|
|
||||||
totalAmount = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalAmount;
|
// Calculate optimal price using hybrid tier combination
|
||||||
|
const optimalPrice = this.calculateOptimalForDuration(durationMs, tiers);
|
||||||
|
|
||||||
|
return parseFloat(optimalPrice.toFixed(2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -676,8 +676,7 @@ const ItemDetail: React.FC = () => {
|
|||||||
<label className="form-label fw-medium mb-2">
|
<label className="form-label fw-medium mb-2">
|
||||||
Start
|
Start
|
||||||
</label>
|
</label>
|
||||||
<div className="d-flex gap-2">
|
<div style={{ width: "60%" }}>
|
||||||
<div style={{ flex: "1 1 50%" }}>
|
|
||||||
<DatePicker
|
<DatePicker
|
||||||
selected={rentalDates.startDate}
|
selected={rentalDates.startDate}
|
||||||
onChange={(date: Date | null) =>
|
onChange={(date: Date | null) =>
|
||||||
@@ -689,13 +688,13 @@ const ItemDetail: React.FC = () => {
|
|||||||
minDate={new Date()}
|
minDate={new Date()}
|
||||||
dateFormat="MM/dd/yyyy"
|
dateFormat="MM/dd/yyyy"
|
||||||
placeholderText="mm/dd/yyyy"
|
placeholderText="mm/dd/yyyy"
|
||||||
className="form-control form-control-lg w-100"
|
className="form-control form-control-lg mb-2 w-100"
|
||||||
popperProps={{ strategy: "fixed" }}
|
popperProps={{ strategy: "fixed" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: "1 1 50%" }}>
|
|
||||||
<select
|
<select
|
||||||
className="form-select form-select-lg"
|
className="form-select form-select-lg"
|
||||||
|
style={{ width: "60%" }}
|
||||||
value={rentalDates.startTime}
|
value={rentalDates.startTime}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setRentalDates((prev) => ({
|
setRentalDates((prev) => ({
|
||||||
@@ -705,57 +704,8 @@ const ItemDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
disabled={!rentalDates.startDate}
|
disabled={!rentalDates.startDate}
|
||||||
>
|
>
|
||||||
<option value="">Pickup</option>
|
<option value="">Pickup Time</option>
|
||||||
{generateTimeOptions(
|
{generateTimeOptions(rentalDates.startDate).map(
|
||||||
rentalDates.startDate
|
|
||||||
).map((option) => (
|
|
||||||
<option
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label fw-medium mb-2">
|
|
||||||
End
|
|
||||||
</label>
|
|
||||||
<div className="d-flex gap-2">
|
|
||||||
<div style={{ flex: "1 1 50%" }}>
|
|
||||||
<DatePicker
|
|
||||||
selected={rentalDates.endDate}
|
|
||||||
onChange={(date: Date | null) =>
|
|
||||||
setRentalDates((prev) => ({
|
|
||||||
...prev,
|
|
||||||
endDate: date,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
minDate={rentalDates.startDate || new Date()}
|
|
||||||
dateFormat="MM/dd/yyyy"
|
|
||||||
placeholderText="mm/dd/yyyy"
|
|
||||||
className="form-control form-control-lg w-100"
|
|
||||||
popperProps={{ strategy: "fixed" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: "1 1 50%" }}>
|
|
||||||
<select
|
|
||||||
className="form-select form-select-lg"
|
|
||||||
value={rentalDates.endTime}
|
|
||||||
onChange={(e) =>
|
|
||||||
setRentalDates((prev) => ({
|
|
||||||
...prev,
|
|
||||||
endTime: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={!rentalDates.endDate}
|
|
||||||
>
|
|
||||||
<option value="">Return</option>
|
|
||||||
{generateTimeOptions(rentalDates.endDate).map(
|
|
||||||
(option) => (
|
(option) => (
|
||||||
<option
|
<option
|
||||||
key={option.value}
|
key={option.value}
|
||||||
@@ -767,7 +717,51 @@ const ItemDetail: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label fw-medium mb-2">
|
||||||
|
End
|
||||||
|
</label>
|
||||||
|
<div style={{ width: "60%" }}>
|
||||||
|
<DatePicker
|
||||||
|
selected={rentalDates.endDate}
|
||||||
|
onChange={(date: Date | null) =>
|
||||||
|
setRentalDates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
endDate: date,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
minDate={rentalDates.startDate || new Date()}
|
||||||
|
dateFormat="MM/dd/yyyy"
|
||||||
|
placeholderText="mm/dd/yyyy"
|
||||||
|
className="form-control form-control-lg mb-2 w-100"
|
||||||
|
popperProps={{ strategy: "fixed" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<select
|
||||||
|
className="form-select form-select-lg"
|
||||||
|
style={{ width: "60%" }}
|
||||||
|
value={rentalDates.endTime}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRentalDates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
endTime: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!rentalDates.endDate}
|
||||||
|
>
|
||||||
|
<option value="">Return Time</option>
|
||||||
|
{generateTimeOptions(rentalDates.endDate).map(
|
||||||
|
(option) => (
|
||||||
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rentalDates.startDate &&
|
{rentalDates.startDate &&
|
||||||
@@ -802,7 +796,7 @@ const ItemDetail: React.FC = () => {
|
|||||||
{!isOwner && item.isAvailable && !isAlreadyRenting && (
|
{!isOwner && item.isAvailable && !isAlreadyRenting && (
|
||||||
<div className="d-grid">
|
<div className="d-grid">
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary btn-lg"
|
||||||
onClick={handleRent}
|
onClick={handleRent}
|
||||||
disabled={
|
disabled={
|
||||||
!rentalDates.startDate ||
|
!rentalDates.startDate ||
|
||||||
@@ -819,7 +813,7 @@ const ItemDetail: React.FC = () => {
|
|||||||
{!isOwner && isAlreadyRenting && (
|
{!isOwner && isAlreadyRenting && (
|
||||||
<div className="d-grid">
|
<div className="d-grid">
|
||||||
<button
|
<button
|
||||||
className="btn btn-success"
|
className="btn btn-success btn-lg"
|
||||||
disabled
|
disabled
|
||||||
style={{ opacity: 0.8 }}
|
style={{ opacity: 0.8 }}
|
||||||
>
|
>
|
||||||
@@ -928,8 +922,7 @@ const ItemDetail: React.FC = () => {
|
|||||||
<label className="form-label fw-medium mb-2">
|
<label className="form-label fw-medium mb-2">
|
||||||
Start
|
Start
|
||||||
</label>
|
</label>
|
||||||
<div className="d-flex gap-2">
|
<div style={{ width: "60%" }}>
|
||||||
<div style={{ flex: "1 1 50%" }}>
|
|
||||||
<DatePicker
|
<DatePicker
|
||||||
selected={rentalDates.startDate}
|
selected={rentalDates.startDate}
|
||||||
onChange={(date: Date | null) =>
|
onChange={(date: Date | null) =>
|
||||||
@@ -941,12 +934,12 @@ const ItemDetail: React.FC = () => {
|
|||||||
minDate={new Date()}
|
minDate={new Date()}
|
||||||
dateFormat="MM/dd/yyyy"
|
dateFormat="MM/dd/yyyy"
|
||||||
placeholderText="mm/dd/yyyy"
|
placeholderText="mm/dd/yyyy"
|
||||||
className="form-control w-100"
|
className="form-control mb-2 w-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: "1 1 50%" }}>
|
|
||||||
<select
|
<select
|
||||||
className="form-select"
|
className="form-select"
|
||||||
|
style={{ width: "60%" }}
|
||||||
value={rentalDates.startTime}
|
value={rentalDates.startTime}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setRentalDates((prev) => ({
|
setRentalDates((prev) => ({
|
||||||
@@ -956,28 +949,22 @@ const ItemDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
disabled={!rentalDates.startDate}
|
disabled={!rentalDates.startDate}
|
||||||
>
|
>
|
||||||
<option value="">Pickup</option>
|
<option value="">Pickup Time</option>
|
||||||
{generateTimeOptions(rentalDates.startDate).map(
|
{generateTimeOptions(rentalDates.startDate).map(
|
||||||
(option) => (
|
(option) => (
|
||||||
<option
|
<option key={option.value} value={option.value}>
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
>
|
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label fw-medium mb-2">
|
<label className="form-label fw-medium mb-2">
|
||||||
End
|
End
|
||||||
</label>
|
</label>
|
||||||
<div className="d-flex gap-2">
|
<div style={{ width: "60%" }}>
|
||||||
<div style={{ flex: "1 1 50%" }}>
|
|
||||||
<DatePicker
|
<DatePicker
|
||||||
selected={rentalDates.endDate}
|
selected={rentalDates.endDate}
|
||||||
onChange={(date: Date | null) =>
|
onChange={(date: Date | null) =>
|
||||||
@@ -989,12 +976,12 @@ const ItemDetail: React.FC = () => {
|
|||||||
minDate={rentalDates.startDate || new Date()}
|
minDate={rentalDates.startDate || new Date()}
|
||||||
dateFormat="MM/dd/yyyy"
|
dateFormat="MM/dd/yyyy"
|
||||||
placeholderText="mm/dd/yyyy"
|
placeholderText="mm/dd/yyyy"
|
||||||
className="form-control w-100"
|
className="form-control mb-2 w-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: "1 1 50%" }}>
|
|
||||||
<select
|
<select
|
||||||
className="form-select"
|
className="form-select"
|
||||||
|
style={{ width: "60%" }}
|
||||||
value={rentalDates.endTime}
|
value={rentalDates.endTime}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setRentalDates((prev) => ({
|
setRentalDates((prev) => ({
|
||||||
@@ -1004,21 +991,16 @@ const ItemDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
disabled={!rentalDates.endDate}
|
disabled={!rentalDates.endDate}
|
||||||
>
|
>
|
||||||
<option value="">Return</option>
|
<option value="">Return Time</option>
|
||||||
{generateTimeOptions(rentalDates.endDate).map(
|
{generateTimeOptions(rentalDates.endDate).map(
|
||||||
(option) => (
|
(option) => (
|
||||||
<option
|
<option key={option.value} value={option.value}>
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
>
|
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{rentalDates.startDate &&
|
{rentalDates.startDate &&
|
||||||
rentalDates.startTime &&
|
rentalDates.startTime &&
|
||||||
|
|||||||
Reference in New Issue
Block a user