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