feat(overview): add point-in-time, suspended, OR-group and count-range filters

This commit is contained in:
Simon 2026-07-10 16:27:15 +02:00
parent c547ca19af
commit c0d1550401
23 changed files with 1116 additions and 117 deletions

View file

@ -44,6 +44,14 @@ defmodule Mv.Constants do
@payment_filter_param "pay_filter"
@payment_count_min_param "pay_min"
@payment_count_max_param "pay_max"
@suspended_param "suspended"
@stichtag_param "stichtag"
@max_boolean_filters 50
@max_mailto_bulk_recipients 50
@ -200,6 +208,49 @@ defmodule Mv.Constants do
"""
def payment_filter_param, do: @payment_filter_param
@doc """
Returns the URL parameter name for the payment open-cycle count lower bound
(used with `pay_filter=has_unpaid` for the two-sided count range).
## Examples
iex> Mv.Constants.payment_count_min_param()
"pay_min"
"""
def payment_count_min_param, do: @payment_count_min_param
@doc """
Returns the URL parameter name for the payment open-cycle count upper bound.
## Examples
iex> Mv.Constants.payment_count_max_param()
"pay_max"
"""
def payment_count_max_param, do: @payment_count_max_param
@doc """
Returns the URL parameter name for the suspended-status filter flag.
## Examples
iex> Mv.Constants.suspended_param()
"suspended"
"""
def suspended_param, do: @suspended_param
@doc """
Returns the URL parameter name for the point-in-time membership ("Stichtag")
filter: keeps members who were active on the given ISO-8601 date X, i.e.
`join_date <= X and (exit_date is nil or exit_date > X)`.
## Examples
iex> Mv.Constants.stichtag_param()
"stichtag"
"""
def stichtag_param, do: @stichtag_param
@doc """
Returns the maximum number of boolean custom field filters allowed per request.

View file

@ -35,6 +35,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
alias Mv.Constants
alias MvWeb.MemberLive.Index.DateFilter
alias MvWeb.MemberLive.Index.DatePresets
alias MvWeb.MemberLive.Index.FilterDescriptor
alias MvWeb.MemberLive.Index.FilterParams
alias MvWeb.MemberLive.Index.PaymentAging
@ -968,32 +969,30 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
# --- date presets ---------------------------------------------------------
defp date_presets do
[
{"this_year", gettext("This year")},
{"last_year", gettext("Last year")},
{"this_month", gettext("This month")},
{"last_30_days", gettext("Last 30 days")}
]
Enum.map(DatePresets.all(), fn preset -> {Atom.to_string(preset), preset_label(preset)} end)
end
defp preset_range("this_year") do
y = Date.utc_today().year
{Date.new!(y, 1, 1), Date.new!(y, 12, 31)}
end
defp preset_label(:last_7_days), do: gettext("Last 7 days")
defp preset_label(:last_30_days), do: gettext("Last 30 days")
defp preset_label(:last_3_months), do: gettext("Last 3 months")
defp preset_label(:last_12_months), do: gettext("Last 12 months")
defp preset_label(:this_month), do: gettext("This month")
defp preset_label(:this_quarter), do: gettext("This quarter")
defp preset_label(:this_year), do: gettext("This year")
defp preset_range("last_year") do
y = Date.utc_today().year - 1
{Date.new!(y, 1, 1), Date.new!(y, 12, 31)}
end
# Resolves a preset string (from `phx-value-preset`) to a concrete `{from, to}`
# tuple via the shared, clock-free `DatePresets` resolver, or nil for an
# unknown preset. The resolver defines "this year" as period-to-date
# ([Jan 1, today]) per §2.8, superseding the prior full-year range.
defp preset_range(preset) when is_binary(preset) do
case Enum.find(DatePresets.all(), &(Atom.to_string(&1) == preset)) do
nil ->
nil
defp preset_range("this_month") do
today = Date.utc_today()
{Date.beginning_of_month(today), Date.end_of_month(today)}
end
defp preset_range("last_30_days") do
today = Date.utc_today()
{Date.add(today, -30), today}
key ->
%{from: from, to: to} = DatePresets.resolve(key)
{from, to}
end
end
defp preset_range(_), do: nil

