Repository navigation
feat: season swap checklist - #279
TheBlackSide243 wants to merge 3 commits into
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideAdds an authenticated season-swap API and service that produces hemisphere-aware wardrobe recommendations using item tags and prior-year wear history, plus a localized dashboard checklist whose completion state is persisted per season transition in localStorage. Sequence diagram for the season swap checklistsequenceDiagram
actor User
participant Page as SeasonSwapPage
participant Hook as useSeasonSwap
participant API as GET /api/v1/season-swap
participant Service as SeasonSwapService
participant DB as Database
User->>Page: Open /dashboard/season
Page->>Hook: useSeasonSwap()
Hook->>API: GET /season-swap
API->>Service: plan(current_user, today)
Service->>DB: Load ready, non-archived clothing items
Service->>DB: Load prior-year ItemHistory
Service-->>API: SwapPlan
API-->>Hook: SwapResponse
Hook-->>Page: Render put-away and bring-out items
User->>Page: Toggle item checklist
Page->>Page: localStorage.setItem(DONE_KEY)
Flow diagram for season swap recommendation logicflowchart TD
A[Load eligible wardrobe items] --> B[Calculate current and upcoming seasons]
B --> C{Worn in last 21 days?}
C -->|Yes| D[Keep item out]
C -->|No| E{Item has season tags?}
E -->|No| F{Worn in matching weeks last year?}
F -->|Yes| G[Bring out: last_year]
F -->|No| D
E -->|Yes| H{Tags match current or upcoming season?}
H -->|Upcoming only| I[Bring out: season]
H -->|Neither| J[Put away: season]
H -->|Current or all-season| D
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 8 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="frontend/lib/hooks/use-season-swap.ts" line_range="27" />
<code_context>
+ const { status } = useSession();
+ useSetTokenIfAvailable();
+ return useQuery({
+ queryKey: queryKeys.seasonSwap,
+ queryFn: () => api.get<SeasonSwap>('/season-swap'),
+ enabled: status === 'authenticated',
</code_context>
<issue_to_address>
**Previous account's wardrobe appears**
When the authenticated account changes while the previous account's season-swap result remains fresh in the same app, `queryKeys.seasonSwap` is shared across accounts, so TanStack Query reuses the previous user's result without immediately running `queryFn`; while refetching is pending or fails, the new account sees the previous user's clothing and image URLs.
Include the authenticated user in the season-swap query key so each account has a separate cache entry.
Also at `frontend/lib/hooks/query-keys.ts:26`.
</issue_to_address>
### Comment 2
<location path="backend/app/services/season_swap.py" line_range="22" />
<code_context>
+LOOK_AHEAD_DAYS = 60
+# Last year's wears inside this window around the same date mark an untagged item as seasonal.
+HISTORY_WINDOW = (timedelta(days=15), timedelta(days=105))
+STILL_IN_USE_DAYS = 21
+ALL_SEASONS = {"all-season", "spring", "summer", "fall", "winter"}
+
</code_context>
<issue_to_address>
**Untagged items get wrong recommendations**
When an untagged item's wear history falls in the corresponding weeks last year, or only in the current 15–105-day interval, `SeasonSwapService.plan` builds `start` and `end` from `HISTORY_WINDOW` as 15–105 days after the corresponding date, so it misses wears from the matching weeks and counts unrelated later wears; `plan_swap` then gives users incorrect bring-out recommendations.
Set `HISTORY_WINDOW` and the resulting query bounds to cover the corresponding weeks last year rather than days 15–105 after the date.
Also at `backend/app/services/season_swap.py:21`, `backend/app/services/season_swap.py:88-89`.
</issue_to_address>
### Comment 3
<location path="frontend/lib/hooks/use-season-swap.ts" line_range="27" />
<code_context>
+ const { status } = useSession();
+ useSetTokenIfAvailable();
+ return useQuery({
+ queryKey: queryKeys.seasonSwap,
+ queryFn: () => api.get<SeasonSwap>('/season-swap'),
+ enabled: status === 'authenticated',
</code_context>
<issue_to_address>
**Checklist plan stays stale**
When an item or wear mutation succeeds and the cached season-swap result is revisited before it becomes stale, item and wear mutations invalidate their own queries but not `seasonSwap`, so React Query serves the old plan; users see items in the wrong checklist section or miss recent-wear exceptions until a refetch.
Invalidate the season-swap query when item or wear mutations succeed.
</issue_to_address>
### Comment 4
<location path="frontend/app/dashboard/season/page.tsx" line_range="108" />
<code_context>
+ const t = useTranslations('season');
+ const seasonLabel = useSeasonLabel();
+ const { data, isLoading, isError } = useSeasonSwap();
+ const key = data ? `${data.current_season}-${data.upcoming_season}` : '';
+ const [done, setDone] = useState<Set<string>>(new Set());
+
</code_context>
<issue_to_address>
**Last year's checklist stays checked**
When the same current/upcoming season pair recurs in a later year and the prior item IDs are still on the checklist, `key` contains only the season names, so `readDone` accepts the prior year's saved IDs and displays those items as completed in the new swap.
Include the year or season-cycle identity in the saved checklist key.
</issue_to_address>
### Comment 5
<location path="frontend/app/dashboard/season/page.tsx" line_range="116-121" />
<code_context>
+ }, [key]);
+
+ const toggle = (id: string) =>
+ setDone((current) => {
+ const next = new Set(current);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ try {
+ window.localStorage.setItem(DONE_KEY, JSON.stringify({ season: key, ids: Array.from(next) }));
+ } catch {
+ // Not remembered in private mode; the list still works for this visit.
</code_context>
<issue_to_address>
**Checklist changes disappear across tabs**
When two tabs toggle different checklist items before either tab sees the other's update, each tab writes its entire in-memory ID set to the same localStorage key, so the later write overwrites the earlier tab's change and completed items appear unchecked after reload.
Merge each tab's changes with the latest saved IDs or synchronize tab state before writing.
</issue_to_address>
### Comment 6
<location path="frontend/app/dashboard/season/page.tsx" line_range="19" />
<code_context>
+import { useSeasonSwap, type SwapEntry } from '@/lib/hooks/use-season-swap';
+import { useSeasonLabel, useTypeLabel } from '@/lib/hooks/use-translated-constants';
+
+const DONE_KEY = 'wardrowbe.seasonSwapDone';
+
+function readDone(season: string): Set<string> {
</code_context>
<issue_to_address>
**Accounts overwrite saved progress**
When multiple accounts use the same browser and both update their checklist for the same season pair, the single localStorage record is shared by every account in the browser, so when one account saves its checked IDs it replaces another account's progress for the same season pair.
Scope the localStorage key or stored record by authenticated user ID.
Also at `frontend/app/dashboard/season/page.tsx:121`.
</issue_to_address>
### Comment 7
<location path="backend/tests/test_season_swap.py" line_range="69" />
<code_context>
+ response = await client.get("/api/v1/season-swap", headers=auth_headers)
+ assert response.status_code == 200, response.json()
+ data = response.json()
+ assert {"current_season", "upcoming_season", "put_away", "bring_out", "stays"} <= set(data)
</code_context>
<issue_to_address>
**Empty checklists pass endpoint test**
When the endpoint returns empty `put_away` and `bring_out` lists despite the committed item, `test_endpoint` checks only the status and response keys at lines 67-69, so an endpoint that returns empty checklist arrays despite the committed summer item passes and the item is absent from the user's checklist.
Assert that the committed sandals item is present in the appropriate checklist entry.
</issue_to_address>
### Comment 8
<location path="backend/tests/test_season_swap.py" line_range="45" />
<code_context>
+
+ def test_recently_worn_items_stay_out(self):
+ shorts = _item("shorts", ["summer"], last_worn_at=OCTOBER - timedelta(days=3))
+ assert plan_swap([shorts], OCTOBER, 45.7, set()).put_away == []
+
+ def test_untagged_items_follow_last_year(self):
</code_context>
<issue_to_address>
**Recently worn items can disappear**
When `plan_swap` drops a recently worn item from all plan lists, `test_recently_worn_items_stay_out` checks only that `put_away` is empty at line 45, so a regression that drops `shorts` from every plan list still passes even though the item does not stay out.
Assert that the recently worn item remains represented in the plan, such as by checking `stays` or its expected list membership.
</issue_to_address>Sourcery assessment
Approval pending. 8 findings to address first.
Blocking findings: frontend/lib/hooks/use-season-swap.ts:27, backend/app/services/season_swap.py:22, frontend/lib/hooks/use-season-swap.ts:27, frontend/app/dashboard/season/page.tsx:108, frontend/app/dashboard/season/page.tsx:121, and 3 more
| const { status } = useSession(); | ||
| useSetTokenIfAvailable(); | ||
| return useQuery({ | ||
| queryKey: queryKeys.seasonSwap, |
There was a problem hiding this comment.
🔴 Critical · Previous account's wardrobe appears
When the authenticated account changes while the previous account's season-swap result remains fresh in the same app, queryKeys.seasonSwap is shared across accounts, so TanStack Query reuses the previous user's result without immediately running queryFn; while refetching is pending or fails, the new account sees the previous user's clothing and image URLs.
Include the authenticated user in the season-swap query key so each account has a separate cache entry.
Also at frontend/lib/hooks/query-keys.ts:26.
Prompt for AI agents
In `frontend/lib/hooks/use-season-swap.ts` at line 27:
**Previous account's wardrobe appears**
When the authenticated account changes while the previous account's season-swap result remains fresh in the same app, `queryKeys.seasonSwap` is shared across accounts, so TanStack Query reuses the previous user's result without immediately running `queryFn`; while refetching is pending or fails, the new account sees the previous user's clothing and image URLs.
Include the authenticated user in the season-swap query key so each account has a separate cache entry.
Also at `frontend/lib/hooks/query-keys.ts:26`.| LOOK_AHEAD_DAYS = 60 | ||
| # Last year's wears inside this window around the same date mark an untagged item as seasonal. | ||
| HISTORY_WINDOW = (timedelta(days=15), timedelta(days=105)) | ||
| STILL_IN_USE_DAYS = 21 |
There was a problem hiding this comment.
🟡 Medium · Untagged items get wrong recommendations
When an untagged item's wear history falls in the corresponding weeks last year, or only in the current 15–105-day interval, SeasonSwapService.plan builds start and end from HISTORY_WINDOW as 15–105 days after the corresponding date, so it misses wears from the matching weeks and counts unrelated later wears; plan_swap then gives users incorrect bring-out recommendations.
Set HISTORY_WINDOW and the resulting query bounds to cover the corresponding weeks last year rather than days 15–105 after the date.
Also at backend/app/services/season_swap.py:21, backend/app/services/season_swap.py:88-89.
Prompt for AI agents
In `backend/app/services/season_swap.py` at line 22:
**Untagged items get wrong recommendations**
When an untagged item's wear history falls in the corresponding weeks last year, or only in the current 15–105-day interval, `SeasonSwapService.plan` builds `start` and `end` from `HISTORY_WINDOW` as 15–105 days after the corresponding date, so it misses wears from the matching weeks and counts unrelated later wears; `plan_swap` then gives users incorrect bring-out recommendations.
Set `HISTORY_WINDOW` and the resulting query bounds to cover the corresponding weeks last year rather than days 15–105 after the date.
Also at `backend/app/services/season_swap.py:21`, `backend/app/services/season_swap.py:88-89`.| const { status } = useSession(); | ||
| useSetTokenIfAvailable(); | ||
| return useQuery({ | ||
| queryKey: queryKeys.seasonSwap, |
There was a problem hiding this comment.
🟡 Medium · Checklist plan stays stale
When an item or wear mutation succeeds and the cached season-swap result is revisited before it becomes stale, item and wear mutations invalidate their own queries but not seasonSwap, so React Query serves the old plan; users see items in the wrong checklist section or miss recent-wear exceptions until a refetch.
Invalidate the season-swap query when item or wear mutations succeed.
Prompt for AI agents
In `frontend/lib/hooks/use-season-swap.ts` at line 27:
**Checklist plan stays stale**
When an item or wear mutation succeeds and the cached season-swap result is revisited before it becomes stale, item and wear mutations invalidate their own queries but not `seasonSwap`, so React Query serves the old plan; users see items in the wrong checklist section or miss recent-wear exceptions until a refetch.
Invalidate the season-swap query when item or wear mutations succeed.| const t = useTranslations('season'); | ||
| const seasonLabel = useSeasonLabel(); | ||
| const { data, isLoading, isError } = useSeasonSwap(); | ||
| const key = data ? `${data.current_season}-${data.upcoming_season}` : ''; |
There was a problem hiding this comment.
🟡 Medium · Last year's checklist stays checked
When the same current/upcoming season pair recurs in a later year and the prior item IDs are still on the checklist, key contains only the season names, so readDone accepts the prior year's saved IDs and displays those items as completed in the new swap.
Include the year or season-cycle identity in the saved checklist key.
Prompt for AI agents
In `frontend/app/dashboard/season/page.tsx` at line 108:
**Last year's checklist stays checked**
When the same current/upcoming season pair recurs in a later year and the prior item IDs are still on the checklist, `key` contains only the season names, so `readDone` accepts the prior year's saved IDs and displays those items as completed in the new swap.
Include the year or season-cycle identity in the saved checklist key.| setDone((current) => { | ||
| const next = new Set(current); | ||
| if (next.has(id)) next.delete(id); | ||
| else next.add(id); | ||
| try { | ||
| window.localStorage.setItem(DONE_KEY, JSON.stringify({ season: key, ids: Array.from(next) })); |
There was a problem hiding this comment.
🟡 Medium · Checklist changes disappear across tabs
When two tabs toggle different checklist items before either tab sees the other's update, each tab writes its entire in-memory ID set to the same localStorage key, so the later write overwrites the earlier tab's change and completed items appear unchecked after reload.
Merge each tab's changes with the latest saved IDs or synchronize tab state before writing.
Prompt for AI agents
In `frontend/app/dashboard/season/page.tsx` at lines 116-121:
**Checklist changes disappear across tabs**
When two tabs toggle different checklist items before either tab sees the other's update, each tab writes its entire in-memory ID set to the same localStorage key, so the later write overwrites the earlier tab's change and completed items appear unchecked after reload.
Merge each tab's changes with the latest saved IDs or synchronize tab state before writing.| import { useSeasonSwap, type SwapEntry } from '@/lib/hooks/use-season-swap'; | ||
| import { useSeasonLabel, useTypeLabel } from '@/lib/hooks/use-translated-constants'; | ||
|
|
||
| const DONE_KEY = 'wardrowbe.seasonSwapDone'; |
There was a problem hiding this comment.
🟡 Medium · Accounts overwrite saved progress
When multiple accounts use the same browser and both update their checklist for the same season pair, the single localStorage record is shared by every account in the browser, so when one account saves its checked IDs it replaces another account's progress for the same season pair.
Scope the localStorage key or stored record by authenticated user ID.
Also at frontend/app/dashboard/season/page.tsx:121.
Prompt for AI agents
In `frontend/app/dashboard/season/page.tsx` at line 19:
**Accounts overwrite saved progress**
When multiple accounts use the same browser and both update their checklist for the same season pair, the single localStorage record is shared by every account in the browser, so when one account saves its checked IDs it replaces another account's progress for the same season pair.
Scope the localStorage key or stored record by authenticated user ID.
Also at `frontend/app/dashboard/season/page.tsx:121`.| response = await client.get("/api/v1/season-swap", headers=auth_headers) | ||
| assert response.status_code == 200, response.json() | ||
| data = response.json() | ||
| assert {"current_season", "upcoming_season", "put_away", "bring_out", "stays"} <= set(data) |
There was a problem hiding this comment.
🟡 Medium · Empty checklists pass endpoint test
When the endpoint returns empty put_away and bring_out lists despite the committed item, test_endpoint checks only the status and response keys at lines 67-69, so an endpoint that returns empty checklist arrays despite the committed summer item passes and the item is absent from the user's checklist.
Assert that the committed sandals item is present in the appropriate checklist entry.
Prompt for AI agents
In `backend/tests/test_season_swap.py` at line 69:
**Empty checklists pass endpoint test**
When the endpoint returns empty `put_away` and `bring_out` lists despite the committed item, `test_endpoint` checks only the status and response keys at lines 67-69, so an endpoint that returns empty checklist arrays despite the committed summer item passes and the item is absent from the user's checklist.
Assert that the committed sandals item is present in the appropriate checklist entry.|
|
||
| def test_recently_worn_items_stay_out(self): | ||
| shorts = _item("shorts", ["summer"], last_worn_at=OCTOBER - timedelta(days=3)) | ||
| assert plan_swap([shorts], OCTOBER, 45.7, set()).put_away == [] |
There was a problem hiding this comment.
🟡 Medium · Recently worn items can disappear
When plan_swap drops a recently worn item from all plan lists, test_recently_worn_items_stay_out checks only that put_away is empty at line 45, so a regression that drops shorts from every plan list still passes even though the item does not stay out.
Assert that the recently worn item remains represented in the plan, such as by checking stays or its expected list membership.
Prompt for AI agents
In `backend/tests/test_season_swap.py` at line 45:
**Recently worn items can disappear**
When `plan_swap` drops a recently worn item from all plan lists, `test_recently_worn_items_stay_out` checks only that `put_away` is empty at line 45, so a regression that drops `shorts` from every plan list still passes even though the item does not stay out.
Assert that the recently worn item remains represented in the plan, such as by checking `stays` or its expected list membership.- The swap query is keyed by account and fetched again on every visit, so another sign-in never sees this one's clothes and recent wears count. - Ticks are stored per account and per swap year, and each toggle merges with what other tabs saved (they also pick up each other's changes). - The history window comment says what it covers: the coming season as it was a year ago. - Tests pin the date and check where items land. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Description
Adds a Season swap page: what to put away and what to bring out for the next two months, based on
each item's seasons (hemisphere-aware from the user's location) and, for items without seasons,
on what was worn in the same weeks last year. Items worn in the last three weeks stay out. The
user ticks items off as they go (remembered in the browser).
GET /api/v1/season-swap, no new tables (services/season_swap.py)./dashboard/season, nav entry, translations for all 8 locales.This is one of a set of small, independent PRs (each branches from
mainand can be reviewed ordropped on its own); they only overlap in the nav/router registration lines.
Type of Change
Checklist
Testing
ruff check,ruff format --check, migration heads check and the full pytest suite on top of currentmain(2031 passed).npm run lint,i18n:check,vocab:check,tsc --noEmitand vitest (693 passed).Test Environment
🤖 Generated with Claude Code
Summary by Sourcery
Add a location-aware seasonal wardrobe swap checklist for organizing items between seasons.
New Features:
Enhancements:
Tests: