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:
1 parent
0af468f47c
commit
35c568333d
6 files changed
+149
-36
No files matched your search
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
+94
-24
@@ -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();
|
||||
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?")) {
|
||||
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";
|
||||
}
|
||||
} else {
|
||||
db_.save();
|
||||
std::cout << "Input closed - changes 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,8 +407,15 @@ bool Repl::cmdTskAdd(const Args& args) {
|
||||
|
||||
Status status = StatusUtil::make(StatusKind::Prompt);
|
||||
if (next < args.size()) {
|
||||
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
|
||||
// stays Prompt and follows it.
|
||||
@@ -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,10 +703,17 @@ bool Repl::cmdHelp(const Args&) {
|
||||
}
|
||||
|
||||
bool Repl::cmdQuit(const Args&) {
|
||||
if (db_.dirty() && confirm("Save changes before exiting?")) {
|
||||
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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user