tasku 0.3.9 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74f35cfb28f9ec9b5b7cc0ce5a0a62028d7162faa7ed1a8e9efedb4f63fa2638
4
- data.tar.gz: a064d7fa64823a9f3362e9220b4bc69311f2c20f21e10e6ae85501ed5f5f92de
3
+ metadata.gz: 206dc71e9498feb028e4c8b05fc3de8a23d6073b1da9db0988ef0acea3fc5f5d
4
+ data.tar.gz: 58dacb2562825ff61794649652c617b3ea7a4a619804a32c56067ff5ef2fd1ef
5
5
  SHA512:
6
- metadata.gz: 93955913ae30ae85a403b3e11977739d64eac51c1fbebfeb97f07b6beac7149bba991e7da74f067778838f7fdda455ebfe6d74f273cc83e5239daaedc697be48
7
- data.tar.gz: 0033c68f813e016cb2891fb7ed63e29d736f14460f18617fe021dbeeace9dc46c08639fc25f8764c1ccd0961edea2ad151225677ca7fb561e7214531706e338d
6
+ metadata.gz: 719c9a295c5868d5dbeb152e35cacde49d5be9f2ad229e2818a20d3eeaecf6ae86c461ea27e2d921c5b15ecbb48f0180f3e4c4ddc56d020dd36baaf5d901fb3e
7
+ data.tar.gz: 3c09b8f6970813d4a73d0772c2a92530d9e5958ad22f9fdd52c77a4f6fdc0a1b132af902ee59ba75060b25227695b48f7b22cf30541990cc763a4de41d5216c0
data/CHANGELOG.md ADDED
@@ -0,0 +1,97 @@
1
+ # Changelog
2
+
3
+ All notable changes to Tasku are documented here.
4
+
5
+ ## [0.4.1] - 2026-09-29
6
+
7
+ ### Fixed
8
+ - **Cloud sync: project colours now sync down** — `tasku cloud sync` applied only the tasks from the server's response and ignored its `projects`, so colours set on another machine never arrived. They are now created or updated locally, and the sync summary reports how many changed. In Mado this also restores project colours and workspaces on a freshly synced machine
9
+
10
+ ## [0.4.0] - 2026-09-23
11
+
12
+ ### Added
13
+ - **Tasku Cloud sync** — two-way sync with `tasku cloud sync`; authenticate with `tasku cloud login <token>`, check status with `tasku cloud status`, and revoke with `tasku cloud logout`
14
+ - **`tasku config open`** — opens the config file in `$EDITOR` pre-seeded with all available keys and their defaults
15
+ - **Numeric range preferences** — config keys that accept a numeric range (e.g. column minimum widths) now show their valid range in `tasku config list`
16
+ - **`--code` flag** on `add` and `edit` — attach a short code to a task (e.g. `MDO-42`)
17
+ - **`tasku sql`** — run a raw SQL `SELECT` query against the local database
18
+ - **Category filtering** — `tasku list --category` and category column in list output
19
+ - **Column visibility preferences** — `col_project`, `col_category`, `col_priority`, `col_status`, `col_due` can each be toggled `on`/`off` via `tasku config set`
20
+ - **Column minimum width preferences** — `col_name_min`, `col_priority_min`, `col_status_min`, `col_due_min` for fine-tuning layout
21
+ - **`tasku config open`** — open the config JSON in `$EDITOR` (pre-seeded with defaults)
22
+ - **UUID field** on tasks for stable cross-device identity during cloud sync
23
+
24
+ ### Fixed
25
+ - **Mado: status filter preserved after add/edit/delete** — `MadoList` now receives the full `list_cmd` (including `--status`, `--today`, `--overdue`, etc.) and passes it to `exec` on return, so the active view is restored correctly after an interactive command
26
+ - **Mado: project filter preserved after add/edit/delete** — the inline-exec protocol (`\x02`) now replaces the MadoList process via `exec` instead of nesting it in a `system()` call, so the new MadoList inherits the correct `@list_cmd` and filter is preserved when navigating back from an interactive command
27
+ - **Mado: project filter lost on workspace switch** — the open-panel flag is now tracked correctly for sidebar-positioned Tasku (not just the top-bar overlay), so switching workspace reliably refreshes the task list with the new project filter
28
+ - **Mado: terminal left in raw mode after inline exec** — `$stdin.cooked!` is now called before `exec` when handling a `\x02tasku list` command, preventing the shell from inheriting raw-mode terminal attributes
29
+ - **Due date column truncation** — `DUE_COL_MIN_WIDTH` raised to 17 to accommodate the longest possible due cell (`"Mmm DD (NNNd ago)"`)
30
+ - **MadoList row overflow** — row prefix accounting corrected to 6 characters (`" [>] "`), matching the actual rendered prefix; was previously 4, causing rows to exceed terminal width and wrap
31
+ - **Name truncation** — task names that exceed available space are now truncated with `…`; names that fit are never truncated, fixing cases where the due-date column wrapped to the next line
32
+ - **Column auto-drop on narrow terminals** — optional columns (project → category → priority → status) are dropped one at a time until at least 15 characters are available for task names, rather than wrapping or clipping
33
+
34
+ ### Changed
35
+ - `tasku list` output uses a consistent `│`-separated column layout with a header row
36
+ - `tasku config set` validates values via `ArgumentError` rather than a plain string comparison, supporting range constraints
37
+ - `tasku stats` includes a "By Project" section sorted by task count
38
+ - Sort defaults to `id` (ascending) when no `--sort` flag is given
39
+ - `id` added as an explicit `--sort` option
40
+
41
+ ---
42
+
43
+ ## [0.3.9] - 2026-09-04
44
+
45
+ ### Added
46
+ - **Mado TUI list mode** — `tasku list` launches an interactive cursor-driven list when `MADO=1` is set in the environment, designed for embedding in the Mado terminal multiplexer sidebar
47
+ - Arrow-key navigation, selected task ID written to `TASKU_SEL_FILE` for Mado button integration
48
+ - Inline-exec protocol (`\x02`) for receiving filter and display commands from Mado without shell round-trips
49
+ - `build_mado_rows` and `ansi_ljust` helpers exposed on `Output::Terminal` for Mado's renderer
50
+
51
+ ---
52
+
53
+ ## [0.3.1] - 2026-08-29
54
+
55
+ ### Fixed
56
+ - Terminal width was capped at 80 columns regardless of actual PTY size, severely truncating the name column in wider terminals — now uses the real terminal width
57
+
58
+ ### Changed
59
+ - `tasku list` sorts by `id` by default
60
+ - `exit_on_failure?` enabled on the CLI so bad subcommands exit with a non-zero status
61
+
62
+ ---
63
+
64
+ ## [0.3.0] - 2026-07-24
65
+
66
+ ### Added
67
+ - **Project colours** — `tasku colour PROJECT #HEX` stores a hex colour per project; project names are tinted throughout `list`, `show`, and `stats`
68
+ - **Colour bar** — three-segment bar in `tasku list` showing project, priority, and status at a glance (toggle each segment with `tasku config set bar_project on/off` etc.)
69
+ - **User preferences** — `tasku config list` / `tasku config set KEY VALUE` / `tasku config get KEY`, backed by `~/.tasku/config.json`
70
+ - `list_spacing` preference (`compact` / `spacious`)
71
+ - **"By Project" section** in `tasku stats` sorted by task count
72
+ - Logo restored on `tasku help` and bare `tasku` invocation
73
+
74
+ ---
75
+
76
+ ## [0.2.0] - 2026-07-01
77
+
78
+ ### Added
79
+ - `tasku projects` — list all projects with task counts and colour swatches
80
+ - `tasku categories` — list all categories
81
+ - `tasku done ID` — shorthand to mark a task done
82
+ - `--overdue`, `--today`, `--tomorrow` filter flags on `tasku list`
83
+ - `--sort` and `--order` flags on `tasku list`
84
+ - `--priority`, `--status`, `--tags` filter flags on `tasku list`
85
+ - `--force` / `-f` flag on `tasku delete` to skip confirmation
86
+
87
+ ---
88
+
89
+ ## [0.1.0] - 2026-06-29
90
+
91
+ Initial release.
92
+
93
+ - `tasku add`, `tasku list`, `tasku show`, `tasku edit`, `tasku delete`
94
+ - SQLite-backed storage at `~/.tasku/tasks.db`
95
+ - Task fields: name, description, project, category, priority, status, start date, due date, tags, estimated hours
96
+ - `tasku stats` — task breakdown by priority and status
97
+ - Interactive add/edit with `-i` flag (TTY::Prompt)
data/lib/tasku/cli.rb CHANGED
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+
3
8
  module Tasku
