From dccd7bb3009c138c5b3f08b98ff861795d62b6c1 Mon Sep 17 00:00:00 2001 From: mohamad Date: Mon, 9 Jun 2025 13:06:01 +0200 Subject: [PATCH 1/2] feat: Implement JWT refresh token endpoint and update OAuth routing --- be/app/api/auth/oauth.py | 33 ++++++++++++++++++++++++++++++--- be/app/api/v1/api.py | 2 ++ fe/package-lock.json | 8 ++++++++ fe/package.json | 1 + fe/src/services/api.ts | 6 +++--- 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/be/app/api/auth/oauth.py b/be/app/api/auth/oauth.py index 13223d6..6a1b624 100644 --- a/be/app/api/auth/oauth.py +++ b/be/app/api/auth/oauth.py @@ -1,11 +1,12 @@ -from fastapi import APIRouter, Depends, Request -from fastapi.responses import RedirectResponse +from fastapi import APIRouter, Depends, Request, HTTPException, status +from fastapi.responses import RedirectResponse, JSONResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.database import get_transactional_session from app.models import User from app.auth import oauth, fastapi_users, auth_backend, get_jwt_strategy, get_refresh_jwt_strategy from app.config import settings +from fastapi.security import OAuth2PasswordRequestForm router = APIRouter() @@ -92,4 +93,30 @@ async def apple_callback(request: Request, db: AsyncSession = Depends(get_transa # Redirect to frontend with tokens redirect_url = f"{settings.FRONTEND_URL}/auth/callback?access_token={access_token}&refresh_token={refresh_token}" - return RedirectResponse(url=redirect_url) \ No newline at end of file + 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" + }) \ No newline at end of file diff --git a/be/app/api/v1/api.py b/be/app/api/v1/api.py index adc28cc..4d0599e 100644 --- a/be/app/api/v1/api.py +++ b/be/app/api/v1/api.py @@ -9,6 +9,7 @@ from app.api.v1.endpoints import ocr from app.api.v1.endpoints import costs from app.api.v1.endpoints import financials from app.api.v1.endpoints import chores +from app.api.auth import oauth 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(financials.router, prefix="/financials", tags=["Financials"]) 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 # e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"]) \ No newline at end of file diff --git a/fe/package-lock.json b/fe/package-lock.json index ff478d6..31a8204 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -34,6 +34,7 @@ "@types/date-fns": "^2.5.3", "@types/jsdom": "^21.1.7", "@types/node": "^22.15.17", + "@types/qs": "^6.14.0", "@vitejs/plugin-vue": "^5.2.3", "@vitest/eslint-plugin": "^1.1.39", "@vue/eslint-config-prettier": "^10.2.0", @@ -4291,6 +4292,13 @@ "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", "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": { "version": "19.1.6", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz", diff --git a/fe/package.json b/fe/package.json index b9253fb..6e9b709 100644 --- a/fe/package.json +++ b/fe/package.json @@ -45,6 +45,7 @@ "@types/date-fns": "^2.5.3", "@types/jsdom": "^21.1.7", "@types/node": "^22.15.17", + "@types/qs": "^6.14.0", "@vitejs/plugin-vue": "^5.2.3", "@vitest/eslint-plugin": "^1.1.39", "@vue/eslint-config-prettier": "^10.2.0", diff --git a/fe/src/services/api.ts b/fe/src/services/api.ts index 9b16cbf..6215ce8 100644 --- a/fe/src/services/api.ts +++ b/fe/src/services/api.ts @@ -58,10 +58,10 @@ api.interceptors.response.use( return Promise.reject(error) } - // Send refresh token in Authorization header as expected by backend - const response = await api.post(API_ENDPOINTS.AUTH.REFRESH, {}, { + // Send refresh token in request body as expected by backend + const response = await api.post(API_ENDPOINTS.AUTH.REFRESH, { refresh_token: refreshTokenValue }, { headers: { - Authorization: `Bearer ${refreshTokenValue}` + 'Content-Type': 'application/json', } }) -- 2.45.2 From 10845d2e5f0cb34252effa9545acf6a91299645a Mon Sep 17 00:00:00 2001 From: mohamad Date: Mon, 9 Jun 2025 14:04:30 +0200 Subject: [PATCH 2/2] feat: Enhance OCR processing and UI components This commit introduces significant updates to the OCR processing logic and UI components: - Updated the OCR extraction prompt in `config.py` to provide detailed instructions for handling shopping list images, improving accuracy in item identification and extraction. - Upgraded the `GEMINI_MODEL_NAME` to a newer version for enhanced OCR capabilities. - Added a new `CreateGroupModal.vue` component for creating groups, improving user interaction. - Updated `MainLayout.vue` to integrate the new group creation modal and enhance the user menu and language selector functionality. - Improved styling and structure in various components, including `ChoresPage.vue` and `GroupDetailPage.vue`, for better user experience and visual consistency. These changes aim to enhance the overall functionality and usability of the application. --- be/app/config.py | 176 +++++++++- fe/package-lock.json | 21 +- fe/package.json | 1 + fe/src/components/CreateGroupModal.vue | 102 ++++++ fe/src/layouts/MainLayout.vue | 440 +++++++++++++++---------- fe/src/main.ts | 35 +- fe/src/pages/ChoresPage.vue | 38 ++- fe/src/pages/GroupDetailPage.vue | 112 ++----- 8 files changed, 596 insertions(+), 329 deletions(-) create mode 100644 fe/src/components/CreateGroupModal.vue diff --git a/be/app/config.py b/be/app/config.py index e709af5..fd48199 100644 --- a/be/app/config.py +++ b/be/app/config.py @@ -26,18 +26,168 @@ class Settings(BaseSettings): 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 OCR_ITEM_EXTRACTION_PROMPT: str = """ -Extract the shopping list items from this image. -List each distinct item on a new line. -Ignore prices, quantities, store names, discounts, taxes, totals, and other non-item text. -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. -If the image does not appear to be a shopping list or receipt, state that clearly. -Example output for a grocery list: -Milk -Eggs -Bread -__Apples__ -Organic Bananas +**ROLE & GOAL** + +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." + +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. + +**INPUT** + +You will receive a single image (`[Image]`). This image contains a shopping list. It may be: +* Neatly written or very messy. +* On lined paper, a whiteboard, a napkin, or a dedicated notepad. +* 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_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}" # --- 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 = { "HARM_CATEGORY_HATE_SPEECH": "BLOCK_MEDIUM_AND_ABOVE", "HARM_CATEGORY_DANGEROUS_CONTENT": "BLOCK_MEDIUM_AND_ABOVE", diff --git a/fe/package-lock.json b/fe/package-lock.json index 31a8204..b296c5a 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -15,6 +15,7 @@ "@vueuse/core": "^13.1.0", "axios": "^1.9.0", "date-fns": "^4.1.0", + "framer-motion": "^12.16.0", "motion": "^12.15.0", "pinia": "^3.0.2", "qs": "^6.14.0", @@ -7499,12 +7500,12 @@ } }, "node_modules/framer-motion": { - "version": "12.15.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.15.0.tgz", - "integrity": "sha512-XKg/LnKExdLGugZrDILV7jZjI599785lDIJZLxMiiIFidCsy0a4R2ZEf+Izm67zyOuJgQYTHOmodi7igQsw3vg==", + "version": "12.16.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.16.0.tgz", + "integrity": "sha512-xryrmD4jSBQrS2IkMdcTmiS4aSKckbS7kLDCuhUn9110SQKG1w3zlq1RTqCblewg+ZYe+m3sdtzQA6cRwo5g8Q==", "license": "MIT", "dependencies": { - "motion-dom": "^12.15.0", + "motion-dom": "^12.16.0", "motion-utils": "^12.12.1", "tslib": "^2.4.0" }, @@ -9303,9 +9304,9 @@ } }, "node_modules/motion-dom": { - "version": "12.15.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.15.0.tgz", - "integrity": "sha512-D2ldJgor+2vdcrDtKJw48k3OddXiZN1dDLLWrS8kiHzQdYVruh0IoTwbJBslrnTXIPgFED7PBN2Zbwl7rNqnhA==", + "version": "12.16.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.16.0.tgz", + "integrity": "sha512-Z2nGwWrrdH4egLEtgYMCEN4V2qQt1qxlKy/uV7w691ztyA41Q5Rbn0KNGbsNVDZr9E8PD2IOQ3hSccRnB6xWzw==", "license": "MIT", "dependencies": { "motion-utils": "^12.12.1" @@ -11740,9 +11741,9 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/fe/package.json b/fe/package.json index 6e9b709..fedc729 100644 --- a/fe/package.json +++ b/fe/package.json @@ -26,6 +26,7 @@ "@vueuse/core": "^13.1.0", "axios": "^1.9.0", "date-fns": "^4.1.0", + "framer-motion": "^12.16.0", "motion": "^12.15.0", "pinia": "^3.0.2", "qs": "^6.14.0", diff --git a/fe/src/components/CreateGroupModal.vue b/fe/src/components/CreateGroupModal.vue new file mode 100644 index 0000000..91ac849 --- /dev/null +++ b/fe/src/components/CreateGroupModal.vue @@ -0,0 +1,102 @@ + + + + + \ No newline at end of file diff --git a/fe/src/layouts/MainLayout.vue b/fe/src/layouts/MainLayout.vue index 7a47c33..a6ad428 100644 --- a/fe/src/layouts/MainLayout.vue +++ b/fe/src/layouts/MainLayout.vue @@ -1,45 +1,75 @@ + \ No newline at end of file diff --git a/fe/src/main.ts b/fe/src/main.ts index 3226f59..b8f6b2b 100644 --- a/fe/src/main.ts +++ b/fe/src/main.ts @@ -5,36 +5,20 @@ import { BrowserTracing } from '@sentry/tracing' import App from './App.vue' import router from './router' 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 frMessages from './i18n/fr.json' import esMessages from './i18n/es.json' import nlMessages from './i18n/nl.json' - -// Global styles import './assets/main.scss' - -// API client (from your axios boot file) -import { api, globalAxios } from '@/services/api' // Renamed from boot/axios to services/api +import { api, globalAxios } from '@/services/api' 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({ - legacy: false, // Recommended for Vue 3 - locale: 'en', // Default locale - fallbackLocale: 'en', // Fallback locale + legacy: false, + locale: 'en', + fallbackLocale: 'en', messages: { en: enMessages, de: deMessages, @@ -48,7 +32,6 @@ const app = createApp(App) const pinia = createPinia() app.use(pinia) -// Initialize Sentry Sentry.init({ app, dsn: import.meta.env.VITE_SENTRY_DSN, @@ -58,27 +41,21 @@ Sentry.init({ 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, - // Set environment environment: import.meta.env.MODE, }) -// Initialize auth state before mounting the app const authStore = useAuthStore() if (authStore.accessToken) { authStore.fetchCurrentUser().catch((error) => { console.error('Failed to initialize current user state:', error) - // The fetchCurrentUser action handles token clearing on failure. }) } app.use(router) app.use(i18n) -// Make API instance globally available (optional, prefer provide/inject or store) app.config.globalProperties.$api = api -app.config.globalProperties.$axios = globalAxios // The original axios instance if needed +app.config.globalProperties.$axios = globalAxios app.mount('#app') diff --git a/fe/src/pages/ChoresPage.vue b/fe/src/pages/ChoresPage.vue index 2619853..f071408 100644 --- a/fe/src/pages/ChoresPage.vue +++ b/fe/src/pages/ChoresPage.vue @@ -10,6 +10,8 @@ import { useStorage } from '@vueuse/core' const { t } = useI18n() +const props = defineProps<{ groupId?: number | string }>(); + // Types interface ChoreWithCompletion extends Chore { current_assignment_id: number | null; @@ -162,10 +164,19 @@ const getChoreSubtext = (chore: ChoreWithCompletion): string => { return parts.join(' · '); }; -const groupedChores = computed(() => { - if (!chores.value) return [] +const filteredChores = computed(() => { + 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') if (!acc[dueDate]) { acc[dueDate] = [] @@ -177,7 +188,6 @@ const groupedChores = computed(() => { return Object.keys(choresByDate) .sort((a, b) => new Date(a).getTime() - new Date(b).getTime()) .map(dateStr => { - // Create a new Date object and ensure it's interpreted as local time, not UTC const dateParts = dateStr.split('-').map(Number); const date = new Date(dateParts[0], dateParts[1] - 1, dateParts[2]); return { @@ -190,7 +200,7 @@ const groupedChores = computed(() => { })) } }); -}) +}); const formatDateHeader = (date: Date) => { const today = startOfDay(new Date()) @@ -210,6 +220,10 @@ const resetChoreForm = () => { const openCreateChoreModal = () => { resetChoreForm() + if (props.groupId) { + choreForm.value.type = 'group'; + choreForm.value.group_id = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId; + } showChoreModal.value = true } @@ -402,7 +416,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {