From 34af4935fc44221969dbcd0d3c865edf240e450e Mon Sep 17 00:00:00 2001 From: Michael van Laar Date: Tue, 5 May 2026 15:04:10 +0200 Subject: [PATCH 1/2] Add tag management dialog with bulk operations Introduces a new "Manage tags" dialog accessible from the main menu. The dialog lists all tags with usage stats and supports per-tag configure/rename/delete actions as well as bulk color, priority, and rename/merge operations across selected tags. - Add get_all_tags_stats() to RecordStore for tag usage aggregation - Add rename_tag_in_records() helper to TagRenameDialog - Implement TagManagementDialog with search/filter, per-tag actions, and accordion bulk-operation footer (color with Default/Random/Apply, priority, rename/merge) - Register dialog on canvas and wire "Manage tags" menu entry - Add test coverage for get_all_tags_stats() --- tests/test_client_recordstore.py | 32 ++ timetagger/app/dialogs.py | 489 +++++++++++++++++++++++++++++++ timetagger/app/front.py | 1 + timetagger/app/stores.py | 18 ++ 4 files changed, 540 insertions(+) diff --git a/tests/test_client_recordstore.py b/tests/test_client_recordstore.py index 57f8914f..a7322887 100644 --- a/tests/test_client_recordstore.py +++ b/tests/test_client_recordstore.py @@ -352,5 +352,37 @@ def test_deleting_records(): assert len(rs.get_stats(0, 1e15)) == 1 +def test_get_all_tags_stats(): + datastore = DataStoreStub() + rs = RecordStore(datastore) + + # Empty store returns empty dict + assert rs.get_all_tags_stats() == {} + + # Add two records for #p1 + r1 = rs.create("2018-04-23 15:00:00", "2018-04-23 16:00:00", "#p1") # 3600s + rs.put(r1) + r2 = rs.create("2018-04-23 17:00:00", "2018-04-23 17:30:00", "#p1") # 1800s + rs.put(r2) + + # Add one record for #p2 + r3 = rs.create("2018-04-24 10:00:00", "2018-04-24 11:30:00", "#p2") # 5400s + rs.put(r3) + + stats = rs.get_all_tags_stats() + assert set(stats.keys()) == {"#p1", "#p2"} + assert stats["#p1"]["total_t"] == 3600 + 1800 + assert stats["#p1"]["last_t2"] == r2.t2 # r2 ends later than r1 + assert stats["#p2"]["total_t"] == 5400 + assert stats["#p2"]["last_t2"] == r3.t2 + + # Untagged records do not appear in stats + rs.put(rs.create("2018-04-25 10:00:00", "2018-04-25 11:00:00", "")) + rs.put(rs.create("2018-04-25 12:00:00", "2018-04-25 13:00:00", "no tags here")) + stats = rs.get_all_tags_stats() + assert "#untagged" not in stats + assert len(stats) == 2 + + if __name__ == "__main__": run_tests(globals()) diff --git a/timetagger/app/dialogs.py b/timetagger/app/dialogs.py index 51dbb3ce..5241cc30 100644 --- a/timetagger/app/dialogs.py +++ b/timetagger/app/dialogs.py @@ -460,6 +460,7 @@ def open(self): ("\uf0a1", True, whatsnew_notify, whatsnew, whatsnew_url), (None, store_valid, False, "Manage", None), ("\uf002", store_valid, False, "Search", self._search), + ("\uf02c", store_valid, False, "Manage tags", self._manage_tags), ("\uf56f", store_valid, False, "Import records", self._import), ("\uf56e", store_valid, False, "Export all records", self._export), (None, True, False, "User", None), @@ -540,6 +541,10 @@ def _import(self): self.close() self._canvas.import_dialog.open() + def _manage_tags(self): + self.close() + self._canvas.tag_management_dialog.open() + class TimeSelectionDialog(BaseDialog): """Dialog to show a popup for selecting the time range.""" @@ -2551,6 +2556,38 @@ def _really_replace_all(self): self._button_replace_comfirm.disabled = True window.setTimeout(self._hide_confirm_button, 500) + def rename_tag_in_records(self, tag_from, tag_to): + """Replace or remove a single tag across all matching records. + tag_to=None strips the tag token; otherwise it replaces it. + """ + search_tags = [tag_from] + replacement_tags = [] if tag_to is None else [tag_to] + for record in window.store.records.get_dump(): + tags = window.store.records.tags_from_record(record) + if tag_from not in tags: + continue + _, parts = utils.get_tags_and_parts_from_string(record.ds) + new_parts = [] + replacement_made = False + for part in parts: + if part.startswith("#") and ( + part in search_tags or part in replacement_tags + ): + if not replacement_made: + replacement_made = True + if len(replacement_tags) > 0: + new_parts.push(replacement_tags[0]) + else: + new_parts.push(part) + record.ds = "".join(new_parts) + window.store.records.put(record) + if tag_to is not None: + info = window.store.settings.get_tag_info(tag_from) + window.store.settings.set_tag_info(tag_from, {}) + window.store.settings.set_tag_info(tag_to, info) + else: + window.store.settings.set_tag_info(tag_from, {}) + class SearchDialog(BaseDialog): """Dialog to search for records and tags.""" @@ -4482,3 +4519,455 @@ def on_notificationclick(self, message_event): elif event.action == "break": self._set_state("break") self._play_sound("wind") + + +class TagManagementDialog(BaseDialog): + """Dialog to list and manage all tags across all records.""" + + def open(self, callback=None): + self.maindiv.innerHTML = """ +

  Manage tags + +

