Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions src/accessiweather/alert_lifecycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""Alert lifecycle intelligence: diff two alert snapshots to detect changes."""

from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum

from accessiweather.constants import SEVERITY_PRIORITY_MAP
from accessiweather.models.alerts import WeatherAlert, WeatherAlerts


class AlertChangeKind(Enum):
NEW = "new"
UPDATED = "updated"
CANCELLED = "cancelled"


@dataclass
class AlertChange:
"""Represents a single alert change (new, updated, or cancelled)."""

kind: AlertChangeKind
alert: WeatherAlert | None = None
alert_id: str = ""
title: str = ""
old_severity: str | None = None
new_severity: str | None = None

@property
def is_severity_upgrade(self) -> bool:
"""Return True when the alert severity increased to a higher priority."""
if self.old_severity is None or self.new_severity is None:
return False
old_priority: int = SEVERITY_PRIORITY_MAP.get(self.old_severity.lower(), 0)
new_priority: int = SEVERITY_PRIORITY_MAP.get(self.new_severity.lower(), 0)
return bool(new_priority > old_priority)


@dataclass
class AlertLifecycleDiff:
"""Structured diff between two alert snapshots."""

new_alerts: list[AlertChange] = field(default_factory=list)
updated_alerts: list[AlertChange] = field(default_factory=list)
cancelled_alerts: list[AlertChange] = field(default_factory=list)
summary: str = "No changes"

@property
def has_changes(self) -> bool:
"""Return True if any alerts changed."""
return bool(self.new_alerts or self.updated_alerts or self.cancelled_alerts)


def _alert_label(count: int, singular: str, plural: str | None = None) -> str:
if plural is None:
plural = singular + "s"
return f"{count} {singular if count == 1 else plural}"


def _build_summary(
new: list[AlertChange],
updated: list[AlertChange],
cancelled: list[AlertChange],
) -> str:
parts: list[str] = []

if new:
parts.append(_alert_label(len(new), "new alert"))

if updated:
# Check if any are severity upgrades
upgrades = [c for c in updated if c.is_severity_upgrade]
if upgrades:
# Include the worst new severity in the label
highest = max(
upgrades,
key=lambda c: SEVERITY_PRIORITY_MAP.get((c.new_severity or "").lower(), 0),
)
severity_note = (highest.new_severity or "higher").capitalize()
label = _alert_label(len(updated), "updated")
parts.append(f"{label} (severity upgraded to {severity_note})")
else:
parts.append(_alert_label(len(updated), "updated"))

if cancelled:
parts.append(_alert_label(len(cancelled), "cancelled"))

return ", ".join(parts) if parts else "No changes"


def diff_alerts(
previous: WeatherAlerts | None,
current: WeatherAlerts | None,
) -> AlertLifecycleDiff:
"""
Compare two alert snapshots and return a structured diff.

Args:
previous: The earlier snapshot (or None if no history).
current: The latest snapshot (or None if alerts are unavailable).

Returns:
An :class:`AlertLifecycleDiff` describing what changed.