4
9
  module CLI
5
10
  LOGO = <<~LOGO
@@ -20,7 +25,11 @@ module Tasku
20
25
  puts ""
21
26
  Tasku::Config::VALID_KEYS.each do |key, meta|
22
27
  current = Tasku::Config.get(key)
23
- options_str = meta[:values].map { |v| v == current ? pastel.bold(pastel.green(v)) : pastel.dim(v) }.join(", ")
28
+ if meta[:range]
29
+ options_str = pastel.bold(pastel.green(current)) + pastel.dim(" (#{meta[:range].min}–#{meta[:range].max})")
30
+ else
31
+ options_str = meta[:values].map { |v| v == current ? pastel.bold(pastel.green(v)) : pastel.dim(v) }.join(", ")
32
+ end
24
33
  puts " #{pastel.bold(key.ljust(20))} #{options_str} #{pastel.dim("— #{meta[:description]}")}"
25
34
  end
26
35
  puts ""
@@ -31,10 +40,11 @@ module Tasku
31
40
  pastel = Pastel.new
32
41
  meta = Tasku::Config::VALID_KEYS[key]
33
42
  abort pastel.red("Unknown preference '#{key}'. Run `tasku config list` to see available keys.") unless meta
34
- unless meta[:values].include?(value)
35
- abort pastel.red("Invalid value '#{value}' for '#{key}'. Valid: #{meta[:values].join(', ')}")
43
+ begin
44
+ Tasku::Config.set(key, value)
45
+ rescue ArgumentError => e
46
+ abort pastel.red(" #{e.message}")
36
47
  end
