From 3010a66d59f1cdae49e6c85d40ab6ef69e40d3cd Mon Sep 17 00:00:00 2001 From: igor Date: Sat, 14 Feb 2026 07:13:06 +0100 Subject: [PATCH] added implementation frontend by prompt @ 2026-02-14 05:35:18 #CODEX --- doc/prompt.txt | 12 +- frontend/package-lock.json | 61 +++ frontend/package.json | 1 + frontend/src/assets/css/style.css | 347 ++++++++++++++++++ .../components/ingredients/IngredientForm.vue | 96 +++++ .../src/components/meals/MealItemsEditor.vue | 108 ++++++ .../src/components/meals/MealTypeFilter.vue | 27 ++ .../components/navigation/AppBottomTabs.vue | 22 ++ .../src/components/navigation/AppSidebar.vue | 28 ++ .../src/components/navigation/AppTopbar.vue | 38 ++ frontend/src/components/today/DayMealCard.vue | 47 +++ .../src/components/today/DayTotalsCard.vue | 31 ++ .../src/components/today/MealPickerModal.vue | 5 + frontend/src/main.ts | 2 + frontend/src/router/index.ts | 60 ++- frontend/src/stores/auth.ts | 73 ++++ frontend/src/stores/diary.ts | 127 +++++++ frontend/src/stores/index.ts | 3 + frontend/src/stores/ingredients.ts | 100 +++++ frontend/src/stores/meals.ts | 164 +++++++++ frontend/src/types/domain.ts | 77 ++++ frontend/src/utils/api.ts | 17 + frontend/src/utils/date.ts | 8 + frontend/src/utils/nutrition.ts | 83 +++++ frontend/src/views/AppLayout.vue | 19 + frontend/src/views/AuthView.vue | 38 +- frontend/src/views/IngredientsView.vue | 103 ++++++ frontend/src/views/MealDetailView.vue | 157 ++++++++ frontend/src/views/MealsView.vue | 86 +++++ frontend/src/views/SettingsView.vue | 22 ++ frontend/src/views/StatsView.vue | 8 + frontend/src/views/TodayView.vue | 93 +++++ 32 files changed, 2024 insertions(+), 39 deletions(-) create mode 100644 frontend/src/components/ingredients/IngredientForm.vue create mode 100644 frontend/src/components/meals/MealItemsEditor.vue create mode 100644 frontend/src/components/meals/MealTypeFilter.vue create mode 100644 frontend/src/components/navigation/AppBottomTabs.vue create mode 100644 frontend/src/components/navigation/AppSidebar.vue create mode 100644 frontend/src/components/navigation/AppTopbar.vue create mode 100644 frontend/src/components/today/DayMealCard.vue create mode 100644 frontend/src/components/today/DayTotalsCard.vue create mode 100644 frontend/src/components/today/MealPickerModal.vue create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/stores/diary.ts create mode 100644 frontend/src/stores/index.ts create mode 100644 frontend/src/stores/ingredients.ts create mode 100644 frontend/src/stores/meals.ts create mode 100644 frontend/src/types/domain.ts create mode 100644 frontend/src/utils/api.ts create mode 100644 frontend/src/utils/date.ts create mode 100644 frontend/src/utils/nutrition.ts create mode 100644 frontend/src/views/AppLayout.vue create mode 100644 frontend/src/views/IngredientsView.vue create mode 100644 frontend/src/views/MealDetailView.vue create mode 100644 frontend/src/views/MealsView.vue create mode 100644 frontend/src/views/SettingsView.vue create mode 100644 frontend/src/views/StatsView.vue create mode 100644 frontend/src/views/TodayView.vue diff --git a/doc/prompt.txt b/doc/prompt.txt index 4f941dd..48e8936 100644 --- a/doc/prompt.txt +++ b/doc/prompt.txt @@ -19,11 +19,11 @@ Ciele: Pre položku s grams: macro = grams/100 * macro_100. kcal = protein*4 + carbs*4 + fat*9. (Sugar je podmnožina carbs, neráta sa zvlášť do kcal.) 5) Navrhni štruktúru projektu: - - /src/router - - /src/views - - /src/components - - /src/stores (Pinia) - - /src/utils (nutrition math) + - frontend/src/router + - frontend/src/views + - frontend/src/components + - frontend/src/stores (Pinia) + - frontend/src/utils (nutrition math) 6) Daj konkrétny návrh názvov súborov a exportov + ukážku kódu: - router index.ts s routes + guard - auth store (token/user) @@ -34,6 +34,6 @@ Preferencie: - Vue 3 + Composition API + TypeScript - Pinia - Vue Router -- UI môže byť čisté bez knižnice, alebo minimalisticky (napr. jednoduché CSS/Tailwind – rozhodni a drž konzistentne). +- UI môže byť čisté bez knižnice, alebo minimalisticky (napr. jednoduché CSS – rozhodni a drž konzistentne a pokracuj v pouzivani suboru frontend/src/assets/css/style.css). - Použi slovenské názvy v UI (Raňajky, Obed, Večera), ale kľúče v kóde nech sú anglické (breakfast/lunch/dinner). Výstup: konkrétny návrh + ukážky kódu, nie všeobecné rady. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8d7dac2..ba0309e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/vue-fontawesome": "^3.1.3", + "pinia": "^3.0.4", "vue": "^3.5.27", "vue-i18n": "^11.2.8", "vue-router": "^5.0.1" @@ -4251,6 +4252,66 @@ "node": ">=0.10" } }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/pinia/node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/pinia/node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/pinia/node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, "node_modules/pkg-types": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 23795d0..3ce370b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/vue-fontawesome": "^3.1.3", + "pinia": "^3.0.4", "vue": "^3.5.27", "vue-i18n": "^11.2.8", "vue-router": "^5.0.1" diff --git a/frontend/src/assets/css/style.css b/frontend/src/assets/css/style.css index fea548e..42d15e0 100644 --- a/frontend/src/assets/css/style.css +++ b/frontend/src/assets/css/style.css @@ -325,3 +325,350 @@ select { justify-content: center; } } + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 250px 1fr; +} + +.app-sidebar { + background: var(--color-surface); + border-right: 1px solid var(--color-border); + padding: var(--space-lg); + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +.app-sidebar__brand { + display: flex; + align-items: center; + gap: var(--space-sm); + font-family: 'Space Grotesk', 'Segoe UI', sans-serif; + font-size: 1.1rem; +} + +.app-sidebar__logo { + width: 36px; + height: 36px; +} + +.app-sidebar__nav { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.app-nav-link { + text-decoration: none; + color: var(--color-text); + padding: 0.62rem 0.8rem; + border-radius: 0.7rem; + border: 1px solid transparent; + transition: all var(--transition-fast); +} + +.app-nav-link.router-link-active { + border-color: var(--color-green); + background: color-mix(in srgb, var(--color-green) 16%, transparent); +} + +.app-main { + display: flex; + flex-direction: column; + min-width: 0; +} + +.app-topbar { + position: sticky; + top: 0; + z-index: 2; + background: color-mix(in srgb, var(--color-bg) 84%, transparent); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--color-border); + padding: var(--space-md) var(--space-xl); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); +} + +.app-topbar h1 { + margin: 0; + font-size: 1.25rem; + font-family: 'Space Grotesk', 'Segoe UI', sans-serif; +} + +.app-topbar__user { + font-size: var(--fs-sm); + color: var(--color-muted); +} + +.app-content { + padding: var(--space-lg) var(--space-xl); +} + +.page { + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +.page-header { + display: flex; + gap: var(--space-md); + align-items: center; +} + +.page-header--split { + justify-content: space-between; +} + +.card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-soft); + padding: var(--space-lg); +} + +.grid-three { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-md); +} + +.grid-two { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-md); +} + +.filter-control { + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + +.filter-control span { + font-size: var(--fs-sm); + color: var(--color-muted); +} + +.input-text, +.input-number, +.input-select, +.input-date { + width: 100%; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-text); + padding: 0.64rem 0.72rem; +} + +.btn { + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + border-radius: var(--radius-md); + padding: 0.62rem 0.86rem; + cursor: pointer; +} + +.btn-primary { + border-color: var(--color-green); + background: var(--color-green); + color: var(--color-white); +} + +.btn-danger { + border-color: color-mix(in srgb, var(--color-error) 24%, var(--color-border)); + color: var(--color-error); +} + +.form-inline { + display: grid; + grid-template-columns: 1.3fr 0.9fr auto; + gap: var(--space-sm); +} + +.form-actions { + margin-top: var(--space-md); + display: flex; + gap: var(--space-sm); +} + +.list { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.list-row { + display: flex; + justify-content: space-between; + gap: var(--space-md); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 0.72rem 0.82rem; + color: inherit; + text-decoration: none; +} + +.list-row p { + margin: 0.3rem 0 0; + font-size: var(--fs-sm); + color: var(--color-muted); +} + +.list-row__meta { + align-self: center; + color: var(--color-muted); + font-size: var(--fs-sm); +} + +.list-row__actions { + display: flex; + gap: var(--space-xs); +} + +.empty-state { + margin: 0; + color: var(--color-muted); +} + +.day-meal-card__header h3, +.totals-card h3 { + margin-top: 0; +} + +.day-meal-card__summary { + margin: var(--space-sm) 0 0; + font-size: var(--fs-sm); + color: var(--color-muted); +} + +.totals-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-sm); +} + +.totals-grid span { + display: block; + color: var(--color-muted); + font-size: var(--fs-sm); +} + +.totals-grid strong { + font-size: 1.1rem; +} + +.meal-items-editor h3, +.ingredient-form h3 { + margin-top: 0; +} + +.meal-items-editor__add-row { + display: grid; + grid-template-columns: 1fr 140px auto; + gap: var(--space-sm); + margin-bottom: var(--space-md); +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table th, +.table td { + text-align: left; + border-bottom: 1px solid var(--color-border); + padding: 0.55rem 0.2rem; +} + +.ingredient-form label { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.ingredient-form label span { + font-size: var(--fs-sm); + color: var(--color-muted); +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + margin-bottom: var(--space-md); +} + +.app-bottom-tabs { + display: none; +} + +@media (max-width: 980px) { + .app-shell { + grid-template-columns: 1fr; + padding-bottom: 62px; + } + + .desktop-nav { + display: none; + } + + .app-content { + padding: var(--space-md); + } + + .grid-three { + grid-template-columns: 1fr; + } + + .totals-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .grid-two { + grid-template-columns: 1fr; + } + + .form-inline { + grid-template-columns: 1fr; + } + + .meal-items-editor__add-row { + grid-template-columns: 1fr; + } + + .app-bottom-tabs { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: var(--color-surface); + border-top: 1px solid var(--color-border); + z-index: 4; + } + + .app-bottom-tabs__link { + text-align: center; + text-decoration: none; + color: var(--color-muted); + padding: 0.62rem 0.2rem; + font-size: 0.76rem; + } + + .app-bottom-tabs__link.router-link-active { + color: var(--color-green); + font-weight: 700; + } +} diff --git a/frontend/src/components/ingredients/IngredientForm.vue b/frontend/src/components/ingredients/IngredientForm.vue new file mode 100644 index 0000000..d37986e --- /dev/null +++ b/frontend/src/components/ingredients/IngredientForm.vue @@ -0,0 +1,96 @@ + + + diff --git a/frontend/src/components/meals/MealItemsEditor.vue b/frontend/src/components/meals/MealItemsEditor.vue new file mode 100644 index 0000000..495278b --- /dev/null +++ b/frontend/src/components/meals/MealItemsEditor.vue @@ -0,0 +1,108 @@ + + + diff --git a/frontend/src/components/meals/MealTypeFilter.vue b/frontend/src/components/meals/MealTypeFilter.vue new file mode 100644 index 0000000..10c7d20 --- /dev/null +++ b/frontend/src/components/meals/MealTypeFilter.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/components/navigation/AppBottomTabs.vue b/frontend/src/components/navigation/AppBottomTabs.vue new file mode 100644 index 0000000..a218d41 --- /dev/null +++ b/frontend/src/components/navigation/AppBottomTabs.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/navigation/AppSidebar.vue b/frontend/src/components/navigation/AppSidebar.vue new file mode 100644 index 0000000..ff7958a --- /dev/null +++ b/frontend/src/components/navigation/AppSidebar.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/src/components/navigation/AppTopbar.vue b/frontend/src/components/navigation/AppTopbar.vue new file mode 100644 index 0000000..361a732 --- /dev/null +++ b/frontend/src/components/navigation/AppTopbar.vue @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/components/today/DayMealCard.vue b/frontend/src/components/today/DayMealCard.vue new file mode 100644 index 0000000..9a54527 --- /dev/null +++ b/frontend/src/components/today/DayMealCard.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/components/today/DayTotalsCard.vue b/frontend/src/components/today/DayTotalsCard.vue new file mode 100644 index 0000000..c4fc679 --- /dev/null +++ b/frontend/src/components/today/DayTotalsCard.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/today/MealPickerModal.vue b/frontend/src/components/today/MealPickerModal.vue new file mode 100644 index 0000000..c12728a --- /dev/null +++ b/frontend/src/components/today/MealPickerModal.vue @@ -0,0 +1,5 @@ + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index d0d27d6..44f9442 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -4,10 +4,12 @@ import './assets/css/style.css' import App from './App.vue' import i18n from './i18n' import router from './router' +import { pinia } from './stores' const app = createApp(App) app.component('font-awesome-icon', FontAwesomeIcon) +app.use(pinia) app.use(router) app.use(i18n) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 70c45be..18aff29 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,15 +1,57 @@ -import { createRouter, createWebHistory } from 'vue-router' +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import AuthView from '@/views/AuthView.vue' +import AppLayout from '@/views/AppLayout.vue' +import TodayView from '@/views/TodayView.vue' +import MealsView from '@/views/MealsView.vue' +import MealDetailView from '@/views/MealDetailView.vue' +import IngredientsView from '@/views/IngredientsView.vue' +import StatsView from '@/views/StatsView.vue' +import SettingsView from '@/views/SettingsView.vue' +import { pinia } from '@/stores' +import { useAuthStore } from '@/stores/auth' + +const routes: RouteRecordRaw[] = [ + { + path: '/', + name: 'auth', + component: AuthView, + meta: { guestOnly: true }, + }, + { + path: '/app', + component: AppLayout, + meta: { requiresAuth: true }, + children: [ + { path: '', redirect: { name: 'today' } }, + { path: 'today/:date(\\d{4}-\\d{2}-\\d{2})?', name: 'today', component: TodayView }, + { path: 'meals', name: 'meals', component: MealsView }, + { path: 'meals/:mealId(\\d+)', name: 'meal-detail', component: MealDetailView, props: true }, + { path: 'ingredients', name: 'ingredients', component: IngredientsView }, + { path: 'stats', name: 'stats', component: StatsView }, + { path: 'settings', name: 'settings', component: SettingsView }, + ], + }, +] const router = createRouter({ - history: createWebHistory(import.meta.env.BASE_URL), - routes: [ - { - path: '/', - name: 'auth', - component: AuthView, - }, - ], + history: createWebHistory(import.meta.env.BASE_URL), + routes, +}) + +router.beforeEach((to) => { + const auth = useAuthStore(pinia) + const requiresAuth = to.matched.some((route) => route.meta.requiresAuth === true) + const guestOnly = to.matched.some((route) => route.meta.guestOnly === true) + + if (requiresAuth && !auth.isAuthenticated) { + return { name: 'auth', query: { redirect: to.fullPath } } + } + + if (guestOnly && auth.isAuthenticated) { + return { name: 'today' } + } + + return true }) export default router diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..a580296 --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,73 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import BackendAPI from '@/BackendAPI.ts' +import { unwrapApiData } from '@/utils/api' + +type LoginPayload = { + auto_registered?: boolean + user?: { + email?: string | null + token?: string | null + } +} + +const TOKEN_KEY = 'token' +const USER_EMAIL_KEY = 'user_email' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem(TOKEN_KEY)) + const userEmail = ref(localStorage.getItem(USER_EMAIL_KEY)) + const isAuthenticated = computed(() => typeof token.value === 'string' && token.value.length > 0) + + const setSession = (nextToken: string, nextUserEmail: string) => { + token.value = nextToken + userEmail.value = nextUserEmail + localStorage.setItem(TOKEN_KEY, nextToken) + localStorage.setItem(USER_EMAIL_KEY, nextUserEmail) + } + + const clearSession = () => { + token.value = null + userEmail.value = null + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(USER_EMAIL_KEY) + } + + const login = async (email: string, password: string): Promise<{ autoRegistered: boolean }> => { + const payload = unwrapApiData(await BackendAPI.userLogin(email, password)) + const nextToken = payload.user?.token ?? null + const nextUserEmail = payload.user?.email ?? email + + if (!nextToken) { + throw new Error('Token missing in login response') + } + + setSession(nextToken, nextUserEmail) + + return { + autoRegistered: payload.auto_registered === true, + } + } + + const logout = async () => { + if (token.value) { + try { + await BackendAPI.userLogout(token.value) + } catch { + // Keep local logout flow even if API call fails. + } + } + + clearSession() + } + + return { + token, + userEmail, + isAuthenticated, + setSession, + clearSession, + login, + logout, + } +}) diff --git a/frontend/src/stores/diary.ts b/frontend/src/stores/diary.ts new file mode 100644 index 0000000..30d28b2 --- /dev/null +++ b/frontend/src/stores/diary.ts @@ -0,0 +1,127 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import BackendAPI from '@/BackendAPI.ts' +import { useAuthStore } from '@/stores/auth' +import { useMealsStore } from '@/stores/meals' +import type { DayMealsByType, DiaryDay, MealType } from '@/types/domain' +import { MEAL_TYPES } from '@/types/domain' +import { computeDayTotals, emptyTotals } from '@/utils/nutrition' +import { unwrapApiData } from '@/utils/api' + +const createEmptyDay = (date: string): DiaryDay => ({ + user_id: 0, + day_date: date, + diary_day_id: null, + entries: [], + totals: emptyTotals(), +}) + +export const useDiaryStore = defineStore('diary', () => { + const auth = useAuthStore() + const mealsStore = useMealsStore() + + const currentDay = ref(null) + const loading = ref(false) + + const requireToken = (): string => { + if (!auth.token) { + throw new Error('User is not authenticated') + } + return auth.token + } + + const mealsByType = computed(() => { + const ret: DayMealsByType = { + breakfast: null, + lunch: null, + dinner: null, + } + + for (const entry of currentDay.value?.entries ?? []) { + const cachedMeal = mealsStore.getMealFromCache(entry.meal_id) + ret[entry.meal_type] = cachedMeal ?? entry.meal ?? null + } + + return ret + }) + + const computedTotals = computed(() => computeDayTotals(mealsByType.value)) + + const hydrateDayMeals = async (day: DiaryDay) => { + const loadTasks: Promise[] = [] + for (const entry of day.entries) { + loadTasks.push(mealsStore.ensureMeal(entry.meal_id)) + } + if (loadTasks.length > 0) { + await Promise.all(loadTasks) + } + } + + const setCurrentDay = async (day: DiaryDay) => { + currentDay.value = day + await hydrateDayMeals(day) + } + + const loadDay = async (date: string) => { + loading.value = true + try { + const token = requireToken() + const payload = unwrapApiData(await BackendAPI.diaryDayGet(token, date, true)) + await setCurrentDay(payload) + } finally { + loading.value = false + } + } + + const setMealForType = async (date: string, mealType: MealType, mealId: number) => { + const token = requireToken() + const payload = unwrapApiData(await BackendAPI.diaryDaySetMeal(token, date, mealType, mealId)) + await setCurrentDay(payload) + } + + const unsetMealForType = async (date: string, mealType: MealType) => { + const token = requireToken() + const payload = unwrapApiData(await BackendAPI.diaryDayUnsetMeal(token, date, mealType)) + await setCurrentDay(payload) + } + + const selectedMealId = (mealType: MealType): number | null => { + const entry = currentDay.value?.entries.find((item) => item.meal_type === mealType) + return entry?.meal_id ?? null + } + + const ensureCurrentDay = (date: string) => { + if (!currentDay.value || currentDay.value.day_date !== date) { + currentDay.value = createEmptyDay(date) + } + } + + const selectedMealOptions = computed(() => { + const map: Record = { + breakfast: [], + lunch: [], + dinner: [], + } + + for (const type of MEAL_TYPES) { + map[type] = mealsStore.sortedMeals + .filter((meal) => meal.meal_type === type) + .map((meal) => ({ value: meal.meal_id, label: meal.name })) + } + + return map + }) + + return { + currentDay, + loading, + mealsByType, + computedTotals, + selectedMealOptions, + selectedMealId, + ensureCurrentDay, + loadDay, + setMealForType, + unsetMealForType, + } +}) diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts new file mode 100644 index 0000000..1c2af7e --- /dev/null +++ b/frontend/src/stores/index.ts @@ -0,0 +1,3 @@ +import { createPinia } from 'pinia' + +export const pinia = createPinia() diff --git a/frontend/src/stores/ingredients.ts b/frontend/src/stores/ingredients.ts new file mode 100644 index 0000000..5fa0967 --- /dev/null +++ b/frontend/src/stores/ingredients.ts @@ -0,0 +1,100 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import BackendAPI from '@/BackendAPI.ts' +import { useAuthStore } from '@/stores/auth' +import type { Ingredient } from '@/types/domain' +import { unwrapApiData } from '@/utils/api' + +export type IngredientInput = { + name: string + protein_g_100: number + carbs_g_100: number + sugar_g_100: number + fat_g_100: number + fiber_g_100: number + kcal_100: number +} + +export const useIngredientsStore = defineStore('ingredients', () => { + const auth = useAuthStore() + const items = ref([]) + const loading = ref(false) + + const sortedItems = computed(() => { + return [...items.value].sort((a, b) => a.name.localeCompare(b.name, 'sk')) + }) + + const requireToken = (): string => { + if (!auth.token) { + throw new Error('User is not authenticated') + } + return auth.token + } + + const loadIngredients = async (query = '', includeGlobal = true) => { + loading.value = true + try { + const token = requireToken() + const data = unwrapApiData( + await BackendAPI.ingredientList(token, query, includeGlobal), + ) + items.value = Array.isArray(data) ? data : [] + } finally { + loading.value = false + } + } + + const createIngredient = async (payload: IngredientInput) => { + const token = requireToken() + const created = unwrapApiData( + await BackendAPI.ingredientCreate( + token, + payload.name, + payload.protein_g_100, + payload.carbs_g_100, + payload.sugar_g_100, + payload.fat_g_100, + payload.fiber_g_100, + payload.kcal_100, + ), + ) + items.value = [...items.value, created] + return created + } + + const updateIngredient = async (ingredientId: number, payload: IngredientInput) => { + const token = requireToken() + const updated = unwrapApiData( + await BackendAPI.ingredientUpdate( + token, + ingredientId, + payload.name, + payload.protein_g_100, + payload.carbs_g_100, + payload.sugar_g_100, + payload.fat_g_100, + payload.fiber_g_100, + payload.kcal_100, + ), + ) + + items.value = items.value.map((item) => (item.ingredient_id === ingredientId ? updated : item)) + return updated + } + + const deleteIngredient = async (ingredientId: number) => { + const token = requireToken() + await BackendAPI.ingredientDelete(token, ingredientId) + items.value = items.value.filter((item) => item.ingredient_id !== ingredientId) + } + + return { + items, + sortedItems, + loading, + loadIngredients, + createIngredient, + updateIngredient, + deleteIngredient, + } +}) diff --git a/frontend/src/stores/meals.ts b/frontend/src/stores/meals.ts new file mode 100644 index 0000000..dfcf619 --- /dev/null +++ b/frontend/src/stores/meals.ts @@ -0,0 +1,164 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import BackendAPI from '@/BackendAPI.ts' +import { useAuthStore } from '@/stores/auth' +import type { Meal, MealItem, MealType } from '@/types/domain' +import { withComputedMealTotals } from '@/utils/nutrition' +import { unwrapApiData } from '@/utils/api' + +export type MealInput = { + name: string + meal_type: MealType +} + +export type MealItemInput = { + ingredient_id: number + grams: number + position: number +} + +type MealItemListResponse = { + meal_id: number + items: MealItem[] +} + +export const useMealsStore = defineStore('meals', () => { + const auth = useAuthStore() + const list = ref([]) + const mealById = ref>({}) + const loading = ref(false) + + const sortedMeals = computed(() => { + return [...list.value].sort((a, b) => a.name.localeCompare(b.name, 'sk')) + }) + + const requireToken = (): string => { + if (!auth.token) { + throw new Error('User is not authenticated') + } + return auth.token + } + + const upsertMeal = (meal: Meal) => { + const computedMeal = withComputedMealTotals(meal) + mealById.value[computedMeal.meal_id] = computedMeal + + const index = list.value.findIndex((item) => item.meal_id === computedMeal.meal_id) + if (index >= 0) { + list.value.splice(index, 1, computedMeal) + } else { + list.value.push(computedMeal) + } + } + + const loadMeals = async (mealType = '') => { + loading.value = true + try { + const token = requireToken() + const rows = unwrapApiData(await BackendAPI.mealList(token, mealType, false, false)) + const nextList = Array.isArray(rows) + ? rows.map((meal) => withComputedMealTotals({ ...meal, items: meal.items ?? [] })) + : [] + + list.value = nextList + const nextById: Record = {} + for (const meal of nextList) { + nextById[meal.meal_id] = meal + } + mealById.value = nextById + } finally { + loading.value = false + } + } + + const loadMeal = async (mealId: number): Promise => { + const token = requireToken() + const mealData = unwrapApiData(await BackendAPI.mealGet(token, mealId, true, false)) + const itemsData = unwrapApiData(await BackendAPI.mealItemList(token, mealId, false)) + const mealWithItems = withComputedMealTotals({ + ...mealData, + items: itemsData.items ?? [], + }) + upsertMeal(mealWithItems) + return mealWithItems + } + + const getMealFromCache = (mealId: number): Meal | null => { + return mealById.value[mealId] ?? null + } + + const ensureMeal = async (mealId: number): Promise => { + const cached = getMealFromCache(mealId) + if (cached?.items?.length) { + return cached + } + return loadMeal(mealId) + } + + const createMeal = async (payload: MealInput): Promise => { + const token = requireToken() + const created = unwrapApiData(await BackendAPI.mealCreate(token, payload.name, payload.meal_type)) + const nextMeal = withComputedMealTotals({ ...created, items: [] }) + upsertMeal(nextMeal) + return nextMeal + } + + const updateMeal = async (mealId: number, payload: MealInput): Promise => { + const token = requireToken() + const updated = unwrapApiData(await BackendAPI.mealUpdate(token, mealId, payload.name, payload.meal_type)) + const existingItems = mealById.value[mealId]?.items ?? [] + const nextMeal = withComputedMealTotals({ ...updated, items: existingItems }) + upsertMeal(nextMeal) + return nextMeal + } + + const deleteMeal = async (mealId: number) => { + const token = requireToken() + await BackendAPI.mealDelete(token, mealId) + list.value = list.value.filter((meal) => meal.meal_id !== mealId) + const nextById = { ...mealById.value } + delete nextById[mealId] + mealById.value = nextById + } + + const addMealItem = async (mealId: number, payload: MealItemInput): Promise => { + const token = requireToken() + await BackendAPI.mealItemAdd(token, mealId, payload.ingredient_id, payload.grams, payload.position) + return loadMeal(mealId) + } + + const updateMealItem = async (mealItemId: number, payload: MealItemInput, mealId: number): Promise => { + const token = requireToken() + await BackendAPI.mealItemUpdate( + token, + mealItemId, + payload.ingredient_id, + payload.grams, + payload.position, + ) + return loadMeal(mealId) + } + + const deleteMealItem = async (mealId: number, mealItemId: number): Promise => { + const token = requireToken() + await BackendAPI.mealItemDelete(token, mealItemId) + return loadMeal(mealId) + } + + return { + list, + mealById, + sortedMeals, + loading, + loadMeals, + loadMeal, + getMealFromCache, + ensureMeal, + createMeal, + updateMeal, + deleteMeal, + addMealItem, + updateMealItem, + deleteMealItem, + } +}) diff --git a/frontend/src/types/domain.ts b/frontend/src/types/domain.ts new file mode 100644 index 0000000..48d6957 --- /dev/null +++ b/frontend/src/types/domain.ts @@ -0,0 +1,77 @@ +export type MealType = 'breakfast' | 'lunch' | 'dinner' + +export interface Ingredient { + ingredient_id: number + user_id: number | null + name: string + protein_g_100: number + carbs_g_100: number + sugar_g_100: number + fat_g_100: number + fiber_g_100: number + kcal_100: number +} + +export interface MealItemNutrition { + protein_g: number + carbs_g: number + sugar_g: number + fat_g: number + fiber_g: number + kcal: number +} + +export interface MealItem { + meal_item_id: number + meal_id: number + ingredient_id: number + grams: number + position: number + ingredient?: Ingredient + nutrition?: MealItemNutrition +} + +export interface NutritionTotals { + protein_g: number + carbs_g: number + sugar_g: number + fat_g: number + fiber_g: number + kcal: number +} + +export interface Meal { + meal_id: number + user_id: number + name: string + meal_type: MealType + items?: MealItem[] + totals?: NutritionTotals +} + +export interface DiaryEntry { + diary_entry_id: number + diary_day_id: number + meal_type: MealType + meal_id: number + meal?: Meal + meal_totals?: NutritionTotals +} + +export interface DiaryDay { + user_id: number + day_date: string + diary_day_id: number | null + entries: DiaryEntry[] + totals?: NutritionTotals +} + +export type DayMealsByType = Record + +export const MEAL_TYPES: MealType[] = ['breakfast', 'lunch', 'dinner'] + +export const MEAL_TYPE_LABELS_SK: Record = { + breakfast: 'Raňajky', + lunch: 'Obed', + dinner: 'Večera', +} diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts new file mode 100644 index 0000000..326d103 --- /dev/null +++ b/frontend/src/utils/api.ts @@ -0,0 +1,17 @@ +export type ApiEnvelope = { + status: 'OK' + data: T +} + +export const unwrapApiData = (payload: unknown): T => { + if ( + typeof payload === 'object' && + payload !== null && + 'status' in payload && + 'data' in payload + ) { + return (payload as ApiEnvelope).data + } + + return payload as T +} diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts new file mode 100644 index 0000000..60c7680 --- /dev/null +++ b/frontend/src/utils/date.ts @@ -0,0 +1,8 @@ +export const formatISODate = (date: Date): string => { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export const todayISO = (): string => formatISODate(new Date()) diff --git a/frontend/src/utils/nutrition.ts b/frontend/src/utils/nutrition.ts new file mode 100644 index 0000000..e3b3a93 --- /dev/null +++ b/frontend/src/utils/nutrition.ts @@ -0,0 +1,83 @@ +import type { + DayMealsByType, + Ingredient, + Meal, + MealItem, + NutritionTotals, +} from '@/types/domain' + +const round2 = (value: number): number => Math.round(value * 100) / 100 + +export const emptyTotals = (): NutritionTotals => ({ + protein_g: 0, + carbs_g: 0, + sugar_g: 0, + fat_g: 0, + fiber_g: 0, + kcal: 0, +}) + +export const computeItemNutrition = (ingredient: Ingredient, grams: number): NutritionTotals => { + const factor = grams / 100 + const protein = round2(ingredient.protein_g_100 * factor) + const carbs = round2(ingredient.carbs_g_100 * factor) + const sugar = round2(ingredient.sugar_g_100 * factor) + const fat = round2(ingredient.fat_g_100 * factor) + const fiber = round2(ingredient.fiber_g_100 * factor) + const kcal = round2(protein * 4 + carbs * 4 + fat * 9) + + return { + protein_g: protein, + carbs_g: carbs, + sugar_g: sugar, + fat_g: fat, + fiber_g: fiber, + kcal, + } +} + +export const addTotals = (base: NutritionTotals, add: NutritionTotals): NutritionTotals => { + return { + protein_g: round2(base.protein_g + add.protein_g), + carbs_g: round2(base.carbs_g + add.carbs_g), + sugar_g: round2(base.sugar_g + add.sugar_g), + fat_g: round2(base.fat_g + add.fat_g), + fiber_g: round2(base.fiber_g + add.fiber_g), + kcal: round2(base.kcal + add.kcal), + } +} + +export const computeMealTotals = (items: MealItem[]): NutritionTotals => { + let totals = emptyTotals() + + for (const item of items) { + if (!item.ingredient || item.grams <= 0) { + continue + } + const itemTotals = computeItemNutrition(item.ingredient, item.grams) + totals = addTotals(totals, itemTotals) + } + + return totals +} + +export const computeDayTotals = (mealsByType: Partial): NutritionTotals => { + let totals = emptyTotals() + + for (const meal of Object.values(mealsByType)) { + if (!meal) { + continue + } + const mealTotals = computeMealTotals(meal.items ?? []) + totals = addTotals(totals, mealTotals) + } + + return totals +} + +export const withComputedMealTotals = (meal: Meal): Meal => { + return { + ...meal, + totals: computeMealTotals(meal.items ?? []), + } +} diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue new file mode 100644 index 0000000..73bab10 --- /dev/null +++ b/frontend/src/views/AppLayout.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/views/AuthView.vue b/frontend/src/views/AuthView.vue index 83c1cc1..958b5e1 100644 --- a/frontend/src/views/AuthView.vue +++ b/frontend/src/views/AuthView.vue @@ -1,5 +1,6 @@ + + diff --git a/frontend/src/views/MealDetailView.vue b/frontend/src/views/MealDetailView.vue new file mode 100644 index 0000000..121499c --- /dev/null +++ b/frontend/src/views/MealDetailView.vue @@ -0,0 +1,157 @@ + + + diff --git a/frontend/src/views/MealsView.vue b/frontend/src/views/MealsView.vue new file mode 100644 index 0000000..ffb2b14 --- /dev/null +++ b/frontend/src/views/MealsView.vue @@ -0,0 +1,86 @@ + + + diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue new file mode 100644 index 0000000..cec4f74 --- /dev/null +++ b/frontend/src/views/SettingsView.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/views/StatsView.vue b/frontend/src/views/StatsView.vue new file mode 100644 index 0000000..3fa27b7 --- /dev/null +++ b/frontend/src/views/StatsView.vue @@ -0,0 +1,8 @@ + diff --git a/frontend/src/views/TodayView.vue b/frontend/src/views/TodayView.vue new file mode 100644 index 0000000..7e4afda --- /dev/null +++ b/frontend/src/views/TodayView.vue @@ -0,0 +1,93 @@ + + +