"""
prev_active = previous.get_active_alerts() if previous is not None else []
curr_active = current.get_active_alerts() if current is not None else []

prev_map: dict[str, WeatherAlert] = {a.get_unique_id(): a for a in prev_active}
curr_map: dict[str, WeatherAlert] = {a.get_unique_id(): a for a in curr_active}

new_changes: list[AlertChange] = []
updated_changes: list[AlertChange] = []
cancelled_changes: list[AlertChange] = []

# New: in current but not previous
for alert_id, alert in curr_map.items():
if alert_id not in prev_map:
new_changes.append(
AlertChange(
kind=AlertChangeKind.NEW,
alert=alert,
alert_id=alert_id,
title=alert.title or "",
)
)

# Cancelled: in previous but not current
for alert_id, prev_alert in prev_map.items():
if alert_id not in curr_map:
cancelled_changes.append(
AlertChange(
kind=AlertChangeKind.CANCELLED,
alert_id=alert_id,
title=prev_alert.title or "",
)
)

# Updated: in both maps but content or severity/urgency changed
for alert_id, alert in curr_map.items():
if alert_id in prev_map:
prev_alert = prev_map[alert_id]
content_changed = alert.get_content_hash() != prev_alert.get_content_hash()
severity_changed = alert.severity != prev_alert.severity
urgency_changed = alert.urgency != prev_alert.urgency
if content_changed or severity_changed or urgency_changed:
updated_changes.append(
AlertChange(
kind=AlertChangeKind.UPDATED,
alert=alert,
alert_id=alert_id,
title=alert.title or "",
old_severity=prev_alert.severity,
new_severity=alert.severity,
)
)

summary = _build_summary(new_changes, updated_changes, cancelled_changes)

return AlertLifecycleDiff(
new_alerts=new_changes,
updated_alerts=updated_changes,
cancelled_alerts=cancelled_changes,
summary=summary,
)
76 changes: 76 additions & 0 deletions src/accessiweather/alert_notification_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import asyncio
import logging

from .alert_lifecycle import AlertLifecycleDiff
from .alert_manager import AlertManager, AlertSettings
from .constants import (
MAX_DISPLAYED_AREAS,
Expand Down Expand Up @@ -272,6 +273,81 @@ async def _send_alert_notification(
logger.error(f"[notify] Error in _send_alert_notification: {type(e).__name__}: {e}")
return False

async def notify_lifecycle_changes(
self,
diff: AlertLifecycleDiff,
) -> int:
"""
Fire desktop notifications for updated and cancelled alerts.

New alerts are handled by :meth:`process_and_notify`. This method
covers the other two lifecycle events so users hear about alerts that
changed severity or were withdrawn.

Args:
----
diff: The :class:`~accessiweather.alert_lifecycle.AlertLifecycleDiff`
produced by the most recent fetch.

Returns:
-------
Number of notifications sent.

"""
if not diff.has_changes:
return 0

if not self.alert_manager.settings.notifications_enabled:
logger.debug("[notify] lifecycle notifications skipped — notifications disabled")
return 0

sent = 0

# --- Updated alerts ---
for change in diff.updated_alerts:
if change.alert is None:
continue
reason = "escalation" if change.is_severity_upgrade else "content_changed"
try:
success = await self._send_alert_notification(
change.alert, reason, play_sound=change.is_severity_upgrade
)
if success:
sent += 1
logger.info(
f"[notify] lifecycle update notification sent: "
f"{change.title!r} reason={reason}"
)
except Exception as exc:
logger.error(
f"[notify] lifecycle update notification failed for {change.alert_id!r}: {exc}"
)

# --- Cancelled alerts ---
for change in diff.cancelled_alerts:
try:
title = f"CANCELLED: {change.title}" if change.title else "Alert Cancelled"
message = (
f"The alert '{change.title}' has been cancelled or expired."
if change.title
else "A weather alert has been cancelled."
)
success = self.notifier.send_notification(
title=title,
message=message,
timeout=10,
play_sound=False,
)
if success:
sent += 1
logger.info(f"[notify] lifecycle cancel notification sent: {change.title!r}")
except Exception as exc:
logger.error(
f"[notify] lifecycle cancel notification failed for {change.alert_id!r}: {exc}"
)

return sent

def update_settings(
self,
settings: AlertSettings,
Expand Down
37 changes: 34 additions & 3 deletions src/accessiweather/display/presentation/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,35 @@

from __future__ import annotations

from typing import TYPE_CHECKING

from ...models import AppSettings, Location, WeatherAlert, WeatherAlerts
from ..weather_presenter import AlertPresentation, AlertsPresentation
from .formatters import format_display_datetime, truncate, wrap_text

if TYPE_CHECKING:
from accessiweather.alert_lifecycle import AlertLifecycleDiff


def build_alerts(
alerts: WeatherAlerts, location: Location, settings: AppSettings | None = None
alerts: WeatherAlerts,
location: Location,
settings: AppSettings | None = None,
*,
lifecycle_diff: AlertLifecycleDiff | None = None,
) -> AlertsPresentation:
"""Create an alerts presentation for a given location."""
"""
Create an alerts presentation for a given location.

