Restrict the activities that we display on the alerts page's filters to just ones that can actually be in alerts #147

This commit is contained in:
Ian Renton
2026-09-18 21:20:42 +01:00
parent 54d779f036
commit a24e03028b
5 changed files with 70 additions and 34 deletions
+15
View File
@@ -12,6 +12,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
# any spots with "contest" in the comment will get allocated to this activity. # any spots with "contest" in the comment will get allocated to this activity.
comment_names=["CONTEST"], comment_names=["CONTEST"],
icon="fa-trophy", icon="fa-trophy",
alerts_possible=True,
), ),
ActivityName.DXPEDITION: Activity( ActivityName.DXPEDITION: Activity(
name=ActivityName.DXPEDITION, name=ActivityName.DXPEDITION,
@@ -23,6 +24,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
# this activity to a spot other ways. # this activity to a spot other ways.
comment_names=[], comment_names=[],
icon="fa-book-atlas", icon="fa-book-atlas",
alerts_possible=True,
), ),
ActivityName.SATELLITE: Activity( ActivityName.SATELLITE: Activity(
name=ActivityName.SATELLITE, name=ActivityName.SATELLITE,
@@ -32,6 +34,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
refs_globally_unique=False, refs_globally_unique=False,
comment_names=[], comment_names=[],
icon="fa-satellite", icon="fa-satellite",
alerts_possible=True,
), ),
ActivityName.EME: Activity( ActivityName.EME: Activity(
name=ActivityName.EME, name=ActivityName.EME,
@@ -70,6 +73,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
refs_globally_unique=False, refs_globally_unique=False,
comment_names=["QRP"], comment_names=["QRP"],
icon="fa-volume-low", icon="fa-volume-low",
alerts_possible=True,
), ),
ActivityName.POTA: Activity( ActivityName.POTA: Activity(
name=ActivityName.POTA, name=ActivityName.POTA,
@@ -81,6 +85,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ref_regex=r"[A-Z]{2}\-\d{4,5}|K\-TEST", ref_regex=r"[A-Z]{2}\-\d{4,5}|K\-TEST",
comment_names=["POTA"], comment_names=["POTA"],
icon="fa-tree", icon="fa-tree",
alerts_possible=True,
), ),
ActivityName.SOTA: Activity( ActivityName.SOTA: Activity(
name=ActivityName.SOTA, name=ActivityName.SOTA,
@@ -92,6 +97,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
comment_names=["SOTA"], comment_names=["SOTA"],
icon="fa-mountain-sun", icon="fa-mountain-sun",
alerts_possible=True,
), ),
ActivityName.WWFF: Activity( ActivityName.WWFF: Activity(
name=ActivityName.WWFF, name=ActivityName.WWFF,
@@ -103,6 +109,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}",
comment_names=["WWFF"], comment_names=["WWFF"],
icon="fa-seedling", icon="fa-seedling",
alerts_possible=True,
), ),
ActivityName.GMA: Activity( ActivityName.GMA: Activity(
name=ActivityName.GMA, name=ActivityName.GMA,
@@ -136,6 +143,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}",
comment_names=["HEMA"], comment_names=["HEMA"],
icon="fa-mound", icon="fa-mound",
alerts_possible=True,
), ),
ActivityName.IOTA: Activity( ActivityName.IOTA: Activity(
name=ActivityName.IOTA, name=ActivityName.IOTA,
@@ -202,6 +210,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d",
comment_names=["SIOTA"], comment_names=["SIOTA"],
icon="fa-wheat-awn", icon="fa-wheat-awn",
alerts_possible=True,
), ),
ActivityName.WCA: Activity( ActivityName.WCA: Activity(
name=ActivityName.WCA, name=ActivityName.WCA,
@@ -225,6 +234,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
comment_names=["ZLOTA"], comment_names=["ZLOTA"],
icon="fa-kiwi-bird", icon="fa-kiwi-bird",
region_flag="🇳🇿", region_flag="🇳🇿",
alerts_possible=True,
), ),
ActivityName.WOTA: Activity( ActivityName.WOTA: Activity(
name=ActivityName.WOTA, name=ActivityName.WOTA,
@@ -237,6 +247,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
comment_names=["WOTA"], comment_names=["WOTA"],
icon="fa-w", icon="fa-w",
region_flag="🇬🇧", region_flag="🇬🇧",
alerts_possible=True,
), ),
ActivityName.BOTA: Activity( ActivityName.BOTA: Activity(
name=ActivityName.BOTA, name=ActivityName.BOTA,
@@ -247,6 +258,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ref_type=ActivityRefType.BEACH, ref_type=ActivityRefType.BEACH,
comment_names=[], comment_names=[],
icon="fa-umbrella-beach", icon="fa-umbrella-beach",
alerts_possible=True,
), ),
ActivityName.KRMNPA: Activity( ActivityName.KRMNPA: Activity(
name=ActivityName.KRMNPA, name=ActivityName.KRMNPA,
@@ -259,6 +271,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
comment_names=["KRMNPA"], comment_names=["KRMNPA"],
icon="fa-earth-oceania", icon="fa-earth-oceania",
region_flag="🇦🇺", region_flag="🇦🇺",
alerts_possible=True,
), ),
ActivityName.SANPCPA: Activity( ActivityName.SANPCPA: Activity(
name=ActivityName.SANPCPA, name=ActivityName.SANPCPA,
@@ -271,6 +284,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
comment_names=["SANPCPA"], comment_names=["SANPCPA"],
icon="fa-earth-oceania", icon="fa-earth-oceania",
region_flag="🇦🇺", region_flag="🇦🇺",
alerts_possible=True,
), ),
ActivityName.LLOTA: Activity( ActivityName.LLOTA: Activity(
name=ActivityName.LLOTA, name=ActivityName.LLOTA,
@@ -282,6 +296,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ref_regex=r"LL[A-Z]{2}\-\d{4}", ref_regex=r"LL[A-Z]{2}\-\d{4}",
comment_names=["LLOTA"], comment_names=["LLOTA"],
icon="fa-water", icon="fa-water",
alerts_possible=True,
), ),
ActivityName.TOWERS: Activity( ActivityName.TOWERS: Activity(
name=ActivityName.TOWERS, name=ActivityName.TOWERS,
+4
View File
@@ -43,3 +43,7 @@ class Activity:
# Emoji flag for the country or region where this activity is relevant, if any. If None, this implies the # Emoji flag for the country or region where this activity is relevant, if any. If None, this implies the
# activity is in worldwide usage. # activity is in worldwide usage.
region_flag: str | None = None region_flag: str | None = None
# Can alerts ever have this activity? A lot of activities can be figured out from spot comments but can never
# occur in alerts, so to avoid showing all activities on the "upcoming" page, we allow it to filter based on
# this.
alerts_possible: bool = False
+15
View File
@@ -20,6 +20,7 @@ info:
* Renamed AMSAT SIG to "Satellite" as AMSAT is a specific organisation not just a general term for satellite QSOs * Renamed AMSAT SIG to "Satellite" as AMSAT is a specific organisation not just a general term for satellite QSOs
* Removed `alert_type` from alert data. Contest, DXpedition and Satellite alerts now give those values in `sig` instead, alongside the existing outdoor activity programmes. Teeeeechnically a breaking change but AlertType is so new I doubt anyone is using it yet, so slipped this one in anyway. Sorry :) * Removed `alert_type` from alert data. Contest, DXpedition and Satellite alerts now give those values in `sig` instead, alongside the existing outdoor activity programmes. Teeeeechnically a breaking change but AlertType is so new I doubt anyone is using it yet, so slipped this one in anyway. Sorry :)
* Added QRP, RaDAR Rally, /AM and /MM activities * Added QRP, RaDAR Rally, /AM and /MM activities
* Added `has_refs` and `alerts_possible` to Activity data
### 2.1 ### 2.1
@@ -1604,6 +1605,15 @@ components:
specific event (e.g. MOTA). Generally for Spothole's own internal use, clients probably won't need this. specific event (e.g. MOTA). Generally for Spothole's own internal use, clients probably won't need this.
Used to group them in the web UI. Still named "sig_type" in the API for backwards compatibility. Used to group them in the web UI. Still named "sig_type" in the API for backwards compatibility.
$ref: "#/components/schemas/ActivityType" $ref: "#/components/schemas/ActivityType"
has_refs:
type: boolean
description: >
Whether this activity has a fixed set of references (e.g. parks, summits) with an ID that gets
"activated", as opposed to activities like Contest or DXpedition which don't.
ref_type:
description: >
The type of reference that gets activated for this activity, e.g. Park or Summit.
$ref: "#/components/schemas/ActivityRefType"
comment_names: comment_names:
type: array type: array
description: > description: >
@@ -1639,6 +1649,11 @@ components:
Flag emoji, if this activity is specific to a country or region. If null, this activity is treated as Flag emoji, if this activity is specific to a country or region. If null, this activity is treated as
worldwide. worldwide.
example: "🇺🇳" example: "🇺🇳"
alerts_possible:
type: boolean
description: >
Whether alerts can ever have this activity. While Spothole can assign any activity from spot comments, there
are only a limited set that can ever crop up in alerts, because we don't parse general comments for alerts.
SolarConditions: SolarConditions:
type: object type: object
+2 -1
View File
@@ -332,7 +332,8 @@ function loadOptions() {
// Populate the filters panel // Populate the filters panel
generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]); generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#source-options", "source", options["alert_providers"]); generateMultiToggleFilterCard("#source-options", "source", options["alert_providers"]);
generateActivitiesMultiToggleFilterCard(options["sigs"]); // Alerts can only ever be tagged with activities that have an alert source, so only offer those as filters here
generateActivitiesMultiToggleFilterCard(options["sigs"].filter(o => o["alerts_possible"]), false);
// Load URL params. These may select things from the various filter & display options, so the function needs // Load URL params. These may select things from the various filter & display options, so the function needs
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress // to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
+34 -33
View File
@@ -136,8 +136,12 @@ function generateMultiToggleFilterCard(elementID, filterQuery, options) {
} }
// Generate a collapsable section for the activities filter card, and append it to the given list. The section is // Generate a collapsable section for the activities filter card, and append it to the given list. The section is
// expanded by default if isExpanded is true. // expanded by default if isExpanded is true. If hasContent is false (i.e. there are no activities to show in this
function appendActivityFilterSection($list, sectionId, heading, isExapanded, buildBody) { // section), the section is omitted entirely rather than showing an empty collapsible heading.
function appendActivityFilterSection($list, sectionId, heading, isExapanded, hasContent, buildBody) {
if (!hasContent) {
return;
}
const collapseId = `activity-options-${sectionId}-collapse`; const collapseId = `activity-options-${sectionId}-collapse`;
const $li = $('<li class="mb-1">'); const $li = $('<li class="mb-1">');
$li.append(`<button class="btn-toggle${isExapanded ? '' : ' collapsed'}" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="${isExapanded ? 'true' : 'false'}" aria-controls="${collapseId}">${heading}</button>`); $li.append(`<button class="btn-toggle${isExapanded ? '' : ' collapsed'}" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="${isExapanded ? 'true' : 'false'}" aria-controls="${collapseId}">${heading}</button>`);
@@ -147,45 +151,42 @@ function appendActivityFilterSection($list, sectionId, heading, isExapanded, bui
$list.append($li); $list.append($li);
} }
// Generate activities filter card. This one is also a special case. // Build the grid of activity checkboxes for a given (already filtered) list of activities.
function generateActivitiesMultiToggleFilterCard(activity_options) { function buildActivityFilterGrid(activity_options) {
const $grid = $('<div class="row row-cols-2 g-1 mb-1">');
activity_options.forEach(o => {
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
});
return $grid;
}
// Generate activities filter card. This one is also a special case. includeGeneralDX controls whether the "General
// DX" (NO_SIG) option is offered - this doesn't apply on the alerts page, where every alert has an activity.
function generateActivitiesMultiToggleFilterCard(activity_options, includeGeneralDX = true) {
const $list = $('<ul class="list-unstyled filter-section-list ps-0">'); const $list = $('<ul class="list-unstyled filter-section-list ps-0">');
appendActivityFilterSection($list, 'traditional', 'Traditional', true, $body => { const traditional = activity_options.filter(o => o["sig_type"] === "TRADITIONAL");
const $grid = $('<div class="row row-cols-2 g-1 mb-1">'); appendActivityFilterSection($list, 'traditional', 'Traditional', true, includeGeneralDX || traditional.length > 0, $body => {
activity_options.filter(o => o["sig_type"] === "TRADITIONAL").forEach(o => { if (includeGeneralDX) {
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, ""); $body.append(`<div class="w-100 mb-1"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label></div></div>`);
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`); }
}); $body.append(buildActivityFilterGrid(traditional));
$body.append(`<div class="w-100 mb-1"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label></div></div>`);
$body.append($grid);
}); });
appendActivityFilterSection($list, 'adventure', 'Adventure', true, $body => { const adventure = activity_options.filter(o => o["sig_type"] === "ADVENTURE");
const $grid = $('<div class="row row-cols-2 g-1 mb-1">'); appendActivityFilterSection($list, 'adventure', 'Adventure', true, adventure.length > 0, $body => {
activity_options.filter(o => o["sig_type"] === "ADVENTURE").forEach(o => { $body.append(buildActivityFilterGrid(adventure));
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
});
$body.append($grid);
}); });
appendActivityFilterSection($list, 'regional', 'Regional', false, $body => { const regional = activity_options.filter(o => o["sig_type"] === "REGIONAL");
const $grid = $('<div class="row row-cols-2 g-1 mb-1">'); appendActivityFilterSection($list, 'regional', 'Regional', false, regional.length > 0, $body => {
activity_options.filter(o => o["sig_type"] === "REGIONAL").forEach(o => { $body.append(buildActivityFilterGrid(regional));
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
});
$body.append($grid);
}); });
appendActivityFilterSection($list, 'event', 'Event', false, $body => { const event = activity_options.filter(o => o["sig_type"] === "EVENT");
const $grid = $('<div class="row row-cols-2 g-1 mb-1">'); appendActivityFilterSection($list, 'event', 'Event', false, event.length > 0, $body => {
activity_options.filter(o => o["sig_type"] === "EVENT").forEach(o => { $body.append(buildActivityFilterGrid(event));
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
});
$body.append($grid);
}); });
$("#activity-options").append($list); $("#activity-options").append($list);