Skip to content

feat: declutter review - #273

Open
TheBlackSide243 wants to merge 2 commits into
Anyesh:mainfrom
TheBlackSide243:pr/declutter-review
Open

TheBlackSide243 wants to merge 2 commits into
Anyesh:mainfrom
TheBlackSide243:pr/declutter-review

Conversation

@TheBlackSide243

@TheBlackSide243 TheBlackSide243 commented Oct 18, 2026 •

Copy link
Copy Markdown

Description

Adds a Declutter page listing the clothes that have not been worn for a chosen period (3, 6, 12
or 24 months): never-worn items first, then the longest idle, with the total purchase value
sitting idle. Each item can be kept, which hides it from the review for the same period, or
opened to be edited or deleted.

  • GET /api/v1/declutter?months= and POST /api/v1/declutter/{item_id}/keep.
  • The keep date lives in a new declutter_keeps table rather than a clothing_items column, so
    item writes are untouched (migration c4d9e2a7b1f3).
  • Frontend page /dashboard/declutter, nav entry, translations for all 8 locales.

This is one of a set of small, independent PRs (each branches from main and can be reviewed or
dropped on its own); they only overlap in the nav/router registration lines.

Note on migrations

This PR and the shopping list one each add a migration on top of a7d3c9e1f5b2. They are
independent, so whichever is merged second needs its first migration's down_revision bumped to
the other's head; happy to rebase it.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)
  • CI/CD or build changes

Checklist

  • I have read the CONTRIBUTING guide
  • My code follows the project's coding style
  • I have added tests that prove my fix/feature works
  • New and existing tests pass locally
  • I have updated documentation as needed
  • My changes don't introduce new warnings or errors

Testing

  • Backend: ruff check, ruff format --check, migration heads check and the full pytest suite on top of current main (2033 passed).
  • Frontend: npm run lint, i18n:check, vocab:check, tsc --noEmit and vitest (693 passed).
  • Running on my own self-hosted instance (Docker Compose on TrueNAS, Ollama).

Test Environment

  • Docker Compose
  • Kubernetes
  • Local development

🤖 Generated with Claude Code

Summary by Sourcery

Add a localized declutter workflow for reviewing and acting on clothing that has remained unworn for a selected period.

New Features:

  • Add a declutter review that identifies never-worn and long-idle clothing across configurable time windows, shows idle purchase value, and supports keeping, donating, selling, editing, or deleting items.
  • Expose authenticated declutter review and keep endpoints backed by a dedicated persistence table.
  • Add a localized dashboard declutter page and navigation entry for all supported locales.

Enhancements:

  • Keep declutter decisions separate from clothing item records while enforcing active-item eligibility and ownership checks.

Tests:

  • Add service and API coverage for candidate selection, ordering, idle value totals, keep expiration, repeated keeps, authorization, item status validation, and parameter validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Introduces a full Declutter review workflow: backend candidate calculation and keep-period persistence exposed through authenticated APIs, plus a localized frontend page with filtering, keep/donate/sell actions, navigation, and automated coverage.

Sequence diagram for the declutter review workflow

sequenceDiagram
    actor User
    participant Page as DeclutterPage
    participant Hook as useDeclutter
    participant API as GET_declutter
    participant Service as DeclutterService
    participant Keep as DeclutterKeep

    User->>Page: Select months
    Page->>Hook: useDeclutter(months)
    Hook->>API: api.get /declutter?months=months
    API->>Service: active_items(current_user)
    API->>Service: kept_until(current_user)
    API->>Service: find_candidates(items, today, months, kept_until)
    Service-->>API: sorted candidates
    API-->>Hook: DeclutterResponse
    Hook-->>Page: candidates and idle_value

    User->>Page: Click Keep
    Page->>API: api.post /declutter/item_id/keep
    API->>Service: keep(item, today, months)
    Service->>Keep: upsert kept_until
    Keep-->>Service: persisted keep date
    Service-->>API: kept_until
    API-->>Page: KeepResponse
