diff --git a/README.md b/README.md index d6373f7..d1d8330 100644 --- a/README.md +++ b/README.md @@ -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 wait` (or `tsk add 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 `. diff --git a/include/Repl.h b/include/Repl.h index 8728e44..9b10a09 100644 --- a/include/Repl.h +++ b/include/Repl.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include @@ -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 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 runningWait(const std::string& account) const; + void makeTaskFollowAccount(const TaskRow& task); // Account commands bool cmdAccAdd(const Args& args); diff --git a/include/Status.h b/include/Status.h index 066ab2b..f2dcb92 100644 --- a/include/Status.h +++ b/include/Status.h @@ -42,6 +42,8 @@ Status effective(const Status& status, std::time_t now); std::string timeToText(std::time_t value); std::optional 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", ... diff --git a/src/Display.cpp b/src/Display.cpp index 3bae765..1d47f61 100644 --- a/src/Display.cpp +++ b/src/Display.cpp @@ -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 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 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(std::toupper(static_cast(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(); } diff --git a/src/Repl.cpp b/src/Repl.cpp index 4c8dab0..1d66c9e 100644 --- a/src/Repl.cpp +++ b/src/Repl.cpp @@ -77,7 +77,7 @@ void Repl::registerCommands() { {ArgKind::Task, ArgKind::None}, &Repl::cmdTskRename); add("tsk reacc", "tsk reacc ", "move a task to another account ('-' = none)", 2, 2, {ArgKind::Task, ArgKind::AccountOrDash}, &Repl::cmdTskReacc); - add("tsk restat", "tsk restat ", "change a task's status", 2, kUnlimited, + add("tsk restat", "tsk restat ", "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 ", "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 = 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::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 Repl::runningWait(const std::string& account) const { + std::optional row = db_.getAccount(account); + if (!row || !row->cooldown) { + return std::nullopt; + } + std::optional 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 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