├── .editorconfig ├── .env.ci ├── .env.docker.example ├── .env.example ├── .eslintrc.js ├── .gitattributes ├── .gitignore ├── .styleci.yml ├── Dockerfile ├── app ├── Console │ └── Kernel.php ├── Exceptions │ └── Handler.php ├── Extensions │ └── ExtendedValidator.php ├── Http │ ├── Controllers │ │ ├── AuthController.php │ │ ├── Controller.php │ │ ├── DashboardController.php │ │ ├── EntryController.php │ │ ├── ReportController.php │ │ └── UserController.php │ ├── Kernel.php │ └── Middleware │ │ ├── Authenticate.php │ │ ├── EncryptCookies.php │ │ ├── PreventRequestsDuringMaintenance.php │ │ ├── RedirectIfAuthenticated.php │ │ ├── TrimStrings.php │ │ ├── TrustHosts.php │ │ ├── TrustProxies.php │ │ └── VerifyCsrfToken.php ├── Models │ ├── Entry.php │ ├── Model.php │ └── User.php ├── Policies │ ├── EntryPolicy.php │ └── UserPolicy.php ├── Providers │ ├── AppServiceProvider.php │ ├── AuthServiceProvider.php │ ├── BroadcastServiceProvider.php │ ├── EventServiceProvider.php │ └── RouteServiceProvider.php └── helpers.php ├── artisan ├── bootstrap ├── app.php └── cache │ └── .gitignore ├── composer.json ├── composer.lock ├── config ├── app.php ├── auth.php ├── broadcasting.php ├── cache.php ├── cors.php ├── database.php ├── filesystems.php ├── hashing.php ├── logging.php ├── mail.php ├── queue.php ├── sanctum.php ├── services.php ├── session.php └── view.php ├── cypress.json ├── cypress ├── fixtures │ ├── user_admin.json │ ├── user_manager.json │ ├── user_new.json │ ├── user_update.json │ └── user_user.json ├── integration │ ├── auth.js │ ├── dashboard.js │ ├── entries.js │ ├── profile.js │ └── reports.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── database ├── .gitignore ├── factories │ ├── EntryFactory.php │ └── UserFactory.php ├── migrations │ ├── 2014_10_12_000000_create_users_table.php │ ├── 2014_10_12_100000_create_password_resets_table.php │ ├── 2016_12_13_204920_create_entries_table.php │ └── 2019_12_14_000001_create_personal_access_tokens_table.php └── seeders │ └── DatabaseSeeder.php ├── docker-compose.yml ├── docker ├── default.conf ├── php.ini └── supervisord.conf ├── lang ├── en.json └── en │ ├── auth.php │ ├── pagination.php │ ├── passwords.php │ └── validation.php ├── package.json ├── phpunit.xml ├── public ├── .htaccess ├── favicon.ico ├── fonts │ └── vendor │ │ └── bootstrap-sass │ │ └── bootstrap │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.svg │ │ ├── glyphicons-halflings-regular.ttf │ │ ├── glyphicons-halflings-regular.woff │ │ └── glyphicons-halflings-regular.woff2 ├── index.php ├── robots.txt └── vendor │ └── telescope │ ├── app-dark.css │ ├── app.css │ ├── app.js │ ├── favicon.ico │ └── mix-manifest.json ├── readme.md ├── resources ├── assets │ ├── js │ │ ├── app.js │ │ ├── components │ │ │ ├── App.vue │ │ │ ├── layout │ │ │ │ ├── Navbar.vue │ │ │ │ ├── Spinner.vue │ │ │ │ └── Toast.vue │ │ │ └── pages │ │ │ │ ├── 404.vue │ │ │ │ ├── DeleteAccount.vue │ │ │ │ ├── Policy.vue │ │ │ │ ├── admin │ │ │ │ ├── Admin.vue │ │ │ │ ├── dashboard │ │ │ │ │ └── Dashboard.vue │ │ │ │ ├── entry │ │ │ │ │ ├── Edit.vue │ │ │ │ │ ├── List.vue │ │ │ │ │ └── partials │ │ │ │ │ │ └── Row.vue │ │ │ │ └── user │ │ │ │ │ ├── Edit.vue │ │ │ │ │ ├── List.vue │ │ │ │ │ ├── Show.vue │ │ │ │ │ └── partials │ │ │ │ │ ├── Form.vue │ │ │ │ │ └── Row.vue │ │ │ │ ├── auth │ │ │ │ ├── Login.vue │ │ │ │ ├── Logout.vue │ │ │ │ ├── Profile.vue │ │ │ │ └── Register.vue │ │ │ │ ├── dashboard │ │ │ │ └── Dashboard.vue │ │ │ │ ├── entry │ │ │ │ ├── Edit.vue │ │ │ │ ├── List.vue │ │ │ │ ├── New.vue │ │ │ │ └── partials │ │ │ │ │ ├── Form.vue │ │ │ │ │ └── Row.vue │ │ │ │ ├── front │ │ │ │ └── Front.vue │ │ │ │ └── report │ │ │ │ └── Weekly.vue │ │ ├── config.js │ │ ├── mixins.js │ │ ├── router.js │ │ └── vuex │ │ │ ├── helpers.js │ │ │ ├── modules │ │ │ ├── all-entries.js │ │ │ ├── auth.js │ │ │ ├── entries.js │ │ │ ├── general.js │ │ │ ├── reports.js │ │ │ ├── toast.js │ │ │ └── users.js │ │ │ └── store.js │ └── sass │ │ ├── _variables.scss │ │ └── app.scss └── views │ └── app.blade.php ├── routes ├── api.php ├── channels.php ├── console.php └── web.php ├── storage ├── app │ ├── .gitignore │ └── public │ │ └── .gitignore ├── framework │ ├── .gitignore │ ├── cache │ │ ├── .gitignore │ │ └── data │ │ │ └── .gitignore │ ├── sessions │ │ └── .gitignore │ ├── testing │ │ └── .gitignore │ └── views │ │ └── .gitignore └── logs │ └── .gitignore ├── tests ├── CreatesApplication.php ├── Feature │ ├── AuthTest.php │ ├── DashboardTest.php │ ├── EntryTest.php │ ├── ReportTest.php │ └── UserTest.php └── TestCase.php ├── webpack.mix.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml,js,json,vue}] 15 | indent_size = 2 16 | 17 | -------------------------------------------------------------------------------- /.env.ci: -------------------------------------------------------------------------------- 1 | APP_ENV=testing 2 | APP_KEY= 3 | APP_DEBUG=true 4 | APP_LOG_LEVEL=debug 5 | APP_URL=http://localhost 6 | APP_DEMO=false 7 | 8 | DB_CONNECTION=sqlite 9 | #DB_HOST=127.0.0.1 10 | #DB_PORT=3306 11 | #DB_DATABASE=homestead 12 | #DB_USERNAME=homestead 13 | #DB_PASSWORD=secret 14 | 15 | BROADCAST_DRIVER=log 16 | CACHE_DRIVER=array 17 | SESSION_DRIVER=array 18 | QUEUE_DRIVER=sync 19 | 20 | REDIS_HOST=127.0.0.1 21 | REDIS_PASSWORD=null 22 | REDIS_PORT=6379 23 | 24 | MAIL_DRIVER=smtp 25 | MAIL_HOST=mailtrap.io 26 | MAIL_PORT=2525 27 | MAIL_USERNAME=null 28 | MAIL_PASSWORD=null 29 | MAIL_ENCRYPTION=null 30 | 31 | PUSHER_APP_ID= 32 | PUSHER_KEY= 33 | PUSHER_SECRET= 34 | 35 | GOOGLE_CLIENT_ID= 36 | GOOGLE_CLIENT_SECRET= 37 | -------------------------------------------------------------------------------- /.env.docker.example: -------------------------------------------------------------------------------- 1 | APP_ENV=local 2 | APP_KEY= 3 | APP_DEBUG=true 4 | APP_LOG_LEVEL=debug 5 | APP_URL=http://localhost 6 | APP_DEMO=false 7 | 8 | DB_CONNECTION=mysql 9 | DB_HOST=mariadb 10 | DB_PORT=3306 11 | DB_DATABASE=running_time 12 | DB_USERNAME=running_time 13 | DB_PASSWORD=secret 14 | 15 | BROADCAST_DRIVER=log 16 | CACHE_DRIVER=redis 17 | SESSION_DRIVER=file 18 | QUEUE_DRIVER=redis 19 | 20 | REDIS_HOST=redis 21 | REDIS_PASSWORD=null 22 | REDIS_PORT=6379 23 | 24 | MAIL_DRIVER=smtp 25 | MAIL_HOST=mmailcatcher 26 | MAIL_PORT=1025 27 | MAIL_USERNAME=null 28 | MAIL_PASSWORD=null 29 | MAIL_ENCRYPTION=null 30 | 31 | PUSHER_APP_ID= 32 | PUSHER_KEY= 33 | PUSHER_SECRET= 34 | 35 | GOOGLE_CLIENT_ID= 36 | GOOGLE_CLIENT_SECRET= 37 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME="Running Time" 2 | APP_ENV=local 3 | APP_KEY= 4 | APP_DEBUG=true 5 | APP_URL=http://localhost 6 | 7 | LOG_CHANNEL=stack 8 | LOG_DEPRECATIONS_CHANNEL=null 9 | LOG_LEVEL=debug 10 | 11 | DB_CONNECTION=sqlite 12 | #DB_HOST=127.0.0.1 13 | #DB_PORT=3306 14 | #DB_DATABASE=example_app 15 | #DB_USERNAME=root 16 | #DB_PASSWORD= 17 | 18 | BROADCAST_DRIVER=log 19 | CACHE_DRIVER=file 20 | FILESYSTEM_DISK=local 21 | QUEUE_CONNECTION=sync 22 | SESSION_DRIVER=file 23 | SESSION_LIFETIME=120 24 | 25 | MEMCACHED_HOST=127.0.0.1 26 | 27 | REDIS_HOST=127.0.0.1 28 | REDIS_PASSWORD=null 29 | REDIS_PORT=6379 30 | 31 | MAIL_MAILER=smtp 32 | MAIL_HOST=mailhog 33 | MAIL_PORT=1025 34 | MAIL_USERNAME=null 35 | MAIL_PASSWORD=null 36 | MAIL_ENCRYPTION=null 37 | MAIL_FROM_ADDRESS=null 38 | MAIL_FROM_NAME="${APP_NAME}" 39 | 40 | AWS_ACCESS_KEY_ID= 41 | AWS_SECRET_ACCESS_KEY= 42 | AWS_DEFAULT_REGION=us-east-1 43 | AWS_BUCKET= 44 | AWS_USE_PATH_STYLE_ENDPOINT=false 45 | 46 | PUSHER_APP_ID= 47 | PUSHER_APP_KEY= 48 | PUSHER_APP_SECRET= 49 | PUSHER_APP_CLUSTER=mt1 50 | 51 | MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" 52 | MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" 53 | 54 | GOOGLE_CLIENT_ID= 55 | GOOGLE_CLIENT_SECRET= 56 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // http://eslint.org/docs/user-guide/configuring 2 | 3 | module.exports = { 4 | root: true, 5 | parserOptions: { 6 | parser: 'babel-eslint', 7 | sourceType: 'module', 8 | ecmaVersion: 2017, 9 | }, 10 | env: { 11 | browser: true, 12 | }, 13 | extends: [ 14 | 'plugin:vue/recommended', 15 | ], 16 | // add your custom rules here 17 | rules: { 18 | // allow paren-less arrow functions 19 | 'arrow-parens': 0, 20 | // allow async-await 21 | 'generator-star-spacing': 0, 22 | // allow debugger during development 23 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, 24 | 25 | 'comma-dangle': 0, 26 | 27 | 'no-multiple-empty-lines': 0, 28 | 29 | 'space-unary-ops': [2, { 30 | 'words': true, 31 | 'nonwords': true, 32 | 'overrides': { 33 | '++': false, 34 | '--': false, 35 | }, 36 | }], 37 | 38 | 'vue/singleline-html-element-content-newline': 0, 39 | 'vue/multiline-html-element-content-newline': 0, 40 | 'vue/max-attributes-per-line': ['error', { 41 | 'singleline': 6, 42 | 'multiline': 1, 43 | }], 44 | 'vue/multi-word-component-names': 0, 45 | 'vue/no-mutating-props': ['warn'], 46 | }, 47 | 48 | globals: { 49 | '$': false, 50 | 'jQuery': false, 51 | 'Together': false, 52 | 'bootbox': false, 53 | 'Stripe': false, 54 | 'Laravel': false, 55 | }, 56 | } 57 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.blade.php diff=html 4 | *.css diff=css 5 | *.html diff=html 6 | *.md diff=markdown 7 | *.php diff=php 8 | 9 | /.github export-ignore 10 | CHANGELOG.md export-ignore 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /public/hot 3 | /public/storage 4 | /storage/*.key 5 | /vendor 6 | .env 7 | .env.backup 8 | .phpunit.result.cache 9 | docker-compose.override.yml 10 | Homestead.json 11 | Homestead.yaml 12 | npm-debug.log 13 | yarn-error.log 14 | /.idea 15 | /.vscode 16 | /cypress/videos 17 | /cypress/screenshots 18 | /public/css 19 | /public/js 20 | /public/mix-manifest.json 21 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | php: 2 | preset: laravel 3 | version: 8 4 | disabled: 5 | - no_unused_imports 6 | finder: 7 | not-name: 8 | - index.php 9 | js: 10 | finder: 11 | not-name: 12 | - webpack.mix.js 13 | css: true 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.2-fpm 2 | 3 | # Install supervisor 4 | RUN apt-get update && apt-get install -y git supervisor unzip 5 | 6 | # Install Composer 7 | RUN curl -sS https://getcomposer.org/installer | php -- \ 8 | --install-dir=/usr/local/bin \ 9 | --filename=composer 10 | 11 | # Install Node 12 | RUN curl -sL https://deb.nodesource.com/setup_18.x | bash - \ 13 | && apt-get install -y nodejs 14 | 15 | # Install Yarn 16 | RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ 17 | && echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list \ 18 | && apt-get update && apt-get install -y yarn 19 | 20 | # Clean repository 21 | RUN apt-get clean && rm -rf /var/lib/apt/lists/* 22 | 23 | CMD mkdir -p /app 24 | 25 | WORKDIR /app 26 | 27 | CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisord.conf"] 28 | -------------------------------------------------------------------------------- /app/Console/Kernel.php: -------------------------------------------------------------------------------- 1 | command('inspire')->hourly(); 19 | } 20 | 21 | /** 22 | * Register the commands for the application. 23 | * 24 | * @return void 25 | */ 26 | protected function commands() 27 | { 28 | $this->load(__DIR__.'/Commands'); 29 | 30 | require base_path('routes/console.php'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/Exceptions/Handler.php: -------------------------------------------------------------------------------- 1 | > 14 | */ 15 | protected $dontReport = [ 16 | // 17 | ]; 18 | 19 | /** 20 | * A list of the inputs that are never flashed for validation exceptions. 21 | * 22 | * @var array 23 | */ 24 | protected $dontFlash = [ 25 | 'current_password', 26 | 'password', 27 | 'password_confirmation', 28 | ]; 29 | 30 | /** 31 | * Register the exception handling callbacks for the application. 32 | * 33 | * @return void 34 | */ 35 | public function register() 36 | { 37 | $this->reportable(function (Throwable $e) { 38 | // 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/Extensions/ExtendedValidator.php: -------------------------------------------------------------------------------- 1 | 0; 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /app/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | user(); 31 | 32 | $week_chart = DB::table('entries') 33 | ->select(DB::raw('avg(speed) as avg_speed, avg(distance) as avg_distance, date')) 34 | ->where('user_id', $me->id) 35 | ->where('date', '>=', Carbon::now()->subWeeks(2)->toDateString()) 36 | ->groupBy('date') 37 | ->get()->map(function ($item) { 38 | return [Carbon::parse($item->date)->format('m/d'), round($item->avg_speed, 2), round($item->avg_distance, 2)]; 39 | }); 40 | 41 | return [ 42 | 'weekly_count' => $me->entries()->where('date', '>=', Carbon::now()->startOfWeek()->toDateString())->count(), 43 | 'weekly_avg_speed' => $me->entries()->where('date', '>=', Carbon::now()->startOfWeek()->toDateString())->avg('speed'), 44 | 'weekly_avg_pace' => $me->entries()->where('date', '>=', Carbon::now()->startOfWeek()->toDateString())->avg('pace'), 45 | 'week_chart' => $week_chart, 46 | 'max_speed' => $me->entries()->max('speed'), 47 | 'max_distance' => $me->entries()->max('distance'), 48 | 'max_time' => $me->entries()->max('time'), 49 | ]; 50 | } 51 | 52 | /** 53 | * Admin dashboard 54 | * 55 | * Get admin dashboard data 56 | * 57 | * @param Request $request 58 | * @return mixed 59 | */ 60 | public function adminData(Request $request) 61 | { 62 | /** @var User $me */ 63 | $me = auth()->user(); 64 | 65 | if ( ! $me->isAdmin() && ! $me->isManager()) abort(401); 66 | 67 | $usersCount = User::count(); 68 | $entriesCount = Entry::count(); 69 | 70 | return [ 71 | 'total_users' => $usersCount, 72 | 'new_users_this_week' => User::where('created_at', '>=', Carbon::now()->startOfWeek()->toDateTimeString())->count(), 73 | 'new_users_this_month' => User::where('created_at', '>=', Carbon::now()->startOfMonth()->toDateTimeString())->count(), 74 | 'total_entries' => $entriesCount, 75 | 'avg_entries_per_user' => round($entriesCount / $usersCount), 76 | 'fastest_run' => Entry::with('user')->whereRaw('speed = (select max(`speed`) from entries)')->first(), 77 | 'longest_run' => Entry::with('user')->whereRaw('distance = (select max(`distance`) from entries)')->first(), 78 | ]; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/Http/Controllers/EntryController.php: -------------------------------------------------------------------------------- 1 | user(); 30 | 31 | $entries = $me->entries() 32 | ->orderBy('date', 'desc') 33 | ->orderBy('id', 'desc') 34 | ->filter($request->only('dateFrom', 'dateTo')); 35 | 36 | return ['entries' => $entries->paginate()]; 37 | } 38 | 39 | /** 40 | * All ennties list 41 | * 42 | * Display a listing of all users time entries (admin access only). 43 | * 44 | * @param Request $request 45 | * @return array 46 | */ 47 | public function all(Request $request) 48 | { 49 | $this->authorize('all', Entry::class); 50 | 51 | $entries = (new Entry)->with('user') 52 | ->orderBy('date', 'desc') 53 | ->orderBy('distance', 'desc') 54 | ->filter($request->only('dateFrom', 'dateTo')); 55 | 56 | return ['entries' => $entries->paginate()]; 57 | } 58 | 59 | /** 60 | * Store Entry 61 | * 62 | * Store a newly created time entry in storage. 63 | * 64 | * @param Request $request 65 | * @return array 66 | */ 67 | public function store(Request $request) 68 | { 69 | $this->validate($request, [ 70 | 'date' => 'required|date|before:tomorrow', 71 | 'distance' => 'required|numeric|min:0.01', 72 | 'time' => 'required|date_format:H:i:s|time_required', 73 | ]); 74 | 75 | $entry = new Entry($request->only('distance', 'time', 'locations')); 76 | $entry->user_id = auth()->id(); 77 | $entry->date = Carbon::parse($request->get('date')); 78 | $entry->speed = $entry->distance / ($entry->seconds() / 3600); 79 | $entry->pace = ($entry->seconds() / 60) / $entry->distance; 80 | $entry->save(); 81 | 82 | return ['entry' => $entry]; 83 | } 84 | 85 | /** 86 | * Show entry 87 | * 88 | * Display the specified time entry. 89 | * 90 | * @param Entry $entry 91 | * @return array 92 | */ 93 | public function show(Entry $entry) 94 | { 95 | $this->authorize($entry); 96 | 97 | return ['entry' => $entry]; 98 | } 99 | 100 | /** 101 | * Update entry 102 | * 103 | * Update time entry in storage. 104 | * 105 | * @param Request $request 106 | * @param Entry $entry 107 | * @return array 108 | */ 109 | public function update(Request $request, Entry $entry) 110 | { 111 | $this->authorize($entry); 112 | 113 | $this->validate($request, [ 114 | 'date' => 'required|date|before:tomorrow', 115 | 'distance' => 'required|numeric|min:0.01', 116 | 'time' => 'required|date_format:H:i:s|time_required', 117 | ]); 118 | 119 | $entry->fill($request->only('distance', 'time')); 120 | $entry->date = Carbon::parse($request->get('date')); 121 | $entry->speed = $entry->distance / ($entry->seconds() / 3600); 122 | $entry->pace = ($entry->seconds() / 60) / $entry->distance; 123 | $entry->save(); 124 | 125 | return ['entry' => $entry]; 126 | } 127 | 128 | /** 129 | * Delete entry 130 | * 131 | * Remove time entry from storage. 132 | * 133 | * @param Entry $entry 134 | * @return array 135 | * @internal param int $id 136 | */ 137 | public function destroy(Entry $entry) 138 | { 139 | $this->authorize($entry); 140 | 141 | $entry->delete(); 142 | 143 | return ['message' => 'Success']; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /app/Http/Controllers/ReportController.php: -------------------------------------------------------------------------------- 1 | user(); 30 | 31 | $sqlite = DB::connection()->getDriverName() === 'sqlite'; 32 | 33 | /** @var LengthAwarePaginator $weekly */ 34 | $query = DB::table('entries') 35 | ->select($sqlite ? 36 | DB::raw('STRFTIME("%W", `date`) as `week`, STRFTIME("%Y", `date`) as `year`, avg(`speed`) as `avg_speed`, avg(`distance`) as `avg_distance`') 37 | : DB::raw('WEEK(`date`) as `week`, YEAR(`date`) as `year`, avg(`speed`) as `avg_speed`, avg(`distance`) as `avg_distance`') 38 | ) 39 | ->where('user_id', $me->id) 40 | ->where($sqlite ? 'year' : DB::raw('YEAR(`date`)'), $request->get('year', date('Y'))) 41 | ->groupBy('year', 'week') 42 | ->orderBy('year', 'desc') 43 | ->orderBy('week', 'desc'); 44 | 45 | $min_year = DB::table('entries')->select($sqlite ? 46 | DB::raw('MIN(STRFTIME("%Y", `date`)) as year') 47 | : DB::raw('MIN(YEAR(`date`)) as year'))->value('year'); 48 | 49 | $max_year = DB::table('entries')->select($sqlite ? 50 | DB::raw('MAX(STRFTIME("%Y", `date`)) as year') 51 | : DB::raw('MAX(YEAR(`date`)) as year'))->value('year'); 52 | 53 | 54 | $result = $query->get(); 55 | 56 | $data = $result->map(function ($item) { 57 | $date = (new Carbon())->setISODate($item->year, $item->week); 58 | return [ 59 | 'week' => $item->week, 60 | 'week_start' => $date->toDateString(), 61 | 'week_end' => $date->endOfWeek()->toDateString(), 62 | 'avg_speed' => round($item->avg_speed, 2), 63 | 'avg_distance' => round($item->avg_distance, 2), 64 | ]; 65 | }); 66 | 67 | $data_chart = (new Collection(array_reverse($result->toArray())))->map(function ($item) { 68 | return [$item->week, round($item->avg_speed, 2), round($item->avg_distance, 2)]; 69 | }); 70 | 71 | return [ 72 | 'weekly' => [ 73 | 'year' => $request->get('year', date('Y')), 74 | 'min_year' => $min_year, 75 | 'max_year' => $max_year, 76 | 'data' => $data, 77 | 'chart' => $data_chart, 78 | ], 79 | ]; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/Http/Controllers/UserController.php: -------------------------------------------------------------------------------- 1 | user(); 27 | } 28 | 29 | /** 30 | * Users list 31 | * 32 | * Display a listing of users 33 | * 34 | * @param Request $request 35 | * @return array 36 | */ 37 | public function index(Request $request) 38 | { 39 | $this->authorize('viewAll', User::class); 40 | 41 | $users = (new User)->latest(); 42 | 43 | if ($request->get('query')) { 44 | $users->where('name', 'like', '%' . $request->get('query') . '%') 45 | ->orWhere('email', 'like', '%' . $request->get('query') . '%'); 46 | } 47 | 48 | return ['users' => $users->paginate()]; 49 | } 50 | 51 | /** 52 | * Show user 53 | * 54 | * Display the specified user. 55 | * 56 | * @param User $user 57 | * @return array 58 | */ 59 | public function show(User $user) 60 | { 61 | $this->authorize($user); 62 | 63 | return ['user' => $user]; 64 | } 65 | 66 | /** 67 | * Update user 68 | * 69 | * Update the specified user in storage. 70 | * 71 | * @param \Illuminate\Http\Request $request 72 | * @param User $user 73 | * @return array 74 | */ 75 | public function update(Request $request, User $user) 76 | { 77 | $this->authorize($user); 78 | 79 | $this->validate($request, [ 80 | 'name' => 'required|max:255', 81 | 'email' => 'required|email|max:255|unique:users,email,' . $user->id, 82 | 'password' => 'nullable|min:6|confirmed', 83 | ]); 84 | 85 | // Do not udpate test users email or password 86 | if (in_array($user->email, ['user@gmail.com', 'admin@gmail.com'])) { 87 | $request->offsetUnset('email'); 88 | $request->offsetUnset('password'); 89 | } 90 | 91 | $user->fill($request->only('name', 'email')); 92 | if ($request->get('password')) { 93 | $user->password = bcrypt($request->get('password')); 94 | } 95 | 96 | // Update user role only by admin 97 | if ($request->get('role') && $request->get('role') !== $user->role) { 98 | if (! auth()->user()->isAdmin()) abort(401, 'Unathorized to edit user role.'); 99 | 100 | if (auth()->id() === $user->id) abort(401, 'You can not revoke your own admin role.'); 101 | $user->role = $request->get('role'); 102 | } 103 | 104 | $user->save(); 105 | 106 | return ['user' => $user]; 107 | } 108 | 109 | /** 110 | * Delete user 111 | * 112 | * Remove the specified user from storage. 113 | * 114 | * @param User $user 115 | * @return array 116 | */ 117 | public function destroy(User $user) 118 | { 119 | $this->authorize($user); 120 | 121 | $user->delete(); 122 | 123 | return ['message' => 'Success']; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /app/Http/Kernel.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | protected $middleware = [ 17 | // \App\Http\Middleware\TrustHosts::class, 18 | \App\Http\Middleware\TrustProxies::class, 19 | \Illuminate\Http\Middleware\HandleCors::class, 20 | \App\Http\Middleware\PreventRequestsDuringMaintenance::class, 21 | \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, 22 | \App\Http\Middleware\TrimStrings::class, 23 | \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, 24 | ]; 25 | 26 | /** 27 | * The application's route middleware groups. 28 | * 29 | * @var array> 30 | */ 31 | protected $middlewareGroups = [ 32 | 'web' => [ 33 | \App\Http\Middleware\EncryptCookies::class, 34 | \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, 35 | \Illuminate\Session\Middleware\StartSession::class, 36 | // \Illuminate\Session\Middleware\AuthenticateSession::class, 37 | \Illuminate\View\Middleware\ShareErrorsFromSession::class, 38 | \App\Http\Middleware\VerifyCsrfToken::class, 39 | \Illuminate\Routing\Middleware\SubstituteBindings::class, 40 | ], 41 | 42 | 'api' => [ 43 | // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, 44 | 'throttle:api', 45 | \Illuminate\Routing\Middleware\SubstituteBindings::class, 46 | ], 47 | ]; 48 | 49 | /** 50 | * The application's route middleware. 51 | * 52 | * These middleware may be assigned to groups or used individually. 53 | * 54 | * @var array 55 | */ 56 | protected $routeMiddleware = [ 57 | 'auth' => \App\Http\Middleware\Authenticate::class, 58 | 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 59 | 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 60 | 'can' => \Illuminate\Auth\Middleware\Authorize::class, 61 | 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 62 | 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, 63 | 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, 64 | 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 65 | 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, 66 | ]; 67 | } 68 | -------------------------------------------------------------------------------- /app/Http/Middleware/Authenticate.php: -------------------------------------------------------------------------------- 1 | expectsJson()) { 18 | return route('login'); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/Http/Middleware/EncryptCookies.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | protected $except = [ 15 | // 16 | ]; 17 | } 18 | -------------------------------------------------------------------------------- /app/Http/Middleware/PreventRequestsDuringMaintenance.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | protected $except = [ 15 | // 16 | ]; 17 | } 18 | -------------------------------------------------------------------------------- /app/Http/Middleware/RedirectIfAuthenticated.php: -------------------------------------------------------------------------------- 1 | check()) { 26 | return redirect(RouteServiceProvider::HOME); 27 | } 28 | } 29 | 30 | return $next($request); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/Http/Middleware/TrimStrings.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | protected $except = [ 15 | 'current_password', 16 | 'password', 17 | 'password_confirmation', 18 | ]; 19 | } 20 | -------------------------------------------------------------------------------- /app/Http/Middleware/TrustHosts.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public function hosts() 15 | { 16 | return [ 17 | $this->allSubdomainsOfApplicationUrl(), 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/Http/Middleware/TrustProxies.php: -------------------------------------------------------------------------------- 1 | |string|null 14 | */ 15 | protected $proxies; 16 | 17 | /** 18 | * The headers that should be used to detect proxies. 19 | * 20 | * @var int 21 | */ 22 | protected $headers = 23 | Request::HEADER_X_FORWARDED_FOR | 24 | Request::HEADER_X_FORWARDED_HOST | 25 | Request::HEADER_X_FORWARDED_PORT | 26 | Request::HEADER_X_FORWARDED_PROTO | 27 | Request::HEADER_X_FORWARDED_AWS_ELB; 28 | } 29 | -------------------------------------------------------------------------------- /app/Http/Middleware/VerifyCsrfToken.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | protected $except = [ 15 | // 16 | ]; 17 | } 18 | -------------------------------------------------------------------------------- /app/Models/Entry.php: -------------------------------------------------------------------------------- 1 | 'int', 30 | 'date' => 'date', 31 | 'distance' => 'float', 32 | 'time' => 'string', 33 | 'speed' => 'float', 34 | 'pace' => 'float', 35 | 'locations' => 'json', 36 | ]; 37 | 38 | 39 | /* ========================================================================= *\ 40 | * Relations 41 | \* ========================================================================= */ 42 | 43 | /** 44 | * Belongs to user 45 | */ 46 | public function user() 47 | { 48 | return $this->belongsTo(User::class); 49 | } 50 | 51 | 52 | /* ========================================================================= *\ 53 | * Helpers 54 | \* ========================================================================= */ 55 | 56 | /** 57 | * Get time in seconds 58 | * 59 | * @return int 60 | */ 61 | public function seconds() 62 | { 63 | return time2secconds($this->time); 64 | } 65 | 66 | /** 67 | * Apply filters 68 | * 69 | * @param Builder $query 70 | * @param $filters 71 | */ 72 | public function scopeFilter($query, $filters) 73 | { 74 | foreach ($filters as $filter => $value) { 75 | if ( ! $value) continue; 76 | switch ($filter) { 77 | case 'dateFrom': 78 | $query->where('date', '>=', Carbon::parse($value)->toDateString()); 79 | break; 80 | case 'dateTo': 81 | $query->where('date', '<=', Carbon::parse($value)->toDateString()); 82 | break; 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/Models/Model.php: -------------------------------------------------------------------------------- 1 | entries()->delete(); 50 | }); 51 | } 52 | 53 | 54 | /* ========================================================================= *\ 55 | * Relations 56 | \* ========================================================================= */ 57 | 58 | /** 59 | * User has many entries 60 | */ 61 | public function entries() 62 | { 63 | return $this->hasMany(Entry::class); 64 | } 65 | 66 | 67 | /* ========================================================================= *\ 68 | * Helpers 69 | \* ========================================================================= */ 70 | 71 | /** 72 | * Get existing or make new access token 73 | */ 74 | public function makeApiToken(): string 75 | { 76 | return $this->createToken('api')->plainTextToken; 77 | } 78 | 79 | /** 80 | * Is user admin 81 | * 82 | * @return bool 83 | */ 84 | public function isAdmin() 85 | { 86 | return $this->role === 'admin'; 87 | } 88 | 89 | /** 90 | * Is user manager 91 | * 92 | * @return bool 93 | */ 94 | public function isManager() 95 | { 96 | return $this->role === 'manager'; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/Policies/EntryPolicy.php: -------------------------------------------------------------------------------- 1 | isAdmin()) { 23 | return true; 24 | } 25 | } 26 | 27 | /** 28 | * Determine whether the user can view all users entries 29 | * 30 | * @param \App\User $user 31 | * @return mixed 32 | */ 33 | public function all(User $user) 34 | { 35 | return false; 36 | } 37 | 38 | /** 39 | * Determine whether the user can view the entry. 40 | * 41 | * @param \App\User $user 42 | * @param \App\Entry $entry 43 | * @return mixed 44 | */ 45 | public function view(User $user, Entry $entry) 46 | { 47 | return $user->id === $entry->user_id; 48 | } 49 | 50 | /** 51 | * Determine whether the user can create entries. 52 | * 53 | * @param \App\User $user 54 | * @return mixed 55 | */ 56 | public function create(User $user) 57 | { 58 | return true; 59 | } 60 | 61 | /** 62 | * Determine whether the user can update the entry. 63 | * 64 | * @param \App\User $user 65 | * @param \App\Entry $entry 66 | * @return mixed 67 | */ 68 | public function update(User $user, Entry $entry) 69 | { 70 | return $user->id === $entry->user_id; 71 | } 72 | 73 | /** 74 | * Determine whether the user can delete the entry. 75 | * 76 | * @param \App\User $user 77 | * @param \App\Entry $entry 78 | * @return mixed 79 | */ 80 | public function delete(User $user, Entry $entry) 81 | { 82 | return $user->id === $entry->user_id; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /app/Policies/UserPolicy.php: -------------------------------------------------------------------------------- 1 | isAdmin() || $user->isManager()) { 22 | return true; 23 | } 24 | } 25 | 26 | /** 27 | * Determine whether the user can view the user. 28 | * 29 | * @param \App\User $user 30 | * @return mixed 31 | */ 32 | public function viewAll(User $user) 33 | { 34 | return false; 35 | } 36 | 37 | /** 38 | * Determine whether the user can view the user. 39 | * 40 | * @param \App\User $user 41 | * @param \App\User $onUser 42 | * @return mixed 43 | */ 44 | public function view(User $user, User $onUser) 45 | { 46 | return $user->id === $onUser->id; 47 | } 48 | 49 | /** 50 | * Determine whether the user can create users. 51 | * 52 | * @param \App\User $user 53 | * @return mixed 54 | */ 55 | public function create(User $user) 56 | { 57 | return false; 58 | } 59 | 60 | /** 61 | * Determine whether the user can update the user. 62 | * 63 | * @param \App\User $user 64 | * @param \App\User $onUser 65 | * @return mixed 66 | */ 67 | public function update(User $user, User $onUser) 68 | { 69 | return $user->id === $onUser->id; 70 | } 71 | 72 | /** 73 | * Determine whether the user can delete the user. 74 | * 75 | * @param \App\User $user 76 | * @param \App\User $onUser 77 | * @return mixed 78 | */ 79 | public function delete(User $user, User $onUser) 80 | { 81 | return false; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/Providers/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | protected $policies = [ 16 | // 'App\Models\Model' => 'App\Policies\ModelPolicy', 17 | ]; 18 | 19 | /** 20 | * Register any authentication / authorization services. 21 | * 22 | * @return void 23 | */ 24 | public function boot() 25 | { 26 | $this->registerPolicies(); 27 | 28 | // 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/Providers/BroadcastServiceProvider.php: -------------------------------------------------------------------------------- 1 | > 16 | */ 17 | protected $listen = [ 18 | Registered::class => [ 19 | SendEmailVerificationNotification::class, 20 | ], 21 | ]; 22 | 23 | /** 24 | * Register any events for your application. 25 | * 26 | * @return void 27 | */ 28 | public function boot() 29 | { 30 | // 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/Providers/RouteServiceProvider.php: -------------------------------------------------------------------------------- 1 | configureRateLimiting(); 39 | 40 | $this->routes(function () { 41 | Route::prefix('api') 42 | ->middleware('api') 43 | ->namespace($this->namespace) 44 | ->group(base_path('routes/api.php')); 45 | 46 | Route::middleware('web') 47 | ->namespace($this->namespace) 48 | ->group(base_path('routes/web.php')); 49 | }); 50 | } 51 | 52 | /** 53 | * Configure the rate limiters for the application. 54 | * 55 | * @return void 56 | */ 57 | protected function configureRateLimiting() 58 | { 59 | RateLimiter::for('api', function (Request $request) { 60 | return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/helpers.php: -------------------------------------------------------------------------------- 1 | make(Illuminate\Contracts\Console\Kernel::class); 34 | 35 | $status = $kernel->handle( 36 | $input = new Symfony\Component\Console\Input\ArgvInput, 37 | new Symfony\Component\Console\Output\ConsoleOutput 38 | ); 39 | 40 | /* 41 | |-------------------------------------------------------------------------- 42 | | Shutdown The Application 43 | |-------------------------------------------------------------------------- 44 | | 45 | | Once Artisan has finished running, we will fire off the shutdown events 46 | | so that any final work may be done by the application before we shut 47 | | down the process. This is the last thing to happen to the request. 48 | | 49 | */ 50 | 51 | $kernel->terminate($input, $status); 52 | 53 | exit($status); 54 | -------------------------------------------------------------------------------- /bootstrap/app.php: -------------------------------------------------------------------------------- 1 | singleton( 30 | Illuminate\Contracts\Http\Kernel::class, 31 | App\Http\Kernel::class 32 | ); 33 | 34 | $app->singleton( 35 | Illuminate\Contracts\Console\Kernel::class, 36 | App\Console\Kernel::class 37 | ); 38 | 39 | $app->singleton( 40 | Illuminate\Contracts\Debug\ExceptionHandler::class, 41 | App\Exceptions\Handler::class 42 | ); 43 | 44 | /* 45 | |-------------------------------------------------------------------------- 46 | | Return The Application 47 | |-------------------------------------------------------------------------- 48 | | 49 | | This script returns the application instance. The instance is given to 50 | | the calling script so we can separate the building of the instances 51 | | from the actual running of the application and sending responses. 52 | | 53 | */ 54 | 55 | return $app; 56 | -------------------------------------------------------------------------------- /bootstrap/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vedmant/running-time", 3 | "description": "Laravel & Vue.js + Vuex Sample Project", 4 | "type": "project", 5 | "keywords": [ 6 | "framework", 7 | "laravel" 8 | ], 9 | "license": "MIT", 10 | "require": { 11 | "php": "^8.2", 12 | "fakerphp/faker": "^1.19", 13 | "guzzlehttp/guzzle": "^7.2", 14 | "laravel/framework": "^10.0", 15 | "laravel/sanctum": "^3.2", 16 | "laravel/socialite": "^5.12", 17 | "laravel/telescope": "^v4.16.0", 18 | "laravel/tinker": "^2.7", 19 | "laravel/ui": "^v4.2.2" 20 | }, 21 | "require-dev": { 22 | "barryvdh/laravel-ide-helper": "^2.12", 23 | "codedungeon/phpunit-result-printer": "^0.31.0", 24 | "laravel/browser-kit-testing": "^6.3", 25 | "laravel/sail": "^1.0.1", 26 | "mockery/mockery": "^1.4.4", 27 | "nunomaduro/collision": "^6.1", 28 | "phpunit/phpunit": "^9.5.10", 29 | "spatie/laravel-ignition": "^2.0" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "App\\": "app/", 34 | "Database\\Factories\\": "database/factories/", 35 | "Database\\Seeders\\": "database/seeders/" 36 | }, 37 | "files": [ 38 | "app/helpers.php" 39 | ] 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "Tests\\": "tests/" 44 | } 45 | }, 46 | "scripts": { 47 | "post-autoload-dump": [ 48 | "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", 49 | "@php artisan package:discover --ansi" 50 | ], 51 | "post-update-cmd": [ 52 | "@php artisan vendor:publish --tag=laravel-assets --ansi --force" 53 | ], 54 | "post-root-package-install": [ 55 | "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" 56 | ], 57 | "post-create-project-cmd": [ 58 | "@php artisan key:generate --ansi" 59 | ] 60 | }, 61 | "extra": { 62 | "laravel": { 63 | "dont-discover": [] 64 | } 65 | }, 66 | "config": { 67 | "optimize-autoloader": true, 68 | "preferred-install": "dist", 69 | "sort-packages": true, 70 | "allow-plugins": { 71 | "pestphp/pest-plugin": true, 72 | "php-http/discovery": true 73 | } 74 | }, 75 | "minimum-stability": "stable", 76 | "prefer-stable": true 77 | } 78 | -------------------------------------------------------------------------------- /config/auth.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'guard' => 'web', 18 | 'passwords' => 'users', 19 | ], 20 | 21 | /* 22 | |-------------------------------------------------------------------------- 23 | | Authentication Guards 24 | |-------------------------------------------------------------------------- 25 | | 26 | | Next, you may define every authentication guard for your application. 27 | | Of course, a great default configuration has been defined for you 28 | | here which uses session storage and the Eloquent user provider. 29 | | 30 | | All authentication drivers have a user provider. This defines how the 31 | | users are actually retrieved out of your database or other storage 32 | | mechanisms used by this application to persist your user's data. 33 | | 34 | | Supported: "session" 35 | | 36 | */ 37 | 38 | 'guards' => [ 39 | 'web' => [ 40 | 'driver' => 'session', 41 | 'provider' => 'users', 42 | ], 43 | ], 44 | 45 | /* 46 | |-------------------------------------------------------------------------- 47 | | User Providers 48 | |-------------------------------------------------------------------------- 49 | | 50 | | All authentication drivers have a user provider. This defines how the 51 | | users are actually retrieved out of your database or other storage 52 | | mechanisms used by this application to persist your user's data. 53 | | 54 | | If you have multiple user tables or models you may configure multiple 55 | | sources which represent each model / table. These sources may then 56 | | be assigned to any extra authentication guards you have defined. 57 | | 58 | | Supported: "database", "eloquent" 59 | | 60 | */ 61 | 62 | 'providers' => [ 63 | 'users' => [ 64 | 'driver' => 'eloquent', 65 | 'model' => App\Models\User::class, 66 | ], 67 | 68 | // 'users' => [ 69 | // 'driver' => 'database', 70 | // 'table' => 'users', 71 | // ], 72 | ], 73 | 74 | /* 75 | |-------------------------------------------------------------------------- 76 | | Resetting Passwords 77 | |-------------------------------------------------------------------------- 78 | | 79 | | You may specify multiple password reset configurations if you have more 80 | | than one user table or model in the application and you want to have 81 | | separate password reset settings based on the specific user types. 82 | | 83 | | The expire time is the number of minutes that each reset token will be 84 | | considered valid. This security feature keeps tokens short-lived so 85 | | they have less time to be guessed. You may change this as needed. 86 | | 87 | */ 88 | 89 | 'passwords' => [ 90 | 'users' => [ 91 | 'provider' => 'users', 92 | 'table' => 'password_resets', 93 | 'expire' => 60, 94 | 'throttle' => 60, 95 | ], 96 | ], 97 | 98 | /* 99 | |-------------------------------------------------------------------------- 100 | | Password Confirmation Timeout 101 | |-------------------------------------------------------------------------- 102 | | 103 | | Here you may define the amount of seconds before a password confirmation 104 | | times out and the user is prompted to re-enter their password via the 105 | | confirmation screen. By default, the timeout lasts for three hours. 106 | | 107 | */ 108 | 109 | 'password_timeout' => 10800, 110 | 111 | ]; 112 | -------------------------------------------------------------------------------- /config/broadcasting.php: -------------------------------------------------------------------------------- 1 | env('BROADCAST_DRIVER', 'null'), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Broadcast Connections 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may define all of the broadcast connections that will be used 26 | | to broadcast events to other systems or over websockets. Samples of 27 | | each available type of connection are provided inside this array. 28 | | 29 | */ 30 | 31 | 'connections' => [ 32 | 33 | 'pusher' => [ 34 | 'driver' => 'pusher', 35 | 'key' => env('PUSHER_APP_KEY'), 36 | 'secret' => env('PUSHER_APP_SECRET'), 37 | 'app_id' => env('PUSHER_APP_ID'), 38 | 'options' => [ 39 | 'cluster' => env('PUSHER_APP_CLUSTER'), 40 | 'useTLS' => true, 41 | ], 42 | 'client_options' => [ 43 | // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html 44 | ], 45 | ], 46 | 47 | 'ably' => [ 48 | 'driver' => 'ably', 49 | 'key' => env('ABLY_KEY'), 50 | ], 51 | 52 | 'redis' => [ 53 | 'driver' => 'redis', 54 | 'connection' => 'default', 55 | ], 56 | 57 | 'log' => [ 58 | 'driver' => 'log', 59 | ], 60 | 61 | 'null' => [ 62 | 'driver' => 'null', 63 | ], 64 | 65 | ], 66 | 67 | ]; 68 | -------------------------------------------------------------------------------- /config/cache.php: -------------------------------------------------------------------------------- 1 | env('CACHE_DRIVER', 'file'), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Cache Stores 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may define all of the cache "stores" for your application as 26 | | well as their drivers. You may even define multiple stores for the 27 | | same cache driver to group types of items stored in your caches. 28 | | 29 | | Supported drivers: "apc", "array", "database", "file", 30 | | "memcached", "redis", "dynamodb", "octane", "null" 31 | | 32 | */ 33 | 34 | 'stores' => [ 35 | 36 | 'apc' => [ 37 | 'driver' => 'apc', 38 | ], 39 | 40 | 'array' => [ 41 | 'driver' => 'array', 42 | 'serialize' => false, 43 | ], 44 | 45 | 'database' => [ 46 | 'driver' => 'database', 47 | 'table' => 'cache', 48 | 'connection' => null, 49 | 'lock_connection' => null, 50 | ], 51 | 52 | 'file' => [ 53 | 'driver' => 'file', 54 | 'path' => storage_path('framework/cache/data'), 55 | ], 56 | 57 | 'memcached' => [ 58 | 'driver' => 'memcached', 59 | 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), 60 | 'sasl' => [ 61 | env('MEMCACHED_USERNAME'), 62 | env('MEMCACHED_PASSWORD'), 63 | ], 64 | 'options' => [ 65 | // Memcached::OPT_CONNECT_TIMEOUT => 2000, 66 | ], 67 | 'servers' => [ 68 | [ 69 | 'host' => env('MEMCACHED_HOST', '127.0.0.1'), 70 | 'port' => env('MEMCACHED_PORT', 11211), 71 | 'weight' => 100, 72 | ], 73 | ], 74 | ], 75 | 76 | 'redis' => [ 77 | 'driver' => 'redis', 78 | 'connection' => 'cache', 79 | 'lock_connection' => 'default', 80 | ], 81 | 82 | 'dynamodb' => [ 83 | 'driver' => 'dynamodb', 84 | 'key' => env('AWS_ACCESS_KEY_ID'), 85 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 86 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 87 | 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), 88 | 'endpoint' => env('DYNAMODB_ENDPOINT'), 89 | ], 90 | 91 | 'octane' => [ 92 | 'driver' => 'octane', 93 | ], 94 | 95 | ], 96 | 97 | /* 98 | |-------------------------------------------------------------------------- 99 | | Cache Key Prefix 100 | |-------------------------------------------------------------------------- 101 | | 102 | | When utilizing a RAM based store such as APC or Memcached, there might 103 | | be other applications utilizing the same cache. So, we'll specify a 104 | | value to get prefixed to all our keys so we can avoid collisions. 105 | | 106 | */ 107 | 108 | 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache'), 109 | 110 | ]; 111 | -------------------------------------------------------------------------------- /config/cors.php: -------------------------------------------------------------------------------- 1 | ['api/*', 'sanctum/csrf-cookie'], 19 | 20 | 'allowed_methods' => ['*'], 21 | 22 | 'allowed_origins' => ['*'], 23 | 24 | 'allowed_origins_patterns' => [], 25 | 26 | 'allowed_headers' => ['*'], 27 | 28 | 'exposed_headers' => [], 29 | 30 | 'max_age' => 0, 31 | 32 | 'supports_credentials' => false, 33 | 34 | ]; 35 | -------------------------------------------------------------------------------- /config/filesystems.php: -------------------------------------------------------------------------------- 1 | env('FILESYSTEM_DISK', 'local'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Filesystem Disks 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Here you may configure as many filesystem "disks" as you wish, and you 24 | | may even configure multiple disks of the same driver. Defaults have 25 | | been setup for each driver as an example of the required options. 26 | | 27 | | Supported Drivers: "local", "ftp", "sftp", "s3" 28 | | 29 | */ 30 | 31 | 'disks' => [ 32 | 33 | 'local' => [ 34 | 'driver' => 'local', 35 | 'root' => storage_path('app'), 36 | ], 37 | 38 | 'public' => [ 39 | 'driver' => 'local', 40 | 'root' => storage_path('app/public'), 41 | 'url' => env('APP_URL').'/storage', 42 | 'visibility' => 'public', 43 | ], 44 | 45 | 's3' => [ 46 | 'driver' => 's3', 47 | 'key' => env('AWS_ACCESS_KEY_ID'), 48 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 49 | 'region' => env('AWS_DEFAULT_REGION'), 50 | 'bucket' => env('AWS_BUCKET'), 51 | 'url' => env('AWS_URL'), 52 | 'endpoint' => env('AWS_ENDPOINT'), 53 | 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 54 | ], 55 | 56 | ], 57 | 58 | /* 59 | |-------------------------------------------------------------------------- 60 | | Symbolic Links 61 | |-------------------------------------------------------------------------- 62 | | 63 | | Here you may configure the symbolic links that will be created when the 64 | | `storage:link` Artisan command is executed. The array keys should be 65 | | the locations of the links and the values should be their targets. 66 | | 67 | */ 68 | 69 | 'links' => [ 70 | public_path('storage') => storage_path('app/public'), 71 | ], 72 | 73 | ]; 74 | -------------------------------------------------------------------------------- /config/hashing.php: -------------------------------------------------------------------------------- 1 | 'bcrypt', 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Bcrypt Options 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may specify the configuration options that should be used when 26 | | passwords are hashed using the Bcrypt algorithm. This will allow you 27 | | to control the amount of time it takes to hash the given password. 28 | | 29 | */ 30 | 31 | 'bcrypt' => [ 32 | 'rounds' => env('BCRYPT_ROUNDS', 10), 33 | ], 34 | 35 | /* 36 | |-------------------------------------------------------------------------- 37 | | Argon Options 38 | |-------------------------------------------------------------------------- 39 | | 40 | | Here you may specify the configuration options that should be used when 41 | | passwords are hashed using the Argon algorithm. These will allow you 42 | | to control the amount of time it takes to hash the given password. 43 | | 44 | */ 45 | 46 | 'argon' => [ 47 | 'memory' => 65536, 48 | 'threads' => 1, 49 | 'time' => 4, 50 | ], 51 | 52 | ]; 53 | -------------------------------------------------------------------------------- /config/logging.php: -------------------------------------------------------------------------------- 1 | env('LOG_CHANNEL', 'stack'), 21 | 22 | /* 23 | |-------------------------------------------------------------------------- 24 | | Deprecations Log Channel 25 | |-------------------------------------------------------------------------- 26 | | 27 | | This option controls the log channel that should be used to log warnings 28 | | regarding deprecated PHP and library features. This allows you to get 29 | | your application ready for upcoming major versions of dependencies. 30 | | 31 | */ 32 | 33 | 'deprecations' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), 34 | 35 | /* 36 | |-------------------------------------------------------------------------- 37 | | Log Channels 38 | |-------------------------------------------------------------------------- 39 | | 40 | | Here you may configure the log channels for your application. Out of 41 | | the box, Laravel uses the Monolog PHP logging library. This gives 42 | | you a variety of powerful log handlers / formatters to utilize. 43 | | 44 | | Available Drivers: "single", "daily", "slack", "syslog", 45 | | "errorlog", "monolog", 46 | | "custom", "stack" 47 | | 48 | */ 49 | 50 | 'channels' => [ 51 | 'stack' => [ 52 | 'driver' => 'stack', 53 | 'channels' => ['single'], 54 | 'ignore_exceptions' => false, 55 | ], 56 | 57 | 'single' => [ 58 | 'driver' => 'single', 59 | 'path' => storage_path('logs/laravel.log'), 60 | 'level' => env('LOG_LEVEL', 'debug'), 61 | ], 62 | 63 | 'daily' => [ 64 | 'driver' => 'daily', 65 | 'path' => storage_path('logs/laravel.log'), 66 | 'level' => env('LOG_LEVEL', 'debug'), 67 | 'days' => 14, 68 | ], 69 | 70 | 'slack' => [ 71 | 'driver' => 'slack', 72 | 'url' => env('LOG_SLACK_WEBHOOK_URL'), 73 | 'username' => 'Laravel Log', 74 | 'emoji' => ':boom:', 75 | 'level' => env('LOG_LEVEL', 'critical'), 76 | ], 77 | 78 | 'papertrail' => [ 79 | 'driver' => 'monolog', 80 | 'level' => env('LOG_LEVEL', 'debug'), 81 | 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), 82 | 'handler_with' => [ 83 | 'host' => env('PAPERTRAIL_URL'), 84 | 'port' => env('PAPERTRAIL_PORT'), 85 | 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), 86 | ], 87 | ], 88 | 89 | 'stderr' => [ 90 | 'driver' => 'monolog', 91 | 'level' => env('LOG_LEVEL', 'debug'), 92 | 'handler' => StreamHandler::class, 93 | 'formatter' => env('LOG_STDERR_FORMATTER'), 94 | 'with' => [ 95 | 'stream' => 'php://stderr', 96 | ], 97 | ], 98 | 99 | 'syslog' => [ 100 | 'driver' => 'syslog', 101 | 'level' => env('LOG_LEVEL', 'debug'), 102 | ], 103 | 104 | 'errorlog' => [ 105 | 'driver' => 'errorlog', 106 | 'level' => env('LOG_LEVEL', 'debug'), 107 | ], 108 | 109 | 'null' => [ 110 | 'driver' => 'monolog', 111 | 'handler' => NullHandler::class, 112 | ], 113 | 114 | 'emergency' => [ 115 | 'path' => storage_path('logs/laravel.log'), 116 | ], 117 | ], 118 | 119 | ]; 120 | -------------------------------------------------------------------------------- /config/mail.php: -------------------------------------------------------------------------------- 1 | env('MAIL_MAILER', 'smtp'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Mailer Configurations 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Here you may configure all of the mailers used by your application plus 24 | | their respective settings. Several examples have been configured for 25 | | you and you are free to add your own as your application requires. 26 | | 27 | | Laravel supports a variety of mail "transport" drivers to be used while 28 | | sending an e-mail. You will specify which one you are using for your 29 | | mailers below. You are free to add additional mailers as required. 30 | | 31 | | Supported: "smtp", "sendmail", "mailgun", "ses", 32 | | "postmark", "log", "array", "failover" 33 | | 34 | */ 35 | 36 | 'mailers' => [ 37 | 'smtp' => [ 38 | 'transport' => 'smtp', 39 | 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), 40 | 'port' => env('MAIL_PORT', 587), 41 | 'encryption' => env('MAIL_ENCRYPTION', 'tls'), 42 | 'username' => env('MAIL_USERNAME'), 43 | 'password' => env('MAIL_PASSWORD'), 44 | 'timeout' => null, 45 | ], 46 | 47 | 'ses' => [ 48 | 'transport' => 'ses', 49 | ], 50 | 51 | 'mailgun' => [ 52 | 'transport' => 'mailgun', 53 | ], 54 | 55 | 'postmark' => [ 56 | 'transport' => 'postmark', 57 | ], 58 | 59 | 'sendmail' => [ 60 | 'transport' => 'sendmail', 61 | 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -t -i'), 62 | ], 63 | 64 | 'log' => [ 65 | 'transport' => 'log', 66 | 'channel' => env('MAIL_LOG_CHANNEL'), 67 | ], 68 | 69 | 'array' => [ 70 | 'transport' => 'array', 71 | ], 72 | 73 | 'failover' => [ 74 | 'transport' => 'failover', 75 | 'mailers' => [ 76 | 'smtp', 77 | 'log', 78 | ], 79 | ], 80 | ], 81 | 82 | /* 83 | |-------------------------------------------------------------------------- 84 | | Global "From" Address 85 | |-------------------------------------------------------------------------- 86 | | 87 | | You may wish for all e-mails sent by your application to be sent from 88 | | the same address. Here, you may specify a name and address that is 89 | | used globally for all e-mails that are sent by your application. 90 | | 91 | */ 92 | 93 | 'from' => [ 94 | 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), 95 | 'name' => env('MAIL_FROM_NAME', 'Example'), 96 | ], 97 | 98 | /* 99 | |-------------------------------------------------------------------------- 100 | | Markdown Mail Settings 101 | |-------------------------------------------------------------------------- 102 | | 103 | | If you are using Markdown based email rendering, you may configure your 104 | | theme and component paths here, allowing you to customize the design 105 | | of the emails. Or, you may simply stick with the Laravel defaults! 106 | | 107 | */ 108 | 109 | 'markdown' => [ 110 | 'theme' => 'default', 111 | 112 | 'paths' => [ 113 | resource_path('views/vendor/mail'), 114 | ], 115 | ], 116 | 117 | ]; 118 | -------------------------------------------------------------------------------- /config/queue.php: -------------------------------------------------------------------------------- 1 | env('QUEUE_CONNECTION', 'sync'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Queue Connections 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Here you may configure the connection information for each server that 24 | | is used by your application. A default configuration has been added 25 | | for each back-end shipped with Laravel. You are free to add more. 26 | | 27 | | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" 28 | | 29 | */ 30 | 31 | 'connections' => [ 32 | 33 | 'sync' => [ 34 | 'driver' => 'sync', 35 | ], 36 | 37 | 'database' => [ 38 | 'driver' => 'database', 39 | 'table' => 'jobs', 40 | 'queue' => 'default', 41 | 'retry_after' => 90, 42 | 'after_commit' => false, 43 | ], 44 | 45 | 'beanstalkd' => [ 46 | 'driver' => 'beanstalkd', 47 | 'host' => 'localhost', 48 | 'queue' => 'default', 49 | 'retry_after' => 90, 50 | 'block_for' => 0, 51 | 'after_commit' => false, 52 | ], 53 | 54 | 'sqs' => [ 55 | 'driver' => 'sqs', 56 | 'key' => env('AWS_ACCESS_KEY_ID'), 57 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 58 | 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), 59 | 'queue' => env('SQS_QUEUE', 'default'), 60 | 'suffix' => env('SQS_SUFFIX'), 61 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 62 | 'after_commit' => false, 63 | ], 64 | 65 | 'redis' => [ 66 | 'driver' => 'redis', 67 | 'connection' => 'default', 68 | 'queue' => env('REDIS_QUEUE', 'default'), 69 | 'retry_after' => 90, 70 | 'block_for' => null, 71 | 'after_commit' => false, 72 | ], 73 | 74 | ], 75 | 76 | /* 77 | |-------------------------------------------------------------------------- 78 | | Failed Queue Jobs 79 | |-------------------------------------------------------------------------- 80 | | 81 | | These options configure the behavior of failed queue job logging so you 82 | | can control which database and table are used to store the jobs that 83 | | have failed. You may change them to any database / table you wish. 84 | | 85 | */ 86 | 87 | 'failed' => [ 88 | 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), 89 | 'database' => env('DB_CONNECTION', 'mysql'), 90 | 'table' => 'failed_jobs', 91 | ], 92 | 93 | ]; 94 | -------------------------------------------------------------------------------- /config/sanctum.php: -------------------------------------------------------------------------------- 1 | explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( 17 | '%s%s', 18 | 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', 19 | env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : '' 20 | ))), 21 | 22 | /* 23 | |-------------------------------------------------------------------------- 24 | | Sanctum Guards 25 | |-------------------------------------------------------------------------- 26 | | 27 | | This array contains the authentication guards that will be checked when 28 | | Sanctum is trying to authenticate a request. If none of these guards 29 | | are able to authenticate the request, Sanctum will use the bearer 30 | | token that's present on an incoming request for authentication. 31 | | 32 | */ 33 | 34 | 'guard' => ['web'], 35 | 36 | /* 37 | |-------------------------------------------------------------------------- 38 | | Expiration Minutes 39 | |-------------------------------------------------------------------------- 40 | | 41 | | This value controls the number of minutes until an issued token will be 42 | | considered expired. If this value is null, personal access tokens do 43 | | not expire. This won't tweak the lifetime of first-party sessions. 44 | | 45 | */ 46 | 47 | 'expiration' => null, 48 | 49 | /* 50 | |-------------------------------------------------------------------------- 51 | | Sanctum Middleware 52 | |-------------------------------------------------------------------------- 53 | | 54 | | When authenticating your first-party SPA with Sanctum you may need to 55 | | customize some of the middleware Sanctum uses while processing the 56 | | request. You may change the middleware listed below as required. 57 | | 58 | */ 59 | 60 | 'middleware' => [ 61 | 'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class, 62 | 'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class, 63 | ], 64 | 65 | ]; 66 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'domain' => env('MAILGUN_DOMAIN'), 19 | 'secret' => env('MAILGUN_SECRET'), 20 | 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), 21 | ], 22 | 23 | 'postmark' => [ 24 | 'token' => env('POSTMARK_TOKEN'), 25 | ], 26 | 27 | 'ses' => [ 28 | 'key' => env('AWS_ACCESS_KEY_ID'), 29 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 30 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 31 | ], 32 | 33 | ]; 34 | -------------------------------------------------------------------------------- /config/view.php: -------------------------------------------------------------------------------- 1 | [ 17 | resource_path('views'), 18 | ], 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Compiled View Path 23 | |-------------------------------------------------------------------------- 24 | | 25 | | This option determines where all the compiled Blade templates will be 26 | | stored for your application. Typically, this is within the storage 27 | | directory. However, as usual, you are free to change this value. 28 | | 29 | */ 30 | 31 | 'compiled' => env( 32 | 'VIEW_COMPILED_PATH', 33 | realpath(storage_path('framework/views')) 34 | ), 35 | 36 | ]; 37 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:8000/#" 3 | } 4 | -------------------------------------------------------------------------------- /cypress/fixtures/user_admin.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Admin", 3 | "email": "admin@gmail.com", 4 | "password": "123456" 5 | } -------------------------------------------------------------------------------- /cypress/fixtures/user_manager.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Manager", 3 | "email": "manager@gmail.com", 4 | "password": "123456" 5 | } -------------------------------------------------------------------------------- /cypress/fixtures/user_new.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "New User", 3 | "email": "newuser@gmail.com", 4 | "password": "123456" 5 | } -------------------------------------------------------------------------------- /cypress/fixtures/user_update.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Updated User", 3 | "email": "updateduser@gmail.com", 4 | "password": "123456updated" 5 | } -------------------------------------------------------------------------------- /cypress/fixtures/user_user.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "User", 3 | "email": "user@gmail.com", 4 | "password": "123456" 5 | } -------------------------------------------------------------------------------- /cypress/integration/auth.js: -------------------------------------------------------------------------------- 1 | const faker = require('faker') 2 | 3 | describe('Auth pages', function () { 4 | 5 | before(() => { 6 | cy.resetDb() 7 | }) 8 | 9 | it('can login', function () { 10 | cy.visit('/login') 11 | 12 | cy.fixture('user_user').as('user') 13 | 14 | cy.get('#login_form').within(() => { 15 | cy.get('#email').type(this.user.email) 16 | cy.get('#password').type(this.user.password) 17 | cy.root().submit() 18 | }) 19 | cy.url().should('include', '/dashboard') 20 | cy.get('#dashboard').should('be.visible') 21 | }) 22 | 23 | it('can register', function () { 24 | cy.visit('/register') 25 | 26 | cy.fixture('user_new').as('user') 27 | 28 | cy.get('#register_form').within(() => { 29 | cy.get('#name').type(faker.name.findName()) 30 | cy.get('#email').type(faker.internet.email()) 31 | cy.get('#password').type(this.user.password) 32 | cy.get('#password-confirm').type(this.user.password) 33 | cy.root().submit() 34 | cy.url().should('include', '/dashboard') 35 | }) 36 | }) 37 | 38 | it('can logout', function () { 39 | cy.login('user') 40 | cy.get('.navbar a').contains('User').click() 41 | cy.get('.navbar a').contains('Logout').click() 42 | cy.get('.navbar a').contains('Login').should('be.visible') 43 | cy.get('.navbar a').contains('Register').should('be.visible') 44 | 45 | cy.store().its('state.auth.me').should('be.equal', null) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /cypress/integration/dashboard.js: -------------------------------------------------------------------------------- 1 | describe('Dashboard', function () { 2 | before(() => { 3 | cy.resetDb() 4 | cy.login('user') 5 | }) 6 | 7 | it('can see dashboard widgets', function () { 8 | cy.visit('/dashboard') 9 | 10 | cy.get('.panel').contains('This week').should('be.visible') 11 | cy.get('.panel').contains('Best results').should('be.visible') 12 | cy.get('.panel').contains('Add new Time Record').should('be.visible') 13 | cy.get('.vue-chart').contains('My Performance').should('be.visible') 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /cypress/integration/entries.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment-mini'); 2 | 3 | describe('Entries', function () { 4 | before(() => { 5 | cy.resetDb() 6 | }) 7 | 8 | beforeEach(() => { 9 | cy.login('user') 10 | }) 11 | 12 | it('can see list of entries', function () { 13 | cy.visit('/entries') 14 | 15 | cy.get('#entries_list').should('be.visible') 16 | }) 17 | 18 | it('can add new entry', function () { 19 | cy.visit('/entries') // Needs for correct redirect back 20 | cy.visit('/entry/new') 21 | 22 | cy.wait(500) 23 | cy.get('#entry_form').within(() => { 24 | cy.get('#date').type(moment().format('YYYY-MM-DD')) 25 | cy.get('#distance').type('6') 26 | cy.get('#time_hours').invoke('attr', 'maxlength', '10').clear().type('0') 27 | cy.get('#time_minutes').invoke('attr', 'maxlength', '10').clear().type('30') 28 | cy.get('#time_seconds').invoke('attr', 'maxlength', '10').clear().type('17') 29 | cy.root().submit() 30 | }) 31 | cy.url().should('include', '/entries') 32 | cy.get('#entries_list').should('be.visible') 33 | cy.get('.toast-message').should('contain', 'added') 34 | }) 35 | 36 | it('can update entry', function () { 37 | cy.visit('/entries') 38 | 39 | cy.get('#entries_list > tbody > tr:first-child').then($row => { 40 | const rowId = $row.attr('id').split('-').pop() 41 | cy.visit('/entry/edit/' + rowId) 42 | }) 43 | 44 | cy.get('#entry_form').within(() => { 45 | cy.get('#date').type(moment().subtract(2, 'days').format('YYYY-MM-DD')) 46 | cy.get('#distance').invoke('attr', 'maxlength', '10').clear().type('6') 47 | cy.get('#time_hours').invoke('attr', 'maxlength', '10').clear().type('0') 48 | cy.get('#time_minutes').invoke('attr', 'maxlength', '10').clear().type('20') 49 | cy.get('#time_seconds').invoke('attr', 'maxlength', '10').clear().type('10') 50 | cy.root().submit() 51 | }) 52 | cy.url().should('include', '/entries') 53 | cy.get('#entries_list').should('be.visible') 54 | 55 | cy.get('.toast-message').should('contain', 'updated') 56 | }) 57 | 58 | it('can delete entry', function () { 59 | cy.visit('/entries') 60 | 61 | cy.get('#entries_list > tbody > tr:first-child').then($row => { 62 | const rowId = $row.attr('id') 63 | cy.get('#' + rowId).should('exist') 64 | $row.find(`.btn-danger`).click() 65 | cy.get('#' + rowId).should('not.exist') 66 | }) 67 | 68 | cy.get('.toast-message').should('contain', 'deleted') 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /cypress/integration/profile.js: -------------------------------------------------------------------------------- 1 | describe('Profile', function () { 2 | before(() => { 3 | cy.resetDb() 4 | cy.login('user') 5 | }) 6 | 7 | it('can update profile', function () { 8 | cy.visit('/profile') 9 | 10 | cy.fixture('user_update').as('userUpd') 11 | 12 | cy.get('#profile_form').within(() => { 13 | cy.get('#name').clear().type(this.userUpd.name) 14 | cy.get('#email').clear().type(this.userUpd.email) 15 | cy.get('#password').clear().type(this.userUpd.password) 16 | cy.get('#password-confirm').clear().type(this.userUpd.password) 17 | cy.root().submit() 18 | }) 19 | }) 20 | 21 | it('can login with updated profile data', function () { 22 | cy.login('update') 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /cypress/integration/reports.js: -------------------------------------------------------------------------------- 1 | describe('Reports', function () { 2 | before(() => { 3 | cy.resetDb() 4 | cy.login('user') 5 | }) 6 | 7 | it('can see weekly report', function () { 8 | cy.visit('/report/weekly') 9 | 10 | cy.get('.vue-chart').contains('My Performance').should('be.visible') 11 | cy.get('table').contains('Week').should('be.visible') 12 | cy.get('table').contains('Avg. Speed').should('be.visible') 13 | cy.get('table').contains('Avg. Distance').should('be.visible') 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | } 18 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | 27 | Cypress.Commands.add('store', function () { 28 | return cy.window().its('store') 29 | }) 30 | 31 | Cypress.Commands.add('login', function (userType, options = {}) { 32 | cy.visit('/') 33 | cy.fixture(`user_${userType}`).as('user') 34 | 35 | cy.store().then(store => { 36 | store.dispatch('login', {email: this.user.email, password: this.user.password}) 37 | cy.store().its('state.auth.me').should('not.equal', null) 38 | }) 39 | }) 40 | 41 | Cypress.Commands.add('resetDb', function () { 42 | cy.exec('php artisan migrate:refresh --seed && php artisan passport:install').its('code').should('eq', 0) 43 | }) 44 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /database/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite* 2 | -------------------------------------------------------------------------------- /database/factories/EntryFactory.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class EntryFactory extends Factory 12 | { 13 | /** 14 | * Define the model's default state. 15 | * 16 | * @return array 17 | */ 18 | public function definition() 19 | { 20 | $distance = $this->faker->numberBetween(2, 20); 21 | $time = round($distance * rand(4 * 60, 7 * 60)); 22 | 23 | return [ 24 | 'user_id' => 0, 25 | 'date' => $this->faker->dateTimeBetween('-90 days', 'now')->format('Y-m-d'), 26 | 'distance' => $distance, 27 | 'time' => seconds2time($time), 28 | 'speed' => $distance / ($time / 3600), 29 | 'pace' => ($time / 60) / $distance, 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class UserFactory extends Factory 12 | { 13 | /** 14 | * Define the model's default state. 15 | * 16 | * @return array 17 | */ 18 | public function definition() 19 | { 20 | return [ 21 | 'name' => $this->faker->name(), 22 | 'email' => $this->faker->unique()->safeEmail(), 23 | 'password' => bcrypt('123456'), // password 24 | 'role' => 'user', 25 | 'remember_token' => Str::random(10), 26 | ]; 27 | } 28 | 29 | public function admin() 30 | { 31 | return $this->state(function (array $attributes) { 32 | return [ 33 | 'role' => 'admin', 34 | ]; 35 | }); 36 | } 37 | 38 | public function manager() 39 | { 40 | return $this->state(function (array $attributes) { 41 | return [ 42 | 'role' => 'manager', 43 | ]; 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /database/migrations/2014_10_12_000000_create_users_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('name'); 19 | $table->string('email')->unique(); 20 | $table->string('password'); 21 | $table->enum('role', ['user', 'manager', 'admin'])->default('user'); 22 | $table->rememberToken(); 23 | $table->timestamps(); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('users'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /database/migrations/2014_10_12_100000_create_password_resets_table.php: -------------------------------------------------------------------------------- 1 | string('email')->index(); 18 | $table->string('token')->index(); 19 | $table->timestamp('created_at')->nullable(); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | * @return void 27 | */ 28 | public function down() 29 | { 30 | Schema::dropIfExists('password_resets'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /database/migrations/2016_12_13_204920_create_entries_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->integer('user_id')->unsigned(); 19 | $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); 20 | $table->date('date'); 21 | $table->decimal('distance'); 22 | $table->time('time'); 23 | $table->decimal('speed'); 24 | $table->decimal('pace'); 25 | $table->json('locations')->nullable(); 26 | $table->timestamps(); 27 | }); 28 | } 29 | 30 | /** 31 | * Reverse the migrations. 32 | * 33 | * @return void 34 | */ 35 | public function down() 36 | { 37 | Schema::dropIfExists('entries'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->morphs('tokenable'); 17 | $table->string('name'); 18 | $table->string('token', 64)->unique(); 19 | $table->text('abilities')->nullable(); 20 | $table->timestamp('last_used_at')->nullable(); 21 | $table->timestamp('expires_at')->nullable(); 22 | $table->timestamps(); 23 | }); 24 | } 25 | 26 | /** 27 | * Reverse the migrations. 28 | */ 29 | public function down(): void 30 | { 31 | Schema::dropIfExists('personal_access_tokens'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /database/seeders/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | create(); 20 | 21 | User::factory()->admin() 22 | ->has(Entry::factory()->count(30)) 23 | ->create(['name' => 'Admin', 'email' => 'admin@gmail.com']); 24 | 25 | User::factory()->manager() 26 | ->has(Entry::factory()->count(30)) 27 | ->create(['name' => 'Manager', 'email' => 'user@gmail.com']); 28 | 29 | User::factory()->count(5) 30 | ->has(Entry::factory()->count(30)) 31 | ->create(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | 4 | php: 5 | build: ./ 6 | volumes: 7 | - .:/app:cached 8 | - ./docker/php.ini:/usr/local/etc/php/conf.d/custom.ini 9 | - ./docker/supervisord.conf:/etc/supervisord.conf 10 | links: 11 | - mariadb 12 | - mailcatcher 13 | - redis 14 | 15 | nginx: 16 | image: nginx:alpine 17 | ports: 18 | - 8000:80 19 | volumes: 20 | - .:/app 21 | - ./docker/default.conf:/etc/nginx/conf.d/default.conf 22 | links: 23 | - php 24 | 25 | mariadb: 26 | image: mariadb:latest 27 | ports: 28 | - 33060:3306 29 | volumes: 30 | - mysql:/var/lib/mysql 31 | environment: 32 | MYSQL_ROOT_PASSWORD: secret 33 | MYSQL_DATABASE: running_time 34 | MYSQL_USER: running_time 35 | MYSQL_PASS: secret 36 | 37 | mailcatcher: 38 | image: schickling/mailcatcher 39 | ports: 40 | - 1080:1080 41 | 42 | redis: 43 | image: redis:3-alpine 44 | ports: 45 | - 63790:6379 46 | 47 | volumes: 48 | mysql: 49 | -------------------------------------------------------------------------------- /docker/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | #charset koi8-r; 5 | #access_log /var/log/nginx/log/host.access.log main; 6 | 7 | client_max_body_size 200M; 8 | 9 | index index.html index.php; 10 | error_log /var/log/nginx/error.log; 11 | access_log /var/log/nginx/access.log; 12 | root /app/public; 13 | 14 | location / { 15 | try_files $uri $uri/ /index.php?$query_string; 16 | } 17 | 18 | error_page 404 /404.html; 19 | 20 | # redirect server error pages to the static page /50x.html 21 | error_page 500 502 503 504 /50x.html; 22 | location = /50x.html { 23 | root /usr/share/nginx/html; 24 | } 25 | 26 | # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 27 | location ~* \.php$ { 28 | try_files $uri =404; 29 | fastcgi_split_path_info ^(.+\.php)(/.+)$; 30 | fastcgi_pass php:9000; 31 | fastcgi_index index.php; 32 | include fastcgi_params; 33 | fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; 34 | fastcgi_param PATH_INFO $fastcgi_path_info; 35 | } 36 | 37 | # deny access to .htaccess files, if Apache's document root 38 | # concurs with nginx's one 39 | location ~ /\.ht { 40 | deny all; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /docker/php.ini: -------------------------------------------------------------------------------- 1 | memory_limit = 256M 2 | opcache.validate_timestamps = 1 3 | opcache.revalidate_freq = 1 4 | -------------------------------------------------------------------------------- /docker/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | logfile=/var/log/supervisor/supervisord.log 4 | pidfile=/var/run/supervisord.pid 5 | childlogdir=/var/log/supervisor 6 | 7 | [program:php-fpm] 8 | process_name=%(program_name)s_%(process_num)02d 9 | command=/usr/local/sbin/php-fpm -F 10 | numprocs=1 11 | stdout_logfile=/dev/stdout 12 | stdout_logfile_maxbytes=0 13 | stderr_logfile=/dev/stderr 14 | stderr_logfile_maxbytes=0 15 | autorestart=true 16 | startretries=0 17 | 18 | [program:laravel-queue] 19 | process_name=%(program_name)s_%(process_num)02d 20 | command=php /app/artisan queue:work --sleep=1 --tries=3 21 | numprocs=2 22 | redirect_stderr=true 23 | stdout_logfile=/app/storage/logs/queue.log 24 | autorestart=true 25 | startretries=0 26 | -------------------------------------------------------------------------------- /lang/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "The :attribute must contain at least one letter.": "The :attribute must contain at least one letter.", 3 | "The :attribute must contain at least one number.": "The :attribute must contain at least one number.", 4 | "The :attribute must contain at least one symbol.": "The :attribute must contain at least one symbol.", 5 | "The :attribute must contain at least one uppercase and one lowercase letter.": "The :attribute must contain at least one uppercase and one lowercase letter.", 6 | "The given :attribute has appeared in a data leak. Please choose a different :attribute.": "The given :attribute has appeared in a data leak. Please choose a different :attribute." 7 | } 8 | -------------------------------------------------------------------------------- /lang/en/auth.php: -------------------------------------------------------------------------------- 1 | 'These credentials do not match our records.', 17 | 'password' => 'The provided password is incorrect.', 18 | 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 19 | 20 | ]; 21 | -------------------------------------------------------------------------------- /lang/en/pagination.php: -------------------------------------------------------------------------------- 1 | '« Previous', 17 | 'next' => 'Next »', 18 | 19 | ]; 20 | -------------------------------------------------------------------------------- /lang/en/passwords.php: -------------------------------------------------------------------------------- 1 | 'Your password has been reset!', 17 | 'sent' => 'We have emailed your password reset link!', 18 | 'throttled' => 'Please wait before retrying.', 19 | 'token' => 'This password reset token is invalid.', 20 | 'user' => "We can't find a user with that email address.", 21 | 22 | ]; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "npm run development", 5 | "development": "mix", 6 | "watch": "mix watch", 7 | "watch-poll": "mix watch -- --watch-options-poll=1000", 8 | "hot": "mix watch --hot", 9 | "prod": "npm run production", 10 | "production": "mix --production", 11 | "test": "./vendor/bin/phpunit", 12 | "e2e": "node_modules/.bin/cypress open", 13 | "e2e-run": "node_modules/.bin/cypress run", 14 | "lint": "node node_modules/eslint/bin/eslint.js --fix --ext .js,.vue resources/assets/js" 15 | }, 16 | "devDependencies": { 17 | "axios": "^0.25.0", 18 | "babel-eslint": "^10.1.0", 19 | "cypress": "^9.4.1", 20 | "eslint": "^6.2.0", 21 | "eslint-config-standard": "^16.0.3", 22 | "eslint-loader": "^4.0.2", 23 | "eslint-plugin-html": "^6.2.0", 24 | "eslint-plugin-node": "^11.1.0", 25 | "eslint-plugin-promise": "^6.0.0", 26 | "eslint-plugin-standard": "^5.0.0", 27 | "eslint-plugin-vue": "^8.4.1", 28 | "laravel-mix": "^6.0.6", 29 | "lodash": "^4.17.19", 30 | "postcss": "^8.1.14", 31 | "resolve-url-loader": "^5.0.0", 32 | "sass": "^1.49.7", 33 | "sass-loader": "^12.4.0", 34 | "vue-loader": "^17.0.0", 35 | "vue-template-compiler": "^2.6.14" 36 | }, 37 | "dependencies": { 38 | "bootstrap-sass": "^3.4.1", 39 | "chart.js": "^3.7.1", 40 | "core-js": "^3.21.0", 41 | "faker": "^6.6.6", 42 | "jquery": "^3.6.0", 43 | "lodash-es": "^4.17.21", 44 | "moment-mini": "^2.24.0", 45 | "vue": "^3.2.30", 46 | "vue-chartjs": "^4.0.5", 47 | "vue-router": "^4.0.12", 48 | "vuex": "^4.0.2", 49 | "vuex-router-sync": "^5.0.0", 50 | "webpack-bundle-analyzer": "^4.5.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | ./tests/Feature 11 | 12 | 13 | 14 | 15 | ./app 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews -Indexes 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Handle Authorization Header 9 | RewriteCond %{HTTP:Authorization} . 10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 11 | 12 | # Redirect Trailing Slashes If Not A Folder... 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_URI} (.+)/$ 15 | RewriteRule ^ %1 [L,R=301] 16 | 17 | # Send Requests To Front Controller... 18 | RewriteCond %{REQUEST_FILENAME} !-d 19 | RewriteCond %{REQUEST_FILENAME} !-f 20 | RewriteRule ^ index.php [L] 21 | 22 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vedmant/running-time/70e872a54276d894e042eff956eac28d590dcb6e/public/favicon.ico -------------------------------------------------------------------------------- /public/fonts/vendor/bootstrap-sass/bootstrap/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vedmant/running-time/70e872a54276d894e042eff956eac28d590dcb6e/public/fonts/vendor/bootstrap-sass/bootstrap/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /public/fonts/vendor/bootstrap-sass/bootstrap/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vedmant/running-time/70e872a54276d894e042eff956eac28d590dcb6e/public/fonts/vendor/bootstrap-sass/bootstrap/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /public/fonts/vendor/bootstrap-sass/bootstrap/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vedmant/running-time/70e872a54276d894e042eff956eac28d590dcb6e/public/fonts/vendor/bootstrap-sass/bootstrap/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /public/fonts/vendor/bootstrap-sass/bootstrap/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vedmant/running-time/70e872a54276d894e042eff956eac28d590dcb6e/public/fonts/vendor/bootstrap-sass/bootstrap/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | make(Kernel::class); 50 | 51 | $response = $kernel->handle( 52 | $request = Request::capture() 53 | )->send(); 54 | 55 | $kernel->terminate($request, $response); 56 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /public/vendor/telescope/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vedmant/running-time/70e872a54276d894e042eff956eac28d590dcb6e/public/vendor/telescope/favicon.ico -------------------------------------------------------------------------------- /public/vendor/telescope/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/app.js": "/app.js?id=613c227dfb4d6e1fc4db1b1a90513610", 3 | "/app-dark.css": "/app-dark.css?id=b11fa9a28e9d3aeb8c92986f319b3c44", 4 | "/app.css": "/app.css?id=b3ccfbe68f24cff776f83faa8dead721" 5 | } 6 | -------------------------------------------------------------------------------- /resources/assets/js/app.js: -------------------------------------------------------------------------------- 1 | import 'core-js/stable' 2 | import 'regenerator-runtime/runtime' 3 | import { createApp } from 'vue' 4 | import axios from 'axios' 5 | import jQuery from 'jquery' 6 | import moment from 'moment-mini' 7 | import store from './vuex/store' // vuex store instance 8 | import router from './router' // vue-router instance 9 | import './mixins' 10 | import App from './components/App' 11 | import Navbar from './components/layout/Navbar' 12 | import Spinner from './components/layout/Spinner' 13 | import Toast from './components/layout/Toast' 14 | import mixins from './mixins' 15 | 16 | /** 17 | * Assing global variables 18 | */ 19 | 20 | window.$ = window.jQuery = jQuery 21 | window.moment = moment 22 | 23 | /** 24 | * Require jQuery and Vue dependant libaries 25 | */ 26 | 27 | require('bootstrap-sass') 28 | 29 | /** 30 | * Vue Settings 31 | */ 32 | 33 | // Authorization header 34 | axios.interceptors.request.use(function (config) { 35 | config['headers'] = { 36 | Authorization: 'Bearer ' + localStorage.getItem('access_token'), 37 | Accept: 'application/json', 38 | } 39 | return config 40 | }, error => Promise.reject(error)) 41 | 42 | // Show toast with message for non OK responses 43 | axios.interceptors.response.use(response => response, error => { 44 | // Ignore /me url 45 | if (! error.response.config.url.includes('/me')) { 46 | store.dispatch('addToastMessage', { 47 | text: error.response.data.message || 'Request error status: ' + error.response.status, 48 | type: 'danger', 49 | }) 50 | } 51 | return Promise.reject(error) 52 | }) 53 | 54 | /** 55 | * Application 56 | */ 57 | const app = createApp(App) 58 | app.use(router) 59 | app.use(store) 60 | 61 | // Global Vue Components 62 | app.component('Navbar', Navbar) 63 | app.component('Spinner', Spinner) 64 | app.component('Toast', Toast) 65 | 66 | app.mixin(mixins) 67 | 68 | app.mount('#app') // Vue Instance - Root component 69 | 70 | if (window.Cypress) { 71 | window.store = store 72 | } 73 | -------------------------------------------------------------------------------- /resources/assets/js/components/App.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /resources/assets/js/components/layout/Spinner.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 36 | 37 | 99 | -------------------------------------------------------------------------------- /resources/assets/js/components/layout/Toast.vue: -------------------------------------------------------------------------------- 1 | 127 | 128 | 138 | 139 | 172 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/404.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 20 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/DeleteAccount.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 20 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/admin/Admin.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 51 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/admin/dashboard/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 85 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/admin/entry/Edit.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 96 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/admin/entry/List.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 126 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/admin/entry/partials/Row.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 34 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/admin/user/Edit.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 90 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/admin/user/List.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 110 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/admin/user/Show.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 64 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/admin/user/partials/Form.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 92 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/admin/user/partials/Row.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 38 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/auth/Login.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 99 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/auth/Logout.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 22 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/auth/Register.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 110 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/dashboard/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 140 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/entry/Edit.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 96 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/entry/List.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 126 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/entry/New.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 72 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/entry/partials/Form.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 96 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/entry/partials/Row.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 32 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/front/Front.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 21 | -------------------------------------------------------------------------------- /resources/assets/js/components/pages/report/Weekly.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 107 | -------------------------------------------------------------------------------- /resources/assets/js/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Base api path 3 | * 4 | * @type {string} 5 | */ 6 | export const apiPath = '/api/v1/' 7 | -------------------------------------------------------------------------------- /resources/assets/js/mixins.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment-mini' 2 | import padStart from 'lodash-es/padStart' 3 | 4 | export default { 5 | methods: { 6 | range (begin, end) { 7 | begin = parseInt(begin) 8 | end = parseInt(end) 9 | 10 | let result = [] 11 | 12 | if (begin < end) { 13 | for (let i = begin; i <= end; i++) { 14 | result.push(i) 15 | } 16 | } else { 17 | for (let i = begin; i >= end; i--) { 18 | result.push(i) 19 | } 20 | } 21 | 22 | return result 23 | }, 24 | 25 | secondsToTime (seconds) { 26 | const duration = moment.duration(parseInt(seconds), 'seconds') 27 | const hours = Math.floor(duration.asHours()) 28 | 29 | return (hours ? hours + ':' : '') + 30 | padStart(duration.minutes(), 2, '0') + 31 | ':' + padStart(duration.seconds(), 2, '0') 32 | }, 33 | 34 | formatDate (date) { 35 | return moment(date).format('MM/DD/YYYY') 36 | }, 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /resources/assets/js/router.js: -------------------------------------------------------------------------------- 1 | import { createWebHashHistory, createRouter } from 'vue-router' 2 | import store from './vuex/store' 3 | import { sync } from 'vuex-router-sync' 4 | 5 | import Front from './components/pages/front/Front' 6 | 7 | import Login from './components/pages/auth/Login' 8 | import Logout from './components/pages/auth/Logout' 9 | import Register from './components/pages/auth/Register' 10 | 11 | import Dashboard from './components/pages/dashboard/Dashboard' 12 | 13 | import EntryList from './components/pages/entry/List' 14 | import EntryNew from './components/pages/entry/New' 15 | import EntryEdit from './components/pages/entry/Edit' 16 | 17 | import ReportWeekly from './components/pages/report/Weekly' 18 | 19 | import Admin from './components/pages/admin/Admin' 20 | import AdminDashboard from './components/pages/admin/dashboard/Dashboard' 21 | import UserList from './components/pages/admin/user/List' 22 | import UserShow from './components/pages/admin/user/Show' 23 | import UserEdit from './components/pages/admin/user/Edit' 24 | 25 | import AdminEntryList from './components/pages/admin/entry/List' 26 | import AdminEntryEdit from './components/pages/admin/entry/Edit' 27 | import Error404 from './components/pages/404' 28 | import Profile from './components/pages/auth/Profile' 29 | import Policy from './components/pages/Policy' 30 | import DeleteAccount from './components/pages/DeleteAccount' 31 | 32 | const routes = [ 33 | { path: '/', component: Front }, 34 | 35 | { path: '/policy', component: Policy }, 36 | { path: '/delete-account', component: DeleteAccount }, 37 | { path: '/login', component: Login, meta: { guestOnly: true } }, 38 | { path: '/logout', component: Logout, meta: { requiresAuth: true } }, 39 | { path: '/register', component: Register, meta: { guestOnly: true } }, 40 | { path: '/profile', component: Profile, meta: { requiresAuth: true } }, 41 | 42 | { path: '/dashboard', component: Dashboard, meta: { requiresAuth: true } }, 43 | 44 | { path: '/entries', component: EntryList, meta: { requiresAuth: true } }, 45 | { path: '/entry/new', component: EntryNew, meta: { requiresAuth: true } }, 46 | { path: '/entry/edit/:id', component: EntryEdit, meta: { requiresAuth: true } }, 47 | 48 | { path: '/report/weekly', component: ReportWeekly, meta: { requiresAuth: true } }, 49 | 50 | { 51 | path: '/admin', 52 | component: Admin, 53 | meta: { requiresAdmin: true }, 54 | children: [ 55 | { path: '', redirect: '/admin/dashboard' }, 56 | { path: 'dashboard', component: AdminDashboard }, 57 | 58 | { path: 'users', component: UserList }, 59 | { path: 'user/show/:id', component: UserShow }, 60 | { path: 'user/edit/:id', component: UserEdit }, 61 | 62 | { path: 'entries', component: AdminEntryList }, 63 | { path: 'entry/edit/:id', component: AdminEntryEdit }, 64 | ], 65 | }, 66 | 67 | { 68 | path: '/:catchAll(.*)', // Unrecognized path automatically matches 404 69 | component: Error404, 70 | }, 71 | ] 72 | 73 | 74 | const router = createRouter({ 75 | history: createWebHashHistory(), 76 | routes, 77 | }) 78 | 79 | // Sync Vuex and vue-router; 80 | sync(store, router) 81 | 82 | /** 83 | * Authenticated routes 84 | */ 85 | router.beforeEach(async (to, from, next) => { 86 | if (! store.state.auth.me && ! store.state.auth.authChecked) { 87 | await store.dispatch('checkLogin') 88 | .catch(() => { 89 | }) 90 | } 91 | const me = store.state.auth.me 92 | 93 | if (to.matched.some(record => record.meta.guestOnly) && me) { 94 | // Guest only page, dont follow there when user is authenticated 95 | next(false) 96 | } else if (to.matched.some(record => record.meta.requiresAuth) && ! me) { 97 | // if route requires auth and user isn't authenticated 98 | next('/login') 99 | } else if (to.matched.some(record => record.meta.requiresAdmin) && 100 | (! me || ! ['admin', 'manager'].includes(store.state.auth.me.role))) { 101 | // if route required admin or manager role 102 | next('/login') 103 | } else { 104 | next() 105 | } 106 | }) 107 | 108 | 109 | export default router 110 | -------------------------------------------------------------------------------- /resources/assets/js/vuex/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Mutation creator helper 3 | * 4 | * @param types 5 | * @param fn 6 | * @return {{}} 7 | */ 8 | export function makeMutations (types, fn) { 9 | const res = {} 10 | types.forEach(type => { 11 | res[type] = fn 12 | }) 13 | 14 | return res 15 | } 16 | -------------------------------------------------------------------------------- /resources/assets/js/vuex/modules/all-entries.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import * as Config from '../../config' 3 | 4 | const state = { 5 | entries: { 6 | current_page: 1, 7 | data: [], 8 | }, 9 | } 10 | 11 | const actions = { 12 | 13 | loadAllEntries ({ commit, dispatch }, params) { 14 | commit('LOAD_ALL_ENTRIES') 15 | 16 | return new Promise((resolve, reject) => { 17 | axios.get(Config.apiPath + 'entry/all', { params }) 18 | .then( 19 | response => { 20 | commit('LOAD_ALL_ENTRIES_OK', response.data.entries) 21 | resolve() 22 | }) 23 | .catch(error => { 24 | commit('LOAD_ALL_ENTRIES_FAIL') 25 | reject(error.response.data) 26 | }) 27 | }) 28 | }, 29 | 30 | } 31 | 32 | const mutations = { 33 | 34 | LOAD_ALL_ENTRIES_OK (state, entries) { 35 | state.entries = entries 36 | }, 37 | 38 | } 39 | 40 | export default { 41 | state, 42 | actions, 43 | mutations, 44 | } 45 | -------------------------------------------------------------------------------- /resources/assets/js/vuex/modules/auth.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import * as Config from '../../config' 3 | 4 | const state = { 5 | me: null, // Logged in user 6 | accessToken: localStorage.getItem('access_token'), 7 | authChecked: false, 8 | } 9 | 10 | const actions = { 11 | 12 | checkLogin ({ commit }) { 13 | commit('CHECK_LOGIN') 14 | 15 | return new Promise((resolve, reject) => { 16 | axios.get(Config.apiPath + 'user/me') 17 | .then( 18 | response => { 19 | commit('CHECK_LOGIN_OK', response.data) 20 | resolve() 21 | }) 22 | .catch(error => { 23 | commit('CHECK_LOGIN_FAIL') 24 | reject(error.response.data) 25 | }) 26 | }) 27 | }, 28 | 29 | login ({ commit, dispatch }, form) { 30 | commit('LOGIN') 31 | 32 | return new Promise((resolve, reject) => { 33 | axios.post(Config.apiPath + 'auth/login', form) 34 | .then( 35 | response => { 36 | const accessToken = response.data.access_token 37 | localStorage.setItem('access_token', accessToken) 38 | 39 | commit('LOGIN_OK', { user: response.data.user, accessToken }) 40 | resolve() 41 | }) 42 | .catch(error => { 43 | commit('LOGIN_FAIL') 44 | reject(error.response.data) 45 | }) 46 | }) 47 | }, 48 | 49 | logout ({ commit }) { 50 | commit('LOGOUT_OK') 51 | 52 | localStorage.removeItem('access_token') 53 | }, 54 | 55 | register ({ commit, dispatch }, form) { 56 | commit('REGISTER') 57 | 58 | return new Promise((resolve, reject) => { 59 | axios.post(Config.apiPath + 'auth/register', form) 60 | .then( 61 | response => { 62 | const accessToken = response.data.access_token 63 | localStorage.setItem('access_token', accessToken) 64 | 65 | commit('REGISTER_OK', { user: response.data.user, accessToken }) 66 | resolve() 67 | }) 68 | .catch(error => { 69 | commit('REGISTER_FAIL') 70 | reject(error.response.data) 71 | }) 72 | }) 73 | }, 74 | 75 | updateProfile ({ commit, dispatch }, { id, form }) { 76 | commit('UPDATE_PROFILE') 77 | 78 | return new Promise((resolve, reject) => { 79 | axios.post(Config.apiPath + 'user/' + id, { _method: 'PUT', ...form }) 80 | .then( 81 | response => { 82 | commit('UPDATE_PROFILE_OK', response.data.user) 83 | resolve() 84 | }) 85 | .catch(error => { 86 | commit('UPDATE_PROFILE_FAIL') 87 | reject(error.response.data) 88 | }) 89 | }) 90 | }, 91 | 92 | } 93 | 94 | const mutations = { 95 | 96 | CHECK_LOGIN_OK (state, user) { 97 | state.me = user 98 | state.authChecked = true 99 | }, 100 | 101 | CHECK_LOGIN_FAIL (state) { 102 | state.accessToken = null 103 | state.authChecked = true 104 | }, 105 | 106 | LOGIN_OK (state, { user, accessToken }) { 107 | state.me = user 108 | state.accessToken = accessToken 109 | }, 110 | 111 | LOGOUT_OK (state) { 112 | state.me = null 113 | state.accessToken = null 114 | }, 115 | 116 | REGISTER_OK (state, { user, accessToken }) { 117 | state.me = user 118 | state.accessToken = accessToken 119 | }, 120 | 121 | UPDATE_PROFILE_OK (state, user) { 122 | state.me = user 123 | }, 124 | 125 | } 126 | 127 | export default { 128 | state, 129 | actions, 130 | mutations, 131 | } 132 | -------------------------------------------------------------------------------- /resources/assets/js/vuex/modules/entries.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import * as Config from '../../config' 3 | 4 | const state = { 5 | entries: { 6 | current_page: 1, 7 | data: [], 8 | }, 9 | entry: {}, 10 | } 11 | 12 | const actions = { 13 | 14 | loadEntries ({ commit, dispatch }, params) { 15 | commit('LOAD_ENTRIES') 16 | 17 | return new Promise((resolve, reject) => { 18 | axios.get(Config.apiPath + 'entry', { params }) 19 | .then( 20 | response => { 21 | commit('LOAD_ENTRIES_OK', response.data.entries) 22 | resolve() 23 | }) 24 | .catch(error => { 25 | commit('LOAD_ENTRIES_FAIL') 26 | reject(error.response.data) 27 | }) 28 | }) 29 | }, 30 | 31 | loadEntry ({ commit, dispatch }, id) { 32 | commit('LOAD_ENTRY') 33 | 34 | return new Promise((resolve, reject) => { 35 | axios.get(Config.apiPath + 'entry/' + id) 36 | .then( 37 | response => { 38 | commit('LOAD_ENTRY_OK', response.data.entry) 39 | resolve() 40 | }) 41 | .catch(error => { 42 | commit('LOAD_ENTRY_FAIL') 43 | reject(error.response.data) 44 | }) 45 | }) 46 | }, 47 | 48 | storeEntry ({ commit, dispatch }, form) { 49 | commit('STORE_ENTRY') 50 | 51 | return new Promise((resolve, reject) => { 52 | axios.post(Config.apiPath + 'entry', form) 53 | .then( 54 | response => { 55 | commit('STORE_ENTRY_OK', response.data.entry) 56 | resolve() 57 | }) 58 | .catch(error => { 59 | commit('STORE_ENTRY_FAIL') 60 | reject(error.response.data) 61 | }) 62 | }) 63 | }, 64 | 65 | updateEntry ({ commit, dispatch }, { id, form }) { 66 | commit('UPDATE_ENTRY') 67 | 68 | return new Promise((resolve, reject) => { 69 | axios.post(Config.apiPath + 'entry/' + id, { _method: 'PUT', ...form }) 70 | .then( 71 | response => { 72 | commit('UPDATE_ENTRY_OK', response.data.entry) 73 | resolve() 74 | }) 75 | .catch(error => { 76 | commit('UPDATE_ENTRY_FAIL') 77 | reject(error.response.data) 78 | }) 79 | }) 80 | }, 81 | 82 | deleteEntry ({ commit, dispatch }, id) { 83 | commit('DELETE_ENTRY') 84 | 85 | return new Promise((resolve, reject) => { 86 | axios.post(Config.apiPath + 'entry/' + id, { _method: 'DELETE' }) 87 | .then( 88 | response => { 89 | commit('DELETE_ENTRY_OK', id) 90 | resolve() 91 | }) 92 | .catch(error => { 93 | commit('DELETE_ENTRY_FAIL') 94 | reject(error.response.data) 95 | }) 96 | }) 97 | }, 98 | 99 | } 100 | 101 | const mutations = { 102 | 103 | LOAD_ENTRIES_OK (state, entries) { 104 | state.entries = entries 105 | }, 106 | 107 | LOAD_ENTRY_OK (state, entry) { 108 | state.entry = entry 109 | }, 110 | 111 | } 112 | 113 | export default { 114 | state, 115 | actions, 116 | mutations, 117 | } 118 | -------------------------------------------------------------------------------- /resources/assets/js/vuex/modules/general.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { makeMutations } from '../helpers' 3 | import * as Config from '../../config' 4 | 5 | const state = { 6 | loading: false, 7 | dashboard: {}, 8 | admin_dashboard: { 9 | fastest_run: { user: {} }, 10 | longest_run: { user: {} }, 11 | }, 12 | } 13 | 14 | const actions = { 15 | 16 | stopLoading ({ commit }) { 17 | commit('STOP_LOADING') 18 | }, 19 | 20 | loadDashboard ({ commit, dispatch }) { 21 | commit('LOAD_DASHBOARD') 22 | 23 | return new Promise((resolve, reject) => { 24 | axios.get(Config.apiPath + 'dashboard/data') 25 | .then( 26 | response => { 27 | commit('LOAD_DASHBOARD_OK', response.data) 28 | resolve() 29 | }) 30 | .catch(error => { 31 | commit('LOAD_DASHBOARD_FAIL') 32 | reject(error.response.data) 33 | }) 34 | }) 35 | }, 36 | 37 | loadAdminDashboard ({ commit, dispatch }) { 38 | commit('LOAD_ADMIN_DASHBOARD') 39 | 40 | return new Promise((resolve, reject) => { 41 | axios.get(Config.apiPath + 'dashboard/admin-data') 42 | .then( 43 | response => { 44 | commit('LOAD_ADMIN_DASHBOARD_OK', response.data) 45 | resolve() 46 | }) 47 | .catch(error => { 48 | commit('LOAD_ADMIN_DASHBOARD_FAIL') 49 | reject(error.response.data) 50 | }) 51 | }) 52 | }, 53 | 54 | } 55 | 56 | const mutations = { 57 | 58 | ...makeMutations([ 59 | 'CHECK_LOGIN', 60 | 'LOGIN', 61 | 'REGISTER', 62 | 'UPDATE_PROFILE', 63 | 'LOAD_DASHBOARD', 64 | 'LOAD_ADMIN_DASHBOARD', 65 | 'LOAD_ENTRIES', 66 | 'LOAD_ALL_ENTRIES', 67 | 'LOAD_ENTRY', 68 | 'STORE_ENTRY', 69 | 'UPDATE_ENTRY', 70 | 'DELETE_ENTRY', 71 | 'LOAD_USERS', 72 | 'LOAD_USER', 73 | 'UPDATE_USER', 74 | 'DELETE_USER', 75 | 'LOAD_WEEKLY_REPORT', 76 | ], (state) => { 77 | state.loading = true 78 | }), 79 | 80 | ...makeMutations([ 81 | 'STOP_LOADING', 82 | 'CHECK_LOGIN_OK', 83 | 'CHECK_LOGIN_FAIL', 84 | 'LOGIN_OK', 85 | 'LOGIN_FAIL', 86 | 'REGISTER_OK', 87 | 'REGISTER_FAIL', 88 | 'UPDATE_PROFILE_OK', 89 | 'UPDATE_PROFILE_FAIL', 90 | 'LOAD_DASHBOARD_FAIL', 91 | 'LOAD_ADMIN_DASHBOARD_FAIL', 92 | 'LOAD_ENTRIES_OK', 93 | 'LOAD_ENTRIES_FAIL', 94 | 'LOAD_ALL_ENTRIES_OK', 95 | 'LOAD_ALL_ENTRIES_FAIL', 96 | 'LOAD_ENTRY_OK', 97 | 'LOAD_ENTRY_FAIL', 98 | 'STORE_ENTRY_OK', 99 | 'STORE_ENTRY_FAIL', 100 | 'UPDATE_ENTRY_OK', 101 | 'UPDATE_ENTRY_FAIL', 102 | 'DELETE_ENTRY_OK', 103 | 'DELETE_ENTRY_FAIL', 104 | 'LOAD_USERS_OK', 105 | 'LOAD_USERS_FAIL', 106 | 'LOAD_USER_OK', 107 | 'LOAD_USER_FAIL', 108 | 'UPDATE_USER_OK', 109 | 'UPDATE_USER_FAIL', 110 | 'DELETE_USER_OK', 111 | 'DELETE_USER_FAIL', 112 | 'LOAD_WEEKLY_REPORT_OK', 113 | 'LOAD_WEEKLY_REPORT_FAIL', 114 | ], (state) => { 115 | state.loading = false 116 | }), 117 | 118 | LOAD_DASHBOARD_OK (state, dashboard) { 119 | state.dashboard = dashboard 120 | state.loading = false 121 | }, 122 | 123 | LOAD_ADMIN_DASHBOARD_OK (state, dashboard) { 124 | state.admin_dashboard = dashboard 125 | state.loading = false 126 | }, 127 | 128 | } 129 | 130 | export default { 131 | state, 132 | actions, 133 | mutations, 134 | } 135 | -------------------------------------------------------------------------------- /resources/assets/js/vuex/modules/reports.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import * as Config from '../../config' 3 | import moment from 'moment-mini' 4 | 5 | const state = { 6 | weekly: { 7 | year: moment().year(), 8 | min_year: '', 9 | max_year: '', 10 | data: [], 11 | }, 12 | } 13 | 14 | const actions = { 15 | 16 | loadWeeklyReport ({ commit, dispatch }, params) { 17 | commit('LOAD_WEEKLY_REPORT') 18 | 19 | return new Promise((resolve, reject) => { 20 | axios.get(Config.apiPath + 'report/weekly', { params }) 21 | .then( 22 | response => { 23 | commit('LOAD_WEEKLY_REPORT_OK', response.data.weekly) 24 | resolve() 25 | }) 26 | .catch(error => { 27 | commit('LOAD_WEEKLY_REPORT_FAIL') 28 | reject(error.response.data) 29 | }) 30 | }) 31 | }, 32 | 33 | } 34 | 35 | const mutations = { 36 | 37 | LOAD_WEEKLY_REPORT_OK (state, report) { 38 | state.weekly = report 39 | }, 40 | 41 | } 42 | 43 | export default { 44 | state, 45 | actions, 46 | mutations, 47 | } 48 | -------------------------------------------------------------------------------- /resources/assets/js/vuex/modules/toast.js: -------------------------------------------------------------------------------- 1 | let maxToastId = 0 2 | 3 | const state = { 4 | messages: [], 5 | } 6 | 7 | const getters = { 8 | toastMessages: (state) => state.messages, 9 | } 10 | 11 | const actions = { 12 | addToastMessage ({ commit }, { text, type = 'info', dismissAfter = 5000 }) { 13 | const id = ++maxToastId 14 | 15 | commit('ADD_TOAST_MESSAGE', { 16 | id, 17 | text, 18 | type, 19 | dismissAfter, 20 | }) 21 | setTimeout(() => commit('REMOVE_TOAST_MESSAGE', id), dismissAfter) 22 | }, 23 | 24 | removeToastMessage ({ commit }, id) { 25 | commit('REMOVE_TOAST_MESSAGE', id) 26 | }, 27 | } 28 | 29 | const mutations = { 30 | ADD_TOAST_MESSAGE (state, data) { 31 | state.messages.push(data) 32 | }, 33 | 34 | REMOVE_TOAST_MESSAGE (state, id) { 35 | state.messages = state.messages.filter(m => m.id !== id) 36 | }, 37 | } 38 | 39 | export default { 40 | state, 41 | getters, 42 | actions, 43 | mutations, 44 | } 45 | -------------------------------------------------------------------------------- /resources/assets/js/vuex/modules/users.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import * as Config from '../../config' 3 | 4 | const state = { 5 | users: { 6 | current_page: 1, 7 | data: [], 8 | }, 9 | user: {}, 10 | } 11 | 12 | const actions = { 13 | 14 | loadUsers ({ commit, dispatch }, params) { 15 | commit('LOAD_USERS') 16 | 17 | return new Promise((resolve, reject) => { 18 | axios.get(Config.apiPath + 'user', { params }) 19 | .then( 20 | response => { 21 | commit('LOAD_USERS_OK', response.data.users) 22 | resolve() 23 | }) 24 | .catch(error => { 25 | commit('LOAD_USERS_FAIL') 26 | reject(error.response.data) 27 | }) 28 | }) 29 | }, 30 | 31 | loadUser ({ commit, dispatch }, id) { 32 | commit('LOAD_USER') 33 | 34 | return new Promise((resolve, reject) => { 35 | axios.get(Config.apiPath + 'user/' + id) 36 | .then( 37 | response => { 38 | commit('LOAD_USER_OK', response.data.user) 39 | resolve() 40 | }) 41 | .catch(error => { 42 | commit('LOAD_USER_FAIL') 43 | reject(error.response.data) 44 | }) 45 | }) 46 | }, 47 | 48 | updateUser ({ commit, dispatch }, { id, form }) { 49 | commit('UPDATE_USER') 50 | 51 | return new Promise((resolve, reject) => { 52 | axios.post(Config.apiPath + 'user/' + id, { _method: 'PUT', ...form }) 53 | .then( 54 | response => { 55 | commit('UPDATE_USER_OK', response.data.user) 56 | resolve() 57 | }) 58 | .catch(error => { 59 | commit('UPDATE_USER_FAIL') 60 | reject(error.response.data) 61 | }) 62 | }) 63 | }, 64 | 65 | deleteUser ({ commit, dispatch }, id) { 66 | commit('DELETE_USER') 67 | 68 | return new Promise((resolve, reject) => { 69 | axios.post(Config.apiPath + 'user/' + id, { _method: 'DELETE' }) 70 | .then( 71 | response => { 72 | commit('DELETE_USER_OK', id) 73 | resolve() 74 | }) 75 | .catch(error => { 76 | commit('DELETE_USER_FAIL') 77 | reject(error.response.data) 78 | }) 79 | }) 80 | }, 81 | 82 | } 83 | 84 | const mutations = { 85 | 86 | LOAD_USERS_OK (state, users) { 87 | state.users = users 88 | }, 89 | 90 | LOAD_USER_OK (state, user) { 91 | state.user = user 92 | }, 93 | 94 | UPDATE_USER_OK (state, user) { 95 | state.user = user 96 | }, 97 | 98 | } 99 | 100 | export default { 101 | state, 102 | actions, 103 | mutations, 104 | } 105 | -------------------------------------------------------------------------------- /resources/assets/js/vuex/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, createLogger } from 'vuex' 2 | import auth from './modules/auth' 3 | import toast from './modules/toast' 4 | import entries from './modules/entries' 5 | import allEntries from './modules/all-entries' 6 | import users from './modules/users' 7 | import general from './modules/general' 8 | import reports from './modules/reports' 9 | 10 | const debug = process.env.NODE_ENV !== 'production' 11 | 12 | export default new createStore({ 13 | strict: debug, 14 | plugins: debug ? [createLogger()] : [], 15 | modules: { 16 | auth, 17 | toast, 18 | entries, 19 | all_entries: allEntries, 20 | users, 21 | general, 22 | reports, 23 | }, 24 | }) 25 | -------------------------------------------------------------------------------- /resources/assets/sass/_variables.scss: -------------------------------------------------------------------------------- 1 | // Body 2 | $body-bg: #f5f8fa; 3 | 4 | // Borders 5 | $laravel-border-color: darken($body-bg, 10%); 6 | $list-group-border: $laravel-border-color; 7 | $navbar-default-border: $laravel-border-color; 8 | $panel-default-border: $laravel-border-color; 9 | $panel-inner-border: $laravel-border-color; 10 | 11 | // Brands 12 | $brand-primary: #3097d1; 13 | $brand-info: #8eb4cb; 14 | $brand-success: #2ab27b; 15 | $brand-warning: #cbb956; 16 | $brand-danger: #bf5329; 17 | 18 | // Typography 19 | $font-family-sans-serif: "Raleway", sans-serif; 20 | $font-size-base: 14px; 21 | $line-height-base: 1.6; 22 | $text-color: #636b6f; 23 | 24 | // Navbar 25 | $navbar-default-bg: #fff; 26 | 27 | // Buttons 28 | $btn-default-color: $text-color; 29 | 30 | // Inputs 31 | $input-border: lighten($text-color, 40%); 32 | $input-border-focus: lighten($brand-primary, 25%); 33 | $input-color-placeholder: lighten($text-color, 30%); 34 | 35 | // Panels 36 | $panel-default-heading-bg: #fff; 37 | -------------------------------------------------------------------------------- /resources/assets/sass/app.scss: -------------------------------------------------------------------------------- 1 | // Fonts 2 | @import url(https://fonts.googleapis.com/css?family=Raleway:300,400,600); 3 | // Variables 4 | @import "variables"; 5 | // Bootstrap 6 | $icon-font-path: '~bootstrap-sass/assets/fonts/bootstrap/'; 7 | @import "~bootstrap-sass/assets/stylesheets/bootstrap"; 8 | 9 | /** 10 | * Transitions 11 | */ 12 | .fade-enter-active, .fade-leave-active{ transition: opacity .2s; } 13 | .fade-enter, .fade-leave-active{ opacity: 0; } 14 | 15 | /** 16 | * Helpers 17 | */ 18 | .middle{ width: 100%; height: 100%; display: table; } 19 | .middle .middle-inner{ width: 100%; height: 100%; display: table-cell; vertical-align: middle; text-align: center; } 20 | .embed-responsive-1by1{ padding-bottom: 100%; } 21 | .no-gutter.row{ margin-left: 0; margin-right: 0; } 22 | .no-gutter > [class*='col-']{ padding-right: 0; padding-left: 0; } 23 | .margintop5{ margin-top: 5px; } 24 | .margintop0{ margin-top: 0; } 25 | .marginbot10{ margin-bottom: 10px; } 26 | .marginpulltop15{ margin-top: -15px; } 27 | .marginpulltop20{ margin-top: -20px; } 28 | .marginright10{ margin-right: 10px; } 29 | @media (max-width: $screen-sm-max){ 30 | .input-group-stack-sm{ display: block; } 31 | } 32 | 33 | /** 34 | * Spinner 35 | */ 36 | .main-spinner{ visibility: hidden; opacity: 0; transition: opacity 0.3s 0.3s ease; 37 | position: fixed; z-index: 99999; top: 0; left: 0; right: 0; bottom: 0; 38 | background-color: rgba(255, 255, 255, 0.5); 39 | } 40 | .main-spinner.active{ visibility: visible; opacity: 1; } 41 | 42 | /** 43 | * Tables 44 | */ 45 | .page-info{ display: inline-block; margin-right: 20px; } 46 | 47 | /** 48 | * Fixes 49 | */ 50 | .input-group-btn:not(:first-child):not(:last-child) .btn{ 51 | border-radius: 0; 52 | } 53 | .github-menu-item { margin-top: 10px; margin-left: 15px; } 54 | -------------------------------------------------------------------------------- /resources/views/app.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {{ config('app.name', 'Running Times') }} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | 'v1'], function () { 24 | /* 25 | * Guest area 26 | */ 27 | Route::post('auth/login', [AuthController::class, 'login']); 28 | Route::post('auth/register', [AuthController::class, 'register']); 29 | Route::post('/login/google', [AuthController::class, 'google']); 30 | 31 | /* 32 | * Authenticated area 33 | */ 34 | Route::group(['middleware' => 'auth:sanctum'], function () { 35 | Route::get('dashboard/data', [DashboardController::class, 'data']); 36 | Route::get('dashboard/admin-data', [DashboardController::class, 'adminData']); 37 | 38 | Route::get('user/me', [UserController::class, 'me']); 39 | Route::resource('user', UserController::class, ['except' => ['create', 'store', 'edit']]); 40 | 41 | Route::get('entry/all', [EntryController::class, 'all']); 42 | Route::resource('entry', EntryController::class, ['except' => ['create', 'edit']]); 43 | 44 | Route::get('report/weekly', [ReportController::class, 'weekly']); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /routes/channels.php: -------------------------------------------------------------------------------- 1 | id === (int) $id; 18 | }); 19 | -------------------------------------------------------------------------------- /routes/console.php: -------------------------------------------------------------------------------- 1 | comment(Inspiring::quote()); 19 | })->purpose('Display an inspiring quote'); 20 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | make(Kernel::class)->bootstrap(); 19 | 20 | return $app; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Feature/AuthTest.php: -------------------------------------------------------------------------------- 1 | has(Entry::factory()->count(30))->create(); 19 | 20 | $this->json('POST', '/api/v1/auth/login', [ 21 | 'email' => $user->email, 22 | 'password' => '123456', 23 | ]) 24 | ->assertOk() 25 | ->assertJsonStructure([ 26 | 'access_token', 27 | 'user' => [ 28 | 'id', 29 | 'email', 30 | 'name' 31 | ] 32 | ]); 33 | } 34 | 35 | public function testWrongLogin() 36 | { 37 | $user = User::factory()->has(Entry::factory()->count(30))->create(); 38 | 39 | $this->json('POST', '/api/v1/auth/login', [ 40 | 'email' => $user->email, 41 | 'password' => 'wrong-pass', 42 | ]) 43 | ->assertStatus(401) 44 | ->assertJsonStructure([ 45 | 'message', 46 | ]); 47 | } 48 | 49 | public function testRegister() 50 | { 51 | $faker = \Faker\Factory::create(); 52 | 53 | $this->json('POST', '/api/v1/auth/register', [ 54 | 'name' => $faker->name, 55 | 'email' => $faker->email, 56 | 'password' => '123456', 57 | 'password_confirmation' => '123456', 58 | ]) 59 | ->assertOk() 60 | ->assertJsonStructure([ 61 | 'access_token', 62 | 'user' => [ 63 | 'id', 64 | 'email', 65 | 'name' 66 | ] 67 | ]); 68 | } 69 | 70 | public function testRegisterValidationError() 71 | { 72 | $this->json('POST', '/api/v1/auth/register', [ 73 | 'name' => '', 74 | 'email' => '', 75 | 'password' => '', 76 | 'password_confirmation' => '', 77 | ]) 78 | ->assertStatus(422) 79 | ->assertJsonStructure([ 80 | 'errors' => [ 81 | 'name', 82 | 'email', 83 | 'password' 84 | ], 85 | ]); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/Feature/DashboardTest.php: -------------------------------------------------------------------------------- 1 | json('GET', 'api/v1/dashboard/data')->assertStatus(401); 19 | $this->json('GET', 'api/v1/dashboard/admin-data')->assertStatus(401); 20 | } 21 | 22 | public function testGetData() 23 | { 24 | $user = User::factory()->has(Entry::factory()->count(30))->create(); 25 | 26 | $this->actingAs($user, 'sanctum') 27 | ->json('GET', '/api/v1/dashboard/data') 28 | ->assertOk() 29 | ->assertJsonStructure([ 30 | 'weekly_count', 31 | 'weekly_avg_speed', 32 | 'weekly_avg_pace', 33 | 'week_chart', 34 | 'max_speed', 35 | 'max_distance', 36 | 'max_time', 37 | ]); 38 | } 39 | 40 | public function testGetAdminData() 41 | { 42 | $user = User::factory()->admin()->has(Entry::factory()->count(30))->create(); 43 | 44 | $this->actingAs($user, 'sanctum') 45 | ->json('GET', '/api/v1/dashboard/admin-data') 46 | ->assertOk() 47 | ->assertJsonStructure([ 48 | 'total_users', 49 | 'new_users_this_week', 50 | 'new_users_this_month', 51 | 'total_entries', 52 | 'avg_entries_per_user', 53 | 'fastest_run', 54 | 'longest_run', 55 | ]); 56 | } 57 | 58 | public function testGetAdminDataByNonAdmin() 59 | { 60 | $user = User::factory()->create(); 61 | 62 | $this->actingAs($user, 'sanctum') 63 | ->json('GET', '/api/v1/dashboard/admin-data') 64 | ->assertStatus(401); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/Feature/ReportTest.php: -------------------------------------------------------------------------------- 1 | json('GET', 'api/v1/report/weekly') 19 | ->assertStatus(401); 20 | } 21 | 22 | public function testGetWeekly() 23 | { 24 | $user = User::factory()->has(Entry::factory()->count(30))->create(); 25 | 26 | $this->actingAs($user, 'sanctum') 27 | ->json('GET', '/api/v1/report/weekly') 28 | ->assertOk() 29 | ->assertJsonStructure([ 30 | 'weekly' => [ 31 | 'year', 32 | 'min_year', 33 | 'max_year', 34 | 'data', 35 | 'chart', 36 | ] 37 | ]); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 |