Loading

Entity relationship diagram for declutter keep persistence

erDiagram
    CLOTHING_ITEMS {
        UUID id PK
        UUID user_id
        date last_worn_at
        decimal purchase_price
        boolean is_archived
    }
    DECLUTTER_KEEPS {
        UUID item_id PK, FK
        date kept_until
    }
    CLOTHING_ITEMS ||--o| DECLUTTER_KEEPS : has
Loading

File-Level Changes

Change Details Files
Adds the backend declutter domain, persistence, and authenticated API endpoints for finding idle clothing and temporarily keeping items out of review.
  • Introduces candidate selection with configurable month windows, never-worn prioritization, idle-day calculation, and purchase-value aggregation.
  • Adds the declutter_keeps table/model with upsert-based keep periods and cascade cleanup.
  • Registers GET review and POST keep endpoints with validation, ownership checks, and item serialization.
  • Adds service and API coverage for filtering, ordering, keep expiry, value totals, authorization, and invalid windows.
backend/app/api/declutter.py
backend/app/api/router.py
backend/app/models/__init__.py
backend/app/models/item.py
backend/app/services/declutter_service.py
backend/migrations/versions/c4d9e2a7b1f3_add_declutter_keeps.py
backend/tests/test_declutter.py
Implements the authenticated frontend Declutter page and data hooks for reviewing, keeping, and removing idle clothing.
  • Adds a responsive page with 3-, 6-, 12-, and 24-month filters, summary metrics, idle-value display, candidate cards, loading/error/empty states, and localized actions.
  • Connects React Query hooks for fetching review windows, keeping items, and invalidating all declutter queries after mutations.
  • Links each candidate to wardrobe editing and reuses archive actions for donation or sale.
frontend/app/dashboard/declutter/page.tsx
frontend/lib/hooks/use-declutter.ts
frontend/lib/hooks/query-keys.ts
Exposes Declutter through dashboard navigation and adds localized UI text across all supported locales.
  • Adds the Declutter route to desktop and mobile navigation with a recycle icon.
  • Registers the new translation namespace and supplies page and navigation translations for eight locales.
frontend/components/sidebar.tsx
frontend/components/mobile-sidebar.tsx
frontend/lib/i18n/locales.ts
frontend/messages/de/declutter.json
frontend/messages/de/nav.json
frontend/messages/en/declutter.json
frontend/messages/en/nav.json
frontend/messages/fr/declutter.json
frontend/messages/fr/nav.json
frontend/messages/it/declutter.json
frontend/messages/it/nav.json
frontend/messages/ja/declutter.json
frontend/messages/ja/nav.json
frontend/messages/ko/declutter.json
frontend/messages/ko/nav.json
frontend/messages/zh-CN/declutter.json
frontend/messages/zh-CN/nav.json
frontend/messages/zh-TW/declutter.json
frontend/messages/zh-TW/nav.json

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions github-actions Bot added javascript Pull requests that update javascript code python Pull requests that update python code labels Oct 18, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="frontend/lib/hooks/use-declutter.ts" line_range="27-31" />
<code_context>
+  const { status } = useSession();
+  useSetTokenIfAvailable();
+
+  return useQuery({
+    queryKey: queryKeys.declutter(months),
+    queryFn: () => api.get<DeclutterReview>(`/declutter?months=${months}`),
+    enabled: status === 'authenticated',
+  });
+}
+
</code_context>
<issue_to_address>
**Declutter results remain stale**

When another item mutation changes declutter eligibility or item existence while the cached review is less than one minute old, `useDeclutter` caches the review without being invalidated by other item mutations, so after an item is worn, edited, archived, or deleted, the page continues showing the old candidate list and idle value while that query remains fresh. A user can then act on an item that no longer qualifies, or receive an error trying to keep or archive an item that was deleted.

Invalidate `queryKeys.declutterAll` whenever a mutation changes item data used by the review, including wear, archive, edit, and delete operations.
</issue_to_address>

### Comment 2
<location path="backend/app/api/declutter.py" line_range="80" />
<code_context>
+    db: DbSession,
+    current_user: Annotated[User, Depends(get_current_user)],
+) -> KeepResponse:
+    item = await ItemService(db).get_by_id(item_id, current_user.id)
+    if not item:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found")
</code_context>
<issue_to_address>
**Archived items retain stale keeps**

When a keep request targets an owned item that is archived or not ready, `ItemService.get_by_id` checks ownership but not whether the item is active, so `keep_item` writes a keep row for archived or processing items. Restoring an archived item later makes it active again, but `find_candidates` still suppresses it until that keep expires, even though it was never eligible for the keep action.

Reject keep requests unless the item is ready and not archived, matching the eligibility filter used by the review endpoint.
</issue_to_address>

### Comment 3
<location path="backend/app/services/declutter_service.py" line_range="95-98" />
<code_context>
+
+    async def keep(self, item: ClothingItem, today: date, months: int) -> date:
+        until = today + timedelta(days=months * DAYS_PER_MONTH)
+        statement = insert(DeclutterKeep).values(item_id=item.id, kept_until=until)
+        await self.db.execute(
+            statement.on_conflict_do_update(
+                index_elements=[DeclutterKeep.item_id], set_={"kept_until": until}
+            )
+        )
</code_context>
<issue_to_address>
**Older keep request overwrites newer choice**

When two keep requests for the same item with different month values execute out of order, `keep` unconditionally upserts `kept_until`, so when an earlier request with a different `months` value reaches this statement after a newer request, it overwrites the newer choice and the item stays hidden for the wrong period.

Add ordering/version protection so a delayed earlier request cannot overwrite a newer keep decision.
</issue_to_address>

### Comment 4
<location path="backend/tests/test_declutter.py" line_range="117" />
<code_context>
+    @pytest.mark.asyncio
+    async def test_months_out_of_range(self, client: AsyncClient, auth_headers):
+        response = await client.get("/api/v1/declutter?months=0", headers=auth_headers)
+        assert response.status_code == 422
</code_context>
<issue_to_address>
**Ownership check is untested**

When the endpoint accepts an existing item owned by another user, when the endpoint incorrectly allows keeping another user's item, `test_cannot_keep_someone_elses_item` still passes: line 110 sends a random UUID that does not exist, and line 112 gets 404 for the missing item rather than verifying ownership is enforced.

Create an item for a second user and assert the authenticated user cannot keep it.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 4 findings to address first, and keeping an item writes a date that can hide it from future reviews for up to 36 months; reverting the code would not remove those saved choices, though they expire and can be cleared. Donate and sell use the existing archive flow after an explicit user action.

