Merge pull request 'Allow household switcher reordering' (#3) from feature/household-reordering into feature-custom-store-locations

This commit is contained in:
nalalangan 2026-05-31 00:26:48 -09:00
commit 35d0fc72cc
21 changed files with 976 additions and 53 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
# Environment variables (DO NOT COMMIT)
.env
.codex-local.env
# Node dependencies
node_modules/

View File

@ -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 <pr-number>` status check.

View File

@ -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 {

View File

@ -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)

View File

@ -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)

View File

@ -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(),
}));

View File

@ -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();
});
});

View File

@ -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",
}),
})
);
});
});

View File

@ -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")

71
docs/GITEA_PR_WORKFLOW.md Normal file
View File

@ -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 = "<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=<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 <base>..HEAD --oneline --decorate
git diff --stat <base>..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 <base> --title "<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.

View File

@ -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

View File

@ -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
*/

View File

@ -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"

View File

@ -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">&#10003;</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`}
>
&#9650;
</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`}
>
&#9660;
</button>
</div>
)}
</button>
</div>
))}
<div className="household-divider"></div>
<button

View File

@ -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 (

View File

@ -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;
}

View File

@ -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;

View File

@ -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");

View File

@ -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 --",

View File

@ -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;

379
scripts/gitea-pr.js Normal file
View File

@ -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));
});