How to Manage Packing Lists with Bags, Categories, and Member Assignments in TREK
TREK manages packing lists through a three-tier architecture that organizes items into categories, tracks them in physical bags, and assigns trip members to both categories and bags, all implemented in server/src/services/packingService.ts.
TREK is an open-source trip planning application that provides collaborative packing list functionality through a relational data model. The system enables travelers to categorize items, assign them to specific bags with weight limits, and distribute responsibility among trip members using fine-grained visibility controls. According to the TREK source code, all packing logic resides in the packingService.ts service layer and follows Zod validation schemas defined in shared/src/packing/packing.schema.ts.
Core Architecture and Data Model
The packing feature centers on four database entities managed through packingService.ts:
packing_items– Stores individual items with visibility flags and category namespacking_item_recipients– Links items to shared users for the "Shared" visibility tierpacking_bags– Defines physical bags with color, weight limits, and sort orderpacking_bag_members– Assigns trip members to specific bagspacking_category_assignees– Pins members to category headers for notifications
The API exposes these through endpoints under /api/trips/:tripId/packing, including routes for items, bags, and category assignees.
Managing Item Visibility and Creation
TREK implements three visibility tiers that control item access:
- Common – Group pool visible to all trip members (
is_private = 0) - Personal – Private to the owner (
is_private = 1and owner matches) - Shared – Visible to selected members via the
packing_item_recipientstable
When fetching items, the listItems function in packingService.ts applies user-specific filtering:
// server/src/services/packingService.ts
export function listItems(tripId: string | number, userId?: number) {
const rows = userId == null
? db.prepare('SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC')
.all(tripId)
: db.prepare(`
SELECT * FROM packing_items
WHERE trip_id = ?
AND (is_private = 0
OR owner_id = ?
OR EXISTS (SELECT 1 FROM packing_item_recipients r WHERE r.item_id = packing_items.id AND r.user_id = ?))
ORDER BY sort_order ASC, created_at ASC
`).all(tripId, userId, userId);
return enrichItems(rows);
}
Creating items involves mapping the visibility parameter to the internal is_private flag via visibilityToPrivate, then inserting recipient rows for shared items:
// server/src/services/packingService.ts
export function createItem(
tripId: string | number,
data: {
name: string;
category?: string;
visibility?: PackingVisibility;
recipient_ids?: number[];
},
ownerId?: number,
) {
const sortOrder = (db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?')
.get(tripId).max ?? -1) + 1;
const isPrivate = visibilityToPrivate(data.visibility, data.is_private);
const create = db.transaction(() => {
const result = db.prepare(
'INSERT INTO packing_items (trip_id, name, checked, category, sort_order, quantity, is_private, owner_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)'
).run(tripId, data.name, data.checked ? 1 : 0, data.category || 'Allgemein', sortOrder,
Math.max(1, Math.min(999, Number(data.quantity) || 1)),
isPrivate, ownerId ?? null);
const itemId = Number(result.lastInsertRowid);
if (data.visibility === 'shared' && Array.isArray(data.recipient_ids)) {
const ins = db.prepare('INSERT OR IGNORE INTO packing_item_recipients (item_id, user_id) VALUES (?, ?)');
for (const uid of data.recipient_ids) if (uid !== ownerId) ins.run(itemId, uid);
}
return itemId;
});
const itemId = create();
return enrichItems([db.prepare('SELECT * FROM packing_items WHERE id = ?').get(itemId)])[0];
}
Organizing Categories and Assigning Members
Categories in TREK function as grouping headers that support member assignment. When users assign members to a category, the system stores these in packing_category_assignees and triggers packing notifications without affecting item visibility.
The updateCategoryAssignees function handles this assignment:
// server/src/services/packingService.ts
export function updateCategoryAssignees(
tripId: string | number,
categoryName: string,
userIds: number[] | undefined,
) {
db.prepare('DELETE FROM packing_category_assignees WHERE trip_id = ? AND category_name = ?')
.run(tripId, categoryName);
if (Array.isArray(userIds) && userIds.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)');
for (const uid of userIds) insert.run(tripId, categoryName, uid);
}
const updated = db.prepare(`
SELECT pca.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM packing_category_assignees pca
JOIN users u ON pca.user_id = u.id
WHERE pca.trip_id = ? AND pca.category_name = ?
`).all(tripId, categoryName);
return updated.map(m => ({ ...m, avatar: avatarUrl(m) }));
}
The UI sends assignments via POST /api/trips/:tripId/packing/category-assignees with a JSON payload containing category and user_ids.
Bag Tracking and Member Assignment
Bags provide physical organization with optional weight tracking and member assignment. Each bag supports a name, hex color code, weight limit, and assigned member list.
Creating a bag uses the createBag function:
// server/src/services/packingService.ts
export function createBag(tripId: string | number, data: { name: string; color?: string }) {
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_bags WHERE trip_id = ?')
.get(tripId).max ?? -1;
const result = db.prepare('INSERT INTO packing_bags (trip_id, name, color, sort_order) VALUES (?, ?, ?, ?)')
.run(tripId, data.name.trim(), data.color || '#6366f1', maxOrder + 1);
return db.prepare('SELECT * FROM packing_bags WHERE id = ?').get(result.lastInsertRowid);
}
Assigning members to bags occurs through setBagMembers, which manages the packing_bag_members junction table:
// server/src/services/packingService.ts
export function setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?')
.get(bagId, tripId);
if (!bag) return null;
db.prepare('DELETE FROM packing_bag_members WHERE bag_id = ?').run(bagId);
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
for (const uid of userIds) ins.run(bagId, uid);
const rows = db.prepare(`
SELECT bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id
WHERE bm.bag_id = ?
`).all(bagId);
return rows.map(m => ({ ...m, avatar: avatarUrl(m) }));
}
Items reference bags through a bag_id foreign key, enabling the UI to filter items by physical container and display weight totals per bag.
Templates and Offline Conflict Detection
The service supports reusable templates through saveAsTemplate and applyTemplate functions, allowing users to serialize entire packing lists for future trips. For offline scenarios, items track an updated_at timestamp, and the updateItem function accepts an If-Match header to detect conflicts when timestamps diverge, returning a conflict object if the server state has changed since the client last fetched.
Summary
- Three-tier visibility (Common, Personal, Shared) controls access to items through
is_privateflags and thepacking_item_recipientstable - Category assignments notify members via
packing_category_assigneeswithout affecting item visibility permissions - Bag tracking organizes items physically, supports weight limits, and assigns members through
packing_bag_members - Conflict detection uses
updated_attimestamps to handle offline modifications safely - Template system enables saving and reapplying complete packing lists across trips
Frequently Asked Questions
How does TREK handle visibility for shared packing items?
TREK implements shared visibility through the packing_item_recipients table. When an item's visibility is set to "shared", the createItem function inserts rows into packing_item_recipients for each user ID in the recipient_ids array, excluding the owner. The listItems function then checks for these recipient relationships when filtering queries for specific users.
Can multiple trip members be assigned to a single bag?
Yes. The setBagMembers function in packingService.ts supports assigning multiple members to a bag by accepting a userIds array. It clears existing assignments for the bag in packing_bag_members and inserts new junction rows, then returns enriched member data including avatar URLs for UI display.
What is the difference between category assignees and item recipients?
Category assignees (packing_category_assignees) trigger packing notifications for members responsible for an entire category but do not control item visibility. Item recipients (packing_item_recipients) determine which users can view and interact with specific items when visibility is set to "Shared", functioning as an access control list.
How does TREK prevent conflicts when users edit packing items offline?
Each packing item maintains an updated_at timestamp. When updating via PATCH /packing/:id, clients include this timestamp in an If-Match header. The service compares the provided timestamp against the current database value; if they differ, indicating a concurrent modification, the service returns a conflict response rather than applying the update.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →