feat(hunt): Add bingo board to hunts
This commit is contained in:
@@ -168,6 +168,13 @@ async function reorderQuest(quest: QuestListItem, direction: 'up' | 'down') {
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<VaChip
|
||||
v-if="quest.isBingo"
|
||||
size="small"
|
||||
color="danger"
|
||||
title="Bingo"
|
||||
><VaIcon name="apps"
|
||||
/></VaChip>
|
||||
<VaChip v-if="quest.optional" size="small" color="secondary"
|
||||
>Opt</VaChip
|
||||
>
|
||||
|
||||
@@ -31,6 +31,7 @@ const defaultForm = () => ({
|
||||
title_en: '',
|
||||
description_de: '',
|
||||
description_en: '',
|
||||
isBingo: false,
|
||||
question_de: '',
|
||||
question_en: '',
|
||||
location: '',
|
||||
@@ -73,6 +74,7 @@ watch(showModal, async (visible) => {
|
||||
title_en: quest.title_en,
|
||||
description_de: quest.description_de,
|
||||
description_en: quest.description_en,
|
||||
isBingo: quest.isBingo,
|
||||
question_de: quest.question_de ?? '',
|
||||
question_en: quest.question_en ?? '',
|
||||
location: quest.location ?? '',
|
||||
@@ -295,6 +297,10 @@ async function deleteImage() {
|
||||
<VaDivider />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<VaCheckbox
|
||||
v-model="form.isBingo"
|
||||
:label="t('page.quest_admin.is_bingo')"
|
||||
/>
|
||||
<VaCheckbox
|
||||
v-model="form.optional"
|
||||
:label="t('page.quest_admin.optional')"
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
"hunt": {
|
||||
"admin_unreviewed": "Admin ({count} unbewertet)",
|
||||
"all_pictures": "Bilderwand",
|
||||
"bingo_board": "Bingofeld",
|
||||
"diashow": "Diashow",
|
||||
"hide_answers": "Antworten verstecken",
|
||||
"invite": "Einladen:",
|
||||
@@ -243,6 +244,7 @@
|
||||
"extra_points": "Extrapunkte",
|
||||
"extra_text_de": "Extra-Text (Deutsch)",
|
||||
"extra_text_en": "Extra-Text (Englisch)",
|
||||
"is_bingo": "Bingoaufgabe",
|
||||
"location": "Ort",
|
||||
"location_link": "Orts-Link (URL)",
|
||||
"move_down": "Nach unten",
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
"hunt": {
|
||||
"admin_unreviewed": "Admin ({count} unreviewed)",
|
||||
"all_pictures": "All pictures",
|
||||
"bingo_board": "Bingo board",
|
||||
"diashow": "Diashow",
|
||||
"hide_answers": "Hide answers",
|
||||
"invite": "Invite:",
|
||||
@@ -243,6 +244,7 @@
|
||||
"extra_points": "Extra points",
|
||||
"extra_text_de": "Extra text (German)",
|
||||
"extra_text_en": "Extra text (English)",
|
||||
"is_bingo": "Bingo task",
|
||||
"location": "Location",
|
||||
"location_link": "Location link (URL)",
|
||||
"move_down": "Move down",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { parseError } from '#imports';
|
||||
import { findBingo } from '#shared/utils/bingo';
|
||||
import TeamAddModal from '~/components/TeamAddModal.vue';
|
||||
import TeamSettingsModal from '~/components/TeamSettingsModal.vue';
|
||||
import { useHuntStore } from '~/stores/hunt';
|
||||
@@ -25,6 +26,21 @@ const { data, pending, refresh, error } = await useFetch(`/api/hunt/${id}`);
|
||||
|
||||
huntStore.setMembership(id, data.value?.isMember ?? false);
|
||||
|
||||
const hasBingo = computed(
|
||||
() => data.value?.quests?.some((q) => q.isBingo) ?? false
|
||||
);
|
||||
|
||||
const hasBingoDone = computed(() => {
|
||||
if (!hasBingo.value || !data.value?.quests) {
|
||||
return 0;
|
||||
}
|
||||
const bingoQuests = data
|
||||
.value!.quests!.filter((q) => q.isBingo)
|
||||
.map((q) => q.answer?.final ?? false);
|
||||
|
||||
return findBingo(bingoQuests);
|
||||
});
|
||||
|
||||
const settings = useSettingsStore();
|
||||
|
||||
useHead({
|
||||
@@ -136,7 +152,8 @@ ${document.location.href}`
|
||||
>
|
||||
</div>
|
||||
<div v-if="data">
|
||||
<VaCard class="my-4">
|
||||
<div class="my-4 flex flex-col gap-4 md:flex-row">
|
||||
<VaCard class="flex-grow">
|
||||
<VaCardTitle>
|
||||
{{ data ? tKey(data, 'name') : name }}
|
||||
</VaCardTitle>
|
||||
@@ -204,6 +221,67 @@ ${document.location.href}`
|
||||
</div>
|
||||
</VaCardContent>
|
||||
</VaCard>
|
||||
<VaCard
|
||||
v-if="(data.revealQuests || data.isMember) && hasBingo"
|
||||
class="mb-auto sm:max-w-[30rem]"
|
||||
stripe-color="success"
|
||||
:stripe="hasBingoDone > 0"
|
||||
>
|
||||
<VaCardTitle>{{ t('page.hunt.bingo_board') }}</VaCardTitle>
|
||||
<VaCardContent
|
||||
v-if="data.isMember"
|
||||
class="grid grid-cols-3 gap-1 text-center"
|
||||
>
|
||||
<VaButton
|
||||
v-for="quest in data.quests?.filter((q) => q.isBingo)"
|
||||
:key="quest.id"
|
||||
class="aspect-square"
|
||||
color="danger"
|
||||
:preset="(quest.unreviewed || 0) <= 0 ? 'secondary' : undefined"
|
||||
:border-color="
|
||||
(quest.unreviewed || 0) <= 0 ? 'danger' : undefined
|
||||
"
|
||||
:to="
|
||||
localePath(
|
||||
`/hunt/${tSluggy(data).value}/${tSluggy(quest).value}/admin`
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ tKey(quest, 'title') }}
|
||||
</VaButton>
|
||||
</VaCardContent>
|
||||
<VaCardContent v-else class="grid grid-cols-3 gap-1 text-center">
|
||||
<VaAlert
|
||||
v-if="hasBingoDone > 0"
|
||||
color="success"
|
||||
class="col-span-3 text-3xl"
|
||||
>🎉 {{ hasBingoDone > 1 ? `${hasBingoDone}x ` : '' }}BINGO!
|
||||
🎉</VaAlert
|
||||
>
|
||||
<VaButton
|
||||
v-for="quest in data.quests?.filter((q) => q.isBingo)"
|
||||
:key="quest.id"
|
||||
class="aspect-square"
|
||||
:color="
|
||||
(quest.answer?.review?.score &&
|
||||
quest.answer?.review?.score > 0) ||
|
||||
quest.answer?.final
|
||||
? 'success'
|
||||
: !!quest.answer
|
||||
? 'warning'
|
||||
: undefined
|
||||
"
|
||||
:to="
|
||||
localePath(
|
||||
`/hunt/${tSluggy(data).value}/${tSluggy(quest).value}`
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ tKey(quest, 'title') }}
|
||||
</VaButton>
|
||||
</VaCardContent>
|
||||
</VaCard>
|
||||
</div>
|
||||
<VaDivider />
|
||||
<VaCard
|
||||
v-if="data.ownTeam === null && !data.isMember"
|
||||
@@ -237,7 +315,7 @@ ${document.location.href}`
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<VaCard
|
||||
v-for="quest in data.quests"
|
||||
v-for="quest in data.quests?.filter((q) => !q.isBingo)"
|
||||
:key="`q-${quest.id}`"
|
||||
stripe
|
||||
:stripe-color="
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."HuntQuest" ADD COLUMN "isBingo" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -166,6 +166,7 @@ model HuntQuest {
|
||||
title_de String
|
||||
description_en String @default("")
|
||||
description_de String @default("")
|
||||
isBingo Boolean @default(false)
|
||||
question_en String?
|
||||
question_de String?
|
||||
location String?
|
||||
|
||||
@@ -31,6 +31,7 @@ export default defineEventHandler(async (event) => {
|
||||
title_en: true,
|
||||
description_de: true,
|
||||
description_en: true,
|
||||
isBingo: true,
|
||||
question_de: true,
|
||||
question_en: true,
|
||||
location: true,
|
||||
|
||||
@@ -42,6 +42,7 @@ export default defineEventHandler(async (event) => {
|
||||
title_en: z.string().min(1).max(256).trim(),
|
||||
description_de: z.string().max(2000).trim().default(''),
|
||||
description_en: z.string().max(2000).trim().default(''),
|
||||
isBingo: z.boolean().default(false),
|
||||
question_de: z.string().max(1000).trim().nullish(),
|
||||
question_en: z.string().max(1000).trim().nullish(),
|
||||
location: z.string().max(256).trim().nullish(),
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineEventHandler(async (event) => {
|
||||
id: true,
|
||||
title_de: true,
|
||||
title_en: true,
|
||||
isBingo: true,
|
||||
order: true,
|
||||
points: true,
|
||||
optional: true,
|
||||
|
||||
@@ -43,6 +43,7 @@ export default defineEventHandler(async (event) => {
|
||||
title_en: z.string().min(1).max(256).trim(),
|
||||
description_de: z.string().max(2000).trim().default(''),
|
||||
description_en: z.string().max(2000).trim().default(''),
|
||||
isBingo: z.boolean().default(false),
|
||||
question_de: z.string().max(1000).trim().nullish(),
|
||||
question_en: z.string().max(1000).trim().nullish(),
|
||||
location: z.string().max(256).trim().nullish(),
|
||||
|
||||
@@ -86,6 +86,7 @@ export default defineEventHandler(async (event) => {
|
||||
title_en: true,
|
||||
description_de: true,
|
||||
description_en: true,
|
||||
isBingo: true,
|
||||
picture: {
|
||||
select: {
|
||||
url: true
|
||||
@@ -197,7 +198,6 @@ export default defineEventHandler(async (event) => {
|
||||
0
|
||||
);
|
||||
|
||||
// TODO: filter out questions before start
|
||||
return {
|
||||
isMember,
|
||||
ownTeam,
|
||||
|
||||
@@ -22,6 +22,7 @@ export default defineEventHandler(async (event) => {
|
||||
title_en: true,
|
||||
description_de: true,
|
||||
description_en: true,
|
||||
isBingo: true,
|
||||
picture: {
|
||||
select: {
|
||||
url: true
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { bingoDone, findBingo } from './bingo';
|
||||
|
||||
describe('Bingo', () => {
|
||||
it('no bingo', () => {
|
||||
expect(bingoDone([])).toBeFalse();
|
||||
expect(bingoDone(new Array(9).fill(false))).toBeFalse();
|
||||
|
||||
expect(findBingo([])).toBe(0);
|
||||
expect(findBingo(new Array(9).fill(false))).toBe(0);
|
||||
});
|
||||
|
||||
it('bingo', () => {
|
||||
expect(
|
||||
bingoDone([true, true, true, false, false, false, false, false, false])
|
||||
).toBeTrue();
|
||||
expect(
|
||||
bingoDone([true, false, false, true, false, false, true, false, false])
|
||||
).toBeTrue();
|
||||
expect(
|
||||
bingoDone([true, false, false, false, true, false, false, false, true])
|
||||
).toBeTrue();
|
||||
expect(
|
||||
bingoDone([true, false, true, true, true, false, false, false, true])
|
||||
).toBeTrue();
|
||||
expect(bingoDone(new Array(9).fill(true))).toBeTrue();
|
||||
|
||||
expect(
|
||||
findBingo([true, true, true, false, false, false, false, false, false])
|
||||
).toBe(1);
|
||||
expect(
|
||||
findBingo([true, false, false, true, false, false, true, false, false])
|
||||
).toBe(1);
|
||||
expect(
|
||||
findBingo([true, false, false, false, true, false, false, false, true])
|
||||
).toBe(1);
|
||||
expect(
|
||||
findBingo([true, false, true, true, true, false, false, false, true])
|
||||
).toBe(1);
|
||||
expect(
|
||||
findBingo([true, false, false, true, true, false, true, false, true])
|
||||
).toBe(2);
|
||||
expect(
|
||||
findBingo([true, false, true, true, true, false, true, false, true])
|
||||
).toBe(3);
|
||||
expect(findBingo(new Array(9).fill(true))).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
const possibleCombos = [
|
||||
// horizontal
|
||||
[0, 1, 2],
|
||||
[3, 4, 5],
|
||||
[6, 7, 8],
|
||||
// vertical
|
||||
[0, 3, 6],
|
||||
[1, 4, 7],
|
||||
[2, 5, 8],
|
||||
// diagonal
|
||||
[0, 4, 8],
|
||||
[2, 4, 6]
|
||||
];
|
||||
|
||||
export function bingoDone(fields: boolean[]) {
|
||||
// TODO: Support different sizes
|
||||
if (fields.length < 9) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return possibleCombos.some((combo) => combo.every((ci) => fields[ci]));
|
||||
}
|
||||
export function findBingo(fields: boolean[]) {
|
||||
// TODO: Support different sizes
|
||||
if (fields.length < 9) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return possibleCombos.filter((combo) => combo.every((ci) => !!fields[ci]))
|
||||
.length;
|
||||
}
|
||||
Reference in New Issue
Block a user