From fd2312fe476d0cf1b1705dd916f0d7054420325f Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:46:40 -0500 Subject: [PATCH] Edited layout of mmddyyyy and time dropdown. Changed algorithm for determining pricing so that it choosest the cheapest option for users --- .../utils/rentalDurationCalculator.test.js | 376 ++++++++++++++++++ backend/utils/rentalDurationCalculator.js | 166 ++++++-- frontend/src/pages/ItemDetail.tsx | 318 +++++++-------- 3 files changed, 650 insertions(+), 210 deletions(-) create mode 100644 backend/tests/unit/utils/rentalDurationCalculator.test.js diff --git a/backend/tests/unit/utils/rentalDurationCalculator.test.js b/backend/tests/unit/utils/rentalDurationCalculator.test.js new file mode 100644 index 0000000..44cd82d --- /dev/null +++ b/backend/tests/unit/utils/rentalDurationCalculator.test.js @@ -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"); + }); + }); +}); diff --git a/backend/utils/rentalDurationCalculator.js b/backend/utils/rentalDurationCalculator.js index 67113bf..dbe65b6 100644 --- a/backend/utils/rentalDurationCalculator.js +++ b/backend/utils/rentalDurationCalculator.js @@ -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 { + // 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 + * Uses hybrid pricing to find the optimal (cheapest) combination of tiers + * * @param {Date|string} startDateTime - Rental start date/time * @param {Date|string} endDateTime - Rental end date/time * @param {Object} item - Item object with pricing information * @returns {number} Total rental cost */ static calculateRentalCost(startDateTime, endDateTime, item) { - const rentalStartDateTime = new Date(startDateTime); - const rentalEndDateTime = new Date(endDateTime); + const start = new Date(startDateTime); + const end = new Date(endDateTime); - // Calculate rental duration - const diffMs = rentalEndDateTime.getTime() - rentalStartDateTime.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 total duration in milliseconds + const durationMs = end.getTime() - start.getTime(); - // Calculate difference in calendar months - let diffMonths = (rentalEndDateTime.getFullYear() - rentalStartDateTime.getFullYear()) * 12; - 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; + if (durationMs <= 0) { + return 0; } - diffMonths = Math.max(1, diffMonths); + // Build available pricing tiers + const tiers = this.buildPricingTiers(item); - // Calculate base amount based on duration (tiered pricing) - let totalAmount; - - 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; + // No pricing tiers = free item + if (tiers.length === 0) { + return 0; } - return totalAmount; + // Calculate optimal price using hybrid tier combination + const optimalPrice = this.calculateOptimalForDuration(durationMs, tiers); + + return parseFloat(optimalPrice.toFixed(2)); } } diff --git a/frontend/src/pages/ItemDetail.tsx b/frontend/src/pages/ItemDetail.tsx index 724c7a1..55fa552 100644 --- a/frontend/src/pages/ItemDetail.tsx +++ b/frontend/src/pages/ItemDetail.tsx @@ -676,98 +676,92 @@ const ItemDetail: React.FC = () => { -