Add conflict resolution for list creation and updates; implement offline action handling for list items. Enhance service worker with background sync capabilities and improve UI for offline states.

This commit is contained in:
mohamad 2025-05-16 02:07:41 +02:00
parent 3f0cfff9f1
commit 515534dcce
14 changed files with 865 additions and 214 deletions

View File

@ -18,7 +18,8 @@ from app.core.exceptions import (
ListNotFoundError,
ListPermissionError,
ListStatusNotFoundError,
ConflictError # Added ConflictError
ConflictError, # Added ConflictError
DatabaseIntegrityError # Added DatabaseIntegrityError
)
logger = logging.getLogger(__name__)
@ -29,7 +30,13 @@ router = APIRouter()
response_model=ListPublic, # Return basic list info on creation
status_code=status.HTTP_201_CREATED,
summary="Create New List",
tags=["Lists"]
tags=["Lists"],
responses={
status.HTTP_409_CONFLICT: {
"description": "Conflict: A list with this name already exists in the specified group",
"model": ListPublic
}
}
)
async def create_list(
list_in: ListCreate,
@ -40,6 +47,7 @@ async def create_list(
Creates a new shopping list.
- If `group_id` is provided, the user must be a member of that group.
- If `group_id` is null, it's a personal list.
- If a list with the same name already exists in the group, returns 409 with the existing list.
"""
logger.info(f"User {current_user.email} creating list: {list_in.name}")
group_id = list_in.group_id
@ -51,9 +59,29 @@ async def create_list(
logger.warning(f"User {current_user.email} attempted to create list in group {group_id} but is not a member.")
raise GroupMembershipError(group_id, "create lists")
try:
created_list = await crud_list.create_list(db=db, list_in=list_in, creator_id=current_user.id)
logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.")
return created_list
except DatabaseIntegrityError as e:
# Check if this is a unique constraint violation
if "unique constraint" in str(e).lower():
# Find the existing list with the same name in the group
existing_list = await crud_list.get_list_by_name_and_group(
db=db,
name=list_in.name,
group_id=group_id,
user_id=current_user.id
)
if existing_list:
logger.info(f"List '{list_in.name}' already exists in group {group_id}. Returning existing list.")
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A list named '{list_in.name}' already exists in this group.",
headers={"X-Existing-List": str(existing_list.id)}
)
# If it's not a unique constraint or we couldn't find the existing list, re-raise
raise
@router.get(

View File

@ -207,3 +207,45 @@ async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to get list status: {str(e)}")
async def get_list_by_name_and_group(
db: AsyncSession,
name: str,
group_id: Optional[int],
user_id: int
) -> Optional[ListModel]:
"""
Gets a list by name and group, ensuring the user has permission to access it.
Used for conflict resolution when creating lists.
"""
try:
# Build the base query
query = select(ListModel).where(ListModel.name == name)
# Add group condition
if group_id is not None:
query = query.where(ListModel.group_id == group_id)
else:
query = query.where(ListModel.group_id.is_(None))
# Add permission conditions
conditions = [
ListModel.created_by_id == user_id # User is creator
]
if group_id is not None:
# User is member of the group
conditions.append(
and_(
ListModel.group_id == group_id,
ListModel.created_by_id != user_id # Not the creator
)
)
query = query.where(or_(*conditions))
result = await db.execute(query)
return result.scalars().first()
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query list by name and group: {str(e)}")

3
fe/.gitignore vendored
View File

@ -7,7 +7,7 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
**/node_modules/
.DS_Store
dist
dist-ssr
@ -28,6 +28,7 @@ coverage
*.sw?
*.tsbuildinfo
*.sw.js
test-results/
playwright-report/

6
fe/package-lock.json generated
View File

@ -17,7 +17,8 @@
"pinia": "^3.0.2",
"vue": "^3.5.13",
"vue-i18n": "^12.0.0-alpha.2",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"workbox-background-sync": "^7.3.0"
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8",
@ -7585,7 +7586,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ignore": {
@ -12067,7 +12067,6 @@
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz",
"integrity": "sha512-PCSk3eK7Mxeuyatb22pcSx9dlgWNv3+M8PqPaYDokks8Y5/FX4soaOqj3yhAZr5k6Q5JWTOMYgaJBpbw11G9Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
"idb": "^7.0.1",
@ -12402,7 +12401,6 @@
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.3.0.tgz",
"integrity": "sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==",
"dev": true,
"license": "MIT"
},
"node_modules/workbox-expiration": {

View File

@ -26,7 +26,8 @@
"pinia": "^3.0.2",
"vue": "^3.5.13",
"vue-i18n": "^12.0.0-alpha.2",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"workbox-background-sync": "^7.3.0"
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8",

45
fe/public/offline.html Normal file
View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline</title>
<style>
body {
font-family: sans-serif;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f0f0f0;
color: #333;
text-align: center;
}
.container {
padding: 20px;
border-radius: 8px;
background-color: #fff;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
h1 {
color: #d32f2f;
/* A reddish color to indicate an issue */
}
</style>
</head>
<body>
<div class="container">
<h1>You are Offline</h1>
<p>It seems you've lost your internet connection.</p>
<p>Please check your network settings and try again once you're back online.</p>
<p><small>Some previously cached content might still be available.</small></p>
</div>
</body>
</html>

View File

@ -25,4 +25,72 @@ a {
padding: 1rem;
}
// Offline UI styles
.offline-item {
position: relative;
opacity: 0.8;
transition: opacity 0.3s ease;
&::after {
content: '';
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 1rem;
height: 1rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3Cpath d='M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16'/%3E%3Cpath d='M16 21h5v-5'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
animation: spin 1s linear infinite;
}
&.synced {
opacity: 1;
&::after {
display: none;
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// Disabled offline features
.feature-offline-disabled {
position: relative;
cursor: not-allowed;
opacity: 0.6;
&::before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 0.5rem;
background-color: var(--bg-color-tooltip, #333);
color: white;
border-radius: 0.25rem;
font-size: 0.875rem;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
z-index: 1000;
}
&:hover::before {
opacity: 1;
visibility: visible;
}
}
// Add more global utility classes or base styles

View File

@ -1,53 +1,54 @@
<template>
<div>
<div
v-if="!isOnline || hasPendingActions"
class="alert offline-indicator"
:class="{
<div v-if="!isOnline || hasPendingActions" class="alert offline-indicator" :class="{
'alert-error': !isOnline,
'alert-warning': isOnline && hasPendingActions
}"
role="status"
>
}" role="status">
<div class="alert-content">
<svg class="icon" aria-hidden="true">
<use :xlink:href="!isOnline ? '#icon-alert-triangle' : '#icon-info'" />
<!-- Placeholder icons, wifi_off and sync are not in Valerie UI default -->
<use :xlink:href="!isOnline ? '#icon-wifi-off' : '#icon-sync'" />
</svg>
<span v-if="!isOnline">
<span v-if="!isOnline" class="status-text">
You are currently offline. Changes will be saved locally.
</span>
<span v-else>
<span v-else class="status-text">
Syncing {{ pendingActionCount }} pending {{ pendingActionCount === 1 ? 'change' : 'changes' }}...
</span>
</div>
<button
v-if="hasPendingActions"
class="btn btn-sm btn-neutral"
@click="showPendingActionsModal = true"
>
<button v-if="hasPendingActions" class="btn btn-sm btn-neutral" @click="showPendingActionsModal = true">
View Changes
</button>
</div>
<div v-if="showPendingActionsModal" class="modal-backdrop open" @click.self="showPendingActionsModal = false">
<div class="modal-container" ref="pendingActionsModalRef" role="dialog" aria-modal="true" aria-labelledby="pendingActionsTitle">
<div class="modal-container" ref="pendingActionsModalRef" role="dialog" aria-modal="true"
aria-labelledby="pendingActionsTitle">
<div class="modal-header">
<h3 id="pendingActionsTitle">Pending Changes</h3>
<button class="close-button" @click="showPendingActionsModal = false" aria-label="Close">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-close" />
</svg>
</button>
</div>
<div class="modal-body">
<ul v-if="pendingActions.length" class="item-list">
<li v-for="action in pendingActions" :key="action.id" class="list-item">
<div class="list-item-content" style="flex-direction: column; align-items: flex-start;">
<div class="list-item-content">
<div class="action-info">
<span class="item-text">{{ getActionLabel(action) }}</span>
<small class="text-caption">{{ new Date(action.timestamp).toLocaleString() }}</small>
</div>
<button class="btn btn-sm btn-error" @click="removePendingAction(action.id)"
title="Remove this pending action">
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-trash" />
</svg>
</button>
</div>
</li>
</ul>
<p v-else>No pending changes.</p>
<p v-else class="empty-state">No pending changes.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" @click="showPendingActionsModal = false">Close</button>
@ -56,58 +57,55 @@
</div>
<!-- Conflict Resolution Dialog -->
<ConflictResolutionDialog
v-model="offlineStore.showConflictDialog"
:conflict-data="offlineStore.currentConflict"
@resolve="offlineStore.handleConflictResolution"
/>
<ConflictResolutionDialog v-model="offlineStore.showConflictDialog" :conflict-data="offlineStore.currentConflict"
@resolve="offlineStore.handleConflictResolution" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useNetwork, onClickOutside } from '@vueuse/core';
import { useOfflineStore } from '@/stores/offline'; // Assuming path
import type { OfflineAction } from '@/stores/offline'; // Assuming path
import { useOfflineStore } from '@/stores/offline';
import type { OfflineAction } from '@/stores/offline';
import ConflictResolutionDialog from '@/components/ConflictResolutionDialog.vue';
const offlineStore = useOfflineStore();
const showPendingActionsModal = ref(false);
const pendingActionsModalRef = ref<HTMLElement | null>(null);
const { isOnline } = useNetwork(); // VueUse composable for network status
const { isOnline } = useNetwork();
// Expose parts of the store directly, this pattern is fine with Pinia
const {
pendingActions,
hasPendingActions,
pendingActionCount,
// showConflictDialog, // Handled by offlineStore.showConflictDialog
// currentConflict, // Handled by offlineStore.currentConflict
// handleConflictResolution // Handled by offlineStore.handleConflictResolution
} = offlineStore;
onClickOutside(pendingActionsModalRef, () => {
showPendingActionsModal.value = false;
});
const removePendingAction = (actionId: string) => {
offlineStore.pendingActions = offlineStore.pendingActions.filter(a => a.id !== actionId);
};
const getActionLabel = (action: OfflineAction) => {
// This is a simplified version of your original getActionLabel
// You might need to adjust based on the actual structure of action.data
const data = action.payload as { title?: string; name?: string; [key: string]: unknown };
const data = action.payload as { title?: string; name?: string;[key: string]: unknown };
const itemTitle = data.title || data.name || (typeof data === 'string' ? data : 'Untitled Item');
switch (action.type) {
case 'add':
case 'create': // Common alias
return `Add: ${itemTitle}`;
case 'complete':
return `Complete: ${itemTitle}`;
case 'update':
return `Update: ${itemTitle}`;
case 'delete':
return `Delete: ${itemTitle}`;
case 'create_list':
return `Create List: ${itemTitle}`;
case 'update_list':
return `Update List: ${itemTitle}`;
case 'delete_list':
return `Delete List: ${itemTitle}`;
case 'create_list_item':
return `Add Item: ${itemTitle}`;
case 'update_list_item':
return `Update Item: ${itemTitle}`;
case 'delete_list_item':
return `Delete Item: ${itemTitle}`;
default:
return `Unknown action: ${action.type} for ${itemTitle}`;
}
@ -121,22 +119,83 @@ const getActionLabel = (action: OfflineAction) => {
right: 1rem;
z-index: 1000;
max-width: 400px;
/* Valerie UI .alert already has box-shadow */
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
}
.alert-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
.status-text {
font-weight: 500;
}
/* Styles for text-caption if not globally available enough */
.text-caption {
font-size: 0.85rem;
color: var(--dark);
opacity: 0.7;
}
/* Simplified list item for pending actions modal */
.item-list .list-item .list-item-content {
padding: 0.75rem 1rem;
.item-list {
list-style: none;
padding: 0;
margin: 0;
}
.item-list .list-item .item-text {
.list-item {
border-bottom: 1px solid var(--border-color);
}
.list-item:last-child {
border-bottom: none;
}
.list-item-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
gap: 1rem;
}
.action-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.item-text {
font-weight: 500;
margin-bottom: 0.25rem;
}
.empty-state {
text-align: center;
color: var(--text-muted);
padding: 2rem;
}
.btn-error {
padding: 0.25rem;
min-width: auto;
}
.btn-error .icon {
width: 1rem;
height: 1rem;
}
</style>

View File

@ -21,14 +21,14 @@
</svg>
Continue with Google
</button>
<button @click="handleAppleLogin" class="btn btn-social btn-apple">
<!-- <button @click="handleAppleLogin" class="btn btn-social btn-apple">
<svg class="icon" viewBox="0 0 24 24">
<path
d="M17.05 20.28c-.98.95-2.05.88-3.08.41-1.09-.47-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.41C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.19 2.31-.89 3.51-.84 1.54.07 2.7.61 3.44 1.57-3.14 1.88-2.29 5.13.22 6.41-.65 1.29-1.51 2.58-2.25 4.03zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
fill="#000" />
</svg>
Continue with Apple
</button>
</button> -->
</div>
</div>
</template>

View File

@ -19,16 +19,20 @@
<div class="flex justify-between items-center flex-wrap mb-2">
<h1>{{ list.name }}</h1>
<div class="flex items-center flex-wrap" style="gap: 0.5rem;">
<button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true">
<button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true"
:class="{ 'feature-offline-disabled': !isOnline }"
:data-tooltip="!isOnline ? 'Cost summary requires online connection' : ''">
<svg class="icon icon-sm">
<use xlink:href="#icon-clipboard" />
</svg> <!-- Placeholder icon -->
</svg>
Cost Summary
</button>
<button class="btn btn-secondary btn-sm" @click="openOcrDialog">
<button class="btn btn-secondary btn-sm" @click="openOcrDialog"
:class="{ 'feature-offline-disabled': !isOnline }"
:data-tooltip="!isOnline ? 'OCR requires online connection' : ''">
<svg class="icon icon-sm">
<use xlink:href="#icon-plus" />
</svg> <!-- Placeholder, camera_alt not in Valerie -->
</svg>
Add via OCR
</button>
<span class="item-badge ml-1" :class="list.is_complete ? 'badge-settled' : 'badge-pending'">
@ -68,9 +72,12 @@
</div>
<ul v-else class="item-list">
<li v-for="item in list.items" :key="item.id" class="list-item"
:class="{ 'completed': item.is_complete, 'is-swiped': item.swiped }" @touchstart="handleTouchStart"
@touchmove="handleTouchMove" @touchend="handleTouchEnd">
<li v-for="item in list.items" :key="item.id" class="list-item" :class="{
'completed': item.is_complete,
'is-swiped': item.swiped,
'offline-item': isItemPendingSync(item),
'synced': !isItemPendingSync(item)
}" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="handleTouchEnd">
<div class="list-item-content">
<div class="list-item-main">
<label class="checkbox-label mb-0 flex-shrink-0">
@ -90,7 +97,6 @@
</div>
</div>
</div>
<!-- Non-swipe actions can be added here or handled by swipe -->
<div class="list-item-actions">
<button class="btn btn-danger btn-sm btn-icon-only" @click.stop="confirmDeleteItem(item)"
:disabled="item.deleting" aria-label="Delete item">
@ -100,7 +106,6 @@
</button>
</div>
</div>
<!-- Swipe actions could be added here if fully implementing swipe from Valerie UI example -->
</li>
</ul>
</template>
@ -152,6 +157,28 @@
</div>
</div>
<!-- Confirmation Dialog -->
<div v-if="showConfirmDialogState" class="modal-backdrop open" @click.self="cancelConfirmation">
<div class="modal-container confirm-modal" ref="confirmModalRef">
<div class="modal-header">
<h3>Confirmation</h3>
<button class="close-button" @click="cancelConfirmation" aria-label="Close"><svg class="icon">
<use xlink:href="#icon-close" />
</svg></button>
</div>
<div class="modal-body">
<svg class="icon icon-lg mb-2" style="color: var(--warning);">
<use xlink:href="#icon-alert-triangle" />
</svg>
<p>{{ confirmDialogMessage }}</p>
</div>
<div class="modal-footer">
<button class="btn btn-neutral" @click="cancelConfirmation">Cancel</button>
<button class="btn btn-primary ml-2" @click="handleConfirmedAction">Confirm</button>
</div>
</div>
</div>
<!-- Cost Summary Dialog -->
<div v-if="showCostSummaryDialog" class="modal-backdrop open" @click.self="showCostSummaryDialog = false">
<div class="modal-container" ref="costSummaryModalRef" style="min-width: 550px;">
@ -208,37 +235,16 @@
</div>
</div>
<!-- Confirmation Dialog -->
<div v-if="showConfirmDialogState" class="modal-backdrop open" @click.self="cancelConfirmation">
<div class="modal-container confirm-modal" ref="confirmModalRef">
<div class="modal-header">
<h3>Confirmation</h3>
<button class="close-button" @click="cancelConfirmation" aria-label="Close"><svg class="icon">
<use xlink:href="#icon-close" />
</svg></button>
</div>
<div class="modal-body">
<svg class="icon icon-lg mb-2" style="color: var(--warning);">
<use xlink:href="#icon-alert-triangle" />
</svg>
<p>{{ confirmDialogMessage }}</p>
</div>
<div class="modal-footer">
<button class="btn btn-neutral" @click="cancelConfirmation">Cancel</button>
<button class="btn btn-primary ml-2" @click="handleConfirmedAction">Confirm</button>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { onClickOutside, useEventListener, useFileDialog } from '@vueuse/core';
import { onClickOutside, useEventListener, useFileDialog, useNetwork } from '@vueuse/core';
import { useNotificationStore } from '@/stores/notifications';
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
interface Item {
id: number;
@ -283,8 +289,9 @@ interface ListCostSummaryData {
}
const route = useRoute();
const { isOnline } = useNetwork();
const notificationStore = useNotificationStore();
const offlineStore = useOfflineStore();
const list = ref<List | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
@ -391,6 +398,16 @@ const stopPolling = () => {
if (pollingInterval.value) clearInterval(pollingInterval.value);
};
const isItemPendingSync = (item: Item) => {
return offlineStore.pendingActions.some(action => {
if (action.type === 'update_list_item' || action.type === 'delete_list_item') {
const payload = action.payload as { listId: string; itemId: string };
return payload.itemId === String(item.id);
}
return false;
});
};
const onAddItem = async () => {
if (!list.value || !newItem.value.name.trim()) {
notificationStore.addNotification({ message: 'Please enter an item name.', type: 'warning' });
@ -398,6 +415,35 @@ const onAddItem = async () => {
return;
}
addingItem.value = true;
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'create_list_item',
payload: {
listId: String(list.value.id),
itemData: {
name: newItem.value.name,
quantity: newItem.value.quantity?.toString()
}
}
});
// Optimistically add to UI
const optimisticItem: Item = {
id: Date.now(), // Temporary ID
name: newItem.value.name,
quantity: newItem.value.quantity,
is_complete: false,
version: 1,
updated_at: new Date().toISOString()
};
list.value.items.push(processListItems([optimisticItem])[0]);
newItem.value = { name: '' };
itemNameInputRef.value?.focus();
addingItem.value = false;
return;
}
try {
const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
@ -420,77 +466,104 @@ const updateItem = async (item: Item, newCompleteStatus: boolean) => {
const originalCompleteStatus = item.is_complete;
item.is_complete = newCompleteStatus; // Optimistic update
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'update_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id),
data: {
completed: newCompleteStatus
},
version: item.version
}
});
item.updating = false;
return;
}
try {
const payload: { is_complete: boolean; version: number; price?: number | null } = {
is_complete: item.is_complete,
version: item.version,
};
if (item.is_complete && item.priceInput !== undefined && item.priceInput !== null && String(item.priceInput).trim() !== '') {
payload.price = parseFloat(String(item.priceInput));
} else if (item.is_complete && (item.priceInput === undefined || String(item.priceInput).trim() === '')) {
// If complete and price is empty, don't send price, or send null if API expects it
payload.price = null; // Or omit, depending on API
}
const response = await apiClient.put(API_ENDPOINTS.ITEMS.BY_ID(String(item.id)), payload);
const updatedItemFromServer = processListItems([response.data as Item])[0];
const index = list.value.items.findIndex(i => i.id === item.id);
if (index !== -1) {
list.value.items[index] = { ...list.value.items[index], ...updatedItemFromServer, updating: false };
}
// If cost summary was open, refresh it
if (showCostSummaryDialog.value) await fetchListCostSummary();
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ completed: newCompleteStatus, version: item.version }
);
item.version++;
} catch (err) {
item.is_complete = originalCompleteStatus; // Revert optimistic update
item.is_complete = originalCompleteStatus; // Revert on error
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item.', type: 'error' });
} finally {
item.updating = false;
}
};
const updateItemPrice = async (item: Item) => {
if (!list.value || !item.is_complete) return; // Only update price if item is complete
if (!list.value || !item.is_complete) return;
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null;
if (item.price === newPrice) return; // No change
if (item.price === newPrice) return;
item.updating = true;
const originalPrice = item.price;
item.price = newPrice; // Optimistic
const originalPriceInput = item.priceInput;
item.price = newPrice;
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'update_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id),
data: {
price: newPrice,
completed: item.is_complete
},
version: item.version
}
});
item.updating = false;
return;
}
try {
const response = await apiClient.put(
API_ENDPOINTS.ITEMS.BY_ID(String(item.id)),
{ price: item.price, is_complete: item.is_complete, version: item.version }
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ price: newPrice, completed: item.is_complete, version: item.version }
);
const updatedItemFromServer = processListItems([response.data as Item])[0];
const index = list.value.items.findIndex(i => i.id === item.id);
if (index !== -1) {
list.value.items[index] = { ...list.value.items[index], ...updatedItemFromServer, updating: false };
}
if (showCostSummaryDialog.value) await fetchListCostSummary();
item.version++;
} catch (err) {
item.price = originalPrice; // Revert
item.priceInput = originalPrice !== null && originalPrice !== undefined ? originalPrice : '';
item.price = originalPrice;
item.priceInput = originalPriceInput;
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item price.', type: 'error' });
} finally {
item.updating = false;
}
};
const deleteItem = async (item: Item) => {
if (!list.value) return;
item.deleting = true;
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'delete_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id)
}
});
// Optimistically remove from UI
list.value.items = list.value.items.filter(i => i.id !== item.id);
item.deleting = false;
return;
}
try {
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)));
list.value.items = list.value.items.filter(i => i.id !== item.id);
if (showCostSummaryDialog.value) await fetchListCostSummary();
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' });
} finally {
@ -757,4 +830,70 @@ onUnmounted(() => {
padding-left: 1rem;
/* Space before actions */
}
.offline-item {
position: relative;
opacity: 0.8;
transition: opacity 0.3s ease;
}
.offline-item::after {
content: '';
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 1rem;
height: 1rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3Cpath d='M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16'/%3E%3Cpath d='M16 21h5v-5'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
animation: spin 1s linear infinite;
}
.offline-item.synced {
opacity: 1;
}
.offline-item.synced::after {
display: none;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.feature-offline-disabled {
position: relative;
cursor: not-allowed;
opacity: 0.6;
}
.feature-offline-disabled::before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 0.5rem;
background-color: var(--bg-color-tooltip, #333);
color: white;
border-radius: 0.25rem;
font-size: 0.875rem;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
z-index: 1000;
}
.feature-offline-disabled:hover::before {
opacity: 1;
visibility: visible;
}
</style>

View File

@ -2,7 +2,7 @@
<main class="flex items-center justify-center page-container">
<div class="card login-card">
<div class="card-header">
<h3>Login</h3>
<h3>mitlist</h3>
</div>
<div class="card-body">
<form @submit.prevent="onSubmit" class="form-layout">

View File

@ -4,12 +4,32 @@ import { ref, computed } from 'vue';
// import { LocalStorage } from 'quasar'; // REMOVE
import { useStorage } from '@vueuse/core'; // VueUse alternative
import { useNotificationStore } from '@/stores/notifications'; // Your custom notification store
import { apiClient, API_ENDPOINTS } from '@/services/api'; // Import apiClient and API_ENDPOINTS
export type CreateListPayload = { name: string; description?: string; /* other list properties */ };
export type UpdateListPayload = { listId: string; data: Partial<CreateListPayload>; version?: number; };
export type DeleteListPayload = { listId: string; };
export type CreateListItemPayload = { listId: string; itemData: { name: string; quantity?: number | string; completed?: boolean; price?: number | null; /* other item properties */ }; };
export type UpdateListItemPayload = { listId: string; itemId: string; data: Partial<CreateListItemPayload['itemData']>; version?: number; };
export type DeleteListItemPayload = { listId: string; itemId: string; };
export type OfflineAction = {
id: string;
timestamp: number;
type: string;
payload: Record<string, unknown>; // Added payload property
type:
| 'create_list'
| 'update_list'
| 'delete_list'
| 'create_list_item'
| 'update_list_item'
| 'delete_list_item';
payload:
| CreateListPayload
| UpdateListPayload
| DeleteListPayload
| CreateListItemPayload
| UpdateListItemPayload
| DeleteListItemPayload;
};
export type ConflictData = {
@ -18,6 +38,20 @@ export type ConflictData = {
action: OfflineAction;
};
interface ServerListData {
id: string;
version: number;
name: string;
[key: string]: unknown;
}
interface ServerItemData {
id: string;
version: number;
name: string;
[key: string]: unknown;
}
export const useOfflineStore = defineStore('offline', () => {
// const $q = useQuasar(); // REMOVE
const notificationStore = useNotificationStore();
@ -35,13 +69,12 @@ export const useOfflineStore = defineStore('offline', () => {
// saveToStorage is also handled by useStorage automatically saving on change
const addAction = (action: Omit<OfflineAction, 'id' | 'timestamp'>) => {
const newAction: OfflineAction = {
const newAction = {
...action,
id: crypto.randomUUID(),
timestamp: Date.now(),
};
} as OfflineAction;
pendingActions.value.push(newAction);
// useStorage handles saving
};
const processQueue = async () => {
@ -51,52 +84,132 @@ export const useOfflineStore = defineStore('offline', () => {
for (const action of actionsToProcess) {
try {
await processAction(action); // processAction needs to use your actual API client
await processAction(action);
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
} catch (error) {
if (error instanceof Error && 'response' in error && typeof error.response === 'object' && error.response && 'status' in error.response && error.response.status === 409) {
} catch (error: any) { // Catch error as any to check for our custom flag
if (error && error.isConflict && error.serverVersionData) {
notificationStore.addNotification({
type: 'warning',
message: 'Item was modified by someone else. Please review.',
// actions: [ ... ] // Custom actions for notifications would be more complex
message: `Conflict detected for action ${action.type}. Please review.`,
});
// Here you would trigger the conflict resolution dialog
// For example, find the item and its server version, then:
// currentConflict.value = { localVersion: ..., serverVersion: ..., action };
// showConflictDialog.value = true;
// The loop should probably pause or handle this conflict before continuing
console.warn('Conflict detected for action:', action.id, error);
// Break or decide how to handle queue processing on conflict
break;
let localData: Record<string, unknown>;
// Extract local data based on action type
if (action.type === 'update_list' || action.type === 'update_list_item') {
localData = (action.payload as UpdateListPayload | UpdateListItemPayload).data;
} else if (action.type === 'create_list' || action.type === 'create_list_item') {
localData = action.payload as CreateListPayload | CreateListItemPayload;
} else {
console.error('Failed to process offline action:', action.id, error);
notificationStore.addNotification({
type: 'error',
message: `Failed to sync action: ${action.type}`,
});
console.error("Conflict detected for unhandled action type for data extraction:", action.type);
localData = {}; // Fallback
}
currentConflict.value = {
localVersion: {
data: localData,
timestamp: action.timestamp,
},
serverVersion: {
data: error.serverVersionData, // Assumes API 409 response body is the server item
timestamp: error.serverVersionData.updated_at ? new Date(error.serverVersionData.updated_at).getTime() : action.timestamp + 1, // Prefer server updated_at
},
action: action,
};
showConflictDialog.value = true;
console.warn('Conflict detected by processQueue for action:', action.id, error);
// Stop processing queue on first conflict to await resolution
isProcessingQueue.value = false; // Allow queue to be re-triggered after resolution
return; // Stop processing further actions
} else {
console.error('processQueue: Action failed, remains in queue:', action.id, error);
}
}
}
isProcessingQueue.value = false;
};
// processAction needs to be implemented with your actual API calls
const processAction = async (action: OfflineAction) => {
console.log('Processing action (TODO: Implement API call):', action);
// Example:
// import { apiClient } from '@/services/api';
// import { API_ENDPOINTS } from '@/config/api-config';
// switch (action.type) {
// case 'add':
// // Assuming action.data is { listId: string, itemData: { name: string, quantity?: string } }
// // await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(action.data.listId), action.data.itemData);
// break;
// // ... other cases
// }
// Simulate async work
return new Promise(resolve => setTimeout(resolve, 500));
};
try {
let request: Request;
let endpoint: string;
let method: 'POST' | 'PUT' | 'DELETE' = 'POST';
let body: any;
switch (action.type) {
case 'create_list':
endpoint = API_ENDPOINTS.LISTS.BASE;
body = action.payload;
break;
case 'update_list': {
const { listId, data } = action.payload as UpdateListPayload;
endpoint = API_ENDPOINTS.LISTS.BY_ID(listId);
method = 'PUT';
body = data;
break;
}
case 'delete_list': {
const { listId } = action.payload as DeleteListPayload;
endpoint = API_ENDPOINTS.LISTS.BY_ID(listId);
method = 'DELETE';
break;
}
case 'create_list_item': {
const { listId, itemData } = action.payload as CreateListItemPayload;
endpoint = API_ENDPOINTS.LISTS.ITEMS(listId);
body = itemData;
break;
}
case 'update_list_item': {
const { listId, itemId, data } = action.payload as UpdateListItemPayload;
endpoint = API_ENDPOINTS.LISTS.ITEM(listId, itemId);
method = 'PUT';
body = data;
break;
}
case 'delete_list_item': {
const { listId, itemId } = action.payload as DeleteListItemPayload;
endpoint = API_ENDPOINTS.LISTS.ITEM(listId, itemId);
method = 'DELETE';
break;
}
default:
throw new Error(`Unknown action type: ${action.type}`);
}
// Create the request with the action metadata
request = new Request(endpoint, {
method,
headers: {
'Content-Type': 'application/json',
'X-Offline-Action': action.id,
},
body: method !== 'DELETE' ? JSON.stringify(body) : undefined,
});
// Use fetch with the request
const response = await fetch(request);
if (!response.ok) {
if (response.status === 409) {
const error = new Error('Conflict detected') as any;
error.isConflict = true;
error.serverVersionData = await response.json();
throw error;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
// If successful, remove from pending actions
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
return await response.json();
} catch (error: any) {
if (error.isConflict) {
throw error;
}
// For other errors, let Workbox handle the retry
throw error;
}
};
const setupNetworkListeners = () => {
window.addEventListener('online', () => {
@ -113,28 +226,148 @@ export const useOfflineStore = defineStore('offline', () => {
const hasPendingActions = computed(() => pendingActions.value.length > 0);
const pendingActionCount = computed(() => pendingActions.value.length);
const handleConflictResolution = (resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }) => {
console.log('Conflict resolution chosen:', resolution);
// TODO: Implement logic to apply the chosen resolution
// This might involve making another API call with the resolved data
// or updating local state and then trying to sync again.
// After resolving, remove the action from pending or mark as resolved.
// For now, just remove it as an example:
pendingActions.value = pendingActions.value.filter(a => a.id !== resolution.action.id);
const handleConflictResolution = async (resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }) => {
if (!resolution.action || !currentConflict.value) {
console.error("handleConflictResolution called without an action or active conflict.");
showConflictDialog.value = false;
currentConflict.value = null;
processQueue().catch(err => console.error("Error processing queue after conflict resolution:", err)); // Try processing queue again
return;
}
const { action, version, mergedData } = resolution;
const serverVersionNumber = (currentConflict.value.serverVersion.data as any)?.version;
try {
let success = false;
if (version === 'local') {
let dataToPush: any;
let endpoint: string;
let method: 'post' | 'put' = 'put';
if (action.type === 'update_list') {
const payload = action.payload as UpdateListPayload;
dataToPush = { ...payload.data, version: serverVersionNumber };
endpoint = API_ENDPOINTS.LISTS.BY_ID(payload.listId);
} else if (action.type === 'update_list_item') {
const payload = action.payload as UpdateListItemPayload;
dataToPush = { ...payload.data, version: serverVersionNumber };
endpoint = API_ENDPOINTS.LISTS.ITEM(payload.listId, payload.itemId);
} else if (action.type === 'create_list') {
const serverData = currentConflict.value.serverVersion.data as ServerListData | null;
if (serverData?.id) {
// Server returned existing list, update it instead
dataToPush = { ...action.payload, version: serverData.version };
endpoint = API_ENDPOINTS.LISTS.BY_ID(serverData.id);
} else {
// True conflict, need to modify the data
dataToPush = {
...action.payload,
name: `${(action.payload as CreateListPayload).name} (${new Date().toLocaleString()})`
};
endpoint = API_ENDPOINTS.LISTS.BASE;
method = 'post';
}
} else if (action.type === 'create_list_item') {
const serverData = currentConflict.value.serverVersion.data as ServerItemData | null;
if (serverData?.id) {
// Server returned existing item, update it instead
dataToPush = { ...action.payload, version: serverData.version };
endpoint = API_ENDPOINTS.LISTS.ITEM(
(action.payload as CreateListItemPayload).listId,
serverData.id
);
} else {
// True conflict, need to modify the data
dataToPush = {
...action.payload,
name: `${(action.payload as CreateListItemPayload).itemData.name} (${new Date().toLocaleString()})`
};
endpoint = API_ENDPOINTS.LISTS.ITEMS((action.payload as CreateListItemPayload).listId);
method = 'post';
}
} else {
console.error("Unsupported action type for 'keep local' resolution:", action.type);
throw new Error("Unsupported action for 'keep local'");
}
if (method === 'put') {
await apiClient.put(endpoint, dataToPush);
} else {
await apiClient.post(endpoint, dataToPush);
}
success = true;
notificationStore.addNotification({ type: 'success', message: 'Your version was saved to the server.' });
} else if (version === 'server') {
success = true;
notificationStore.addNotification({ type: 'info', message: 'Local changes discarded; server version kept.' });
} else if (version === 'merge' && mergedData) {
let dataWithVersion: any;
let endpoint: string;
if (action.type === 'update_list') {
const payload = action.payload as UpdateListPayload;
dataWithVersion = { ...mergedData, version: serverVersionNumber };
endpoint = API_ENDPOINTS.LISTS.BY_ID(payload.listId);
} else if (action.type === 'update_list_item') {
const payload = action.payload as UpdateListItemPayload;
dataWithVersion = { ...mergedData, version: serverVersionNumber };
endpoint = API_ENDPOINTS.LISTS.ITEM(payload.listId, payload.itemId);
} else if (action.type === 'create_list' || action.type === 'create_list_item') {
// For create actions, merging means updating the existing item
const serverData = currentConflict.value.serverVersion.data as (ServerListData | ServerItemData) | null;
if (!serverData?.id) {
throw new Error("Cannot merge create action: server data is missing or invalid");
}
if (action.type === 'create_list') {
dataWithVersion = { ...mergedData, version: serverData.version };
endpoint = API_ENDPOINTS.LISTS.BY_ID(serverData.id);
} else {
dataWithVersion = { ...mergedData, version: serverData.version };
endpoint = API_ENDPOINTS.LISTS.ITEM(
(action.payload as CreateListItemPayload).listId,
serverData.id
);
}
} else {
console.error("Merge resolution for unsupported action type:", action.type);
throw new Error("Merge for this action type is not supported");
}
await apiClient.put(endpoint, dataWithVersion);
success = true;
notificationStore.addNotification({ type: 'success', message: 'Merged version saved to the server.' });
}
if (success) {
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
}
} catch (error) {
console.error('Error during conflict resolution API call:', error);
notificationStore.addNotification({
type: 'error',
message: `Failed to resolve conflict for ${action.type}. Please try again.`,
});
} finally {
showConflictDialog.value = false;
currentConflict.value = null;
processQueue().catch(err => console.error("Error processing queue after conflict resolution:", err));
}
};
return {
isOnline,
pendingActions,
hasPendingActions,
pendingActionCount,
isProcessingQueue,
showConflictDialog,
currentConflict,
addAction,
processAction,
processQueue,
handleConflictResolution,
hasPendingActions,
pendingActionCount,
};
});

View File

@ -5,7 +5,10 @@
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope &
typeof globalThis & { skipWaiting: () => Promise<void> };
typeof globalThis & {
skipWaiting: () => Promise<void>;
__WB_MANIFEST: Array<{ url: string; revision: string | null }>;
};
import { clientsClaim } from 'workbox-core';
import {
@ -17,16 +20,32 @@ import { registerRoute, NavigationRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import type { WorkboxPlugin } from 'workbox-core/types';
self.skipWaiting().catch((error) => {
console.error('Error during service worker activation:', error);
// Create a background sync plugin instance
const bgSyncPlugin = new BackgroundSyncPlugin('offline-actions-queue', {
maxRetentionTime: 24 * 60, // Retry for max of 24 Hours (specified in minutes)
});
clientsClaim();
// Initialize service worker
const initializeSW = async () => {
try {
await self.skipWaiting();
clientsClaim();
console.log('Service Worker initialized successfully');
} catch (error) {
console.error('Error during service worker initialization:', error);
}
};
// Use with precache injection
// vite-plugin-pwa will populate self.__WB_MANIFEST
precacheAndRoute(self.__WB_MANIFEST || []); // Provide a fallback empty array
if (self.__WB_MANIFEST) {
precacheAndRoute(self.__WB_MANIFEST);
} else {
console.warn('No manifest found for precaching');
}
cleanupOutdatedCaches();
@ -51,7 +70,7 @@ registerRoute(
})
);
// Cache API calls with Network First strategy
// Cache API calls with Network First strategy and Background Sync for failed requests
registerRoute(
({ url }) => url.pathname.startsWith('/api/'), // Make sure this matches your actual API path structure
new NetworkFirst({
@ -64,6 +83,7 @@ registerRoute(
maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60, // 24 hours
}) as WorkboxPlugin,
bgSyncPlugin, // Add background sync plugin for failed requests
],
})
);
@ -81,3 +101,6 @@ if (import.meta.env.MODE !== 'ssr' || import.meta.env.PROD) {
}),
);
}
// Initialize the service worker
initializeSW();

View File

@ -7,12 +7,15 @@ import path from 'node:path';
const pwaOptions: Partial<VitePWAOptions> = {
registerType: 'autoUpdate',
strategies: 'injectManifest', // Crucial for custom service worker
srcDir: 'src', // Directory where sw.ts is located
filename: 'sw.ts', // Your custom service worker filename
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
devOptions: {
enabled: true, // Enable PWA in development
enabled: true,
type: 'module',
navigateFallback: 'index.html',
suppressWarnings: true,
swSrc: 'src/sw.ts',
},
manifest: {
name: 'mitlist',
@ -31,8 +34,20 @@ const pwaOptions: Partial<VitePWAOptions> = {
],
},
injectManifest: {
// Options for workbox.injectManifest
// Ensure your custom service worker (sw.ts) correctly handles __WB_MANIFEST
globPatterns: [
'**/*.{js,css,html,ico,png,svg,woff2}',
'offline.html',
],
globIgnores: [
'**/node_modules/**',
'**/dist/**',
'sw.js',
],
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB
},
workbox: {
cleanupOutdatedCaches: true,
sourcemap: true,
},
};
@ -42,8 +57,8 @@ export default defineConfig({
VitePWA(pwaOptions),
VueI18nPlugin({
include: [path.resolve(path.dirname(fileURLToPath(import.meta.url)), './src/i18n/**')],
strictMessage: false, // To avoid warnings for missing Quasar translations initially
runtimeOnly: false, // If you use <i18n> component or complex messages
strictMessage: false,
runtimeOnly: false,
}),
],
resolve: {
@ -51,9 +66,8 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
// Define env variables similar to Quasar's process.env
define: {
'process.env.PWA_FALLBACK_HTML': JSON.stringify('/index.html'), // Adjust if you have a specific offline.html
'process.env.PWA_FALLBACK_HTML': JSON.stringify('/index.html'),
'process.env.PWA_SERVICE_WORKER_REGEX': JSON.stringify(/^(sw|workbox)-.*\.js$/),
'process.env.MODE': JSON.stringify(process.env.NODE_ENV),
'process.env.PROD': JSON.stringify(process.env.NODE_ENV === 'production'),