From b9a6e8dbf0ad3518b86dc552c05ace66cc2d4e1a Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Sun, 15 Dec 2024 21:11:53 +0100 Subject: [PATCH] Implement basics of reminders UI and client-side state --- priv/static/index.html | 26 ++++++ priv/static/js/components.js | 127 ++++++++++++++++++++++++++++ priv/static/js/jenot.js | 11 ++- priv/static/js/reminders.js | 10 +++ priv/static/js/synced-store-test.js | 6 +- priv/static/js/synced-store.js | 95 ++++++++++++--------- 6 files changed, 229 insertions(+), 46 deletions(-) create mode 100644 priv/static/js/reminders.js diff --git a/priv/static/index.html b/priv/static/index.html index 2a926d2..c736c2c 100644 --- a/priv/static/index.html +++ b/priv/static/index.html @@ -72,8 +72,21 @@ + + @@ -88,8 +101,21 @@ + + diff --git a/priv/static/js/components.js b/priv/static/js/components.js index a58d7ec..cf12b25 100644 --- a/priv/static/js/components.js +++ b/priv/static/js/components.js @@ -1,4 +1,99 @@ import { renderText, html } from "./dom.js"; +import { reminderLabel } from "./reminders.js"; + +// reminder-picker component + +class ReminderPicker extends HTMLElement { + constructor() { + super(); + this.reminder = this.#new(); + } + + connectedCallback() { + this.dateInput = this.querySelector(".date"); + this.timeInput = this.querySelector(".time"); + this.repeatInput = this.querySelector(".repeat"); + this.clearButton = this.querySelector(".clear"); + + const that = this; + + this.dateInput.addEventListener("change", () => { + that.reminder.date = that.dateInput.value; + + this.#emitUpdate(); + }); + + this.timeInput.addEventListener("change", () => { + that.reminder.time = that.timeInput.value; + + this.#emitUpdate(); + }); + + this.repeatInput.addEventListener("change", () => { + that.reminder.unit = that.repeatInput.value; + + this.#emitUpdate(); + }); + + this.clearButton.addEventListener("click", () => { + this.reminder = this.#new(); + this.reminder.enabled = false; + this.#updateUI(); + this.#emitUpdate(); + }); + + this.#updateUI(); + } + + enableDefault() { + this.reminder = this.#new(); + this.reminder.enabled = true; + this.#updateUI(); + this.#emitUpdate(); + } + + set value(reminder) { + this.reminder = reminder || this.#new(); + this.#updateUI(); + } + + get value() { + return this.reminder; + } + + get label() { + return reminderLabel(this.reminder); + } + + #updateUI() { + this.dateInput.value = this.reminder.date; + this.timeInput.value = this.reminder.time; + this.repeatInput.value = this.reminder.unit; + } + + #new() { + const now = new Date().toISOString(); + const [date, rest] = now.split("T"); + const fullTime = rest.split(".")[0]; + const [hour, minute, _minute] = fullTime.split(":"); + + return { + enabled: false, + count: 1, + date: date, + time: `${hour}:${minute}`, + unit: "", + }; + } + + #emitUpdate() { + if (this.reminder.date && this.reminder.time) { + this.dispatchEvent(new Event("reminderUpdate", { bubbles: true })); + } + } +} + +customElements.define("reminder-picker", ReminderPicker); // editable-area component @@ -469,6 +564,9 @@ class NoteForm extends HTMLElement { this.removeButton = this.querySelector(".remove"); this.addButton = this.querySelector(".add"); this.saveButton = this.querySelector(".save"); + this.reminderButton = this.querySelector(".reminder"); + this.reminderButtonLabel = this.querySelector(".reminder span"); + this.reminderPicker = this.querySelector("reminder-picker"); this.addEventListener("click", (e) => { const textareaInside = e.target.querySelector("textarea"); @@ -517,6 +615,25 @@ class NoteForm extends HTMLElement { this.#reset(); }); + this.reminderButton.addEventListener("click", (e) => { + e.preventDefault(); + this.reminderPicker.classList.toggle("hidden"); + // reset and enable the reminder when first clicking the + // reminder button and there's either no reminder set yet + // or it's disabled + if ( + !this.note.reminder?.enabled && + !this.reminderPicker.classList.contains("hidden") + ) { + this.reminderPicker.enableDefault(); + } + }); + + this.addEventListener("reminderUpdate", () => { + this.note.reminder = this.reminderPicker.value; + this.#updateUI(); + }); + if (this.mode === "add") { this.addButton.addEventListener("click", (e) => { e.preventDefault(); @@ -551,6 +668,7 @@ class NoteForm extends HTMLElement { load(note) { this.note = note; + this.reminderPicker.value = note.reminder; this.#updateUI(); this.#setContent(); } @@ -559,13 +677,22 @@ class NoteForm extends HTMLElement { this.note = { type: "note", content: "", + reminder: null, }; + this.reminderPicker.value = null; + this.#updateUI(); this.#setContent(); } #updateUI() { + if (this.note.reminder?.enabled) { + this.reminderButtonLabel.textContent = reminderLabel(this.note.reminder); + } else { + this.reminderPicker.classList.add("hidden"); + this.reminderButtonLabel.textContent = ""; + } if (this.note.type === "note") { this.tasklistModeButton.classList.remove("hidden"); this.noteModeButton.classList.add("hidden"); diff --git a/priv/static/js/jenot.js b/priv/static/js/jenot.js index 8f73415..ed99aca 100644 --- a/priv/static/js/jenot.js +++ b/priv/static/js/jenot.js @@ -8,6 +8,7 @@ import { sendNotification, } from "./notifications.js"; import "./components.js"; +import { reminderLabel } from "./reminders.js"; async function resetApp() { await window.navigator.serviceWorker @@ -32,7 +33,7 @@ const isLoggedIn = !!document.cookie // The storage is a combination of IndexedDB + network sync. // Network sync is only enabled is user is logged in. const endpoint = isLoggedIn ? "/" : null; -const Notes = new SyncedNoteStore("jenot-app", "notes", endpoint); +const Notes = new SyncedNoteStore("jenot-app", endpoint); // Reset metadata to force full sync if (URL_PARAMS.has("reset-meta")) { @@ -213,6 +214,14 @@ function renderNote(note) { container.appendChild(list); } + if (note.reminder?.enabled) { + const labelText = reminderLabel(note.reminder); + const reminderBadge = document.createElement("span"); + reminderBadge.classList.add("reminder-label"); + reminderBadge.textContent = `⏲ ${labelText}`; + container.appendChild(reminderBadge); + } + container.addEventListener("click", async (e) => { const note = await Notes.get(container.id); editNote.load(note); diff --git a/priv/static/js/reminders.js b/priv/static/js/reminders.js new file mode 100644 index 0000000..40304c8 --- /dev/null +++ b/priv/static/js/reminders.js @@ -0,0 +1,10 @@ +export function reminderLabel(reminder) { + if (reminder.enabled) { + const date = new Date(`${reminder.date}T${reminder.time}:00`); + const day = date.getDate(); + const month = date.toLocaleString("en", { month: "short" }); + return `${day} ${month} ${reminder.time}`; + } + + return ""; +} diff --git a/priv/static/js/synced-store-test.js b/priv/static/js/synced-store-test.js index 681ac74..aa392b5 100644 --- a/priv/static/js/synced-store-test.js +++ b/priv/static/js/synced-store-test.js @@ -2,7 +2,7 @@ import { test, assert } from "./test-utils.js"; import { SyncedNoteStore } from "./synced-store.js"; test("synced store stores a note", async (_container, idx) => { - const store = new SyncedNoteStore(`jenot-app-test-${idx}`, "notes", "/", { + const store = new SyncedNoteStore(`jenot-app-test-${idx}`, "/", { add: () => null, }); @@ -24,7 +24,7 @@ test("synced store stores a note", async (_container, idx) => { test("synced store gets a note", async (_container, idx) => { let addCalled = false; - const store = new SyncedNoteStore(`jenot-app-test-${idx}`, "notes", "/", { + const store = new SyncedNoteStore(`jenot-app-test-${idx}`, "/", { add: () => (addCalled = true), }); @@ -44,7 +44,7 @@ test("synced store updates a note", async (_container, idx) => { let addCalled = false, updateCalled = false; - const store = new SyncedNoteStore(`jenot-app-test-${idx}`, "notes", "/", { + const store = new SyncedNoteStore(`jenot-app-test-${idx}`, "/", { add: () => (addCalled = true), update: () => (updateCalled = true), }); diff --git a/priv/static/js/synced-store.js b/priv/static/js/synced-store.js index 6090dcd..5257a76 100644 --- a/priv/static/js/synced-store.js +++ b/priv/static/js/synced-store.js @@ -1,3 +1,35 @@ +let databases = {}; + +async function connect(dbName) { + if (!databases[dbName]) { + databases[dbName] = await dbConnect(dbName); + } + + return databases[dbName]; +} + +function dbConnect(dbName) { + return new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, 1); + + request.onsuccess = (e) => { + resolve(e.target.result); + }; + + request.onerror = (e) => { + console.error(`indexedDB error: ${e.target.errorCode}`); + }; + + request.onupgradeneeded = (e) => { + const db = e.target.result; + + db.createObjectStore("notes", { + keyPath: "id", + }); + }; + }); +} + class WebNoteStore { constructor(endpoint) { this.endpoint = endpoint || "/"; @@ -70,26 +102,26 @@ class WebNoteStore { } export class SyncedNoteStore extends EventTarget { - constructor(dbName, storeName, endpoint, webStore) { + constructor(dbName, endpoint, webStore) { super(); this.dbName = dbName; - this.storeName = storeName; + this.storeName = "notes"; this.db = null; this.webStore = webStore || (endpoint && new WebNoteStore(endpoint)); } async all(since, includeDeleted) { const that = this; - return this.#connect().then( + return connect(this.dbName).then( (db) => new Promise((resolve, reject) => { db .transaction([that.storeName], "readonly") .objectStore(that.storeName) .getAll().onsuccess = (data) => { - const results = data.target.result.filter( - (n) => (includeDeleted || !n.deleted) && n.id !== "meta", - ).toSorted((a, b) => b.created - a.created); + const results = data.target.result + .filter((n) => (includeDeleted || !n.deleted) && n.id !== "meta") + .toSorted((a, b) => b.created - a.created); if (since > 0) { return resolve(results.filter((n) => n.updated > since)); @@ -105,7 +137,7 @@ export class SyncedNoteStore extends EventTarget { const that = this; let result; - return this.#connect().then( + return connect(this.dbName).then( (db) => new Promise( (resolve, reject) => @@ -134,11 +166,21 @@ export class SyncedNoteStore extends EventTarget { id: "id_" + now, type: note.type, content: note.content, + reminder: note.reminder + ? { + enabled: note.reminder.enabled, + date: note.reminder.date, + time: note.reminder.time, + repeat: note.reminder.count, + unit: note.reminder.unit, + } + : null, created: now, updated: now, deleted: null, }; - return this.#connect() + + return connect(this.dbName) .then( (db) => new Promise( @@ -151,7 +193,7 @@ export class SyncedNoteStore extends EventTarget { ) .then(() => { (async () => this.webStore?.add(entry))(); - return null; + return entry; }); } @@ -170,7 +212,7 @@ export class SyncedNoteStore extends EventTarget { note.updated = Date.now(); } - return this.#connect() + return connect(this.dbName) .then( (db) => new Promise( @@ -183,7 +225,7 @@ export class SyncedNoteStore extends EventTarget { ) .then(() => { (async () => (skipNetwork ? null : this.webStore?.add(note)))(); - return null; + return note; }); } @@ -224,35 +266,4 @@ export class SyncedNoteStore extends EventTarget { saveStorage() { this.dispatchEvent(new CustomEvent("save")); } - - async #connect() { - if (!this.db) { - this.db = await this.#dbConnect(); - } - - return this.db; - } - - #dbConnect() { - const that = this; - - return new Promise((resolve, reject) => { - const request = indexedDB.open(this.dbName, 1); - - request.onsuccess = (e) => { - resolve(e.target.result); - }; - - request.onerror = (e) => { - console.error(`indexedDB error: ${e.target.errorCode}`); - }; - - request.onupgradeneeded = (e) => { - const db = e.target.result; - db.createObjectStore(that.storeName, { - keyPath: "id", - }); - }; - }); - } }