mirror of
https://github.com/zoldar/jenot.git
synced 2026-01-03 14:32:54 +00:00
Implement basics of reminders UI and client-side state
This commit is contained in:
parent
97365447e1
commit
b9a6e8dbf0
6 changed files with 229 additions and 46 deletions
|
|
@ -72,8 +72,21 @@
|
||||||
<button class="add">➕</button>
|
<button class="add">➕</button>
|
||||||
<button class="tasklist-mode">☑️</button>
|
<button class="tasklist-mode">☑️</button>
|
||||||
<button class="note-mode hidden">📑</button>
|
<button class="note-mode hidden">📑</button>
|
||||||
|
<button class="reminder">⏰ <span></span></button>
|
||||||
<button class="remove">🗑️</button>
|
<button class="remove">🗑️</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</note-form>
|
</note-form>
|
||||||
|
|
||||||
|
|
@ -88,8 +101,21 @@
|
||||||
<button class="save">💾</button>
|
<button class="save">💾</button>
|
||||||
<button class="tasklist-mode">☑️</button>
|
<button class="tasklist-mode">☑️</button>
|
||||||
<button class="note-mode">📑</button>
|
<button class="note-mode">📑</button>
|
||||||
|
<button class="reminder">⏰ <span></span></button>
|
||||||
<button class="remove">🗑️</button>
|
<button class="remove">🗑️</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</note-form>
|
</note-form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,99 @@
|
||||||
import { renderText, html } from "./dom.js";
|
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
|
// editable-area component
|
||||||
|
|
||||||
|
|
@ -469,6 +564,9 @@ class NoteForm extends HTMLElement {
|
||||||
this.removeButton = this.querySelector(".remove");
|
this.removeButton = this.querySelector(".remove");
|
||||||
this.addButton = this.querySelector(".add");
|
this.addButton = this.querySelector(".add");
|
||||||
this.saveButton = this.querySelector(".save");
|
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) => {
|
this.addEventListener("click", (e) => {
|
||||||
const textareaInside = e.target.querySelector("textarea");
|
const textareaInside = e.target.querySelector("textarea");
|
||||||
|
|
@ -517,6 +615,25 @@ class NoteForm extends HTMLElement {
|
||||||
this.#reset();
|
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") {
|
if (this.mode === "add") {
|
||||||
this.addButton.addEventListener("click", (e) => {
|
this.addButton.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -551,6 +668,7 @@ class NoteForm extends HTMLElement {
|
||||||
|
|
||||||
load(note) {
|
load(note) {
|
||||||
this.note = note;
|
this.note = note;
|
||||||
|
this.reminderPicker.value = note.reminder;
|
||||||
this.#updateUI();
|
this.#updateUI();
|
||||||
this.#setContent();
|
this.#setContent();
|
||||||
}
|
}
|
||||||
|
|
@ -559,13 +677,22 @@ class NoteForm extends HTMLElement {
|
||||||
this.note = {
|
this.note = {
|
||||||
type: "note",
|
type: "note",
|
||||||
content: "",
|
content: "",
|
||||||
|
reminder: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.reminderPicker.value = null;
|
||||||
|
|
||||||
this.#updateUI();
|
this.#updateUI();
|
||||||
this.#setContent();
|
this.#setContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateUI() {
|
#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") {
|
if (this.note.type === "note") {
|
||||||
this.tasklistModeButton.classList.remove("hidden");
|
this.tasklistModeButton.classList.remove("hidden");
|
||||||
this.noteModeButton.classList.add("hidden");
|
this.noteModeButton.classList.add("hidden");
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
sendNotification,
|
sendNotification,
|
||||||
} from "./notifications.js";
|
} from "./notifications.js";
|
||||||
import "./components.js";
|
import "./components.js";
|
||||||
|
import { reminderLabel } from "./reminders.js";
|
||||||
|
|
||||||
async function resetApp() {
|
async function resetApp() {
|
||||||
await window.navigator.serviceWorker
|
await window.navigator.serviceWorker
|
||||||
|
|
@ -32,7 +33,7 @@ const isLoggedIn = !!document.cookie
|
||||||
// The storage is a combination of IndexedDB + network sync.
|
// The storage is a combination of IndexedDB + network sync.
|
||||||
// Network sync is only enabled is user is logged in.
|
// Network sync is only enabled is user is logged in.
|
||||||
const endpoint = isLoggedIn ? "/" : null;
|
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
|
// Reset metadata to force full sync
|
||||||
if (URL_PARAMS.has("reset-meta")) {
|
if (URL_PARAMS.has("reset-meta")) {
|
||||||
|
|
@ -213,6 +214,14 @@ function renderNote(note) {
|
||||||
container.appendChild(list);
|
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) => {
|
container.addEventListener("click", async (e) => {
|
||||||
const note = await Notes.get(container.id);
|
const note = await Notes.get(container.id);
|
||||||
editNote.load(note);
|
editNote.load(note);
|
||||||
|
|
|
||||||
10
priv/static/js/reminders.js
Normal file
10
priv/static/js/reminders.js
Normal 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 "";
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import { test, assert } from "./test-utils.js";
|
||||||
import { SyncedNoteStore } from "./synced-store.js";
|
import { SyncedNoteStore } from "./synced-store.js";
|
||||||
|
|
||||||
test("synced store stores a note", async (_container, idx) => {
|
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,
|
add: () => null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@ test("synced store stores a note", async (_container, idx) => {
|
||||||
test("synced store gets a note", async (_container, idx) => {
|
test("synced store gets a note", async (_container, idx) => {
|
||||||
let addCalled = false;
|
let addCalled = false;
|
||||||
|
|
||||||
const store = new SyncedNoteStore(`jenot-app-test-${idx}`, "notes", "/", {
|
const store = new SyncedNoteStore(`jenot-app-test-${idx}`, "/", {
|
||||||
add: () => (addCalled = true),
|
add: () => (addCalled = true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@ test("synced store updates a note", async (_container, idx) => {
|
||||||
let addCalled = false,
|
let addCalled = false,
|
||||||
updateCalled = false;
|
updateCalled = false;
|
||||||
|
|
||||||
const store = new SyncedNoteStore(`jenot-app-test-${idx}`, "notes", "/", {
|
const store = new SyncedNoteStore(`jenot-app-test-${idx}`, "/", {
|
||||||
add: () => (addCalled = true),
|
add: () => (addCalled = true),
|
||||||
update: () => (updateCalled = true),
|
update: () => (updateCalled = true),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
class WebNoteStore {
|
||||||
constructor(endpoint) {
|
constructor(endpoint) {
|
||||||
this.endpoint = endpoint || "/";
|
this.endpoint = endpoint || "/";
|
||||||
|
|
@ -70,26 +102,26 @@ class WebNoteStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SyncedNoteStore extends EventTarget {
|
export class SyncedNoteStore extends EventTarget {
|
||||||
constructor(dbName, storeName, endpoint, webStore) {
|
constructor(dbName, endpoint, webStore) {
|
||||||
super();
|
super();
|
||||||
this.dbName = dbName;
|
this.dbName = dbName;
|
||||||
this.storeName = storeName;
|
this.storeName = "notes";
|
||||||
this.db = null;
|
this.db = null;
|
||||||
this.webStore = webStore || (endpoint && new WebNoteStore(endpoint));
|
this.webStore = webStore || (endpoint && new WebNoteStore(endpoint));
|
||||||
}
|
}
|
||||||
|
|
||||||
async all(since, includeDeleted) {
|
async all(since, includeDeleted) {
|
||||||
const that = this;
|
const that = this;
|
||||||
return this.#connect().then(
|
return connect(this.dbName).then(
|
||||||
(db) =>
|
(db) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
db
|
db
|
||||||
.transaction([that.storeName], "readonly")
|
.transaction([that.storeName], "readonly")
|
||||||
.objectStore(that.storeName)
|
.objectStore(that.storeName)
|
||||||
.getAll().onsuccess = (data) => {
|
.getAll().onsuccess = (data) => {
|
||||||
const results = data.target.result.filter(
|
const results = data.target.result
|
||||||
(n) => (includeDeleted || !n.deleted) && n.id !== "meta",
|
.filter((n) => (includeDeleted || !n.deleted) && n.id !== "meta")
|
||||||
).toSorted((a, b) => b.created - a.created);
|
.toSorted((a, b) => b.created - a.created);
|
||||||
|
|
||||||
if (since > 0) {
|
if (since > 0) {
|
||||||
return resolve(results.filter((n) => n.updated > since));
|
return resolve(results.filter((n) => n.updated > since));
|
||||||
|
|
@ -105,7 +137,7 @@ export class SyncedNoteStore extends EventTarget {
|
||||||
const that = this;
|
const that = this;
|
||||||
let result;
|
let result;
|
||||||
|
|
||||||
return this.#connect().then(
|
return connect(this.dbName).then(
|
||||||
(db) =>
|
(db) =>
|
||||||
new Promise(
|
new Promise(
|
||||||
(resolve, reject) =>
|
(resolve, reject) =>
|
||||||
|
|
@ -134,11 +166,21 @@ export class SyncedNoteStore extends EventTarget {
|
||||||
id: "id_" + now,
|
id: "id_" + now,
|
||||||
type: note.type,
|
type: note.type,
|
||||||
content: note.content,
|
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,
|
created: now,
|
||||||
updated: now,
|
updated: now,
|
||||||
deleted: null,
|
deleted: null,
|
||||||
};
|
};
|
||||||
return this.#connect()
|
|
||||||
|
return connect(this.dbName)
|
||||||
.then(
|
.then(
|
||||||
(db) =>
|
(db) =>
|
||||||
new Promise(
|
new Promise(
|
||||||
|
|
@ -151,7 +193,7 @@ export class SyncedNoteStore extends EventTarget {
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
(async () => this.webStore?.add(entry))();
|
(async () => this.webStore?.add(entry))();
|
||||||
return null;
|
return entry;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,7 +212,7 @@ export class SyncedNoteStore extends EventTarget {
|
||||||
note.updated = Date.now();
|
note.updated = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.#connect()
|
return connect(this.dbName)
|
||||||
.then(
|
.then(
|
||||||
(db) =>
|
(db) =>
|
||||||
new Promise(
|
new Promise(
|
||||||
|
|
@ -183,7 +225,7 @@ export class SyncedNoteStore extends EventTarget {
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
(async () => (skipNetwork ? null : this.webStore?.add(note)))();
|
(async () => (skipNetwork ? null : this.webStore?.add(note)))();
|
||||||
return null;
|
return note;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,35 +266,4 @@ export class SyncedNoteStore extends EventTarget {
|
||||||
saveStorage() {
|
saveStorage() {
|
||||||
this.dispatchEvent(new CustomEvent("save"));
|
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",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue