fix(webpub): the published page could not sort a date, and could not be unsorted

parseFloat accepts a numeric PREFIX. "2026-08-10" therefore became the number
2026, every date in the same year compared equal, and since the sort is stable
nothing moved: the Date column looked as though it were simply not sortable.
The same trap caught every callsign starting with a digit - 8B81SU and 8P9AB
both read as 8 - and the times, where "09:56" became 9. The numeric test is now
anchored to the whole cell, so anything that is not entirely a number is
compared as text, which is exactly right for an ISO date.

Sorting had no way back either. Each row now carries the index it was published
at, and a third click on a header restores that order. Headers also show an
arrow: with no indicator, a column that silently refused to sort was
indistinguishable from one that had sorted into the same order.

Blank cells sink in both directions rather than leading the ascending sort. An
empty field is missing data, not the smallest value.

Tested where it can be: the page ships its own script, so a regression is silent
- the table still renders, it just sorts wrongly.
This commit is contained in:
2026-08-10 13:52:13 +02:00
parent 9d8b69d804
commit 9f62808392
3 changed files with 85 additions and 14 deletions
+4 -2
View File
@@ -5,12 +5,14 @@
"en": [
"Band openings: OpsLog now tells you when 6, 4 or 2 m opens. It watches the spots already arriving from your clusters and RBN — several different stations appearing at single-hop range (5002400 km) in the same bearing sector within a few minutes is the signature of sporadic E, and nothing else looks like it. You get one message per band per opening, naming the sector and the typical distance. An opening outside the usual season is still announced, and flagged as unusual: those are the ones worth knowing about. Nothing to configure — but the quality depends on having a cluster or RBN feed carrying VHF spots, and 2 m openings are often worked without ever being spotted.",
"DX cluster, two display options (Settings DX cluster). The first drops the colour and the badges on stations you have already worked: they stay in the list, they simply stop competing for your attention. The second colours every callsign you have not worked on this band and this mode, even when the entity itself is long since confirmed — the view for filling slots rather than chasing new ones. Both apply to the cluster list and to the band map, so the two panels always agree.",
"DX cluster, clearer at a glance. A novelty now FILLS the cell that carries it — a filled Band cell means new band, a filled Pfx cell means new prefix, a filled County cell means new county — instead of only tinting the text. A US County column joins the list, resolved offline from the ULS database. The station-not-worked-here highlight is its own NEW CALL status with its own filter chip, no longer borrowed from NEW SLOT, which means something narrower. The two display options moved from Preferences into the cluster filter panel, next to Hide worked. The Locator column is now labelled Spotter locator, because that is what a cluster line actually carries."
"DX cluster, clearer at a glance. A novelty now FILLS the cell that carries it — a filled Band cell means new band, a filled Pfx cell means new prefix, a filled County cell means new county — instead of only tinting the text. A US County column joins the list, resolved offline from the ULS database. The station-not-worked-here highlight is its own NEW CALL status with its own filter chip, no longer borrowed from NEW SLOT, which means something narrower. The two display options moved from Preferences into the cluster filter panel, next to Hide worked. The Locator column is now labelled Spotter locator, because that is what a cluster line actually carries.",
"Web publishing: the published page sorts properly. The Date column would not sort at all, and neither would callsigns starting with a digit — the script read a number from the front of the text, so every date in the same year counted as equal. A third click on a header now puts the table back in the order it was published in, and an arrow shows which way a column is pointing."
],
"fr": [
"Ouvertures de bande : OpsLog te signale désormais l ouverture du 6, du 4 ou du 2 m. Il surveille les spots qui arrivent déjà de tes clusters et du RBN — plusieurs stations différentes apparaissant à distance de saut simple (5002400 km) dans le même secteur d azimut en quelques minutes, c est la signature de l Es, et rien d autre n y ressemble. Un message par bande et par ouverture, avec le secteur et la distance typique. Une ouverture hors saison est annoncée quand même, et signalée comme inhabituelle : ce sont celles qu il ne faut surtout pas manquer. Rien à configurer — mais la qualité dépend d avoir un flux cluster ou RBN qui porte des spots VHF, et les ouvertures 2 m sont souvent travaillées sans jamais être spottées.",
"Cluster DX, deux options d affichage (Paramètres Cluster DX). La première enlève la couleur et les badges sur les stations déjà contactées : elles restent dans la liste, elles cessent simplement d attirer l œil. La seconde colore tout indicatif non contacté sur cette bande et ce mode, même si l entité est confirmée depuis longtemps — la vue pour remplir des slots plutôt que pour chasser du nouveau. Les deux s appliquent à la liste cluster et au bandmap, les deux panneaux restent donc cohérents.",
"Cluster DX, plus lisible d un coup d œil. Une nouveauté REMPLIT désormais la cellule qui la porte — cellule Bande remplie = nouvelle bande, cellule Préf. remplie = nouveau préfixe, cellule Comté remplie = nouveau comté — au lieu de seulement colorer le texte. Une colonne Comté US rejoint la liste, résolue hors ligne depuis la base ULS. La mise en couleur des stations non contactées ici devient un statut CALL NEUF à part entière, avec sa propre puce de filtre, au lieu d emprunter NOUV SLOT qui veut dire autre chose. Les deux options d affichage passent des Préférences au panneau de filtres du cluster, à côté de Hide worked. La colonne Locator s appelle maintenant Locator du spotter, puisque c est ce qu une ligne de cluster porte réellement."
"Cluster DX, plus lisible d un coup d œil. Une nouveauté REMPLIT désormais la cellule qui la porte — cellule Bande remplie = nouvelle bande, cellule Préf. remplie = nouveau préfixe, cellule Comté remplie = nouveau comté — au lieu de seulement colorer le texte. Une colonne Comté US rejoint la liste, résolue hors ligne depuis la base ULS. La mise en couleur des stations non contactées ici devient un statut CALL NEUF à part entière, avec sa propre puce de filtre, au lieu d emprunter NOUV SLOT qui veut dire autre chose. Les deux options d affichage passent des Préférences au panneau de filtres du cluster, à côté de Hide worked. La colonne Locator s appelle maintenant Locator du spotter, puisque c est ce qu une ligne de cluster porte réellement.",
"Publication web : la page publiée se trie correctement. La colonne Date ne se triait pas du tout, ni les indicatifs commençant par un chiffre — le script lisait un nombre au début du texte, donc toutes les dates d une même année se valaient. Un troisième clic sur un en-tête remet le tableau dans l ordre de publication, et une flèche indique le sens du tri."
]
},
{
+29 -9
View File
@@ -233,6 +233,9 @@ th,td{padding:.45rem .6rem;text-align:left;border-bottom:1px solid var(--line);w
th{position:sticky;top:0;background:var(--head);font-size:.72rem;letter-spacing:.05em;
text-transform:uppercase;color:var(--mut);cursor:pointer;user-select:none}
th:hover{color:var(--fg)}
th::after{content:'';font-size:.7em;opacity:.7}
th[data-asc="1"]::after{content:' \25B2'}
th[data-asc="0"]::after{content:' \25BC'}
tbody tr:nth-child(even){background:var(--zebra)}
tbody tr:last-child td{border-bottom:0}
td.call{font-family:ui-monospace,Consolas,monospace;font-weight:700;color:var(--accent)}
@@ -248,7 +251,9 @@ td.call{font-family:ui-monospace,Consolas,monospace;font-weight:700;color:var(--
}
b.WriteString(`</tr></thead><tbody>`)
for i := range qsos {
b.WriteString(`<tr>`)
// The row's position as published. Sorting is a view on top of it, so a
// third click can put the table back the way the operator first saw it.
b.WriteString(`<tr data-i="` + strconv.Itoa(i) + `">`)
for _, c := range cols {
cls := ""
if c.Key == "callsign" {
@@ -262,23 +267,38 @@ td.call{font-family:ui-monospace,Consolas,monospace;font-weight:700;color:var(--
<p class="foot">Generated by OpsLog</p>
</div>
<script>
// Click a header to sort. Kept tiny and dependency-free: the page has to work
// offline and on any hosting.
// Click a header to sort: ascending, descending, then back to the published
// order. Kept tiny and dependency-free — the page has to work offline and on any
// hosting, so no library is loaded.
//
// NUM is deliberately strict: the whole cell must be a number. parseFloat alone
// accepts a numeric PREFIX, so "2026-08-10" became 2026 and every date in a year
// compared equal — the date column looked as though it simply would not sort.
// Callsigns starting with a digit (8B81SU) hit the same trap.
var NUM=/^[+-]?\d+(\.\d+)?$/;
document.querySelectorAll('th').forEach(function(th,i){
th.addEventListener('click',function(){
var tb=th.closest('table').tBodies[0],
rows=Array.prototype.slice.call(tb.rows),
asc=th.dataset.asc!=='1';
cur=th.dataset.asc,
next=cur===undefined?'1':(cur==='1'?'0':'');
if(next===''){
// Third click: restore the order the page was published in.
rows.sort(function(a,b){return a.dataset.i-b.dataset.i});
}else{
var asc=next==='1';
rows.sort(function(a,b){
var x=a.cells[i].textContent.trim(), y=b.cells[i].textContent.trim(),
nx=parseFloat(x), ny=parseFloat(y),
n=!isNaN(nx)&&!isNaN(ny)&&x!==''&&y!=='';
var c=n?(nx-ny):x.localeCompare(y);
var x=a.cells[i].textContent.trim(), y=b.cells[i].textContent.trim();
// Blanks always sink, whichever way the column is pointing: an empty
// cell is missing data, not the smallest value.
if(x===''||y==='') return x===y?0:(x===''?1:-1);
var c=NUM.test(x)&&NUM.test(y)?(parseFloat(x)-parseFloat(y)):x.localeCompare(y);
return asc?c:-c;
});
}
rows.forEach(function(r){tb.appendChild(r)});
th.closest('tr').querySelectorAll('th').forEach(function(o){delete o.dataset.asc});
th.dataset.asc=asc?'1':'0';
if(next!=='') th.dataset.asc=next;
});
});
</script>
+49
View File
@@ -0,0 +1,49 @@
package webpub
import (
"strings"
"testing"
"time"
"hamlog/internal/qso"
)
// The published page carries its own sort script, so a regression there is
// silent: the table still renders, it just sorts wrongly. These pin the two
// things that were actually broken in the field.
func renderSample(t *testing.T) string {
t.Helper()
cfg := Config{Columns: []string{"qso_date", "callsign", "freq"}}
cfg.Normalise()
qsos := []qso.QSO{
{Callsign: "8B81SU", QSODate: time.Date(2026, 8, 10, 9, 56, 0, 0, time.UTC)},
{Callsign: "LZ8NG", QSODate: time.Date(2025, 12, 31, 23, 1, 0, 0, time.UTC)},
}
return string(renderHTML(cfg, columnsFor(cfg.Columns), qsos, "F4BPO"))
}
// A third click restores the published order, which is only possible if each row
// remembers where it started.
func TestRowsCarryTheirPublishedIndex(t *testing.T) {
html := renderSample(t)
for _, want := range []string{`<tr data-i="0">`, `<tr data-i="1">`} {
if !strings.Contains(html, want) {
t.Errorf("published page is missing %s — the reset-to-original click cannot work", want)
}
}
}
// parseFloat accepts a numeric PREFIX, so "2026-08-10" became 2026 and every
// date in the same year compared equal: the Date column looked unsortable.
// Callsigns starting with a digit ("8B81SU") hit the same trap. The whole cell
// must match for a numeric comparison to be used.
func TestSortScriptRejectsNumericPrefixes(t *testing.T) {
html := renderSample(t)
if !strings.Contains(html, `var NUM=/^[+-]?\d+(\.\d+)?$/;`) {
t.Error("the anchored numeric test is gone; a bare parseFloat makes dates and 8-prefixed calls sort as equal")
}
if strings.Contains(html, "n=!isNaN(nx)&&!isNaN(ny)") {
t.Error("the old prefix-tolerant numeric detection is back")
}
}