From 05ce93a57f425579f58a697e65811ec29cbf165a Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 11 Dec 2025 19:45:01 +0100 Subject: [PATCH 01/16] feat: implement calendar-based cycle calculation functions Add CalendarCycles module with functions for all interval types. Includes comprehensive tests for edge cases. --- lib/mv/membership_fees/calendar_cycles.ex | 267 ++++++++++++++ .../membership_fees/calendar_cycles_test.exs | 327 ++++++++++++++++++ 2 files changed, 594 insertions(+) create mode 100644 lib/mv/membership_fees/calendar_cycles.ex create mode 100644 test/mv/membership_fees/calendar_cycles_test.exs diff --git a/lib/mv/membership_fees/calendar_cycles.ex b/lib/mv/membership_fees/calendar_cycles.ex new file mode 100644 index 0000000..77e1479 --- /dev/null +++ b/lib/mv/membership_fees/calendar_cycles.ex @@ -0,0 +1,267 @@ +defmodule Mv.MembershipFees.CalendarCycles do + @moduledoc """ + Calendar-based cycle calculation functions for membership fees. + + This module provides pure functions for calculating cycle boundaries + based on interval types (monthly, quarterly, half-yearly, yearly). + + All functions are pure (no side effects) and work with Elixir's `Date` type. + + ## Interval Types + + - `:monthly` - Cycles from 1st to last day of each month + - `:quarterly` - Cycles from 1st of Jan/Apr/Jul/Oct to last day of quarter + - `:half_yearly` - Cycles from 1st of Jan/Jul to last day of half-year + - `:yearly` - Cycles from Jan 1st to Dec 31st + + ## Examples + + iex> date = ~D[2024-03-15] + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(date, :monthly) + ~D[2024-03-01] + + iex> cycle_start = ~D[2024-01-01] + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle_start, :yearly) + ~D[2024-12-31] + + iex> cycle_start = ~D[2024-01-01] + iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(cycle_start, :yearly) + ~D[2025-01-01] + """ + + @type interval :: :monthly | :quarterly | :half_yearly | :yearly + + @doc """ + Calculates the start date of the cycle that contains the given date. + + ## Parameters + + - `date` - The date for which to find the cycle start + - `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`) + - `reference_date` - Optional reference date (defaults to `date`) + + ## Returns + + The start date of the cycle containing the given date. + + ## Examples + + iex> calculate_cycle_start(~D[2024-03-15], :monthly) + ~D[2024-03-01] + + iex> calculate_cycle_start(~D[2024-05-15], :quarterly) + ~D[2024-04-01] + + iex> calculate_cycle_start(~D[2024-09-15], :half_yearly) + ~D[2024-07-01] + + iex> calculate_cycle_start(~D[2024-12-15], :yearly) + ~D[2024-01-01] + """ + @spec calculate_cycle_start(Date.t(), interval(), Date.t() | nil) :: Date.t() + def calculate_cycle_start(date, interval, reference_date \\ nil) do + reference = reference_date || date + + case interval do + :monthly -> monthly_cycle_start(reference) + :quarterly -> quarterly_cycle_start(reference) + :half_yearly -> half_yearly_cycle_start(reference) + :yearly -> yearly_cycle_start(reference) + end + end + + @doc """ + Calculates the end date of a cycle based on its start date and interval. + + ## Parameters + + - `cycle_start` - The start date of the cycle + - `interval` - The interval type + + ## Returns + + The end date of the cycle. + + ## Examples + + iex> calculate_cycle_end(~D[2024-03-01], :monthly) + ~D[2024-03-31] + + iex> calculate_cycle_end(~D[2024-02-01], :monthly) + ~D[2024-02-29] + + iex> calculate_cycle_end(~D[2024-01-01], :quarterly) + ~D[2024-03-31] + + iex> calculate_cycle_end(~D[2024-01-01], :half_yearly) + ~D[2024-06-30] + + iex> calculate_cycle_end(~D[2024-01-01], :yearly) + ~D[2024-12-31] + """ + @spec calculate_cycle_end(Date.t(), interval()) :: Date.t() + def calculate_cycle_end(cycle_start, interval) do + case interval do + :monthly -> monthly_cycle_end(cycle_start) + :quarterly -> quarterly_cycle_end(cycle_start) + :half_yearly -> half_yearly_cycle_end(cycle_start) + :yearly -> yearly_cycle_end(cycle_start) + end + end + + @doc """ + Calculates the start date of the next cycle. + + ## Parameters + + - `cycle_start` - The start date of the current cycle + - `interval` - The interval type + + ## Returns + + The start date of the next cycle. + + ## Examples + + iex> next_cycle_start(~D[2024-01-01], :monthly) + ~D[2024-02-01] + + iex> next_cycle_start(~D[2024-01-01], :quarterly) + ~D[2024-04-01] + + iex> next_cycle_start(~D[2024-01-01], :half_yearly) + ~D[2024-07-01] + + iex> next_cycle_start(~D[2024-01-01], :yearly) + ~D[2025-01-01] + """ + @spec next_cycle_start(Date.t(), interval()) :: Date.t() + def next_cycle_start(cycle_start, interval) do + cycle_end = calculate_cycle_end(cycle_start, interval) + next_date = Date.add(cycle_end, 1) + calculate_cycle_start(next_date, interval) + end + + @doc """ + Checks if the cycle contains today's date. + + ## Parameters + + - `cycle_start` - The start date of the cycle + - `interval` - The interval type + + ## Returns + + `true` if today is within the cycle, `false` otherwise. + + ## Examples + + # Assuming today is 2024-03-15 + iex> current_cycle?(~D[2024-03-01], :monthly) + true + + iex> current_cycle?(~D[2024-02-01], :monthly) + false + """ + @spec current_cycle?(Date.t(), interval()) :: boolean() + def current_cycle?(cycle_start, interval) do + today = Date.utc_today() + cycle_end = calculate_cycle_end(cycle_start, interval) + + Date.compare(cycle_start, today) in [:lt, :eq] and + Date.compare(today, cycle_end) in [:lt, :eq] + end + + @doc """ + Checks if the cycle was just completed (ended yesterday or earlier, but is the most recent completed cycle). + + ## Parameters + + - `cycle_start` - The start date of the cycle + - `interval` - The interval type + + ## Returns + + `true` if the cycle ended yesterday or earlier and is the last completed cycle, `false` otherwise. + + ## Examples + + # Assuming today is 2024-04-01 (cycle ended yesterday) + iex> last_completed_cycle?(~D[2024-03-01], :monthly) + true + + # Assuming today is 2024-03-15 (cycle is still current) + iex> last_completed_cycle?(~D[2024-03-01], :monthly) + false + """ + @spec last_completed_cycle?(Date.t(), interval()) :: boolean() + def last_completed_cycle?(cycle_start, interval) do + today = Date.utc_today() + cycle_end = calculate_cycle_end(cycle_start, interval) + + # Cycle must have ended (yesterday or earlier) + case Date.compare(today, cycle_end) do + :gt -> + # Check if this is the most recent completed cycle + # by verifying that the next cycle hasn't ended yet + next_start = next_cycle_start(cycle_start, interval) + next_end = calculate_cycle_end(next_start, interval) + + Date.compare(today, next_end) in [:lt, :eq] + + _ -> + false + end + end + + # Private helper functions + + defp monthly_cycle_start(date) do + Date.new!(date.year, date.month, 1) + end + + defp monthly_cycle_end(cycle_start) do + Date.end_of_month(cycle_start) + end + + defp quarterly_cycle_start(date) do + quarter_start_month = + case date.month do + m when m in [1, 2, 3] -> 1 + m when m in [4, 5, 6] -> 4 + m when m in [7, 8, 9] -> 7 + m when m in [10, 11, 12] -> 10 + end + + Date.new!(date.year, quarter_start_month, 1) + end + + defp quarterly_cycle_end(cycle_start) do + case cycle_start.month do + 1 -> Date.new!(cycle_start.year, 3, 31) + 4 -> Date.new!(cycle_start.year, 6, 30) + 7 -> Date.new!(cycle_start.year, 9, 30) + 10 -> Date.new!(cycle_start.year, 12, 31) + end + end + + defp half_yearly_cycle_start(date) do + half_start_month = if date.month in 1..6, do: 1, else: 7 + Date.new!(date.year, half_start_month, 1) + end + + defp half_yearly_cycle_end(cycle_start) do + case cycle_start.month do + 1 -> Date.new!(cycle_start.year, 6, 30) + 7 -> Date.new!(cycle_start.year, 12, 31) + end + end + + defp yearly_cycle_start(date) do + Date.new!(date.year, 1, 1) + end + + defp yearly_cycle_end(cycle_start) do + Date.new!(cycle_start.year, 12, 31) + end +end diff --git a/test/mv/membership_fees/calendar_cycles_test.exs b/test/mv/membership_fees/calendar_cycles_test.exs new file mode 100644 index 0000000..7079e3f --- /dev/null +++ b/test/mv/membership_fees/calendar_cycles_test.exs @@ -0,0 +1,327 @@ +defmodule Mv.MembershipFees.CalendarCyclesTest do + @moduledoc """ + Tests for CalendarCycles module. + """ + use ExUnit.Case, async: true + + alias Mv.MembershipFees.CalendarCycles + + describe "calculate_cycle_start/3" do + test "monthly: returns 1st of month for any date" do + assert CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly) == ~D[2024-03-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-03-31], :monthly) == ~D[2024-03-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-03-01], :monthly) == ~D[2024-03-01] + end + + test "quarterly: returns 1st of quarter (Jan/Apr/Jul/Oct)" do + # Q1 (Jan-Mar) + assert CalendarCycles.calculate_cycle_start(~D[2024-01-15], :quarterly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-02-15], :quarterly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-03-15], :quarterly) == ~D[2024-01-01] + + # Q2 (Apr-Jun) + assert CalendarCycles.calculate_cycle_start(~D[2024-04-15], :quarterly) == ~D[2024-04-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly) == ~D[2024-04-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-06-15], :quarterly) == ~D[2024-04-01] + + # Q3 (Jul-Sep) + assert CalendarCycles.calculate_cycle_start(~D[2024-07-15], :quarterly) == ~D[2024-07-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-08-15], :quarterly) == ~D[2024-07-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-09-15], :quarterly) == ~D[2024-07-01] + + # Q4 (Oct-Dec) + assert CalendarCycles.calculate_cycle_start(~D[2024-10-15], :quarterly) == ~D[2024-10-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-11-15], :quarterly) == ~D[2024-10-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-12-15], :quarterly) == ~D[2024-10-01] + end + + test "half_yearly: returns 1st of half (Jan/Jul)" do + # First half (Jan-Jun) + assert CalendarCycles.calculate_cycle_start(~D[2024-01-15], :half_yearly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-03-15], :half_yearly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-06-15], :half_yearly) == ~D[2024-01-01] + + # Second half (Jul-Dec) + assert CalendarCycles.calculate_cycle_start(~D[2024-07-15], :half_yearly) == ~D[2024-07-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly) == ~D[2024-07-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-12-15], :half_yearly) == ~D[2024-07-01] + end + + test "yearly: returns 1st of January" do + assert CalendarCycles.calculate_cycle_start(~D[2024-01-15], :yearly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-06-15], :yearly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_start(~D[2024-12-15], :yearly) == ~D[2024-01-01] + end + + test "uses reference_date when provided" do + date = ~D[2024-03-15] + reference = ~D[2024-05-20] + + assert CalendarCycles.calculate_cycle_start(date, :monthly, reference) == ~D[2024-05-01] + assert CalendarCycles.calculate_cycle_start(date, :quarterly, reference) == ~D[2024-04-01] + end + end + + describe "calculate_cycle_end/2" do + test "monthly: returns last day of month" do + # 31-day month + assert CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly) == ~D[2024-03-31] + + # 30-day month + assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :monthly) == ~D[2024-04-30] + + # February in leap year + assert CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly) == ~D[2024-02-29] + + # February in non-leap year + assert CalendarCycles.calculate_cycle_end(~D[2023-02-01], :monthly) == ~D[2023-02-28] + end + + test "quarterly: returns last day of quarter" do + # Q1: Jan-Mar -> Mar 31 + assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly) == ~D[2024-03-31] + + # Q2: Apr-Jun -> Jun 30 + assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly) == ~D[2024-06-30] + + # Q3: Jul-Sep -> Sep 30 + assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :quarterly) == ~D[2024-09-30] + + # Q4: Oct-Dec -> Dec 31 + assert CalendarCycles.calculate_cycle_end(~D[2024-10-01], :quarterly) == ~D[2024-12-31] + end + + test "half_yearly: returns last day of half-year" do + # First half: Jan-Jun -> Jun 30 + assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly) == ~D[2024-06-30] + + # Second half: Jul-Dec -> Dec 31 + assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :half_yearly) == ~D[2024-12-31] + end + + test "yearly: returns Dec 31" do + assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly) == ~D[2024-12-31] + assert CalendarCycles.calculate_cycle_end(~D[2023-01-01], :yearly) == ~D[2023-12-31] + end + end + + describe "next_cycle_start/2" do + test "monthly: adds one month" do + assert CalendarCycles.next_cycle_start(~D[2024-01-01], :monthly) == ~D[2024-02-01] + assert CalendarCycles.next_cycle_start(~D[2024-02-01], :monthly) == ~D[2024-03-01] + assert CalendarCycles.next_cycle_start(~D[2024-12-01], :monthly) == ~D[2025-01-01] + end + + test "quarterly: adds three months" do + assert CalendarCycles.next_cycle_start(~D[2024-01-01], :quarterly) == ~D[2024-04-01] + assert CalendarCycles.next_cycle_start(~D[2024-04-01], :quarterly) == ~D[2024-07-01] + assert CalendarCycles.next_cycle_start(~D[2024-07-01], :quarterly) == ~D[2024-10-01] + assert CalendarCycles.next_cycle_start(~D[2024-10-01], :quarterly) == ~D[2025-01-01] + end + + test "half_yearly: adds six months" do + assert CalendarCycles.next_cycle_start(~D[2024-01-01], :half_yearly) == ~D[2024-07-01] + assert CalendarCycles.next_cycle_start(~D[2024-07-01], :half_yearly) == ~D[2025-01-01] + end + + test "yearly: adds one year" do + assert CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) == ~D[2025-01-01] + assert CalendarCycles.next_cycle_start(~D[2023-01-01], :yearly) == ~D[2024-01-01] + end + end + + describe "current_cycle?/2" do + test "returns true when today is within cycle" do + today = Date.utc_today() + cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly) + + assert CalendarCycles.current_cycle?(cycle_start, :monthly) == true + end + + test "returns true when today equals cycle start" do + today = Date.utc_today() + cycle_start = today + + # For monthly, if today is the 1st, it's the cycle start + if today.day == 1 do + assert CalendarCycles.current_cycle?(cycle_start, :monthly) == true + end + end + + test "returns true when today equals cycle end" do + today = Date.utc_today() + cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly) + cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, :monthly) + + # If today is the last day of the month, it's the cycle end + if today == cycle_end do + assert CalendarCycles.current_cycle?(cycle_start, :monthly) == true + end + end + + test "returns false when today is before cycle start" do + future_date = Date.add(Date.utc_today(), 35) + cycle_start = CalendarCycles.calculate_cycle_start(future_date, :monthly) + + assert CalendarCycles.current_cycle?(cycle_start, :monthly) == false + end + + test "returns false when today is after cycle end" do + past_date = Date.add(Date.utc_today(), -35) + cycle_start = CalendarCycles.calculate_cycle_start(past_date, :monthly) + + assert CalendarCycles.current_cycle?(cycle_start, :monthly) == false + end + + test "works for all interval types" do + today = Date.utc_today() + + for interval <- [:monthly, :quarterly, :half_yearly, :yearly] do + cycle_start = CalendarCycles.calculate_cycle_start(today, interval) + result = CalendarCycles.current_cycle?(cycle_start, interval) + + assert result == true, "Expected current cycle for #{interval} with start #{cycle_start}" + end + end + end + + describe "last_completed_cycle?/2" do + test "returns true when cycle ended yesterday" do + yesterday = Date.add(Date.utc_today(), -1) + cycle_start = CalendarCycles.calculate_cycle_start(yesterday, :monthly) + cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, :monthly) + + # Only test if yesterday was actually the cycle end + if yesterday == cycle_end do + assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly) == true + end + end + + test "returns false when cycle is still current" do + today = Date.utc_today() + cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly) + + assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly) == false + end + + test "returns false when cycle is in the future" do + future_date = Date.add(Date.utc_today(), 35) + cycle_start = CalendarCycles.calculate_cycle_start(future_date, :monthly) + + assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly) == false + end + + test "returns false when next cycle has also ended" do + # Use a date from two cycles ago + past_date = Date.add(Date.utc_today(), -65) + cycle_start = CalendarCycles.calculate_cycle_start(past_date, :monthly) + + assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly) == false + end + + test "works correctly for quarterly intervals" do + # Test with a known past quarter + past_quarter_start = ~D[2024-01-01] + today = Date.utc_today() + + if Date.compare(today, CalendarCycles.calculate_cycle_end(past_quarter_start, :quarterly)) == + :gt do + # Check if next quarter hasn't ended yet + next_quarter_start = CalendarCycles.next_cycle_start(past_quarter_start, :quarterly) + next_quarter_end = CalendarCycles.calculate_cycle_end(next_quarter_start, :quarterly) + + if Date.compare(today, next_quarter_end) in [:lt, :eq] do + assert CalendarCycles.last_completed_cycle?(past_quarter_start, :quarterly) == true + end + end + end + end + + describe "edge cases" do + test "leap year: February has 29 days" do + # 2024 is a leap year + assert CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly) == ~D[2024-02-29] + + # 2023 is not a leap year + assert CalendarCycles.calculate_cycle_end(~D[2023-02-01], :monthly) == ~D[2023-02-28] + end + + test "year boundary: December 31 to January 1" do + # Yearly cycle + assert CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) == ~D[2025-01-01] + + # Monthly cycle across year boundary + assert CalendarCycles.next_cycle_start(~D[2024-12-01], :monthly) == ~D[2025-01-01] + + # Half-yearly cycle across year boundary + assert CalendarCycles.next_cycle_start(~D[2024-07-01], :half_yearly) == ~D[2025-01-01] + + # Quarterly cycle across year boundary + assert CalendarCycles.next_cycle_start(~D[2024-10-01], :quarterly) == ~D[2025-01-01] + end + + test "month boundary: different month lengths" do + # 31-day months + assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :monthly) == ~D[2024-01-31] + assert CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly) == ~D[2024-03-31] + assert CalendarCycles.calculate_cycle_end(~D[2024-05-01], :monthly) == ~D[2024-05-31] + + # 30-day months + assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :monthly) == ~D[2024-04-30] + assert CalendarCycles.calculate_cycle_end(~D[2024-06-01], :monthly) == ~D[2024-06-30] + assert CalendarCycles.calculate_cycle_end(~D[2024-09-01], :monthly) == ~D[2024-09-30] + assert CalendarCycles.calculate_cycle_end(~D[2024-11-01], :monthly) == ~D[2024-11-30] + end + + test "date in middle of cycle: all functions work correctly" do + middle_date = ~D[2024-03-15] + + # calculate_cycle_start + assert CalendarCycles.calculate_cycle_start(middle_date, :monthly) == ~D[2024-03-01] + assert CalendarCycles.calculate_cycle_start(middle_date, :quarterly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_start(middle_date, :half_yearly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_start(middle_date, :yearly) == ~D[2024-01-01] + + # calculate_cycle_end + monthly_start = CalendarCycles.calculate_cycle_start(middle_date, :monthly) + assert CalendarCycles.calculate_cycle_end(monthly_start, :monthly) == ~D[2024-03-31] + + # next_cycle_start + assert CalendarCycles.next_cycle_start(monthly_start, :monthly) == ~D[2024-04-01] + end + + test "quarterly: all quarter boundaries correct" do + # Q1 boundaries + assert CalendarCycles.calculate_cycle_start(~D[2024-01-15], :quarterly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly) == ~D[2024-03-31] + + # Q2 boundaries + assert CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly) == ~D[2024-04-01] + assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly) == ~D[2024-06-30] + + # Q3 boundaries + assert CalendarCycles.calculate_cycle_start(~D[2024-08-15], :quarterly) == ~D[2024-07-01] + assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :quarterly) == ~D[2024-09-30] + + # Q4 boundaries + assert CalendarCycles.calculate_cycle_start(~D[2024-11-15], :quarterly) == ~D[2024-10-01] + assert CalendarCycles.calculate_cycle_end(~D[2024-10-01], :quarterly) == ~D[2024-12-31] + end + + test "half_yearly: both half boundaries correct" do + # First half boundaries + assert CalendarCycles.calculate_cycle_start(~D[2024-03-15], :half_yearly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly) == ~D[2024-06-30] + + # Second half boundaries + assert CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly) == ~D[2024-07-01] + assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :half_yearly) == ~D[2024-12-31] + end + + test "yearly: full year boundaries" do + assert CalendarCycles.calculate_cycle_start(~D[2024-06-15], :yearly) == ~D[2024-01-01] + assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly) == ~D[2024-12-31] + assert CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) == ~D[2025-01-01] + end + end +end From bbe4807eddc712efa6982d1d7cddfd13c23911ea Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 11 Dec 2025 20:08:19 +0100 Subject: [PATCH 02/16] refactor: improve CalendarCycles API and tests based on code review --- lib/mv/membership_fees/calendar_cycles.ex | 126 ++++++---- .../membership_fees/calendar_cycles_test.exs | 238 ++++-------------- 2 files changed, 127 insertions(+), 237 deletions(-) diff --git a/lib/mv/membership_fees/calendar_cycles.ex b/lib/mv/membership_fees/calendar_cycles.ex index 77e1479..5e25432 100644 --- a/lib/mv/membership_fees/calendar_cycles.ex +++ b/lib/mv/membership_fees/calendar_cycles.ex @@ -2,10 +2,16 @@ defmodule Mv.MembershipFees.CalendarCycles do @moduledoc """ Calendar-based cycle calculation functions for membership fees. - This module provides pure functions for calculating cycle boundaries + This module provides functions for calculating cycle boundaries based on interval types (monthly, quarterly, half-yearly, yearly). - All functions are pure (no side effects) and work with Elixir's `Date` type. + The calculation functions (`calculate_cycle_start/3`, `calculate_cycle_end/2`, + `next_cycle_start/2`) are pure functions with no side effects. + + The time-dependent functions (`current_cycle?/3`, `last_completed_cycle?/3`) + depend on a date parameter for testability. Their 2-argument variants + (`current_cycle?/2`, `last_completed_cycle?/2`) use `Date.utc_today()` and + are not referentially transparent. ## Interval Types @@ -29,47 +35,61 @@ defmodule Mv.MembershipFees.CalendarCycles do ~D[2025-01-01] """ + @typedoc """ + Interval type for membership fee cycles. + + - `:monthly` - Monthly cycles (1st to last day of month) + - `:quarterly` - Quarterly cycles (1st of quarter to last day of quarter) + - `:half_yearly` - Half-yearly cycles (1st of half-year to last day of half-year) + - `:yearly` - Yearly cycles (Jan 1st to Dec 31st) + """ @type interval :: :monthly | :quarterly | :half_yearly | :yearly @doc """ - Calculates the start date of the cycle that contains the given date. + Calculates the start date of the cycle that contains the reference date. ## Parameters - - `date` - The date for which to find the cycle start + - `date` - The date for which to find the cycle start (used as default if `reference_date` not provided) - `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`) - - `reference_date` - Optional reference date (defaults to `date`) + - `reference_date` - The reference date to use for calculation (defaults to `date`) ## Returns - The start date of the cycle containing the given date. + The start date of the cycle containing the reference date. ## Examples - iex> calculate_cycle_start(~D[2024-03-15], :monthly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly) ~D[2024-03-01] - iex> calculate_cycle_start(~D[2024-05-15], :quarterly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly) ~D[2024-04-01] - iex> calculate_cycle_start(~D[2024-09-15], :half_yearly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly) ~D[2024-07-01] - iex> calculate_cycle_start(~D[2024-12-15], :yearly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-12-15], :yearly) ~D[2024-01-01] - """ - @spec calculate_cycle_start(Date.t(), interval(), Date.t() | nil) :: Date.t() - def calculate_cycle_start(date, interval, reference_date \\ nil) do - reference = reference_date || date + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly, ~D[2024-05-20]) + ~D[2024-05-01] + """ + @spec calculate_cycle_start(Date.t(), interval(), Date.t()) :: Date.t() + def calculate_cycle_start(_date, interval, reference_date) do case interval do - :monthly -> monthly_cycle_start(reference) - :quarterly -> quarterly_cycle_start(reference) - :half_yearly -> half_yearly_cycle_start(reference) - :yearly -> yearly_cycle_start(reference) + :monthly -> monthly_cycle_start(reference_date) + :quarterly -> quarterly_cycle_start(reference_date) + :half_yearly -> half_yearly_cycle_start(reference_date) + :yearly -> yearly_cycle_start(reference_date) end end + @spec calculate_cycle_start(Date.t(), interval()) :: Date.t() + def calculate_cycle_start(date, interval) do + calculate_cycle_start(date, interval, date) + end + @doc """ Calculates the end date of a cycle based on its start date and interval. @@ -84,19 +104,19 @@ defmodule Mv.MembershipFees.CalendarCycles do ## Examples - iex> calculate_cycle_end(~D[2024-03-01], :monthly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly) ~D[2024-03-31] - iex> calculate_cycle_end(~D[2024-02-01], :monthly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly) ~D[2024-02-29] - iex> calculate_cycle_end(~D[2024-01-01], :quarterly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly) ~D[2024-03-31] - iex> calculate_cycle_end(~D[2024-01-01], :half_yearly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly) ~D[2024-06-30] - iex> calculate_cycle_end(~D[2024-01-01], :yearly) + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly) ~D[2024-12-31] """ @spec calculate_cycle_end(Date.t(), interval()) :: Date.t() @@ -123,16 +143,16 @@ defmodule Mv.MembershipFees.CalendarCycles do ## Examples - iex> next_cycle_start(~D[2024-01-01], :monthly) + iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :monthly) ~D[2024-02-01] - iex> next_cycle_start(~D[2024-01-01], :quarterly) + iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :quarterly) ~D[2024-04-01] - iex> next_cycle_start(~D[2024-01-01], :half_yearly) + iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :half_yearly) ~D[2024-07-01] - iex> next_cycle_start(~D[2024-01-01], :yearly) + iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) ~D[2025-01-01] """ @spec next_cycle_start(Date.t(), interval()) :: Date.t() @@ -143,63 +163,74 @@ defmodule Mv.MembershipFees.CalendarCycles do end @doc """ - Checks if the cycle contains today's date. + Checks if the cycle contains the given date. ## Parameters - `cycle_start` - The start date of the cycle - `interval` - The interval type + - `today` - The date to check (defaults to today's date) ## Returns - `true` if today is within the cycle, `false` otherwise. + `true` if the given date is within the cycle, `false` otherwise. ## Examples - # Assuming today is 2024-03-15 - iex> current_cycle?(~D[2024-03-01], :monthly) + iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15]) true - iex> current_cycle?(~D[2024-02-01], :monthly) + iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-02-01], :monthly, ~D[2024-03-15]) false + + iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-01]) + true + + iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-31]) + true """ - @spec current_cycle?(Date.t(), interval()) :: boolean() - def current_cycle?(cycle_start, interval) do - today = Date.utc_today() + @spec current_cycle?(Date.t(), interval(), Date.t()) :: boolean() + def current_cycle?(cycle_start, interval, today) do cycle_end = calculate_cycle_end(cycle_start, interval) Date.compare(cycle_start, today) in [:lt, :eq] and Date.compare(today, cycle_end) in [:lt, :eq] end + @spec current_cycle?(Date.t(), interval()) :: boolean() + def current_cycle?(cycle_start, interval) do + current_cycle?(cycle_start, interval, Date.utc_today()) + end + @doc """ - Checks if the cycle was just completed (ended yesterday or earlier, but is the most recent completed cycle). + Checks if the cycle was just completed (ended before or on the given date, but is the most recent completed cycle). ## Parameters - `cycle_start` - The start date of the cycle - `interval` - The interval type + - `today` - The date to check against (defaults to today's date) ## Returns - `true` if the cycle ended yesterday or earlier and is the last completed cycle, `false` otherwise. + `true` if the cycle ended before or on the given date and is the last completed cycle, `false` otherwise. ## Examples - # Assuming today is 2024-04-01 (cycle ended yesterday) - iex> last_completed_cycle?(~D[2024-03-01], :monthly) + iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-04-01]) true - # Assuming today is 2024-03-15 (cycle is still current) - iex> last_completed_cycle?(~D[2024-03-01], :monthly) + iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15]) + false + + iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-02-01], :monthly, ~D[2024-04-15]) false """ - @spec last_completed_cycle?(Date.t(), interval()) :: boolean() - def last_completed_cycle?(cycle_start, interval) do - today = Date.utc_today() + @spec last_completed_cycle?(Date.t(), interval(), Date.t()) :: boolean() + def last_completed_cycle?(cycle_start, interval, today) do cycle_end = calculate_cycle_end(cycle_start, interval) - # Cycle must have ended (yesterday or earlier) + # Cycle must have ended (before or on the given date) case Date.compare(today, cycle_end) do :gt -> # Check if this is the most recent completed cycle @@ -214,6 +245,11 @@ defmodule Mv.MembershipFees.CalendarCycles do end end + @spec last_completed_cycle?(Date.t(), interval()) :: boolean() + def last_completed_cycle?(cycle_start, interval) do + last_completed_cycle?(cycle_start, interval, Date.utc_today()) + end + # Private helper functions defp monthly_cycle_start(date) do diff --git a/test/mv/membership_fees/calendar_cycles_test.exs b/test/mv/membership_fees/calendar_cycles_test.exs index 7079e3f..29fec48 100644 --- a/test/mv/membership_fees/calendar_cycles_test.exs +++ b/test/mv/membership_fees/calendar_cycles_test.exs @@ -6,53 +6,9 @@ defmodule Mv.MembershipFees.CalendarCyclesTest do alias Mv.MembershipFees.CalendarCycles + doctest Mv.MembershipFees.CalendarCycles + describe "calculate_cycle_start/3" do - test "monthly: returns 1st of month for any date" do - assert CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly) == ~D[2024-03-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-03-31], :monthly) == ~D[2024-03-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-03-01], :monthly) == ~D[2024-03-01] - end - - test "quarterly: returns 1st of quarter (Jan/Apr/Jul/Oct)" do - # Q1 (Jan-Mar) - assert CalendarCycles.calculate_cycle_start(~D[2024-01-15], :quarterly) == ~D[2024-01-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-02-15], :quarterly) == ~D[2024-01-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-03-15], :quarterly) == ~D[2024-01-01] - - # Q2 (Apr-Jun) - assert CalendarCycles.calculate_cycle_start(~D[2024-04-15], :quarterly) == ~D[2024-04-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly) == ~D[2024-04-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-06-15], :quarterly) == ~D[2024-04-01] - - # Q3 (Jul-Sep) - assert CalendarCycles.calculate_cycle_start(~D[2024-07-15], :quarterly) == ~D[2024-07-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-08-15], :quarterly) == ~D[2024-07-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-09-15], :quarterly) == ~D[2024-07-01] - - # Q4 (Oct-Dec) - assert CalendarCycles.calculate_cycle_start(~D[2024-10-15], :quarterly) == ~D[2024-10-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-11-15], :quarterly) == ~D[2024-10-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-12-15], :quarterly) == ~D[2024-10-01] - end - - test "half_yearly: returns 1st of half (Jan/Jul)" do - # First half (Jan-Jun) - assert CalendarCycles.calculate_cycle_start(~D[2024-01-15], :half_yearly) == ~D[2024-01-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-03-15], :half_yearly) == ~D[2024-01-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-06-15], :half_yearly) == ~D[2024-01-01] - - # Second half (Jul-Dec) - assert CalendarCycles.calculate_cycle_start(~D[2024-07-15], :half_yearly) == ~D[2024-07-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly) == ~D[2024-07-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-12-15], :half_yearly) == ~D[2024-07-01] - end - - test "yearly: returns 1st of January" do - assert CalendarCycles.calculate_cycle_start(~D[2024-01-15], :yearly) == ~D[2024-01-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-06-15], :yearly) == ~D[2024-01-01] - assert CalendarCycles.calculate_cycle_start(~D[2024-12-15], :yearly) == ~D[2024-01-01] - end - test "uses reference_date when provided" do date = ~D[2024-03-15] reference = ~D[2024-05-20] @@ -62,178 +18,76 @@ defmodule Mv.MembershipFees.CalendarCyclesTest do end end - describe "calculate_cycle_end/2" do - test "monthly: returns last day of month" do - # 31-day month - assert CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly) == ~D[2024-03-31] - - # 30-day month - assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :monthly) == ~D[2024-04-30] - - # February in leap year - assert CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly) == ~D[2024-02-29] - - # February in non-leap year - assert CalendarCycles.calculate_cycle_end(~D[2023-02-01], :monthly) == ~D[2023-02-28] - end - - test "quarterly: returns last day of quarter" do - # Q1: Jan-Mar -> Mar 31 - assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly) == ~D[2024-03-31] - - # Q2: Apr-Jun -> Jun 30 - assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly) == ~D[2024-06-30] - - # Q3: Jul-Sep -> Sep 30 - assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :quarterly) == ~D[2024-09-30] - - # Q4: Oct-Dec -> Dec 31 - assert CalendarCycles.calculate_cycle_end(~D[2024-10-01], :quarterly) == ~D[2024-12-31] - end - - test "half_yearly: returns last day of half-year" do - # First half: Jan-Jun -> Jun 30 - assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly) == ~D[2024-06-30] - - # Second half: Jul-Dec -> Dec 31 - assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :half_yearly) == ~D[2024-12-31] - end - - test "yearly: returns Dec 31" do - assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly) == ~D[2024-12-31] - assert CalendarCycles.calculate_cycle_end(~D[2023-01-01], :yearly) == ~D[2023-12-31] - end - end - - describe "next_cycle_start/2" do - test "monthly: adds one month" do - assert CalendarCycles.next_cycle_start(~D[2024-01-01], :monthly) == ~D[2024-02-01] - assert CalendarCycles.next_cycle_start(~D[2024-02-01], :monthly) == ~D[2024-03-01] - assert CalendarCycles.next_cycle_start(~D[2024-12-01], :monthly) == ~D[2025-01-01] - end - - test "quarterly: adds three months" do - assert CalendarCycles.next_cycle_start(~D[2024-01-01], :quarterly) == ~D[2024-04-01] - assert CalendarCycles.next_cycle_start(~D[2024-04-01], :quarterly) == ~D[2024-07-01] - assert CalendarCycles.next_cycle_start(~D[2024-07-01], :quarterly) == ~D[2024-10-01] - assert CalendarCycles.next_cycle_start(~D[2024-10-01], :quarterly) == ~D[2025-01-01] - end - - test "half_yearly: adds six months" do - assert CalendarCycles.next_cycle_start(~D[2024-01-01], :half_yearly) == ~D[2024-07-01] - assert CalendarCycles.next_cycle_start(~D[2024-07-01], :half_yearly) == ~D[2025-01-01] - end - - test "yearly: adds one year" do - assert CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) == ~D[2025-01-01] - assert CalendarCycles.next_cycle_start(~D[2023-01-01], :yearly) == ~D[2024-01-01] - end - end - - describe "current_cycle?/2" do - test "returns true when today is within cycle" do - today = Date.utc_today() - cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly) - - assert CalendarCycles.current_cycle?(cycle_start, :monthly) == true - end - - test "returns true when today equals cycle start" do - today = Date.utc_today() - cycle_start = today - - # For monthly, if today is the 1st, it's the cycle start - if today.day == 1 do - assert CalendarCycles.current_cycle?(cycle_start, :monthly) == true - end - end - - test "returns true when today equals cycle end" do - today = Date.utc_today() - cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly) - cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, :monthly) - - # If today is the last day of the month, it's the cycle end - if today == cycle_end do - assert CalendarCycles.current_cycle?(cycle_start, :monthly) == true - end - end - - test "returns false when today is before cycle start" do - future_date = Date.add(Date.utc_today(), 35) - cycle_start = CalendarCycles.calculate_cycle_start(future_date, :monthly) - - assert CalendarCycles.current_cycle?(cycle_start, :monthly) == false - end - - test "returns false when today is after cycle end" do - past_date = Date.add(Date.utc_today(), -35) - cycle_start = CalendarCycles.calculate_cycle_start(past_date, :monthly) - - assert CalendarCycles.current_cycle?(cycle_start, :monthly) == false - end + describe "current_cycle?/3" do + # Basic examples are covered by doctests test "works for all interval types" do - today = Date.utc_today() + today = ~D[2024-03-15] for interval <- [:monthly, :quarterly, :half_yearly, :yearly] do cycle_start = CalendarCycles.calculate_cycle_start(today, interval) - result = CalendarCycles.current_cycle?(cycle_start, interval) + result = CalendarCycles.current_cycle?(cycle_start, interval, today) assert result == true, "Expected current cycle for #{interval} with start #{cycle_start}" end end end - describe "last_completed_cycle?/2" do - test "returns true when cycle ended yesterday" do - yesterday = Date.add(Date.utc_today(), -1) - cycle_start = CalendarCycles.calculate_cycle_start(yesterday, :monthly) - cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, :monthly) - - # Only test if yesterday was actually the cycle end - if yesterday == cycle_end do - assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly) == true - end - end - - test "returns false when cycle is still current" do + describe "current_cycle?/2 wrapper" do + test "calls current_cycle?/3 with Date.utc_today()" do today = Date.utc_today() cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly) - assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly) == false - end + # This test verifies the wrapper works, but uses actual today + # The real testing happens in current_cycle?/3 tests above + result = CalendarCycles.current_cycle?(cycle_start, :monthly) - test "returns false when cycle is in the future" do - future_date = Date.add(Date.utc_today(), 35) - cycle_start = CalendarCycles.calculate_cycle_start(future_date, :monthly) - - assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly) == false + assert result == true end + end + + describe "last_completed_cycle?/3" do + # Basic examples are covered by doctests test "returns false when next cycle has also ended" do - # Use a date from two cycles ago - past_date = Date.add(Date.utc_today(), -65) - cycle_start = CalendarCycles.calculate_cycle_start(past_date, :monthly) + # Two cycles ago: cycle ended, but next cycle also ended + today = ~D[2024-05-15] + cycle_start = ~D[2024-03-01] + # Cycle ended 2024-03-31, next cycle ended 2024-04-30, today is 2024-05-15 - assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly) == false + assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly, today) == false end test "works correctly for quarterly intervals" do - # Test with a known past quarter + # Q1 2024 ended on 2024-03-31 + # Q2 2024 ends on 2024-06-30 + # Today is 2024-04-15 (after Q1 ended, before Q2 ended) + today = ~D[2024-04-15] past_quarter_start = ~D[2024-01-01] + + assert CalendarCycles.last_completed_cycle?(past_quarter_start, :quarterly, today) == true + end + + test "returns false when cycle ended on the given date" do + # Cycle ends on today, so it's still current, not completed + today = ~D[2024-03-31] + cycle_start = ~D[2024-03-01] + + assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly, today) == false + end + end + + describe "last_completed_cycle?/2 wrapper" do + test "calls last_completed_cycle?/3 with Date.utc_today()" do today = Date.utc_today() + cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly) - if Date.compare(today, CalendarCycles.calculate_cycle_end(past_quarter_start, :quarterly)) == - :gt do - # Check if next quarter hasn't ended yet - next_quarter_start = CalendarCycles.next_cycle_start(past_quarter_start, :quarterly) - next_quarter_end = CalendarCycles.calculate_cycle_end(next_quarter_start, :quarterly) + # This test verifies the wrapper works, but uses actual today + # The real testing happens in last_completed_cycle?/3 tests above + result = CalendarCycles.last_completed_cycle?(cycle_start, :monthly) - if Date.compare(today, next_quarter_end) in [:lt, :eq] do - assert CalendarCycles.last_completed_cycle?(past_quarter_start, :quarterly) == true - end - end + # Result depends on actual today, so we just verify it's a boolean + assert is_boolean(result) end end From aa7e5a7d386bfe21a4f6670433121f27cf4d74d5 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 11 Dec 2025 20:21:20 +0100 Subject: [PATCH 03/16] docs: fix CalendarCycles documentation to match actual implementation --- lib/mv/membership_fees/calendar_cycles.ex | 64 ++++++++++++++++------- 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/lib/mv/membership_fees/calendar_cycles.ex b/lib/mv/membership_fees/calendar_cycles.ex index 5e25432..8a4ef24 100644 --- a/lib/mv/membership_fees/calendar_cycles.ex +++ b/lib/mv/membership_fees/calendar_cycles.ex @@ -50,14 +50,47 @@ defmodule Mv.MembershipFees.CalendarCycles do ## Parameters - - `date` - The date for which to find the cycle start (used as default if `reference_date` not provided) + - `date` - Ignored in this 3-argument version (kept for API consistency) - `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`) - - `reference_date` - The reference date to use for calculation (defaults to `date`) + - `reference_date` - The date used to determine which cycle to calculate ## Returns The start date of the cycle containing the reference date. + ## Examples + + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly, ~D[2024-05-20]) + ~D[2024-05-01] + + iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :quarterly, ~D[2024-05-20]) + ~D[2024-04-01] + """ + @spec calculate_cycle_start(Date.t(), interval(), Date.t()) :: Date.t() + def calculate_cycle_start(_date, interval, reference_date) do + case interval do + :monthly -> monthly_cycle_start(reference_date) + :quarterly -> quarterly_cycle_start(reference_date) + :half_yearly -> half_yearly_cycle_start(reference_date) + :yearly -> yearly_cycle_start(reference_date) + end + end + + @doc """ + Calculates the start date of the cycle that contains the given date. + + This is a convenience function that calls `calculate_cycle_start/3` with `date` as both + the input and reference date. + + ## Parameters + + - `date` - The date used to determine which cycle to calculate + - `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`) + + ## Returns + + The start date of the cycle containing the given date. + ## Examples iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly) @@ -71,20 +104,7 @@ defmodule Mv.MembershipFees.CalendarCycles do iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-12-15], :yearly) ~D[2024-01-01] - - iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly, ~D[2024-05-20]) - ~D[2024-05-01] """ - @spec calculate_cycle_start(Date.t(), interval(), Date.t()) :: Date.t() - def calculate_cycle_start(_date, interval, reference_date) do - case interval do - :monthly -> monthly_cycle_start(reference_date) - :quarterly -> quarterly_cycle_start(reference_date) - :half_yearly -> half_yearly_cycle_start(reference_date) - :yearly -> yearly_cycle_start(reference_date) - end - end - @spec calculate_cycle_start(Date.t(), interval()) :: Date.t() def calculate_cycle_start(date, interval) do calculate_cycle_start(date, interval, date) @@ -203,7 +223,13 @@ defmodule Mv.MembershipFees.CalendarCycles do end @doc """ - Checks if the cycle was just completed (ended before or on the given date, but is the most recent completed cycle). + Checks if the cycle is the last completed cycle. + + A cycle is considered the last completed cycle if: + - The cycle has ended (cycle_end < today) + - The next cycle has not ended yet (today <= next_end) + + In other words: `cycle_end < today <= next_end` ## Parameters @@ -213,7 +239,7 @@ defmodule Mv.MembershipFees.CalendarCycles do ## Returns - `true` if the cycle ended before or on the given date and is the last completed cycle, `false` otherwise. + `true` if the cycle is the last completed cycle, `false` otherwise. ## Examples @@ -230,11 +256,11 @@ defmodule Mv.MembershipFees.CalendarCycles do def last_completed_cycle?(cycle_start, interval, today) do cycle_end = calculate_cycle_end(cycle_start, interval) - # Cycle must have ended (before or on the given date) + # Cycle must have ended (cycle_end < today) case Date.compare(today, cycle_end) do :gt -> # Check if this is the most recent completed cycle - # by verifying that the next cycle hasn't ended yet + # by verifying that the next cycle hasn't ended yet (today <= next_end) next_start = next_cycle_start(cycle_start, interval) next_end = calculate_cycle_end(next_start, interval) From 8bfa5b7d1dccb5b6a5d2ecd602a63c6e555033f5 Mon Sep 17 00:00:00 2001 From: carla Date: Fri, 12 Dec 2025 14:20:07 +0100 Subject: [PATCH 04/16] chore: remove immutable from custom fields --- lib/membership/custom_field.ex | 9 ++------ .../live/custom_field_live/form_component.ex | 4 ++-- ...49_remove_immutable_from_custom_fields.exs | 21 +++++++++++++++++++ priv/repo/seeds.exs | 12 ----------- 4 files changed, 25 insertions(+), 21 deletions(-) create mode 100644 priv/repo/migrations/20251211172549_remove_immutable_from_custom_fields.exs diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index 5b7514c..18b8154 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -12,7 +12,6 @@ defmodule Mv.Membership.CustomField do - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`) - `description` - Optional human-readable description - - `immutable` - If true, custom field values cannot be changed after creation - `required` - If true, all members must have this custom field (future feature) - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted @@ -60,10 +59,10 @@ defmodule Mv.Membership.CustomField do actions do defaults [:read, :update] - default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] + default_accept [:name, :value_type, :description, :required, :show_in_overview] create :create do - accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] + accept [:name, :value_type, :description, :required, :show_in_overview] change Mv.Membership.CustomField.Changes.GenerateSlug validate string_length(:slug, min: 1) end @@ -113,10 +112,6 @@ defmodule Mv.Membership.CustomField do trim?: true ] - attribute :immutable, :boolean, - default: false, - allow_nil?: false - attribute :required, :boolean, default: false, allow_nil?: false diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex index 4fe8579..69eb9e9 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -6,7 +6,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do - Create new custom field definitions - Edit existing custom fields - Select value type from supported types - - Set immutable and required flags + - Set required flag - Real-time validation ## Props @@ -50,10 +50,10 @@ defmodule MvWeb.CustomFieldLive.FormComponent do label={gettext("Value type")} options={ Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of] + |> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end) } /> <.input field={@form[:description]} type="text" label={gettext("Description")} /> - <.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} /> <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> <.input field={@form[:show_in_overview]} diff --git a/priv/repo/migrations/20251211172549_remove_immutable_from_custom_fields.exs b/priv/repo/migrations/20251211172549_remove_immutable_from_custom_fields.exs new file mode 100644 index 0000000..9d25d49 --- /dev/null +++ b/priv/repo/migrations/20251211172549_remove_immutable_from_custom_fields.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.RemoveImmutableFromCustomFields do + @moduledoc """ + Removes the immutable column from custom_fields table. + + The immutable field is no longer needed in the custom field definition. + """ + + use Ecto.Migration + + def up do + alter table(:custom_fields) do + remove :immutable + end + end + + def down do + alter table(:custom_fields) do + add :immutable, :boolean, null: false, default: false + end + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index bec9006..10af66b 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -12,28 +12,24 @@ for attrs <- [ name: "String Field", value_type: :string, description: "Example for a field of type string", - immutable: true, required: false }, %{ name: "Date Field", value_type: :date, description: "Example for a field of type date", - immutable: true, required: false }, %{ name: "Boolean Field", value_type: :boolean, description: "Example for a field of type boolean", - immutable: true, required: false }, %{ name: "Email Field", value_type: :email, description: "Example for a field of type email", - immutable: true, required: false }, # Realistic custom fields @@ -41,56 +37,48 @@ for attrs <- [ name: "Membership Number", value_type: :string, description: "Unique membership identification number", - immutable: false, required: false }, %{ name: "Emergency Contact", value_type: :string, description: "Emergency contact person name and phone", - immutable: false, required: false }, %{ name: "T-Shirt Size", value_type: :string, description: "T-Shirt size for events (XS, S, M, L, XL, XXL)", - immutable: false, required: false }, %{ name: "Newsletter Subscription", value_type: :boolean, description: "Whether member wants to receive newsletter", - immutable: false, required: false }, %{ name: "Date of Last Medical Check", value_type: :date, description: "Date of last medical examination", - immutable: false, required: false }, %{ name: "Secondary Email", value_type: :email, description: "Alternative email address", - immutable: false, required: false }, %{ name: "Membership Type", value_type: :string, description: "Type of membership (e.g., Regular, Student, Senior)", - immutable: false, required: false }, %{ name: "Parking Permit", value_type: :boolean, description: "Whether member has parking permit", - immutable: false, required: false } ] do From 4e86351e1ccd7369468e7cd550c234a23290b0c5 Mon Sep 17 00:00:00 2001 From: carla Date: Mon, 15 Dec 2025 08:44:24 +0100 Subject: [PATCH 05/16] feat: disable email buttons instead hide them --- lib/mv_web/components/core_components.ex | 26 +++++++++++++++++++-- lib/mv_web/live/member_live/index.html.heex | 12 +++++++--- test/mv_web/member_live/index_test.exs | 8 ------- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index a23381d..f0a9fdb 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -95,9 +95,11 @@ defmodule MvWeb.CoreComponents do <.button>Send! <.button phx-click="go" variant="primary">Send! <.button navigate={~p"/"}>Home + <.button disabled={true}>Disabled """ attr :rest, :global, include: ~w(href navigate patch method) attr :variant, :string, values: ~w(primary) + attr :disabled, :boolean, default: false, doc: "Whether the button is disabled" slot :inner_block, required: true def button(%{rest: rest} = assigns) do @@ -105,14 +107,34 @@ defmodule MvWeb.CoreComponents do assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant])) if rest[:href] || rest[:navigate] || rest[:patch] do + # For links, we can't use disabled attribute, so we use btn-disabled class + # DaisyUI's btn-disabled provides the same styling as :disabled on buttons + link_class = + if assigns[:disabled], + do: ["btn", assigns.class, "btn-disabled"], + else: ["btn", assigns.class] + + # Prevent interaction when disabled + link_attrs = + if assigns[:disabled] do + Map.merge(rest, %{tabindex: "-1", "aria-disabled": "true"}) + else + rest + end + + assigns = + assigns + |> assign(:link_class, link_class) + |> assign(:link_attrs, link_attrs) + ~H""" - <.link class={["btn", @class]} {@rest}> + <.link class={@link_class} {@link_attrs}> {render_slot(@inner_block)} """ else ~H""" - """ diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index fbeb416..8e8d18b 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -3,23 +3,29 @@ {gettext("Members")} <:actions> <.button - :if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))} + class="secondary" id="copy-emails-btn" phx-hook="CopyToClipboard" phx-click="copy_emails" + disabled={not Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))} aria-label={gettext("Copy email addresses of selected members")} > <.icon name="hero-clipboard-document" /> - {gettext("Copy emails")} ({Enum.count(@members, &MapSet.member?(@selected_members, &1.id))}) + {gettext("Copy email addresses")} ({Enum.count( + @members, + &MapSet.member?(@selected_members, &1.id) + )}) <.button - :if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))} + class="secondary" + id="open-email-btn" href={ "mailto:?bcc=" <> (MvWeb.MemberLive.Index.format_selected_member_emails(@members, @selected_members) |> Enum.join(", ") |> URI.encode()) } + disabled={not Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))} aria-label={gettext("Open email program with BCC recipients")} > <.icon name="hero-envelope" /> diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 30b61c7..5b826bd 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -410,14 +410,6 @@ defmodule MvWeb.MemberLive.IndexTest do assert render(view) =~ "1" end - test "copy button is not visible when no members are selected", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Ensure no members are selected (default state) - refute has_element?(view, "#copy-emails-btn") - end - test "copy button is visible when members are selected", %{ conn: conn, member1: member1 From e0712d47bce30e8e8667d716483eed3952930f25 Mon Sep 17 00:00:00 2001 From: carla Date: Mon, 15 Dec 2025 08:50:05 +0100 Subject: [PATCH 06/16] chore: change payment filter text --- .../components/payment_filter_component.ex | 4 +- .../live/custom_field_live/index_component.ex | 4 +- priv/gettext/de/LC_MESSAGES/default.po | 44 ++++++++++++------- priv/gettext/default.pot | 27 +++++------- priv/gettext/en/LC_MESSAGES/default.po | 42 +++++++++++------- 5 files changed, 68 insertions(+), 53 deletions(-) diff --git a/lib/mv_web/live/components/payment_filter_component.ex b/lib/mv_web/live/components/payment_filter_component.ex index 47556dd..1ba9d8b 100644 --- a/lib/mv_web/live/components/payment_filter_component.ex +++ b/lib/mv_web/live/components/payment_filter_component.ex @@ -77,7 +77,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do phx-target={@myself} > <.icon name="hero-users" class="h-4 w-4" /> - {gettext("All")} + {gettext("All payment statuses")}
  • @@ -140,7 +140,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do defp parse_filter(_), do: nil # Get display label for current filter - defp filter_label(nil), do: gettext("All") + defp filter_label(nil), do: gettext("All payment statuses") defp filter_label(:paid), do: gettext("Paid") defp filter_label(:not_paid), do: gettext("Not paid") end diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex index 8f63bf8..ccae3c6 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -5,7 +5,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do ## Features - List all custom fields - Display type information (name, value type, description) - - Show immutable and required flags + - Show required flag - Create new custom fields - Edit existing custom fields - Delete custom fields with confirmation (cascades to all custom field values) @@ -30,7 +30,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do phx-click="new_custom_field" phx-target={@myself} > - <.icon name="hero-plus" /> {gettext("New Custom field")} + <.icon name="hero-plus" /> {gettext("New Custom Field")} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 25f685d..81653a4 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -282,11 +282,6 @@ msgstr "Benutzer*in bearbeiten" msgid "Enabled" msgstr "Aktiviert" -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Immutable" -msgstr "Unveränderlich" - #: lib/mv_web/components/layouts/navbar.ex #, elixir-autogen, elixir-format msgid "Logout" @@ -760,11 +755,6 @@ msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert" msgid "Copy email addresses of selected members" msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Copy emails" -msgstr "E-Mails kopieren" - #: lib/mv_web/live/member_live/index.ex #, elixir-autogen, elixir-format msgid "No email addresses found" @@ -796,7 +786,6 @@ msgid "This field cannot be empty" msgstr "Dieses Feld darf nicht leer bleiben" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/payment_filter_component.ex #, elixir-autogen, elixir-format msgid "All" msgstr "Alle" @@ -1389,14 +1378,10 @@ msgid "Failed to delete custom field: %{error}" msgstr "Konnte Feld nicht löschen: %{error}" #: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "New Custom Field" -msgstr "Benutzerdefiniertes Feld speichern" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format, fuzzy -msgid "New Custom field" -msgstr "Benutzerdefiniertes Feld speichern" +msgid "New Custom Field" +msgstr "Neues Benutzerdefiniertes Feld" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format @@ -1438,6 +1423,16 @@ msgstr "Textfeld" msgid "Yes/No-Selection" msgstr "Ja/Nein-Auswahl" +#: lib/mv_web/live/components/payment_filter_component.ex +#, elixir-autogen, elixir-format +msgid "All payment statuses" +msgstr "Jeder Zahlungs-Zustand" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Copy email addresses" +msgstr "E-Mail-Adressen kopieren" + #~ #: lib/mv_web/live/custom_field_live/show.ex #~ #, elixir-autogen, elixir-format #~ msgid "Auto-generated identifier (immutable)" @@ -1449,6 +1444,11 @@ msgstr "Ja/Nein-Auswahl" #~ msgid "Birth Date" #~ msgstr "Geburtsdatum" +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Copy emails" +#~ msgstr "E-Mails kopieren" + #~ #: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/live/member_live/show.ex #~ #, elixir-autogen, elixir-format @@ -1471,6 +1471,16 @@ msgstr "Ja/Nein-Auswahl" #~ msgid "Id" #~ msgstr "ID" +#~ #: lib/mv_web/live/custom_field_live/form_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Immutable" +#~ msgstr "Unveränderlich" + +#~ #: lib/mv_web/live/custom_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "New Custom field" +#~ msgstr "Benutzerdefiniertes Feld speichern" + #~ #: lib/mv_web/live/user_live/form.ex #~ #: lib/mv_web/live/user_live/show.ex #~ #, elixir-autogen, elixir-format diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a7ab36b..451e2b5 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -283,11 +283,6 @@ msgstr "" msgid "Enabled" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Immutable" -msgstr "" - #: lib/mv_web/components/layouts/navbar.ex #, elixir-autogen, elixir-format msgid "Logout" @@ -761,11 +756,6 @@ msgstr[1] "" msgid "Copy email addresses of selected members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Copy emails" -msgstr "" - #: lib/mv_web/live/member_live/index.ex #, elixir-autogen, elixir-format msgid "No email addresses found" @@ -797,7 +787,6 @@ msgid "This field cannot be empty" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/payment_filter_component.ex #, elixir-autogen, elixir-format msgid "All" msgstr "" @@ -1390,13 +1379,9 @@ msgid "Failed to delete custom field: %{error}" msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "New Custom Field" -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format -msgid "New Custom field" +msgid "New Custom Field" msgstr "" #: lib/mv_web/live/global_settings_live.ex @@ -1438,3 +1423,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Yes/No-Selection" msgstr "" + +#: lib/mv_web/live/components/payment_filter_component.ex +#, elixir-autogen, elixir-format +msgid "All payment statuses" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Copy email addresses" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index e2a1876..5995656 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -283,11 +283,6 @@ msgstr "" msgid "Enabled" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Immutable" -msgstr "" - #: lib/mv_web/components/layouts/navbar.ex #, elixir-autogen, elixir-format msgid "Logout" @@ -761,11 +756,6 @@ msgstr[1] "" msgid "Copy email addresses of selected members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Copy emails" -msgstr "" - #: lib/mv_web/live/member_live/index.ex #, elixir-autogen, elixir-format msgid "No email addresses found" @@ -797,7 +787,6 @@ msgid "This field cannot be empty" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/payment_filter_component.ex #, elixir-autogen, elixir-format msgid "All" msgstr "" @@ -1390,13 +1379,9 @@ msgid "Failed to delete custom field: %{error}" msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "New Custom Field" -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format, fuzzy -msgid "New Custom field" +msgid "New Custom Field" msgstr "" #: lib/mv_web/live/global_settings_live.ex @@ -1439,6 +1424,16 @@ msgstr "" msgid "Yes/No-Selection" msgstr "" +#: lib/mv_web/live/components/payment_filter_component.ex +#, elixir-autogen, elixir-format +msgid "All payment statuses" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Copy email addresses" +msgstr "" + #~ #: lib/mv_web/live/custom_field_live/show.ex #~ #, elixir-autogen, elixir-format #~ msgid "Auto-generated identifier (immutable)" @@ -1450,6 +1445,11 @@ msgstr "" #~ msgid "Birth Date" #~ msgstr "" +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Copy emails" +#~ msgstr "" + #~ #: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/live/member_live/show.ex #~ #, elixir-autogen, elixir-format @@ -1471,6 +1471,16 @@ msgstr "" #~ msgid "Id" #~ msgstr "" +#~ #: lib/mv_web/live/custom_field_live/form_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Immutable" +#~ msgstr "" + +#~ #: lib/mv_web/live/custom_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "New Custom field" +#~ msgstr "" + #~ #: lib/mv_web/live/user_live/show.ex #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Not set" From dd4048669cefaddc5d9550836f2ccab137b9c15d Mon Sep 17 00:00:00 2001 From: carla Date: Mon, 15 Dec 2025 08:50:42 +0100 Subject: [PATCH 07/16] fix: update clubname on save --- lib/mv_web/components/layouts.ex | 6 +++++- lib/mv_web/components/layouts/navbar.ex | 9 ++++++--- lib/mv_web/live/global_settings_live.ex | 9 ++++++--- mix.lock | 6 +++--- .../index_custom_fields_accessibility_test.exs | 9 ++++++++- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 487a01f..86090a8 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -36,12 +36,16 @@ defmodule MvWeb.Layouts do default: nil, doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)" + attr :club_name, :string, + default: nil, + doc: "optional club name to pass to navbar" + slot :inner_block, required: true def app(assigns) do ~H""" <%= if @current_user do %> - <.navbar current_user={@current_user} /> + <.navbar current_user={@current_user} club_name={@club_name} /> <% end %>
    diff --git a/lib/mv_web/components/layouts/navbar.ex b/lib/mv_web/components/layouts/navbar.ex index 4246c99..1ff589b 100644 --- a/lib/mv_web/components/layouts/navbar.ex +++ b/lib/mv_web/components/layouts/navbar.ex @@ -12,15 +12,18 @@ defmodule MvWeb.Layouts.Navbar do required: true, doc: "The current user - navbar is only shown when user is present" - def navbar(assigns) do - club_name = get_club_name() + attr :club_name, :string, + default: nil, + doc: "Optional club name - if not provided, will be loaded from database" + def navbar(assigns) do + club_name = assigns[:club_name] || get_club_name() assigns = assign(assigns, :club_name, club_name) ~H"""