···11-{"id":"01KGA7S43ENCHDZQ9DXPGEA6W1","title":"Consolidate three modal systems into one","description":"CRITICAL: Consolidate three different modal implementations:\n- entity_modals.templ (Alpine.js x-show)\n- dialog_modals.templ (native HTML5 \u003cdialog\u003e)\n- profile.templ (inline Alpine modals)\n\nRecommendation: Migrate all to native \u003cdialog\u003e for modern standard and better a11y.\nRemove Alpine.js modal code and inline modal implementations.\n\nAcceptance criteria:\n- All modals use consistent implementation\n- Better accessibility\n- Reduced maintenance burden","status":"completed","priority":"high","assignee":"patrick","change_id":"zvoovqqnlutxuzrwzpslzvxvsokyvyux","labels":["frontend","refactor","modals"],"created_at":"2026-01-31T14:37:13.710608293Z","updated_at":"2026-01-31T15:07:11.640016975Z","completed_at":"2026-01-31T15:07:11.609500036Z"}
22-{"id":"01KGA7SZW8TPBWJZ40DHFC9RSF","title":"Fix modal fade-out animations","description":"Modals don't fade out on save/cancel as expected. They should smoothly transition out.\n\nAcceptance criteria:\n- Modals fade out smoothly when closed\n- Consistent animation timing\n- Works for both save and cancel actions","status":"completed","priority":"normal","labels":["frontend","modals","ux"],"created_at":"2026-01-31T14:37:42.152625516Z","updated_at":"2026-01-31T22:01:05.514388522Z","completed_at":"2026-01-31T22:01:05.499540746Z"}
33-{"id":"01KGA7SZY65S5V97C7B3PKA172","title":"Fix feed view details button","description":"Feed item 'view details' button should be removed. The 'new brew' text in 'added a new brew' should link to view page instead (underline this text).\n\nAcceptance criteria:\n- Remove 'view details' button\n- Make 'new brew' text clickable and underlined\n- Links to correct brew view page","status":"completed","priority":"normal","labels":["frontend","feed","ux"],"created_at":"2026-01-31T14:37:42.214656646Z","updated_at":"2026-01-31T16:28:52.517267974Z","completed_at":"2026-01-31T16:28:52.506611951Z"}
44-{"id":"01KGA7T004T9HZR1Q5MHSC48G6","title":"Fix manage page notes truncation","description":"Manage brews page truncates notes unnecessarily, while variables wrap to many more lines.\n\nAcceptance criteria:\n- Notes should not be truncated (or have higher character limit)\n- Consistent text wrapping behavior\n- Better readability","status":"completed","priority":"low","labels":["frontend","manage","ux"],"created_at":"2026-01-31T14:37:42.276141862Z","updated_at":"2026-01-31T21:36:44.490824871Z","completed_at":"2026-01-31T21:36:44.478912822Z"}
51{"id":"01KGA7T026JJ8H9EK24K17QRA4","title":"Improve loading UX with progress bars or transitions","description":"Loading on HTMX could be snappier with loading bars or transitionary animations.\n\nOptions:\n- Loading bar waiting until everything is loaded\n- Transitionary animations between skeleton and full loads\n- Reconsider if skeleton loading is needed with SSR (probably yes due to PDS data fetches)\n\nAcceptance criteria:\n- Smoother loading experience\n- No jarring transitions\n- Consistent loading indicators","status":"open","priority":"normal","labels":["frontend","ux","htmx"],"created_at":"2026-01-31T14:37:42.342432216Z","updated_at":"2026-01-31T14:37:42.342432216Z"}
62{"id":"01KGA7T04HWD93MJQDKZ62XQNB","title":"Fix skeleton headers to match actual table headers","description":"Headers in loading skeletons need to exactly match final table headers.\n\nCurrent issue:\n- Profile refresh shows brew headers for all tabs\n- Inconsistent column counts (brew_list: 6 cols, profile: 5 cols, manage: 5 cols)\n\nAcceptance criteria:\n- Skeleton headers match actual content headers\n- Tab-specific skeletons or dynamic skeletons based on active tab\n- Consistent column structure","status":"open","priority":"normal","labels":["frontend","ux","skeleton"],"created_at":"2026-01-31T14:37:42.417653543Z","updated_at":"2026-01-31T14:37:42.417653543Z"}
77-{"id":"01KGA7T06FC5V4Z92570G0BY5G","title":"Style mail link and back button on terms page","description":"Add proper styling to mail link and 'back to home' button on terms page.\n\nAcceptance criteria:\n- Consistent button/link styling\n- Matches site design system\n- Good visual hierarchy","status":"completed","priority":"low","assignee":"patrick","change_id":"znoumknmuoxkwwrrzrlvtonksrokkvnu","labels":["frontend","styling"],"created_at":"2026-01-31T14:37:42.479212418Z","updated_at":"2026-02-01T00:25:41.74512756Z","completed_at":"2026-02-01T00:25:41.714091178Z"}
83{"id":"01KGA7T08BAWPB977KSHZNS8NE","title":"Revision pass on about and terms text","description":"Review and revise text content in about and terms pages for clarity and accuracy.\n\nAcceptance criteria:\n- Clear, concise messaging\n- No typos or grammar issues\n- Accurate information","status":"claimed","priority":"low","assignee":"patrick","labels":["content","documentation"],"created_at":"2026-01-31T14:37:42.539757583Z","updated_at":"2026-01-31T15:30:48.329882372Z"}
94{"id":"01KGA7T0AE0R3F8PRHX761W03E","title":"Implement profile picture caching in database","description":"Cache profile pictures to database to avoid reloading them frequently. This may already be partially implemented - needs investigation.\n\nAcceptance criteria:\n- Profile pictures cached in database\n- Reduced PDS API calls for avatar fetching\n- Cache invalidation strategy\n- Performance improvement","status":"open","priority":"high","labels":["backend","performance","caching"],"created_at":"2026-01-31T14:37:42.606749189Z","updated_at":"2026-01-31T14:37:42.606749189Z"}
1010-{"id":"01KGA7T0CRPVYSNS9FHE13NMQC","title":"Fix double page transition on back navigation","description":"Hitting back from /brews/new to home page shows welcome box, then transitions in - looks bad.\n\nIssue: Transition seems to trigger twice\nRoot cause: Transition code may be brittle\n\nAcceptance criteria:\n- Single smooth transition on back navigation\n- No double-rendering of welcome box\n- Consider refactoring transition code to be less brittle","status":"completed","priority":"normal","labels":["frontend","navigation","transitions"],"created_at":"2026-01-31T14:37:42.680660721Z","updated_at":"2026-01-31T21:37:20.31354036Z","completed_at":"2026-01-31T21:37:20.300721855Z"}
1111-{"id":"01KGA7TZ6365GY1YG55BGYVKMH","title":"Fix non-functional back button on view page","description":"Back button on brew view page does nothing and produces no logs.\n\nAcceptance criteria:\n- Back button navigates to previous page\n- Proper navigation behavior\n- Debug logging if needed","status":"completed","priority":"normal","labels":["frontend","navigation","bug"],"created_at":"2026-01-31T14:38:14.211441899Z","updated_at":"2026-01-31T20:23:51.90297311Z","completed_at":"2026-01-31T20:23:51.889306189Z"}
1212-{"id":"01KGA7TZ94GDGY53NFXW1K7K6Y","title":"Fix modal record creation not updating list","description":"Adding a record from a modal in brews page doesn't update the list until page refresh.\n\nOlder version on main branch had correct behavior: adds to list and auto-selects.\n\nAcceptance criteria:\n- New records appear in list immediately after creation\n- Auto-select newly created record\n- No page refresh needed","status":"completed","priority":"high","change_id":"kkktpzkmkwuwwrqoptsyoyuuwvqwwwrz","workspace_path":"/home/patrick/projects/arabica/.worktrees/cell-01KGA7TZ94GD","labels":["frontend","htmx","modals","bug"],"created_at":"2026-01-31T14:38:14.308945383Z","updated_at":"2026-01-31T15:55:09.476557868Z","completed_at":"2026-01-31T15:55:09.462025879Z"}
1313-{"id":"01KGA7TZBJ5KYR9HVS57SY5CBD","title":"Reduce table flash on page load","description":"Tables flash briefly on load. Could load more smoothly, perhaps by delaying page until skeleton is rendered.\n\nProfile page does this well with profile banner and stats.\n\nAcceptance criteria:\n- Smoother table loading\n- No visible flash/jump\n- Better perceived performance","status":"completed","priority":"low","change_id":"opplvnrzuswososkrrsrurokmlltszwn","labels":["frontend","ux","performance"],"created_at":"2026-01-31T14:38:14.386960128Z","updated_at":"2026-01-31T16:27:23.272054602Z","completed_at":"2026-01-31T16:27:23.246146545Z"}
1414-{"id":"01KGA7TZE5PE47X9JTQ85ZZJM4","title":"Refactor web code into organized subdirectory","description":"Move all frontend code into web subdirectory within internal.\n\nCurrent structure needs organization:\n- Move components and pages into separate dirs\n- Target: internal/web/components, internal/web/pages\n- bff directory should also move to internal/web/bff\n\nAcceptance criteria:\n- Clean directory structure\n- All imports updated\n- Tests still pass\n- No broken references","status":"completed","priority":"normal","labels":["refactor","structure"],"created_at":"2026-01-31T14:38:14.469128797Z","updated_at":"2026-01-31T21:41:04.12120685Z","completed_at":"2026-01-31T21:41:04.109407281Z"}
155{"id":"01KGA7TZG6AP8FJJTW5GAA60X5","title":"Evaluate merging manage and profile pages","description":"Consider if manage, profile, and brew list should be separate pages.\n\nOptions:\n- Merge manage and profile\n- Merge brew list and manage\n- Keep separate\n\nNeeds design decision before implementation.\n\nAcceptance criteria:\n- Evaluate user flow and UX\n- Document decision rationale\n- Implementation plan if merge is chosen","status":"open","priority":"low","labels":["design","ux","frontend"],"created_at":"2026-01-31T14:38:14.534537889Z","updated_at":"2026-01-31T14:38:14.534537889Z"}
1616-{"id":"01KGA7TZJDMR7DWT1M0MAKP6NR","title":"Standardize edit/delete buttons on profile tables","description":"Profile tables are inconsistent - some have edit/delete buttons, others don't.\n\nAlso consider adding buttons below each table for that record type.\n\nAcceptance criteria:\n- All tables have consistent button placement\n- Either all have edit/delete or none do\n- Add buttons below tables for adding new records\n- Consistent UX across all tables","status":"completed","priority":"normal","labels":["frontend","ux","consistency"],"created_at":"2026-01-31T14:38:14.605304682Z","updated_at":"2026-01-31T22:23:51.571545748Z","completed_at":"2026-01-31T22:23:51.559620864Z"}
1717-{"id":"01KGA7TZMPFGZH6R6ZMASAD2GE","title":"Design nested modal system for entity creation","description":"Enable creating related entities from within modals (e.g., create roaster from within bean modal).\n\nDesign idea:\n- Transition that moves first modal left\n- Opens second modal to the right\n- Smooth nested flow\n\nAcceptance criteria:\n- Design nested modal UX\n- Smooth transitions\n- Good visual hierarchy\n- Can return to parent modal\n- Data flows correctly between modals","status":"blocked","priority":"low","blocked_by":["01KGA7S43ENCHDZQ9DXPGEA6W1"],"labels":["design","ux","modals","future"],"created_at":"2026-01-31T14:38:14.678025134Z","updated_at":"2026-01-31T14:38:59.768553798Z"}
1818-{"id":"01KGA7TZPQPY2JC3HFPVPKHNBQ","title":"Refactor forms to use form components","description":"Form components in forms.templ are defined but rarely used (only in brew_form.templ).\nModals and other forms use inline HTML instead.\n\nComponents available:\n- TextInput\n- NumberInput\n- TextArea\n- Select\n- FormField\n\nAcceptance criteria:\n- All forms use form components\n- Remove inline form HTML\n- Consistent form styling\n- Better maintainability","status":"completed","priority":"normal","assignee":"patrick","change_id":"rlzoowttmqxrqwlmpkottlurxzsxslyu","labels":["refactor","frontend","forms"],"created_at":"2026-01-31T14:38:14.743183429Z","updated_at":"2026-01-31T14:58:58.965241694Z","completed_at":"2026-01-31T14:58:58.924457633Z"}
1919-{"id":"01KGA7TZRHNG2AVHBT91NJY4KD","title":"Replace inline card classes with Card component","description":"card.templ defines Card component but many places use inline card classes instead.\n\nAcceptance criteria:\n- All card-style elements use Card component\n- Remove inline card class usage\n- Consistent card styling\n- Component-based approach","status":"completed","priority":"low","assignee":"patrick","change_id":"yokzvvstynposzwsznxpqptwstqrxzqu","labels":["refactor","frontend","components"],"created_at":"2026-01-31T14:38:14.801428078Z","updated_at":"2026-02-01T00:25:45.227470232Z","completed_at":"2026-02-01T00:25:45.186028348Z"}
66+{"id":"01KGA7TZMPFGZH6R6ZMASAD2GE","title":"Design nested modal system for entity creation","description":"Enable creating related entities from within modals (e.g., create roaster from within bean modal).\n\nDesign idea:\n- Transition that moves first modal left\n- Opens second modal to the right\n- Smooth nested flow\n\nAcceptance criteria:\n- Design nested modal UX\n- Smooth transitions\n- Good visual hierarchy\n- Can return to parent modal\n- Data flows correctly between modals","status":"open","priority":"low","labels":["design","ux","modals","future"],"created_at":"2026-01-31T14:38:14.678025134Z","updated_at":"2026-02-15T16:06:00.000000000Z"}
207{"id":"01KGA7TZTT05WWENTHR41X4RQH","title":"Replace inline buttons with button components","description":"buttons.templ defines PrimaryButton/SecondaryButton but they're rarely used.\nMost places use inline button classes.\n\nAcceptance criteria:\n- All buttons use button components\n- Remove inline button class usage\n- Consistent button styling\n- Component-based approach","status":"open","priority":"low","labels":["refactor","frontend","components"],"created_at":"2026-01-31T14:38:14.874329448Z","updated_at":"2026-01-31T14:38:14.874329448Z"}
2121-{"id":"01KGA7VXDTVY5W11QJ1VPZ5M3Y","title":"Implement settings menu","description":"Create settings menu with the following features:\n\n1. Private mode - Don't show in community feed (records still public via PDS API)\n2. Dev mode - Show DID, copy DID in profiles (remove 'logged in as \u003cdid\u003e' from home)\n3. Toggle for table view vs future post-style view\n\nAcceptance criteria:\n- Settings page/modal created\n- All three settings implemented\n- Settings persisted per user\n- UI updated based on settings","status":"blocked","priority":"normal","blocked_by":["01KGA7VXFQABCAQV83A6C49WEY"],"labels":["feature","frontend","settings"],"created_at":"2026-01-31T14:38:45.178941765Z","updated_at":"2026-01-31T14:39:00.016324424Z"}
88+{"id":"01KGA7VXDTVY5W11QJ1VPZ5M3Y","title":"Implement settings menu","description":"Create settings menu with the following features:\n\n1. Private mode - Don't show in community feed (records still public via PDS API)\n2. Dev mode - Show DID, copy DID in profiles (remove 'logged in as <did>' from home)\n3. Toggle for table view vs future post-style view\n\nAcceptance criteria:\n- Settings page/modal created\n- All three settings implemented\n- Settings persisted per user\n- UI updated based on settings","status":"blocked","priority":"normal","blocked_by":["01KGA7VXFQABCAQV83A6C49WEY"],"labels":["feature","frontend","settings"],"created_at":"2026-01-31T14:38:45.178941765Z","updated_at":"2026-01-31T14:39:00.016324424Z"}
229{"id":"01KGA7VXFQABCAQV83A6C49WEY","title":"Design post-style record view (mobile-friendly)","description":"LARGE FEATURE: Complete record styling refactor from table-style to mobile-friendly post-style.\n\nSimilar to Bluesky posts format.\nShould include setting to use legacy table view.\n\nThis is a major redesign - to be done later down the line.\n\nAcceptance criteria:\n- Design post-style layout\n- Mobile-friendly and responsive\n- Legacy table view option\n- Smooth migration path\n- Better UX on mobile devices","status":"open","priority":"low","labels":["feature","design","frontend","mobile","future"],"created_at":"2026-01-31T14:38:45.23927703Z","updated_at":"2026-01-31T14:38:45.23927703Z"}
2310{"id":"01KGA7VXHR31MSHEKH91C41TYM","title":"Add loading progress bars to page navigation","description":"Consider adding loading bars to page loads (above header perhaps).\n\nSeparate nicer/prettier loading bar would also be nice on brews page.\n\nAcceptance criteria:\n- Loading bar visible during page navigation\n- Positioned appropriately (above header)\n- Smooth animations\n- Better perceived performance","status":"open","priority":"low","labels":["feature","frontend","ux"],"created_at":"2026-01-31T14:38:45.304772299Z","updated_at":"2026-01-31T14:38:45.304772299Z"}
2411{"id":"01KGA7VXM0F3A1R10BC3V29A0Y","title":"Verify context flows through all methods","description":"From CLAUDE.md Known Issues: Context should flow through methods (some fixed, verify all paths).\n\nAudit codebase to ensure context.Context is properly passed through all method chains.\n\nAcceptance criteria:\n- All methods accept and use context\n- No context.Background() in handlers\n- Context cancellation works correctly\n- Request timeouts respected","status":"open","priority":"high","labels":["backend","refactor","correctness"],"created_at":"2026-01-31T14:38:45.376033105Z","updated_at":"2026-01-31T14:38:45.376033105Z"}
···2613{"id":"01KGA7VXR4EVGR470P4M9JH80C","title":"Add CID validation on record updates","description":"From CLAUDE.md Known Issues: Missing CID validation on record updates (AT Protocol best practice).\n\nImplement CID (Content Identifier) validation when updating records to ensure record hasn't changed.\n\nAcceptance criteria:\n- CID validation on all record updates\n- Proper error handling for CID mismatches\n- Follows AT Protocol best practices\n- Tests covering CID validation","status":"open","priority":"normal","labels":["backend","atproto","validation"],"created_at":"2026-01-31T14:38:45.508526626Z","updated_at":"2026-01-31T14:38:45.508526626Z"}
2714{"id":"01KGA7VXT4S4VGYZ2FDE1RR2XX","title":"Implement rate limiting for PDS calls","description":"From CLAUDE.md Known Issues: Rate limiting for PDS calls not implemented.\n\nAdd rate limiting to prevent excessive API calls to PDS servers.\n\nAcceptance criteria:\n- Rate limiting implemented for PDS API calls\n- Configurable limits\n- Proper error handling when rate limited\n- Backoff strategy\n- Monitoring/logging of rate limit hits","status":"open","priority":"high","labels":["backend","performance","atproto"],"created_at":"2026-01-31T14:38:45.572884867Z","updated_at":"2026-01-31T14:38:45.572884867Z"}
2815{"id":"01KGA7VXVZQBMZX6RVG3B49TYW","title":"Consider migration from BoltDB to SQLite","description":"Far future consideration: Maybe swap from BoltDB to SQLite using non-cgo library.\n\nThis is exploratory - need to evaluate:\n- Benefits of SQLite vs BoltDB\n- Migration effort\n- Performance impact\n- Query capabilities\n\nAcceptance criteria:\n- Evaluate pros/cons\n- Document decision\n- If proceeding: migration plan and implementation","status":"claimed","priority":"low","assignee":"patrick","labels":["backend","database","future","evaluation"],"created_at":"2026-01-31T14:38:45.631852717Z","updated_at":"2026-01-31T15:31:07.948160371Z"}
2929-{"id":"01KGACBM7JBN0G9R5PYXEPCFXS","title":"Fix manage page table refresh after modal operations","description":"The manage page tables don't refresh when entities are created, updated, or deleted via modals. The entity managers and entity-helpers already try to trigger refreshes but the HTMX element only responds to initial page load.","status":"completed","priority":"normal","assignee":"patrick","created_at":"2026-01-31T15:57:14.354187839Z","updated_at":"2026-01-31T16:08:49.974241439Z","completed_at":"2026-01-31T15:58:50.307512595Z","notes":[{"timestamp":"2026-01-31T15:57:40.913521034Z","author":"patrick","message":"## Problem\nThe entity managers (in manage-page.js) and entity-helpers.js already try to trigger refreshes by calling:\n htmx.trigger(manageLoader, 'load')\n\nHowever, the HTMX element in manage.templ (line 64) has:\n hx-trigger=\"load\"\n\nThis only responds to the initial page load event, not subsequent programmatic 'load' triggers.\n\n## Requirements\n1. Modify the HTMX trigger in manage.templ to respond to a custom refresh event\n2. Update manage-page.js entity manager onSuccess callbacks to dispatch the custom event\n3. Update entity-helpers.js refreshEntityDropdown to dispatch the custom event\n4. Ensure both creates and deletes trigger the refresh\n5. Test all CRUD operations for all entity types (beans, roasters, grinders, brewers)\n\n## Implementation Approach\n1. Add custom event listener to hx-trigger (e.g., 'load, refreshManage from:body')\n2. Dispatch 'refreshManage' event instead of/in addition to triggering 'load'\n3. Verify the event bubbles up to trigger the HTMX reload\n\n## Files to Modify\n- internal/web/pages/manage.templ (HTMX trigger)\n- web/static/js/manage-page.js (entity manager onSuccess callbacks)\n- web/static/js/entity-helpers.js (refreshEntityDropdown function)\n\n## Success Criteria\n- Creating an entity via modal immediately shows it in the table\n- Updating an entity via modal immediately shows changes in the table\n- Deleting an entity immediately removes it from the table\n- Works for all four entity types (beans, roasters, grinders, brewers)"},{"timestamp":"2026-01-31T15:58:47.084907175Z","author":"patrick","message":"## Implementation Complete\n\nModified three files to fix table refresh:\n\n1. **internal/web/pages/manage.templ**\n - Updated hx-trigger to: 'load, refreshManage from:body'\n - Now responds to both initial page load AND custom refresh events\n\n2. **web/static/js/manage-page.js**\n - Updated all 4 entity manager onSuccess callbacks\n - Now dispatches 'refreshManage' CustomEvent on document.body\n\n3. **web/static/js/entity-helpers.js**\n - Updated refreshEntityDropdown function\n - Dispatches 'refreshManage' event when on manage page\n\nThe custom event approach is cleaner than triggering 'load' programmatically and ensures proper event bubbling for HTMX to catch."},{"timestamp":"2026-01-31T16:00:19.33430635Z","author":"patrick","message":"## Bug Fix: Delete not refreshing table\n\nFound that the delete() method in entity-manager.js wasn't calling the onSuccess callback, while save() was.\n\n**Modified:** web/static/js/entity-manager.js (lines 154-162)\n- Added onSuccess callback invocation after cache invalidation in delete method\n- Now both save() and delete() trigger the refreshManage event\n- Delete operations will now properly refresh the manage page tables"},{"timestamp":"2026-01-31T16:02:50.833338786Z","author":"patrick","message":"## Additional fix for delete refresh\n\nThe onSuccess callback approach wasn't sufficient for deletes. Added explicit event dispatching:\n\n**Modified files:**\n1. web/static/js/entity-manager.js\n - delete() method now returns boolean (true if successful, false if cancelled/failed)\n \n2. web/static/js/manage-page.js \n - All delete methods now check return value and dispatch refreshManage only on success\n - Prevents refresh if user cancels the confirmation dialog\n\n**Flow:**\n1. User clicks Delete → Alpine.js calls deleteBean/deleteRoaster/etc\n2. Entity manager shows confirmation dialog\n3. If cancelled: return false, no refresh\n4. If confirmed: DELETE request → return true\n5. manage-page.js receives true → dispatches refreshManage event\n6. HTMX hears event → reloads table\n\nThis is more reliable than relying solely on the onSuccess callback."},{"timestamp":"2026-01-31T16:08:49.961660711Z","author":"patrick","message":"## SOLUTION: Converted delete to use HTMX instead of Alpine.js\n\nThe root issue was mixing patterns - Edit used HTMX but Delete used Alpine.js + JavaScript fetch. This created inconsistency and timing issues.\n\n**Changed:** internal/web/components/manage_partial.templ\n- All delete buttons now use HTMX attributes instead of @click\n- hx-delete=\"/api/beans/{rkey}\" - sends DELETE request\n- hx-confirm=\"Are you sure...\" - built-in confirmation dialog\n- hx-target=\"closest tr\" - targets the parent table row\n- hx-swap=\"outerHTML swap:0.5s\" - removes row with fade animation\n\n**How it works:**\n1. User clicks Delete\n2. HTMX shows confirmation dialog\n3. If confirmed, sends DELETE to /api/beans/{rkey}\n4. Server deletes and returns 200 OK (empty body)\n5. HTMX swaps the table row's outerHTML with empty response\n6. Row instantly vanishes with 0.5s fade effect\n\n**Benefits:**\n- Immediate visual feedback (no full table reload needed)\n- Consistent with Edit button pattern\n- Simpler than JavaScript approach\n- Row-level granularity (only removes what was deleted)\n- No Alpine.js/HTMX coordination issues\n\n**Note:** The delete methods in manage-page.js are now unused and could be cleaned up in a future refactor."}]}
3030-{"id":"01KGAEB648HERFCPSKW446H8SE","title":"Add closed/open status for coffee bean bags","description":"Add a boolean 'closed' field to the bean lexicon. Update UI to:\n1. Filter closed bags from brew dropdown\n2. Show open bags table at top of profile page\n3. Show closed bags table below roasters on profile page\n4. Show all bags in manage page with newest first, adding an 'open' column\n\nChanges needed:\n- Update lexicons/social.arabica.alpha.bean.json\n- Update internal/atproto/records.go for Bean model\n- Update internal/models/models.go\n- Update brew form dropdown to filter closed beans\n- Update profile page layout with separate tables\n- Update manage page table with open/closed column","status":"completed","priority":"high","assignee":"patrick","created_at":"2026-01-31T16:31:57.0649568Z","updated_at":"2026-01-31T16:55:24.596373389Z","completed_at":"2026-01-31T16:43:26.855808963Z","notes":[{"timestamp":"2026-01-31T16:43:26.748989495Z","author":"patrick","message":"Successfully implemented closed bag feature:\n- Added 'closed' boolean field to bean lexicon\n- Updated Bean model and request structs\n- Updated record conversion functions\n- Filtered closed bags from brew form dropdown\n- Split beans table on profile page into open bags (top) and closed bags (below roasters)\n- Added Status column to manage page beans table with Open/Closed badges\n- Added checkbox to bean form modals for marking bags as closed\n- Updated form handlers to parse closed field\nAll changes tested and verified to compile successfully."},{"timestamp":"2026-01-31T16:50:21.14203731Z","author":"patrick","message":"Fixed bean editing/closing issue:\n- Removed method='dialog' from all entity form dialogs (bean, roaster, grinder, brewer)\n- Added HTMX event handler to close dialog after successful submission\n- Added 'closed: false' to beanManager defaultFormData in manage-page.js\n- Updated editBean() to accept and pass closed parameter\n- Forms now properly submit via HTMX before closing\n- The 'closed' checkbox value is now correctly saved to PDS"},{"timestamp":"2026-01-31T16:55:24.581637936Z","author":"patrick","message":"Additional debugging and fixes:\n- Added explicit hx-trigger='submit' to all dialog forms to ensure HTMX intercepts form submission\n- Changed Alpine.js @ syntax to hx-on:: syntax for HTMX event handlers for better compatibility\n- Added explicit value='true' to closed checkbox for clearer form data\n- Updated handlers to check for 'true' instead of 'on' for closed field\n- Added debug logging to show what values are being received in handlers\n\nPlease test again and check server logs for debug output showing the closed_value and closed_parsed fields."}]}
3131-{"id":"01KGAW0BCP3ZN415GBW40JFYST","title":"Use consistent default profile picture across profile and feed pages","description":"The default avatar fallback on the profile page uses bg-brown-300 (lighter brown) for large size, while the feed uses bg-brown-600 (darker brown) for medium size. This creates visual inconsistency. Update the Avatar component to use the same background color (bg-brown-300) for all sizes to maintain consistency across the app.","status":"completed","priority":"normal","assignee":"patrick","created_at":"2026-01-31T20:30:42.070239386Z","updated_at":"2026-01-31T20:32:38.61578807Z","completed_at":"2026-01-31T20:32:38.60500932Z"}
3232-{"id":"01KGAW9PRR6X6WHKCZVKY2KQBH","title":"Add hover effect to profile avatars","description":"Add visual hover effects to profile avatars throughout the app (feed, header, profile pages). The effect should provide visual feedback when users hover over clickable profile pictures. Consider adding a subtle scale transform, glow effect, or ring color change to indicate interactivity.","status":"completed","priority":"normal","assignee":"patrick","created_at":"2026-01-31T20:35:48.632605618Z","updated_at":"2026-01-31T21:13:34.89255148Z","completed_at":"2026-01-31T20:37:03.856961732Z","notes":[{"timestamp":"2026-01-31T20:37:00.955611391Z","author":"patrick","message":"## Hover Effects Added\n\nUpdated the Avatar component in internal/web/components/shared.templ to add interactive hover effects:\n\n**For image avatars (all sizes):**\n- transition-all duration-200 - Smooth transitions for all properties\n- hover:ring-amber-400 - Ring color changes to amber on hover\n- hover:ring-4 - Ring grows thicker on hover (from ring-2 to ring-4)\n- hover:scale-110 (sm/md) - Avatar scales to 110% on hover\n- hover:scale-105 (lg) - Large avatars scale to 105% (subtler)\n\n**For fallback avatars (initials):**\n- Same transition and ring effects for sm/md sizes\n- hover:bg-brown-400 for large size (background darkens slightly)\n- hover:scale-110 (sm/md) and hover:scale-105 (lg)\n\n**Also fixed:**\n- Updated fallback avatar ring color from ring-brown-600 to ring-brown-500 for consistency with image avatars\n\n**Effect:**\n- Avatars in the feed, header, and profile pages now have smooth, visually appealing hover effects\n- The amber ring glow provides clear visual feedback that the avatar is clickable\n- Scale transform adds a subtle \"lift\" effect that feels responsive\n- 200ms duration provides smooth transitions without lag"},{"timestamp":"2026-01-31T21:13:34.87780839Z","author":"patrick","message":"## Final Implementation\n\nUpdated avatar hover effects based on feedback:\n\n**Changes:**\n1. Changed hover ring color from amber-400 (yellow) to brown-400 (more brown tone)\n2. Removed all scale transforms - avatars stay the same size on hover\n3. Only the ring/border changes color and thickness\n\n**Hover Effects (all sizes):**\n- transition-all duration-200 - Smooth 200ms transitions\n- hover:ring-brown-400 (sm/md) - Ring color changes to lighter brown\n- hover:border-brown-400 (lg) - Border color changes to lighter brown \n- hover:ring-4 / hover:border-4 - Ring/border grows from 2px to 4px thickness\n- hover:bg-brown-400 (lg fallback only) - Background darkens slightly\n\n**Result:**\n- Subtle, elegant hover effect that doesn't distort the avatar\n- Brown color palette stays consistent with the app theme\n- Clear visual feedback that avatars are clickable\n- Works across feed, header, and profile pages"}]}
3333-{"id":"01KGAYXQVTNKFSNEBYZHV0XVPG","title":"Remove unused Go functions in atproto package","description":"Several exported functions in the atproto package are completely unused:\n\n1. ResolveBeanRef() - internal/atproto/resolver.go:78\n - Wrapper for resolveRef() that's never called\n - ResolveBeanRefWithRoaster() is used instead\n\n2. GetSessionDataFromContext() - internal/atproto/oauth.go:203\n - Leftover from refactoring, never called\n\n3. RequireAuth() - internal/atproto/oauth.go:212\n - Alternative auth middleware that's never registered\n\n4. Commented-out fetchAllData() - internal/handlers/handlers.go:1530-1564\n - Replaced by inline errgroup usage\n\nAdditionally verify if these are truly unused:\n- DeleteAuthRequestInfo() in boltstore/session_store.go\n- DeleteAllSessionsForDID() in boltstore/session_store.go\n\nAcceptance criteria:\n- Remove all confirmed dead code\n- Verify no external usage exists\n- Run tests to confirm nothing breaks","status":"completed","priority":"high","created_at":"2026-01-31T21:21:42.26644177Z","updated_at":"2026-01-31T21:28:01.229416423Z","completed_at":"2026-01-31T21:28:01.218546904Z","notes":[{"timestamp":"2026-01-31T21:27:57.208998517Z","author":"patrick","message":"Successfully removed unused Go functions:\n\nREMOVED:\n1. ResolveBeanRef() from internal/atproto/resolver.go:78 - Confirmed unused, only ResolveBeanRefWithRoaster() is used\n2. GetSessionDataFromContext() from internal/atproto/oauth.go:203 - Confirmed unused\n3. RequireAuth() from internal/atproto/oauth.go:212 - Confirmed unused middleware\n4. DeleteAllSessionsForDID() from internal/database/boltstore/session_store.go:168 - Confirmed unused\n\nRETAINED:\n- DeleteAuthRequestInfo() in boltstore/session_store.go - Required by oauth.ClientAuthStore interface, cannot be removed\n\nNOT FOUND:\n- Commented-out fetchAllData() in handlers.go - Already removed in previous cleanup\n\nVERIFICATION:\n- Searched codebase to confirm no usage of removed functions\n- go build completes without errors\n- All tests pass (go test ./...)"}]}
3434-{"id":"01KGAYXQY6N1RPV042XF9907N6","title":"Refactor entity CRUD handlers to reduce duplication","description":"The handlers.go file contains significant code duplication across entity CRUD operations:\n\n**Create handlers (4 nearly identical):**\n- HandleBeanCreate (lines 897-960)\n- HandleRoasterCreate (lines 963-1011)\n- HandleGrinderCreate (lines 1223-1272)\n- HandleBrewerCreate (lines 1359-1407)\n\n**Update handlers (4 nearly identical):**\n- HandleBeanUpdate (lines 1039-1114)\n- HandleRoasterUpdate (lines 1139-1198)\n- HandleGrinderUpdate (lines 1274-1334)\n- HandleBrewerUpdate (lines 1409-1468)\n\nEach follows the exact same pattern:\n1. Check authentication\n2. Parse Content-Type (JSON vs form)\n3. Decode request body\n4. Validate request\n5. Call store method\n6. Encode JSON response\n\n**Proposed refactoring:**\nCreate a generic handler factory or helper that accepts:\n- Request type struct\n- Entity type/NSID\n- Form field mapping\n- Store method reference\n\nThis could reduce 500+ lines of repetitive code.\n\nAcceptance criteria:\n- Extract common logic into reusable functions\n- Reduce total lines by at least 300\n- Maintain all existing functionality\n- All tests pass","status":"completed","priority":"normal","created_at":"2026-01-31T21:21:42.342615886Z","updated_at":"2026-01-31T21:28:10.769242471Z","completed_at":"2026-01-31T21:28:10.756429903Z","notes":[{"timestamp":"2026-01-31T21:28:08.516192437Z","author":"patrick","message":"Refactoring completed successfully.\n\nChanges made:\n1. Added 3 new helper functions:\n - isJSONRequest() - Checks if request Content-Type is JSON\n - decodeRequest() - Generic decoder for JSON or form data\n - writeJSON() - Standardized JSON response encoding\n\n2. Refactored 8 entity CRUD handlers to use helpers:\n - HandleBeanCreate, HandleBeanUpdate\n - HandleRoasterCreate, HandleRoasterUpdate\n - HandleGrinderCreate, HandleGrinderUpdate\n - HandleBrewerCreate, HandleBrewerUpdate\n\nResults:\n- Reduced file from 2239 to 2185 lines (54 lines removed)\n- Eliminated duplication of Content-Type parsing logic (8 occurrences)\n- Eliminated duplication of form parsing logic (8 occurrences)\n- Eliminated duplication of JSON encoding logic (8 occurrences)\n- All handlers now follow consistent patterns\n- All tests pass (15 test cases, 100% pass rate)\n- Code successfully compiles\n\nPattern established:\nEach handler now follows a clean, consistent flow:\n1. Authenticate via getAtprotoStore()\n2. Decode via decodeRequest() helper\n3. Validate request\n4. Execute store operation\n5. Encode response via writeJSON() helper\n\nThe refactoring makes it much easier to add new entity types in the future by following the established pattern."}]}
3535-{"id":"01KGAYXR0JP80F5KCQG26VHF5B","title":"Remove deprecated modal component files","description":"Several modal component files are marked as deprecated and should be removed:\n\n**Files to remove:**\n1. internal/web/components/modal.templ\n - Contains Alpine.js x-show based Modal components\n - Comment: 'This file is kept only for backwards compatibility and will be removed in a future cleanup'\n\n2. internal/web/components/modal_templ.go (generated)\n\n3. internal/web/components/entity_modals.templ\n - Contains deprecated Alpine.js modals for Bean, Grinder, Brewer, Roaster\n - Superseded by dialog_modals.templ using native HTML5 \u003cdialog\u003e\n\n4. internal/web/components/entity_modals_templ.go (generated)\n\n**Also update:**\n- internal/web/pages/components_test.go has test 'Modal with static title' that tests deprecated component\n\n**Current replacement:**\n- dialog_modals.templ uses native HTML5 \u003cdialog\u003e elements\n\nAcceptance criteria:\n- Remove all deprecated modal files\n- Update or remove any tests using deprecated components\n- Verify no imports reference removed files\n- App still functions correctly","status":"completed","priority":"normal","created_at":"2026-01-31T21:21:42.418646862Z","updated_at":"2026-01-31T21:26:34.970210414Z","completed_at":"2026-01-31T21:26:34.955317746Z","notes":[{"timestamp":"2026-01-31T21:26:34.938735417Z","author":"patrick","message":"All deprecated modal component files have been successfully removed:\n\nFiles removed:\n- internal/web/components/modal.templ\n- internal/web/components/modal_templ.go\n- internal/web/components/entity_modals.templ\n- internal/web/components/entity_modals_templ.go\n\nTest updates:\n- Removed TestModalComponents from internal/web/pages/components_test.go\n\nVerification:\n- No code references to removed components remain\n- All web page tests pass\n- The app uses dialog_modals.templ for all modal functionality"}]}
3636-{"id":"01KGAYXR359TWZPS2AJF9ZB4S5","title":"Extract profile data fetching into reusable function","description":"Two handlers contain nearly identical ~100-line blocks for fetching user profile data:\n\n**Duplicated in:**\n1. HandleProfile (lines 1628-1733)\n2. HandleProfilePartial (lines 1875-1980)\n\nBoth do:\n- Create errgroup context\n- Fetch beans, roasters, grinders, brewers, brews in parallel\n- Parse records and build lookup maps\n- Resolve references between entities\n\n**Proposed refactoring:**\nExtract into a single helper function:\n\nfunc (h *Handler) fetchUserData(ctx context.Context, did string, publicClient *atproto.PublicClient) (*UserDataBundle, error)\n\nThis function would return a struct containing:\n- Beans (with roasters resolved)\n- Roasters\n- Grinders\n- Brewers\n- Brews (with all refs resolved)\n\nAcceptance criteria:\n- Create fetchUserData helper function\n- Refactor both handlers to use it\n- Reduce duplicated code by ~80-100 lines\n- All profile functionality works as before","status":"completed","priority":"low","created_at":"2026-01-31T21:21:42.501437644Z","updated_at":"2026-01-31T21:32:30.483213969Z","completed_at":"2026-01-31T21:32:30.468389045Z"}
3737-{"id":"01KGB1KDNNZZ7ZDA5NCK0T4E1G","title":"Create shared entity table components for profile and manage pages","description":"## Summary\nRefactor the profile and manage page tables to use shared components, reducing duplication and improving maintainability.\n\n## Current State\n- `manage_partial.templ` has tables for beans, roasters, grinders, brewers with Edit/Delete actions\n- `profile_partial.templ` has similar tables but without actions\n- Beans on profile are split into \"Open Bags\" and \"Closed Bags\" sections\n- Significant HTML/structure duplication between the two files\n\n## Proposed Solution\nCreate shared table components with configurable props:\n\n### Shared Components to Create\n1. **BeansTable** - shared beans table component\n2. **RoastersTable** - shared roasters table component \n3. **GrindersTable** - shared grinders table component\n4. **BrewersTable** - shared brewers table component\n\n### Props Pattern\nEach component should accept props that control behavior:\n\n```go\ntype EntityTableProps struct {\n Items []*models.Entity\n ShowActions bool // Whether to render Edit/Delete column\n // Other entity-specific options as needed\n}\n```\n\n### Beans Table Special Handling\nThe beans table has unique requirements:\n- Manage page: single table with Status column, shows all beans\n- Profile page: split into Open/Closed sections, no Status column\n\nOptions to handle this:\n1. **Single component with `ShowStatus` prop** - manage uses it, profile calls it twice with filtered lists\n2. **`GroupByStatus` prop** - component internally groups and renders sections\n3. Keep beans separate if complexity doesn't warrant sharing\n\nRecommend option 1 - simpler, profile already filters beans via `filterOpenBeans`/`filterClosedBeans`.\n\n### Actions Implementation\nWhen `ShowActions: true`:\n- Render action column header\n- Render Edit button with `hx-get=\"/api/modals/{entity}/{rkey}\"`\n- Render Delete button with `hx-delete=\"/api/{entities}/{rkey}\"`\n\n### Files to Modify\n- `internal/web/components/manage_partial.templ` - use shared components\n- `internal/web/components/profile_partial.templ` - use shared components\n- `internal/web/components/entity_tables.templ` (new) - shared table components\n\n### Acceptance Criteria\n- [ ] Shared table components created in `entity_tables.templ`\n- [ ] `manage_partial.templ` refactored to use shared components\n- [ ] `profile_partial.templ` refactored to use shared components\n- [ ] Actions can be enabled/disabled via props\n- [ ] No visual regressions on manage page\n- [ ] No visual regressions on profile page\n- [ ] `templ generate` runs successfully\n- [ ] Tests pass (if any)","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend","refactoring"],"created_at":"2026-01-31T22:08:29.877786981Z","updated_at":"2026-01-31T22:16:42.669635082Z","completed_at":"2026-01-31T22:16:42.657711384Z"}
3838-{"id":"01KGB2WBMP539SFSKZCKJAYH0C","title":"Prevent line wrap between emoji and text in table headers","description":"Add whitespace-nowrap to table header cells to prevent the emoji and column name from wrapping onto separate lines.","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend"],"created_at":"2026-01-31T22:30:51.286845962Z","updated_at":"2026-01-31T22:31:23.380048647Z","completed_at":"2026-01-31T22:31:23.369060496Z"}
3939-{"id":"01KGBF5Q2KJ8PZNSMJPFFF31C8","title":"Add AT Protocol explanation page","description":"Create a page explaining what the AT Protocol is that the footer link redirects to instead of atproto.com.\n\nAcceptance criteria:\n- New /atproto page explaining the AT Protocol\n- Footer AT Protocol link redirects to /atproto instead of external site\n- Page matches site design system\n- Explains what AT Protocol is and how Arabica uses it\n- Links to atproto.com for those who want more info","status":"cancelled","priority":"normal","assignee":"patrick","created_at":"2026-02-01T02:05:40.819263564Z","updated_at":"2026-02-06T01:44:49.962642705Z","notes":[{"timestamp":"2026-02-01T02:10:19.279321594Z","author":"patrick","message":"Initial implementation complete:\n- Created /atproto page with AT Protocol explanation\n- Updated footer link to point to /atproto instead of external site\n- Added route and handler\n- Page covers: PDS, DIDs, Lexicons, AT-URIs, how Arabica uses ATProto\n- Includes links to atproto.com for more info\nReady for review and final polish"}]}
4040-{"id":"01KGBM1YCGZRNTVCJDZFV3100R","title":"Move web/static to static directory","description":"Move web/static directory to top-level static directory and update all references across the codebase.\n\nFiles to update:\n- flake.nix (tailwindcss paths)\n- .gitignore (output.css path)\n- justfile (tailwindcss paths)\n- default.nix (tailwindcss paths)\n- .gitattributes (vendored JS paths)\n- internal/routing/routing.go (file server path)\n- deploy/Dockerfile (COPY command)\n- CLAUDE.md (documentation)\n\nAcceptance criteria:\n- web/static moved to static/\n- All references updated\n- Server still serves static files correctly\n- Tailwind CSS build still works","status":"completed","priority":"normal","assignee":"patrick","created_at":"2026-02-01T03:31:00.112467261Z","updated_at":"2026-02-01T03:32:59.754877212Z","completed_at":"2026-02-01T03:32:59.739794374Z"}
4141-{"id":"01KGDXVE566WVDG8TC9DWPSYXF","title":"Implement likes for social interactions","description":"Add a like lexicon and implement like functionality across the app.\n\n## Lexicon: social.arabica.alpha.like\n\nThe like record should:\n- Use `com.atproto.repo.strongRef` for the subject (URI + CID for immutability)\n- Support liking any arabica.social lexicon type (beans, roasters, grinders, brewers, brews, and comments once implemented)\n- Include createdAt timestamp\n- Use TID as record key\n\n## Backend Implementation\n\n1. Create lexicon file: `lexicons/social.arabica.alpha.like.json`\n2. Add NSID constant in `internal/atproto/nsid.go`\n3. Add Like model in `internal/models/models.go`\n4. Add record conversion functions in `internal/atproto/records.go`\n5. Add Store interface methods: CreateLike, DeleteLike, GetLikesForSubject, GetUserLikes\n6. Implement in AtprotoStore\n7. Update firehose indexing to track likes\n\n## Frontend Implementation\n\n1. Add like button component\n2. Show like counts on brews/beans/etc in feed\n3. Update like counts in response to firehose events (real-time updates)\n4. Toggle like state based on current user's likes\n\n## Acceptance Criteria\n\n- Users can like any arabica.social record\n- Likes are stored in the user's PDS (actor-owned data)\n- Like counts display on records in the feed\n- Real-time like count updates via firehose\n- Optimistic UI updates for likes","status":"cancelled","priority":"high","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:41.510646368Z","updated_at":"2026-02-06T01:45:10.223739659Z"}
4216{"id":"01KGDXVR86CB2H44DJHKN4Z9M0","title":"Implement comments for social interactions","description":"Add a comment lexicon and implement comment functionality across the app.\n\n## Lexicon: social.arabica.alpha.comment\n\nThe comment record should:\n- Use `com.atproto.repo.strongRef` for the subject (URI + CID for immutability)\n- Support commenting on any arabica.social lexicon type (beans, roasters, grinders, brewers, brews, and other comments)\n- Include text field (max 1000 chars / 300 graphemes as per AT Protocol conventions)\n- Include createdAt timestamp\n- Use TID as record key\n- NOTE: This cell implements flat comments only. Threaded/nested comments are handled in a separate cell.\n\n## Backend Implementation\n\n1. Create lexicon file: `lexicons/social.arabica.alpha.comment.json`\n2. Add NSID constant in `internal/atproto/nsid.go`\n3. Add Comment model in `internal/models/models.go`\n4. Add record conversion functions in `internal/atproto/records.go`\n5. Add Store interface methods: CreateComment, DeleteComment, GetCommentsForSubject, GetUserComments\n6. Implement in AtprotoStore\n7. Update firehose indexing to track comments\n\n## Frontend Implementation\n\n1. Add comment section component below brew detail view\n2. Add comment form (authenticated users only)\n3. Display comment count on records in feed\n4. Update comment counts in response to firehose events\n5. Show commenter profile info (avatar, handle)\n\n## Acceptance Criteria\n\n- Users can comment on any arabica.social record\n- Comments are stored in the user's PDS (actor-owned data)\n- Comment counts display on records in the feed\n- Comments visible on record detail views\n- Real-time comment count updates via firehose","status":"open","priority":"normal","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:51.846427126Z","updated_at":"2026-02-02T01:00:51.846427126Z"}
4317{"id":"01KGDXW31PHFMBE3WTV83HPET1","title":"Implement comment threading","description":"Add support for threaded/nested comments, building on the flat comment system.\n\n## Lexicon Changes\n\nUpdate `social.arabica.alpha.comment` to add optional threading fields:\n- `parent`: Optional strongRef to parent comment (for replies)\n- `root`: Optional strongRef to root subject (maintains context when replying to comments)\n\nAlternatively, consider a separate reply field or keeping comments flat with UI-level threading.\n\n## Backend Implementation\n\n1. Update comment model to include parent/root references\n2. Add methods to fetch comment threads: GetCommentThread, GetReplies\n3. Update firehose indexing to track parent-child relationships\n4. Add depth limits for threading (prevent infinite nesting)\n\n## Frontend Implementation\n\n1. Design threaded comment UI (indentation, collapse/expand)\n2. Add 'reply' button on comments\n3. Show reply context when replying\n4. Consider max nesting depth for display (e.g., 3-4 levels)\n5. Mobile-friendly thread navigation\n\n## Design Considerations\n\n- How deep should threading go? (Recommend max 3-4 levels visible, then flatten)\n- How to handle deleted parent comments?\n- Should users be notified when someone replies to their comment?\n- Performance: lazy-load deep threads vs eager-load\n\n## Acceptance Criteria\n\n- Users can reply directly to comments\n- Thread structure is visually clear\n- Threads can be collapsed/expanded\n- Works well on mobile devices\n- Parent context shown when replying","status":"blocked","priority":"low","blocked_by":["01KGDXVR86CB2H44DJHKN4Z9M0"],"labels":["frontend","atproto"],"created_at":"2026-02-02T01:01:02.902692829Z","updated_at":"2026-02-02T01:01:06.361891137Z"}
4444-{"id":"01KGGG8EHG9Y6VQ8PZM9W74584","title":"Refactor lexicon record types to use concrete RecordType","description":"Replace magic strings for lexicon record types with a concrete RecordType type and defined constants.\n\n## Current State\nMagic strings like \"brew\", \"bean\", \"roaster\", \"grinder\", \"brewer\" are used throughout the codebase:\n- `internal/feed/service.go` - FeedItem.RecordType field uses string type\n- `internal/firehose/index.go` - Sets RecordType to string literals\n- `internal/web/pages/feed.templ` - Switch cases on string values\n- `internal/handlers/handlers.go` - writeJSON calls with string type names\n\n## Proposed Changes\n1. Define a `RecordType` type in `internal/atproto/nsid.go` (or a new file)\n2. Create constants: `RecordTypeBrew`, `RecordTypeBean`, `RecordTypeRoaster`, `RecordTypeGrinder`, `RecordTypeBrewer`, `RecordTypeLike`\n3. Update `FeedItem.RecordType` in `feed/service.go` to use the new type\n4. Update all string literal usages to use the constants\n5. Consider adding helper methods (e.g., `RecordType.String()`, `RecordType.DisplayName()`)\n\n## Acceptance Criteria\n- No magic strings for record types remain in Go code\n- Template switch statements updated to use constants\n- Type safety enforced at compile time\n- All tests pass","status":"completed","priority":"normal","labels":["refactoring","tech-debt"],"created_at":"2026-02-03T01:00:51.120049807Z","updated_at":"2026-02-03T01:06:55.448414863Z","completed_at":"2026-02-03T01:06:55.435337779Z"}
4545-{"id":"01KGGHX6R13BR2S5K4WZM79F6K","title":"Design profile brews with social features","description":"## Problem\n\nThe profile page currently displays brews in a table format, but we need to add likes and shares which don't fit ergonomically into table columns. Additionally, the table design doesn't work well on mobile.\n\n## Options to Evaluate\n\n### Option A: Add columns to existing table\n- Add Like count and Share button columns\n- Pros: Minimal change, consistent with current design\n- Cons: Tables already cramped, poor mobile experience, doesn't match feed UX\n\n### Option B: Convert brews to card-based layout (like feed)\n- Reuse/adapt FeedBrewContent component for profile brews\n- Keep tables for equipment/beans (less social, more data-dense)\n- Pros: Better mobile experience, natural fit for social buttons, consistent with feed\n- Cons: More work, different UX between profile and brew list\n\n### Option C: Hybrid responsive approach\n- Cards on mobile, table on desktop\n- Pros: Best of both worlds\n- Cons: More complexity, two layouts to maintain\n\n## Acceptance Criteria\n\n1. Research complete: Document the chosen approach with rationale\n2. Design spec: Mockup or clear description of the new layout\n3. If cards: Define how profile brew cards differ from feed cards (actions, ownership indicators)\n4. Consider: Should beans/equipment also get social features eventually?\n\n## Implementation Subtasks (create as separate cells)\n\nAfter design is approved:\n- [ ] Create ProfileBrewCard component (if card approach)\n- [ ] Update profile brews tab to use new layout \n- [ ] Add like count display to brew entries\n- [ ] Add share button to brew entries\n- [ ] Mobile responsiveness testing\n- [ ] Update any related styles in app.css\n\n## Notes\n\n- Social buttons already exist in components/social_buttons.templ\n- Feed cards provide a good reference: pages/feed.templ\n- Current brew table: components/brew_list_table.templ\n- Profile page: pages/profile.templ","status":"completed","priority":"high","assignee":"patrick","labels":["frontend","design","ux"],"created_at":"2026-02-03T01:29:39.841071728Z","updated_at":"2026-02-03T01:40:12.593195795Z","completed_at":"2026-02-03T01:40:12.57909568Z","notes":[{"timestamp":"2026-02-03T01:39:17.359358581Z","author":"patrick","message":"## Final Design Approved\n\n### Card Structure\n- Reuse feed cards as-is with author header (username/avatar)\n- Content area clickable → navigates to /brews/{id}\n- New action bar below content (outside record div)\n\n### Action Bar Order\n[💬 Comments] [♡ Like] [↗ Share] [⋯ More]\n\n- Comments: placeholder/disabled for future\n- Like/Share: moved from top-right to action bar\n- More menu (shows for ALL users):\n - Edit (owner only)\n - Delete (owner only) \n - Report (all users - placeholder endpoint + TODO)"}]}
4646-{"id":"01KGGJF66PJV9SSKCTGJP4ETWP","title":"Create ActionBar component for feed/profile cards","description":"## Task\n\nCreate a new ActionBar component for use below feed card content.\n\n## Component: ActionBar\n\n**Location:** `internal/web/components/action_bar.templ`\n\n**Props:**\n- SubjectURI string - AT-URI of the record\n- SubjectCID string - CID for strong reference\n- IsLiked bool - current user liked state\n- LikeCount int - number of likes\n- IsOwner bool - whether current user owns this record\n- ViewURL string - URL for the detail view (card click)\n- EditURL string - URL for edit action (owner only)\n- DeleteURL string - URL/handler for delete (owner only)\n- IsAuthenticated bool - whether user is logged in\n\n**Layout:**\n`[💬 Comments] [♡ Like] [↗ Share] [⋯ More]`\n\n- Comments: disabled placeholder icon + '0' count\n- Like: reuse existing like button logic (HTMX toggle)\n- Share: reuse existing share button logic\n- More: Alpine.js dropdown menu with Edit/Delete/Report\n\n**Styling:**\n- Horizontal flex row with gap\n- Icons with counts where applicable\n- More menu as dropdown positioned bottom-right\n\n## Acceptance Criteria\n- [ ] ActionBar component created with all props\n- [ ] Comments placeholder (disabled, shows 0)\n- [ ] Like button functional (HTMX toggle)\n- [ ] Share button functional (Web Share API)\n- [ ] More dropdown with Edit/Delete (owner) and Report (all)\n- [ ] templ generate runs successfully","status":"completed","priority":"high","assignee":"patrick","labels":["frontend","component"],"created_at":"2026-02-03T01:39:29.110143411Z","updated_at":"2026-02-03T01:43:15.859354984Z","completed_at":"2026-02-03T01:43:15.845204627Z"}
4747-{"id":"01KGGJFDK0S6VBVY51HJWDS6CF","title":"Update feed cards to use ActionBar","description":"## Task\n\nUpdate the feed page to use the new ActionBar component instead of inline social buttons.\n\n## Changes to feed.templ\n\n1. Remove social buttons from author row (top-right)\n2. Make content area clickable (link to /brews/{rkey} or appropriate detail URL)\n3. Add ActionBar below the content div\n4. Pass appropriate props (SubjectURI, SubjectCID, like state, ownership, URLs)\n\n## Card Click Behavior\n\n- Wrap .feed-content-box in an anchor tag\n- Link to brew detail: /brews/{rkey}\n- For other record types (beans, equipment): link to appropriate manage section or skip\n\n## Acceptance Criteria\n- [ ] Social buttons removed from author row\n- [ ] Content area clickable for brews\n- [ ] ActionBar rendered below content\n- [ ] Like/Share still functional via ActionBar\n- [ ] More menu shows Edit/Delete for owner, Report for all\n- [ ] templ generate runs successfully\n- [ ] Visual regression check on feed page","status":"completed","priority":"high","assignee":"patrick","labels":["frontend"],"created_at":"2026-02-03T01:39:36.672372228Z","updated_at":"2026-02-03T01:44:45.86254562Z","completed_at":"2026-02-03T01:44:45.851397847Z"}
4848-{"id":"01KGGJFSZ4YDNF6ZE8F9WRT79F","title":"Replace profile brew table with cards","description":"## Task\n\nReplace the table-based brew list on the profile page with card-based layout matching the feed.\n\n## Changes\n\n### profile.templ / profile_partial.templ\n1. Replace BrewListTablePartial with card-based layout\n2. Reuse FeedItem/FeedBrewContent components or create ProfileBrewCard\n3. Include ActionBar with owner actions enabled\n4. Maintain grid/list layout for multiple brews\n\n### Layout Considerations\n- Cards should stack vertically (like feed)\n- Consider max-width for readability\n- Mobile: single column\n- Desktop: could be single column or 2-column grid\n\n### Props to Pass\n- Full brew data\n- Bean/roaster/grinder/brewer references\n- IsOwner: true (it's the user's profile)\n- Like/share state for each brew\n\n## Acceptance Criteria\n- [ ] Brew table replaced with cards on profile\n- [ ] Cards show all brew info (bean, method, variables, notes, rating)\n- [ ] ActionBar with Edit/Delete functional for owner\n- [ ] Cards link to brew detail view\n- [ ] Mobile responsive\n- [ ] templ generate runs successfully","status":"completed","priority":"high","assignee":"patrick","labels":["frontend"],"created_at":"2026-02-03T01:39:49.348915829Z","updated_at":"2026-02-03T01:49:13.11140332Z","completed_at":"2026-02-03T01:49:13.098282125Z"}
4949-{"id":"01KGGJG2FDK2NN8H059PTHNME0","title":"Add placeholder report endpoint","description":"## Task\n\nAdd a placeholder report endpoint for the More menu Report action.\n\n## Endpoint\n\n**Route:** POST /api/report\n**Handler:** HandleReport\n\n## Request Body\n```json\n{\n \"subject_uri\": \"at://did:plc:xxx/social.arabica.alpha.brew/rkey\",\n \"subject_cid\": \"bafyrei...\",\n \"reason\": \"spam|inappropriate|other\"\n}\n```\n\n## Response\n- 200 OK with `{\"status\": \"received\"}`\n- Log the report for now\n- TODO comment indicating future implementation needs\n\n## Implementation Notes\n- Add route to routing.go\n- Create minimal handler that logs and returns success\n- No actual moderation system yet - just capture the intent\n\n## Acceptance Criteria\n- [ ] POST /api/report endpoint exists\n- [ ] Accepts subject_uri, subject_cid, reason\n- [ ] Logs report details\n- [ ] Returns success response\n- [ ] TODO comment for future moderation system","status":"completed","priority":"normal","assignee":"patrick","labels":["backend","api"],"created_at":"2026-02-03T01:39:58.061904095Z","updated_at":"2026-02-03T01:43:12.681518812Z","completed_at":"2026-02-03T01:43:12.667221097Z"}
5050-{"id":"01KGGK6V4WMC3X67E72JTE27A7","title":"Fix 'failed to convert record to feed item' warning for likes","description":"The firehose index logs a warning 'failed to convert record to feed item' when processing like records. This is expected behavior since likes are not displayed as standalone feed items - they're indexed for like counts but shouldn't appear in the feed.\n\nThe warning appears in internal/firehose/index.go:433 when recordToFeedItem returns an error for NSIDLike records (line 582-584).\n\nFix: Handle the like case silently without logging a warning, since this is expected behavior. Either:\n1. Skip like records before calling recordToFeedItem\n2. Return a special sentinel error from recordToFeedItem that indicates 'skip without warning'\n3. Check the collection type at the call site and skip likes there\n\nThis applies to both authenticated and unauthenticated users.","status":"completed","priority":"normal","labels":["bug"],"created_at":"2026-02-03T01:52:24.220349056Z","updated_at":"2026-02-03T01:54:54.698106017Z","completed_at":"2026-02-03T01:54:54.686934901Z"}
5151-{"id":"01KGGNPG25M2AJ619P65FPMEH4","title":"Add OpenGraph metadata to brew pages","description":"Add dynamic OpenGraph metadata support for brew pages to enable rich social sharing previews.\n\n## Background\n\nCurrently, the site uses static OpenGraph tags in layout.templ:\n- og:title: \"Arabica - Coffee Brew Tracker\" (static)\n- og:description: \"Track your coffee brewing journey...\" (static) \n- og:type: \"website\" (static)\n- og:image: NOT PRESENT\n\nWhen users share brew links on social media, all previews look identical and provide no context about the specific brew.\n\n## Requirements\n\n### 1. Extend LayoutData struct\n\nAdd optional OpenGraph fields to `internal/web/components/layout.templ`:\n\n```go\ntype LayoutData struct {\n Title string\n IsAuthenticated bool\n UserDID string\n UserProfile *bff.UserProfile\n CSPNonce string\n \n // OpenGraph metadata (optional, falls back to defaults)\n OGTitle string\n OGDescription string\n OGImage string\n OGType string // \"website\", \"article\"\n OGUrl string // Canonical URL\n}\n```\n\n### 2. Update layout.templ head section\n\nModify meta tag rendering to use dynamic values with fallbacks:\n- If OGTitle set, use it; otherwise use site default\n- If OGDescription set, use it; otherwise use site default\n- If OGImage set, render og:image tag (currently missing entirely)\n- If OGUrl set, render og:url tag\n- Support og:type (default \"website\", brew pages use \"article\")\n\n### 3. Update buildLayoutData helper\n\nExtend `handlers.go` buildLayoutData() to accept optional OG parameters, or create a new helper `buildLayoutDataWithOG()`.\n\n### 4. Update HandleBrewView handler\n\nConstruct descriptive OG metadata from brew data:\n- **og:title**: \"{Bean Name} from {Origin} - Arabica\" or similar\n- **og:description**: \"{Rating}/10 - {Tasting Notes preview}\" or brew summary\n- **og:type**: \"article\"\n- **og:url**: The share URL (already computed as `shareURL`)\n- **og:image**: Static default for now (e.g., /static/og-brew-default.png)\n\nAvailable data for description construction:\n- brew.Rating, brew.TastingNotes\n- brew.Bean.Name, brew.Bean.Origin, brew.Bean.RoastLevel\n- brew.Bean.Roaster.Name\n- brew.CoffeeAmount, brew.WaterAmount, brew.Temperature\n\n### 5. Add Twitter Card support\n\nAdd Twitter-specific meta tags:\n- twitter:card = \"summary\" (or \"summary_large_image\" if we have images)\n- twitter:title, twitter:description (same as OG values)\n\n### 6. Static fallback image\n\nCreate or source a default OG image for brews:\n- Location: /static/og-brew-default.png or similar\n- Size: 1200x630px (recommended OG image size)\n- Design: Coffee-themed, includes Arabica branding\n\n## Acceptance Criteria\n\n- [ ] LayoutData extended with optional OG fields\n- [ ] layout.templ renders dynamic OG tags with fallbacks\n- [ ] Brew view pages include descriptive OG metadata\n- [ ] Twitter Card tags included\n- [ ] Static default OG image created and served\n- [ ] Existing pages (home, about, etc.) continue working with defaults\n- [ ] templ generate runs successfully\n- [ ] Manual testing: share brew URL to social platform preview tool\n\n## Implementation Notes\n\n- Follow existing patterns in handlers.go for data flow\n- Keep backwards compatible - pages not setting OG fields should use defaults\n- Consider creating a helper function to build OG description from brew data\n- The shareURL is already constructed in HandleBrewView (lines 695-701)\n\n## Future Enhancements (out of scope)\n\n- Dynamic image generation showing brew stats\n- Profile page OG metadata\n- Feed page OG metadata with recent brew preview","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend","social"],"created_at":"2026-02-03T02:35:54.309356038Z","updated_at":"2026-02-03T02:53:43.170537883Z","completed_at":"2026-02-03T02:53:43.15817599Z","notes":[{"timestamp":"2026-02-03T02:53:36.100130152Z","author":"patrick","message":"Implementation complete:\n- Extended LayoutData with OG fields (OGTitle, OGDescription, OGImage, OGType, OGUrl)\n- Added helper methods ogTitle(), ogDescription(), ogType() with fallbacks\n- Updated layout.templ to render dynamic OG and Twitter Card meta tags\n- Added populateBrewOGMetadata() helper in handlers.go\n- Updated HandleBrewView to populate OG metadata from brew data\n- Added PublicURL to handler Config for absolute URLs\n- Added comprehensive tests for OG metadata generation\n- All tests pass"}]}
5252-{"id":"01KGM9QB86N5RX50042DT1YN1N","title":"Consolidate Tailwind CSS usage","description":"Reduce Tailwind class duplication by leveraging existing CSS abstractions and adding missing utility classes.\n\n## Changes Required\n\n### 1. Add new utility classes to app.css\n- `.page-container` variants (sm, md, lg, xl) for max-width containers\n- `.avatar-sm`, `.avatar-md`, `.avatar-lg` size classes\n- `.section-box` for bg-brown-50 rounded sections\n\n### 2. Update templ components to use existing abstractions\n- WelcomeCard (shared.templ) - use `.card` instead of inline gradient\n- ProfileHeader, ProfileStat, ProfileTabs (profile.templ) - use `.card`\n- Header dropdown (header.templ) - use `.action-menu`\n- Login/CTA buttons (shared.templ) - use `.btn-primary`\n- Avatar component (shared.templ) - use new CSS classes instead of templ.KV\n\n### 3. Replace inline page containers\n- Replace `max-w-4xl mx-auto` etc. with `.page-container-*` classes across all pages\n\n## Acceptance Criteria\n- No visual changes to the site\n- Reduced class verbosity in templ files\n- Avatar component simplified\n- Consistent use of existing abstractions","status":"completed","priority":"normal","assignee":"patrick","labels":["css","refactor"],"created_at":"2026-02-04T12:23:36.966151046Z","updated_at":"2026-02-04T12:27:59.86186814Z","completed_at":"2026-02-04T12:27:59.850499942Z"}
5353-{"id":"01KHABKRB6RHR2AVE8PR28R66D","title":"Account request page (/join)","description":"Create a /join page where prospective users can request an arabica.systems PDS account.\n\n## Requirements\n- Simple form collecting: email address, preferred handle (optional), brief reason/message (optional)\n- On submit, sends an email notification to the admin (arabica.systems address TBD) with the request details\n- Stores the request in BoltDB for record-keeping\n- Shows a confirmation page after submission (\"Thanks, we'll review your request and email you an invite code\")\n- Rate limiting / basic spam prevention (e.g. honeypot field)\n- Matches the existing arabica.social design system (templ components, Tailwind, brown color palette)\n\n## Technical Details\n- Add SMTP support to the application (Go stdlib net/smtp or similar)\n- New env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM, ADMIN_EMAIL\n- New BoltDB bucket for account requests\n- New handler + templ page + route\n- No authentication required (public page)\n\n## Acceptance Criteria\n- Form renders correctly and matches site design\n- Email is sent to admin on submission\n- Request is persisted in BoltDB\n- Confirmation shown to user\n- Basic input validation (valid email format)","status":"in_progress","priority":"normal","assignee":"patrick","labels":["frontend","backend"],"created_at":"2026-02-13T01:59:53.958852961Z","updated_at":"2026-02-13T02:05:46.199584438Z","notes":[{"timestamp":"2026-02-13T02:03:54.190326645Z","author":"patrick","message":"SMTP approach decided: use Go stdlib net/smtp. Support both implicit TLS (port 465 via tls.Dial + smtp.NewClient) and STARTTLS (port 587/2525). VPS may block ports 25/587, so port 465 and 2525 must work. No external email SaaS dependency needed."}]}
5454-{"id":"01KHABM3J6XPXFTCPW6JX378W7","title":"Account creation page (/join/create)","description":"Create a /join/create page where users with an invite code can create their arabica.systems PDS account.\n\n## Requirements\n- Form collecting: invite code, desired handle (with @arabica.systems suffix shown), email, password (with confirmation)\n- Calls describeServer on arabica.systems PDS first to get available domains and requirements\n- Calls com.atproto.server.createAccount on the PDS (arabica.systems) via XRPC\n- Shows success page with next steps (e.g. log in to arabica.social, set up your profile)\n- Proper error handling for: invalid invite code, handle taken, invalid password, etc.\n- Password strength indicator or requirements display\n- Matches existing arabica.social design system\n\n## Technical Details\n- The PDS is at arabica.systems - XRPC calls go to https://arabica.systems/xrpc/...\n- createAccount params: handle, email, password, inviteCode\n- describeServer tells us available handle domains and whether invites are required\n- New env var: PDS_SERVICE_URL (e.g. https://arabica.systems)\n- New handler + templ page + route\n- No arabica.social authentication required (public page)\n- Do NOT store the user's password - it goes directly to the PDS\n\n## Security Considerations \n- Password goes directly to PDS via server-side XRPC call (not client-side JS)\n- HTTPS only for the XRPC call\n- No password logging\n\n## Acceptance Criteria\n- Form renders with all required fields\n- Valid invite code + details creates account on PDS\n- Appropriate error messages for each failure mode (InvalidInviteCode, HandleNotAvailable, etc.)\n- Success page with clear next steps\n- Matches site design system","status":"open","priority":"normal","labels":["frontend","backend"],"created_at":"2026-02-13T02:00:05.446933524Z","updated_at":"2026-02-13T02:43:36.680372523Z"}
-24
CLAUDE.md
···11-# Current Cell Task
22-33-**Cell ID:** 01KGA7T06FC5
44-**Title:** Style mail link and back button on terms page
55-66-## Task Description
77-88-Add proper styling to mail link and 'back to home' button on terms page.
99-1010-Acceptance criteria:
1111-1212-- Consistent button/link styling
1313-- Matches site design system
1414-- Good visual hierarchy
1515-1616-## Completion Instructions
1717-1818-When you have completed this task:
1919-2020-1. Ensure all work is committed and ready to merge
2121-2. Exit the session - you'll be prompted to complete, keep, or reopen the cell
2222-2323----
2424-251# Arabica - Project Context for AI Agents
262273Coffee brew tracking application using AT Protocol for decentralized storage.
+672
internal/handlers/brew.go
···11+package handlers
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "fmt"
77+ "net/http"
88+ "strconv"
99+ "strings"
1010+1111+ "arabica/internal/atproto"
1212+ "arabica/internal/models"
1313+ "arabica/internal/web/bff"
1414+ "arabica/internal/web/components"
1515+ "arabica/internal/web/pages"
1616+1717+ "github.com/rs/zerolog/log"
1818+)
1919+2020+// populateBrewOGMetadata sets OpenGraph metadata on layoutData for a brew page.
2121+// This enriches social media previews when brew links are shared.
2222+func (h *Handler) populateBrewOGMetadata(layoutData *components.LayoutData, brew *models.Brew, shareURL string) {
2323+ if brew == nil {
2424+ return
2525+ }
2626+2727+ // Build OG title from bean info
2828+ var ogTitle string
2929+ if brew.Bean != nil {
3030+ if brew.Bean.Origin != "" {
3131+ ogTitle = fmt.Sprintf("%s from %s", brew.Bean.Name, brew.Bean.Origin)
3232+ } else {
3333+ ogTitle = brew.Bean.Name
3434+ }
3535+ } else {
3636+ ogTitle = "Coffee Brew"
3737+ }
3838+3939+ // Build OG description with rating and tasting notes
4040+ var descParts []string
4141+ if brew.Rating > 0 {
4242+ descParts = append(descParts, fmt.Sprintf("Rated %d/10", brew.Rating))
4343+ }
4444+ if brew.TastingNotes != "" {
4545+ // Truncate tasting notes if too long
4646+ notes := brew.TastingNotes
4747+ if len(notes) > 100 {
4848+ notes = notes[:97] + "..."
4949+ }
5050+ descParts = append(descParts, notes)
5151+ }
5252+ if brew.Bean != nil && brew.Bean.Roaster != nil {
5353+ descParts = append(descParts, fmt.Sprintf("Roasted by %s", brew.Bean.Roaster.Name))
5454+ }
5555+5656+ var ogDescription string
5757+ if len(descParts) > 0 {
5858+ ogDescription = strings.Join(descParts, " · ")
5959+ } else {
6060+ ogDescription = "A coffee brew tracked on Arabica"
6161+ }
6262+6363+ // Build absolute URL if public URL is configured
6464+ var ogURL string
6565+ if h.config.PublicURL != "" && shareURL != "" {
6666+ ogURL = h.config.PublicURL + shareURL
6767+ }
6868+6969+ layoutData.OGTitle = ogTitle
7070+ layoutData.OGDescription = ogDescription
7171+ layoutData.OGType = "article"
7272+ layoutData.OGUrl = ogURL
7373+}
7474+7575+// Brew list partial (loaded async via HTMX)
7676+func (h *Handler) HandleBrewListPartial(w http.ResponseWriter, r *http.Request) {
7777+ // Require authentication
7878+ store, authenticated := h.getAtprotoStore(r)
7979+ if !authenticated {
8080+ http.Error(w, "Authentication required", http.StatusUnauthorized)
8181+ return
8282+ }
8383+8484+ brews, err := store.ListBrews(r.Context(), 1) // User ID is not used with atproto
8585+ if err != nil {
8686+ http.Error(w, "Failed to fetch brews", http.StatusInternalServerError)
8787+ log.Error().Err(err).Msg("Failed to fetch brews")
8888+ return
8989+ }
9090+9191+ if err := components.BrewListTablePartial(components.BrewListTableProps{
9292+ Brews: brews,
9393+ IsOwnProfile: true,
9494+ }).Render(r.Context(), w); err != nil {
9595+ http.Error(w, "Failed to render content", http.StatusInternalServerError)
9696+ log.Error().Err(err).Msg("Failed to render brew list partial")
9797+ }
9898+}
9999+100100+// List all brews
101101+func (h *Handler) HandleBrewList(w http.ResponseWriter, r *http.Request) {
102102+ // Require authentication
103103+ _, authenticated := h.getAtprotoStore(r)
104104+ if !authenticated {
105105+ http.Redirect(w, r, "/login", http.StatusFound)
106106+ return
107107+ }
108108+109109+ didStr, _ := atproto.GetAuthenticatedDID(r.Context())
110110+ userProfile := h.getUserProfile(r.Context(), didStr)
111111+112112+ // Create layout data
113113+ layoutData := h.buildLayoutData(r, "Your Brews", authenticated, didStr, userProfile)
114114+115115+ // Create brew list props
116116+ brewListProps := pages.BrewListProps{}
117117+118118+ // Render using templ component
119119+ if err := pages.BrewList(layoutData, brewListProps).Render(r.Context(), w); err != nil {
120120+ http.Error(w, "Failed to render page", http.StatusInternalServerError)
121121+ log.Error().Err(err).Msg("Failed to render brew list page")
122122+ }
123123+}
124124+125125+// Show new brew form
126126+func (h *Handler) HandleBrewNew(w http.ResponseWriter, r *http.Request) {
127127+ // Require authentication
128128+ _, authenticated := h.getAtprotoStore(r)
129129+ if !authenticated {
130130+ http.Redirect(w, r, "/login", http.StatusFound)
131131+ return
132132+ }
133133+134134+ didStr, _ := atproto.GetAuthenticatedDID(r.Context())
135135+ userProfile := h.getUserProfile(r.Context(), didStr)
136136+137137+ // Don't fetch data from PDS - client will populate dropdowns from cache
138138+ // This makes the page load much faster
139139+ layoutData := h.buildLayoutData(r, "New Brew", authenticated, didStr, userProfile)
140140+141141+ brewFormProps := pages.BrewFormProps{
142142+ Brew: nil,
143143+ }
144144+145145+ if err := pages.BrewFormPage(layoutData, brewFormProps).Render(r.Context(), w); err != nil {
146146+ http.Error(w, "Failed to render page", http.StatusInternalServerError)
147147+ log.Error().Err(err).Msg("Failed to render brew form")
148148+ }
149149+}
150150+151151+// Show brew view page
152152+func (h *Handler) HandleBrewView(w http.ResponseWriter, r *http.Request) {
153153+ rkey := validateRKey(w, r.PathValue("id"))
154154+ if rkey == "" {
155155+ return
156156+ }
157157+158158+ // Check if owner (DID or handle) is specified in query params
159159+ owner := r.URL.Query().Get("owner")
160160+161161+ // Check authentication
162162+ didStr, err := atproto.GetAuthenticatedDID(r.Context())
163163+ isAuthenticated := err == nil && didStr != ""
164164+165165+ var userProfile *bff.UserProfile
166166+ if isAuthenticated {
167167+ userProfile = h.getUserProfile(r.Context(), didStr)
168168+ }
169169+170170+ var brew *models.Brew
171171+ var brewOwnerDID string
172172+ var isOwner bool
173173+ var subjectURI, subjectCID string
174174+175175+ if owner != "" {
176176+ // Viewing someone else's brew - use public client
177177+ publicClient := atproto.NewPublicClient()
178178+179179+ // Resolve owner to DID if it's a handle
180180+ if strings.HasPrefix(owner, "did:") {
181181+ brewOwnerDID = owner
182182+ } else {
183183+ resolved, err := publicClient.ResolveHandle(r.Context(), owner)
184184+ if err != nil {
185185+ log.Warn().Err(err).Str("handle", owner).Msg("Failed to resolve handle for brew view")
186186+ http.Error(w, "User not found", http.StatusNotFound)
187187+ return
188188+ }
189189+ brewOwnerDID = resolved
190190+ }
191191+192192+ // Fetch the brew record from the owner's PDS
193193+ record, err := publicClient.GetRecord(r.Context(), brewOwnerDID, atproto.NSIDBrew, rkey)
194194+ if err != nil {
195195+ log.Error().Err(err).Str("did", brewOwnerDID).Str("rkey", rkey).Msg("Failed to get brew record")
196196+ http.Error(w, "Brew not found", http.StatusNotFound)
197197+ return
198198+ }
199199+200200+ // Store URI and CID for like button
201201+ subjectURI = record.URI
202202+ subjectCID = record.CID
203203+204204+ // Convert record to brew
205205+ brew, err = atproto.RecordToBrew(record.Value, record.URI)
206206+ if err != nil {
207207+ log.Error().Err(err).Msg("Failed to convert brew record")
208208+ http.Error(w, "Failed to load brew", http.StatusInternalServerError)
209209+ return
210210+ }
211211+212212+ // Resolve references (bean, grinder, brewer)
213213+ if err := h.resolveBrewReferences(r.Context(), brew, brewOwnerDID, record.Value); err != nil {
214214+ log.Warn().Err(err).Msg("Failed to resolve some brew references")
215215+ // Don't fail the request, just log the warning
216216+ }
217217+218218+ // Check if viewing user is the owner
219219+ isOwner = isAuthenticated && didStr == brewOwnerDID
220220+ } else {
221221+ // Viewing own brew - require authentication
222222+ store, authenticated := h.getAtprotoStore(r)
223223+ if !authenticated {
224224+ http.Redirect(w, r, "/login", http.StatusFound)
225225+ return
226226+ }
227227+228228+ // Use type assertion to access GetBrewRecordByRKey
229229+ atprotoStore, ok := store.(*atproto.AtprotoStore)
230230+ if !ok {
231231+ http.Error(w, "Internal error", http.StatusInternalServerError)
232232+ log.Error().Msg("Failed to cast store to AtprotoStore")
233233+ return
234234+ }
235235+236236+ brewRecord, err := atprotoStore.GetBrewRecordByRKey(r.Context(), rkey)
237237+ if err != nil {
238238+ http.Error(w, "Brew not found", http.StatusNotFound)
239239+ log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brew for view")
240240+ return
241241+ }
242242+243243+ brew = brewRecord.Brew
244244+ subjectURI = brewRecord.URI
245245+ subjectCID = brewRecord.CID
246246+ isOwner = true
247247+ }
248248+249249+ // Construct share URL (needed for both OG metadata and props)
250250+ var shareURL string
251251+ if owner != "" {
252252+ shareURL = fmt.Sprintf("/brews/%s?owner=%s", rkey, owner)
253253+ } else if userProfile != nil && userProfile.Handle != "" {
254254+ shareURL = fmt.Sprintf("/brews/%s?owner=%s", rkey, userProfile.Handle)
255255+ }
256256+257257+ // Create layout data with OpenGraph metadata
258258+ layoutData := h.buildLayoutData(r, "Brew Details", isAuthenticated, didStr, userProfile)
259259+ h.populateBrewOGMetadata(layoutData, brew, shareURL)
260260+261261+ // Get like data
262262+ var isLiked bool
263263+ var likeCount int
264264+ if h.feedIndex != nil && subjectURI != "" {
265265+ likeCount = h.feedIndex.GetLikeCount(subjectURI)
266266+ if isAuthenticated {
267267+ isLiked = h.feedIndex.HasUserLiked(didStr, subjectURI)
268268+ }
269269+ }
270270+271271+ // Create brew view props
272272+ brewViewProps := pages.BrewViewProps{
273273+ Brew: brew,
274274+ IsOwnProfile: isOwner,
275275+ IsAuthenticated: isAuthenticated,
276276+ SubjectURI: subjectURI,
277277+ SubjectCID: subjectCID,
278278+ IsLiked: isLiked,
279279+ LikeCount: likeCount,
280280+ ShareURL: shareURL,
281281+ }
282282+283283+ // Render using templ component
284284+ if err := pages.BrewView(layoutData, brewViewProps).Render(r.Context(), w); err != nil {
285285+ http.Error(w, "Failed to render page", http.StatusInternalServerError)
286286+ log.Error().Err(err).Msg("Failed to render brew view")
287287+ }
288288+}
289289+290290+// resolveBrewReferences resolves bean, grinder, and brewer references for a brew
291291+func (h *Handler) resolveBrewReferences(ctx context.Context, brew *models.Brew, ownerDID string, record map[string]interface{}) error {
292292+ publicClient := atproto.NewPublicClient()
293293+294294+ // Resolve bean reference
295295+ if beanRef, ok := record["beanRef"].(string); ok && beanRef != "" {
296296+ beanRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDBean, atproto.ExtractRKeyFromURI(beanRef))
297297+ if err == nil {
298298+ if bean, err := atproto.RecordToBean(beanRecord.Value, beanRecord.URI); err == nil {
299299+ brew.Bean = bean
300300+301301+ // Resolve roaster reference for the bean
302302+ if roasterRef, ok := beanRecord.Value["roasterRef"].(string); ok && roasterRef != "" {
303303+ roasterRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDRoaster, atproto.ExtractRKeyFromURI(roasterRef))
304304+ if err == nil {
305305+ if roaster, err := atproto.RecordToRoaster(roasterRecord.Value, roasterRecord.URI); err == nil {
306306+ brew.Bean.Roaster = roaster
307307+ }
308308+ }
309309+ }
310310+ }
311311+ }
312312+ }
313313+314314+ // Resolve grinder reference
315315+ if grinderRef, ok := record["grinderRef"].(string); ok && grinderRef != "" {
316316+ grinderRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDGrinder, atproto.ExtractRKeyFromURI(grinderRef))
317317+ if err == nil {
318318+ if grinder, err := atproto.RecordToGrinder(grinderRecord.Value, grinderRecord.URI); err == nil {
319319+ brew.GrinderObj = grinder
320320+ }
321321+ }
322322+ }
323323+324324+ // Resolve brewer reference
325325+ if brewerRef, ok := record["brewerRef"].(string); ok && brewerRef != "" {
326326+ brewerRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDBrewer, atproto.ExtractRKeyFromURI(brewerRef))
327327+ if err == nil {
328328+ if brewer, err := atproto.RecordToBrewer(brewerRecord.Value, brewerRecord.URI); err == nil {
329329+ brew.BrewerObj = brewer
330330+ }
331331+ }
332332+ }
333333+334334+ return nil
335335+}
336336+337337+// Show edit brew form
338338+func (h *Handler) HandleBrewEdit(w http.ResponseWriter, r *http.Request) {
339339+ rkey := validateRKey(w, r.PathValue("id"))
340340+ if rkey == "" {
341341+ return
342342+ }
343343+344344+ // Require authentication
345345+ store, authenticated := h.getAtprotoStore(r)
346346+ if !authenticated {
347347+ http.Redirect(w, r, "/login", http.StatusFound)
348348+ return
349349+ }
350350+351351+ didStr, _ := atproto.GetAuthenticatedDID(r.Context())
352352+ userProfile := h.getUserProfile(r.Context(), didStr)
353353+354354+ brew, err := store.GetBrewByRKey(r.Context(), rkey)
355355+ if err != nil {
356356+ http.Error(w, "Brew not found", http.StatusNotFound)
357357+ log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brew for edit")
358358+ return
359359+ }
360360+361361+ // Don't fetch dropdown data from PDS - client will populate from cache
362362+ // This makes the page load much faster
363363+ layoutData := h.buildLayoutData(r, "Edit Brew", authenticated, didStr, userProfile)
364364+365365+ brewFormProps := pages.BrewFormProps{
366366+ Brew: brew,
367367+ PoursJSON: bff.PoursToJSON(brew.Pours),
368368+ }
369369+370370+ if err := pages.BrewFormPage(layoutData, brewFormProps).Render(r.Context(), w); err != nil {
371371+ http.Error(w, "Failed to render page", http.StatusInternalServerError)
372372+ log.Error().Err(err).Msg("Failed to render brew edit form")
373373+ }
374374+}
375375+376376+// maxPours is the maximum number of pours allowed in a single brew
377377+const maxPours = 100
378378+379379+// parsePours extracts pour data from form values with bounds checking
380380+func parsePours(r *http.Request) []models.CreatePourData {
381381+ var pours []models.CreatePourData
382382+383383+ for i := 0; i < maxPours; i++ {
384384+ waterKey := "pour_water_" + strconv.Itoa(i)
385385+ timeKey := "pour_time_" + strconv.Itoa(i)
386386+387387+ waterStr := r.FormValue(waterKey)
388388+ timeStr := r.FormValue(timeKey)
389389+390390+ if waterStr == "" && timeStr == "" {
391391+ break
392392+ }
393393+394394+ water, _ := strconv.Atoi(waterStr)
395395+ pourTime, _ := strconv.Atoi(timeStr)
396396+397397+ if water > 0 && pourTime >= 0 {
398398+ pours = append(pours, models.CreatePourData{
399399+ WaterAmount: water,
400400+ TimeSeconds: pourTime,
401401+ })
402402+ }
403403+ }
404404+405405+ return pours
406406+}
407407+408408+// ValidationError represents a validation error with field name and message
409409+type ValidationError struct {
410410+ Field string
411411+ Message string
412412+}
413413+414414+// validateBrewRequest validates brew form input and returns any validation errors
415415+func validateBrewRequest(r *http.Request) (temperature float64, waterAmount, coffeeAmount, timeSeconds, rating int, pours []models.CreatePourData, errs []ValidationError) {
416416+ // Parse and validate temperature
417417+ if tempStr := r.FormValue("temperature"); tempStr != "" {
418418+ var err error
419419+ temperature, err = strconv.ParseFloat(tempStr, 64)
420420+ if err != nil {
421421+ errs = append(errs, ValidationError{Field: "temperature", Message: "invalid temperature format"})
422422+ } else if temperature < 0 || temperature > 212 {
423423+ errs = append(errs, ValidationError{Field: "temperature", Message: "temperature must be between 0 and 212"})
424424+ }
425425+ }
426426+427427+ // Parse and validate water amount
428428+ if waterStr := r.FormValue("water_amount"); waterStr != "" {
429429+ var err error
430430+ waterAmount, err = strconv.Atoi(waterStr)
431431+ if err != nil {
432432+ errs = append(errs, ValidationError{Field: "water_amount", Message: "invalid water amount"})
433433+ } else if waterAmount < 0 || waterAmount > 10000 {
434434+ errs = append(errs, ValidationError{Field: "water_amount", Message: "water amount must be between 0 and 10000ml"})
435435+ }
436436+ }
437437+438438+ // Parse and validate coffee amount
439439+ if coffeeStr := r.FormValue("coffee_amount"); coffeeStr != "" {
440440+ var err error
441441+ coffeeAmount, err = strconv.Atoi(coffeeStr)
442442+ if err != nil {
443443+ errs = append(errs, ValidationError{Field: "coffee_amount", Message: "invalid coffee amount"})
444444+ } else if coffeeAmount < 0 || coffeeAmount > 1000 {
445445+ errs = append(errs, ValidationError{Field: "coffee_amount", Message: "coffee amount must be between 0 and 1000g"})
446446+ }
447447+ }
448448+449449+ // Parse and validate time
450450+ if timeStr := r.FormValue("time_seconds"); timeStr != "" {
451451+ var err error
452452+ timeSeconds, err = strconv.Atoi(timeStr)
453453+ if err != nil {
454454+ errs = append(errs, ValidationError{Field: "time_seconds", Message: "invalid time"})
455455+ } else if timeSeconds < 0 || timeSeconds > 3600 {
456456+ errs = append(errs, ValidationError{Field: "time_seconds", Message: "brew time must be between 0 and 3600 seconds"})
457457+ }
458458+ }
459459+460460+ // Parse and validate rating
461461+ if ratingStr := r.FormValue("rating"); ratingStr != "" {
462462+ var err error
463463+ rating, err = strconv.Atoi(ratingStr)
464464+ if err != nil {
465465+ errs = append(errs, ValidationError{Field: "rating", Message: "invalid rating"})
466466+ } else if rating < 0 || rating > 10 {
467467+ errs = append(errs, ValidationError{Field: "rating", Message: "rating must be between 0 and 10"})
468468+ }
469469+ }
470470+471471+ // Parse pours
472472+ pours = parsePours(r)
473473+474474+ return
475475+}
476476+477477+// Create new brew
478478+func (h *Handler) HandleBrewCreate(w http.ResponseWriter, r *http.Request) {
479479+ // Require authentication first
480480+ store, authenticated := h.getAtprotoStore(r)
481481+ if !authenticated {
482482+ http.Redirect(w, r, "/login", http.StatusFound)
483483+ return
484484+ }
485485+486486+ if err := r.ParseForm(); err != nil {
487487+ http.Error(w, "Invalid form data", http.StatusBadRequest)
488488+ return
489489+ }
490490+491491+ // Validate input
492492+ temperature, waterAmount, coffeeAmount, timeSeconds, rating, pours, validationErrs := validateBrewRequest(r)
493493+ if len(validationErrs) > 0 {
494494+ // Return first validation error
495495+ http.Error(w, validationErrs[0].Message, http.StatusBadRequest)
496496+ return
497497+ }
498498+499499+ // Validate required fields
500500+ beanRKey := r.FormValue("bean_rkey")
501501+ if beanRKey == "" {
502502+ http.Error(w, "Bean selection is required", http.StatusBadRequest)
503503+ return
504504+ }
505505+ if !atproto.ValidateRKey(beanRKey) {
506506+ http.Error(w, "Invalid bean selection", http.StatusBadRequest)
507507+ return
508508+ }
509509+510510+ // Validate optional rkeys
511511+ grinderRKey := r.FormValue("grinder_rkey")
512512+ if errMsg := validateOptionalRKey(grinderRKey, "Grinder selection"); errMsg != "" {
513513+ http.Error(w, errMsg, http.StatusBadRequest)
514514+ return
515515+ }
516516+ brewerRKey := r.FormValue("brewer_rkey")
517517+ if errMsg := validateOptionalRKey(brewerRKey, "Brewer selection"); errMsg != "" {
518518+ http.Error(w, errMsg, http.StatusBadRequest)
519519+ return
520520+ }
521521+522522+ req := &models.CreateBrewRequest{
523523+ BeanRKey: beanRKey,
524524+ Method: r.FormValue("method"),
525525+ Temperature: temperature,
526526+ WaterAmount: waterAmount,
527527+ CoffeeAmount: coffeeAmount,
528528+ TimeSeconds: timeSeconds,
529529+ GrindSize: r.FormValue("grind_size"),
530530+ GrinderRKey: grinderRKey,
531531+ BrewerRKey: brewerRKey,
532532+ TastingNotes: r.FormValue("tasting_notes"),
533533+ Rating: rating,
534534+ Pours: pours,
535535+ }
536536+537537+ _, err := store.CreateBrew(r.Context(), req, 1) // User ID not used with atproto
538538+ if err != nil {
539539+ http.Error(w, "Failed to create brew", http.StatusInternalServerError)
540540+ log.Error().Err(err).Msg("Failed to create brew")
541541+ return
542542+ }
543543+544544+ // Redirect to brew list
545545+ w.Header().Set("HX-Redirect", "/brews")
546546+ w.WriteHeader(http.StatusOK)
547547+}
548548+549549+// Update existing brew
550550+func (h *Handler) HandleBrewUpdate(w http.ResponseWriter, r *http.Request) {
551551+ rkey := validateRKey(w, r.PathValue("id"))
552552+ if rkey == "" {
553553+ return
554554+ }
555555+556556+ // Require authentication
557557+ store, authenticated := h.getAtprotoStore(r)
558558+ if !authenticated {
559559+ http.Redirect(w, r, "/login", http.StatusFound)
560560+ return
561561+ }
562562+563563+ if err := r.ParseForm(); err != nil {
564564+ http.Error(w, "Invalid form data", http.StatusBadRequest)
565565+ return
566566+ }
567567+568568+ // Validate input
569569+ temperature, waterAmount, coffeeAmount, timeSeconds, rating, pours, validationErrs := validateBrewRequest(r)
570570+ if len(validationErrs) > 0 {
571571+ http.Error(w, validationErrs[0].Message, http.StatusBadRequest)
572572+ return
573573+ }
574574+575575+ // Validate required fields
576576+ beanRKey := r.FormValue("bean_rkey")
577577+ if beanRKey == "" {
578578+ http.Error(w, "Bean selection is required", http.StatusBadRequest)
579579+ return
580580+ }
581581+ if !atproto.ValidateRKey(beanRKey) {
582582+ http.Error(w, "Invalid bean selection", http.StatusBadRequest)
583583+ return
584584+ }
585585+586586+ // Validate optional rkeys
587587+ grinderRKey := r.FormValue("grinder_rkey")
588588+ if errMsg := validateOptionalRKey(grinderRKey, "Grinder selection"); errMsg != "" {
589589+ http.Error(w, errMsg, http.StatusBadRequest)
590590+ return
591591+ }
592592+ brewerRKey := r.FormValue("brewer_rkey")
593593+ if errMsg := validateOptionalRKey(brewerRKey, "Brewer selection"); errMsg != "" {
594594+ http.Error(w, errMsg, http.StatusBadRequest)
595595+ return
596596+ }
597597+598598+ req := &models.CreateBrewRequest{
599599+ BeanRKey: beanRKey,
600600+ Method: r.FormValue("method"),
601601+ Temperature: temperature,
602602+ WaterAmount: waterAmount,
603603+ CoffeeAmount: coffeeAmount,
604604+ TimeSeconds: timeSeconds,
605605+ GrindSize: r.FormValue("grind_size"),
606606+ GrinderRKey: grinderRKey,
607607+ BrewerRKey: brewerRKey,
608608+ TastingNotes: r.FormValue("tasting_notes"),
609609+ Rating: rating,
610610+ Pours: pours,
611611+ }
612612+613613+ err := store.UpdateBrewByRKey(r.Context(), rkey, req)
614614+ if err != nil {
615615+ http.Error(w, "Failed to update brew", http.StatusInternalServerError)
616616+ log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update brew")
617617+ return
618618+ }
619619+620620+ // Redirect to brew list
621621+ w.Header().Set("HX-Redirect", "/brews")
622622+ w.WriteHeader(http.StatusOK)
623623+}
624624+625625+// Delete brew
626626+func (h *Handler) HandleBrewDelete(w http.ResponseWriter, r *http.Request) {
627627+ rkey := validateRKey(w, r.PathValue("id"))
628628+ if rkey == "" {
629629+ return
630630+ }
631631+632632+ // Require authentication
633633+ store, authenticated := h.getAtprotoStore(r)
634634+ if !authenticated {
635635+ http.Error(w, "Authentication required", http.StatusUnauthorized)
636636+ return
637637+ }
638638+639639+ if err := store.DeleteBrewByRKey(r.Context(), rkey); err != nil {
640640+ http.Error(w, "Failed to delete brew", http.StatusInternalServerError)
641641+ log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete brew")
642642+ return
643643+ }
644644+645645+ w.WriteHeader(http.StatusOK)
646646+}
647647+648648+// Export brews as JSON
649649+func (h *Handler) HandleBrewExport(w http.ResponseWriter, r *http.Request) {
650650+ // Require authentication
651651+ store, authenticated := h.getAtprotoStore(r)
652652+ if !authenticated {
653653+ http.Error(w, "Authentication required", http.StatusUnauthorized)
654654+ return
655655+ }
656656+657657+ brews, err := store.ListBrews(r.Context(), 1) // User ID is not used with atproto
658658+ if err != nil {
659659+ http.Error(w, "Failed to fetch brews", http.StatusInternalServerError)
660660+ log.Error().Err(err).Msg("Failed to list brews for export")
661661+ return
662662+ }
663663+664664+ w.Header().Set("Content-Type", "application/json")
665665+ w.Header().Set("Content-Disposition", "attachment; filename=arabica-brews.json")
666666+667667+ encoder := json.NewEncoder(w)
668668+ encoder.SetIndent("", " ")
669669+ if err := encoder.Encode(brews); err != nil {
670670+ log.Error().Err(err).Msg("Failed to encode brews for export")
671671+ }
672672+}