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

+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;