diff --git a/app/Actions/CloseQuizSubmissionAction.php b/app/Actions/CloseQuizSubmissionAction.php new file mode 100644 index 00000000..91cea39e --- /dev/null +++ b/app/Actions/CloseQuizSubmissionAction.php @@ -0,0 +1,17 @@ +closed_at = Carbon::now(); + $submission->save(); + } +} diff --git a/app/Http/Controllers/QuizSubmissionController.php b/app/Http/Controllers/QuizSubmissionController.php index 3378cf79..9a11b434 100644 --- a/app/Http/Controllers/QuizSubmissionController.php +++ b/app/Http/Controllers/QuizSubmissionController.php @@ -4,8 +4,10 @@ namespace App\Http\Controllers; +use App\Actions\CloseQuizSubmissionAction; use App\Http\Resources\QuizSubmissionResource; use App\Models\QuizSubmission; +use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -13,11 +15,18 @@ class QuizSubmissionController extends Controller { public function show(QuizSubmission $quizSubmission): Response { - $quizSubmission->load(["answerRecords.question.answers", "quiz"]); + $quizSubmission->load(["answerRecords.question.answers"]); return Inertia::render("User/Quiz", ["submission" => QuizSubmissionResource::make($quizSubmission)]); } + public function close(QuizSubmission $quizSubmission, CloseQuizSubmissionAction $action): RedirectResponse + { + $action->execute($quizSubmission); + + return redirect()->route("submissions.result", $quizSubmission->id)->with("status", "Test został oddany."); + } + public function result(QuizSubmission $quizSubmission): Response { $quizSubmission->load(["answerRecords.question.answers", "quiz"]); diff --git a/app/Http/Resources/QuizSubmissionResource.php b/app/Http/Resources/QuizSubmissionResource.php index 4bc34380..a0a1d6ae 100644 --- a/app/Http/Resources/QuizSubmissionResource.php +++ b/app/Http/Resources/QuizSubmissionResource.php @@ -16,6 +16,7 @@ public function toArray(Request $request): array "name" => $this->quiz->name, "createdAt" => $this->created_at, "updatedAt" => $this->updated_at, + "openedAt" => $this->quiz->scheduled_at, "closedAt" => $this->closed_at, "closed" => $this->isClosed, "quiz" => $this->quiz_id, diff --git a/app/Models/AnswerRecord.php b/app/Models/AnswerRecord.php index 59706bf4..35e862e8 100644 --- a/app/Models/AnswerRecord.php +++ b/app/Models/AnswerRecord.php @@ -21,7 +21,7 @@ * @property bool $isCorrect * @property QuizSubmission $quizSubmission * @property Question $question - * @property Answer $answer + * @property ?Answer $answer */ class AnswerRecord extends Model { diff --git a/app/Policies/QuizSubmissionPolicy.php b/app/Policies/QuizSubmissionPolicy.php index 29d48567..8310e800 100644 --- a/app/Policies/QuizSubmissionPolicy.php +++ b/app/Policies/QuizSubmissionPolicy.php @@ -14,6 +14,11 @@ public function view(User $user, QuizSubmission $quizSubmission): bool return $user->id === $quizSubmission->user_id; } + public function close(User $user, QuizSubmission $quizSubmission): bool + { + return $user->id === $quizSubmission->user_id && !$quizSubmission->isClosed; + } + public function result(User $user, QuizSubmission $quizSubmission): bool { return $user->id === $quizSubmission->user_id && $quizSubmission->isClosed; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 40b638ca..b891b606 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -22,6 +22,7 @@ public function run(): void ]); Quiz::factory()->locked()->count(5)->create(["scheduled_at" => Carbon::now()->addMonth()]); + Quiz::factory()->locked()->create(["name" => "6 Minutes", "scheduled_at" => Carbon::now(), "duration" => 6]); $quiz = Quiz::factory()->locked()->create(["name" => "Test Quiz", "scheduled_at" => Carbon::now(), "duration" => 2]); $questions = Question::factory()->count(10)->create(["quiz_id" => $quiz->id]); diff --git a/package-lock.json b/package-lock.json index 677e3047..819b46bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@inertiajs/vue3": "^1.2.0", "@tailwindcss/forms": "^0.5.8", "@tailwindcss/typography": "^0.5.15", + "@vueuse/core": "^11.0.3", "dayjs": "^1.11.13", "laravel-vite-plugin": "^1.0.5", "lodash": "^4.17.21", @@ -1316,6 +1317,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.4.0.tgz", @@ -2126,6 +2133,94 @@ "integrity": "sha512-q0xCiLkuWWQLzVrecPb0RMsNWyxICOjPrcrwxTUEHb1fsnvni4dcuyG7RT/Ie7VPTvnjzIaWzRMUBsrqNj/hhw==", "license": "MIT" }, + "node_modules/@vueuse/core": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-11.0.3.tgz", + "integrity": "sha512-RENlh64+SYA9XMExmmH1a3TPqeIuJBNNB/63GT35MZI+zpru3oMRUA6cEFr9HmGqEgUisurwGwnIieF6qu3aXw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "11.0.3", + "@vueuse/shared": "11.0.3", + "vue-demi": ">=0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-11.0.3.tgz", + "integrity": "sha512-+FtbO4SD5WpsOcQTcC0hAhNlOid6QNLzqedtquTtQ+CRNBoAt9GuV07c6KNHK1wCmlq8DFPwgiLF2rXwgSHX5Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-11.0.3.tgz", + "integrity": "sha512-0rY2m6HS5t27n/Vp5cTDsKTlNnimCqsbh/fmT2LgE+aaU42EMfXo8+bNX91W9I7DDmxfuACXMmrd7d79JxkqWA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", diff --git a/package.json b/package.json index 5f1ea7b4..052e0dbd 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@inertiajs/vue3": "^1.2.0", "@tailwindcss/forms": "^0.5.8", "@tailwindcss/typography": "^0.5.15", + "@vueuse/core": "^11.0.3", "dayjs": "^1.11.13", "laravel-vite-plugin": "^1.0.5", "lodash": "^4.17.21", diff --git a/resources/js/Helpers/Plurals.ts b/resources/js/Helpers/Plurals.ts new file mode 100644 index 00000000..ede5b180 --- /dev/null +++ b/resources/js/Helpers/Plurals.ts @@ -0,0 +1,13 @@ +export const usePlurals = (singular: string, pluralNominativ: string, pluralGenitive: string) => (value: number) => { + value = Math.abs(value) + + if (value === 1) { + return singular + } + + if (value % 10 >= 2 && value % 10 <= 4 && (value % 100 < 10 || value % 100 >= 20)) { + return pluralNominativ + } + + return pluralGenitive +} diff --git a/resources/js/Helpers/Time.ts b/resources/js/Helpers/Time.ts new file mode 100644 index 00000000..178cfe3d --- /dev/null +++ b/resources/js/Helpers/Time.ts @@ -0,0 +1,46 @@ +import {type TimeObject} from '@/Types/TimeObject' +import dayjs, { type Dayjs } from 'dayjs' +import {usePlurals} from '@/Helpers/Plurals' + +export function calcSecondsBetweenDates(from: string | number | Dayjs = 0, to: string | number | Dayjs = 0): number { + return dayjs(from).diff(dayjs(to), 's') +} + +export function calcSecondsLeftToDate(date: string | number | Dayjs = 0): number { + return Math.max(calcSecondsBetweenDates(date, dayjs()), 0) +} + +export function secondsToHour(seconds: number): TimeObject { + return { + 'h': Math.floor(seconds / 3600), + 'm': Math.floor(seconds % 3600 / 60), + 's': seconds % 60, + } +} + +const translateLeft = usePlurals('Pozostała', 'Pozostały', 'Pozostało') +const translateSecondsLeft = usePlurals('sekunda', 'sekundy', 'sekund') +const translateMinutesLeft = usePlurals('minuta', 'minuty', 'minut') +const translateHoursLeft = usePlurals('godzina', 'godziny', 'godzin') + +export function timeToString(time: TimeObject, withLeft = false): string { + const { s, m, h } = time + + if (h <= 0 && m <= 0) { + return `${withLeft ? translateLeft(s) : ''} ${s} ${translateSecondsLeft(s)}`.trimStart() + } + + if (h <= 0 && m < 10 && s > 0 ) { + return `${withLeft ? translateLeft(m) : ''} ${m} ${translateMinutesLeft(m)} i ${s} ${translateSecondsLeft(s)}`.trimStart() + } + + if (h <= 0) { + return `${withLeft ? translateLeft(m) : ''} ${m} ${translateMinutesLeft(m)}`.trimStart() + } + + if (h <= 0 && m > 0) { + return `${withLeft ? translateLeft(h) : ''} ${h} ${translateHoursLeft(h)} i ${m} ${translateMinutesLeft(m)}`.trimStart() + } + + return `${withLeft ? translateLeft(h) : ''} ${h} ${translateHoursLeft(h)}`.trimStart() +} diff --git a/resources/js/Helpers/Timer.ts b/resources/js/Helpers/Timer.ts new file mode 100644 index 00000000..33545ccf --- /dev/null +++ b/resources/js/Helpers/Timer.ts @@ -0,0 +1,17 @@ +import {computed, ref} from 'vue' +import {calcSecondsLeftToDate, secondsToHour, timeToString} from '@/Helpers/Time' + +export function useTimer(to: string | number, timeout: () => void) { + const left = ref(calcSecondsLeftToDate(to)) + + const interval = setInterval(() => { + left.value = Math.max(0, left.value - 1) + + if (left.value === 0) { + timeout() + clearInterval(interval) + } + }, 1000) + + return computed(() => timeToString(secondsToHour(left.value), true)) +} diff --git a/resources/js/Layouts/AdminLayout.vue b/resources/js/Layouts/AdminLayout.vue index c4550ae6..0eb46659 100644 --- a/resources/js/Layouts/AdminLayout.vue +++ b/resources/js/Layouts/AdminLayout.vue @@ -1,5 +1,4 @@ diff --git a/resources/js/Pages/Admin/Ranking.vue b/resources/js/Pages/Admin/Ranking.vue index 1d5162e4..e95d9b9d 100644 --- a/resources/js/Pages/Admin/Ranking.vue +++ b/resources/js/Pages/Admin/Ranking.vue @@ -1,37 +1,25 @@