37
- Tasku::Config.set(key, value)
38
48
  puts pastel.green(" ✓ #{key} set to '#{value}'.")
39
49
  end
40
50
 
@@ -45,6 +55,240 @@ module Tasku
45
55
  abort pastel.red("Unknown preference '#{key}'. Run `tasku config list` to see available keys.") unless meta
46
56
  puts " #{key}: #{pastel.bold(Tasku::Config.get(key))}"
47
57
  end
58
+
59
+ desc "open", "Open the config file in $EDITOR"
60
+ def open
61
+ path = Tasku::Config::CONFIG_PATH
62
+ FileUtils.mkdir_p(File.dirname(path))
63
+ # Seed file with all defaults so every key is visible for editing.
64
+ current = Tasku::Config.all
65
+ seeded = Tasku::Config::VALID_KEYS.transform_values { |meta| meta[:default] }.merge(current)
66
+ File.write(path, JSON.pretty_generate(seeded))
67
+ editor = ENV["EDITOR"] || ENV["VISUAL"] || "vi"
68
+ exec(editor, path)
69
+ end
70
+ end
71
+
72
+ class CloudApp < Thor
73
+ desc "login TOKEN", "Authenticate with Tasku Cloud using an API token"
74
+ option :url, type: :string, desc: "Tasku Cloud URL (default: #{Tasku::Config::CLOUD_URL_DEFAULT})"
75
+ def login(token)
76
+ pastel = Pastel.new
77
+ url = options[:url] || Tasku::Config.cloud_url
78
+
79
+ puts pastel.dim(" Verifying token with #{url}...")
80
+
81
+ begin
82
+ uri = URI.join(url, "/api/v1/sync")
83
+ http = Net::HTTP.new(uri.host, uri.port)
84
+ http.use_ssl = uri.scheme == "https"
85
+ http.open_timeout = 10
86
+ http.read_timeout = 10
87
+
88
+ req = Net::HTTP::Post.new(uri.path, {
89
+ "Content-Type" => "application/json",
90
+ "Authorization" => "Bearer #{token}"
91
+ })
92
+ req.body = JSON.generate({ tasks: [], last_synced_at: nil })
93
+
94
+ res = http.request(req)
95
+ rescue Errno::ECONNREFUSED, Errno::ENOENT, SocketError, Net::OpenTimeout => e
96
+ abort pastel.red(" Could not connect to #{url}: #{e.message}")
97
+ end
98
+
99
+ if res.code == "200"
100
+ Tasku::Config.cloud_token = token
101
+ Tasku::Config.cloud_url = url if options[:url]
102
+ puts ""
103
+ puts pastel.green(" ✓ Logged in to Tasku Cloud.")
104
+ puts pastel.dim(" Token saved to ~/.tasku/config.json")
105
+ puts pastel.dim(" Run `tasku cloud sync` to sync your tasks.")
106
+ puts ""
107
+ elsif res.code == "401"
108
+ abort pastel.red(" Invalid token. Check it and try again.")
109
+ else
110
+ abort pastel.red(" Unexpected response from server (#{res.code}).")
111
+ end
112
+ end
113
+
114
+ desc "status", "Show Tasku Cloud connection status"
115
+ def status
116
+ pastel = Pastel.new
117
+ puts ""
118
+ if Tasku::Config.cloud_configured?
119
+ puts " #{pastel.green("●")} Connected to #{pastel.bold(Tasku::Config.cloud_url)}"
120
+ last = Tasku::Config.last_synced_at
121
+ puts " Last synced: #{last ? pastel.bold(last.localtime.strftime("%-d %b %Y at %H:%M")) : pastel.dim("never")}"
122
+ else
123
+ puts " #{pastel.dim("○")} Not connected."
124
+ puts " #{pastel.dim("Run `tasku cloud login <token>` to connect.")}"
125
+ end
126
+ puts ""
127
+ end
128
+
129
+ desc "logout", "Remove saved Tasku Cloud credentials"
130
+ def logout
131
+ pastel = Pastel.new
132
+ Tasku::Config.cloud_token = nil
133
+ Tasku::Config.last_synced_at = nil
134
+ puts pastel.green(" ✓ Logged out of Tasku Cloud.")
135
+ end
136
+
137
+ desc "sync", "Two-way sync tasks with Tasku Cloud"
138
+ def sync
139
+ pastel = Pastel.new
140
+
141
+ unless Tasku::Config.cloud_configured?
142
+ abort pastel.red(" Not connected. Run `tasku cloud login <token>` first.")
143
+ end
144
+
145
+ url = Tasku::Config.cloud_url
146
+ token = Tasku::Config.cloud_token
147
+ last_synced_at = Tasku::Config.last_synced_at
148
+
149
+ puts pastel.dim(" Syncing with #{url}...")
150
+
151
+ # Serialise every local task for upload
152
+ local_tasks = Tasku::Task.all.map do |t|
153
+ {
154
+ uuid: t.uuid,
155
+ name: t.name,
156
+ description: t.description,
157
+ project: t.project,
158
+ category: t.category,
159
+ start_day: t.start_day&.iso8601,
160
+ due_day: t.due_day&.iso8601,
161
+ code: t.code,
162
+ priority: t.priority,
163
+ status: t.status,
164
+ tags: t.tags,
165
+ estimated_hours: t.estimated_hours,
166
+ updated_at: t.updated_at&.utc&.iso8601,
167
+ created_at: t.created_at&.utc&.iso8601
168
+ }
169
+ end
170
+
171
+ begin
172
+ uri = URI.join(url, "/api/v1/sync")
173
+ http = Net::HTTP.new(uri.host, uri.port)
174
+ http.use_ssl = uri.scheme == "https"
175
+ http.open_timeout = 15
176
+ http.read_timeout = 30
177
+
178
+ req = Net::HTTP::Post.new(uri.path, {
179
+ "Content-Type" => "application/json",
180
+ "Authorization" => "Bearer #{token}"
181
+ })
182
+ local_projects = Tasku::Database.db[:projects].all.map do |p|
183
+ { name: p[:name], colour: p[:colour] }
184
+ end
185
+
186
+ req.body = JSON.generate({ tasks: local_tasks, projects: local_projects, last_synced_at: last_synced_at&.iso8601 })
187
+
188
+ res = http.request(req)
189
+ rescue Errno::ECONNREFUSED, Errno::ENOENT, SocketError, Net::OpenTimeout => e
190
+ abort pastel.red(" Could not connect to #{url}: #{e.message}")
191
+ end
192
+
193
+ if res.code == "401"
194
+ abort pastel.red(" Invalid token — run `tasku cloud login <token>` to reauthenticate.")
195
+ elsif res.code != "200"
196
+ abort pastel.red(" Sync failed (HTTP #{res.code}).")
197
+ end
198
+
199
+ body = JSON.parse(res.body)
200
+ server_tasks = body["tasks"] || []
201
+ synced_at = body["synced_at"]
202
+
203
+ created = updated = conflicts = 0
204
+
205
+ server_tasks.each do |st|
206
+ next unless st["uuid"]
207
+
208
+ # Conflict tasks live server-side; the user resolves them on the web.
209
+ if st["conflict"]
210
+ conflicts += 1
211
+ next
212
+ end
213
+
214
+ local = Tasku::Task.first(uuid: st["uuid"])
215
+
216
+ server_updated = st["updated_at"] ? Time.parse(st["updated_at"]).utc : nil
217
+
218
+ if local.nil?
219
+ # New task from cloud — write directly to bypass timestamps plugin
220
+ # so updated_at matches the server's value exactly.
221
+ Tasku::Database.db[:tasks].insert(
222
+ uuid: st["uuid"],
223
+ name: st["name"],
224
+ description: st["description"],
225
+ project: st["project"],
226
+ category: st["category"],
227
+ start_day: st["start_day"] ? Date.parse(st["start_day"]) : nil,
228
+ due_day: st["due_day"] ? Date.parse(st["due_day"]) : nil,
229
+ code: st["code"],
230
+ priority: st["priority"] || "none",
231
+ status: st["status"] || "todo",
232
+ tags: st["tags"],
233
+ estimated_hours: st["estimated_hours"],
234
+ created_at: st["created_at"] ? Time.parse(st["created_at"]).utc : Time.now.utc,
235
+ updated_at: server_updated || Time.now.utc
236
+ )
237
+ created += 1
238
+ else
239
+ local_updated = local.updated_at ? local.updated_at.utc : nil
240
+
241
+ if server_updated && local_updated && server_updated > local_updated
242
+ # Write via dataset to preserve server's updated_at exactly,
243
+ # preventing the local timestamp from drifting forward and
244
+ # triggering another upload next sync.
245
+ Tasku::Database.db[:tasks].where(id: local.id).update(
246
+ name: st["name"],
247
+ description: st["description"],
248
+ project: st["project"],
249
+ category: st["category"],
250
+ start_day: st["start_day"] ? Date.parse(st["start_day"]) : nil,
251
+ due_day: st["due_day"] ? Date.parse(st["due_day"]) : nil,
252
+ code: st["code"],
253
+ priority: st["priority"] || "none",
254
+ status: st["status"] || "todo",
255
+ tags: st["tags"],
256
+ estimated_hours: st["estimated_hours"],
257
+ updated_at: server_updated
258
+ )
259
+ updated += 1
260
+ end
261
+ end
262
+ end
263
+
264
+ # Local projects were pushed above, so the server's list already reflects
265
+ # them — adopt it wholesale so colours set on other machines come down.
266
+ projects_pulled = 0
267
+ (body["projects"] || []).each do |sp|
268
+ name = sp["name"].to_s.strip
269
+ colour = sp["colour"].to_s.strip
270
+ next if name.empty? || colour !~ /\A#[0-9A-Fa-f]{6}\z/
271
+
272
+ proj = Tasku::Project.find_or_create(name: name)
273
+ next if proj.colour == colour
274
+
275
+ proj.update(colour: colour)
276
+ projects_pulled += 1
277
+ end
278
+
279
+ Tasku::Config.last_synced_at = synced_at ? Time.parse(synced_at).utc : Time.now.utc
280
+
281
+ puts ""
282
+ puts " #{pastel.green("✓")} Sync complete."
283
+ puts " #{pastel.bold(local_tasks.count.to_s)} #{local_tasks.count == 1 ? "task" : "tasks"} pushed to cloud."
284
+ puts " #{pastel.bold(created.to_s)} #{created == 1 ? "task" : "tasks"} pulled from cloud." if created > 0
285
+ puts " #{pastel.bold(updated.to_s)} #{updated == 1 ? "task" : "tasks"} updated from cloud." if updated > 0
286
+ puts " #{pastel.bold(projects_pulled.to_s)} #{projects_pulled == 1 ? "project colour" : "project colours"} updated from cloud." if projects_pulled > 0
287
+ if conflicts > 0
288
+ puts " #{pastel.yellow("⚠")} #{conflicts} #{conflicts == 1 ? "conflict" : "conflicts"} — visit #{pastel.bold(url)} to resolve."
289
+ end
290
+ puts ""
291
+ end
48
292
  end
