ph5 #64

Merged
mo merged 2 commits from ph5 into prod 2025-06-09 14:04:57 +02:00
11 changed files with 640 additions and 335 deletions

View File

@ -1,11 +1,12 @@
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request, HTTPException, status
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse, JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.database import get_transactional_session from app.database import get_transactional_session
from app.models import User from app.models import User
from app.auth import oauth, fastapi_users, auth_backend, get_jwt_strategy, get_refresh_jwt_strategy from app.auth import oauth, fastapi_users, auth_backend, get_jwt_strategy, get_refresh_jwt_strategy
from app.config import settings from app.config import settings
from fastapi.security import OAuth2PasswordRequestForm
router = APIRouter() router = APIRouter()
@ -93,3 +94,29 @@ async def apple_callback(request: Request, db: AsyncSession = Depends(get_transa
redirect_url = f"{settings.FRONTEND_URL}/auth/callback?access_token={access_token}&refresh_token={refresh_token}" redirect_url = f"{settings.FRONTEND_URL}/auth/callback?access_token={access_token}&refresh_token={refresh_token}"
return RedirectResponse(url=redirect_url) return RedirectResponse(url=redirect_url)
@router.post('/jwt/refresh')
async def refresh_jwt_token(request: Request):
data = await request.json()
refresh_token = data.get('refresh_token')
if not refresh_token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token")
refresh_strategy = get_refresh_jwt_strategy()
try:
user = await refresh_strategy.read_token(refresh_token, None)
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
access_strategy = get_jwt_strategy()
access_token = await access_strategy.write_token(user)
# Optionally, issue a new refresh token (rotation)
new_refresh_token = await refresh_strategy.write_token(user)
return JSONResponse({
"access_token": access_token,
"refresh_token": new_refresh_token,
"token_type": "bearer"
})

View File

@ -9,6 +9,7 @@ from app.api.v1.endpoints import ocr
from app.api.v1.endpoints import costs from app.api.v1.endpoints import costs
from app.api.v1.endpoints import financials from app.api.v1.endpoints import financials
from app.api.v1.endpoints import chores from app.api.v1.endpoints import chores
from app.api.auth import oauth
api_router_v1 = APIRouter() api_router_v1 = APIRouter()
@ -21,5 +22,6 @@ api_router_v1.include_router(ocr.router, prefix="/ocr", tags=["OCR"])
api_router_v1.include_router(costs.router, prefix="/costs", tags=["Costs"]) api_router_v1.include_router(costs.router, prefix="/costs", tags=["Costs"])
api_router_v1.include_router(financials.router, prefix="/financials", tags=["Financials"]) api_router_v1.include_router(financials.router, prefix="/financials", tags=["Financials"])
api_router_v1.include_router(chores.router, prefix="/chores", tags=["Chores"]) api_router_v1.include_router(chores.router, prefix="/chores", tags=["Chores"])
api_router_v1.include_router(oauth.router, prefix="/auth", tags=["Auth"])
# Add other v1 endpoint routers here later # Add other v1 endpoint routers here later
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"]) # e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])

View File

@ -26,18 +26,168 @@ class Settings(BaseSettings):
MAX_FILE_SIZE_MB: int = 10 # Maximum allowed file size for OCR processing MAX_FILE_SIZE_MB: int = 10 # Maximum allowed file size for OCR processing
ALLOWED_IMAGE_TYPES: list[str] = ["image/jpeg", "image/png", "image/webp"] # Supported image formats ALLOWED_IMAGE_TYPES: list[str] = ["image/jpeg", "image/png", "image/webp"] # Supported image formats
OCR_ITEM_EXTRACTION_PROMPT: str = """ OCR_ITEM_EXTRACTION_PROMPT: str = """
Extract the shopping list items from this image. **ROLE & GOAL**
List each distinct item on a new line.
Ignore prices, quantities, store names, discounts, taxes, totals, and other non-item text. You are an expert AI assistant specializing in Optical Character Recognition (OCR) and structured data extraction. Your primary function is to act as a "Shopping List Digitizer."
Focus only on the names of the products or items to be purchased.
Add 2 underscores before and after the item name, if it is struck through. Your goal is to meticulously analyze the provided image of a shopping list, which is likely handwritten, and convert it into a structured, machine-readable JSON format. You must be accurate, infer context where necessary, and handle the inherent ambiguities of handwriting and informal list-making.
If the image does not appear to be a shopping list or receipt, state that clearly.
Example output for a grocery list: **INPUT**
Milk
Eggs You will receive a single image (`[Image]`). This image contains a shopping list. It may be:
Bread * Neatly written or very messy.
__Apples__ * On lined paper, a whiteboard, a napkin, or a dedicated notepad.
Organic Bananas * Containing doodles, stains, or other visual noise.
* Using various formats (bullet points, numbered lists, columns, simple line breaks).
* could be in English or in German.
**CORE TASK: STEP-BY-STEP ANALYSIS**
Follow these steps precisely:
1. **Initial Image Analysis & OCR:**
* Perform an advanced OCR scan on the entire image to transcribe all visible text.
* Pay close attention to the spatial layout. Identify headings, columns, and line items. Note which text elements appear to be grouped together.
2. **Item Identification & Filtering:**
* Differentiate between actual list items and non-item elements.
* **INCLUDE:** Items intended for purchase.
* **EXCLUDE:** List titles (e.g., "GROCERIES," "Target List"), dates, doodles, unrelated notes, or stray marks. Capture the list title separately if one exists.
3. **Detailed Extraction for Each Item:**
For every single item you identify, extract the following attributes. If an attribute is not present, use `null`.
* `item_name` (string): The primary name of the product.
* **Standardize:** Normalize the name. (e.g., "B. Powder" -> "Baking Powder", "A. Juice" -> "Apple Juice").
* **Contextual Guessing:** If a word is poorly written, use the context of a shopping list to make an educated guess. (e.g., "Ciffee" is almost certainly "Coffee").
* `quantity` (number or string): The amount needed.
* If a number is present (e.g., "**2** milks"), extract the number `2`.
* If it's a word (e.g., "**a dozen** eggs"), extract the string `"a dozen"`.
* If no quantity is specified (e.g., "Bread"), infer a default quantity of `1`.
* `unit` (string): The unit of measurement or packaging.
* Examples: "kg", "lbs", "liters", "gallons", "box", "can", "bag", "bunch".
* Infer where possible (e.g., for "2 Milks," the unit could be inferred as "cartons" or "gallons" depending on regional context, but it's safer to leave it `null` if not explicitly stated).
* `notes` (string): Any additional descriptive text.
* Examples: "low-sodium," "organic," "brand name (Tide)," "for the cake," "get the ripe ones."
* `category` (string): Infer a logical category for the item.
* Use common grocery store categories: `Produce`, `Dairy & Eggs`, `Meat & Seafood`, `Pantry`, `Frozen`, `Bakery`, `Beverages`, `Household`, `Personal Care`.
* If the list itself has category headings (e.g., a "DAIRY" section), use those first.
* `original_text` (string): Provide the exact, unaltered text that your OCR transcribed for this entire line item. This is crucial for verification.
* `is_crossed_out` (boolean): Set to `true` if the item is struck through, crossed out, or clearly marked as completed. Otherwise, set to `false`.
**HANDLING AMBIGUITIES AND EDGE CASES**
* **Illegible Text:** If a line or word is completely unreadable, set `item_name` to `"UNREADABLE"` and place the garbled OCR attempt in the `original_text` field.
* **Abbreviations:** Expand common shopping list abbreviations (e.g., "OJ" -> "Orange Juice", "TP" -> "Toilet Paper", "AVOs" -> "Avocados", "G. Beef" -> "Ground Beef").
* **Implicit Items:** If a line is vague like "Snacks for kids," list it as is. Do not invent specific items.
* **Multi-item Lines:** If a line contains multiple items (e.g., "Onions, Garlic, Ginger"), split them into separate item objects.
**OUTPUT FORMAT**
Your final output MUST be a single JSON object with the following structure. Do not include any explanatory text before or after the JSON block.
```json
{
"list_title": "string or null",
"items": [
{
"item_name": "string",
"quantity": "number or string",
"unit": "string or null",
"category": "string",
"notes": "string or null",
"original_text": "string",
"is_crossed_out": "boolean"
}
],
"summary": {
"total_items": "integer",
"unread_items": "integer",
"crossed_out_items": "integer"
}
}
```
**EXAMPLE WALKTHROUGH**
* **IF THE IMAGE SHOWS:** A crumpled sticky note with the title "Stuff for tonight" and the items:
* `2x Chicken Breasts`
* `~~Baguette~~` (this item is crossed out)
* `Salad mix (bag)`
* `Tomatos` (misspelled)
* `Choc Ice Cream`
* **YOUR JSON OUTPUT SHOULD BE:**
```json
{
"list_title": "Stuff for tonight",
"items": [
{
"item_name": "Chicken Breasts",
"quantity": 2,
"unit": null,
"category": "Meat & Seafood",
"notes": null,
"original_text": "2x Chicken Breasts",
"is_crossed_out": false
},
{
"item_name": "Baguette",
"quantity": 1,
"unit": null,
"category": "Bakery",
"notes": null,
"original_text": "Baguette",
"is_crossed_out": true
},
{
"item_name": "Salad Mix",
"quantity": 1,
"unit": "bag",
"category": "Produce",
"notes": null,
"original_text": "Salad mix (bag)",
"is_crossed_out": false
},
{
"item_name": "Tomatoes",
"quantity": 1,
"unit": null,
"category": "Produce",
"notes": null,
"original_text": "Tomatos",
"is_crossed_out": false
},
{
"item_name": "Chocolate Ice Cream",
"quantity": 1,
"unit": null,
"category": "Frozen",
"notes": null,
"original_text": "Choc Ice Cream",
"is_crossed_out": false
}
],
"summary": {
"total_items": 5,
"unread_items": 0,
"crossed_out_items": 1
}
}
```
**FINAL INSTRUCTION**
If the image provided is not a shopping list or is completely blank/unintelligible, respond with a JSON object where the `items` array is empty and add a note in the `list_title` field, such as "Image does not appear to be a shopping list."
Now, analyze the provided image and generate the JSON output.
""" """
# --- OCR Error Messages --- # --- OCR Error Messages ---
OCR_SERVICE_UNAVAILABLE: str = "OCR service is currently unavailable. Please try again later." OCR_SERVICE_UNAVAILABLE: str = "OCR service is currently unavailable. Please try again later."
@ -49,7 +199,7 @@ Organic Bananas
OCR_PROCESSING_ERROR: str = "Error processing image: {detail}" OCR_PROCESSING_ERROR: str = "Error processing image: {detail}"
# --- Gemini AI Settings --- # --- Gemini AI Settings ---
GEMINI_MODEL_NAME: str = "gemini-2.0-flash" # The model to use for OCR GEMINI_MODEL_NAME: str = "gemini-2.5-flash-preview-05-20" # The model to use for OCR
GEMINI_SAFETY_SETTINGS: dict = { GEMINI_SAFETY_SETTINGS: dict = {
"HARM_CATEGORY_HATE_SPEECH": "BLOCK_MEDIUM_AND_ABOVE", "HARM_CATEGORY_HATE_SPEECH": "BLOCK_MEDIUM_AND_ABOVE",
"HARM_CATEGORY_DANGEROUS_CONTENT": "BLOCK_MEDIUM_AND_ABOVE", "HARM_CATEGORY_DANGEROUS_CONTENT": "BLOCK_MEDIUM_AND_ABOVE",

29
fe/package-lock.json generated
View File

@ -15,6 +15,7 @@
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",
"axios": "^1.9.0", "axios": "^1.9.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"framer-motion": "^12.16.0",
"motion": "^12.15.0", "motion": "^12.15.0",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"qs": "^6.14.0", "qs": "^6.14.0",
@ -34,6 +35,7 @@
"@types/date-fns": "^2.5.3", "@types/date-fns": "^2.5.3",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/node": "^22.15.17", "@types/node": "^22.15.17",
"@types/qs": "^6.14.0",
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"@vitest/eslint-plugin": "^1.1.39", "@vitest/eslint-plugin": "^1.1.39",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
@ -4291,6 +4293,13 @@
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.1.6", "version": "19.1.6",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz",
@ -7491,12 +7500,12 @@
} }
}, },
"node_modules/framer-motion": { "node_modules/framer-motion": {
"version": "12.15.0", "version": "12.16.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.15.0.tgz", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.16.0.tgz",
"integrity": "sha512-XKg/LnKExdLGugZrDILV7jZjI599785lDIJZLxMiiIFidCsy0a4R2ZEf+Izm67zyOuJgQYTHOmodi7igQsw3vg==", "integrity": "sha512-xryrmD4jSBQrS2IkMdcTmiS4aSKckbS7kLDCuhUn9110SQKG1w3zlq1RTqCblewg+ZYe+m3sdtzQA6cRwo5g8Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"motion-dom": "^12.15.0", "motion-dom": "^12.16.0",
"motion-utils": "^12.12.1", "motion-utils": "^12.12.1",
"tslib": "^2.4.0" "tslib": "^2.4.0"
}, },
@ -9295,9 +9304,9 @@
} }
}, },
"node_modules/motion-dom": { "node_modules/motion-dom": {
"version": "12.15.0", "version": "12.16.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.15.0.tgz", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.16.0.tgz",
"integrity": "sha512-D2ldJgor+2vdcrDtKJw48k3OddXiZN1dDLLWrS8kiHzQdYVruh0IoTwbJBslrnTXIPgFED7PBN2Zbwl7rNqnhA==", "integrity": "sha512-Z2nGwWrrdH4egLEtgYMCEN4V2qQt1qxlKy/uV7w691ztyA41Q5Rbn0KNGbsNVDZr9E8PD2IOQ3hSccRnB6xWzw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"motion-utils": "^12.12.1" "motion-utils": "^12.12.1"
@ -11732,9 +11741,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.13", "version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -26,6 +26,7 @@
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",
"axios": "^1.9.0", "axios": "^1.9.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"framer-motion": "^12.16.0",
"motion": "^12.15.0", "motion": "^12.15.0",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"qs": "^6.14.0", "qs": "^6.14.0",
@ -45,6 +46,7 @@
"@types/date-fns": "^2.5.3", "@types/date-fns": "^2.5.3",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/node": "^22.15.17", "@types/node": "^22.15.17",
"@types/qs": "^6.14.0",
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"@vitest/eslint-plugin": "^1.1.39", "@vitest/eslint-plugin": "^1.1.39",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",

View File

@ -0,0 +1,102 @@
<template>
<VModal :model-value="isOpen" @update:model-value="closeModal" title="Create New Group">
<template #default>
<form @submit.prevent="onSubmit">
<VFormField label="Group Name" :error-message="formError ?? undefined">
<VInput type="text" v-model="groupName" required ref="groupNameInput" />
</VFormField>
</form>
</template>
<template #footer>
<VButton variant="neutral" @click="closeModal" type="button">Cancel</VButton>
<VButton type="submit" variant="primary" :disabled="loading" @click="onSubmit" class="ml-2">
<VSpinner v-if="loading" size="sm" />
Create
</VButton>
</template>
</VModal>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';
import { useVModel } from '@vueuse/core';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { useNotificationStore } from '@/stores/notifications';
import VModal from '@/components/valerie/VModal.vue';
import VFormField from '@/components/valerie/VFormField.vue';
import VInput from '@/components/valerie/VInput.vue';
import VButton from '@/components/valerie/VButton.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
const props = defineProps<{
modelValue: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'created', newGroup: any): void;
}>();
const isOpen = useVModel(props, 'modelValue', emit);
const groupName = ref('');
const loading = ref(false);
const formError = ref<string | null>(null);
const notificationStore = useNotificationStore();
const groupNameInput = ref<InstanceType<typeof VInput> | null>(null);
watch(isOpen, (newVal) => {
if (newVal) {
groupName.value = '';
formError.value = null;
nextTick(() => {
// groupNameInput.value?.focus?.();
});
}
});
const closeModal = () => {
isOpen.value = false;
};
const validateForm = () => {
formError.value = null;
if (!groupName.value.trim()) {
formError.value = 'Name is required';
return false;
}
return true;
};
const onSubmit = async () => {
if (!validateForm()) {
return;
}
loading.value = true;
try {
const payload = { name: groupName.value };
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, payload);
notificationStore.addNotification({ message: 'Group created successfully', type: 'success' });
emit('created', response.data);
closeModal();
} catch (error: any) {
const message = error?.response?.data?.detail || (error instanceof Error ? error.message : 'Failed to create group');
formError.value = message;
notificationStore.addNotification({ message, type: 'error' });
console.error(message, error);
} finally {
loading.value = false;
}
};
</script>
<style>
.form-error-text {
color: var(--danger);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
</style>

View File

@ -1,45 +1,75 @@
<template> <template>
<div class="main-layout"> <div class="main-layout">
<header class="app-header"> <header class="app-header">
<div class="toolbar-title">mitlist</div> <div class="toolbar-title">mitlist</div>
<!-- Group all authenticated controls for cleaner conditional rendering -->
<div v-if="authStore.isAuthenticated" class="header-controls">
<!-- Add Menu -->
<div class="control-item">
<button ref="addMenuTrigger" class="icon-button" :class="{ 'is-active': addMenuOpen }"
:aria-expanded="addMenuOpen" aria-controls="add-menu-dropdown" aria-label="Add new list or group"
@click="toggleAddMenu">
<span class="material-icons">add_circle_outline</span>
</button>
<Transition name="dropdown-fade">
<div v-if="addMenuOpen" id="add-menu-dropdown" ref="addMenuDropdown" class="dropdown-menu add-dropdown"
role="menu">
<div class="dropdown-header">{{ $t('addSelector.title') }}</div>
<a href="#" role="menuitem" @click.prevent="handleAddList">{{ $t('addSelector.addList') }}</a>
<a href="#" role="menuitem" @click.prevent="handleAddGroup">{{ $t('addSelector.addGroup') }}</a>
</div>
</Transition>
</div>
<!-- Language Menu -->
<div class="flex align-end"> <div class="control-item">
<div class="language-selector" v-if="authStore.isAuthenticated"> <button ref="languageMenuTrigger" class="icon-button language-button"
<button @click="toggleLanguageMenu" class="language-menu-button"> :class="{ 'is-active': languageMenuOpen }" :aria-expanded="languageMenuOpen"
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 0 24 24" width="20px" fill="#ff7b54"> aria-controls="language-menu-dropdown"
<path d="M0 0h24v24H0z" fill="none" /> :aria-label="`Change language, current: ${currentLanguageCode.toUpperCase()}`" @click="toggleLanguageMenu">
<path <span class="material-icons-outlined">translate</span>
d="m12.87 15.07-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z" />
</svg>
<span class="current-language">{{ currentLanguageCode.toUpperCase() }}</span> <span class="current-language">{{ currentLanguageCode.toUpperCase() }}</span>
</button> </button>
<div v-if="languageMenuOpen" class="dropdown-menu language-dropdown" ref="languageMenuDropdown"> <Transition name="dropdown-fade">
<div v-if="languageMenuOpen" id="language-menu-dropdown" ref="languageMenuDropdown"
class="dropdown-menu language-dropdown" role="menu">
<div class="dropdown-header">{{ $t('languageSelector.title') }}</div> <div class="dropdown-header">{{ $t('languageSelector.title') }}</div>
<a v-for="(name, code) in availableLanguages" :key="code" href="#" @click.prevent="changeLanguage(code)" <a v-for="(name, code) in availableLanguages" :key="code" href="#" role="menuitem" class="language-option"
class="language-option" :class="{ 'active': currentLanguageCode === code }"> :class="{ 'active': currentLanguageCode === code }" @click.prevent="changeLanguage(code)">
{{ name }} {{ name }}
</a> </a>
</div> </div>
</Transition>
</div> </div>
<div class="user-menu" v-if="authStore.isAuthenticated"> <!-- User Menu -->
<button @click="toggleUserMenu" class="user-menu-button"> <div class="control-item">
<!-- Placeholder for user icon --> <button ref="userMenuTrigger" class="icon-button user-menu-button" :class="{ 'is-active': userMenuOpen }"
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ff7b54"> :aria-expanded="userMenuOpen" aria-controls="user-menu-dropdown" aria-label="User menu"
<path d="M0 0h24v24H0z" fill="none" /> @click="toggleUserMenu">
<path <!-- Show user avatar if available, otherwise a fallback icon -->
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" /> <img v-if="authStore.user?.avatarUrl" :src="authStore.user.avatarUrl" alt="User Avatar"
</svg> class="user-avatar" />
<span v-else class="material-icons">account_circle</span>
</button> </button>
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown"> <Transition name="dropdown-fade">
<a href="#" @click.prevent="handleLogout">Logout</a> <div v-if="userMenuOpen" id="user-menu-dropdown" ref="userMenuDropdown" class="dropdown-menu" role="menu">
<div v-if="authStore.user" class="dropdown-user-info">
<strong>{{ authStore.user.name }}</strong>
<small>{{ authStore.user.email }}</small>
</div> </div>
<a href="#" role="menuitem" @click.prevent="handleLogout">Logout</a>
</div>
</Transition>
</div> </div>
</div> </div>
</header> </header>
<!-- =================================================================
MAIN CONTENT
================================================================== -->
<main class="page-container"> <main class="page-container">
<router-view v-slot="{ Component, route }"> <router-view v-slot="{ Component, route }">
<keep-alive v-if="route.meta.keepAlive"> <keep-alive v-if="route.meta.keepAlive">
@ -51,44 +81,50 @@
<OfflineIndicator /> <OfflineIndicator />
<!-- =================================================================
FOOTER NAVIGATION
Improved with more semantic router-links and better active state styling.
================================================================== -->
<footer class="app-footer"> <footer class="app-footer">
<nav class="tabs"> <nav class="tabs">
<router-link to="/lists" class="tab-item" active-class="active"> <router-link to="/lists" class="tab-item" active-class="active">
<span class="material-icons">list</span> <span class="material-icons">list</span>
<span class="tab-text">Lists</span> <span class="tab-text">Lists</span>
</router-link> </router-link>
<a @click.prevent="navigateToGroups" href="/groups" class="tab-item" <!-- Use a RouterLink for semantics and a11y, but keep custom click handler -->
:class="{ 'active': $route.path.startsWith('/groups') }"> <router-link to="/groups" class="tab-item" :class="{ 'active': $route.path.startsWith('/groups') }"
@click.prevent="navigateToGroups">
<span class="material-icons">group</span> <span class="material-icons">group</span>
<span class="tab-text">Groups</span> <span class="tab-text">Groups</span>
</a> </router-link>
<router-link to="/chores" class="tab-item" active-class="active"> <router-link to="/chores" class="tab-item" active-class="active">
<span class="material-icons">person_pin_circle</span> <span class="material-icons">task_alt</span> <!-- More appropriate icon for chores -->
<span class="tab-text">Chores</span> <span class="tab-text">Chores</span>
</router-link> </router-link>
<!-- <router-link to="/account" class="tab-item" active-class="active">
<span class="material-icons">person</span>
<span class="tab-text">Account</span>
</router-link> -->
</nav> </nav>
</footer> </footer>
<!-- =================================================================
MODALS
================================================================== -->
<CreateListModal v-model="showCreateListModal" @created="handleListCreated" />
<CreateGroupModal v-model="showCreateGroupModal" @created="handleGroupCreated" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, defineComponent, onMounted, computed } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import OfflineIndicator from '@/components/OfflineIndicator.vue'; import OfflineIndicator from '@/components/OfflineIndicator.vue';
import { onClickOutside } from '@vueuse/core';
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
import { useGroupStore } from '@/stores/groupStore'; import { useGroupStore } from '@/stores/groupStore';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import CreateListModal from '@/components/CreateListModal.vue';
import CreateGroupModal from '@/components/CreateGroupModal.vue';
import { onClickOutside } from '@vueuse/core';
defineComponent({ // Store and Router setup
name: 'MainLayout'
});
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const authStore = useAuthStore(); const authStore = useAuthStore();
@ -96,48 +132,30 @@ const notificationStore = useNotificationStore();
const groupStore = useGroupStore(); const groupStore = useGroupStore();
const { t, locale } = useI18n(); const { t, locale } = useI18n();
// Add initialization logic // --- Dropdown Logic (Re-integrated from composable) ---
const initializeApp = async () => {
if (authStore.isAuthenticated) {
try {
await authStore.fetchCurrentUser();
} catch (error) {
console.error('Failed to initialize app:', error);
// Don't automatically logout - let the API interceptor handle token refresh
// The response interceptor will handle 401s and refresh tokens automatically
}
}
};
// Call initialization when component is mounted // 1. Add Menu Dropdown
onMounted(() => { const addMenuOpen = ref(false);
initializeApp(); const addMenuDropdown = ref<HTMLElement | null>(null);
if (authStore.isAuthenticated) { const addMenuTrigger = ref<HTMLElement | null>(null);
groupStore.fetchGroups(); const toggleAddMenu = () => { addMenuOpen.value = !addMenuOpen.value; };
} onClickOutside(addMenuDropdown, () => { addMenuOpen.value = false; }, { ignore: [addMenuTrigger] });
// Load saved language from localStorage // 2. Language Menu Dropdown
const savedLanguage = localStorage.getItem('language');
if (savedLanguage && ['en', 'de', 'nl', 'fr', 'es'].includes(savedLanguage)) {
locale.value = savedLanguage;
}
});
const userMenuOpen = ref(false);
const userMenuDropdown = ref<HTMLElement | null>(null);
const toggleUserMenu = () => {
userMenuOpen.value = !userMenuOpen.value;
};
onClickOutside(userMenuDropdown, () => {
userMenuOpen.value = false;
}, { ignore: ['.user-menu-button'] });
// Language selector state and functions
const languageMenuOpen = ref(false); const languageMenuOpen = ref(false);
const languageMenuDropdown = ref<HTMLElement | null>(null); const languageMenuDropdown = ref<HTMLElement | null>(null);
const languageMenuTrigger = ref<HTMLElement | null>(null);
const toggleLanguageMenu = () => { languageMenuOpen.value = !languageMenuOpen.value; };
onClickOutside(languageMenuDropdown, () => { languageMenuOpen.value = false; }, { ignore: [languageMenuTrigger] });
// 3. User Menu Dropdown
const userMenuOpen = ref(false);
const userMenuDropdown = ref<HTMLElement | null>(null);
const userMenuTrigger = ref<HTMLElement | null>(null);
const toggleUserMenu = () => { userMenuOpen.value = !userMenuOpen.value; };
onClickOutside(userMenuDropdown, () => { userMenuOpen.value = false; }, { ignore: [userMenuTrigger] });
// --- Language Selector Logic ---
const availableLanguages = computed(() => ({ const availableLanguages = computed(() => ({
en: t('languageSelector.languages.en'), en: t('languageSelector.languages.en'),
de: t('languageSelector.languages.de'), de: t('languageSelector.languages.de'),
@ -145,64 +163,96 @@ const availableLanguages = computed(() => ({
fr: t('languageSelector.languages.fr'), fr: t('languageSelector.languages.fr'),
es: t('languageSelector.languages.es') es: t('languageSelector.languages.es')
})); }));
const currentLanguageCode = computed(() => locale.value); const currentLanguageCode = computed(() => locale.value);
const toggleLanguageMenu = () => {
languageMenuOpen.value = !languageMenuOpen.value;
};
const changeLanguage = (languageCode: string) => { const changeLanguage = (languageCode: string) => {
locale.value = languageCode; locale.value = languageCode;
localStorage.setItem('language', languageCode); localStorage.setItem('language', languageCode);
languageMenuOpen.value = false; languageMenuOpen.value = false; // Close menu on selection
notificationStore.addNotification({ notificationStore.addNotification({
type: 'success', type: 'success',
message: `Language changed to ${availableLanguages.value[languageCode as keyof typeof availableLanguages.value]}`, message: `Language changed to ${availableLanguages.value[languageCode as keyof typeof availableLanguages.value]}`,
}); });
}; };
onClickOutside(languageMenuDropdown, () => { // --- Modal Handling ---
languageMenuOpen.value = false; const showCreateListModal = ref(false);
}, { ignore: ['.language-menu-button'] }); const showCreateGroupModal = ref(false);
const handleAddList = () => {
addMenuOpen.value = false; // Close menu
showCreateListModal.value = true;
};
const handleAddGroup = () => {
addMenuOpen.value = false; // Close menu
showCreateGroupModal.value = true;
};
const handleListCreated = (newList: any) => {
notificationStore.addNotification({ message: `List '${newList.name}' created successfully`, type: 'success' });
showCreateListModal.value = false;
};
const handleGroupCreated = (newGroup: any) => {
notificationStore.addNotification({ message: `Group '${newGroup.name}' created successfully`, type: 'success' });
showCreateGroupModal.value = false;
groupStore.fetchGroups(); // Refresh groups after creation
};
// --- User and Navigation Logic ---
const handleLogout = async () => { const handleLogout = async () => {
try { try {
authStore.logout(); // Pinia action userMenuOpen.value = false; // Close menu
notificationStore.addNotification({ authStore.logout();
type: 'success', notificationStore.addNotification({ type: 'success', message: 'Logged out successfully' });
message: 'Logged out successfully', await router.push('/auth/login');
});
await router.push('/auth/login'); // Adjusted path
} catch (error: unknown) { } catch (error: unknown) {
notificationStore.addNotification({ notificationStore.addNotification({
type: 'error', type: 'error',
message: error instanceof Error ? error.message : 'Logout failed', message: error instanceof Error ? error.message : 'Logout failed',
}); });
} }
userMenuOpen.value = false;
}; };
const navigateToGroups = () => { const navigateToGroups = () => {
// The groups should have been fetched on mount, but we can check isLoading if (groupStore.isLoading) return;
if (groupStore.isLoading) {
// Maybe show a toast or do nothing
console.log('Groups are still loading...');
return;
}
if (groupStore.groupCount === 1 && groupStore.firstGroupId) { if (groupStore.groupCount === 1 && groupStore.firstGroupId) {
router.push(`/groups/${groupStore.firstGroupId}`); router.push(`/groups/${groupStore.firstGroupId}`);
} else { } else {
router.push('/groups'); router.push('/groups');
} }
}; };
// --- App Initialization ---
onMounted(async () => {
// Fetch essential data for authenticated users
if (authStore.isAuthenticated) {
try {
await authStore.fetchCurrentUser();
await groupStore.fetchGroups();
} catch (error) {
console.error('Failed to initialize app data:', error);
}
}
// Load saved language preference
const savedLanguage = localStorage.getItem('language');
if (savedLanguage && Object.keys(availableLanguages.value).includes(savedLanguage)) {
locale.value = savedLanguage;
}
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
// Using Google's outlined icons for a lighter feel
@import url('https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined');
.main-layout { .main-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
background-color: #f9f9f9; // A slightly off-white background for the main page
} }
.app-header { .app-header {
@ -212,7 +262,7 @@ const navigateToGroups = () => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
@ -225,24 +275,47 @@ const navigateToGroups = () => {
color: var(--primary); color: var(--primary);
} }
.language-selector { .header-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-item {
position: relative; position: relative;
} }
.language-menu-button { .icon-button {
background: none; background: none;
border: none; border: 1px solid transparent; // Prevents layout shift on hover
color: var(--primary); color: var(--primary);
cursor: pointer; cursor: pointer;
padding: 0.5rem; padding: 0.5rem;
border-radius: 8px; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; justify-content: center;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
&:hover { &:hover {
background-color: rgba(255, 123, 84, 0.1); background-color: rgba(255, 123, 84, 0.1);
} }
&.is-active {
background-color: rgba(255, 123, 84, 0.15);
box-shadow: 0 0 0 2px rgba(255, 123, 84, 0.3);
}
.material-icons,
.material-icons-outlined {
font-size: 26px;
}
}
.language-button {
border-radius: 20px;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
} }
.current-language { .current-language {
@ -251,8 +324,43 @@ const navigateToGroups = () => {
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.language-dropdown { .user-menu-button {
padding: 0; // Remove padding if image is used
width: 40px;
height: 40px;
overflow: hidden;
.user-avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.dropdown-menu {
position: absolute;
right: 0;
top: calc(100% + 8px); // A bit more space
background-color: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 180px; min-width: 180px;
z-index: 101;
overflow: hidden; // To respect child border-radius
a {
display: block;
padding: 0.75rem 1rem;
color: var(--text-color);
text-decoration: none;
transition: background-color 0.2s ease;
&:hover {
background-color: #f5f5f5;
}
}
}
.dropdown-header { .dropdown-header {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
@ -265,71 +373,43 @@ const navigateToGroups = () => {
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0;
} }
.language-option { .dropdown-user-info {
display: block;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
color: var(--text-color); border-bottom: 1px solid #e0e0e0;
text-decoration: none; display: flex;
flex-direction: column;
&:hover { strong {
background-color: #f5f5f5; font-weight: 500;
} }
&.active { small {
font-size: 0.8em;
opacity: 0.7;
}
}
.language-option.active {
background-color: rgba(255, 123, 84, 0.1); background-color: rgba(255, 123, 84, 0.1);
color: var(--primary); color: var(--primary);
font-weight: 500; font-weight: 500;
} }
}
/* Dropdown transition */
.dropdown-fade-enter-active,
.dropdown-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
} }
.dropdown-menu { .dropdown-fade-enter-from,
position: absolute; .dropdown-fade-leave-to {
right: 0; opacity: 0;
top: calc(100% + 5px); transform: translateY(-10px);
color: var(--primary);
background-color: #f3f3f3;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
min-width: 150px;
z-index: 101;
a {
display: block;
padding: 0.5rem 1rem;
color: var(--text-color);
text-decoration: none;
&:hover {
background-color: #f5f5f5;
}
}
}
.user-menu {
position: relative;
}
.user-menu-button {
background: none;
border: none;
color: var(--primary);
cursor: pointer;
padding: 0.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
} }
.page-container { .page-container {
flex-grow: 1; flex-grow: 1;
padding-bottom: calc(var(--footer-height) + 1rem); // Space for fixed footer padding-bottom: calc(var(--footer-height) + 1rem);
} }
.app-footer { .app-footer {
@ -341,6 +421,7 @@ const navigateToGroups = () => {
left: 0; left: 0;
right: 0; right: 0;
z-index: 100; z-index: 100;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
} }
.tabs { .tabs {
@ -354,19 +435,43 @@ const navigateToGroups = () => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--text-color); color: #757575; // Softer default color
text-decoration: none; text-decoration: none;
font-size: 0.8rem;
padding: 0.5rem 0; padding: 0.5rem 0;
border-bottom: 2px solid transparent;
gap: 4px; gap: 4px;
transition: background-color 0.2s ease, color 0.2s ease;
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 3px;
background-color: var(--primary);
transition: width 0.3s ease;
}
&.active {
color: var(--primary);
&::after {
width: 50%;
}
}
&:hover:not(.active) {
color: var(--primary);
}
.material-icons { .material-icons {
font-size: 24px; font-size: 24px;
} }
.tab-text { .tab-text {
display: none; font-size: 0.75rem;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@ -374,17 +479,8 @@ const navigateToGroups = () => {
gap: 8px; gap: 8px;
.tab-text { .tab-text {
display: inline; font-size: 0.9rem;
} }
} }
&.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
&:hover {
background-color: #f0f0f0;
}
} }
</style> </style>

View File

@ -5,36 +5,20 @@ import { BrowserTracing } from '@sentry/tracing'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import enMessages from './i18n/en.json' // Import en.json directly import enMessages from './i18n/en.json'
import deMessages from './i18n/de.json' import deMessages from './i18n/de.json'
import frMessages from './i18n/fr.json' import frMessages from './i18n/fr.json'
import esMessages from './i18n/es.json' import esMessages from './i18n/es.json'
import nlMessages from './i18n/nl.json' import nlMessages from './i18n/nl.json'
// Global styles
import './assets/main.scss' import './assets/main.scss'
import { api, globalAxios } from '@/services/api'
// API client (from your axios boot file)
import { api, globalAxios } from '@/services/api' // Renamed from boot/axios to services/api
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
// Vue I18n setup (from your i18n boot file)
// // export type MessageLanguages = keyof typeof messages;
// // export type MessageSchema = (typeof messages)['en-US'];
// // export type MessageLanguages = keyof typeof messages;
// // export type MessageSchema = (typeof messages)['en-US'];
// // declare module 'vue-i18n' {
// // export interface DefineLocaleMessage extends MessageSchema {}
// // // eslint-disable-next-line @typescript-eslint/no-empty-object-type
// // export interface DefineDateTimeFormat {}
// // // eslint-disable-next-line @typescript-eslint/no-empty-object-type
// // export interface DefineNumberFormat {}
// // }
const i18n = createI18n({ const i18n = createI18n({
legacy: false, // Recommended for Vue 3 legacy: false,
locale: 'en', // Default locale locale: 'en',
fallbackLocale: 'en', // Fallback locale fallbackLocale: 'en',
messages: { messages: {
en: enMessages, en: enMessages,
de: deMessages, de: deMessages,
@ -48,7 +32,6 @@ const app = createApp(App)
const pinia = createPinia() const pinia = createPinia()
app.use(pinia) app.use(pinia)
// Initialize Sentry
Sentry.init({ Sentry.init({
app, app,
dsn: import.meta.env.VITE_SENTRY_DSN, dsn: import.meta.env.VITE_SENTRY_DSN,
@ -58,27 +41,21 @@ Sentry.init({
tracingOrigins: ['localhost', /^\//], tracingOrigins: ['localhost', /^\//],
}), }),
], ],
// Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring.
// We recommend adjusting this value in production
tracesSampleRate: 1.0, tracesSampleRate: 1.0,
// Set environment
environment: import.meta.env.MODE, environment: import.meta.env.MODE,
}) })
// Initialize auth state before mounting the app
const authStore = useAuthStore() const authStore = useAuthStore()
if (authStore.accessToken) { if (authStore.accessToken) {
authStore.fetchCurrentUser().catch((error) => { authStore.fetchCurrentUser().catch((error) => {
console.error('Failed to initialize current user state:', error) console.error('Failed to initialize current user state:', error)
// The fetchCurrentUser action handles token clearing on failure.
}) })
} }
app.use(router) app.use(router)
app.use(i18n) app.use(i18n)
// Make API instance globally available (optional, prefer provide/inject or store)
app.config.globalProperties.$api = api app.config.globalProperties.$api = api
app.config.globalProperties.$axios = globalAxios // The original axios instance if needed app.config.globalProperties.$axios = globalAxios
app.mount('#app') app.mount('#app')

View File

@ -10,6 +10,8 @@ import { useStorage } from '@vueuse/core'
const { t } = useI18n() const { t } = useI18n()
const props = defineProps<{ groupId?: number | string }>();
// Types // Types
interface ChoreWithCompletion extends Chore { interface ChoreWithCompletion extends Chore {
current_assignment_id: number | null; current_assignment_id: number | null;
@ -162,10 +164,19 @@ const getChoreSubtext = (chore: ChoreWithCompletion): string => {
return parts.join(' · '); return parts.join(' · ');
}; };
const groupedChores = computed(() => { const filteredChores = computed(() => {
if (!chores.value) return [] if (props.groupId) {
return chores.value.filter(
c => c.type === 'group' && String(c.group_id) === String(props.groupId)
);
}
return chores.value;
});
const choresByDate = chores.value.reduce((acc, chore) => { const groupedChores = computed(() => {
if (!filteredChores.value) return []
const choresByDate = filteredChores.value.reduce((acc, chore) => {
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd') const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd')
if (!acc[dueDate]) { if (!acc[dueDate]) {
acc[dueDate] = [] acc[dueDate] = []
@ -177,7 +188,6 @@ const groupedChores = computed(() => {
return Object.keys(choresByDate) return Object.keys(choresByDate)
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime()) .sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
.map(dateStr => { .map(dateStr => {
// Create a new Date object and ensure it's interpreted as local time, not UTC
const dateParts = dateStr.split('-').map(Number); const dateParts = dateStr.split('-').map(Number);
const date = new Date(dateParts[0], dateParts[1] - 1, dateParts[2]); const date = new Date(dateParts[0], dateParts[1] - 1, dateParts[2]);
return { return {
@ -190,7 +200,7 @@ const groupedChores = computed(() => {
})) }))
} }
}); });
}) });
const formatDateHeader = (date: Date) => { const formatDateHeader = (date: Date) => {
const today = startOfDay(new Date()) const today = startOfDay(new Date())
@ -210,6 +220,10 @@ const resetChoreForm = () => {
const openCreateChoreModal = () => { const openCreateChoreModal = () => {
resetChoreForm() resetChoreForm()
if (props.groupId) {
choreForm.value.type = 'group';
choreForm.value.group_id = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
}
showChoreModal.value = true showChoreModal.value = true
} }
@ -402,7 +416,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
<template> <template>
<div class="container"> <div class="container">
<header class="flex justify-between items-center"> <header v-if="!props.groupId" class="flex justify-between items-center">
<h1 style="margin-block-start: 0;">{{ t('choresPage.title') }}</h1> <h1 style="margin-block-start: 0;">{{ t('choresPage.title') }}</h1>
<button class="btn btn-primary" @click="openCreateChoreModal"> <button class="btn btn-primary" @click="openCreateChoreModal">
{{ t('choresPage.addChore', '+') }} {{ t('choresPage.addChore', '+') }}

View File

@ -71,104 +71,13 @@
</div> </div>
</div> </div>
<div class="neo-section-container"> <div class="neo-section-cntainer">
<!-- Lists Section --> <!-- Lists Section -->
<div class="neo-section"> <div class="neo-section">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.lists.title') }}</VHeading> <ChoresPage :group-id="groupId" />
<ListsPage :group-id="groupId" /> <ListsPage :group-id="groupId" />
</div> </div>
<!-- Chores Section -->
<div class="mt-4 neo-section">
<div class="flex justify-between items-center w-full mb-2">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading>
<VButton @click="showGenerateScheduleModal = true">{{ t('groupDetailPage.chores.generateScheduleButton')
}}
</VButton>
</div>
<div v-if="upcomingChores.length > 0" class="enhanced-chores-list">
<div v-for="chore in upcomingChores" :key="chore.id" class="enhanced-chore-item"
:class="`status-${getDueDateStatus(chore)} ${getChoreStatusInfo(chore).isCompleted ? 'completed' : ''}`"
@click="openChoreDetailModal(chore)">
<div class="chore-main-content">
<div class="chore-icon-container">
<div class="chore-status-indicator" :class="{
'overdue': getDueDateStatus(chore) === 'overdue',
'due-today': getDueDateStatus(chore) === 'due-today',
'completed': getChoreStatusInfo(chore).isCompleted
}">
{{ getChoreStatusInfo(chore).isCompleted ? '✅' :
getDueDateStatus(chore) === 'overdue' ? '⚠️' :
getDueDateStatus(chore) === 'due-today' ? '📅' : '📋' }}
</div>
</div>
<div class="chore-text-content">
<div class="chore-header">
<span class="neo-chore-name" :class="{ completed: getChoreStatusInfo(chore).isCompleted }">
{{ chore.name }}
</span>
<div class="chore-badges">
<VBadge :text="formatFrequency(chore.frequency)"
:variant="getFrequencyBadgeVariant(chore.frequency)" />
<VBadge v-if="getDueDateStatus(chore) === 'overdue'" text="Overdue" variant="danger" />
<VBadge v-if="getDueDateStatus(chore) === 'due-today'" text="Due Today" variant="warning" />
<VBadge v-if="getChoreStatusInfo(chore).isCompleted" text="Completed" variant="success" />
</div>
</div>
<div class="chore-details">
<div class="chore-due-info">
<span class="due-label">Due:</span>
<span class="due-date" :class="getDueDateStatus(chore)">
{{ formatDate(chore.next_due_date) }}
<span v-if="getDueDateStatus(chore) === 'due-today'" class="today-indicator">(Today)</span>
<span v-if="getDueDateStatus(chore) === 'overdue'" class="overdue-indicator">
({{ formatDistanceToNow(new Date(chore.next_due_date), { addSuffix: true }) }})
</span>
</span>
</div>
<div class="chore-assignment-info">
<span class="assignment-label">Assigned to:</span>
<span class="assigned-user">{{ getChoreStatusInfo(chore).assignedUserName }}</span>
</div>
<div v-if="chore.description" class="chore-description">
{{ chore.description }}
</div>
<div
v-if="getChoreStatusInfo(chore).isCompleted && getChoreStatusInfo(chore).currentAssignment?.completed_at"
class="completion-info">
Completed {{ formatDistanceToNow(new
Date(getChoreStatusInfo(chore).currentAssignment!.completed_at!),
{ addSuffix: true }) }}
</div>
</div>
</div>
</div>
<div class="chore-actions">
<VButton size="sm" variant="neutral" @click.stop="openChoreDetailModal(chore)" title="View Details">
👁
</VButton>
</div>
</div>
</div>
<div v-else class="text-center py-4">
<VIcon name="cleaning_services" size="lg" class="opacity-50 mb-2" />
<p>{{ t('groupDetailPage.chores.emptyState') }}</p>
</div>
</div>
<!-- Group Activity Log Section -->
<div class="mt-4 neo-section">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.activityLog.title') }}</VHeading>
<div v-if="groupHistoryLoading" class="text-center">
<VSpinner />
</div>
<ul v-else-if="groupChoreHistory.length > 0" class="activity-log-list">
<li v-for="entry in groupChoreHistory" :key="entry.id" class="activity-log-item">
{{ formatHistoryEntry(entry) }}
</li>
</ul>
<p v-else>{{ t('groupDetailPage.activityLog.emptyState') }}</p>
</div>
<!-- Expenses Section --> <!-- Expenses Section -->
<div class="mt-4 neo-section"> <div class="mt-4 neo-section">
@ -269,6 +178,22 @@
<p>{{ t('groupDetailPage.expenses.emptyState') }}</p> <p>{{ t('groupDetailPage.expenses.emptyState') }}</p>
</div> </div>
</div> </div>
<!-- Group Activity Log Section -->
<div class="mt-4 neo-section">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.activityLog.title') }}</VHeading>
<div v-if="groupHistoryLoading" class="text-center">
<VSpinner />
</div>
<ul v-else-if="groupChoreHistory.length > 0" class="activity-log-list">
<li v-for="entry in groupChoreHistory" :key="entry.id" class="activity-log-item">
{{ formatHistoryEntry(entry) }}
</li>
</ul>
<p v-else>{{ t('groupDetailPage.activityLog.emptyState') }}</p>
</div>
</div> </div>
</div> </div>
@ -499,6 +424,7 @@ import VModal from '@/components/valerie/VModal.vue';
import VSelect from '@/components/valerie/VSelect.vue'; import VSelect from '@/components/valerie/VSelect.vue';
import { onClickOutside } from '@vueuse/core' import { onClickOutside } from '@vueuse/core'
import { groupService } from '../services/groupService'; // New service import { groupService } from '../services/groupService'; // New service
import ChoresPage from './ChoresPage.vue';
const { t } = useI18n(); const { t } = useI18n();

View File

@ -58,10 +58,10 @@ api.interceptors.response.use(
return Promise.reject(error) return Promise.reject(error)
} }
// Send refresh token in Authorization header as expected by backend // Send refresh token in request body as expected by backend
const response = await api.post(API_ENDPOINTS.AUTH.REFRESH, {}, { const response = await api.post(API_ENDPOINTS.AUTH.REFRESH, { refresh_token: refreshTokenValue }, {
headers: { headers: {
Authorization: `Bearer ${refreshTokenValue}` 'Content-Type': 'application/json',
} }
}) })