feat(shopping): stats achat produit + édition quantité article

Backend :
- Migration 004 : last_purchased_at (DATE) + avg_interval_days (NUMERIC)
  sur shopping.products
- update_item : met à jour les stats au premier cochage d'un article
  lié à un produit (moyenne mobile exp. 70/30)
- ProductResponse expose les deux nouveaux champs

Frontend :
- ItemRow : long press 500ms → onEdit() (mobile) ; crayon + croix (laptop)
- ShoppingPage : modal édition quantité/unité, état editingItem
- api/shopping.ts : Product inclut last_purchased_at + avg_interval_days

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 07:08:14 +02:00
co-authored by Claude Sonnet 4.6
parent 377531d08e
commit dee7037d70
7 changed files with 171 additions and 14 deletions
@@ -0,0 +1,24 @@
"""004 - stats d'achat sur les produits (last_purchased_at, avg_interval_days)
Revision ID: 004
Revises: 003
Create Date: 2026-05-25
"""
from alembic import op
import sqlalchemy as sa
revision = '004'
down_revision = '003'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('products', sa.Column('last_purchased_at', sa.Date(), nullable=True), schema='shopping')
op.add_column('products', sa.Column('avg_interval_days', sa.Numeric(8, 1), nullable=True), schema='shopping')
def downgrade():
op.drop_column('products', 'avg_interval_days', schema='shopping')
op.drop_column('products', 'last_purchased_at', schema='shopping')
+22 -1
View File
@@ -1,6 +1,7 @@
# backend/app/api/shopping.py # backend/app/api/shopping.py
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone, date as date_type
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response from fastapi.responses import Response
from sqlalchemy import select, text, or_ from sqlalchemy import select, text, or_
@@ -270,8 +271,28 @@ async def update_item(
item = result.scalar_one_or_none() item = result.scalar_one_or_none()
if not item: if not item:
raise HTTPException(404, "Article introuvable") raise HTTPException(404, "Article introuvable")
was_checked = item.is_checked
for field, value in payload.model_dump(exclude_unset=True).items(): for field, value in payload.model_dump(exclude_unset=True).items():
setattr(item, field, value) setattr(item, field, value)
# Mise à jour des stats produit lors du premier cochage
if not was_checked and item.is_checked and item.product_id:
product = await session.get(Product, item.product_id)
if product:
today = date_type.today()
if product.last_purchased_at and product.last_purchased_at < today:
days = (today - product.last_purchased_at).days
if product.avg_interval_days is None:
product.avg_interval_days = Decimal(str(days))
else:
# Moyenne mobile exponentielle (70 % passé, 30 % nouvel intervalle)
product.avg_interval_days = Decimal(str(
round(float(product.avg_interval_days) * 0.7 + days * 0.3, 1)
))
product.last_purchased_at = today
product.frequency_score += 1
await session.commit() await session.commit()
await session.refresh(item, ["product"]) await session.refresh(item, ["product"])
return _item_to_response(item) return _item_to_response(item)
+2
View File
@@ -24,6 +24,8 @@ class Product(Base):
quantity_per_unit: Mapped[Decimal | None] = mapped_column(Numeric(8, 3)) quantity_per_unit: Mapped[Decimal | None] = mapped_column(Numeric(8, 3))
default_store_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.stores.id", ondelete="SET NULL")) default_store_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.stores.id", ondelete="SET NULL"))
frequency_score: Mapped[int] = mapped_column(Integer, server_default=text("0")) frequency_score: Mapped[int] = mapped_column(Integer, server_default=text("0"))
last_purchased_at: Mapped[date | None] = mapped_column(Date)
avg_interval_days: Mapped[Decimal | None] = mapped_column(Numeric(8, 1))
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()")) created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()"))
+2
View File
@@ -69,6 +69,8 @@ class ProductResponse(BaseModel):
quantity_per_unit: Decimal | None quantity_per_unit: Decimal | None
default_store_id: uuid.UUID | None default_store_id: uuid.UUID | None
frequency_score: int frequency_score: int
last_purchased_at: date | None
avg_interval_days: Decimal | None
image_path: str | None image_path: str | None
thumbnail_path: str | None thumbnail_path: str | None
+2
View File
@@ -34,6 +34,8 @@ export interface Product {
quantity_per_unit: string | null quantity_per_unit: string | null
default_store_id: string | null default_store_id: string | null
frequency_score: number frequency_score: number
last_purchased_at: string | null
avg_interval_days: string | null
image_path: string | null image_path: string | null
thumbnail_path: string | null thumbnail_path: string | null
} }
+58 -12
View File
@@ -5,35 +5,62 @@ interface ItemRowProps {
item: ShoppingItem item: ShoppingItem
onCheck: () => void onCheck: () => void
onDelete: () => void onDelete: () => void
onEdit?: () => void
storeMode?: boolean storeMode?: boolean
} }
const SWIPE_THRESHOLD = 80 const SWIPE_THRESHOLD = 80
const LONG_PRESS_MS = 500
export default function ItemRow({ item, onCheck, onDelete, storeMode = false }: ItemRowProps) { export default function ItemRow({ item, onCheck, onDelete, onEdit, storeMode = false }: ItemRowProps) {
const [offsetX, setOffsetX] = useState(0) const [offsetX, setOffsetX] = useState(0)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const startX = useRef<number | null>(null) const startX = useRef<number | null>(null)
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const didLongPress = useRef(false)
function clearLongPress() {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current)
longPressTimer.current = null
}
}
function onTouchStart(e: React.TouchEvent) { function onTouchStart(e: React.TouchEvent) {
if (storeMode) return
startX.current = e.touches[0].clientX startX.current = e.touches[0].clientX
setIsDragging(true) setIsDragging(true)
didLongPress.current = false
if (onEdit) {
longPressTimer.current = setTimeout(() => {
didLongPress.current = true
onEdit()
}, LONG_PRESS_MS)
}
} }
function onTouchMove(e: React.TouchEvent) { function onTouchMove(e: React.TouchEvent) {
if (startX.current === null) return if (startX.current === null) return
const dx = e.touches[0].clientX - startX.current const dx = e.touches[0].clientX - startX.current
setOffsetX(Math.max(Math.min(dx, 0), -120)) if (Math.abs(dx) > 8) clearLongPress()
if (!storeMode) setOffsetX(Math.max(Math.min(dx, 0), -120))
} }
function onTouchEnd() { function onTouchEnd() {
if (offsetX < -SWIPE_THRESHOLD) onDelete() clearLongPress()
if (!storeMode && offsetX < -SWIPE_THRESHOLD) {
onDelete()
}
setOffsetX(0) setOffsetX(0)
setIsDragging(false) setIsDragging(false)
startX.current = null startX.current = null
} }
function handleClick() {
if (didLongPress.current) return
onCheck()
}
const minHeight = storeMode ? 64 : 52 const minHeight = storeMode ? 64 : 52
return ( return (
@@ -54,7 +81,7 @@ export default function ItemRow({ item, onCheck, onDelete, storeMode = false }:
onTouchStart={onTouchStart} onTouchStart={onTouchStart}
onTouchMove={onTouchMove} onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd} onTouchEnd={onTouchEnd}
onClick={onCheck} onClick={handleClick}
style={{ style={{
transform: `translateX(${offsetX}px)`, transform: `translateX(${offsetX}px)`,
transition: isDragging ? 'none' : 'transform 0.2s ease', transition: isDragging ? 'none' : 'transform 0.2s ease',
@@ -104,17 +131,36 @@ export default function ItemRow({ item, onCheck, onDelete, storeMode = false }:
)} )}
</div> </div>
{/* Actions laptop */}
<div className="hidden lg:flex" style={{ gap: 4 }} onClick={e => e.stopPropagation()}>
{onEdit && (
<button
onClick={onEdit}
title="Modifier quantité"
style={{
background: 'var(--bg-5)', border: 'none', color: 'var(--ink-3)',
borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 13, minHeight: 32,
}}
>✏️</button>
)}
<button
onClick={onDelete}
title="Supprimer"
style={{
background: 'transparent', border: 'none', color: 'var(--ink-4)',
borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 16, minHeight: 32,
}}
>✕</button>
</div>
{/* Suppression mode magasin mobile uniquement */}
{storeMode && !item.is_checked && ( {storeMode && !item.is_checked && (
<button <button
className="flex lg:hidden"
onClick={e => { e.stopPropagation(); onDelete() }} onClick={e => { e.stopPropagation(); onDelete() }}
style={{ style={{
background: 'transparent', background: 'transparent', border: 'none', color: 'var(--ink-4)',
border: 'none', fontSize: 18, cursor: 'pointer', padding: '4px 8px', minHeight: 44,
color: 'var(--ink-4)',
fontSize: 18,
cursor: 'pointer',
padding: '4px 8px',
minHeight: 44,
}} }}
>✕</button> >✕</button>
)} )}
+61 -1
View File
@@ -1,7 +1,7 @@
// frontend/src/pages/ShoppingPage.tsx // frontend/src/pages/ShoppingPage.tsx
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { matchesSearch } from '../utils/search' import { matchesSearch } from '../utils/search'
import type { ShoppingListDetail, ShoppingList, Store, Product } from '../api/shopping' import type { ShoppingListDetail, ShoppingList, Store, Product, ShoppingItem } from '../api/shopping'
import { import {
fetchLists, createList, fetchListDetail, deleteList, fetchLists, createList, fetchListDetail, deleteList,
addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList, addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList,
@@ -40,6 +40,9 @@ export default function ShoppingPage() {
const [showHistoryModal, setShowHistoryModal] = useState(false) const [showHistoryModal, setShowHistoryModal] = useState(false)
const [showCatalogueModal, setShowCatalogueModal] = useState(false) const [showCatalogueModal, setShowCatalogueModal] = useState(false)
const [showBoutiquesModal, setShowBoutiquesModal] = useState(false) const [showBoutiquesModal, setShowBoutiquesModal] = useState(false)
const [editingItem, setEditingItem] = useState<ShoppingItem | null>(null)
const [editQty, setEditQty] = useState('')
const [editUnit, setEditUnit] = useState('')
const [itemSearch, setItemSearch] = useState('') const [itemSearch, setItemSearch] = useState('')
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null) const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
@@ -172,6 +175,26 @@ export default function ShoppingPage() {
} }
} }
function openEditItem(item: ShoppingItem) {
setEditingItem(item)
setEditQty(item.quantity ?? '')
setEditUnit(item.unit ?? '')
}
async function handleEditItem() {
if (!currentList || !editingItem) return
try {
await updateItem(currentList.id, editingItem.id, {
quantity: editQty || undefined,
unit: editUnit || undefined,
})
setEditingItem(null)
void refreshCurrentList()
} catch {
setError('Erreur lors de la modification')
}
}
async function handleFinish() { async function handleFinish() {
if (!currentList) return if (!currentList) return
try { try {
@@ -367,6 +390,7 @@ export default function ShoppingPage() {
item={item} item={item}
onCheck={() => void handleCheckItem(item.id, true)} onCheck={() => void handleCheckItem(item.id, true)}
onDelete={() => void handleDeleteItem(item.id)} onDelete={() => void handleDeleteItem(item.id)}
onEdit={() => openEditItem(item)}
storeMode storeMode
/> />
))} ))}
@@ -390,6 +414,7 @@ export default function ShoppingPage() {
item={item} item={item}
onCheck={() => void handleCheckItem(item.id, false)} onCheck={() => void handleCheckItem(item.id, false)}
onDelete={() => void handleDeleteItem(item.id)} onDelete={() => void handleDeleteItem(item.id)}
onEdit={() => openEditItem(item)}
storeMode storeMode
/> />
))} ))}
@@ -414,6 +439,41 @@ export default function ShoppingPage() {
{/* ── Modals ── */} {/* ── Modals ── */}
{/* Modal édition quantité/unité */}
{editingItem && (
<Modal title={`Modifier — ${editingItem.display_name}`} onClose={() => setEditingItem(null)} width={320}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<input
style={inputStyle}
placeholder="Quantité"
value={editQty}
onChange={e => setEditQty(e.target.value)}
autoFocus
onKeyDown={e => e.key === 'Enter' && void handleEditItem()}
/>
<input
style={inputStyle}
placeholder="Unité (kg, L…)"
value={editUnit}
onChange={e => setEditUnit(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void handleEditItem()}
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={() => setEditingItem(null)}
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 48 }}
>Annuler</button>
<button
onClick={() => void handleEditItem()}
style={{ padding: '10px 20px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 48 }}
>Enregistrer</button>
</div>
</div>
</Modal>
)}
{showAddItemModal && ( {showAddItemModal && (
<Modal title="Ajouter un article" onClose={closeAddItemModal} width={420}> <Modal title="Ajouter un article" onClose={closeAddItemModal} width={420}>
{/* Barre de recherche / nom personnalisé */} {/* Barre de recherche / nom personnalisé */}