Blocking findings: frontend/lib/hooks/use-declutter.ts:31, backend/app/api/declutter.py:80, backend/app/services/declutter_service.py:98, backend/tests/test_declutter.py:117


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment on lines +27 to +31
return useQuery({
queryKey: queryKeys.declutter(months),
queryFn: () => api.get<DeclutterReview>(`/declutter?months=${months}`),
enabled: status === 'authenticated',
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Declutter results remain stale

When another item mutation changes declutter eligibility or item existence while the cached review is less than one minute old, useDeclutter caches the review without being invalidated by other item mutations, so after an item is worn, edited, archived, or deleted, the page continues showing the old candidate list and idle value while that query remains fresh. A user can then act on an item that no longer qualifies, or receive an error trying to keep or archive an item that was deleted.

Invalidate queryKeys.declutterAll whenever a mutation changes item data used by the review, including wear, archive, edit, and delete operations.

Prompt for AI agents
In `frontend/lib/hooks/use-declutter.ts` at lines 27-31:

**Declutter results remain stale**

When another item mutation changes declutter eligibility or item existence while the cached review is less than one minute old, `useDeclutter` caches the review without being invalidated by other item mutations, so after an item is worn, edited, archived, or deleted, the page continues showing the old candidate list and idle value while that query remains fresh. A user can then act on an item that no longer qualifies, or receive an error trying to keep or archive an item that was deleted.

Invalidate `queryKeys.declutterAll` whenever a mutation changes item data used by the review, including wear, archive, edit, and delete operations.

db: DbSession,
current_user: Annotated[User, Depends(get_current_user)],
) -> KeepResponse:
item = await ItemService(db).get_by_id(item_id, current_user.id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Archived items retain stale keeps

When a keep request targets an owned item that is archived or not ready, ItemService.get_by_id checks ownership but not whether the item is active, so keep_item writes a keep row for archived or processing items. Restoring an archived item later makes it active again, but find_candidates still suppresses it until that keep expires, even though it was never eligible for the keep action.

Reject keep requests unless the item is ready and not archived, matching the eligibility filter used by the review endpoint.

Prompt for AI agents
In `backend/app/api/declutter.py` at line 80:

**Archived items retain stale keeps**

When a keep request targets an owned item that is archived or not ready, `ItemService.get_by_id` checks ownership but not whether the item is active, so `keep_item` writes a keep row for archived or processing items. Restoring an archived item later makes it active again, but `find_candidates` still suppresses it until that keep expires, even though it was never eligible for the keep action.

Reject keep requests unless the item is ready and not archived, matching the eligibility filter used by the review endpoint.

Comment on lines +95 to +98
statement = insert(DeclutterKeep).values(item_id=item.id, kept_until=until)
await self.db.execute(
statement.on_conflict_do_update(
index_elements=[DeclutterKeep.item_id], set_={"kept_until": until}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Older keep request overwrites newer choice

When two keep requests for the same item with different month values execute out of order, keep unconditionally upserts kept_until, so when an earlier request with a different months value reaches this statement after a newer request, it overwrites the newer choice and the item stays hidden for the wrong period.

Add ordering/version protection so a delayed earlier request cannot overwrite a newer keep decision.

Prompt for AI agents
In `backend/app/services/declutter_service.py` at lines 95-98:

**Older keep request overwrites newer choice**

When two keep requests for the same item with different month values execute out of order, `keep` unconditionally upserts `kept_until`, so when an earlier request with a different `months` value reaches this statement after a newer request, it overwrites the newer choice and the item stays hidden for the wrong period.

Add ordering/version protection so a delayed earlier request cannot overwrite a newer keep decision.

@pytest.mark.asyncio
async def test_months_out_of_range(self, client: AsyncClient, auth_headers):
response = await client.get("/api/v1/declutter?months=0", headers=auth_headers)
assert response.status_code == 422

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Ownership check is untested

When the endpoint accepts an existing item owned by another user, when the endpoint incorrectly allows keeping another user's item, test_cannot_keep_someone_elses_item still passes: line 110 sends a random UUID that does not exist, and line 112 gets 404 for the missing item rather than verifying ownership is enforced.

Create an item for a second user and assert the authenticated user cannot keep it.

Prompt for AI agents
In `backend/tests/test_declutter.py` at line 117:

**Ownership check is untested**

When the endpoint accepts an existing item owned by another user, when the endpoint incorrectly allows keeping another user's item, `test_cannot_keep_someone_elses_item` still passes: line 110 sends a random UUID that does not exist, and line 112 gets 404 for the missing item rather than verifying ownership is enforced.

Create an item for a second user and assert the authenticated user cannot keep it.

- Only ready, unarchived items can be kept, like the review itself.
- The review is fetched again whenever the page opens, so items worn,
  edited, archived or deleted elsewhere do not linger in it.
- The ownership test now uses another user's real item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

javascript Pull requests that update javascript code python Pull requests that update python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants