From 1241261ca46931135d66fc6ddbab1832e034f483 Mon Sep 17 00:00:00 2001 From: Bernd Bestel Date: Tue, 25 Jul 2017 20:03:31 +0200 Subject: [PATCH] Added habit tracking --- GrocyDbMigrator.php | 31 +++- GrocyDemoDataGenerator.php | 9 ++ GrocyLogicHabits.php | 59 ++++++++ GrocyPhpHelper.php | 6 + bower.json | 3 +- grocy.phpproj | 13 +- index.php | 78 +++++++++- views/habitform.js | 54 +++++++ views/habitform.php | 46 ++++++ views/habits.js | 43 ++++++ views/habits.php | 50 +++++++ views/habitsoverview.js | 7 + views/habitsoverview.php | 38 +++++ views/habittracking.js | 165 +++++++++++++++++++++ views/habittracking.php | 42 ++++++ views/layout.php | 34 ++++- views/{dashboard.js => stockoverview.js} | 2 +- views/{dashboard.php => stockoverview.php} | 6 +- 18 files changed, 671 insertions(+), 15 deletions(-) create mode 100644 GrocyLogicHabits.php create mode 100644 views/habitform.js create mode 100644 views/habitform.php create mode 100644 views/habits.js create mode 100644 views/habits.php create mode 100644 views/habitsoverview.js create mode 100644 views/habitsoverview.php create mode 100644 views/habittracking.js create mode 100644 views/habittracking.php rename views/{dashboard.js => stockoverview.js} (63%) rename views/{dashboard.php => stockoverview.php} (86%) diff --git a/GrocyDbMigrator.php b/GrocyDbMigrator.php index 6fe1d517..a62e4823 100644 --- a/GrocyDbMigrator.php +++ b/GrocyDbMigrator.php @@ -88,7 +88,7 @@ class GrocyDbMigrator CREATE VIEW stock_current AS SELECT product_id, SUM(amount) AS amount, MIN(best_before_date) AS best_before_date - from stock + FROM stock GROUP BY product_id ORDER BY MIN(best_before_date) ASC;" ); @@ -102,6 +102,35 @@ class GrocyDbMigrator row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime')) )" ); + + self::ExecuteMigrationWhenNeeded($pdo, 10, " + CREATE TABLE habits ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE, + name TEXT NOT NULL UNIQUE, + description TEXT, + period_type TEXT NOT NULL, + period_days INTEGER, + row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime')) + )" + ); + + self::ExecuteMigrationWhenNeeded($pdo, 11, " + CREATE TABLE habits_log ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE, + habit_id INTEGER NOT NULL, + tracked_time DATETIME, + row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime')) + )" + ); + + self::ExecuteMigrationWhenNeeded($pdo, 12, " + CREATE VIEW habits_current + AS + SELECT habit_id, MAX(tracked_time) AS last_tracked_time + FROM habits_log + GROUP BY habit_id + ORDER BY MAX(tracked_time) DESC;" + ); } private static function ExecuteMigrationWhenNeeded(PDO $pdo, int $migrationId, string $sql) diff --git a/GrocyDemoDataGenerator.php b/GrocyDemoDataGenerator.php index eaf66973..0c8b7823 100644 --- a/GrocyDemoDataGenerator.php +++ b/GrocyDemoDataGenerator.php @@ -35,6 +35,9 @@ class GrocyDemoDataGenerator INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock) VALUES ('Radieschen', 4, 6, 6, 1); --14 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, qu_factor_purchase_to_stock) VALUES ('Tomate', 4, 1, 1, 1); --15 + INSERT INTO habits (name, period_type, period_days) VALUES ('Changed towels in the bathroom', 'manually', 5); --1 + INSERT INTO habits (name, period_type, period_days) VALUES ('Cleaned the kitchen floor', 'dynamic-regular', 7); --2 + INSERT INTO migrations (migration) VALUES (-1); "; @@ -54,6 +57,12 @@ class GrocyDemoDataGenerator GrocyLogicStock::AddProduct(14, 5, date('Y-m-d', strtotime('+2 days')), GrocyLogicStock::TRANSACTION_TYPE_PURCHASE); GrocyLogicStock::AddProduct(15, 5, date('Y-m-d', strtotime('-2 days')), GrocyLogicStock::TRANSACTION_TYPE_PURCHASE); GrocyLogicStock::AddMissingProductsToShoppingList(); + + GrocyLogicHabits::TrackHabit(1, date('Y-m-d H:i:s', strtotime('-5 days'))); + GrocyLogicHabits::TrackHabit(1, date('Y-m-d H:i:s', strtotime('-10 days'))); + GrocyLogicHabits::TrackHabit(1, date('Y-m-d H:i:s', strtotime('-15 days'))); + GrocyLogicHabits::TrackHabit(2, date('Y-m-d H:i:s', strtotime('-10 days'))); + GrocyLogicHabits::TrackHabit(2, date('Y-m-d H:i:s', strtotime('-20 days'))); } } } diff --git a/GrocyLogicHabits.php b/GrocyLogicHabits.php new file mode 100644 index 00000000..0e978d3c --- /dev/null +++ b/GrocyLogicHabits.php @@ -0,0 +1,59 @@ +fetchAll(PDO::FETCH_OBJ); + } + + public static function GetNextHabitTime(int $habitId) + { + $db = Grocy::GetDbConnection(); + + $habit = $db->habits($habitId); + $habitLastLogRow = Grocy::ExecuteDbQuery(Grocy::GetDbConnectionRaw(), "SELECT * from habits_current WHERE habit_id = $habitId LIMIT 1")->fetch(PDO::FETCH_OBJ); + + switch ($habit->period_type) + { + case self::HABIT_TYPE_MANUALLY: + return date('Y-m-d H:i:s'); + case self::HABIT_TYPE_DYNAMIC_REGULAR: + return date('Y-m-d H:i:s', strtotime('+' . $habit->period_days . ' day', strtotime($habitLastLogRow->last_tracked_time))); + } + + return null; + } + + public static function GetHabitDetails(int $habitId) + { + $db = Grocy::GetDbConnection(); + + $habit = $db->habits($habitId); + $habitTrackedCount = $db->habits_log()->where('habit_id', $habitId)->count(); + $habitLastTrackedTime = $db->habits_log()->where('habit_id', $habitId)->max('tracked_time'); + + return array( + 'habit' => $habit, + 'last_tracked' => $habitLastTrackedTime, + 'tracked_count' => $habitTrackedCount + ); + } + + public static function TrackHabit(int $habitId, string $trackedTime) + { + $db = Grocy::GetDbConnection(); + + $logRow = $db->habits_log()->createRow(array( + 'habit_id' => $habitId, + 'tracked_time' => $trackedTime + )); + $logRow->save(); + + return true; + } +} diff --git a/GrocyPhpHelper.php b/GrocyPhpHelper.php index 28c9015b..e76a762a 100644 --- a/GrocyPhpHelper.php +++ b/GrocyPhpHelper.php @@ -57,4 +57,10 @@ class GrocyPhpHelper return $sum; } + + public static function GetClassConstants($className) + { + $r = new ReflectionClass($className); + return $r->getConstants(); + } } diff --git a/bower.json b/bower.json index b7bf4bbb..f13627c9 100644 --- a/bower.json +++ b/bower.json @@ -16,6 +16,7 @@ "datatables.net-responsive-bs": "2.1.1", "jquery-timeago": "1.5.4", "toastr": "2.1.3", - "tagmanager": "3.0.2" + "tagmanager": "3.0.2", + "eonasdan-bootstrap-datetimepicker": "4.17.47" } } diff --git a/grocy.phpproj b/grocy.phpproj index f5f02a63..31d4c13f 100644 --- a/grocy.phpproj +++ b/grocy.phpproj @@ -24,14 +24,18 @@ + + + + @@ -41,7 +45,8 @@ - + + @@ -58,10 +63,14 @@ + - + + + + diff --git a/index.php b/index.php index 55c81c40..fc3f8e15 100644 --- a/index.php +++ b/index.php @@ -10,6 +10,7 @@ require_once __DIR__ . '/Grocy.php'; require_once __DIR__ . '/GrocyDbMigrator.php'; require_once __DIR__ . '/GrocyDemoDataGenerator.php'; require_once __DIR__ . '/GrocyLogicStock.php'; +require_once __DIR__ . '/GrocyLogicHabits.php'; require_once __DIR__ . '/GrocyPhpHelper.php'; $app = new \Slim\App(new \Slim\Container([ @@ -86,9 +87,14 @@ $app->get('/', function(Request $request, Response $response) use($db) { $db = Grocy::GetDbConnection(true); //For database schema migration + return $response->withRedirect('/stockoverview'); +}); + +$app->get('/stockoverview', function(Request $request, Response $response) use($db) +{ return $this->renderer->render($response, '/layout.php', [ - 'title' => 'Dashboard', - 'contentPage' => 'dashboard.php', + 'title' => 'Stock overview', + 'contentPage' => 'stockoverview.php', 'products' => $db->products(), 'quantityunits' => $db->quantity_units(), 'currentStock' => GrocyLogicStock::GetCurrentStock(), @@ -96,6 +102,16 @@ $app->get('/', function(Request $request, Response $response) use($db) ]); }); +$app->get('/habitsoverview', function(Request $request, Response $response) use($db) +{ + return $this->renderer->render($response, '/layout.php', [ + 'title' => 'Habits overview', + 'contentPage' => 'habitsoverview.php', + 'habits' => $db->habits(), + 'currentHabits' => GrocyLogicHabits::GetCurrentHabits(), + ]); +}); + $app->get('/purchase', function(Request $request, Response $response) use($db) { return $this->renderer->render($response, '/layout.php', [ @@ -135,6 +151,15 @@ $app->get('/shoppinglist', function(Request $request, Response $response) use($d ]); }); +$app->get('/habittracking', function(Request $request, Response $response) use($db) +{ + return $this->renderer->render($response, '/layout.php', [ + 'title' => 'Habit tracking', + 'contentPage' => 'habittracking.php', + 'habits' => $db->habits() + ]); +}); + $app->get('/products', function(Request $request, Response $response) use($db) { return $this->renderer->render($response, '/layout.php', [ @@ -164,6 +189,16 @@ $app->get('/quantityunits', function(Request $request, Response $response) use($ ]); }); +$app->get('/habits', function(Request $request, Response $response) use($db) +{ + return $this->renderer->render($response, '/layout.php', [ + 'title' => 'Habits', + 'contentPage' => 'habits.php', + 'habits' => $db->habits() + ]); +}); + + $app->get('/product/{productId}', function(Request $request, Response $response, $args) use($db) { if ($args['productId'] == 'new') @@ -231,6 +266,29 @@ $app->get('/quantityunit/{quantityunitId}', function(Request $request, Response } }); +$app->get('/habit/{habitId}', function(Request $request, Response $response, $args) use($db) +{ + if ($args['habitId'] == 'new') + { + return $this->renderer->render($response, '/layout.php', [ + 'title' => 'Create habit', + 'contentPage' => 'habitform.php', + 'periodTypes' => GrocyPhpHelper::GetClassConstants('GrocyLogicHabits'), + 'mode' => 'create' + ]); + } + else + { + return $this->renderer->render($response, '/layout.php', [ + 'title' => 'Edit habit', + 'contentPage' => 'habitform.php', + 'habit' => $db->habits($args['habitId']), + 'periodTypes' => GrocyPhpHelper::GetClassConstants('GrocyLogicHabits'), + 'mode' => 'edit' + ]); + } +}); + $app->get('/shoppinglistitem/{itemId}', function(Request $request, Response $response, $args) use($db) { if ($args['itemId'] == 'new') @@ -350,6 +408,22 @@ $app->group('/api', function() use($db) GrocyLogicStock::AddMissingProductsToShoppingList(); echo json_encode(array('success' => true)); }); + + $this->get('/habits/track-habit/{habitId}', function(Request $request, Response $response, $args) + { + $trackedTime = date('Y-m-d H:i:s'); + if (isset($request->getQueryParams()['tracked_time']) && !empty($request->getQueryParams()['tracked_time'])) + { + $trackedTime = $request->getQueryParams()['tracked_time']; + } + + echo json_encode(array('success' => GrocyLogicHabits::TrackHabit($args['habitId'], $trackedTime))); + }); + + $this->get('/habits/get-habit-details/{habitId}', function(Request $request, Response $response, $args) + { + echo json_encode(GrocyLogicHabits::GetHabitDetails($args['habitId'])); + }); })->add(function($request, $response, $next) { $response = $next($request, $response); diff --git a/views/habitform.js b/views/habitform.js new file mode 100644 index 00000000..5970606e --- /dev/null +++ b/views/habitform.js @@ -0,0 +1,54 @@ +$('#save-habit-button').on('click', function(e) +{ + e.preventDefault(); + + if (Grocy.EditMode === 'create') + { + Grocy.PostJson('/api/add-object/habits', $('#habit-form').serializeJSON(), + function(result) + { + window.location.href = '/habits'; + }, + function(xhr) + { + console.error(xhr); + } + ); + } + else + { + Grocy.PostJson('/api/edit-object/habits/' + Grocy.EditObjectId, $('#habit-form').serializeJSON(), + function(result) + { + window.location.href = '/habits'; + }, + function(xhr) + { + console.error(xhr); + } + ); + } +}); + +$(function() +{ + $('#name').focus(); + $('#habit-form').validator(); + $('#habit-form').validator('validate'); +}); + +$('.input-group-habit-period-type').on('change', function(e) +{ + var periodType = $('#period_type').val(); + var periodDays = $('#period_days').val(); + + if (periodType === 'dynamic-regular') + { + $('#habit-period-type-info').text('This means it is estimated that a new "execution" of this habit is tracked ' + periodDays.toString() + ' days after the last was tracked.'); + $('#habit-period-type-info').show(); + } + else + { + $('#habit-period-type-info').hide(); + } +}); diff --git a/views/habitform.php b/views/habitform.php new file mode 100644 index 00000000..8e7791df --- /dev/null +++ b/views/habitform.php @@ -0,0 +1,46 @@ +
+ +

+ + + + + + + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ +

+ + + +
+ +
diff --git a/views/habits.js b/views/habits.js new file mode 100644 index 00000000..42840c07 --- /dev/null +++ b/views/habits.js @@ -0,0 +1,43 @@ +$(document).on('click', '.habit-delete-button', function(e) +{ + bootbox.confirm({ + message: 'Delete habit ' + $(e.target).attr('data-habit-name') + '?', + buttons: { + confirm: { + label: 'Yes', + className: 'btn-success' + }, + cancel: { + label: 'No', + className: 'btn-danger' + } + }, + callback: function(result) + { + if (result === true) + { + Grocy.FetchJson('/api/delete-object/habits/' + $(e.target).attr('data-habit-id'), + function(result) + { + window.location.href = '/habits'; + }, + function(xhr) + { + console.error(xhr); + } + ); + } + } + }); +}); + +$(function() +{ + $('#habits-table').DataTable({ + 'pageLength': 50, + 'order': [[1, 'asc']], + 'columnDefs': [ + { 'orderable': false, 'targets': 0 } + ] + }); +}); diff --git a/views/habits.php b/views/habits.php new file mode 100644 index 00000000..1b5d2fc6 --- /dev/null +++ b/views/habits.php @@ -0,0 +1,50 @@ +
+ +

+ Habits + +  Add + +

+ +
+ + + + + + + + + + + + + + + + + + + + + +
#NamePeriod typePeriod daysDescription
+ + + + + + + + name; ?> + + period_type; ?> + + period_days; ?> + + description; ?> +
+
+ +
diff --git a/views/habitsoverview.js b/views/habitsoverview.js new file mode 100644 index 00000000..4d7fb551 --- /dev/null +++ b/views/habitsoverview.js @@ -0,0 +1,7 @@ +$(function() +{ + $('#habits-overview-table').DataTable({ + 'pageLength': 50, + 'order': [[1, 'desc']] + }); +}); diff --git a/views/habitsoverview.php b/views/habitsoverview.php new file mode 100644 index 00000000..49d61e43 --- /dev/null +++ b/views/habitsoverview.php @@ -0,0 +1,38 @@ +
+ +

Habits overview

+ +
+ + + + + + + + + + + + + + + + + +
HabitNext estimated trackingLast tracked
+ habit_id)->name; ?> + + habit_id)->period_type === GrocyLogicHabits::HABIT_TYPE_DYNAMIC_REGULAR): ?> + habit_id); ?> + + + Whenever you want... + + + last_tracked_time; ?> + +
+
+ +
diff --git a/views/habittracking.js b/views/habittracking.js new file mode 100644 index 00000000..3ab0ddd2 --- /dev/null +++ b/views/habittracking.js @@ -0,0 +1,165 @@ +$('#save-habittracking-button').on('click', function(e) +{ + e.preventDefault(); + + var jsonForm = $('#habittracking-form').serializeJSON(); + + Grocy.FetchJson('/api/habits/get-habit-details/' + jsonForm.habit_id, + function (habitDetails) + { + Grocy.FetchJson('/api/habits/track-habit/' + jsonForm.habit_id + '?tracked_time=' + $('#tracked_time').val(), + function(result) + { + toastr.success('Tracked execution of habit ' + habitDetails.habit.name + ' on ' + $('#tracked_time').val()); + + $('#habit_id').val(''); + $('#habit_id_text_input').focus(); + $('#habit_id_text_input').val(''); + $('#tracked_time').val(moment().format('YYYY-MM-DD HH:mm:ss')); + $('#tracked_time').trigger('change'); + $('#habit_id_text_input').trigger('change'); + $('#habittracking-form').validator('validate'); + }, + function(xhr) + { + console.error(xhr); + } + ); + }, + function(xhr) + { + console.error(xhr); + } + ); +}); + +$('#habit_id').on('change', function(e) +{ + var habitId = $(e.target).val(); + + if (habitId) + { + Grocy.FetchJson('/api/habits/get-habit-details/' + habitId, + function(habitDetails) + { + $('#selected-habit-name').text(habitDetails.habit.name); + $('#selected-habit-last-tracked').text((habitDetails.last_tracked || 'never')); + $('#selected-habit-last-tracked-timeago').text($.timeago(habitDetails.last_tracked || '')); + $('#selected-habit-tracked-count').text((habitDetails.tracked_count || '0')); + + Grocy.EmptyElementWhenMatches('#selected-habit-last-tracked-timeago', 'NaN years ago'); + }, + function(xhr) + { + console.error(xhr); + } + ); + } +}); + +$(function() +{ + $('.datetimepicker').datetimepicker( + { + format: 'YYYY-MM-DD HH:mm:ss', + showTodayButton: true, + calendarWeeks: true, + maxDate: moment() + }); + + $('#tracked_time').val(moment().format('YYYY-MM-DD HH:mm:ss')); + $('#tracked_time').trigger('change'); + + $('#tracked_time').on('focus', function(e) + { + if ($('#habit_id_text_input').val().length === 0) + { + $('#habit_id_text_input').focus(); + } + }); + + $('.combobox').combobox({ + appendId: '_text_input' + }); + + $('#habit_id').val(''); + $('#habit_id_text_input').focus(); + $('#habit_id_text_input').val(''); + $('#habit_id_text_input').trigger('change'); + + $('#habittracking-form').validator(); + $('#habittracking-form').validator('validate'); + + $('#habittracking-form input').keydown(function(event) + { + if (event.keyCode === 13) //Enter + { + if ($('#habittracking-form').validator('validate').has('.has-error').length !== 0) //There is at least one validation error + { + event.preventDefault(); + return false; + } + } + }); +}); + +$('#tracked_time').on('change', function(e) +{ + var value = $('#tracked_time').val(); + var now = new Date(); + var centuryStart = Number.parseInt(now.getFullYear().toString().substring(0, 2) + '00'); + var centuryEnd = Number.parseInt(now.getFullYear().toString().substring(0, 2) + '99'); + + if (value === 'x' || value === 'X') { + value = '29991231'; + } + + if (value.length === 4 && !(Number.parseInt(value) > centuryStart && Number.parseInt(value) < centuryEnd)) + { + value = (new Date()).getFullYear().toString() + value; + } + + if (value.length === 8 && $.isNumeric(value)) + { + value = value.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3'); + $('#tracked_time').val(value); + $('#habittracking-form').validator('validate'); + } +}); + +$('#tracked_time').on('keypress', function(e) +{ + var element = $(e.target); + var value = element.val(); + var dateObj = moment(element.val(), 'YYYY-MM-DD', true); + + $('.datepicker').datepicker('hide'); + + //If input is empty and any arrow key is pressed, set date to today + if (value.length === 0 && (e.keyCode === 38 || e.keyCode === 40 || e.keyCode === 37 || e.keyCode === 39)) + { + dateObj = moment(new Date(), 'YYYY-MM-DD', true); + } + + if (dateObj.isValid()) + { + if (e.keyCode === 38) //Up + { + element.val(dateObj.add(-1, 'days').format('YYYY-MM-DD')); + } + else if (e.keyCode === 40) //Down + { + element.val(dateObj.add(1, 'days').format('YYYY-MM-DD')); + } + else if (e.keyCode === 37) //Left + { + element.val(dateObj.add(-1, 'weeks').format('YYYY-MM-DD')); + } + else if (e.keyCode === 39) //Right + { + element.val(dateObj.add(1, 'weeks').format('YYYY-MM-DD')); + } + } + + $('#habittracking-form').validator('validate'); +}); diff --git a/views/habittracking.php b/views/habittracking.php new file mode 100644 index 00000000..a9a69c05 --- /dev/null +++ b/views/habittracking.php @@ -0,0 +1,42 @@ +
+ +

Habit tracking

+ +
+ +
+ + +
+
+ +
+ +
+ + + + +
+
+
+ + + +
+ +
+ +
+

Habit overview

+ +

+ Tracked count:
+ Last tracked:
+

+
diff --git a/views/layout.php b/views/layout.php index ff582724..97066020 100644 --- a/views/layout.php +++ b/views/layout.php @@ -20,6 +20,7 @@ + @@ -49,9 +50,15 @@