Skip to content

Commit 5b1abdb

Browse files
Suncussclaude
andcommitted
feat: 12/24h time format toggle, i18n consistency, and clickable species labels
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8ab97a6 commit 5b1abdb

27 files changed

Lines changed: 1783 additions & 187 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## [Unreleased]
44

5+
- Added a "Use 24-hour Clock" toggle in Settings → Personalization that drives every time-of-day display across the app — Recent Detections list, Latest Observation timestamp, Table, Most Active Hour summary, and the Hourly Activity bar chart and heatmap x-axes. Defaults to the browser's locale on first run; the setting is only persisted once the user explicitly flips it, so existing installs aren't migrated and the toggle reflects what the OS already shows. Fixes #49, where the Hourly Activity bar chart hardcoded 12-hour AM/PM regardless of OS setting while sibling charts and lists deferred to the browser locale — producing a mix of 12h and 24h labels on the same Dashboard. Also fixes an `Intl.DateTimeFormat` quirk where `hour12: false` on `en-US` rendered midnight as "24:30" instead of "00:30" (now uses explicit `hourCycle: 'h23'`)
6+
- Fixed Apprise notifications ignoring the Bird Name Language setting — title and body always rendered the English string straight off the model output; they now follow the user's chosen language via the same scientific-name lookup the web UI uses (#47)
7+
- Fixed inconsistent Bird Name Language rendering across the web UI and duplicate-species rows on the Dashboard. ~17% of V2 species (e.g. "Eurasian Blackbird" for Turdus merula, vs the species table's canonical "Common Blackbird") missed the English-keyed translation and rendered in English on the Activity Overview, Charts, and Heatmap; the same species emitted under different English names by V2 vs V3 also appeared as two rows after a model switch — one translated, one not. Bird-name routes now resolve any known English variant (canonical, label_en, label_en_uk) to a stable scientific name at the API boundary, aggregations group by that key, and `/api/bird/<any-english-variant>` serves the combined V2+V3 history (#48)
8+
- Added a WebSocket handshake regression test that exercises the real Flask-SocketIO test client instead of mocking `socketio` — would have caught the 0.6.8 Live Feed outage where Flask-SocketIO 5.5.1 crashed on Flask 3.1.3's read-only `RequestContext.session` (b6b0f26)
9+
- Made species names on the Bird Activity Overview's Total Detections bar chart clickable — y-axis labels now link to each species's detail page on both the Dashboard and Charts views, and middle-click / "open in new tab" / keyboard focus all work as on any other text link. Implemented as an HTML overlay over the Chart.js canvas (canvas-rendered text can't be hyperlinks) with the overlay font matched to Chart.js's tick font so the labels don't render wider than they used to
10+
511
## [0.6.8] - 2026-05-08
612

713
- Added a "customize image" modal on bird detail pages — pick a different Wikimedia thumbnail per species; the choice persists across redeploys as a sidecar and the gallery is live-patched on apply without a reload. Wikimedia rate-limits (429) now surface as a friendly retry-later message

backend/config/settings.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@ def _get_positive_int_env(name: str, default: int) -> int:
6060
"display": {
6161
"use_metric_units": True,
6262
"bird_name_language": "en",
63-
"station_name": ""
63+
"station_name": "",
64+
# null = no explicit choice yet, frontend detects from browser locale.
65+
# Once the user flips the toggle, this is persisted as "12h" or "24h".
66+
"time_format": None
6467
},
6568
"birdweather": {
6669
"id": None # Station token from birdweather.com

backend/core/api.py

Lines changed: 94 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
add_display_species,
6969
clear_bird_name_caches,
7070
get_bird_name_language,
71-
get_localized_common_name_from_english,
71+
get_localized_common_name,
7272
)
7373
from core.db import DatabaseManager
7474
from core.ha_mode import get_runtime_mode, is_home_assistant_mode
@@ -103,7 +103,7 @@
103103
)
104104
from core.storage_manager import delete_detection_files
105105
from core.timezone_service import get_timezone_str, local_now
106-
from model_service.label_utils import get_species_list
106+
from model_service.label_utils import get_species_list, resolve_to_scientific_name
107107
from version import DISPLAY_NAME, __version__
108108

109109
# Setup logging
@@ -875,11 +875,13 @@ def serve_spectrogram(filename):
875875
@log_api_request
876876
def get_bird_details(species_name):
877877
settings = load_user_settings()
878-
details = db_manager.get_bird_details(species_name)
878+
sci, common = _resolve_species_filter(species_name)
879+
details = db_manager.get_bird_details(common, scientific_name=sci)
879880
if details:
880881
details = _localize_detection(details, settings=settings)
881882
logger.debug("Bird details retrieved", extra={
882883
'species': species_name,
884+
'resolved_scientific': sci,
883885
'total_detections': details.get('detectionCount', 0)
884886
})
885887
return jsonify(details)
@@ -963,12 +965,14 @@ def get_bird_recordings(species_name):
963965
return jsonify({"error": "Sort must be 'recent' or 'best'"}), 400
964966

965967
settings = load_user_settings()
968+
sci, common = _resolve_species_filter(species_name)
966969
recordings = _localize_detection_list(
967-
db_manager.get_bird_recordings(species_name, sort, limit),
970+
db_manager.get_bird_recordings(common, sort, limit, scientific_name=sci),
968971
settings=settings,
969972
)
970973
logger.debug("Bird recordings retrieved", extra={
971974
'species': species_name,
975+
'resolved_scientific': sci,
972976
'sort': sort,
973977
'limit': limit,
974978
'records_count': len(recordings)
@@ -981,7 +985,10 @@ def get_bird_recordings(species_name):
981985
def get_detection_distribution(species_name):
982986
view = request.args.get('view', 'month')
983987
date = request.args.get('date', local_now().strftime('%Y-%m-%d'))
984-
distribution = db_manager.get_detection_distribution(species_name, view, date)
988+
sci, common = _resolve_species_filter(species_name)
989+
distribution = db_manager.get_detection_distribution(
990+
common, view, date, scientific_name=sci,
991+
)
985992
return jsonify(distribution)
986993

987994
@api.route('/api/species/all', methods=['GET'])
@@ -1076,6 +1083,7 @@ def get_detections():
10761083
per_page = min(max(1, per_page), 100)
10771084
settings = load_user_settings()
10781085
bird_name_language = get_bird_name_language(settings)
1086+
sci, common = _resolve_species_filter(species)
10791087

10801088
if sort == 'common_name' and bird_name_language != DEFAULT_BIRD_NAME_LANGUAGE:
10811089
# Sort the fully localized labels in memory so the rendered order matches
@@ -1084,7 +1092,8 @@ def get_detections():
10841092
db_manager.get_all_detections(
10851093
start_date=start_date,
10861094
end_date=end_date,
1087-
species=species,
1095+
species=common,
1096+
scientific_name=sci,
10881097
),
10891098
settings=settings,
10901099
)
@@ -1103,9 +1112,10 @@ def get_detections():
11031112
per_page=per_page,
11041113
start_date=start_date,
11051114
end_date=end_date,
1106-
species=species,
1115+
species=common,
11071116
sort=sort,
1108-
order=order
1117+
order=order,
1118+
scientific_name=sci,
11091119
)
11101120
detections = _localize_detection_list(detections, settings=settings)
11111121

@@ -1150,11 +1160,13 @@ def export_detections_csv():
11501160
except ValueError:
11511161
return jsonify({'error': f'Invalid {date_param} format. Use YYYY-MM-DD'}), 400
11521162

1163+
sci, common = _resolve_species_filter(species)
11531164
# Fetch all detections
11541165
detections = db_manager.get_all_detections_for_export(
11551166
start_date=start_date,
11561167
end_date=end_date,
1157-
species=species
1168+
species=common,
1169+
scientific_name=sci,
11581170
)
11591171

11601172
# Build CSV in memory
@@ -1303,6 +1315,25 @@ def delete_detections_batch():
13031315
_available_species_cache = {}
13041316

13051317

1318+
def _resolve_species_filter(name):
1319+
"""Map a route-supplied English bird name to a DB filter pair.
1320+
1321+
Returns ``(scientific_name, common_name)`` where exactly one is populated.
1322+
When the resolver recognises the input (canonical common_name, V2 label_en,
1323+
or label_en_uk), we filter the underlying query by ``scientific_name`` —
1324+
this is what merges V2's "Eurasian Blackbird" and V3's "Common Blackbird"
1325+
history for the same Turdus merula into a single result.
1326+
1327+
When the resolver misses (unknown English string, legacy migration row),
1328+
we fall back to filtering by ``common_name`` so the user keeps access to
1329+
data the species table doesn't know about.
1330+
"""
1331+
if not name:
1332+
return None, None
1333+
sci = resolve_to_scientific_name(name)
1334+
return (sci, None) if sci else (None, name)
1335+
1336+
13061337
def _localize_detection(detection, settings=None):
13071338
return add_display_common_name(
13081339
detection,
@@ -1322,19 +1353,28 @@ def _localize_species_list(species_list, settings=None):
13221353

13231354

13241355
def _localize_summary(summary, settings=None):
1356+
"""Add ``mostCommonBirdDisplay`` and ``rarestBirdDisplay`` fields.
1357+
1358+
Looks up the translation by ``{key}ScientificName`` (the stable key from
1359+
the DB CTE), so V2's "Eurasian Blackbird" and V3's "Common Blackbird" for
1360+
the same Turdus merula both translate to "Amsel" under German. Falls back
1361+
to the English ``{key}`` value when no scientific name is available
1362+
(legacy rows) or when the summary is empty.
1363+
"""
13251364
localized_summary = dict(summary)
13261365

13271366
for key in ('mostCommonBird', 'rarestBird'):
13281367
bird_name = localized_summary.get(key)
1329-
localized_summary[f'{key}Display'] = (
1330-
get_localized_common_name_from_english(
1368+
sci_name = localized_summary.get(f'{key}ScientificName')
1369+
if bird_name and bird_name != 'N/A':
1370+
localized_summary[f'{key}Display'] = get_localized_common_name(
1371+
sci_name,
13311372
bird_name,
13321373
language=get_bird_name_language(settings),
13331374
settings=settings,
13341375
)
1335-
if bird_name and bird_name != 'N/A'
1336-
else bird_name
1337-
)
1376+
else:
1377+
localized_summary[f'{key}Display'] = bird_name
13381378

13391379
return localized_summary
13401380

@@ -1855,6 +1895,46 @@ def update_units_setting():
18551895
return jsonify({'error': str(e)}), 500
18561896

18571897

1898+
@api.route('/api/settings/time-format', methods=['PUT'])
1899+
@log_api_request
1900+
@require_auth
1901+
def update_time_format_setting():
1902+
"""Update the display time-format setting without triggering a restart.
1903+
1904+
Frontend-only preference for 12-hour vs 24-hour clock display.
1905+
Only explicit user choices are persisted; the absence of a value means
1906+
"detect from browser locale" and is the default for new installs.
1907+
"""
1908+
try:
1909+
data = request.json
1910+
if not data or 'time_format' not in data:
1911+
return jsonify({'error': 'time_format field required'}), 400
1912+
1913+
time_format = data['time_format']
1914+
if time_format not in ('12h', '24h'):
1915+
return jsonify({'error': "time_format must be '12h' or '24h'"}), 400
1916+
1917+
current_settings = load_user_settings()
1918+
if 'display' not in current_settings:
1919+
current_settings['display'] = {}
1920+
current_settings['display']['time_format'] = time_format
1921+
save_user_settings(current_settings)
1922+
invalidate_runtime_settings_cache()
1923+
1924+
logger.info("Display time format changed", extra={'time_format': time_format})
1925+
1926+
return jsonify({
1927+
'success': True,
1928+
'time_format': time_format
1929+
}), 200
1930+
1931+
except Exception as e:
1932+
logger.error("Failed to update time format setting", extra={
1933+
'error': str(e)
1934+
}, exc_info=True)
1935+
return jsonify({'error': str(e)}), 500
1936+
1937+
18581938
@api.route('/api/settings/notifications', methods=['PUT'])
18591939
@log_api_request
18601940
@require_auth

backend/core/bird_name_utils.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
clear_species_cache,
99
get_localized_name,
1010
get_localized_name_from_english,
11+
resolve_to_scientific_name,
1112
)
1213

1314
DEFAULT_BIRD_NAME_LANGUAGE = 'en'
@@ -191,12 +192,28 @@ def add_display_species(
191192
language: str | None = None,
192193
settings: dict | None = None,
193194
) -> dict | None:
194-
"""Return a copy with displaySpecies attached."""
195+
"""Return a copy with displaySpecies attached.
196+
197+
Resolves the translation by ``scientific_name`` when available — the
198+
aggregation queries return it alongside ``species`` so the stable-key
199+
path is the norm. For legacy or partial records where ``scientific_name``
200+
is missing, falls back to the English-synonym resolver before giving up
201+
on the English string. This is what makes both V2's "Eurasian Blackbird"
202+
and V3's "Common Blackbird" translate correctly when the record carries
203+
only the English form.
204+
"""
195205
if not data:
196206
return data
197207

198-
localized = get_localized_common_name_from_english(
199-
data.get('species'),
208+
sci = data.get('scientific_name')
209+
common = data.get('species') or data.get('common_name')
210+
211+
if not sci and common:
212+
sci = resolve_to_scientific_name(common)
213+
214+
localized = get_localized_common_name(
215+
sci,
216+
common,
200217
language=language,
201218
settings=settings,
202219
)

0 commit comments

Comments
 (0)