Compare commits

...

39 Commits

Author SHA1 Message Date
Maurice 532010aa79 feat(journey): the journal settings list who a linked trip lets in
Under the contributors, the journey settings on desktop and phone get a section of their own, Through trips, with everyone a trip's journal link lets in, their role and the trip they come through. It is read only there, since the trip's settings manage it, and it leaves out whoever is a contributor already. getJourneyFull answers it as trip_access.
2026-10-18 22:04:41 +02:00
Maurice 451167bde3 fix(trips): the settings button as a pill beside the trip's, and the dialog centred
The settings button was a bare round icon next to the trip's back pill; it is the same pill now, with the gear and the word Settings. The settings dialog opens centred again: its fixed height already keeps it from jumping between areas, so it does not need to be pinned to the top.
2026-10-18 22:04:40 +02:00
Maurice 92181cdefb test(trips): the suites follow the settings dialog's areas and the journal link
The dialog's nav buttons are named by their area alone (aria-label), so a hint line naming invites no longer matches the invite button. The sharing cases open the Sharing area first, the wiring and More sheet tests drive the settings button and row, the hook test reads the toasts as keys (renderHook loads no locale), and the lazy list and store registry know the journal panel and store.
2026-10-18 20:49:38 +02:00
Maurice ffa7333bb1 test(journey): the journal link repository on its own, without the two reads nothing called
linkRecipientUserIds and listTripIdsForJourney had no caller and kept src/db/repositories under its 99 % statement floor. TJLREPO-001..007 cover the link rows, the members list, the reach across trips ending with the membership, the hide-skeletons choice of a link member, the role precedence and leaveJournalLink.
2026-10-18 20:40:46 +02:00
Maurice 48d573c60d refactor(trips): leaving a trip tidies its journal link in the roster's own repository
The departure port needed a global binding that every suite building TripMembersModule without AppModule lacked. The roster's repository now deletes the leaver's row on the trip's journal link itself (leaveJournalLink), inside the transaction that removes the member: no edge into journey, no DI, no test stub, and the eight suites the stub touched are back as they were.

The schema inventory tests count the two new tables and their NOT NULL columns, the legacy step map lists the migration as post-legacy, and the TJL scenes give their users numbered names so cases sharing one database do not collide.
2026-10-18 20:02:09 +02:00
Maurice 33c5c7decc test(shared): the event inventory counts trip:journal:changed 2026-10-18 19:23:56 +02:00
Maurice 3db20dd71c chore(client): theme baseline down one for the share button the navbar lost 2026-10-18 19:13:36 +02:00
Maurice 0751b3e8c9 fix(trips): the journal link's toast and tab texts as literal keys
lint:i18n-keys only reaches a key through a literal t() or a *Key property: the three save toasts were passed to run() as bare keys and the tab label came out of TRIP_TAB_LABEL_KEYS, so four new en keys counted as unused. They are translated where they are named now.
2026-10-18 19:04:10 +02:00
Maurice 4a074ec93c refactor(journey): the link role from the generated entity, not a second union
TripJournalLinksRepository spelled 'viewer' | 'editor' by hand while the generated entity exports the same roles, which knip then counted as an unused export. The repository now derives its type and values from the entity.
2026-10-18 18:54:02 +02:00
Maurice a6da69ee36 refactor(trips): the member departure port in common/, where both sides may import it
Declared in trip-members, the port gave journey an edge into the roster, which reaches journey through other domains: the cycle again, the other way round. In the shared kernel neither domain owns it.
2026-10-18 18:44:02 +02:00
Maurice ce8d1e5a0a refactor(trips): leaving a trip reaches the journal link through a port, not an import
TripMembersService called TripJournalLinkService directly, which made trip-members and journey depend on each other (lint:boundaries). The roster now declares TRIP_MEMBER_DEPARTURE and calls memberLeft in the transaction that removes the member; the journey domain binds it globally (TripMemberDepartureModule), the way JourneyAccessModule binds the gateway's JOURNEY_ACCESS. Suites that build TripMembersModule alone import MemberDepartureStubModule.
2026-10-18 18:31:26 +02:00
Maurice 340d429415 test(trips): the journal link stub beside the other journey share helpers
budget.service.db and trips.service tests are held at their size baselines; the stub now comes from journey-share-repos, which both already import, so neither suite grows.
2026-10-18 18:17:04 +02:00
Maurice 89c96ed554 fix(trips): the embedded journal's offsets as classes on its wrapper, not in index.css
index.css is held at its line baseline. The two variables the embedded journal overrides (--nav-h on the desktop, --m-safe-top on the phone) now sit as arbitrary properties on the wrapper that needs them.
2026-10-18 18:04:34 +02:00
Maurice 889e88c362 refactor(trips): the journal member choice in a hook of its own, the test seed without an await loop
useTripJournalLink had grown past the 120-line limit for a function. The member list (candidates per journal, roles, who is new) is now useJournalMemberChoice. What loaded is kept with the journal it belongs to, so a new pick shows nothing until its own people are in, without resetting state by hand. The integration test adds its trip members with Promise.all.
2026-10-18 17:55:41 +02:00
Maurice fe05afec19 feat(trips): trip settings with a linked journal tab
The Share button becomes the trip's Settings, a round button beside the back
pill. The dialog is rebuilt around three areas (People, Sharing, Journal) with
a side navigation, a fixed height and 960 px width on wide screens.

Journal links one of your journals from the Journey addon to the trip. Every
trip member is ticked as a reader by default, each can be raised to editor or
left out, and the people a save would let in for the first time are named
before it saves. The link can be changed or removed. With a link the trip gets
a Journal tab on desktop and phone that shows the Journey addon's own view,
live through its sync and trip:journal:changed. Nothing shows with the addon
off.

members.shareTrip and nav.share are gone with the button that used them.
2026-10-18 17:41:25 +02:00
Maurice 89f3055a29 fix(journey): one journal link per trip as a unique trip id, factories within tsc's depth
The link table keyed by the trip id made both entities a one-to-one, and the
test typecheck failed with TS2589 in the itinerary factory. It now holds its
own id with trip_id UNIQUE, as trip_invite_tokens does, and its members hang
off link_id.

The factory error was not this table's alone: any further relation on trips,
journeys or users tipped the same check. insertRow and createRow now take a
caller's overrides as their own argument instead of a spread checked against
RequiredEntityData, so a new relation no longer breaks the itinerary and tour
factories.

The suites that hand-build TripMembersService get a journalLinksStub.
2026-10-18 17:41:18 +02:00
Maurice 80b6739362 feat(journey): a trip can link a journal and open it to the members it picks
A trip can now show one journal from the Journey addon as its own: any
journal its owner picks, and one journal on as many trips as they like.
The trip members chosen for the link get into the journal as viewers or
editors, see it in their own journal list and follow it live, for as long
as the link holds them and they stay on the trip.

That access is never copied into journey_contributors. It is read where
journal access is read: the contributor repository answers with the
stronger of a direct role and a link role, the journal list and the live
update recipients take the link members in, and the photo access check
goes the same way. Every one of those reads starts from linkReach, which
only counts members still on the linking trip, so unlinking, deselecting
someone and their leaving the trip all end exactly what the link gave.
A member who leaves is also taken off the link, so coming back later does
not quietly reopen the journal.

Linking or changing the members takes the journal's owner and the new
journal_link trip permission (trip owner by default); unlinking takes
either. /api/trips/:tripId/journal answers the link as each member may see
it, /candidates says who a journal would be opened to for the first time,
and four MCP tools do the same over the same service. A trip:journal:changed
event tells the trip to look again.
2026-10-18 15:59:33 +02:00
Maurice e725d4f0a9 test(packing): sorted A to Z only the category grip stays draggable 2026-10-18 14:43:55 +02:00
Maurice 36f1b5b7d9 refactor(journey): read the stored dismissed suggestions as the list they are written as
The reader also took {id, dismissedAt} records, a shape nothing writes.
2026-10-18 14:07:25 +02:00
Luffy acce063dbf fix: fix journey test fail 2026-10-18 14:04:23 +02:00
Luffy d229613e0e fix(journey): add localized error messages for dismissing suggestions 2026-10-18 14:04:22 +02:00
Luffy 5622113348 fix(journey): persist dismissed suggestions in local storage 2026-10-18 14:04:22 +02:00
Maurice cd2ecf897b test(mcp): search_pois asks the index by Overture path 2026-10-18 13:58:30 +02:00
Maurice f5af9d1259 chore(deps-dev): bump source-map-js to 1.2.2 in /plugin-sdk
Replaces Dependabot's #2680, whose branch sat 983 commits behind dev.
2026-10-18 13:21:54 +02:00
Maurice cecfdf2dcc fix(packing): tell a dragged category by its data type, not by a state flag
A flag set on dragstart could still be unrendered when a quick drag reached
the next card, and the drop was lost. The category now travels in the drag
under its own type, read from the first dragover on and at the drop.
2026-10-18 13:21:54 +02:00
Maurice 23b28a1f3f feat(packing): drag a category by its head to move it among the others
Categories showed in the order they were created, which a list kept per day
cannot undo once the days swap. A grip in each category's head band moves
the whole category onto another's place on desktop. A category has no
position of its own, it shows where its first item is, so the move reorders
the items as one block through the item reorder the list already has: no
new column, and the order reaches everyone the way a dragged item does. The
other view's categories keep their order.
2026-10-18 13:21:54 +02:00
Maurice f7f6b83d33 refactor(journey,docsync): keep both services inside their size baseline
The time placement of a journey entry moves whole into journey-entry-order,
and a moved entry only gets a sort_order when it actually moves, so a patch
never carries an undefined one. The Synology login comment shrinks to a line.
2026-10-18 13:21:53 +02:00
Maurice 097c1da0af fix(mobile): a transport card shows the notes of its booking
On the phone the Transports tab never rendered a booking's notes, so what
someone typed into a flight was only to be found by opening its sheet,
while the booking cards next to it showed theirs. Both cards now take the
notes from one part, sized on the caption tier so the text size setting
reaches them.
2026-10-18 13:21:53 +02:00
Maurice a74a2cf9b5 fix(map): the day route waits out a busy router instead of keeping straight lines
The public OSRM hosts answer 429 above about one request a second. The trip
overview and the road trip waited that out and asked again; the day route
took the first refusal as final and drew straight lines between the stops
until the next edit, which is what a day with a change of mode or a quick
switch between days ran into. Both the day route and the overview now go
through one retry helper: a rate limit is waited out, as long as the host
asks when it says, and a refusal the host meant still ends at once.
2026-10-18 13:21:52 +02:00
Maurice dc1ff06573 fix(docsync): Synology login sends plain credentials, a refused test shows its reason
The login went to auth.cgi with account and password JSON-quoted, the way
the FileStation calls on entry.cgi take their values. entry.cgi reads JSON
where it can; auth.cgi takes a value as it comes, so DSM looked for a user
named "trek" with the quotes and refused every account. Both go plain now.

