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 names
  • packing_item_recipients – Links items to shared users for the "Shared" visibility tier
  • packing_bags – Defines physical bags with color, weight limits, and sort order
  • packing_bag_members – Assigns trip members to specific bags
  • packing_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 = 1 and owner matches)
  • Shared – Visible to selected members via the packing_item_recipients table

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_private flags and the packing_item_recipients table
  • Category assignments notify members via packing_category_assignees without affecting item visibility permissions
  • Bag tracking organizes items physically, supports weight limits, and assigns members through packing_bag_members
  • Conflict detection uses updated_at timestamps 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →