feat(bal): Group D / D1 - configurable distributed calendar reminders + save-only .ics

D1 - number of reminders (default 3, max 5):
  - new NUM_REMINDERS config in plugin_base.py
  - new BalSpinBox widget bound to a BalConfig
  - new pure helper compute_reminder_offsets(days, count): reminders spread
    uniformly across the check-alive period, always before the deadline
    (offset >= 1), at most one per available day, de-duplicated, earliest
    first (e.g. (30,3)->[30,16,1], (2,3)->[2,1], (1,3)->[1], (0,3)->[])
  - create_alarms() rewritten to use it and emit one VALARM per offset with a
    DESCRIPTION reminder text
  - 'Number of reminders' spin box (range 1..5) added to the settings dialog
    and to the Reset list

D1b - save-only .ics (ask where, default to Desktop):
  - BalCalendar.desktop_dir() helper (~/Desktop with home fallback)
  - open_or_save_calendar() no longer opens the file with a calendar app; it
    always shows a 'save as' dialog (.ics filter, default will_event.ics,
    starting on the Desktop) and copies the file there, then confirms.
    Identical behaviour on Windows/Linux/macOS. save_to_cwd -> save_ics_to.

Follow-up: removed the now-unused 'Calendar App' field from the settings
  dialog (and from the Reset list). CALENDAR_APP config and
  open_with_default_app left in place (unused) to avoid unrelated changes.

D2 intentionally skipped (per user request).

tests/test_group_d_alarms.py: 7 new tests (NUM_REMINDERS default/change and
  all distribution rules). Full suite: 217 passed.
This commit is contained in:
2026-06-28 23:00:31 -04:00
parent 9d2cbc2814
commit b03251e864
6 changed files with 399 additions and 23 deletions

View File

