Exit prompt

Cause: any answer other than y was treated as no, so your t discarded the changes.
Fix: the prompt now only accepts y (save), n (discard) or c (cancel the exit). Anything else asks again, so a typo can't lose data. In my test, t, an empty line, then y saved correctly.
Also: I changed the db restore confirmation to y/n only, with no cancel option. Closing stdin still saves automatically.

Wait without a time

tsk restat <task> wait: works with no time while the task's account is still waiting, and the task just follows that wait. tsk add <task> <acc> wait works the same way.
Time required: otherwise it asks for one, with a message saying why. That covers an account that isn't waiting, an account whose wait is already over (Limit reset), and a task with no account.
acc wait: it still requires a time. A plain acc wait would have nothing to do, since the account already has a wait.

Account panel: there's a new RESET column showing when the account's wait ends. It shows 16:30 for today, tomorrow 06:40 for the next day, or a full date like 2020-01-01 10:00 (which is how an old, already-reset wait appears). Accounts with no wait show -. Since the time is in its own column now, an account with a wait but no task showing it lists just (wait) or (limit reset) in the TASKS column.
This commit is contained in:
alejo committed 2026-10-24 09:18:43 -03:00
1 parent 0af468f47c
commit 35c568333d
6 files changed
+153 -40

No files matched your search

+9 -3
View File
@@ -69,13 +69,15 @@ goes back to `$VISUAL` / `$EDITOR` / nano / vim / vi. The choice is stored in
* Data lives in an in-memory SQLite database. `db save` writes it to the file, * Data lives in an in-memory SQLite database. `db save` writes it to the file,
`db backup` writes a copy, `db restore` loads a file. On `quit` you are asked `db backup` writes a copy, `db restore` loads a file. On `quit` you are asked
to save if there are unsaved changes. to save if there are unsaved changes (`y` save, `n` discard, `c` cancel the
exit); any other answer asks again, so a mistyped key never loses data.
* The stored status is never shown directly: a `wait` task displays as * The stored status is never shown directly: a `wait` task displays as
`Wait HH:MM` until that time passes, then as `Limit reset`. Tasks are sorted `Wait HH:MM` until that time passes, then as `Limit reset`. Tasks are sorted
by computed status (Limit reset, Testing, Prompt, Wait, Uploading, Frozen, by computed status (Limit reset, Testing, Prompt, Wait, Uploading, Frozen,
Done); Wait and Limit reset tasks then sort by time (earliest first: when Done); Wait and Limit reset tasks then sort by time (earliest first: when
the wait ends / ended), then account A-Z (unassigned last), then name. The panels are cleared and redrawn after changes. the wait ends / ended), then account A-Z (unassigned last), then name. The panels are cleared and redrawn after changes.
* Account panel: one row per account, judged by its tasks: `Busy` if any is * Account panel: the RESET column is when the account's wait ends (`16:30`,
`tomorrow 06:40`, or a full date); `-` if it has none. One row per account, judged by its tasks: `Busy` if any is
Testing, Limit reset or Wait; else `Taken` if any is Prompt; else `Free` Testing, Limit reset or Wait; else `Taken` if any is Prompt; else `Free`
(only Done, Frozen or Uploading tasks, or no tasks). The TASKS column lists (only Done, Frozen or Uploading tasks, or no tasks). The TASKS column lists
the tasks at that level, so a Free account shows its Uploading, Frozen and the tasks at that level, so a Free account shows its Uploading, Frozen and
@@ -90,7 +92,11 @@ goes back to `$VISUAL` / `$EDITOR` / nano / vim / vi. The choice is stored in
underneath. A wait that no task shows (no tasks, or only Done / Frozen / underneath. A wait that no task shows (no tasks, or only Done / Frozen /
Uploading ones) is listed in the account row as `(wait HH:MM)` or Uploading ones) is listed in the account row as `(wait HH:MM)` or
`(limit reset)` and keeps the account Busy. Tasks without an account keep `(limit reset)` and keeps the account Busy. Tasks without an account keep
their own wait. Old databases with per-task waits are converted on load. their own wait.
* A time is only optional while the account is already waiting: then
`tsk restat <task> wait` (or `tsk add <task> <acc> wait`) just makes the task
follow that wait. Otherwise (no account, or its wait is over) a time is
required. Old databases with per-task waits are converted on load.
* Deleting an account leaves its tasks unassigned (`-`). * Deleting an account leaves its tasks unassigned (`-`).
* Names are case-insensitive; the original spelling is kept. * Names are case-insensitive; the original spelling is kept.
* Notes live in the database (SQLite TEXT, binary-safe, SQLite's default limit is ~1 GB). The task table shows `-` when a task has no notes, otherwise the number of lines (e.g. `3 lines`); open them with `tsk notes <name>`. * Notes live in the database (SQLite TEXT, binary-safe, SQLite's default limit is ~1 GB). The task table shows `-` when a task has no notes, otherwise the number of lines (e.g. `3 lines`); open them with `tsk notes <name>`.
+10 -2
View File
@@ -1,6 +1,8 @@
#pragma once #pragma once
#include <cstddef> #include <cstddef>
#include <ctime>
#include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -40,12 +42,18 @@ private:
void setAccountWait(const std::string& account, std::time_t until); void setAccountWait(const std::string& account, std::time_t until);
void noteAccountWait(const std::string& taskName); void noteAccountWait(const std::string& taskName);
void flushMessages(); void flushMessages();
void endOfInput(); bool endOfInput(); // true if the program should exit
bool confirm(const std::string& question) const;
enum class Answer { Yes, No, Cancel };
// Asks until the reply is valid (y / n, and c when allowed). nullopt if input closed.
std::optional<Answer> ask(const std::string& question, bool allowCancel) const;
// Helpers // Helpers
static void requireName(const std::string& name); static void requireName(const std::string& name);
static Status parseStatusFrom(const Args& args, std::size_t from); static Status parseStatusFrom(const Args& args, std::size_t from);
static bool isBareWait(const Args& args, std::size_t from);
std::optional<std::time_t> runningWait(const std::string& account) const;
void makeTaskFollowAccount(const TaskRow& task);
// Account commands // Account commands
bool cmdAccAdd(const Args& args); bool cmdAccAdd(const Args& args);
+2
View File
@@ -42,6 +42,8 @@ Status effective(const Status& status, std::time_t now);
std::string timeToText(std::time_t value); std::string timeToText(std::time_t value);
std::optional<std::time_t> timeFromText(const std::string& text); std::optional<std::time_t> timeFromText(const std::string& text);
std::string clockLabel(std::time_t value); // "16:30" std::string clockLabel(std::time_t value); // "16:30"
// "16:30" for today, "tomorrow 06:40" for the next day, otherwise "2026-10-26 16:30".
std::string resetLabel(std::time_t value, std::time_t now);
std::string label(const Status& status); // "Prompt", "Limit reset", "Wait 16:30", ... std::string label(const Status& status); // "Prompt", "Limit reset", "Wait 16:30", ...
std::string kindName(StatusKind kind); // lowercase token: "prompt", "limit-reset", ... std::string kindName(StatusKind kind); // lowercase token: "prompt", "limit-reset", ...
+14 -7
View File
@@ -148,12 +148,13 @@ std::string Display::renderAccounts(std::time_t now) const {
// One row per account. Its level is the most taken of its tasks // One row per account. Its level is the most taken of its tasks
// (Busy > Taken > Free); the TASKS column lists the tasks at that level // (Busy > Taken > Free); the TASKS column lists the tasks at that level
// (for a Free account that is its Uploading, Frozen and Done tasks). // (for a Free account that is its Uploading, Frozen and Done tasks).
// An account wait that no task is showing (no tasks, or only Done / Frozen / // The RESET column is when the account's wait ends. A wait that no task is
// Uploading ones) is listed as "(wait HH:MM)" / "(limit reset)" and keeps the // showing (no tasks, or only Done / Frozen / Uploading ones) is listed as
// account Busy. // "(wait)" / "(limit reset)" and keeps the account Busy.
struct AccountView { struct AccountView {
std::string name; std::string name;
AccountLevel level; AccountLevel level;
std::string reset; // when its wait ends, "-" if it has none
std::vector<std::string> tasks; std::vector<std::string> tasks;
}; };
@@ -180,14 +181,19 @@ std::string Display::renderAccounts(std::time_t now) const {
} }
} }
if (row.cooldown && !showsWait) { std::string reset = "-";
if (row.cooldown) {
if (std::optional<std::time_t> until = StatusUtil::timeFromText(*row.cooldown)) { if (std::optional<std::time_t> until = StatusUtil::timeFromText(*row.cooldown)) {
busy.push_back(*until > now ? "(wait " + StatusUtil::clockLabel(*until) + ")" : "(limit reset)"); reset = StatusUtil::resetLabel(*until, now);
if (!showsWait) {
busy.push_back(*until > now ? "(wait)" : "(limit reset)");
}
} }
} }
AccountView entry; AccountView entry;
entry.name = row.id; entry.name = row.id;
entry.reset = reset;
if (!busy.empty()) { if (!busy.empty()) {
entry.level = AccountLevel::Busy; entry.level = AccountLevel::Busy;
entry.tasks = busy; entry.tasks = busy;
@@ -211,14 +217,15 @@ std::string Display::renderAccounts(std::time_t now) const {
return a.level != AccountLevel::Free && a.tasks.size() < b.tasks.size(); return a.level != AccountLevel::Free && a.tasks.size() < b.tasks.size();
}); });
TextTable table({"ACCOUNT", "STATUS", "TASKS"}); TextTable table({"ACCOUNT", "STATUS", "RESET", "TASKS"});
for (const AccountView& entry : accounts) { for (const AccountView& entry : accounts) {
if (settings_.hiddenAccountLevels.count(entry.level) > 0) { if (settings_.hiddenAccountLevels.count(entry.level) > 0) {
continue; continue;
} }
std::string status = accountLevelName(entry.level); std::string status = accountLevelName(entry.level);
status[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(status[0]))); status[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(status[0])));
table.addRow({entry.name, status, entry.tasks.empty() ? "-" : StringUtil::join(entry.tasks, 0, ", ")}); table.addRow({entry.name, status, entry.reset,
entry.tasks.empty() ? "-" : StringUtil::join(entry.tasks, 0, ", ")});
} }
return table.render(); return table.render();
} }
+98 -28
View File
@@ -77,7 +77,7 @@ void Repl::registerCommands() {
{ArgKind::Task, ArgKind::None}, &Repl::cmdTskRename); {ArgKind::Task, ArgKind::None}, &Repl::cmdTskRename);
add("tsk reacc", "tsk reacc <name> <new>", "move a task to another account ('-' = none)", 2, 2, add("tsk reacc", "tsk reacc <name> <new>", "move a task to another account ('-' = none)", 2, 2,
{ArgKind::Task, ArgKind::AccountOrDash}, &Repl::cmdTskReacc); {ArgKind::Task, ArgKind::AccountOrDash}, &Repl::cmdTskReacc);
add("tsk restat", "tsk restat <name> <new>", "change a task's status", 2, kUnlimited, add("tsk restat", "tsk restat <name> <new>", "change a task's status (plain 'wait' works while its account is waiting)", 2, kUnlimited,
{ArgKind::Task, ArgKind::Status}, &Repl::cmdTskRestat); {ArgKind::Task, ArgKind::Status}, &Repl::cmdTskRestat);
add("tsk notes", "tsk notes <name>", "edit a task's notes in your editor (see 'editor')", 1, 1, add("tsk notes", "tsk notes <name>", "edit a task's notes in your editor (see 'editor')", 1, 1,
{ArgKind::Task}, &Repl::cmdTskNotes); {ArgKind::Task}, &Repl::cmdTskNotes);
@@ -138,27 +138,35 @@ int Repl::run() {
std::string line; std::string line;
while (!quit_) { while (!quit_) {
if (!lineEditor_.readLine("> ", line)) { if (!lineEditor_.readLine("> ", line)) {
endOfInput(); if (endOfInput()) {
return 0; return 0;
}
continue;
} }
execute(line); execute(line);
} }
return 0; return 0;
} }
void Repl::endOfInput() { bool Repl::endOfInput() {
if (!db_.dirty()) { if (!db_.dirty()) {
return; return true;
} }
if (Terminal::stdinIsInteractive()) { if (!Terminal::stdinIsInteractive()) {
if (confirm("Save changes before exiting?")) {
db_.save();
std::cout << "Saved to " << db_.path() << ".\n";
}
} else {
db_.save(); db_.save();
std::cout << "Input closed - changes saved to " << db_.path() << ".\n"; std::cout << "Input closed - changes saved to " << db_.path() << ".\n";
return true;
} }
const std::optional<Answer> answer = ask("Save changes before exiting?", true);
if (answer == Answer::Cancel) {
return false;
}
if (!answer || *answer == Answer::Yes) { // input closed: keep the changes
db_.save();
std::cout << "Saved to " << db_.path() << ".\n";
}
return true;
} }
void Repl::say(const std::string& message) { void Repl::say(const std::string& message) {
@@ -221,14 +229,51 @@ void Repl::refresh() const {
} }
} }
bool Repl::confirm(const std::string& question) const { std::optional<Repl::Answer> Repl::ask(const std::string& question, bool allowCancel) const {
std::cout << question << " (y/n): " << std::flush; for (;;) {
std::string answer; std::cout << question << (allowCancel ? " (y/n/c): " : " (y/n): ") << std::flush;
if (!std::getline(std::cin, answer)) { std::string reply;
return false; if (!std::getline(std::cin, reply)) {
return std::nullopt;
}
reply = StringUtil::lower(StringUtil::trim(reply));
if (reply == "y" || reply == "yes") {
return Answer::Yes;
}
if (reply == "n" || reply == "no") {
return Answer::No;
}
if (allowCancel && (reply == "c" || reply == "cancel")) {
return Answer::Cancel;
}
std::cout << (allowCancel ? "Please answer y (save), n (discard) or c (cancel).\n"
: "Please answer y or n.\n");
}
}
bool Repl::isBareWait(const Args& args, std::size_t from) {
return StringUtil::lower(StringUtil::join(args, from, " ")) == "wait";
}
// The account's wait if it is still running (not yet a Limit reset).
std::optional<std::time_t> Repl::runningWait(const std::string& account) const {
std::optional<AccountRow> row = db_.getAccount(account);
if (!row || !row->cooldown) {
return std::nullopt;
}
std::optional<std::time_t> until = StatusUtil::timeFromText(*row->cooldown);
if (until && *until > std::time(nullptr)) {
return until;
}
return std::nullopt;
}
// Prompt and Testing tasks follow their account's wait; anything else becomes Prompt.
void Repl::makeTaskFollowAccount(const TaskRow& task) {
std::optional<Status> current = StatusUtil::deserialize(task.status);
if (!current || !StatusUtil::followsAccountWait(current->kind)) {
db_.setTaskStatus(task.id, StatusUtil::serialize(StatusUtil::make(StatusKind::Prompt)));
} }
answer = StringUtil::lower(StringUtil::trim(answer));
return answer == "y" || answer == "yes";
} }
void Repl::requireName(const std::string& name) { void Repl::requireName(const std::string& name) {
@@ -354,7 +399,7 @@ bool Repl::cmdTskAdd(const Args& args) {
++next; ++next;
} else if (args[next] == "-") { } else if (args[next] == "-") {
++next; ++next;
} else if (!StatusUtil::parse(StringUtil::join(args, next, " "), std::time(nullptr))) { } else if (!isBareWait(args, next) && !StatusUtil::parse(StringUtil::join(args, next, " "), std::time(nullptr))) {
throw std::runtime_error("'" + args[next] + "' is neither an existing account nor a valid status (" + throw std::runtime_error("'" + args[next] + "' is neither an existing account nor a valid status (" +
kStatusHelp + ")."); kStatusHelp + ").");
} }
@@ -362,7 +407,14 @@ bool Repl::cmdTskAdd(const Args& args) {
Status status = StatusUtil::make(StatusKind::Prompt); Status status = StatusUtil::make(StatusKind::Prompt);
if (next < args.size()) { if (next < args.size()) {
status = parseStatusFrom(args, next); if (isBareWait(args, next)) {
// "wait" without a time: only while the account is already waiting.
if (!account || !runningWait(*account)) {
throw std::runtime_error("A time is required unless the task's account is already waiting: wait <time>");
}
} else {
status = parseStatusFrom(args, next);
}
} }
// A wait on a task that has an account is the account's wait; the task itself // A wait on a task that has an account is the account's wait; the task itself
@@ -433,15 +485,26 @@ bool Repl::cmdTskRestat(const Args& args) {
if (!task) { if (!task) {
throw std::runtime_error("No task named '" + args[0] + "'."); throw std::runtime_error("No task named '" + args[0] + "'.");
} }
if (isBareWait(args, 1)) {
// "wait" without a time: the task follows its account's running wait.
if (!task->account || !runningWait(*task->account)) {
throw std::runtime_error(
task->account ? "Account '" + *task->account + "' is not waiting, so a time is required: wait <time>"
: "Task '" + task->id + "' has no account, so a time is required: wait <time>");
}
makeTaskFollowAccount(*task);
say("Task '" + task->id + "' now follows account '" + *task->account + "'.");
noteAccountWait(task->id);
return true;
}
Status status = parseStatusFrom(args, 1); Status status = parseStatusFrom(args, 1);
if (status.kind == StatusKind::Wait && task->account) { if (status.kind == StatusKind::Wait && task->account) {
// The wait belongs to the account. Keep an active task as it is so it // The wait belongs to the account. Keep an active task as it is so it
// follows the wait; any other status becomes Prompt. // follows the wait; any other status becomes Prompt.
std::optional<Status> current = StatusUtil::deserialize(task->status); makeTaskFollowAccount(*task);
if (!current || !StatusUtil::followsAccountWait(current->kind)) {
db_.setTaskStatus(task->id, StatusUtil::serialize(StatusUtil::make(StatusKind::Prompt)));
}
setAccountWait(*task->account, status.waitUntil); setAccountWait(*task->account, status.waitUntil);
return true; return true;
} }
@@ -584,7 +647,7 @@ bool Repl::cmdDbBackup(const Args& args) {
} }
bool Repl::cmdDbRestore(const Args& args) { bool Repl::cmdDbRestore(const Args& args) {
if (db_.dirty() && !confirm("Unsaved changes will be replaced. Continue?")) { if (db_.dirty() && ask("Unsaved changes will be replaced. Continue?", false) != Answer::Yes) {
say("Restore cancelled."); say("Restore cancelled.");
return false; return false;
} }
@@ -640,9 +703,16 @@ bool Repl::cmdHelp(const Args&) {
} }
bool Repl::cmdQuit(const Args&) { bool Repl::cmdQuit(const Args&) {
if (db_.dirty() && confirm("Save changes before exiting?")) { if (db_.dirty()) {
db_.save(); const std::optional<Answer> answer = ask("Save changes before exiting?", true);
say("Saved to " + db_.path() + "."); if (answer == Answer::Cancel) {
say("Exit cancelled.");
return false;
}
if (!answer || *answer == Answer::Yes) { // input closed: keep the changes
db_.save();
say("Saved to " + db_.path() + ".");
}
} }
quit_ = true; quit_ = true;
return false; return false;
+20
View File
@@ -286,6 +286,26 @@ std::string clockLabel(std::time_t value) {
return formatTime(value, "%H:%M"); return formatTime(value, "%H:%M");
} }
std::string resetLabel(std::time_t value, std::time_t now) {
const std::tm day = toLocal(value);
const std::tm today = toLocal(now);
std::tm next = today;
next.tm_mday += 1;
next.tm_isdst = -1;
std::mktime(&next); // normalizes the date
auto sameDay = [](const std::tm& a, const std::tm& b) {
return a.tm_year == b.tm_year && a.tm_yday == b.tm_yday;
};
if (sameDay(day, today)) {
return clockLabel(value);
}
if (sameDay(day, next)) {
return "tomorrow " + clockLabel(value);
}
return formatTime(value, "%Y-%m-%d %H:%M");
}
std::string label(const Status& status) { std::string label(const Status& status) {
if (status.kind == StatusKind::Wait) { if (status.kind == StatusKind::Wait) {
return "Wait " + formatTime(status.waitUntil, "%H:%M"); return "Wait " + formatTime(status.waitUntil, "%H:%M");