The test verdict sat truncated next to the dialog buttons ("The credentials
were re..."), with nothing to read the rest by. It wraps to two lines and
shows the store's own reason on hover; the phone sheet prints it below.

Closes #2666
2026-10-18 13:21:52 +02:00
Maurice fc631d36b6 fix(maps): category pills ask the index by Overture path, near misses are left out
The index matches a term anywhere inside a place's category text, so the
bare names behind the pills caught whatever was spelled like them: "park"
brought car parks and an amusement park, "garden" furniture stores and beer
gardens, "bar" barbers, "pub" publishers and public squares, "market"
marketing agencies. In Vienna four of sixty "Nature & parks" pins were
parks. Every pill now names its Overture categories by full path, a place
counts only when its path is that path or lies under it, and a page of
near misses only falls through to Overpass. Terms Overture no longer has
(nightclub, nature_preserve, shopping_center ...) got their current names.

Closes #2663
2026-10-18 13:21:52 +02:00
Maurice 6463060f67 chore(deps): bump concurrently to 10.0.6 and shell-quote to 1.12.0
Replaces Dependabot's #2658, closed because its security rebase kept
bouncing between main and dev. Only the two packages move in the lockfile.
2026-10-18 13:21:51 +02:00
Maurice 593f839592 fix(journey): a timed entry takes its place in the day by its time
A new entry always went to the end of its day, so writing up 1 am after
2 am left the night out of order, and changing an entry's time never moved
it. A timed entry now lands before the first entry of its day that starts
later, on create and when its time or day changes; untimed entries still
go last, and the hand-made order of the others stays as it was. A save
that leaves time and day untouched keeps an entry where it was dragged.

Closes #2679
2026-10-18 13:21:51 +02:00
Maurice a9c5cf6d9a fix(share): a stop's stay comes from its time window, not the one-hour place default
The public page showed places.duration_minutes under every stop, and that is
60 on every place nobody set a road-trip stay for, so a shared plan read "1 h"
everywhere. The stay is now the stop's start to end time, per visit, past
midnight included, and a stop without an end shows none.

Closes #2676
2026-10-18 13:21:51 +02:00
Maurice f901982903 fix(llm-parse): LLM_MAX_OUTPUT_TOKENS sets the answer cap, a cut-off answer says so
The OpenAI-compatible client always asked for 4096 tokens, and a document
whose bookings needed more came back as "no reservations" with nothing that
pointed at the cap. LLM_MAX_OUTPUT_TOKENS now sets the cap for every
provider; unset keeps 4096 and 8192 so a small local model's context still
fits. A finish_reason of "length" is reported like Anthropic's max_tokens
stop, naming the limit and the variable that raises it.

Closes #2677
2026-10-18 13:21:50 +02:00
Maurice 8f64431ff1 refactor(auth): one helper finds a backup code for login, reset and MFA disable
Keeps auth.service.ts inside its size baseline: the recovery-code check the
disable path gained is the lookup the password reset already spelled out.
2026-10-18 12:35:37 +02:00
Maurice c378722d63 test(auth): read the disabled MFA state through the ORM, formatted 2026-10-18 12:11:41 +02:00
Maurice bfec874d04 fix(i18n): mention backup codes in the disable-2FA hint for every locale 2026-10-18 11:47:11 +02:00
Jens Kowalsky a36019b7e2 fix(auth): allow backup codes for TOTP recovery 2026-10-18 11:47:10 +02:00
324 changed files with 5929 additions and 445 deletions
+2 -2
View File
@@ -55,7 +55,7 @@
"components/Layout/DemoBanner.tsx": 25, "components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 10, "components/Layout/InAppNotificationBell.tsx": 10,
"components/Layout/MobileTopBar.tsx": 51, "components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 22, "components/Layout/Navbar.tsx": 21,
"components/Layout/NewVersionNotice.tsx": 3, "components/Layout/NewVersionNotice.tsx": 3,
"components/Layout/OfflineBanner.tsx": 2, "components/Layout/OfflineBanner.tsx": 2,
"components/Layout/PageSidebar.tsx": 3, "components/Layout/PageSidebar.tsx": 3,
@@ -269,7 +269,7 @@
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7, "mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3, "mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13, "mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
"mobile/screens/trip/tabs/MBookingsTab.tsx": 3, "mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
"mobile/screens/trip/tabs/MCollabChat.tsx": 22, "mobile/screens/trip/tabs/MCollabChat.tsx": 22,
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19, "mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18, "mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
+31
View File
@@ -0,0 +1,31 @@
import type {
TripJournalCandidatesResponse,
TripJournalLinkMemberRequest,
TripJournalLinkResponse,
} from '@trek/shared';
import apiClient from './client';
/**
* `/api/trips/:tripId/journal`: the journal from the Journey addon a trip shows,
* and who it is open to. In its own file rather than in the `client.ts` god
* module; repo/tripJournalLinkRepo.ts is the one caller.
*/
export const tripJournalApi = {
get: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data),
candidates: (tripId: number, journeyId: number): Promise<TripJournalCandidatesResponse> =>
apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data),
link: (
tripId: number,
journeyId: number,
members: TripJournalLinkMemberRequest[]
): Promise<TripJournalLinkResponse> =>
apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data),
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> =>
apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data),
unlink: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.delete(`/trips/${tripId}/journal`).then((r) => r.data),
/** The journals this person can open; the dialog offers the ones they own. */
journals: (): Promise<{ journeys: { id: number; user_id: number; title: string; subtitle?: string | null }[] }> =>
apiClient.get('/journeys').then((r) => r.data),
};
+4
View File
@@ -93,6 +93,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// the bag ping: what a run changed is per-binding server state, and the // the bag ping: what a run changed is per-binding server state, and the
// files it moved arrive as file:* events. // files it moved arrive as file:* events.
'docsync:changed', 'docsync:changed',
// Linked journal: components/Trips/useTripJournalLink asks again which journal
// the trip shows and who it is open to. Content-free, because that answer is
// per person (a member left off the link must not learn its title).
'trip:journal:changed',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener) // Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress', 'import:progress',
'import:done', 'import:done',
@@ -24,7 +24,7 @@ export const CATEGORIES = [
{ id: 'members', keys: ['member_manage'] }, { id: 'members', keys: ['member_manage'] },
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] }, { id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] }, { id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] }, { id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage', 'journal_link'] },
]; ];
/** /**
@@ -1,12 +1,13 @@
import { useId, useState } from 'react' import { useId, useState } from 'react'
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react' import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell' import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
import { Tooltip } from '../../shared/Tooltip'
import ToggleSwitch from '../../Settings/ToggleSwitch' import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useTranslation } from '../../../i18n/TranslationContext' import { useTranslation } from '../../../i18n/TranslationContext'
import { useConnectForm } from './useConnectForm' import { useConnectForm } from './useConnectForm'
import { Badge } from './DocSyncBits' import { Badge } from './DocSyncBits'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons' import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import type { DocSyncProvider, useDocSync } from './useDocSync' import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
/** /**
* Connecting a store, as a dialog rather than a panel that unfolds in place. * Connecting a store, as a dialog rather than a panel that unfolds in place.
@@ -32,7 +33,7 @@ export default function DocSyncConnectModal({
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id] const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
const form = useConnectForm(provider, existing, sync) const form = useConnectForm(provider, existing, sync)
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null) const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
const test = async () => setVerdict(await form.probe()) const test = async () => setVerdict(await form.probe())
const labelId = useId() const labelId = useId()
@@ -134,7 +135,7 @@ function Verdict({
busy, busy,
failure, failure,
}: { }: {
verdict: { connected: boolean; account?: string; error?: string } | null verdict: DocSyncVerdict | null
busy: boolean busy: boolean
/** A call that failed outside the connection test (saving, most of all). */ /** A call that failed outside the connection test (saving, most of all). */
failure: string | null failure: string | null
@@ -172,10 +173,16 @@ function Verdict({
) )
} }
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted' const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
const message = t(`docsync.error.${verdict.error || 'unknown'}`)
// The footer shares its row with the buttons, and a refusal cut to "The
// credentials were re..." with no way to read the rest left nothing to go on
// (#2666). Two lines, and the whole of it with the store's reason on hover.
return ( return (
<span className="flex min-w-0 items-center gap-2 text-caption text-danger"> <Tooltip label={verdict.detail ? `${message} ${verdict.detail}` : message} placement="top">
{blocked ? <ShieldAlert size={14} /> : <AlertTriangle size={14} />} <span className="flex min-w-0 items-center gap-2 text-caption text-danger">
<span className="truncate">{t(`docsync.error.${verdict.error || 'unknown'}`)}</span> {blocked ? <ShieldAlert size={14} className="flex-none" /> : <AlertTriangle size={14} className="flex-none" />}
</span> <span className="line-clamp-2 [overflow-wrap:anywhere]">{message}</span>
</span>
</Tooltip>
) )
} }
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import type { DocSyncConnection, DocSyncProvider, useDocSync } from './useDocSync' import type { DocSyncConnection, DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
/** /**
* The credential form's rules, for both shells. * The credential form's rules, for both shells.
@@ -39,7 +39,7 @@ export interface ConnectForm {
setInsecureTls: (on: boolean) => void setInsecureTls: (on: boolean) => void
/** Every required field is either filled in or already stored. */ /** Every required field is either filled in or already stored. */
complete: boolean complete: boolean
probe: () => Promise<{ connected: boolean; account?: string; error?: string }> probe: () => Promise<DocSyncVerdict>
save: () => Promise<boolean> save: () => Promise<boolean>
} }
@@ -12,6 +12,14 @@ import { useServerPing } from '../../../sync/useServerPing'
* reason for the same problem. * reason for the same problem.
*/ */
/** What a connection test answers; `detail` is the store's own reason, for the person fixing it. */
export interface DocSyncVerdict {
connected: boolean
account?: string
error?: string
detail?: string
}
export interface DocSyncField { export interface DocSyncField {
field_key: string field_key: string
label: string label: string
@@ -264,11 +272,9 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => { const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
setBusy('test') setBusy('test')
try { try {
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as { return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as DocSyncVerdict
connected: boolean; account?: string; error?: string; detail?: string
}
} catch (e: unknown) { } catch (e: unknown) {
return { connected: false, error: readError(e) } return { connected: false, error: readError(e) } satisfies DocSyncVerdict
} finally { } finally {
setBusy(null) setBusy(null)
} }
@@ -98,6 +98,19 @@ describe('JourneySettingsDialog', () => {
await screen.findByRole('button', { name: /create share link/i }) await screen.findByRole('button', { name: /create share link/i })
}) })
it('FE-JRN-SETTINGS-2683: lists the people a linked trip lets in under their own heading, and nothing without', () => {
const { unmount } = mountDialog()
expect(screen.queryByText('Through trips')).not.toBeInTheDocument()
unmount()
mountDialog(buildJourney({
trip_access: [{ user_id: 7, role: 'viewer', username: 'lena', avatar: null, trip_id: 5, trip_title: 'Italy Trip' }],
}))
expect(screen.getByText('Through trips')).toBeInTheDocument()
expect(screen.getByText('lena')).toBeInTheDocument()
expect(screen.getByText('via Italy Trip')).toBeInTheDocument()
})
it('FE-JRN-SETTINGS-002: saves the edited title and subtitle', async () => { it('FE-JRN-SETTINGS-002: saves the edited title and subtitle', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const { onSaved } = mountDialog() const { onSaved } = mountDialog()
@@ -7,7 +7,7 @@ import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection,
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts' import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
import JourneyShareSection from './JourneyShareSection' import JourneyShareSection from './JourneyShareSection'
import { useJourneySettings } from './useJourneySettings' import { useJourneySettings } from './useJourneySettings'
import type { JourneyContributor, JourneyDetail } from '../../store/journeyStore' import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore'
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { AddTripDialog } from './JourneyDetailPageAddTripDialog' import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
import ToggleSwitch from '../Settings/ToggleSwitch' import ToggleSwitch from '../Settings/ToggleSwitch'
@@ -277,6 +277,8 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
</div> </div>
<AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton> <AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton>
</DialogSection> </DialogSection>
<TripAccessSection access={journey.trip_access} roleLabel={roleLabel} />
</div> </div>
</div> </div>
@@ -324,3 +326,32 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
</> </>
) )
} }
/**
* Who a trip's linked journal lets in (#2683), under its own heading below the
* contributors: shown here, but managed in that trip's settings, so read only.
*/
function TripAccessSection({ access, roleLabel }: {
access: JourneyTripAccess[] | undefined
roleLabel: (role: JourneyContributor['role']) => string
}) {
const { t } = useTranslation()
if (!access?.length) return null
return (
<DialogSection label={t('journey.contributors.viaTrips')}>
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('journey.contributors.viaTripsHint')}</p>
<div className={ROWS}>
{access.map(a => (
<div key={`${a.user_id}-${a.trip_id}`} className={ROW}>
<TripMemberAvatar username={a.username} avatarUrl={avatarSrc(a.avatar)} size={28} />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{a.username}</div>
<div className="truncate text-content-faint" style={fs(11)}>{t('journey.contributors.viaTrip', { trip: a.trip_title })}</div>
</div>
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{roleLabel(a.role)}</span>
</div>
))}
</div>
</DialogSection>
)
}
+14 -18
View File
@@ -169,23 +169,24 @@ describe('Navbar', () => {
expect(adminLink).toHaveAttribute('href', '/admin'); expect(adminLink).toHaveAttribute('href', '/admin');
}); });
it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => { it('FE-COMP-NAVBAR-019: the trip settings button sits beside the back pill when onSettings is given', () => {
render(<Navbar onShare={vi.fn()} />); render(<Navbar tripTitle="Japan" showBack onBack={vi.fn()} onSettings={vi.fn()} />);
const shareBtn = screen.getByRole('button', { name: /share/i }); const back = screen.getByRole('button', { name: /back/i });
expect(shareBtn).toBeInTheDocument(); const settings = screen.getByRole('button', { name: 'Trip settings' });
expect(back.parentElement).toBe(settings.closest('div'));
}); });
it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => { it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onShare = vi.fn(); const onSettings = vi.fn();
render(<Navbar onShare={onShare} />); render(<Navbar onSettings={onSettings} />);
const shareBtn = screen.getByRole('button', { name: /share/i }); await user.click(screen.getByRole('button', { name: 'Trip settings' }));
await user.click(shareBtn); expect(onSettings).toHaveBeenCalled();
expect(onShare).toHaveBeenCalled();
}); });
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => { it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => {
render(<Navbar />); render(<Navbar />);
expect(screen.queryByRole('button', { name: 'Trip settings' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
}); });
@@ -453,8 +454,8 @@ describe('Navbar styling and menu details', () => {
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument(); expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
}); });
it('FE-W5NAV-009: the back, share and theme buttons reset their hover background', () => { it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => {
render(<Navbar showBack onBack={vi.fn()} onShare={vi.fn()} />); render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />);
const back = screen.getByRole('button', { name: /back/i }); const back = screen.getByRole('button', { name: /back/i });
fireEvent.mouseEnter(back); fireEvent.mouseEnter(back);
@@ -462,11 +463,6 @@ describe('Navbar styling and menu details', () => {
fireEvent.mouseLeave(back); fireEvent.mouseLeave(back);
expect(back.style.background).toBe('transparent'); expect(back.style.background).toBe('transparent');
const share = screen.getByRole('button', { name: /share/i });
fireEvent.mouseEnter(share);
expect(share.style.background).toBe('var(--bg-hover)');
fireEvent.mouseLeave(share);
expect(share.style.background).toBe('var(--bg-card)');
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement; const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
fireEvent.mouseEnter(theme); fireEvent.mouseEnter(theme);
+19 -14
View File
@@ -6,7 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { usePluginStore } from '../../store/pluginStore' import { usePluginStore } from '../../store/pluginStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react' import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx' import InAppNotificationBell from './InAppNotificationBell.tsx'
import HelpButton from '../Help/HelpButton' import HelpButton from '../Help/HelpButton'
@@ -21,7 +21,8 @@ interface NavbarProps {
tripId?: number | string tripId?: number | string
onBack?: () => void onBack?: () => void
showBack?: boolean showBack?: boolean
onShare?: () => void /** The trip's Settings: who is on it, its links and its journal. */
onSettings?: () => void
} }
interface Addon { interface Addon {
@@ -32,7 +33,7 @@ interface Addon {
enabled: boolean enabled: boolean
} }
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement { export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
const { user, logout, isPrerelease, appVersion } = useAuthStore() const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { settings, updateSetting } = useSettingsStore() const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore() const { addons: allAddons, loadAddons } = useAddonStore()
@@ -173,6 +174,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span> <span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
</span> </span>
))} ))}
{/* The trip's Settings sit with the trip, beside its pill: they are about this
trip, not about the app the right side of the bar belongs to. */}
{onSettings && (
// The same pill as the trip's own beside it, so the two read as one pair.
<button
type="button"
onClick={onSettings}
aria-label={t('trip.settings.title')}
className="inline-flex flex-none items-center gap-1.5 rounded-full bg-surface-card py-1 ps-2 pe-3 text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<Settings className="h-4 w-4 flex-none text-content-muted" aria-hidden="true" />
<span className="text-caption font-semibold">{t('nav.settings')}</span>
</button>
)}
</div> </div>
{/* Centred liquid-glass tab menu (design handoff). {/* Centred liquid-glass tab menu (design handoff).
@@ -245,17 +261,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
itself on top of them. */} itself on top of them. */}
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end"> <div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
{/* Share button */}
{onShare && (
<button type="button" onClick={onShare}
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0 border-edge text-content-secondary bg-surface-card"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
<Users className="w-4 h-4" />
<span className="hidden sm:inline">{t('nav.share')}</span>
</button>
)}
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */} {/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
{user && <HelpButton />} {user && <HelpButton />}
@@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
import { calculateRouteWithRetries, ROUTE_RETRY_DELAYS_MS } from './routeRetry';
vi.mock('./RouteCalculator', async (importActual) => {
const actual = await importActual<typeof import('./RouteCalculator')>();
return { ...actual, calculateRouteWithLegs: vi.fn() };
});
const WAYPOINTS = [
{ lat: 47.5, lng: 19.04 },
{ lat: 47.49, lng: 19.05 },
];
const ROUTED = {
coordinates: [
[47.5, 19.04],
[47.49, 19.05],
],
legs: [],
} as unknown as Awaited<ReturnType<typeof calculateRouteWithLegs>>;
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(calculateRouteWithLegs).mockReset();
});
afterEach(() => vi.useRealTimers());
describe('calculateRouteWithRetries', () => {
it('waits out a rate limit and answers with the road (Discord, 16.10.2026)', async () => {
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
.mockResolvedValueOnce(ROUTED);
const answer = calculateRouteWithRetries(WAYPOINTS, { profile: 'driving' });
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
await expect(answer).resolves.toBe(ROUTED);
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2);
});
it('waits as long as the host asks when that is longer', async () => {
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new RoutingRefusedError(503, 3000))
.mockResolvedValueOnce(ROUTED);
const answer = calculateRouteWithRetries(WAYPOINTS, {});
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(3000 - ROUTE_RETRY_DELAYS_MS[0]);
await expect(answer).resolves.toBe(ROUTED);
});
it('gives up after the last delay and throws the refusal', async () => {
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(429, null));
const answer = calculateRouteWithRetries(WAYPOINTS, {});
const settled = expect(answer).rejects.toBeInstanceOf(RoutingRefusedError);
for (const ms of ROUTE_RETRY_DELAYS_MS) await vi.advanceTimersByTimeAsync(ms);
await settled;
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(ROUTE_RETRY_DELAYS_MS.length + 1);
});
it('throws a refusal the host meant at once', async () => {
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(400, null));
await expect(calculateRouteWithRetries(WAYPOINTS, {})).rejects.toBeInstanceOf(RoutingRefusedError);
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
});
it('stops waiting when the day changes', async () => {
const controller = new AbortController();
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
const answer = calculateRouteWithRetries(WAYPOINTS, { signal: controller.signal });
const settled = expect(answer).rejects.toMatchObject({ name: 'AbortError' });
await vi.advanceTimersByTimeAsync(10);
controller.abort();
await settled;
});
});
+48
View File
@@ -0,0 +1,48 @@
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
// The profile a caller hands in, so a hook routing through here needs nothing else from Map/.
export type { RouteProfileKey } from './RouteCalculator';
/** How often a rate-limited route is asked for again, and how long after. */
export const ROUTE_RETRY_DELAYS_MS = [1500, 4000];
/** A pause that ends early, without throwing, when the signal aborts. */
export const sleepUnlessAborted = (ms: number, signal?: AbortSignal): Promise<void> =>
new Promise((resolve) => {
if (signal?.aborted || ms <= 0) {
resolve();
return;
}
const timer = setTimeout(done, ms);
function done(): void {
clearTimeout(timer);
signal?.removeEventListener('abort', done);
resolve();
}
signal?.addEventListener('abort', done, { once: true });
});
/**
* `calculateRouteWithLegs`, asked again while the host says it is busy.
*
* The public OSRM hosts allow about one request a second and answer 429 above that. A
* day with a change of mode, or a quick switch between days, asks more often than that,
* and the day route used to give up on the first refusal and keep its straight lines
* until the next edit (reported on Discord, 16.10.2026). A rate limit is waited out, for
* as long as the host asks when it says; a refusal the host meant is thrown at once,
* because the same request earns the same answer. An abort ends the wait, and the next
* attempt then throws the AbortError the caller already handles.
*/
export async function calculateRouteWithRetries(
...[waypoints, opts]: Parameters<typeof calculateRouteWithLegs>
): ReturnType<typeof calculateRouteWithLegs> {
for (let attempt = 0; ; attempt++) {
try {
return await calculateRouteWithLegs(waypoints, opts);
} catch (err) {
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null;
if (!rateLimit || attempt >= ROUTE_RETRY_DELAYS_MS.length || opts?.signal?.aborted) throw err;
await sleepUnlessAborted(Math.max(ROUTE_RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), opts?.signal);
}
}
}
+12 -32
View File
@@ -1,4 +1,5 @@
import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator' import type { RouteProfileKey } from './RouteCalculator'
import { calculateRouteWithRetries, sleepUnlessAborted as sleep } from './routeRetry'
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan' import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias' import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias'
import { projectDayItinerary } from './dayTourProjection' import { projectDayItinerary } from './dayTourProjection'
@@ -185,20 +186,6 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
const REQUEST_SPACING_MS = 1100 const REQUEST_SPACING_MS = 1100
/** Anything answered faster than this came out of RouteCalculator's cache, not the network. */ /** Anything answered faster than this came out of RouteCalculator's cache, not the network. */
const CACHE_HIT_MS = 60 const CACHE_HIT_MS = 60
/** How often a rate-limited leg is tried again, and how long after. */
const RETRY_DELAYS_MS = [1500, 4000]
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
new Promise(resolve => {
if (signal?.aborted || ms <= 0) { resolve(); return }
const timer = setTimeout(done, ms)
function done(): void {
clearTimeout(timer)
signal?.removeEventListener('abort', done)
resolve()
}
signal?.addEventListener('abort', done, { once: true })
})
/** /**
* Ask the router for every leg of every day, one at a time. * Ask the router for every leg of every day, one at a time.
@@ -233,23 +220,16 @@ export async function routeTripLegs(
runs.forEach((chunks, r) => { runs.forEach((chunks, r) => {
chunks.forEach((chunk, c) => { chunks.forEach((chunk, c) => {
tasks.push(async () => { tasks.push(async () => {
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) { if (signal?.aborted) return
if (signal?.aborted) return try {
try { const answer = await calculateRouteWithRetries(
const answer = await calculateRouteWithLegs( chunk.waypoints,
chunk.waypoints, { signal, profile: chunk.mode, tripId, dayId: day.id },
{ signal, profile: chunk.mode, tripId, dayId: day.id }, )
) routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) } onAnswer?.(routed)
onAnswer?.(routed) } catch {
return // Refused for good, or aborted: the leg keeps its straight line.
} catch (err) {
if (signal?.aborted) return
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null
if (!rateLimit || attempt === RETRY_DELAYS_MS.length) return
// When the host says how long to wait, waiting less is just a second refusal.
await sleep(Math.max(RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), signal)
}
} }
}) })
}) })
@@ -271,7 +271,9 @@ describe('PackingListPanel', () => {
]; ];
const { container } = render(<PackingListPanel tripId={1} items={items} />); const { container } = render(<PackingListPanel tripId={1} items={items} />);
expect(screen.getByRole('button', { name: 'A to Z' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: 'A to Z' })).toHaveAttribute('aria-pressed', 'true');
expect(container.querySelector('[draggable="true"]')).toBeNull(); // Only the category grip stays: the order of the categories is not the A to Z of their items.
const draggable = [...container.querySelectorAll('[draggable="true"]')];
expect(draggable.map((el) => el.getAttribute('aria-label'))).toEqual(['Drag to reorder the category']);
}); });
it('FE-COMP-PACKING-023: inline edit item name via pencil icon calls PUT', async () => { it('FE-COMP-PACKING-023: inline edit item name via pencil icon calls PUT', async () => {
@@ -4,7 +4,7 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { import {
Trash2, Plus, ChevronDown, ChevronRight, Trash2, Plus, ChevronDown, ChevronRight,
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus, GripVertical,
} from 'lucide-react' } from 'lucide-react'
import type { PackingItem, PackingBag } from '../../types' import type { PackingItem, PackingBag } from '../../types'
import { katColor } from './packingListPanel.helpers' import { katColor } from './packingListPanel.helpers'
@@ -38,6 +38,16 @@ interface KategorieGruppeProps {
// Off while the list is shown A-Z: a drop there would move an item in an order // Off while the list is shown A-Z: a drop there would move an item in an order
// the reader cannot see. // the reader cannot see.
reorderable?: boolean reorderable?: boolean
// Moving the whole category among the others, wired by the list, which sees every
// card. `onDrop` gets the category that was dragged onto this one.
categoryDrag?: {
isDragging: boolean
isOver: boolean
onStart: () => void
onOver: () => void
onEnd: () => void
onDrop: (dragged: string) => void
}
// Three-tier sharing (#858) — threaded down to each item's share control. // Three-tier sharing (#858) — threaded down to each item's share control.
currentUserId?: number currentUserId?: number
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
@@ -46,7 +56,7 @@ interface KategorieGruppeProps {
onLeave?: (id: number, userId: number) => void onLeave?: (id: number, userId: number) => void
} }
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) { export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, categoryDrag, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true) const [offen, setOffen] = useState(true)
const [dragId, setDragId] = useState<number | null>(null) const [dragId, setDragId] = useState<number | null>(null)
const [overId, setOverId] = useState<number | null>(null) const [overId, setOverId] = useState<number | null>(null)
@@ -120,8 +130,9 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
return ( return (
// Shaped after the phone's category card: a tinted head band carrying the // Shaped after the phone's category card: a tinted head band carrying the
// name as written, the count, and the fold arrow on the far right. // name as written, the count, and the fold arrow on the far right.
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible' }}> <CategoryDropFrame drag={categoryDrag}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBlock: 11, paddingInlineEnd: 12, paddingInlineStart: 14, background: 'var(--bg-tertiary)', borderRadius: offen ? '15px 15px 0 0' : 15 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBlock: 11, paddingInlineEnd: 12, paddingInlineStart: 14, background: 'var(--bg-tertiary)', borderRadius: offen ? '15px 15px 0 0' : 15 }}>
<CategoryGrip drag={categoryDrag} kategorie={kategorie} label={t('packing.dragCategory')} />
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} /> <span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
{editingName && canEdit ? ( {editingName && canEdit ? (
@@ -344,6 +355,65 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
))} ))}
</div> </div>
)} )}
</CategoryDropFrame>
)
}
type CategoryDrag = NonNullable<KategorieGruppeProps['categoryDrag']>
/** The data type a dragged category carries, its name as the value. */
const CATEGORY_DRAG_TYPE = 'application/x-trek-packing-category'
/**
* The card, and while a category is dragged its drop target. Captured, because
* the item rows stop their own drops from bubbling.
*/
function CategoryDropFrame({ drag, children }: { drag?: CategoryDrag; children: React.ReactNode }) {
// Told apart by the drag's own data type, which is there from the first event on:
// a flag set in state on dragstart can still be unrendered when a quick drag
// arrives, and a dragged item must pass through to its rows untouched.
const isCategoryDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(CATEGORY_DRAG_TYPE)
return (
<div
onDragOverCapture={drag ? (e => {
if (!isCategoryDrag(e)) return
e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag.onOver()
}) : undefined}
onDropCapture={drag ? (e => {
if (!isCategoryDrag(e)) return
e.preventDefault(); e.stopPropagation(); drag.onDrop(e.dataTransfer.getData(CATEGORY_DRAG_TYPE))
}) : undefined}
style={{
marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible',
opacity: drag?.isDragging ? 0.4 : 1,
boxShadow: drag?.isOver ? '0 0 0 2px var(--accent)' : 'none',
transition: 'opacity 0.15s, box-shadow 0.15s',
}}
>
{children}
</div> </div>
) )
} }
/** The handle a category is dragged by, in the head band before its colour dot. */
function CategoryGrip({ drag, kategorie, label }: { drag?: CategoryDrag; kategorie: string; label: string }) {
if (!drag) return null
return (
<span
draggable
role="button"
aria-label={label}
title={label}
onDragStart={e => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData(CATEGORY_DRAG_TYPE, kategorie)
e.dataTransfer.setData('text/plain', kategorie)
drag.onStart()
}}
onDragEnd={drag.onEnd}
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, marginInlineStart: -6 }}
>
<GripVertical size={14} />
</span>
)
}
@@ -1,3 +1,4 @@
import { useState } from 'react'
import type { PackingState } from './usePackingListPanel' import type { PackingState } from './usePackingListPanel'
import { KategorieGruppe } from './PackingListPanelCategoryGroup' import { KategorieGruppe } from './PackingListPanelCategoryGroup'
import EmptyState from '../shared/EmptyState' import EmptyState from '../shared/EmptyState'
@@ -7,8 +8,13 @@ export function PackingList(S: PackingState) {
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem, items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees, handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems, bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort, currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort, moveCategory,
} = S } = S
// Which category is being dragged and which card it is over. Held here, above
// the cards, because the drag starts in one card and ends in another.
const [draggedCategory, setDraggedCategory] = useState<string | null>(null)
const [overCategory, setOverCategory] = useState<string | null>(null)
const endCategoryDrag = () => { setDraggedCategory(null); setOverCategory(null) }
return ( return (
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}> <div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
{items.length === 0 ? ( {items.length === 0 ? (
@@ -40,6 +46,14 @@ export function PackingList(S: PackingState) {
allItems={items} allItems={items}
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)} onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
reorderable={sort === 'manual'} reorderable={sort === 'manual'}
categoryDrag={canEdit ? {
isDragging: draggedCategory === kat,
isOver: overCategory === kat && draggedCategory !== null && draggedCategory !== kat,
onStart: () => { setDraggedCategory(kat); setOverCategory(null) },
onOver: () => setOverCategory(kat),
onEnd: endCategoryDrag,
onDrop: (dragged) => { moveCategory(dragged, kat); endCategoryDrag() },
} : undefined}
currentUserId={currentUserId} currentUserId={currentUserId}
onSetSharing={handleSetSharing} onSetSharing={handleSetSharing}
onClone={handleCloneItem} onClone={handleCloneItem}
@@ -6,6 +6,7 @@ import {
groupPackingItems, groupPackingItems,
isLastCustomItemInCategory, isLastCustomItemInCategory,
isPackingPlaceholder, isPackingPlaceholder,
itemOrderWithCategoryMoved,
packingCategoryOrder, packingCategoryOrder,
packingItemWeight, packingItemWeight,
packingProgress, packingProgress,
@@ -14,7 +15,7 @@ import {
} from './packingListModel'; } from './packingListModel';
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'; import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
// FE-PACK-MODEL-001 to FE-PACK-MODEL-008 // FE-PACK-MODEL-001 to FE-PACK-MODEL-009
const shared = buildPackingItem({ id: 1, name: 'Tent', category: 'Camp', checked: 1 }); const shared = buildPackingItem({ id: 1, name: 'Tent', category: 'Camp', checked: 1 });
const mine = buildPackingItem({ id: 2, name: 'Socks', category: 'Clothes', is_private: 1 }); const mine = buildPackingItem({ id: 2, name: 'Socks', category: 'Clothes', is_private: 1 });
@@ -76,4 +77,28 @@ describe('packingListModel', () => {
expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true); expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true);
expect(isPackingPlaceholder({ name: 'Tent' })).toBe(false); expect(isPackingPlaceholder({ name: 'Tent' })).toBe(false);
}); });
it('FE-PACK-MODEL-009: moving a category moves its items as a block, the other view stays as it was', () => {
const day1 = buildPackingItem({ id: 11, name: 'Shirt', category: 'Day 1' });
const day2a = buildPackingItem({ id: 12, name: 'Shorts', category: 'Day 2' });
const own = buildPackingItem({ id: 13, name: 'Pills', category: 'Day 2', is_private: 1 });
const day3 = buildPackingItem({ id: 14, name: 'Jacket', category: 'Day 3' });
const day2b = buildPackingItem({ id: 15, name: 'Hat', category: 'Day 2' });
const items = [day1, day2a, own, day3, day2b];
const common = packingViewItems(items, 'common');
const order = itemOrderWithCategoryMoved(items, common, 'Day 3', 'Day 1', 'Other');
expect(order).toEqual([14, 11, 12, 15, 13]);
const moved = (order ?? []).flatMap((id) => items.filter((i) => i.id === id));
expect(packingCategoryOrder(packingViewItems(moved, 'common'), 'Other')).toEqual(['Day 3', 'Day 1', 'Day 2']);
expect(packingCategoryOrder(packingViewItems(moved, 'personal'), 'Other')).toEqual(['Day 2']);
expect(itemOrderWithCategoryMoved(items, common, 'Day 1', 'Day 3', 'Other')).toEqual([12, 15, 14, 11, 13]);
});
it('FE-PACK-MODEL-009b: nothing moves for the same category or one the view does not show', () => {
const items = [shared, stove, loose];
expect(itemOrderWithCategoryMoved(items, items, 'Camp', 'Camp', 'Other')).toBeNull();
expect(itemOrderWithCategoryMoved(items, items, 'Clothes', 'Camp', 'Other')).toBeNull();
expect(itemOrderWithCategoryMoved(items, items, 'Other', 'Camp', 'Other')).toEqual([4, 1, 3]);
});
}); });
@@ -25,6 +25,34 @@ export function packingCategoryOrder(viewItems: PackingItem[], defaultCategory:
return seen; return seen;
} }
/**
* The whole list's item order with category `from` moved to where `to` stands.
*
* A category has no position of its own: it shows where its first item is. So a
* category is moved by moving its items, as one block, through the item reorder
* the list already has; nothing new is stored, and the order reaches every device
* the way a dragged item does. The view's items come first, grouped in the new
* order with each group in its own order; the other view's items follow in theirs,
* which leaves that view's categories as they were. Null when nothing would move.
*/
export function itemOrderWithCategoryMoved(
items: PackingItem[],
viewItems: PackingItem[],
from: string,
to: string,
defaultCategory: string
): number[] | null {
const order = packingCategoryOrder(viewItems, defaultCategory);
const fromIndex = order.indexOf(from);
const toIndex = order.indexOf(to);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null;
order.splice(fromIndex, 1);
order.splice(toIndex, 0, from);
const inView = new Set(viewItems.map((i) => i.id));
const grouped = order.flatMap((cat) => viewItems.filter((i) => (i.category || defaultCategory) === cat));
return [...grouped, ...items.filter((i) => !inView.has(i.id))].map((i) => i.id);
}
export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] { export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] {
if (status === 'open') return items.filter((i) => !i.checked); if (status === 'open') return items.filter((i) => !i.checked);
if (status === 'done') return items.filter((i) => !!i.checked); if (status === 'done') return items.filter((i) => !!i.checked);
@@ -8,7 +8,7 @@ import { tripsApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import type { PackingItem } from '../../types' import type { PackingItem } from '../../types'
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers' import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
import { groupPackingItems, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel' import { groupPackingItems, itemOrderWithCategoryMoved, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
import { usePackingBags } from './usePackingBags' import { usePackingBags } from './usePackingBags'
import { usePackingCategoryAssignees } from './usePackingCategoryAssignees' import { usePackingCategoryAssignees } from './usePackingCategoryAssignees'
import { usePackingImport } from './usePackingImport' import { usePackingImport } from './usePackingImport'
@@ -106,6 +106,13 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems) const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems)
// A category dropped onto another takes its place; the slice reorders
// optimistically and rolls back with a toast of its own.
const moveCategory = (from: string, to: string) => {
const orderedIds = itemOrderWithCategoryMoved(items, viewItems, from, to, t('packing.defaultCategory'))
if (orderedIds) void reorderPackingItems(tripId, orderedIds)
}
const { const {
addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName, addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName,
addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory, addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory,
@@ -213,7 +220,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems, tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
filter, setFilter, sort, setSort, addingCategory, setAddingCategory, newCatName, setNewCatName, filter, setFilter, sort, setSort, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt, tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt, moveCategory,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked, handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal, bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers, handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
@@ -308,9 +308,10 @@ export default function AccountTab(): React.ReactElement {
/> />
<input <input
type="text" type="text"
inputMode="numeric" inputMode="text"
autoComplete="one-time-code"
value={mfaDisableCode} value={mfaDisableCode}
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))} onChange={e => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
placeholder={t('settings.mfa.codePlaceholder')} placeholder={t('settings.mfa.codePlaceholder')}
aria-label={t('settings.mfa.codePlaceholder')} aria-label={t('settings.mfa.codePlaceholder')}
className={`${INPUT} font-geist tabular-nums`} className={`${INPUT} font-geist tabular-nums`}
@@ -0,0 +1,37 @@
import { BookOpen, Link2 } from 'lucide-react';
/**
* The trip's Journal tab: a book with a link on its corner, because the journal
* is not the trip's own but one from the Journey addon tied to it. Takes the
* props the tab bars hand a lucide icon.
*
* The book is masked out under the link rather than the link given a backing:
* a backing has to match whatever the icon sits on (a card, the accent of a
* picked tab), and the moment it does not, the link vanishes into it.
*/
export function LinkedJournalIcon({
size = 16,
strokeWidth = 2,
className,
}: {
size?: number;
strokeWidth?: number;
className?: string;
}) {
const badge = Math.round(size * 0.6);
// The hole: a circle round the link's centre, a little wider than the link.
const hole = `radial-gradient(circle at calc(100% + 2px) calc(100% + 1px), transparent ${badge * 0.62}px, currentColor ${badge * 0.62 + 0.5}px)`;
return (
// dir="ltr": the hole is cut on the right, so the link has to stay there in Arabic too.
<span
dir="ltr"
className={`relative inline-flex flex-none ${className ?? ''}`}
style={{ width: size, height: size }}
>
<span className="inline-flex" style={{ maskImage: hole, WebkitMaskImage: hole }}>
<BookOpen size={size} strokeWidth={strokeWidth} />
</span>
<Link2 size={badge} strokeWidth={strokeWidth + 0.6} className="absolute -bottom-[3px] -end-[4px]" />
</span>
);
}
@@ -0,0 +1,150 @@
// FE-TJL-SECTION-001 to FE-TJL-SECTION-007
/**
* The Journal part of the trip's Settings, rendered: what someone sees with no
* journal linked, with one linked, while picking, and the warning that names
* the people a save would let in for the first time.
*/
import userEvent from '@testing-library/user-event';
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { seedStore } from '../../../tests/helpers/store';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
import { LinkedJournalIcon } from './LinkedJournalIcon';
import { TripJournalLinkSection } from './TripJournalLinkSection';
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const TRIP = 4;
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
const LINKED: TripJournalLinkResponse = {
link: {
journey_id: 9,
title: 'Japan',
subtitle: null,
cover_image: 'covers/j.jpg',
owner_id: 1,
linked_by: 1,
created_at: 1,
my_role: 'owner',
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
},
can_link: true,
can_unlink: true,
can_edit_members: true,
};
const CANDIDATES: TripJournalCandidate[] = [
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: true, access: 'editor' },
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
];
let answer: TripJournalLinkResponse = EMPTY;
let deletes = 0;
beforeEach(() => {
answer = EMPTY;
deletes = 0;
vi.clearAllMocks();
useTripJournalLinkStore.setState({ byTrip: {} });
seedStore(useAuthStore, { user: buildUser({ id: 1 }) });
useAddonStore.setState({
loaded: true,
addons: [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }],
});
server.use(
http.get('/api/trips/:tripId/journal', () => HttpResponse.json(answer)),
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
http.get('/api/journeys', () => HttpResponse.json({ journeys: [{ id: 9, user_id: 1, title: 'Japan' }] })),
http.delete('/api/trips/:tripId/journal', () => {
deletes += 1;
return HttpResponse.json(EMPTY);
})
);
});
describe('TripJournalLinkSection', () => {
it('FE-TJL-SECTION-001: renders nothing with the Journey addon off', async () => {
useAddonStore.setState({ addons: [] });
const { container } = render(<TripJournalLinkSection tripId={TRIP} />);
await new Promise((r) => setTimeout(r, 30));
expect(container).toBeEmptyDOMElement();
});
it('FE-TJL-SECTION-002: with no journal linked it offers to link one, in its own card', async () => {
render(<TripJournalLinkSection tripId={TRIP} />);
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
expect(screen.getByText('Journal')).toBeInTheDocument();
});
it('FE-TJL-SECTION-003: picking a journal lists the trip and warns about who gets in for the first time', async () => {
const user = userEvent.setup();
render(<TripJournalLinkSection tripId={TRIP} framed={false} />);
await user.click(await screen.findByText('Link a journal'));
await user.click(await screen.findByText('Choose a journal'));
// The select opens its list in a portal, one button per journal.
await user.click(await screen.findByRole('button', { name: 'Japan' }));
expect(await screen.findByText('tom')).toBeInTheDocument();
expect(screen.getByText('Has access')).toBeInTheDocument();
expect(screen.getByRole('note')).toHaveTextContent('tom will get access to this journal for the first time');
await user.click(screen.getAllByRole('radio', { name: 'No access' })[1]);
expect(screen.queryByRole('note')).not.toBeInTheDocument();
await user.click(screen.getByText('Everyone can read'));
expect(screen.getByRole('note')).toBeInTheDocument();
await user.click(screen.getByText('Nobody'));
expect(screen.queryByRole('note')).not.toBeInTheDocument();
await user.click(screen.getByText('Cancel'));
expect(screen.getByText('Link a journal')).toBeInTheDocument();
});
it('FE-TJL-SECTION-004: a linked journal shows its cover, my role and who it is open to', async () => {
answer = LINKED;
render(<TripJournalLinkSection tripId={TRIP} />);
expect(await screen.findByText('Japan')).toBeInTheDocument();
expect(screen.getByText('Your journal')).toBeInTheDocument();
expect(screen.getByText('Linked')).toBeInTheDocument();
expect(screen.getByText('lena')).toBeInTheDocument();
expect(document.querySelector('img[src="/uploads/covers/j.jpg"]')).not.toBeNull();
});
it('FE-TJL-SECTION-005: unlinking asks first, then takes it off', async () => {
const user = userEvent.setup();
answer = LINKED;
render(<TripJournalLinkSection tripId={TRIP} />);
await user.click(await screen.findByRole('button', { name: 'Unlink' }));
expect(screen.getByText('Unlink this journal?')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Unlink' });
await user.click(buttons[buttons.length - 1]);
await waitFor(() => expect(deletes).toBe(1));
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
});
it('FE-TJL-SECTION-006: editing access lists the trip with each one as the link holds them', async () => {
const user = userEvent.setup();
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, members: [] } };
render(<TripJournalLinkSection tripId={TRIP} />);
expect(await screen.findByText('Nobody else from this trip has access yet.')).toBeInTheDocument();
await user.click(screen.getByText('Edit access'));
expect(await screen.findByText('tom')).toBeInTheDocument();
expect(screen.getByText('Owner')).toBeInTheDocument();
expect(
screen.getAllByRole('radio', { name: 'No access' }).every((r) => r.getAttribute('aria-checked') === 'true')
).toBe(true);
});
it('FE-TJL-SECTION-007: the linked-journal icon draws a book with the link on its corner', () => {
const { container } = render(<LinkedJournalIcon size={20} className="x" />);
const root = container.firstElementChild as HTMLElement;
expect(root.getAttribute('dir')).toBe('ltr');
expect(root.style.width).toBe('20px');
expect(container.querySelectorAll('svg')).toHaveLength(2);
});
});
@@ -0,0 +1,324 @@
import type { TripJournalCandidate, TripJournalLinkRole } from '@trek/shared';
import { AlertTriangle, BookOpen, Link2, Unlink, Users } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import ConfirmDialog from '../shared/ConfirmDialog';
import CustomSelect from '../shared/CustomSelect';
import { DialogButton, fs } from '../shared/DialogShell';
import { AddRowButton } from '../shared/dialogParts';
import { LinkedJournalIcon } from './LinkedJournalIcon';
import { TripMemberAvatar } from './TripMemberAvatar';
import { LinkCard } from './TripShareDialog';
import { useTripJournalLink, type JournalMemberChoice } from './useTripJournalLink';
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5';
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5';
const SEGMENT = 'rounded-full px-2.5 py-1 font-semibold';
const ROLES: { value: TripJournalLinkRole | null; labelKey: string }[] = [
{ value: null, labelKey: 'trip.journal.roleNone' },
{ value: 'viewer', labelKey: 'trip.journal.roleViewer' },
{ value: 'editor', labelKey: 'trip.journal.roleEditor' },
];
/**
* The Journal part of the trip's Settings, for the desktop dialog and the phone
* sheet alike: the journal the trip shows, and who of the trip it is open to.
* Hidden with the Journey addon off, and for someone who can neither link one
* nor see one that is linked.
*/
export function TripJournalLinkSection({ tripId, framed = true }: { tripId: number; framed?: boolean }) {
const { t } = useTranslation();
const j = useTripJournalLink(tripId);
const [askUnlink, setAskUnlink] = useState(false);
if (!j.visible) return null;
// The desktop dialog names the area above it already; the phone sheet gets the card with its own title.
const Frame = framed ? JournalCard : Bare;
return (
<>
<Frame>
{j.mode === 'view' && j.link && <LinkedJournal j={j} />}
{j.mode === 'view' && !j.link && j.canLink && (
<AddRowButton onClick={j.startPick}>{t('trip.journal.link')}</AddRowButton>
)}
{j.mode === 'view' && j.link && (
<div className="flex flex-wrap gap-2">
{j.canEditMembers && (
<DialogButton onClick={j.editMembers} icon={<Users size={14} strokeWidth={2.2} />}>
{t('trip.journal.editMembers')}
</DialogButton>
)}
{j.canLink && (
<DialogButton onClick={j.startPick} icon={<BookOpen size={14} strokeWidth={2.2} />}>
{t('trip.journal.change')}
</DialogButton>
)}
{j.canUnlink && (
<button
type="button"
onClick={() => setAskUnlink(true)}
disabled={j.busy}
className="inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:opacity-50"
style={fs(12.5, 'body')}
>
<Unlink size={13} strokeWidth={2.2} />
{t('trip.journal.unlink')}
</button>
)}
</div>
)}
{j.mode === 'pick' && <JournalPicker j={j} />}
{(j.mode === 'members' || (j.mode === 'pick' && j.pickedId != null)) && <MemberChooser j={j} />}
{j.mode !== 'view' && (
<div className="flex justify-end gap-2">
<DialogButton onClick={j.cancel} disabled={j.busy}>
{t('common.cancel')}
</DialogButton>
<DialogButton
variant="primary"
onClick={() => void j.save()}
disabled={!j.canSave}
icon={<Link2 size={14} strokeWidth={2.2} />}
>
{j.mode === 'pick' ? t('trip.journal.linkSubmit') : t('common.save')}
</DialogButton>
</div>
)}
</Frame>
<ConfirmDialog
isOpen={askUnlink}
onClose={() => setAskUnlink(false)}
onConfirm={() => {
setAskUnlink(false);
void j.unlink();
}}
title={t('trip.journal.unlinkTitle')}
message={t('trip.journal.unlinkMessage')}
confirmLabel={t('trip.journal.unlink')}
/>
</>
);
}
function JournalCard({ children }: { children: ReactNode }) {
const { t } = useTranslation();
return (
<LinkCard
icon={<LinkedJournalIcon size={15} strokeWidth={2.2} />}
title={t('trip.journal.title')}
hint={t('trip.journal.hint')}
>
{children}
</LinkCard>
);
}
function Bare({ children }: { children: ReactNode }) {
return <div className="flex flex-col gap-4">{children}</div>;
}
type JournalLink = ReturnType<typeof useTripJournalLink>;
/** The linked journal: cover, title, what the reader may do in it, and who it is open to. */
function LinkedJournal({ j }: { j: JournalLink }) {
const { t } = useTranslation();
const link = j.link;
if (!link) return null;
return (
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-card p-2.5">
<span className="grid h-11 w-11 flex-none place-items-center overflow-hidden rounded-[10px] bg-surface-tertiary text-content-muted">
{link.cover_image ? (
<img src={`/uploads/${link.cover_image}`} alt="" className="h-full w-full object-cover" />
) : (
<BookOpen size={18} strokeWidth={2} />
)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13.5, 'body')}>
{link.title}
</div>
{link.my_role && (
<div className="truncate text-content-faint" style={fs(11.5)}>
{t(`trip.journal.yourRole.${link.my_role}`)}
</div>
)}
</div>
<span
className="inline-flex flex-none items-center gap-1 rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success"
style={fs(10.5)}
>
<Link2 size={10} strokeWidth={2.6} />
{t('trip.journal.linkedBadge')}
</span>
</div>
{link.members && link.members.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{link.members.map((m) => (
<span
key={m.user_id}
className="inline-flex items-center gap-1.5 rounded-full bg-surface-card py-[3px] pe-2.5 ps-[3px] shadow-sm ring-1 ring-edge-faint"
style={fs(11.5)}
>
<TripMemberAvatar username={m.username} avatarUrl={m.avatar_url} size={20} />
<span className="font-medium text-content">{m.username}</span>
<span className="text-content-faint">
{t(m.role === 'editor' ? 'trip.journal.roleEditor' : 'trip.journal.roleViewer')}
</span>
</span>
))}
</div>
)}
{link.members && link.members.length === 0 && (
<p className="m-0 text-content-faint" style={fs(11.5)}>
{t('trip.journal.onlyYou')}
</p>
)}
</div>
);
}
/** The journals of one's own to choose from; one only contributed to is not one's to give away. */
function JournalPicker({ j }: { j: JournalLink }) {
const { t } = useTranslation();
if (j.journals == null) return <div className="h-10 animate-pulse rounded-[10px] bg-surface-tertiary" />;
if (j.journals.length === 0)
return (
<p className="m-0 text-content-faint" style={fs(12)}>
{t('trip.journal.noJournals')}
</p>
);
return (
<CustomSelect
value={j.pickedId == null ? '' : String(j.pickedId)}
onChange={(value) => j.pickJournal(value === '' ? null : Number(value))}
placeholder={t('trip.journal.pickPlaceholder')}
options={[
{ value: '', label: t('trip.journal.pickPlaceholder') },
...j.journals.map((journal) => ({ value: String(journal.id), label: journal.title })),
]}
searchable
size="sm"
/>
);
}
/**
* Everyone of the trip, each with no access, read or edit. Before saving, the
* people the link would let in for the first time are named: from then on they
* see the journal in their own Journey list as well.
*/
function MemberChooser({ j }: { j: JournalLink }) {
const { t } = useTranslation();
if (j.candidates == null) return <div className="h-24 animate-pulse rounded-[14px] bg-surface-tertiary" />;
if (j.candidates.length === 0)
return (
<p className="m-0 text-content-faint" style={fs(12)}>
{t('trip.journal.noMembers')}
</p>
);
return (
<div className="flex flex-col gap-2.5">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-content-secondary" style={fs(12, 'body')}>
{t('trip.journal.members')}
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={() => j.setAll('viewer')}
className="text-content-muted hover:text-content"
style={fs(11.5)}
>
{t('trip.journal.allRead')}
</button>
<span className="text-content-faint" aria-hidden>
/
</span>
<button
type="button"
onClick={() => j.setAll(null)}
className="text-content-muted hover:text-content"
style={fs(11.5)}
>
{t('trip.journal.allNone')}
</button>
</div>
</div>
<div className={ROWS}>
{j.candidates.map((c) => (
<MemberRow key={c.user_id} c={c} choice={j.choice} onRole={j.setRole} showAccess={j.mode === 'pick'} />
))}
</div>
{j.newlyGranted.length > 0 && (
<div role="note" className="flex items-start gap-2 rounded-[12px] bg-warning-soft px-3 py-2.5 text-warning">
<AlertTriangle size={15} strokeWidth={2.2} className="mt-px flex-none" />
<p className="m-0 leading-normal" style={fs(12)}>
{t('trip.journal.warnNew', {
count: j.newlyGranted.length,
names: j.newlyGranted.map((c) => c.username).join(', '),
})}
</p>
</div>
)}
</div>
);
}
function MemberRow({
c,
choice,
onRole,
showAccess,
}: {
c: TripJournalCandidate;
choice: JournalMemberChoice;
onRole: (userId: number, role: TripJournalLinkRole | null) => void;
showAccess: boolean;
}) {
const { t } = useTranslation();
const current = choice[c.user_id] ?? null;
return (
<div className={ROW}>
<TripMemberAvatar username={c.username} avatarUrl={c.avatar_url} />
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<span className="truncate font-semibold text-content" style={fs(13, 'body')}>
{c.username}
</span>
{c.is_trip_owner && (
<span
className="rounded-full bg-surface-tertiary px-2 py-[2px] font-semibold text-content-muted"
style={fs(10.5)}
>
{t('members.owner')}
</span>
)}
{showAccess && c.access && (
<span className="rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success" style={fs(10.5)}>
{t('trip.journal.hasAccess')}
</span>
)}
</div>
<div
role="radiogroup"
aria-label={c.username}
className="flex flex-none gap-0.5 rounded-full bg-surface-card p-0.5 ring-1 ring-edge-faint"
>
{ROLES.map((r) => (
<button
key={r.labelKey}
type="button"
role="radio"
aria-checked={current === r.value}
onClick={() => onRole(c.user_id, r.value)}
className={`${SEGMENT} ${current === r.value ? 'bg-accent text-accent-text' : 'text-content-muted hover:text-content'}`}
style={fs(11, 'body')}
>
{t(r.labelKey)}
</button>
))}
</div>
</div>
);
}
@@ -122,10 +122,10 @@ describe('TripMembersModal', () => {
expect(document.body).toBeInTheDocument(); expect(document.body).toBeInTheDocument();
}); });
it('FE-COMP-MEMBERS-002: shows Share Trip title', () => { it('FE-COMP-MEMBERS-002: shows the Trip settings title', () => {
render(<TripMembersModal {...defaultProps} />); render(<TripMembersModal {...defaultProps} />);
// members.shareTrip = "Share Trip" // trip.settings.title = "Trip settings"
expect(screen.getByText('Share Trip')).toBeInTheDocument(); expect(screen.getByText('Trip settings')).toBeInTheDocument();
}); });
it('FE-COMP-MEMBERS-003: shows owner username after load', async () => { it('FE-COMP-MEMBERS-003: shows owner username after load', async () => {
@@ -245,7 +245,7 @@ describe('TripMembersModal', () => {
it('FE-COMP-MEMBERS-015: modal renders when isOpen is true', () => { it('FE-COMP-MEMBERS-015: modal renders when isOpen is true', () => {
render(<TripMembersModal {...defaultProps} isOpen={true} />); render(<TripMembersModal {...defaultProps} isOpen={true} />);
expect(screen.getByText('Share Trip')).toBeInTheDocument(); expect(screen.getByText('Trip settings')).toBeInTheDocument();
}); });
// ── Share Link Section (016-021) ─────────────────────────────────────────── // ── Share Link Section (016-021) ───────────────────────────────────────────
@@ -291,6 +291,7 @@ describe('TripMembersModal', () => {
); );
render(<TripMembersModal {...defaultProps} />); render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
const createBtn = await screen.findByText('Create link'); const createBtn = await screen.findByText('Create link');
await user.click(createBtn); await user.click(createBtn);
@@ -689,6 +690,7 @@ describe('TripMembersModal', () => {
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({ success: true })), http.delete('/api/trips/1/invite-link', () => HttpResponse.json({ success: true })),
); );
const view = render(<TripMembersModal {...defaultProps} />); const view = render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
const createBtn = await screen.findByRole('button', { name: /Create invite link/i }); const createBtn = await screen.findByRole('button', { name: /Create invite link/i });
fireEvent.click(createBtn); fireEvent.click(createBtn);
@@ -719,6 +721,7 @@ describe('TripMembersModal', () => {
asShareOwner(); asShareOwner();
server.use(http.post('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 }))); server.use(http.post('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })));
render(<TripMembersModal {...defaultProps} />); render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
await user.click(await screen.findByRole('button', { name: /Create invite link/i })); await user.click(await screen.findByRole('button', { name: /Create invite link/i }));
@@ -734,6 +737,7 @@ describe('TripMembersModal', () => {
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })), http.delete('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })),
); );
render(<TripMembersModal {...defaultProps} />); render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
await user.click(await screen.findByRole('button', { name: /Disable/i })); await user.click(await screen.findByRole('button', { name: /Disable/i }));
@@ -1,3 +1,4 @@
import { TripJournalLinkSection } from './TripJournalLinkSection'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react' import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
@@ -181,7 +182,7 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
} }
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl"> <Modal isOpen={isOpen} onClose={onClose} title={t('trip.settings.title')} size="3xl">
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid"> <div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid">
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style> <style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
@@ -402,6 +403,8 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
<TripInviteLinkSection tripId={tripId} t={t} /> <TripInviteLinkSection tripId={tripId} t={t} />
</div>} </div>}
<TripJournalLinkSection tripId={tripId} />
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style> <style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
</div> </div>
</Modal> </Modal>
+81 -11
View File
@@ -1,12 +1,15 @@
import { useId, useState, type ReactNode } from 'react' import { useId, useState, type ReactNode } from 'react'
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react' import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Settings, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import ConfirmDialog from '../shared/ConfirmDialog' import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip' import { Tooltip } from '../shared/Tooltip'
import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell' import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
import { AddRowButton, INPUT, LABEL, PANEL } from '../shared/dialogParts' import { AddRowButton, INPUT, LABEL, PANEL } from '../shared/dialogParts'
import { LinkedJournalIcon } from './LinkedJournalIcon'
import { TripJournalLinkSection } from './TripJournalLinkSection'
import { TripMemberAvatar } from './TripMemberAvatar' import { TripMemberAvatar } from './TripMemberAvatar'
import { useTripJournalSectionVisible } from './useTripJournalLink'
import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers, type TripMembersState } from './useTripShare' import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers, type TripMembersState } from './useTripShare'
interface TripShareDialogProps { interface TripShareDialogProps {
@@ -29,18 +32,38 @@ const COUNT = 'rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bo
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5' const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5 hover:bg-surface-card' const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5 hover:bg-surface-card'
const BADGE = 'inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-semibold' const BADGE = 'inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-semibold'
/** A fixed height on a wide screen, so the dialog does not jump when another area is picked. */
const BODY = 'flex min-h-0 flex-1 max-md:flex-col md:h-[min(640px,calc(100dvh-240px))]'
const NAV = 'flex flex-none gap-1 border-edge-faint bg-surface-secondary p-3 md:w-[236px] md:flex-col md:border-e max-md:overflow-x-auto max-md:border-b'
const DANGER_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50' const DANGER_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50'
/** The desktop dialog behind Share: who is on the trip, its guests, and the two links that let others in. */ /**
* The trip's Settings on the desktop: who is on the trip, its guests, the two
* links that let others in, and the journal the trip shows.
*/
export default function TripShareDialog(props: TripShareDialogProps) { export default function TripShareDialog(props: TripShareDialogProps) {
return props.isOpen ? <OpenDialog {...props} /> : null return props.isOpen ? <OpenDialog {...props} /> : null
} }
type Area = 'people' | 'sharing' | 'journal'
/** The areas of the dialog, each with its icon, its name and the line under the name. */
const AREAS: { id: Area; icon: (on: boolean) => ReactNode; labelKey: string; hintKey: string }[] = [
{ id: 'people', icon: () => <Users size={16} strokeWidth={2} />, labelKey: 'trip.settings.people', hintKey: 'trip.settings.peopleHint' },
{ id: 'sharing', icon: () => <Link2 size={16} strokeWidth={2} />, labelKey: 'trip.settings.sharing', hintKey: 'trip.settings.sharingHint' },
{ id: 'journal', icon: () => <LinkedJournalIcon size={16} />, labelKey: 'trip.journal.title', hintKey: 'trip.journal.hint' },
]
function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareDialogProps) { function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const labelId = useId() const labelId = useId()
const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged }) const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged })
const [ask, setAsk] = useState<Ask | null>(null) const [ask, setAsk] = useState<Ask | null>(null)
const [area, setArea] = useState<Area>('people')
const journalSection = useTripJournalSectionVisible(tripId)
const areas = AREAS.filter(a => a.id === 'people' || (a.id === 'sharing' ? m.canManageShare : journalSection))
const current = areas.find(a => a.id === area) ?? areas[0]
const panelOf = (id: Area) => areas.find(a => a.id === id)
const persons = `${m.realMembers.length} ${t('members.persons', { count: m.realMembers.length })}` const persons = `${m.realMembers.length} ${t('members.persons', { count: m.realMembers.length })}`
return ( return (
@@ -48,15 +71,16 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
<DialogShell <DialogShell
onClose={onClose} onClose={onClose}
labelledBy={labelId} labelledBy={labelId}
width={m.canManageShare ? 'wide' : 'detail'} width={areas.length > 1 ? 'wide' : 'detail'}
blocked={!!ask} blocked={!!ask}
bodyClassName={BODY}
header={( header={(
<DialogHeader <DialogHeader
tile={<DialogTile><Users size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>} tile={<DialogTile><Settings size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT} tint={NEUTRAL_TINT}
labelId={labelId} labelId={labelId}
onClose={onClose} onClose={onClose}
eyebrow={t('members.shareTrip')} eyebrow={t('trip.settings.title')}
title={tripTitle} title={tripTitle}
pills={m.loading ? undefined : ( pills={m.loading ? undefined : (
<> <>
@@ -67,17 +91,24 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
/> />
)} )}
> >
<div className={m.canManageShare ? 'grid grid-cols-2 items-start gap-6 max-md:grid-cols-1' : ''}> {areas.length > 1 && <AreaNav areas={areas} current={current.id} onPick={setArea} />}
<div className="flex flex-col gap-5"> <div className="min-h-0 flex-1 overflow-y-auto px-7 pb-7 pt-6 max-sm:px-5">
{/* Every area stays mounted, so its links and lists are loaded by the time it is picked. */}
<AreaPanel area={panelOf('people')} shown={current.id === 'people'} titled={areas.length > 1}>
{m.canManageMembers && <InviteUser m={m} />} {m.canManageMembers && <InviteUser m={m} />}
<MembersList m={m} onAsk={setAsk} /> <MembersList m={m} onAsk={setAsk} />
{(m.isCurrentOwner || m.guests.length > 0) && <GuestsList m={m} onAsk={setAsk} />} {(m.isCurrentOwner || m.guests.length > 0) && <GuestsList m={m} onAsk={setAsk} />}
</div> </AreaPanel>
{m.canManageShare && ( {m.canManageShare && (
<div className="flex flex-col gap-5"> <AreaPanel area={panelOf('sharing')} shown={current.id === 'sharing'} titled>
<PublicLink tripId={tripId} /> <PublicLink tripId={tripId} />
<InviteLink tripId={tripId} /> <InviteLink tripId={tripId} />
</div> </AreaPanel>
)}
{journalSection && (
<AreaPanel area={panelOf('journal')} shown={current.id === 'journal'} titled>
<TripJournalLinkSection tripId={tripId} framed={false} />
</AreaPanel>
)} )}
</div> </div>
</DialogShell> </DialogShell>
@@ -93,6 +124,45 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
) )
} }
/** The areas down the side on a wide screen, a row of pills above the content on a narrow one. */
function AreaNav({ areas, current, onPick }: { areas: typeof AREAS; current: Area; onPick: (area: Area) => void }) {
const { t } = useTranslation()
return (
<nav aria-label={t('trip.settings.title')} className={NAV}>
{areas.map(a => {
const on = a.id === current
return (
<button key={a.id} type="button" aria-current={on ? 'page' : undefined} aria-label={t(a.labelKey)} onClick={() => onPick(a.id)}
className={`flex flex-none items-center gap-2.5 rounded-[12px] px-2.5 py-2 text-start transition-colors ${on ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-muted hover:bg-surface-hover hover:text-content'}`}>
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[9px] ${on ? 'bg-accent text-accent-text' : 'bg-surface-tertiary'}`}>{a.icon(on)}</span>
<span className="min-w-0">
<span className="block truncate font-semibold" style={fs(13, 'body')}>{t(a.labelKey)}</span>
<span className="block truncate text-content-faint max-md:hidden" style={fs(11)}>{t(a.hintKey)}</span>
</span>
</button>
)
})}
</nav>
)
}
/** One area: its name and what it is for, then its parts. Hidden rather than unmounted while another one shows. */
function AreaPanel({ area, shown, titled, children }: { area: (typeof AREAS)[number] | undefined; shown: boolean; titled: boolean; children: ReactNode }) {
const { t } = useTranslation()
return (
// The class, not the hidden attribute alone: flex would win over the attribute's display:none.
<section hidden={!shown} className={shown ? 'flex flex-col gap-5' : 'hidden'}>
{titled && area && (
<header>
<h3 className="m-0 font-semibold text-content" style={fs(17, 'subtitle')}>{t(area.labelKey)}</h3>
<p className="m-0 mt-1 max-w-[560px] leading-normal text-content-muted" style={fs(12.5, 'body')}>{t(area.hintKey)}</p>
</header>
)}
{children}
</section>
)
}
function InviteUser({ m }: { m: TripMembersState }) { function InviteUser({ m }: { m: TripMembersState }) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
@@ -291,7 +361,7 @@ function LinkLine({ url, copied, onCopy }: { url: string; copied: boolean; onCop
) )
} }
function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) { export function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) {
return ( return (
<section className={PANEL}> <section className={PANEL}>
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
@@ -0,0 +1,290 @@
// FE-TJL-HOOK-001 to FE-TJL-HOOK-014
/**
* The trip's linked journal as the planner and the Settings dialog read it.
*
* `useTripJournal` decides whether the trip has a Journal tab at all: only with
* the Journey addon on, only once the server answered, and live on every
* `trip:journal:changed`. `useTripJournalLink` is the Settings section: pick one
* of your own journals, everybody of the trip ticked as a reader by default,
* the people who would get in for the first time named before the save.
*/
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { seedStore } from '../../../tests/helpers/store';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode';
import { useTripJournal, useTripJournalLink, useTripJournalSectionVisible } from './useTripJournalLink';
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const listeners = new Set<(e: Record<string, unknown>) => void>();
vi.mock('../../api/websocket', async (importOriginal) => ({
...(await importOriginal<typeof import('../../api/websocket')>()),
addListener: (fn: (e: Record<string, unknown>) => void) => {
listeners.add(fn);
},
removeListener: (fn: (e: Record<string, unknown>) => void) => {
listeners.delete(fn);
},
}));
const TRIP = 4;
const ME = 1;
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
const LINKED: TripJournalLinkResponse = {
link: {
journey_id: 9,
title: 'Japan',
subtitle: null,
cover_image: null,
owner_id: ME,
linked_by: ME,
created_at: 1,
my_role: 'owner',
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
},
can_link: true,
can_unlink: true,
can_edit_members: true,
};
const CANDIDATES: TripJournalCandidate[] = [
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: false, access: 'editor' },
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
];
let gets = 0;
let answer: TripJournalLinkResponse = EMPTY;
const writes: { method: string; body: unknown }[] = [];
function routes({ writeStatus = 200 } = {}) {
server.use(
http.get('/api/trips/:tripId/journal', () => {
gets += 1;
return HttpResponse.json(answer);
}),
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
http.get('/api/journeys', () =>
HttpResponse.json({
journeys: [
{ id: 9, user_id: ME, title: 'Japan' },
{ id: 10, user_id: 99, title: 'Not mine' },
],
})
),
http.put('/api/trips/:tripId/journal', async ({ request }) => {
writes.push({ method: 'PUT', body: await request.json() });
return writeStatus === 200
? HttpResponse.json(LINKED)
: HttpResponse.json({ error: 'no' }, { status: writeStatus });
}),
http.patch('/api/trips/:tripId/journal/members', async ({ request }) => {
writes.push({ method: 'PATCH', body: await request.json() });
return HttpResponse.json(LINKED);
}),
http.delete('/api/trips/:tripId/journal', () => {
writes.push({ method: 'DELETE', body: null });
return HttpResponse.json(EMPTY);
})
);
}
function journeyAddon(enabled: boolean): void {
useAddonStore.setState({
loaded: true,
addons: enabled ? [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }] : [],
});
}
beforeEach(() => {
gets = 0;
answer = EMPTY;
writes.length = 0;
listeners.clear();
vi.clearAllMocks();
useTripJournalLinkStore.setState({ byTrip: {} });
seedStore(useAuthStore, { user: buildUser({ id: ME }) });
journeyAddon(true);
routes();
});
afterEach(() => _resetNetworkMode());
describe('useTripJournal', () => {
it('FE-TJL-HOOK-001: with the Journey addon off it asks nothing and there is no journal', async () => {
journeyAddon(false);
const { result } = renderHook(() => useTripJournal(TRIP));
expect(result.current.openJournalId).toBeNull();
expect(result.current.settled).toBe(true);
await new Promise((r) => setTimeout(r, 30));
expect(gets).toBe(0);
});
it('FE-TJL-HOOK-002: a linked journal open to this person is the one the tab opens', async () => {
answer = LINKED;
const { result } = renderHook(() => useTripJournal(TRIP));
expect(result.current.settled).toBe(false);
await waitFor(() => expect(result.current.openJournalId).toBe(9));
expect(result.current.settled).toBe(true);
});
it('FE-TJL-HOOK-003: a link that leaves this person out gives no tab', async () => {
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, my_role: null } };
const { result } = renderHook(() => useTripJournal(TRIP));
await waitFor(() => expect(result.current.link).not.toBeNull());
expect(result.current.openJournalId).toBeNull();
});
it('FE-TJL-HOOK-004: trip:journal:changed asks again', async () => {
const { result } = renderHook(() => useTripJournal(TRIP));
await waitFor(() => expect(gets).toBe(1));
answer = LINKED;
act(() => {
for (const fn of [...listeners]) fn({ type: 'trip:journal:changed' });
});
await waitFor(() => expect(result.current.openJournalId).toBe(9));
});
it('FE-TJL-HOOK-005: offline it asks nothing and is settled', () => {
setForcedOffline(true);
const { result } = renderHook(() => useTripJournal(TRIP));
expect(result.current.settled).toBe(true);
expect(gets).toBe(0);
});
it('FE-TJL-HOOK-006: without a trip id it asks nothing', async () => {
renderHook(() => useTripJournal(null));
await new Promise((r) => setTimeout(r, 30));
expect(gets).toBe(0);
});
it('FE-TJL-HOOK-007: the Settings section shows for someone who may link, and not for someone who may not', async () => {
const { result, rerender } = renderHook(() => useTripJournalSectionVisible(TRIP));
await waitFor(() => expect(result.current).toBe(true));
act(() => useTripJournalLinkStore.setState({ byTrip: { [TRIP]: { ...EMPTY, can_link: false } } }));
rerender();
expect(result.current).toBe(false);
});
});
describe('useTripJournalLink', () => {
it('FE-TJL-HOOK-008: picking offers only my journals and ticks everybody as a reader', async () => {
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
act(() => result.current.startPick());
await waitFor(() => expect(result.current.journals).toEqual([{ id: 9, title: 'Japan', subtitle: null }]));
act(() => result.current.pickJournal(9));
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
expect(result.current.choice).toEqual({ 2: 'viewer', 3: 'viewer' });
// lena is in already; tom would be let in for the first time.
expect(result.current.newlyGranted.map((c) => c.username)).toEqual(['tom']);
expect(result.current.canSave).toBe(true);
});
it('FE-TJL-HOOK-009: saving a pick links with the chosen roles and goes back to the view', async () => {
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
act(() => result.current.startPick());
act(() => result.current.pickJournal(9));
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
act(() => {
result.current.setRole(2, 'editor');
result.current.setRole(3, null);
});
expect(result.current.newlyGranted).toEqual([]);
await act(async () => {
await result.current.save();
});
expect(writes).toEqual([{ method: 'PUT', body: { journey_id: 9, members: [{ user_id: 2, role: 'editor' }] } }]);
expect(toast.success).toHaveBeenCalledWith('trip.journal.linked');
expect(result.current.mode).toBe('view');
expect(result.current.link?.title).toBe('Japan');
});
it('FE-TJL-HOOK-010: editing members starts from who the link holds and saves them', async () => {
answer = LINKED;
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.link).not.toBeNull());
act(() => result.current.editMembers());
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
expect(result.current.choice).toEqual({ 2: 'editor', 3: null });
act(() => result.current.setAll('viewer'));
await act(async () => {
await result.current.save();
});
expect(writes).toEqual([
{
method: 'PATCH',
body: {
members: [
{ user_id: 2, role: 'viewer' },
{ user_id: 3, role: 'viewer' },
],
},
},
]);
expect(toast.success).toHaveBeenCalledWith('trip.journal.membersSaved');
});
it('FE-TJL-HOOK-011: unlinking takes the journal off the trip', async () => {
answer = LINKED;
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.canUnlink).toBe(true));
await act(async () => {
await result.current.unlink();
});
expect(writes).toEqual([{ method: 'DELETE', body: null }]);
expect(result.current.link).toBeNull();
expect(toast.success).toHaveBeenCalledWith('trip.journal.unlinked');
});
it('FE-TJL-HOOK-012: a refused save says it was not allowed and keeps the form open', async () => {
routes({ writeStatus: 403 });
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
act(() => result.current.startPick());
act(() => result.current.pickJournal(9));
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
await act(async () => {
await result.current.save();
});
expect(toast.error).toHaveBeenCalledWith('trip.journal.errorForbidden');
expect(result.current.mode).toBe('pick');
expect(result.current.busy).toBe(false);
});
it('FE-TJL-HOOK-013: offline, picking says a connection is needed', async () => {
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
setForcedOffline(true);
act(() => result.current.startPick());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('trip.journal.errorOffline'));
expect(result.current.journals).toEqual([]);
});
it('FE-TJL-HOOK-014: a failing candidate list is reported and cancel goes back to the view', async () => {
server.use(
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ error: 'x' }, { status: 500 }))
);
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
act(() => result.current.startPick());
act(() => result.current.pickJournal(9));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('trip.journal.errorGeneric'));
expect(result.current.candidates).toEqual([]);
act(() => result.current.cancel());
expect(result.current.mode).toBe('view');
expect(result.current.canSave).toBe(false);
});
});
@@ -0,0 +1,243 @@
import type { TripJournalCandidate, TripJournalLinkMember, TripJournalLinkRole } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { useTranslation } from '../../i18n';
import { tripJournalLinkRepo, TripJournalOfflineError, type OwnJournal } from '../../repo/tripJournalLinkRepo';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
import { useServerPing } from '../../sync/useServerPing';
import { useToast } from '../shared/Toast';
/** Who a link lets in, as the member list edits it: a role, or left out. */
export type JournalMemberChoice = Record<number, TripJournalLinkRole | null>;
/** One empty list for "the link holds nobody", so the member effect does not rerun on every render. */
const NOBODY: TripJournalLinkMember[] = [];
/** The status of a failed request, when the server gave one. */
function statusOf(err: unknown): number | undefined {
return (err as { response?: { status?: number } })?.response?.status;
}
/**
* The trip's linked journal as everybody on the trip reads it: answered once per
* trip from the store, asked again on mount, when the connection returns and on
* every `trip:journal:changed`. With the Journey addon off there is no journal.
*/
export function useTripJournal(tripId: number | null | undefined) {
const addonsLoaded = useAddonStore((s) => s.loaded);
const journeyOn = useAddonStore((s) => s.isEnabled('journey'));
const answer = useTripJournalLinkStore((s) => (tripId != null ? s.byTrip[String(tripId)] : undefined));
const refresh = useTripJournalLinkStore((s) => s.refresh);
const { offline } = useNetworkMode();
const active = journeyOn && tripId != null && !offline;
const reload = useCallback(async () => {
if (tripId != null) await refresh(tripId);
}, [tripId, refresh]);
useEffect(() => {
if (active) void reload();
}, [active, reload]);
useServerPing('trip:journal:changed', active, reload);
const link = journeyOn ? (answer?.link ?? null) : null;
return {
journeyOn,
answer: journeyOn ? answer : undefined,
link,
/** The journal the trip's Journal tab opens: linked, and open to this person. */
openJournalId: link?.my_role ? link.journey_id : null,
/** Whether the answer is in, or none is coming (addon off, offline): the planner may drop the tab then. */
settled: addonsLoaded && (!journeyOn || offline || answer !== undefined),
};
}
/** Whether the trip's Settings carry a Journal section for this person, for laying the dialog out. */
export function useTripJournalSectionVisible(tripId: number): boolean {
const { journeyOn, answer } = useTripJournal(tripId);
return journeyOn && !!answer && (answer.can_link || !!answer.link);
}
/** The toast for a failed journal request: offline, refused, or anything else. */
function useJournalFailure() {
const { t } = useTranslation();
const toast = useToast();
return useCallback(
(err: unknown) => {
if (err instanceof TripJournalOfflineError) toast.error(t('trip.journal.errorOffline'));
else if (statusOf(err) === 403) toast.error(t('trip.journal.errorForbidden'));
else toast.error(t('trip.journal.errorGeneric'));
},
[t, toast]
);
}
/**
* The people of the trip for one journal, each with the role the save would
* give them. `current` is who the link holds now, to start from; without it
* everybody starts as a reader, the default the section promises.
*
* What loaded is kept with the journal it belongs to, so picking another one
* shows nothing until its own people are in, never the last journal's.
*/
function useJournalMemberChoice(
tripId: number,
journeyId: number | null,
current: TripJournalLinkMember[] | null,
fail: (err: unknown) => void
) {
const [loaded, setLoaded] = useState<{ journeyId: number; list: TripJournalCandidate[] } | null>(null);
const [choice, setChoice] = useState<JournalMemberChoice>({});
useEffect(() => {
if (journeyId == null) return;
let cancelled = false;
tripJournalLinkRepo
.candidates(tripId, journeyId)
.then((list) => {
if (cancelled) return;
const held = new Map((current ?? []).map((m) => [m.user_id, m.role]));
setLoaded({ journeyId, list });
setChoice(Object.fromEntries(list.map((c) => [c.user_id, current ? (held.get(c.user_id) ?? null) : 'viewer'])));
})
.catch((err: unknown) => {
if (cancelled) return;
fail(err);
setLoaded({ journeyId, list: [] });
});
return () => {
cancelled = true;
};
}, [tripId, journeyId, current, fail]);
const candidates = journeyId != null && loaded?.journeyId === journeyId ? loaded.list : null;
const setRole = useCallback((memberId: number, role: TripJournalLinkRole | null) => {
setChoice((prev) => ({ ...prev, [memberId]: role }));
}, []);
const setAll = useCallback(
(role: TripJournalLinkRole | null) =>
setChoice(Object.fromEntries((candidates ?? []).map((c) => [c.user_id, role]))),
[candidates]
);
const chosenMembers = useMemo(
() => Object.entries(choice).flatMap(([id, role]) => (role ? [{ user_id: Number(id), role }] : [])),
[choice]
);
/** The people the save would let into the journal for the first time; the warning names them. */
const newlyGranted = useMemo(
() => (candidates ?? []).filter((c) => c.access === null && choice[c.user_id]),
[candidates, choice]
);
return { candidates, choice, setRole, setAll, chosenMembers, newlyGranted };
}
/**
* The Journal section of the trip's Settings: link one of your journals, choose
* who it is open to and as what, change it, or take it off. One hook for the
* desktop dialog and the phone sheet, so both offer and warn about the same thing.
*
* Picking a journal asks the server who of the trip already has access to it;
* whoever would be let in for the first time is named before anything is saved,
* because they will see the journal in their own Journey list from then on.
*/
export function useTripJournalLink(tripId: number) {
const { t } = useTranslation();
const toast = useToast();
const fail = useJournalFailure();
const userId = useAuthStore((s) => s.user?.id ?? null);
const { journeyOn, answer, link } = useTripJournal(tripId);
const store = useTripJournalLinkStore.getState;
/** 'view' shows the link; 'pick' chooses a journal; 'members' changes who it is open to. */
const [mode, setMode] = useState<'view' | 'pick' | 'members'>('view');
const [journals, setJournals] = useState<OwnJournal[] | null>(null);
const [pickedId, setPickedId] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const journeyId = mode === 'members' ? (link?.journey_id ?? null) : mode === 'pick' ? pickedId : null;
// Who the link holds, to start from: when editing it, or when re-picking the
// journal it already shows with people on it.
const held = link?.members ?? NOBODY;
const current = mode === 'members' || (link?.journey_id === pickedId && held.length > 0) ? held : null;
const members = useJournalMemberChoice(tripId, journeyId, current, fail);
const { chosenMembers } = members;
const startPick = useCallback(() => {
setMode('pick');
setPickedId(null);
if (userId == null) return;
tripJournalLinkRepo
.ownJournals(userId)
.then(setJournals)
.catch((err: unknown) => {
fail(err);
setJournals([]);
});
}, [userId, fail]);
const editMembers = useCallback(() => setMode('members'), []);
const cancel = useCallback(() => {
setMode('view');
setPickedId(null);
}, []);
const run = useCallback(
async (write: () => Promise<void>, done: string) => {
setBusy(true);
try {
await write();
toast.success(done);
cancel();
} catch (err: unknown) {
fail(err);
} finally {
setBusy(false);
}
},
[cancel, fail, toast]
);
const save = useCallback(async () => {
if (mode === 'pick' && pickedId != null) {
await run(() => store().link(tripId, pickedId, chosenMembers), t('trip.journal.linked'));
} else if (mode === 'members') {
await run(() => store().setMembers(tripId, chosenMembers), t('trip.journal.membersSaved'));
}
}, [mode, pickedId, run, store, tripId, chosenMembers, t]);
const unlink = useCallback(async () => {
await run(() => store().unlink(tripId), t('trip.journal.unlinked'));
}, [run, store, tripId, t]);
return {
/** The section shows at all: the addon is on and this person may link, or the trip has a journal they see. */
visible: journeyOn && !!answer && (answer.can_link || !!answer.link),
link,
canLink: !!answer?.can_link,
canUnlink: !!answer?.can_unlink,
canEditMembers: !!answer?.can_edit_members,
mode,
startPick,
cancel,
editMembers,
journals,
pickedId,
pickJournal: setPickedId,
candidates: members.candidates,
choice: members.choice,
setRole: members.setRole,
setAll: members.setAll,
newlyGranted: members.newlyGranted,
canSave: !busy && (mode === 'members' || (mode === 'pick' && pickedId != null)),
busy,
save,
unlink,
};
}
+2
View File
@@ -20,6 +20,7 @@ export const TRIP_TAB_IDS = [
'finanzplan', 'finanzplan',
'dateien', 'dateien',
'collab', 'collab',
'journal',
] as const ] as const
export type TripTabId = (typeof TRIP_TAB_IDS)[number] export type TripTabId = (typeof TRIP_TAB_IDS)[number]
@@ -40,6 +41,7 @@ export const TRIP_TAB_LABEL_KEYS: Record<TripTabId, string> = {
finanzplan: 'trip.tabs.budget', finanzplan: 'trip.tabs.budget',
dateien: 'trip.tabs.files', dateien: 'trip.tabs.files',
collab: 'admin.addons.catalog.collab.name', collab: 'admin.addons.catalog.collab.name',
journal: 'trip.tabs.journal',
} }
export const isTripTabId = (value: unknown): value is TripTabId => export const isTripTabId = (value: unknown): value is TripTabId =>
+3 -2
View File
@@ -4,7 +4,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useTripStore } from '../store/tripStore' import { useTripStore } from '../store/tripStore'
import { useSettingsStore } from '../store/settingsStore' import { useSettingsStore } from '../store/settingsStore'
import { useAddonStore } from '../store/addonStore' import { useAddonStore } from '../store/addonStore'
import { calculateRouteWithLegs, type RouteProfileKey } from '../components/Map/RouteCalculator' import { calculateRouteWithRetries, type RouteProfileKey } from '../components/Map/routeRetry'
import { buildDayRouteRuns, hotelBookendOf, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan' import { buildDayRouteRuns, hotelBookendOf, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan'
import { dayViaLookup, foldViaLegs, threadVias, viaKeyOf, viasOnDayLeg } from '../utils/dayRouteVias' import { dayViaLookup, foldViaLegs, threadVias, viaKeyOf, viasOnDayLeg } from '../utils/dayRouteVias'
import { resolveLegMode } from '../components/Planner/legMode' import { resolveLegMode } from '../components/Planner/legMode'
@@ -131,7 +131,8 @@ export function useRouteCalculation(tripStore: Pick<TripStoreState, 'assignments
const straight = (): [number, number][] => chunk.map(p => [p.lat, p.lng] as [number, number]) const straight = (): [number, number][] => chunk.map(p => [p.lat, p.lng] as [number, number])
const { waypoints, stopAt } = threadVias(chunk, (from, to) => viasOnDayLeg(viaLookup, dayId, mode, from, to)) const { waypoints, stopAt } = threadVias(chunk, (from, to) => viasOnDayLeg(viaLookup, dayId, mode, from, to))
try { try {
const r = await calculateRouteWithLegs(waypoints, { signal: controller.signal, profile: mode, tripId, dayId }) // A busy router is waited out rather than left as straight lines until the next edit.
const r = await calculateRouteWithRetries(waypoints, { signal: controller.signal, profile: mode, tripId, dayId })
pushCoords(r.coordinates.length >= 2 ? r.coordinates : straight(), mode === 'walking') pushCoords(r.coordinates.length >= 2 ? r.coordinates : straight(), mode === 'walking')
// Leg k runs from chunk[k] to chunk[k + 1]; a hotel bookend says so on the // Leg k runs from chunk[k] to chunk[k + 1]; a hotel bookend says so on the
// segment, which is how the phone timeline finds it (#2501). // segment, which is how the phone timeline finds it (#2501).
@@ -7,7 +7,7 @@ import ContributorInviteDialog from '../../../components/Journey/ContributorInvi
import ConfirmDialog from '../../../components/shared/ConfirmDialog' import ConfirmDialog from '../../../components/shared/ConfirmDialog'
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker' import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers' import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail' import { useJourneyDetail, type JourneyDetailOptions } from '../../../pages/journeyDetail/useJourneyDetail'
import { useJourneyStore } from '../../../store/journeyStore' import { useJourneyStore } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore' import { useAddonStore } from '../../../store/addonStore'
import DawarichIcon from '../../../components/shared/DawarichIcon' import DawarichIcon from '../../../components/shared/DawarichIcon'
@@ -32,8 +32,12 @@ import MJourneySettingsSheet from './MJourneySettingsSheet'
* Journey detail — integrated map with the horizontal 280px card timeline * Journey detail — integrated map with the horizontal 280px card timeline
* (Journey tab) and the 2-column photo gallery (Gallery tab). Cards and map * (Journey tab) and the 2-column photo gallery (Gallery tab). Cards and map
* markers stay in sync; tapping the centered card opens the entry sheet. * markers stay in sync; tapping the centered card opens the entry sheet.
*
* A trip that links the journal shows this same screen in its own tab
* (`embedded`): the trip's back button and controls sit on top, so the screen
* drops its own back button and leaves the trip's URL alone.
*/ */
export default function MJourneyDetail() { export default function MJourneyDetail({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
const { const {
id, navigate, toast, t, id, navigate, toast, t,
current, loading, current, loading,
@@ -48,7 +52,7 @@ export default function MJourneyDetail() {
dismissSuggestion, restoreSuggestions, openAtEntryId, dismissSuggestion, restoreSuggestions, openAtEntryId,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
addPickedProviderPhotos, addEntryProviderPhotos, addPickedProviderPhotos, addEntryProviderPhotos,
} = useJourneyDetail() } = useJourneyDetail({ journeyId, embedded })
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the // The dock's FAB is a sibling of this screen: on the Gallery it becomes the
// upload button, so it has to know which tab is open — and it stops knowing // upload button, so it has to know which tab is open — and it stops knowing
@@ -175,7 +179,7 @@ export default function MJourneyDetail() {
const [params, setParams] = useSearchParams() const [params, setParams] = useSearchParams()
const [pendingUpload, setPendingUpload] = useState(false) const [pendingUpload, setPendingUpload] = useState(false)
useEffect(() => { useEffect(() => {
if (params.get('create') !== 'photo') return if (embedded || params.get('create') !== 'photo') return
setParams(prev => { setParams(prev => {
const next = new URLSearchParams(prev) const next = new URLSearchParams(prev)
next.delete('create') next.delete('create')
@@ -184,7 +188,7 @@ export default function MJourneyDetail() {
if (!canEditEntries) return if (!canEditEntries) return
setView('gallery') setView('gallery')
setPendingUpload(true) setPendingUpload(true)
}, [params, setParams, canEditEntries, setView]) }, [params, setParams, canEditEntries, setView, embedded])
useEffect(() => { useEffect(() => {
if (!pendingUpload || !providersReady) return if (!pendingUpload || !providersReady) return
@@ -331,14 +335,18 @@ export default function MJourneyDetail() {
{/* Header: back — segment — upload / overflow menu */} {/* Header: back — segment — upload / overflow menu */}
<div className="absolute inset-x-4 top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2"> <div className="absolute inset-x-4 top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2">
<button {embedded ? (
type="button" <span className="w-[38px] flex-none" aria-hidden />
onClick={() => navigate('/journey')} ) : (
aria-label={t('journey.detail.backToJourney')} <button
className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]" type="button"
> onClick={() => navigate('/journey')}
<ChevronLeft size={18} strokeWidth={2.2} /> aria-label={t('journey.detail.backToJourney')}
</button> className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
>
<ChevronLeft size={18} strokeWidth={2.2} />
</button>
)}
<div className="absolute left-1/2 top-0 flex -translate-x-1/2 rounded-full bg-[color:var(--m-sheet)] p-[3px] shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"> <div className="absolute left-1/2 top-0 flex -translate-x-1/2 rounded-full bg-[color:var(--m-sheet)] p-[3px] shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]">
<button <button
type="button" type="button"
@@ -1,8 +1,5 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { import { X, Image, Plus, Trash2, UserPlus, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2 } from 'lucide-react'
X, Image, Plus, Trash2, UserPlus, Link as LinkIcon,
List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2,
} from 'lucide-react'
import MSheet from '../../components/MSheet' import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn' import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle' import MToggle from '../../components/MToggle'
@@ -237,6 +234,26 @@ export default function MJourneySettingsSheet({
{t('journey.contributors.invite')} {t('journey.contributors.invite')}
</button> </button>
{/* Through a trip's linked journal (#2683): shown, but changed in that trip. */}
{!!journey.trip_access?.length && (
<>
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.contributors.viaTrips')}</div>
<p className="m-0 mb-[6px] text-caption text-m-faint">{t('journey.contributors.viaTripsHint')}</p>
{journey.trip_access.map(a => (
<div key={`${a.user_id}-${a.trip_id}`} className="flex items-center gap-[10px] px-[2px] py-1">
<Link2 size={15} className="flex-none text-m-muted" />
<span className="min-w-0 flex-1">
<span className="block truncate text-caption font-bold">{a.username}</span>
<span className="block truncate text-caption text-m-faint">{t('journey.contributors.viaTrip', { trip: a.trip_title })}</span>
</span>
<span className="rounded-full bg-[color:var(--m-ic)] px-[10px] py-[3px] font-geist text-caption font-extrabold text-m-muted">
{a.role}
</span>
</div>
))}
</>
)}
{/* Public share */} {/* Public share */}
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.share.publicShare')}</div> <div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.share.publicShare')}</div>
{!shareLink ? ( {!shareLink ? (
@@ -244,9 +244,10 @@ export default function MSettingsAccount() {
/> />
<MSetInput <MSetInput
className="mt-2" className="mt-2"
inputMode="numeric" inputMode="text"
autoComplete="one-time-code"
value={mfaDisableCode} value={mfaDisableCode}
onChange={(e) => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))} onChange={(e) => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
placeholder={t('settings.mfa.codePlaceholder')} placeholder={t('settings.mfa.codePlaceholder')}
/> />
<MSetButton <MSetButton
@@ -1,4 +1,4 @@
import { ChevronRight, FileDown, Settings, Share2 } from 'lucide-react' import { ChevronRight, FileDown, Settings, Settings2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import MSheet from '../../../components/MSheet' import MSheet from '../../../components/MSheet'
import type { MTripSheetsProps } from '../MTripShell' import type { MTripSheetsProps } from '../MTripShell'
@@ -14,6 +14,7 @@ const TILE_COLORS: Record<string, string> = {
finanzplan: '#E8A33D', finanzplan: '#E8A33D',
dateien: '#68686F', dateien: '#68686F',
collab: '#EC4899', collab: '#EC4899',
journal: '#C2410C',
} }
/** /**
@@ -48,7 +49,7 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
} }
const actions: { id: string; icon: LucideIcon; label: string; go: () => void }[] = [ const actions: { id: string; icon: LucideIcon; label: string; go: () => void }[] = [
{ id: 'members', icon: Share2, label: t('members.shareTrip'), go: () => shell.openSheet('members') }, { id: 'members', icon: Settings2, label: t('trip.settings.title'), go: () => shell.openSheet('members') },
{ id: 'export', icon: FileDown, label: t('mobileTrip.export'), go: () => shell.openSheet('export') }, { id: 'export', icon: FileDown, label: t('mobileTrip.export'), go: () => shell.openSheet('export') },
...(canEditTrip ...(canEditTrip
? [{ id: 'tripedit', icon: Settings, label: t('dashboard.editTrip'), go: () => shell.openSheet('tripedit') }] ? [{ id: 'tripedit', icon: Settings, label: t('dashboard.editTrip'), go: () => shell.openSheet('tripedit') }]
@@ -4,7 +4,7 @@ import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constan
import { formatTime, formatPriceText } from '../../../../utils/formatters' import { formatTime, formatPriceText } from '../../../../utils/formatters'
import { useTranslation } from '../../../../i18n' import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types' import type { Reservation } from '../../../../types'
import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts' import { CardActions, CardDeleteSheet, CardFiles, CardNotes, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome' import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel' import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { cardWhen, parseTransportMeta } from './transportsModel' import { cardWhen, parseTransportMeta } from './transportsModel'
@@ -173,11 +173,7 @@ function BookingCard({ res, planner, canEdit, compact }: {
</div> </div>
)} )}
{res.notes && ( <CardNotes notes={res.notes} />
<div className="mt-2 rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-2">
<p className="whitespace-pre-wrap font-geist text-[0.6875rem] leading-[1.5] text-m-muted">{res.notes}</p>
</div>
)}
<CardFiles files={files} card={card} t={t} /> <CardFiles files={files} card={card} t={t} />
</button> </button>
@@ -12,7 +12,7 @@ import { useTranslation } from '../../../../i18n'
import { DOCUMENT_PROVIDER_ICONS } from '../../../../components/shared/DocumentProviderIcons' import { DOCUMENT_PROVIDER_ICONS } from '../../../../components/shared/DocumentProviderIcons'
import TrekIcon from '../../../../components/shared/TrekIcon' import TrekIcon from '../../../../components/shared/TrekIcon'
import { import {
bindingNotice, needsReauth, storeName, useDocSync, type DocSyncLink, type DocSyncProvider, bindingNotice, needsReauth, storeName, useDocSync, type DocSyncLink, type DocSyncProvider, type DocSyncVerdict,
} from '../../../../components/Files/docsync/useDocSync' } from '../../../../components/Files/docsync/useDocSync'
import { useConnectForm } from '../../../../components/Files/docsync/useConnectForm' import { useConnectForm } from '../../../../components/Files/docsync/useConnectForm'
import { conflictPolicyKey, nextConflictPolicy } from '../../../../components/Files/docsync/DocSyncBits' import { conflictPolicyKey, nextConflictPolicy } from '../../../../components/Files/docsync/DocSyncBits'
@@ -450,7 +450,7 @@ function ConnectView({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const form = useConnectForm(provider, sync.connectionFor(provider.id), sync) const form = useConnectForm(provider, sync.connectionFor(provider.id), sync)
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null) const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
return ( return (
<div className="mt-3 flex flex-col gap-3"> <div className="mt-3 flex flex-col gap-3">
@@ -503,6 +503,10 @@ function ConnectView({
? t('docsync.connect.okAs', { account: verdict.account }) ? t('docsync.connect.okAs', { account: verdict.account })
: t('docsync.connected') : t('docsync.connected')
: t(`docsync.error.${verdict.error ?? 'unknown'}`)} : t(`docsync.error.${verdict.error ?? 'unknown'}`)}
{/* The store's own reason, so a refusal can be told apart from a typo (#2666). */}
{!verdict.connected && verdict.detail && (
<span className="mt-0.5 block text-m-muted [overflow-wrap:anywhere]">{verdict.detail}</span>
)}
</p> </p>
)} )}
@@ -1,11 +1,18 @@
// FE-COMP-MRESCARD-001 to FE-COMP-MRESCARD-009 // FE-COMP-MRESCARD-001 to FE-COMP-MRESCARD-010
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { Hotel, Plane } from 'lucide-react'; import { Hotel, Plane } from 'lucide-react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard'; import type { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard';
import type { Reservation } from '../../../../types'; import type { Reservation } from '../../../../types';
import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'; import {
CardActions,
CardDeleteSheet,
CardFiles,
CardNotes,
CardTitleButton,
CardWhenAndCode,
} from './MReservationCardParts';
const t = (key: string, params?: Record<string, string | number>) => const t = (key: string, params?: Record<string, string | number>) =>
params ? `${key}:${Object.values(params).join(',')}` : key; params ? `${key}:${Object.values(params).join(',')}` : key;
@@ -149,4 +156,15 @@ describe('MReservationCardParts', () => {
expect(title.contains(date)).toBe(false); expect(title.contains(date)).toBe(false);
expect(container.querySelector('button > div')).toHaveClass('flex-col'); expect(container.querySelector('button > div')).toHaveClass('flex-col');
}); });
it('FE-COMP-MRESCARD-010: shows the notes of a booking, and nothing for empty ones (Discord, 15.10.2026)', () => {
const notes = 'Gate closes 40 min early\nBag drop at counter C';
const { container, rerender } = render(<CardNotes notes={notes} />);
expect(container.querySelector('p')?.textContent).toBe(notes);
rerender(<CardNotes notes=" " />);
expect(container).toBeEmptyDOMElement();
rerender(<CardNotes notes={null} />);
expect(container).toBeEmptyDOMElement();
});
}); });
@@ -128,6 +128,22 @@ export function CardWhenAndCode({
); );
} }
/**
* The booking's notes, inside the card body button. A transport card on the phone
* never showed them, so notes typed into a flight were nowhere to be read
* (reported on Discord, 15.10.2026); both cards now take them from here.
*/
export function CardNotes({ notes }: { notes: string | null | undefined }) {
if (!notes?.trim()) return null;
return (
<div className="mt-2 rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-2">
<p className="whitespace-pre-wrap font-geist text-caption leading-[1.5] text-m-muted [overflow-wrap:anywhere]">
{notes}
</p>
</div>
);
}
/** Attached files, listed inside the card body button. */ /** Attached files, listed inside the card body button. */
export function CardFiles({ files, card, t }: { files: ReturnType<typeof filesFor>; card: CardState; t: T }) { export function CardFiles({ files, card, t }: { files: ReturnType<typeof filesFor>; card: CardState; t: T }) {
if (files.length === 0) return null; if (files.length === 0) return null;
@@ -3,7 +3,7 @@ import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constan
import { formatPriceText } from '../../../../utils/formatters' import { formatPriceText } from '../../../../utils/formatters'
import { useTranslation } from '../../../../i18n' import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types' import type { Reservation } from '../../../../types'
import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts' import { CardActions, CardDeleteSheet, CardFiles, CardNotes, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome' import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel' import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { cardWhen, parseTransportMeta } from './transportsModel' import { cardWhen, parseTransportMeta } from './transportsModel'
@@ -189,6 +189,8 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
<TravelerAvatars travelers={res.travelers || []} label={t('reservations.travelers.label')} /> <TravelerAvatars travelers={res.travelers || []} label={t('reservations.travelers.label')} />
<CardNotes notes={res.notes} />
<CardFiles files={files} card={card} t={t} /> <CardFiles files={files} card={card} t={t} />
</button> </button>
</div> </div>
@@ -7,6 +7,7 @@ import MFilesTab from './MFilesTab'
import MCollabTab from './MCollabTab' import MCollabTab from './MCollabTab'
import MListsTab from './MListsTab' import MListsTab from './MListsTab'
import MRoadtripTab from '../roadtrip/MRoadtripTab' import MRoadtripTab from '../roadtrip/MRoadtripTab'
import MJourneyDetail from '../../journey/MJourneyDetail'
import PluginFrame from '../../../../components/Plugins/PluginFrame' import PluginFrame from '../../../../components/Plugins/PluginFrame'
/** /**
@@ -61,6 +62,14 @@ export default function MTripTabPanel({ planner, shell, tab }: MTripTabPanelProp
// switch between them must not lose it. // switch between them must not lose it.
case 'roadtrip': case 'roadtrip':
return <MRoadtripTab planner={planner} shell={shell} tab={tab} /> return <MRoadtripTab planner={planner} shell={shell} tab={tab} />
// The journal the trip links: the Journey addon's own phone screen, moved
// below the trip's floating top controls: its bars measure from --m-safe-top.
case 'journal':
return planner.journalId == null ? null : (
<div className="absolute inset-0 overflow-hidden [--m-safe-top:calc(env(safe-area-inset-top,0px)+62px)]">
<MJourneyDetail journeyId={planner.journalId} embedded />
</div>
)
default: default:
return <TabScroller>{null}</TabScroller> return <TabScroller>{null}</TabScroller>
} }
+37 -24
View File
@@ -18,7 +18,7 @@ import JourneyDayDawarich from '../components/Journey/JourneyDayDawarich'
import MobileEntryView from '../components/Journey/MobileEntryView' import MobileEntryView from '../components/Journey/MobileEntryView'
import { useJourneyStore } from '../store/journeyStore' import { useJourneyStore } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle' import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
import { useJourneyDetail } from './journeyDetail/useJourneyDetail' import { useJourneyDetail, type JourneyDetailOptions } from './journeyDetail/useJourneyDetail'
import { createDraftJourneyEntry, pickGradient, groupByDate, formatDate, photoUrl, matchJourneyEntries } from './journeyDetail/JourneyDetailPage.helpers' import { createDraftJourneyEntry, pickGradient, groupByDate, formatDate, photoUrl, matchJourneyEntries } from './journeyDetail/JourneyDetailPage.helpers'
import { EntryCard, SkeletonCard, CheckinCard } from '../components/Journey/JourneyDetailPageEntryCard' import { EntryCard, SkeletonCard, CheckinCard } from '../components/Journey/JourneyDetailPageEntryCard'
import { GalleryView } from '../components/Journey/JourneyDetailPageGalleryView' import { GalleryView } from '../components/Journey/JourneyDetailPageGalleryView'
@@ -39,7 +39,12 @@ export default function JourneyDetailPage() {
) )
} }
function JourneyDetailPageDesktop() { /**
* The journal screen. The journal page renders it for the route's journal; a trip
* that links a journal renders the same screen inside its own tab (`embedded`),
* so the trip shows the journal with nothing of it built twice.
*/
export function JourneyDetailPageDesktop({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
// Page = wiring container: load + live sync, view state, dialogs, the // Page = wiring container: load + live sync, view state, dialogs, the
// scroll-synced map and the map/trip-date derivations live in the hook. // scroll-synced map and the map/trip-date derivations live in the hook.
const { const {
@@ -62,13 +67,17 @@ function JourneyDetailPageDesktop() {
feedEdge, scrollFeedTo, feedEdge, scrollFeedTo,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
addPickedProviderPhotos, addEntryProviderPhotos, addPickedProviderPhotos, addEntryProviderPhotos,
} = useJourneyDetail() } = useJourneyDetail({ journeyId, embedded })
// Inside a trip the trip draws the bar and owns the height; the journal fills
// the box it is given and starts at its top.
const frame = embedded ? 'h-full bg-zinc-50 dark:bg-zinc-950' : 'min-h-screen bg-zinc-50 dark:bg-zinc-950'
const below = embedded ? undefined : { paddingTop: 'var(--nav-h, 0px)' }
if (loading || !current) { if (loading || !current) {
return ( return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950"> <div className={frame}>
<Navbar /> {!embedded && <Navbar />}
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className="flex justify-center py-20"> <div style={below} className="flex justify-center py-20">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" /> <div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
</div> </div>
</div> </div>
@@ -166,8 +175,8 @@ function JourneyDetailPageDesktop() {
) : null ) : null
return ( return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950"> <div className={frame}>
<Navbar /> {!embedded && <Navbar />}
{/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */} {/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */}
{showMobileCombined && ( {showMobileCombined && (
@@ -206,13 +215,15 @@ function JourneyDetailPageDesktop() {
className="fixed inset-x-0 z-30 flex items-start justify-between gap-2 px-4" className="fixed inset-x-0 z-30 flex items-start justify-between gap-2 px-4"
style={{ top: 'calc(var(--nav-h, 56px) + 12px)' }} style={{ top: 'calc(var(--nav-h, 56px) + 12px)' }}
> >
<button type="button" {!embedded && (
onClick={() => navigate('/journey')} <button type="button"
aria-label={t('journey.detail.backToJourney')} onClick={() => navigate('/journey')}
className={barButton} aria-label={t('journey.detail.backToJourney')}
> className={barButton}
<ArrowLeft size={16} /> >
</button> <ArrowLeft size={16} />
</button>
)}
<div className="flex-1 min-w-0 flex justify-center"> <div className="flex-1 min-w-0 flex justify-center">
<div className="flex bg-surface-elevated backdrop-blur-lg border border-edge rounded-lg overflow-hidden shadow-lg"> <div className="flex bg-surface-elevated backdrop-blur-lg border border-edge rounded-lg overflow-hidden shadow-lg">
@@ -270,7 +281,7 @@ function JourneyDetailPageDesktop() {
</div> </div>
)} )}
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className={showMobileCombined ? 'hidden' : ''}> <div style={below} className={showMobileCombined ? 'hidden' : embedded ? 'h-full' : ''}>
<div <div
className={ className={
isMobile isMobile
@@ -310,13 +321,15 @@ function JourneyDetailPageDesktop() {
<div className="relative z-[3] flex items-center justify-between mb-5"> <div className="relative z-[3] flex items-center justify-between mb-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button type="button" {!embedded && (
onClick={() => navigate('/journey')} <button type="button"
aria-label={t('journey.detail.backToJourney')} onClick={() => navigate('/journey')}
className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25" aria-label={t('journey.detail.backToJourney')}
> className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
<ArrowLeft size={14} /> >
</button> <ArrowLeft size={14} />
</button>
)}
{/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */} {/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */}
<div className="hidden md:flex items-center gap-2"> <div className="hidden md:flex items-center gap-2">
{lifecycle !== 'live' && lifecycle !== 'archived' && ( {lifecycle !== 'live' && lifecycle !== 'archived' && (
@@ -821,7 +834,7 @@ function JourneyDetailPageDesktop() {
{/* TREK Studio. It portals itself over the page, so this journey keeps {/* TREK Studio. It portals itself over the page, so this journey keeps
rendering underneath and shows through the panel's margin. */} rendering underneath and shows through the panel's margin. */}
<Outlet /> {!embedded && <Outlet />}
</div> </div>
) )
} }
+77 -1
View File
@@ -12,6 +12,8 @@ import { useAddonStore } from '../store/addonStore';
import { usePermissionsStore } from '../store/permissionsStore'; import { usePermissionsStore } from '../store/permissionsStore';
import JourneyPage from './JourneyPage'; import JourneyPage from './JourneyPage';
const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn() }));
// ── Mocks ──────────────────────────────────────────────────────────────────── // ── Mocks ────────────────────────────────────────────────────────────────────
const mockNavigate = vi.fn(); const mockNavigate = vi.fn();
@@ -24,6 +26,10 @@ vi.mock('../components/Layout/Navbar', () => ({
default: () => <nav data-testid="navbar" />, default: () => <nav data-testid="navbar" />,
})); }));
vi.mock('../components/shared/Toast', () => ({
useToast: () => ({ error: mockToastError }),
}));
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
let _seq = 500; let _seq = 500;
@@ -78,6 +84,7 @@ function setupDefaultHandlers(journeys: ReturnType<typeof buildJourneyListItem>[
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
window.localStorage.clear();
mockNavigate.mockReset(); mockNavigate.mockReset();
resetAllStores(); resetAllStores();
seedDefaults(); seedDefaults();
@@ -193,6 +200,75 @@ describe('JourneyPage', () => {
}); });
}); });
it('FE-PAGE-JOURNEY-007c: keeps a dismissed suggestion hidden after remount', async () => {
const user = userEvent.setup();
const suggestions = [
{ id: 991, title: 'Kyoto Weekend', start_date: '2026-03-01', end_date: '2026-03-02', place_count: 2 },
{ id: 993, title: 'Lisbon Loop', start_date: '2026-04-01', end_date: '2026-04-02', place_count: 3 },
];
server.use(
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
http.get('/api/journeys/suggestions', () => HttpResponse.json({ trips: suggestions })),
);
const firstRender = render(<JourneyPage />);
const dismissButton = await screen.findByRole('button', { name: 'Dismiss' });
await user.click(dismissButton);
expect(await screen.findByText(/Lisbon Loop/)).toBeInTheDocument();
const dismissedRecord = JSON.parse(window.localStorage.getItem(`trek:journey:dismissed-suggestions:v1:${useAuthStore.getState().user?.id}`) || '[]');
expect(dismissedRecord).toContain(991);
firstRender.unmount();
render(<JourneyPage />);
expect(await screen.findByText(/Lisbon Loop/)).toBeInTheDocument();
expect(screen.queryByText(/Kyoto Weekend/)).not.toBeInTheDocument();
});
it('FE-PAGE-JOURNEY-007d: keeps the suggestion visible and reports a storage failure', async () => {
const user = userEvent.setup();
const suggestion = { id: 992, title: 'Coast Trip', start_date: '2026-03-01', end_date: '2026-03-02', place_count: 2 };
mockToastError.mockClear();
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('Storage is full', 'QuotaExceededError');
});
server.use(
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
http.get('/api/journeys/suggestions', () => HttpResponse.json({ trips: [suggestion] })),
);
render(<JourneyPage />);
await user.click(await screen.findByRole('button', { name: 'Dismiss' }));
expect(mockToastError).toHaveBeenCalledWith('Could not dismiss this suggestion');
expect(screen.getByText(/Trip just ended/i)).toBeInTheDocument();
setItem.mockRestore();
});
it('FE-PAGE-JOURNEY-007e: deletes dismissed IDs absent from the suggestions response', async () => {
const userId = useAuthStore.getState().user?.id;
const absentTripId = 994;
const stillDismissedTripId = 995;
const returnedTripId = 996;
const key = `trek:journey:dismissed-suggestions:v1:${userId}`;
window.localStorage.setItem(key, JSON.stringify([absentTripId, stillDismissedTripId]));
server.use(
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
http.get('/api/journeys/suggestions', () => HttpResponse.json({
trips: [
{ id: returnedTripId, title: 'Returned Trip', start_date: '2026-04-01', end_date: '2026-04-02', place_count: 2 },
{ id: stillDismissedTripId, title: 'Still Dismissed', start_date: '2026-05-01', end_date: '2026-05-02', place_count: 2 },
],
})),
);
render(<JourneyPage />);
expect(await screen.findByText(/Returned Trip/)).toBeInTheDocument();
expect(screen.queryByText(/Still Dismissed/)).not.toBeInTheDocument();
expect(JSON.parse(window.localStorage.getItem(key) || '[]')).toEqual([stillDismissedTripId]);
});
// FE-PAGE-JOURNEY-007b — XSS regression: the suggestion banner interpolates // FE-PAGE-JOURNEY-007b — XSS regression: the suggestion banner interpolates
// a user-controlled trip title into an HTML template that is later passed to // a user-controlled trip title into an HTML template that is later passed to
// dangerouslySetInnerHTML. The sanitiser in @trek/shared must drop any script // dangerouslySetInnerHTML. The sanitiser in @trek/shared must drop any script
@@ -266,7 +342,7 @@ describe('JourneyPage', () => {
}); });
// Click dismiss // Click dismiss
await user.click(screen.getByText(/Dismiss/i)); await user.click(screen.getByRole('button', { name: 'Dismiss' }));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Trip just ended/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Trip just ended/i)).not.toBeInTheDocument();
+2 -2
View File
@@ -45,7 +45,7 @@ function JourneyPageDesktop() {
showCreate, setShowCreate, newTitle, setNewTitle, newSubtitle, setNewSubtitle, showCreate, setShowCreate, newTitle, setNewTitle, newSubtitle, setNewSubtitle,
availableTrips, selectedTripIds, setSelectedTripIds, availableTrips, selectedTripIds, setSelectedTripIds,
searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef, searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef,
activeSuggestion, setDismissedSuggestions, activeSuggestion, dismissSuggestion,
activeJourney, activeJourneyIsLive, filteredJourneys, activeJourney, activeJourneyIsLive, filteredJourneys,
openCreateModal, handleCreate, totalPlaces, openCreateModal, handleCreate, totalPlaces,
} = useJourney() } = useJourney()
@@ -112,7 +112,7 @@ function JourneyPageDesktop() {
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<button type="button" <button type="button"
onClick={() => setDismissedSuggestions(prev => new Set([...prev, activeSuggestion.id]))} onClick={() => dismissSuggestion(activeSuggestion.id)}
className="px-3 py-1.5 rounded-lg bg-white/10 border border-white/20 text-[12px] font-medium text-white hover:bg-white/20" className="px-3 py-1.5 rounded-lg bg-white/10 border border-white/20 text-[12px] font-medium text-white hover:bg-white/20"
> >
{t('journey.frontpage.dismiss')} {t('journey.frontpage.dismiss')}
+2 -4
View File
@@ -38,7 +38,7 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
availableTrips: [], selectedTripIds: new Set<number>(), setSelectedTripIds: vi.fn(), availableTrips: [], selectedTripIds: new Set<number>(), setSelectedTripIds: vi.fn(),
searchOpen: false, setSearchOpen: vi.fn(), searchQuery: '', setSearchQuery: vi.fn(), searchOpen: false, setSearchOpen: vi.fn(), searchQuery: '', setSearchQuery: vi.fn(),
searchInputRef: { current: null }, searchInputRef: { current: null },
activeSuggestion: undefined, setDismissedSuggestions: vi.fn(), activeSuggestion: undefined, dismissSuggestion: vi.fn(),
activeJourney: null, activeJourneyIsLive: false, activeJourney: null, activeJourneyIsLive: false,
filteredJourneys: journeys, filteredJourneys: journeys,
openCreateModal: vi.fn(), handleCreate: vi.fn(), totalPlaces: 0, openCreateModal: vi.fn(), handleCreate: vi.fn(), totalPlaces: 0,
@@ -116,9 +116,7 @@ describe('JourneyPage wiring', () => {
expect(screen.getByText('Trip just ended')).toBeInTheDocument(); expect(screen.getByText('Trip just ended')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
const updater = (hook.setDismissedSuggestions as ReturnType<typeof vi.fn>).mock.calls[0][0] as expect(hook.dismissSuggestion).toHaveBeenCalledWith(42);
(prev: Set<number>) => Set<number>;
expect([...updater(new Set<number>())]).toEqual([42]);
fireEvent.click(screen.getAllByRole('button', { name: 'Create Journey' })[1]); fireEvent.click(screen.getAllByRole('button', { name: 'Create Journey' })[1]);
expect(hook.openCreateModal).toHaveBeenCalledWith(42); expect(hook.openCreateModal).toHaveBeenCalledWith(42);
+20 -2
View File
@@ -1586,8 +1586,9 @@ describe('SharedTripPage', () => {
id: 301, day_id: 21, order_index: 0, notes: 'go early, before the coaches', id: 301, day_id: 21, order_index: 0, notes: 'go early, before the coaches',
place: { place: {
id: 501, name: 'Louvre', description: 'The big one', address: 'Rue de Rivoli, Paris', id: 501, name: 'Louvre', description: 'The big one', address: 'Rue de Rivoli, Paris',
lat: 48.86, lng: 2.34, category_id: null, price: null, place_time: '09:00', end_time: null, // The stay comes from the window; the place default of an hour is not a plan (#2676).
duration_minutes: 150, notes: 'Skip the pyramid queue', website: 'https://louvre.fr', lat: 48.86, lng: 2.34, category_id: null, price: null, place_time: '09:00', end_time: '11:30',
duration_minutes: 60, notes: 'Skip the pyramid queue', website: 'https://louvre.fr',
phone: '+33 1 40 20 50 50', image_url: null, transport_mode: 'walking', category: null, tags: [], phone: '+33 1 40 20 50 50', image_url: null, transport_mode: 'walking', category: null, tags: [],
}, },
}, },
@@ -1639,6 +1640,23 @@ describe('SharedTripPage', () => {
expect(document.querySelector('a[href^="javascript:"]')).toBeNull(); expect(document.querySelector('a[href^="javascript:"]')).toBeNull();
expect(screen.queryByText(/min$/)).toBeNull(); expect(screen.queryByText(/min$/)).toBeNull();
}); });
it('shows no stay for a stop with only a start, whatever the place default says (#2676)', async () => {
const startOnly = {
21: [{
id: 303, day_id: 21, order_index: 0, notes: null,
place: {
id: 503, name: 'Café', description: null, address: 'Somewhere 1', lat: null, lng: null,
duration_minutes: 60, notes: null, website: null, phone: null,
category_id: null, price: null, place_time: '15:00', end_time: null, image_url: null,
transport_mode: 'walking', category: null, tags: [],
},
}],
};
await open('start-only-token', payload({ days, assignments: startOnly }));
await waitFor(() => expect(screen.getByText('Somewhere 1')).toBeInTheDocument());
expect(screen.queryByText('1 h')).toBeNull();
});
}); });
describe('FE-PAGE-SHARED-039: a booking shows its note and its link, and nothing that is not http(s) (#2320)', () => { describe('FE-PAGE-SHARED-039: a booking shows its note and its link, and nothing that is not http(s) (#2320)', () => {
+4 -4
View File
@@ -39,7 +39,7 @@ import { latestReservation } from './tripPlanner/transportEditorOpeners'
import { import {
ReservationsPanel, FileManager, CostsPanel, ExpenseModal, CollabPanel, ReservationsPanel, FileManager, CostsPanel, ExpenseModal, CollabPanel,
RoadtripSidebar, RoadtripCorridorPanel, RoadtripLimitsCard, RoadtripStopPopup, RoadtripStayModal, RoadtripTrackModal, RoadtripSidebar, RoadtripCorridorPanel, RoadtripLimitsCard, RoadtripStopPopup, RoadtripStayModal, RoadtripTrackModal,
TourWorkspaceRail, TourPlannerToursRail, TransportModal, TourWorkspaceRail, TourPlannerToursRail, TransportModal, TripJournalPanel,
} from './tripPlanner/plannerLazy' } from './tripPlanner/plannerLazy'
import { LazyPanel } from './tripPlanner/LazyPanel' import { LazyPanel } from './tripPlanner/LazyPanel'
import { ListsContainer } from './tripPlanner/ListsContainer' import { ListsContainer } from './tripPlanner/ListsContainer'
@@ -70,7 +70,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files, trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles, selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo, pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations, enabledAddons, collabFeatures, journalId, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor, roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewActive, tripOverview, toggleOverview, overviewShown, overviewActive, tripOverview, toggleOverview, overviewShown,
dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates, dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates,
@@ -242,7 +242,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
return ( return (
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}> <div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
<HelpAnchor id={helpId} /> <HelpAnchor id={helpId} />
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} /> <Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onSettings={() => setShowMembersModal(true)} />
<div className="bg-surface-elevated border-b border-edge-faint" style={{ <div className="bg-surface-elevated border-b border-edge-faint" style={{
position: 'fixed', top: 'var(--nav-h)', insetInline: 0, zIndex: 40, position: 'fixed', top: 'var(--nav-h)', insetInline: 0, zIndex: 40,
@@ -792,7 +792,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
</LazyPanel> </LazyPanel>
</div> </div>
)} )}
{activeTab === 'journal' && <LazyPanel id="journal"><TripJournalPanel journeyId={journalId} /></LazyPanel>}
{activeTab.startsWith('plugin:') && ( {activeTab.startsWith('plugin:') && (
<div style={{ position: 'absolute', top: 0, insetInline: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}> <div style={{ position: 'absolute', top: 0, insetInline: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
@@ -657,14 +657,14 @@ describe('TripPlannerPage — shell', () => {
expect(container).toBeEmptyDOMElement() expect(container).toBeEmptyDOMElement()
}) })
it('FE-PAGE-TPW-005: the navbar gets the trip title and drives back / share', () => { it('FE-PAGE-TPW-005: the navbar gets the trip title and drives back / settings', () => {
renderPage() renderPage()
expect(props('navbar').tripTitle).toBe('Kyoto') expect(props('navbar').tripTitle).toBe('Kyoto')
act(() => { props('navbar').onBack() }) act(() => { props('navbar').onBack() })
expect(hookState.navigate).toHaveBeenCalledWith('/dashboard') expect(hookState.navigate).toHaveBeenCalledWith('/dashboard')
act(() => { props('navbar').onShare() }) act(() => { props('navbar').onSettings() })
expect(hookState.setShowMembersModal).toHaveBeenCalledWith(true) expect(hookState.setShowMembersModal).toHaveBeenCalledWith(true)
}) })
+59 -3
View File
@@ -1,11 +1,35 @@
import { useEffect, useState, useMemo, useRef } from 'react' import { useEffect, useState, useMemo, useRef } from 'react'
import { useNavigate, useSearchParams } from 'react-router' import { useNavigate, useSearchParams } from 'react-router'
import { useJourneyStore } from '../../store/journeyStore' import { useJourneyStore } from '../../store/journeyStore'
import { useAuthStore } from '../../store/authStore'
import { journeyApi } from '../../api/client' import { journeyApi } from '../../api/client'
import { useToast } from '../../components/shared/Toast' import { useToast } from '../../components/shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { computeJourneyLifecycle } from '../../utils/journeyLifecycle' import { computeJourneyLifecycle } from '../../utils/journeyLifecycle'
function dismissedSuggestionsKey(userId: number | undefined): string {
return `trek:journey:dismissed-suggestions:v1:${userId ?? 'anonymous'}`
}
/** The trips this user put away, as stored here; anything unreadable reads as none. */
function readDismissedSuggestions(userId: number | undefined): Set<number> {
try {
const value: unknown = JSON.parse(window.localStorage.getItem(dismissedSuggestionsKey(userId)) || '[]')
return new Set(Array.isArray(value) ? value.filter((id): id is number => Number.isInteger(id)) : [])
} catch {
return new Set()
}
}
function saveDismissedSuggestions(userId: number | undefined, dismissed: Set<number>): void {
const key = dismissedSuggestionsKey(userId)
if (dismissed.size === 0) {
window.localStorage.removeItem(key)
return
}
window.localStorage.setItem(key, JSON.stringify([...dismissed]))
}
/** /**
* Journey list page logic — owns the journey store load, the create-journey * Journey list page logic — owns the journey store load, the create-journey
* modal (title, available trips, selection), the search box, the trip * modal (title, available trips, selection), the search box, the trip
@@ -18,6 +42,7 @@ export function useJourney() {
const toast = useToast() const toast = useToast()
const { t } = useTranslation() const { t } = useTranslation()
const { journeys, loading, loadJourneys, createJourney } = useJourneyStore() const { journeys, loading, loadJourneys, createJourney } = useJourneyStore()
const userId = useAuthStore(s => s.user?.id)
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [newTitle, setNewTitle] = useState('') const [newTitle, setNewTitle] = useState('')
@@ -30,15 +55,35 @@ export function useJourney() {
// suggestion // suggestion
const [suggestions, setSuggestions] = useState<any[]>([]) const [suggestions, setSuggestions] = useState<any[]>([])
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(new Set()) const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(() => readDismissedSuggestions(userId))
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
useEffect(() => { useEffect(() => {
loadJourneys() loadJourneys()
journeyApi.suggestions().then(d => setSuggestions(d.trips || [])).catch(() => {})
}, []) }, [])
useEffect(() => {
let cancelled = false
journeyApi.suggestions().then(d => {
if (cancelled) return
const trips = d.trips || []
const suggestionIds = new Set<number>(trips.map((trip: { id: number }) => trip.id))
const stored = readDismissedSuggestions(userId)
const retained = new Set([...stored].filter(id => suggestionIds.has(id)))
setSuggestions(trips)
setDismissedSuggestions(retained)
if (retained.size !== stored.size) {
try {
saveDismissedSuggestions(userId, retained)
} catch {
// Pruning is best effort; a failed write leaves the old IDs to retry later.
}
}
}).catch(() => {})
return () => { cancelled = true }
}, [userId])
// The bottom-nav "+" opens the new-journey modal via ?create=1. // The bottom-nav "+" opens the new-journey modal via ?create=1.
useEffect(() => { useEffect(() => {
if (searchParams.get('create') === '1') { if (searchParams.get('create') === '1') {
@@ -49,6 +94,17 @@ export function useJourney() {
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id)) const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
const dismissSuggestion = (tripId: number) => {
const next = new Set(dismissedSuggestions)
next.add(tripId)
try {
saveDismissedSuggestions(userId, next)
setDismissedSuggestions(next)
} catch {
toast.error(t('journey.frontpage.dismissError'))
}
}
// The frontpage always features one journey as the hero. A journey tied to a // The frontpage always features one journey as the hero. A journey tied to a
// currently-running trip wins; otherwise we fall back to the most recent one // currently-running trip wins; otherwise we fall back to the most recent one
// (the list arrives sorted by updated_at DESC, so journeys[0] is the newest). // (the list arrives sorted by updated_at DESC, so journeys[0] is the newest).
@@ -118,7 +174,7 @@ export function useJourney() {
showCreate, setShowCreate, newTitle, setNewTitle, newSubtitle, setNewSubtitle, showCreate, setShowCreate, newTitle, setNewTitle, newSubtitle, setNewSubtitle,
availableTrips, selectedTripIds, setSelectedTripIds, availableTrips, selectedTripIds, setSelectedTripIds,
searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef, searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef,
activeSuggestion, setDismissedSuggestions, activeSuggestion, dismissSuggestion,
activeJourney, activeJourneyIsLive, filteredJourneys, activeJourney, activeJourneyIsLive, filteredJourneys,
openCreateModal, handleCreate, totalPlaces, openCreateModal, handleCreate, totalPlaces,
} }
@@ -29,9 +29,23 @@ const NO_TRACKS: JourneyTrack[] = []
* map/trip-date derivations. JourneyDetailPage stays a wiring container around * map/trip-date derivations. JourneyDetailPage stays a wiring container around
* its large two-pane JSX and many presentational sub-components. * its large two-pane JSX and many presentational sub-components.
* Behaviour is identical to the previous in-component logic. * Behaviour is identical to the previous in-component logic.
*
* The journal can also show inside a trip that links it, as the same screen with
* the same dialogs. There the trip names the journal (`journeyId`) instead of the
* route, and `embedded` keeps the screen to its own box: no scroll lock on the
* page, no `?create=` handling on the trip's URL, and no trip out to the journal
* list when the journal goes away; the trip decides what shows then.
*/ */
export function useJourneyDetail() { export interface JourneyDetailOptions {
const { id } = useParams() /** The journal to show; the route's `:id` when left out. */
journeyId?: number
/** Shown inside another screen (a trip) rather than as the journal page. */
embedded?: boolean
}
export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
const params = useParams()
const id = journeyId != null ? String(journeyId) : params.id
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast() const toast = useToast()
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
@@ -67,9 +81,10 @@ export function useJourneyDetail() {
const [showAddTrip, setShowAddTrip] = useState(false) const [showAddTrip, setShowAddTrip] = useState(false)
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
// The bottom-nav "+" starts a new entry via ?create=entry. // The bottom-nav "+" starts a new entry via ?create=entry. Not inside a trip:
// the trip's own `create` values live on that same URL.
useEffect(() => { useEffect(() => {
if (searchParams.get('create') === 'entry' && current && canEditEntries) { if (!embedded && searchParams.get('create') === 'entry' && current && canEditEntries) {
setEditingEntry(createDraftJourneyEntry(current.id)) setEditingEntry(createDraftJourneyEntry(current.id))
setSearchParams(p => { p.delete('create'); return p }, { replace: true }) setSearchParams(p => { p.delete('create'); return p }, { replace: true })
} }
@@ -119,11 +134,11 @@ export function useJourneyDetail() {
}, [current?.hide_skeletons]) }, [current?.hide_skeletons])
useEffect(() => { useEffect(() => {
if (notFound) { if (notFound && !embedded) {
toast.error(t('journey.notFound')) toast.error(t('journey.notFound'))
navigate('/journey') navigate('/journey')
} }
}, [notFound]) }, [notFound, embedded])
// WebSocket real-time updates // WebSocket real-time updates
useEffect(() => { useEffect(() => {
@@ -374,9 +389,9 @@ export function useJourneyDetail() {
// the body must not scroll underneath it. Goes through the shared counter so a // the body must not scroll underneath it. Goes through the shared counter so a
// modal opening on top of it cannot hand the page back early (#1809). // modal opening on top of it cannot hand the page back early (#1809).
useEffect(() => { useEffect(() => {
if (isMobile) return if (isMobile || embedded) return
return lockBodyScroll() return lockBodyScroll()
}, [isMobile]) }, [isMobile, embedded])
// Map only shows real journal entries — skeletons are trip-derived // Map only shows real journal entries — skeletons are trip-derived
// suggestions, not something the user actually journaled at that spot. // suggestions, not something the user actually journaled at that spot.
@@ -549,7 +564,7 @@ export function useJourneyDetail() {
}, []) }, [])
return { return {
id, navigate, toast, t, locale, id, navigate, toast, t, locale, embedded, notFound,
openStudio, prefetchStudio, openStudio, prefetchStudio,
current, loading, current, loading,
canEditEntries, canEditJourney, myRole, canEditEntries, canEditJourney, myRole,
@@ -4,7 +4,7 @@ import { fs } from '../../components/shared/DialogShell'
import { Tooltip } from '../../components/shared/Tooltip' import { Tooltip } from '../../components/shared/Tooltip'
import { WhiteBadge } from '../../components/Planner/planParts' import { WhiteBadge } from '../../components/Planner/planParts'
import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps' import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps'
import { formatDurationMinutes, isHttpUrl } from './sharedTripModel' import { formatDurationMinutes, isHttpUrl, plannedStayMinutes } from './sharedTripModel'
/** /**
* The read-only detail of one stop on a shared plan (#2320). * The read-only detail of one stop on a shared plan (#2320).
@@ -14,7 +14,8 @@ import { formatDurationMinutes, isHttpUrl } from './sharedTripModel'
* usually also left a note on the place and another on the day it was * usually also left a note on the place and another on the day it was
* planned for, and a link would show none of that. This shows what the owner * planned for, and a link would show none of that. This shows what the owner
* chose to share, laid out the way the planner lays it out: address, then * chose to share, laid out the way the planner lays it out: address, then
* description, then the two notes, then how long they meant to stay, then * description, then the two notes, then how long they meant to stay (from the
* stop's start and end time), then
* the ways to reach the place. * the ways to reach the place.
* *
* Nothing here is editable and nothing here is a route into the app: every * Nothing here is editable and nothing here is a route into the app: every
@@ -46,7 +47,7 @@ export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPl
name: place.name, address: place.address ?? null, lat: place.lat ?? null, lng: place.lng ?? null, name: place.name, address: place.address ?? null, lat: place.lat ?? null, lng: place.lng ?? null,
google_place_id: null, google_ftid: null, google_place_id: null, google_ftid: null,
}) })
const duration = formatDurationMinutes(place.duration_minutes) const duration = formatDurationMinutes(plannedStayMinutes(place.place_time, place.end_time))
const dayNote = assignmentNotes?.trim() || null const dayNote = assignmentNotes?.trim() || null
const placeNote = place.notes?.trim() || null const placeNote = place.notes?.trim() || null
const hasLinks = !!(website || phone || mapsUrl) const hasLinks = !!(website || phone || mapsUrl)
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { AxiosError, AxiosHeaders } from 'axios' import { AxiosError, AxiosHeaders } from 'axios'
import { import {
coverSrc, dayHasEntries, formatDateRange, formatDurationMinutes, groupInOrder, isHttpUrl, isSharedTripPayload, legFacts, linkHost, coverSrc, dayHasEntries, formatDateRange, formatDurationMinutes, groupInOrder, isHttpUrl, isSharedTripPayload, legFacts, linkHost, plannedStayMinutes,
sharedTripLoadError, spanLabelKey, stopNumbers, transportFacts, unplannedPlaces, sharedTripLoadError, spanLabelKey, stopNumbers, transportFacts, unplannedPlaces,
} from './sharedTripModel' } from './sharedTripModel'
@@ -25,6 +25,15 @@ describe('sharedTripModel (#2320)', () => {
expect(formatDurationMinutes(89.6)).toBe('1 h 30 min') expect(formatDurationMinutes(89.6)).toBe('1 h 30 min')
}) })
it('reads the planned stay off the time window, never off the place default (#2676)', () => {
expect(plannedStayMinutes('10:00', '12:30')).toBe(150)
expect(plannedStayMinutes('22:30', '01:00')).toBe(150)
expect(plannedStayMinutes('10:00', null)).toBeNull()
expect(plannedStayMinutes(null, '12:00')).toBeNull()
expect(plannedStayMinutes('10:00', '10:00')).toBeNull()
expect(plannedStayMinutes(undefined, undefined)).toBeNull()
})
it('writes nothing for a missing, zero or nonsense figure', () => { it('writes nothing for a missing, zero or nonsense figure', () => {
expect(formatDurationMinutes(null)).toBeNull() expect(formatDurationMinutes(null)).toBeNull()
expect(formatDurationMinutes(undefined)).toBeNull() expect(formatDurationMinutes(undefined)).toBeNull()
@@ -3,6 +3,7 @@
* page and its detail blocks can share them and a test can drive them * page and its detail blocks can share them and a test can drive them
* without rendering. * without rendering.
*/ */
import { parseTimeToMinutes } from '../../utils/dayMerge'
/** /**
* Whether a string is a link the page may render as one. * Whether a string is a link the page may render as one.
@@ -22,6 +23,22 @@ export function isHttpUrl(value: unknown): value is string {
} }
} }
/**
* How long the owner planned to stay, read off the stop's time window.
*
* Not `places.duration_minutes`: every place carries one, 60 unless someone
* set a road-trip stay, so a link showed "1 h" under every stop (#2676). The
* window is what the owner actually planned, and it can differ per visit.
* Without an end there is no stay to show. An end before the start runs past
* midnight.
*/
export function plannedStayMinutes(start: string | null | undefined, end: string | null | undefined): number | null {
const from = parseTimeToMinutes(start)
const to = parseTimeToMinutes(end)
if (from == null || to == null || from === to) return null
return to > from ? to - from : to + 24 * 60 - from
}
/** /**
* "45 min", "2 h", "1 h 30 min" — the planned time at a place. * "45 min", "2 h", "1 h 30 min" — the planned time at a place.
* *
@@ -0,0 +1,20 @@
import { JourneyDetailPageDesktop } from '../JourneyDetailPage';
/**
* The trip's Journal tab on the desktop: the Journey addon's own journal view,
* not a copy of it, so the journal looks and works the same in both places and
* stays live through the journey's own sync.
*
* The view's fixed bars measure themselves from --nav-h, so the wrapper sets it
* to the navbar plus the trip's 44 px tab bar, and they sit below both.
*/
const BELOW_TRIP_TABS = '[--nav-h:44px] md:[--nav-h:calc(64px+env(safe-area-inset-top,0px)+44px)]';
export default function TripJournalPanel({ journeyId }: { journeyId: number | null }) {
if (journeyId == null) return null;
return (
<div className={`${BELOW_TRIP_TABS} absolute inset-0 overflow-y-auto`}>
<JourneyDetailPageDesktop journeyId={journeyId} embedded />
</div>
);
}
@@ -24,6 +24,7 @@ vi.mock('../../components/Tours/planner/TourPlannerPanels', () => ({
TourPlannerToursRail: label('tour planner tours rail'), TourPlannerToursRail: label('tour planner tours rail'),
})) }))
vi.mock('../../components/Planner/TransportModal', () => ({ TransportModal: label('transport modal') })) vi.mock('../../components/Planner/TransportModal', () => ({ TransportModal: label('transport modal') }))
vi.mock('./TripJournalPanel', () => ({ default: label('trip journal panel') }))
const cases: Array<[keyof typeof lazy, string]> = [ const cases: Array<[keyof typeof lazy, string]> = [
['ReservationsPanel', 'reservations panel'], ['ReservationsPanel', 'reservations panel'],
@@ -43,6 +44,7 @@ const cases: Array<[keyof typeof lazy, string]> = [
['TourWorkspaceRail', 'tour workspace rail'], ['TourWorkspaceRail', 'tour workspace rail'],
['TourPlannerToursRail', 'tour planner tours rail'], ['TourPlannerToursRail', 'tour planner tours rail'],
['TransportModal', 'transport modal'], ['TransportModal', 'transport modal'],
['TripJournalPanel', 'trip journal panel'],
] ]
function renderLazy(name: keyof typeof lazy) { function renderLazy(name: keyof typeof lazy) {
@@ -17,6 +17,8 @@ export const ExpenseModal = lazyWithRetry(() =>
import('../../components/Budget/CostsPanel').then(m => ({ default: m.ExpenseModal })) import('../../components/Budget/CostsPanel').then(m => ({ default: m.ExpenseModal }))
) )
export const CollabPanel = lazyWithRetry(() => import('../../components/Collab/CollabPanel')) export const CollabPanel = lazyWithRetry(() => import('../../components/Collab/CollabPanel'))
// The Journey addon's journal view, which only a trip with a linked journal opens.
export const TripJournalPanel = lazyWithRetry(() => import('./TripJournalPanel'))
export const RoadtripSidebar = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripSidebar')) export const RoadtripSidebar = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripSidebar'))
export const RoadtripCorridorPanel = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripCorridorPanel')) export const RoadtripCorridorPanel = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripCorridorPanel'))
export const RoadtripLimitsCard = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripLimitsCard')) export const RoadtripLimitsCard = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripLimitsCard'))
@@ -24,6 +24,8 @@ import { isServiceStopType } from '../../components/Roadtrip/roadtripModel'
import { inspectorStay } from '../../components/Roadtrip/stayReading' import { inspectorStay } from '../../components/Roadtrip/stayReading'
import { usePlaceSelection } from '../../hooks/usePlaceSelection' import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { useTourPlaceIds } from '../../hooks/useTourPlaceIds' import { useTourPlaceIds } from '../../hooks/useTourPlaceIds'
import { LinkedJournalIcon } from '../../components/Trips/LinkedJournalIcon'
import { useTripJournal } from '../../components/Trips/useTripJournalLink'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { usePlannerHistory } from '../../hooks/usePlannerHistory' import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useIsTouch } from '../../hooks/useIsTouch' import { useIsTouch } from '../../hooks/useIsTouch'
@@ -198,6 +200,7 @@ export function useTripPlanner() {
// A trip-page plugin may replace core tabs while it's active (its manifest names // A trip-page plugin may replace core tabs while it's active (its manifest names
// them; 'plan' is never replaceable) and may pick where its own tab sits. // them; 'plan' is never replaceable) and may pick where its own tab sits.
const journal = useTripJournal(Number.isFinite(tripId) ? tripId : null)
const replacedTabs = new Set(tripPagePlugins.flatMap(p => p.tripPage?.replaces ?? [])) const replacedTabs = new Set(tripPagePlugins.flatMap(p => p.tripPage?.replaces ?? []))
const TRIP_TABS = [ const TRIP_TABS = [
{ id: 'plan', label: t(TRIP_TAB_LABEL_KEYS.plan), icon: Map }, { id: 'plan', label: t(TRIP_TAB_LABEL_KEYS.plan), icon: Map },
@@ -211,6 +214,8 @@ export function useTripPlanner() {
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t(TRIP_TAB_LABEL_KEYS.finanzplan), icon: Wallet }] : []), ...(enabledAddons.budget ? [{ id: 'finanzplan', label: t(TRIP_TAB_LABEL_KEYS.finanzplan), icon: Wallet }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t(TRIP_TAB_LABEL_KEYS.dateien), icon: FolderOpen }] : []), ...(enabledAddons.documents ? [{ id: 'dateien', label: t(TRIP_TAB_LABEL_KEYS.dateien), icon: FolderOpen }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t(TRIP_TAB_LABEL_KEYS.collab), icon: Users }] : []), ...(enabledAddons.collab ? [{ id: 'collab', label: t(TRIP_TAB_LABEL_KEYS.collab), icon: Users }] : []),
// A journal from the Journey addon tied to this trip, for whoever the link lets in.
...(journal.openJournalId != null ? [{ id: 'journal', label: t('trip.tabs.journal'), icon: LinkedJournalIcon }] : []),
].filter(tab => tab.id === 'plan' || !replacedTabs.has(tab.id)) ].filter(tab => tab.id === 'plan' || !replacedTabs.has(tab.id))
// Positioned plugin tabs splice in ascending order so two positions stay stable; // Positioned plugin tabs splice in ascending order so two positions stay stable;
// the rest append, exactly as before this capability existed. // the rest append, exactly as before this capability existed.
@@ -237,12 +242,14 @@ export function useTripPlanner() {
// Same for the addon-owned tabs: until the feed answers, enabledAddons is a // Same for the addon-owned tabs: until the feed answers, enabledAddons is a
// guess, and evicting on a guess would drop a legitimately requested tab. // guess, and evicting on a guess would drop a legitimately requested tab.
if (!addonsLoaded) return if (!addonsLoaded) return
// And for the Journal tab, until the link has been asked for.
if (activeTab === 'journal' && !journal.settled) return
const validTabIds = TRIP_TABS.map(t => t.id) const validTabIds = TRIP_TABS.map(t => t.id)
if (!validTabIds.includes(activeTab)) { if (!validTabIds.includes(activeTab)) {
setActiveTab('plan') setActiveTab('plan')
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan') sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
} }
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded, isMobile]) }, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded, isMobile, journal.settled, journal.openJournalId])
const handleTabChange = (rawTabId: string): void => { const handleTabChange = (rawTabId: string): void => {
// A core tab a plugin replaced is gone from the bar, but a programmatic jump // A core tab a plugin replaced is gone from the bar, but a programmatic jump
@@ -653,7 +660,7 @@ export function useTripPlanner() {
trip, days, places, assignments, storedAssignments, packingItems, todoItems, categories, reservations, budgetItems, files, trip, days, places, assignments, storedAssignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles, selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo, pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations, enabledAddons, collabFeatures, journalId: journal.openJournalId, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor, roadtripMode, toggleRoadtripMode, roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewShown, toggleOverview, overviewActive, tripOverview, overviewShown, toggleOverview, overviewActive, tripOverview,
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates, dawarichEnabled: !!enabledAddons.dawarich, dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates, dawarichEnabled: !!enabledAddons.dawarich,
+106
View File
@@ -0,0 +1,106 @@
/**
* FE-TJL-REPO-001 to FE-TJL-REPO-006
*
* `tripJournalLinkRepo` is online-only on purpose (who a journal is open to is
* the server's answer, never a cached guess), so what is pinned is that it
* refuses before a request while offline, that every call reaches its route
* with the body the contract names, and that the picker only offers journals
* the person owns.
*/
import type { TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { afterEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { _resetNetworkMode, setForcedOffline } from '../sync/networkMode';
import { tripJournalLinkRepo, TripJournalOfflineError } from './tripJournalLinkRepo';
const NONE: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
afterEach(() => _resetNetworkMode());
describe('tripJournalLinkRepo', () => {
it('FE-TJL-REPO-001: refuses every call while offline, before a request leaves', async () => {
let calls = 0;
server.use(
http.get('/api/trips/:tripId/journal', () => {
calls += 1;
return HttpResponse.json(NONE);
})
);
setForcedOffline(true);
await expect(tripJournalLinkRepo.get(4)).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.candidates(4, 1)).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.link(4, 1, [])).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.setMembers(4, [])).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.unlink(4)).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.ownJournals(1)).rejects.toBeInstanceOf(TripJournalOfflineError);
expect(calls).toBe(0);
});
it('FE-TJL-REPO-002: reads the trip link', async () => {
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json(NONE)));
expect(await tripJournalLinkRepo.get(4)).toEqual(NONE);
});
it('FE-TJL-REPO-003: asks for the candidates of one journal and hands back the list', async () => {
let asked: string | null = null;
server.use(
http.get('/api/trips/4/journal/candidates', ({ request }) => {
asked = new URL(request.url).searchParams.get('journey_id');
return HttpResponse.json({
candidates: [{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: false, access: null }],
});
})
);
const list = await tripJournalLinkRepo.candidates(4, 9);
expect(asked).toBe('9');
expect(list.map((c) => c.username)).toEqual(['lena']);
});
it('FE-TJL-REPO-004: links, sets members and unlinks with the bodies the contract names', async () => {
const bodies: unknown[] = [];
server.use(
http.put('/api/trips/4/journal', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json(NONE);
}),
http.patch('/api/trips/4/journal/members', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json(NONE);
}),
http.delete('/api/trips/4/journal', () => HttpResponse.json(NONE))
);
await tripJournalLinkRepo.link(4, 9, [{ user_id: 2, role: 'viewer' }]);
await tripJournalLinkRepo.setMembers(4, [{ user_id: 2, role: 'editor' }]);
expect(await tripJournalLinkRepo.unlink(4)).toEqual(NONE);
expect(bodies).toEqual([
{ journey_id: 9, members: [{ user_id: 2, role: 'viewer' }] },
{ members: [{ user_id: 2, role: 'editor' }] },
]);
});
it('FE-TJL-REPO-005: offers only the journals the person owns', async () => {
server.use(
http.get('/api/journeys', () =>
HttpResponse.json({
journeys: [
{ id: 1, user_id: 7, title: 'Mine', subtitle: 'Japan' },
{ id: 2, user_id: 8, title: 'Theirs' },
{ id: 3, user_id: 7, title: 'Also mine' },
],
})
)
);
expect(await tripJournalLinkRepo.ownJournals(7)).toEqual([
{ id: 1, title: 'Mine', subtitle: 'Japan' },
{ id: 3, title: 'Also mine', subtitle: null },
]);
});
it('FE-TJL-REPO-006: the offline error says what it needs', () => {
const err = new TripJournalOfflineError();
expect(err.name).toBe('TripJournalOfflineError');
expect(err.message).toMatch(/connection/);
});
});
+70
View File
@@ -0,0 +1,70 @@
import type { TripJournalCandidate, TripJournalLinkMemberRequest, TripJournalLinkResponse } from '@trek/shared';
import { tripJournalApi } from '../api/tripJournal';
import { isEffectivelyOffline } from '../sync/networkMode';
/** A journal the person owns, as the link picker lists it. */
export interface OwnJournal {
id: number;
title: string;
subtitle: string | null;
}
/**
* The journal a trip links, and who it is open to.
*
* Online-only with no Dexie table, for the reason docSyncRepo gives: every
* answer is the server's (who may see the link, who would be let in), and the
* journal it opens is online-only too, as the Journey addon is. Writing it
* offline would mean granting access nobody could check. The store in front of
* this keeps the last answer for the session.
*/
export class TripJournalOfflineError extends Error {
constructor() {
super('Linking a journal needs a connection');
this.name = 'TripJournalOfflineError';
}
}
function requireOnline(): void {
if (isEffectivelyOffline()) throw new TripJournalOfflineError();
}
export const tripJournalLinkRepo = {
async get(tripId: number): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.get(tripId);
},
async candidates(tripId: number, journeyId: number): Promise<TripJournalCandidate[]> {
requireOnline();
return (await tripJournalApi.candidates(tripId, journeyId)).candidates;
},
async link(
tripId: number,
journeyId: number,
members: TripJournalLinkMemberRequest[]
): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.link(tripId, journeyId, members);
},
async setMembers(tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.setMembers(tripId, members);
},
async unlink(tripId: number): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.unlink(tripId);
},
/** The journals `userId` owns, newest first, for the picker. Those they only contribute to are not theirs to link. */
async ownJournals(userId: number): Promise<OwnJournal[]> {
requireOnline();
const { journeys } = await tripJournalApi.journals();
return journeys
.filter((j) => j.user_id === userId)
.map((j) => ({ id: j.id, title: j.title, subtitle: j.subtitle ?? null }));
},
};
+12
View File
@@ -141,11 +141,23 @@ export interface JourneyContributor {
avatar?: string | null avatar?: string | null
} }
/** Someone a trip's linked journal lets in, with the trip they come through (#2683). */
export interface JourneyTripAccess {
user_id: number
role: 'editor' | 'viewer'
username: string
avatar?: string | null
trip_id: number
trip_title: string
}
export interface JourneyDetail extends Journey { export interface JourneyDetail extends Journey {
entries: JourneyEntry[] entries: JourneyEntry[]
gallery: GalleryPhoto[] gallery: GalleryPhoto[]
trips: JourneyTrip[] trips: JourneyTrip[]
contributors: JourneyContributor[] contributors: JourneyContributor[]
/** Who the trips' journal links let in besides the contributors, managed in those trips. */
trip_access?: JourneyTripAccess[]
stats: { entries: number; photos: number; places: number } stats: { entries: number; photos: number; places: number }
hide_skeletons?: boolean hide_skeletons?: boolean
/** How many suggestions were waved away one at a time, so the settings sheet can offer them back. */ /** How many suggestions were waved away one at a time, so the settings sheet can offer them back. */
@@ -0,0 +1,81 @@
/**
* FE-TJL-STORE-001 to FE-TJL-STORE-005
*
* The store keeps the server's answer per trip so the Journal tab and the
* Settings dialog read the same one: a write replaces it with what the server
* answered, a 404/403 means there is no journal for this person, and any other
* failure leaves the last answer standing rather than flashing the tab away.
*/
import type { TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { useTripJournalLinkStore } from './tripJournalLinkStore';
const LINKED: TripJournalLinkResponse = {
link: {
journey_id: 9,
title: 'Japan',
subtitle: null,
cover_image: null,
owner_id: 1,
linked_by: 1,
created_at: 1,
my_role: 'owner',
members: [],
},
can_link: true,
can_unlink: true,
can_edit_members: true,
};
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
beforeEach(() => useTripJournalLinkStore.setState({ byTrip: {} }));
describe('tripJournalLinkStore', () => {
it('FE-TJL-STORE-001: refresh keeps the answer under the trip', async () => {
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json(LINKED)));
await useTripJournalLinkStore.getState().refresh(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
});
it('FE-TJL-STORE-002: a 404 (addon off, trip out of reach) is no journal at all', async () => {
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json({ error: 'gone' }, { status: 404 })));
await useTripJournalLinkStore.getState().refresh(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual({
link: null,
can_link: false,
can_unlink: false,
can_edit_members: false,
});
});
it('FE-TJL-STORE-003: any other failure leaves the last answer standing', async () => {
useTripJournalLinkStore.setState({ byTrip: { 4: LINKED } });
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
await useTripJournalLinkStore.getState().refresh(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
});
it('FE-TJL-STORE-004: link, setMembers and unlink each keep what the server answered', async () => {
server.use(
http.put('/api/trips/4/journal', () => HttpResponse.json(LINKED)),
http.patch('/api/trips/4/journal/members', () => HttpResponse.json({ ...LINKED, can_unlink: false })),
http.delete('/api/trips/4/journal', () => HttpResponse.json(EMPTY))
);
const s = useTripJournalLinkStore.getState();
await s.link(4, 9, []);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
await s.setMembers(4, []);
expect(useTripJournalLinkStore.getState().byTrip['4']?.can_unlink).toBe(false);
await s.unlink(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(EMPTY);
});
it('FE-TJL-STORE-005: a failed write rejects for its caller and keeps the last answer', async () => {
useTripJournalLinkStore.setState({ byTrip: { 4: EMPTY } });
server.use(http.put('/api/trips/4/journal', () => HttpResponse.json({ error: 'no' }, { status: 403 })));
await expect(useTripJournalLinkStore.getState().link(4, 9, [])).rejects.toBeTruthy();
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(EMPTY);
});
});
+49
View File
@@ -0,0 +1,49 @@
import type { TripJournalLinkMemberRequest, TripJournalLinkResponse } from '@trek/shared';
import { create } from 'zustand';
import { tripJournalLinkRepo } from '../repo/tripJournalLinkRepo';
/**
* The journal each trip links, as this person may see it, remembered for the
* session so the trip's Journal tab and its Settings dialog render the last
* answer at once. Both read it from here, so the tab appears and goes the moment
* the dialog links or unlinks, without a second request.
*/
interface TripJournalLinkState {
/** Per trip id, the server's answer. Missing until asked. */
byTrip: Record<string, TripJournalLinkResponse>;
refresh: (tripId: number) => Promise<void>;
link: (tripId: number, journeyId: number, members: TripJournalLinkMemberRequest[]) => Promise<void>;
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]) => Promise<void>;
unlink: (tripId: number) => Promise<void>;
}
/** The answer for a trip someone cannot reach, or with the Journey addon off: no journal. */
const NONE: TripJournalLinkResponse = { link: null, can_link: false, can_unlink: false, can_edit_members: false };
export const useTripJournalLinkStore = create<TripJournalLinkState>((set) => {
const keep = (tripId: number, answer: TripJournalLinkResponse) =>
set((s) => ({ byTrip: { ...s.byTrip, [String(tripId)]: answer } }));
return {
byTrip: {},
refresh: async (tripId) => {
try {
keep(tripId, await tripJournalLinkRepo.get(tripId));
} catch (err) {
// 404 is the Journey addon switched off or a trip this person no longer
// reaches: both a real "no journal". Anything else, offline included, is
// no answer at all, and the last one stands.
const status = (err as { response?: { status?: number } })?.response?.status;
if (status === 404 || status === 403) keep(tripId, NONE);
}
},
// The writes answer with the link as it now stands, so the tab and the
// dialog move together; a failure is the caller's to report.
link: async (tripId, journeyId, members) =>
keep(tripId, await tripJournalLinkRepo.link(tripId, journeyId, members)),
setMembers: async (tripId, members) => keep(tripId, await tripJournalLinkRepo.setMembers(tripId, members)),
unlink: async (tripId) => keep(tripId, await tripJournalLinkRepo.unlink(tripId)),
};
});
+1
View File
@@ -59,4 +59,5 @@ export const UNRESET_STORES: Record<string, string> = {
useSystemNoticeStore: NOT_YET, useSystemNoticeStore: NOT_YET,
useTourFerrataCrossingStore: 'set by the tour map stage; the tests that read it reset it in their own beforeEach', useTourFerrataCrossingStore: 'set by the tour map stage; the tests that read it reset it in their own beforeEach',
useTourPlaceToolStore: 'reset in the tour tests themselves to initialTourPlaceToolState()', useTourPlaceToolStore: 'reset in the tour tests themselves to initialTourPlaceToolState()',
useTripJournalLinkStore: 'the journal link per trip; the tests that read it reset it in their own beforeEach',
}; };
@@ -89,6 +89,17 @@ describe('MJourneySettingsSheet', () => {
expect(screen.getByText('8 places')).toBeInTheDocument(); expect(screen.getByText('8 places')).toBeInTheDocument();
}); });
it('FE-MOB-JSET-2683: lists the people a linked trip lets in, and nothing without', async () => {
const { unmount } = setup();
expect(screen.queryByText('Through trips')).not.toBeInTheDocument();
unmount();
setup({ trip_access: [{ user_id: 7, role: 'editor', username: 'lena', avatar: null, trip_id: 3, trip_title: 'Tokyo trip' }] });
expect(await screen.findByText('Through trips')).toBeInTheDocument();
expect(screen.getByText('lena')).toBeInTheDocument();
expect(screen.getByText('via Tokyo trip')).toBeInTheDocument();
});
it('FE-MOB-JSET-002: shows the empty hint when no trip is linked', async () => { it('FE-MOB-JSET-002: shows the empty hint when no trip is linked', async () => {
setup({ trips: [] }); setup({ trips: [] });
expect(await screen.findByText('No trips linked')).toBeInTheDocument(); expect(await screen.findByText('No trips linked')).toBeInTheDocument();
@@ -104,7 +104,7 @@ describe('MMehrSheet', () => {
it('FE-MOB-MEHR-009: the action rows open the share, export and edit sheets', () => { it('FE-MOB-MEHR-009: the action rows open the share, export and edit sheets', () => {
const { shell } = renderSheet() const { shell } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Share Trip' })) fireEvent.click(screen.getByRole('button', { name: 'Trip settings' }))
fireEvent.click(screen.getByRole('button', { name: 'Export' })) fireEvent.click(screen.getByRole('button', { name: 'Export' }))
fireEvent.click(screen.getByRole('button', { name: 'Edit Trip' })) fireEvent.click(screen.getByRole('button', { name: 'Edit Trip' }))
expect(shell.openSheet).toHaveBeenNthCalledWith(1, 'members') expect(shell.openSheet).toHaveBeenNthCalledWith(1, 'members')
@@ -116,7 +116,7 @@ describe('MMehrSheet', () => {
const { planner } = renderSheet({ can: vi.fn(() => false) }) const { planner } = renderSheet({ can: vi.fn(() => false) })
expect(planner.can).toHaveBeenCalledWith('trip_edit', planner.trip) expect(planner.can).toHaveBeenCalledWith('trip_edit', planner.trip)
expect(screen.queryByRole('button', { name: 'Edit Trip' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Edit Trip' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Share Trip' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Trip settings' })).toBeInTheDocument()
}) })
it('FE-MOB-MEHR-011: renders only the action rows when every section sits in the dock', () => { it('FE-MOB-MEHR-011: renders only the action rows when every section sits in the dock', () => {
@@ -124,13 +124,13 @@ describe('MMehrSheet', () => {
TRIP_TABS: TABS.filter(tab => tab.id !== 'dateien' && tab.id !== 'collab') as TripPlanner['TRIP_TABS'], TRIP_TABS: TABS.filter(tab => tab.id !== 'dateien' && tab.id !== 'collab') as TripPlanner['TRIP_TABS'],
}) })
expect(screen.queryByText(/files$/)).not.toBeInTheDocument() expect(screen.queryByText(/files$/)).not.toBeInTheDocument()
const rows = screen.getByRole('button', { name: 'Share Trip' }).parentElement const rows = screen.getByRole('button', { name: 'Trip settings' }).parentElement
expect(rows).not.toHaveClass('mt-2') expect(rows).not.toHaveClass('mt-2')
}) })
it('FE-MOB-MEHR-012: separates the action rows from the tile grid when both are present', () => { it('FE-MOB-MEHR-012: separates the action rows from the tile grid when both are present', () => {
renderSheet() renderSheet()
const rows = screen.getByRole('button', { name: 'Share Trip' }).parentElement const rows = screen.getByRole('button', { name: 'Trip settings' }).parentElement
expect(rows).toHaveClass('mt-2') expect(rows).toHaveClass('mt-2')
}) })
@@ -172,7 +172,7 @@ describe('MMehrSheet', () => {
// Five sections still fit beside the More button, so nothing overflows and the // Five sections still fit beside the More button, so nothing overflows and the
// sheet is the action rows alone. // sheet is the action rows alone.
expect(screen.queryByRole('button', { name: 'Lists' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Lists' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Share Trip' }).parentElement).not.toHaveClass('mt-2') expect(screen.getByRole('button', { name: 'Trip settings' }).parentElement).not.toHaveClass('mt-2')
}) })
}) })
}) })
+1
View File
@@ -52,6 +52,7 @@ services:
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20) # - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
# - KITINERARY_EXTRACTOR_PATH= # Optional. Full path to kitinerary-extractor binary. Auto-detected from PATH and /usr/lib/*/libexec/kf6/ when unset. # - KITINERARY_EXTRACTOR_PATH= # Optional. Full path to kitinerary-extractor binary. Auto-detected from PATH and /usr/lib/*/libexec/kf6/ when unset.
# - LLM_TIMEOUT_MS=900000 # Optional. Ceiling (ms) for one AI-parsing call before it is abandoned, applied to every provider and to the HTTP client underneath. Generous by default so heavier documents fit; lower it on a cloud provider to fail fast. Must be a whole number of milliseconds between 1 and 2147483647 — anything else stops the server at startup with the reason. (default: 900000) # - LLM_TIMEOUT_MS=900000 # Optional. Ceiling (ms) for one AI-parsing call before it is abandoned, applied to every provider and to the HTTP client underneath. Generous by default so heavier documents fit; lower it on a cloud provider to fail fast. Must be a whole number of milliseconds between 1 and 2147483647 — anything else stops the server at startup with the reason. (default: 900000)
# - LLM_MAX_OUTPUT_TOKENS=8192 # Optional. Longest answer (tokens) one AI-parsing call may ask for. Raise it when a document with many bookings comes back cut off; keep prompt plus cap inside your model's context. Whole number from 256 to 1000000. (default: 4096 for OpenAI-compatible, 8192 for Anthropic)
# - TREK_PLUGINS_IGNORE_TREK_RANGE=true # Optional. Lets plugins whose declared TREK version range predates this build, and plugins that declare none, be installed and activated anyway. Every gate warns instead of refusing; the plugin-API version gate and the Discover compatibility verdict stay hard. Off unless set. (default: off) # - TREK_PLUGINS_IGNORE_TREK_RANGE=true # Optional. Lets plugins whose declared TREK version range predates this build, and plugins that declare none, be installed and activated anyway. Every gate warns instead of refusing; the plugin-API version gate and the Discover compatibility verdict stay hard. Off unless set. (default: off)
# - UNSPLASH_ACCESS_KEY= # Optional. Unsplash Access Key for trip-cover / place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from (#1449). Free key at unsplash.com/developers. Can also be set per-admin in Admin > Settings; the env var overrides that. # - UNSPLASH_ACCESS_KEY= # Optional. Unsplash Access Key for trip-cover / place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from (#1449). Free key at unsplash.com/developers. Can also be set per-admin in Admin > Settings; the env var overrides that.
# - TREK_PLACES_ENABLED=false # Optional. Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads) asks the TREK Places API at https://places.liketrek.com by default, so the server makes outbound requests to that host. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing there. No switch in the admin panel on purpose: whether searches leave the instance is decided here. (default: true) # - TREK_PLACES_ENABLED=false # Optional. Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads) asks the TREK Places API at https://places.liketrek.com by default, so the server makes outbound requests to that host. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing there. No switch in the admin panel on purpose: whether searches leave the instance is decided here. (default: true)
+8 -8
View File
@@ -13,7 +13,7 @@
"shared" "shared"
], ],
"devDependencies": { "devDependencies": {
"concurrently": "^10.0.4", "concurrently": "^10.0.6",
"knip": "^6.38.0", "knip": "^6.38.0",
"unrun": "^0.3.1" "unrun": "^0.3.1"
}, },
@@ -11082,15 +11082,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/concurrently": { "node_modules/concurrently": {
"version": "10.0.4", "version": "10.0.6",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.6.tgz",
"integrity": "sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==", "integrity": "sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chalk": "5.6.2", "chalk": "5.6.2",
"rxjs": "7.8.2", "rxjs": "7.8.2",
"shell-quote": "1.9.0", "shell-quote": "1.12.0",
"supports-color": "10.2.2", "supports-color": "10.2.2",
"tree-kill": "1.2.2", "tree-kill": "1.2.2",
"yargs": "18.0.0" "yargs": "18.0.0"
@@ -19748,9 +19748,9 @@
} }
}, },
"node_modules/shell-quote": { "node_modules/shell-quote": {
"version": "1.9.0", "version": "1.12.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
+1 -1
View File
@@ -32,7 +32,7 @@
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client" "format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^10.0.4", "concurrently": "^10.0.6",
"knip": "^6.38.0", "knip": "^6.38.0",
"unrun": "^0.3.1" "unrun": "^0.3.1"
}, },
+3 -3
View File
@@ -1906,9 +1906,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
+1
View File
@@ -84,6 +84,7 @@ DEMO_MODE=false # Demo mode: resets data hourly
# KITINERARY_EXTRACTOR_PATH= # Full path to the kitinerary-extractor binary. Unset, TREK searches /usr/lib/*/libexec/kf6/ and then PATH; the official image pins it. Set it to run a newer extractor than your distribution packages — the version in use is in the startup log and in GET /api/admin/system-info. (default: auto-detected) # KITINERARY_EXTRACTOR_PATH= # Full path to the kitinerary-extractor binary. Unset, TREK searches /usr/lib/*/libexec/kf6/ and then PATH; the official image pins it. Set it to run a newer extractor than your distribution packages — the version in use is in the startup log and in GET /api/admin/system-info. (default: auto-detected)
# LLM_TIMEOUT_MS=900000 # Ceiling (ms) for one AI-parsing call before it is abandoned. One value for every provider, and the underlying HTTP client is held to it too. Generous by default so heavier parsing work fits without a code change; lower it on a cloud provider to fail fast. (default: 900000) # LLM_TIMEOUT_MS=900000 # Ceiling (ms) for one AI-parsing call before it is abandoned. One value for every provider, and the underlying HTTP client is held to it too. Generous by default so heavier parsing work fits without a code change; lower it on a cloud provider to fail fast. (default: 900000)
# LLM_MAX_OUTPUT_TOKENS=8192 # Longest answer (tokens) one AI-parsing call may ask for. Raise it when a document with many bookings comes back cut off; keep prompt plus cap inside your model's context. (default: 4096 for OpenAI-compatible, 8192 for Anthropic)
# VAPID_PUBLIC_KEY= # Web Push: the public half of the key pair browsers subscribe with (base64url, 65 bytes). Leave both halves unset and TREK generates a pair on first start and keeps it in the database, so it travels with every backup. Set both to use a pair of your own, e.g. printed by `npx web-push generate-vapid-keys`. Changing the pair later means every device has to turn push on again. # VAPID_PUBLIC_KEY= # Web Push: the public half of the key pair browsers subscribe with (base64url, 65 bytes). Leave both halves unset and TREK generates a pair on first start and keeps it in the database, so it travels with every backup. Set both to use a pair of your own, e.g. printed by `npx web-push generate-vapid-keys`. Changing the pair later means every device has to turn push on again.
# VAPID_PRIVATE_KEY= # Web Push: the private half (base64url, 32 bytes). Only used together with VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns push off with an error in the log until the pair is fixed. A pair stored in the database is not used in its place, and every device stays registered. # VAPID_PRIVATE_KEY= # Web Push: the private half (base64url, 32 bytes). Only used together with VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns push off with an error in the log until the pair is fixed. A pair stored in the database is not used in its place, and every device stays registered.
-1
View File
@@ -15,7 +15,6 @@
"src/db/repositories/Days.repository.ts": 7, "src/db/repositories/Days.repository.ts": 7,
"src/db/repositories/InviteTokens.repository.ts": 3, "src/db/repositories/InviteTokens.repository.ts": 3,
"src/db/repositories/JourneyBooks.repository.ts": 3, "src/db/repositories/JourneyBooks.repository.ts": 3,
"src/db/repositories/JourneyContributors.repository.ts": 3,
"src/db/repositories/JourneyEntries.repository.ts": 9, "src/db/repositories/JourneyEntries.repository.ts": 9,
"src/db/repositories/JourneyPhotos.repository.ts": 6, "src/db/repositories/JourneyPhotos.repository.ts": 6,
"src/db/repositories/Journeys.repository.ts": 4, "src/db/repositories/Journeys.repository.ts": 4,
+2
View File
@@ -117,6 +117,8 @@
"TripDocumentLinks": "doc-sync", "TripDocumentLinks": "doc-sync",
"TripFiles": "files", "TripFiles": "files",
"TripInviteTokens": "trip-invite", "TripInviteTokens": "trip-invite",
"TripJournalLinkMembers": "journey",
"TripJournalLinks": "journey",
"TripMembers": "trip-membership", "TripMembers": "trip-membership",
"TripPhotos": "photos", "TripPhotos": "photos",
"Trips": "trips", "Trips": "trips",
+1 -1
View File
@@ -165,7 +165,7 @@ ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL,
TREK_DB_PRE_MIGRATE_SNAPSHOT(_KEEP) (read when a migration run starts; the TREK_DB_PRE_MIGRATE_SNAPSHOT(_KEEP) (read when a migration run starts; the
legacy-upgrade suites set it per file), legacy-upgrade suites set it per file),
ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check), ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check),
LLM_TIMEOUT_MS, NOMINATIM_URL, VAPID_* (resolved per use by the Web Push key LLM_TIMEOUT_MS, LLM_MAX_OUTPUT_TOKENS, NOMINATIM_URL, VAPID_* (resolved per use by the Web Push key
service, which the notification suites build without the container). service, which the notification suites build without the container).
## Exemptions — raw `process.env` stays ## Exemptions — raw `process.env` stays
+7
View File
@@ -295,6 +295,13 @@ export function deriveIntegrations(raw: RawEnv) {
* rejects a fractional value. * rejects a fractional value.
*/ */
llmTimeoutMs: Math.floor(positiveNumberOr(raw.LLM_TIMEOUT_MS, 900_000)), llmTimeoutMs: Math.floor(positiveNumberOr(raw.LLM_TIMEOUT_MS, 900_000)),
/**
* How long one AI-parsing answer may get. Unset leaves each client the cap it
* has always sent (4096 to OpenAI-compatible servers, 8192 to Anthropic): a
* bigger default would make the server of a small local model refuse every
* request whose prompt plus cap overruns its context (#2677).
*/
llmMaxOutputTokens: positiveIntOr(raw.LLM_MAX_OUTPUT_TOKENS, 0) || undefined,
// TRANSIT_API_URL, KITINERARY_EXTRACTOR_PATH and PATH are boot-stable and // TRANSIT_API_URL, KITINERARY_EXTRACTOR_PATH and PATH are boot-stable and
// read only by Nest providers, so the transitConfig and kitineraryConfig // read only by Nest providers, so the transitConfig and kitineraryConfig
// tokens own them (boot-derive.ts). // tokens own them (boot-derive.ts).
+2
View File
@@ -144,6 +144,8 @@ export const envSchema = z.object({
// 2^31-1 makes Node clamp the delay to 1 ms and do the same. Both refuse at // 2^31-1 makes Node clamp the delay to 1 ms and do the same. Both refuse at
// boot rather than degrading into a timeout of zero. // boot rather than degrading into a timeout of zero.
LLM_TIMEOUT_MS: integer(1, 2_147_483_647, 'must be a whole number of milliseconds between 1 and 2147483647'), LLM_TIMEOUT_MS: integer(1, 2_147_483_647, 'must be a whole number of milliseconds between 1 and 2147483647'),
// Below 256 not even one reservation fits, so a value that small is a typo, not a choice.
LLM_MAX_OUTPUT_TOKENS: integer(256, 1_000_000, 'must be a whole number of tokens between 256 and 1000000'),
KITINERARY_EXTRACTOR_PATH: anyString, KITINERARY_EXTRACTOR_PATH: anyString,
// The OS search path. Not configuration anybody sets for TREK — it is here so // The OS search path. Not configuration anybody sets for TREK — it is here so
// the kitinerary probe can resolve its binary to an absolute path itself // the kitinerary probe can resolve its binary to an absolute path itself
@@ -6,6 +6,7 @@ import { JourneyEntries } from './JourneyEntries.entity';
import { JourneyPhotos } from './JourneyPhotos.entity'; import { JourneyPhotos } from './JourneyPhotos.entity';
import { JourneyShareTokens } from './JourneyShareTokens.entity'; import { JourneyShareTokens } from './JourneyShareTokens.entity';
import { JourneyTrips } from './JourneyTrips.entity'; import { JourneyTrips } from './JourneyTrips.entity';
import { TripJournalLinks } from './TripJournalLinks.entity';
import { Users } from './Users.entity'; import { Users } from './Users.entity';
export class Journeys { export class Journeys {
@@ -32,6 +33,7 @@ export class Journeys {
journey_photos_collection = new Collection<JourneyPhotos>(this); journey_photos_collection = new Collection<JourneyPhotos>(this);
journey_share_tokens: Ref<JourneyShareTokens> | null = null; journey_share_tokens: Ref<JourneyShareTokens> | null = null;
journey_trips_collection = new Collection<JourneyTrips>(this); journey_trips_collection = new Collection<JourneyTrips>(this);
trip_journal_links_collection = new Collection<TripJournalLinks>(this);
} }
export const JourneysSchema = defineEntity({ export const JourneysSchema = defineEntity({
@@ -60,5 +62,6 @@ export const JourneysSchema = defineEntity({
journey_photos_collection: () => p.oneToMany(JourneyPhotos).mappedBy('journey').hidden(), journey_photos_collection: () => p.oneToMany(JourneyPhotos).mappedBy('journey').hidden(),
journey_share_tokens: () => p.oneToOne(JourneyShareTokens).ref().mappedBy('journey').hidden(), journey_share_tokens: () => p.oneToOne(JourneyShareTokens).ref().mappedBy('journey').hidden(),
journey_trips_collection: () => p.oneToMany(JourneyTrips).mappedBy('journey').hidden(), journey_trips_collection: () => p.oneToMany(JourneyTrips).mappedBy('journey').hidden(),
trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('journey').hidden(),
}, },
}); });
@@ -0,0 +1,38 @@
import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { TripJournalLinkMembersRepository } from '../repositories/TripJournalLinkMembers.repository';
import { TripJournalLinks } from './TripJournalLinks.entity';
import { Users } from './Users.entity';
export class TripJournalLinkMembers {
[EntityRepositoryType]?: TripJournalLinkMembersRepository;
[PrimaryKeyProp]?: ['link', 'user'];
link!: Ref<TripJournalLinks>;
link_id!: number;
user!: Ref<Users>;
user_id!: number;
role: TTripJournalLinkMembersRole & Opt = TripJournalLinkMembersRole.VIEWER;
hide_skeletons: number & Opt = 0;
added_at!: number;
}
export const TripJournalLinkMembersRole = {
VIEWER: 'viewer',
EDITOR: 'editor',
} as const;
export type TTripJournalLinkMembersRole = (typeof TripJournalLinkMembersRole)[keyof typeof TripJournalLinkMembersRole];
export const TripJournalLinkMembersSchema = defineEntity({
class: TripJournalLinkMembers,
repository: () => TripJournalLinkMembersRepository,
uniques: [{ properties: ['link', 'user'] }],
properties: {
link: () => p.manyToOne(TripJournalLinks).primary().ref().hidden(),
link_id: p.integer().persist(false),
user: () => p.manyToOne(Users).primary().ref().hidden().index('idx_trip_journal_link_members_user'),
user_id: p.integer().persist(false).index('idx_trip_journal_link_members_user'),
role: p.enum(() => TripJournalLinkMembersRole).default('viewer'),
hide_skeletons: p.integer().default(0),
added_at: p.integer(),
},
});
@@ -0,0 +1,36 @@
import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
import { TripJournalLinksRepository } from '../repositories/TripJournalLinks.repository';
import { Journeys } from './Journeys.entity';
import { TripJournalLinkMembers } from './TripJournalLinkMembers.entity';
import { Trips } from './Trips.entity';
import { Users } from './Users.entity';
export class TripJournalLinks {
[EntityRepositoryType]?: TripJournalLinksRepository;
id!: number & Opt;
trip!: Ref<Trips>;
trip_id!: number;
journey!: Ref<Journeys>;
journey_id!: number;
linked_by?: number | null;
created_at!: number;
linkedByRef?: Ref<Users> | null;
trip_journal_link_members_collection = new Collection<TripJournalLinkMembers>(this);
}
export const TripJournalLinksSchema = defineEntity({
class: TripJournalLinks,
repository: () => TripJournalLinksRepository,
uniques: [{ properties: ['trip'] }],
properties: {
id: p.integer().primary(),
trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(),
trip_id: p.integer().persist(false),
journey: () => p.manyToOne(Journeys).ref().deleteRule('cascade').hidden().index('idx_trip_journal_links_journey'),
journey_id: p.integer().persist(false).index('idx_trip_journal_links_journey'),
linked_by: p.integer().nullable().persist(false).index('idx_trip_journal_links_linked_by'),
created_at: p.integer(),
linkedByRef: () => p.manyToOne(Users).ref().joinColumn('linked_by').nullable().hidden().index('idx_trip_journal_links_linked_by'),
trip_journal_link_members_collection: () => p.oneToMany(TripJournalLinkMembers).mappedBy('link').hidden(),
},
});
+3
View File
@@ -32,6 +32,7 @@ import { TripAlbumLinks } from './TripAlbumLinks.entity';
import { TripDocumentLinks } from './TripDocumentLinks.entity'; import { TripDocumentLinks } from './TripDocumentLinks.entity';
import { TripFiles } from './TripFiles.entity'; import { TripFiles } from './TripFiles.entity';
import { TripInviteTokens } from './TripInviteTokens.entity'; import { TripInviteTokens } from './TripInviteTokens.entity';
import { TripJournalLinks } from './TripJournalLinks.entity';
import { TripMembers } from './TripMembers.entity'; import { TripMembers } from './TripMembers.entity';
import { TripPhotos } from './TripPhotos.entity'; import { TripPhotos } from './TripPhotos.entity';
import { Users } from './Users.entity'; import { Users } from './Users.entity';
@@ -84,6 +85,7 @@ export class Trips {
trip_document_links_collection = new Collection<TripDocumentLinks>(this); trip_document_links_collection = new Collection<TripDocumentLinks>(this);
trip_files_collection = new Collection<TripFiles>(this); trip_files_collection = new Collection<TripFiles>(this);
trip_invite_tokens_collection = new Collection<TripInviteTokens>(this); trip_invite_tokens_collection = new Collection<TripInviteTokens>(this);
trip_journal_links_collection = new Collection<TripJournalLinks>(this);
trip_members_collection = new Collection<TripMembers>(this); trip_members_collection = new Collection<TripMembers>(this);
trip_photos_collection = new Collection<TripPhotos>(this); trip_photos_collection = new Collection<TripPhotos>(this);
} }
@@ -145,6 +147,7 @@ export const TripsSchema = defineEntity({
trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('trip').hidden(), trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('trip').hidden(),
trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('trip').hidden(), trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('trip').hidden(),
trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('trip').hidden(), trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('trip').hidden(),
trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('trip').hidden(),
trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('trip').hidden(), trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('trip').hidden(),
trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('trip').hidden(), trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('trip').hidden(),
}, },
+6
View File
@@ -57,6 +57,8 @@ import { TripAlbumLinks } from './TripAlbumLinks.entity';
import { TripDocumentLinks } from './TripDocumentLinks.entity'; import { TripDocumentLinks } from './TripDocumentLinks.entity';
import { TripFiles } from './TripFiles.entity'; import { TripFiles } from './TripFiles.entity';
import { TripInviteTokens } from './TripInviteTokens.entity'; import { TripInviteTokens } from './TripInviteTokens.entity';
import { TripJournalLinkMembers } from './TripJournalLinkMembers.entity';
import { TripJournalLinks } from './TripJournalLinks.entity';
import { TripMembers } from './TripMembers.entity'; import { TripMembers } from './TripMembers.entity';
import { TripPhotos } from './TripPhotos.entity'; import { TripPhotos } from './TripPhotos.entity';
import { Trips } from './Trips.entity'; import { Trips } from './Trips.entity';
@@ -182,6 +184,8 @@ export class Users {
trip_document_links_collection = new Collection<TripDocumentLinks>(this); trip_document_links_collection = new Collection<TripDocumentLinks>(this);
trip_files_collection = new Collection<TripFiles>(this); trip_files_collection = new Collection<TripFiles>(this);
trip_invite_tokens_collection = new Collection<TripInviteTokens>(this); trip_invite_tokens_collection = new Collection<TripInviteTokens>(this);
trip_journal_link_members_collection = new Collection<TripJournalLinkMembers>(this);
trip_journal_links_collection = new Collection<TripJournalLinks>(this);
trip_members_collection = new Collection<TripMembers>(this); trip_members_collection = new Collection<TripMembers>(this);
trip_members_collection1 = new Collection<TripMembers>(this); trip_members_collection1 = new Collection<TripMembers>(this);
trip_photos_collection = new Collection<TripPhotos>(this); trip_photos_collection = new Collection<TripPhotos>(this);
@@ -325,6 +329,8 @@ export const UsersSchema = defineEntity({
trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('createdByRef').hidden(), trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('createdByRef').hidden(),
trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('uploadedByRef').hidden(), trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('uploadedByRef').hidden(),
trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('createdByRef').hidden(), trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('createdByRef').hidden(),
trip_journal_link_members_collection: () => p.oneToMany(TripJournalLinkMembers).mappedBy('user').hidden(),
trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('linkedByRef').hidden(),
trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('user').hidden(), trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('user').hidden(),
trip_members_collection1: () => p.oneToMany(TripMembers).mappedBy('invitedByRef').hidden(), trip_members_collection1: () => p.oneToMany(TripMembers).mappedBy('invitedByRef').hidden(),
trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('user').hidden(), trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('user').hidden(),
+4
View File
@@ -116,6 +116,8 @@ import { TripAlbumLinksSchema } from './TripAlbumLinks.entity';
import { TripDocumentLinksSchema } from './TripDocumentLinks.entity'; import { TripDocumentLinksSchema } from './TripDocumentLinks.entity';
import { TripFilesSchema } from './TripFiles.entity'; import { TripFilesSchema } from './TripFiles.entity';
import { TripInviteTokensSchema } from './TripInviteTokens.entity'; import { TripInviteTokensSchema } from './TripInviteTokens.entity';
import { TripJournalLinkMembersSchema } from './TripJournalLinkMembers.entity';
import { TripJournalLinksSchema } from './TripJournalLinks.entity';
import { TripMembersSchema } from './TripMembers.entity'; import { TripMembersSchema } from './TripMembers.entity';
import { TripPhotosSchema } from './TripPhotos.entity'; import { TripPhotosSchema } from './TripPhotos.entity';
import { TripsSchema } from './Trips.entity'; import { TripsSchema } from './Trips.entity';
@@ -256,6 +258,8 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
TripDocumentLinksSchema, TripDocumentLinksSchema,
TripFilesSchema, TripFilesSchema,
TripInviteTokensSchema, TripInviteTokensSchema,
TripJournalLinkMembersSchema,
TripJournalLinksSchema,
TripMembersSchema, TripMembersSchema,
TripPhotosSchema, TripPhotosSchema,
TripsSchema, TripsSchema,
@@ -0,0 +1,11 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
import type { InsertOptional } from './columns';
export interface TripJournalLinkMembersTable {
link_id: number;
user_id: number;
role: InsertOptional<string>;
hide_skeletons: InsertOptional<number>;
added_at: number;
}
@@ -0,0 +1,11 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
import type { InsertOptional } from './columns';
export interface TripJournalLinksTable {
id: InsertOptional<number>;
trip_id: number;
journey_id: number;
linked_by: InsertOptional<number | null>;
created_at: number;
}
+4
View File
@@ -122,6 +122,8 @@ import type { TripAlbumLinksTable } from './TripAlbumLinks.table';
import type { TripDocumentLinksTable } from './TripDocumentLinks.table'; import type { TripDocumentLinksTable } from './TripDocumentLinks.table';
import type { TripFilesTable } from './TripFiles.table'; import type { TripFilesTable } from './TripFiles.table';
import type { TripInviteTokensTable } from './TripInviteTokens.table'; import type { TripInviteTokensTable } from './TripInviteTokens.table';
import type { TripJournalLinkMembersTable } from './TripJournalLinkMembers.table';
import type { TripJournalLinksTable } from './TripJournalLinks.table';
import type { TripMembersTable } from './TripMembers.table'; import type { TripMembersTable } from './TripMembers.table';
import type { TripPhotosTable } from './TripPhotos.table'; import type { TripPhotosTable } from './TripPhotos.table';
import type { TripsTable } from './Trips.table'; import type { TripsTable } from './Trips.table';
@@ -268,6 +270,8 @@ export interface DB {
trip_document_links: TripDocumentLinksTable; trip_document_links: TripDocumentLinksTable;
trip_files: TripFilesTable; trip_files: TripFilesTable;
trip_invite_tokens: TripInviteTokensTable; trip_invite_tokens: TripInviteTokensTable;
trip_journal_link_members: TripJournalLinkMembersTable;
trip_journal_links: TripJournalLinksTable;
trip_members: TripMembersTable; trip_members: TripMembersTable;
trip_photos: TripPhotosTable; trip_photos: TripPhotosTable;
trips: TripsTable; trips: TripsTable;
@@ -0,0 +1,49 @@
import { Migration } from '@mikro-orm/migrations';
/**
* A trip can show one journal from the Journey addon as its own. The link
* belongs to the trip, not to the journal's own trips (`journey_trips`): any
* journal its owner chooses, and one journal may hang off several trips.
* `linked_by` names who linked it, kept when that account goes as unknown.
*
* `trip_journal_link_members` lists the trip members the link lets into the
* journal, as viewer or editor, apart from `journey_contributors` so that
* unlinking, deselecting someone or their leaving the trip takes away exactly
* the access the link gave and nothing the journal's owner granted directly.
* `hide_skeletons` is that member's own journal preference, which for a direct
* contributor sits on the contributor row. A member's rows go with the link,
* with the trip and with the account.
*
* One link per trip is `trip_id UNIQUE` beside its own id rather than the trip
* id as key, as `trip_invite_tokens` holds one per trip: a key that is also a
* foreign key maps to a one-to-one on both entities, and a second one of those
* next to `tours` made the ORM's entity types recurse past what tsc expands.
*/
export class Migration20200101044300_a_trip_links_a_journal extends Migration {
override name = 'Migration20200101044300_a_trip_links_a_journal';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS trip_journal_links (
id INTEGER PRIMARY KEY,
trip_id INTEGER NOT NULL UNIQUE REFERENCES trips(id) ON DELETE CASCADE,
journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
linked_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at INTEGER NOT NULL
)
`);
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_journal_links_journey ON trip_journal_links(journey_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_journal_links_linked_by ON trip_journal_links(linked_by)');
this.addSql(`
CREATE TABLE IF NOT EXISTS trip_journal_link_members (
link_id INTEGER NOT NULL REFERENCES trip_journal_links(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('viewer', 'editor')),
hide_skeletons INTEGER NOT NULL DEFAULT 0,
added_at INTEGER NOT NULL,
PRIMARY KEY (link_id, user_id)
)
`);
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_journal_link_members_user ON trip_journal_link_members(user_id)');
}
}
@@ -1,5 +1,12 @@
import type { JourneyContributors } from '../entities/JourneyContributors.entity'; import type { JourneyContributors } from '../entities/JourneyContributors.entity';
import type { DB } from '../kysely/db'; import type { DB } from '../kysely/db';
import {
linkReach,
readLinkHideSkeletons,
readLinkRole,
type TripJournalLinkKyselyDB,
type TripJournalLinkRole,
} from './TripJournalLinks.repository';
import { TrekRepository } from './_shared/trek-repository'; import { TrekRepository } from './_shared/trek-repository';
/** JG18's contributor-list row (`getJourneyFull`) — the junction plus the joined username/avatar. */ /** JG18's contributor-list row (`getJourneyFull`) — the junction plus the joined username/avatar. */
@@ -15,6 +22,19 @@ export interface JourneyContributorListRow {
/** The `journey_contributors`/`users` tables {@link JourneyContributorsRepository.listForJourney} reads. */ /** The `journey_contributors`/`users` tables {@link JourneyContributorsRepository.listForJourney} reads. */
type JourneyContributorListKyselyDB = Pick<DB, 'journey_contributors' | 'users'>; type JourneyContributorListKyselyDB = Pick<DB, 'journey_contributors' | 'users'>;
/** Someone a trip's journal link lets in, with the trip they come through (#2683). */
export interface JourneyTripAccessRow {
user_id: number;
role: TripJournalLinkRole;
username: string;
avatar: string | null;
trip_id: number;
trip_title: string;
}
/** The table the access reads below take a person's own contributor row from. */
type JourneyContributorRowKyselyDB = Pick<DB, 'journey_contributors'>;
/** /**
* `journey_contributors` — a genuine TWO-column composite primary key * `journey_contributors` — a genuine TWO-column composite primary key
* (`journey` + `user`, both `.primary()`, R3 — pinned against * (`journey` + `user`, both `.primary()`, R3 — pinned against
@@ -26,36 +46,71 @@ type JourneyContributorListKyselyDB = Pick<DB, 'journey_contributors' | 'users'>
* pinned in `task-1-report.md`. * pinned in `task-1-report.md`.
*/ */
export class JourneyContributorsRepository extends TrekRepository<JourneyContributors> { export class JourneyContributorsRepository extends TrekRepository<JourneyContributors> {
/** JG4 — `canAccessJourney`'s contributor-match primitive: `SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?`. */ /** The person's own contributor row on the journal, if they have one. */
async existsForUser(journeyId: number, userId: number): Promise<boolean> { private async directRow(journeyId: number, userId: number) {
const row = await this.qb('jc') return await this.kysely<JourneyContributorRowKyselyDB>()
.select(['jc.journey']) .selectFrom('journey_contributors')
.where({ journey: journeyId, user: userId }) .select(['role', 'hide_skeletons'])
.execute<unknown>('get', false); .where('journey_id', '=', journeyId)
return !!row; .where('user_id', '=', userId)
.executeTakeFirst();
} }
/** JG7/JG21 — `canEdit`'s edit-access primitive and `getJourneyFull`'s `myRole` resolution: `SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?`. */ /** JG4 — whether a person may open the journal: as a contributor, or through a trip's link. */
async findRole(journeyId: number, userId: number): Promise<string | undefined> { async hasAccess(journeyId: number, userId: number): Promise<boolean> {
const row = await this.qb('jc') return (await this.findAccessRole(journeyId, userId)) !== undefined;
.select(['jc.role'])
.where({ journey: journeyId, user: userId })
.execute<{ role: string } | undefined>('get', false);
return row?.role;
} }
/** JG20/JG27 — `getJourneyFull`'s `hide_skeletons` preference read and `updateJourneyPreferences`'s re-read, one statement text. */ /**
* JG7/JG21 — the role a person acts with on the journal, for `canEdit` and
* `my_role`: their contributor role, or the role a trip's link gives them
* when that is the stronger one. Undefined with neither.
*/
async findAccessRole(journeyId: number, userId: number): Promise<string | undefined> {
const direct = await this.directRow(journeyId, userId);
if (direct?.role === 'owner' || direct?.role === 'editor') return direct.role;
const linked = await readLinkRole(this.kysely<TripJournalLinkKyselyDB>(), journeyId, userId);
return linked ?? direct?.role;
}
/** JG20/JG27 — the person's "hide skeletons" choice, from their contributor row or else from their link. */
async getHideSkeletons(journeyId: number, userId: number): Promise<boolean> { async getHideSkeletons(journeyId: number, userId: number): Promise<boolean> {
const row = await this.qb('jc') const direct = await this.directRow(journeyId, userId);
.select(['jc.hide_skeletons']) if (direct) return !!direct.hide_skeletons;
.where({ journey: journeyId, user: userId }) return !!(await readLinkHideSkeletons(this.kysely<TripJournalLinkKyselyDB>(), journeyId, userId));
.execute<{ hide_skeletons: number } | undefined>('get', false);
return !!row?.hide_skeletons;
} }
/** JG26 — `updateJourneyPreferences`'s write: `UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?`. */ /** JG26 — stores that choice where `getHideSkeletons` reads it: the contributor row, or else the person's links. */
async setHideSkeletons(journeyId: number, userId: number, value: number): Promise<void> { async setHideSkeletons(journeyId: number, userId: number, value: number): Promise<void> {
await this.nativeUpdate({ journey: journeyId, user: userId }, { hide_skeletons: value }); if (await this.directRow(journeyId, userId)) {
await this.nativeUpdate({ journey: journeyId, user: userId }, { hide_skeletons: value });
return;
}
const db = this.kysely<TripJournalLinkKyselyDB>();
await db
.updateTable('trip_journal_link_members')
.set({ hide_skeletons: value })
.where('user_id', '=', userId)
.where('link_id', 'in', db.selectFrom('trip_journal_links').select('id').where('journey_id', '=', journeyId))
.execute();
}
/**
* #2683 — the people the trips' journal links let into this journal, one row
* per trip they come through, leaving out whoever is a contributor already:
* the settings show them apart, since they are managed in the trip.
*/
async listTripAccessForJourney(journeyId: number): Promise<JourneyTripAccessRow[]> {
const direct = new Set((await this.listForJourney(journeyId)).map((c) => c.user_id));
const rows = await linkReach(this.kysely<TripJournalLinkKyselyDB>())
.innerJoin('users as u', 'u.id', 'm.user_id')
.select(['m.user_id', 'm.role', 'u.username', 'u.avatar', 't.id as trip_id', 't.title as trip_title'])
.where('l.journey_id', '=', journeyId)
.orderBy('u.username', 'asc')
.execute();
return rows
.filter((r) => !direct.has(r.user_id))
.map((r) => ({ ...r, role: r.role === 'editor' ? 'editor' : 'viewer' }));
} }
/** JG18 — `getJourneyFull`'s contributor list, joined to `users` for `username`/`avatar` (`avatar_url` is computed in the SERVICE, unchanged). */ /** JG18 — `getJourneyFull`'s contributor list, joined to `users` for `username`/`avatar` (`avatar_url` is computed in the SERVICE, unchanged). */
@@ -241,6 +241,21 @@ export class JourneyEntriesRepository extends TrekRepository<JourneyEntries> {
return rows as { entry_date: string; m: number }[]; return rows as { entry_date: string; m: number }[];
} }
/** One day's entries in timeline order, with what placing a timed entry among them reads (#2679). */
async listDayOrder(
journeyId: number,
entryDate: string,
): Promise<{ id: number; entry_time: string | null; sort_order: number | null }[]> {
return await this.kysely<JourneyEntriesDateSortKyselyDB>()
.selectFrom('journey_entries')
.select(['id', 'entry_time', 'sort_order'])
.where('journey_id', '=', journeyId)
.where('entry_date', '=', entryDate)
.orderBy('sort_order', 'asc')
.orderBy('id', 'asc')
.execute();
}
/** JG42 — `onPlaceCreated`'s next-order lookup: `SELECT MAX(sort_order) AS m FROM journey_entries WHERE journey_id = ? AND entry_date = ?`. The `?? -1` fallback stays the SERVICE's, unchanged (same text as Task 2's own JG74). */ /** JG42 — `onPlaceCreated`'s next-order lookup: `SELECT MAX(sort_order) AS m FROM journey_entries WHERE journey_id = ? AND entry_date = ?`. The `?? -1` fallback stays the SERVICE's, unchanged (same text as Task 2's own JG74). */
async maxSortOrderForDate(journeyId: number, entryDate: string): Promise<number | null> { async maxSortOrderForDate(journeyId: number, entryDate: string): Promise<number | null> {
const row = await this.kysely<JourneyEntriesDateSortKyselyDB>() const row = await this.kysely<JourneyEntriesDateSortKyselyDB>()
@@ -1,5 +1,6 @@
import type { Journeys } from '../entities/Journeys.entity'; import type { Journeys } from '../entities/Journeys.entity';
import type { DB } from '../kysely/db'; import type { DB } from '../kysely/db';
import { linkReach, type TripJournalLinkKyselyDB } from './TripJournalLinks.repository';
import { TrekRepository } from './_shared/trek-repository'; import { TrekRepository } from './_shared/trek-repository';
/** /**
@@ -114,6 +115,12 @@ export class JourneysRepository extends TrekRepository<Journeys> {
.where('journey_id', '=', id) .where('journey_id', '=', id)
.execute(); .execute();
const ids = new Set(contributors.map((c) => c.user_id)); const ids = new Set(contributors.map((c) => c.user_id));
// The trip members a trip's link lets in follow the journal live as well.
const linked = await linkReach(this.kysely<TripJournalLinkKyselyDB>())
.select('m.user_id')
.where('l.journey_id', '=', id)
.execute();
for (const l of linked) ids.add(l.user_id);
const ownerId = await this.findOwnerId(id); const ownerId = await this.findOwnerId(id);
if (ownerId !== undefined) ids.add(ownerId); if (ownerId !== undefined) ids.add(ownerId);
return [...ids]; return [...ids];
@@ -169,7 +176,18 @@ export class JourneysRepository extends TrekRepository<Journeys> {
.whereRef('jt2.journey_id', '=', 'j.id') .whereRef('jt2.journey_id', '=', 'j.id')
.as('trip_date_max'), .as('trip_date_max'),
]) ])
.where((eb) => eb.or([eb('j.user_id', '=', user_id), eb('jc.user_id', '=', user_id)])) // A journal a trip's link lets this person into is theirs to open here too.
.where((eb) =>
eb.or([
eb('j.user_id', '=', user_id),
eb('jc.user_id', '=', user_id),
eb(
'j.id',
'in',
linkReach(this.kysely<TripJournalLinkKyselyDB>()).select('l.journey_id').where('m.user_id', '=', user_id),
),
]),
)
.orderBy('j.updated_at', 'desc') .orderBy('j.updated_at', 'desc')
.execute(); .execute();
return rows as JourneyListRow[]; return rows as JourneyListRow[];
@@ -0,0 +1,4 @@
import type { TripJournalLinkMembers } from '../entities/TripJournalLinkMembers.entity';
import { TrekRepository } from './_shared/trek-repository';
export class TripJournalLinkMembersRepository extends TrekRepository<TripJournalLinkMembers> {}
@@ -0,0 +1,216 @@
import {
TripJournalLinkMembersRole,
type TTripJournalLinkMembersRole,
} from '../entities/TripJournalLinkMembers.entity';
import type { TripJournalLinks } from '../entities/TripJournalLinks.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
import type { Kysely } from 'kysely';
/** The tables a link's reach is read from. */
export type TripJournalLinkKyselyDB = Pick<
DB,
'trip_journal_links' | 'trip_journal_link_members' | 'trips' | 'trip_members' | 'journeys' | 'users'
>;
/** The role a link gives, as the migration's CHECK and the generated entity spell it. */
export type TripJournalLinkRole = TTripJournalLinkMembersRole;
const { EDITOR, VIEWER } = TripJournalLinkMembersRole;
/** A trip's link with the journal it shows, as the settings dialog reads it. */
export interface TripJournalLinkRow {
trip_id: number;
journey_id: number;
linked_by: number | null;
created_at: number;
journey_title: string;
journey_subtitle: string | null;
journey_cover_image: string | null;
journey_owner_id: number;
}
/** Someone on the trip, as the member picker lists them. */
export interface TripPersonRow {
user_id: number;
username: string;
avatar: string | null;
is_guest: boolean;
is_trip_owner: boolean;
}
/** One person the link lets into its journal. */
export interface TripJournalLinkMemberRow {
user_id: number;
role: TripJournalLinkRole;
username: string;
avatar: string | null;
}
/**
* The link members who still belong to the linking trip, as its owner or on its
* roster, with the link they hold. Every read of link access starts here, so
* leaving a trip ends what its link gave in the same instant, whether or not
* anything cleaned up after it. A caller narrows it by journal or by person and
* picks its own columns.
*/
export function linkReach(db: Kysely<TripJournalLinkKyselyDB>) {
return db
.selectFrom('trip_journal_links as l')
.innerJoin('trip_journal_link_members as m', 'm.link_id', 'l.id')
.innerJoin('trips as t', 't.id', 'l.trip_id')
.where((eb) =>
eb.or([
eb('t.user_id', '=', eb.ref('m.user_id')),
eb.exists(
eb
.selectFrom('trip_members as tm')
.select('tm.id')
.whereRef('tm.trip_id', '=', 'l.trip_id')
.whereRef('tm.user_id', '=', 'm.user_id'),
),
]),
);
}
/** The stronger of two link roles; a person linked through two trips gets the better one. */
function strongest(roles: string[]): TripJournalLinkRole | null {
if (roles.includes(EDITOR)) return EDITOR;
return roles.length > 0 ? VIEWER : null;
}
/** The role a person holds on a journal through any trip's link, or null when none lets them in. */
export async function readLinkRole(
db: Kysely<TripJournalLinkKyselyDB>,
journeyId: number,
userId: number,
): Promise<TripJournalLinkRole | null> {
const rows = await linkReach(db)
.select('m.role')
.where('l.journey_id', '=', journeyId)
.where('m.user_id', '=', userId)
.execute();
return strongest(rows.map((r) => r.role));
}
/** A link member's own "hide skeletons" choice on the journal, or null when no link lets them in. */
export async function readLinkHideSkeletons(
db: Kysely<TripJournalLinkKyselyDB>,
journeyId: number,
userId: number,
): Promise<number | null> {
const row = await linkReach(db)
.select('m.hide_skeletons')
.where('l.journey_id', '=', journeyId)
.where('m.user_id', '=', userId)
.executeTakeFirst();
return row?.hide_skeletons ?? null;
}
/**
* `trip_journal_links` and its members: which journal a trip shows, and who
* of the trip may read or write it through that link. The access this table
* gives is read through {@link linkReach} everywhere, never copied into
* `journey_contributors`, so a direct contributor's role is never overwritten
* and undoing a link takes away exactly what it gave.
*/
export class TripJournalLinksRepository extends TrekRepository<TripJournalLinks> {
/** The trip's link with its journal's title and cover, or null without one. */
async findForTrip(tripId: number): Promise<TripJournalLinkRow | null> {
const row = await this.kysely<TripJournalLinkKyselyDB>()
.selectFrom('trip_journal_links as l')
.innerJoin('journeys as j', 'j.id', 'l.journey_id')
.select([
'l.trip_id',
'l.journey_id',
'l.linked_by',
'l.created_at',
'j.title as journey_title',
'j.subtitle as journey_subtitle',
'j.cover_image as journey_cover_image',
'j.user_id as journey_owner_id',
])
.where('l.trip_id', '=', tripId)
.executeTakeFirst();
return row ?? null;
}
/** The people the trip's link lets in, still on the trip or not, with their name for the list. */
async listMembers(tripId: number): Promise<TripJournalLinkMemberRow[]> {
const rows = await this.kysely<TripJournalLinkKyselyDB>()
.selectFrom('trip_journal_link_members as m')
.innerJoin('trip_journal_links as l', 'l.id', 'm.link_id')
.innerJoin('users as u', 'u.id', 'm.user_id')
.select(['m.user_id', 'm.role', 'u.username', 'u.avatar'])
.where('l.trip_id', '=', tripId)
.orderBy('u.username', 'asc')
.execute();
return rows.map((r) => ({ ...r, role: r.role === EDITOR ? EDITOR : VIEWER }));
}
/** Everyone on the trip, its owner first, with what the member picker shows of them. */
async listTripPeople(tripId: number): Promise<TripPersonRow[]> {
const db = this.kysely<TripJournalLinkKyselyDB>();
const owner = await db
.selectFrom('trips as t')
.innerJoin('users as u', 'u.id', 't.user_id')
.select(['u.id as user_id', 'u.username', 'u.avatar', 'u.is_guest'])
.where('t.id', '=', tripId)
.executeTakeFirst();
const members = await db
.selectFrom('trip_members as tm')
.innerJoin('users as u', 'u.id', 'tm.user_id')
.select(['u.id as user_id', 'u.username', 'u.avatar', 'u.is_guest'])
.where('tm.trip_id', '=', tripId)
.orderBy('u.username', 'asc')
.execute();
const people = owner ? [{ ...owner, is_trip_owner: true }] : [];
for (const m of members) if (m.user_id !== owner?.user_id) people.push({ ...m, is_trip_owner: false });
return people.map((p) => ({ ...p, is_guest: p.is_guest === 1 }));
}
/** Points the trip at a journal, replacing the one it showed; the link keeps its id. */
async upsertLink(tripId: number, journeyId: number, linkedBy: number, now: number): Promise<void> {
await this.kysely<TripJournalLinkKyselyDB>()
.insertInto('trip_journal_links')
.values({ trip_id: tripId, journey_id: journeyId, linked_by: linkedBy, created_at: now })
.onConflict((oc) =>
oc.column('trip_id').doUpdateSet({ journey_id: journeyId, linked_by: linkedBy, created_at: now }),
)
.execute();
}
/** The id of the trip's link, as a subquery the member writes narrow by. */
private linkIdOf(tripId: number) {
return this.kysely<TripJournalLinkKyselyDB>()
.selectFrom('trip_journal_links')
.select('id')
.where('trip_id', '=', tripId);
}
/** Sets who the trip's link lets in, replacing the list it had. */
async replaceMembers(
tripId: number,
members: { user_id: number; role: TripJournalLinkRole }[],
now: number,
): Promise<void> {
const db = this.kysely<TripJournalLinkKyselyDB>();
const link = await this.linkIdOf(tripId).executeTakeFirst();
if (!link) return;
await db.deleteFrom('trip_journal_link_members').where('link_id', '=', link.id).execute();
if (members.length === 0) return;
await db
.insertInto('trip_journal_link_members')
.values(members.map((m) => ({ link_id: link.id, user_id: m.user_id, role: m.role, added_at: now })))
.execute();
}
/** Removes the trip's link; its members go with it. */
async deleteForTrip(tripId: number): Promise<void> {
await this.kysely<TripJournalLinkKyselyDB>()
.deleteFrom('trip_journal_links')
.where('trip_id', '=', tripId)
.execute();
}
}
@@ -1,6 +1,7 @@
import { caseWhenEquals, coalesce } from '../dialect/sql-functions'; import { caseWhenEquals, coalesce } from '../dialect/sql-functions';
import type { TripMembers } from '../entities/TripMembers.entity'; import type { TripMembers } from '../entities/TripMembers.entity';
import { Trips } from '../entities/Trips.entity'; import { Trips } from '../entities/Trips.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository'; import { TrekRepository } from './_shared/trek-repository';
/** /**
@@ -266,6 +267,22 @@ export class TripMembersRepository extends TrekRepository<TripMembers> {
await this.qb('m').delete().where({ trip: trip_id }).andWhere('m.user_id = ?', [user_id]).execute('run'); await this.qb('m').delete().where({ trip: trip_id }).andWhere('m.user_id = ?', [user_id]).execute('run');
} }
/**
* Takes someone who left the trip off its journal link, so coming back later
* does not quietly reopen the journal. Their access already ended with the
* membership (the link's reach requires it); this tidies the row. Here rather
* than in the journey domain because leaving is the roster's to finish, and
* reaching into journey from the roster closed a cycle between the two.
*/
async leaveJournalLink(tripId: number, userId: number): Promise<void> {
const db = this.kysely<Pick<DB, 'trip_journal_links' | 'trip_journal_link_members'>>();
await db
.deleteFrom('trip_journal_link_members')
.where('user_id', '=', userId)
.where('link_id', 'in', db.selectFrom('trip_journal_links').select('id').where('trip_id', '=', tripId))
.execute();
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plan 3c Task 7 (`trips.rpc.ts::members`) — additive: no other repository // Plan 3c Task 7 (`trips.rpc.ts::members`) — additive: no other repository
// is rooted at `trip_members` joined to `users`, and RP3's shape is // is rooted at `trip_members` joined to `users`, and RP3's shape is
+2
View File
@@ -52,6 +52,7 @@ import { AirtrailModule } from './integrations/airtrail.module';
import { DawarichModule } from './integrations/dawarich.module'; import { DawarichModule } from './integrations/dawarich.module';
import { JourneyAccessModule } from './journey/journey-access.module'; import { JourneyAccessModule } from './journey/journey-access.module';
import { JourneyModule } from './journey/journey.module'; import { JourneyModule } from './journey/journey.module';
import { TripJournalLinkModule } from './journey/trip-journal-link.module';
import { LlmParseModule } from './llm-parse/llm-parse.module'; import { LlmParseModule } from './llm-parse/llm-parse.module';
import { ManagedExtModule } from './managed/managed-ext.module'; import { ManagedExtModule } from './managed/managed-ext.module';
import { MapsModule } from './maps/maps.module'; import { MapsModule } from './maps/maps.module';
@@ -166,6 +167,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
AirtrailModule, AirtrailModule,
DawarichModule, DawarichModule,
JourneyModule, JourneyModule,
TripJournalLinkModule,
CollectionsModule, CollectionsModule,
ShareModule, ShareModule,
TripInviteModule, TripInviteModule,
+5
View File
@@ -135,3 +135,8 @@ export function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] {
export function parseBackupCodeHashes(raw: string | null | undefined): string[] { export function parseBackupCodeHashes(raw: string | null | undefined): string[] {
return decodeJson(USER_MFA_BACKUP_CODES, raw).filter((v): v is string => typeof v === 'string'); return decodeJson(USER_MFA_BACKUP_CODES, raw).filter((v): v is string => typeof v === 'string');
} }
/** Where `plaintext` sits among a user's stored backup-code hashes, or -1 when it is none of them. */
export function backupCodeIndex(plaintext: string, raw: string | null | undefined): number {
return parseBackupCodeHashes(raw).findIndex((hash) => matchBackupCode(plaintext, hash));
}
+7 -7
View File
@@ -64,6 +64,7 @@ import {
generateBackupCodes, generateBackupCodes,
hashBackupCodeBcrypt, hashBackupCodeBcrypt,
mask_stored_api_key, mask_stored_api_key,
backupCodeIndex,
matchBackupCode, matchBackupCode,
parseBackupCodeHashes, parseBackupCodeHashes,
stripUserForClient, stripUserForClient,
@@ -871,9 +872,10 @@ export class AuthService {
throw new DomainError(401, 'Incorrect password'); throw new DomainError(401, 'Incorrect password');
} }
const secret = decryptMfaSecret(user.mfa_secret); const secret = decryptMfaSecret(user.mfa_secret);
const tokenStr = String(code).replace(/\s/g, ''); const tokenStr = String(code).trim();
const ok = authenticator.verify({ token: tokenStr, secret }); const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret });
if (!ok) { // A backup code proves the second factor as at login; disabling drops them all (#2665).
if (!okTotp && backupCodeIndex(tokenStr, user.mfa_backup_codes) === -1) {
throw new DomainError(401, 'Invalid verification code'); throw new DomainError(401, 'Invalid verification code');
} }
// Every other session ends with the second factor; this one just proved both. // Every other session ends with the second factor; this one just proved both.
@@ -1053,10 +1055,8 @@ export class AuthService {
const secret = decryptMfaSecret(user.mfa_secret); const secret = decryptMfaSecret(user.mfa_secret);
const okTotp = authenticator.verify({ token: supplied.replace(/\s/g, ''), secret }); const okTotp = authenticator.verify({ token: supplied.replace(/\s/g, ''), secret });
if (!okTotp) { if (!okTotp) {
const hashes = parseBackupCodeHashes(user.mfa_backup_codes); backupCodeConsumedIndex = backupCodeIndex(supplied, user.mfa_backup_codes);
const idx = hashes.findIndex((h) => matchBackupCode(supplied, h)); if (backupCodeConsumedIndex === -1) throw new DomainError(401, 'Invalid MFA code');
if (idx === -1) throw new DomainError(401, 'Invalid MFA code');
backupCodeConsumedIndex = idx;
} }
} }
@@ -780,8 +780,8 @@ export class SynologyDriveClient {
api: 'SYNO.API.Auth', api: 'SYNO.API.Auth',
version: '6', version: '6',
method: 'login', method: 'login',
account: JSON.stringify(creds.username), // Plain: auth.cgi reads no JSON, a quoted account was refused as `"trek"` (#2666).
passwd: JSON.stringify(creds.password), ...{ account: creds.username, passwd: creds.password },
session: SESSION_NAME, session: SESSION_NAME,
format: 'sid', format: 'sid',
client: 'browser', client: 'browser',
@@ -10,6 +10,7 @@ import { todayUtc } from '@trek/shared';
import { TrekPhotoRegistrationService } from '../photos/trek-photo-registration.service'; import { TrekPhotoRegistrationService } from '../photos/trek-photo-registration.service';
import { getCountryFromCoords } from '../atlas/atlas-geo'; import { getCountryFromCoords } from '../atlas/atlas-geo';
import { computeJourneyStats, type StatsInputPoint } from './journey-stats'; import { computeJourneyStats, type StatsInputPoint } from './journey-stats';
import { newEntrySortOrder, placeMovedEntry } from './journey-entry-order';
import { Journeys } from '../../db/entities/Journeys.entity'; import { Journeys } from '../../db/entities/Journeys.entity';
import type { JourneysRepository } from '../../db/repositories/Journeys.repository'; import type { JourneysRepository } from '../../db/repositories/Journeys.repository';
import { JourneyContributors } from '../../db/entities/JourneyContributors.entity'; import { JourneyContributors } from '../../db/entities/JourneyContributors.entity';
@@ -177,7 +178,7 @@ export class JourneyDomainService {
// `SELECT *` read this repository now serves. // `SELECT *` read this repository now serves.
const own = await this.journeysRepo.findOwnedByUser(journeyId, userId); const own = await this.journeysRepo.findOwnedByUser(journeyId, userId);
if (own) return own as Journey; if (own) return own as Journey;
const isContributor = await this.contributorsRepo.existsForUser(journeyId, userId); const isContributor = await this.contributorsRepo.hasAccess(journeyId, userId);
if (isContributor) return ((await this.journeysRepo.findById(journeyId)) as Journey) || null; if (isContributor) return ((await this.journeysRepo.findById(journeyId)) as Journey) || null;
return null; return null;
} }
@@ -188,7 +189,7 @@ export class JourneyDomainService {
async canEdit(journeyId: number, userId: number): Promise<boolean> { async canEdit(journeyId: number, userId: number): Promise<boolean> {
if (await this.isOwner(journeyId, userId)) return true; if (await this.isOwner(journeyId, userId)) return true;
const role = await this.contributorsRepo.findRole(journeyId, userId); const role = await this.contributorsRepo.findAccessRole(journeyId, userId);
return role === 'editor' || role === 'owner'; return role === 'editor' || role === 'owner';
} }
@@ -269,12 +270,10 @@ export class JourneyDomainService {
// linked trips (JG17) // linked trips (JG17)
const trips = await this.journeyTripsRepo.listForJourney(journeyId); const trips = await this.journeyTripsRepo.listForJourney(journeyId);
// contributors (JG18) const withAvatar = <R extends { avatar: string | null }>(rows: R[]) => rows.map((r) => ({ ...r, avatar_url: avatarUrl(r) }));
const contributorsRaw = await this.contributorsRepo.listForJourney(journeyId); const contributors = withAvatar(await this.contributorsRepo.listForJourney(journeyId)); // JG18
const contributors = contributorsRaw.map((c) => ({ // Who a trip's journal link lets in besides them (#2683), shown apart in the settings.
...c, const tripAccess = withAvatar(await this.contributorsRepo.listTripAccessForJourney(journeyId));
avatar_url: avatarUrl(c),
}));
// stats // stats
const entryCount = entries.filter((e) => e.type === 'entry').length; const entryCount = entries.filter((e) => e.type === 'entry').length;
@@ -292,7 +291,7 @@ export class JourneyDomainService {
myRole = 'owner'; myRole = 'owner';
} else { } else {
// JG21 — same text as JG7 (`findRole`). // JG21 — same text as JG7 (`findRole`).
const role = await this.contributorsRepo.findRole(journeyId, userId); const role = await this.contributorsRepo.findAccessRole(journeyId, userId);
myRole = (role as 'editor' | 'viewer' | undefined) ?? null; myRole = (role as 'editor' | 'viewer' | undefined) ?? null;
} }
@@ -305,6 +304,7 @@ export class JourneyDomainService {
gallery, gallery,
trips, trips,
contributors, contributors,
trip_access: tripAccess,
stats: { entries: entryCount, photos: photoCount, places: places.length }, stats: { entries: entryCount, photos: photoCount, places: places.length },
hide_skeletons: hideSkeletons, hide_skeletons: hideSkeletons,
// What the eye toggle cannot say: how much was waved away one at a time, and // What the eye toggle cannot say: how much was waved away one at a time, and
@@ -1209,10 +1209,8 @@ export class JourneyDomainService {
? JSON.stringify(data.pros_cons) ? JSON.stringify(data.pros_cons)
: null; : null;
// The next position and the row that takes it in one transaction, so two // Position and row in one transaction, so two adds cannot read the same MAX (JG74, #2679).
// entries added at once cannot both read the same MAX (JG74).
const insertedId = await this.uow.transactional(async () => { const insertedId = await this.uow.transactional(async () => {
const maxOrder = await this.entriesRepo.maxSortOrderForDate(journeyId, data.entry_date);
return await this.entriesRepo.insertEntry({ return await this.entriesRepo.insertEntry({
journey_id: journeyId, journey_id: journeyId,
author_id: userId, author_id: userId,
@@ -1230,7 +1228,7 @@ export class JourneyDomainService {
tags: data.tags?.length ? JSON.stringify(data.tags) : null, tags: data.tags?.length ? JSON.stringify(data.tags) : null,
pros_cons: prosConsJson, pros_cons: prosConsJson,
visibility: data.visibility || 'private', visibility: data.visibility || 'private',
sort_order: (maxOrder ?? -1) + 1, sort_order: await newEntrySortOrder(this.entriesRepo, journeyId, data.entry_date, data.entry_time, now),
is_draft: data.is_draft ? 1 : 0, is_draft: data.is_draft ? 1 : 0,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
@@ -1356,6 +1354,7 @@ export class JourneyDomainService {
if (Object.keys(patch).length === 1) return decodeEntryRow(entry); if (Object.keys(patch).length === 1) return decodeEntryRow(entry);
await this.uow.transactional(async () => { await this.uow.transactional(async () => {
await placeMovedEntry(this.entriesRepo, entry, patch, this.ts());
await this.entriesRepo.updateFields(entryId, patch); await this.entriesRepo.updateFields(entryId, patch);
await this.journeysRepo.updateFields(entry.journey_id, { updated_at: this.ts() }); // touch the journey await this.journeysRepo.updateFields(entry.journey_id, { updated_at: this.ts() }); // touch the journey
}); });
@@ -0,0 +1,78 @@
import type { JourneyEntriesRepository } from '../../db/repositories/JourneyEntries.repository';
/** "HH:MM" (seconds ignored) as minutes past midnight, or null for anything else. */
function minutesOf(time: string | null | undefined): number | null {
const match = /^(\d{1,2}):(\d{2})/.exec(time?.trim() ?? '');
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
}
/**
* Where an entry at `time` belongs among its day's entries, given in their
* current order: right before the first one that starts later. Entries
* without a time are no anchor either way, and an unreadable `time` goes last.
*/
export function chronologicalSlot(day: readonly { entry_time: string | null }[], time: string): number {
const at = minutesOf(time);
if (at == null) return day.length;
const later = day.findIndex((e) => {
const other = minutesOf(e.entry_time);
return other != null && other > at;
});
return later === -1 ? day.length : later;
}
/**
* Makes room for a timed entry in its day and returns the sort_order it takes.
*
* A new entry used to go to the end of its day whatever its time, so writing
* up the morning after the evening left the morning last (#2679). It now lands
* before the first entry that starts later; the order of everything else,
* dragged by hand or not, stays as it was. Run it inside the write's
* transaction, so the room and the entry that takes it land together.
*/
export async function makeRoomAtTime(
entries: JourneyEntriesRepository,
journeyId: number,
date: string,
time: string,
now: number,
exceptId?: number,
): Promise<number> {
const day = (await entries.listDayOrder(journeyId, date)).filter((e) => e.id !== exceptId);
const slot = chronologicalSlot(day, time);
const moves = day
.map((e, index) => ({ id: e.id, from: e.sort_order, to: index < slot ? index : index + 1 }))
.filter((m) => m.from !== m.to);
await Promise.all(moves.map((m) => entries.updateSortOrder(m.id, m.to, now)));
return slot;
}
/** The sort_order a new entry takes: where its time puts it in its day, last when it has none. */
export async function newEntrySortOrder(
entries: JourneyEntriesRepository,
journeyId: number,
date: string,
time: string | null | undefined,
now: number,
): Promise<number> {
if (time) return await makeRoomAtTime(entries, journeyId, date, time, now);
return ((await entries.maxSortOrderForDate(journeyId, date)) ?? -1) + 1;
}
/**
* Moves an edited entry to where its new time or day puts it, by giving the patch
* the sort_order it takes. Left alone when neither changed, so a save keeps an
* entry where it was dragged, and when the caller sent a sort_order of its own.
*/
export async function placeMovedEntry(
entries: JourneyEntriesRepository,
entry: { id: number; journey_id: number; entry_date: string; entry_time?: string | null },
patch: { entry_date?: string; entry_time?: string | null; sort_order?: number },
now: number,
): Promise<void> {
if (patch.sort_order !== undefined) return;
const date = patch.entry_date ?? entry.entry_date;
const time = patch.entry_time !== undefined ? patch.entry_time : entry.entry_time;
if (!time || (date === entry.entry_date && time === entry.entry_time)) return;
patch.sort_order = await makeRoomAtTime(entries, entry.journey_id, date, time, now, entry.id);
}
@@ -0,0 +1,70 @@
import { ADDON_IDS } from '../../addons';
import type { User } from '../../types';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ResponseContract } from '../common/response-contract';
import { TripAccessGuard } from '../permissions/trip-access.guard';
import { TripJournalCandidatesQueryDto, TripJournalLinkDto, TripJournalLinkMembersDto } from './trip-journal-link.dto';
import { TripJournalLinkService } from './trip-journal-link.service';
import { Body, Controller, Delete, Get, Headers, HttpCode, Param, Patch, Put, Query, UseGuards } from '@nestjs/common';
import { tripJournalCandidatesResponseSchema, tripJournalLinkResponseSchema } from '@trek/shared';
/**
* The journal a trip shows, at `/api/trips/:tripId/journal`. Every member of
* the trip may read the link as it concerns them; changing it is decided in the
* service, because it turns on who owns the journal as well as on the trip
* permission. Behind the Journey addon: with it off the routes answer 404.
*/
@Controller('api/trips/:tripId/journal')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.JOURNEY, 'Journey')
export class TripJournalLinkController {
constructor(private readonly links: TripJournalLinkService) {}
@Get()
@ResponseContract(tripJournalLinkResponseSchema)
async get(@Param('tripId') tripId: string, @CurrentUser() user: User) {
return this.links.get(Number(tripId), user);
}
@Get('candidates')
@ResponseContract(tripJournalCandidatesResponseSchema)
async candidates(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Query() query: TripJournalCandidatesQueryDto,
) {
return { candidates: await this.links.candidates(Number(tripId), user, query.journey_id) };
}
@Put()
@ResponseContract(tripJournalLinkResponseSchema)
async link(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Body() body: TripJournalLinkDto,
@Headers('x-socket-id') socketId?: string,
) {
return this.links.link(Number(tripId), user, body.journey_id, body.members, socketId);
}
@Patch('members')
@ResponseContract(tripJournalLinkResponseSchema)
async setMembers(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Body() body: TripJournalLinkMembersDto,
@Headers('x-socket-id') socketId?: string,
) {
return this.links.setMembers(Number(tripId), user, body.members, socketId);
}
@Delete()
@HttpCode(200)
@ResponseContract(tripJournalLinkResponseSchema)
async unlink(@Param('tripId') tripId: string, @CurrentUser() user: User, @Headers('x-socket-id') socketId?: string) {
return this.links.unlink(Number(tripId), user, socketId);
}
}
@@ -0,0 +1,11 @@
import {
tripJournalCandidatesQuerySchema,
tripJournalLinkMembersRequestSchema,
tripJournalLinkRequestSchema,
} from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
export class TripJournalLinkDto extends createZodDto(tripJournalLinkRequestSchema) {}
export class TripJournalLinkMembersDto extends createZodDto(tripJournalLinkMembersRequestSchema) {}
export class TripJournalCandidatesQueryDto extends createZodDto(tripJournalCandidatesQuerySchema) {}
@@ -0,0 +1,97 @@
import { ADDON_IDS } from '../../addons';
import {
McpController,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
Tool,
ok,
type McpContext,
} from '../../nest-mcp';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { TripJournalLinkService, type TripJournalActor } from './trip-journal-link.service';
import { idSchema, tripJournalLinkRequestSchema } from '@trek/shared';
const journeyAddonOn = addonGate(ADDON_IDS.JOURNEY);
/**
* The trip's linked journal over MCP: the same service, the same permission and
* journal-owner rules as `/api/trips/:tripId/journal`, behind the Journey addon.
*/
@McpController()
export class TripJournalLinkMcp {
constructor(
private readonly links: TripJournalLinkService,
private readonly guards: McpToolGuardsService,
readonly addons: AddonsService,
) {}
private async actor(tripId: number, userId: number): Promise<TripJournalActor> {
const { role } = await this.guards.tripWriter(tripId, userId);
return { id: userId, role };
}
@Tool({
name: 'get_trip_journal',
description:
'The journal from the Journey addon a trip shows as its own, if any: its id and title, your access to it, and whether you may link, unlink or change who it is open to. Open the journal itself with get_journey.',
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
when: journeyAddonOn,
access: { group: 'journey', mode: 'read' },
})
async getTripJournal({ tripId }: { tripId: number }, ctx: McpContext) {
return ok(await this.links.get(tripId, await this.actor(tripId, ctx.userId)));
}
@Tool({
name: 'link_trip_journal',
description:
'Show one of your own journals in a trip, replacing the journal it showed. The trip members listed get into the journal as viewers or editors and see it in their own journal list; members left out do not. Needs the journal to be yours and the trip permission to link journals.',
inputSchema: { tripId: idSchema, journey_id: idSchema, members: tripJournalLinkRequestSchema.shape.members },
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
async linkTripJournal(
{
tripId,
journey_id,
members,
}: { tripId: number; journey_id: number; members: { user_id: number; role: 'viewer' | 'editor' }[] },
ctx: McpContext,
) {
return ok(await this.links.link(tripId, await this.actor(tripId, ctx.userId), journey_id, members));
}
@Tool({
name: 'set_trip_journal_members',
description:
"Change which trip members the trip's linked journal is open to, and as viewer or editor. The list replaces the one before; anyone left out loses the access the link gave them. Only the journal's owner can do this.",
inputSchema: { tripId: idSchema, members: tripJournalLinkRequestSchema.shape.members },
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
async setTripJournalMembers(
{ tripId, members }: { tripId: number; members: { user_id: number; role: 'viewer' | 'editor' }[] },
ctx: McpContext,
) {
return ok(await this.links.setMembers(tripId, await this.actor(tripId, ctx.userId), members));
}
@Tool({
name: 'unlink_trip_journal',
description:
'Take the linked journal off a trip. The journal itself stays; the members the link let in lose that access, while its owner and direct contributors keep theirs.',
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_DELETE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
async unlinkTripJournal({ tripId }: { tripId: number }, ctx: McpContext) {
return ok(await this.links.unlink(tripId, await this.actor(tripId, ctx.userId)));
}
}

Some files were not shown because too many files have changed in this diff Show More