@@ -23,6 +23,58 @@ from .common import _, _logger # underscore names are not re-exported by "impor
from .calendar import BalCalendar
def compute_reminder_offsets(days, count):
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
Group D / D1. The reminders are spread uniformly across the check-alive
period and always fall *before* the delivery deadline, i.e. every returned
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
Rules:
* ``count`` is the requested number of reminders (the settings dialog
caps it at 5, default 3).
* at most ONE reminder per available day: the effective number is
``min(count, days)``;
* with ``days`` available days, offsets are chosen as evenly spaced
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
= the first day of the period), de-duplicated and returned sorted
descending (earliest reminder first).
Args:
days: number of whole days between check-alive and the deadline.
count: requested number of reminders.
Returns:
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
``days=30, count=3``. Empty if there is no room for any reminder.
"""
# No room for any reminder (deadline today or already passed).
if days < 1 or count < 1:
return []
# Never more reminders than available days (one per day at most).
effective = min(int(count), int(days))
# A single reminder: put it one day before the deadline.
if effective == 1:
return [1]
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
# This places the first reminder at the start of the period (offset ~days)
# and the last one one day before the deadline (offset 1).
offsets = set()
for i in range(effective):
frac = i / (effective - 1) # 0.0 .. 1.0
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
offset = round(days - frac * (days - 1))
offset = max(1, min(days, offset))
offsets.add(offset)
# Sorted descending: earliest reminder (largest offset) first.
return sorted(offsets, reverse=True)
class ClickableLabel(QLabel):
doubleClicked = pyqtSignal()
@@ -683,15 +735,45 @@ class WillSettingsWidget(QWidget):
self.widgets["baltx_fees"].set_read_only(True)
def create_alarms(self, alarm_start, alarm_end):
days = (alarm_end - alarm_start).days+1
"""Build the VALARM reminder blocks for the .ics event (Group D / D1).
The number of reminders is read from the NUM_REMINDERS setting (default
3, capped at 5 by the settings dialog). They are spread uniformly across
the check-alive period and always fall before the delivery deadline; if
the period is shorter than the requested number, at most one reminder
per day is produced (see compute_reminder_offsets).
Args:
alarm_start: the check-alive datetime (start of the period).
alarm_end: the delivery-time / locktime datetime (the deadline).
Returns:
A list of .ics text lines (possibly empty) describing the VALARMs.
"""
days = (alarm_end - alarm_start).days
# How many reminders the user asked for (default 3 if unreadable).
try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
except Exception:
count = 3
offsets = compute_reminder_offsets(days, count)
# Reminder text shown by the calendar app when each alarm fires.
description = _(
"BAL reminder: check in before your will is delivered to your heirs."
)
lines = []
for i in range(1, days):
for offset in offsets:
lines.extend(
[
"BEGIN:VALARM",
f"TRIGGER;RELATED=END:-P{i}D",
# Fire "offset" days before the event end (the deadline).
f"TRIGGER;RELATED=END:-P{offset}D",
"ACTION:DISPLAY",
# f"DESCRIPTION:{self.bal_window.bal_plugin.ALARM_DESCRIPTION.get()}",
f"DESCRIPTION:{BalCalendar.ical_escape(description)}",
"END:VALARM",
]
)
@@ -704,7 +786,6 @@ class WillSettingsWidget(QWidget):
threshold = self.widgets["threshold"].alarm
alarm_end = BalCalendar.format_time(locktime)
alarm_start = BalCalendar.format_time(threshold)
days_difference = (locktime - threshold).days
heirs_details = "\r\n".join(f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}" for heir in self.bal_window.heirs)
event_description = BalCalendar.ical_escape(
@@ -735,23 +816,54 @@ class WillSettingsWidget(QWidget):
lines = [s.rstrip("\r\n") for s in lines]
ics_content = "\r\n".join(lines) + "\r\n"
# Keep the generated .ics in a temp file; it is copied to the path the
# user picks below.
self.temp_path = BalCalendar.write_temp_ics(ics_content)
opened = BalCalendar.open_with_default_app(
self.bal_window.bal_plugin.CALENDAR_APP.get(), self.temp_path
# Group D / D1b: always ask the user WHERE to save the .ics file (the
# plugin no longer tries to open it with a calendar app). The save
# dialog opens on the user's Desktop by default, with an .ics filter and
# a sensible default filename.
desktop = BalCalendar.desktop_dir()
default_path = os.path.join(desktop, "will_event.ics")
target = getSaveFileName(
parent=self.bal_window.window,
title=_("Save calendar reminder (.ics)"),
# An absolute filename makes getSaveFileName ignore its remembered
# IO_DIRECTORY and open on the Desktop instead.
filename=default_path,
filter="iCalendar (*.ics);;All files (*)",
default_extension="ics",
config=self.bal_window.window.config,
)
if opened:
_logger.info(f"File opened with default app: {self.temp_path}")
else:
export_meta_gui(
self.bal_window.window, f"will_event.ics",self.save_to_cwd
if not target:
# User cancelled the save dialog: nothing to do.
return
try:
self.save_ics_to(target)
except Exception as save_err:
_logger.error(f"saving .ics failed: {save_err}")
self.bal_window.show_warning(
_("Could not save the calendar file: {}").format(save_err)
)
return
self.bal_window.show_message(
_("Calendar file saved to:\n{}").format(target)
)
def save_ics_to(self, target):
"""Copy the generated .ics from the temp file to ``target`` (Group D / D1b).
def save_to_cwd(self,filename="event.ics"):
target = os.path.abspath(filename)
# se il file esiste, sovrascrive
_logger.debug(f"save_to_cwd {self.temp_path},{filename}")
Overwrites the destination if it already exists. Used by
open_or_save_calendar after the user picks a save location.
Args:
target: absolute destination path chosen by the user.
Returns:
The destination path.
"""
_logger.debug(f"save_ics_to {self.temp_path} -> {target}")
with open(self.temp_path, "rb") as src, open(target, "wb") as dst:
dst.write(src.read())
return target
@@ -884,6 +996,43 @@ class BalCheckBox(QCheckBox):
self.stateChanged.connect(on_check)
class BalSpinBox(QSpinBox):
"""Integer spin box bound to a BalConfig value (Group D / D1).
Mirrors BalCheckBox / BalLineEdit: it shows the persisted value on creation
and writes the new value back to the config whenever the user changes it.
Args:
variable: the BalConfig accessor to read from / write to.
minimum: smallest selectable value (default 1).
maximum: largest selectable value (default 5, used by "Number of
reminders").
on_change: optional callback invoked after the value is persisted.
"""
def __init__(self, variable, minimum=1, maximum=5, on_change=None):
QSpinBox.__init__(self)
self.setMinimum(minimum)
self.setMaximum(maximum)
# Coerce the stored value to int and clamp it into the allowed range,
# so a stale/invalid config value can never push the spin box out of
# bounds.
try:
current = int(variable.get())
except Exception:
current = minimum
current = max(minimum, min(maximum, current))
self.setValue(current)
self.on_change = on_change
def on_value_changed(v):
variable.set(int(v))
if self.on_change:
self.on_change()
self.valueChanged.connect(on_value_changed)
class WillWidget(QWidget):
def __init__(self, father=None, parent=None):