package main // Row colouring for the log grid, by QSL / LoTW status — the thing Logger32 does // and the reason an operator can tell at a glance what still needs sending. // // Rules are ORDERED and the first match wins, because a contact is usually // several things at once: one confirmed on LoTW and by card is confirmed, not // "sent, awaiting reply". Putting the order in the data rather than in a chain // of ifs is what lets the settings panel show it in the same order it applies. import ( "encoding/json" "regexp" "strings" ) // RowColorRule is one status and the colour it paints. type RowColorRule struct { ID string `json:"id"` Color string `json:"color"` Enabled bool `json:"enabled"` // Channels this rule looks at: qsl (paper), lotw, eqsl, qrz. // // Empty means ALL of them — what an operator expects from a rule they have // not narrowed, and what keeps a config written before this field meaningful. Channels []string `json:"channels"` } // The QSL channels a rule can be scoped to. var rowColorChannels = []string{"qsl", "lotw", "eqsl", "qrz"} // RowColorSettings is the whole appearance block. type RowColorSettings struct { Enabled bool `json:"enabled"` // Style is how the colour is shown: "bar" paints a stripe down the left edge, // "tint" washes the row, "both" does each. // // A bar is the default because a filled row is a poor signal in a log where // nearly every contact has SOME QSL state: colour that is always present // stops being information and becomes a striped background, with the data // behind it. The stripe says the same thing and costs nothing to read. Style string `json:"style"` // Intensity is the tint strength in percent. Only used by "tint"/"both". Intensity int `json:"intensity"` // BandMapLotw marks stations that upload to LoTW on the band map, with the // same "L" badge the cluster list uses — one visual vocabulary across the two // views rather than a second invention. BandMapLotw bool `json:"bandmap_lotw"` // Configured distinguishes "saved with this off" from "saved before the // option existed", so a new marker can default ON without silently turning // itself back on for an operator who switched it off. Configured bool `json:"configured"` Rules []RowColorRule `json:"rules"` } // The rule ids, in priority order. The frontend matches on these and holds the // labels, so a translated name never has to travel through the settings. // The four categories, in the order they are tested — first match wins. // // "To send" comes FIRST on purpose. A contact can be confirmed on LoTW and // still owe a paper card, and the colour an operator scans for is the one that // means "there is something left to do here". Put after "confirmed", that row // would go green and the card would never be printed. // // "Worked" is the catch-all: a contact with nothing sent, nothing asked for and // nothing back. Off by default — with it on, every remaining row is painted. var rowColorOrder = []string{ "to_send", // requested or queued on a watched channel, not gone out "confirmed", // a watched channel has a confirmation back "sent", // gone out on a watched channel, nothing back yet "worked", // none of the above } // Defaults: green for done, amber for waiting, blue for owed. Deliberately // muted — they are composited at low opacity over a dark grid, and a saturated // value there reads as an error state rather than a status. var rowColorDefaults = map[string]string{ "to_send": "#a855f7", "confirmed": "#16a34a", "sent": "#f59e0b", "worked": "#64748b", } // hexColor guards what reaches the stylesheet. The value is interpolated into a // CSS color-mix() by the grid, so anything that is not plainly a hex colour is // refused rather than passed through. var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) func normRowColors(s RowColorSettings) RowColorSettings { byID := map[string]RowColorRule{} for _, r := range s.Rules { byID[r.ID] = r } out := RowColorSettings{ Enabled: s.Enabled, Style: s.Style, Intensity: s.Intensity, BandMapLotw: s.BandMapLotw, Configured: true, } if !s.Configured { out.BandMapLotw = true // new option, on unless the operator says otherwise } switch out.Style { case "bar", "tint", "both": default: out.Style = "bar" } // Clamped rather than rejected: the value only shapes a colour-mix, and a // number outside the range is a slider that got away, not a fault worth // resetting the operator's whole choice for. if out.Intensity < 5 { out.Intensity = 12 } else if out.Intensity > 45 { out.Intensity = 45 } ok := map[string]bool{} for _, c := range rowColorChannels { ok[c] = true } for _, id := range rowColorOrder { r := byID[id] r.ID = id if !hexColor.MatchString(strings.TrimSpace(r.Color)) { r.Color = rowColorDefaults[id] } // Keep only channels we know, in the canonical order, de-duplicated. want := map[string]bool{} for _, c := range r.Channels { want[strings.ToLower(strings.TrimSpace(c))] = true } chans := []string{} for _, c := range rowColorChannels { if want[c] { chans = append(chans, c) } } r.Channels = chans out.Rules = append(out.Rules, r) } return out } // GetRowColors returns the row-colouring configuration, defaults included so the // panel never has to invent one. func (a *App) GetRowColors() RowColorSettings { var s RowColorSettings if raw := a.settingOr(keyRowColors, ""); raw != "" { _ = json.Unmarshal([]byte(raw), &s) } return normRowColors(s) } // SaveRowColors persists it. func (a *App) SaveRowColors(s RowColorSettings) error { b, err := json.Marshal(normRowColors(s)) if err != nil { return err } a.setSetting(keyRowColors, string(b)) return nil }