feat: Analytics dashboard, compression improvements, and image search#35
Conversation
Change default compression quality from 85 to 75 and age threshold from 60 to 90 days. Add force recompression feature that allows re-compressing all screenshots (including already compressed) older than a user-selected age, with preview and warning UI in Settings. - Update CompressionSettings defaults (quality 75, after_days 90) - Add force recompression DB methods with breakdown counts - Add batched force recompress with progress callbacks - Add API preview and execute endpoints with Pydantic schemas - Add force recompress modal in Settings with age selector and warnings
The previous approach fetched the same rows each batch since get_force_recompressible_screenshots doesn't filter on is_compressed. The processed_ids set filtered them all out, breaking the loop after the first batch. Switch to LIMIT/OFFSET pagination instead.
- Add floating banner on timeline when compressible screenshots exist - Banner offers one-click auto-compression enable with dismiss option - Uses localStorage to remember dismissal across sessions - Change default compression threshold from 90 to 60 days (2 months) - Add confirmation checkbox to force recompress modal for safety
- Check start_force_recompress return value to handle TOCTOU race - Save localStorage dismissal immediately, not after animation delay - Reset confirmation checkbox when closing force recompress modal - Handle partial failure: dismiss popup even if startCompression fails after config is successfully enabled
Update compression quality default from 85 to 75 in test assertions and add 18 new tests covering force recompress service, DB methods, and API endpoints.
Add support for searching by image (screenshot ID or base64 data) through the existing POST /search endpoint. No new endpoints needed — the image field on SearchRequest accepts either an int (screenshot ID to use stored CLIP embedding) or a string (base64-encoded image to generate embedding). Frontend changes: - Find Similar button in lightbox sets imageSearchImage state - Camera icon + drag-and-drop reads files as base64 - Search effect handles image search reactively — changing time range, visibility, or safe mode re-runs the image search automatically - Thumbnail preview chip in search bar with clear button - Search mode dropdown shows "Image Only" and is disabled during image search Backend changes: - SearchRequest.image field accepts int | str | None - When int: fetches stored CLIP embedding via db.get_embedding() - When str: decodes base64 and generates embedding via get_image_embedding_from_bytes() - Source screenshot filtered from results when searching by ID - Text embedding generation skipped when image is provided - Added get_embedding() method to Database class - Added get_image_embedding_from_bytes() to core/embeddings.py - Increased KNN k multiplier from 2x to 10x for better filtered search coverage
📝 WalkthroughWalkthroughThis PR introduces force recompression capability to re-compress already-compressed screenshots, adds image-based search functionality for finding similar items via drag-and-drop or file upload, and lowers default JPEG quality from 85 to 75. It includes new API endpoints, database methods, embeddings functions, UI components, and comprehensive test coverage. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant WebUI as Web UI
participant API as API Server
participant DB as Database
participant Compression as Compression Service
User->>WebUI: Click "Force Recompress"
WebUI->>API: POST /force-recompress/preview<br/>(older_than_days)
API->>DB: get_force_recompressible_count()
DB-->>API: {total, already_compressed, not_compressed}
API-->>WebUI: Preview response with stats
WebUI->>User: Show modal with counts & warning
User->>WebUI: Confirm & click "Recompress"
WebUI->>API: POST /force-recompress<br/>(older_than_days, confirm=true)
API->>Compression: start_force_recompress()
Compression->>DB: get_force_recompressible_count()
DB-->>Compression: Total work count
loop For each batch
Compression->>DB: get_force_recompressible_screenshots(limit, offset)
DB-->>Compression: Batch of screenshots
Compression->>Compression: _compress_screenshot(force=true)
Compression->>DB: Mark as compressed
end
Compression-->>API: Process complete
API-->>WebUI: Success response
WebUI->>User: Show completion status
sequenceDiagram
actor User
participant WebUI as Web UI
participant API as API Server
participant Embeddings as Embeddings Service
participant DB as Database
User->>WebUI: Drag/drop image or click upload
WebUI->>WebUI: Generate preview & encode base64
WebUI->>API: POST /search<br/>(image=base64_or_id, query='')
API->>API: Validate image parameter
alt image is ID
API->>DB: get_embedding(screenshot_id)
DB-->>API: Stored embedding vector
else image is base64
API->>Embeddings: get_image_embedding_from_bytes()
Embeddings-->>API: Computed embedding vector
end
API->>DB: Search with embedding vector
DB-->>API: Similar results (including source)
API->>API: Filter out source image
API-->>WebUI: Search results
WebUI->>User: Display similar screenshots
Estimated code review effort🎯 4 (Complex) | ⏱️ ~65 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
⚔️ Resolve merge conflicts (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
api/routes/search.py (1)
202-215:⚠️ Potential issue | 🟡 MinorFiltering source screenshot may return fewer results than
limit.The source screenshot is removed at line 203–204 after results are already trimmed by date filters (line 189–200) but before the final
request.limitslice at line 215. If the source screenshot was in the results, the user getslimit - 1results. Consider requestingsearch_limit + 1when searching by image ID, or moving the filter before the date-range pass.Proposed fix: bump search_limit when searching by ID
- search_limit = request.limit * 3 if (request.start_date or request.end_date) else request.limit + search_limit = request.limit * 3 if (request.start_date or request.end_date) else request.limit + # Request one extra result to account for filtering out the source screenshot + if isinstance(request.image, int): + search_limit += 1web/src/lib/api.ts (1)
109-123:⚠️ Potential issue | 🟡 Minor
searchWithParamsand theSearchParamsinterface don't support theimageparameter.The
searchfunction was updated to acceptimage(line 91), butsearchWithParamsdoesn't forward it. More importantly, theSearchParamsinterface (inweb/src/types/index.ts) doesn't include animagefield, so this API is incomplete for image-based search. To alignsearchWithParamswithsearch, addimage?: number | stringto theSearchParamsinterface and include it in the request body.web/src/app/page.tsx (1)
506-513:⚠️ Potential issue | 🟠 MajorPressing Escape during image search creates UI inconsistency, but the proposed fix has a critical flaw.
The bug is real: when image search is active (query contains
[find-similar]or[image: filename]), pressing Escape clears the query viasetQuery('')but leavesimageSearchImageandimageSearchPreviewactive. This causes:
- Grid shows
gallerySnapshots(line 1560:query.trim() ? searchResults : gallerySnapshots)- Search bar still displays "N similar results" (line 1547:
query.trim() || imageSearchPreview)- Gallery fetch effect triggers (line 314:
!query.trim())However, the proposed fix will not work due to a stale closure bug. The keydown useEffect dependency array (line 589) omits
imageSearchPreview,imageSearchImage, andclearImageSearch. CheckingimageSearchPreviewin the handler would use a stale value from when the effect was last created.Correct fix: Add the missing dependencies to the keydown useEffect array:
}, [activeView, selectedImage, query, totalSnapshots, selection, incognitoStatus.active, navigateTimeline, windowOffset, snapshots.length, loadWindowAtOffset, imageSearchPreview, imageSearchImage, clearImageSearch]);Then the proposed Escape handler logic becomes valid:
if (e.key === 'Escape') { if (selectedImage) setSelectedImage(null); else if (selection.selectedIds.size > 0) selection.clearSelection(); + else if (imageSearchPreview) clearImageSearch(); else if (query) setQuery(''); return; }
🤖 Fix all issues with AI agents
In `@core/tests/test_compression.py`:
- Around line 286-292: The first offset assertion is tautological and the second
is fragile; replace both with explicit checks against the recorded call kwargs
from mock_db.get_force_recompressible_screenshots.call_args_list (the local
variable calls) so you assert calls[0].kwargs["offset"] == 0 (falling back to
examining positional args in calls[0].args if kwargs missing) and assert
calls[1].kwargs["offset"] > calls[0].kwargs["offset"] (or equals 30 if you
expect that exact value), ensuring you consistently read offset from call.kwargs
before falling back to call.args to avoid the always-true or brittle positional
checks.
In `@web/src/app/page.tsx`:
- Around line 1009-1025: Before creating a new preview in handleImageSearch,
revoke any existing blob URL (call clearImageSearch() or
URL.revokeObjectURL(prevPreview) using the current imageSearchPreview) so the
previous object URL is not leaked; then create the new URL and
setImageSearchPreview as before. Also replace the silent early returns for
invalid type/oversize with explicit user feedback (e.g., call an existing
setImageSearchError or trigger a toast/alert) so the user knows why the file was
rejected. Ensure clearImageSearch also revokes the current preview and is
invoked on unmount or when replacing previews.
In `@web/src/app/settings/page.tsx`:
- Around line 149-160: When the age dropdown changes, reset the confirmation
state so the user must re-confirm for the new preview; in
handleForceRecompressAgeChange (which already calls setForceRecompressAge,
previewForceRecompress, setForceRecompressPreview and setForceRecompressLoading)
add a call to setForceRecompressConfirmed(false) (and ensure any related UI
checkbox/state that represents confirmation is cleared) before or immediately
after fetching the new preview so the previous confirmation cannot persist
across age changes.
In `@web/src/components/CompressionSuggestion.tsx`:
- Around line 96-98: The copy in CompressionSuggestion.tsx hardcodes "older than
2 months" which can be out of sync with the actual compression threshold; update
the component to derive the text from the real configuration (e.g., use the
compression.after_days value passed into props or read from the settings object
used elsewhere) and render a dynamic phrase (convert after_days to months or
show days) instead of the hardcoded string; update any JSX that references
stats.compressible_count to combine with the dynamic threshold text so the UI
always reflects compression.after_days.
🧹 Nitpick comments (8)
api/routes/search.py (2)
72-95: Base64 decode error detail may leak internal info on line 95.Line 95 includes
{e}in the HTTP response detail, which could expose internal paths or library error messages. For a local/personal app this is low risk, but worth noting.Also, consider adding a size check on the decoded
image_bytesbefore passing toget_image_embedding_from_bytesto guard against excessively large payloads consuming GPU/CPU memory.Proposed size guard
try: image_bytes = base64.b64decode(request.image) except Exception as e: raise HTTPException(status_code=400, detail="Invalid base64 image data") from e + + # Guard against excessively large uploads (e.g. 20MB) + if len(image_bytes) > 20 * 1024 * 1024: + raise HTTPException(status_code=400, detail="Image too large (max 20MB)") + from core.embeddings import get_image_embedding_from_bytes
98-119: Whenimageis provided, text query is silently ignored.If a user sends both
query="blue shirt"andimage=<some_id>, the query is completely discarded — no CLIP text embedding (line 98) and no BGE text embedding (line 115) are generated. Thequerystring is still passed tosearch_hybridon line 174, which may use it for FTS, but there's no documentation of this behavior.Consider noting this in the endpoint docstring to set expectations.
api/schemas.py (1)
211-214: Verify Pydantic union coercion behavior forint | str | None.With
image: int | str | None, Pydantic v2's smart union mode should correctly distinguish a JSON number (→int, screenshot ID) from a JSON string (→str, base64 data). However, if a client sends a numeric string like"123"intending it as a screenshot ID, it will be treated as a base64 string and fail at decode. Consider documenting this contract clearly in the field description.#!/bin/bash # Check if there's any Pydantic model_config that might affect union behavior rg -n "model_config" --type=py -C3 rg -n "smart_union\|union_mode" --type=py -C3web/src/app/settings/page.tsx (1)
439-516: Modal lacks keyboard dismiss and focus trapping.The force recompress modal doesn't handle Escape key to close, and doesn't trap focus within the dialog. For a destructive operation modal, this is a minor accessibility gap.
web/src/components/CompressionSuggestion.tsx (1)
9-14:formatBytescan produceundefinedunit for very large values.If
bytesis ≥ 1 TB,iwill be 4, exceeding theunitsarray bounds. While unlikely for estimated savings, a guard would be safer.🛡️ Suggested guard
function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); return `${(bytes / Math.pow(1024, i)).toFixed(i > 1 ? 1 : 0)} ${units[i]}`; }web/src/lib/api.ts (1)
82-107: Search function signature is growing – consider an options object.The
searchfunction now has 9 positional parameters. This makes call sites fragile and hard to read. Consider migrating to an options/params object pattern, similar tosearchWithParams.core/compression.py (1)
203-208: Missing return type annotation.
start_force_recompressreturnsboolbut the signature lacks a return type hint. As per coding guidelines, Python 3.10+ with type hints should be used.🔧 Proposed fix
def start_force_recompress( self, older_than_days: int, quality: int | None = None, on_progress: Callable[[CompressionProgress], None] | None = None, - ): + ) -> bool: """Start force recompression in background thread"""web/src/app/page.tsx (1)
1558-1561:itemsToShowrelies on synthetic query to select search results — fragile coupling.The expression
query.trim() ? searchResults : gallerySnapshotsworks during image search only becausequeryis set to a synthetic string. If the query gets cleared independently (e.g., via the Escape key bug noted above, or future refactoring), this silently falls back to gallery items while image search results are still populated.Proposed fix — decouple from synthetic query
- const itemsToShow = query.trim() ? searchResults : gallerySnapshots; + const itemsToShow = (query.trim() || imageSearchImage !== null) ? searchResults : gallerySnapshots;
| # Verify offset-based calls | ||
| calls = mock_db.get_force_recompressible_screenshots.call_args_list | ||
| assert len(calls) == 3 | ||
| # First call: offset=0 | ||
| assert calls[0].kwargs.get("offset", calls[0][1][2] if len(calls[0][1]) > 2 else 0) == 0 or calls[0] == calls[0] | ||
| # Check that offset increases | ||
| assert calls[1][1] == (30,) or calls[1].kwargs.get("offset", 0) > 0 |
There was a problem hiding this comment.
Tautological assertion – offset verification always passes.
Line 290's assertion has or calls[0] == calls[0] which is always True, so the assertion never fails regardless of the actual offset value. Line 292 is similarly weak — calls[1][1] == (30,) checks positional args which may or may not match the actual call shape.
Consider asserting on the actual keyword arguments directly:
🔧 Proposed fix
- # First call: offset=0
- assert calls[0].kwargs.get("offset", calls[0][1][2] if len(calls[0][1]) > 2 else 0) == 0 or calls[0] == calls[0]
- # Check that offset increases
- assert calls[1][1] == (30,) or calls[1].kwargs.get("offset", 0) > 0
+ # Verify offset increases across calls
+ offsets = [c.kwargs.get("offset", 0) for c in calls]
+ assert offsets[0] == 0
+ assert offsets[1] > 0
+ assert offsets[2] > offsets[1]🤖 Prompt for AI Agents
In `@core/tests/test_compression.py` around lines 286 - 292, The first offset
assertion is tautological and the second is fragile; replace both with explicit
checks against the recorded call kwargs from
mock_db.get_force_recompressible_screenshots.call_args_list (the local variable
calls) so you assert calls[0].kwargs["offset"] == 0 (falling back to examining
positional args in calls[0].args if kwargs missing) and assert
calls[1].kwargs["offset"] > calls[0].kwargs["offset"] (or equals 30 if you
expect that exact value), ensuring you consistently read offset from call.kwargs
before falling back to call.args to avoid the always-true or brittle positional
checks.
| const handleImageSearch = useCallback((file: File) => { | ||
| if (!file.type.startsWith('image/')) return; | ||
| if (file.size > 10 * 1024 * 1024) return; | ||
|
|
||
| const previewUrl = URL.createObjectURL(file); | ||
| setImageSearchPreview(previewUrl); | ||
| setQuery(`[image: ${file.name}]`); | ||
| setActiveView('search'); | ||
|
|
||
| // Read file as base64 — the search effect handles the actual API call | ||
| const reader = new FileReader(); | ||
| reader.onload = () => { | ||
| const base64 = (reader.result as string).split(',')[1]; | ||
| setImageSearchImage(base64); | ||
| }; | ||
| reader.readAsDataURL(file); | ||
| }, []); |
There was a problem hiding this comment.
Blob URL leak when a second image is dropped without clearing the first.
When handleImageSearch is called while there's already an active blob-URL preview (e.g., user drags a second image), the previous URL.createObjectURL is never revoked — clearImageSearch is not called, and the callback has no reference to the prior URL.
Additionally, the silent return on invalid type / oversized file gives no user feedback.
Proposed fix
- const handleImageSearch = useCallback((file: File) => {
- if (!file.type.startsWith('image/')) return;
- if (file.size > 10 * 1024 * 1024) return;
+ const handleImageSearch = useCallback((file: File) => {
+ if (!file.type.startsWith('image/')) {
+ console.warn('Unsupported file type:', file.type);
+ return;
+ }
+ if (file.size > 10 * 1024 * 1024) {
+ console.warn('File too large (max 10 MB):', file.size);
+ return;
+ }
+
+ // Revoke previous blob URL to avoid memory leak
+ if (imageSearchPreview && imageSearchPreview.startsWith('blob:')) {
+ URL.revokeObjectURL(imageSearchPreview);
+ }
const previewUrl = URL.createObjectURL(file);
setImageSearchPreview(previewUrl);
...
- }, []);
+ }, [imageSearchPreview]);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleImageSearch = useCallback((file: File) => { | |
| if (!file.type.startsWith('image/')) return; | |
| if (file.size > 10 * 1024 * 1024) return; | |
| const previewUrl = URL.createObjectURL(file); | |
| setImageSearchPreview(previewUrl); | |
| setQuery(`[image: ${file.name}]`); | |
| setActiveView('search'); | |
| // Read file as base64 — the search effect handles the actual API call | |
| const reader = new FileReader(); | |
| reader.onload = () => { | |
| const base64 = (reader.result as string).split(',')[1]; | |
| setImageSearchImage(base64); | |
| }; | |
| reader.readAsDataURL(file); | |
| }, []); | |
| const handleImageSearch = useCallback((file: File) => { | |
| if (!file.type.startsWith('image/')) { | |
| console.warn('Unsupported file type:', file.type); | |
| return; | |
| } | |
| if (file.size > 10 * 1024 * 1024) { | |
| console.warn('File too large (max 10 MB):', file.size); | |
| return; | |
| } | |
| // Revoke previous blob URL to avoid memory leak | |
| if (imageSearchPreview && imageSearchPreview.startsWith('blob:')) { | |
| URL.revokeObjectURL(imageSearchPreview); | |
| } | |
| const previewUrl = URL.createObjectURL(file); | |
| setImageSearchPreview(previewUrl); | |
| setQuery(`[image: ${file.name}]`); | |
| setActiveView('search'); | |
| // Read file as base64 — the search effect handles the actual API call | |
| const reader = new FileReader(); | |
| reader.onload = () => { | |
| const base64 = (reader.result as string).split(',')[1]; | |
| setImageSearchImage(base64); | |
| }; | |
| reader.readAsDataURL(file); | |
| }, [imageSearchPreview]); |
🤖 Prompt for AI Agents
In `@web/src/app/page.tsx` around lines 1009 - 1025, Before creating a new preview
in handleImageSearch, revoke any existing blob URL (call clearImageSearch() or
URL.revokeObjectURL(prevPreview) using the current imageSearchPreview) so the
previous object URL is not leaked; then create the new URL and
setImageSearchPreview as before. Also replace the silent early returns for
invalid type/oversize with explicit user feedback (e.g., call an existing
setImageSearchError or trigger a toast/alert) so the user knows why the file was
rejected. Ensure clearImageSearch also revokes the current preview and is
invoked on unmount or when replacing previews.
| const handleForceRecompressAgeChange = async (days: number) => { | ||
| setForceRecompressAge(days); | ||
| setForceRecompressLoading(true); | ||
| try { | ||
| const preview = await previewForceRecompress(days); | ||
| setForceRecompressPreview(preview); | ||
| } catch (err) { | ||
| console.error('Failed to preview:', err); | ||
| } finally { | ||
| setForceRecompressLoading(false); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Confirmation checkbox is not reset when age changes.
When the user changes the age dropdown, the preview data updates but forceRecompressConfirmed retains its previous value. This means a user could confirm for "90 days" and then change to "30 days" (affecting a different set of screenshots) without re-confirming.
🔧 Proposed fix
const handleForceRecompressAgeChange = async (days: number) => {
setForceRecompressAge(days);
+ setForceRecompressConfirmed(false);
setForceRecompressLoading(true);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleForceRecompressAgeChange = async (days: number) => { | |
| setForceRecompressAge(days); | |
| setForceRecompressLoading(true); | |
| try { | |
| const preview = await previewForceRecompress(days); | |
| setForceRecompressPreview(preview); | |
| } catch (err) { | |
| console.error('Failed to preview:', err); | |
| } finally { | |
| setForceRecompressLoading(false); | |
| } | |
| }; | |
| const handleForceRecompressAgeChange = async (days: number) => { | |
| setForceRecompressAge(days); | |
| setForceRecompressConfirmed(false); | |
| setForceRecompressLoading(true); | |
| try { | |
| const preview = await previewForceRecompress(days); | |
| setForceRecompressPreview(preview); | |
| } catch (err) { | |
| console.error('Failed to preview:', err); | |
| } finally { | |
| setForceRecompressLoading(false); | |
| } | |
| }; |
🤖 Prompt for AI Agents
In `@web/src/app/settings/page.tsx` around lines 149 - 160, When the age dropdown
changes, reset the confirmation state so the user must re-confirm for the new
preview; in handleForceRecompressAgeChange (which already calls
setForceRecompressAge, previewForceRecompress, setForceRecompressPreview and
setForceRecompressLoading) add a call to setForceRecompressConfirmed(false) (and
ensure any related UI checkbox/state that represents confirmation is cleared)
before or immediately after fetching the new preview so the previous
confirmation cannot persist across age changes.
| <p className="text-xs text-[#666] mt-1.5 leading-relaxed"> | ||
| You have <span className="text-[#86efac] font-medium">{stats.compressible_count.toLocaleString()}</span> screenshots older than 2 months that can be compressed. Search and embeddings stay untouched. | ||
| </p> |
There was a problem hiding this comment.
Hardcoded "older than 2 months" may not match actual config.
The text says "screenshots older than 2 months" but the actual compression.after_days config value could differ (the default was changed across commits — 90 then 60). If the user or a future commit changes the threshold, this text becomes misleading.
Consider either fetching the config value or deriving the text from the actual after_days setting.
🤖 Prompt for AI Agents
In `@web/src/components/CompressionSuggestion.tsx` around lines 96 - 98, The copy
in CompressionSuggestion.tsx hardcodes "older than 2 months" which can be out of
sync with the actual compression threshold; update the component to derive the
text from the real configuration (e.g., use the compression.after_days value
passed into props or read from the settings object used elsewhere) and render a
dynamic phrase (convert after_days to months or show days) instead of the
hardcoded string; update any JSX that references stats.compressible_count to
combine with the dynamic threshold text so the UI always reflects
compression.after_days.
Summary
This branch adds several major features and improvements to LiveRecall:
Analytics Dashboard
/analyticspage with activity insights — daily/weekly/monthly screenshot counts, peak hours heatmap, incognito usage stats, storage breakdown, and streak tracking/api/v1/analyticswith endpoints for activity stats, daily trends, hourly heatmaps, streaks, storage info, and top OCR keywordsCompression Feature Improvements
enabled=False(opt-in),after_days=60,quality=75Image-Based Search (Find Similar + Upload)
POST /searchendpoint — added an optionalimagefield (accepts int screenshot ID or string base64 data)Bug Fixes
Test plan
uv run pytest— all 179 tests passcd web && npm run build— no TypeScript errorsSummary by CodeRabbit
New Features
Chores