49
293
 
50
294
  class App < Thor
@@ -74,6 +318,9 @@ module Tasku
74
318
  desc "config SUBCOMMAND", "Manage user preferences"
75
319
  subcommand "config", ConfigApp
76
320
 
321
+ desc "cloud SUBCOMMAND", "Sync tasks with Tasku Cloud"
322
+ subcommand "cloud", CloudApp
323
+
77
324
  def help(*args)
78
325
  if args.empty?
79
326
  puts ""
@@ -161,6 +408,11 @@ module Tasku
161
408
  option_add
162
409
  end
163
410
 
411
+ if attrs.nil?
412
+ puts pastel.yellow(" Add cancelled.")
413
+ return
414
+ end
415
+
164
416
  task = Task.create(attrs)
165
417
  terminal.render_added(task)
166
418
  rescue Sequel::ValidationFailed => e
@@ -173,9 +425,11 @@ module Tasku
173
425
  option :project, type: :string, desc: "Filter by project"
174
426
  option :category, type: :string, desc: "Filter by category"
175
427
  option :tags, type: :string, desc: "Filter by tag (comma-separated)"
176
- option :overdue, type: :boolean, desc: "Show only overdue tasks"
177
- option :sort, type: :string, desc: "Sort by: id, name, priority, due, status, created"
178
- option :order, type: :string, desc: "Order: asc, desc", default: "asc"
428
+ option :overdue, type: :boolean, desc: "Show only overdue tasks"
429
+ option :today, type: :boolean, desc: "Show tasks due today"
430
+ option :tomorrow, type: :boolean, desc: "Show tasks due tomorrow"
431
+ option :sort, type: :string, desc: "Sort by: id, name, priority, due, status, created"
432
+ option :order, type: :string, desc: "Order: asc, desc", default: "asc"
179
433
  def list
180
434
  dataset = Task.dataset
181
435
 
@@ -196,6 +450,16 @@ module Tasku
196
450
  dataset = dataset.where { due_day < today }.exclude(status: %w[done cancelled])
197
451
  end
198
452
 
453
+ if options[:today]
454
+ today = Date.today
455
+ dataset = dataset.where(due_day: today).exclude(status: %w[done cancelled])
456
+ end
457
+
458
+ if options[:tomorrow]
459
+ tomorrow = Date.today + 1
460
+ dataset = dataset.where(due_day: tomorrow).exclude(status: %w[done cancelled])
461
+ end
462
+
199
463
  sort_col = case options[:sort]
200
464
  when "name" then :name
201
465
  when "priority" then Sequel.case(Task::VALID_PRIORITIES.each_with_index.to_h, 999, :priority)
@@ -210,10 +474,29 @@ module Tasku
210
474
 
211
475
  tasks = dataset.all
212
476
  bar = { "bar_project" => Config.get("bar_project"), "bar_priority" => Config.get("bar_priority"), "bar_status" => Config.get("bar_status") }
477
+ cols_cfg = {
478
+ "col_project" => Config.get("col_project"),
479
+ "col_category" => Config.get("col_category"),
480
+ "col_priority" => Config.get("col_priority"),
481
+ "col_status" => Config.get("col_status"),
482
+ "col_due" => Config.get("col_due"),
483
+ "col_name_min" => Config.get("col_name_min"),
484
+ "col_priority_min" => Config.get("col_priority_min"),
485
+ "col_status_min" => Config.get("col_status_min"),
486
+ "col_due_min" => Config.get("col_due_min")
487
+ }
213
488
  if ENV["MADO"] == "1"
