refactor(overview): use English identifier as_of_date for point-in-time filter

This commit is contained in:
Simon 2026-07-10 16:27:16 +02:00
parent ce877463d6
commit 0acd12360f
11 changed files with 113 additions and 112 deletions

View file

@ -50,7 +50,7 @@ defmodule Mv.Constants do
@suspended_param "suspended" @suspended_param "suspended"
@stichtag_param "stichtag" @as_of_date_param "as_of_date"
@max_boolean_filters 50 @max_boolean_filters 50
@ -240,16 +240,16 @@ defmodule Mv.Constants do
def suspended_param, do: @suspended_param def suspended_param, do: @suspended_param
@doc """ @doc """
Returns the URL parameter name for the point-in-time membership ("Stichtag") Returns the URL parameter name for the point-in-time "active on X" membership
filter: keeps members who were active on the given ISO-8601 date X, i.e. 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)`. `join_date <= X and (exit_date is nil or exit_date > X)`.
## Examples ## Examples
iex> Mv.Constants.stichtag_param() iex> Mv.Constants.as_of_date_param()
"stichtag" "as_of_date"
""" """
def stichtag_param, do: @stichtag_param def as_of_date_param, do: @as_of_date_param
@doc """ @doc """
Returns the maximum number of boolean custom field filters allowed per request. Returns the maximum number of boolean custom field filters allowed per request.

View file

@ -35,7 +35,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
* `{:payment_filter_changed, nil | :fully_paid | {:has_unpaid, 1..3} | {:unpaid_range, pos_integer(), pos_integer() | nil}}` * `{:payment_filter_changed, nil | :fully_paid | {:has_unpaid, 1..3} | {:unpaid_range, pos_integer(), pos_integer() | nil}}`
* `{:payment_period_changed, %{from: Date.t() | nil, to: Date.t() | nil}}` * `{:payment_period_changed, %{from: Date.t() | nil, to: Date.t() | nil}}`
* `{:suspended_changed, boolean()}` * `{:suspended_changed, boolean()}`
* `{:stichtag_changed, Date.t() | nil}` * `{:as_of_date_changed, Date.t() | nil}`
and `{:clear_all_filters}` for Clear all. and `{:clear_all_filters}` for Clear all.
""" """
@ -91,7 +91,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
|> assign(:payment_filter, assigns[:payment_filter]) |> assign(:payment_filter, assigns[:payment_filter])
|> assign(:payment_period, assigns[:payment_period] || PaymentAging.default_period()) |> assign(:payment_period, assigns[:payment_period] || PaymentAging.default_period())
|> assign(:suspended, assigns[:suspended] || false) |> assign(:suspended, assigns[:suspended] || false)
|> assign(:stichtag, assigns[:stichtag]) |> assign(:as_of_date, assigns[:as_of_date])
end end
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@ -461,23 +461,23 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
</fieldset> </fieldset>
<%!-- Separate paragraph (not part of the Active/Former button-join): the <%!-- Separate paragraph (not part of the Active/Former button-join): the
point-in-time "active on X" Stichtag filter (§1.24). --%> point-in-time "active on X" as-of-date filter (§1.24). --%>
<div class="flex flex-col gap-1" data-testid="stichtag-paragraph"> <div class="flex flex-col gap-1" data-testid="as-of-date-paragraph">
<.input <.input
type="date" type="date"
name="stichtag" name="as_of_date"
label={gettext("Active on reference date")} label={gettext("Active on reference date")}
class="input input-sm input-bordered" class="input input-sm input-bordered"
value={date_iso(@stichtag)} value={date_iso(@as_of_date)}
data-testid="stichtag-input" data-testid="as-of-date-input"
/> />
</div> </div>
</form> </form>
<%!-- Third paragraph "Eigener Zeitraum": the same native From/To range the <%!-- Third paragraph, a custom range: the same native From/To range the
join-date builder uses, wired to the exit_date custom mode members whose join-date builder uses, wired to the exit_date custom mode members whose
exit date falls within [from, to] (§1.24). A sibling form (not the exit date falls within [from, to] (§1.24). A sibling form (not the
quick/Stichtag one) so committing a bound only touches the exit_date quick/as-of-date one) so committing a bound only touches the exit_date
slice and never re-reads the quick radios. --%> slice and never re-reads the quick radios. --%>
<form <form
phx-change="commit" phx-change="commit"
@ -614,7 +614,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
{local_range(@from_val, @to_val)} {local_range(@from_val, @to_val)}
</p> </p>
<%!-- Separate "Eigener Zeitraum" paragraph: two native <input type="date"> <%!-- Separate custom-range paragraph: two native <input type="date">
controls side by side (§3.12 native-first no cally). Committing a bound controls side by side (§3.12 native-first no cally). Committing a bound
through the shared date dispatch applies the custom range; the browser's through the shared date dispatch applies the custom range; the browser's
own date popup gives year-jump, manual entry and WCAG a11y for free. --%> own date popup gives year-jump, manual entry and WCAG a11y for free. --%>
@ -722,7 +722,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
def handle_event("commit", params, socket) do def handle_event("commit", params, socket) do
dispatch_payment(socket, params) dispatch_payment(socket, params)
dispatch_quick(socket, params) dispatch_quick(socket, params)
dispatch_stichtag(socket, params) dispatch_as_of_date(socket, params)
dispatch_exit_range(socket, params) dispatch_exit_range(socket, params)
dispatch_prefix( dispatch_prefix(
@ -824,14 +824,14 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
end end
end end
defp dispatch_stichtag(socket, params) do defp dispatch_as_of_date(socket, params) do
if Map.has_key?(params, "stichtag") do if Map.has_key?(params, "as_of_date") do
date = parse_date(Map.get(params, "stichtag")) date = parse_date(Map.get(params, "as_of_date"))
maybe_send(date != socket.assigns.stichtag, {:stichtag_changed, date}) maybe_send(date != socket.assigns.as_of_date, {:as_of_date_changed, date})
end end
end end
# The exit-date "Eigener Zeitraum" range writes only the exit_date slice # The exit-date custom range writes only the exit_date slice
# (mode :custom + bounds), preserving join_date and custom-date entries — so # (mode :custom + bounds), preserving join_date and custom-date entries — so
# editing the exit range never wipes another active date filter. Committing an # editing the exit range never wipes another active date filter. Committing an
# empty range simply parks exit_date in :custom with nil bounds (no chip, no # empty range simply parks exit_date in :custom with nil bounds (no chip, no
@ -917,7 +917,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
end end
defp remove_chip("suspended", _id, _socket), do: send(self(), {:suspended_changed, false}) defp remove_chip("suspended", _id, _socket), do: send(self(), {:suspended_changed, false})
defp remove_chip("stichtag", _id, _socket), do: send(self(), {:stichtag_changed, nil}) defp remove_chip("as_of_date", _id, _socket), do: send(self(), {:as_of_date_changed, nil})
defp remove_chip("group", id, _socket), do: send(self(), {:group_filter_changed, id, nil}) defp remove_chip("group", id, _socket), do: send(self(), {:group_filter_changed, id, nil})
defp remove_chip("fee_type", id, _socket), do: send(self(), {:fee_type_filter_changed, id, nil}) defp remove_chip("fee_type", id, _socket), do: send(self(), {:fee_type_filter_changed, id, nil})
@ -947,7 +947,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
fee_type_chips(assigns) ++ fee_type_chips(assigns) ++
boolean_chips(assigns) ++ boolean_chips(assigns) ++
date_chips(assigns) ++ date_chips(assigns) ++
stichtag_chip(assigns) ++ as_of_date_chip(assigns) ++
payment_chips(assigns) ++ payment_chips(assigns) ++
suspended_chip(assigns) suspended_chip(assigns)
end end
@ -1117,18 +1117,18 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
defp suspended_chip(_), do: [] defp suspended_chip(_), do: []
defp stichtag_chip(%{stichtag: %Date{} = date}) do defp as_of_date_chip(%{as_of_date: %Date{} = date}) do
[ [
%{ %{
kind: "stichtag", kind: "as_of_date",
id: "stichtag", id: "as_of_date",
edit_key: "active_former", edit_key: "active_former",
label: gettext("Active on %{date}", date: DateFormatter.format_date(date)) label: gettext("Active on %{date}", date: DateFormatter.format_date(date))
} }
] ]
end end
defp stichtag_chip(_), do: [] defp as_of_date_chip(_), do: []
defp unpaid_range_label(min, nil) when min <= 1, do: gettext("Open contributions") defp unpaid_range_label(min, nil) when min <= 1, do: gettext("Open contributions")
defp unpaid_range_label(min, nil), do: gettext("≥ %{n} unpaid", n: min) defp unpaid_range_label(min, nil), do: gettext("≥ %{n} unpaid", n: min)
@ -1235,7 +1235,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
defp date_iso(_), do: nil defp date_iso(_), do: nil
# ISO string for a bound of the exit_date custom range, pre-filling the # ISO string for a bound of the exit_date custom range, pre-filling the
# "Eigener Zeitraum" inputs only when the exit filter is actually in :custom # custom-range inputs only when the exit filter is actually in :custom
# mode (the quick active/former/all states carry no user range). # mode (the quick active/former/all states carry no user range).
defp exit_bound_iso(filters, bound) do defp exit_bound_iso(filters, bound) do
case Map.get(filters, :exit_date, %{}) do case Map.get(filters, :exit_date, %{}) do

View file

@ -49,6 +49,7 @@ defmodule MvWeb.MemberLive.Index do
alias Mv.MembershipFees alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.DateFormatter alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.AsOfDate
alias MvWeb.MemberLive.Index.CustomFieldValueLookup alias MvWeb.MemberLive.Index.CustomFieldValueLookup
alias MvWeb.MemberLive.Index.DateFilter alias MvWeb.MemberLive.Index.DateFilter
alias MvWeb.MemberLive.Index.ExportPayload alias MvWeb.MemberLive.Index.ExportPayload
@ -59,7 +60,6 @@ defmodule MvWeb.MemberLive.Index do
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery alias MvWeb.MemberLive.Index.OverviewQuery
alias MvWeb.MemberLive.Index.PaymentAging alias MvWeb.MemberLive.Index.PaymentAging
alias MvWeb.MemberLive.Index.Stichtag
alias MvWeb.MemberLive.Index.ViewSettings alias MvWeb.MemberLive.Index.ViewSettings
require Ash.Query require Ash.Query
@ -179,7 +179,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:payment_filter, nil) |> assign(:payment_filter, nil)
|> assign(:payment_period, PaymentAging.default_period()) |> assign(:payment_period, PaymentAging.default_period())
|> assign(:suspended, false) |> assign(:suspended, false)
|> assign(:stichtag, nil) |> assign(:as_of_date, nil)
|> assign(:group_filters, %{}) |> assign(:group_filters, %{})
|> assign(:groups, groups) |> assign(:groups, groups)
|> assign(:fee_type_filters, %{}) |> assign(:fee_type_filters, %{})
@ -473,7 +473,7 @@ defmodule MvWeb.MemberLive.Index do
payment_filter: nil, payment_filter: nil,
payment_period: PaymentAging.default_period(), payment_period: PaymentAging.default_period(),
suspended: false, suspended: false,
stichtag: nil, as_of_date: nil,
group_filters: %{}, group_filters: %{},
fee_type_filters: %{}, fee_type_filters: %{},
boolean_filters: %{}, boolean_filters: %{},
@ -504,16 +504,16 @@ defmodule MvWeb.MemberLive.Index do
end end
@impl true @impl true
def handle_info({:stichtag_changed, stichtag}, socket) do def handle_info({:as_of_date_changed, as_of_date}, socket) do
socket = socket =
socket socket
|> assign(:stichtag, stichtag) |> assign(:as_of_date, as_of_date)
|> load_members() |> load_members()
|> scroll_list_to_top() |> scroll_list_to_top()
|> update_selection_assigns() |> update_selection_assigns()
query_params = query_params =
build_query_params(opts_for_query_params(socket, %{stichtag: stichtag})) build_query_params(opts_for_query_params(socket, %{as_of_date: as_of_date}))
|> maybe_add_field_selection( |> maybe_add_field_selection(
socket.assigns[:user_field_selection], socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false socket.assigns[:fields_in_url?] || false
@ -638,7 +638,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{})) |> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default())) |> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|> assign(:suspended, Map.get(opts, :suspended, false)) |> assign(:suspended, Map.get(opts, :suspended, false))
|> assign(:stichtag, Map.get(opts, :stichtag)) |> assign(:as_of_date, Map.get(opts, :as_of_date))
|> load_members() |> load_members()
|> scroll_list_to_top() |> scroll_list_to_top()
|> update_selection_assigns() |> update_selection_assigns()
@ -727,7 +727,7 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_payment_filter(params) |> maybe_update_payment_filter(params)
|> maybe_update_payment_period(params) |> maybe_update_payment_period(params)
|> maybe_update_suspended(params) |> maybe_update_suspended(params)
|> maybe_update_stichtag(params) |> maybe_update_as_of_date(params)
|> maybe_update_group_filters(params) |> maybe_update_group_filters(params)
|> maybe_update_fee_type_filters(params) |> maybe_update_fee_type_filters(params)
|> maybe_update_boolean_filters(params) |> maybe_update_boolean_filters(params)
@ -778,7 +778,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.payment_filter, socket.assigns.payment_filter,
socket.assigns.payment_period, socket.assigns.payment_period,
socket.assigns[:suspended], socket.assigns[:suspended],
socket.assigns[:stichtag], socket.assigns[:as_of_date],
socket.assigns[:group_filters], socket.assigns[:group_filters],
socket.assigns[:fee_type_filters], socket.assigns[:fee_type_filters],
socket.assigns.boolean_custom_field_filters, socket.assigns.boolean_custom_field_filters,
@ -934,17 +934,17 @@ defmodule MvWeb.MemberLive.Index do
base_params = add_group_filters(base_params, opts.group_filters || %{}) 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_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_payment_params(base_params, opts.payment_filter, opts.payment_period)
base_params = add_status_params(base_params, opts[:suspended], opts[:stichtag]) base_params = add_status_params(base_params, opts[:suspended], opts[:as_of_date])
base_params = add_boolean_filters(base_params, opts.boolean_filters || %{}) base_params = add_boolean_filters(base_params, opts.boolean_filters || %{})
add_date_filters(base_params, opts.date_filters) add_date_filters(base_params, opts.date_filters)
end end
# Suspended-status flag and Stichtag date serialize additively into the flat # Suspended-status flag and as-of date serialize additively into the flat
# URL contract (suspended=1 / stichtag=<iso>), omitted when inactive (§2.1). # URL contract (suspended=1 / as_of_date=<iso>), omitted when inactive (§2.1).
defp add_status_params(params, suspended, stichtag) do defp add_status_params(params, suspended, as_of_date) do
params params
|> Map.merge(PaymentAging.suspended_to_params(suspended)) |> Map.merge(PaymentAging.suspended_to_params(suspended))
|> Map.merge(Stichtag.to_params(stichtag)) |> Map.merge(AsOfDate.to_params(as_of_date))
end end
# Period-scoped payment model (§3.3): the payment-count filter and the active # Period-scoped payment model (§3.3): the payment-count filter and the active
@ -967,7 +967,7 @@ defmodule MvWeb.MemberLive.Index do
payment_filter: socket.assigns.payment_filter, payment_filter: socket.assigns.payment_filter,
payment_period: socket.assigns.payment_period, payment_period: socket.assigns.payment_period,
suspended: socket.assigns[:suspended] || false, suspended: socket.assigns[:suspended] || false,
stichtag: socket.assigns[:stichtag], as_of_date: socket.assigns[:as_of_date],
group_filters: socket.assigns[:group_filters] || %{}, group_filters: socket.assigns[:group_filters] || %{},
boolean_filters: socket.assigns.boolean_custom_field_filters || %{}, boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
fee_type_filters: socket.assigns[:fee_type_filters] || %{}, fee_type_filters: socket.assigns[:fee_type_filters] || %{},
@ -1429,7 +1429,7 @@ defmodule MvWeb.MemberLive.Index do
payment_filter: socket.assigns.payment_filter, payment_filter: socket.assigns.payment_filter,
payment_period: socket.assigns.payment_period, payment_period: socket.assigns.payment_period,
suspended: socket.assigns[:suspended] || false, suspended: socket.assigns[:suspended] || false,
stichtag: socket.assigns[:stichtag], as_of_date: socket.assigns[:as_of_date],
sort_field: socket.assigns.sort_field, sort_field: socket.assigns.sort_field,
sort_order: socket.assigns.sort_order, sort_order: socket.assigns.sort_order,
custom_fields: socket.assigns.all_custom_fields custom_fields: socket.assigns.all_custom_fields
@ -1644,14 +1644,14 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_payment_period(socket, params), defp maybe_update_payment_period(socket, params),
do: assign(socket, :payment_period, PaymentAging.parse_period(params)) do: assign(socket, :payment_period, PaymentAging.parse_period(params))
# Suspended-status filter (§1.23) and point-in-time Stichtag (§1.24/§2.6): # Suspended-status filter (§1.23) and point-in-time as-of date (§1.24/§2.6):
# both decoded straight from the URL so `handle_params` remains the single # both decoded straight from the URL so `handle_params` remains the single
# source of truth. Absent params fall back to no filter. # source of truth. Absent params fall back to no filter.
defp maybe_update_suspended(socket, params), defp maybe_update_suspended(socket, params),
do: assign(socket, :suspended, PaymentAging.parse_suspended(params)) do: assign(socket, :suspended, PaymentAging.parse_suspended(params))
defp maybe_update_stichtag(socket, params), defp maybe_update_as_of_date(socket, params),
do: assign(socket, :stichtag, Stichtag.parse(params)) do: assign(socket, :as_of_date, AsOfDate.parse(params))
defp maybe_update_group_filters(socket, params) when is_map(params) do defp maybe_update_group_filters(socket, params) when is_map(params) do
prefix = @group_filter_prefix prefix = @group_filter_prefix

View file

@ -64,7 +64,7 @@
payment_filter={@payment_filter} payment_filter={@payment_filter}
payment_period={@payment_period} payment_period={@payment_period}
suspended={@suspended} suspended={@suspended}
stichtag={@stichtag} as_of_date={@as_of_date}
/> />
</div> </div>

View file

@ -0,0 +1,37 @@
defmodule MvWeb.MemberLive.Index.AsOfDate do
@moduledoc """
URL codec for the point-in-time "active on X" membership filter (§1.24/§2.6).
The as-of date is a single ISO-8601 date X carried in the flat URL contract
under the `as_of_date` 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.
"""
@as_of_date_param Mv.Constants.as_of_date_param()
@doc """
Decodes the as-of 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, @as_of_date_param))
@doc """
Encodes an as-of 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: %{@as_of_date_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

View file

@ -46,7 +46,7 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
opts[:boolean_custom_fields] opts[:boolean_custom_fields]
) )
|> apply_date_filters(opts[:date_filters]) |> apply_date_filters(opts[:date_filters])
|> apply_stichtag_filter(opts[:stichtag]) |> apply_as_of_date_filter(opts[:as_of_date])
|> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields]) |> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
|> apply_payment_filter(opts[:payment_filter], payment_period(opts)) |> apply_payment_filter(opts[:payment_filter], payment_period(opts))
|> apply_suspended_filter(opts[:suspended], payment_period(opts)) |> apply_suspended_filter(opts[:suspended], payment_period(opts))
@ -285,20 +285,20 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
defp apply_custom_date_filters(query, _filters, _custom_fields), do: query defp apply_custom_date_filters(query, _filters, _custom_fields), do: query
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Point-in-time membership ("Stichtag"): a member counts as a member on the # Point-in-time membership (as-of date): 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 # 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 # (§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. # exit_date strictly after X (or nil) still counts as a member on X.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
defp apply_stichtag_filter(query, %Date{} = x) do defp apply_as_of_date_filter(query, %Date{} = x) do
Ash.Query.filter( Ash.Query.filter(
query, query,
expr(join_date <= ^x and (is_nil(exit_date) or exit_date > ^x)) expr(join_date <= ^x and (is_nil(exit_date) or exit_date > ^x))
) )
end end
defp apply_stichtag_filter(query, _), do: query defp apply_as_of_date_filter(query, _), do: query
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Sort (always append the unique id tie-breaker for keyset stability) # Sort (always append the unique id tie-breaker for keyset stability)
@ -327,7 +327,7 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
]) ])
end end
# "Beiträge" (payment) column: sort DB-side by the period-scoped unpaid-cycle # Fees (payment) column: sort DB-side by the period-scoped unpaid-cycle
# count (§1.27). The count calculation takes the active period as arguments so # 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 # the sort key matches the displayed value; the id tie-breaker keeps keyset
# pages stable across equal counts. # pages stable across equal counts.

View file

@ -1,36 +0,0 @@
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

View file

@ -62,16 +62,16 @@ defmodule MvWeb.MemberLive.IndexActiveFormerTest do
assert has_element?(view, "#row-#{ctx.former.id}") assert has_element?(view, "#row-#{ctx.former.id}")
end end
describe "Stichtag paragraph (§1.24)" do describe "as-of-date paragraph (§1.24)" do
test "the active/former control carries a separate 'active on reference date' input", ctx do test "the active/former control carries a separate 'active on reference date' input", ctx do
{:ok, view, _html} = live(ctx.conn, ~p"/members") {:ok, view, _html} = live(ctx.conn, ~p"/members")
pick_field(view, "active_former") pick_field(view, "active_former")
# The Stichtag input sits in its own paragraph, not inside the # The as-of-date input sits in its own paragraph, not inside the
# Active/Former button-join. # Active/Former button-join.
assert has_element?(view, "[data-testid='stichtag-paragraph'] input[name='stichtag']") assert has_element?(view, "[data-testid='as-of-date-paragraph'] input[name='as_of_date']")
refute has_element?(view, ".join input[name='stichtag']") refute has_element?(view, ".join input[name='as_of_date']")
end end
test "setting a reference date activates the point-in-time membership filter", ctx do test "setting a reference date activates the point-in-time membership filter", ctx do
@ -81,22 +81,22 @@ defmodule MvWeb.MemberLive.IndexActiveFormerTest do
view view
|> element("[data-testid='value-control-active-former']") |> element("[data-testid='value-control-active-former']")
|> render_change(%{"quick" => "active", "stichtag" => "2024-06-15"}) |> render_change(%{"quick" => "active", "as_of_date" => "2024-06-15"})
_ = render(view) _ = render(view)
path = assert_patch(view) path = assert_patch(view)
assert path =~ "stichtag=2024-06-15" assert path =~ "as_of_date=2024-06-15"
end end
end end
describe "Eigener Zeitraum exit-date range (§1.24)" do describe "custom exit-date range (§1.24)" do
test "the exit-date control carries a separate native From/To range paragraph", ctx do test "the exit-date control carries a separate native From/To range paragraph", ctx do
{:ok, view, _html} = live(ctx.conn, ~p"/members") {:ok, view, _html} = live(ctx.conn, ~p"/members")
pick_field(view, "active_former") pick_field(view, "active_former")
# Third paragraph, same native two-input range control the join-date # Third paragraph, same native two-input range control the join-date
# builder uses — separate from the Active/Former join and the Stichtag box. # builder uses — separate from the Active/Former join and the as-of-date box.
assert has_element?(view, "[data-testid='value-control-exit-range']") assert has_element?(view, "[data-testid='value-control-exit-range']")
assert has_element?( assert has_element?(

View file

@ -1,8 +1,8 @@
defmodule MvWeb.MemberLive.IndexStichtagPropertyTest do defmodule MvWeb.MemberLive.IndexAsOfDatePropertyTest do
@moduledoc """ @moduledoc """
§2.6 Point-in-time membership ("member on the reference date X"). §2.6 Point-in-time membership ("member on the reference date X").
For arbitrary (join_date, exit_date) pairs and reference dates X, the Stichtag For arbitrary (join_date, exit_date) pairs and reference dates X, the as-of-date
filter keeps a member iff `join_date <= X and (exit_date is nil or exit_date > X)`. filter keeps a member iff `join_date <= X and (exit_date is nil or exit_date > X)`.
Verified at the `OverviewQuery` layer against an in-memory oracle. Verified at the `OverviewQuery` layer against an in-memory oracle.
""" """
@ -42,7 +42,7 @@ defmodule MvWeb.MemberLive.IndexStichtagPropertyTest do
end) end)
end end
property "Stichtag filter keeps exactly the members active on X" do property "as-of-date filter keeps exactly the members active on X" do
actor = Mv.Helpers.SystemActor.get_system_actor() actor = Mv.Helpers.SystemActor.get_system_actor()
check all( check all(
@ -67,7 +67,7 @@ defmodule MvWeb.MemberLive.IndexStichtagPropertyTest do
do: id do: id
actual = actual =
%{stichtag: x} %{as_of_date: x}
|> OverviewQuery.build() |> OverviewQuery.build()
|> Ash.read!(actor: actor) |> Ash.read!(actor: actor)
|> MapSet.new(& &1.id) |> MapSet.new(& &1.id)

View file

@ -1,7 +1,7 @@
defmodule MvWeb.MemberLive.IndexFilterUrlRoundtripTest do defmodule MvWeb.MemberLive.IndexFilterUrlRoundtripTest do
@moduledoc """ @moduledoc """
§2.1 (extended) the v2 filter keys survive the LiveView decodeencode round §2.1 (extended) the v2 filter keys survive the LiveView decodeencode round
trip. Loading `/members` with a Stichtag date, the suspended flag, and a trip. Loading `/members` with an as-of date, the suspended flag, and a
two-sided payment count range, then triggering an unrelated re-patch (a sort two-sided payment count range, then triggering an unrelated re-patch (a sort
click), preserves every one of those keys in the pushed URL. click), preserves every one of those keys in the pushed URL.
""" """
@ -21,15 +21,15 @@ defmodule MvWeb.MemberLive.IndexFilterUrlRoundtripTest do
%{conn: conn_with_oidc_user(conn)} %{conn: conn_with_oidc_user(conn)}
end end
test "stichtag, suspended and payment count range persist through a re-patch", %{conn: conn} do test "as-of date, suspended and payment count range persist through a re-patch", %{conn: conn} do
url = "/members?stichtag=2026-03-15&suspended=1&pay_filter=has_unpaid&pay_min=2&pay_max=4" url = "/members?as_of_date=2026-03-15&suspended=1&pay_filter=has_unpaid&pay_min=2&pay_max=4"
{:ok, view, _html} = live(conn, url) {:ok, view, _html} = live(conn, url)
# Trigger an unrelated URL re-patch by sorting a stable column. # Trigger an unrelated URL re-patch by sorting a stable column.
view |> element("[data-testid='payment']") |> render_click() view |> element("[data-testid='payment']") |> render_click()
path = assert_patch(view) path = assert_patch(view)
assert path =~ "stichtag=2026-03-15" assert path =~ "as_of_date=2026-03-15"
assert path =~ "suspended=1" assert path =~ "suspended=1"
assert path =~ "pay_filter=has_unpaid" assert path =~ "pay_filter=has_unpaid"
assert path =~ "pay_min=2" assert path =~ "pay_min=2"

View file

@ -1,28 +1,28 @@
defmodule MvWeb.MemberLive.IndexStatusParamsCodecTest do defmodule MvWeb.MemberLive.IndexStatusParamsCodecTest do
@moduledoc """ @moduledoc """
§2.1 (extended) URL codec round-trip for the new v2 filter keys: the §2.1 (extended) URL codec round-trip for the new v2 filter keys: the
point-in-time Stichtag date and the suspended-status flag. Encoding a state to point-in-time as-of date and the suspended-status flag. Encoding a state to
params then decoding yields the canonical state, and the default (no filter) params then decoding yields the canonical state, and the default (no filter)
encodes to the empty map so a fresh URL is canonical. encodes to the empty map so a fresh URL is canonical.
""" """
use ExUnit.Case, async: true use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.AsOfDate
alias MvWeb.MemberLive.Index.PaymentAging alias MvWeb.MemberLive.Index.PaymentAging
alias MvWeb.MemberLive.Index.Stichtag
describe "Stichtag date" do describe "as-of date" do
test "a set date round-trips" do test "a set date round-trips" do
date = ~D[2026-03-15] date = ~D[2026-03-15]
assert date |> Stichtag.to_params() |> Stichtag.parse() == date assert date |> AsOfDate.to_params() |> AsOfDate.parse() == date
end end
test "nil encodes to the empty map and decodes to nil" do test "nil encodes to the empty map and decodes to nil" do
assert Stichtag.to_params(nil) == %{} assert AsOfDate.to_params(nil) == %{}
assert Stichtag.parse(%{}) == nil assert AsOfDate.parse(%{}) == nil
end end
test "a malformed date param decodes to nil" do test "a malformed date param decodes to nil" do
assert Stichtag.parse(%{"stichtag" => "not-a-date"}) == nil assert AsOfDate.parse(%{"as_of_date" => "not-a-date"}) == nil
end end
end end