+
+ +
+
+
+
+ Tags exist only while used in records — to create a tag, + include it in a record's description. +
+ + """ + + close_but = self.maindiv.children[0].children[-1] + close_but.onclick = self.close + + search_div = self.maindiv.children[1] + self._search_input = search_div.children[0] + self._search_input.oninput = self._on_search + + self._list_div = document.getElementById("tmgmt-list") + + footer = document.getElementById("tmgmt-footer") + # children[0] = headline, children[1] = toggle row, + # children[2] = rename body, children[3] = color body, children[4] = priority body + + toggle_row = footer.children[1] + self._rename_toggle = toggle_row.children[0] + self._color_toggle = toggle_row.children[1] + self._priority_toggle = toggle_row.children[2] + self._rename_toggle.onclick = self._make_accordion_toggle("tmgmt-rename-body", self._rename_toggle) + self._color_toggle.onclick = self._make_accordion_toggle("tmgmt-color-body", self._color_toggle) + self._priority_toggle.onclick = self._make_accordion_toggle("tmgmt-priority-body", self._priority_toggle) + self._rename_toggle.disabled = True + self._color_toggle.disabled = True + self._priority_toggle.disabled = True + + rename_body = footer.children[2] + rename_inner = rename_body.children[0] + self._bulk_target = rename_inner.children[1] + self._bulk_button = rename_inner.children[2] + self._bulk_button.onclick = self._bulk_rename + + color_body = footer.children[3] + color_inner = color_body.children[0] + self._bulk_color_input = color_inner.children[1] + self._bulk_color_input.oninput = self._on_bulk_color_input + self._bulk_color_default_button = color_inner.children[2] + self._bulk_color_default_button.onclick = self._bulk_set_default_color + self._bulk_color_random_button = color_inner.children[3] + self._bulk_color_random_button.onclick = self._bulk_set_random_color + self._bulk_color_button = color_inner.children[4] + self._bulk_color_button.onclick = self._bulk_set_color + + color_grid = document.getElementById("tmgmt-color-swatches") + color_grid.style.gridTemplateColumns = "auto ".repeat(utils.PALETTE_COLS) + for hex in utils.PALETTE2: + swatch = document.createElement("span") + swatch.style.background = hex + swatch.style.width = "24px" + swatch.style.height = "24px" + swatch.style.display = "inline-block" + swatch.style.borderRadius = "3px" + swatch.style.cursor = "pointer" + self._make_swatch_handler(swatch, hex) + color_grid.appendChild(swatch) + + priority_body = footer.children[4] + priority_inner = priority_body.children[0] + self._bulk_priority_select = priority_inner.children[1] + self._bulk_priority_button = priority_inner.children[2] + self._bulk_priority_button.onclick = self._bulk_set_priority + + self._load_tags() + super().open(callback) + + def _load_tags(self): + stats = window.store.records.get_all_tags_stats() + self._list_div.innerHTML = "" + + tags = [] + for tag in stats.keys(): + tags.push(tag) + tags.sort() + + if len(tags) == 0: + empty = document.createElement("div") + empty.style.color = "#888" + empty.style.padding = "1em 0" + empty.innerText = "No tags found" + self._list_div.appendChild(empty) + return + + for tag in tags: + self._render_tag_row(tag, stats[tag]) + + self._update_empty_state() + + def _render_tag_row(self, tag, stat): + color = window.store.settings.get_color_for_tag(tag) + info = window.store.settings.get_tag_info(tag) + priority = info.get("priority", 0) or 0 + total_t = stat["total_t"] + last_t2 = stat["last_t2"] + + hours = int(total_t) // 3600 + minutes = (int(total_t) % 3600) // 60 + if hours > 0: + time_str = f"{hours}h {minutes:02.0f}m" + else: + time_str = f"{minutes}m" + + if last_t2 > 0: + date_str = "Last: " + dt.time2str(last_t2).split("T")[0] + else: + date_str = "\u2014" + + row = document.createElement("div") + row.setAttribute("data-tag", tag) + row.style.display = "flex" + row.style.alignItems = "center" + row.style.flexWrap = "wrap" + row.style.gap = "0.3em" + row.style.padding = "3px 0" + row.style.borderBottom = "1px solid #eee" + + cb = document.createElement("input") + cb.type = "checkbox" + cb.onchange = self._on_checkbox_change + row.appendChild(cb) + + label = document.createElement("span") + label.innerText = tag + label.style.color = color + label.style.fontWeight = "bold" + label.style.minWidth = "120px" + label.style.flex = "1" + row.appendChild(label) + + if priority == 2: + prio_badge = document.createElement("span") + prio_badge.innerText = "Secondary" + prio_badge.title = "Secondary priority" + prio_badge.style.fontSize = "0.72em" + prio_badge.style.color = "#aaa" + prio_badge.style.border = "1px solid #ddd" + prio_badge.style.borderRadius = "3px" + prio_badge.style.padding = "0 4px" + prio_badge.style.whiteSpace = "nowrap" + row.appendChild(prio_badge) + + hours_span = document.createElement("span") + hours_span.innerText = time_str + hours_span.title = "Total time tracked with this tag across all records" + hours_span.style.color = "#888" + hours_span.style.minWidth = "60px" + row.appendChild(hours_span) + + date_span = document.createElement("span") + date_span.innerText = date_str + date_span.title = "Date this tag was last used in any record" + date_span.style.color = "#888" + date_span.style.minWidth = "90px" + row.appendChild(date_span) + + conf_but = document.createElement("button") + conf_but.type = "button" + conf_but.innerHTML = "" + conf_but.title = "Configure" + conf_but.onclick = self._make_configure_handler(tag) + row.appendChild(conf_but) + + ren_but = document.createElement("button") + ren_but.type = "button" + ren_but.innerHTML = "" + ren_but.title = "Rename" + ren_but.onclick = self._make_rename_handler(tag) + row.appendChild(ren_but) + + del_but = document.createElement("button") + del_but.type = "button" + del_but.innerHTML = "" + del_but.title = "Delete" + del_but.onclick = self._make_delete_handler(tag, row, del_but) + row.appendChild(del_but) + + self._list_div.appendChild(row) + + def _make_configure_handler(self, tag): + def handler(): + self._canvas.tag_dialog.open(tag, self._reload_after_action) + + return handler + + def _make_rename_handler(self, tag): + def handler(): + self._canvas.tag_rename_dialog.open([tag], self._reload_after_action) + + return handler + + def _make_delete_handler(self, tag, row, del_but): + def handler(): + del_but.style.display = "none" + + confirm_but = document.createElement("button") + confirm_but.type = "button" + confirm_but.innerText = "Confirm delete" + confirm_but.style.color = "red" + + cancel_but = document.createElement("button") + cancel_but.type = "button" + cancel_but.innerText = "Cancel" + + def do_delete(): + self._canvas.tag_rename_dialog.rename_tag_in_records(tag, None) + self._reload_after_action() + + def cancel(): + row.removeChild(confirm_but) + row.removeChild(cancel_but) + del_but.style.display = "" + + confirm_but.onclick = do_delete + cancel_but.onclick = cancel + row.appendChild(confirm_but) + row.appendChild(cancel_but) + + return handler + + def _make_accordion_toggle(self, body_id, button): + def handler(): + target = document.getElementById(body_id) + is_visible = target.style.display != "none" + for bid in ["tmgmt-rename-body", "tmgmt-color-body", "tmgmt-priority-body"]: + document.getElementById(bid).style.display = "none" + for btn in [self._rename_toggle, self._color_toggle, self._priority_toggle]: + btn.style.fontWeight = "normal" + btn.style.boxShadow = "none" + if not is_visible: + target.style.display = "block" + button.style.fontWeight = "bold" + button.style.boxShadow = "inset 0 -2px 0 #555" + + return handler + + def _make_swatch_handler(self, swatch, hex): + swatch.onclick = lambda: self._set_bulk_color_from_swatch(hex) + + def _set_bulk_color_from_swatch(self, hex): + self._bulk_color_input.value = hex + self._bulk_color_input.style.borderColor = hex + + def _on_checkbox_change(self): + has_checked = False + for i in range(self._list_div.children.length): + row = self._list_div.children[i] + if row.getAttribute("data-tag") is None: + continue + cb = row.children[0] + if cb.checked: + has_checked = True + break + self._rename_toggle.disabled = not has_checked + self._color_toggle.disabled = not has_checked + self._priority_toggle.disabled = not has_checked + + def _on_search(self): + query = self._search_input.value.lower() + for i in range(self._list_div.children.length): + row = self._list_div.children[i] + if row.getAttribute("data-tag") is None: + continue + tag = row.getAttribute("data-tag") + if query in tag: + row.style.display = "flex" + else: + row.style.display = "none" + self._update_empty_state() + + def _update_empty_state(self): + empty_id = "tag-mgmt-empty" + existing = document.getElementById(empty_id) + if existing: + existing.parentNode.removeChild(existing) + + has_visible = False + for i in range(self._list_div.children.length): + row = self._list_div.children[i] + if row.getAttribute("data-tag") is None: + continue + if row.style.display != "none": + has_visible = True + break + + if not has_visible: + empty = document.createElement("div") + empty.id = empty_id + empty.style.color = "#888" + empty.style.padding = "1em 0" + empty.innerText = "No tags found" + self._list_div.appendChild(empty) + + def _reload_after_action(self): + self._search_input.value = "" + for bid in ["tmgmt-rename-body", "tmgmt-color-body", "tmgmt-priority-body"]: + el = document.getElementById(bid) + if el: + el.style.display = "none" + self._rename_toggle.disabled = True + self._color_toggle.disabled = True + self._priority_toggle.disabled = True + self._bulk_target.value = "" + self._bulk_button.innerText = "Rename" + self._bulk_button.onclick = self._bulk_rename + self._bulk_color_input.value = "" + self._bulk_color_input.style.borderColor = "#eee" + self._bulk_priority_select.value = "1" + self._load_tags() + + def _get_selected_tags(self): + selected = [] + for i in range(self._list_div.children.length): + row = self._list_div.children[i] + t = row.getAttribute("data-tag") + if t is None: + continue + cb = row.children[0] + if cb.checked: + selected.push(t) + return selected + + def _bulk_set_default_color(self): + clr = window.front.COLORS.acc_clr + self._bulk_color_input.value = clr + self._bulk_color_input.style.borderColor = clr + + def _bulk_set_random_color(self): + clr = "#" + Math.floor(Math.random() * 16777215).toString(16) + self._bulk_color_input.value = clr + self._bulk_color_input.style.borderColor = clr + + def _on_bulk_color_input(self): + clr = self._bulk_color_input.value.strip() + if clr: + self._bulk_color_input.style.borderColor = clr + else: + self._bulk_color_input.style.borderColor = "#eee" + + def _bulk_set_color(self): + clr = self._bulk_color_input.value.strip() + selected = self._get_selected_tags() + if len(selected) == 0: + return + default_clr = window.front.COLORS.acc_clr + for tag in selected: + info = window.store.settings.get_tag_info(tag) + info["color"] = "" if clr == default_clr else clr + window.store.settings.set_tag_info(tag, info) + self._reload_after_action() + + def _bulk_set_priority(self): + prio = int(self._bulk_priority_select.value) + selected = self._get_selected_tags() + if len(selected) == 0: + return + for tag in selected: + info = window.store.settings.get_tag_info(tag) + info["priority"] = 0 if prio == 1 else prio + window.store.settings.set_tag_info(tag, info) + self._reload_after_action() + + def _bulk_rename(self): + target = self._bulk_target.value.strip() + if not target: + self._bulk_target.focus() + return + + tags, _ = utils.get_tags_and_parts_from_string(target) + if len(tags) != 1: + self._bulk_target.style.borderColor = "red" + return + self._bulk_target.style.borderColor = "" + tag_to = tags[0] + + selected = self._get_selected_tags() + if len(selected) == 0: + return + + n = len(selected) + self._bulk_button.innerText = f"Confirm renaming {n} tag(s) to {tag_to}" + self._bulk_button.onclick = self._make_bulk_confirm_handler(selected, tag_to) + + def _make_bulk_confirm_handler(self, selected, tag_to): + def handler(): + for tag_from in selected: + self._canvas.tag_rename_dialog.rename_tag_in_records( + tag_from, tag_to + ) + self._reload_after_action() + + return handler diff --git a/timetagger/app/front.py b/timetagger/app/front.py index 3910818f..4b738fdf 100644 --- a/timetagger/app/front.py +++ b/timetagger/app/front.py @@ -174,6 +174,7 @@ def __init__(self, canvas): self.report_dialog = dialogs.ReportDialog(self) self.tag_preset_dialog = dialogs.TagPresetsDialog(self) self.tag_rename_dialog = dialogs.TagRenameDialog(self) + self.tag_management_dialog = dialogs.TagManagementDialog(self) self.search_dialog = dialogs.SearchDialog(self) self.export_dialog = dialogs.ExportDialog(self) self.import_dialog = dialogs.ImportDialog(self) diff --git a/timetagger/app/stores.py b/timetagger/app/stores.py index fc94a37d..cff14d50 100644 --- a/timetagger/app/stores.py +++ b/timetagger/app/stores.py @@ -401,6 +401,24 @@ def tags_from_record(self, record): else: return tags + def get_all_tags_stats(self): + """Get stats for all tags across all records, ignoring time range. + Returns dict of tag -> {total_t, last_t2}. Excludes #untagged. + """ + result = {} + for record in self.get_dump(): + tags = self.tags_from_record(record) + if tags[0] == "#untagged": + continue + duration = max(0, record.t2 - record.t1) + for tag in tags: + if tag not in result: + result[tag] = {"total_t": 0, "last_t2": 0} + result[tag]["total_t"] += duration + if record.t2 > result[tag]["last_t2"]: + result[tag]["last_t2"] = record.t2 + return result + def _normalize_more(self, items): """Ensure that t1 <= t2""" for i in range(len(items)): From 32b4b9aa60d7e89738a85de20271b546bbd6090f Mon Sep 17 00:00:00 2001 From: Michael van Laar Date: Tue, 5 May 2026 17:43:26 +0200 Subject: [PATCH 2/2] Address Copilot PR review recommendations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix running record duration in get_all_tags_stats() — records where t1 == t2 now use dt.now() as effective_t2 instead of producing 0 s - Add count field to get_all_tags_stats() stats dict - Fix random color hex padding in _bulk_set_random_color() to always produce 6-digit hex strings - Add bulk_rename_tags_in_records() for single-pass O(records) bulk rename that avoids repeated settings overwrites and duplicate tags - Update _make_bulk_confirm_handler() to use bulk method for multi-tag selections and single method for single-tag selections - Add select-all checkbox above tag list with full state sync - Extend test suite for get_all_tags_stats() (count, running records) --- tests/test_client_recordstore.py | 18 +++++- timetagger/app/dialogs.py | 95 ++++++++++++++++++++++++++++++-- timetagger/app/stores.py | 13 +++-- 3 files changed, 115 insertions(+), 11 deletions(-) diff --git a/tests/test_client_recordstore.py b/tests/test_client_recordstore.py index a7322887..7afaa946 100644 --- a/tests/test_client_recordstore.py +++ b/tests/test_client_recordstore.py @@ -353,6 +353,8 @@ def test_deleting_records(): def test_get_all_tags_stats(): + import time + datastore = DataStoreStub() rs = RecordStore(datastore) @@ -372,9 +374,11 @@ def test_get_all_tags_stats(): stats = rs.get_all_tags_stats() assert set(stats.keys()) == {"#p1", "#p2"} assert stats["#p1"]["total_t"] == 3600 + 1800 - assert stats["#p1"]["last_t2"] == r2.t2 # r2 ends later than r1 + assert stats["#p1"]["count"] == 2 + assert stats["#p1"]["last_t2"] == r2["t2"] # r2 ends later than r1 assert stats["#p2"]["total_t"] == 5400 - assert stats["#p2"]["last_t2"] == r3.t2 + assert stats["#p2"]["count"] == 1 + assert stats["#p2"]["last_t2"] == r3["t2"] # Untagged records do not appear in stats rs.put(rs.create("2018-04-25 10:00:00", "2018-04-25 11:00:00", "")) @@ -383,6 +387,16 @@ def test_get_all_tags_stats(): assert "#untagged" not in stats assert len(stats) == 2 + # Running record (t1 == t2): duration comes from current time, not zero + now = int(time.time()) + running = rs.create(now, now, "#running") + rs.put(running) + stats = rs.get_all_tags_stats() + assert "#running" in stats + assert stats["#running"]["total_t"] >= 0 + assert stats["#running"]["last_t2"] >= now + assert stats["#running"]["count"] == 1 + if __name__ == "__main__": run_tests(globals()) diff --git a/timetagger/app/dialogs.py b/timetagger/app/dialogs.py index 5241cc30..2ef13640 100644 --- a/timetagger/app/dialogs.py +++ b/timetagger/app/dialogs.py @@ -2588,6 +2588,58 @@ def rename_tag_in_records(self, tag_from, tag_to): else: window.store.settings.set_tag_info(tag_from, {}) + def bulk_rename_tags_in_records(self, mapping): + """Rename multiple source tags to a single target in one pass over records. + mapping is a dict of {tag_from: tag_to}. Avoids repeated overwrites of + target tag settings that would occur when calling rename_tag_in_records + multiple times. + """ + source_set = {} + for tag_from in mapping.keys(): + source_set[tag_from] = True + + for record in window.store.records.get_dump(): + tags = window.store.records.tags_from_record(record) + has_source = False + for tag in tags: + if source_set.get(tag, False): + has_source = True + break + if not has_source: + continue + + # Pre-seed with existing record tags to avoid duplicating target + added_targets = {} + for tag in tags: + added_targets[tag] = True + + _, parts = utils.get_tags_and_parts_from_string(record.ds) + new_parts = [] + for part in parts: + if part.startswith("#") and source_set.get(part, False): + tag_to = mapping[part] + if not added_targets.get(tag_to, False): + added_targets[tag_to] = True + new_parts.push(tag_to) + # else: target already present, skip to avoid duplicate + else: + new_parts.push(part) + record.ds = "".join(new_parts) + window.store.records.put(record) + + # Transfer settings: keep existing target settings; clear all sources + target_settings_set = False + for tag_from in mapping.keys(): + tag_to = mapping[tag_from] + if not target_settings_set: + target_settings_set = True + existing = window.store.settings.get_tag_info(tag_to) + if not existing.get("color", ""): + info = window.store.settings.get_tag_info(tag_from) + window.store.settings.set_tag_info(tag_to, info) + continue + window.store.settings.set_tag_info(tag_from, {}) + class SearchDialog(BaseDialog): """Dialog to search for records and tags.""" @@ -4533,6 +4585,11 @@ def open(self, callback=None): +
+ + +
@@ -4590,6 +4647,10 @@ def open(self, callback=None): self._search_input = search_div.children[0] self._search_input.oninput = self._on_search + select_all_div = self.maindiv.children[2] + self._select_all_cb = select_all_div.children[0] + self._select_all_cb.onchange = self._on_select_all_change + self._list_div = document.getElementById("tmgmt-list") footer = document.getElementById("tmgmt-footer") @@ -4822,19 +4883,37 @@ def _set_bulk_color_from_swatch(self, hex): self._bulk_color_input.value = hex self._bulk_color_input.style.borderColor = hex + def _on_select_all_change(self): + checked = self._select_all_cb.checked + for i in range(self._list_div.children.length): + row = self._list_div.children[i] + if row.getAttribute("data-tag") is None: + continue + if row.style.display == "none": + continue + row.children[0].checked = checked + self._on_checkbox_change() + def _on_checkbox_change(self): has_checked = False + all_visible_checked = True + has_visible = False for i in range(self._list_div.children.length): row = self._list_div.children[i] if row.getAttribute("data-tag") is None: continue + if row.style.display == "none": + continue + has_visible = True cb = row.children[0] if cb.checked: has_checked = True - break + else: + all_visible_checked = False self._rename_toggle.disabled = not has_checked self._color_toggle.disabled = not has_checked self._priority_toggle.disabled = not has_checked + self._select_all_cb.checked = has_visible and all_visible_checked and has_checked def _on_search(self): query = self._search_input.value.lower() @@ -4847,6 +4926,7 @@ def _on_search(self): row.style.display = "flex" else: row.style.display = "none" + self._select_all_cb.checked = False self._update_empty_state() def _update_empty_state(self): @@ -4874,6 +4954,7 @@ def _update_empty_state(self): def _reload_after_action(self): self._search_input.value = "" + self._select_all_cb.checked = False for bid in ["tmgmt-rename-body", "tmgmt-color-body", "tmgmt-priority-body"]: el = document.getElementById(bid) if el: @@ -4907,7 +4988,8 @@ def _bulk_set_default_color(self): self._bulk_color_input.style.borderColor = clr def _bulk_set_random_color(self): - clr = "#" + Math.floor(Math.random() * 16777215).toString(16) + hex = Math.floor(Math.random() * 16777215).toString(16) + clr = "#" + ("000000" + hex).slice(-6) self._bulk_color_input.value = clr self._bulk_color_input.style.borderColor = clr @@ -4964,10 +5046,15 @@ def _bulk_rename(self): def _make_bulk_confirm_handler(self, selected, tag_to): def handler(): - for tag_from in selected: + if len(selected) == 1: self._canvas.tag_rename_dialog.rename_tag_in_records( - tag_from, tag_to + selected[0], tag_to ) + else: + mapping = {} + for tag_from in selected: + mapping[tag_from] = tag_to + self._canvas.tag_rename_dialog.bulk_rename_tags_in_records(mapping) self._reload_after_action() return handler diff --git a/timetagger/app/stores.py b/timetagger/app/stores.py index cff14d50..42078ce2 100644 --- a/timetagger/app/stores.py +++ b/timetagger/app/stores.py @@ -403,20 +403,23 @@ def tags_from_record(self, record): def get_all_tags_stats(self): """Get stats for all tags across all records, ignoring time range. - Returns dict of tag -> {total_t, last_t2}. Excludes #untagged. + Returns dict of tag -> {total_t, last_t2, count}. Excludes #untagged. """ result = {} + now = dt.now() for record in self.get_dump(): tags = self.tags_from_record(record) if tags[0] == "#untagged": continue - duration = max(0, record.t2 - record.t1) + effective_t2 = now if record.t1 == record.t2 else record.t2 + duration = max(0, effective_t2 - record.t1) for tag in tags: if tag not in result: - result[tag] = {"total_t": 0, "last_t2": 0} + result[tag] = {"total_t": 0, "last_t2": 0, "count": 0} result[tag]["total_t"] += duration - if record.t2 > result[tag]["last_t2"]: - result[tag]["last_t2"] = record.t2 + result[tag]["count"] += 1 + if effective_t2 > result[tag]["last_t2"]: + result[tag]["last_t2"] = effective_t2 return result def _normalize_more(self, items):