214
- Tasku::TUI::MadoList.new(tasks, colour_map: Project.colour_map, bar: bar).run
489
+ list_cmd = "tasku list"
490
+ list_cmd += " --status #{Shellwords.shellescape(options[:status])}" if options[:status]
491
+ list_cmd += " --priority #{Shellwords.shellescape(options[:priority])}" if options[:priority]
492
+ list_cmd += " --project #{Shellwords.shellescape(options[:project])}" if options[:project]
493
+ list_cmd += " --category #{Shellwords.shellescape(options[:category])}" if options[:category]
494
+ list_cmd += " --today" if options[:today]
495
+ list_cmd += " --tomorrow" if options[:tomorrow]
496
+ list_cmd += " --overdue" if options[:overdue]
497
+ Tasku::TUI::MadoList.new(tasks, colour_map: Project.colour_map, bar: bar, cols_cfg: cols_cfg, project: options[:project], list_cmd: list_cmd).run
215
498
  else
216
- terminal.render_list(tasks, colour_map: Project.colour_map, spacing: Config.get("list_spacing"), bar: bar)
499
+ terminal.render_list(tasks, colour_map: Project.colour_map, spacing: Config.get("list_spacing"), bar: bar, cols_cfg: cols_cfg)
217
500
  end
218
501
  end
219
502
 
@@ -237,10 +520,19 @@ module Tasku
237
520
  option :hours, type: :numeric, desc: "Estimated hours"
238
521
  option :code, type: :string, desc: "Code"
239
522
  option :clear, type: :string, desc: "Clear a field: description, start, due, model, tags, hours, code"
523
+ option :interactive, type: :boolean, aliases: "-i", desc: "Interactive mode", default: false
240
524
  def edit(id)
241
525
  task = find_task(id)
242
526
 
243
- attrs = option_edit
527
+ attrs = if options[:interactive]
528
+ interactive_edit(task)
529
+ else
530
+ option_edit
531
+ end
532
+ if attrs.nil?
533
+ puts pastel.yellow(" Edit cancelled.")
534
+ return
535
+ end
244
536
  if attrs.empty? && !options[:clear]
245
537
  puts pastel.yellow("No changes specified. Use --help to see available options.")
246
538
  return
@@ -296,8 +588,11 @@ module Tasku
296
588
  end
297
589
 
298
590
  desc "stats", "Show task statistics"
591
+ option :project, type: :string, desc: "Filter by project"
299
592
  def stats
300
- tasks = Task.dataset.all
593
+ dataset = Task.dataset
594
+ dataset = dataset.where(project: options[:project]) if options[:project]
595
+ tasks = dataset.all
301
596
  terminal.render_stats(tasks, colour_map: Project.colour_map) if tasks
302
597
  end
303
598
 
@@ -437,6 +732,69 @@ module Tasku
437
732
  tags: (tags unless tags&.empty?),
438
733
  estimated_hours: hours
439
734
  }.compact
735
+ rescue TTY::Reader::InputInterrupt
736
+ nil
737
+ end
738
+
739
+ def interactive_edit(task)
740
+ prompt = TTY::Prompt.new
741
+
742
+ name = prompt.ask("Task name:", default: task.name, required: true) do |q|
743
+ q.modify :strip
744
+ end
745
+
746
+ description = prompt.ask("Description:", default: task.description || "")
747
+ description = nil if description&.empty?
748
+
749
+ project = prompt.ask("Project:", default: task.project || "")
750
+ project = nil if project&.empty?
751
+
752
+ category = prompt.ask("Category:", default: task.category || "")
753
+ category = nil if category&.empty?
754
+
755
+ priority = prompt.select("Priority?", %w[none low medium high urgent],
756
+ default: task.priority || "none")
757
+ status = prompt.select("Status?", Task::VALID_STATUSES,
758
+ default: task.status || "todo")
759
+
760
+ start_day = prompt.ask("Start date (YYYY-MM-DD, optional):",
761
+ default: task.start_day&.to_s || "")
762
+ start_day = nil if start_day&.empty?
763
+
764
+ due_day = prompt.ask("Due date (YYYY-MM-DD, optional):",
765
+ default: task.due_day&.to_s || "")
766
+ due_day = nil if due_day&.empty?
767
+
768
+ model_name = prompt.ask("Model:", default: task.model_name || "")
769
+ model_name = nil if model_name&.empty?
770
+
771
+ code = prompt.ask("Code:", default: task.code || "")
772
+ code = nil if code&.empty?
773
+
774
+ tags = prompt.ask("Tags (comma-separated):", default: task.tags || "")
775
+ tags = nil if tags&.empty?
776
+
777
+ hours = prompt.ask("Estimated hours:", default: task.estimated_hours&.to_s || "") do |q|
778
+ q.convert(:float, "")
779
+ end
780
+ hours = nil if hours.is_a?(String) && hours.empty?
781
+
782
+ {
783
+ name: name,
784
+ description: description,
785
+ project: project,
786
+ category: category,
787
+ start_day: parse_date(start_day),
788
+ due_day: parse_date(due_day),
789
+ model_name: model_name,
790
+ code: code,
791
+ priority: priority,
792
+ status: status,
793
+ tags: tags,
794
+ estimated_hours: hours
795
+ }.compact
796
+ rescue TTY::Reader::InputInterrupt
797
+ nil
440
798
  end
441
799
 
442
800
  def option_add