diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index 440b6b7a5..3526e38eb 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -23,4 +23,4 @@ jobs: - name: Publish to GitHub wiki uses: Andrew-Chen-Wang/github-wiki-action@v5 with: - strategy: init + strategy: clone diff --git a/README.md b/README.md index 3e90a0e82..f3dfae0f2 100644 --- a/README.md +++ b/README.md @@ -400,6 +400,7 @@ Caddy handles TLS and WebSockets automatically. | `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | | `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` | +| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` | | `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto | | `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` | | `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` | diff --git a/TRADEMARKS.md b/TRADEMARKS.md new file mode 100644 index 000000000..3483065ee --- /dev/null +++ b/TRADEMARKS.md @@ -0,0 +1,121 @@ +# Trademark Policy + +## Introduction + +This is the TREK project's policy for the use of our trademarks. While TREK is +available under the GNU Affero General Public License v3.0 (AGPL-3.0), that +license does not include a license to use our trademarks. + +This policy describes how you may use our trademarks. Our goal is to strike a +balance between: 1) our need to ensure that our trademarks remain reliable +indicators of the software we release; and 2) our community members' desire to +be full participants in the TREK project. + +## Our trademarks + +This policy covers the name "TREK" as well as any associated logos, trade dress, +goodwill, or designs (our "Marks"). + +## In general + +Whenever you use our Marks, you must always do so in a way that does not mislead +anyone about exactly who is the source of the software. For example, you cannot +say you are distributing TREK when you're distributing a modified version of it, +because people would think they would be getting the same software that they +can get directly from us when they aren't. You also cannot use our Marks on +your website in a way that suggests that your website is an official TREK +website or that we endorse your website. But, if true, you can say you like +TREK, that you participate in the TREK community, that you are providing an +unmodified version of TREK, or that you wrote a guide describing how to use +TREK. + +This fundamental requirement, that it is always clear to people what they are +getting and from whom, is reflected throughout this policy. It should also +serve as your guide if you are not sure about how you are using the Marks. + +In addition: + +* You may not use or register, in whole or in part, the Marks as part of your + own trademark, service mark, domain name, company name, trade name, product + name or service name. +* Trademark law does not allow your use of names or trademarks that are too + similar to ours. You therefore may not use an obvious variation of any of our + Marks or any phonetic equivalent, foreign language equivalent, takeoff, or + abbreviation for a similar or compatible product or service. +* You agree that you will not acquire any rights in the Marks and that any + goodwill generated by your use of the Marks and participation in our + community inures solely to our benefit. + +## Distribution of unmodified source code or unmodified executable code we have compiled + +When you redistribute an unmodified copy of TREK, you are not changing the +quality or nature of it. Therefore, you may retain the Marks we have placed on +the software to identify your redistribution. This kind of use only applies if +you are redistributing an official TREK distribution that has not been changed +in any way. + +## Distribution of executable code that you have compiled, or modified code + +You may use the word mark "TREK", but not any TREK logos, to truthfully +describe the origin of the software that you are providing, that is, that the +code you are distributing is a modification of TREK. You may say, for example, +that "this software is derived from the source code for TREK." + +Of course, you can place your own trademarks or logos on versions of the +software to which you have made substantive modifications, because by modifying +the software, you have become the origin of that exact version. In that case, +you should not use our Marks. + +However, you may use our Marks for the distribution of code (source or +executable) on the condition that any executable is built from an official TREK +source code release and that any modifications are limited to switching on or +off features already included in the software, translations into other +languages, and incorporating minor bug-fix patches. Use of our Marks on any +further modification is not permitted. + +## Mobile wrappers, hosted instances, and forks + +The following clarifications apply specifically to common ways TREK is +redistributed: + +* **Self-hosted instances of unmodified TREK.** You may refer to your instance + as "a TREK instance" or "running TREK." You may not name the service itself + in a way that suggests it is the official TREK ("TREK Cloud," "TREK + Official," etc.). +* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at + TREK.** You may describe your app as "a mobile client for TREK" or "for use + with TREK." You may not publish it on app stores under the name "TREK" or a + confusingly similar name, and you may not use the TREK logo as the app icon + unless your wrapper distributes only an unmodified, official TREK instance + and you have obtained permission. +* **Forks of the TREK source code.** Forks that diverge from upstream must use + a different name. You may state that your fork is "based on TREK" or "a fork + of TREK," but the project name itself must be your own. + +## Statements about your software's relation to TREK + +You may use the word mark, but not TREK logos, to truthfully describe the +relationship between your software and ours. The word mark "TREK" should be +used after a verb or preposition that describes the relationship between your +software and ours. So you may say, for example, "Bob's app for TREK" but may +not say "Bob's TREK app." Some other examples that may work for you are: + +* [Your software] uses TREK +* [Your software] is powered by TREK +* [Your software] runs on TREK +* [Your software] for use with TREK +* [Your software] for TREK + +## Questions and permission requests + +If you are not sure whether your intended use of the Marks is permitted under +this policy, or if you would like to request explicit permission for a use that +is not covered, please open an issue on the TREK GitHub repository or contact +the maintainers directly. + +--- + +These guidelines are based on the +[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used +under a +[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US). \ No newline at end of file diff --git a/charts/trek/Chart.yaml b/charts/trek/Chart.yaml index 717650006..a06ed5a98 100644 --- a/charts/trek/Chart.yaml +++ b/charts/trek/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 name: trek -version: 2.9.14 +version: 3.0.9 description: Minimal Helm chart for TREK app -appVersion: "2.9.14" +appVersion: "3.0.9" diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml index af3a71822..33efce0c6 100644 --- a/charts/trek/templates/configmap.yaml +++ b/charts/trek/templates/configmap.yaml @@ -22,6 +22,9 @@ data: {{- if .Values.env.FORCE_HTTPS }} FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }} {{- end }} + {{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }} + HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }} + {{- end }} {{- if .Values.env.COOKIE_SECURE }} COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }} {{- end }} diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index 42c86b1f6..0f19d2301 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -30,6 +30,8 @@ env: # Also used as the base URL for links in email notifications and other external links. # FORCE_HTTPS: "false" # Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY. + # HSTS_INCLUDE_SUBDOMAINS: "false" + # When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP. # COOKIE_SECURE: "true" # Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production. # TRUST_PROXY: "1" diff --git a/client/package-lock.json b/client/package-lock.json index 1763c702f..646cd40a2 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-client", - "version": "2.9.14", + "version": "3.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-client", - "version": "2.9.14", + "version": "3.0.9", "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", @@ -8907,9 +8907,9 @@ } }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { diff --git a/client/package.json b/client/package.json index 9efbb68c4..04e502f94 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "trek-client", - "version": "2.9.14", + "version": "3.0.9", "private": true, "type": "module", "scripts": { diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 378ddeabd..3182d2adc 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -236,13 +236,17 @@ export const assignmentsApi = { export const packingApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data), + listCategories: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/categories`).then(r => r.data), + createCategory: (tripId: number | string, data: { name: string; type: 'shared' | 'personal' | 'private' }) => apiClient.post(`/trips/${tripId}/packing/categories`, data).then(r => r.data), + updateCategory: (tripId: number | string, catId: number, data: { name?: string; type?: 'shared' | 'personal' | 'private' }) => apiClient.patch(`/trips/${tripId}/packing/categories/${catId}`, data).then(r => r.data), + deleteCategory: (tripId: number | string, catId: number) => apiClient.delete(`/trips/${tripId}/packing/categories/${catId}`).then(r => r.data), create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data), bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data), update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data), reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data), getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data), - setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data), + setCategoryAssignees: (tripId: number | string, categoryId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${categoryId}`, { user_ids: userIds }).then(r => r.data), applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data), saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data), setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data), diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index e442a0ffe..a91a977ec 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro } const handleRenameCategory = async (oldName, newName) => { if (!newName.trim() || newName.trim() === oldName) return - const items = grouped[oldName] || [] + const items = grouped.get(oldName) || [] for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) } const handleAddCategory = () => { diff --git a/client/src/components/Layout/DemoBanner.tsx b/client/src/components/Layout/DemoBanner.tsx index ba77cd40d..c4068017a 100644 --- a/client/src/components/Layout/DemoBanner.tsx +++ b/client/src/components/Layout/DemoBanner.tsx @@ -266,17 +266,22 @@ export default function DemoBanner(): React.ReactElement | null { return (
setDismissed(true)}>
) => e.stopPropagation()}> {/* Header */} @@ -367,8 +372,10 @@ export default function DemoBanner(): React.ReactElement | null { {/* Footer */}
diff --git a/client/src/components/PDF/TripPDF.test.ts b/client/src/components/PDF/TripPDF.test.ts index 46549188c..77e33eac3 100644 --- a/client/src/components/PDF/TripPDF.test.ts +++ b/client/src/components/PDF/TripPDF.test.ts @@ -78,6 +78,7 @@ const transportReservation = { id: 400, title: 'Flight to Rome', type: 'flight', + day_id: 10, reservation_time: '2025-06-01T14:30:00', confirmation_number: 'ABC123', metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }), diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 040bb7114..1a5a33167 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -140,23 +140,58 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor const totalCost = Object.values(assignments || {}) .flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0) + // Span helpers for multi-day transport (mirrors DayPlanSidebar logic) + const pdfGetDayOrder = (d: Day) => d.day_number + const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => { + const startId = r.day_id + const endId = r.end_day_id ?? startId + if (!startId || startId === endId) return 'single' + if (dayId === startId) return 'start' + if (dayId === endId) return 'end' + return 'middle' + } + const pdfGetDisplayTime = (r: any, dayId: number): string | null => { + const phase = pdfGetSpanPhase(r, dayId) + if (phase === 'end') return r.reservation_end_time || null + if (phase === 'middle') return null + return r.reservation_time || null + } + const pdfGetSpanLabel = (r: any, phase: string): string | null => { + if (phase === 'single') return null + if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`) + if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`) + return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`) + } + const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => { + if (r.type === 'hotel') return false + const startId = r.day_id + const endId = r.end_day_id ?? startId + if (startId == null) return false + if (endId !== startId) { + const startDay = sorted.find(d => d.id === startId) + const endDay = sorted.find(d => d.id === endId) + const thisDay = sorted.find(d => d.id === dayId) + if (!startDay || !endDay || !thisDay) return false + return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay) + } + return startId === dayId + }) + // Build day HTML const daysHtml = sorted.map((day, di) => { const assigned = assignments[String(day.id)] || [] const notes = (dayNotes || []).filter(n => n.day_id === day.id) const cost = dayCost(assignments, day.id, loc) - // Reservations for this day (hotel rendered via accommodations block) - const dayReservations = (reservations || []).filter(r => { - if (!r.reservation_time || r.type === 'hotel') return false - return day.date && r.reservation_time.split('T')[0] === day.date - }) + // Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only) + const dayReservations = pdfGetTransportForDay(day.id) + .filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle')) const merged = [] assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a })) notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n })) dayReservations.forEach(r => { - const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5) + const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5) merged.push({ type: 'reservation', k: pos, data: r }) }) merged.sort((a, b) => a.k - b.k) @@ -177,13 +212,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ') else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ') const locationLine = r.location || meta.location || '' - const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : '' + const phase = pdfGetSpanPhase(r, day.id) + const spanLabel = pdfGetSpanLabel(r, phase) + const displayTime = pdfGetDisplayTime(r, day.id) + const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : '' + const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}` return `
${icon}
-
${escHtml(r.title)}${time ? ` ${time}` : ''}
+
${titleHtml}${time ? ` ${time}` : ''}
${subtitle ? `
${escHtml(subtitle)}
` : ''} ${locationLine ? `
${escHtml(locationLine)}
` : ''} ${r.confirmation_number ? `
Code: ${escHtml(r.confirmation_number)}
` : ''} diff --git a/client/src/components/Packing/PackingListPanel.test.tsx b/client/src/components/Packing/PackingListPanel.test.tsx index 2e1414eca..3cc082989 100644 --- a/client/src/components/Packing/PackingListPanel.test.tsx +++ b/client/src/components/Packing/PackingListPanel.test.tsx @@ -7,11 +7,12 @@ import { server } from '../../../tests/helpers/msw/server'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; -import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories'; +import { buildUser, buildTrip, buildPackingItem, packingCategoryIdFor, resetPackingCategoryIds } from '../../../tests/helpers/factories'; import PackingListPanel from './PackingListPanel'; beforeEach(() => { resetAllStores(); + resetPackingCategoryIds(); // Side-effect APIs PackingListPanel calls on mount server.use( http.get('/api/trips/:id/members', () => @@ -20,6 +21,9 @@ beforeEach(() => { http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({ assignees: {} }) ), + http.get('/api/trips/:id/packing/categories', () => + HttpResponse.json({ categories: [] }) + ), http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: false }) ), @@ -313,13 +317,13 @@ describe('PackingListPanel', () => { await waitFor(() => expect(patchBody).toMatchObject({ quantity: 5 })); }); - it('FE-COMP-PACKING-027: add new category via form calls POST', async () => { + it('FE-COMP-PACKING-027: add new category via form POSTs to /categories with name and type', async () => { const user = userEvent.setup(); let postBody: Record | null = null; server.use( - http.post('/api/trips/1/packing', async ({ request }) => { + http.post('/api/trips/1/packing/categories', async ({ request }) => { postBody = await request.json() as Record; - return HttpResponse.json({ item: buildPackingItem({ name: '...', category: 'Valuables' }) }); + return HttpResponse.json({ category: { id: 999, trip_id: 1, name: 'Valuables', type: 'shared', owner_user_id: null, sort_order: 0 } }); }) ); render(); @@ -329,7 +333,7 @@ describe('PackingListPanel', () => { await user.type(input, 'Valuables'); await user.keyboard('{Enter}'); - await waitFor(() => expect(postBody).toMatchObject({ category: 'Valuables' })); + await waitFor(() => expect(postBody).toMatchObject({ name: 'Valuables', type: 'shared' })); }); it('FE-COMP-PACKING-028: category group collapse hides items, expand shows them', async () => { @@ -460,33 +464,31 @@ describe('PackingListPanel', () => { }); }); - it('FE-COMP-PACKING-035: category rename via context menu calls PUT', async () => { + it('FE-COMP-PACKING-035: category rename via context menu PATCHes /categories/:catId', async () => { const user = userEvent.setup(); const item = buildPackingItem({ id: 90, name: 'Shirt', category: 'Clothing' }); - let putBody: Record | null = null; + const catId = packingCategoryIdFor('Clothing')!; + let patchBody: Record | null = null; server.use( - http.put('/api/trips/1/packing/90', async ({ request }) => { - putBody = await request.json() as Record; - return HttpResponse.json({ item: buildPackingItem({ id: 90, name: 'Shirt', category: 'Apparel' }) }); + http.patch(`/api/trips/1/packing/categories/${catId}`, async ({ request }) => { + patchBody = await request.json() as Record; + return HttpResponse.json({ category: { id: catId, trip_id: 1, name: 'Apparel', type: 'shared', owner_user_id: null, sort_order: 0 } }); }) ); const { container } = render(); - // Open the category context menu const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); expect(moreBtn).toBeTruthy(); await user.click(moreBtn!); - // Click "Rename" in the menu await user.click(await screen.findByText('Rename')); - // Category name input appears — type new name and save const catInput = screen.getByDisplayValue('Clothing'); await user.clear(catInput); await user.type(catInput, 'Apparel'); await user.keyboard('{Enter}'); - await waitFor(() => expect(putBody).toMatchObject({ category: 'Apparel' })); + await waitFor(() => expect(patchBody).toMatchObject({ name: 'Apparel' })); }); it('FE-COMP-PACKING-036: assignee dropdown opens and lists members when clicked', async () => { @@ -879,9 +881,10 @@ describe('PackingListPanel', () => { }); it('FE-COMP-PACKING-052: category assignee chip renders when assignees exist', async () => { + const catId = packingCategoryIdFor('Electronics')!; server.use( http.get('/api/trips/:id/packing/category-assignees', () => - HttpResponse.json({ assignees: { Electronics: [{ user_id: 2, username: 'alice', avatar: null }] } }) + HttpResponse.json({ assignees: { [catId]: [{ user_id: 2, username: 'alice', avatar: null }] } }) ) ); const item = buildPackingItem({ name: 'Camera', category: 'Electronics' }); @@ -959,28 +962,25 @@ describe('PackingListPanel', () => { expect(screen.getByText('8 items')).toBeInTheDocument(); }); - it('FE-COMP-PACKING-037: delete category via context menu calls DELETE for all items', async () => { + it('FE-COMP-PACKING-037: delete category via context menu DELETEs /categories/:catId once', async () => { const user = userEvent.setup(); const item1 = buildPackingItem({ id: 100, name: 'Rope', category: 'Gear' }); const item2 = buildPackingItem({ id: 101, name: 'Map', category: 'Gear' }); - const deletedIds: number[] = []; + const catId = packingCategoryIdFor('Gear')!; + let deletedCatId = -1; server.use( - http.delete('/api/trips/1/packing/:itemId', ({ params }) => { - deletedIds.push(Number(params.itemId)); + http.delete(`/api/trips/1/packing/categories/${catId}`, () => { + deletedCatId = catId; return HttpResponse.json({ success: true }); }) ); const { container } = render(); - // Open context menu and click Delete Category const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); await user.click(moreBtn!); await user.click(await screen.findByText('Delete Category')); - await waitFor(() => { - expect(deletedIds).toContain(100); - expect(deletedIds).toContain(101); - }); + await waitFor(() => expect(deletedCatId).toBe(catId)); }); it('FE-COMP-PACKING-056: pressing Enter in quantity input commits value', async () => { @@ -1019,9 +1019,10 @@ describe('PackingListPanel', () => { }); }); - it('FE-COMP-PACKING-058: selecting a different category in picker calls PUT with new category', async () => { + it('FE-COMP-PACKING-058: selecting a different category in picker PUTs with new category_id', async () => { const itemA = buildPackingItem({ id: 74, name: 'Camera', category: 'Electronics' }); const itemB = buildPackingItem({ id: 75, name: 'Passport', category: 'Documents' }); + const documentsId = packingCategoryIdFor('Documents')!; let putBody: Record | null = null; server.use( http.put('/api/trips/1/packing/74', async ({ request }) => { @@ -1031,15 +1032,13 @@ describe('PackingListPanel', () => { ); render(); - // Use fireEvent (no pointer events) to open the category picker — avoids mouseLeave closing picker const catChangeBtns = screen.getAllByTitle('Change Category'); fireEvent.click(catChangeBtns[0]); - // Picker shows available categories — find and click the 'Documents' button (role=button, text=Documents) - const docBtn = await screen.findByRole('button', { name: 'Documents' }); + const docBtn = await screen.findByRole('button', { name: /Documents/ }); fireEvent.click(docBtn); - await waitFor(() => expect(putBody).toMatchObject({ category: 'Documents' })); + await waitFor(() => expect(putBody).toMatchObject({ category_id: documentsId })); }); it('FE-COMP-PACKING-059: clicking member in UserPlus dropdown calls setCategoryAssignees', async () => { @@ -1075,10 +1074,11 @@ describe('PackingListPanel', () => { }); it('FE-COMP-PACKING-060: clicking assignee chip removes assignee via setCategoryAssignees', async () => { + const catId = packingCategoryIdFor('Electronics')!; let putBody: Record | null = null; server.use( http.get('/api/trips/:id/packing/category-assignees', () => - HttpResponse.json({ assignees: { Electronics: [{ user_id: 2, username: 'alice', avatar: null }] } }) + HttpResponse.json({ assignees: { [catId]: [{ user_id: 2, username: 'alice', avatar: null }] } }) ), http.get('/api/trips/:id/members', () => HttpResponse.json({ @@ -1087,7 +1087,7 @@ describe('PackingListPanel', () => { current_user_id: 1, }) ), - http.put('/api/trips/1/packing/category-assignees/:cat', async ({ request }) => { + http.put(`/api/trips/1/packing/category-assignees/${catId}`, async ({ request }) => { putBody = await request.json() as Record; return HttpResponse.json({ assignees: [] }); }) diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx index 9311cbc1b..916bbbe18 100644 --- a/client/src/components/Packing/PackingListPanel.tsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -8,8 +8,10 @@ import ReactDOM from 'react-dom' import { CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, Upload, + Lock, Users, Globe, } from 'lucide-react' -import type { PackingItem } from '../../types' +import type { PackingItem, PackingCategory } from '../../types' +import Tooltip from '../shared/Tooltip' const VORSCHLAEGE = [ { name: 'Passport', category: 'Documents' }, @@ -200,7 +202,7 @@ function QuantityInput({ value, onSave }: { value: number; onSave: (qty: number) interface ArtikelZeileProps { item: PackingItem tripId: number - categories: string[] + categories: PackingCategory[] onCategoryChange: () => void bagTrackingEnabled?: boolean bags?: PackingBag[] @@ -208,14 +210,9 @@ interface ArtikelZeileProps { canEdit?: boolean } -// A category's first item is seeded with this sentinel because the server -// rejects empty names. Treat it as a placeholder in the UI. -const PACKING_PLACEHOLDER_NAME = '...' - function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) { - const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME const [editing, setEditing] = useState(false) - const [editName, setEditName] = useState(isPlaceholder ? '' : item.name) + const [editName, setEditName] = useState(item.name) const [hovered, setHovered] = useState(false) const [showCatPicker, setShowCatPicker] = useState(false) const [showBagPicker, setShowBagPicker] = useState(false) @@ -228,7 +225,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked) const handleSaveName = async () => { - if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return } + if (!editName.trim()) { setEditing(false); setEditName(item.name); return } try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) } catch { toast.error(t('packing.toast.saveError')) } } @@ -238,10 +235,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE catch { toast.error(t('packing.toast.deleteError')) } } - const handleCatChange = async (cat) => { + const handleCatChange = async (catId: number) => { setShowCatPicker(false) - if (cat === item.category) return - try { await updatePackingItem(tripId, item.id, { category: cat }) } + if (catId === item.category_id) return + try { await updatePackingItem(tripId, item.id, { category_id: catId }) } catch { toast.error(t('common.error')) } } @@ -280,10 +277,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE {editing && canEdit ? ( setEditName(e.target.value)} onBlur={handleSaveName} - onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }} + onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }} style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }} /> ) : ( @@ -292,7 +288,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE style={{ flex: 1, fontSize: 13.5, cursor: !canEdit || item.checked ? 'default' : 'text', - color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'), + color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)', transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)', textDecoration: item.checked ? 'line-through' : 'none', }} @@ -409,23 +405,25 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE title={t('packing.changeCategory')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 5px', borderRadius: 6, display: 'flex', alignItems: 'center', color: 'var(--text-faint)', fontSize: 10, gap: 2 }} > - + c.name)), display: 'inline-block' }} /> {showCatPicker && (
{categories.map(cat => ( - ))}
@@ -462,23 +460,25 @@ interface CategoryAssignee { } interface KategorieGruppeProps { - kategorie: string + category: PackingCategory items: PackingItem[] tripId: number - allCategories: string[] - onRename: (oldName: string, newName: string) => Promise - onDeleteAll: (items: PackingItem[]) => Promise - onAddItem: (category: string, name: string) => Promise + allCategories: PackingCategory[] + onRename: (categoryId: number, newName: string) => Promise + onDeleteAll: (categoryId: number) => Promise + onChangeType: (categoryId: number, type: 'shared' | 'personal' | 'private') => Promise + onAddItem: (categoryId: number, name: string) => Promise assignees: CategoryAssignee[] tripMembers: TripMember[] - onSetAssignees: (category: string, userIds: number[]) => Promise + onSetAssignees: (categoryId: number, userIds: number[]) => Promise bagTrackingEnabled?: boolean bags?: PackingBag[] onCreateBag: (name: string) => Promise canEdit?: boolean } -function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) { +function KategorieGruppe({ category, items, tripId, allCategories, onRename, onDeleteAll, onChangeType, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) { + const kategorie = category.name const [offen, setOffen] = useState(true) const [editingName, setEditingName] = useState(false) const [editKatName, setEditKatName] = useState(kategorie) @@ -505,12 +505,12 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on const abgehakt = items.filter(i => i.checked).length const alleAbgehakt = abgehakt === items.length - const dot = katColor(kategorie, allCategories) + const dot = katColor(kategorie, allCategories.map(c => c.name)) const handleSaveKatName = async () => { const neu = editKatName.trim() if (!neu || neu === kategorie) { setEditingName(false); setEditKatName(kategorie); return } - try { await onRename(kategorie, neu); setEditingName(false) } + try { await onRename(category.id, neu); setEditingName(false) } catch { toast.error(t('packing.toast.renameError')) } } @@ -525,7 +525,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on } } const handleDeleteAll = async () => { - await onDeleteAll(items) + await onDeleteAll(category.id) setShowMenu(false) } @@ -552,11 +552,26 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
)} + {/* Type pill */} + + + {category.type === 'shared' && } + {category.type === 'personal' && } + {category.type === 'private' && } + {t(`packing.type.${category.type}`).charAt(0).toUpperCase() + t(`packing.type.${category.type}`).slice(1)} + + + {/* Assignee chips */}
{assignees.map(a => (
{ e.stopPropagation(); if (canEdit) onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }} + onClick={e => { e.stopPropagation(); if (canEdit) onSetAssignees(category.id, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }} >
a.user_id !== m.id).map(a => a.user_id) : [...assignees.map(a => a.user_id), m.id] - onSetAssignees(kategorie, newIds) + onSetAssignees(category.id, newIds) }} style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', @@ -665,6 +680,10 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on } label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} /> {canEdit && <>
+ {category.type !== 'shared' && } label={t('packing.type.makeShared')} onClick={() => { onChangeType(category.id, 'shared'); setShowMenu(false) }} />} + {category.type !== 'personal' && } label={t('packing.type.makePersonal')} onClick={() => { onChangeType(category.id, 'personal'); setShowMenu(false) }} />} + {category.type !== 'private' && } label={t('packing.type.makePrivate')} onClick={() => { onChangeType(category.id, 'private'); setShowMenu(false) }} />} +
} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} /> }
@@ -689,7 +708,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on onChange={e => setNewItemName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) { - onAddItem(kategorie, newItemName.trim()) + onAddItem(category.id, newItemName.trim()) setNewItemName('') setTimeout(() => addItemRef.current?.focus(), 30) } @@ -698,7 +717,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on placeholder={t('packing.addItemPlaceholder')} style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 12.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }} /> - - +
+
+ setSaveTemplateName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }} + placeholder={t('packing.templateName')} + style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }} + /> + + +
+ + {t('packing.saveAsTemplate.hint')} +
)} {inlineHeader && canEdit && ( @@ -1223,22 +1303,42 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0, )} {canEdit && (addingCategory ? ( -
- setNewCatName(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleAddNewCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }} - placeholder={t('packing.newCategoryPlaceholder')} - style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }} - /> - - +
+
+ setNewCatName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleAddNewCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName(''); setNewCatType('shared') } }} + placeholder={t('packing.newCategoryPlaceholder')} + style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }} + /> + + +
+
+ {(['shared', 'personal', 'private'] as const).map(type => ( + + + + ))} +
) : (