diff --git a/package-lock.json b/package-lock.json index ab8755124decc7449562e17c3f6fe85531d220d2..e033404d9e73b55448ecc76c9f34f9b993e8682d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "idatt2106_2024_02_frontend", "version": "0.0.0", "dependencies": { + "animejs": "^3.2.2", "pinia": "^2.1.7", "vue": "^3.4.21", "vue-router": "^4.3.1" @@ -15,8 +16,11 @@ "devDependencies": { "@rushstack/eslint-patch": "^1.8.0", "@tsconfig/node20": "^20.1.4", + "@types/animejs": "^3.1.12", "@types/jsdom": "^21.1.6", "@types/node": "^20.12.5", + "@typescript-eslint/eslint-plugin": "^7.7.0", + "@typescript-eslint/parser": "^7.7.0", "@vitejs/plugin-vue": "^5.0.4", "@vitest/coverage-v8": "^1.5.0", "@vue/eslint-config-prettier": "^9.0.0", @@ -1143,6 +1147,12 @@ "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==", "dev": true }, + "node_modules/@types/animejs": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/@types/animejs/-/animejs-3.1.12.tgz", + "integrity": "sha512-fpdH+ZtlO0kqjTOqRaBdsEmvpRNOayI8k4EVkEtitL5l6wducDOXk0rgQgfZqWf/ZX9DzXrHf257S5i9xTcISQ==", + "dev": true + }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -1210,16 +1220,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.6.0.tgz", - "integrity": "sha512-gKmTNwZnblUdnTIJu3e9kmeRRzV2j1a/LUO27KNNAnIC5zjy1aSvXSRp4rVNlmAoHlQ7HzX42NbKpcSr4jF80A==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.7.0.tgz", + "integrity": "sha512-GJWR0YnfrKnsRoluVO3PRb9r5aMZriiMMM/RHj5nnTrBy1/wIgk76XCtCKcnXGjpZQJQRFtGV9/0JJ6n30uwpQ==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.6.0", - "@typescript-eslint/type-utils": "7.6.0", - "@typescript-eslint/utils": "7.6.0", - "@typescript-eslint/visitor-keys": "7.6.0", + "@typescript-eslint/scope-manager": "7.7.0", + "@typescript-eslint/type-utils": "7.7.0", + "@typescript-eslint/utils": "7.7.0", + "@typescript-eslint/visitor-keys": "7.7.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.3.1", @@ -1245,15 +1255,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.6.0.tgz", - "integrity": "sha512-usPMPHcwX3ZoPWnBnhhorc14NJw9J4HpSXQX4urF2TPKG0au0XhJoZyX62fmvdHONUkmyUe74Hzm1//XA+BoYg==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.7.0.tgz", + "integrity": "sha512-fNcDm3wSwVM8QYL4HKVBggdIPAy9Q41vcvC/GtDobw3c4ndVT3K6cqudUmjHPw8EAp4ufax0o58/xvWaP2FmTg==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "7.6.0", - "@typescript-eslint/types": "7.6.0", - "@typescript-eslint/typescript-estree": "7.6.0", - "@typescript-eslint/visitor-keys": "7.6.0", + "@typescript-eslint/scope-manager": "7.7.0", + "@typescript-eslint/types": "7.7.0", + "@typescript-eslint/typescript-estree": "7.7.0", + "@typescript-eslint/visitor-keys": "7.7.0", "debug": "^4.3.4" }, "engines": { @@ -1273,13 +1283,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.6.0.tgz", - "integrity": "sha512-ngttyfExA5PsHSx0rdFgnADMYQi+Zkeiv4/ZxGYUWd0nLs63Ha0ksmp8VMxAIC0wtCFxMos7Lt3PszJssG/E6w==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.7.0.tgz", + "integrity": "sha512-/8INDn0YLInbe9Wt7dK4cXLDYp0fNHP5xKLHvZl3mOT5X17rK/YShXaiNmorl+/U4VKCVIjJnx4Ri5b0y+HClw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.6.0", - "@typescript-eslint/visitor-keys": "7.6.0" + "@typescript-eslint/types": "7.7.0", + "@typescript-eslint/visitor-keys": "7.7.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -1290,13 +1300,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.6.0.tgz", - "integrity": "sha512-NxAfqAPNLG6LTmy7uZgpK8KcuiS2NZD/HlThPXQRGwz6u7MDBWRVliEEl1Gj6U7++kVJTpehkhZzCJLMK66Scw==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.7.0.tgz", + "integrity": "sha512-bOp3ejoRYrhAlnT/bozNQi3nio9tIgv3U5C0mVDdZC7cpcQEDZXvq8inrHYghLVwuNABRqrMW5tzAv88Vy77Sg==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "7.6.0", - "@typescript-eslint/utils": "7.6.0", + "@typescript-eslint/typescript-estree": "7.7.0", + "@typescript-eslint/utils": "7.7.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1317,9 +1327,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.6.0.tgz", - "integrity": "sha512-h02rYQn8J+MureCvHVVzhl69/GAfQGPQZmOMjG1KfCl7o3HtMSlPaPUAPu6lLctXI5ySRGIYk94clD/AUMCUgQ==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.7.0.tgz", + "integrity": "sha512-G01YPZ1Bd2hn+KPpIbrAhEWOn5lQBrjxkzHkWvP6NucMXFtfXoevK82hzQdpfuQYuhkvFDeQYbzXCjR1z9Z03w==", "dev": true, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -1330,13 +1340,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.6.0.tgz", - "integrity": "sha512-+7Y/GP9VuYibecrCQWSKgl3GvUM5cILRttpWtnAu8GNL9j11e4tbuGZmZjJ8ejnKYyBRb2ddGQ3rEFCq3QjMJw==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.7.0.tgz", + "integrity": "sha512-8p71HQPE6CbxIBy2kWHqM1KGrC07pk6RJn40n0DSc6bMOBBREZxSDJ+BmRzc8B5OdaMh1ty3mkuWRg4sCFiDQQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.6.0", - "@typescript-eslint/visitor-keys": "7.6.0", + "@typescript-eslint/types": "7.7.0", + "@typescript-eslint/visitor-keys": "7.7.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1358,17 +1368,17 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.6.0.tgz", - "integrity": "sha512-x54gaSsRRI+Nwz59TXpCsr6harB98qjXYzsRxGqvA5Ue3kQH+FxS7FYU81g/omn22ML2pZJkisy6Q+ElK8pBCA==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.7.0.tgz", + "integrity": "sha512-LKGAXMPQs8U/zMRFXDZOzmMKgFv3COlxUQ+2NMPhbqgVm6R1w+nU1i4836Pmxu9jZAuIeyySNrN/6Rc657ggig==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.15", "@types/semver": "^7.5.8", - "@typescript-eslint/scope-manager": "7.6.0", - "@typescript-eslint/types": "7.6.0", - "@typescript-eslint/typescript-estree": "7.6.0", + "@typescript-eslint/scope-manager": "7.7.0", + "@typescript-eslint/types": "7.7.0", + "@typescript-eslint/typescript-estree": "7.7.0", "semver": "^7.6.0" }, "engines": { @@ -1383,12 +1393,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.6.0.tgz", - "integrity": "sha512-4eLB7t+LlNUmXzfOu1VAIAdkjbu5xNSerURS9X/S5TUKWFRpXRQZbmtPqgKmYx8bj3J0irtQXSiWAOY82v+cgw==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.7.0.tgz", + "integrity": "sha512-h0WHOj8MhdhY8YWkzIF30R379y0NqyOHExI9N9KCzvmu05EgG4FumeYa3ccfKUSphyWkWQE1ybVrgz/Pbam6YA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/types": "7.7.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1831,6 +1841,11 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/animejs": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/animejs/-/animejs-3.2.2.tgz", + "integrity": "sha512-Ao95qWLpDPXXM+WrmwcKbl6uNlC5tjnowlaRYtuVDHHoygjtIPfDUoK9NthrlZsQSKjZXlmji2TrBUAVbiH0LQ==" + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", diff --git a/package.json b/package.json index 5fcdb2e2afb3b02c395000e730ffc39b72207653..53e12f251ec26cadb44ba81d493081cb723e8159 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "format-test": "prettier --check src/" }, "dependencies": { + "animejs": "^3.2.2", "pinia": "^2.1.7", "vue": "^3.4.21", "vue-router": "^4.3.1" @@ -25,8 +26,11 @@ "devDependencies": { "@rushstack/eslint-patch": "^1.8.0", "@tsconfig/node20": "^20.1.4", + "@types/animejs": "^3.1.12", "@types/jsdom": "^21.1.6", "@types/node": "^20.12.5", + "@typescript-eslint/eslint-plugin": "^7.7.0", + "@typescript-eslint/parser": "^7.7.0", "@vitejs/plugin-vue": "^5.0.4", "@vitest/coverage-v8": "^1.5.0", "@vue/eslint-config-prettier": "^9.0.0", diff --git a/src/assets/coffee.png b/src/assets/coffee.png new file mode 100644 index 0000000000000000000000000000000000000000..4862babd365d69b653cd83415f00520ce3bf3684 Binary files /dev/null and b/src/assets/coffee.png differ diff --git a/src/assets/completed.png b/src/assets/completed.png new file mode 100644 index 0000000000000000000000000000000000000000..3b3e629dc3b97bfc0eb09aa27cc520579ddc322b Binary files /dev/null and b/src/assets/completed.png differ diff --git a/src/assets/finishLine.png b/src/assets/finishLine.png new file mode 100644 index 0000000000000000000000000000000000000000..a0da9ecd3be1e39e70d342a2c580d09ce80015d0 Binary files /dev/null and b/src/assets/finishLine.png differ diff --git a/src/assets/frozenStreak.png b/src/assets/frozenStreak.png new file mode 100644 index 0000000000000000000000000000000000000000..9d60b296b4432f7ea2f469b0309c1ebae9b5699f Binary files /dev/null and b/src/assets/frozenStreak.png differ diff --git a/src/assets/gaming.png b/src/assets/gaming.png new file mode 100644 index 0000000000000000000000000000000000000000..b02ffa01a3b3a7267925a1eeb8084ac70c2f132c Binary files /dev/null and b/src/assets/gaming.png differ diff --git a/src/assets/lock.png b/src/assets/lock.png new file mode 100644 index 0000000000000000000000000000000000000000..d4598c4c7e310202edd3b81af5edc13fcecdaf76 Binary files /dev/null and b/src/assets/lock.png differ diff --git a/src/assets/pending.png b/src/assets/pending.png new file mode 100644 index 0000000000000000000000000000000000000000..734d180e43d50e93d315ff8c56f1d54c1cb05224 Binary files /dev/null and b/src/assets/pending.png differ diff --git a/src/assets/pigSteps.png b/src/assets/pigSteps.png new file mode 100644 index 0000000000000000000000000000000000000000..45309466a7ec3d8b163f5ca4e74671750fc628f2 Binary files /dev/null and b/src/assets/pigSteps.png differ diff --git a/src/assets/snacks.png b/src/assets/snacks.png new file mode 100644 index 0000000000000000000000000000000000000000..45ad39383c62ca25334634bd5747bccab7a4dac3 Binary files /dev/null and b/src/assets/snacks.png differ diff --git a/src/assets/spare.png b/src/assets/spare.png new file mode 100644 index 0000000000000000000000000000000000000000..8e652fe5646673f24da26874beb8b7bd109b6760 Binary files /dev/null and b/src/assets/spare.png differ diff --git a/src/assets/streak.png b/src/assets/streak.png new file mode 100644 index 0000000000000000000000000000000000000000..16a0e93231f89c033cfb25938f0a97b1fccbeae6 Binary files /dev/null and b/src/assets/streak.png differ diff --git a/src/components/ButtonAddGoalOrChallange.vue b/src/components/ButtonAddGoalOrChallange.vue new file mode 100644 index 0000000000000000000000000000000000000000..820233b39e4ef56f96b58303cc965e33444f9fde --- /dev/null +++ b/src/components/ButtonAddGoalOrChallange.vue @@ -0,0 +1,33 @@ +<template> + <button + class="w-full max-w-60 max-h-12 bg-green-500 text-white font-bold py-2 rounded-full flex items-center justify-start pl-4 space-x-2 hover:bg-green-600 drop-shadow-lg focus:outline-none focus:ring-2 focus:ring-green-700 focus:ring-opacity-50 shadow-md transition duration-300 ease-in-out text-xs md:text-sm lg:text-base" + > + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke="currentColor" + class="w-6 h-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + d="M12 4v16m8-8H4" + /> + </svg> + <span class="truncate">{{ btnText }}</span> + </button> +</template> + +<script setup lang="ts"> +import { defineProps, ref } from 'vue' + +interface Props { + buttonText: string +} + +const props = defineProps<Props>() + +const btnText = ref(props.buttonText) +</script> diff --git a/src/components/ButtonDIsplayStreak.vue b/src/components/ButtonDIsplayStreak.vue new file mode 100644 index 0000000000000000000000000000000000000000..ec0276559164c1ec544453a4c8e5c880b94281cf --- /dev/null +++ b/src/components/ButtonDIsplayStreak.vue @@ -0,0 +1,76 @@ +<template> + <div class="flex flex-col items-center"> + <Span class="text-sm text-bold">STREAK</Span> + <button @click="toggleStreakCard" class="bg-transparent"> + <img src="@/assets/streak.png" alt="streak" class="mx-auto w-12 h-12" /> + </button> + + <div + v-if="displayStreakCard" + class="w-96 h-64 duration-500 group overflow-hidden relative rounded bg-white-800 text-neutral-50 p-4 flex flex-col justify-evenly" + > + <div + class="absolute blur opacity-40 duration-500 group-hover:blur-none w-72 h-72 rounded-full group-hover:translate-x-12 group-hover:translate-y-12 bg-green-100 right-1 -bottom-24" + ></div> + <div + class="absolute blur opacity-40 duration-500 group-hover:blur-none w-12 h-12 rounded-full group-hover:translate-x-12 group-hover:translate-y-2 bg-green-300 right-12 bottom-12" + ></div> + <div + class="absolute blur opacity-40 duration-500 group-hover:blur-none w-36 h-36 rounded-full group-hover:translate-x-12 group-hover:-translate-y-12 bg-green-500 right-1 -top-12" + ></div> + <div + class="absolute blur opacity-40 duration-500 group-hover:blur-none w-24 h-24 bg-green-400 rounded-full group-hover:-translate-x-12" + ></div> + <div class="z-10 flex flex-col justify-evenly w-full h-full px-4"> + <span class="text-2xl font-bold text-black" + >{{ currentStreak }}{{ currentStreak === 1 ? ' dag' : ' dager' }} streak</span + > + <p class="text-black text-1xl font-bold"> + {{ + currentStreak > 0 + ? 'Bra jobba du har spart i ' + currentStreak + ' dager!' + : 'Du har ikke gjort noe i dag. Gjør noe nÃ¥ for Ã¥ starte en streak!' + }} + </p> + <!-- Row component with horizontal padding and auto margins for centering --> + <div + class="flex flex-row justify-content-center items-center h-20 w-full mx-auto bg-black-400 gap-4" + > + <div class="flex flex-1 overflow-x-auto"> + <div v-for="index in 6" :key="index" class="min-w-max mx-auto"> + <div class="flex flex-col items-center"> + <span class="text-black" + >Dag {{ currentStreak - ((currentStreak % 7) - index) }}</span + > + <!-- Conditional rendering for streak images --> + <img + v-if="index - 1 < currentStreak % 7" + src="@/assets/streak.png" + alt="challenge completed" + class="max-h-8 max-w-8" + /> + <img + v-else + src="@/assets/streak.png" + alt="challenge not completed" + class="max-h-8 max-w-8 grayscale" + /> + </div> + </div> + </div> + </div> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { ref } from 'vue' + +const displayStreakCard = ref(false) +const currentStreak = ref(5) + +function toggleStreakCard() { + displayStreakCard.value = !displayStreakCard.value +} +</script> diff --git a/src/components/InteractiveSpare.vue b/src/components/InteractiveSpare.vue new file mode 100644 index 0000000000000000000000000000000000000000..5d50f1e4a45eb8fc7a748b4d10209f6a309de4b9 --- /dev/null +++ b/src/components/InteractiveSpare.vue @@ -0,0 +1,62 @@ +<template> + <div + class="flex items-center max-w-80 w-full" + :class="{ 'flex-row': direction === 'right', 'flex-row-reverse': direction === 'left' }" + > + <!-- Image --> + <img + :src="spareImageSrc" + :class="['w-' + pngSize, 'h-' + pngSize, imageClass]" + alt="Sparemannen" + @click="nextSpeech" + /> + <!-- Speech Bubble --> + <div + v-if="currentSpeech" + class="rounded-lg bg-white p-4 text-black border-black border-2 min-h-16 max-w-48" + > + <span>{{ currentSpeech }}</span> + </div> + </div> +</template> + +<script setup lang="ts"> +import { ref, defineProps, computed } from 'vue' +import spareImageSrc from '@/assets/spare.png' + +interface Props { + speech?: string[] // Using TypeScript's type for speech as an array of strings + direction: 'left' | 'right' // This restricts direction to either 'left' or 'right' + pngSize: number // Just declaring the type directly since it's simple +} + +const props = defineProps<Props>() + +const speech = ref<String[]>(props.speech || []) + +const currentSpeechIndex = ref(0) +const currentSpeech = computed(() => speech.value[currentSpeechIndex.value]) + +const nextSpeech = () => { + if (speech.value.length > 0) { + // Remove the currently displayed speech first + speech.value.splice(currentSpeechIndex.value, 1) + + // Check if there are any speeches left after removal + if (speech.value.length > 0) { + // Move to the next speech or reset to the beginning if the current index is out of range + currentSpeechIndex.value = currentSpeechIndex.value % speech.value.length + } else { + // If no speeches left, reset index to indicate no available speech + currentSpeechIndex.value = -1 + } + } +} + +const imageClass = computed(() => { + return [ + 'transform', + props.direction === 'right' ? 'scale-x-[-1]' : '' // Flip image if right + ] +}) +</script> diff --git a/src/components/__tests__/ButtonAddGoalOrChallengeTest.spec.ts b/src/components/__tests__/ButtonAddGoalOrChallengeTest.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..144e004d9fca598adc9c71298004944d5721c8af --- /dev/null +++ b/src/components/__tests__/ButtonAddGoalOrChallengeTest.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import ButtonComponent from '@/components/ButtonAddGoalOrChallange.vue' // Adjust the import path as needed. + +describe('ButtonComponent', () => { + it('renders correctly', () => { + const wrapper = mount(ButtonComponent, { + props: { + buttonText: 'Click me' + } + }) + expect(wrapper.exists()).toBe(true) + }) + + it('has the correct classes', () => { + const wrapper = mount(ButtonComponent, { + props: { + buttonText: 'Click me' + } + }) + const button = wrapper.find('button') + const expectedClasses = [ + 'w-full', + 'max-w-60', + 'max-h-12', + 'bg-green-500', + 'text-white', + 'font-bold', + 'py-2', + 'rounded-full', + 'flex', + 'items-center', + 'justify-start', + 'pl-4', + 'space-x-2', + 'hover:bg-green-600', + 'drop-shadow-lg', + 'focus:outline-none', + 'focus:ring-2', + 'focus:ring-green-700', + 'focus:ring-opacity-50', + 'shadow-md', + 'transition', + 'duration-300', + 'ease-in-out', + 'text-xs', + 'md:text-sm', + 'lg:text-base' + ] + expectedClasses.forEach((cls) => { + expect(button.classes()).toContain(cls) + }) + }) + + it('displays the correct button text', () => { + const wrapper = mount(ButtonComponent, { + props: { + buttonText: 'Submit' + } + }) + const buttonText = wrapper.find('span.truncate') + expect(buttonText.text()).toBe('Submit') + }) +}) diff --git a/src/components/__tests__/HomeViewTest.spec.ts b/src/components/__tests__/HomeViewTest.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d51d5d7d72355f883b1dabc6cbb4a44816800b9 --- /dev/null +++ b/src/components/__tests__/HomeViewTest.spec.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import HomeView from '@/views/HomeView.vue' // Adjust the import path as needed. +import anime from 'animejs' +import type { Challenge } from '../../types/challenge' +import type { Goal } from '../../types/goal' + +// Setup localStorage mock +const localStorageMock = (function () { + let store = {} as { [key: string]: string } + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: any) => { + store[key] = value.toString() + }), + clear: vi.fn(() => { + store = {} + }), + removeItem: vi.fn((key: string) => { + delete store[key] + }), + get length() { + return Object.keys(store).length + }, + key: vi.fn((index: number): string | null => { + const keys = Object.keys(store) + return keys[index] || null + }), + __store: store // expose store for assertions + } +})() +Object.defineProperty(global, 'localStorage', { + value: localStorageMock, + writable: true +}) + +// Mocking animejs with a default export +vi.mock('animejs', () => ({ + default: { + // Ensuring the mock includes a 'default' export + timeline: vi.fn(() => ({ + add: vi.fn().mockReturnThis() + })) + } +})) + +describe('HomeView', () => { + let wrapper: any + + beforeEach(() => { + // Clear localStorage and reset all mocks + localStorage.clear() + vi.clearAllMocks() + wrapper = mount(HomeView, { + global: { + mocks: { + $router: { + push: vi.fn() + } + } + } + }) + }) + + it('renders correctly and initializes data', () => { + expect(wrapper.find('.no-scrollbar').exists()).toBe(true) + expect(wrapper.vm.challenges.length).toBeGreaterThan(0) + }) + + it('handles incrementSaved correctly', async () => { + const challenge: Challenge = { + createdOn: new Date(), + description: '', + title: 'Kaffe', + saved: 90, + target: 100, + completion: 90 + } + wrapper.vm.incrementSaved(challenge) + expect(challenge.saved).toBe(100) + expect(challenge.completion).toBe(100) + }) + + it('animates on challenge completion', async () => { + const challenge: Challenge = { + createdOn: new Date(), + description: '', + title: 'Mat og Drikke', + type: 'SNACKS', + saved: 100, + target: 100, + completion: 100 + } + wrapper.vm.animateChallenge(challenge) + await wrapper.vm.$nextTick() + expect(anime.timeline).toHaveBeenCalled() + }) + + it('persists animated challenges to localStorage', async () => { + const challenge = { + title: 'Gaming', + challengeType: 'GAMING', + saved: 100, + target: 100, + completion: 100 + } + await wrapper.vm.animateChallenge(challenge) + expect(localStorage.setItem).toHaveBeenCalled() + expect(localStorage.getItem('animatedChallenges')).toContain('Gaming') + }) + + it('correctly computes currentGoal based on goals', async () => { + const goal: Goal = { + id: 1, + due: new Date(), + createdOn: new Date(), + title: 'Vacation', + saved: 500, + target: 1500, + description: 'Summer vacation', + priority: 1, + completion: 33 + } + await wrapper.vm.$nextTick() + expect(goal.title).toBe('Vacation') + }) + + it('responds to changes in challenges and updates animation states', async () => { + wrapper.vm.challenges.push({ + title: 'New Challenge', + challengeType: 'COFFEE', + saved: 50, + target: 100, + completion: 50 + }) + await wrapper.vm.$nextTick() + wrapper.vm.challenges[wrapper.vm.challenges.length - 1].completion = 100 // Directly modify the data + await wrapper.vm.$nextTick() + expect(wrapper.vm.animatedChallenges.has('New Challenge')).toBe(true) + }) + + it('triggers animation on completion', async () => { + const challenge = { + title: 'Test Challenge', + challengeType: 'TEST', + saved: 100, + target: 100, + completion: 100 + } + await wrapper.vm.animateChallenge(challenge) + await wrapper.vm.$nextTick() // Wait for all nextTick callbacks to resolve + + expect(anime.timeline).toHaveBeenCalled() // Check if anime.timeline was called + }) + + // Test other methods like animateIcon, getChallengeIcon, etc. + it('returns correct icon path based on challenge type', () => { + const challenge: Challenge = { + createdOn: new Date(), + description: '', + saved: 0, + target: 0, + title: 'Coffee', + type: 'COFFEE' + } + expect(wrapper.vm.getChallengeIcon(challenge)).toBe('src/assets/coffee.png') + }) +}) diff --git a/src/components/__tests__/InteractiveSpareTest.spec.ts b/src/components/__tests__/InteractiveSpareTest.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..50351141febb49f6af92d3310d41b4de1ead3dfd --- /dev/null +++ b/src/components/__tests__/InteractiveSpareTest.spec.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import SpeechBubbleComponent from '@/components/InteractiveSpare.vue' // Adjust the import path as needed. + +describe('SpeechBubbleComponent', () => { + it('renders correctly with default props', () => { + const wrapper: any = mount(SpeechBubbleComponent, { + props: { + direction: 'left', + speech: ['Hello', 'World'], + pngSize: 100 + } + }) + expect(wrapper.exists()).toBeTruthy() + }) + + it('applies dynamic classes based on direction prop', () => { + const wrapper = mount(SpeechBubbleComponent, { + props: { + direction: 'right', + speech: ['Hello', 'World'], + pngSize: 100 + } + }) + expect(wrapper.find('div').classes()).toContain('flex-row') + const wrapperReverse = mount(SpeechBubbleComponent, { + props: { + direction: 'left', + speech: ['Hello', 'World'], + pngSize: 100 + } + }) + expect(wrapperReverse.find('div').classes()).toContain('flex-row-reverse') + }) + + it('image class is computed based on direction', () => { + const wrapper = mount(SpeechBubbleComponent, { + props: { + direction: 'right', + speech: ['Hello', 'World'], + pngSize: 100 + } + }) + expect(wrapper.find('img').classes()).toContain('scale-x-[-1]') + }) + + it('updates speech text on image click', async () => { + const wrapper = mount(SpeechBubbleComponent, { + props: { + direction: 'left', + speech: ['First speech', 'Second speech'], + pngSize: 100 + } + }) + expect(wrapper.find('span').text()).toBe('First speech') + await wrapper.find('img').trigger('click') + expect(wrapper.find('span').text()).toBe('Second speech') + }) +}) diff --git a/src/types/challenge.ts b/src/types/challenge.ts new file mode 100644 index 0000000000000000000000000000000000000000..f122bcc4b7b865af2245ad43fb93cdf0bdb2a5b3 --- /dev/null +++ b/src/types/challenge.ts @@ -0,0 +1,13 @@ +// Assuming the use of classes from 'class-transformer' for date handling or plain TypeScript + +export interface Challenge { + title: string + saved: number // BigDecimal in Java, but TypeScript uses number for floating points + target: number + description: string + createdOn: Date // Mapping ZonedDateTime to Date + dueDate?: Date // Mapping ZonedDateTime to Date, optional since Temporal annotation not always implies required + type?: string // Not specified as @NotNull, so it's optional + completion?: number // Assuming BigDecimal maps to number, optional due to @Transient + completedOn?: Date // Adding the new variable as optional +} diff --git a/src/types/goal.ts b/src/types/goal.ts new file mode 100644 index 0000000000000000000000000000000000000000..910d9f4359473f9b92a32dd7098a686e2784e5d6 --- /dev/null +++ b/src/types/goal.ts @@ -0,0 +1,44 @@ +export interface Goal { + /** The unique identifier for the Goal, must not be null. */ + id: number + + /** + * The title of the Goal, must not be null, empty, or only whitespace. + */ + title: string + + /** + * The amount saved towards the Goal so far. Must not be null and must be zero or positive. + */ + saved: number + + /** + * The target amount to achieve for the Goal. Must not be null and must be positive. + */ + target: number + + /** + * Completion percentage of the Goal. Must not be null and must be zero or positive. + */ + completion: number + + /** + * A description of the Goal, must not be null, empty, or only whitespace. + */ + description: string + + /** + * The priority of the Goal, must not be null and must be zero or positive. + */ + priority: number + + /** + * The date and time when the Goal was created. Must be a date in the past. + */ + createdOn: Date + + /** + * The date and time by which the Goal is due. Must be a date in the future. + */ + due: Date +} diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 4e54fd3352ccc46d6452a07b81e8b278828a3197..ef848f68ecd2052db2c6e052931a7f9a417d3185 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -1,16 +1,401 @@ -<script lang="ts" setup></script> - <template> - <h1>Heading 1</h1> - <h2>Heading 2</h2> - <h3>Heading 3</h3> - - <p>Paragraph</p> - - <button>Button</button> - <br /> - <a href="#">Link</a> - <div>Div</div> - <section>Section</section> - <article>Article</article> + <div class="flex flex-col max-h-[60vh] md:flex-row md:max-h-[80vh] mx-auto"> + <div class="flex flex-col basis-1/3 order-last md:order-first md:basis-1/2"> + <InteractiveSpare + :speech="speech" + :direction="'right'" + :pngSize="60" + class="opacity-0 h-0 w-0 md:opacity-100 md:h-auto md:w-auto md:mx-auto md:my-20" + ></InteractiveSpare> + <div class="flex flex-row gap-12 items-center mx-auto p-8 md:flex-col md:gap-4 md:m-8"> + <ButtonAddGoalOrChallenge :buttonText="'Legg til sparemÃ¥l'" /> + <ButtonAddGoalOrChallenge :buttonText="'Legg til spareutfordring'" /> + </div> + </div> + <div + class="flex flex-col basis-2/3 max-h-[70vh] mx-auto max-w-5/6 md:basis-1/2 md:max-h-full" + > + <div class="flex justify-center align-center"> + <span + class="w-full max-w-60 max-h-12 bg-green-500 text-white font-bold py-2 rounded mt-8 text-center space-x-2" + > + Din Sparesti + </span> + </div> + <div class="h-2 w-4/6 bg-black mx-auto my-2 opacity-10"></div> + <div + ref="containerRef" + class="container relative mx-auto p-6 no-scrollbar max-h-[60vh] overflow-y-auto" + > + <div + v-for="(challenge, index) in challenges" + :key="challenge.title" + class="flex flex-col items-center mx-8" + > + <!-- Challenge Row --> + <div + :class="{ + 'justify-end ml-40 md:ml-30': index % 2 === 1, + 'justify-start': index % 2 === 0 + }" + class="flex flex-row w-2/3 ml-8" + > + <!-- Challenge Icon and Details --> + <div class="flex"> + <!-- Challenge Icon --> + <div class="flex flex-col"> + <img + :src="getChallengeIcon(challenge)" + class="max-w-20 max-h-20" + :alt="challenge.title" + /> + <!-- Progress Bar, if the challenge is not complete --> + <div + v-if=" + challenge.completion != undefined && + challenge.completion < 100 + " + class="flex-grow w-full mt-2" + > + <div class="flex flex-row"> + <div class="flex flex-col"> + <div + class="bg-gray-200 rounded-full h-2.5 dark:bg-gray-700" + > + <div + class="bg-green-600 h-2.5 rounded-full" + :style="{ + width: + (challenge.saved / challenge.target) * + 100 + + '%' + }" + ></div> + </div> + <div class="text-center"> + {{ challenge.saved }}kr / {{ challenge.target }}kr + </div> + </div> + + <button + @click="incrementSaved(challenge)" + type="button" + class="inline-block mb-2 ml-2 h-8 w-8 rounded-full bg-green-500 p-1 uppercase leading-normal text-white bg-color-green shadow-green-500 transition duration-150 ease-in-out hover:bg-green-700 hover:shadow-green-200 focus:bg-green-accent-300 focus:shadow-green-2 focus:outline-none focus:ring-0 active:bg-green-600 active:shadow-green-200 motion-reduce:transition-none dark:shadow-black/30 dark:hover:shadow-dark-strong dark:focus:shadow-dark-strong dark:active:shadow-dark-strong" + > + + + </button> + </div> + </div> + <span v-else class="text-center" + >Ferdig: {{ challenge.saved }}</span + > + </div> + <!-- Check Icon --> + <div + v-if=" + challenge.completion !== undefined && + challenge.completion >= 100 + " + class="max-w-16 max-h-16" + > + <img src="@/assets/completed.png" alt="" />ï¸ + </div> + <div v-else class="max-w-16 max-h-16"> + <img src="@/assets/pending.png" alt="" />ï¸ + </div> + </div> + </div> + <!-- Piggy Steps, centered --> + <div v-if="index !== challenges.length" class="flex justify-center w-full"> + <img + :src="getPigStepsIcon()" + :class="{ 'transform scale-x-[-1]': index % 2 === 0 }" + class="w-20 h-20" + alt="Pig Steps" + /> + </div> + + <div + v-if="index === challenges.length - 1 && index % 2 === 0" + class="ml-40 flex flex-row" + > + <button class="text-2xl mr-2 color-black bg-slate-400">+</button> + <span class="">Legg til <br />Spareutfordring</span> + </div> + <div + v-else-if="index === challenges.length - 1 && index % 2 !== 0" + class="mr-40" + > + <button + class="text-2xl mr-2 color-black bg-slate-400 rounded-full" + @click="addSpareUtfordring" + > + + + </button> + </div> + </div> + <!-- Sparemannen --> + <InteractiveSpare + :speech="speech" + :direction="'left'" + :pngSize="20" + class="fixed bottom-0 right-0 mb-40 mr-4 md:opacity-0 md:h-0 md:w-0" + ></InteractiveSpare> + </div> + <!-- Finish line --> + <img src="@/assets/finishLine.png" class="w-1/2 max-h-4 mx-auto" alt="Finish Line" /> + + <!-- Goal --> + <div v-if="goal" class="flex flex-row gap-24 m-t-2 pt-6 mx-auto"> + <div class="flex flex-col items-start"> + <img :src="getGoalIcon(goal)" class="w-12 h-12 mx-auto" :alt="goal.title" /> + <div class="text-lg font-bold">{{ goal.title }}</div> + </div> + <div class="flex flex-col items-end"> + <div @click="goToEditGoal" class="cursor-pointer"> + <h3 class="text-blue-500 text-base">Endre mÃ¥l</h3> + </div> + <div ref="targetRef" class="bg-yellow-400 px-4 py-1 rounded-full text-black"> + {{ goal.saved }}kr / {{ goal.target }}kr + </div> + </div> + </div> + </div> + <!-- Animation icon --> + <img + src="@/assets/coins.png" + alt="Coins" + ref="iconRef" + class="max-w-20 max-h-20 absolute opacity-0" + /> + </div> + <div></div> </template> + +<script setup lang="ts"> +import { nextTick, onMounted, ref, watch } from 'vue' +import anime from 'animejs' +import InteractiveSpare from '@/components/InteractiveSpare.vue' +import ButtonAddGoalOrChallenge from '@/components/ButtonAddGoalOrChallange.vue' +import router from '@/router' +import type { Challenge } from '@/types/challenge' +import type { Goal } from '@/types/goal' + +// Define your speech array +const speechArray = [ + 'Hei! Jeg er Sparemannen.', + 'Jeg hjelper deg med Ã¥ spare penger.', + 'Klikk pÃ¥ meg for Ã¥ høre mer.' +] +// Correctly initialize the ref +const speech = ref(speechArray) + +// Reactive references for DOM elements +const iconRef = ref<HTMLElement | null>(null) +const containerRef = ref<HTMLElement | null>(null) +const targetRef = ref<HTMLElement | null>(null) + +const goal: Goal = { + id: 1, + title: 'gaming', + saved: 200, + description: 'none', + target: 400, + completion: 50, + priority: 0, + createdOn: new Date(), + due: new Date() +} + +const challenge: Challenge = { + title: 'Coffe', + saved: 1200.5, + target: 3000, + description: 'Saving monthly for a year-end vacation to Bali', + createdOn: new Date('2023-01-01T00:00:00Z'), + dueDate: new Date('2023-12-31T23:59:59Z'), + type: 'COFFE', + completion: 40, + completedOn: undefined // Not yet completed +} +const challenge1: Challenge = { + title: 'Snacks', + saved: 200, + target: 400, + description: 'Saving monthly for a year-end vacation to Bali', + createdOn: new Date('2023-01-01T00:00:00Z'), + dueDate: new Date('2023-12-31T23:59:59Z'), + type: 'SNACKS', + completion: 50, + completedOn: undefined // Not yet completed +} + +const challenges = ref([challenge, challenge1]) + +// AddSpareUtfordring +function addSpareUtfordring() { + console.log('Add Spare Utfordring') +} + +// Increment saved amount +function incrementSaved(challenge: Challenge) { + challenge.saved += 10 + if (challenge.saved >= challenge.target) { + challenge.completion = 100 + } +} + +function recalculateAndAnimate() { + nextTick(() => { + if (iconRef.value && containerRef.value && targetRef.value) { + animateIcon() + } else { + console.error('Element references are not ready.') + } + }) +} + +const animatedChallenges = ref(new Set()) + +const loadAnimatedStates = () => { + const animated = localStorage.getItem('animatedChallenges') + animatedChallenges.value = animated ? new Set(JSON.parse(animated)) : new Set() +} + +const saveAnimatedState = (title: String) => { + animatedChallenges.value.add(title) + localStorage.setItem('animatedChallenges', JSON.stringify([...animatedChallenges.value])) +} + +const animateChallenge = (challenge: Challenge) => { + if ( + challenge.completion !== undefined && + challenge.completion >= 100 && + !animatedChallenges.value.has(challenge.title) + ) { + console.log('Animating for:', challenge.title) + recalculateAndAnimate() // Assumes this function triggers the actual animation + saveAnimatedState(challenge.title) + } +} + +watch( + challenges, + (newChallenges) => { + newChallenges.forEach((challenge) => { + if (challenge.completion === 100 && !animatedChallenges.value.has(challenge.title)) { + animateChallenge(challenge) + } + }) + }, + { deep: true } +) + +onMounted(() => { + // Filter challenges that are already completed + const completedChallenges = challenges.value + .filter((challenge) => challenge.completion === 100) + .map((challenge) => challenge.title) + + // For testing purposes, clear localStorage + localStorage.clear() + + // Update localStorage with the titles of completed challenges + localStorage.setItem('animatedChallenges', JSON.stringify(completedChallenges)) + + // Load the initial state of animated challenges from localStorage + loadAnimatedStates() +}) + +function animateIcon() { + const icon = iconRef.value + const container = containerRef.value + const target = targetRef.value + + if (!icon || !container || !target) { + console.error('Required animation elements are not available.') + return + } + + const containerRect = container.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const iconRect = icon.getBoundingClientRect() + + const translateX1 = + containerRect.left + containerRect.width / 2 - iconRect.width / 2 - iconRect.left + const translateY1 = + containerRect.top + containerRect.height / 2 - iconRect.height / 2 - iconRect.top + + const translateX2 = targetRect.left + targetRect.width / 2 - iconRect.width / 2 - iconRect.left + const translateY2 = targetRect.top + targetRect.height / 2 - iconRect.height / 2 - iconRect.top + + anime + .timeline({ + easing: 'easeInOutQuad', + duration: 1500 + }) + .add({ + targets: icon, + translateX: translateX1, + translateY: translateY1, + opacity: 0, // Start invisible + duration: 1000 + }) + .add({ + targets: icon, + opacity: 1, // Reveal the icon once it starts moving to the container + duration: 1000, // Make the opacity change almost instantaneously + scale: 3 + }) + .add({ + targets: icon, + translateX: translateX2, + translateY: translateY2, + scale: 0.5, + opacity: 1, // Keep the icon visible while moving to the target + duration: 1500 + }) + .add({ + targets: icon, + opacity: 0, // Fade out once it reaches the target + scale: 1, + duration: 500 + }) + .add({ + targets: icon, + translateX: 0, // Reset translation to original + translateY: 0, // Reset translation to original + duration: 500 + }) +} + +// Helper methods to get icons +function getChallengeIcon(challenge: Challenge): string { + if (challenge.type === undefined) { + return 'src/assets/coins.png' + } + return `src/assets/${challenge.type.toLowerCase()}.png` +} + +function getGoalIcon(goal: Goal): string { + return `src/assets/${goal.title.toLowerCase()}.png` +} +function getPigStepsIcon() { + return 'src/assets/pigSteps.png' +} + +// TODO - Change when EditGoal view is created +function goToEditGoal() { + router.push({ name: 'EditGoal' }) +} +</script> + +<style scoped> +/* Tailwind CSS - Custom CSS for hiding scrollbars */ +.no-scrollbar::-webkit-scrollbar { + display: none; /* for Chrome, Safari, and Opera */ +} +.no-scrollbar { + -ms-overflow-style: none; /* for Internet Explorer and Edge */ +} +</style>