Implement basics of reminders UI and client-side state

This commit is contained in:
Adrian Gruntkowski 2024-12-15 21:11:53 +01:00
parent 97365447e1
commit b9a6e8dbf0
6 changed files with 229 additions and 46 deletions

View file

@ -72,8 +72,21 @@
<button class="add"></button>
<button class="tasklist-mode">☑️</button>
<button class="note-mode hidden">📑</button>
<button class="reminder"><span></span></button>
<button class="remove">🗑️</button>
</div>
<reminder-picker class="hidden">
<input class="date" type="date" />
<input class="time" type="time" />
<select class="repeat">
<option value="">Does not repeat</option>
<option value="day">Daily</option>
<option value="week">Weekly</option>
<option value="month">Monthly</option>
<option value="year">Yearly</option>
</select>
<button class="clear">X</button>
</reminder-picker>
</div>
</note-form>
@ -88,8 +101,21 @@
<button class="save">💾</button>
<button class="tasklist-mode">☑️</button>
<button class="note-mode">📑</button>
<button class="reminder"><span></span></button>
<button class="remove">🗑️</button>
</div>
<reminder-picker class="hidden">
<input class="date" type="date" />
<input class="time" type="time" />
<select class="repeat">
<option value="">Does not repeat</option>
<option value="day">Daily</option>
<option value="week">Weekly</option>
<option value="month">Monthly</option>
<option value="year">Yearly</option>
</select>
<button class="clear">X</button>
</reminder-picker>
</div>
</note-form>
</dialog>

View file

@ -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");

View file

@ -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);

View file

@ -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 "";
}

View file

@ -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),
});

View file

@ -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",
});
};
});
}
}