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 @@
+
+
+
+
+
+
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 @@
diff --git a/src/ui/main/timer/widgets/task-button.vala b/src/ui/main/timer/widgets/task-button.vala
new file mode 100644
index 00000000..18c64591
--- /dev/null
+++ b/src/ui/main/timer/widgets/task-button.vala
@@ -0,0 +1,207 @@
+/*
+ * Copyright (c) 2026 focus-timer contributors
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+using GLib;
+
+
+namespace Ft
+{
+ /**
+ * A button displaying the task time is tracked against.
+ *
+ * Clicking it opens a popover with a text entry and recently used tasks.
+ */
+ public class TaskButton : Adw.Bin
+ {
+ private const int MAX_TASK_NAME_LENGTH = 100;
+
+ private Ft.SessionManager? session_manager = null;
+ private GLib.Settings? settings = null;
+ private Gtk.MenuButton? menubutton = null;
+ private Gtk.Label? task_label = null;
+ private Gtk.Entry? task_entry = null;
+ private Gtk.Popover? popover = null;
+ private Gtk.Box? recent_box = null;
+ private Gtk.ListBox? recent_listbox = null;
+ private Gtk.Button? clear_button = null;
+ private ulong notify_current_task_id = 0;
+
+ static construct
+ {
+ set_css_name ("taskbutton");
+ }
+
+ construct
+ {
+ this.session_manager = Ft.SessionManager.get_default ();
+ this.settings = Ft.get_settings ();
+
+ this.task_entry = new Gtk.Entry ();
+ this.task_entry.placeholder_text = _("What are you working on?");
+ this.task_entry.input_hints = Gtk.InputHints.NO_SPELLCHECK;
+ this.task_entry.max_length = MAX_TASK_NAME_LENGTH;
+ this.task_entry.width_chars = 24;
+ this.task_entry.activate.connect (this.on_task_entry_activate);
+
+ this.recent_listbox = new Gtk.ListBox ();
+ this.recent_listbox.selection_mode = Gtk.SelectionMode.NONE;
+ this.recent_listbox.add_css_class ("boxed-list");
+ this.recent_listbox.row_activated.connect (this.on_recent_row_activated);
+
+ var recent_label = new Gtk.Label (_("Recent"));
+ recent_label.halign = Gtk.Align.START;
+ recent_label.add_css_class ("caption-heading");
+ recent_label.add_css_class ("dim-label");
+
+ this.recent_box = new Gtk.Box (Gtk.Orientation.VERTICAL, 6);
+ this.recent_box.append (recent_label);
+ this.recent_box.append (this.recent_listbox);
+
+ this.clear_button = new Gtk.Button.with_label (_("Clear Task"));
+ this.clear_button.halign = Gtk.Align.START;
+ this.clear_button.add_css_class ("flat");
+ this.clear_button.clicked.connect (this.on_clear_button_clicked);
+
+ var popover_box = new Gtk.Box (Gtk.Orientation.VERTICAL, 12);
+ popover_box.append (this.task_entry);
+ popover_box.append (this.recent_box);
+ popover_box.append (this.clear_button);
+
+ this.popover = new Gtk.Popover ();
+ this.popover.child = popover_box;
+ this.popover.map.connect (this.on_popover_map);
+
+ this.task_label = new Gtk.Label (null);
+ this.task_label.ellipsize = Pango.EllipsizeMode.END;
+ this.task_label.max_width_chars = 30;
+ this.task_label.xalign = 0.0f;
+
+ var button_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 8);
+ button_box.append (new Gtk.Image.from_icon_name ("document-edit-symbolic"));
+ button_box.append (this.task_label);
+
+ this.menubutton = new Gtk.MenuButton ();
+ this.menubutton.child = button_box;
+ this.menubutton.popover = this.popover;
+ this.menubutton.has_frame = false;
+ this.menubutton.halign = Gtk.Align.START;
+ this.menubutton.tooltip_text = _("Set the task you're working on");
+
+ this.child = this.menubutton;
+
+ this.notify_current_task_id = this.session_manager.notify["current-task"].connect (
+ this.on_notify_current_task);
+
+ this.update_task_label ();
+ }
+
+ private void set_task (string task)
+ {
+ this.session_manager.current_task = task;
+ this.popover.popdown ();
+ }
+
+ private void update_task_label ()
+ {
+ var current_task = this.session_manager.current_task;
+
+ if (current_task != "") {
+ this.task_label.label = current_task;
+ this.task_label.remove_css_class ("dim-label");
+ }
+ else {
+ this.task_label.label = _("Set a task…");
+ this.task_label.add_css_class ("dim-label");
+ }
+ }
+
+ private void update_recent_listbox ()
+ {
+ Gtk.Widget? row;
+
+ while ((row = this.recent_listbox.get_first_child ()) != null) {
+ this.recent_listbox.remove (row);
+ }
+
+ var task_history = this.settings.get_strv ("task-history");
+
+ foreach (unowned var task in task_history)
+ {
+ if (task == "") {
+ continue;
+ }
+
+ var label = new Gtk.Label (task);
+ label.ellipsize = Pango.EllipsizeMode.END;
+ label.max_width_chars = 30;
+ label.xalign = 0.0f;
+ label.margin_top = 8;
+ label.margin_bottom = 8;
+ label.margin_start = 10;
+ label.margin_end = 10;
+
+ this.recent_listbox.append (label);
+ }
+
+ this.recent_box.visible = task_history.length > 0;
+ }
+
+ private void on_popover_map ()
+ {
+ var current_task = this.session_manager.current_task;
+
+ this.update_recent_listbox ();
+
+ this.task_entry.text = current_task;
+ this.task_entry.set_position (-1);
+ this.clear_button.visible = current_task != "";
+ }
+
+ private void on_task_entry_activate ()
+ {
+ this.set_task (this.task_entry.text);
+ }
+
+ private void on_recent_row_activated (Gtk.ListBoxRow row)
+ {
+ var label = row.child as Gtk.Label;
+
+ if (label != null) {
+ this.set_task (label.label);
+ }
+ }
+
+ private void on_clear_button_clicked ()
+ {
+ this.set_task ("");
+ }
+
+ private void on_notify_current_task ()
+ {
+ this.update_task_label ();
+ }
+
+ public override void dispose ()
+ {
+ if (this.notify_current_task_id != 0) {
+ this.session_manager.disconnect (this.notify_current_task_id);
+ this.notify_current_task_id = 0;
+ }
+
+ this.session_manager = null;
+ this.settings = null;
+ this.menubutton = null;
+ this.task_label = null;
+ this.task_entry = null;
+ this.popover = null;
+ this.recent_box = null;
+ this.recent_listbox = null;
+ this.clear_button = null;
+
+ base.dispose ();
+ }
+ }
+}
diff --git a/src/ui/style.css b/src/ui/style.css
index 641a239a..913af657 100644
--- a/src/ui/style.css
+++ b/src/ui/style.css
@@ -184,6 +184,10 @@ timerview .ft-state-menu > button {
padding: 8px 12px 8px 16px;
}
+timerview taskbutton menubutton > button {
+ padding: 4px 12px 4px 16px;
+}
+
timerview .ft-state-menu arrow {
opacity: 0;
margin: 0;
diff --git a/tests/test-database.vala b/tests/test-database.vala
index 28f5145f..b77f8d82 100644
--- a/tests/test-database.vala
+++ b/tests/test-database.vala
@@ -139,8 +139,11 @@ namespace Tests
assert_true (this.run_main_loop ());
// Apply v3 migration (populate stats and drop legacy tables)
+ // and v4 migration (add task columns), so that the schema matches
+ // the current Gom resources before querying.
try {
repository.migrate_sync (3U, Ft.Database.migrate_repository);
+ repository.migrate_sync (4U, Ft.Database.migrate_repository);
}
catch (GLib.Error error) {
assert_no_error (error);
diff --git a/tests/test-stats-manager.vala b/tests/test-stats-manager.vala
index b615423f..998dc6eb 100644
--- a/tests/test-stats-manager.vala
+++ b/tests/test-stats-manager.vala
@@ -196,6 +196,8 @@ namespace Tests
this.add_test ("track_time_block__pomodoro_1",
this.test_track_time_block__pomodoro_1);
+ this.add_test ("track_time_block__task",
+ this.test_track_time_block__task);
this.add_test ("track_time_block__pomodoro_2",
this.test_track_time_block__pomodoro_2);
this.add_test ("track_time_block__break",
@@ -591,6 +593,63 @@ namespace Tests
}
}
+ public void test_track_time_block__task ()
+ {
+ var timestamp = Ft.Timestamp.from_datetime (
+ new GLib.DateTime (this.new_york_timezone, 2000, 1, 1, 7, 0, 0));
+
+ var time_block = new Ft.TimeBlock (Ft.State.POMODORO);
+ time_block.set_time_range (timestamp, timestamp + 5 * Ft.Interval.MINUTE);
+ time_block.set_status (Ft.TimeBlockStatus.COMPLETED);
+ time_block.set_task ("Write report");
+
+ var session = new Ft.Session ();
+ session.append (time_block);
+
+ this.stats_manager.track_time_block (time_block);
+
+ this.run_flush ();
+
+ try {
+ var stats_entry = (Ft.StatsEntry?) this.repository.find_one_sync (
+ typeof (Ft.StatsEntry), null);
+ assert_cmpstr (
+ stats_entry.category,
+ GLib.CompareOperator.EQ,
+ "pomodoro");
+ assert_cmpstr (
+ stats_entry.task,
+ GLib.CompareOperator.EQ,
+ "Write report");
+ }
+ catch (GLib.Error error) {
+ assert_no_error (error);
+ }
+
+ // Re-assigning the time-block to another task should update stats entries.
+ time_block.set_task ("Review patches");
+
+ this.stats_manager.track_time_block (time_block);
+
+ this.run_flush ();
+
+ try {
+ var results = this.repository.find_sync (typeof (Ft.StatsEntry), null);
+ assert_cmpuint (results.count, GLib.CompareOperator.EQ, 1U);
+
+ results.fetch_sync (0U, results.count);
+
+ var stats_entry = (Ft.StatsEntry?) results.get_index (0U);
+ assert_cmpstr (
+ stats_entry.task,
+ GLib.CompareOperator.EQ,
+ "Review patches");
+ }
+ catch (GLib.Error error) {
+ assert_no_error (error);
+ }
+ }
+
public void test_track_time_block__pomodoro_2 ()
{
var timestamp = Ft.Timestamp.from_datetime (