Limits are now account-tied rather than task-tied
This commit is contained in:
1 parent
315a2102a7
commit
0af468f47c
9 files changed
+235
-14
No files matched your search
@@ -21,9 +21,9 @@ installed; otherwise CMake downloads the amalgamation (needs network once).
|
||||
acc add <name> tsk add <name> [acc] [status]
|
||||
acc del <name> tsk del <name>
|
||||
acc rename <name> <new> tsk rename <name> <new>
|
||||
acc ls tsk reacc <name> <new|->
|
||||
tsk restat <name> <new>
|
||||
tsk notes <name> (opens your editor)
|
||||
acc wait <name> <time> tsk reacc <name> <new|->
|
||||
acc unwait <name> tsk restat <name> <new>
|
||||
acc ls tsk notes <name> (opens your editor)
|
||||
tsk ls
|
||||
|
||||
disp tsk (hide|show)
|
||||
@@ -82,6 +82,15 @@ goes back to `$VISUAL` / `$EDITOR` / nano / vim / vi. The choice is stored in
|
||||
Done tasks (`-` if it has none). Rows are sorted most free first: Free, Taken, Busy;
|
||||
within Taken and Busy fewer tasks first, ties by name (see `levelOf` in `Display.cpp` and
|
||||
`StatusUtil::isBusy` / `isTaken` to change the rules).
|
||||
* Waits belong to the account: `acc wait <name> <time>` (or `tsk restat <task> wait
|
||||
<time>`, which sets its account's wait). Until the time the account's Prompt
|
||||
and Testing tasks show `Wait HH:MM`; after it they show `Limit reset`, until
|
||||
you run `acc unwait <name>`, which also clears a stale one. Uploading, Frozen
|
||||
and Done tasks are not affected, and the tasks keep their own stored status
|
||||
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.
|
||||
* 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>`.
|
||||
|
||||
@@ -32,6 +32,9 @@ public:
|
||||
void addAccount(const std::string& name);
|
||||
void removeAccount(const std::string& name);
|
||||
void renameAccount(const std::string& name, const std::string& newName);
|
||||
std::optional<AccountRow> getAccount(const std::string& name) const;
|
||||
std::vector<AccountRow> listAccountRows() const;
|
||||
void setAccountCooldown(const std::string& name, const std::optional<std::string>& cooldown);
|
||||
|
||||
// Tasks
|
||||
std::optional<std::string> findTask(const std::string& name) const; // canonical spelling
|
||||
|
||||
@@ -9,3 +9,8 @@ struct TaskRow {
|
||||
std::string status; // serialized, see StatusUtil::serialize
|
||||
std::string notes; // free text, any length (stored as SQLite TEXT)
|
||||
};
|
||||
|
||||
struct AccountRow {
|
||||
std::string id;
|
||||
std::optional<std::string> cooldown; // the account's wait: when its limit ends (StatusUtil::timeToText)
|
||||
};
|
||||
@@ -37,6 +37,8 @@ private:
|
||||
void execute(const std::string& line);
|
||||
void refresh() const;
|
||||
void say(const std::string& message);
|
||||
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;
|
||||
@@ -49,6 +51,8 @@ private:
|
||||
bool cmdAccAdd(const Args& args);
|
||||
bool cmdAccDel(const Args& args);
|
||||
bool cmdAccRename(const Args& args);
|
||||
bool cmdAccWait(const Args& args);
|
||||
bool cmdAccUnwait(const Args& args);
|
||||
bool cmdAccLs(const Args& args);
|
||||
|
||||
// Task commands
|
||||
|
||||
@@ -26,9 +26,23 @@ std::optional<Status> parse(const std::string& text, std::time_t now);
|
||||
std::string serialize(const Status& status);
|
||||
std::optional<Status> deserialize(const std::string& text);
|
||||
|
||||
// Prompt and Testing tasks follow their account's wait; Uploading, Frozen and
|
||||
// Done tasks do not.
|
||||
bool followsAccountWait(StatusKind kind);
|
||||
|
||||
// The status to show for a task. If its account has a wait, the tasks that
|
||||
// follow it show Wait until then and Limit reset after; otherwise the stored
|
||||
// status is used (see effective).
|
||||
Status resolve(const Status& stored, const std::optional<std::time_t>& accountWait, std::time_t now);
|
||||
|
||||
// The status shown to the user: an expired wait becomes LimitReset.
|
||||
Status effective(const Status& status, std::time_t now);
|
||||
|
||||
// Times kept outside a Status (an account's cooldown): "2026-10-17T16:30".
|
||||
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"
|
||||
|
||||
std::string label(const Status& status); // "Prompt", "Limit reset", "Wait 16:30", ...
|
||||
std::string kindName(StatusKind kind); // lowercase token: "prompt", "limit-reset", ...
|
||||
std::vector<StatusKind> allKinds();
|
||||
|
||||
+46
-1
@@ -13,7 +13,8 @@ namespace {
|
||||
|
||||
const char* const kSchema =
|
||||
"CREATE TABLE IF NOT EXISTS Account ("
|
||||
" id TEXT NOT NULL PRIMARY KEY COLLATE NOCASE"
|
||||
" id TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,"
|
||||
" cooldown TEXT"
|
||||
");"
|
||||
"CREATE TABLE IF NOT EXISTS Task ("
|
||||
" id TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,"
|
||||
@@ -97,6 +98,24 @@ void Database::createSchema() {
|
||||
exec(kSchema);
|
||||
// "available" was split into "prompt" and "limit-reset"; old files still say "available".
|
||||
exec("UPDATE Task SET status = 'prompt' WHERE status = 'available';");
|
||||
|
||||
// Files from before account cooldowns have no such column.
|
||||
bool hasCooldown = false;
|
||||
for (const Row& row : query("PRAGMA table_info(Account)")) {
|
||||
hasCooldown = hasCooldown || (row.size() > 1 && row[1] && *row[1] == "cooldown");
|
||||
}
|
||||
if (!hasCooldown) {
|
||||
exec("ALTER TABLE Account ADD COLUMN cooldown TEXT;");
|
||||
}
|
||||
|
||||
// Waits used to be set on each task. They belong to the account now: move
|
||||
// them over (the latest one wins) and let those tasks follow the account.
|
||||
exec(
|
||||
"UPDATE Account SET cooldown = max(coalesce(cooldown, ''), ("
|
||||
" SELECT max(substr(Task.status, 6)) FROM Task"
|
||||
" WHERE Task.account = Account.id AND Task.status LIKE 'wait %'))"
|
||||
" WHERE EXISTS (SELECT 1 FROM Task WHERE Task.account = Account.id AND Task.status LIKE 'wait %');"
|
||||
"UPDATE Task SET status = 'prompt' WHERE account IS NOT NULL AND status LIKE 'wait %';");
|
||||
}
|
||||
|
||||
void Database::exec(const std::string& sql) {
|
||||
@@ -232,6 +251,32 @@ void Database::renameAccount(const std::string& name, const std::string& newName
|
||||
run("UPDATE Account SET id = ?2 WHERE id = ?1", {name, newName});
|
||||
}
|
||||
|
||||
std::optional<AccountRow> Database::getAccount(const std::string& name) const {
|
||||
std::vector<Row> rows = query("SELECT id, cooldown FROM Account WHERE id = ?1", {name});
|
||||
if (rows.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
AccountRow account;
|
||||
account.id = *rows[0][0];
|
||||
account.cooldown = rows[0][1];
|
||||
return account;
|
||||
}
|
||||
|
||||
std::vector<AccountRow> Database::listAccountRows() const {
|
||||
std::vector<AccountRow> accounts;
|
||||
for (const Row& row : query("SELECT id, cooldown FROM Account ORDER BY id")) {
|
||||
AccountRow account;
|
||||
account.id = *row[0];
|
||||
account.cooldown = row[1];
|
||||
accounts.push_back(account);
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
void Database::setAccountCooldown(const std::string& name, const std::optional<std::string>& cooldown) {
|
||||
run("UPDATE Account SET cooldown = ?2 WHERE id = ?1", {name, cooldown});
|
||||
}
|
||||
|
||||
// ---- Tasks ------------------------------------------------------------------
|
||||
|
||||
std::optional<std::string> Database::findTask(const std::string& name) const {
|
||||
|
||||
+37
-5
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
@@ -18,6 +19,16 @@ struct TaskView {
|
||||
};
|
||||
|
||||
std::vector<TaskView> loadViews(const Database& db, std::time_t now) {
|
||||
// Each account's wait (when its limit ends), keyed by lowercase name.
|
||||
std::map<std::string, std::time_t> accountWaits;
|
||||
for (const AccountRow& account : db.listAccountRows()) {
|
||||
if (account.cooldown) {
|
||||
if (std::optional<std::time_t> until = StatusUtil::timeFromText(*account.cooldown)) {
|
||||
accountWaits[StringUtil::lower(account.id)] = *until;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<TaskView> views;
|
||||
for (const TaskRow& row : db.listTasks()) {
|
||||
TaskView view;
|
||||
@@ -25,7 +36,15 @@ std::vector<TaskView> loadViews(const Database& db, std::time_t now) {
|
||||
view.account = row.account.value_or("");
|
||||
view.notes = row.notes;
|
||||
std::optional<Status> stored = StatusUtil::deserialize(row.status);
|
||||
view.status = StatusUtil::effective(stored.value_or(StatusUtil::make(StatusKind::Prompt)), now);
|
||||
|
||||
std::optional<std::time_t> accountWait;
|
||||
if (row.account) {
|
||||
auto found = accountWaits.find(StringUtil::lower(*row.account));
|
||||
if (found != accountWaits.end()) {
|
||||
accountWait = found->second;
|
||||
}
|
||||
}
|
||||
view.status = StatusUtil::resolve(stored.value_or(StatusUtil::make(StatusKind::Prompt)), accountWait, now);
|
||||
views.push_back(view);
|
||||
}
|
||||
return views;
|
||||
@@ -129,6 +148,9 @@ 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.
|
||||
struct AccountView {
|
||||
std::string name;
|
||||
AccountLevel level;
|
||||
@@ -136,12 +158,13 @@ std::string Display::renderAccounts(std::time_t now) const {
|
||||
};
|
||||
|
||||
std::vector<AccountView> accounts;
|
||||
for (const std::string& account : db_.listAccounts()) {
|
||||
for (const AccountRow& row : db_.listAccountRows()) {
|
||||
std::vector<std::string> busy;
|
||||
std::vector<std::string> taken;
|
||||
std::vector<std::string> free;
|
||||
bool showsWait = false;
|
||||
for (const TaskView& view : views) {
|
||||
if (view.account != account) {
|
||||
if (view.account != row.id) {
|
||||
continue;
|
||||
}
|
||||
const AccountLevel level = levelOf(view.status.kind);
|
||||
@@ -152,10 +175,19 @@ std::string Display::renderAccounts(std::time_t now) const {
|
||||
} else {
|
||||
free.push_back(view.name);
|
||||
}
|
||||
if (view.status.kind == StatusKind::Wait || view.status.kind == StatusKind::LimitReset) {
|
||||
showsWait = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (row.cooldown && !showsWait) {
|
||||
if (std::optional<std::time_t> until = StatusUtil::timeFromText(*row.cooldown)) {
|
||||
busy.push_back(*until > now ? "(wait " + StatusUtil::clockLabel(*until) + ")" : "(limit reset)");
|
||||
}
|
||||
}
|
||||
|
||||
AccountView entry;
|
||||
entry.name = account;
|
||||
entry.name = row.id;
|
||||
if (!busy.empty()) {
|
||||
entry.level = AccountLevel::Busy;
|
||||
entry.tasks = busy;
|
||||
@@ -170,7 +202,7 @@ std::string Display::renderAccounts(std::time_t now) const {
|
||||
}
|
||||
|
||||
// Most free first: Free, then Taken, then Busy; within Taken and Busy fewer
|
||||
// tasks first (Free accounts are all equally free). listAccounts is
|
||||
// tasks first (Free accounts are all equally free). listAccountRows is
|
||||
// alphabetical and stable_sort keeps that order for ties.
|
||||
std::stable_sort(accounts.begin(), accounts.end(), [](const AccountView& a, const AccountView& b) {
|
||||
if (a.level != b.level) {
|
||||
|
||||
+82
-5
@@ -62,6 +62,11 @@ void Repl::registerCommands() {
|
||||
{ArgKind::Account}, &Repl::cmdAccDel);
|
||||
add("acc rename", "acc rename <name> <new>", "rename an account", 2, 2,
|
||||
{ArgKind::Account, ArgKind::None}, &Repl::cmdAccRename);
|
||||
add("acc wait", "acc wait <name> <time>",
|
||||
"the account hit its limit until <time>: its Prompt/Testing tasks show Wait, then Limit reset",
|
||||
2, kUnlimited, {ArgKind::Account, ArgKind::None}, &Repl::cmdAccWait);
|
||||
add("acc unwait", "acc unwait <name>", "clear an account's wait / limit reset (its tasks show their own status again)",
|
||||
1, 1, {ArgKind::Account}, &Repl::cmdAccUnwait);
|
||||
add("acc ls", "acc ls", "show the account table", 0, 0, {}, &Repl::cmdAccLs);
|
||||
|
||||
add("tsk add", "tsk add <name> [acc] [status]", "create a task", 1, kUnlimited,
|
||||
@@ -277,6 +282,55 @@ bool Repl::cmdAccRename(const Args& args) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void Repl::setAccountWait(const std::string& account, std::time_t until) {
|
||||
db_.setAccountCooldown(account, StatusUtil::timeToText(until));
|
||||
say("Account '" + account + "' waits until " + StatusUtil::clockLabel(until) +
|
||||
": its Prompt and Testing tasks show Wait, then Limit reset (acc unwait " + account + " to clear).");
|
||||
}
|
||||
|
||||
// Tells the user when a task's own status is hidden by its account's wait.
|
||||
void Repl::noteAccountWait(const std::string& taskName) {
|
||||
std::optional<TaskRow> task = db_.getTask(taskName);
|
||||
std::optional<Status> stored = task ? StatusUtil::deserialize(task->status) : std::nullopt;
|
||||
if (!task || !task->account || !stored || !StatusUtil::followsAccountWait(stored->kind)) {
|
||||
return;
|
||||
}
|
||||
std::optional<AccountRow> account = db_.getAccount(*task->account);
|
||||
if (account && account->cooldown) {
|
||||
say("It shows as Wait / Limit reset because account '" + account->id + "' is waiting (acc unwait " +
|
||||
account->id + " to clear).");
|
||||
}
|
||||
}
|
||||
|
||||
bool Repl::cmdAccWait(const Args& args) {
|
||||
std::optional<AccountRow> account = db_.getAccount(args[0]);
|
||||
if (!account) {
|
||||
throw std::runtime_error("No account named '" + args[0] + "'.");
|
||||
}
|
||||
const std::string text = "wait " + StringUtil::join(args, 1, " ");
|
||||
std::optional<Status> status = StatusUtil::parse(text, std::time(nullptr));
|
||||
if (!status) {
|
||||
throw std::runtime_error("Invalid time '" + StringUtil::join(args, 1, " ") +
|
||||
"'. Use e.g. 16:30, 4:30pm or 4pm.");
|
||||
}
|
||||
setAccountWait(account->id, status->waitUntil);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Repl::cmdAccUnwait(const Args& args) {
|
||||
std::optional<AccountRow> account = db_.getAccount(args[0]);
|
||||
if (!account) {
|
||||
throw std::runtime_error("No account named '" + args[0] + "'.");
|
||||
}
|
||||
if (!account->cooldown) {
|
||||
say("Account '" + account->id + "' is not waiting.");
|
||||
return false;
|
||||
}
|
||||
db_.setAccountCooldown(account->id, std::nullopt);
|
||||
say("Wait cleared on '" + account->id + "'.");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Repl::cmdAccLs(const Args&) {
|
||||
Terminal::clearScreen();
|
||||
std::cout << display_.renderAccounts(std::time(nullptr)) << "\n";
|
||||
@@ -311,8 +365,18 @@ bool Repl::cmdTskAdd(const Args& args) {
|
||||
status = parseStatusFrom(args, next);
|
||||
}
|
||||
|
||||
db_.addTask(name, account, StatusUtil::serialize(status));
|
||||
// A wait on a task that has an account is the account's wait; the task itself
|
||||
// stays Prompt and follows it.
|
||||
const bool accountWait = (status.kind == StatusKind::Wait && account);
|
||||
const Status stored = accountWait ? StatusUtil::make(StatusKind::Prompt) : status;
|
||||
|
||||
db_.addTask(name, account, StatusUtil::serialize(stored));
|
||||
say("Task '" + name + "' added.");
|
||||
if (accountWait) {
|
||||
setAccountWait(*account, status.waitUntil);
|
||||
} else {
|
||||
noteAccountWait(name);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -365,13 +429,26 @@ bool Repl::cmdTskReacc(const Args& args) {
|
||||
}
|
||||
|
||||
bool Repl::cmdTskRestat(const Args& args) {
|
||||
std::optional<std::string> name = db_.findTask(args[0]);
|
||||
if (!name) {
|
||||
std::optional<TaskRow> task = db_.getTask(args[0]);
|
||||
if (!task) {
|
||||
throw std::runtime_error("No task named '" + args[0] + "'.");
|
||||
}
|
||||
Status status = parseStatusFrom(args, 1);
|
||||
db_.setTaskStatus(*name, StatusUtil::serialize(status));
|
||||
say("Task '" + *name + "' set to " + StatusUtil::label(status) + ".");
|
||||
|
||||
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)));
|
||||
}
|
||||
setAccountWait(*task->account, status.waitUntil);
|
||||
return true;
|
||||
}
|
||||
|
||||
db_.setTaskStatus(task->id, StatusUtil::serialize(status));
|
||||
say("Task '" + task->id + "' set to " + StatusUtil::label(status) + ".");
|
||||
noteAccountWait(task->id);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -245,6 +245,19 @@ std::optional<Status> deserialize(const std::string& text) {
|
||||
return status;
|
||||
}
|
||||
|
||||
bool followsAccountWait(StatusKind kind) {
|
||||
return kind == StatusKind::Prompt || kind == StatusKind::Testing;
|
||||
}
|
||||
|
||||
Status resolve(const Status& stored, const std::optional<std::time_t>& accountWait, std::time_t now) {
|
||||
if (accountWait && followsAccountWait(stored.kind)) {
|
||||
Status shown = make(now < *accountWait ? StatusKind::Wait : StatusKind::LimitReset);
|
||||
shown.waitUntil = *accountWait;
|
||||
return shown;
|
||||
}
|
||||
return effective(stored, now);
|
||||
}
|
||||
|
||||
Status effective(const Status& status, std::time_t now) {
|
||||
if (status.kind == StatusKind::Wait && now >= status.waitUntil) {
|
||||
Status expired = make(StatusKind::LimitReset);
|
||||
@@ -254,6 +267,25 @@ Status effective(const Status& status, std::time_t now) {
|
||||
return status;
|
||||
}
|
||||
|
||||
std::string timeToText(std::time_t value) {
|
||||
return formatTime(value, "%Y-%m-%dT%H:%M");
|
||||
}
|
||||
|
||||
std::optional<std::time_t> timeFromText(const std::string& text) {
|
||||
std::tm parts{};
|
||||
std::istringstream in(text);
|
||||
in >> std::get_time(&parts, "%Y-%m-%dT%H:%M");
|
||||
if (in.fail()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
parts.tm_isdst = -1;
|
||||
return std::mktime(&parts);
|
||||
}
|
||||
|
||||
std::string clockLabel(std::time_t value) {
|
||||
return formatTime(value, "%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