From 6f2de018fca32f9429be9ff6786055228c87367b Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Wed, 19 Nov 2025 15:49:49 +0100 Subject: [PATCH 01/33] #70 Updated Docks for new settings feature --- docs/API.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 89f75da..bc64126 100644 --- a/docs/API.md +++ b/docs/API.md @@ -119,6 +119,7 @@ Alle HTTP Aufrufe sind englisch und kleingeschrieben! ``` # Details zu Services +TODO Rework this to implement two new intervals ## Plants - GET - `…/api/plants/get/[id]` @@ -301,4 +302,25 @@ Beispiel: - `…/api/upload/image` - Speichert das im Body mit gesendete Bild auf dem Server unter: `/public/images/plants/.` - hinterlegt das Bild bei der Pflanze mit der angegebenen `plant_id` - - Pflicht Felder: `picture` und `plant_id` \ No newline at end of file + - Pflicht Felder: `picture` und `plant_id` + +## Settings +- GET + - `…/api/settings/[key]` + - Liefert den dazugehörigen Wert zum angefragten [key] +- PUT + - `…/api/settings/[key]` + - Setzt den Wert für den angegebenen [key] + +### Settings Objekt +Beispiel: +```JSON +{ + "key": "watering_profile", + "value": "cold" +} +``` + +### Available Keys and Values +- Key `watering_profile` + - Values: `cold`, `normal`, `warm` \ No newline at end of file From 4bfca56368787e7d59a2267e81a032dba90a7be8 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Wed, 19 Nov 2025 22:02:15 +0100 Subject: [PATCH 02/33] #70 Finished planing API and updating docs --- docs/API.md | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/docs/API.md b/docs/API.md index bc64126..fdb8a05 100644 --- a/docs/API.md +++ b/docs/API.md @@ -119,7 +119,6 @@ Alle HTTP Aufrufe sind englisch und kleingeschrieben! ``` # Details zu Services -TODO Rework this to implement two new intervals ## Plants - GET - `…/api/plants/get/[id]` @@ -159,9 +158,9 @@ Beispiel: "image": "1.png", "added": "2023-03-14", "watering_interval": 14, - "watering_interval_offset": -3, - "watering_interval_calculated": 11, - "days_since_watering": 10, + "watering_interval_cold": 20, + "watering_interval_warm": 10, + "days_since_watering": 13, "days_until_watering": 1, "repotted": "2023-03-14", "composted": null @@ -177,11 +176,11 @@ Beispiel: - name - Eindeutig - Typ: Text - - Pflichtfeld bei: PUT und POST + - Pflichtfeld bei: POST - Nullbar: nein - species_name - Typ: Text - - Pflichtfeld bei: PUT und POST + - Pflichtfeld bei: POST - Nullbar: nein - image - Typ: Text @@ -195,28 +194,25 @@ Beispiel: - Defaultwert: generiert durch DB bei POST - watering_interval - Typ: Integer - - Pflichtfeld bei: PUT und POST + - Pflichtfeld bei: POST - Nullbar: nein - Defaultwert: 7 - - Wie häufig die Pflanze gegossen werden muss in Tagen -- watering_interval_offset + - Wie häufig die Pflanze gegossen werden muss in Tagen wenn `watering_profile`=`normal` +- watering_interval_warm - Typ: Integer - - Pflichtfeld bei: PUT und POST + - Pflichtfeld bei: POST - Nullbar: nein - - Defaultwert: 0 - - kann auch negativ sein - - Repräsentiert den Standort - - Wird auf den Interval addiert -- watering_interval_calculated + - Wie häufig die Pflanze gegossen werden muss in Tagen wenn `watering_profile`=`warm` +- watering_interval_cold - Typ: Integer - - Pflichtfeld bei: nie + - Pflichtfeld bei: POST - Nullbar: nein - - Berechneter Wert aus Interval Plus Offset -- days_since_watering + - Wie häufig die Pflanze gegossen werden muss in Tagen wenn `watering_profile`=`cold` +- days_until_watering - Typ: Integer - Pflichtfeld bei: nie - Nullbar: nein - - Durch Backend berechneter Wert + - Durch Backend berechneter Wert abhängig von `watering_profile` - Tage seit letztem gießen - days_since_watering - Typ: Integer @@ -231,7 +227,7 @@ Beispiel: - Nullbar: nein - Durch Backend berechneter Wert - Datum des letzten Umtopfen - - Wenn noch nie umgetopft Datum des hinzufügens + - Wenn noch nie umgetopft Datum des Hinzufügens - composted - Typ: Text(Datum) - Pflichtfeld bei: nie From 01b1a678d5cf8b862ecac134c7881f1ea3eb807a Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Wed, 19 Nov 2025 23:41:16 +0100 Subject: [PATCH 03/33] Updated package versions - Frontend 1.2.2 to 1.3.0 - Backend 1.1.3 to 1.2.0 --- backend/package.json | 2 +- frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/package.json b/backend/package.json index dc40b21..dc163c4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "plant_manager_backend", - "version": "1.1.3", + "version": "1.2.0", "description": "Backend for PlantManager with file upload and sqlite", "main": "server.js", "scripts": { diff --git a/frontend/package.json b/frontend/package.json index 8df3d42..966ad7f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "plant_manager_frontend", - "version": "1.2.2", + "version": "1.3.0", "description": "Frontend for PlantManager", "main": "client.js", "scripts": { From 79967c76c84eb93a13724945c256372f4e11e8c0 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Wed, 19 Nov 2025 23:41:45 +0100 Subject: [PATCH 04/33] #70 Added DB migration to add settings table --- backend/dbMigrationTool.js | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/backend/dbMigrationTool.js b/backend/dbMigrationTool.js index defa4c0..f92fe6f 100644 --- a/backend/dbMigrationTool.js +++ b/backend/dbMigrationTool.js @@ -4,7 +4,7 @@ * @returns {boolean} false if the DB is fine and true if it needs to be migrated */ module.exports.dbNeedsMigration = function(dbConnection) { - // ============== Check requirement for v1.0.1 ============== + // ============== Check requirement for v1.1.0 ============== const columnExists = dbConnection.prepare(` PRAGMA table_info(plants) `).all().some(col => col.name === 'composted'); @@ -14,6 +14,14 @@ module.exports.dbNeedsMigration = function(dbConnection) { return true; } + // ============== Check requirement for v1.2.0 ============== + const settingsTableExists = dbConnection.prepare(` + SELECT name FROM sqlite_master WHERE type='table' AND name='settings' + `).get(); + if (!settingsTableExists) { + return true; + } + // ============== Check requirement for v.. ============== // ... @@ -41,6 +49,23 @@ module.exports.migrateDB = function(dbConnection) { console.log("Column 'composted' already exists in 'plants' table."); } - // ============== Upgrade from v1.0.0 to v.. ============== + // ============== Upgrade from v1.1.0 to v1.2.0 ============== + const settingsTableExists = dbConnection.prepare(` + SELECT name FROM sqlite_master WHERE type='table' AND name='settings' + `).get(); + + if (!settingsTableExists) { + dbConnection.prepare(` + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `).run(); + console.log("Table 'settings' created."); + } else { + console.log("Table 'settings' already exists."); + } + + // ============== Upgrade from v.. to v.. ============== // ... } \ No newline at end of file From 82839d07b66502b9490e00cd3ef5a819f2a79db9 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Thu, 20 Nov 2025 00:46:38 +0100 Subject: [PATCH 05/33] #70 corrected API --- docs/API.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/API.md b/docs/API.md index fdb8a05..343eeda 100644 --- a/docs/API.md +++ b/docs/API.md @@ -305,8 +305,9 @@ Beispiel: - `…/api/settings/[key]` - Liefert den dazugehörigen Wert zum angefragten [key] - PUT - - `…/api/settings/[key]` - - Setzt den Wert für den angegebenen [key] + - `…/api/settings/` + - Setzt den Wert für den angegebenen `key` auf den Wert der Variable `value` + - Pflicht Felder: `key` und `value` ### Settings Objekt Beispiel: From d44e18ea4af813f85313e227afdcfd5a88050a01 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Thu, 20 Nov 2025 00:48:26 +0100 Subject: [PATCH 06/33] #70 added default value for "watering_profile" and more logs to dbMigrationTool --- backend/dbMigrationTool.js | 9 +++++++++ backend/server.js | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/dbMigrationTool.js b/backend/dbMigrationTool.js index f92fe6f..d921546 100644 --- a/backend/dbMigrationTool.js +++ b/backend/dbMigrationTool.js @@ -1,3 +1,5 @@ +const settingsDao = require('./dao/settingsDao.js'); + /** * Checks if the DB is from an earlier version and needs to be upgraded * @param {import('better-sqlite3').Database} dbConnection the connection to the DB @@ -34,6 +36,7 @@ module.exports.dbNeedsMigration = function(dbConnection) { * @param {import('better-sqlite3').Database} dbConnection the connection to the DB */ module.exports.migrateDB = function(dbConnection) { + console.log('========================== Migrating started =========================='); // ============== Upgrade from v1.0.0 to v1.1.0 ============== // Add 'composted' column to 'plants' table if it does not exist const columnExists = dbConnection.prepare(` @@ -66,6 +69,12 @@ module.exports.migrateDB = function(dbConnection) { console.log("Table 'settings' already exists."); } + console.log("Default setting 'watering_profile' added with value 'normal'."); + let settingsDaoInstance = new settingsDao(dbConnection); + settingsDaoInstance.save('watering_profile', 'normal'); + // ============== Upgrade from v.. to v.. ============== // ... + + console.log('========================== Migrating completed =========================='); } \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index f6c95f5..1e95973 100644 --- a/backend/server.js +++ b/backend/server.js @@ -36,7 +36,7 @@ try console.log('Check database...'); if(dbMigrationTool.dbNeedsMigration(dbConnection)) { - console.log('Migrating database...'); + console.warn('Database needs migration, migrating now...'); dbMigrationTool.migrateDB(dbConnection); } From 85fab1ec85808cdd42234913de04daace85244b4 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Thu, 20 Nov 2025 00:50:17 +0100 Subject: [PATCH 07/33] #70 added settings DAO and API Endpoint with validation --- backend/dao/settingsDao.js | 41 +++++++++++++++++++++ backend/server.js | 3 ++ backend/services/settings.js | 55 ++++++++++++++++++++++++++++ backend/services/validationHelper.js | 30 +++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 backend/dao/settingsDao.js create mode 100644 backend/services/settings.js diff --git a/backend/dao/settingsDao.js b/backend/dao/settingsDao.js new file mode 100644 index 0000000..a45bea0 --- /dev/null +++ b/backend/dao/settingsDao.js @@ -0,0 +1,41 @@ +class settingsDao { + constructor(dbConnection) { + this._conn = dbConnection; + } + + /** + * Loads a setting by key + * @param {string} key The setting key + * @returns {string|null} value for the key, or null if not found + */ + load(key) { + const stmt = this._conn.prepare('SELECT value FROM settings WHERE key=?'); + const row = stmt.get(key); + return row ? row.value : null; + } + + /** + * Sets a setting value by key + * The setting is created if it does not exist + * @param {string} key The setting key + * @param {string} value The setting value + * @returns {Object} The result of the insert operation + */ + save(key, value) { + const insert = this._conn.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?,?)'); + const result = insert.run(key, value); + return result; + } + + /** + * Loads all settings as key-value pairs + * @returns {Object} All settings as key-value pairs + */ + loadAll() { + const stmt = this._conn.prepare('SELECT key, value FROM settings'); + const rows = stmt.all(); + return rows.reduce((acc, r) => (acc[r.key] = r.value, acc), {}); + } +} + +module.exports = settingsDao; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 1e95973..e29d502 100644 --- a/backend/server.js +++ b/backend/server.js @@ -91,6 +91,9 @@ try serviceRouter = require('./services/upload.js'); app.use(TOPLEVELPATH, serviceRouter); + serviceRouter = require('./services/settings.js'); + app.use(TOPLEVELPATH, serviceRouter); + // send default error message if no matching endpoint found app.use(function (request, response) { console.log('Error occurred, 404, resource not found'); diff --git a/backend/services/settings.js b/backend/services/settings.js new file mode 100644 index 0000000..d62f714 --- /dev/null +++ b/backend/services/settings.js @@ -0,0 +1,55 @@ +const validationHelper = require('./validationHelper.js'); +const express = require('express'); +const settingsDao = require('../dao/settingsDao.js'); +const { body, param, matchedData, validationResult } = require('express-validator'); + +const serviceRouter = express.Router(); +console.log('- Service Settings'); + +serviceRouter.get('/settings/:key', + param('key').isString().trim().custom(validationHelper.validateSettingKey), + function(req, resp) { + + console.log('Service settings: Client requested loading of setting'); + const result = validationResult(req); + if (!result.isEmpty()) { + console.warn('Service settings: Loading not possible, validation errors'); + return resp.status(400).json({ errors: result.array() }); + } + const data = matchedData(req); + const dao = new settingsDao(req.app.locals.dbConnection); + + try { + const val = dao.load(data.key); + if (val === null) return resp.status(404).json({ errors: [{ msg: 'Not found' }]}); + resp.status(200).json({ key: data.key, value: val }); + } catch (ex) { + resp.status(500).json({ errors: [{ msg: ex.message }] }); + } + } +); + +serviceRouter.put('/settings/', + body('key').isString().trim().custom(validationHelper.validateSettingKey), + body('value').isString().trim().custom(validationHelper.validateSettingValue), + function(req, resp) { + + console.log('Service settings: Client requested saving of setting'); + const result = validationResult(req); + if (!result.isEmpty()) { + console.warn('Service settings: Saving not possible, validation errors'); + return resp.status(400).json({ errors: result.array() }); + } + const data = matchedData(req); + const dao = new settingsDao(req.app.locals.dbConnection); + + try { + dao.save(data.key, data.value); + resp.status(200).json({ key: data.key, value: data.value }); + } catch (ex) { + resp.status(500).json({ errors: [{ msg: ex.message }] }); + } + } +); + +module.exports = serviceRouter; \ No newline at end of file diff --git a/backend/services/validationHelper.js b/backend/services/validationHelper.js index ecdc7e5..f34010f 100644 --- a/backend/services/validationHelper.js +++ b/backend/services/validationHelper.js @@ -1,5 +1,6 @@ const plantsDao = require('../dao/plantsDao.js'); const activitiesDao = require('../dao/activitiesDao.js'); +const { matchedData } = require('express-validator'); // plant exists validation function module.exports.validatePlantIDExists = (value,{req}) => { @@ -21,6 +22,35 @@ module.exports.validateActivityIDExists = (value,{req}) => { return true; } +module.exports.validateSettingKey = (value,{req}) => { + const validKeys = ['watering_profile']; + console.log('Validating settings key: ' + value); + if (!validKeys.includes(value)) { + throw new Error('Invalid settings key'); + } + return true; +} + +/** + * setting value validation function + * requires that the setting key has already been validated + * @param {*} value + * @param {*} param1 + * @returns {boolean} true if valid, throws error if invalid + */ +module.exports.validateSettingValue = (value,{req}) => { + let key = matchedData(req).key; + console.log('Validating setting value for key ' + key + ': ' + value); + // Add specific validation logic based on the key + if (key === 'watering_profile') { + const validProfiles = ['cold', 'normal', 'warm']; + if (!validProfiles.includes(value)) { + throw new Error('Invalid value for watering_profile. Allowed values are: ' + validProfiles.join(', ')); + } + } + return true; +} + /** * Converts an exception to a JSON object. * @param {Error} ex The exception to convert From 8d4fbf47bab6afca3b6f8028d709b6a8e91598a2 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Thu, 20 Nov 2025 00:56:11 +0100 Subject: [PATCH 08/33] #70 Added TODOs to Backend --- backend/dao/plantsDao.js | 2 ++ backend/dbMigrationTool.js | 4 ++++ backend/services/plants.js | 3 +++ 3 files changed, 9 insertions(+) diff --git a/backend/dao/plantsDao.js b/backend/dao/plantsDao.js index 420ceae..bbba004 100644 --- a/backend/dao/plantsDao.js +++ b/backend/dao/plantsDao.js @@ -8,6 +8,8 @@ const daoHelper = require('./daoHelper.js'); // Strings is probably better because the validation middleware uses strings // TODO change var to const/let +// TODO #70 Update this file according to new DB schema + /** * Data Access Object for plants */ diff --git a/backend/dbMigrationTool.js b/backend/dbMigrationTool.js index d921546..5470441 100644 --- a/backend/dbMigrationTool.js +++ b/backend/dbMigrationTool.js @@ -73,6 +73,10 @@ module.exports.migrateDB = function(dbConnection) { let settingsDaoInstance = new settingsDao(dbConnection); settingsDaoInstance.save('watering_profile', 'normal'); + // TODO update plants table: add offset to watering interval + // TODO update plants table: remove column watering_interval_offset + // TODO update plants table: add two new intervals + // ============== Upgrade from v.. to v.. ============== // ... diff --git a/backend/services/plants.js b/backend/services/plants.js index 2eed831..51675ff 100644 --- a/backend/services/plants.js +++ b/backend/services/plants.js @@ -8,6 +8,9 @@ const { body, param, matchedData, validationResult } = require('express-validato console.log('- Service Plants'); +// TODO #70 Update this file according to new DB schema + +// TODO #70 Move this function to plantsDao.js function extendPlantJSON(json,activitiesDaoInstance) { //const plantDaoInstance = new plantsDao(request.app.locals.dbConnection); //const activitiesDaoInstance = new activitiesDao(request.app.locals.dbConnection); From 93e3254c83f149c9af137b6b24f036f0399915a5 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Fri, 21 Nov 2025 23:25:36 +0100 Subject: [PATCH 09/33] #70 finished DB Migration Tool - Creates new Columns - Deletes Offset Column - Updated PlantsDao to load all plants, including composted - Work on #76 --- backend/dao/plantsDao.js | 25 +++++++++++------- backend/dbMigrationTool.js | 54 ++++++++++++++++++++++++++++++-------- 2 files changed, 58 insertions(+), 21 deletions(-) diff --git a/backend/dao/plantsDao.js b/backend/dao/plantsDao.js index bbba004..72f9fa3 100644 --- a/backend/dao/plantsDao.js +++ b/backend/dao/plantsDao.js @@ -36,14 +36,19 @@ class plantsDao { /** * Loads all plants from the DB - * Ignores plants that have been composted + * By default, composted plants are excluded + * @param {boolean} includeComposted whether to include composted plants * @returns {json[]} */ - loadAll() { - var sql = 'SELECT * FROM plants WHERE composted IS NULL ORDER BY name'; - var statement = this._conn.prepare(sql); - var result = statement.all(); - var arrayResult = daoHelper.guaranteeArray(result); + loadAll(includeComposted = false) { + let sql = 'SELECT * FROM plants '; + if (!includeComposted) { + sql += 'WHERE composted IS NULL '; + } + sql += 'ORDER BY name'; + let statement = this._conn.prepare(sql); + let result = statement.all(); + let arrayResult = daoHelper.guaranteeArray(result); return arrayResult; } @@ -52,10 +57,10 @@ class plantsDao { * @returns {json[]} */ loadAllComposted() { - var sql = 'SELECT * FROM plants WHERE composted IS NOT NULL ORDER BY name'; - var statement = this._conn.prepare(sql); - var result = statement.all(); - var arrayResult = daoHelper.guaranteeArray(result); + let sql = 'SELECT * FROM plants WHERE composted IS NOT NULL ORDER BY name'; + let statement = this._conn.prepare(sql); + let result = statement.all(); + let arrayResult = daoHelper.guaranteeArray(result); return arrayResult; } diff --git a/backend/dbMigrationTool.js b/backend/dbMigrationTool.js index 5470441..52f2f4a 100644 --- a/backend/dbMigrationTool.js +++ b/backend/dbMigrationTool.js @@ -1,4 +1,5 @@ const settingsDao = require('./dao/settingsDao.js'); +const plantsDao = require('./dao/plantsDao.js'); /** * Checks if the DB is from an earlier version and needs to be upgraded @@ -36,8 +37,9 @@ module.exports.dbNeedsMigration = function(dbConnection) { * @param {import('better-sqlite3').Database} dbConnection the connection to the DB */ module.exports.migrateDB = function(dbConnection) { - console.log('========================== Migrating started =========================='); - // ============== Upgrade from v1.0.0 to v1.1.0 ============== + console.log('========================== Migration started =========================='); + + console.log('-------------------- Upgrade from v1.0.0 to v1.1.0 --------------------'); // Add 'composted' column to 'plants' table if it does not exist const columnExists = dbConnection.prepare(` PRAGMA table_info(plants) @@ -51,13 +53,15 @@ module.exports.migrateDB = function(dbConnection) { } else { console.log("Column 'composted' already exists in 'plants' table."); } + console.log('-----------------------------------------------------------------------'); - // ============== Upgrade from v1.1.0 to v1.2.0 ============== + console.log('-------------------- Upgrade from v1.1.0 to v1.2.0 --------------------'); const settingsTableExists = dbConnection.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name='settings' `).get(); if (!settingsTableExists) { + // create settings table dbConnection.prepare(` CREATE TABLE settings ( key TEXT PRIMARY KEY, @@ -65,20 +69,48 @@ module.exports.migrateDB = function(dbConnection) { ) `).run(); console.log("Table 'settings' created."); + + // add default setting 'watering_profile' = 'normal' + let settingsDaoInstance = new settingsDao(dbConnection); + settingsDaoInstance.save('watering_profile', 'normal'); + console.log("Default setting 'watering_profile' added with value 'normal'."); + + // update plants table: add two new intervals + dbConnection.prepare(` + ALTER TABLE plants ADD COLUMN watering_interval_warm INTEGER NOT NULL DEFAULT 7 + `).run(); + dbConnection.prepare(` + ALTER TABLE plants ADD COLUMN watering_interval_cold INTEGER NOT NULL DEFAULT 7 + `).run(); + console.log("Columns 'watering_interval_warm' and 'watering_interval_cold' added to 'plants' table."); + + // update plants table: add offset to watering interval + let plantsDaoInstance = new plantsDao(dbConnection); + let allPlants = plantsDaoInstance.loadAll(true); + for(const plant of allPlants) { + let watering_interval = plant.watering_interval + plant.watering_interval_offset; + let sql = 'UPDATE plants SET watering_interval=?, watering_interval_warm=?, watering_interval_cold=? WHERE plant_id=?'; + let statement = dbConnection.prepare(sql); + let params = [watering_interval, watering_interval, watering_interval, plant.plant_id]; + statement.run(params); + } + console.log("Plants' watering intervals updated with their offsets."); + + // update plants table: remove column watering_interval_offset + dbConnection.prepare(` + ALTER TABLE plants DROP COLUMN watering_interval_offset + `).run(); + console.log("Column 'watering_interval_offset' removed from 'plants' table."); + } else { console.log("Table 'settings' already exists."); } - console.log("Default setting 'watering_profile' added with value 'normal'."); - let settingsDaoInstance = new settingsDao(dbConnection); - settingsDaoInstance.save('watering_profile', 'normal'); - - // TODO update plants table: add offset to watering interval - // TODO update plants table: remove column watering_interval_offset - // TODO update plants table: add two new intervals + console.log('-----------------------------------------------------------------------'); + // ============== Upgrade from v.. to v.. ============== // ... - console.log('========================== Migrating completed =========================='); + console.log('========================== Migration completed =========================='); } \ No newline at end of file From 17eadeca4caf3bf50fb72b2688021906add98dd1 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Sun, 23 Nov 2025 20:41:03 +0100 Subject: [PATCH 10/33] updated API Docs, null when never repotted --- docs/API.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/API.md b/docs/API.md index 343eeda..798280b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -224,10 +224,10 @@ Beispiel: - repotted - Typ: Text(Datum) - Pflichtfeld bei: nie - - Nullbar: nein + - Nullbar: ja - Durch Backend berechneter Wert - Datum des letzten Umtopfen - - Wenn noch nie umgetopft Datum des Hinzufügens + - Wenn noch nie umgetopft Wert null - composted - Typ: Text(Datum) - Pflichtfeld bei: nie From 236a366a9b6af22b5532918f569818e74a5286da Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:09:17 +0100 Subject: [PATCH 11/33] Updated plantsDao - uses helper now to calculate days between - #70 updated according to new DB schema - moved extendPlantJSON - made date handling consistent (always ISO string) - added JS Doc - #76 changed var to let --- backend/dao/plantsDao.js | 170 +++++++++++++++++++++++++++++-------- backend/services/plants.js | 81 ------------------ 2 files changed, 135 insertions(+), 116 deletions(-) diff --git a/backend/dao/plantsDao.js b/backend/dao/plantsDao.js index 72f9fa3..e2c0cb6 100644 --- a/backend/dao/plantsDao.js +++ b/backend/dao/plantsDao.js @@ -1,14 +1,8 @@ // load helpers const helper = require('../helper.js'); const daoHelper = require('./daoHelper.js'); - -// TODO Add JSDoc comments to all methods -// TODO Make date handling consistent (either use strings or DateTime objects throughout the DAO) -// Post uses DateTime objects, Put uses strings -// Strings is probably better because the validation middleware uses strings -// TODO change var to const/let - -// TODO #70 Update this file according to new DB schema +const activitiesDao = require('../dao/activitiesDao.js'); +const settingsDao = require('../dao/settingsDao.js'); /** * Data Access Object for plants @@ -22,16 +16,88 @@ class plantsDao { this._conn = dbConnection; } + /** + * Extends the plant object with calculated fields + * This method modifies the input object directly + * It is not intended to be called from outside the DAO + * @param {object} plant The plant object to extend + */ + #extendPlantObject(plant) { + const activitiesDaoInstance = new activitiesDao(this._conn); + const settingsDaoInstance = new settingsDao(this._conn); + + // Datum letztes Bewässern ermitteln + let arrWat = activitiesDaoInstance.loadByPlantIdAndType(plant.plant_id,0); + let last_watered = null; + if (helper.isArrayEmpty(arrWat)) + { + // No elements = empty array + last_watered = new Date(plant.added); + } + else + { + last_watered = new Date(arrWat[0].date); + } + + // Berechnung days_since_watering + let days_since_watering = helper.calculateDaysBetween(last_watered, new Date()); + + // get interval based on settings + let interval = plant.watering_interval; + switch (settingsDaoInstance.load("watering_profile")) { + case 'warm': + interval = plant.watering_interval_warm; + break; + case 'cold': + interval = plant.watering_interval_cold; + break; + } + // Berechnung days_until_watering + let watering_due = new Date(); + watering_due.setDate(last_watered.getDate() + interval); + const days_until_watering = helper.calculateDaysBetween(new Date(), watering_due); + + //Bestimmung repotted + let arrPot = activitiesDaoInstance.loadByPlantIdAndType(plant.plant_id,1); + let repotted = null; + if (!helper.isArrayEmpty(arrPot)) + { + repotted = arrPot[0].date; + } + + //plant erweitern + plant.days_since_watering = days_since_watering; + plant.days_until_watering = days_until_watering; + plant.repotted = repotted; + } + + /** + * Extends an array of plant objects with calculated fields + * This method modifies the input objects directly + * It is not intended to be called from outside the DAO + * @param {object[]} plants The array of plant objects to extend + */ + #extendPlantObjects(plants) { + for (let plant of plants) { + this.#extendPlantObject(plant); + } + } + + /** + * Loads a plant by its plant_id + * @param {number} plant_id The plant's id + * @returns {object} the plant object + */ loadById(plant_id) { - var sql = 'SELECT * FROM plants WHERE plant_id=?'; - var statement = this._conn.prepare(sql); - var result = statement.get(plant_id); + let sql = 'SELECT * FROM plants WHERE plant_id=?'; + let statement = this._conn.prepare(sql); + let result = statement.get(plant_id); if (helper.isUndefined(result)){ throw new Error('No record found by plant_id=' + plant_id); } - return result; + return this.#extendPlantObject(result); } /** @@ -49,7 +115,7 @@ class plantsDao { let statement = this._conn.prepare(sql); let result = statement.all(); let arrayResult = daoHelper.guaranteeArray(result); - return arrayResult; + return this.#extendPlantObjects(arrayResult); } /** @@ -61,13 +127,18 @@ class plantsDao { let statement = this._conn.prepare(sql); let result = statement.all(); let arrayResult = daoHelper.guaranteeArray(result); - return arrayResult; + return this.#extendPlantObjects(arrayResult); } - exists(id) { - var sql = 'SELECT COUNT(plant_id) AS cnt FROM plants WHERE plant_id=?'; - var statement = this._conn.prepare(sql); - var result = statement.get(id); + /** + * Checks whether a plant with the given plant_id exists + * @param {number} plant_id The plant's id + * @returns {boolean} true if the plant exists, false otherwise + */ + exists(plant_id) { + let sql = 'SELECT COUNT(plant_id) AS cnt FROM plants WHERE plant_id=?'; + let statement = this._conn.prepare(sql); + let result = statement.get(plant_id); if (result.cnt == 1){ return true; @@ -76,38 +147,67 @@ class plantsDao { return false; } - create(name, species_name, image, added, watering_interval, watering_interval_offset) { - var formatted_date = helper.formatToSQLDate(added); - var sql = 'INSERT INTO plants (name, species_name, image, added, watering_interval, watering_interval_offset) VALUES (?,?,?,?,?,?)'; - var statement = this._conn.prepare(sql); - var params = [name, species_name, image, formatted_date, watering_interval, watering_interval_offset]; - var result = statement.run(params); + /** + * Creates a new plant record + * @param {string} name The plant's name + * @param {string} species_name The plant's species name + * @param {string|null} image The plant's image filename or null if no image + * @param {string} added The date the plant was added (YYYY-MM-DD) + * @param {number} watering_interval The plant's watering interval + * @param {number} watering_interval_warm The plant's watering interval for warm profile + * @param {number} watering_interval_cold The plant's watering interval for cold profile + * @returns {object} the created plant object + */ + create(name, species_name, image, added, watering_interval, watering_interval_warm, watering_interval_cold) { + let sql = 'INSERT INTO plants (name, species_name, image, added, watering_interval, watering_interval_warm, watering_interval_cold) VALUES (?,?,?,?,?,?,?)'; + let statement = this._conn.prepare(sql); + let params = [name, species_name, image, added, watering_interval, watering_interval_warm, watering_interval_cold]; + let result = statement.run(params); if (result.changes != 1){ throw new Error('Could not insert new record. Data: ' + params); } - return this.loadById(result.lastInsertRowid); + let createdPlant = this.loadById(result.lastInsertRowid) + return this.#extendPlantObject(createdPlant); } - update(plant_id, name, species_name, image, watering_interval, watering_interval_offset, composted) { - var sql = 'UPDATE plants SET name=?, species_name=?, image=?, watering_interval=?, watering_interval_offset=?, composted=? WHERE plant_id=?'; - var statement = this._conn.prepare(sql); - var params = [name, species_name, image, watering_interval, watering_interval_offset, composted, plant_id]; - var result = statement.run(params); + /** + * Updates an existing plant record + * @param {number} plant_id The plant's id + * @param {string} name The plant's name + * @param {string} species_name The plant's species name + * @param {string|null} image The plant's image filename or null if no image + * @param {number} watering_interval The plant's watering interval + * @param {number} watering_interval_warm The plant's watering interval for warm profile + * @param {number} watering_interval_cold The plant's watering interval for cold profile + * @param {string|null} composted The date the plant was composted (YYYY-MM-DD) or null if not composted + * @returns {object} the updated plant object + */ + update(plant_id, name, species_name, image, watering_interval, watering_interval_warm, watering_interval_cold, composted) { + let sql = 'UPDATE plants SET name=?, species_name=?, image=?, watering_interval=?, watering_interval_offset=?, composted=? WHERE plant_id=?'; + let statement = this._conn.prepare(sql); + let params = [name, species_name, image, watering_interval, watering_interval_warm, watering_interval_cold, composted, plant_id]; + let result = statement.run(params); if (result.changes != 1){ throw new Error('Could not update existing record. Data: ' + params); } - return this.loadById(plant_id); + let updatedPlant = this.loadById(plant_id); + return this.#extendPlantObject(updatedPlant); } + /** + * Deletes a plant by its plant_id + * @param {number} plant_id The plant's id + * @returns {boolean} true if the plant was deleted, false otherwise + */ delete(plant_id) { try{ - var sql = 'DELETE FROM plants WHERE plant_id=?'; - var statement = this._conn.prepare(sql); - var result = statement.run(plant_id); + let sql = 'DELETE FROM plants WHERE plant_id=?'; + let statement = this._conn.prepare(sql); + let result = statement.run(plant_id); if (result.changes != 1){ throw new Error('Could not delete record by plant_id=' + plant_id); @@ -121,7 +221,7 @@ class plantsDao { } toString() { - console.log('templateDao [_conn=' + this._conn + ']'); + console.log('plantsDao [_conn=' + this._conn + ']'); } } diff --git a/backend/services/plants.js b/backend/services/plants.js index 51675ff..6abaceb 100644 --- a/backend/services/plants.js +++ b/backend/services/plants.js @@ -2,7 +2,6 @@ const helper = require('../helper.js'); const validationHelper = require('./validationHelper.js'); const express = require('express'); const plantsDao = require('../dao/plantsDao.js'); -const activitiesDao = require('../dao/activitiesDao.js'); var serviceRouter = express.Router(); const { body, param, matchedData, validationResult } = require('express-validator'); @@ -10,86 +9,6 @@ console.log('- Service Plants'); // TODO #70 Update this file according to new DB schema -// TODO #70 Move this function to plantsDao.js -function extendPlantJSON(json,activitiesDaoInstance) { - //const plantDaoInstance = new plantsDao(request.app.locals.dbConnection); - //const activitiesDaoInstance = new activitiesDao(request.app.locals.dbConnection); - - // Berechnung watering_interval_calculated - let watering_interval_calculated = json.watering_interval + json.watering_interval_offset; - - // Datum letztes Bewässern ermitteln - let arrWat = activitiesDaoInstance.loadByPlantIdAndType(json.plant_id,0); - let last_watered = null; - if(helper.isArray(arrWat)) - { - if (helper.isArrayEmpty(arrWat)) - { - // No elements = empty array - last_watered = new Date(json.added); - } - else - { - // 2 or more elements = array - last_watered = new Date(arrWat[0].date); - } - } - else if (!helper.isUndefined(arrWat)) - { - // exactly one element - last_watered = new Date(arrWat.date); - } - else{ - // something went wrong - last_watered = new Date(json.added); - } - - - // Berechnung days_since_watering - const currentDate = new Date(); - let ms_since_watering = currentDate - last_watered; - let days_since_watering = Math.floor(ms_since_watering / (1000 * 60 * 60 * 24)); - - // Berechnung days_until_watering - // Erst berechnen wann das nächste mal gegossen werden muss - const watering_due = new Date(last_watered.getTime() + watering_interval_calculated * 24 * 60 * 60 * 1000); - // ms von heute von ms vom gießdatum abziehen und runden --> negativ heisst ueberfaellig - let ms_until_watering = watering_due - currentDate; - let days_until_watering = Math.floor(ms_until_watering / (1000 * 60 * 60 * 24)) +1; - - //Bestimmung repotted - let arrPot = activitiesDaoInstance.loadByPlantIdAndType(json.plant_id,1); - let repotted = null; - if(helper.isArray(arrPot)) - { - if (helper.isArrayEmpty(arrPot)) - { - // No elements = empty array - repotted = json.added; - } - else - { - // 2 or more elements = array - repotted = arrPot[0].date; - } - } - else if (!helper.isUndefined(arrPot)) - { - // exactly one element - repotted = arrPot.date; - } - else{ - // something went wrong - repotted = json.added; - } - - //JSON erweitern - json.watering_interval_calculated = watering_interval_calculated; - json.days_since_watering = days_since_watering; - json.days_until_watering = days_until_watering; - json.repotted = repotted; -} - serviceRouter.get('/plants/get/:plant_id', param("plant_id").isInt({min:0}).bail().toInt().custom(validationHelper.validatePlantIDExists), function(req, resp) { From ecdc19d0c3c93f00e1557c2bb86e3433c5fc8225 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:10:34 +0100 Subject: [PATCH 12/33] changed calculateDaysBetween from floor to round maybe helps with #39 --- backend/helper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/helper.js b/backend/helper.js index fc56d4f..a80073f 100644 --- a/backend/helper.js +++ b/backend/helper.js @@ -211,7 +211,7 @@ module.exports.compareDateTimes = function(leftdatetime, rightdatetime) { */ module.exports.calculateDaysBetween = function(leftdatetime, rightdatetime) { const timeDifference = Math.abs(leftdatetime - rightdatetime); - return Math.floor(timeDifference / (1000 * 60 * 60 * 24)); + return Math.round(timeDifference / (1000 * 60 * 60 * 24)); } // modifies a given datetime object From 02c12018295ae81b7ab527480e40672c24834c0b Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:35:03 +0100 Subject: [PATCH 13/33] #70 fixed incorrect use of extendPlantObject and extendPlantObjects --- backend/dao/plantsDao.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/backend/dao/plantsDao.js b/backend/dao/plantsDao.js index e2c0cb6..c811e51 100644 --- a/backend/dao/plantsDao.js +++ b/backend/dao/plantsDao.js @@ -97,7 +97,8 @@ class plantsDao { throw new Error('No record found by plant_id=' + plant_id); } - return this.#extendPlantObject(result); + this.#extendPlantObject(result); + return result; } /** @@ -115,7 +116,8 @@ class plantsDao { let statement = this._conn.prepare(sql); let result = statement.all(); let arrayResult = daoHelper.guaranteeArray(result); - return this.#extendPlantObjects(arrayResult); + this.#extendPlantObjects(arrayResult); + return arrayResult; } /** @@ -127,7 +129,8 @@ class plantsDao { let statement = this._conn.prepare(sql); let result = statement.all(); let arrayResult = daoHelper.guaranteeArray(result); - return this.#extendPlantObjects(arrayResult); + this.#extendPlantObjects(arrayResult) + return arrayResult; } /** @@ -169,7 +172,8 @@ class plantsDao { } let createdPlant = this.loadById(result.lastInsertRowid) - return this.#extendPlantObject(createdPlant); + this.#extendPlantObject(createdPlant) + return createdPlant; } /** @@ -195,7 +199,8 @@ class plantsDao { } let updatedPlant = this.loadById(plant_id); - return this.#extendPlantObject(updatedPlant); + this.#extendPlantObject(updatedPlant) + return updatedPlant; } /** From 50185a393d4eb1349ee7a77abc87f472944c2b85 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:36:11 +0100 Subject: [PATCH 14/33] #70 updated plants service --- backend/services/plants.js | 43 +++++++++++++------------------------- 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/backend/services/plants.js b/backend/services/plants.js index 6abaceb..825d073 100644 --- a/backend/services/plants.js +++ b/backend/services/plants.js @@ -7,7 +7,7 @@ const { body, param, matchedData, validationResult } = require('express-validato console.log('- Service Plants'); -// TODO #70 Update this file according to new DB schema +// TODO Test all endpoints. Especially dates and intervals with setting different watering profiles serviceRouter.get('/plants/get/:plant_id', param("plant_id").isInt({min:0}).bail().toInt().custom(validationHelper.validatePlantIDExists), @@ -22,13 +22,9 @@ serviceRouter.get('/plants/get/:plant_id', const data = matchedData(req); const plantDaoInstance = new plantsDao(req.app.locals.dbConnection); - const activitiesDaoInstance = new activitiesDao(req.app.locals.dbConnection); try { // JSON Objekt aus DB holen var obj = plantDaoInstance.loadById(data.plant_id); - - extendPlantJSON(obj,activitiesDaoInstance); - console.log('Service plants: Record loaded'); resp.status(200).json(obj); } catch (ex) { @@ -41,14 +37,8 @@ serviceRouter.get('/plants/composted', function(req, resp) { console.log('Service plants: Client requested all composted records'); const plantDaoInstance = new plantsDao(req.app.locals.dbConnection); - const activitiesDaoInstance = new activitiesDao(req.app.locals.dbConnection); try { var plantArr = plantDaoInstance.loadAllComposted(); - // foreach Schleife über alle plant JSON, diese werden dabei erweitert - plantArr.forEach(plant => { - extendPlantJSON(plant,activitiesDaoInstance); - }); - console.log('Service plants: Composted records loaded, count= ' + plantArr.length); resp.status(200).json(plantArr); } catch (ex) { @@ -61,14 +51,8 @@ serviceRouter.get('/plants/all', function(req, resp) { console.log('Service plants: Client requested all records'); const plantDaoInstance = new plantsDao(req.app.locals.dbConnection); - const activitiesDaoInstance = new activitiesDao(req.app.locals.dbConnection); try { var plantArr = plantDaoInstance.loadAll(); - // foreach Schleife über alle plant JSON, diese werden dabei erweitert - plantArr.forEach(plant => { - extendPlantJSON(plant,activitiesDaoInstance); - }); - console.log('Service plants: Records loaded, count= ' + plantArr.length); resp.status(200).json(plantArr); } catch (ex) { @@ -104,7 +88,8 @@ serviceRouter.post('/plants', body("name").default("New Plant").isString().notEmpty().trim().escape(), body("species_name").default("Unknown").isString().notEmpty().trim().escape(), body("watering_interval").isInt({min:1,max:100}).toInt(), - body("watering_interval_offset").isInt({min:-25,max:25}).toInt(), + body("watering_interval_warm").isInt({min:1,max:100}).toInt(), + body("watering_interval_cold").isInt({min:1,max:100}).toInt(), body("added").optional().isISO8601(), function(req, resp) { @@ -118,16 +103,13 @@ serviceRouter.post('/plants', // use current date if date is not provided if (helper.isUndefined(data.added)) { - data.added = helper.getNow(); - } - else { - data.added = helper.parseDateTimeString(data.added); + data.added = helper.formatToSQLDate(helper.getNow()); } const plantDaoInstance = new plantsDao(req.app.locals.dbConnection); try { // #37 image is set to null - var obj = plantDaoInstance.create(data.name, data.species_name,null,data.added,data.watering_interval,data.watering_interval_offset); + var obj = plantDaoInstance.create(data.name, data.species_name,null,data.added,data.watering_interval,data.watering_interval_warm,data.watering_interval_cold); console.log('Service plants: Record inserted'); resp.status(200).json(obj); } catch (ex) { @@ -141,7 +123,8 @@ serviceRouter.put('/plants', body("name").optional().isString().notEmpty().trim().escape(), body("species_name").optional().isString().notEmpty().trim().escape(), body("watering_interval").optional().isInt({min:1,max:100}).toInt(), - body("watering_interval_offset").optional().isInt({min:-25,max:25}).toInt(), + body("watering_interval_warm").optional().isInt({min:1,max:100}).toInt(), + body("watering_interval_cold").optional().isInt({min:1,max:100}).toInt(), body("composted").optional({values: "null"}).isISO8601(), function(req, resp) { @@ -178,9 +161,13 @@ serviceRouter.put('/plants', // use current watering_interval from DB data.watering_interval = oldPlantData.watering_interval; } - if (helper.isUndefined(data.watering_interval_offset)) { - // use current watering_interval_offset from DB - data.watering_interval_offset = oldPlantData.watering_interval_offset; + if (helper.isUndefined(data.watering_interval_warm)) { + // use current watering_interval from DB + data.watering_interval_warm = oldPlantData.watering_interval_warm; + } + if (helper.isUndefined(data.watering_interval_cold)) { + // use current watering_interval from DB + data.watering_interval_cold = oldPlantData.watering_interval_cold; } // null is not passed by express-validator, so we need to check the original req.body @@ -198,7 +185,7 @@ serviceRouter.put('/plants', try { // update the plant - let obj = plantDaoInstance.update(data.plant_id, data.name, data.species_name, data.image, data.watering_interval, data.watering_interval_offset, data.composted); + let obj = plantDaoInstance.update(data.plant_id, data.name, data.species_name, data.image, data.watering_interval, data.watering_interval_warm, data.watering_interval_cold, data.composted); console.log('Service plants: Record updated, plant_id=' + data.plant_id); resp.status(200).json(obj); } catch (ex) { From 2a72743b17d2cd02dc181ee426feb14f391d1a1d Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:48:11 +0100 Subject: [PATCH 15/33] #70 fixed days_until_watering being of by a day --- backend/dao/plantsDao.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/dao/plantsDao.js b/backend/dao/plantsDao.js index c811e51..3bfb079 100644 --- a/backend/dao/plantsDao.js +++ b/backend/dao/plantsDao.js @@ -55,7 +55,7 @@ class plantsDao { // Berechnung days_until_watering let watering_due = new Date(); watering_due.setDate(last_watered.getDate() + interval); - const days_until_watering = helper.calculateDaysBetween(new Date(), watering_due); + const days_until_watering = helper.calculateDaysBetween(new Date(), watering_due) - 1; //Bestimmung repotted let arrPot = activitiesDaoInstance.loadByPlantIdAndType(plant.plant_id,1); From 7abfaf356485900d8ad8fd6ece57e05f948f7b4e Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Mon, 24 Nov 2025 21:55:21 +0100 Subject: [PATCH 16/33] #70 Made days since and until calculations more robust because they weren't correct. Probably needs some more refinement for edge cases... --- backend/dao/plantsDao.js | 4 +--- backend/helper.js | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/backend/dao/plantsDao.js b/backend/dao/plantsDao.js index 3bfb079..634a2db 100644 --- a/backend/dao/plantsDao.js +++ b/backend/dao/plantsDao.js @@ -53,9 +53,7 @@ class plantsDao { break; } // Berechnung days_until_watering - let watering_due = new Date(); - watering_due.setDate(last_watered.getDate() + interval); - const days_until_watering = helper.calculateDaysBetween(new Date(), watering_due) - 1; + const days_until_watering = interval - days_since_watering; //Bestimmung repotted let arrPot = activitiesDaoInstance.loadByPlantIdAndType(plant.plant_id,1); diff --git a/backend/helper.js b/backend/helper.js index a80073f..ac6dd09 100644 --- a/backend/helper.js +++ b/backend/helper.js @@ -205,13 +205,24 @@ module.exports.compareDateTimes = function(leftdatetime, rightdatetime) { /** * Probably not correctly implemented, see issue #39 * Calculates the number of days between two datetime objects + * returns negative values if rightdatetime is greater than leftdatetime and useAbsolute is false * @param {*} leftdatetime The left datetime object * @param {*} rightdatetime The right datetime object + * @param {*} useAbsolute If true, the absolute value of the difference is returned * @returns The number of days between the two datetime objects */ -module.exports.calculateDaysBetween = function(leftdatetime, rightdatetime) { - const timeDifference = Math.abs(leftdatetime - rightdatetime); - return Math.round(timeDifference / (1000 * 60 * 60 * 24)); +module.exports.calculateDaysBetween = function(leftdatetime, rightdatetime, useAbsolute = true) { + let date1 = new Date(leftdatetime); + let date2 = new Date(rightdatetime); + date1.setHours(12,0,0,0); + date2.setHours(12,0,0,0); + const timeDifference = date1 - date2 + const days = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); + if (useAbsolute) { + return Math.abs(days); + } else { + return days; + } } // modifies a given datetime object From ce7139d3e7ebace18a9c29b2ddf5d691b4c64f50 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Mon, 24 Nov 2025 21:56:18 +0100 Subject: [PATCH 17/33] #70 removed wateringIntervalToLocation from Frontend as its not needed anymore --- frontend/public/mjs/utils.mjs | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/frontend/public/mjs/utils.mjs b/frontend/public/mjs/utils.mjs index 1826799..e59e1c9 100644 --- a/frontend/public/mjs/utils.mjs +++ b/frontend/public/mjs/utils.mjs @@ -15,33 +15,6 @@ export function getArgumentFromURL(argument){ } } -/** - * Returns the translated location to the given number. If number invalid, returns "not specified" - * @param {int} watering_interval_offset key, that is then translated - * @returns string with the desired location - */ - -export function wateringIntervalToLocation(watering_interval_offset) { - // TODO Add the days in brackets after the description - let plantLocation = "not specified"; - if (watering_interval_offset == -3) { - plantLocation = "extrem sonnig" - } else if (watering_interval_offset == -2) { - plantLocation = "sehr sonnig"; - } else if (watering_interval_offset == -1) { - plantLocation = "sonnig"; - } else if (watering_interval_offset == 0) { - plantLocation = "normal"; - } else if (watering_interval_offset == 1) { - plantLocation = "schattig" - } else if (watering_interval_offset == 2) { - plantLocation = "sehr schattig"; - } else if (watering_interval_offset == 3) { - plantLocation = "extrem schattig"; - } - return plantLocation; -} - /** * Converts a Date String from the format YYYY-MM-DD to DD.MM.YYYY * @param {string} sqlDate in format YYYY-MM-DD From fe527ea35a0df6c8bd7b59a162b1199725de9787 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Mon, 24 Nov 2025 22:48:08 +0100 Subject: [PATCH 18/33] #77 Cleaned up activitiesDao - #76 changed var to let - added JSDoc - verified that it always returns an array - Made date handling consistent to string ISO date - moved extendActivityObject to dao --- backend/dao/activitiesDao.js | 133 +++++++++++++++++++++++++-------- backend/services/activities.js | 38 +--------- 2 files changed, 106 insertions(+), 65 deletions(-) diff --git a/backend/dao/activitiesDao.js b/backend/dao/activitiesDao.js index 76e2740..0b6b99c 100644 --- a/backend/dao/activitiesDao.js +++ b/backend/dao/activitiesDao.js @@ -11,41 +11,92 @@ class activitiesDao { this._conn = dbConnection; } + /** + * Adds additional information to the provided activity. + * Note: This method modifies the activity object in place. + * Not intended to be called from outside the DAO. + * @param {object} activities The activity object to process + */ + #extendActivityObject(activity) { + if (activity && activity.date) { + const activityDate = new Date(activity.date); + const currentDate = new Date(); + const daysSince = helper.calculateDaysBetween(activityDate, currentDate); + activity.days_since = daysSince; + } + } + + /** + * Adds additional information to each activity in the provided array. + * Note: This method modifies the activity objects in place. + * Not intended to be called from outside the DAO. + * @param {object[]} activities The array of activity objects to process + */ + #extendActivityObjects(activities) { + activities.forEach(activity => { + this.#extendActivityObject(activity); + }); + } + + /** + * Loads an activity by its ID + * @param {int} id The activity ID + * @returns The activity object + */ loadById(id) { - var sql = 'SELECT * FROM activities WHERE id=? ORDER BY date DESC'; - var statement = this._conn.prepare(sql); - var result = statement.get(id); + let sql = 'SELECT * FROM activities WHERE id=? ORDER BY date DESC'; + let statement = this._conn.prepare(sql); + let result = statement.get(id); if (helper.isUndefined(result)){ throw new Error('No record found by id=' + id); } + this.#extendActivityObject(result); return result; } + /** + * Loads all activities for a given plant ID + * @param {int} plant_id The plant ID + * @returns {object[]} An array of activity objects + */ loadByPlantId(plant_id) { - var sql = 'SELECT * FROM activities WHERE plant_id=? ORDER BY date DESC'; - var statement = this._conn.prepare(sql); - var result = statement.all(plant_id); - var arrayResult = daoHelper.guaranteeArray(result); + let sql = 'SELECT * FROM activities WHERE plant_id=? ORDER BY date DESC'; + let statement = this._conn.prepare(sql); + let result = statement.all(plant_id); + let arrayResult = daoHelper.guaranteeArray(result); + this.#extendActivityObjects(arrayResult); return arrayResult; } + /** + * Loads all activities for a given plant ID and activity type + * @param {int} plant_id The plant ID + * @param {int} type The activity type + * @returns {object[]} An array of activity objects + */ loadByPlantIdAndType(plant_id, type) { - var sql = 'SELECT * FROM activities WHERE plant_id=? AND type=? ORDER BY date DESC'; - var statement = this._conn.prepare(sql); - var params = [plant_id, type]; - var result = statement.get(params); - var arrayResult = daoHelper.guaranteeArray(result); + let sql = 'SELECT * FROM activities WHERE plant_id=? AND type=? ORDER BY date DESC'; + let statement = this._conn.prepare(sql); + let params = [plant_id, type]; + let result = statement.get(params); + let arrayResult = daoHelper.guaranteeArray(result); + this.#extendActivityObjects(arrayResult); return arrayResult; } + /** + * Checks if an activity exists by its ID + * @param {int} id The activity ID + * @returns {boolean} true if the activity exists, false otherwise + */ exists(id) { - var sql = 'SELECT COUNT(id) AS cnt FROM activities WHERE id=?'; - var statement= this._conn.prepare(sql); - var result = statement.get(id); + let sql = 'SELECT COUNT(id) AS cnt FROM activities WHERE id=?'; + let statement= this._conn.prepare(sql); + let result = statement.get(id); if (result.cnt == 1){ return true; @@ -54,6 +105,13 @@ class activitiesDao { return false; } + /** + * Creates a new activity record + * @param {number} plant_id The plant ID + * @param {number} type The activity type + * @param {String} date The activity date (YYYY-MM-DD) + * @returns {object} The created activity object + */ create(plant_id, type, date) { if (typeof(plant_id) != "number"){ throw new Error('Could not insert new record. Invalid plant id of type ' + typeof(plant_id)); @@ -61,19 +119,28 @@ class activitiesDao { if (typeof(type) != "number"){ throw new Error('Could not insert new record. Invalid activity type of type ' + typeof(type)); } - var formatted_date = helper.formatToSQLDate(date); - var sql = 'INSERT INTO activities (plant_id, type, date) VALUES (?,?,?)'; - var statement = this._conn.prepare(sql); - var params = [plant_id, type, formatted_date]; - var result = statement.run(params); + let sql = 'INSERT INTO activities (plant_id, type, date) VALUES (?,?,?)'; + let statement = this._conn.prepare(sql); + let params = [plant_id, type, date]; + let result = statement.run(params); if (result.changes != 1){ throw new Error('Could not insert new record. Data: ' + params); } - return this.loadById(result.lastInsertRowid); + let createdActivity = this.loadById(result.lastInsertRowid); + this.#extendActivityObject(createdActivity); + return createdActivity; } + /** + * Updates an existing activity record + * @param {number} id The activity ID + * @param {number} plant_id The plant ID + * @param {number} type The activity type + * @param {String} date The activity date (YYYY-MM-DD) + * @returns {object} The updated activity object + */ update(id, plant_id, type, date) { if (typeof(plant_id) != "number"){ throw new Error('Could not update existing record. Invalid plant id of type ' + typeof(plant_id)); @@ -81,24 +148,30 @@ class activitiesDao { if (typeof(type) != "number"){ throw new Error('Could not update existing record. Invalid activity type of type ' + typeof(type)); } - var formatted_date = helper.formatToSQLDate(date); - var sql = 'UPDATE activities SET plant_id=?, type=?, date=? WHERE id=?'; - var statement = this._conn.prepare(sql); - var params = [plant_id, type, formatted_date, id]; - var result = statement.run(params); + let sql = 'UPDATE activities SET plant_id=?, type=?, date=? WHERE id=?'; + let statement = this._conn.prepare(sql); + let params = [plant_id, type, date, id]; + let result = statement.run(params); if (result.changes != 1){ throw new Error('Could not update existing record. Data: ' + params); } - return this.loadById(id); + let updatedActivity = this.loadById(id); + this.#extendActivityObject(updatedActivity); + return updatedActivity; } + /** + * Deletes an activity by its ID + * @param {number} id The activity ID + * @returns {boolean} true if the activity was deleted, false otherwise + */ delete(id) { try{ - var sql = 'DELETE FROM activities WHERE id=?'; - var statement = this._conn.prepare(sql); - var result = statement.run(id); + let sql = 'DELETE FROM activities WHERE id=?'; + let statement = this._conn.prepare(sql); + let result = statement.run(id); if (result.changes != 1){ throw new Error('Could not delete record by id=' + id); diff --git a/backend/services/activities.js b/backend/services/activities.js index ff6c154..7e9b11f 100644 --- a/backend/services/activities.js +++ b/backend/services/activities.js @@ -7,21 +7,6 @@ const { body, param, matchedData, validationResult } = require('express-validato console.log('- Service Activities'); -/** - * Adds the days_since field to each activity in the provided array. - * @param {*} activities The array of activity objects to process - */ -function addDaysSinceToActivities(activities) { - const currentDate = new Date(); - activities.forEach(activity => { - if (activity && activity.date) { - const activityDate = new Date(activity.date); - const daysSince = helper.calculateDaysBetween(activityDate, currentDate); - activity.days_since = daysSince; - } - }); -} - // Create new activity serviceRouter.post('/activities', body("plant_id").isInt({min:0}).bail().toInt().custom(validationHelper.validatePlantIDExists), @@ -38,11 +23,8 @@ serviceRouter.post('/activities', const data = matchedData(req); // use current date if date is not provided - if (helper.isUndefined(data.date)) { - data.date = helper.getNow(); - } - else { - data.date = helper.parseDateTimeString(data.date); + if (!data.date) { + data.date = helper.formatToSQLDate(helper.getNow()); } const activitiesDaoInstance = new activitiesDao(req.app.locals.dbConnection); @@ -98,22 +80,8 @@ serviceRouter.get('/activities/all/:plant_id', try { let result = activitiesDaoInstance.loadByPlantId(data.plant_id); console.log('Service activities: Records loaded for plant_id = ' + data.plant_id); - - let activities = []; - - // Check if result is an array or a single object - if (Array.isArray(result)) { - activities = result; - } else if (result && typeof result === 'object') { - activities = [result]; - } else { - return resp.status(404).json({ errors: [{msg: 'No activities found for the given plant ID.'}] }); - } - - // Process each activity - addDaysSinceToActivities(activities); - resp.status(200).json(activities); + resp.status(200).json(result); } catch (ex) { console.error('Service activities: Error loading all records based on plant_id. Exception occurred: ' + ex.message); resp.status(500).json({ errors: [validationHelper.exceptionToJson(ex)] }); From 3b699e56bad64a2de3516eba176effa29fbf5d22 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Tue, 25 Nov 2025 00:28:51 +0100 Subject: [PATCH 19/33] #70 basic implementation of the new intervals. Probably better with list-group. --- frontend/public/detailseite_pflanze.html | 10 ++++++---- frontend/public/js/detailseite_pflanze.mjs | 13 +++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/frontend/public/detailseite_pflanze.html b/frontend/public/detailseite_pflanze.html index d228fff..3d00723 100644 --- a/frontend/public/detailseite_pflanze.html +++ b/frontend/public/detailseite_pflanze.html @@ -39,11 +39,13 @@

-

+

- Hinzugefügt am -
- Umgetopft: -
- Standort: -
- Alle - Tage gießen + Hinzugefügt am -
+ Umgetopft: -
+ Alle - Tage gießen
+ An warmen Tagen alle - Tage gießen
+ An kalten Tagen alle - Tage gießen

diff --git a/frontend/public/js/detailseite_pflanze.mjs b/frontend/public/js/detailseite_pflanze.mjs index 9b5a729..97bedc5 100644 --- a/frontend/public/js/detailseite_pflanze.mjs +++ b/frontend/public/js/detailseite_pflanze.mjs @@ -12,13 +12,14 @@ function showPlantDetails(plant) { document.getElementById('species-name').textContent = speciesName; const addedDate = utils.convertSqlDateToGermanFormat(plant.added); document.getElementById('added-date').innerText = addedDate; - const repottedDate = utils.convertSqlDateToGermanFormat(plant.repotted); + let repottedDate = "Noch nie"; + if(plant.repotted){ + repottedDate = utils.convertSqlDateToGermanFormat(plant.repotted); + } document.getElementById('repotted-date').innerText = repottedDate; - const watering_interval_offset = plant.watering_interval_offset; - const location = utils.wateringIntervalToLocation(watering_interval_offset); - document.getElementById('location').innerText = location; - const wateringFrequency = plant.watering_interval; - document.getElementById('watering-frequency').innerText = wateringFrequency; + document.getElementById('watering-interval').innerText = plant.watering_interval; + document.getElementById('watering-interval-warm').innerText = plant.watering_interval_warm; + document.getElementById('watering-interval-cold').innerText = plant.watering_interval_cold; // disable compost button if it has been composted let compostButton = document.getElementById('compost-button'); From 2312080eb1c55e0e2fda0256cba7895f1359b37d Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Tue, 25 Nov 2025 23:55:39 +0100 Subject: [PATCH 20/33] #70 forgot to change SQL Statement for update in plantsDao --- backend/dao/plantsDao.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/dao/plantsDao.js b/backend/dao/plantsDao.js index 634a2db..b05d27f 100644 --- a/backend/dao/plantsDao.js +++ b/backend/dao/plantsDao.js @@ -187,7 +187,7 @@ class plantsDao { * @returns {object} the updated plant object */ update(plant_id, name, species_name, image, watering_interval, watering_interval_warm, watering_interval_cold, composted) { - let sql = 'UPDATE plants SET name=?, species_name=?, image=?, watering_interval=?, watering_interval_offset=?, composted=? WHERE plant_id=?'; + let sql = 'UPDATE plants SET name=?, species_name=?, image=?, watering_interval=?, watering_interval_warm=?, watering_interval_cold=?, composted=? WHERE plant_id=?'; let statement = this._conn.prepare(sql); let params = [name, species_name, image, watering_interval, watering_interval_warm, watering_interval_cold, composted, plant_id]; let result = statement.run(params); From ac122c05682ffaa3e21a044ddbe7bc73a3170a6d Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Tue, 25 Nov 2025 23:58:26 +0100 Subject: [PATCH 21/33] #70 added colors to CSS for the three temperatures --- frontend/public/css/style.css | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/frontend/public/css/style.css b/frontend/public/css/style.css index 8ba31f8..5638ede 100644 --- a/frontend/public/css/style.css +++ b/frontend/public/css/style.css @@ -16,6 +16,12 @@ --qd-color-water-rgb: 0, 176, 240; --qd-color-repot: #754005; --qd-color-repot-rgb: 117, 64, 5; + --qd-color-temp_warm: #FFA600; + --qd-color-temp_warm_rgb: 255, 166, 0; + --qd-color-temp_normal: #00C71B; + --qd-color-temp_normal_rgb: 0, 199, 27; + --qd-color-temp_cold: #00EEFF; + --qd-color-temp_cold_rgb: 0, 238, 255; } main { @@ -67,6 +73,18 @@ main { background-color: var(--qd-color-repot) !important; } +.bg-tmp_warm{ + background-color: var(--qd-color-temp_warm) !important; +} + +.bg-tmp_normal{ + background-color: var(--qd-color-temp_normal) !important; +} + +.bg-tmp_cold{ + background-color: var(--qd-color-temp_cold) !important; +} + .bg-center-cover { background-size: cover; background-position: center; @@ -100,4 +118,16 @@ main { .text-repot { color: var(--qd-color-repot); +} + +.text-tmp_warm { + color: var(--qd-color-temp_warm); +} + +.text-tmp_normal { + color: var(--qd-color-temp_normal); +} + +.text-tmp_cold { + color: var(--qd-color-temp_cold); } \ No newline at end of file From 4fc86aea859dbd494ad98a8a30b311c8ea529602 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Tue, 25 Nov 2025 23:58:35 +0100 Subject: [PATCH 22/33] #70 redesigned plant details page --- frontend/public/detailseite_pflanze.html | 71 ++++++++++++++++++++---- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/frontend/public/detailseite_pflanze.html b/frontend/public/detailseite_pflanze.html index 3d00723..2bf47b4 100644 --- a/frontend/public/detailseite_pflanze.html +++ b/frontend/public/detailseite_pflanze.html @@ -37,22 +37,73 @@

Lade Pflanze

-

-

+

-

- -

- Hinzugefügt am -
- Umgetopft: -
- Alle - Tage gießen
- An warmen Tagen alle - Tage gießen
- An kalten Tagen alle - Tage gießen -

+
    +
  • +
    +
    + +
    +
    +

    Hinzugefügt

    +

    am -

    +
    +
    +
  • +
  • +
    +
    + +
    +
    +

    Umgetopft

    +

    -

    +
    +
    +
  • +
+
    +
  • +
    +
    + +
    +
    +

    An warmen Tagen

    +

    Alle - Tage gießen

    +
    +
    +
  • +
  • +
    +
    + +
    +
    +

    An normalen Tagen

    +

    Alle - Tage gießen

    +
    +
    +
  • +
  • +
    +
    + +
    +
    +

    An kalten Tagen

    +

    Alle - Tage gießen

    +
    +
    +
  • +
From b8af32ff77181ca233a347943e5501e3add17e66 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Wed, 26 Nov 2025 21:18:08 +0100 Subject: [PATCH 23/33] Added user feedback for saving changes --- frontend/public/js/pflanze_bearbeiten.mjs | 27 ++++++++++++++--------- frontend/public/pflanze_bearbeiten.html | 2 +- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/frontend/public/js/pflanze_bearbeiten.mjs b/frontend/public/js/pflanze_bearbeiten.mjs index a1974e9..0562fdf 100644 --- a/frontend/public/js/pflanze_bearbeiten.mjs +++ b/frontend/public/js/pflanze_bearbeiten.mjs @@ -82,7 +82,7 @@ async function onImageUploadFormSubmit(event, form) reloadImage(plant_id); } -async function onUploadDetailFormSubmit(event, form){ +async function onDetailFormSubmit(event, form){ // disable default event event.preventDefault(); @@ -95,10 +95,10 @@ async function onUploadDetailFormSubmit(event, form){ }; // disable Button - let button = $("#detailUpload"); - button.prop('disabled', true); - let value = button.prop("value"); - button.prop("value", 'Uploading...'); + let button = document.getElementById('detailsSaveButton'); + button.disabled = true; + let originalText = button.innerHTML; + button.innerHTML = '
Speichern...'; // upload try{ @@ -107,14 +107,19 @@ async function onUploadDetailFormSubmit(event, form){ catch(e) { error_handler.handleError(e); + // enable Button + button.disabled = false; + button.innerHTML = originalText; + return; } - // enable Button - button.prop('disabled', false); - button.prop("value", value); - - // IDEA Speichern Button kurz deaktivieren und in einen Hacken ändern und dann wieder zurück + // Speichern Button kurz in einen Hacken ändern und dann wieder aktivieren + button.innerHTML = ' Gespeichert'; + await new Promise(r => setTimeout(r, 2000)); + // enable Button + button.disabled = false; + button.innerHTML = originalText; } async function reloadPlant(plant_id) { @@ -152,7 +157,7 @@ async function init() { onImageUploadFormSubmit(event, this); }); $('#detailForm').submit(function(event) { - onUploadDetailFormSubmit(event, this); + onDetailFormSubmit(event, this); }); let plant_id = utils.getArgumentFromURL("plant_id"); diff --git a/frontend/public/pflanze_bearbeiten.html b/frontend/public/pflanze_bearbeiten.html index 21625bf..848585f 100644 --- a/frontend/public/pflanze_bearbeiten.html +++ b/frontend/public/pflanze_bearbeiten.html @@ -74,7 +74,7 @@
- From 199a9f4503def6569486cbdb119e068f15722e2d Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Wed, 26 Nov 2025 22:11:31 +0100 Subject: [PATCH 24/33] #70 Updated plant edit page now includes new intervals --- frontend/public/js/pflanze_bearbeiten.mjs | 24 ++++++-------- frontend/public/pflanze_bearbeiten.html | 38 ++++++++++++----------- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/frontend/public/js/pflanze_bearbeiten.mjs b/frontend/public/js/pflanze_bearbeiten.mjs index 0562fdf..c7e3c7b 100644 --- a/frontend/public/js/pflanze_bearbeiten.mjs +++ b/frontend/public/js/pflanze_bearbeiten.mjs @@ -23,20 +23,15 @@ function displayData(plant_json) { const species_input = document.getElementById("inputSpecies"); species_input.value = plant_json["species_name"]; - const location_input = document.getElementById("location"); - const wa_offset = plant_json["watering_interval_offset"]; - if(wa_offset > 3 || wa_offset < -3) - { - console.log("offset nicht gültig"); - } - else{ - location_input.value = wa_offset; - } - location_input.value = wa_offset; - - const watering_input = document.getElementById("interval"); + const watering_input = document.getElementById("interval_normal"); watering_input.value = plant_json["watering_interval"]; + const watering_input_warm = document.getElementById("interval_warm"); + watering_input_warm.value = plant_json["watering_interval_warm"]; + + const watering_input_cold = document.getElementById("interval_cold"); + watering_input_cold.value = plant_json["watering_interval_cold"]; + const plant_id_input = document.getElementById("plant_id"); plant_id_input.value = utils.getArgumentFromURL("plant_id"); } @@ -90,8 +85,9 @@ async function onDetailFormSubmit(event, form){ "plant_id": utils.getArgumentFromURL("plant_id"), "name": document.getElementById('name').value.trim(), "species_name": document.getElementById('inputSpecies').value.trim(), - "watering_interval": parseInt(document.getElementById('interval').value), - "watering_interval_offset": document.getElementById('location').value, + "watering_interval": parseInt(document.getElementById('interval_normal').value), + "watering_interval_warm": parseInt(document.getElementById('interval_warm').value), + "watering_interval_cold": parseInt(document.getElementById('interval_cold').value), }; // disable Button diff --git a/frontend/public/pflanze_bearbeiten.html b/frontend/public/pflanze_bearbeiten.html index 848585f..68518d1 100644 --- a/frontend/public/pflanze_bearbeiten.html +++ b/frontend/public/pflanze_bearbeiten.html @@ -51,25 +51,27 @@
-
- -
- -
-
-
+
-
- +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
From d79b57c0ad2740c4bcb253d560f915bd1b251207 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Wed, 26 Nov 2025 22:30:36 +0100 Subject: [PATCH 25/33] Added basic validation to plant edit page --- frontend/public/js/pflanze_bearbeiten.mjs | 13 +++++++++++++ frontend/public/pflanze_bearbeiten.html | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/frontend/public/js/pflanze_bearbeiten.mjs b/frontend/public/js/pflanze_bearbeiten.mjs index c7e3c7b..ddc3345 100644 --- a/frontend/public/js/pflanze_bearbeiten.mjs +++ b/frontend/public/js/pflanze_bearbeiten.mjs @@ -96,6 +96,19 @@ async function onDetailFormSubmit(event, form){ let originalText = button.innerHTML; button.innerHTML = '
Speichern...'; + // validate intervals + if (plant.watering_interval < plant.watering_interval_warm || + plant.watering_interval > plant.watering_interval_cold) + { + if(!confirm("Das Gießintervall sieht falsch aus. Möchtest du wirklich speichern?")) + { + // enable Button + button.disabled = false; + button.innerHTML = originalText; + return; + } + } + // upload try{ await backend.updatePlant(plant); diff --git a/frontend/public/pflanze_bearbeiten.html b/frontend/public/pflanze_bearbeiten.html index 68518d1..18b1bec 100644 --- a/frontend/public/pflanze_bearbeiten.html +++ b/frontend/public/pflanze_bearbeiten.html @@ -42,13 +42,13 @@
- +
- +
From 6cac5d4e592eb3bab38f192d04a5bef9f80c458f Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:48:53 +0100 Subject: [PATCH 26/33] Moved navbar JS into its own file --- frontend/public/js/nav.mjs | 7 +++++++ frontend/templates/nav.html | 13 +++---------- 2 files changed, 10 insertions(+), 10 deletions(-) create mode 100644 frontend/public/js/nav.mjs diff --git a/frontend/public/js/nav.mjs b/frontend/public/js/nav.mjs new file mode 100644 index 0000000..d024455 --- /dev/null +++ b/frontend/public/js/nav.mjs @@ -0,0 +1,7 @@ +// Set the active link based on the current page +document.querySelectorAll('.nav-link').forEach(link => { + const currentPage = location.pathname.split('/').pop() || 'index.html'; + if (link.getAttribute('href') === currentPage) { + link.classList.add('active'); + } +}); \ No newline at end of file diff --git a/frontend/templates/nav.html b/frontend/templates/nav.html index c87975e..59edf8d 100644 --- a/frontend/templates/nav.html +++ b/frontend/templates/nav.html @@ -23,13 +23,6 @@
- - \ No newline at end of file + + + \ No newline at end of file From eebc0913ecf22c2fb845bf8754091b8f1570e003 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:49:31 +0100 Subject: [PATCH 27/33] #70 added watering profile selector to navbar --- frontend/public/css/style.css | 1 + .../public/css/wateringProfileSelector.css | 18 +++++++++++++++++ frontend/templates/nav.html | 20 +++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 frontend/public/css/wateringProfileSelector.css diff --git a/frontend/public/css/style.css b/frontend/public/css/style.css index 5638ede..767df51 100644 --- a/frontend/public/css/style.css +++ b/frontend/public/css/style.css @@ -10,6 +10,7 @@ --bs-secondary-rgb: 217, 242, 208; --bs-body-bg: #F2F2F2; --bs-body-bg-rgb: 242, 242, 242; + --bs-focus-ring-color: var(--bs-primary); /* Quad Core Dev Vars */ --qd-color-water: #00B0F0; diff --git a/frontend/public/css/wateringProfileSelector.css b/frontend/public/css/wateringProfileSelector.css new file mode 100644 index 0000000..7598913 --- /dev/null +++ b/frontend/public/css/wateringProfileSelector.css @@ -0,0 +1,18 @@ +.cold, .warm, .normal +label{ + background-color: white; +} + +.cold:checked+label { + background-color: var(--qd-color-temp_cold) !important; + color: white !important; +} + +.normal:checked+label { + background-color: var(--qd-color-temp_normal) !important; + color: white !important; +} + +.warm:checked+label { + background-color: var(--qd-color-temp_warm) !important; + color: white !important; +} \ No newline at end of file diff --git a/frontend/templates/nav.html b/frontend/templates/nav.html index 59edf8d..5036d8a 100644 --- a/frontend/templates/nav.html +++ b/frontend/templates/nav.html @@ -1,3 +1,5 @@ + +
From 792c8b4fff3aae2afde2c833fc6ecbed471f930c Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Sun, 14 Dec 2025 23:08:01 +0100 Subject: [PATCH 28/33] #70 added TODOs --- backend/services/plants.js | 2 -- frontend/public/js/index.mjs | 2 ++ frontend/public/js/meine_pflanzen.mjs | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/services/plants.js b/backend/services/plants.js index 825d073..6e6e5b0 100644 --- a/backend/services/plants.js +++ b/backend/services/plants.js @@ -7,8 +7,6 @@ const { body, param, matchedData, validationResult } = require('express-validato console.log('- Service Plants'); -// TODO Test all endpoints. Especially dates and intervals with setting different watering profiles - serviceRouter.get('/plants/get/:plant_id', param("plant_id").isInt({min:0}).bail().toInt().custom(validationHelper.validatePlantIDExists), function(req, resp) { diff --git a/frontend/public/js/index.mjs b/frontend/public/js/index.mjs index 975cf0c..fb6704b 100644 --- a/frontend/public/js/index.mjs +++ b/frontend/public/js/index.mjs @@ -5,6 +5,8 @@ import * as alerts from "../mjs/alerts.mjs"; import * as backend from "../mjs/backend_api.mjs"; import * as error_handler from "../mjs/error_handler.mjs"; +// TODO: Handle Watering Profile Change to update plant list accordingly + async function init() { alerts.initializeAlertDisplay(); diff --git a/frontend/public/js/meine_pflanzen.mjs b/frontend/public/js/meine_pflanzen.mjs index 464b77c..b2fc92d 100644 --- a/frontend/public/js/meine_pflanzen.mjs +++ b/frontend/public/js/meine_pflanzen.mjs @@ -5,6 +5,8 @@ import * as alerts from "../mjs/alerts.mjs"; import * as backend from "../mjs/backend_api.mjs"; import * as error_handler from "../mjs/error_handler.mjs"; +// TODO: Handle Watering Profile Change to update plant list accordingly + function displayAddButton(){ let buttonContainer = $('#button'); buttonContainer.empty(); From 66ed3d38d791ffd7cb9c1380a654ba604e1ef6a9 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Sun, 14 Dec 2025 23:08:39 +0100 Subject: [PATCH 29/33] #70 Added functions to fetch and update settings in the backend --- frontend/public/mjs/backend_api.mjs | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/frontend/public/mjs/backend_api.mjs b/frontend/public/mjs/backend_api.mjs index a60d7ca..9f0157e 100644 --- a/frontend/public/mjs/backend_api.mjs +++ b/frontend/public/mjs/backend_api.mjs @@ -354,4 +354,71 @@ export async function updatePlant(plant) { console.error("Error updating plant:", exception); throw exception; } +} + +/** + * async function to fetch a setting from the backend. + * Throws an exception on error. + * + * @param {string} key The key of the setting + * @returns {string} The value of the setting + */ +export async function fetchSetting(key) { + try{ + const res = await fetch(backendUrl_api + '/settings/' + key); + // check if it was successful + if(res.status !== 200) { + const errorResponse = await res.json(); + throw new BackendError(`Failed to fetch setting`,res.status, errorResponse.errors); + } + else + { + const setting = await res.json(); + console.log("fetched setting"); + return setting.value; + } + } + catch(exception) + { + console.error("Error fetching setting:", exception); + throw exception; + } +} + +/** + * async function to update a setting on the backend. + * Throws an exception on error. + * + * @param {string} key The key of the setting + * @param {string} value The value of the setting + */ +export async function updateSetting(key, value) { + try{ + const setting = { + "key": key, + "value": value + }; + const res = await fetch(backendUrl_api + "/settings", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(setting) + }); + + // check if it was successful + if (res.status !== 200) { + const errorResponse = await res.json(); + throw new BackendError(`Failed to update setting`,res.status, errorResponse.errors); + } + else + { + console.log("Setting updated successfully"); + } + } + catch (exception) + { + console.error("Error updating setting:", exception); + throw exception; + } } \ No newline at end of file From b7979de4840c4d77707474914b5a244969ee06de Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Sun, 14 Dec 2025 23:09:41 +0100 Subject: [PATCH 30/33] #70 Added WIP JS Code for the Watering Profile Selector --- frontend/public/js/nav.mjs | 7 ++- frontend/public/mjs/error_handler.mjs | 4 ++ .../public/mjs/wateringProfileSelector.mjs | 55 +++++++++++++++++++ frontend/templates/nav.html | 2 +- 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 frontend/public/mjs/wateringProfileSelector.mjs diff --git a/frontend/public/js/nav.mjs b/frontend/public/js/nav.mjs index d024455..1f7bd63 100644 --- a/frontend/public/js/nav.mjs +++ b/frontend/public/js/nav.mjs @@ -1,7 +1,12 @@ +import * as wps from "../mjs/wateringProfileSelector.mjs"; + // Set the active link based on the current page document.querySelectorAll('.nav-link').forEach(link => { const currentPage = location.pathname.split('/').pop() || 'index.html'; if (link.getAttribute('href') === currentPage) { link.classList.add('active'); } -}); \ No newline at end of file +}); + +// Initialize watering profile selector +wps.initWpsSelector(); \ No newline at end of file diff --git a/frontend/public/mjs/error_handler.mjs b/frontend/public/mjs/error_handler.mjs index d65d3cb..dd457d2 100644 --- a/frontend/public/mjs/error_handler.mjs +++ b/frontend/public/mjs/error_handler.mjs @@ -56,6 +56,10 @@ function generatePrimaryErrorMessage(error) { else{ return "Konnte keine neue Aktivität anlegen"; } + } else if (error.message.includes("Failed to fetch setting")) { + return "Fehler beim Abrufen der Einstellungen"; + } else if (error.message.includes("Failed to update setting")) { + return "Fehler beim Anwenden der Einstellungen"; } break; } diff --git a/frontend/public/mjs/wateringProfileSelector.mjs b/frontend/public/mjs/wateringProfileSelector.mjs new file mode 100644 index 0000000..54ec7bc --- /dev/null +++ b/frontend/public/mjs/wateringProfileSelector.mjs @@ -0,0 +1,55 @@ +import * as backend from "../mjs/backend_api.mjs"; +import * as error_handler from "../mjs/error_handler.mjs"; + +let changeListeners = []; + +// TODO: Needs AlertsDisplay which is not yet initialized when this module is loaded +// TODO: Flickering when changing profile because of async fetchSetting or animation + +function onWpsRadioClick(radio) { + let profile = radio.value; + console.log("Selected watering profile:", profile); + + // Send to backend + try { + backend.updateSetting("watering_profile", profile); + } catch (e) { + error_handler.handleError(e); + return; + } + + // Notify listeners + for (let i = 0; i < changeListeners.length; i++) { + changeListeners[i](profile); + } +} + +function setRadioListeners() { + let wpsRadios = document.getElementsByName("wateringProfileSelector"); + for (let i = 0; i < wpsRadios.length; i++) { + wpsRadios[i].addEventListener('click', function() { + onWpsRadioClick(wpsRadios[i]); + }); + } +} + +function displayProfile(profile) { + let wpsRadios = document.getElementsByName("wateringProfileSelector"); + for (let i = 0; i < wpsRadios.length; i++) { + if (wpsRadios[i].value === profile) { + wpsRadios[i].checked = true; + return; + } + } + throw new Error("Unknown watering profile: " + profile); +} + +export async function initWpsSelector() { + const currentProfile = await backend.fetchSetting("watering_profile"); + displayProfile(currentProfile); + setRadioListeners(); +} + +export function addWpsChangeListener(listener) { + changeListeners.push(listener); +} \ No newline at end of file diff --git a/frontend/templates/nav.html b/frontend/templates/nav.html index 5036d8a..b958496 100644 --- a/frontend/templates/nav.html +++ b/frontend/templates/nav.html @@ -29,7 +29,7 @@ - + From 0334034e836923024dd2779a7d4f3d05faa5bbb2 Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:18:24 +0200 Subject: [PATCH 31/33] Alerts Display now initializes itself --- frontend/public/js/detailseite_pflanze.mjs | 1 - frontend/public/js/index.mjs | 2 -- frontend/public/js/kompostierte_pflanzen.mjs | 2 -- frontend/public/js/meine_pflanzen.mjs | 3 --- frontend/public/js/pflanze_bearbeiten.mjs | 2 -- frontend/public/mjs/alerts.mjs | 18 +++++++++++++----- 6 files changed, 13 insertions(+), 15 deletions(-) diff --git a/frontend/public/js/detailseite_pflanze.mjs b/frontend/public/js/detailseite_pflanze.mjs index 97bedc5..b31054f 100644 --- a/frontend/public/js/detailseite_pflanze.mjs +++ b/frontend/public/js/detailseite_pflanze.mjs @@ -218,7 +218,6 @@ function showCompostInfo(date){ } async function init() { - alerts.initializeAlertDisplay(); const plantId = utils.getArgumentFromURL("id"); try { diff --git a/frontend/public/js/index.mjs b/frontend/public/js/index.mjs index fb6704b..a6822fd 100644 --- a/frontend/public/js/index.mjs +++ b/frontend/public/js/index.mjs @@ -1,14 +1,12 @@ import { backendUrl_plantImages } from "../mjs/config.mjs"; import * as ui_helper from "../mjs/ui_helpers.mjs"; import * as navigation from "../mjs/navigation.mjs"; -import * as alerts from "../mjs/alerts.mjs"; import * as backend from "../mjs/backend_api.mjs"; import * as error_handler from "../mjs/error_handler.mjs"; // TODO: Handle Watering Profile Change to update plant list accordingly async function init() { - alerts.initializeAlertDisplay(); let centeredDiv = ui_helper.createCenteredDiv(); ui_helper.createSpinner(centeredDiv, "Lade Pflanzen"); diff --git a/frontend/public/js/kompostierte_pflanzen.mjs b/frontend/public/js/kompostierte_pflanzen.mjs index 029b64f..8930de5 100644 --- a/frontend/public/js/kompostierte_pflanzen.mjs +++ b/frontend/public/js/kompostierte_pflanzen.mjs @@ -1,11 +1,9 @@ import * as navigation from "../mjs/navigation.mjs"; -import * as alerts from "../mjs/alerts.mjs"; import * as backend from "../mjs/backend_api.mjs"; import * as error_handler from "../mjs/error_handler.mjs"; import * as utils from "../mjs/utils.mjs"; async function init() { - alerts.initializeAlertDisplay(); registerEventHandlers(); console.log('Document ready, loading data from Service'); diff --git a/frontend/public/js/meine_pflanzen.mjs b/frontend/public/js/meine_pflanzen.mjs index b2fc92d..4344e87 100644 --- a/frontend/public/js/meine_pflanzen.mjs +++ b/frontend/public/js/meine_pflanzen.mjs @@ -1,7 +1,6 @@ import { backendUrl_plantImages } from "../mjs/config.mjs"; import * as ui_helper from "../mjs/ui_helpers.mjs"; import * as navigation from "../mjs/navigation.mjs"; -import * as alerts from "../mjs/alerts.mjs"; import * as backend from "../mjs/backend_api.mjs"; import * as error_handler from "../mjs/error_handler.mjs"; @@ -141,8 +140,6 @@ async function reloadPlants() { } async function init() { - alerts.initializeAlertDisplay(); - let centeredDiv = ui_helper.createCenteredDiv(); ui_helper.createSpinner(centeredDiv, "Lade Pflanzen"); $("#plants").html(centeredDiv); diff --git a/frontend/public/js/pflanze_bearbeiten.mjs b/frontend/public/js/pflanze_bearbeiten.mjs index ddc3345..71c618a 100644 --- a/frontend/public/js/pflanze_bearbeiten.mjs +++ b/frontend/public/js/pflanze_bearbeiten.mjs @@ -158,8 +158,6 @@ async function reloadImage(plant_id){ } async function init() { - alerts.initializeAlertDisplay(); - console.log('Document ready, loading data from Backend'); // Register event handler $('#uploadForm').submit(function(event) { diff --git a/frontend/public/mjs/alerts.mjs b/frontend/public/mjs/alerts.mjs index 410b6dd..cdb2a20 100644 --- a/frontend/public/mjs/alerts.mjs +++ b/frontend/public/mjs/alerts.mjs @@ -1,17 +1,24 @@ /** * Prepares the DOM to display Bootstrap alerts. - * Must be called before the first time displayAlert(). - * Best called right after the document finished loading. */ -export function initializeAlertDisplay() +function initializeAlertDisplay() { console.log("Initializing Alerts Container"); $("MAIN").prepend($('
')); } +/** + * Checks whether the required container has been created. + * @returns {boolean} true if it has been initialized. + */ +function isAlertDisplayInitialized () { + let container = document.getElementById("alertsContainer"); + return container != null; +} + /** * Displays a Bootstrap alert in the DOM. - * Must be called after initializeAlertDisplay(). + * Will call initializeAlertDisplay() if needed. * * @param {string} mainMessage a string that will be displayed to the user * @param {string} type a string to change the appearance based on Bootsrap (e.g. danger) @@ -20,6 +27,8 @@ export function initializeAlertDisplay() */ export function displayAlert(mainMessage, type, secondaryMessage, icon=null) { + if(!isAlertDisplayInitialized()) {initializeAlertDisplay();} + let alert = $('
'); alert.prop('class', 'alert alert-' + type + ' alert-dismissible'); @@ -62,7 +71,6 @@ export function displayAlert(mainMessage, type, secondaryMessage, icon=null) { /** * Displays the message as in red to the user. - * Must be called after initializeAlertDisplay(). * * @param {string} mainMessage a string that will be displayed to the user * @param {string} secondaryMessage (optional) additional information that will be displayed to the user From cee01ab5af5bfc9455369e9780c56b62e6a15a4a Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:18:59 +0200 Subject: [PATCH 32/33] #70 Fixed and implemented error handling on wps --- .../public/mjs/wateringProfileSelector.mjs | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/frontend/public/mjs/wateringProfileSelector.mjs b/frontend/public/mjs/wateringProfileSelector.mjs index 54ec7bc..834ed67 100644 --- a/frontend/public/mjs/wateringProfileSelector.mjs +++ b/frontend/public/mjs/wateringProfileSelector.mjs @@ -3,16 +3,13 @@ import * as error_handler from "../mjs/error_handler.mjs"; let changeListeners = []; -// TODO: Needs AlertsDisplay which is not yet initialized when this module is loaded -// TODO: Flickering when changing profile because of async fetchSetting or animation - -function onWpsRadioClick(radio) { +async function onWpsRadioClick(radio) { let profile = radio.value; console.log("Selected watering profile:", profile); // Send to backend try { - backend.updateSetting("watering_profile", profile); + await backend.updateSetting("watering_profile", profile); } catch (e) { error_handler.handleError(e); return; @@ -45,9 +42,21 @@ function displayProfile(profile) { } export async function initWpsSelector() { - const currentProfile = await backend.fetchSetting("watering_profile"); - displayProfile(currentProfile); setRadioListeners(); + let currentProfile = null; + try { + currentProfile = await backend.fetchSetting("watering_profile"); + } catch (e) { + if(e.message.includes("NetworkError")){ + // If this happens, then loading of other information has also probably failed. + // We don't want to display two error messages about no internet connection. + console.log("Network Error while loading Setting for watering profile selector"); + } else { + error_handler.handleError(e); + } + return; + } + displayProfile(currentProfile); } export function addWpsChangeListener(listener) { From 3346384ac7322368abcebfefb8c0bbdfba27d8ec Mon Sep 17 00:00:00 2001 From: CoderTobi <77673526+CoderTobi@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:42:10 +0200 Subject: [PATCH 33/33] #70 Dashboard and plant overview now updated when WPS changes --- frontend/public/js/index.mjs | 9 ++++++--- frontend/public/js/meine_pflanzen.mjs | 8 ++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/frontend/public/js/index.mjs b/frontend/public/js/index.mjs index a6822fd..f34abd4 100644 --- a/frontend/public/js/index.mjs +++ b/frontend/public/js/index.mjs @@ -3,11 +3,10 @@ import * as ui_helper from "../mjs/ui_helpers.mjs"; import * as navigation from "../mjs/navigation.mjs"; import * as backend from "../mjs/backend_api.mjs"; import * as error_handler from "../mjs/error_handler.mjs"; - -// TODO: Handle Watering Profile Change to update plant list accordingly +import * as wps from "../mjs/wateringProfileSelector.mjs" async function init() { - + wps.addWpsChangeListener(onWPSChange); let centeredDiv = ui_helper.createCenteredDiv(); ui_helper.createSpinner(centeredDiv, "Lade Pflanzen"); $("#plants").html(centeredDiv); @@ -161,6 +160,10 @@ function createPlantCard(plant) { return col; } +function onWPSChange(){ + reloadPlants(); +} + async function buttonWaterClick(plant) { // call Backend diff --git a/frontend/public/js/meine_pflanzen.mjs b/frontend/public/js/meine_pflanzen.mjs index 4344e87..6849d78 100644 --- a/frontend/public/js/meine_pflanzen.mjs +++ b/frontend/public/js/meine_pflanzen.mjs @@ -3,8 +3,7 @@ import * as ui_helper from "../mjs/ui_helpers.mjs"; import * as navigation from "../mjs/navigation.mjs"; import * as backend from "../mjs/backend_api.mjs"; import * as error_handler from "../mjs/error_handler.mjs"; - -// TODO: Handle Watering Profile Change to update plant list accordingly +import * as wps from "../mjs/wateringProfileSelector.mjs" function displayAddButton(){ let buttonContainer = $('#button'); @@ -139,11 +138,16 @@ async function reloadPlants() { displayPlants(plants); } +function onWPSChange(){ + reloadPlants(); +} + async function init() { let centeredDiv = ui_helper.createCenteredDiv(); ui_helper.createSpinner(centeredDiv, "Lade Pflanzen"); $("#plants").html(centeredDiv); + wps.addWpsChangeListener(onWPSChange); console.log('Document ready, loading data from Service'); await reloadPlants(); }