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,
`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
`Wait HH:MM` until that time passes, then as `Limit reset`. Tasks are sorted
by computed status (Limit reset, Testing, Prompt, Wait, Uploading, Frozen,
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.
* 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`
(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
@@ -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 /
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
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 (`-`).
* 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>`.
+10 -2
View File
@@ -1,6 +1,8 @@
#pragma once
#include <cstddef>
#include <ctime>
#include <optional>
#include <string>
#include <vector>
@@ -40,12 +42,18 @@ private:
void setAccountWait(const std::string& account, std::time_t until);
void noteAccountWait(const std::string& taskName);
void flushMessages();
void endOfInput();
bool confirm(const std::string& question) const;
bool endOfInput(); // true if the program should exit
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
static void requireName(const std::string& name);
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
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::optional<std::time_t> timeFromText(const std::string& text);
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 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
// (Busy > Taken > Free); the TASKS column lists the tasks at that level
// (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 /
// Uploading ones) is listed as "(wait HH:MM)" / "(limit reset)" and keeps the
// account Busy.
// The RESET column is when the account's wait ends. A wait that no task is
// showing (no tasks, or only Done / Frozen / Uploading ones) is listed as
// "(wait)" / "(limit reset)" and keeps the account Busy.
struct AccountView {
std::string name;
AccountLevel level;
std::string reset; // when its wait ends, "-" if it has none
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)) {
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;
entry.name = row.id;
entry.reset = reset;
if (!busy.empty()) {
entry.level = AccountLevel::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();
});
TextTable table({"ACCOUNT", "STATUS", "TASKS"});
TextTable table({"ACCOUNT", "STATUS", "RESET", "TASKS"});
for (const AccountView& entry : accounts) {
if (settings_.hiddenAccountLevels.count(entry.level) > 0) {
continue;
}
std::string status = accountLevelName(entry.level);
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();
}
+98 -28
View File
@@ -77,7 +77,7 @@ void Repl::registerCommands() {
{ArgKind::Task, ArgKind::None}, &Repl::cmdTskRename);
add("tsk reacc", "tsk reacc <name> <new>", "move a task to another account ('-' = none)", 2, 2,
{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);
add("tsk notes", "tsk notes <name>", "edit a task's notes in your editor (see 'editor')", 1, 1,
{ArgKind::Task}, &Repl::cmdTskNotes);
@@ -138,27 +138,35 @@ int Repl::run() {
std::string line;
while (!quit_) {
if (!lineEditor_.readLine("> ", line)) {
endOfInput();
return 0;
if (endOfInput()) {
return 0;
}
continue;
}
execute(line);
}
return 0;
}
void Repl::endOfInput() {
bool Repl::endOfInput() {
if (!db_.dirty()) {
return;
return true;
}
if (Terminal::stdinIsInteractive()) {
if (confirm("Save changes before exiting?")) {
db_.save();
std::cout << "Saved to " << db_.path() << ".\n";
}
} else {
if (!Terminal::stdinIsInteractive()) {
db_.save();
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) {
@@ -221,14 +229,51 @@ void Repl::refresh() const {
}
}
bool Repl::confirm(const std::string& question) const {
std::cout << question << " (y/n): " << std::flush;
std::string answer;
if (!std::getline(std::cin, answer)) {
return false;
std::optional<Repl::Answer> Repl::ask(const std::string& question, bool allowCancel) const {
for (;;) {
std::cout << question << (allowCancel ? " (y/n/c): " : " (y/n): ") << std::flush;
std::string reply;
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) {
@@ -354,7 +399,7 @@ bool Repl::cmdTskAdd(const Args& args) {
++next;
} else if (args[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 (" +
kStatusHelp + ").");
}
@@ -362,7 +407,14 @@ bool Repl::cmdTskAdd(const Args& args) {
Status status = StatusUtil::make(StatusKind::Prompt);
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
@@ -433,15 +485,26 @@ bool Repl::cmdTskRestat(const Args& args) {
if (!task) {
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);
if (status.kind == StatusKind::Wait && task->account) {
// The wait belongs to the account. Keep an active task as it is so it
// follows the wait; any other status becomes Prompt.
std::optional<Status> current = StatusUtil::deserialize(task->status);
if (!current || !StatusUtil::followsAccountWait(current->kind)) {
db_.setTaskStatus(task->id, StatusUtil::serialize(StatusUtil::make(StatusKind::Prompt)));
}
makeTaskFollowAccount(*task);
setAccountWait(*task->account, status.waitUntil);
return true;
}
@@ -584,7 +647,7 @@ bool Repl::cmdDbBackup(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.");
return false;
}
@@ -640,9 +703,16 @@ bool Repl::cmdHelp(const Args&) {
}
bool Repl::cmdQuit(const Args&) {
if (db_.dirty() && confirm("Save changes before exiting?")) {
db_.save();
say("Saved to " + db_.path() + ".");
if (db_.dirty()) {
const std::optional<Answer> answer = ask("Save changes before exiting?", true);
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;
return false;
+20
View File
@@ -286,6 +286,26 @@ std::string clockLabel(std::time_t value) {
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) {
if (status.kind == StatusKind::Wait) {
return "Wait " + formatTime(status.waitUntil, "%H:%M");