Args:
alerts: Current weather alerts snapshot.
location: Location being presented.
settings: Optional app settings for time formatting.
lifecycle_diff: Optional diff from the previous alert snapshot.
When provided and has changes, a concise change summary is
prepended to the fallback text and stored on the presentation.

"""
title = f"Weather alerts for {location.name}"
if not alerts.has_alerts():
return AlertsPresentation(title=title, fallback_text=f"{title}:\nNo active weather alerts.")
Expand All @@ -28,7 +48,18 @@ def build_alerts(
fallback_lines.append(presentation.fallback_text)

fallback_text = "\n\n".join(fallback_lines)
return AlertsPresentation(title=title, alerts=presentations, fallback_text=fallback_text)

change_summary: str | None = None
if lifecycle_diff is not None and lifecycle_diff.has_changes:
change_summary = lifecycle_diff.summary
fallback_text = f"Alert changes: {lifecycle_diff.summary}\n{fallback_text}"

return AlertsPresentation(
title=title,
alerts=presentations,
fallback_text=fallback_text,
change_summary=change_summary,
)


def build_single_alert(
Expand Down
21 changes: 17 additions & 4 deletions src/accessiweather/display/weather_presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from accessiweather.alert_lifecycle import AlertLifecycleDiff

from ..models import (
AppSettings,
Expand Down Expand Up @@ -126,6 +129,7 @@ class AlertsPresentation:
title: str
alerts: list[AlertPresentation] = field(default_factory=list)
fallback_text: str = ""
change_summary: str | None = None


@dataclass(slots=True)
Expand Down Expand Up @@ -215,7 +219,11 @@ def present(self, weather_data: WeatherData) -> WeatherPresentation:
else None
)
alerts = (
self._build_alerts(weather_data.alerts, weather_data.location)
self._build_alerts(
weather_data.alerts,
weather_data.location,
lifecycle_diff=weather_data.alert_lifecycle_diff,
)
if weather_data.alerts
else None
)
Expand Down Expand Up @@ -331,8 +339,13 @@ def _build_forecast(
forecast, hourly_forecast, location, unit_pref, settings=self.settings
)

def _build_alerts(self, alerts: WeatherAlerts, location: Location) -> AlertsPresentation:
return build_alerts(alerts, location, settings=self.settings)
def _build_alerts(
self,
alerts: WeatherAlerts,
location: Location,
lifecycle_diff: AlertLifecycleDiff | None = None,
) -> AlertsPresentation:
return build_alerts(alerts, location, settings=self.settings, lifecycle_diff=lifecycle_diff)

def _build_aviation(
self, aviation: AviationData | None, location: Location
Expand Down
8 changes: 7 additions & 1 deletion src/accessiweather/models/weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from enum import Enum
from typing import Any
from typing import TYPE_CHECKING, Any

from .alerts import WeatherAlerts

if TYPE_CHECKING:
from accessiweather.alert_lifecycle import AlertLifecycleDiff

# Note: CurrentConditions, Forecast, HourlyForecast are defined later in this file
# and used in SourceData via forward references (string annotations)

Expand Down Expand Up @@ -508,6 +511,9 @@ class WeatherData:
source_attribution: SourceAttribution | None = None
incomplete_sections: set[str] = field(default_factory=set)

# Alert lifecycle diff (computed per-fetch from cached previous alerts)
alert_lifecycle_diff: AlertLifecycleDiff | None = None

@property
def current_conditions(self) -> CurrentConditions | None:
"""Backward-compatible accessor for current conditions."""
Expand Down
Loading
Loading