View file

@ -59,6 +59,7 @@ defmodule MvWeb.MemberLive.Index do
alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery
alias MvWeb.MemberLive.Index.PaymentAging
alias MvWeb.MemberLive.Index.Stichtag
alias MvWeb.MemberLive.Index.ViewSettings
require Ash.Query
@ -177,6 +178,8 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:payment_filter, nil)
|> assign(:payment_period, PaymentAging.default_period())
|> assign(:suspended, false)
|> assign(:stichtag, nil)
|> assign(:group_filters, %{})
|> assign(:groups, groups)
|> assign(:fee_type_filters, %{})
@ -679,6 +682,8 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_sort(params)
|> maybe_update_payment_filter(params)
|> maybe_update_payment_period(params)
|> maybe_update_suspended(params)
|> maybe_update_stichtag(params)
|> maybe_update_group_filters(params)
|> maybe_update_fee_type_filters(params)
|> maybe_update_boolean_filters(params)
@ -728,6 +733,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_order,
socket.assigns.payment_filter,
socket.assigns.payment_period,
socket.assigns[:suspended],
socket.assigns[:stichtag],
socket.assigns[:group_filters],
socket.assigns[:fee_type_filters],
socket.assigns.boolean_custom_field_filters,
@ -883,10 +890,19 @@ defmodule MvWeb.MemberLive.Index do
base_params = add_group_filters(base_params, opts.group_filters || %{})
base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{})
base_params = add_payment_params(base_params, opts.payment_filter, opts.payment_period)
base_params = add_status_params(base_params, opts[:suspended], opts[:stichtag])
base_params = add_boolean_filters(base_params, opts.boolean_filters || %{})
add_date_filters(base_params, opts.date_filters)
end
# Suspended-status flag and Stichtag date serialize additively into the flat
# URL contract (suspended=1 / stichtag=<iso>), omitted when inactive (§2.1).
defp add_status_params(params, suspended, stichtag) do
params
|> Map.merge(PaymentAging.suspended_to_params(suspended))
|> Map.merge(Stichtag.to_params(stichtag))
end
# Period-scoped payment model (§3.3): the payment-count filter and the active
# period both serialize into the flat URL contract (pay_filter/pay_from/pay_to).
defp add_payment_params(params, payment_filter, payment_period) do
@ -906,6 +922,8 @@ defmodule MvWeb.MemberLive.Index do
sort_order: socket.assigns.sort_order,
payment_filter: socket.assigns.payment_filter,
payment_period: socket.assigns.payment_period,
suspended: socket.assigns[:suspended] || false,
stichtag: socket.assigns[:stichtag],
group_filters: socket.assigns[:group_filters] || %{},
boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
fee_type_filters: socket.assigns[:fee_type_filters] || %{},
@ -1366,6 +1384,8 @@ defmodule MvWeb.MemberLive.Index do
date_custom_fields: socket.assigns[:date_custom_fields],
payment_filter: socket.assigns.payment_filter,
payment_period: socket.assigns.payment_period,
suspended: socket.assigns[:suspended] || false,
stichtag: socket.assigns[:stichtag],
sort_field: socket.assigns.sort_field,
sort_order: socket.assigns.sort_order,
custom_fields: socket.assigns.all_custom_fields
@ -1448,7 +1468,7 @@ defmodule MvWeb.MemberLive.Index do
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field) or
field in [:groups, :membership_fee_type, :name, :address]
field in [:groups, :membership_fee_type, :name, :address, :payment]
end
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
@ -1458,6 +1478,7 @@ defmodule MvWeb.MemberLive.Index do
field == "membership_fee_type" -> :membership_fee_type
field == "name" -> :name
field == "address" -> :address
field == "payment" -> :payment
true -> safe_member_field_atom_only(field)
end
@ -1507,9 +1528,10 @@ defmodule MvWeb.MemberLive.Index do
defp determine_field(default, nil), do: default
# Computed/pseudo fields that are nonetheless sortable (they resolve to DB
# sort keys in OverviewQuery): groups aggregate and the composite Name/Address
# columns. Other computed fields (e.g. membership_fee_status) are not sortable.
@sortable_computed_fields [:groups, :name, :address]
# sort keys in OverviewQuery): groups aggregate, the composite Name/Address
# columns, and the period-scoped payment (unpaid-cycle-count) column. Other
# computed fields (e.g. membership_fee_status) are not sortable.
@sortable_computed_fields [:groups, :name, :address, :payment]
defp determine_field(default, sf) when is_binary(sf) do
sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1)
@ -1578,6 +1600,15 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_payment_period(socket, params),
do: assign(socket, :payment_period, PaymentAging.parse_period(params))
# Suspended-status filter (§1.23) and point-in-time Stichtag (§1.24/§2.6):
# both decoded straight from the URL so `handle_params` remains the single
# source of truth. Absent params fall back to no filter.
defp maybe_update_suspended(socket, params),
do: assign(socket, :suspended, PaymentAging.parse_suspended(params))
defp maybe_update_stichtag(socket, params),
do: assign(socket, :stichtag, Stichtag.parse(params))
defp maybe_update_group_filters(socket, params) when is_map(params) do
prefix = @group_filter_prefix
prefix_len = String.length(prefix)
@ -1836,17 +1867,16 @@ defmodule MvWeb.MemberLive.Index do
all-outstanding default (both bounds nil) reads just "Payment"; a bounded
period appends the range so the scope is always legible.
"""
def payment_column_label(%{from: nil, to: nil}), do: gettext("Fees")
def payment_column_label(_period), do: gettext("Fees")
def payment_column_label(%{from: from, to: to}) do
range = "#{payment_bound(from)}#{payment_bound(to)}"
gettext("Fees · %{range}", range: range)
end
def payment_column_label(_), do: gettext("Fees")
defp payment_bound(%Date{} = d), do: Date.to_iso8601(d)
defp payment_bound(_), do: ""
@doc """
Whether a payment period is actively scoped (at least one bound set), used to
decide whether the compact period indicator badge renders (§1.28). The
all-outstanding default (both bounds nil) shows no badge.
"""
def payment_period_active?(%{from: nil, to: nil}), do: false
def payment_period_active?(%{from: _, to: _}), do: true
def payment_period_active?(_), do: false
defp update_selection_assigns(socket) do
selected_members = socket.assigns.selected_members

View file

@ -456,7 +456,31 @@
<:col
:let={member}
:if={:membership_fee_status in @member_fields_visible}
label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)}
sort_field={:payment}
label={
~H"""
<div class="flex items-center gap-1.5">
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_payment}
field={:payment}
label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)}
sort_hint={gettext("Click to sort by open payment cycles")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
<span
:if={MvWeb.MemberLive.Index.payment_period_active?(@payment_period)}
class="badge badge-soft badge-info badge-sm shrink-0"
title={PaymentAging.period_tooltip(@payment_period)}
aria-label={PaymentAging.period_tooltip(@payment_period)}
data-testid="payment-period-badge"
>
{PaymentAging.period_short_code(@payment_period) || gettext("filtered")}
</span>
</div>
"""
}
>
<% count = member.unpaid_cycle_count || 0 %>
<% badge = PaymentAging.badge(count) %>

View file

@ -0,0 +1,70 @@
defmodule MvWeb.MemberLive.Index.DatePresets do
@moduledoc """
Relative date-range presets for the date value controls (§1.25/§2.8).
Each preset resolves against a reference date "today" into a concrete
`%{from: Date.t(), to: Date.t()}` range. Rolling "last N days/months" presets
end on today; the "this month/quarter/year" presets run period-to-date from
the respective period start up to today. Resolving is pure and clock-free
the caller passes `today` (defaulting to `Date.utc_today/0`), so the resolved
bounds are testable and reproducible.
The resolved bounds feed the same `jd_*` / `ed_*` / `cdf_*` URL params as an
absolute custom range; presets are purely an input convenience and add no new
persistence dimension.
"""
@type preset ::
:last_7_days
| :last_30_days
| :last_3_months
| :last_12_months
| :this_month
| :this_quarter
| :this_year
@type range :: %{from: Date.t(), to: Date.t()}
@presets [
:last_7_days,
:last_30_days,
:last_3_months,
:last_12_months,
:this_month,
:this_quarter,
:this_year
]
@doc """
Returns every preset key in display order (rolling windows first, then the
period-to-date presets), for rendering the preset radio list.
"""
@spec all() :: [preset()]
def all, do: @presets
@doc """
Whether `key` names a known preset.
"""
@spec preset?(term()) :: boolean()
def preset?(key), do: key in @presets
@doc """
Resolves a preset to a concrete `{from, to}` range relative to `today`
(default `Date.utc_today/0`).
"""
@spec resolve(preset(), Date.t()) :: range()
def resolve(preset, today \\ Date.utc_today())
def resolve(:last_7_days, today), do: %{from: Date.add(today, -6), to: today}
def resolve(:last_30_days, today), do: %{from: Date.add(today, -29), to: today}
def resolve(:last_3_months, today), do: %{from: Date.shift(today, month: -3), to: today}
def resolve(:last_12_months, today), do: %{from: Date.shift(today, month: -12), to: today}
def resolve(:this_month, today), do: %{from: %{today | day: 1}, to: today}
def resolve(:this_quarter, today) do
first_month = div(today.month - 1, 3) * 3 + 1
%{from: Date.new!(today.year, first_month, 1), to: today}
end
def resolve(:this_year, today), do: %{from: Date.new!(today.year, 1, 1), to: today}
end

View file

@ -46,9 +46,16 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
opts[:boolean_custom_fields]
)
|> apply_date_filters(opts[:date_filters])
|> apply_stichtag_filter(opts[:stichtag])
|> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
|> apply_payment_filter(opts[:payment_filter], payment_period(opts))
|> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || [])
|> apply_suspended_filter(opts[:suspended], payment_period(opts))
|> apply_sort(
opts[:sort_field],
opts[:sort_order],
opts[:custom_fields] || [],
payment_period(opts)
)
end
# ---------------------------------------------------------------------------
@ -61,7 +68,12 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
defp apply_search(query, _), do: query
# ---------------------------------------------------------------------------
# Group filters (AND across selected groups)
# Group filters (:in OR across selected groups; :not_in AND-of-not-exists)
#
# Multiple "is" selections bundle into a single membership `exists` over the
# union of group ids, so a member matching *any* selected group is kept (OR,
# §1.29/§2.7). Each "is not" selection stays an independent not-exists, so a
# member is excluded when it belongs to *any* of them.
# ---------------------------------------------------------------------------
defp apply_group_filters(query, group_filters, _groups)
@ -71,31 +83,32 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
defp apply_group_filters(query, group_filters, groups) do
valid_ids = valid_id_set(groups)
Enum.reduce(group_filters, query, fn {group_id_str, value}, q ->
if MapSet.member?(valid_ids, group_id_str) do
apply_one_group_filter(q, group_id_str, value)
else
q
{in_filters, not_in_filters} =
group_filters
|> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end)
|> Enum.split_with(fn {_, value} -> value == :in end)
query
|> apply_group_in_filter(Enum.map(in_filters, fn {id_str, _} -> id_str end))
|> apply_group_not_in_filters(Enum.map(not_in_filters, fn {id_str, _} -> id_str end))
end
defp apply_group_in_filter(query, id_strs) do
case cast_uuids(id_strs) do
[] -> query
uuids -> Ash.Query.filter(query, expr(exists(member_groups, group_id in ^uuids)))
end
end
defp apply_group_not_in_filters(query, id_strs) do
Enum.reduce(id_strs, query, fn id_str, q ->
case Ecto.UUID.cast(id_str) do
{:ok, uuid} -> Ash.Query.filter(q, expr(not exists(member_groups, group_id == ^uuid)))
_ -> q
end
end)
end
defp apply_one_group_filter(query, group_id_str, :in) do
case Ecto.UUID.cast(group_id_str) do
{:ok, uuid} -> Ash.Query.filter(query, expr(exists(member_groups, group_id == ^uuid)))
_ -> query
end
end
defp apply_one_group_filter(query, group_id_str, :not_in) do
case Ecto.UUID.cast(group_id_str) do
{:ok, uuid} -> Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^uuid)))
_ -> query
end
end
defp apply_one_group_filter(query, _id, _value), do: query
# ---------------------------------------------------------------------------
# Fee-type filters (:in OR; :not_in AND)
# ---------------------------------------------------------------------------
@ -209,8 +222,53 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
)
end
# Two-sided open-cycle range (§1.22/§3.14): keeps members whose in-period
# unpaid count is within [min, max]. `max` nil means unbounded above, so
# `{:unpaid_range, 1, nil}` is the default "has open" (>= 1). Both bounds push
# to SQL through the same `unpaid_cycle_count` calculation.
defp apply_payment_filter(query, {:unpaid_range, min, max}, %{from: from, to: to})
when is_integer(min) and min > 0 do
query
|> Ash.Query.filter(unpaid_cycle_count(period_from: ^from, period_to: ^to) >= ^min)
|> apply_unpaid_upper_bound(max, from, to)
end
defp apply_payment_filter(query, _filter, _period), do: query
defp apply_unpaid_upper_bound(query, max, from, to) when is_integer(max) and max > 0 do
Ash.Query.filter(
query,
expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) <= ^max)
)
end
defp apply_unpaid_upper_bound(query, _max, _from, _to), do: query
# Suspended-status filter (§1.23): keeps members with at least one suspended
# cycle whose cycle_end falls in the active period. Independent of the unpaid
# count, which excludes suspended (§2.5). A correlated `exists` pushes to SQL;
# the period bounds are composed conditionally so an unbounded (nil) side adds
# no `cycle_end` comparison rather than comparing against a literal nil.
defp apply_suspended_filter(query, true, %{from: from, to: to}) do
Ash.Query.filter(
query,
expr(exists(membership_fee_cycles, ^suspended_period_predicate(from, to)))
)
end
defp apply_suspended_filter(query, _suspended, _period), do: query
defp suspended_period_predicate(nil, nil), do: expr(status == :suspended)
defp suspended_period_predicate(from, nil),
do: expr(status == :suspended and cycle_end >= ^from)
defp suspended_period_predicate(nil, to),
do: expr(status == :suspended and cycle_end <= ^to)
defp suspended_period_predicate(from, to),
do: expr(status == :suspended and cycle_end >= ^from and cycle_end <= ^to)
# ---------------------------------------------------------------------------
# Built-in date filters (join/exit)
# ---------------------------------------------------------------------------
@ -226,21 +284,41 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
defp apply_custom_date_filters(query, _filters, _custom_fields), do: query
# ---------------------------------------------------------------------------
# Point-in-time membership ("Stichtag"): a member counts as a member on the
# reference date X iff it had joined by X and had not yet left as of X
# (§1.24/§2.6). "Left as of X" means a non-nil exit_date at or before X, so an
# exit_date strictly after X (or nil) still counts as a member on X.
# ---------------------------------------------------------------------------
defp apply_stichtag_filter(query, %Date{} = x) do
Ash.Query.filter(
query,
expr(join_date <= ^x and (is_nil(exit_date) or exit_date > ^x))
)
end
defp apply_stichtag_filter(query, _), do: query
# ---------------------------------------------------------------------------
# Sort (always append the unique id tie-breaker for keyset stability)
# ---------------------------------------------------------------------------
defp apply_sort(query, nil, _order, _custom_fields), do: Ash.Query.sort(query, id: :asc)
defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc)
defp apply_sort(query, nil, _order, _custom_fields, _period),
do: Ash.Query.sort(query, id: :asc)
defp apply_sort(query, _field, nil, _custom_fields, _period),
do: Ash.Query.sort(query, id: :asc)
# Composite "Member" column: sort by last name then first name. The email that
# shares the cell is display-only and does not affect the sort key (§1.21).
defp apply_sort(query, field, order, _custom_fields) when field in [:name, "name"] do
defp apply_sort(query, field, order, _custom_fields, _period) when field in [:name, "name"] do
Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}])
end
# Composite "Address" column: sort by city, then postal code, then street.
defp apply_sort(query, field, order, _custom_fields) when field in [:address, "address"] do
defp apply_sort(query, field, order, _custom_fields, _period)
when field in [:address, "address"] do
Ash.Query.sort(query, [
{:city, order},
{:postal_code, order},
@ -249,7 +327,20 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
])
end
defp apply_sort(query, field, order, custom_fields) do
# "Beiträge" (payment) column: sort DB-side by the period-scoped unpaid-cycle
# count (§1.27). The count calculation takes the active period as arguments so
# the sort key matches the displayed value; the id tie-breaker keeps keyset
# pages stable across equal counts.
defp apply_sort(query, field, order, _custom_fields, %{from: from, to: to})
when field in [:payment, "payment"] do
Ash.Query.sort(query, [
{Ash.Sort.expr_sort(unpaid_cycle_count(period_from: ^from, period_to: ^to), :integer),
order},
{:id, :asc}
])
end
defp apply_sort(query, field, order, custom_fields, _period) do
case sort_key(field) do
nil -> apply_custom_field_sort(query, field, order, custom_fields)
key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}])

View file

@ -29,11 +29,18 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
@type period :: %{from: Date.t() | nil, to: Date.t() | nil}
@type filter :: nil | :fully_paid | {:has_unpaid, 1..3}
@type filter ::
nil
| :fully_paid
| {:has_unpaid, 1..3}
| {:unpaid_range, pos_integer(), pos_integer() | nil}
@payment_period_from_param Constants.payment_period_from_param()
@payment_period_to_param Constants.payment_period_to_param()
@payment_filter_param Constants.payment_filter_param()
@payment_count_min_param Constants.payment_count_min_param()
@payment_count_max_param Constants.payment_count_max_param()
@suspended_param Constants.suspended_param()
@doc """
The default period: all outstanding cycles, all time (both bounds nil).
@ -83,6 +90,10 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
@doc """
Encodes a payment-count filter into URL params. `nil` yields the empty map.
The two-sided `{:unpaid_range, min, max}` form encodes as
`pay_filter=has_unpaid` plus `pay_min` (and `pay_max` when bounded above),
so a fresh "has open" default (`min=1`, `max=nil`) round-trips canonically.
"""
@spec filter_to_params(filter()) :: %{optional(String.t()) => String.t()}
def filter_to_params(:fully_paid), do: %{@payment_filter_param => "fully_paid"}
@ -90,14 +101,61 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
def filter_to_params({:has_unpaid, n}) when n in 1..3,
do: %{@payment_filter_param => "unpaid_#{n}"}
def filter_to_params({:unpaid_range, min, max}) when is_integer(min) and min > 0 do
%{@payment_filter_param => "has_unpaid", @payment_count_min_param => Integer.to_string(min)}
|> maybe_put_max(max)
end
def filter_to_params(_), do: %{}
defp maybe_put_max(params, max) when is_integer(max) and max > 0,
do: Map.put(params, @payment_count_max_param, Integer.to_string(max))
defp maybe_put_max(params, _max), do: params
@doc """
Decodes the payment-count filter from a full params map.
A `pay_filter=has_unpaid` value reads the `pay_min`/`pay_max` bounds into a
`{:unpaid_range, min, max}`; an absent/invalid `pay_min` defaults to 1 and an
absent/invalid `pay_max` to nil (unbounded). Other `pay_filter` values fall
back to the single-token decode (`fully_paid` / `unpaid_N`).
"""
@spec parse_filter_params(map()) :: filter()
def parse_filter_params(params) when is_map(params),
do: parse_filter(Map.get(params, @payment_filter_param))
def parse_filter_params(params) when is_map(params) do
case Map.get(params, @payment_filter_param) do
"has_unpaid" ->
{:unpaid_range, parse_count(Map.get(params, @payment_count_min_param), 1),
parse_count(Map.get(params, @payment_count_max_param), nil)}
other ->
parse_filter(other)
end
end
defp parse_count(value, default) when is_binary(value) do
case Integer.parse(String.trim(value)) do
{n, ""} when n > 0 -> n
_ -> default
end
end
defp parse_count(_value, default), do: default
@doc """
Decodes the suspended-status filter flag (§1.23) from a params map. The flag
is present (`suspended=1`) only when active; any other value reads as false.
"""
@spec parse_suspended(map()) :: boolean()
def parse_suspended(params) when is_map(params), do: Map.get(params, @suspended_param) == "1"
@doc """
Encodes the suspended-status filter flag into URL params. Only `true` emits a
param (`suspended=1`); false/nil yield the empty map so a fresh URL is canonical.
"""
@spec suspended_to_params(boolean() | nil) :: %{optional(String.t()) => String.t()}
def suspended_to_params(true), do: %{@suspended_param => "1"}
def suspended_to_params(_), do: %{}
@doc """
Badge descriptor for an unpaid-cycle count. A count of 0 reads "Alle bezahlt"
@ -132,6 +190,53 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
}
end
@doc """
Compact short code for a payment period (§1.28), or nil when the period is not
compactly codeable (the header then falls back to a plain "filtered" badge).
* `{nil, nil}` (all-outstanding default) nil (no badge)
* a full calendar year (Jan 1 Dec 31) `"2026"`
* a full calendar quarter `"Q1 2026"`
* anything else (arbitrary or open-ended range) nil
"""
@spec period_short_code(period()) :: String.t() | nil
def period_short_code(%{from: %Date{} = from, to: %Date{} = to}) do
cond do
full_year?(from, to) -> Integer.to_string(from.year)
full_quarter?(from, to) -> "Q#{quarter(from.month)} #{from.year}"
true -> nil
end
end
def period_short_code(_), do: nil
@doc """
Human-readable tooltip naming the active payment period with locally formatted
(`dd.MM.yyyy`) bounds (§1.28). The all-outstanding default names all
outstanding cycles; open-ended periods render an open bound as an ellipsis.
"""
@spec period_tooltip(period()) :: String.t()
def period_tooltip(%{from: nil, to: nil}), do: gettext("Fees across all outstanding cycles")
def period_tooltip(%{from: from, to: to}) do
gettext("Fees in period %{from}%{to}", from: period_bound(from), to: period_bound(to))
end
def period_tooltip(_), do: gettext("Fees across all outstanding cycles")
defp period_bound(%Date{} = d), do: DateFormatter.format_date(d)
defp period_bound(_), do: ""
defp full_year?(from, to),
do:
from.month == 1 and from.day == 1 and to.year == from.year and to.month == 12 and
to.day == 31
defp full_quarter?(from, to) do
from.day == 1 and from.month in [1, 4, 7, 10] and to.year == from.year and
to == Date.end_of_month(Date.new!(from.year, from.month + 2, 1))
end
@doc """
A member's open cycles for the badge tooltip: the unpaid and suspended cycles
whose `cycle_end` falls inside `period`, merged into a single list sorted

View file

@ -0,0 +1,36 @@
defmodule MvWeb.MemberLive.Index.Stichtag do
@moduledoc """
URL codec for the point-in-time membership ("Stichtag") filter (§1.24/§2.6).
The Stichtag is a single ISO-8601 date X carried in the flat URL contract
under the `stichtag` key. When set, the overview keeps members who were active
on X (`join_date <= X and (exit_date is nil or exit_date > X)`); the predicate
itself lives in `OverviewQuery`. Absent or malformed values decode to nil (no
filter), and nil encodes to the empty map so a fresh URL stays canonical.
"""
@stichtag_param Mv.Constants.stichtag_param()
@doc """
Decodes the Stichtag date from a params map. Missing or malformed ISO-8601
values yield nil.
"""
@spec parse(map()) :: Date.t() | nil
def parse(params) when is_map(params), do: parse_date(Map.get(params, @stichtag_param))
@doc """
Encodes a Stichtag date into URL params. nil yields the empty map.
"""
@spec to_params(Date.t() | nil) :: %{optional(String.t()) => String.t()}
def to_params(%Date{} = date), do: %{@stichtag_param => Date.to_iso8601(date)}
def to_params(_), do: %{}
defp parse_date(value) when is_binary(value) do
case Date.from_iso8601(String.trim(value)) do
{:ok, date} -> date
_ -> nil
end
end
defp parse_date(_), do: nil
end