diff --git a/config/Migrations/20240706000315_AddUserSlackTimeZone.php b/config/Migrations/20240706000315_AddUserSlackTimeZone.php
new file mode 100644
index 00000000..39a77d7e
--- /dev/null
+++ b/config/Migrations/20240706000315_AddUserSlackTimeZone.php
@@ -0,0 +1,42 @@
+table('users')
+ ->addColumn('slack_time_zone', 'string', [
+ 'after' => 'progression_id',
+ 'default' => null,
+ 'length' => 255,
+ 'null' => true,
+ ])
+ ->update();
+ }
+
+ /**
+ * Down Method.
+ *
+ * More information on this method is available here:
+ * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method
+ *
+ * @return void
+ */
+ public function down(): void
+ {
+ $this->table('users')
+ ->removeColumn('slack_time_zone')
+ ->update();
+ }
+}
diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js
index bbf74108..bc486914 100644
--- a/frontend/src/store/index.js
+++ b/frontend/src/store/index.js
@@ -97,6 +97,14 @@ const store = createStore({
console.log(error)
}
},
+ async toggleTooGoodToGoNotifications({ commit, getters }) {
+ commit('TOGGLE_TOO_GOOD_TO_GO_NOTIFICATIONS')
+ try {
+ const response = await api.patch('user', getters.user)
+ } catch (error) {
+ console.log(error)
+ }
+ },
setRangeFilter({ commit }, range) {
commit('SET_RANGE_FILTER', range)
},
@@ -129,6 +137,9 @@ const store = createStore({
TOGGLE_RECEIVED_NOTIFICATIONS(state) {
state.user.notifications.received = !state.user.notifications.received
},
+ TOGGLE_TOO_GOOD_TO_GO_NOTIFICATIONS(state) {
+ state.user.notifications.too_good_to_go = !state.user.notifications.too_good_to_go
+ },
SET_RANGE_FILTER(state, range) {
state.filter.range = range
localStorage.setItem('filter.range', state.filter.range)
diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue
index 5778c0e5..52161083 100644
--- a/frontend/src/views/Profile.vue
+++ b/frontend/src/views/Profile.vue
@@ -6,8 +6,18 @@
+
+
Your Account Info
+
+ Time zone is set to {{ user.slack_time_zone }} .
+
+
Your Potato Stats
+
+ You have {{ user.potato_left_today }} 🥔 left to gib today.
+ Your potato do reset in {{ user.potato_reset_in_hours }} hours and {{ user.potato_reset_in_minutes }} minutes.
+
You did gib {{ user.sent_count ?? 0 }} 🥔 and did receive {{ user.received_count ?? 0 }} 🥔 since you started potatoing
{{ new Date(user.created).toLocaleDateString('en-us', { year:"numeric", month:"short", day:"numeric"}) }}.
diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue
index 66c31e31..37358f87 100644
--- a/frontend/src/views/Settings.vue
+++ b/frontend/src/views/Settings.vue
@@ -47,6 +47,27 @@
+
+
+
+ Too good to go 🌱
+
+
+ Receive a notification at the end of your work day if you have any leftover potato.
+
+
+
+
+
+
+
@@ -72,6 +93,9 @@ export default {
toggleReceivedNotifications() {
this.$store.dispatch('toggleReceivedNotifications')
},
+ toggleTooGoodToGoNotifications() {
+ this.$store.dispatch('toggleTooGoodToGoNotifications')
+ },
},
}
diff --git a/package-lock.json b/package-lock.json
index 186a520a..cc438f25 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,26 +6,26 @@
"": {
"name": "gib-potato",
"dependencies": {
- "@sentry/vite-plugin": "^2.18.0",
- "@sentry/vue": "^8.8.0",
+ "@sentry/vite-plugin": "^2.20.1",
+ "@sentry/vue": "^8.15.0",
"axios": "^1.7.2",
- "vue": "^3.4.27",
- "vue-router": "^4.3.3",
+ "vue": "^3.4.31",
+ "vue-router": "^4.4.0",
"vue-select": "^4.0.0-beta.6",
"vuex": "^4.1.0"
},
"devDependencies": {
- "@codecov/vite-plugin": "^0.0.1-beta.8",
+ "@codecov/vite-plugin": "^0.0.1-beta.10",
"@rushstack/eslint-patch": "^1.10.3",
"@vitejs/plugin-vue": "^5.0.5",
"@vue/eslint-config-prettier": "^9.0.0",
"autoprefixer": "^10.4.19",
- "eslint": "^9.4.0",
- "eslint-plugin-vue": "^9.26.0",
- "postcss": "^8.4.38",
+ "eslint": "^9.6.0",
+ "eslint-plugin-vue": "^9.27.0",
+ "postcss": "^8.4.39",
"prettier": "^3.3.2",
"tailwindcss": "^3.4.4",
- "vite": "^5.2.13"
+ "vite": "^5.3.3"
}
},
"node_modules/@alloc/quick-lru": {
diff --git a/src/Command/TooGoodToGoCommand.php b/src/Command/TooGoodToGoCommand.php
new file mode 100644
index 00000000..ae8ef2f2
--- /dev/null
+++ b/src/Command/TooGoodToGoCommand.php
@@ -0,0 +1,161 @@
+ $this->_execute($args, $io),
+ monitorConfig: new MonitorConfig(
+ schedule: new MonitorSchedule(
+ type: MonitorSchedule::TYPE_CRONTAB,
+ value: '*/30 * * * 1-5',
+ ),
+ checkinMargin: 5,
+ maxRuntime: 10,
+ timezone: 'UTC',
+ ),
+ );
+ }
+
+ /**
+ * @param \Cake\Console\Arguments $args The command arguments.
+ * @param \Cake\Console\ConsoleIo $io The console io
+ * @return int|null|void The exit code or null for success
+ */
+ protected function _execute(Arguments $args, ConsoleIo $io)
+ {
+ $io->out('Sending out Too Good To Go notifications');
+
+ $slackClient = new SlackClient();
+
+ $logger = new SentryQueryLogger();
+
+ $connection = ConnectionManager::get('default');
+ $connection->getDriver()->setLogger($logger);
+
+ $transactionContext = TransactionContext::make()
+ ->setOp('command')
+ ->setName('COMMAND too_good_to_go')
+ ->setSource(TransactionSource::task());
+
+ $transaction = startTransaction($transactionContext);
+
+ SentrySdk::getCurrentHub()->setSpan($transaction);
+
+ $usersTable = $this->fetchTable('Users');
+ $users = $usersTable->find()
+ ->where([
+ 'slack_time_zone IN' => $this->_getApplicableTimeZones(),
+ ])
+ ->all();
+
+ foreach ($users as $user) {
+ if (
+ $user->notifications['too_good_to_go'] !== true
+ || $user->potatoLeftToday() <= 0
+ ) {
+ continue;
+ }
+
+ $spanContext = SpanContext::make()
+ ->setOp('command')
+ ->setDescription('Send notification');
+ $span = $transaction->startChild($spanContext);
+
+ SentrySdk::getCurrentHub()->setSpan($span);
+
+ try {
+ $message = 'Hallo, just letting you know that you have *' . $user->potatoLeftToday()
+ . '* 🥔 left to gib today 🌱' . PHP_EOL;
+ $message .= 'Would be a bummer if they go to waste 😢' . PHP_EOL;
+ $message .= 'If someone did something nice today, gib them 🥔😊!';
+
+ $slackClient->postMessage(
+ channel: $user->slack_user_id,
+ text: $message,
+ );
+
+ $span->setStatus(SpanStatus::ok());
+ } catch (Throwable $e) {
+ captureException($e);
+ $span->setStatus(SpanStatus::internalError());
+ } finally {
+ $span->finish();
+ }
+ }
+ SentrySdk::getCurrentHub()->setSpan($transaction);
+
+ $transaction->setStatus(SpanStatus::ok())
+ ->finish();
+
+ $io->success("\n[DONE]");
+ }
+
+ /**
+ * @return array
+ */
+ protected function _getApplicableTimeZones(): array
+ {
+ $timeZones = DateTimeZone::listIdentifiers();
+ $applicableTimeZones = [];
+
+ foreach ($timeZones as $timezone) {
+ $localNow = new Chronos(timezone: $timezone);
+ if ($localNow->hour === self::TARGET_HOUR) {
+ $applicableTimeZones[] = $timezone;
+ }
+ }
+
+ return $applicableTimeZones;
+ }
+}
diff --git a/src/Command/UpdateUsersCommand.php b/src/Command/UpdateUsersCommand.php
index 3c949540..c1e8a2b8 100644
--- a/src/Command/UpdateUsersCommand.php
+++ b/src/Command/UpdateUsersCommand.php
@@ -117,6 +117,7 @@ protected function _execute(Arguments $args, ConsoleIo $io)
'slack_user_id' => $slackUser['id'],
'slack_name' => $slackUser['real_name'],
'slack_picture' => $slackUser['profile']['image_72'],
+ 'slack_time_zone' => $slackUser['tz'],
'slack_is_bot' => $slackUser['is_bot'] ?? false,
], [
'accessibleFields' => [
@@ -124,6 +125,7 @@ protected function _execute(Arguments $args, ConsoleIo $io)
'slack_user_id' => true,
'slack_name' => true,
'slack_picture' => true,
+ 'slack_time_zone' => true,
'slack_is_bot' => true,
],
]);
diff --git a/src/Controller/Api/UsersController.php b/src/Controller/Api/UsersController.php
index f47d693e..59d54589 100644
--- a/src/Controller/Api/UsersController.php
+++ b/src/Controller/Api/UsersController.php
@@ -70,6 +70,10 @@ public function get(): Response
/** @var \App\Model\Entity\User $user */
$user->spendable_count = $user->spendablePotato();
+ $user->potato_sent_today = $user->potatoSentToday();
+ $user->potato_left_today = $user->potatoLeftToday();
+ $user->potato_reset_in_hours = $user->potatoResetInHours();
+ $user->potato_reset_in_minutes = $user->potatoResetInMinutes();
return $this->response
->withStatus(200)
@@ -93,6 +97,7 @@ public function edit(): Response
'notifications' => [
'sent' => (bool)$this->request->getData('notifications.sent'),
'received' => (bool)$this->request->getData('notifications.received'),
+ 'too_good_to_go' => (bool)$this->request->getData('notifications.too_good_to_go'),
],
], [
'accessibleFields' => [
diff --git a/src/Model/Entity/User.php b/src/Model/Entity/User.php
index 5d0f520d..f74db4b5 100644
--- a/src/Model/Entity/User.php
+++ b/src/Model/Entity/User.php
@@ -17,6 +17,7 @@
* @property string $slack_user_id
* @property string $slack_name
* @property string $slack_picture
+ * @property string $slack_time_zone
* @property bool $slack_is_bot
* @property array|null $notifications
* @property \Cake\I18n\DateTime|null $created
@@ -57,12 +58,22 @@ protected function _getNotifications(?array $notifications = []): array
return [
'sent' => true,
'received' => true,
+ 'too_good_to_go' => false,
];
}
return $notifications;
}
+ /**
+ * @param string|null $slackTimeZone
+ * @return string
+ */
+ protected function _getSlackTimeZone(?string $slackTimeZone): string
+ {
+ return $slackTimeZone ?? 'UTC';
+ }
+
/**
* @return int
*/
@@ -120,7 +131,7 @@ public function potatoSentToday(): int
->where([
'sender_user_id' => $this->id,
'type' => Message::TYPE_POTATO,
- 'DATE(created)' => $query->func()->now('date'),
+ 'created >=' => $this->getStartOfDay(),
])
->first();
@@ -142,7 +153,7 @@ public function potatoReceivedToday(): int
->where([
'receiver_user_id' => $this->id,
'type' => Message::TYPE_POTATO,
- 'DATE(created)' => $query->func()->now('date'),
+ 'created >=' => $this->getStartOfDay(),
])
->first();
@@ -164,7 +175,7 @@ public function potatoLeftToday(): int
->where([
'sender_user_id' => $this->id,
'type' => Message::TYPE_POTATO,
- 'DATE(created)' => $query->func()->now('date'),
+ 'created >=' => $this->getStartOfDay(),
])
->first();
@@ -176,10 +187,10 @@ public function potatoLeftToday(): int
*/
public function potatoResetInHours(): string
{
- $time = new DateTime();
- $hours = 23 - (int)$time->i18nFormat('HH');
+ $userTime = DateTime::now($this->slack_time_zone);
+ $userEndOfDay = $userTime->endOfDay();
- return (string)$hours;
+ return (string)$userTime->diff($userEndOfDay)->h;
}
/**
@@ -187,10 +198,10 @@ public function potatoResetInHours(): string
*/
public function potatoResetInMinutes(): string
{
- $time = new DateTime();
- $minutes = 59 - (int)$time->i18nFormat('mm');
+ $userTime = DateTime::now($this->slack_time_zone);
+ $userEndOfDay = $userTime->endOfDay();
- return (string)$minutes;
+ return (string)$userTime->diff($userEndOfDay)->i;
}
/**
@@ -212,4 +223,34 @@ public function spendablePotato(): int
return $this->potatoReceived() - (int)$result->spent;
}
+
+ /**
+ * @return \Cake\I18n\DateTime
+ */
+ public function getStartOfDay(): DateTime
+ {
+ $userTime = DateTime::now($this->slack_time_zone);
+ $utcTime = DateTime::now('UTC');
+
+ $utcOffset = $userTime->getOffset($utcTime) / 60 / 60;
+
+ $startOfDayUser = $userTime->startOfDay()->subHours($utcOffset);
+
+ return $startOfDayUser;
+ }
+
+ /**
+ * @return \Cake\I18n\DateTime
+ */
+ public function getEndOfDay(): DateTime
+ {
+ $userTime = DateTime::now($this->slack_time_zone);
+ $utcTime = DateTime::now('UTC');
+
+ $utcOffset = $userTime->getOffset($utcTime) / 60 / 60;
+
+ $endOfDayUser = $userTime->endOfDay()->subHours($utcOffset);
+
+ return $endOfDayUser;
+ }
}
diff --git a/tests/Fixture/UsersFixture.php b/tests/Fixture/UsersFixture.php
index f754097a..abe963d3 100644
--- a/tests/Fixture/UsersFixture.php
+++ b/tests/Fixture/UsersFixture.php
@@ -26,6 +26,7 @@ public function init(): void
'slack_name' => 'User U1111',
'slack_picture' => 'https://example.com/U1111.jpg',
'slack_is_bot' => 0,
+ 'slack_time_zone' => 'Europe/Amsterdam',
'created' => '2023-01-01 00:00:00',
'modified' => '2023-01-01 00:00:00',
],
@@ -37,6 +38,19 @@ public function init(): void
'slack_name' => 'User U2222',
'slack_picture' => 'https://example.com/U2222.jpg',
'slack_is_bot' => 0,
+ 'slack_time_zone' => 'America/Toronto',
+ 'created' => '2023-01-01 00:00:00',
+ 'modified' => '2023-01-01 00:00:00',
+ ],
+ [
+ 'id' => '00000000-0000-0000-0000-000000000003',
+ 'status' => 'active',
+ 'role' => 'user',
+ 'slack_user_id' => 'U3333',
+ 'slack_name' => 'User U3333',
+ 'slack_picture' => 'https://example.com/U3333.jpg',
+ 'slack_is_bot' => 0,
+ 'slack_time_zone' => 'America/Los_Angeles',
'created' => '2023-01-01 00:00:00',
'modified' => '2023-01-01 00:00:00',
],
diff --git a/tests/TestCase/Controller/Component/.gitkeep b/tests/TestCase/Controller/Component/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/TestCase/Model/Behavior/.gitkeep b/tests/TestCase/Model/Behavior/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/TestCase/Model/Entity/UserTest.php b/tests/TestCase/Model/Entity/UserTest.php
new file mode 100644
index 00000000..a3d68683
--- /dev/null
+++ b/tests/TestCase/Model/Entity/UserTest.php
@@ -0,0 +1,124 @@
+
+ */
+ protected array $fixtures = [
+ 'app.Users',
+ ];
+
+ /**
+ * @var \App\Model\Entity\User
+ */
+ protected $UserEurope;
+
+ /**
+ * @var \App\Model\Entity\User
+ */
+ protected $UserCanada;
+
+ /**
+ * @var \App\Model\Entity\User
+ */
+ protected $UserUS;
+
+ /**
+ * setUp method
+ *
+ * @return void
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $usersTable = $this->fetchTable('Users');
+
+ $this->UserEurope = $usersTable->get('00000000-0000-0000-0000-000000000001');
+ $this->UserCanada = $usersTable->get('00000000-0000-0000-0000-000000000002');
+ $this->UserUS = $usersTable->get('00000000-0000-0000-0000-000000000003');
+ }
+
+ /**
+ * tearDown method
+ *
+ * @return void
+ */
+ protected function tearDown(): void
+ {
+ unset($this->UserEurope);
+ unset($this->UserCanada);
+ unset($this->UserUS);
+
+ Chronos::setTestNow();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test getStartOfDay method
+ *
+ * @return void
+ * @uses \App\Model\Entity\User::getStartOfDay()
+ */
+ public function testGetStartOfDay(): void
+ {
+ Chronos::setTestNow(new Chronos('2024-07-17 12:00:00', 'UTC'));
+
+ $startOfDay = $this->UserEurope->getStartOfDay();
+ $this->assertSame('2024-07-16 22:00:00', $startOfDay->toDateTimeString());
+
+ $startOfDay = $this->UserCanada->getStartOfDay();
+ $this->assertSame('2024-07-17 04:00:00', $startOfDay->toDateTimeString());
+
+ $startOfDay = $this->UserUS->getStartOfDay();
+ $this->assertSame('2024-07-17 07:00:00', $startOfDay->toDateTimeString());
+ }
+
+ /**
+ * Test getEndOfDay method
+ *
+ * @return void
+ * @uses \App\Model\Entity\User::getEndOfDay()
+ */
+ public function testGetEndOfDay(): void
+ {
+ Chronos::setTestNow(new Chronos('2024-07-17 12:00:00', 'UTC'));
+
+ $endOfDay = $this->UserEurope->getEndOfDay();
+ $this->assertSame('2024-07-17 21:59:59', $endOfDay->toDateTimeString());
+
+ $endOfDay = $this->UserCanada->getEndOfDay();
+ $this->assertSame('2024-07-18 03:59:59', $endOfDay->toDateTimeString());
+
+ $endOfDay = $this->UserUS->getEndOfDay();
+ $this->assertSame('2024-07-18 06:59:59', $endOfDay->toDateTimeString());
+ }
+
+ /**
+ * Test potatoResetInHours method
+ *
+ * @return void
+ * @uses \App\Model\Entity\User::potatoResetInHours()
+ */
+ public function testPotatoResetInHours(): void
+ {
+ Chronos::setTestNow(new Chronos('2024-07-17 12:00:00', 'UTC'));
+
+ $this->assertSame('9', $this->UserEurope->potatoResetInHours());
+ $this->assertSame('15', $this->UserCanada->potatoResetInHours());
+ $this->assertSame('18', $this->UserUS->potatoResetInHours());
+ }
+}
diff --git a/tests/TestCase/Model/Table/.gitkeep b/tests/TestCase/Model/Table/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/TestCase/Table/.gitkeep b/tests/TestCase/Table/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/TestCase/View/Helper/.gitkeep b/tests/TestCase/View/Helper/.gitkeep
deleted file mode 100644
index e69de29b..00000000