From d992c8f01bfad364dcb2ee5606c47123e44767d1 Mon Sep 17 00:00:00 2001 From: eOvic Date: Fri, 17 Jul 2026 19:57:58 +0100 Subject: [PATCH] tasks: Track and visualize time spent per task --- ...github.focustimerhq.FocusTimer.gschema.xml | 12 + po/POTFILES | 2 + src/core/database.vala | 2 +- src/core/session-manager.vala | 75 +++++ src/core/stats-entry.vala | 4 +- src/core/stats-manager.vala | 17 +- src/core/time-block-entry.vala | 1 + src/core/time-block.vala | 18 + ...thub.focustimerhq.FocusTimer.gresource.xml | 1 + src/meson.build | 2 + src/migrations/version-4.sql | 7 + src/ui/main/stats/stats-day-page.ui | 29 ++ src/ui/main/stats/stats-day-page.vala | 175 ++++++++++ src/ui/main/stats/stats-month-page.ui | 3 + src/ui/main/stats/stats-month-page.vala | 4 + src/ui/main/stats/stats-week-page.ui | 3 + src/ui/main/stats/stats-week-page.vala | 4 + src/ui/main/stats/widgets/task-breakdown.vala | 309 ++++++++++++++++++ src/ui/main/timer/timer-view.ui | 53 +-- src/ui/main/timer/widgets/task-button.vala | 207 ++++++++++++ src/ui/style.css | 4 + tests/test-database.vala | 3 + tests/test-stats-manager.vala | 59 ++++ 23 files changed, 967 insertions(+), 27 deletions(-) create mode 100644 src/migrations/version-4.sql create mode 100644 src/ui/main/stats/widgets/task-breakdown.vala create mode 100644 src/ui/main/timer/widgets/task-button.vala diff --git a/data/io.github.focustimerhq.FocusTimer.gschema.xml b/data/io.github.focustimerhq.FocusTimer.gschema.xml index 7ae762fc..fd2d673d 100644 --- a/data/io.github.focustimerhq.FocusTimer.gschema.xml +++ b/data/io.github.focustimerhq.FocusTimer.gschema.xml @@ -45,6 +45,18 @@ Pause Pomodoro when screen is locked + + + '' + Current task + Name of the task time is currently tracked against. An empty string means no task. + + + [] + Recently used tasks + Recently used task names, most recent first. + + true diff --git a/po/POTFILES b/po/POTFILES index 07b59ad5..f2fe5ed8 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -131,6 +131,7 @@ src/ui/main/stats/widgets/stats-card.ui src/ui/main/stats/widgets/stats-card.vala src/ui/main/stats/widgets/stats-date-popover.ui src/ui/main/stats/widgets/stats-date-popover.vala +src/ui/main/stats/widgets/task-breakdown.vala src/ui/main/stats/widgets/week-chooser.ui src/ui/main/stats/widgets/week-chooser.vala src/ui/main/timer/compact-timer-view.ui @@ -139,6 +140,7 @@ src/ui/main/timer/menus.ui src/ui/main/timer/timer-view.ui src/ui/main/timer/timer-view.vala src/ui/main/timer/widgets/session-progress-bar.vala +src/ui/main/timer/widgets/task-button.vala src/ui/main/timer/widgets/timer-control-buttons.ui src/ui/main/timer/widgets/timer-control-buttons.vala src/ui/main/timer/widgets/timer-label.ui diff --git a/src/core/database.vala b/src/core/database.vala index 65c72344..9960e77c 100644 --- a/src/core/database.vala +++ b/src/core/database.vala @@ -9,7 +9,7 @@ using GLib; namespace Ft.Database { - private const uint VERSION = 3; + private const uint VERSION = 4; private const string MIGRATIONS_URI = "resource:///io/github/focustimerhq/FocusTimer/migrations"; private const string DATE_FORMAT = "%Y-%m-%d"; diff --git a/src/core/session-manager.vala b/src/core/session-manager.vala index ec3ef69c..7724dd5e 100644 --- a/src/core/session-manager.vala +++ b/src/core/session-manager.vala @@ -34,6 +34,11 @@ namespace Ft */ private const int64 OVERDUE_TIMEOUT = Ft.Interval.HOUR; + /** + * Number of recently used task names kept for suggestions. + */ + private const uint TASK_HISTORY_LIMIT = 10; + private static Ft.SessionManager? instance = null; public Ft.Timer timer { @@ -158,6 +163,38 @@ namespace Ft } } + /** + * Name of the task time is tracked against. An empty string means no task. + * + * The task is stamped onto pomodoro time-blocks as they start. Changing it + * mid-pomodoro re-assigns the whole current time-block to the new task. + */ + [CCode (notify = false)] + public string current_task { + get { + return this._current_task; + } + set { + var task = value != null ? value.strip () : ""; + + if (this._current_task == task) { + return; + } + + this._current_task = task; + + if (this._current_time_block != null && + this._current_time_block.state == Ft.State.POMODORO) + { + this._current_time_block.set_task (task); + this.save.begin (); + } + + this.store_current_task (); + this.notify_property ("current-task"); + } + } + private Ft.Timer _timer; private Ft.Scheduler _scheduler; private Ft.Session? _current_session = null; @@ -165,6 +202,7 @@ namespace Ft private Ft.Gap? _current_gap = null; private Ft.State _current_state = Ft.State.STOPPED; private bool _has_uniform_breaks = false; + private string _current_task = ""; private Ft.Session? next_session = null; private Ft.TimeBlock? next_time_block = null; private Ft.IdleMonitor? idle_monitor = null; @@ -217,6 +255,7 @@ namespace Ft this.timezone_monitor = new Ft.TimeZoneMonitor (); this.timezone_history = new Ft.TimezoneHistory (); this.save_callbacks = {}; + this._current_task = this.settings.get_string ("current-task").strip (); this.settings.changed.connect (this.on_settings_changed); this.timezone_monitor.changed.connect (this.on_timezone_changed); @@ -224,6 +263,34 @@ namespace Ft this.update_session_template (); } + /** + * Persist the current task and keep a list of recently used tasks + * for suggestions. + */ + private void store_current_task () + { + this.settings.set_string ("current-task", this._current_task); + + if (this._current_task == "") { + return; + } + + string[] task_history = { this._current_task }; + + foreach (unowned var task in this.settings.get_strv ("task-history")) + { + if (task != this._current_task && task != "") { + task_history += task; + } + + if (task_history.length >= TASK_HISTORY_LIMIT) { + break; + } + } + + this.settings.set_strv ("task-history", task_history); + } + /** * Sets a default `SessionManager`. * @@ -736,6 +803,13 @@ namespace Ft this._current_gap = gap; this._current_state = state; + if (time_block != null && + time_block.state == Ft.State.POMODORO && + time_block.get_task () == "") + { + time_block.set_task (this._current_task); + } + this.notify_property ("current-time-block"); if (state != previous_state) { @@ -1323,6 +1397,7 @@ namespace Ft time_block.set_time_range (time_block_entry.start_time, time_block_entry.end_time); time_block.set_status (Ft.TimeBlockStatus.from_string (time_block_entry.status)); time_block.set_intended_duration (time_block_entry.intended_duration); + time_block.set_task (time_block_entry.task ?? ""); time_block.entry = time_block_entry; time_blocks_by_id.insert (time_block_entry.id, time_block); diff --git a/src/core/stats-entry.vala b/src/core/stats-entry.vala index 10828bd6..eea1bd67 100644 --- a/src/core/stats-entry.vala +++ b/src/core/stats-entry.vala @@ -18,6 +18,7 @@ namespace Ft public int64 duration { get; set; } public string category { get; set; } public int64 source_id { get; set; default = 0; } + public string task { get; set; default = ""; } static construct { @@ -42,7 +43,8 @@ namespace Ft offset: this.offset, duration: this.duration, category: this.category, - source_id: this.source_id); + source_id: this.source_id, + task: this.task); } } diff --git a/src/core/stats-manager.vala b/src/core/stats-manager.vala index 327e4bb4..a72de2df 100644 --- a/src/core/stats-manager.vala +++ b/src/core/stats-manager.vala @@ -37,6 +37,7 @@ namespace Ft { public string category; public int64 source_id; + public string task; public Segment[] segments; } @@ -285,6 +286,7 @@ namespace Ft { var category = item.category; var source_id = item.source_id; + var task = item.task; var segments = item.segments; var repository = Ft.Database.get_repository (); @@ -331,7 +333,8 @@ namespace Ft entry_index++; if (entry.time == segment.timestamp && - entry.duration == segment.duration) + entry.duration == segment.duration && + entry.task == task) { continue; } @@ -353,6 +356,7 @@ namespace Ft entry.offset = offset; entry.duration = segment.duration; entry.source_id = source_id; + entry.task = task; to_save.append (entry); to_save_count++; @@ -418,6 +422,7 @@ namespace Ft private void track_internal (string category, int64 source_id, + string task, owned Segment[] segments) { if (segments.length == 0) { @@ -428,6 +433,7 @@ namespace Ft Item () { category = category, source_id = source_id, + task = task, segments = segments }); @@ -437,7 +443,8 @@ namespace Ft public void track (string category, int64 timestamp, int64 duration, - int64 source_id = 0) + int64 source_id = 0, + string task = "") { if (Ft.Timestamp.is_undefined (timestamp) || duration <= 0) { return; @@ -445,7 +452,7 @@ namespace Ft var segments = this.split (timestamp, duration); - this.track_internal (category, source_id, segments); + this.track_internal (category, source_id, task, segments); } /** @@ -566,7 +573,9 @@ namespace Ft segments, this.split (time_block.start_time, time_block.duration)); - this.track_internal (category, time_block_entry.id, segments); + var task = category == "pomodoro" ? time_block.get_task () : ""; + + this.track_internal (category, time_block_entry.id, task, segments); } public void track_gap (Ft.Gap gap) diff --git a/src/core/time-block-entry.vala b/src/core/time-block-entry.vala index 938c7ffd..056a218e 100644 --- a/src/core/time-block-entry.vala +++ b/src/core/time-block-entry.vala @@ -15,6 +15,7 @@ namespace Ft public string state { get; set; } public string status { get; set; } public int64 intended_duration { get; set; } + public string task { get; set; default = ""; } internal ulong version = 0; diff --git a/src/core/time-block.vala b/src/core/time-block.vala index ca9c3cb4..a2143ca6 100644 --- a/src/core/time-block.vala +++ b/src/core/time-block.vala @@ -211,6 +211,7 @@ namespace Ft private int changed_freeze_count = 0; private bool changed_is_pending = false; private Ft.TimeBlockMeta meta; + private string task = ""; construct { @@ -722,6 +723,22 @@ namespace Ft } } + public string get_task () + { + return this.task; + } + + /** + * A task does not affect scheduling, so it does not emit `changed`. + */ + public void set_task (string task) + { + if (this.task != task) { + this.task = task; + this.version++; + } + } + /* * Database @@ -765,6 +782,7 @@ namespace Ft this.entry.state = this._state.to_string (); this.entry.status = this.meta.status.to_string (); this.entry.intended_duration = this.meta.intended_duration; + this.entry.task = this.task; this.entry.version = this.version; return this.entry; diff --git a/src/io.github.focustimerhq.FocusTimer.gresource.xml b/src/io.github.focustimerhq.FocusTimer.gresource.xml index afd5fd7e..02e0baa1 100644 --- a/src/io.github.focustimerhq.FocusTimer.gresource.xml +++ b/src/io.github.focustimerhq.FocusTimer.gresource.xml @@ -5,6 +5,7 @@ migrations/version-1.sql migrations/version-2.sql migrations/version-3.sql + migrations/version-4.sql ui/log/log-window.ui diff --git a/src/meson.build b/src/meson.build index 79d4ffde..bcc7fa86 100644 --- a/src/meson.build +++ b/src/meson.build @@ -30,6 +30,7 @@ libft_ui_sources = files( 'ui/main/stats/widgets/month-chooser.vala', 'ui/main/stats/widgets/stats-card.vala', 'ui/main/stats/widgets/stats-date-popover.vala', + 'ui/main/stats/widgets/task-breakdown.vala', 'ui/main/stats/widgets/week-chooser.vala', 'ui/main/stats/stats-day-page.vala', 'ui/main/stats/stats-month-page.vala', @@ -37,6 +38,7 @@ libft_ui_sources = files( 'ui/main/stats/stats-view.vala', 'ui/main/stats/stats-week-page.vala', 'ui/main/timer/widgets/session-progress-bar.vala', + 'ui/main/timer/widgets/task-button.vala', 'ui/main/timer/widgets/timer-control-buttons.vala', 'ui/main/timer/widgets/timer-label.vala', 'ui/main/timer/widgets/timer-progress-bar.vala', diff --git a/src/migrations/version-4.sql b/src/migrations/version-4.sql new file mode 100644 index 00000000..ed8ebafc --- /dev/null +++ b/src/migrations/version-4.sql @@ -0,0 +1,7 @@ +-- Add a task name to time-blocks and stats entries. +-- Tasks are identified by their name. An empty string means no task. + +ALTER TABLE "timeblocks" ADD COLUMN "task" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "stats" ADD COLUMN "task" TEXT NOT NULL DEFAULT ''; + +CREATE INDEX "stats-date-task" ON "stats" ("date", "task") WHERE "task" != ''; diff --git a/src/ui/main/stats/stats-day-page.ui b/src/ui/main/stats/stats-day-page.ui index d9db5087..36946487 100644 --- a/src/ui/main/stats/stats-day-page.ui +++ b/src/ui/main/stats/stats-day-page.ui @@ -122,6 +122,35 @@ + + + + + + vertical + 6 + false + 12 + + + History + start + 0 + + + + + + none + + + + + diff --git a/src/ui/main/stats/stats-day-page.vala b/src/ui/main/stats/stats-day-page.vala index f1510d9e..50515fbf 100644 --- a/src/ui/main/stats/stats-day-page.vala +++ b/src/ui/main/stats/stats-day-page.vala @@ -63,6 +63,12 @@ namespace Ft private unowned Ft.StatsCard interruptions_card; [GtkChild] private unowned Ft.StatsCard break_ratio_card; + [GtkChild] + private unowned Ft.TaskBreakdown task_breakdown; + [GtkChild] + private unowned Gtk.Box history_box; + [GtkChild] + private unowned Gtk.ListBox history_listbox; private int64 _interval = DEFAULT_INTERVAL; private Ft.StatsManager? stats_manager; @@ -75,6 +81,7 @@ namespace Ft private int64 entries_end_time = Ft.Timestamp.UNDEFINED; private string time_format; private Ft.Matrix? histogram_data; + private uint update_lists_timeout_id = 0U; construct { @@ -96,6 +103,8 @@ namespace Ft this.update_time_format (); this.update_histogram_y_spacing (); + this.task_breakdown.set_range (this.date, this.date); + this.stats_manager.entry_saved.connect (this.on_entry_saved); this.stats_manager.entry_deleted.connect (this.on_entry_deleted); @@ -530,6 +539,159 @@ namespace Ft for (var index = 0U; index < entries.count; index++) { this.process_entry ((Ft.StatsEntry) entries.get_index (index)); } + + this.update_lists (entries); + } + + private inline string format_row_time (int64 timestamp) + { + var timezone = this.timezone_history.search (timestamp); + var datetime = Ft.Timestamp.to_datetime (timestamp, timezone); + var row_time_format = Ft.Locale.use_12h_format () ? "%I:%M %p" : "%H:%M"; + var time_string = datetime.format (row_time_format); + + if (time_string.has_prefix ("0")) { + time_string = time_string.substring (1); + } + + return time_string; + } + + private void remove_all_rows (Gtk.ListBox listbox) + { + Gtk.Widget? row; + + while ((row = listbox.get_first_child ()) != null) { + listbox.remove (row); + } + } + + private void append_history_row (int64 start_time, + int64 end_time, + int64 duration, + string task) + { + var time_label = new Gtk.Label ( + "%s – %s".printf (this.format_row_time (start_time), + this.format_row_time (end_time))); + time_label.xalign = 0.0f; + time_label.add_css_class ("numeric"); + time_label.add_css_class ("dim-label"); + + var name_label = new Gtk.Label (task != "" ? task : _("Pomodoro")); + name_label.hexpand = true; + name_label.xalign = 0.0f; + name_label.ellipsize = Pango.EllipsizeMode.END; + + if (task == "") { + name_label.add_css_class ("dim-label"); + } + + var duration_label = new Gtk.Label (Ft.Interval.format_short (duration)); + duration_label.add_css_class ("dim-label"); + duration_label.add_css_class ("numeric"); + + var box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 12); + box.margin_top = 8; + box.margin_bottom = 8; + box.margin_start = 12; + box.margin_end = 12; + box.append (time_label); + box.append (name_label); + box.append (duration_label); + + var row = new Gtk.ListBoxRow (); + row.activatable = false; + row.child = box; + + this.history_listbox.append (row); + } + + /** + * Build the "History" timeline for the day. + * + * History rows are stats entries merged by their source time-block, + * so a pomodoro split by pauses shows as a single row. + */ + private void update_lists (Gom.ResourceGroup? entries) + { + this.remove_all_rows (this.history_listbox); + + var date_string = Ft.Database.serialize_date (this.date); + + int64[] group_start_times = {}; + int64[] group_end_times = {}; + int64[] group_durations = {}; + string[] group_tasks = {}; + int64 last_source_id = 0; + + var entries_count = entries != null ? entries.count : 0U; + + for (var index = 0U; index < entries_count; index++) + { + var entry = (Ft.StatsEntry) entries.get_index (index); + var category = Ft.StatsCategory.from_string (entry.category); + + if (category != Ft.StatsCategory.POMODORO || + entry.date != date_string || + entry.duration <= 0) + { + continue; + } + + var task = entry.task ?? ""; + + // Merge entries originating from the same time-block. + var group_index = group_start_times.length - 1; + + if (group_index >= 0 && + entry.source_id != 0 && + entry.source_id == last_source_id) + { + group_end_times[group_index] = entry.time + entry.duration; + group_durations[group_index] += entry.duration; + } + else { + group_start_times += entry.time; + group_end_times += entry.time + entry.duration; + group_durations += entry.duration; + group_tasks += task; + } + + last_source_id = entry.source_id; + } + + for (var i = 0; i < group_start_times.length; i++) + { + this.append_history_row (group_start_times[i], + group_end_times[i], + group_durations[i], + group_tasks[i]); + } + + this.history_box.visible = group_start_times.length > 0; + } + + private void queue_update_lists () + { + if (this.update_lists_timeout_id != 0) { + return; + } + + this.update_lists_timeout_id = GLib.Timeout.add ( + 500, + () => { + this.update_lists_timeout_id = 0; + + this.fetch_entries.begin ( + (obj, res) => { + this.update_lists (this.fetch_entries.end (res)); + }); + + return GLib.Source.REMOVE; + }); + GLib.Source.set_name_by_id (this.update_lists_timeout_id, + "Ft.StatsDayPage.update_lists"); } private void invalidate_histogram_data () @@ -555,6 +717,10 @@ namespace Ft if (this.histogram_data != null) { this.process_entry (entry, 1); } + + if (entry.date == Ft.Database.serialize_date (this.date)) { + this.queue_update_lists (); + } } private void on_entry_deleted (Ft.StatsEntry entry) @@ -562,6 +728,10 @@ namespace Ft if (this.histogram_data != null) { this.process_entry (entry, -1); } + + if (entry.date == Ft.Database.serialize_date (this.date)) { + this.queue_update_lists (); + } } private void on_timezone_history_changed () @@ -611,6 +781,11 @@ namespace Ft public override void dispose () { + if (this.update_lists_timeout_id != 0) { + GLib.Source.remove (this.update_lists_timeout_id); + this.update_lists_timeout_id = 0; + } + this.histogram.set_format_value_func (null); this.stats_manager.entry_saved.disconnect (this.on_entry_saved); this.stats_manager.entry_deleted.disconnect (this.on_entry_deleted); diff --git a/src/ui/main/stats/stats-month-page.ui b/src/ui/main/stats/stats-month-page.ui index 6e8b5086..124a50fd 100644 --- a/src/ui/main/stats/stats-month-page.ui +++ b/src/ui/main/stats/stats-month-page.ui @@ -59,6 +59,9 @@ + + + diff --git a/src/ui/main/stats/stats-month-page.vala b/src/ui/main/stats/stats-month-page.vala index 637628fd..ad788de6 100644 --- a/src/ui/main/stats/stats-month-page.vala +++ b/src/ui/main/stats/stats-month-page.vala @@ -26,6 +26,8 @@ namespace Ft private unowned Ft.StatsCard interruptions_card; [GtkChild] private unowned Ft.StatsCard break_ratio_card; + [GtkChild] + private unowned Ft.TaskBreakdown task_breakdown; private Ft.StatsManager? stats_manager; private GLib.Date display_start_date; @@ -67,6 +69,8 @@ namespace Ft this.display_end_date = Ft.Timeframe.WEEK.normalize_date (month_end_date); this.display_end_date.add_days (6U); + this.task_breakdown.set_range (month_start_date, month_end_date); + this.populate.begin ( (obj, res) => { this.populate.end (res); diff --git a/src/ui/main/stats/stats-week-page.ui b/src/ui/main/stats/stats-week-page.ui index 10cb2a42..68f95a8b 100644 --- a/src/ui/main/stats/stats-week-page.ui +++ b/src/ui/main/stats/stats-week-page.ui @@ -63,6 +63,9 @@ + + + diff --git a/src/ui/main/stats/stats-week-page.vala b/src/ui/main/stats/stats-week-page.vala index 51c8fba9..f0dc8da1 100644 --- a/src/ui/main/stats/stats-week-page.vala +++ b/src/ui/main/stats/stats-week-page.vala @@ -24,6 +24,8 @@ namespace Ft private unowned Ft.StatsCard interruptions_card; [GtkChild] private unowned Ft.StatsCard break_ratio_card; + [GtkChild] + private unowned Ft.TaskBreakdown task_breakdown; private Ft.StatsManager? stats_manager; private GLib.Date start_date; @@ -58,6 +60,8 @@ namespace Ft this.end_date = this.start_date.copy (); this.end_date.add_days (6U); + this.task_breakdown.set_range (this.start_date, this.end_date); + this.populate.begin ( (obj, res) => { this.populate.end (res); diff --git a/src/ui/main/stats/widgets/task-breakdown.vala b/src/ui/main/stats/widgets/task-breakdown.vala new file mode 100644 index 00000000..5eafa0c9 --- /dev/null +++ b/src/ui/main/stats/widgets/task-breakdown.vala @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2026 focus-timer contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +using GLib; + + +namespace Ft +{ + /** + * A "Tasks" section listing time spent per task within a date range. + * + * Used by the day / week / month stats pages. The widget hides itself + * when there are no named tasks in the range. + */ + public class TaskBreakdown : Gtk.Box + { + private Ft.StatsManager? stats_manager = null; + private Gtk.ListBox? listbox = null; + private string first_date_string = ""; + private string last_date_string = ""; + private uint update_timeout_id = 0U; + + construct + { + this.orientation = Gtk.Orientation.VERTICAL; + this.spacing = 6; + this.visible = false; + this.margin_bottom = 12; + + var heading = new Gtk.Label (_("Tasks")); + heading.halign = Gtk.Align.START; + heading.xalign = 0.0f; + heading.add_css_class ("heading"); + + this.listbox = new Gtk.ListBox (); + this.listbox.selection_mode = Gtk.SelectionMode.NONE; + this.listbox.add_css_class ("boxed-list"); + + this.append (heading); + this.append (this.listbox); + + this.stats_manager = new Ft.StatsManager (); + this.stats_manager.entry_saved.connect (this.on_entry_changed); + this.stats_manager.entry_deleted.connect (this.on_entry_changed); + } + + public void set_range (GLib.Date first_date, + GLib.Date last_date) + { + this.first_date_string = Ft.Database.serialize_date (first_date); + this.last_date_string = Ft.Database.serialize_date (last_date); + + this.populate.begin ( + (obj, res) => { + this.populate.end (res); + }); + } + + private async Gom.ResourceGroup? fetch_entries () + { + var repository = Ft.Database.get_repository (); + + var first_date_value = GLib.Value (typeof (string)); + first_date_value.set_string (this.first_date_string); + + var last_date_value = GLib.Value (typeof (string)); + last_date_value.set_string (this.last_date_string); + + var category_value = GLib.Value (typeof (string)); + category_value.set_string ("pomodoro"); + + var first_date_filter = new Gom.Filter.gte ( + typeof (Ft.StatsEntry), + "date", + first_date_value); + var last_date_filter = new Gom.Filter.lte ( + typeof (Ft.StatsEntry), + "date", + last_date_value); + var category_filter = new Gom.Filter.eq ( + typeof (Ft.StatsEntry), + "category", + category_value); + var filter = new Gom.Filter.and ( + new Gom.Filter.and (first_date_filter, last_date_filter), + category_filter); + + try { + var entries = yield repository.find_async (typeof (Ft.StatsEntry), + filter); + yield entries.fetch_async (0U, entries.count); + + return entries; + } + catch (GLib.Error error) { + GLib.critical ("Error while fetching task stats: %s", error.message); + + return null; + } + } + + private void remove_all_rows () + { + Gtk.Widget? row; + + while ((row = this.listbox.get_first_child ()) != null) { + this.listbox.remove (row); + } + } + + private void append_task_row (string task, + int64 duration, + double fraction) + { + var name_label = new Gtk.Label (task != "" ? task : _("No task")); + name_label.xalign = 0.0f; + name_label.ellipsize = Pango.EllipsizeMode.END; + + if (task == "") { + name_label.add_css_class ("dim-label"); + } + + var fraction_bar = new Gtk.ProgressBar (); + fraction_bar.fraction = fraction.clamp (0.0, 1.0); + fraction_bar.hexpand = true; + fraction_bar.valign = Gtk.Align.CENTER; + + var name_box = new Gtk.Box (Gtk.Orientation.VERTICAL, 6); + name_box.hexpand = true; + name_box.valign = Gtk.Align.CENTER; + name_box.append (name_label); + name_box.append (fraction_bar); + + var duration_label = new Gtk.Label (Ft.Interval.format_short (duration)); + duration_label.valign = Gtk.Align.CENTER; + duration_label.add_css_class ("dim-label"); + duration_label.add_css_class ("numeric"); + + var box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 12); + box.margin_top = 10; + box.margin_bottom = 10; + box.margin_start = 12; + box.margin_end = 12; + box.append (name_box); + box.append (duration_label); + + var row = new Gtk.ListBoxRow (); + row.activatable = false; + row.child = box; + + this.listbox.append (row); + } + + private async void populate () + { + if (this.first_date_string == "" || this.last_date_string == "") { + return; + } + + var entries = yield this.fetch_entries (); + + this.remove_all_rows (); + + string[] task_names = {}; + int64[] task_durations = {}; + + var entries_count = entries != null ? entries.count : 0U; + + for (var index = 0U; index < entries_count; index++) + { + var entry = (Ft.StatsEntry) entries.get_index (index); + + if (entry.duration <= 0) { + continue; + } + + var task = entry.task ?? ""; + var task_index = -1; + + for (var i = 0; i < task_names.length; i++) + { + if (task_names[i] == task) { + task_index = i; + break; + } + } + + if (task_index >= 0) { + task_durations[task_index] += entry.duration; + } + else { + task_names += task; + task_durations += entry.duration; + } + } + + // Show named tasks sorted by time spent. The "No task" bucket goes last. + var has_named_tasks = false; + + foreach (unowned var task_name in task_names) + { + if (task_name != "") { + has_named_tasks = true; + break; + } + } + + if (has_named_tasks) + { + int64 total_duration = 0; + + foreach (var task_duration in task_durations) { + total_duration += task_duration; + } + + var remaining = task_names.length; + + while (remaining > 0) + { + var best_index = -1; + + for (var i = 0; i < task_names.length; i++) + { + if (task_durations[i] < 0) { + continue; // already added + } + + if (best_index == -1 || + task_names[best_index] == "" || + (task_names[i] != "" && + task_durations[i] > task_durations[best_index])) + { + best_index = i; + } + } + + this.append_task_row (task_names[best_index], + task_durations[best_index], + (double) task_durations[best_index] / + (double) total_duration); + task_durations[best_index] = -1; + remaining--; + } + } + + this.visible = has_named_tasks; + } + + private void queue_update () + { + if (this.update_timeout_id != 0) { + return; + } + + this.update_timeout_id = GLib.Timeout.add ( + 500, + () => { + this.update_timeout_id = 0; + + this.populate.begin ( + (obj, res) => { + this.populate.end (res); + }); + + return GLib.Source.REMOVE; + }); + GLib.Source.set_name_by_id (this.update_timeout_id, + "Ft.TaskBreakdown.queue_update"); + } + + private void on_entry_changed (Ft.StatsEntry entry) + { + if (entry.date == null || + this.first_date_string == "" || + this.last_date_string == "") + { + return; + } + + // Dates are YYYY-MM-DD, so lexicographic order is chronological. + if (entry.date >= this.first_date_string && + entry.date <= this.last_date_string) + { + this.queue_update (); + } + } + + public override void dispose () + { + if (this.update_timeout_id != 0) { + GLib.Source.remove (this.update_timeout_id); + this.update_timeout_id = 0; + } + + if (this.stats_manager != null) { + this.stats_manager.entry_saved.disconnect (this.on_entry_changed); + this.stats_manager.entry_deleted.disconnect (this.on_entry_changed); + this.stats_manager = null; + } + + this.listbox = null; + + base.dispose (); + } + } +} diff --git a/src/ui/main/timer/timer-view.ui b/src/ui/main/timer/timer-view.ui index d83b035c..cbd5452b 100644 --- a/src/ui/main/timer/timer-view.ui +++ b/src/ui/main/timer/timer-view.ui @@ -5,35 +5,46 @@