diff --git a/.gitignore b/.gitignore index b5c8e5b..ce1835e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ -# Environment variables (DO NOT COMMIT) -.env - -# Node dependencies -node_modules/ +# Environment variables (DO NOT COMMIT) +.env +.codex-local.env + +# Node dependencies +node_modules/ # Build output (if using a bundler or React later) dist/ diff --git a/PROJECT_INSTRUCTIONS.md b/PROJECT_INSTRUCTIONS.md index 4b31bb0..c88b345 100644 --- a/PROJECT_INSTRUCTIONS.md +++ b/PROJECT_INSTRUCTIONS.md @@ -261,6 +261,8 @@ Before editing, run this read-only intake: ### Push and PR coordination - Push the branch before opening a PR. +- For this Gitea repo, use `docs/GITEA_PR_WORKFLOW.md` and `scripts/gitea-pr.js` for PR creation, lookup, and merge operations. +- PR tooling must read auth from `GITEA_TOKEN`/`GITEA_BASE_URL` shell environment or ignored `.codex-local.env` only; never commit tokens or print token values. - Open a draft PR early for non-trivial, collision-prone, or multi-agent work once the first coherent commit exists. - Use the PR body as the coordination record: - `Owner:` @@ -285,3 +287,4 @@ Before editing, run this read-only intake: - Tests run, or a clear reason tests were not run. - For broad branches, organize the summary by subsystem, workflow, or behavior area. - Do not use auto-closing keywords such as `Closes`, `Fixes`, or `Resolves`. +- Merge PRs only after explicit operator approval, required checks, and a final `npm run pr:view -- --number ` status check. diff --git a/backend/controllers/households.controller.js b/backend/controllers/households.controller.js index 49f0e53..03390b3 100644 --- a/backend/controllers/households.controller.js +++ b/backend/controllers/households.controller.js @@ -14,6 +14,42 @@ exports.getUserHouseholds = async (req, res) => { } }; +exports.reorderHouseholds = async (req, res) => { + try { + const rawHouseholdIds = req.body.household_ids || req.body.householdIds; + + if (!Array.isArray(rawHouseholdIds)) { + return sendError(res, 400, "household_ids must be an array"); + } + + const householdIds = rawHouseholdIds.map((householdId) => + Number.parseInt(householdId, 10) + ); + const hasInvalidId = householdIds.some( + (householdId) => !Number.isInteger(householdId) || householdId <= 0 + ); + const hasDuplicates = new Set(householdIds).size !== householdIds.length; + + if (hasInvalidId || hasDuplicates) { + return sendError(res, 400, "household_ids must contain unique positive household IDs"); + } + + const households = await householdModel.reorderUserHouseholds(req.user.id, householdIds); + + if (!households) { + return sendError(res, 400, "Household order must include every household you belong to"); + } + + res.json({ + message: "Household order updated successfully", + households, + }); + } catch (error) { + logError(req, "households.reorderHouseholds", error); + sendError(res, 500, "Failed to update household order"); + } +}; + // Get household details exports.getHousehold = async (req, res) => { try { diff --git a/backend/models/household.model.js b/backend/models/household.model.js index db146d5..5c568d7 100644 --- a/backend/models/household.model.js +++ b/backend/models/household.model.js @@ -1,8 +1,7 @@ const pool = require("../db/pool"); -// Get all households a user belongs to -exports.getUserHouseholds = async (userId) => { - const result = await pool.query( +async function queryUserHouseholds(db, userId) { + const result = await db.query( `SELECT h.id, h.name, @@ -10,14 +9,65 @@ exports.getUserHouseholds = async (userId) => { h.created_at, hm.role, hm.joined_at, + hm.household_sort_order, (SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count FROM households h JOIN household_members hm ON h.id = hm.household_id WHERE hm.user_id = $1 - ORDER BY hm.joined_at DESC`, + ORDER BY hm.household_sort_order ASC NULLS LAST, hm.joined_at DESC`, [userId] ); return result.rows; +} + +// Get all households a user belongs to +exports.getUserHouseholds = async (userId) => { + return queryUserHouseholds(pool, userId); +}; + +exports.reorderUserHouseholds = async (userId, householdIds) => { + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const membershipResult = await client.query( + `SELECT household_id + FROM household_members + WHERE user_id = $1`, + [userId] + ); + + const currentIds = membershipResult.rows.map((row) => Number(row.household_id)); + const currentIdSet = new Set(currentIds); + + if ( + householdIds.length !== currentIds.length || + householdIds.some((householdId) => !currentIdSet.has(householdId)) + ) { + await client.query("ROLLBACK"); + return null; + } + + for (const [index, householdId] of householdIds.entries()) { + await client.query( + `UPDATE household_members + SET household_sort_order = $1 + WHERE user_id = $2 + AND household_id = $3`, + [index, userId, householdId] + ); + } + + const households = await queryUserHouseholds(client, userId); + await client.query("COMMIT"); + return households; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } }; // Get household by ID (with member check) diff --git a/backend/routes/households.routes.js b/backend/routes/households.routes.js index 1dd1a73..970d647 100644 --- a/backend/routes/households.routes.js +++ b/backend/routes/households.routes.js @@ -16,6 +16,7 @@ const { upload, processImage } = require("../middleware/image"); // Public routes (authenticated only) router.get("/", auth, controller.getUserHouseholds); router.post("/", auth, controller.createHousehold); +router.patch("/order", auth, controller.reorderHouseholds); router.post("/join/:inviteCode", auth, controller.joinHousehold); // Household-scoped routes (member access required) diff --git a/backend/tests/available-items.routes.test.js b/backend/tests/available-items.routes.test.js index 5a18381..0e50ae9 100644 --- a/backend/tests/available-items.routes.test.js +++ b/backend/tests/available-items.routes.test.js @@ -43,6 +43,7 @@ jest.mock("../controllers/households.controller", () => ({ joinHousehold: jest.fn(), refreshInviteCode: jest.fn(), removeMember: jest.fn(), + reorderHouseholds: jest.fn(), updateHousehold: jest.fn(), updateMemberRole: jest.fn(), })); diff --git a/backend/tests/household.model.test.js b/backend/tests/household.model.test.js new file mode 100644 index 0000000..835fbfe --- /dev/null +++ b/backend/tests/household.model.test.js @@ -0,0 +1,75 @@ +jest.mock("../db/pool", () => ({ + connect: jest.fn(), + query: jest.fn(), +})); + +const pool = require("../db/pool"); +const Household = require("../models/household.model"); + +describe("household.model household ordering", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("loads households using the user's saved sort order", async () => { + pool.query.mockResolvedValueOnce({ + rows: [{ id: 2, name: "Second", household_sort_order: 0 }], + }); + + const households = await Household.getUserHouseholds(9); + + expect(households).toEqual([{ id: 2, name: "Second", household_sort_order: 0 }]); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining("ORDER BY hm.household_sort_order ASC NULLS LAST"), + [9] + ); + }); + + test("persists a full household order for the current user", async () => { + const client = { + query: jest.fn() + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ rows: [{ household_id: 1 }, { household_id: 2 }] }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ rows: [{ id: 2 }, { id: 1 }] }) + .mockResolvedValueOnce({}), + release: jest.fn(), + }; + pool.connect.mockResolvedValueOnce(client); + + const households = await Household.reorderUserHouseholds(9, [2, 1]); + + expect(households).toEqual([{ id: 2 }, { id: 1 }]); + expect(client.query).toHaveBeenNthCalledWith(1, "BEGIN"); + expect(client.query).toHaveBeenNthCalledWith( + 3, + expect.stringContaining("SET household_sort_order = $1"), + [0, 9, 2] + ); + expect(client.query).toHaveBeenNthCalledWith( + 4, + expect.stringContaining("SET household_sort_order = $1"), + [1, 9, 1] + ); + expect(client.query).toHaveBeenLastCalledWith("COMMIT"); + expect(client.release).toHaveBeenCalled(); + }); + + test("rejects an order that does not match the user's memberships", async () => { + const client = { + query: jest.fn() + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ rows: [{ household_id: 1 }] }) + .mockResolvedValueOnce({}), + release: jest.fn(), + }; + pool.connect.mockResolvedValueOnce(client); + + const households = await Household.reorderUserHouseholds(9, [1, 2]); + + expect(households).toBeNull(); + expect(client.query).toHaveBeenNthCalledWith(3, "ROLLBACK"); + expect(client.release).toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/households.controller.test.js b/backend/tests/households.controller.test.js index 6ff47cb..2e0b645 100644 --- a/backend/tests/households.controller.test.js +++ b/backend/tests/households.controller.test.js @@ -1,5 +1,6 @@ jest.mock("../models/household.model", () => ({ getUserRole: jest.fn(), + reorderUserHouseholds: jest.fn(), transferOwnership: jest.fn(), updateMemberRole: jest.fn(), })); @@ -86,3 +87,72 @@ describe("households.controller updateMemberRole", () => { }); }); }); + +describe("households.controller reorderHouseholds", () => { + beforeEach(() => { + jest.clearAllMocks(); + householdModel.reorderUserHouseholds.mockResolvedValue([ + { id: 3, name: "Third" }, + { id: 1, name: "First" }, + ]); + }); + + test("updates the current user's household order", async () => { + const req = { + body: { household_ids: [3, 1] }, + user: { id: 9 }, + }; + const res = createResponse(); + + await controller.reorderHouseholds(req, res); + + expect(householdModel.reorderUserHouseholds).toHaveBeenCalledWith(9, [3, 1]); + expect(res.json).toHaveBeenCalledWith({ + message: "Household order updated successfully", + households: [ + { id: 3, name: "Third" }, + { id: 1, name: "First" }, + ], + }); + }); + + test("rejects duplicate household IDs", async () => { + const req = { + body: { household_ids: [3, 3] }, + user: { id: 9 }, + }; + const res = createResponse(); + + await controller.reorderHouseholds(req, res); + + expect(householdModel.reorderUserHouseholds).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "household_ids must contain unique positive household IDs", + }), + }) + ); + }); + + test("rejects orders that do not match current memberships", async () => { + householdModel.reorderUserHouseholds.mockResolvedValue(null); + const req = { + body: { household_ids: [999] }, + user: { id: 9 }, + }; + const res = createResponse(); + + await controller.reorderHouseholds(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "Household order must include every household you belong to", + }), + }) + ); + }); +}); diff --git a/backend/tests/store-locations.routes.test.js b/backend/tests/store-locations.routes.test.js index db958b2..b331eae 100644 --- a/backend/tests/store-locations.routes.test.js +++ b/backend/tests/store-locations.routes.test.js @@ -43,6 +43,7 @@ jest.mock("../controllers/households.controller", () => ({ joinHousehold: jest.fn(), refreshInviteCode: jest.fn(), removeMember: jest.fn(), + reorderHouseholds: jest.fn((req, res) => res.json({ message: "ordered" })), updateHousehold: jest.fn(), updateMemberRole: jest.fn(), })); @@ -87,6 +88,7 @@ jest.mock("../controllers/stores.controller", () => ({ const express = require("express"); const request = require("supertest"); const router = require("../routes/households.routes"); +const householdsController = require("../controllers/households.controller"); const storesController = require("../controllers/stores.controller"); describe("store location routes", () => { @@ -106,6 +108,15 @@ describe("store location routes", () => { expect(storesController.getHouseholdStores).toHaveBeenCalled(); }); + test("users can reorder their household switcher list", async () => { + const response = await request(app) + .patch("/households/order") + .send({ household_ids: [3, 1, 2] }); + + expect(response.status).toBe(200); + expect(householdsController.reorderHouseholds).toHaveBeenCalled(); + }); + test("members cannot create household stores", async () => { const response = await request(app) .post("/households/1/stores") diff --git a/docs/GITEA_PR_WORKFLOW.md b/docs/GITEA_PR_WORKFLOW.md new file mode 100644 index 0000000..93322fc --- /dev/null +++ b/docs/GITEA_PR_WORKFLOW.md @@ -0,0 +1,71 @@ +# Gitea PR Workflow + +Use this workflow when creating or merging PRs for this repo. It is designed for Codex and local operators to use the same commands without storing secrets in git. + +## One-time token setup + +Create a Gitea access token with repository pull request permissions, then set it only in the shell or user environment: + +```powershell +$env:GITEA_BASE_URL = "http://192.168.7.78:3000" +$env:GITEA_TOKEN = "" +``` + +For Codex sandbox sessions, use the ignored local env file when inherited environment variables are not visible: + +```powershell +@" +GITEA_BASE_URL=http://192.168.7.78:3000 +GITEA_TOKEN= +"@ | Set-Content .codex-local.env +``` + +Do not commit tokens, paste them into docs, or print them in logs. + +Check auth: + +```powershell +npm run pr:auth +``` + +## Create a PR + +1. Push the branch first. +2. Inspect the cumulative branch diff against the PR target: + +```powershell +git log ..HEAD --oneline --decorate +git diff --stat ..HEAD +``` + +3. Write the PR body to a temporary local file. Include the coordination record required by `PROJECT_INSTRUCTIONS.md`. +4. Create the PR: + +```powershell +npm run pr:create -- --base --title "" --body-file <body-file> +``` + +The helper checks for an existing open PR with the same base/head and returns it instead of creating a duplicate. +If the PR body needs to be changed after creation, update it from a body file: + +```powershell +npm run pr:update -- --number <pr-number> --body-file <body-file> +``` + +For stacked work, pass the parent PR branch as `<base>`. For standalone work, pass `main`. + +## View or merge a PR + +View: + +```powershell +npm run pr:view -- --number <pr-number> +``` + +Merge after explicit operator approval and required checks: + +```powershell +npm run pr:merge -- --number <pr-number> --method merge --delete-branch --yes +``` + +The helper refuses to merge without `--yes`. Use `--method squash` or `--method rebase` only when that is the intended repo workflow. diff --git a/docs/guides/api-documentation.md b/docs/guides/api-documentation.md index 0f8eafb..99c14fb 100644 --- a/docs/guides/api-documentation.md +++ b/docs/guides/api-documentation.md @@ -28,6 +28,21 @@ The active grocery flow is scoped by household-owned store locations, not the le Owners/admins manage stores, locations, zones, and catalog deletion. Members can add/update list items and catalog item details. +### Household Switcher Order + +The current user can persist their own household switcher order: + +- Update household order: `PATCH /households/order` + +Request body: +```json +{ + "household_ids": [3, 1, 2] +} +``` + +The list must contain each household the current user belongs to exactly once. The response returns the reordered household list. + --- ## Authentication diff --git a/frontend/src/api/households.js b/frontend/src/api/households.js index 9ac78e4..e0d04f9 100644 --- a/frontend/src/api/households.js +++ b/frontend/src/api/households.js @@ -5,6 +5,12 @@ import api from "./axios"; */ export const getUserHouseholds = () => api.get("/households"); +/** + * Update the current user's household switcher order + */ +export const reorderHouseholds = (householdIds) => + api.patch("/households/order", { household_ids: householdIds }); + /** * Get details of a specific household */ diff --git a/frontend/src/components/common/ListSearchInput.jsx b/frontend/src/components/common/ListSearchInput.jsx index c3fc6db..8a4518d 100644 --- a/frontend/src/components/common/ListSearchInput.jsx +++ b/frontend/src/components/common/ListSearchInput.jsx @@ -3,14 +3,12 @@ export default function ListSearchInput({ value, onChange, resultCount, totalCou return ( <div className="glist-search"> - <label className="glist-search-label" htmlFor="grocery-list-search"> - Search list - </label> <div className="glist-search-row"> <input id="grocery-list-search" className="glist-search-input" type="search" + aria-label="Search list" value={value} onChange={(event) => onChange(event.target.value)} placeholder="Search list" diff --git a/frontend/src/components/household/HouseholdSwitcher.jsx b/frontend/src/components/household/HouseholdSwitcher.jsx index 8a01b27..d429937 100644 --- a/frontend/src/components/household/HouseholdSwitcher.jsx +++ b/frontend/src/components/household/HouseholdSwitcher.jsx @@ -1,5 +1,7 @@ import { useContext, useState } from "react"; import { HouseholdContext } from "../../context/HouseholdContext"; +import useActionToast from "../../hooks/useActionToast"; +import getApiErrorMessage from "../../lib/getApiErrorMessage"; import "../../styles/components/HouseholdSwitcher.css"; import CreateJoinHousehold from "../manage/CreateJoinHousehold"; @@ -8,10 +10,13 @@ export default function HouseholdSwitcher() { households, activeHousehold, setActiveHousehold, + reorderHouseholds, loading, hasLoaded, } = useContext(HouseholdContext); + const toast = useActionToast(); const [isOpen, setIsOpen] = useState(false); + const [isReordering, setIsReordering] = useState(false); const [showCreateJoin, setShowCreateJoin] = useState(false); if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) { @@ -49,6 +54,28 @@ export default function HouseholdSwitcher() { setIsOpen(false); }; + const handleMove = async (household, currentIndex, direction) => { + const nextIndex = currentIndex + direction; + if (nextIndex < 0 || nextIndex >= households.length || isReordering) return; + + const nextHouseholds = [...households]; + [nextHouseholds[currentIndex], nextHouseholds[nextIndex]] = [ + nextHouseholds[nextIndex], + nextHouseholds[currentIndex], + ]; + + setIsReordering(true); + try { + await reorderHouseholds(nextHouseholds.map((nextHousehold) => nextHousehold.id)); + toast.success("Updated household order", `Moved ${household.name}`); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to update household order"); + toast.error("Household order failed", `Household order failed: ${message}`); + } finally { + setIsReordering(false); + } + }; + return ( <div className="household-switcher"> <button @@ -65,18 +92,48 @@ export default function HouseholdSwitcher() { <> <div className="household-switcher-overlay" onClick={() => setIsOpen(false)} /> <div className="household-switcher-dropdown"> - {households.map((household) => ( - <button + {households.map((household, index) => ( + <div key={household.id} - className={`household-option ${household.id === activeHousehold.id ? "active" : ""}`} - type="button" - onClick={() => handleSelect(household)} + className={ + `household-option-row ${household.id === activeHousehold.id ? "active" : ""}` + } > - {household.name} - {household.id === activeHousehold.id && ( - <span className="check-mark">✓</span> + <button + className="household-option" + type="button" + onClick={() => handleSelect(household)} + > + <span className="household-option-name">{household.name}</span> + </button> + {households.length > 1 && ( + <div + className="household-reorder-controls" + aria-label={`Reorder ${household.name}`} + > + <button + className="household-reorder-button" + type="button" + onClick={() => handleMove(household, index, -1)} + disabled={index === 0 || isReordering} + aria-label={`Move ${household.name} up`} + title={`Move ${household.name} up`} + > + ▲ + </button> + <button + className="household-reorder-button" + type="button" + onClick={() => handleMove(household, index, 1)} + disabled={index === households.length - 1 || isReordering} + aria-label={`Move ${household.name} down`} + title={`Move ${household.name} down`} + > + ▼ + </button> + </div> )} - </button> + </div> ))} <div className="household-divider"></div> <button diff --git a/frontend/src/context/HouseholdContext.jsx b/frontend/src/context/HouseholdContext.jsx index 9171bae..644433b 100644 --- a/frontend/src/context/HouseholdContext.jsx +++ b/frontend/src/context/HouseholdContext.jsx @@ -1,5 +1,9 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'react'; -import { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households'; +import { + createHousehold as createHouseholdApi, + getUserHouseholds, + reorderHouseholds as reorderHouseholdsApi, +} from '../api/households'; import { isTransientApiError } from '../api/offlineCache'; import { AuthContext } from './AuthContext'; @@ -14,6 +18,7 @@ export const HouseholdContext = createContext({ setActiveHousehold: () => { }, refreshHouseholds: () => { }, createHousehold: () => { }, + reorderHouseholds: () => { }, }); export const HouseholdProvider = ({ children }) => { @@ -121,6 +126,57 @@ export const HouseholdProvider = ({ children }) => { } }; + const reorderHouseholds = async (orderedHouseholdIds) => { + const householdById = new Map( + households.map((household) => [String(household.id), household]) + ); + const nextHouseholds = orderedHouseholdIds + .map((householdId) => householdById.get(String(householdId))) + .filter(Boolean); + + if (nextHouseholds.length !== households.length) { + throw new Error("Household order is out of date"); + } + + const previousHouseholds = households; + setHouseholds(nextHouseholds); + if (activeHousehold) { + const nextActiveHousehold = nextHouseholds.find( + (household) => String(household.id) === String(activeHousehold.id) + ); + if (nextActiveHousehold) { + setActiveHouseholdState(nextActiveHousehold); + } + } + + try { + const response = await reorderHouseholdsApi(orderedHouseholdIds); + const savedHouseholds = Array.isArray(response.data.households) + ? response.data.households + : []; + + if (savedHouseholds.length > 0) { + setHouseholds(savedHouseholds); + if (activeHousehold) { + const savedActiveHousehold = savedHouseholds.find( + (household) => String(household.id) === String(activeHousehold.id) + ); + if (savedActiveHousehold) { + setActiveHouseholdState(savedActiveHousehold); + } + } + } + + return savedHouseholds; + } catch (err) { + setHouseholds(previousHouseholds); + if (activeHousehold) { + setActiveHouseholdState(activeHousehold); + } + throw err; + } + }; + const value = { households, activeHousehold, @@ -130,6 +186,7 @@ export const HouseholdProvider = ({ children }) => { setActiveHousehold, refreshHouseholds: loadHouseholds, createHousehold, + reorderHouseholds, }; return ( diff --git a/frontend/src/styles/components/HouseholdSwitcher.css b/frontend/src/styles/components/HouseholdSwitcher.css index 468b9fd..4766fb2 100644 --- a/frontend/src/styles/components/HouseholdSwitcher.css +++ b/frontend/src/styles/components/HouseholdSwitcher.css @@ -70,8 +70,10 @@ position: absolute; top: calc(100% + 0.5rem); left: 0; - right: 0; + right: auto; width: 100%; + min-width: 100%; + max-width: min(320px, calc(100vw - 2rem)); overflow: hidden; background: var(--card-bg); border: 2px solid var(--border); @@ -80,15 +82,32 @@ z-index: 1000; } +.household-option-row { + display: flex; + align-items: stretch; + min-height: 48px; + background: var(--card-bg); + border-bottom: 1px solid var(--border); +} + +.household-option-row:hover { + background: var(--button-secondary-bg); +} + +.household-option-row.active { + background: color-mix(in srgb, var(--primary) 15%, transparent); +} + .household-option { display: flex; align-items: center; justify-content: space-between; + flex: 1; + min-width: 0; width: 100%; - padding: 0.875rem 1rem; - background: var(--card-bg); + padding: 0.875rem 0.625rem 0.875rem 1rem; + background: transparent; border: none; - border-bottom: 1px solid var(--border); color: var(--text-primary); font-size: 1rem; text-align: left; @@ -96,25 +115,59 @@ transition: all 0.2s ease; } -.household-option:last-child { - border-bottom: none; +.household-option-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .household-option:hover { - background: var(--button-secondary-bg); - border-color: var(--primary); + color: var(--primary); } -.household-option.active { - background: color-mix(in srgb, var(--primary) 15%, transparent); +.household-option-row.active .household-option { color: var(--primary); font-weight: 600; } -.check-mark { +.household-reorder-controls { + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; + justify-content: center; + gap: 0.125rem; + width: 28px; + padding: 0.25rem 0.25rem 0.25rem 0; +} + +.household-reorder-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 18px; + padding: 0; + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + font-size: 0.55rem; + line-height: 1; +} + +.household-reorder-button:hover:not(:disabled), +.household-reorder-button:focus-visible { + border-color: var(--primary); color: var(--primary); - font-size: 1.1rem; - font-weight: bold; + outline: none; +} + +.household-reorder-button:disabled { + opacity: 0.35; + cursor: not-allowed; } .household-divider { @@ -124,6 +177,7 @@ } .create-household-btn { + border-bottom: none; color: var(--primary); font-weight: 600; } diff --git a/frontend/src/styles/pages/GroceryList.css b/frontend/src/styles/pages/GroceryList.css index dbd80c1..7dffa2a 100644 --- a/frontend/src/styles/pages/GroceryList.css +++ b/frontend/src/styles/pages/GroceryList.css @@ -109,14 +109,14 @@ /* Classification Groups */ .glist-classification-group { - margin-bottom: var(--spacing-xl); + margin-bottom: 0; } .glist-classification-header { font-size: var(--font-size-lg); font-weight: var(--font-weight-semibold); color: var(--color-primary); - margin: var(--spacing-md) 0 var(--spacing-sm) 0; + margin: var(--spacing-md) 0 0 0; padding: var(--spacing-sm) var(--spacing-md); background: var(--color-primary-light); border-left: var(--border-width-thick) solid var(--color-primary); @@ -232,6 +232,10 @@ margin-top: var(--spacing-md); } +.glist-classification-group .glist-ul { + margin-top: var(--spacing-xs); +} + .glist-li { background: var(--color-bg-surface); border: var(--border-width-thin) solid var(--color-border-light); @@ -247,6 +251,14 @@ transform: translateY(-2px); } +.glist-classification-group .glist-li { + margin-bottom: var(--spacing-xs); +} + +.glist-classification-group .glist-li:last-child { + margin-bottom: 0; +} + .glist-item-layout { display: flex; gap: 1em; @@ -358,14 +370,6 @@ margin: var(--spacing-xs) 0 var(--spacing-sm); } -.glist-search-label { - display: block; - margin-bottom: 4px; - color: var(--color-text-secondary); - font-size: var(--font-size-sm); - font-weight: 700; -} - .glist-search-row { display: flex; align-items: center; diff --git a/frontend/tests/household-selection-persistence.spec.ts b/frontend/tests/household-selection-persistence.spec.ts index 04de148..88f949c 100644 --- a/frontend/tests/household-selection-persistence.spec.ts +++ b/frontend/tests/household-selection-persistence.spec.ts @@ -33,8 +33,8 @@ test("selected household stays active after refreshing on settings and home page ]; const storesByHousehold = { - 1: [{ id: 101, name: "Costco", is_default: true }], - 2: [{ id: 201, name: "Trader Joe's", is_default: true }], + 1: [{ id: 101, household_store_id: 1001, name: "Costco", is_default: true }], + 2: [{ id: 201, household_store_id: 2001, name: "Trader Joe's", is_default: true }], }; await page.route("**/households", async (route) => { @@ -45,8 +45,8 @@ test("selected household stays active after refreshing on settings and home page }); }); - await page.route("**/stores/household/*", async (route) => { - const householdId = Number(route.request().url().split("/").pop()); + await page.route("**/households/*/stores", async (route) => { + const householdId = Number(route.request().url().match(/households\/(\d+)\/stores/)?.[1]); await route.fulfill({ status: 200, contentType: "application/json", @@ -56,7 +56,15 @@ test("selected household stays active after refreshing on settings and home page }); }); - await page.route("**/households/*/stores/*/list/recent", async (route) => { + await page.route("**/households/*/locations/*/zones", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ zones: [] }), + }); + }); + + await page.route("**/households/*/locations/*/list/recent", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", @@ -64,7 +72,7 @@ test("selected household stays active after refreshing on settings and home page }); }); - await page.route("**/households/*/stores/*/list", async (route) => { + await page.route("**/households/*/locations/*/list", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", @@ -84,8 +92,20 @@ test("selected household stays active after refreshing on settings and home page await expect(page.getByRole("button", { name: "Alpha Home" })).toBeVisible(); - await page.getByRole("button", { name: "Alpha Home" }).click(); - await page.getByRole("button", { name: "Bravo Home" }).click(); + const householdTrigger = page.locator(".household-switcher-toggle"); + await expect(householdTrigger).toContainText("Alpha Home"); + await householdTrigger.click(); + const householdDropdown = page.locator(".household-switcher-dropdown"); + await expect(householdDropdown).toBeVisible(); + await expect(page.locator(".check-mark")).toHaveCount(0); + + const triggerBox = await householdTrigger.boundingBox(); + const dropdownBox = await householdDropdown.boundingBox(); + expect(triggerBox).not.toBeNull(); + expect(dropdownBox).not.toBeNull(); + expect(Math.abs((dropdownBox?.x ?? 0) - (triggerBox?.x ?? 0))).toBeLessThan(1); + + await page.getByRole("button", { name: "Bravo Home", exact: true }).click(); await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible(); await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdId"))).toBe("2"); diff --git a/package.json b/package.json index 44595c3..4ed9852 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,11 @@ "db:migrate:new": "node scripts/db-migrate-new.js", "db:migrate:stale": "node scripts/db-stale-sql-tracker.js --write", "db:migrate:stale:check": "node scripts/db-stale-sql-tracker.js --fail-on-stale", + "pr:auth": "node scripts/gitea-pr.js auth-check", + "pr:create": "node scripts/gitea-pr.js create", + "pr:view": "node scripts/gitea-pr.js view", + "pr:update": "node scripts/gitea-pr.js update", + "pr:merge": "node scripts/gitea-pr.js merge", "test": "jest --runInBand", "test:e2e": "npm --prefix frontend run test:e2e --", "test:e2e:headed": "npm --prefix frontend run test:e2e:headed --", diff --git a/packages/db/migrations/20260526_020000_add_household_member_sort_order.sql b/packages/db/migrations/20260526_020000_add_household_member_sort_order.sql new file mode 100644 index 0000000..1a9f7ac --- /dev/null +++ b/packages/db/migrations/20260526_020000_add_household_member_sort_order.sql @@ -0,0 +1,9 @@ +BEGIN; + +ALTER TABLE household_members + ADD COLUMN IF NOT EXISTS household_sort_order INTEGER; + +CREATE INDEX IF NOT EXISTS idx_household_members_user_sort_order + ON household_members(user_id, household_sort_order, joined_at DESC); + +COMMIT; diff --git a/scripts/gitea-pr.js b/scripts/gitea-pr.js new file mode 100644 index 0000000..53bb83d --- /dev/null +++ b/scripts/gitea-pr.js @@ -0,0 +1,379 @@ +#!/usr/bin/env node + +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); + +loadLocalEnv(); + +const DEFAULT_REMOTE = "origin"; +const DEFAULT_SSH_HTTP_PORT = process.env.GITEA_PORT || "3000"; + +function loadLocalEnv() { + const envPath = ".codex-local.env"; + + if (!fs.existsSync(envPath)) { + return; + } + + const lines = fs.readFileSync(envPath, "utf8").split(/\r?\n/); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + + const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match) { + continue; + } + + const key = match[1].replace(/^\uFEFF/, ""); + let value = match[2].trim(); + const isQuoted = + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")); + + if (isQuoted) { + value = value.slice(1, -1); + } + + if (!process.env[key]) { + process.env[key] = value; + } + } +} + +function usage() { + console.log(`Gitea PR helper + +Environment: + GITEA_TOKEN Required for API calls. Never commit this value. + GITEA_BASE_URL Optional, e.g. http://192.168.7.78:3000. + GITEA_OWNER Optional repo owner override. + GITEA_REPO Optional repo name override. + +Commands: + auth-check + Verify the token can authenticate with Gitea. + + create --base <branch> [--head <branch>] --title <title> [--body-file <path> | --body <text>] + Create a PR, or return the existing open PR for the same base/head. + + view --number <pr-number> + Print basic PR status. + + update --number <pr-number> [--title <title>] [--body-file <path> | --body <text>] + Update a PR title/body. Prefer --body-file for multi-line PR bodies. + + merge --number <pr-number> --yes [--method merge|squash|rebase] [--delete-branch] + Merge a PR. The --yes flag is required to avoid accidental merges. + +Examples: + npm run pr:auth + npm run pr:create -- --base feature-custom-store-locations --title "Allow household switcher reordering" --body-file pr-body.md + npm run pr:update -- --number 12 --body-file pr-body.md + npm run pr:view -- --number 12 + npm run pr:merge -- --number 12 --method merge --delete-branch --yes +`); +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function runGit(args) { + return execFileSync("git", args, { encoding: "utf8" }).trim(); +} + +function parseFlags(argv) { + const flags = {}; + const positionals = []; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (!arg.startsWith("--")) { + positionals.push(arg); + continue; + } + + const key = arg.slice(2); + const next = argv[index + 1]; + + if (!next || next.startsWith("--")) { + flags[key] = true; + continue; + } + + flags[key] = next; + index += 1; + } + + return { flags, positionals }; +} + +function requiredFlag(flags, key) { + const value = flags[key]; + if (!value || value === true) { + fail(`Missing required --${key}`); + } + return value; +} + +function currentBranch() { + return runGit(["branch", "--show-current"]); +} + +function parseRemoteUrl(remoteUrl) { + const normalized = remoteUrl.replace(/\.git$/, ""); + + const httpMatch = normalized.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)$/); + if (httpMatch) { + const [, host, owner, repo] = httpMatch; + return { host, owner, repo, scheme: remoteUrl.startsWith("https://") ? "https" : "http" }; + } + + const sshMatch = normalized.match(/^(?:ssh:\/\/)?[^@]+@([^:/]+)(?::\d+)?[:/]([^/]+)\/([^/]+)$/); + if (sshMatch) { + const [, host, owner, repo] = sshMatch; + return { host, owner, repo, scheme: "ssh" }; + } + + fail(`Could not parse git remote URL: ${remoteUrl}`); +} + +function repoConfig() { + const remoteUrl = runGit(["remote", "get-url", DEFAULT_REMOTE]); + const parsed = parseRemoteUrl(remoteUrl); + const baseUrl = + process.env.GITEA_BASE_URL || + process.env.GITEA_URL || + (parsed.scheme === "ssh" + ? `http://${parsed.host}:${DEFAULT_SSH_HTTP_PORT}` + : `${parsed.scheme}://${parsed.host}`); + + return { + baseUrl: baseUrl.replace(/\/$/, ""), + owner: process.env.GITEA_OWNER || parsed.owner, + repo: process.env.GITEA_REPO || parsed.repo, + }; +} + +function getToken() { + const token = process.env.GITEA_TOKEN || process.env.GITEA_ACCESS_TOKEN; + if (!token) { + fail( + "Missing GITEA_TOKEN. Create a Gitea access token with repository pull request permissions and set it in the shell." + ); + } + return token; +} + +async function apiRequest(method, route, body) { + const { baseUrl } = repoConfig(); + const url = `${baseUrl}/api/v1${route}`; + const response = await fetch(url, { + method, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `token ${getToken()}`, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + const text = await response.text(); + const parsed = text ? safeJson(text) : null; + + if (!response.ok) { + const message = + parsed?.message || + parsed?.error || + text || + `${response.status} ${response.statusText}`; + fail(`Gitea API ${method} ${route} failed: ${response.status} ${message}`); + } + + return parsed; +} + +function safeJson(text) { + try { + return JSON.parse(text); + } catch { + return { message: text }; + } +} + +function repoRoute(pathname) { + const { owner, repo } = repoConfig(); + return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}${pathname}`; +} + +async function listOpenPulls() { + const pulls = []; + let page = 1; + + while (true) { + const batch = await apiRequest("GET", repoRoute(`/pulls?state=open&limit=50&page=${page}`)); + pulls.push(...batch); + + if (batch.length < 50) { + break; + } + + page += 1; + } + + return pulls; +} + +async function findOpenPull(base, head) { + const pulls = await listOpenPulls(); + return pulls.find((pull) => { + const headLabels = [pull.head?.ref, pull.head?.label].filter(Boolean); + return ( + pull.base?.ref === base && + headLabels.some((label) => label === head || label.endsWith(`:${head}`)) + ); + }); +} + +function readBody(flags) { + if (flags["body-file"]) { + return fs.readFileSync(flags["body-file"], "utf8"); + } + return typeof flags.body === "string" ? flags.body : ""; +} + +async function authCheck() { + const user = await apiRequest("GET", "/user"); + console.log(`Authenticated as ${user.login || user.username || user.full_name || "Gitea user"}`); +} + +async function createPull(flags) { + const base = requiredFlag(flags, "base"); + const head = flags.head && flags.head !== true ? flags.head : currentBranch(); + const title = requiredFlag(flags, "title"); + const body = readBody(flags); + + const existing = await findOpenPull(base, head); + if (existing) { + console.log(`Existing PR #${existing.number}: ${existing.html_url || existing.url}`); + return; + } + + const pull = await apiRequest("POST", repoRoute("/pulls"), { + base, + head, + title, + body, + }); + + console.log(`Created PR #${pull.number}: ${pull.html_url || pull.url}`); +} + +async function viewPull(flags) { + const number = requiredFlag(flags, "number"); + const pull = await apiRequest("GET", repoRoute(`/pulls/${encodeURIComponent(number)}`)); + console.log( + [ + `PR #${pull.number}: ${pull.title}`, + `URL: ${pull.html_url || pull.url}`, + `State: ${pull.state}${pull.merged ? " (merged)" : ""}`, + `Base: ${pull.base?.ref || "(unknown)"}`, + `Head: ${pull.head?.ref || "(unknown)"}`, + `Mergeable: ${String(pull.mergeable)}`, + ].join("\n") + ); +} + +async function updatePull(flags) { + const number = requiredFlag(flags, "number"); + const payload = {}; + + if (flags.title && flags.title !== true) { + payload.title = flags.title; + } + + if (flags["body-file"] || typeof flags.body === "string") { + payload.body = readBody(flags); + } + + if (Object.keys(payload).length === 0) { + fail("Nothing to update; pass --title, --body, or --body-file"); + } + + const pull = await apiRequest( + "PATCH", + repoRoute(`/pulls/${encodeURIComponent(number)}`), + payload + ); + + console.log(`Updated PR #${pull.number}: ${pull.html_url || pull.url}`); +} + +async function mergePull(flags) { + const number = requiredFlag(flags, "number"); + if (!flags.yes) { + fail("Refusing to merge without --yes"); + } + + const method = flags.method && flags.method !== true ? flags.method : "merge"; + const allowedMethods = new Set(["merge", "squash", "rebase"]); + if (!allowedMethods.has(method)) { + fail("--method must be one of: merge, squash, rebase"); + } + + const pull = await apiRequest("GET", repoRoute(`/pulls/${encodeURIComponent(number)}`)); + if (pull.state !== "open") { + fail(`Refusing to merge PR #${number}; state is ${pull.state}`); + } + if (pull.merged) { + fail(`PR #${number} is already merged`); + } + + await apiRequest("POST", repoRoute(`/pulls/${encodeURIComponent(number)}/merge`), { + Do: method, + delete_branch_after_merge: Boolean(flags["delete-branch"]), + }); + + console.log(`Merged PR #${number} with method ${method}`); +} + +async function main() { + const [command, ...rest] = process.argv.slice(2); + const { flags } = parseFlags(rest); + + switch (command) { + case undefined: + case "help": + case "--help": + case "-h": + usage(); + break; + case "auth-check": + await authCheck(); + break; + case "create": + await createPull(flags); + break; + case "view": + await viewPull(flags); + break; + case "update": + await updatePull(flags); + break; + case "merge": + await mergePull(flags); + break; + default: + fail(`Unknown command: ${command}`); + } +} + +main().catch((error) => { + fail(error.stack || error.message || String(error)); +});