Limits are now account-tied rather than task-tied

This commit is contained in:
alejo committed 2026-10-24 09:13:03 -03:00
1 parent 315a2102a7
commit 0af468f47c
9 files changed
+235 -14

No files matched your search

+12 -3
View File
@@ -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>`.
+3
View File
@@ -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
+5
View File
@@ -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)
};
+4
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+32
View File
@@ -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");