return crypto.subtle.digest("SHA-256", new TextEncoder().encode(str)).then((buf) => (new Uint16Array(buf)[0] * 360) / 2 ** 16);
}
-const Model = {
- addTask: function (timestamp: string, description: string): Element {
- const task = document.createElement("div");
- const desc = document.createElement("span");
- desc.textContent = description;
- desc.classList.add("desc");
- task.appendChild(desc);
- task.classList.add("task");
- task.setAttribute("tabindex", "0");
- task.setAttribute("data-created", timestamp);
- task.setAttribute("data-state", "todo");
- const tasks = document.getElementById("tasks")!;
- tasks.insertBefore(task, tasks.firstElementChild);
- return task;
- },
-
- addTag: function (createTimestamp: string, tagName: string): Element | null {
- const task = this.getTask(createTimestamp);
- if (!task) return null;
- const existingTag = this.hasTag(task, tagName);
- if (existingTag) return existingTag;
- const tag = document.createElement("span");
- tag.appendChild(document.createTextNode(tagName));
- tag.classList.add("tag");
- tag.setAttribute("tabindex", "0");
- hashHue(tagName).then((hue) => (tag.style.backgroundColor = `hsl(${hue},90%,45%)`));
- for (const child of task.getElementsByClassName("tag")) {
- if (tagName > child.textContent!) {
- task.insertBefore(tag, child);
- return tag;
+function Model() {
+ return {
+ addTask: function (timestamp: string, description: string): Element {
+ const task = document.createElement("div");
+ const desc = document.createElement("span");
+ desc.textContent = description;
+ desc.classList.add("desc");
+ task.appendChild(desc);
+ task.classList.add("task");
+ task.setAttribute("tabindex", "0");
+ task.setAttribute("data-created", timestamp);
+ task.setAttribute("data-state", "todo");
+ const tasks = document.getElementById("tasks")!;
+ tasks.insertBefore(task, tasks.firstElementChild);
+ return task;
+ },
+
+ addTag: function (createTimestamp: string, tagName: string): Element | null {
+ const task = this.getTask(createTimestamp);
+ if (!task) return null;
+ const existingTag = this.hasTag(task, tagName);
+ if (existingTag) return existingTag;
+ const tag = document.createElement("span");
+ tag.appendChild(document.createTextNode(tagName));
+ tag.classList.add("tag");
+ tag.setAttribute("tabindex", "0");
+ hashHue(tagName).then((hue) => (tag.style.backgroundColor = `hsl(${hue},90%,45%)`));
+ for (const child of task.getElementsByClassName("tag")) {
+ if (tagName > child.textContent!) {
+ task.insertBefore(tag, child);
+ return tag;
+ }
}
- }
- task.appendChild(tag);
- return tag;
- },
-
- edit: function (createTimestamp: string, newDescription: string): Element | null {
- const target = this.getTask(createTimestamp);
- if (!target) return null;
- if (target.hasAttribute("data-description")) {
- // Oh no: An edit has arrived from a replica while a local edit is in progress.
- const input = target.firstChild as HTMLInputElement;
- if (
- input.value === target.getAttribute("data-description") &&
- input.selectionStart === input.value.length &&
- input.selectionEnd === input.value.length
- ) {
- // No local changes have actually been made yet. Change the contents of the edit box!
- input.value = newDescription;
+ task.insertBefore(tag, task.getElementsByClassName("desc")[0]!);
+ return tag;
+ },
+
+ edit: function (createTimestamp: string, newDescription: string): Element | null {
+ const target = this.getTask(createTimestamp);
+ if (!target) return null;
+ if (target.hasAttribute("data-description")) {
+ // Oh no: An edit has arrived from a replica while a local edit is in progress.
+ const input = target.getElementsByTagName("input")[0]!;
+ if (
+ input.value === target.getAttribute("data-description") &&
+ input.selectionStart === input.value.length &&
+ input.selectionEnd === input.value.length
+ ) {
+ // No local changes have actually been made yet. Change the contents of the edit box!
+ input.value = newDescription;
+ } else {
+ // No great options.
+ // Prefer not to interrupt the local user's edit.
+ // The remote edit is mostly lost; this mostly becomes last-write-wins.
+ target.setAttribute("data-description", newDescription);
+ }
} else {
- // No great options.
- // Prefer not to interrupt the local user's edit.
- // The remote edit is mostly lost; this mostly becomes last-write-wins.
- target.setAttribute("data-description", newDescription);
+ target.getElementsByClassName("desc")[0].textContent = newDescription;
}
- } else {
- target.getElementsByClassName("desc")[0].textContent = newDescription;
- }
- return target;
- },
+ return target;
+ },
- hasTag: function (task: Element, tag: string): Element | null {
- for (const child of task.getElementsByClassName("tag")) {
- if (child.textContent === tag) {
- return child;
+ hasTag: function (task: Element, tag: string): Element | null {
+ for (const child of task.getElementsByClassName("tag")) {
+ if (child.textContent === tag) {
+ return child;
+ }
}
- }
- return null;
- },
+ return null;
+ },
- getPriority: function (task: Element): number {
- if (task.hasAttribute("data-priority")) {
- return parseFloat(task.getAttribute("data-priority")!);
- }
- return parseFloat(task.getAttribute("data-created")!);
- },
+ getPriority: function (task: Element): number {
+ if (task.hasAttribute("data-priority")) {
+ return parseFloat(task.getAttribute("data-priority")!);
+ }
+ return parseFloat(task.getAttribute("data-created")!);
+ },
- getTask: function (createTimestamp: string) {
- for (const task of document.getElementsByClassName("task")) {
- if (task.getAttribute("data-created") === createTimestamp) {
- return task;
+ getTask: function (createTimestamp: string) {
+ for (const task of document.getElementsByClassName("task")) {
+ if (task.getAttribute("data-created") === createTimestamp) {
+ return task;
+ }
}
- }
- },
-
- removeTag: function (createTimestamp: string, tagName: string) {
- const task = this.getTask(createTimestamp);
- if (!task) return null;
- const tag = this.hasTag(task, tagName);
- if (!tag) return;
- task.removeChild(tag);
- if (task instanceof HTMLElement) task.focus();
- },
-
- setPriority: function (createTimestamp: string, priority: number): Element | null {
- const target = this.getTask(createTimestamp);
- if (!target) return null;
- target.setAttribute("data-priority", `${priority}`);
- for (const task of document.getElementsByClassName("task")) {
- if (task !== target && this.getPriority(task) < priority) {
- task.parentElement!.insertBefore(target, task);
- return target;
+ },
+
+ insertInPriorityOrder: function (task: Element, dest: Element) {
+ const priority = this.getPriority(task);
+ for (const t of dest.children) {
+ if (t !== task && this.getPriority(t) < priority) {
+ dest.insertBefore(task, t);
+ return;
+ }
}
- }
- document.getElementById("tasks")!.appendChild(target);
- return target;
- },
-
- setState: function (stateTimestamp: string, createTimestamp: string, state: string) {
- const task = this.getTask(createTimestamp);
- if (!task) return;
- task.setAttribute("data-state", state);
- var date = task.getElementsByClassName("statedate")[0];
- if (state === "todo") {
- task.removeChild(date);
- return;
- }
- if (!date) {
- date = document.createElement("span");
- date.classList.add("statedate");
- task.insertBefore(date, task.firstChild);
- }
- const d = new Date(parseInt(stateTimestamp));
- date.textContent = `${d.getFullYear()}-${`${d.getMonth() + 1}`.padStart(2, "0")}-${`${d.getDate()}`.padStart(2, "0")}`;
- },
-};
+ dest.appendChild(task);
+ },
+
+ removeTag: function (createTimestamp: string, tagName: string) {
+ const task = this.getTask(createTimestamp);
+ if (!task) return null;
+ const tag = this.hasTag(task, tagName);
+ if (!tag) return;
+ task.removeChild(tag);
+ if (task instanceof HTMLElement) task.focus();
+ },
+
+ setPriority: function (createTimestamp: string, priority: number): Element | null {
+ const target = this.getTask(createTimestamp);
+ if (!target) return null;
+ target.setAttribute("data-priority", `${priority}`);
+ this.insertInPriorityOrder(target, target.parentElement!);
+ return target;
+ },
+
+ setState: function (stateTimestamp: string, createTimestamp: string, state: string) {
+ const task = this.getTask(createTimestamp);
+ if (!task) return;
+ task.setAttribute("data-state", state);
+ var date = task.getElementsByClassName("statedate")[0];
+ if (state === "todo") {
+ task.removeChild(date);
+ return;
+ }
+ if (!date) {
+ date = document.createElement("span");
+ date.classList.add("statedate");
+ task.insertBefore(date, task.firstChild);
+ }
+ const d = new Date(parseInt(stateTimestamp));
+ date.textContent = `${d.getFullYear()}-${`${d.getMonth() + 1}`.padStart(2, "0")}-${`${d.getDate()}`.padStart(2, "0")}`;
+ },
+ };
+}
+const model = Model();
function Log(prefix: string = "vp-") {
var next_log_index = 0;
apply: function (entry: string) {
const [timestamp, command, data] = splitN(entry, " ", 2);
if (command == "Create") {
- return Model.addTask(timestamp, data);
+ return model.addTask(timestamp, data);
}
if (command == "Edit") {
const [createTimestamp, description] = splitN(data, " ", 1);
- return Model.edit(createTimestamp, description);
+ return model.edit(createTimestamp, description);
}
if (command == "Priority") {
const [createTimestamp, newPriority] = splitN(data, " ", 1);
- return Model.setPriority(createTimestamp, parseFloat(newPriority));
+ return model.setPriority(createTimestamp, parseFloat(newPriority));
}
if (command == "State") {
const [createTimestamp, state] = splitN(data, " ", 1);
- return Model.setState(timestamp, createTimestamp, state);
+ return model.setState(timestamp, createTimestamp, state);
}
if (command == "Tag") {
const [createTimestamp, tag] = splitN(data, " ", 1);
- return Model.addTag(createTimestamp, tag);
+ return model.addTag(createTimestamp, tag);
}
if (command == "Untag") {
const [createTimestamp, tag] = splitN(data, " ", 1);
- return Model.removeTag(createTimestamp, tag);
+ return model.removeTag(createTimestamp, tag);
}
},
},
replay: function () {
+ document.getElementById("tasks")!.style.display = "none";
while (true) {
const entry = window.localStorage.getItem(`${prefix}${next_log_index}`);
if (entry === null) {
this.apply(entry);
next_log_index++;
}
+ document.getElementById("tasks")!.style.display = "";
},
};
}
todo: "White",
waiting: "MediumOrchid",
};
+ var currentTagView: string | null = null;
var currentViewState = "todo";
var taskFocusedBeforeJumpingToInput: HTMLElement | null = null;
var lastTagNameEntered = "";
},
beginEdit: function (event: Event) {
- var task = document.activeElement;
+ const task = this.currentTask();
if (!task) return;
- if (task.classList.contains("tag")) task = task.parentElement!;
const input = document.createElement("input");
const desc = task.getElementsByClassName("desc")[0];
const oldDescription = desc.textContent!;
},
beginTagEdit: function (event: Event) {
- const task = document.activeElement;
+ const task = this.currentTask();
if (!task) return;
const input = document.createElement("input");
input.classList.add("tag");
input.removeEventListener("blur", this.completeTagEdit);
task.removeChild(input);
task.focus();
- if (resolution === CommitOrAbort.Commit && !newTagName.match(/^ *$/) && !Model.hasTag(task, newTagName)) {
+ if (resolution === CommitOrAbort.Commit && !newTagName.match(/^ *$/) && !model.hasTag(task, newTagName)) {
ui.addTag(task.getAttribute("data-created")!, newTagName);
lastTagNameEntered = newTagName;
}
return target;
},
- firstVisibleTask: function () {
- for (const task of document.getElementsByClassName("task")) {
+ currentTask: function (): HTMLElement | null {
+ var target = document.activeElement;
+ if (!target) return null;
+ if (target.classList.contains("tag")) target = target.parentElement!;
+ return target as HTMLElement;
+ },
+
+ firstVisibleTask: function (root: Element | null = null) {
+ if (root === null) root = document.body;
+ for (const task of root.getElementsByClassName("task")) {
const state = task.getAttribute("data-state");
if (
task instanceof HTMLElement &&
},
focusTaskNameInput: function (event: Event) {
- if (document.activeElement instanceof HTMLElement) {
- taskFocusedBeforeJumpingToInput = document.activeElement;
- }
+ taskFocusedBeforeJumpingToInput = this.currentTask();
document.getElementById("taskName")!.focus();
window.scroll(0, 0);
event.preventDefault();
},
makeBottomPriority: function (task: Element | null = null) {
- if (!task) task = document.activeElement;
+ if (!task) task = this.currentTask();
if (!task) return;
this.setPriority(task, document.getElementById("tasks")!.lastElementChild, null);
},
makeTopPriority: function (task: Element | null = null) {
- if (!task) task = document.activeElement;
+ if (!task) task = this.currentTask();
if (!task) return;
- ui.setPriority(task.getAttribute("data-created")!, clock.now(), Model.getPriority(task));
+ ui.setPriority(task.getAttribute("data-created")!, clock.now(), model.getPriority(task));
task instanceof HTMLElement && task.focus();
},
- moveCursor: function (offset: number): boolean {
- const active = document.activeElement;
+ moveCursorLeft: function () {
+ const active = this.currentTask();
+ if (!active) return false;
+ if (active.parentElement!.classList.contains("task")) {
+ active.parentElement!.focus();
+ }
+ },
+
+ moveCursorRight: function () {
+ const active = this.currentTask();
+ if (!active) return false;
+ (this.firstVisibleTask(active) as HTMLElement | null)?.focus();
+ },
+
+ moveCursorVertically: function (offset: number): boolean {
+ const active = this.currentTask();
if (!active) return false;
const dest = this.visibleTaskAtOffset(active, offset);
if (dest !== active && dest instanceof HTMLElement) {
},
moveTask: function (offset: number) {
- const active = document.activeElement;
+ const active = this.currentTask();
if (!active) return;
const dest = this.visibleTaskAtOffset(active, offset);
if (dest === active) return; // Already extremal
},
resetTagView: function () {
- for (const task of document.getElementsByClassName("task")) {
+ currentTagView = null;
+ const taskList = document.getElementById("tasks")!;
+ for (const task of Array.from(document.getElementsByClassName("task"))) {
task.classList.remove("hide");
+ if (task.parentElement !== taskList) {
+ model.insertInPriorityOrder(task, taskList);
+ }
}
},
// Change task's priority to be between other tasks a and b.
setPriority: function (task: Element, a: Element | null, b: Element | null) {
- const aPriority = a === null ? clock.now() : Model.getPriority(a);
- const bPriority = b === null ? 0 : Model.getPriority(b);
+ const aPriority = a === null ? clock.now() : model.getPriority(a);
+ const bPriority = b === null ? 0 : model.getPriority(b);
console.assert(aPriority > bPriority, aPriority, ">", bPriority);
const span = aPriority - bPriority;
const newPriority = bPriority + 0.1 * span + 0.8 * span * Math.random();
console.assert(aPriority > newPriority && newPriority > bPriority, aPriority, ">", newPriority, ">", bPriority);
const newPriorityRounded = Math.round(newPriority);
const okToRound = aPriority > newPriorityRounded && newPriorityRounded > bPriority;
- ui.setPriority(task.getAttribute("data-created")!, okToRound ? newPriorityRounded : newPriority, Model.getPriority(task));
+ ui.setPriority(task.getAttribute("data-created")!, okToRound ? newPriorityRounded : newPriority, model.getPriority(task));
task instanceof HTMLElement && task.focus();
},
setState: function (newState: string) {
- const task = document.activeElement;
+ const task = this.currentTask();
if (!task) return;
const oldState = task.getAttribute("data-state")!;
if (newState === oldState) return;
const createTimestamp = task.getAttribute("data-created")!;
if (currentViewState !== "all" || newState == "deleted") {
- this.moveCursor(1) || this.moveCursor(-1);
+ this.moveCursorVertically(1) || this.moveCursorVertically(-1);
}
return ui.setState(createTimestamp, newState, oldState);
},
- setTagView: function () {
- const target = this.currentTag();
- if (!target) return;
- const tag = target.textContent!;
+ setTagView: function (tag: string | null = null) {
+ if (tag === null) {
+ const target = this.currentTag();
+ if (!target) return;
+ tag = target.textContent!;
+ }
+
+ if (currentTagView !== null) {
+ this.resetTagView();
+ }
+
+ const tasksWithTag = new Map();
for (const task of document.getElementsByClassName("task")) {
- if (Model.hasTag(task, tag)) {
+ if (model.hasTag(task, tag)) {
+ tasksWithTag.set(task.getElementsByClassName("desc")[0].textContent, [model.getPriority(task), task]);
+ }
+ }
+
+ function highestPrioritySuperTask(t: Element) {
+ var maxPriority = -1;
+ var superTask = null;
+ for (const child of t.getElementsByClassName("tag")) {
+ const e = tasksWithTag.get(child.textContent);
+ if (e !== undefined && e[0] > maxPriority) {
+ maxPriority = e[0];
+ superTask = e[1];
+ }
+ }
+ return superTask;
+ }
+
+ for (const task of Array.from(document.getElementsByClassName("task"))) {
+ if (model.hasTag(task, tag)) {
task.classList.remove("hide");
} else {
- task.classList.add("hide");
+ const superTask = highestPrioritySuperTask(task);
+ if (superTask !== null) {
+ model.insertInPriorityOrder(task, superTask);
+ } else {
+ task.classList.add("hide");
+ }
}
}
+
+ currentTagView = tag;
},
setView: function (state: string) {
sheet.removeRule(2);
sheet.removeRule(2);
currentViewState = state;
- if (document.activeElement?.getAttribute("data-state") !== state) {
+ if (this.currentTask()?.getAttribute("data-state") !== state) {
this.firstVisibleTask()?.focus();
}
},
+ setUntaggedView: function () {
+ if (currentTagView !== null) {
+ this.resetTagView();
+ }
+ for (const task of document.getElementsByClassName("task")) {
+ if (task.getElementsByClassName("tag").length === 0) {
+ task.classList.remove("hide");
+ } else {
+ task.classList.add("hide");
+ }
+ }
+ },
+
undo: function () {
const ret = ui.undo();
if (ret && ret instanceof HTMLElement) ret.focus();
if (event.key == "e") return window.scrollBy(0, (inputCount ?? 1) * scrollIncrement);
if (event.key == "y") return window.scrollBy(0, (inputCount ?? 1) * -scrollIncrement);
} else {
- if (event.key == "j") return browserUI.moveCursor(inputCount ?? 1);
- if (event.key == "k") return browserUI.moveCursor(-(inputCount ?? 1));
+ if (event.key == "h") return browserUI.moveCursorLeft();
+ if (event.key == "l") return browserUI.moveCursorRight();
+ if (event.key == "j") return browserUI.moveCursorVertically(inputCount ?? 1);
+ if (event.key == "k") return browserUI.moveCursorVertically(-(inputCount ?? 1));
if (event.key == "J") return browserUI.moveTask(inputCount ?? 1);
if (event.key == "K") return browserUI.moveTask(-(inputCount ?? 1));
if (event.key == "G") return browserUI.jumpCursor(inputCount ?? MAX_SAFE_INTEGER);
if (event.key == "a") return browserUI.setView("all");
if (event.key == "c") return browserUI.setView("cancelled");
if (event.key == "d") return browserUI.setView("done");
+ if (event.key == "i") return browserUI.setUntaggedView();
+ if (event.key == "p") return browserUI.setTagView("Project");
if (event.key == "q") return browserUI.setView("todo");
if (event.key == "s") return (inputState = InputState.VS);
if (event.key == "T") return browserUI.resetTagView();
if (event.key == "t") return browserUI.setTagView();
+ if (event.key == "u") return browserUI.setUntaggedView();
if (event.key == "v") return browserUI.resetView();
if (event.key == "w") return browserUI.setView("waiting");
if (event.key == "x") return browserUI.setView("deleted");